Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/android-chrome-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/android-chrome-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions public/fallback.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon-16x16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/favicon-32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/favicon.ico
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
<article class="vehicle-card">
<div class="vehicle-card__image-wrapper">
<img
class="vehicle-card__image"
[src]="imageUrl"
[alt]="vehicle().name"
loading="lazy"
/>
@if (showFallback()) {
<app-car-silhouette />
} @else {
<img
class="vehicle-card__image"
[src]="imageUrl()"
[alt]="vehicle().name"
loading="lazy"
(error)="onImageError()"
/>
}
</div>

<div class="vehicle-card__body">
<h2 class="vehicle-card__name">{{ vehicle().name }}</h2>
<p class="vehicle-card__price">From {{ vehicle().price }}</p>
<p class="vehicle-card__description">{{ vehicle().description || 'test' }}</p>
<p class="vehicle-card__price">From {{ vehicle().price || 'unlisted' }}</p>
<p class="vehicle-card__description">{{ vehicle().description }}</p>
</div>

<footer class="vehicle-card__footer">
<app-button label="Show more" (click)="modal.open()" />
</footer>
</article>

<app-vehicle-detail-modal #modal [vehicle]="vehicle()" [emissionsLabel]="emissionsLabel" />
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
}

Expand All @@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<dialog #dialog class="vehicle-detail-modal" >
<div class="vehicle-detail-modal__content">
<button class="vehicle-detail-modal__close" (click)="close()" aria-label="Close details">
&times;
</button>
<h2 class="vehicle-detail-modal__title">{{ vehicle().name }}</h2>
<ul class="vehicle-detail-modal__list">
<li>Bodystyles: {{ vehicle().meta.bodystyles.join(', ') }}</li>
<li>Drivetrain: {{ vehicle().meta.drivetrain.join(', ') }}</li>
<li>Passengers: {{ vehicle().meta.passengers }}</li>
<li>Emissions: {{ emissionsLabel() }}</li>
</ul>
</div>

</dialog>
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<VehicleDetailModal>;

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<HTMLElement>;
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();
});
});
Original file line number Diff line number Diff line change
@@ -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<AnyVehicle>();
emissionsLabel = input();

// viewChild give you a signal based reference to the <dialog> element
private readonly dialog = viewChild.required<ElementRef<HTMLDialogElement>>('dialog');

open() {
this.dialog().nativeElement.showModal();
}

close() {
this.dialog().nativeElement.close();
}



}
8 changes: 8 additions & 0 deletions src/app/shared/ui/button/button.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<button
class="btn"
[class.btn--primary]="variant() === 'primary'"
[class.btn--secondary]="variant() === 'secondary'"
[type]="type()"
>
{{ label() }}
</button>
27 changes: 27 additions & 0 deletions src/app/shared/ui/button/button.component.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
13 changes: 13 additions & 0 deletions src/app/shared/ui/button/button.component.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
variant = input<'primary' | 'secondary'>('primary');
type = input<'button' | 'submit'>('button');
}
Loading