From 943ba77635b906fa8e83ab474141699173309939 Mon Sep 17 00:00:00 2001 From: Tony Date: Mon, 23 Mar 2026 17:56:17 +0200 Subject: [PATCH] Price and description UI --- .../vehicle-card/vehicle-card.component.html | 16 +++ .../vehicle-card/vehicle-card.component.scss | 101 +++++++++++++ .../vehicle-card/vehicle-card.component.ts | 36 +++++ .../vehicle-list/vehicle-list.component.html | 43 ++++++ .../vehicle-list/vehicle-list.component.scss | 134 ++++++++++++++++++ .../vehicle-list/vehicle-list.component.ts | 17 +++ 6 files changed, 347 insertions(+) create mode 100644 src/app/features/vehicles/components/vehicle-card/vehicle-card.component.html create mode 100644 src/app/features/vehicles/components/vehicle-card/vehicle-card.component.scss create mode 100644 src/app/features/vehicles/components/vehicle-card/vehicle-card.component.ts create mode 100644 src/app/features/vehicles/components/vehicle-list/vehicle-list.component.html create mode 100644 src/app/features/vehicles/components/vehicle-list/vehicle-list.component.scss create mode 100644 src/app/features/vehicles/components/vehicle-list/vehicle-list.component.ts diff --git a/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.html b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.html new file mode 100644 index 0000000..276a819 --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.html @@ -0,0 +1,16 @@ +
+
+ +
+ +
+

{{ vehicle.name }}

+

From {{ vehicle.price }}

+

{{ vehicle.description }}

