Initial commit

This commit is contained in:
2026-07-27 18:01:07 +02:00
commit 0758b00d54
192 changed files with 45091 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
{
"name": "feature-product-detail",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "packages/shop/feature-product-detail/src",
"prefix": "shop",
"tags": ["scope:shop", "type:feature"],
"targets": {}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/product-detail/product-detail.component';
export * from './lib/feature-product-detail.routes';

View File

@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
import { ProductDetailComponent } from './product-detail/product-detail.component';
export const featureProductDetailRoutes: Routes = [
{
path: ':id',
component: ProductDetailComponent,
},
];

View File

@@ -0,0 +1,113 @@
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { ProductDetailComponent } from './product-detail.component';
import { ProductsService } from '@org/shop/data';
import { Product } from '@org/models';
import { describe, it, beforeEach, expect, vi } from 'vitest';
describe('ProductDetailComponent', () => {
let component: ProductDetailComponent;
let fixture: ComponentFixture<ProductDetailComponent>;
let mockProductsService: Partial<ProductsService>;
let mockRouter: Partial<Router>;
let mockActivatedRoute: Partial<ActivatedRoute>;
const mockProduct: Product = {
id: '1',
name: 'Test Product',
description: 'Test Description',
price: 99.99,
imageUrl: 'https://example.com/image.jpg',
category: 'Electronics',
inStock: true,
rating: 4.5,
reviewCount: 100,
};
beforeEach(async () => {
mockProductsService = {
getProductById: vi.fn(),
};
mockRouter = {
navigate: vi.fn(),
};
mockActivatedRoute = {
snapshot: {
paramMap: {
get: vi.fn().mockReturnValue('1'),
},
},
};
await TestBed.configureTestingModule({
imports: [ProductDetailComponent],
providers: [
{ provide: ProductsService, useValue: mockProductsService },
{ provide: Router, useValue: mockRouter },
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
],
}).compileComponents();
fixture = TestBed.createComponent(ProductDetailComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load product on init', () => {
mockProductsService.getProductById.mockReturnValue(of(mockProduct));
component.ngOnInit();
expect(mockProductsService.getProductById).toHaveBeenCalledWith('1');
expect(component.product()).toEqual(mockProduct);
expect(component.loading()).toBe(false);
expect(component.error()).toBe(null);
});
it('should handle error when product not found', () => {
mockProductsService.getProductById.mockReturnValue(of(null));
component.ngOnInit();
expect(component.error()).toBe('Product not found');
expect(component.loading()).toBe(false);
});
it('should handle error when loading fails', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
mockProductsService.getProductById.mockReturnValue(
throwError(() => new Error('Network error'))
);
component.ngOnInit();
expect(component.error()).toBe('Failed to load product details');
expect(component.loading()).toBe(false);
consoleSpy.mockRestore();
});
it('should calculate star ratings correctly', () => {
component.product.set(mockProduct);
const stars = component.getStars();
expect(stars).toEqual([true, true, true, true, true]);
});
it('should handle add to cart action', () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
component.product.set(mockProduct);
component.addToCart();
expect(consoleSpy).toHaveBeenCalledWith('Adding to cart:', '1');
expect(alertSpy).toHaveBeenCalledWith('Product added to cart!');
});
});

View File

