diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png new file mode 100644 index 0000000..d477618 Binary files /dev/null and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png new file mode 100644 index 0000000..70ec5f9 Binary files /dev/null and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..77bb6e2 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/fallback.svg b/public/fallback.svg new file mode 100644 index 0000000..d5c8978 --- /dev/null +++ b/public/fallback.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 0000000..e68c173 Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000..74a9c2a Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 57614f9..44986e3 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ 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 index 42aa1eb..167f01c 100644 --- a/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.html +++ b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.html @@ -1,16 +1,27 @@
- + @if (showFallback()) { + + } @else { + + }

{{ vehicle().name }}

-

From {{ vehicle().price }}

-

{{ vehicle().description || 'test' }}

+

From {{ vehicle().price || 'unlisted' }}

+

{{ 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 index a1b8164..0e20e61 100644 --- a/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.scss +++ b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.scss @@ -1,7 +1,9 @@ // Animation runs on the host element so that the parent can control // animation-delay via [style.animation-delay] without ViewEncapsulation issues. :host { - display: block; + display: flex; + flex-direction: column; + height: 100%; opacity: 0; animation: vehicle-card-fade-in 0.5s ease forwards; } @@ -19,6 +21,7 @@ padding: 16px; border-bottom: 1px solid #e0e0e0; background-color: #fff; + flex: 1; // ── Tablet and above: flip to vertical stacked layout ── @media (min-width: 768px) { @@ -67,6 +70,10 @@ } &__name { + border-top: 1px solid black; + border-bottom: 1px solid black; + width: fit-content; + padding-block: 4px; font-family: Georgia, 'Times New Roman', serif; font-size: 1rem; font-weight: 700; @@ -76,9 +83,8 @@ @media (min-width: 768px) { font-size: 1.0625rem; - text-decoration: underline; - text-underline-offset: 5px; margin-bottom: 14px; + margin-inline: auto; } } @@ -98,4 +104,14 @@ line-height: 1.45; margin: 0; } + + &__footer { + margin-top: auto; + padding: 12px 16px; + text-align: center; + + @media (min-width: 768px) { + padding: 16px; + } + } } 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 index c94085a..e32ea9c 100644 --- a/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.ts +++ b/src/app/features/vehicles/components/vehicle-card/vehicle-card.component.ts @@ -1,9 +1,12 @@ -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core'; import { AnyVehicle } from '../../../../core/interfaces/vehicle.interface'; +import { VehicleDetailModal } from "../vehicle-detail-modal/vehicle-detail-modal"; +import { ButtonComponent } from '../../../../shared/ui/button/button.component'; +import { CarSilhouetteComponent } from '../../../../shared/ui/svg/car-silhouette/car-silhouette.component'; @Component({ selector: 'app-vehicle-card', - imports: [], + imports: [VehicleDetailModal, ButtonComponent, CarSilhouetteComponent], templateUrl: './vehicle-card.component.html', styleUrl: './vehicle-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -17,17 +20,25 @@ export class VehicleCardComponent { */ index = input(0); + imageError = signal(false); + /** * 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 { + readonly imageUrl = computed(() => { const { media } = this.vehicle(); return media.find((m) => m.url.includes('16x9'))?.url ?? media[0]?.url ?? ''; - } + }); + + readonly showFallback = computed(() => !this.imageUrl() || this.imageError()); get emissionsLabel(): string { const { template, value } = this.vehicle().meta.emissions; return template.replace('$value', String(value)); } + + onImageError(): void { + this.imageError.set(true); + } } diff --git a/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.html b/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.html new file mode 100644 index 0000000..162155f --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.html @@ -0,0 +1,15 @@ + +
+ +

{{ vehicle().name }}

+
    +
  • Bodystyles: {{ vehicle().meta.bodystyles.join(', ') }}
  • +
  • Drivetrain: {{ vehicle().meta.drivetrain.join(', ') }}
  • +
  • Passengers: {{ vehicle().meta.passengers }}
  • +
  • Emissions: {{ emissionsLabel() }}
  • +
+
+ +
\ No newline at end of file diff --git a/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.scss b/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.scss new file mode 100644 index 0000000..81927b3 --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.scss @@ -0,0 +1,64 @@ +.vehicle-detail-modal { + border: none; + border-radius: 4px; + padding: 0; + width: min(560px, 90vw); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); + + &::backdrop { + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(2px); + } + + &__content { + position: relative; + padding: 32px 28px 28px; + } + + &__close { + position: absolute; + top: 12px; + right: 14px; + background: none; + border: none; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + color: #555; + padding: 4px 8px; + border-radius: 4px; + transition: background 0.15s ease, color 0.15s ease; + + &:hover { + background: #f0f0f0; + color: #1a1a1a; + } + } + + &__title { + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.25rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 20px; + padding-right: 32px; + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; + + li { + font-size: 0.9rem; + color: #333; + padding: 10px 12px; + background: #f7f7f7; + border-radius: 4px; + } + } +} diff --git a/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.spec.ts b/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.spec.ts new file mode 100644 index 0000000..6743228 --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.spec.ts @@ -0,0 +1,77 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VehicleDetailModal } from './vehicle-detail-modal'; +import { AnyVehicle } from '../../../../core/interfaces/vehicle.interface'; + +const mockVehicle: AnyVehicle = { + id: '1', + name: 'Test Vehicle', + modelYear: '2024', + apiUrl: '/api/vehicles/1', + media: [{ name: 'hero', url: '/img/hero-16x9.jpg' }], + description: 'A test vehicle description.', + price: '£30,000', + meta: { + passengers: 5, + drivetrain: ['AWD'], + bodystyles: ['SUV'], + emissions: { template: '$value g/km', value: 120 }, + }, +}; + +describe('VehicleDetailModal', () => { + let component: VehicleDetailModal; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VehicleDetailModal], + }).compileComponents(); + + fixture = TestBed.createComponent(VehicleDetailModal); + component = fixture.componentInstance; + fixture.componentRef.setInput('vehicle', mockVehicle); + fixture.componentRef.setInput('emissionsLabel', '120 g/km'); + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('open() calls showModal() on the dialog element', () => { + const dialog = fixture.nativeElement.querySelector('dialog') as HTMLDialogElement; + const spy = spyOn(dialog, 'showModal'); + component.open(); + expect(spy).toHaveBeenCalled(); + }); + + it('close() calls close() on the dialog element', () => { + const dialog = fixture.nativeElement.querySelector('dialog') as HTMLDialogElement; + const spy = spyOn(dialog, 'close'); + component.close(); + expect(spy).toHaveBeenCalled(); + }); + + it('renders the vehicle name in an h2', () => { + fixture.detectChanges(); + const h2 = fixture.nativeElement.querySelector('h2') as HTMLElement; + expect(h2.textContent).toContain('Test Vehicle'); + }); + + it('renders the emissions label in a list item', () => { + fixture.detectChanges(); + const items = fixture.nativeElement.querySelectorAll('li') as NodeListOf; + const emissionsItem = Array.from(items).find(li => li.textContent?.includes('Emissions')); + expect(emissionsItem?.textContent).toContain('120 g/km'); + }); + + it('clicking the close button calls close() on the dialog element', () => { + fixture.detectChanges(); + const dialog = fixture.nativeElement.querySelector('dialog') as HTMLDialogElement; + const spy = spyOn(dialog, 'close'); + const closeBtn = fixture.nativeElement.querySelector('.vehicle-detail-modal__close') as HTMLButtonElement; + closeBtn.click(); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.ts b/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.ts new file mode 100644 index 0000000..47e9675 --- /dev/null +++ b/src/app/features/vehicles/components/vehicle-detail-modal/vehicle-detail-modal.ts @@ -0,0 +1,28 @@ +import { Component, ElementRef, input, viewChild } from '@angular/core'; +import { AnyVehicle } from '../../../../core/interfaces/vehicle.interface'; + +@Component({ + selector: 'app-vehicle-detail-modal', + imports: [], + templateUrl: './vehicle-detail-modal.html', + styleUrl: './vehicle-detail-modal.scss', +}) +export class VehicleDetailModal { + +vehicle = input.required(); +emissionsLabel = input(); + +// viewChild give you a signal based reference to the element +private readonly dialog = viewChild.required>('dialog'); + +open() { + this.dialog().nativeElement.showModal(); +} + +close() { + this.dialog().nativeElement.close(); +} + + + +} diff --git a/src/app/shared/ui/button/button.component.html b/src/app/shared/ui/button/button.component.html new file mode 100644 index 0000000..dde4011 --- /dev/null +++ b/src/app/shared/ui/button/button.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/shared/ui/button/button.component.scss b/src/app/shared/ui/button/button.component.scss new file mode 100644 index 0000000..34e7fe0 --- /dev/null +++ b/src/app/shared/ui/button/button.component.scss @@ -0,0 +1,27 @@ +.btn { + display: block; + width: 100%; + padding: 10px 20px; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + border: none; + cursor: pointer; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.85; + } + + &--primary { + background-color: #1a1a1a; + color: #fff; + } + + &--secondary { + background-color: #fff; + color: #1a1a1a; + border: 1px solid #1a1a1a; + } +} diff --git a/src/app/shared/ui/button/button.component.ts b/src/app/shared/ui/button/button.component.ts new file mode 100644 index 0000000..aa01065 --- /dev/null +++ b/src/app/shared/ui/button/button.component.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +@Component({ + selector: 'app-button', + templateUrl: './button.component.html', + styleUrl: './button.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ButtonComponent { + label = input.required(); + variant = input<'primary' | 'secondary'>('primary'); + type = input<'button' | 'submit'>('button'); +} diff --git a/src/app/shared/ui/svg/car-silhouette/car-silhouette.component.html b/src/app/shared/ui/svg/car-silhouette/car-silhouette.component.html new file mode 100644 index 0000000..fd9aced --- /dev/null +++ b/src/app/shared/ui/svg/car-silhouette/car-silhouette.component.html @@ -0,0 +1,34 @@ + diff --git a/src/app/shared/ui/svg/car-silhouette/car-silhouette.component.scss b/src/app/shared/ui/svg/car-silhouette/car-silhouette.component.scss new file mode 100644 index 0000000..722a403 --- /dev/null +++ b/src/app/shared/ui/svg/car-silhouette/car-silhouette.component.scss @@ -0,0 +1,5 @@ +.car-silhouette { + display: block; + width: 100%; + height: 100%; +} diff --git a/src/app/shared/ui/svg/car-silhouette/car-silhouette.component.ts b/src/app/shared/ui/svg/car-silhouette/car-silhouette.component.ts new file mode 100644 index 0000000..d297361 --- /dev/null +++ b/src/app/shared/ui/svg/car-silhouette/car-silhouette.component.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +@Component({ + selector: 'app-car-silhouette', + templateUrl: './car-silhouette.component.html', + styleUrl: './car-silhouette.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CarSilhouetteComponent { + primaryColor = input('#1a1a1a'); + secondaryColor = input('#f0f0f0'); + cssClass = input(''); +} diff --git a/src/index.html b/src/index.html index 2ea1d83..b03c200 100644 --- a/src/index.html +++ b/src/index.html @@ -5,7 +5,10 @@ AngularCodeTest - + + + +