+
+
diff --git a/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.scss b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.scss new file mode 100644 index 0000000..a1b8164 --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.scss @@ -0,0 +1,101 @@ +// Animation runs on the host element so that the parent can control +// animation-delay via [style.animation-delay] without ViewEncapsulation issues. +:host { + display: block; + opacity: 0; + animation: vehicle-card-fade-in 0.5s ease forwards; +} + +@keyframes vehicle-card-fade-in { + to { + opacity: 1; + } +} + +.vehicle-card { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + border-bottom: 1px solid #e0e0e0; + background-color: #fff; + + // ── Tablet and above: flip to vertical stacked layout ── + @media (min-width: 768px) { + flex-direction: column; + align-items: stretch; + padding: 0; + border-bottom: none; + } + + // ───────────────────────────────────────────── + // Image wrapper + // The same src is used at every breakpoint. + // Changing aspect-ratio + object-fit: cover gives the + // illusion of a different crop without swapping src. + // ───────────────────────────────────────────── + &__image-wrapper { + flex-shrink: 0; + width: 38%; + aspect-ratio: 1 / 1; // mobile: square crop + overflow: hidden; + + @media (min-width: 768px) { + width: 100%; + aspect-ratio: 16 / 9; // tablet+: widescreen crop + } + } + + &__image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + // ───────────────────────────────────────────── + // Text body + // ───────────────────────────────────────────── + &__body { + flex: 1; + padding: 0 16px; + + @media (min-width: 768px) { + padding: 20px 16px 28px; + text-align: center; + } + } + + &__name { + font-family: Georgia, 'Times New Roman', serif; + font-size: 1rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 6px; + + @media (min-width: 768px) { + font-size: 1.0625rem; + text-decoration: underline; + text-underline-offset: 5px; + margin-bottom: 14px; + } + } + + &__price { + font-size: 0.875rem; + color: #333; + margin: 0 0 6px; + + @media (min-width: 768px) { + margin-bottom: 10px; + } + } + + &__description { + font-size: 0.875rem; + color: #555; + line-height: 1.45; + margin: 0; + } +} diff --git a/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.ts b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.ts new file mode 100644 index 0000000..380485a --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { AnyVehicle } from '../../../../core/interfaces/vehicle.interface'; + +@Component({ + selector: 'app-vehicle-card', + imports: [], + templateUrl: './vehicle-card.component.html', + styleUrl: './vehicle-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VehicleCardComponent { + @Input({ required: true }) vehicle!: AnyVehicle; + + /** + * Used by the parent list to stagger the fade-in animation via + * [style.animation-delay] bound on the host element. + */ + @Input() index = 0; + + /** + * Prefer the 16x9 image; CSS aspect-ratio + object-fit handles the + * visual crop at all breakpoints so we never need to swap src. + */ + get imageUrl(): string { + return ( + this.vehicle.media.find((m) => m.url.includes('16x9'))?.url ?? + this.vehicle.media[0]?.url ?? + '' + ); + } + + get emissionsLabel(): string { + const { template, value } = this.vehicle.meta.emissions; + return template.replace('$value', String(value)); + } +} diff --git a/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.html b/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.html new file mode 100644 index 0000000..216e61c --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.html @@ -0,0 +1,43 @@ +
+ + @if (vehicleService.loading()) { + + + + + } @else if (vehicleService.error()) { + + + + } @else { + + + + } + +
diff --git a/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.scss b/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.scss new file mode 100644 index 0000000..d14758d --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.scss @@ -0,0 +1,134 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Shared shimmer animation used by skeleton elements +// ───────────────────────────────────────────────────────────────────────────── +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Section wrapper +// ───────────────────────────────────────────────────────────────────────────── +.vehicle-list { + max-width: 1440px; + margin: 0 auto; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Responsive grid +// +// Mobile (default): single-column stacked list — no grid needed. +// Tablet (≥768px): 2-column grid. The 1px gap + background-color on the +// container creates the dividing lines between cards without +// needing borders on every card. +// Desktop (≥1280px): 4-column grid. +// ───────────────────────────────────────────────────────────────────────────── +.vehicle-list__grid { + list-style: none; + margin: 0; + padding: 0; + + @media (min-width: 768px) { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1px; + background-color: #d0d0d0; // shows through the 1px gap as grid lines + } + + @media (min-width: 1280px) { + grid-template-columns: repeat(4, 1fr); + } +} + +// Each
  • needs its own background to mask the grid container's background. +.vehicle-list__item { + @media (min-width: 768px) { + background-color: #fff; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Loading skeleton +// Mirrors the card layout so there's no layout shift when data arrives. +// ───────────────────────────────────────────────────────────────────────────── +%shimmer { + background: linear-gradient(90deg, #efefef 25%, #e0e0e0 50%, #efefef 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +.vehicle-list__skeleton { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + border-bottom: 1px solid #e0e0e0; + background-color: #fff; + + @media (min-width: 768px) { + flex-direction: column; + padding: 0; + border-bottom: none; + } +} + +.vehicle-list__skeleton-image { + @extend %shimmer; + + flex-shrink: 0; + width: 38%; + aspect-ratio: 1 / 1; + + @media (min-width: 768px) { + width: 100%; + aspect-ratio: 16 / 9; + } +} + +.vehicle-list__skeleton-body { + flex: 1; + padding: 0 16px; + display: flex; + flex-direction: column; + gap: 10px; + + @media (min-width: 768px) { + padding: 20px 16px 28px; + align-items: center; + } +} + +.vehicle-list__skeleton-line { + @extend %shimmer; + + height: 14px; + border-radius: 2px; + width: 90%; + + &--title { + height: 18px; + width: 70%; + } + + &--short { + width: 55%; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Error state +// ───────────────────────────────────────────────────────────────────────────── +.vehicle-list__error { + padding: 48px 24px; + text-align: center; +} + +.vehicle-list__error-message { + color: #c0392b; + font-size: 1rem; + margin: 0; +} diff --git a/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.ts b/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.ts new file mode 100644 index 0000000..a366f60 --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-list/vehicle-list.component.ts @@ -0,0 +1,17 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { VehicleService } from '../../../../core/services/vehicle.service'; +import { VehicleCardComponent } from '../vehicle-card/vehicle-card.component'; + +@Component({ + selector: 'app-vehicle-list', + imports: [VehicleCardComponent], + templateUrl: './vehicle-list.component.html', + styleUrl: './vehicle-list.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VehicleListComponent { + protected readonly vehicleService = inject(VehicleService); + + /** Drives the skeleton loading grid — one placeholder per expected vehicle. */ + protected readonly skeletonCount = Array.from({ length: 6 }); +}