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,7 @@
# data
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test data` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

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

View File

@@ -0,0 +1 @@
export * from './lib/services/products.service';

View File

@@ -0,0 +1,278 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { ProductsService } from './products.service';
import { Product, ApiResponse, PaginatedResponse, ProductFilter } from '@org/models';
describe('ProductsService', () => {
let service: ProductsService;
let httpMock: HttpTestingController;
const apiUrl = 'http://localhost:3333/api';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
ProductsService
],
});
service = TestBed.inject(ProductsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getProducts', () => {
const mockProductsResponse: ApiResponse<PaginatedResponse<Product>> = {
success: true,
data: {
items: [
{
id: '1',
name: 'Product 1',
description: 'Description 1',
price: 100,
imageUrl: 'image1.jpg',
category: 'Electronics',
inStock: true,
rating: 4.5,
reviewCount: 10,
},
{
id: '2',
name: 'Product 2',
description: 'Description 2',
price: 200,
imageUrl: 'image2.jpg',
category: 'Clothing',
inStock: false,
rating: 3.5,
reviewCount: 5,
},
],
total: 2,
page: 1,
pageSize: 12,
totalPages: 1,
},
};
it('should return products with default pagination', () => {
service.getProducts().subscribe((response) => {
expect(response.items.length).toBe(2);
expect(response.total).toBe(2);
expect(response.page).toBe(1);
expect(service.loading()).toBeFalsy();
expect(service.error()).toBeNull();
});
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
expect(req.request.method).toBe('GET');
req.flush(mockProductsResponse);
});
it('should apply filters when provided', () => {
const filter: ProductFilter = {
category: 'Electronics',
minPrice: 50,
maxPrice: 150,
inStock: true,
searchTerm: 'test',
};
service.getProducts(filter, 2, 20).subscribe((response) => {
expect(response).toBeTruthy();
});
const req = httpMock.expectOne(
`${apiUrl}/products?page=2&pageSize=20&category=Electronics&minPrice=50&maxPrice=150&inStock=true&searchTerm=test`
);
expect(req.request.method).toBe('GET');
req.flush(mockProductsResponse);
});
it('should handle error response', () => {
const errorResponse: ApiResponse<PaginatedResponse<Product>> = {
success: false,
error: 'Server error',
data: undefined as unknown
};
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.getProducts().subscribe((response) => {
expect(response.items).toEqual([]);
expect(response.total).toBe(0);
expect(service.error()).toContain('Server error');
});
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
req.flush(errorResponse);
consoleErrorSpy.mockRestore();
});
it('should handle network error', () => {
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.getProducts().subscribe((response) => {
expect(response.items).toEqual([]);
expect(response.total).toBe(0);
expect(service.error()).toBeTruthy();
});
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
req.error(new ProgressEvent('Network error'));
consoleErrorSpy.mockRestore();
});
});
describe('getProductById', () => {
const mockProduct: Product = {
id: '1',
name: 'Product 1',
description: 'Description 1',
price: 100,
imageUrl: 'image1.jpg',
category: 'Electronics',
inStock: true,
rating: 4.5,
reviewCount: 10,
};
it('should return a product by id', () => {
const mockResponse: ApiResponse<Product> = {
success: true,
data: mockProduct,
};
service.getProductById('1').subscribe((product) => {
expect(product).toEqual(mockProduct);
expect(service.loading()).toBeFalsy();
expect(service.error()).toBeNull();
});
const req = httpMock.expectOne(`${apiUrl}/products/1`);
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
it('should return null on error', () => {
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.getProductById('1').subscribe((product) => {
expect(product).toBeNull();
expect(service.error()).toBeTruthy();
});
const req = httpMock.expectOne(`${apiUrl}/products/1`);
req.error(new ProgressEvent('Network error'));
consoleErrorSpy.mockRestore();
});
});
describe('getCategories', () => {
it('should return categories list', () => {
const mockCategories = ['Electronics', 'Clothing', 'Books'];
const mockResponse: ApiResponse<string[]> = {
success: true,
data: mockCategories,
};
service.getCategories().subscribe((categories) => {
expect(categories).toEqual(mockCategories);
});
const req = httpMock.expectOne(`${apiUrl}/products-metadata/categories`);
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
it('should return empty array on error', () => {
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.getCategories().subscribe((categories) => {
expect(categories).toEqual([]);
});
const req = httpMock.expectOne(`${apiUrl}/products-metadata/categories`);
req.error(new ProgressEvent('Network error'));
consoleErrorSpy.mockRestore();
});
});
describe('getPriceRange', () => {
it('should return price range', () => {
const mockPriceRange = { min: 10, max: 500 };
const mockResponse: ApiResponse<{ min: number; max: number }> = {
success: true,
data: mockPriceRange,
};
service.getPriceRange().subscribe((range) => {
expect(range).toEqual(mockPriceRange);
});
const req = httpMock.expectOne(`${apiUrl}/products-metadata/price-range`);
expect(req.request.method).toBe('GET');
req.flush(mockResponse);
});
it('should return default range on error', () => {
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.getPriceRange().subscribe((range) => {
expect(range).toEqual({ min: 0, max: 1000 });
});
const req = httpMock.expectOne(`${apiUrl}/products-metadata/price-range`);
req.error(new ProgressEvent('Network error'));
consoleErrorSpy.mockRestore();
});
});
describe('loading and error signals', () => {
it('should set loading to true when fetching products', () => {
expect(service.loading()).toBeFalsy();
service.getProducts().subscribe();
expect(service.loading()).toBeTruthy();
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
req.flush({ success: true, data: { items: [], total: 0, page: 1, pageSize: 12, totalPages: 0 } });
expect(service.loading()).toBeFalsy();
});
it('should set error message on failure', () => {
// Silence console.error for this test
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
expect(service.error()).toBeNull();
service.getProductById('1').subscribe(() => {
expect(service.error()).toBeTruthy();
});
const req = httpMock.expectOne(`${apiUrl}/products/1`);
req.error(new ProgressEvent('Network error'));
consoleErrorSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,139 @@
import { inject, Injectable, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map, catchError, of } from 'rxjs';
import { Product, ApiResponse, PaginatedResponse, ProductFilter } from '@org/models';
@Injectable({
providedIn: 'root',
})
export class ProductsService {
private readonly http = inject(HttpClient);
private readonly apiUrl = 'http://localhost:3333/api';
// Signals for state management
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
readonly loading = this.loadingSignal.asReadonly();
readonly error = this.errorSignal.asReadonly();
getProducts(
filter?: ProductFilter,
page = 1,
pageSize = 12
): Observable<PaginatedResponse<Product>> {
this.loadingSignal.set(true);
this.errorSignal.set(null);
let params = new HttpParams()
.set('page', page.toString())
.set('pageSize', pageSize.toString());
if (filter) {
if (filter.category) {
params = params.set('category', filter.category);
}
if (filter.minPrice !== undefined) {
params = params.set('minPrice', filter.minPrice.toString());
}
if (filter.maxPrice !== undefined) {
params = params.set('maxPrice', filter.maxPrice.toString());
}
if (filter.inStock !== undefined) {
params = params.set('inStock', filter.inStock.toString());
}
if (filter.searchTerm) {
params = params.set('searchTerm', filter.searchTerm);
}
}
return this.http
.get<ApiResponse<PaginatedResponse<Product>>>(`${this.apiUrl}/products`, {
params,
})
.pipe(
map((response) => {
this.loadingSignal.set(false);
if (!response.success) {
throw new Error(response.error || 'Failed to load products');
}
return response.data;
}),
catchError((error) => {
this.loadingSignal.set(false);
this.errorSignal.set(
error.message || 'An error occurred while loading products'
);
console.error('Error loading products:', error);
return of({
items: [],
total: 0,
page: 1,
pageSize: 12,
totalPages: 0,
});
})
);
}
getProductById(id: string): Observable<Product | null> {
this.loadingSignal.set(true);
this.errorSignal.set(null);
return this.http
.get<ApiResponse<Product>>(`${this.apiUrl}/products/${id}`)
.pipe(
map((response) => {
this.loadingSignal.set(false);
if (!response.success) {
throw new Error(response.error || 'Failed to load product');
}
return response.data;
}),
catchError((error) => {
this.loadingSignal.set(false);
this.errorSignal.set(
error.message || 'An error occurred while loading the product'
);
console.error('Error loading product:', error);
return of(null);
})
);
}
getCategories(): Observable<string[]> {
return this.http
.get<ApiResponse<string[]>>(`${this.apiUrl}/products-metadata/categories`)
.pipe(
map((response) => {
if (!response.success) {
throw new Error(response.error || 'Failed to load categories');
}
return response.data;
}),
catchError((error) => {
console.error('Error loading categories:', error);
return of([]);
})
);
}
getPriceRange(): Observable<{ min: number; max: number }> {
return this.http
.get<ApiResponse<{ min: number; max: number }>>(
`${this.apiUrl}/products-metadata/price-range`
)
.pipe(
map((response) => {
if (!response.success) {
throw new Error(response.error || 'Failed to load price range');
}
return response.data;
}),
catchError((error) => {
console.error('Error loading price range:', error);
return of({ min: 0, max: 1000 });
})
);
}
}

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,31 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve",
"lib": ["dom", "es2022"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"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/data',
plugins: [angular(), tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
name: 'data',
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/data',
provider: 'v8' as const,
},
},
}));

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,
},
},
}));

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
import { ProductListComponent } from './product-list/product-list.component';
export const featureProductsRoutes: Routes = [
{
path: '',
component: ProductListComponent,
},
];

View File

@@ -0,0 +1,134 @@
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { ProductListComponent } from './product-list.component';
import { ProductsService } from '@org/shop/data';
import { Product } from '@org/models';
import { describe, it, beforeEach, expect, vi } from 'vitest';
describe('ProductListComponent', () => {
let component: ProductListComponent;
let fixture: ComponentFixture<ProductListComponent>;
let mockProductsService: Partial<ProductsService>;
let mockRouter: Partial<Router>;
const mockProducts: Product[] = [
{
id: '1',
name: 'Product 1',
description: 'Description 1',
price: 99.99,
imageUrl: 'https://example.com/1.jpg',
category: 'Electronics',
inStock: true,
rating: 4.5,
reviewCount: 100,
},
{
id: '2',
name: 'Product 2',
description: 'Description 2',
price: 149.99,
imageUrl: 'https://example.com/2.jpg',
category: 'Clothing',
inStock: false,
rating: 4.0,
reviewCount: 50,
},
];
beforeEach(async () => {
mockProductsService = {
getProducts: vi.fn(),
getCategories: vi.fn(),
loading: vi.fn().mockReturnValue(false),
error: vi.fn().mockReturnValue(null),
};
mockRouter = {
navigate: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [ProductListComponent],
providers: [
{ provide: ProductsService, useValue: mockProductsService },
{ provide: Router, useValue: mockRouter },
],
}).compileComponents();
fixture = TestBed.createComponent(ProductListComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load products and categories on init', () => {
mockProductsService.getProducts.mockReturnValue(of({
items: mockProducts,
total: 2,
page: 1,
pageSize: 10,
totalPages: 1,
}));
mockProductsService.getCategories.mockReturnValue(of(['Electronics', 'Clothing']));
component.ngOnInit();
expect(mockProductsService.getProducts).toHaveBeenCalled();
expect(mockProductsService.getCategories).toHaveBeenCalled();
expect(component.products()).toEqual(mockProducts);
});
it('should navigate to product detail when product is selected', () => {
const product = mockProducts[0];
component.onProductSelect(product);
expect(mockRouter.navigate).toHaveBeenCalledWith(['/products', product.id]);
});
it('should apply filters when search term changes', () => {
mockProductsService.getProducts.mockReturnValue(of({
items: mockProducts,
total: 2,
page: 1,
pageSize: 10,
totalPages: 1,
}));
component.searchTerm = 'Product 1';
component.onSearchChange();
expect(mockProductsService.getProducts).toHaveBeenCalledWith(
expect.objectContaining({
searchTerm: 'Product 1',
}),
1,
12
);
});
it('should apply filters when category changes', () => {
mockProductsService.getProducts.mockReturnValue(of({
items: [mockProducts[0]],
total: 1,
page: 1,
pageSize: 10,
totalPages: 1,
}));
component.selectedCategory = 'Electronics';
component.onFilterChange();
expect(mockProductsService.getProducts).toHaveBeenCalledWith(
expect.objectContaining({
category: 'Electronics',
}),
1,
12
);
});
});

View File

@@ -0,0 +1,331 @@
import { Component, inject, signal, computed, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ProductsService } from '@org/shop/data';
import { Product, ProductFilter } from '@org/models';
import {
ProductGridComponent,
LoadingSpinnerComponent,
ErrorMessageComponent,
} from '@org/shop/shared-ui';
@Component({
selector: 'shop-product-list',
imports: [
CommonModule,
FormsModule,
ProductGridComponent,
LoadingSpinnerComponent,
ErrorMessageComponent,
],
template: `
<div class="product-list-container">
<header class="page-header">
<h1>Our Products</h1>
<p>Explore our wide selection of high-quality products</p>
</header>
<div class="filters-section">
<div class="search-box">
<input
type="text"
placeholder="Search products..."
[(ngModel)]="searchTerm"
(ngModelChange)="onSearchChange()"
class="search-input"
/>
</div>
<div class="filter-controls">
<select
[(ngModel)]="selectedCategory"
(ngModelChange)="onFilterChange()"
class="filter-select"
>
<option value="">All Categories</option>
@for (category of categories(); track category) {
<option [value]="category">{{ category }}</option>
}
</select>
<label class="checkbox-label">
<input
type="checkbox"
[(ngModel)]="inStockOnly"
(ngModelChange)="onFilterChange()"
/>
In Stock Only
</label>
</div>
</div>
@if (loading()) {
<shop-loading-spinner />
} @else if (error()) {
<shop-error-message
[message]="error() || undefined"
(retry)="loadProducts()"
/>
} @else {
<div class="results-info">
Showing {{ products().length }} of {{ totalProducts() }} products
</div>
<shop-product-grid
[products]="products()"
(productSelect)="onProductSelect($event)"
/>
@if (hasMorePages()) {
<div class="pagination">
<button
class="btn-secondary"
[disabled]="currentPage() === 1"
(click)="previousPage()"
>
Previous
</button>
<span class="page-info">
Page {{ currentPage() }} of {{ totalPages() }}
</span>
<button
class="btn-secondary"
[disabled]="currentPage() === totalPages()"
(click)="nextPage()"
>
Next
</button>
</div>
}
}
</div>
`,
styles: [`
.product-list-container {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.page-header {
text-align: center;
margin-bottom: 48px;
}
.page-header h1 {
font-size: 2.5rem;
margin-bottom: 8px;
color: #333;
}
.page-header p {
font-size: 1.1rem;
color: #666;
}
.filters-section {
background: #f8f9fa;
padding: 24px;
border-radius: 8px;
margin-bottom: 32px;
}
.search-box {
margin-bottom: 16px;
}
.search-input {
width: 100%;
padding: 12px 16px;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: #3498db;
}
.filter-controls {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.filter-select {
padding: 8px 16px;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 1rem;
}
.checkbox-label input {
width: 18px;
height: 18px;
cursor: pointer;
}
.results-info {
color: #666;
margin-bottom: 16px;
font-size: 0.95rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 48px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
.page-info {
color: #666;
font-size: 1rem;
}
.btn-secondary {
padding: 8px 16px;
background: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background 0.3s;
}
.btn-secondary:hover:not(:disabled) {
background: #5a6268;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.product-list-container {
padding: 16px;
}
.page-header h1 {
font-size: 2rem;
}
.filter-controls {
flex-direction: column;
align-items: stretch;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductListComponent implements OnInit {
private readonly productsService = inject(ProductsService);
private readonly router = inject(Router);
// State signals
readonly products = signal<Product[]>([]);
readonly totalProducts = signal(0);
readonly currentPage = signal(1);
readonly totalPages = signal(0);
readonly categories = signal<string[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
// Filter state
searchTerm = '';
selectedCategory = '';
inStockOnly = false;
// Computed values
readonly hasMorePages = computed(() => this.totalPages() > 1);
ngOnInit() {
this.loadCategories();
this.loadProducts();
}
loadCategories() {
this.productsService.getCategories().subscribe({
next: (categories) => this.categories.set(categories),
error: (err) => console.error('Error loading categories:', err),
});
}
loadProducts() {
this.loading.set(true);
this.error.set(null);
const filter: ProductFilter = {};
if (this.searchTerm) {
filter.searchTerm = this.searchTerm;
}
if (this.selectedCategory) {
filter.category = this.selectedCategory;
}
if (this.inStockOnly) {
filter.inStock = true;
}
this.productsService.getProducts(filter, this.currentPage(), 12).subscribe({
next: (response) => {
this.products.set(response.items);
this.totalProducts.set(response.total);
this.totalPages.set(response.totalPages);
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load products. Please try again.');
this.loading.set(false);
console.error('Error loading products:', err);
},
});
}
onSearchChange() {
this.currentPage.set(1);
this.loadProducts();
}
onFilterChange() {
this.currentPage.set(1);
this.loadProducts();
}
onProductSelect(product: Product) {
this.router.navigate(['/products', product.id]);
}
nextPage() {
if (this.currentPage() < this.totalPages()) {
this.currentPage.update(page => page + 1);
this.loadProducts();
}
}
previousPage() {
if (this.currentPage() > 1) {
this.currentPage.update(page => page - 1);
this.loadProducts();
}
}
}

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-products',
plugins: [angular(), tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
name: 'feature-products',
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-products',
provider: 'v8' as const,
},
},
}));

View File

@@ -0,0 +1,7 @@
# shared-ui
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test shared-ui` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'shop',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'shop',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

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

View File

@@ -0,0 +1,4 @@
export * from './lib/product-card/product-card.component';
export * from './lib/product-grid/product-grid.component';
export * from './lib/loading-spinner/loading-spinner.component';
export * from './lib/error-message/error-message.component';

View File

@@ -0,0 +1,72 @@
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'shop-error-message',
imports: [CommonModule],
template: `
<div class="error-container">
<div class="error-icon">⚠️</div>
<h3>{{ title() || 'Oops! Something went wrong' }}</h3>
<p>{{ message() || 'An unexpected error occurred. Please try again later.' }}</p>
@if (showRetry()) {
<button class="retry-button" (click)="retry.emit()">
Try Again
</button>
}
</div>
`,
styles: [`
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
text-align: center;
background: #fff;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
h3 {
margin: 0 0 8px 0;
font-size: 1.5rem;
color: #333;
}
p {
margin: 0 0 24px 0;
color: #666;
font-size: 1rem;
max-width: 400px;
}
.retry-button {
background: #3498db;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background 0.3s ease;
}
.retry-button:hover {
background: #2980b9;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ErrorMessageComponent {
readonly title = input<string>();
readonly message = input<string>();
readonly showRetry = input(true);
readonly retry = output<void>();
}

View File

@@ -0,0 +1,44 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'shop-loading-spinner',
imports: [CommonModule],
template: `
<div class="spinner-container">
<div class="spinner"></div>
<p>Loading...</p>
</div>
`,
styles: [`
.spinner-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
p {
margin-top: 16px;
color: #666;
font-size: 1rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoadingSpinnerComponent {}

View File

@@ -0,0 +1,78 @@
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { ProductCardComponent } from './product-card.component';
import { Product } from '@org/models';
import { describe, it, beforeEach, expect, vi } from 'vitest';
describe('ProductCardComponent', () => {
let component: ProductCardComponent;
let fixture: ComponentFixture<ProductCardComponent>;
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 () => {
await TestBed.configureTestingModule({
imports: [ProductCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(ProductCardComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('product', mockProduct);
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display product information', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.product-name').textContent).toContain('Test Product');
expect(compiled.querySelector('.product-category').textContent).toContain('Electronics');
expect(compiled.querySelector('.product-price').textContent).toContain('99.99');
});
it('should emit productClick event when card is clicked', () => {
const clickSpy = vi.fn();
component.productClick.subscribe(clickSpy);
fixture.nativeElement.querySelector('.product-card').click();
expect(clickSpy).toHaveBeenCalledWith(mockProduct);
});
it('should display out of stock overlay when product is not in stock', () => {
const outOfStockProduct = { ...mockProduct, inStock: false };
fixture.componentRef.setInput('product', outOfStockProduct);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.out-of-stock-overlay')).toBeTruthy();
expect(compiled.querySelector('.product-card').classList).toContain('out-of-stock');
});
it('should calculate star ratings correctly', () => {
const stars = component.getStars();
expect(stars).toEqual([true, true, true, true, true]);
});
it('should handle product with low rating', () => {
const lowRatedProduct = { ...mockProduct, rating: 2.3 };
fixture.componentRef.setInput('product', lowRatedProduct);
const stars = component.getStars();
expect(stars).toEqual([true, true, false, false, false]);
});
});

View File

@@ -0,0 +1,162 @@
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { Product } from '@org/models';
@Component({
selector: 'shop-product-card',
imports: [CommonModule, CurrencyPipe],
template: `
<div
class="product-card"
(click)="productClick.emit(product())"
(keyup.enter)="productClick.emit(product())"
(keyup.space)="productClick.emit(product())"
[class.out-of-stock]="!product().inStock"
tabindex="0"
role="button"
[attr.aria-label]="'View details for ' + product().name"
>
<div class="product-image">
<img [src]="product().imageUrl" [alt]="product().name" />
@if (!product().inStock) {
<div class="out-of-stock-overlay">Out of Stock</div>
}
</div>
<div class="product-info">
<h3 class="product-name">{{ product().name }}</h3>
<p class="product-category">{{ product().category }}</p>
<div class="product-rating">
<span class="stars">
@for (star of getStars(); track $index) {
<span [class.filled]="star">★</span>
}
</span>
<span class="review-count">({{ product().reviewCount }})</span>
</div>
<div class="product-price">
{{ product().price | currency }}
</div>
</div>
</div>
`,
styles: [`
.product-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
background: white;
height: 100%;
display: flex;
flex-direction: column;
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.product-card.out-of-stock {
opacity: 0.7;
}
.product-image {
position: relative;
width: 100%;
padding-top: 100%;
overflow: hidden;
background: #f5f5f5;
}
.product-image img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.out-of-stock-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
}
.product-info {
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
}
.product-name {
margin: 0 0 8px 0;
font-size: 1.1rem;
font-weight: 600;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.product-category {
margin: 0 0 8px 0;
font-size: 0.9rem;
color: #666;
}
.product-rating {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.stars {
color: #ddd;
}
.stars .filled {
color: #ffd700;
}
.review-count {
font-size: 0.85rem;
color: #666;
}
.product-price {
font-size: 1.25rem;
font-weight: bold;
color: #2c3e50;
margin-top: auto;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductCardComponent {
readonly product = input.required<Product>();
readonly productClick = output<Product>();
getStars(): boolean[] {
const rating = this.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;
});
}
}

View File

@@ -0,0 +1,52 @@
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Product } from '@org/models';
import { ProductCardComponent } from '../product-card/product-card.component';
@Component({
selector: 'shop-product-grid',
imports: [CommonModule, ProductCardComponent],
template: `
<div class="product-grid">
@for (product of products(); track product.id) {
<shop-product-card
[product]="product"
(productClick)="productSelect.emit($event)"
/>
}
@empty {
<div class="no-products">
<p>No products found</p>
</div>
}
</div>
`,
styles: [`
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 24px;
padding: 24px 0;
}
.no-products {
grid-column: 1 / -1;
text-align: center;
padding: 48px;
color: #666;
font-size: 1.1rem;
}
@media (max-width: 768px) {
.product-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductGridComponent {
readonly products = input.required<Product[]>();
readonly productSelect = output<Product>();
}

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,31 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve",
"lib": ["dom", "es2022"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"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/shared-ui',
plugins: [angular(), tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
name: 'shared-ui',
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/shared-ui',
provider: 'v8' as const,
},
},
}));