@@ -0,0 +1,393 @@
import { Component, inject, signal, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ProductsService } from '@org/shop/data';
import { Product } from '@org/models';
import { LoadingSpinnerComponent, ErrorMessageComponent } from '@org/shop/shared-ui';
@Component({
selector: 'shop-product-detail',
imports: [
CommonModule,
CurrencyPipe,
RouterLink,
LoadingSpinnerComponent,
ErrorMessageComponent,
],
template: `
<div class="product-detail-container">
@if (loading()) {
<shop-loading-spinner />
} @else if (error()) {
<shop-error-message
[title]="'Product Not Found'"
[message]="error() || undefined"
(retry)="loadProduct()"
/>
} @else if (product()) {
<div class="breadcrumb">
<a routerLink="/products">← Back to Products</a>
</div>
<div class="product-detail">
<div class="product-image-section">
<img
[src]="product()!.imageUrl"
[alt]="product()!.name"
class="product-image"
/>
@if (!product()!.inStock) {
<div class="out-of-stock-badge">Out of Stock</div>
}
</div>
<div class="product-info-section">
<div class="product-category">{{ product()!.category }}</div>
<h1 class="product-name">{{ product()!.name }}</h1>
<div class="product-rating">
<span class="stars">
@for (star of getStars(); track $index) {
<span [class.filled]="star">★</span>
}
</span>
<span class="rating-text">
{{ product()!.rating }} out of 5
</span>
<span class="review-count">
({{ product()!.reviewCount }} reviews)
</span>
</div>
<div class="product-price">
{{ product()!.price | currency }}
</div>
<div class="product-description">
<h2>Description</h2>
<p>{{ product()!.description }}</p>
</div>
<div class="product-actions">
@if (product()!.inStock) {
<button class="btn-primary" (click)="addToCart()">
Add to Cart
</button>
<button class="btn-secondary" (click)="addToWishlist()">
Add to Wishlist
</button>
} @else {
<button class="btn-disabled" disabled>
Currently Unavailable
</button>
}
</div>
<div class="product-info">
<h3>Product Information</h3>
<dl>
<dt>Product ID:</dt>
<dd>{{ product()!.id }}</dd>
<dt>Category:</dt>
<dd>{{ product()!.category }}</dd>
<dt>Availability:</dt>
<dd>{{ product()!.inStock ? 'In Stock' : 'Out of Stock' }}</dd>
</dl>
</div>
</div>
</div>
}
</div>
`,
styles: [`
.product-detail-container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.breadcrumb {
margin-bottom: 24px;
}
.breadcrumb a {
color: #3498db;
text-decoration: none;
font-size: 0.95rem;
transition: color 0.3s;
}
.breadcrumb a:hover {
color: #2980b9;
text-decoration: underline;
}
.product-detail {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
background: white;
border-radius: 8px;
overflow: hidden;
}
.product-image-section {
position: relative;
}
.product-image {
width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.out-of-stock-badge {
position: absolute;
top: 16px;
right: 16px;
background: rgba(220, 53, 69, 0.9);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
}
.product-info-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.product-category {
color: #6c757d;
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.product-name {
font-size: 2rem;
margin: 0;
color: #333;
}
.product-rating {
display: flex;
align-items: center;
gap: 12px;
}
.stars {
color: #ddd;
font-size: 1.25rem;
}
.stars .filled {
color: #ffd700;
}
.rating-text {
font-weight: 600;
color: #333;
}
.review-count {
color: #666;
font-size: 0.95rem;
}
.product-price {
font-size: 2rem;
font-weight: bold;
color: #28a745;
}
.product-description {
border-top: 1px solid #e0e0e0;
padding-top: 24px;
}
.product-description h2 {
font-size: 1.25rem;
margin-bottom: 12px;
color: #333;
}
.product-description p {
color: #666;
line-height: 1.6;
}
.product-actions {
display: flex;
gap: 16px;
}
.btn-primary {
flex: 1;
padding: 12px 24px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
flex: 1;
padding: 12px 24px;
background: transparent;
color: #3498db;
border: 2px solid #3498db;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-secondary:hover {
background: #3498db;
color: white;
}
.btn-disabled {
flex: 1;
padding: 12px 24px;
background: #6c757d;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: not-allowed;
opacity: 0.6;
}
.product-info {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
}
.product-info h3 {
font-size: 1.1rem;
margin-bottom: 16px;
color: #333;
}
.product-info dl {
display: grid;
grid-template-columns: 120px 1fr;
gap: 12px;
margin: 0;
}
.product-info dt {
font-weight: 600;
color: #666;
}
.product-info dd {
margin: 0;
color: #333;
}
@media (max-width: 768px) {
.product-detail {
grid-template-columns: 1fr;
gap: 32px;
}
.product-name {
font-size: 1.5rem;
}
.product-price {
font-size: 1.5rem;
}
.product-actions {
flex-direction: column;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly productsService = inject(ProductsService);
// State signals
readonly product = signal<Product | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
ngOnInit() {
this.loadProduct();
}
loadProduct() {
const productId = this.route.snapshot.paramMap.get('id');
if (!productId) {
this.error.set('Product ID not provided');
return;
}
this.loading.set(true);
this.error.set(null);
this.productsService.getProductById(productId).subscribe({
next: (product) => {
if (product) {
this.product.set(product);
} else {
this.error.set('Product not found');
}
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load product details');
this.loading.set(false);
console.error('Error loading product:', err);
},
});
}
getStars(): boolean[] {
const product = this.product();
if (!product) return [];
const rating = product.rating;
const fullStars = Math.floor(rating);
const hasHalfStar = rating % 1 >= 0.5;
return Array(5).fill(false).map((_, index) => {
if (index < fullStars) return true;
if (index === fullStars && hasHalfStar) return true;
return false;
});
}
addToCart() {
// This would typically call a cart service
console.log('Adding to cart:', this.product()?.id);
alert('Product added to cart!');
}
addToWishlist() {
// This would typically call a wishlist service
console.log('Adding to wishlist:', this.product()?.id);
alert('Product added to wishlist!');
}
}

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler",
"lib": ["dom", "es2022"]
}
}

View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": ["src/**/*.spec.ts", "src/test-setup.ts"],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,30 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"isolatedModules": true
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,28 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import tsconfigPaths from 'vite-tsconfig-paths';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/packages/shop/feature-product-detail',
plugins: [angular(), tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
name: 'feature-product-detail',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../coverage/packages/shop/feature-product-detail',
provider: 'v8' as const,
},
},
}));