Initial commit: Kottgard production website code

This commit is contained in:
2026-06-19 15:46:23 +00:00
commit e0273c46d7
196 changed files with 29799 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
import { Address, User } from '@/domain/entities';
import { DEMO_PASSWORD } from '@/domain/constants/commerce';
export interface RegisterInput {
name: string;
email: string;
password: string;
phone: string;
address: Address;
}
/**
* DOMAIN SERVICE — Authentication business rules (demo app; no real password hashing)
*/
export class AuthDomainService {
static isDemoCredentials(email: string, password: string): boolean {
return password === DEMO_PASSWORD;
}
static createUser(data: RegisterInput): User {
return {
id: `user-${Date.now()}`,
name: data.name,
email: data.email,
phone: data.phone,
address: data.address,
createdAt: new Date().toISOString(),
};
}
static emailMatchesStoredUser(stored: User | null, email: string): boolean {
return stored !== null && stored.email === email;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { CartItem, Product, ProductCustomization } from '@/domain/entities';
import { CustomizationDomainService } from './CustomizationDomainService';
/**
* DOMAIN SERVICE — Cart business rules (pure functions, no storage)
*/
export class CartDomainService {
static calculateSubtotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.product.price * item.quantity, 0);
}
static calculateItemCount(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.quantity, 0);
}
static addItem(
items: CartItem[],
product: Product,
customization: ProductCustomization,
customizationLabel: string,
quantity = 1
): CartItem[] {
const id = CustomizationDomainService.getCartItemKey(product.id, customization);
const existing = items.find((item) => item.id === id);
if (existing) {
return items.map((item) =>
item.id === id ? { ...item, quantity: item.quantity + quantity } : item
);
}
return [
...items,
{ id, product, quantity, customization, customizationLabel },
];
}
static removeItem(items: CartItem[], id: string): CartItem[] {
return items.filter((item) => item.id !== id);
}
static updateQuantity(items: CartItem[], id: string, quantity: number): CartItem[] {
if (quantity <= 0) {
return CartDomainService.removeItem(items, id);
}
return items.map((item) => (item.id === id ? { ...item, quantity } : item));
}
}
@@ -0,0 +1,77 @@
import { Category, Product, SortOption } from '@/domain/entities';
export type CategoryFilter = Category | 'all';
export interface FilterProductsInput {
products: Product[];
category: CategoryFilter;
searchQuery: string;
sortBy: SortOption;
/** Resolved display names for search (application layer provides these) */
searchIndex?: Array<{
product: Product;
name: string;
description: string;
}>;
}
/**
* DOMAIN SERVICE — Catalog filtering and sorting rules
*/
export class CatalogDomainService {
static readonly CATEGORIES: Category[] = ['chicken', 'beef', 'lamb', 'fish'];
static parseCategory(value: string | null): CategoryFilter {
return CatalogDomainService.CATEGORIES.includes(value as Category)
? (value as Category)
: 'all';
}
static filterAndSort(input: FilterProductsInput): Product[] {
let result = [...input.products];
if (input.category !== 'all') {
result = result.filter((p) => p.category === input.category);
}
const query = input.searchQuery.trim().toLowerCase();
if (query && input.searchIndex) {
const matchingIds = new Set(
input.searchIndex
.filter(
(entry) =>
entry.name.toLowerCase().includes(query) ||
entry.description.toLowerCase().includes(query) ||
entry.product.tags.some((tag) => tag.includes(query))
)
.map((entry) => entry.product.id)
);
result = result.filter((p) => matchingIds.has(p.id));
}
switch (input.sortBy) {
case 'price-asc':
result.sort((a, b) => a.price - b.price);
break;
case 'price-desc':
result.sort((a, b) => b.price - a.price);
break;
case 'name':
if (input.searchIndex) {
const nameMap = new Map(
input.searchIndex.map((e) => [e.product.id, e.name])
);
result.sort((a, b) =>
(nameMap.get(a.id) ?? '').localeCompare(nameMap.get(b.id) ?? '')
);
}
break;
case 'featured':
default:
result.sort((a, b) => (b.featured ? 1 : 0) - (a.featured ? 1 : 0));
break;
}
return result;
}
}
@@ -0,0 +1,72 @@
import {
Category,
MeatCustomization,
ProductCustomization,
} from '@/domain/entities';
export type Translator = (
path: string,
params?: Record<string, string | number>
) => string;
/**
* DOMAIN SERVICE — Product customization rules
*/
export class CustomizationDomainService {
static getDefaultCustomization(category: Category): ProductCustomization {
if (category === 'fish') {
return { type: 'fish' };
}
return {
type: category,
cuts: 8,
cuttingStyle: 'karahi',
};
}
static getCustomizationKey(customization: ProductCustomization): string {
if (customization.type === 'fish') {
return 'standard';
}
return `cuts-${customization.cuts}::${customization.cuttingStyle}`;
}
static getCustomizationLabel(
customization: ProductCustomization,
t: Translator
): string {
if (customization.type === 'fish') {
return t('product.standardCut');
}
return t('product.cutsAndStyle', {
cuts: customization.cuts,
style: t(`cutting.${customization.cuttingStyle}`),
});
}
static getCartItemKey(
productId: string,
customization: ProductCustomization
): string {
return `${productId}::${CustomizationDomainService.getCustomizationKey(customization)}`;
}
static updateMeatCustomization(
current: ProductCustomization,
category: Category,
update: Partial<Pick<MeatCustomization, 'cuts' | 'cuttingStyle'>>
): MeatCustomization {
const base =
current.type !== 'fish' && current.type === category
? current
: (CustomizationDomainService.getDefaultCustomization(
category
) as MeatCustomization);
return {
type: category as MeatCustomization['type'],
cuts: update.cuts ?? base.cuts,
cuttingStyle: update.cuttingStyle ?? base.cuttingStyle,
};
}
}
+21
View File
@@ -0,0 +1,21 @@
import {
FREE_DELIVERY_THRESHOLD_SEK,
STANDARD_DELIVERY_FEE_SEK,
} from '@/domain/constants/commerce';
/**
* DOMAIN SERVICE — Delivery pricing rules (single source of truth)
*/
export class DeliveryFeeService {
static calculateDeliveryFee(subtotal: number): number {
return subtotal > FREE_DELIVERY_THRESHOLD_SEK ? 0 : STANDARD_DELIVERY_FEE_SEK;
}
static calculateGrandTotal(subtotal: number): number {
return subtotal + DeliveryFeeService.calculateDeliveryFee(subtotal);
}
static isFreeDelivery(subtotal: number): boolean {
return subtotal > FREE_DELIVERY_THRESHOLD_SEK;
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Address, CartItem, Order } from '@/domain/entities';
import { DeliveryFeeService } from './DeliveryFeeService';
import { CartDomainService } from './CartDomainService';
export interface CreateOrderInput {
items: CartItem[];
deliveryAddress: Address;
paymentMethod: string;
}
/**
* DOMAIN SERVICE — Order creation rules
*/
export class OrderDomainService {
static generateOrderId(): string {
return `KG-${Date.now().toString(36).toUpperCase()}`;
}
static createOrder(input: CreateOrderInput): Order {
const subtotal = CartDomainService.calculateSubtotal(input.items);
const total = DeliveryFeeService.calculateGrandTotal(subtotal);
return {
id: OrderDomainService.generateOrderId(),
items: [...input.items],
total,
status: 'confirmed',
createdAt: new Date().toISOString(),
deliveryAddress: input.deliveryAddress,
paymentMethod: input.paymentMethod,
};
}
}