Replace entire repo content with code from /root/shahikitchen-google/
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const ADMIN_SESSION_COOKIE = 'shahi_admin_session';
|
||||
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function getSessionSecret(): string {
|
||||
return process.env.ADMIN_SESSION_SECRET ?? 'shahi-kitchen-admin-dev-secret';
|
||||
}
|
||||
|
||||
export function createAdminSessionToken(): string {
|
||||
const issuedAt = Date.now().toString();
|
||||
const payload = `admin:${issuedAt}`;
|
||||
const signature = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
|
||||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyAdminSessionToken(token: string | undefined | null): boolean {
|
||||
if (!token) return false;
|
||||
|
||||
const [payload, signature] = token.split('.');
|
||||
if (!payload || !signature) return false;
|
||||
|
||||
const expected = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
|
||||
const sigBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (sigBuffer.length !== expectedBuffer.length) return false;
|
||||
if (!timingSafeEqual(sigBuffer, expectedBuffer)) return false;
|
||||
|
||||
const [, issuedAtRaw] = payload.split(':');
|
||||
const issuedAt = Number(issuedAtRaw);
|
||||
if (!Number.isFinite(issuedAt)) return false;
|
||||
|
||||
return Date.now() - issuedAt <= SESSION_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
export async function isAdminAuthenticated(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(ADMIN_SESSION_COOKIE)?.value;
|
||||
return verifyAdminSessionToken(token);
|
||||
}
|
||||
|
||||
export function getAdminSessionCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: SESSION_MAX_AGE_MS / 1000,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const CUSTOMER_SESSION_COOKIE = 'shahi_customer_session';
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface CustomerSession {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function getSessionSecret(): string {
|
||||
return process.env.CUSTOMER_SESSION_SECRET ?? 'shahi-kitchen-customer-dev-secret';
|
||||
}
|
||||
|
||||
function encodeEmail(email: string): string {
|
||||
return Buffer.from(email, 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function decodeEmail(encoded: string): string | null {
|
||||
try {
|
||||
return Buffer.from(encoded, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createCustomerSessionToken(session: CustomerSession): string {
|
||||
const issuedAt = Date.now().toString();
|
||||
const namePart = session.name ? `:${encodeEmail(session.name)}` : '';
|
||||
const payload = `customer:${encodeEmail(session.email)}${namePart}:${issuedAt}`;
|
||||
const signature = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
|
||||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyCustomerSessionToken(token: string | undefined | null): CustomerSession | null {
|
||||
if (!token) return null;
|
||||
|
||||
const [payload, signature] = token.split('.');
|
||||
if (!payload || !signature) return null;
|
||||
|
||||
const expected = createHmac('sha256', getSessionSecret()).update(payload).digest('hex');
|
||||
const sigBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (sigBuffer.length !== expectedBuffer.length) return null;
|
||||
if (!timingSafeEqual(sigBuffer, expectedBuffer)) return null;
|
||||
|
||||
const parts = payload.split(':');
|
||||
if (parts[0] !== 'customer' || parts.length < 3) return null;
|
||||
|
||||
const issuedAt = Number(parts[parts.length - 1]);
|
||||
if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > SESSION_MAX_AGE_MS) return null;
|
||||
|
||||
const email = decodeEmail(parts[1]);
|
||||
if (!email) return null;
|
||||
|
||||
let name: string | undefined;
|
||||
if (parts.length === 4) {
|
||||
name = decodeEmail(parts[2]) ?? undefined;
|
||||
}
|
||||
|
||||
return { email, name };
|
||||
}
|
||||
|
||||
export async function getCustomerSession(): Promise<CustomerSession | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(CUSTOMER_SESSION_COOKIE)?.value;
|
||||
return verifyCustomerSessionToken(token);
|
||||
}
|
||||
|
||||
export function getCustomerSessionCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: SESSION_MAX_AGE_MS / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function getClearedCustomerSessionCookieOptions() {
|
||||
return {
|
||||
...getCustomerSessionCookieOptions(),
|
||||
maxAge: 0,
|
||||
expires: new Date(0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo';
|
||||
|
||||
export function isGoogleOAuthConfigured(): boolean {
|
||||
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
export function getSiteOrigin(): string {
|
||||
return process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
|
||||
}
|
||||
|
||||
export function getGoogleRedirectUri(): string {
|
||||
return `${getSiteOrigin()}/api/auth/callback/google`;
|
||||
}
|
||||
|
||||
function getOAuthStateSecret(): string {
|
||||
return process.env.CUSTOMER_SESSION_SECRET ?? 'shahi-kitchen-customer-dev-secret';
|
||||
}
|
||||
|
||||
export function createOAuthState(): string {
|
||||
const nonce = randomBytes(16).toString('hex');
|
||||
const signature = createHmac('sha256', getOAuthStateSecret()).update(nonce).digest('hex');
|
||||
return `${nonce}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyOAuthState(state: string | null | undefined): boolean {
|
||||
if (!state) return false;
|
||||
const [nonce, signature] = state.split('.');
|
||||
if (!nonce || !signature) return false;
|
||||
|
||||
const expected = createHmac('sha256', getOAuthStateSecret()).update(nonce).digest('hex');
|
||||
const sigBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (sigBuffer.length !== expectedBuffer.length) return false;
|
||||
return timingSafeEqual(sigBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
export function buildGoogleAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: process.env.GOOGLE_CLIENT_ID!,
|
||||
redirect_uri: getGoogleRedirectUri(),
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile',
|
||||
access_type: 'online',
|
||||
prompt: 'select_account',
|
||||
state,
|
||||
});
|
||||
|
||||
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
interface GoogleTokenResponse {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface GoogleUserInfo {
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function fetchGoogleUserFromCode(
|
||||
code: string,
|
||||
): Promise<{ email: string; name?: string } | null> {
|
||||
const tokenResponse = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: process.env.GOOGLE_CLIENT_ID!,
|
||||
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
redirect_uri: getGoogleRedirectUri(),
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as GoogleTokenResponse;
|
||||
if (!tokenResponse.ok || !tokenData.access_token) return null;
|
||||
|
||||
const userResponse = await fetch(GOOGLE_USERINFO_URL, {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
});
|
||||
|
||||
const userData = (await userResponse.json()) as GoogleUserInfo;
|
||||
if (!userResponse.ok || !userData.email || userData.email_verified === false) return null;
|
||||
|
||||
return {
|
||||
email: userData.email,
|
||||
name: userData.name,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const OAUTH_RETURN_COOKIE = 'shahi_google_oauth_return_to';
|
||||
const DEFAULT_RETURN_PATH = '/account';
|
||||
const SAFE_PATH_PATTERN = /^\/[a-zA-Z0-9/_-]*$/;
|
||||
|
||||
export function getOAuthReturnCookieName(): string {
|
||||
return OAUTH_RETURN_COOKIE;
|
||||
}
|
||||
|
||||
/** Only same-origin relative paths (optionally with query) are allowed after OAuth. */
|
||||
export function sanitizeOAuthReturnPath(value: string | null | undefined): string {
|
||||
if (!value) return DEFAULT_RETURN_PATH;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.includes('://')) {
|
||||
return DEFAULT_RETURN_PATH;
|
||||
}
|
||||
|
||||
const withoutHash = trimmed.split('#')[0] ?? trimmed;
|
||||
const queryIndex = withoutHash.indexOf('?');
|
||||
const pathname = queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex);
|
||||
const search = queryIndex === -1 ? '' : withoutHash.slice(queryIndex);
|
||||
|
||||
if (!pathname || !SAFE_PATH_PATTERN.test(pathname)) {
|
||||
return DEFAULT_RETURN_PATH;
|
||||
}
|
||||
|
||||
return `${pathname}${search}`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { requireMenuManager } from './require-menu-manager';
|
||||
|
||||
/** Menu Management routes — requires an allowlisted Google customer session. */
|
||||
export async function requireAdmin(): Promise<import('next/server').NextResponse | null> {
|
||||
return requireMenuManager();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isMenuManagerEmail } from '@/domain/auth/menu-managers';
|
||||
import { getCustomerSession } from './customer-session';
|
||||
|
||||
export async function getMenuManagerSession() {
|
||||
const session = await getCustomerSession();
|
||||
if (!session || !isMenuManagerEmail(session.email)) return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireMenuManager(): Promise<NextResponse | null> {
|
||||
const session = await getMenuManagerSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { isGothenburgDeliveryAddress } from '@/domain/delivery/gothenburg-address';
|
||||
import {
|
||||
disableGeoapifyAutocomplete,
|
||||
isGeoapifyApiKeyConfigured,
|
||||
isGeoapifyAutocompleteDisabled,
|
||||
mapGeoapifyHttpStatus,
|
||||
type GeoapifyUnavailableReason,
|
||||
} from './geoapify-availability';
|
||||
import {
|
||||
getCachedGeoapifyFeatures,
|
||||
getInFlightGeoapifyRequest,
|
||||
setCachedGeoapifyFeatures,
|
||||
trackInFlightGeoapifyRequest,
|
||||
waitForGeoapifyRateLimit,
|
||||
} from './geoapify-request-cache';
|
||||
import type { GeoapifyAutocompleteResponse, GeoapifyFeature } from './geoapify-types';
|
||||
|
||||
/** Backaplan branch — bias autocomplete toward Gothenburg. */
|
||||
const GOTHENBURG_BIAS = 'proximity:11.944,57.699';
|
||||
|
||||
/** Approximate Göteborg municipality bounding box (minLon,minLat,maxLon,maxLat). */
|
||||
const GOTHENBURG_RECT_FILTER = 'rect:11.74,57.60,12.15,57.82';
|
||||
|
||||
export type GeoapifyAutocompleteResult =
|
||||
| { status: 'ok'; features: GeoapifyFeature[] }
|
||||
| { status: 'unavailable'; reason: GeoapifyUnavailableReason; features: [] };
|
||||
|
||||
export function isDeliverableGeoapifyFeature(feature: GeoapifyFeature): boolean {
|
||||
const { properties } = feature;
|
||||
return isGothenburgDeliveryAddress({
|
||||
city: properties.city,
|
||||
municipality: properties.municipality,
|
||||
postcode: properties.postcode,
|
||||
countryCode: properties.country_code,
|
||||
formatted: properties.formatted,
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldUseGeoapifyAutocomplete(): boolean {
|
||||
return isGeoapifyApiKeyConfigured() && !isGeoapifyAutocompleteDisabled();
|
||||
}
|
||||
|
||||
function unavailable(reason: GeoapifyUnavailableReason): GeoapifyAutocompleteResult {
|
||||
disableGeoapifyAutocomplete();
|
||||
return { status: 'unavailable', reason, features: [] };
|
||||
}
|
||||
|
||||
async function requestGeoapifyAddressSuggestions(
|
||||
query: string,
|
||||
lang: 'sv' | 'en',
|
||||
): Promise<GeoapifyFeature[]> {
|
||||
const cached = getCachedGeoapifyFeatures(query, lang);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlight = getInFlightGeoapifyRequest(query, lang);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const request = (async () => {
|
||||
await waitForGeoapifyRateLimit();
|
||||
|
||||
const apiKey = process.env.NEXT_PUBLIC_GEOAPIFY_API_KEY!.trim();
|
||||
const params = new URLSearchParams({
|
||||
text: query.trim(),
|
||||
format: 'geojson',
|
||||
lang,
|
||||
limit: '8',
|
||||
filter: `countrycode:se|${GOTHENBURG_RECT_FILTER}`,
|
||||
bias: GOTHENBURG_BIAS,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.geoapify.com/v1/geocode/autocomplete?${params.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new GeoapifyRequestError(mapGeoapifyHttpStatus(response.status));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GeoapifyAutocompleteResponse;
|
||||
const features = (data.features ?? []).filter(isDeliverableGeoapifyFeature);
|
||||
setCachedGeoapifyFeatures(query, lang, features);
|
||||
return features;
|
||||
})();
|
||||
|
||||
return trackInFlightGeoapifyRequest(query, lang, request);
|
||||
}
|
||||
|
||||
class GeoapifyRequestError extends Error {
|
||||
constructor(public readonly reason: GeoapifyUnavailableReason) {
|
||||
super(reason);
|
||||
this.name = 'GeoapifyRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGeoapifyAddressSuggestions(
|
||||
query: string,
|
||||
lang: 'sv' | 'en' = 'sv',
|
||||
): Promise<GeoapifyAutocompleteResult> {
|
||||
if (!isGeoapifyApiKeyConfigured()) {
|
||||
return unavailable('missing_key');
|
||||
}
|
||||
|
||||
if (isGeoapifyAutocompleteDisabled()) {
|
||||
return unavailable('quota_exceeded');
|
||||
}
|
||||
|
||||
if (query.trim().length < 3) {
|
||||
return { status: 'ok', features: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const features = await requestGeoapifyAddressSuggestions(query, lang);
|
||||
return { status: 'ok', features };
|
||||
} catch (error) {
|
||||
if (error instanceof GeoapifyRequestError) {
|
||||
return unavailable(error.reason);
|
||||
}
|
||||
return unavailable('network');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
const SESSION_STORAGE_KEY = 'shahi-geoapify-disabled';
|
||||
|
||||
export type GeoapifyUnavailableReason =
|
||||
| 'missing_key'
|
||||
| 'quota_exceeded'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'rate_limited'
|
||||
| 'payment_required'
|
||||
| 'network'
|
||||
| 'server_error';
|
||||
|
||||
export function isGeoapifyApiKeyConfigured(): boolean {
|
||||
const key = process.env.NEXT_PUBLIC_GEOAPIFY_API_KEY;
|
||||
return typeof key === 'string' && key.trim().length > 0;
|
||||
}
|
||||
|
||||
export function isGeoapifyAutocompleteDisabled(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return sessionStorage.getItem(SESSION_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function disableGeoapifyAutocomplete(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, '1');
|
||||
} catch {
|
||||
// sessionStorage may be unavailable in private mode
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGeoapifyHttpStatus(status: number): GeoapifyUnavailableReason {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return 'unauthorized';
|
||||
case 402:
|
||||
return 'payment_required';
|
||||
case 403:
|
||||
return 'forbidden';
|
||||
case 429:
|
||||
return 'rate_limited';
|
||||
default:
|
||||
if (status >= 500) return 'server_error';
|
||||
return 'quota_exceeded';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { GeoapifyFeature } from './geoapify-types';
|
||||
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
const MAX_MEMORY_ENTRIES = 80;
|
||||
const MAX_SESSION_ENTRIES = 40;
|
||||
const MIN_REQUEST_INTERVAL_MS = 750;
|
||||
const SESSION_CACHE_KEY = 'shahi-geoapify-cache';
|
||||
|
||||
interface CacheEntry {
|
||||
features: GeoapifyFeature[];
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
const memoryCache = new Map<string, CacheEntry>();
|
||||
const inFlight = new Map<string, Promise<GeoapifyFeature[]>>();
|
||||
let lastNetworkRequestAt = 0;
|
||||
let sessionHydrated = false;
|
||||
|
||||
export function buildGeoapifyCacheKey(query: string, lang: string): string {
|
||||
return `${lang}:${query.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function isFresh(entry: CacheEntry): boolean {
|
||||
return Date.now() - entry.cachedAt < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
function trimMemoryCache(): void {
|
||||
while (memoryCache.size > MAX_MEMORY_ENTRIES) {
|
||||
const oldestKey = memoryCache.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
memoryCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateFromSessionStorage(): void {
|
||||
if (sessionHydrated || typeof window === 'undefined') return;
|
||||
sessionHydrated = true;
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_CACHE_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, CacheEntry>;
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
if (isFresh(entry)) {
|
||||
memoryCache.set(key, entry);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore corrupt cache payloads
|
||||
}
|
||||
}
|
||||
|
||||
function persistToSessionStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const payload: Record<string, CacheEntry> = {};
|
||||
const entries = [...memoryCache.entries()]
|
||||
.filter(([, entry]) => isFresh(entry))
|
||||
.slice(-MAX_SESSION_ENTRIES);
|
||||
|
||||
for (const [key, entry] of entries) {
|
||||
payload[key] = entry;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(SESSION_CACHE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
// sessionStorage may be full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedGeoapifyFeatures(
|
||||
query: string,
|
||||
lang: string,
|
||||
): GeoapifyFeature[] | null {
|
||||
hydrateFromSessionStorage();
|
||||
|
||||
const key = buildGeoapifyCacheKey(query, lang);
|
||||
const entry = memoryCache.get(key);
|
||||
if (!entry || !isFresh(entry)) {
|
||||
if (entry) memoryCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.features;
|
||||
}
|
||||
|
||||
export function setCachedGeoapifyFeatures(
|
||||
query: string,
|
||||
lang: string,
|
||||
features: GeoapifyFeature[],
|
||||
): void {
|
||||
hydrateFromSessionStorage();
|
||||
|
||||
const key = buildGeoapifyCacheKey(query, lang);
|
||||
memoryCache.set(key, { features, cachedAt: Date.now() });
|
||||
trimMemoryCache();
|
||||
persistToSessionStorage();
|
||||
}
|
||||
|
||||
export function getInFlightGeoapifyRequest(
|
||||
query: string,
|
||||
lang: string,
|
||||
): Promise<GeoapifyFeature[]> | null {
|
||||
return inFlight.get(buildGeoapifyCacheKey(query, lang)) ?? null;
|
||||
}
|
||||
|
||||
export function trackInFlightGeoapifyRequest(
|
||||
query: string,
|
||||
lang: string,
|
||||
request: Promise<GeoapifyFeature[]>,
|
||||
): Promise<GeoapifyFeature[]> {
|
||||
const key = buildGeoapifyCacheKey(query, lang);
|
||||
inFlight.set(key, request);
|
||||
return request.finally(() => {
|
||||
if (inFlight.get(key) === request) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForGeoapifyRateLimit(): Promise<void> {
|
||||
const elapsed = Date.now() - lastNetworkRequestAt;
|
||||
const waitMs = MIN_REQUEST_INTERVAL_MS - elapsed;
|
||||
if (waitMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
lastNetworkRequestAt = Date.now();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface GeoapifyAddressProperties {
|
||||
formatted?: string;
|
||||
address_line1?: string;
|
||||
address_line2?: string;
|
||||
city?: string;
|
||||
municipality?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface GeoapifyFeature {
|
||||
type: 'Feature';
|
||||
properties: GeoapifyAddressProperties;
|
||||
}
|
||||
|
||||
export interface GeoapifyAutocompleteResponse {
|
||||
type: string;
|
||||
features: GeoapifyFeature[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user