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