diff --git a/Dockerfile.dev b/Dockerfile.dev index 8558cbf..449b453 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -14,7 +14,6 @@ RUN apk add --no-cache \ autoconf \ g++ \ make \ - zsh \ wget # Install PHP extensions @@ -27,11 +26,5 @@ RUN pecl install mongodb \ # Install Composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer -# Install Oh My Zsh -RUN sh -c "$(wget -O- https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended - -# Set zsh as default shell -RUN chsh -s $(which zsh) - # CMD will be executed when container starts CMD ["php", "-S", "0.0.0.0:3000", "-t", "public"] \ No newline at end of file diff --git a/docs/SubSectionTile.md b/docs/SubSectionTile.md new file mode 100644 index 0000000..6220362 --- /dev/null +++ b/docs/SubSectionTile.md @@ -0,0 +1,58 @@ +# SubSectionTile + +The `SubSectionTile` allows you to group similar tiles together within a section, providing better organization and visual hierarchy. + +## Usage + +```php +use App\Domain\Search\Tiles\SubSectionTile; + +// Create a sub-section with grouped tiles +$performanceSubSection = new SubSectionTile( + 'Performance', // Title + [ // Array of tiles + new PowerTile(new Power(210)), + new AccelerationTile(new Acceleration(6.6)), + new TopSpeedTile(new TopSpeed(180)), + new DrivetrainTile(new Drivetrain('rear')), + ], + 'Motor and driving performance specifications' // Optional description +); +``` + +## Parameters + +- **`title`** (string): The title of the sub-section +- **`tiles`** (array): Array of tile objects to group together +- **`description`** (string, optional): Optional description text displayed below the title + +## Example + +```php +new SectionTile('Skoda Elroq 85', [ + new BrandTile('Skoda'), + new PriceTile(new Price(43900, Currency::euro())), + + new SubSectionTile('Performance', [ + new PowerTile(new Power(210)), + new AccelerationTile(new Acceleration(6.6)), + new TopSpeedTile(new TopSpeed(180)), + new DrivetrainTile(new Drivetrain('rear')), + ], 'Motor and driving performance specifications'), + + new SubSectionTile('Range & Efficiency', [ + new RangeTile(new Range(450)), + new BatteryTile(new Battery(77.0, 82.0)), + new ConsumptionTile(new Consumption(171)), + new ChargingTile(new ChargingSpeed(175, 11)), + ], 'Battery capacity, range, and charging capabilities'), +]); +``` + +## Visual Hierarchy + +- **Section**: Large heading (h1) with prominent styling +- **Sub-section**: Medium heading (h2) with subtle styling +- **Tiles**: Individual data tiles within each sub-section + +The sub-section provides a clear visual separation between different categories of information while maintaining the overall design consistency. \ No newline at end of file diff --git a/src/Application/Twig/TileTwigName.php b/src/Application/Twig/TileTwigName.php index aef5481..ebda51a 100644 --- a/src/Application/Twig/TileTwigName.php +++ b/src/Application/Twig/TileTwigName.php @@ -19,6 +19,6 @@ class TileTwigName extends AbstractExtension public function twigName(object $tile): string { - return (new \ReflectionClass($tile))->getShortName(); + return str_replace('tile', '', strtolower((new \ReflectionClass($tile))->getShortName())); } } \ No newline at end of file diff --git a/src/Domain/Model/Brand.php b/src/Domain/Model/Brand.php index 783b0f8..3a381f5 100644 --- a/src/Domain/Model/Brand.php +++ b/src/Domain/Model/Brand.php @@ -2,16 +2,16 @@ namespace App\Domain\Model; -class Brand +final readonly class Brand { public function __construct( - public private(set) readonly string $id, - public private(set) readonly string $name, - public private(set) readonly string $logo, - public private(set) readonly string $description, - public private(set) readonly int $foundedYear, - public private(set) readonly string $headquarters, - public private(set) readonly string $website, - public private(set) readonly array $carModels, + public readonly string $id, + public readonly string $name, + public readonly string $logo, + public readonly string $description, + public readonly int $foundedYear, + public readonly string $headquarters, + public readonly string $website, + public readonly array $carModels, ) {} } \ No newline at end of file diff --git a/src/Domain/Model/CarRevision.php b/src/Domain/Model/CarRevision.php index 9242f2d..119d13a 100644 --- a/src/Domain/Model/CarRevision.php +++ b/src/Domain/Model/CarRevision.php @@ -2,33 +2,29 @@ namespace App\Domain\Model; -class CarRevision +use App\Domain\Model\Value\Acceleration; +use App\Domain\Model\Value\Battery; +use App\Domain\Model\Value\Consumption; +use App\Domain\Model\Value\ChargingSpeed; +use App\Domain\Model\Value\Power; +use App\Domain\Model\Value\Range; +use App\Domain\Model\Value\TopSpeed; +use App\Domain\Model\Value\Price; + +final readonly class CarRevision { - private ?string $id = null; - - private string $name; - - private int $releaseYear; - - private array $engineTypes = []; - - private ?int $horsePower = null; - - private ?int $torque = null; - - private ?int $topSpeed = null; - - private ?float $accelerationZeroToHundred = null; - - private ?int $range = null; - - private ?float $batteryCapacity = null; - - private ?int $chargingTime = null; - - private ?float $consumption = null; - - private ?float $price = null; - - private ?CarModel $carModel = null; + public function __construct( + public string $name, + public int $releaseYear, + public ?Power $power = null, + public ?Acceleration $acceleration = null, + public ?TopSpeed $topSpeed = null, + public ?Range $range = null, + public ?Battery $battery = null, + public ?Consumption $consumption = null, + public ?Price $price = null, + public ?ChargingSpeed $chargingSpeed = null, + public ?CarModel $carModel = null, + public ?Image $image = null, + ) {} } \ No newline at end of file diff --git a/src/Domain/Model/Image.php b/src/Domain/Model/Image.php new file mode 100644 index 0000000..bcc07b4 --- /dev/null +++ b/src/Domain/Model/Image.php @@ -0,0 +1,12 @@ +secondsFrom0To100 . ' sec (0-100 km/h)'; + } + + public function getSeconds(): float + { + return $this->secondsFrom0To100; + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/Battery.php b/src/Domain/Model/Value/Battery.php new file mode 100644 index 0000000..4868f7c --- /dev/null +++ b/src/Domain/Model/Value/Battery.php @@ -0,0 +1,18 @@ +usableCapacity->__toString(); + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/ChargingSpeed.php b/src/Domain/Model/Value/ChargingSpeed.php new file mode 100644 index 0000000..30b4fb3 --- /dev/null +++ b/src/Domain/Model/Value/ChargingSpeed.php @@ -0,0 +1,27 @@ +dcMax() . ' DC / ' . $this->acMax() . ' AC'; + } + + public function dcMax(): Power + { + return $this->dcMaxKw; + } + + public function acMax(): Power + { + return $this->acMaxKw; + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/Consumption.php b/src/Domain/Model/Value/Consumption.php new file mode 100644 index 0000000..995b13b --- /dev/null +++ b/src/Domain/Model/Value/Consumption.php @@ -0,0 +1,21 @@ +energyPerKm->kwh() . ' kWh/100km'; + } + + public function energyPerKm(): Energy + { + return $this->energyPerKm; + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/Currency.php b/src/Domain/Model/Value/Currency.php new file mode 100644 index 0000000..6dcefaf --- /dev/null +++ b/src/Domain/Model/Value/Currency.php @@ -0,0 +1,42 @@ +symbol; + } + + public function symbol(): string + { + return $this->symbol; + } + + public function currency(): string + { + return $this->currency; + } + + public function name(): string + { + return $this->name; + } + + public static function euro(): self + { + return new self('€', 'EUR', 'Euro'); + } + + public static function usd(): self + { + return new self('$', 'USD', 'US Dollar'); + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/Drivetrain.php b/src/Domain/Model/Value/Drivetrain.php new file mode 100644 index 0000000..d30b6c6 --- /dev/null +++ b/src/Domain/Model/Value/Drivetrain.php @@ -0,0 +1,26 @@ +type) { + 'rear' => 'Heckantrieb', + 'front' => 'Frontantrieb', + 'awd' => 'Allradantrieb', + default => $this->type, + }; + } + + public function getType(): string + { + return $this->type; + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/Energy.php b/src/Domain/Model/Value/Energy.php new file mode 100644 index 0000000..27d036c --- /dev/null +++ b/src/Domain/Model/Value/Energy.php @@ -0,0 +1,21 @@ +kwh . ' kWh'; + } + + public function kwh(): float + { + return $this->kwh; + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/Power.php b/src/Domain/Model/Value/Power.php new file mode 100644 index 0000000..95990af --- /dev/null +++ b/src/Domain/Model/Value/Power.php @@ -0,0 +1,26 @@ +kilowatts . ' kW'; + } + + public function kilowatts(): float + { + return $this->kilowatts; + } + + public function horsePower(): int + { + return (int) round($this->kilowatts * 1.36); + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/Price.php b/src/Domain/Model/Value/Price.php new file mode 100644 index 0000000..ce0eb95 --- /dev/null +++ b/src/Domain/Model/Value/Price.php @@ -0,0 +1,16 @@ +price, 0, ',', '.') . ' ' . $this->currency->symbol(); + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/Range.php b/src/Domain/Model/Value/Range.php new file mode 100644 index 0000000..6e8e518 --- /dev/null +++ b/src/Domain/Model/Value/Range.php @@ -0,0 +1,21 @@ +kilometers . ' km'; + } + + public function rangeInKm(): int + { + return $this->kilometers; + } +} \ No newline at end of file diff --git a/src/Domain/Model/Value/TopSpeed.php b/src/Domain/Model/Value/TopSpeed.php new file mode 100644 index 0000000..8824146 --- /dev/null +++ b/src/Domain/Model/Value/TopSpeed.php @@ -0,0 +1,21 @@ +kmh . ' km/h'; + } + + public function getKmh(): int + { + return $this->kmh; + } +} \ No newline at end of file diff --git a/src/Domain/Search/Engine.php b/src/Domain/Search/Engine.php index 95b8524..e171d57 100644 --- a/src/Domain/Search/Engine.php +++ b/src/Domain/Search/Engine.php @@ -2,16 +2,72 @@ namespace App\Domain\Search; -use App\Domain\Search\Tiles\Section; -use App\Domain\Search\Tiles\Brand; +use App\Domain\Model\CarRevision; +use App\Domain\Model\Image; +use App\Domain\Model\Value\Currency; +use App\Domain\Model\Value\Price; +use App\Domain\Model\Value\Range; +use App\Domain\Model\Value\Battery; +use App\Domain\Model\Value\Power; +use App\Domain\Model\Value\Acceleration; +use App\Domain\Model\Value\ChargingSpeed; +use App\Domain\Model\Value\Consumption; +use App\Domain\Model\Value\TopSpeed; +use App\Domain\Model\Value\Drivetrain; +use App\Domain\Model\Value\Energy; +use App\Domain\Search\Tiles\SectionTile; +use App\Domain\Search\Tiles\SubSectionTile; +use App\Domain\Search\Tiles\BrandTile; +use App\Domain\Search\Tiles\PriceTile; +use App\Domain\Search\Tiles\RangeTile; +use App\Domain\Search\Tiles\BatteryTile; +use App\Domain\Search\Tiles\PowerTile; +use App\Domain\Search\Tiles\AccelerationTile; +use App\Domain\Search\Tiles\ChargingTile; +use App\Domain\Search\Tiles\ConsumptionTile; +use App\Domain\Search\Tiles\AvailabilityTile; +use App\Domain\Search\Tiles\CarTile; +use App\Domain\Search\Tiles\TopSpeedTile; +use App\Domain\Search\Tiles\DrivetrainTile; class Engine { public function search(string $query): TileCollection { + $skodaElroq85 = new CarRevision( + 'Skoda Elroq 85', 2024, + new Power(210), + new Acceleration(6.6), + new TopSpeed(180), + new Range(450), + new Battery(new Energy(77.0), new Energy(82.0)), + new Consumption(new Energy(171)), + new Price(43900, Currency::euro()), + new ChargingSpeed(new Power(175), new Power(11)), + image: new Image('https://www.scherer-gruppe.de/media/f3b72d42-4b26-4606-8df4-d840efeff017/01_elroc.jpg?w=1920&h=758&action=crop&scale=both&anchor=middlecenter'), + ); + return new TileCollection([ - new Section('Hello', [ - new Brand('Tesla', 'https://www.tesla.com/tesla_theme/assets/img/meta-tags/apple-touch-icon.png'), + new SectionTile('Skoda Elroq 85', [ + new CarTile($skodaElroq85->image, [ + new BrandTile('Skoda'), + new PriceTile(new Price(43900, Currency::euro())), + new AvailabilityTile('Bestellbar', 'Oktober 2024'), + new RangeTile(new Range(450)), + ]), + new SubSectionTile('Performance', [ + new PowerTile(new Power(210)), + new AccelerationTile(new Acceleration(6.6)), + new TopSpeedTile(new TopSpeed(180)), + new DrivetrainTile(new Drivetrain('rear')), + ], 'Motor and driving performance specifications'), + + new SubSectionTile('Range & Efficiency', [ + new RangeTile(new Range(450)), + new BatteryTile(new Battery(new Energy(77.0), new Energy(82.0))), + new ConsumptionTile(new Consumption(new Energy(171))), + new ChargingTile(new ChargingSpeed(new Power(175), new Power(11))), + ], 'Battery capacity, range, and charging capabilities'), ]), ]); } diff --git a/src/Domain/Search/TileCollection.php b/src/Domain/Search/TileCollection.php index dbb12f8..861017a 100644 --- a/src/Domain/Search/TileCollection.php +++ b/src/Domain/Search/TileCollection.php @@ -2,7 +2,7 @@ namespace App\Domain\Search; -use App\Domain\Search\Tiles\Section; +use App\Domain\Search\Tiles\SectionTile; class TileCollection { diff --git a/src/Domain/Search/Tiles/AccelerationTile.php b/src/Domain/Search/Tiles/AccelerationTile.php new file mode 100644 index 0000000..7d31ef2 --- /dev/null +++ b/src/Domain/Search/Tiles/AccelerationTile.php @@ -0,0 +1,12 @@ + $this->client->selectDatabase($this->databaseName); - } + private Client $client; public function __construct( private readonly string $dsl, @@ -19,4 +15,14 @@ class MongoDBClient ) { $this->client = new Client($this->dsl); } + + public function getClient(): Client + { + return $this->client; + } + + public function getDatabase(): Database + { + return $this->client->selectDatabase($this->databaseName); + } } \ No newline at end of file diff --git a/src/Infrastructure/MongoDB/Repository/BrandRepository/MongoDBBrandRepository.php b/src/Infrastructure/MongoDB/Repository/BrandRepository/MongoDBBrandRepository.php index b012f3a..fd842f8 100644 --- a/src/Infrastructure/MongoDB/Repository/BrandRepository/MongoDBBrandRepository.php +++ b/src/Infrastructure/MongoDB/Repository/BrandRepository/MongoDBBrandRepository.php @@ -15,7 +15,7 @@ final class MongoDBBrandRepository implements BrandRepository public function findAll(): BrandCollection { - $result = $this->client->database->selectCollection('brands')->find(); + $result = $this->client->getDatabase()->selectCollection('brands')->find(); $brands = []; $mapper = new ModelMapper(); diff --git a/templates/base.html.twig b/templates/base.html.twig index 5b50405..0cfff15 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -26,25 +26,25 @@ } .header { - text-align: center; - margin-bottom: 3rem; + display: flex; + justify-content: stretch; + gap: 2rem; + align-items: center; + margin-bottom: 2.5rem; } .title { - font-size: 3.5rem; + font-size: 2.5rem; font-weight: 800; - margin-bottom: 2.5rem; letter-spacing: -1px; background: linear-gradient(90deg, #083d77 0%, #2e4057 35%, #d1495b 100%); - -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; display: inline-block; } .search-container { - max-width: 600px; - margin: 0 auto 3rem; + flex-grow: 1; position: relative; } @@ -55,9 +55,12 @@ border: 2px solid #e0e0e0; border-radius: 50px; outline: none; - transition: all 0.3s ease; + transition: all 0.1s ease; background: white; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + + .search-input:hover { + border-color: #083d77; } .search-input:focus { @@ -68,8 +71,7 @@ .search-button { position: absolute; right: 8px; - top: 50%; - transform: translateY(-50%); + top: 8px; background: #083d77; color: white; border: none; @@ -84,30 +86,109 @@ background: #2e4057; } - .tile { + /* Tiles Grid Layout */ + .tiles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1px; + padding: 0; + overflow: hidden; + } + + .tile-wrapper { display: flex; - border-radius: 10px; - padding: 10px; - background-color: #fff; + flex-direction: column; + height: 100%; + + position: relative; + } + + /* Special handling for subsection tiles */ + .tile-wrapper:has(.subsection) { + grid-column: 1 / -1; + min-height: auto; + } + + .tile { + background: white; + border: none; + border-radius: 0; + padding: 1.5rem; + width: 100%; + height: 100%; + min-height: 120px; + display: flex; + flex-direction: column; + justify-content: space-between; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + flex: 1; + } + + .tile::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #083d77, #2e4057, #d1495b); + opacity: 0; + transition: opacity 0.3s ease; + } + + .tile:hover { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(8, 61, 119, 0.15); + background: rgba(8, 61, 119, 0.02); + z-index: 1; + cursor: pointer; + } + + .tile:hover::before { + opacity: 1; } .tile-title { - font-size: 1.2rem; - font-weight: 600; + font-size: 1.25rem; + font-weight: 700; color: #083d77; + margin-bottom: 0.5rem; + line-height: 1.3; } - .tile-container { + .tile-subtitle { + font-size: 0.9rem; + color: #666; + margin-bottom: 0.75rem; + font-weight: 500; + } + + .tile small { + font-size: 0.75rem; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; + margin-top: auto; + } + + .tile-logo { + width: 40px; + height: 40px; + object-fit: contain; + margin-bottom: 1rem; + border-radius: 8px; + background: rgba(8, 61, 119, 0.05); + padding: 0.5rem; + } + + .tile-content { + flex-grow: 1; display: flex; flex-direction: column; - align-items: flex-start; - justify-content: center; - gap: 0.5rem; - } - - .tile-container > * { - flex-grow: 0; - flex-shrink: 1; + justify-content: flex-start; } .results-container { @@ -115,12 +196,69 @@ } .section-title { - font-size: 1.5rem; - font-weight: 700; + font-size: 1.75rem; + font-weight: 800; color: #083d77; - margin-bottom: 1rem; - border-bottom: 2px solid #e0e0e0; + margin: 2.5rem 0 1.5rem 0; padding-bottom: 0.5rem; + border-bottom: 2px solid rgba(8, 61, 119, 0.1); + position: relative; + } + + .section-title::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 60px; + height: 2px; + background: linear-gradient(90deg, #083d77, #d1495b); + } + + .section-title:first-child { + margin-top: 0; + } + + .section-tiles-container { + margin-bottom: 2rem; + } + + .subsection { + margin-top: 1rem; + } + + .subsection-title { + font-size: 1.1rem; + font-weight: 600; + padding: 0.25rem 0.5rem; + position: relative; + border-radius: 2px 2px 0 0; + border-bottom: none; + display: inline-block; + min-width: 120px; + z-index: 1; + width: 100%; + margin-top: 1.5rem; + } + + .subsection-description { + font-size: 0.9rem; + margin: 0; + padding: 0.25rem 1rem 0.75rem; + font-style: italic; + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(224, 224, 224, 0.5); + border-top: none; + border-bottom: none; + margin-left: 0; + } + + .subsection-tiles-container { + background: transparent; + border: none; + border-radius: 0; + overflow: visible; + margin-top: 0rem; } .loading { @@ -145,8 +283,38 @@ padding: 1rem; } - .brands-grid { + .tiles-grid { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1px; + } + + .tile-wrapper { + min-height: 100px; + } + + .tile { + padding: 1.25rem; + min-height: 100px; + } + + .tile-title { + font-size: 1.1rem; + } + } + + @media (max-width: 480px) { + .tiles-grid { grid-template-columns: 1fr; + gap: 1px; + } + + .tile-wrapper { + min-height: 80px; + } + + .tile { + padding: 1rem; + min-height: 80px; } } diff --git a/templates/result/index.html.twig b/templates/result/index.html.twig index 4093ee6..5f300ae 100644 --- a/templates/result/index.html.twig +++ b/templates/result/index.html.twig @@ -7,5 +7,7 @@ {% include '_components/search.html.twig' with { query: query } %} - {% include 'result/tiles/collection.html.twig' with { tiles: tiles } %} +