Initial commit: Kottgard production website code
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user