Replace entire repo content with code from /root/shahikitchen-google/

This commit is contained in:
root
2026-07-03 02:51:15 +00:00
parent c8b7dc95e4
commit 8ccf033329
79 changed files with 7611 additions and 439 deletions
+248
View File
@@ -0,0 +1,248 @@
import { mkdir, readFile, writeFile } from 'fs/promises';
import path from 'path';
import type { MenuCategory, MenuItem } from '@/domain/menu/entities';
import type { MenuVersionTrigger } from '@/domain/menu/versioning';
import { menuCategories as defaultMenuCategories } from './static-menu-data';
const MENU_DATA_DIR = path.join(process.cwd(), 'data');
const MENU_DATA_FILE = path.join(MENU_DATA_DIR, 'menu.json');
let memoryCache: MenuCategory[] | null = null;
async function ensureMenuFile(): Promise<void> {
await mkdir(MENU_DATA_DIR, { recursive: true });
try {
await readFile(MENU_DATA_FILE, 'utf8');
} catch {
await writeFile(MENU_DATA_FILE, JSON.stringify(defaultMenuCategories, null, 2), 'utf8');
}
}
function isMenuCategoryArray(value: unknown): value is MenuCategory[] {
return (
Array.isArray(value) &&
value.every(
(category) =>
typeof category === 'object' &&
category !== null &&
typeof (category as MenuCategory).id === 'string' &&
Array.isArray((category as MenuCategory).items),
)
);
}
export async function readMenuCategories(): Promise<MenuCategory[]> {
if (memoryCache) return memoryCache;
await ensureMenuFile();
const raw = await readFile(MENU_DATA_FILE, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (!isMenuCategoryArray(parsed)) {
memoryCache = defaultMenuCategories;
return memoryCache;
}
memoryCache = parsed;
return memoryCache;
}
export interface WriteMenuOptions {
snapshotLabel?: string;
snapshotTrigger?: MenuVersionTrigger;
skipSnapshot?: boolean;
}
export async function persistMenuCategories(categories: MenuCategory[]): Promise<void> {
await ensureMenuFile();
await writeFile(MENU_DATA_FILE, JSON.stringify(categories, null, 2), 'utf8');
memoryCache = categories;
}
export async function writeMenuCategories(
categories: MenuCategory[],
options?: WriteMenuOptions,
): Promise<void> {
await persistMenuCategories(categories);
if (!options?.skipSnapshot) {
const { createMenuSnapshot, ensureMenuBaseline } = await import('./menu-versioning');
await ensureMenuBaseline();
await createMenuSnapshot(
categories,
options?.snapshotLabel ?? 'Menu updated',
options?.snapshotTrigger ?? 'auto',
);
}
}
export async function updateMenuItem(
categoryId: string,
itemId: string,
updates: Partial<MenuItem>,
): Promise<MenuItem | null> {
const categories = await readMenuCategories();
let updatedItem: MenuItem | null = null;
const nextCategories = categories.map((category) => {
if (category.id !== categoryId) return category;
return {
...category,
items: category.items.map((item) => {
if (item.id !== itemId) return item;
updatedItem = { ...item, ...updates, id: item.id };
return updatedItem;
}),
};
});
if (!updatedItem) return null;
const dishName =
nextCategories
.find((category) => category.id === categoryId)
?.items.find((item) => item.id === itemId)?.name ?? itemId;
await writeMenuCategories(nextCategories, {
snapshotLabel: `Updated dish: ${dishName}`,
});
return updatedItem;
}
export function slugifyMenuId(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function allItems(categories: MenuCategory[]): MenuItem[] {
return categories.flatMap((category) => category.items);
}
function isItemIdTaken(categories: MenuCategory[], itemId: string): boolean {
return allItems(categories).some((item) => item.id === itemId);
}
function uniqueItemId(categories: MenuCategory[], baseId: string): string {
if (!isItemIdTaken(categories, baseId)) return baseId;
let suffix = 2;
while (isItemIdTaken(categories, `${baseId}-${suffix}`)) suffix += 1;
return `${baseId}-${suffix}`;
}
export async function addMenuItem(categoryId: string, item: MenuItem): Promise<MenuItem | null> {
const categories = await readMenuCategories();
const categoryExists = categories.some((category) => category.id === categoryId);
if (!categoryExists) return null;
const id = uniqueItemId(categories, slugifyMenuId(item.id || item.name));
if (!id) return null;
const newItem: MenuItem = { ...item, id };
const nextCategories = categories.map((category) =>
category.id === categoryId
? { ...category, items: [...category.items, newItem] }
: category,
);
await writeMenuCategories(nextCategories, {
snapshotLabel: `Added dish: ${newItem.name}`,
});
return newItem;
}
export async function removeMenuItem(categoryId: string, itemId: string): Promise<boolean> {
const categories = await readMenuCategories();
let removed = false;
const nextCategories = categories.map((category) => {
if (category.id !== categoryId) return category;
const nextItems = category.items.filter((item) => {
if (item.id === itemId) {
removed = true;
return false;
}
return true;
});
return { ...category, items: nextItems };
});
if (!removed) return false;
const removedName =
categories
.find((category) => category.id === categoryId)
?.items.find((item) => item.id === itemId)?.name ?? itemId;
await writeMenuCategories(nextCategories, {
snapshotLabel: `Removed dish: ${removedName}`,
});
return true;
}
export async function addMenuCategory(category: MenuCategory): Promise<MenuCategory | null> {
const categories = await readMenuCategories();
const id = slugifyMenuId(category.id || category.name);
if (!id) return null;
if (categories.some((existing) => existing.id === id)) return null;
const newCategory: MenuCategory = {
id,
name: category.name.trim(),
items: category.items ?? [],
};
await writeMenuCategories([...categories, newCategory], {
snapshotLabel: `Added category: ${newCategory.name}`,
});
return newCategory;
}
export async function removeMenuCategory(categoryId: string): Promise<boolean> {
const categories = await readMenuCategories();
if (!categories.some((category) => category.id === categoryId)) return false;
const removedCategory = categories.find((category) => category.id === categoryId);
const nextCategories = categories.filter((category) => category.id !== categoryId);
await writeMenuCategories(nextCategories, {
snapshotLabel: `Removed category: ${removedCategory?.name ?? categoryId}`,
});
return true;
}
export async function updateMenuCategory(
categoryId: string,
updates: Partial<Pick<MenuCategory, 'name'>>,
): Promise<MenuCategory | null> {
const categories = await readMenuCategories();
let updatedCategory: MenuCategory | null = null;
const nextCategories = categories.map((category) => {
if (category.id !== categoryId) return category;
updatedCategory = {
...category,
...(typeof updates.name === 'string' ? { name: updates.name.trim() } : {}),
};
return updatedCategory;
});
if (!updatedCategory) return null;
const categoryName =
nextCategories.find((category) => category.id === categoryId)?.name ?? categoryId;
await writeMenuCategories(nextCategories, {
snapshotLabel: `Updated category: ${categoryName}`,
});
return updatedCategory;
}
export function clearMenuCache(): void {
memoryCache = null;
}
+281
View File
@@ -0,0 +1,281 @@
import { mkdir, readFile, writeFile, unlink } from 'fs/promises';
import path from 'path';
import type { MenuCategory } from '@/domain/menu/entities';
import {
MENU_BASELINE_VERSION_ID,
type MenuVersionListItem,
type MenuVersionMeta,
type MenuVersionSnapshot,
type MenuVersionTrigger,
} from '@/domain/menu/versioning';
import { menuCategories as defaultMenuCategories } from './static-menu-data';
const VERSIONS_DIR = path.join(process.cwd(), 'data', 'menu-versions');
const INDEX_FILE = path.join(VERSIONS_DIR, 'index.json');
const BASELINE_FILE = path.join(VERSIONS_DIR, 'baseline.json');
interface VersionIndex {
versions: MenuVersionMeta[];
}
function versionFilePath(id: string): string {
return id === MENU_BASELINE_VERSION_ID
? BASELINE_FILE
: path.join(VERSIONS_DIR, `${id}.json`);
}
function isMenuCategoryArray(value: unknown): value is MenuCategory[] {
return (
Array.isArray(value) &&
value.every(
(category) =>
typeof category === 'object' &&
category !== null &&
typeof (category as MenuCategory).id === 'string' &&
Array.isArray((category as MenuCategory).items),
)
);
}
function countMenuStats(categories: MenuCategory[]): { categoryCount: number; dishCount: number } {
return {
categoryCount: categories.length,
dishCount: categories.reduce((sum, category) => sum + category.items.length, 0),
};
}
function menuFingerprint(categories: MenuCategory[]): string {
return JSON.stringify(categories);
}
function cloneCategories(categories: MenuCategory[]): MenuCategory[] {
return JSON.parse(JSON.stringify(categories)) as MenuCategory[];
}
async function ensureVersionsDir(): Promise<void> {
await mkdir(VERSIONS_DIR, { recursive: true });
}
async function readIndex(): Promise<VersionIndex> {
await ensureVersionsDir();
try {
const raw = await readFile(INDEX_FILE, 'utf8');
const parsed = JSON.parse(raw) as VersionIndex;
if (!Array.isArray(parsed.versions)) return { versions: [] };
return parsed;
} catch {
return { versions: [] };
}
}
async function writeIndex(index: VersionIndex): Promise<void> {
await ensureVersionsDir();
await writeFile(INDEX_FILE, JSON.stringify(index, null, 2), 'utf8');
}
async function writeSnapshotFile(snapshot: MenuVersionSnapshot): Promise<void> {
await ensureVersionsDir();
await writeFile(versionFilePath(snapshot.id), JSON.stringify(snapshot, null, 2), 'utf8');
}
async function readSnapshotFile(id: string): Promise<MenuVersionSnapshot | null> {
try {
const raw = await readFile(versionFilePath(id), 'utf8');
const parsed = JSON.parse(raw) as MenuVersionSnapshot;
if (!isMenuCategoryArray(parsed.categories)) return null;
return parsed;
} catch {
return null;
}
}
function createVersionId(): string {
return `v-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export async function ensureMenuBaseline(): Promise<MenuVersionMeta> {
await ensureVersionsDir();
const index = await readIndex();
const existing = index.versions.find((version) => version.id === MENU_BASELINE_VERSION_ID);
if (existing) return existing;
const categories = cloneCategories(defaultMenuCategories);
const stats = countMenuStats(categories);
const createdAt = new Date().toISOString();
const baseline: MenuVersionSnapshot = {
id: MENU_BASELINE_VERSION_ID,
label: 'Original menu (baseline)',
createdAt,
trigger: 'auto',
categories,
isBaseline: true,
};
await writeSnapshotFile(baseline);
const meta: MenuVersionMeta = {
id: MENU_BASELINE_VERSION_ID,
label: baseline.label,
createdAt,
trigger: 'auto',
isBaseline: true,
...stats,
};
index.versions.push(meta);
await writeIndex(index);
return meta;
}
export async function createMenuSnapshot(
categories: MenuCategory[],
label: string,
trigger: MenuVersionTrigger = 'auto',
): Promise<MenuVersionMeta> {
await ensureMenuBaseline();
const index = await readIndex();
const id = createVersionId();
const createdAt = new Date().toISOString();
const stats = countMenuStats(categories);
const snapshot: MenuVersionSnapshot = {
id,
label: label.trim() || 'Menu updated',
createdAt,
trigger,
categories: cloneCategories(categories),
isBaseline: false,
};
await writeSnapshotFile(snapshot);
const meta: MenuVersionMeta = {
id,
label: snapshot.label,
createdAt,
trigger,
isBaseline: false,
...stats,
};
index.versions.unshift(meta);
await writeIndex(index);
return meta;
}
export async function listMenuVersions(liveCategories: MenuCategory[]): Promise<MenuVersionListItem[]> {
await ensureMenuBaseline();
const index = await readIndex();
const liveHash = menuFingerprint(liveCategories);
const items: MenuVersionListItem[] = [];
for (const meta of index.versions) {
const snapshot = await readSnapshotFile(meta.id);
const matchesLive = snapshot ? menuFingerprint(snapshot.categories) === liveHash : false;
items.push({ ...meta, matchesLive });
}
const baseline = items.find((item) => item.isBaseline);
const nonBaseline = items.filter((item) => !item.isBaseline);
nonBaseline.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return baseline ? [...nonBaseline, baseline] : nonBaseline;
}
export async function getMenuVersionCategories(versionId: string): Promise<MenuCategory[] | null> {
const snapshot = await readSnapshotFile(versionId);
return snapshot ? cloneCategories(snapshot.categories) : null;
}
export async function restoreMenuVersion(
versionId: string,
persist: (categories: MenuCategory[]) => Promise<void>,
readLive: () => Promise<MenuCategory[]>,
): Promise<MenuVersionMeta> {
const snapshot = await readSnapshotFile(versionId);
if (!snapshot) {
throw new Error('Version not found.');
}
const current = await readLive();
await createMenuSnapshot(
current,
`Before rollback to "${snapshot.label}"`,
'restore',
);
await persist(snapshot.categories);
return {
id: snapshot.id,
label: snapshot.label,
createdAt: snapshot.createdAt,
trigger: snapshot.trigger,
isBaseline: snapshot.isBaseline,
...countMenuStats(snapshot.categories),
};
}
export async function resetMenuVersionHistory(categories: MenuCategory[]): Promise<MenuVersionMeta> {
await ensureVersionsDir();
const index = await readIndex();
for (const version of index.versions) {
if (version.id === MENU_BASELINE_VERSION_ID) continue;
try {
await unlink(versionFilePath(version.id));
} catch {
// File may already be missing.
}
}
const stats = countMenuStats(categories);
const createdAt = new Date().toISOString();
const baseline: MenuVersionSnapshot = {
id: MENU_BASELINE_VERSION_ID,
label: 'Current menu (baseline)',
createdAt,
trigger: 'manual',
categories: cloneCategories(categories),
isBaseline: true,
};
await writeSnapshotFile(baseline);
const meta: MenuVersionMeta = {
id: MENU_BASELINE_VERSION_ID,
label: baseline.label,
createdAt,
trigger: 'manual',
isBaseline: true,
...stats,
};
index.versions = [meta];
await writeIndex(index);
return meta;
}
export async function deleteMenuVersion(versionId: string): Promise<boolean> {
if (versionId === MENU_BASELINE_VERSION_ID) {
throw new Error('The baseline version cannot be deleted.');
}
const index = await readIndex();
const nextVersions = index.versions.filter((version) => version.id !== versionId);
if (nextVersions.length === index.versions.length) return false;
index.versions = nextVersions;
await writeIndex(index);
try {
await unlink(versionFilePath(versionId));
} catch {
// File may already be missing; index update is sufficient.
}
return true;
}
+6 -6
View File
@@ -133,7 +133,7 @@ export const menuCategories: MenuCategory[] = [
{ id: "chicken-karahi", name: "Chicken Karahi", description: "Wok-tossed chicken in a robust tomato, chili and ginger gravy.", price: 149, image: "chicken-karahi.jpg", video: "chicken-karahi.mp4" },
{ id: "lahore-sizzler", name: "Lahore Sizzler", description: "Sizzling platter of marinated chicken with vegetables and spicy sauces.", price: 169, image: "lahore-sizzler.jpg", video: "lahore-sizzler.mp4" },
{ id: "butter-chicken", name: "Butter Chicken", description: "Tender chicken in a creamy tomato and butter gravy with mild spices.", price: 149, image: "butter-chicken.jpg" },
{ id: "chicken-haleem", name: "Chicken Haleem", description: "Slow-cooked shredded chicken with lentils, wheat and aromatic spices.", price: 149, image: "chicken-haleem.jpg", video: "chicken-haleem.mp4" },
{ id: "chicken-haleem", name: "Chicken Haleem", description: "Slow-cooked shredded chicken with lentils, wheat and aromatic spices.", price: 139, image: "chicken-haleem.jpg", video: "chicken-haleem.mp4" },
],
},
{
@@ -171,7 +171,7 @@ export const menuCategories: MenuCategory[] = [
{ id: "lambay-gulab-jaman", name: "Lambay Gulab Jamun", description: "Elongated gulab jamun with extra syrup — a Shahi Sweets favourite.", image: "lambay-gulab-jaman.jpg", ...WEIGHT_SWEET_PRICING },
{ id: "cream-gulab-jaman", name: "Cream Gulab Jamun", description: "Gulab jamun filled with creamy centre, finished in fragrant syrup.", image: "cream-gulab-jaman.jpg", ...WEIGHT_SWEET_PRICING },
{ id: "ras-gulay", name: "Ras Gulay", description: "Spongy cottage-cheese balls in light sugar syrup — chilled and refreshing.", image: "ras-gulay.jpg", video: "ras-gulay.mp4", ...WEIGHT_SWEET_PRICING },
{ id: "rasmalai", name: "Rasmalai", description: "Soft cheese dumplings soaked in chilled sweetened milk with cardamom and saffron.", image: "rasmalai.jpg", video: "rasmalai.mp4", ...WEIGHT_SWEET_PRICING },
{ id: "rasmalai", name: "Rasmalai", description: "Soft cheese dumplings soaked in chilled sweetened milk with cardamom and saffron.", price: 45, image: "rasmalai.jpg", video: "rasmalai.mp4" },
{ id: "cham-cham", name: "Cham Cham", description: "Oval Bengali sweet coated in coconut or pistachio — soft and milky.", image: "cham-cham.jpg", video: "cham-cham.mp4", ...WEIGHT_SWEET_PRICING },
{ id: "paira", name: "Paira", description: "Traditional milk fudge sweet with a smooth, grainy texture.", image: "paira.jpg", video: "paira.mp4", ...WEIGHT_SWEET_PRICING },
{ id: "laddu", name: "Laddu", description: "Round gram-flour and ghee sweet balls — festive and aromatic.", image: "laddu.jpg", video: "laddu.mp4", ...WEIGHT_SWEET_PRICING },
@@ -197,10 +197,10 @@ export const menuCategories: MenuCategory[] = [
items: [
{ id: "masala-chai", name: "Masala Chai", description: "Traditional spiced tea brewed with milk, cardamom, ginger and aromatic spices.", price: 39, image: "masala-chai.jpg" },
{ id: "mango-lassi", name: "Mango Lassi", description: "Refreshing sweet yogurt drink blended with ripe mango and cardamom.", price: 45, image: "mango-lassi.jpg", video: "mango-lassi.mp4" },
{ id: "coca-cola", name: "Coca-Cola", description: "Classic chilled cola soft drink.", price: 29, video: "coca-cola.mp4" },
{ id: "pepsi-fanta", name: "Pepsi / Fanta", description: "Refreshing cola or orange flavored carbonated beverage.", price: 29, video: "pepsi-fanta.mp4" },
{ id: "sprite-ramlosa", name: "Sprite / Ramlösa", description: "Crisp lemon-lime soda or sparkling mineral water.", price: 29, video: "sprite-ramlosa.mp4" },
{ id: "energy-drink", name: "Energy Drink", description: "Caffeinated beverage for an instant energy boost.", price: 39, video: "energy-drink.mp4" },
{ id: "coca-cola", name: "Coca-Cola", description: "Classic chilled cola soft drink.", price: 25, video: "coca-cola.mp4" },
{ id: "pepsi-fanta", name: "Pepsi / Fanta", description: "Refreshing cola or orange flavored carbonated beverage.", price: 25, video: "pepsi-fanta.mp4" },
{ id: "sprite-ramlosa", name: "Sprite / Ramlösa", description: "Crisp lemon-lime soda or sparkling mineral water.", price: 25, video: "sprite-ramlosa.mp4" },
{ id: "energy-drink", name: "Energy Drink", description: "Caffeinated beverage for an instant energy boost.", price: 29, video: "energy-drink.mp4" },
{ id: "juice", name: "Juice", description: "Fresh fruit juice, typically mango or other seasonal flavors.", price: 20, image: "mango-juice.jpg" },
{ id: "coffee", name: "Coffee", description: "Freshly brewed hot coffee.", price: 39, image: "black-coffee.jpg" },
{ id: "latte", name: "Latte", description: "Espresso coffee with steamed milk and a light layer of foam.", price: 49, image: "latte.jpg", video: "latte.mp4" },