Replace entire repo content with kottgard-production-v1.1.zip (for shahikitchen-prod repo)

This commit is contained in:
root
2026-06-29 15:04:08 +00:00
parent 6cd5aaa048
commit 57cc67d2d3
850 changed files with 26149 additions and 15588 deletions
-21
View File
@@ -1,21 +0,0 @@
import type { OrderLine } from '../shared/order-line';
export type BranchLocation = 'askim' | 'backaplan' | '';
export interface BookingDetails {
location: BranchLocation;
date: string;
time: string;
guests: string;
name: string;
phone: string;
email: string;
notes: string;
}
export type PreOrderLine = OrderLine;
export interface BookingInquiry {
booking: BookingDetails;
preOrder: PreOrderLine[];
}
-12
View File
@@ -1,12 +0,0 @@
import type { BookingDetails } from './entities';
export function isBookingComplete(booking: BookingDetails): boolean {
return !!(
booking.location &&
booking.date &&
booking.time &&
booking.guests &&
booking.name &&
booking.phone
);
}
-56
View File
@@ -1,56 +0,0 @@
import {
addOrderLine,
calculateOrderItemCount,
calculateOrderTotal,
updateOrderLineQuantity,
} from '../shared/order-line';
import type { CartAction, CartState } from './entities';
/** Pure cart state machine — no React, no localStorage. */
export function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
return { ...state, items: addOrderLine(state.items, action.payload) };
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter((item) => item.id !== action.payload),
};
case 'UPDATE_QUANTITY':
return {
...state,
items: updateOrderLineQuantity(
state.items,
action.payload.id,
action.payload.quantity
),
};
case 'CLEAR_CART':
return { ...state, items: [] };
case 'RESTORE_ITEMS':
return { ...state, items: action.payload };
case 'TOGGLE_CART':
return { ...state, isOpen: !state.isOpen };
case 'OPEN_CART':
return { ...state, isOpen: true };
case 'CLOSE_CART':
return { ...state, isOpen: false };
default:
return state;
}
}
export function getCartTotals(items: CartState['items']) {
return {
totalItems: calculateOrderItemCount(items),
totalPrice: calculateOrderTotal(items),
};
}
-19
View File
@@ -1,19 +0,0 @@
import type { OrderLine, NewOrderLine } from '../shared/order-line';
export type CartItem = OrderLine;
export type NewCartItem = NewOrderLine;
export interface CartState {
items: CartItem[];
isOpen: boolean;
}
export type CartAction =
| { type: 'ADD_ITEM'; payload: NewCartItem }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR_CART' }
| { type: 'TOGGLE_CART' }
| { type: 'OPEN_CART' }
| { type: 'CLOSE_CART' }
| { type: 'RESTORE_ITEMS'; payload: CartItem[] };
-7
View File
@@ -1,7 +0,0 @@
import type { CartItem } from './entities';
/** Port: cart persistence (implemented by infrastructure). */
export interface CartRepository {
load(): CartItem[];
save(items: CartItem[]): void;
}
-66
View File
@@ -1,66 +0,0 @@
import type { CateringPackage } from './entities';
/**
* Catering packages — price per head in SEK (kr).
* Dish IDs reference domain/catering/dish-catalog.ts.
*/
export const cateringPackages: CateringPackage[] = [
{
id: 'silver',
name: 'Silver Package',
description:
'A refined introduction to Shahi catering — one signature curry, dal, rice, naan and fresh salad. Ideal for office lunches and intimate gatherings.',
pricePerHead: 189,
currency: 'SEK',
includedDishes: [
'butter-chicken',
'dal',
'plain-rice',
'naan',
'salad',
],
image: 'butter-chicken.jpg',
},
{
id: 'gold',
name: 'Gold Package',
description:
'Our most popular celebration menu — biryani, two curries, a vegetarian classic, rice, naan, raita and salad. Perfect for weddings and family events.',
pricePerHead: 249,
currency: 'SEK',
includedDishes: [
'chicken-biryani',
'butter-chicken',
'lamb-karahi',
'palak-paneer',
'plain-rice',
'naan',
'raita',
'salad',
],
image: 'chicken-biryani.jpg',
highlight: true,
},
{
id: 'royal',
name: 'Royal Shahi Package',
description:
'The full Shahi experience — biryani, tikka masala, premium lamb, seekh kebab, paneer, dal, and complete sides. For grand occasions that demand royalty.',
pricePerHead: 329,
currency: 'SEK',
includedDishes: [
'chicken-biryani',
'chicken-tikka-masala',
'butter-chicken',
'lamb-korma',
'seekh-kebab',
'shahi-paneer',
'dal',
'plain-rice',
'naan',
'raita',
'salad',
],
image: 'lamm-karahi.jpg',
},
];
-69
View File
@@ -1,69 +0,0 @@
import type { CateringDish, CateringDishCategory } from './entities';
/**
* Master catalog of dishes available for catering packages.
* IDs match the catering brief exactly.
*/
export const CATERING_DISH_CATALOG: CateringDish[] = [
// Lamb
{ id: 'lamb-vindaloo', name: 'Lamb Vindaloo', category: 'lamb', image: 'lamm-vindaloo.jpg' },
{ id: 'lamb-karahi', name: 'Lamb Karahi', category: 'lamb', image: 'lamm-karahi.jpg' },
{ id: 'lamb-korma', name: 'Lamb Korma', category: 'lamb', image: 'lamm-rogan-josh.jpg' },
{ id: 'lamb-achari', name: 'Lamb Achari', category: 'lamb', image: 'lamm-vindaloo.jpg' },
// Chicken
{ id: 'chicken-tikka-masala', name: 'Chicken Tikka Masala', category: 'chicken', image: 'chicken-tikka.jpg' },
{ id: 'butter-chicken', name: 'Butter Chicken', category: 'chicken', image: 'butter-chicken.jpg' },
{ id: 'chicken-korma', name: 'Chicken Korma', category: 'chicken', image: 'butter-chicken.jpg' },
{ id: 'chicken-karahi', name: 'Chicken Karahi', category: 'chicken', image: 'chicken-karahi.jpg' },
{ id: 'chicken-achari', name: 'Chicken Achari', category: 'chicken', image: 'chicken-karahi.jpg' },
{ id: 'chicken-biryani', name: 'Chicken Biryani', category: 'chicken', image: 'chicken-biryani.jpg' },
// Beef
{ id: 'shami-kebab', name: 'Shami Kebab', category: 'beef', image: 'shami-sandwich.jpg' },
{ id: 'seekh-kebab', name: 'Seekh Kebab', category: 'beef', image: 'kebab-roll.jpg' },
{ id: 'beef-karahi', name: 'Beef Karahi', category: 'beef', image: 'bong-nihari.jpg' },
// Vegetarian
{ id: 'dal', name: 'Dal', category: 'vegetarian', image: 'daal-makhani.jpg' },
{ id: 'palak-paneer', name: 'Palak Paneer', category: 'vegetarian', image: 'palak-paneer.jpg' },
{ id: 'shahi-paneer', name: 'Shahi Paneer', category: 'vegetarian', image: 'shahi-paneer.jpg' },
{ id: 'sabzi', name: 'Sabzi', category: 'vegetarian', image: 'lahore-chana.jpg' },
// Sides
{ id: 'plain-rice', name: 'Plain Rice', category: 'sides', image: 'chicken-biryani.jpg' },
{ id: 'naan', name: 'Naan', category: 'sides', image: 'keema-naan.jpg' },
{ id: 'salad', name: 'Salad', category: 'sides', image: 'chana-chaat.jpg' },
{ id: 'raita', name: 'Raita', category: 'sides', image: 'mango-lassi.jpg' },
];
export const CATERING_CATEGORY_ORDER: CateringDishCategory[] = [
'lamb',
'chicken',
'beef',
'vegetarian',
'sides',
];
export function getCateringDishById(id: string): CateringDish | undefined {
return CATERING_DISH_CATALOG.find((d) => d.id === id);
}
export function groupDishesByCategory(
dishIds: string[]
): Record<CateringDishCategory, CateringDish[]> {
const grouped: Record<CateringDishCategory, CateringDish[]> = {
lamb: [],
chicken: [],
beef: [],
vegetarian: [],
sides: [],
};
for (const id of dishIds) {
const dish = getCateringDishById(id);
if (dish) grouped[dish.category].push(dish);
}
return grouped;
}
-25
View File
@@ -1,25 +0,0 @@
export type CateringDishCategory =
| 'lamb'
| 'chicken'
| 'beef'
| 'vegetarian'
| 'sides';
export interface CateringDish {
id: string;
name: string;
category: CateringDishCategory;
/** Optional link to an existing menu poster in /public/images/dishes/ */
image?: string;
}
export interface CateringPackage {
id: string;
name: string;
description: string;
pricePerHead: number;
currency: 'SEK';
includedDishes: string[];
image?: string;
highlight?: boolean;
}
-27
View File
@@ -1,27 +0,0 @@
export interface KitchenOrderItem {
name: string;
qty: number;
}
export type OrderSource = 'table' | 'foodora' | 'uber' | 'wolt' | 'pickup' | 'other';
export type OrderStatus = 'received' | 'review' | 'preparing' | 'packing' | 'ready';
export interface KitchenOrder {
id: string;
source: OrderSource;
label: string;
customer: string;
items: KitchenOrderItem[];
status: OrderStatus;
receivedAt: Date;
etaMinutes: number;
}
export const ORDER_STATUS_PIPELINE: readonly OrderStatus[] = [
'received',
'review',
'preparing',
'packing',
'ready',
] as const;
-29
View File
@@ -1,29 +0,0 @@
export type Language = 'en' | 'sv' | 'hi' | 'ur' | 'ar' | 'tr';
export const SUPPORTED_LANGUAGES: readonly Language[] = [
'sv',
'en',
'ar',
'tr',
'hi',
'ur',
] as const;
export const DEFAULT_LANGUAGE: Language = 'sv';
export const RTL_LANGUAGES: readonly Language[] = ['ar', 'ur'] as const;
export function isLanguage(value: string): value is Language {
return (SUPPORTED_LANGUAGES as readonly string[]).includes(value);
}
export function isRtlLanguage(language: Language): boolean {
return (RTL_LANGUAGES as readonly string[]).includes(language);
}
export interface LanguageOption {
code: Language;
name: string;
native: string;
flag: string;
}
-7
View File
@@ -1,7 +0,0 @@
import type { Language } from './entities';
/** Port: language preference persistence. */
export interface LanguageRepository {
load(): Language | null;
save(language: Language): void;
}
-27
View File
@@ -1,27 +0,0 @@
export type MenuPricingMode = 'standard' | 'weight';
export interface MenuItem {
id: string;
name: string;
description?: string;
price: number;
image?: string;
video?: string;
isVegetarian?: boolean;
/** Weight-based sweets: 90 kr / ½ kg, 179 kr / kg (kulfi stays standard). */
pricing?: MenuPricingMode;
pricePerHalfKg?: number;
pricePerKg?: number;
}
export interface MenuCategory {
id: string;
name: string;
items: MenuItem[];
}
export interface MenuFilter {
categoryId?: string;
searchQuery?: string;
vegetarianOnly?: boolean;
}
-8
View File
@@ -1,8 +0,0 @@
import type { MenuCategory, MenuItem } from './entities';
/** Port: menu data access (implemented by infrastructure). */
export interface MenuRepository {
getCategories(): MenuCategory[];
getAllItems(): MenuItem[];
findItemById(id: string): MenuItem | undefined;
}
-17
View File
@@ -1,17 +0,0 @@
/** Restaurant contact & persistence keys — domain constants (no framework deps). */
export const RESTAURANT_CONTACT = {
whatsappNumber: '46739381089',
phonePrimary: '031288910',
phoneMobile: '0739381089',
email: 'hello@shahikitchen.se',
website: 'https://shahikitchen.se',
} as const;
export const STORAGE_KEYS = {
cart: 'shahi-kitchen-cart',
wishlist: 'shahi-kitchen-wishlist',
language: 'shahi-kitchen-language',
} as const;
export const PICKUP_LEAD_TIME_MINUTES = 30;
-81
View File
@@ -1,81 +0,0 @@
import {
calculateSweetsWeightTotal,
formatSweetWeightLabel,
} from '../sweets/pricing';
/** Shared order-line model used by cart, pre-order, and table orders. */
export type OrderPricingMode = 'standard' | 'weight';
export interface OrderLine {
id: string;
name: string;
price: number;
quantity: number;
image?: string;
pricingMode?: OrderPricingMode;
pricePerKg?: number;
pricePerHalfKg?: number;
}
export type NewOrderLine = Omit<OrderLine, 'quantity'>;
export function addOrderLine(lines: OrderLine[], item: NewOrderLine): OrderLine[] {
const existing = lines.find((line) => line.id === item.id);
if (existing) {
return lines.map((line) =>
line.id === item.id ? { ...line, quantity: line.quantity + 1 } : line
);
}
return [...lines, { ...item, quantity: 1 }];
}
export function updateOrderLineQuantity(
lines: OrderLine[],
id: string,
quantity: number
): OrderLine[] {
if (quantity <= 0) {
return lines.filter((line) => line.id !== id);
}
return lines.map((line) => (line.id === id ? { ...line, quantity } : line));
}
export function removeOrderLine(lines: OrderLine[], id: string): OrderLine[] {
return lines.filter((line) => line.id !== id);
}
export function calculateLineTotal(line: OrderLine): number {
if (line.pricingMode === 'weight') {
return calculateSweetsWeightTotal(line.quantity);
}
return line.price * line.quantity;
}
export function calculateOrderTotal(lines: OrderLine[]): number {
return lines.reduce((sum, line) => sum + calculateLineTotal(line), 0);
}
export function calculateOrderItemCount(lines: OrderLine[]): number {
return lines.reduce((sum, line) => sum + line.quantity, 0);
}
export function formatOrderLinesForMessage(lines: OrderLine[]): string {
return lines
.map((line) => {
const total = calculateLineTotal(line);
if (line.pricingMode === 'weight') {
const weight = formatSweetWeightLabel(line.quantity);
return `${line.name} (${weight}) — ${total} kr`;
}
return `${line.quantity} × ${line.name}${total} kr`;
})
.join('\n');
}
export function formatLineQuantityLabel(line: OrderLine): string {
if (line.pricingMode === 'weight') {
return formatSweetWeightLabel(line.quantity);
}
return String(line.quantity);
}
-21
View File
@@ -1,21 +0,0 @@
/** Weight-based mithai pricing (all sweets except kulfi). */
export const SWEETS_HALF_KG_PRICE = 90;
export const SWEETS_KG_PRICE = 179;
export const KULFI_MENU_ID = 'kulfi';
/** quantity = number of half-kg portions */
export function calculateSweetsWeightTotal(halfKgUnits: number): number {
const fullKg = Math.floor(halfKgUnits / 2);
const halfRemainder = halfKgUnits % 2;
return fullKg * SWEETS_KG_PRICE + halfRemainder * SWEETS_HALF_KG_PRICE;
}
export function halfKgUnitsToKg(halfKgUnits: number): number {
return halfKgUnits * 0.5;
}
export function formatSweetWeightLabel(halfKgUnits: number): string {
const kg = halfKgUnitsToKg(halfKgUnits);
return kg === 0.5 ? '0.5 kg' : `${kg} kg`;
}
-45
View File
@@ -1,45 +0,0 @@
export type Branch = 'askim' | 'backaplan';
export interface ValidTableId {
valid: true;
branch: Branch;
tableNumber: string;
tableIdentifier: string;
}
export interface InvalidTableId {
valid: false;
reason: 'missing' | 'format' | 'range';
}
export type ParsedTableId = ValidTableId | InvalidTableId;
const TABLE_ID_PATTERN = /^(backaplan|askim)-([0-9]{3})$/;
const MIN_TABLE = 1;
const MAX_TABLE = 40;
/** Domain rule: parse and validate QR table identifiers. */
export function parseTableId(raw: string | null | undefined): ParsedTableId {
if (!raw) {
return { valid: false, reason: 'missing' };
}
const match = raw.match(TABLE_ID_PATTERN);
if (!match) {
return { valid: false, reason: 'format' };
}
const branch = match[1] as Branch;
const tableNum = parseInt(match[2], 10);
if (tableNum < MIN_TABLE || tableNum > MAX_TABLE) {
return { valid: false, reason: 'range' };
}
return {
valid: true,
branch,
tableNumber: match[2],
tableIdentifier: raw,
};
}
-22
View File
@@ -1,22 +0,0 @@
export interface WishlistItem {
id: string;
name: string;
price: number;
image?: string;
}
export type NewWishlistItem = WishlistItem;
export interface WishlistState {
items: WishlistItem[];
isOpen: boolean;
}
export type WishlistAction =
| { type: 'TOGGLE_ITEM'; payload: NewWishlistItem }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'CLEAR_WISHLIST' }
| { type: 'TOGGLE_DRAWER' }
| { type: 'OPEN_DRAWER' }
| { type: 'CLOSE_DRAWER' }
| { type: 'RESTORE_ITEMS'; payload: WishlistItem[] };
-6
View File
@@ -1,6 +0,0 @@
import type { WishlistItem } from './entities';
export interface WishlistRepository {
load(): WishlistItem[];
save(items: WishlistItem[]): void;
}
-48
View File
@@ -1,48 +0,0 @@
import type { WishlistAction, WishlistItem, WishlistState } from './entities';
export function wishlistReducer(state: WishlistState, action: WishlistAction): WishlistState {
switch (action.type) {
case 'TOGGLE_ITEM': {
const exists = state.items.some((item) => item.id === action.payload.id);
if (exists) {
return {
...state,
items: state.items.filter((item) => item.id !== action.payload.id),
};
}
return { ...state, items: [...state.items, action.payload] };
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter((item) => item.id !== action.payload),
};
case 'CLEAR_WISHLIST':
return { ...state, items: [] };
case 'RESTORE_ITEMS':
return { ...state, items: action.payload };
case 'TOGGLE_DRAWER':
return { ...state, isOpen: !state.isOpen };
case 'OPEN_DRAWER':
return { ...state, isOpen: true };
case 'CLOSE_DRAWER':
return { ...state, isOpen: false };
default:
return state;
}
}
export function isInWishlist(items: WishlistItem[], id: string): boolean {
return items.some((item) => item.id === id);
}
export function getWishlistCount(items: WishlistItem[]): number {
return items.length;
}