@zorgo/next 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/components/server/item-page.tsx","../../zorgo/universal/constants.ts","../../zorgo/universal/utils/menuSlugs.ts","../../zorgo/universal/utils/menuUtils.ts","../src/images.ts","../src/seo.ts"],"sourcesContent":["import type { Metadata } from \"next\";\nimport type { ReactNode } from \"react\";\nimport Image from \"next/image\";\nimport type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport { baseItemIdFromSlug, menuItemSlug } from \"../../slugs\";\nimport { ITEM_IMAGE_PREFIX, menuImageUrl } from \"../../images\";\nimport { buildMenuItemJsonLd } from \"../../seo\";\nimport { ClientItemForm } from \"../client/ItemForm/ClientItemForm\";\n\n// Single source of truth for resolving the item a slug points at — shared by\n// every export here so the lookup never drifts. Returns undefined for an\n// unknown slug; callers degrade gracefully (the page 404s downstream).\nfunction itemFromSlug(menu: Menu, slug: string) {\n\tconst baseItemId = baseItemIdFromSlug(slug);\n\treturn menu.items.find((item) => item.base_item_id === baseItemId);\n}\n\n/** generateStaticParams for /menu/[slug] — every item in the prebuilt menu. */\nexport function menuItemStaticParams(menu: Menu) {\n\treturn menu.items.map((item) => ({\n\t\tslug: menuItemSlug(item.name, item.base_item_id),\n\t}));\n}\n\n/**\n * generateMetadata for /menu/[slug]: per-item title/description/OG so each item\n * ranks on its own long-tail terms. title is just the item name; the root\n * layout's title template wraps it as `<name> | Brand`, and its metadataBase\n * resolves OG URLs, so nothing absolute is hardcoded here.\n */\nexport function menuItemMetadata(menu: Menu, slug: string): Metadata {\n\tconst item = itemFromSlug(menu, slug);\n\tif (!item) return {};\n\n\tconst description =\n\t\titem.description ?? `Order ${item.name} online for pickup or delivery.`;\n\tconst image = menuImageUrl(item.images?.[0], true);\n\n\treturn {\n\t\ttitle: item.name,\n\t\tdescription,\n\t\topenGraph: {\n\t\t\ttitle: item.name,\n\t\t\tdescription,\n\t\t\t// Next's typed OpenGraph union has no \"product\"; \"website\" is the\n\t\t\t// closest valid value for a standalone item page.\n\t\t\ttype: \"website\",\n\t\t\t...(image ? { images: [image] } : {}),\n\t\t},\n\t};\n}\n\n/**\n * The whole /menu/[slug] body, owned by the package so a package bump updates\n * every scaffolded site. Server Component: the hero image and JSON-LD are\n * resolved from the prebuilt menu at build time and land in the static HTML,\n * while the interactive half sits behind ClientItemForm.\n */\nexport function ItemCustomizationView({\n\tmenu,\n\tslug,\n\tchildren,\n}: {\n\tmenu: Menu;\n\tslug: string;\n\t/**\n\t * The interactive half. Defaults to the package's form; pass a site's own\n\t * client component to keep a custom customization UI and still get the\n\t * server-rendered hero image, JSON-LD, and metadata from here.\n\t */\n\tchildren?: ReactNode;\n}) {\n\tconst item = itemFromSlug(menu, slug);\n\tconst heroImageUrl = menuImageUrl(item?.images?.[0]);\n\n\treturn (\n\t\t<div className=\"p-8 md:p-16 max-w-4xl mx-auto flex flex-col gap-8\">\n\t\t\t{item && (\n\t\t\t\t<script\n\t\t\t\t\ttype=\"application/ld+json\"\n\t\t\t\t\tdangerouslySetInnerHTML={{\n\t\t\t\t\t\t__html: JSON.stringify(buildMenuItemJsonLd(item)),\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{heroImageUrl && (\n\t\t\t\t<Image\n\t\t\t\t\tsrc={heroImageUrl}\n\t\t\t\t\talt={item!.name}\n\t\t\t\t\twidth={1080}\n\t\t\t\t\theight={608}\n\t\t\t\t\tpriority\n\t\t\t\t\t// An image prebuild could not localize is served straight from\n\t\t\t\t\t// the bucket, where no width variants exist.\n\t\t\t\t\tunoptimized={!heroImageUrl.startsWith(ITEM_IMAGE_PREFIX)}\n\t\t\t\t\t// Full width up to the container's max-w-4xl (56rem/896px); the\n\t\t\t\t\t// loader snaps that to the 1080 rung and narrower screens to 640/384.\n\t\t\t\t\tsizes=\"(min-width: 896px) 896px, 100vw\"\n\t\t\t\t\tclassName=\"w-full aspect-video object-cover rounded-2xl bg-background-recessed\"\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{children ?? (\n\t\t\t\t<ClientItemForm baseItemId={baseItemIdFromSlug(slug)} />\n\t\t\t)}\n\t\t</div>\n\t);\n}\n","// Env first, hardcoded production second — the same shape POS and kiosk already\n// use in their own config/environment.ts. Both bundlers inline these by literal\n// name (Next NEXT_PUBLIC_*, Expo EXPO_PUBLIC_*), so the lookups have to stay\n// written out; a destructured or computed read is not substituted. Without this\n// the two vars zorgo_site/.env already declares were dead, and pointing any app\n// at a Supabase preview branch meant editing this file.\nexport const SUPABASE_URL =\n\tprocess.env.NEXT_PUBLIC_SUPABASE_URL ||\n\tprocess.env.EXPO_PUBLIC_SUPABASE_URL ||\n\t\"https://dhnpoxorllfdobwftkyb.supabase.co\";\n\nexport const SUPABASE_ANON_KEY =\n\tprocess.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||\n\tprocess.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ||\n\t\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRobnBveG9ybGxmZG9id2Z0a3liIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzkwNTQxMDgsImV4cCI6MjA1NDYzMDEwOH0.zI8PwbgKwijHZ1QmxJqmaPrFvg02w1vOY2Qcs7SddzI\";\nexport const ZORGO_PURPLE = \"rgb(153,67,233)\";\n\nexport const expoGoGoogleClientId =\n\t\"959654249401-dm6nbi27rj605ul1nkt8o2imb8f7daff.apps.googleusercontent.com\";\n\n// replace instances of API_BASE_URL with env enum\nexport enum Environments {\n\tProduction = \"https://api.zorgotech.com\",\n\tStaging = \"https://staging.zorgo.ai\",\n\tDevelopment = \"http://localhost:3000\",\n}\n\nexport const API_BASE_URL =\n\tprocess.env.NODE_ENV === \"production\"\n\t\t? Environments.Production\n\t\t: Environments.Development;\n\n/** Emails allowed to access developer-only metrics (e.g. /performance/developer). */\nexport const DEVELOPER_EMAILS = [\"clawsorgo@gmail.com\", \"gmandwee@gmail.com\"] as const;\n\nexport function isDeveloperEmail(email?: string | null): boolean {\n\treturn !!email && (DEVELOPER_EMAILS as readonly string[]).includes(email);\n}\n\nexport const ORDER_STORE_CONSTANTS = {\n\tDEFAULTS: {\n\t\tPREP_MINUTES: 20, // default amount of time for prep\n\t\tPAST_HOURS: 24, // hours into the past orders are fetched for\n\t\tFUTURE_HOURS: 72 // hours into the future orders are fetched for \n\t},\n\tORDER_STATUS: {\n\t\tSTAGING: 10,\n\t\tPENDING: 1,\n\t\tCOMPLETED: 2,\n\t\tCANCELLED: 4,\n\t\tPAID: 6,\n\t\tPARTIALLY_PAID: 7,\n\t\tKITCHEN_DONE: 9,\n\t\tCONFIRMED: 11,\n\t\tPREPARING: 12,\n\t\tREQUESTED: 13,\n\t},\n\tPOS_METHOD_ID: 3,\n\t/**\n\t * Internal QA accounts — their orders are excluded from dashboard order queries.\n\t * Matched on dev/QA emails (kcjayd@, clawsorgo@, gmandwee@, aaiden798@, *@zorgo.*),\n\t * the \"c d\" throwaway persona, and known test names (casey donegan, aiden alazo, …).\n\t * ponytail: hardcoded snapshot — new test accounts need re-running the query.\n\t * Promote to an `is_test` column on `customers` if this list keeps growing.\n\t */\n\tTEST_CUSTOMER_IDS: [\n\t\t85, 90, 91, 93, 95, 96, 97, 203, 238, 240, 241, 244, 245, 246, 317, 318, 319, 322,\n\t\t579, 580, 658, 1526, 2567, 2573, 2703, 2725, 2737, 2739, 2740, 2741, 2742, 2744,\n\t\t2746, 2747, 2749, 2751, 2752, 2753, 2754, 2755, 2767, 2768, 2778, 2779, 2780, 2781,\n\t\t2783, 2814, 2816, 2818, 2819, 2820, 2821, 2822, 2823, 2824, 2835, 2836, 2851, 2853,\n\t\t2859, 2871, 2882, 2883, 2887, 2888, 2889, 2891, 2892, 2893, 2894, 2895, 2896, 2897,\n\t\t2898, 2899, 2900, 2901, 2902, 2903, 2904, 2905, 2907, 2908, 2919, 2921, 2922, 2923,\n\t\t2924, 2925, 2926, 2927, 2946, 3017, 3018, 3022, 3024, 3025, 3026, 3027, 3028, 3029,\n\t\t3030, 3031, 3082, 3091, 3092, 3093,\n\t],\n\tORDER_TYPE: {\n\t\tDINE_IN: 1,\n\t\tTAKEOUT: 2,\n\t\tDELIVERY: 3\n\t},\n\t/** KDS edit highlight duration after a POS modification. */\n\tMODIFICATION_HIGHLIGHT_MS: 8_000,\n\t/** POS order realtime: refetch + recycle postgres_changes channel on this cadence. */\n\tLISTENER_SYNC_INTERVAL_MS: 60_000,\n\tORDER_SELECT: `\n\t*,\n\n\tcharges (\n\t\t*\n\t),\n\n\tlocations (name),\n\n\tcustomers\n\t (customer_id, first_name, last_name, phone_number, email),\n\n\torder_items\n\t (\n\t\torder_item_id, item_id, quantity, price_at_order, order_id, comments, status, applied_promo_id, discount_amount,\n\t\titems (\n\t\t\titem_id, name, price,\n\t\t\tportion_schemes (id, franchise_id, geometry, name, min_divisions, max_divisions),\n\t\t\tbase_items (\n\t\t\t\tbase_item_id, category_id, name,\n\t\t\t\tcategories (category_id, name, is_catering)\n\t\t\t)\n\t\t),\n\t\torder_item_selected_items (\n\t\t\torder_item_id, rule_id, item_id, quantity, upcharge_price, portion,\n\t\t\titems (\n\t\t\t\titem_id, name, price,\n\t\t\t\tbase_items (\n\t\t\t\t\tbase_item_id, category_id, name,\n\t\t\t\t\tcategories (category_id, name, is_catering)\n\t\t\t\t),\n\t\t\t\titem_components (\n\t\t\t\t\tservings_per_item,\n\t\t\t\t\tcomponent_quantity_behaviors (headcount_threshold, starting_amount, step_size, step_interval, maximum),\n\t\t\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit)\n\t\t\t\t)\n\t\t\t),\n\t\t\titem_rules (rule_id, group_name)\n\t\t),\n\t\torder_item_selected_components (\n\t\t\torder_item_id, rule_id, component_id, quantity, upcharge_price, portion,\n\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit), item_rules (rule_id, group_name)\n\t\t),\n\t\torder_item_components (\n\t\t\torder_item_id, component_id, quantity_servings, is_base, portion,\n\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit)\n\t\t)\n\t)\n`,\n};\n\nexport const CHARGE_STATUSES = {\n\tSUCCESS: 1,\n\tPENDING: 2,\n\tREFUNDED: 3,\n\tVOIDED: 4,\n\tPAYMENT_QUEUED: 5,\n\tFAILED: 6\n}\n","import { supabase } from \"./supabase\";\n\nexport interface MenuSlugParam {\n\tslug: string;\n}\n\nexport interface BaseMenuSlugRecord {\n\tbaseItemId: number;\n\tname: string;\n\tslug: string;\n\tdescription: string | null;\n}\n\nfunction requireApiKey(apiKey?: string): string {\n\tconst resolvedApiKey = apiKey ?? process.env.ZORGO_API_KEY;\n\tif (!resolvedApiKey) {\n\t\tthrow new Error(\"ZORGO_API_KEY is not set\");\n\t}\n\treturn resolvedApiKey;\n}\n\nexport function menuItemNameToSlug(name: string): string {\n\treturn name\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9\\s-]/g, \"\")\n\t\t.replace(/\\s+/g, \"-\")\n\t\t.trim();\n}\n\n/** Builds the `/menu/[slug]` route segment for an item, e.g. \"chicken-burrito-42\". */\nexport function menuItemSlug(name: string, baseItemId: number): string {\n\treturn `${menuItemNameToSlug(name)}-${baseItemId}`;\n}\n\n/** Parses the trailing numeric base_item_id off a `menuItemSlug` result. */\nexport function baseItemIdFromSlug(slug: string): number {\n\tconst match = slug.match(/-(\\d+)$/);\n\tif (!match) {\n\t\tthrow new Error(`No base_item_id found in slug: ${slug}`);\n\t}\n\treturn Number(match[1]);\n}\n\nexport function buildSlugParam(name: string): MenuSlugParam {\n\treturn { slug: menuItemNameToSlug(name) };\n}\n\nexport function buildSlugParamsFromNames(names: string[]): MenuSlugParam[] {\n\treturn names.map(buildSlugParam);\n}\n\nfunction assertUniqueSlugs(items: Array<{ name: string; slug: string }>): void {\n\tconst duplicates = new Map<string, string[]>();\n\n\tfor (const item of items) {\n\t\tconst matches = duplicates.get(item.slug) ?? [];\n\t\tmatches.push(item.name);\n\t\tduplicates.set(item.slug, matches);\n\t}\n\n\tconst conflicts = Array.from(duplicates.entries()).filter(\n\t\t([, names]) => names.length > 1,\n\t);\n\tif (conflicts.length === 0) return;\n\n\tconst message = conflicts\n\t\t.map(([slug, names]) => `\"${slug}\" => ${names.join(\", \")}`)\n\t\t.join(\"; \");\n\tthrow new Error(`Duplicate menu slugs detected: ${message}`);\n}\n\nasync function getFranchiseIdFromApiKey(apiKey?: string): Promise<number> {\n\tconst keyValue = requireApiKey(apiKey);\n\tconst { data, error } = await supabase\n\t\t.from(\"api_keys\")\n\t\t.select(\"franchise_id\")\n\t\t.eq(\"key_value\", keyValue)\n\t\t.eq(\"is_active\", true)\n\t\t.single();\n\n\tif (error || !data?.franchise_id) {\n\t\tthrow new Error(\"Trouble finding franchise for API key\");\n\t}\n\n\treturn data.franchise_id;\n}\n\nexport async function getBaseMenuSlugRecordsByApiKey(\n\tapiKey?: string,\n\toptions: { assertUniqueSlugs?: boolean } = {},\n): Promise<BaseMenuSlugRecord[]> {\n\tconst { assertUniqueSlugs: shouldAssertUniqueSlugs = true } = options;\n\tconst franchiseId = await getFranchiseIdFromApiKey(apiKey);\n\tconst { data, error } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id, name, description\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"base_item_id\", { ascending: true });\n\n\tif (error || !data) {\n\t\tthrow new Error(\"Trouble loading base menu items for franchise\");\n\t}\n\n\tconst records = data\n\t\t.filter((item) => typeof item.name === \"string\" && item.name.length > 0)\n\t\t.map((item) => ({\n\t\t\tbaseItemId: item.base_item_id,\n\t\t\tname: item.name,\n\t\t\tslug: menuItemNameToSlug(item.name),\n\t\t\tdescription: item.description ?? null,\n\t\t}));\n\n\tif (shouldAssertUniqueSlugs) {\n\t\tassertUniqueSlugs(records);\n\t}\n\treturn records;\n}\n\nexport async function getBaseMenuSlugParamsByApiKey(\n\tapiKey?: string,\n): Promise<MenuSlugParam[]> {\n\tconst records = await getBaseMenuSlugRecordsByApiKey(apiKey);\n\treturn records.map(({ slug }) => ({ slug }));\n}\n\nexport async function getBaseMenuSlugRecordBySlug(\n\tslug: string,\n\tapiKey?: string,\n): Promise<BaseMenuSlugRecord | null> {\n\tconst records = await getBaseMenuSlugRecordsByApiKey(apiKey);\n\treturn records.find((record) => record.slug === slug) ?? null;\n}\n","import { SupabaseClient } from \"@supabase/supabase-js\";\nimport { getDay, isAfter, isBefore, parse, startOfDay } from \"date-fns\";\nimport { SUPABASE_URL } from \"../constants\";\nimport {\n\tBaseItem,\n\tBaseItemImage,\n\tCategory,\n\tComponent,\n\tItemVariant,\n\tMenuItem,\n\tPlatforms,\n\tPromotion,\n\tRule,\n\tVisibilityConditionGroupAssignment,\n\tVisibilityEffect,\n} from \"../interfaces\";\nimport { csvFormat } from \"./misc\";\nimport {\n\tbuildVisibilityEvaluationContext,\n\tevaluateVisibilityCondition,\n} from \"./visibilityConditions\";\n\nexport interface Menu {\n\titems: MenuItem[];\n\tcategories: Category[];\n\tcomponents: Component[];\n\trules: Rule[];\n}\n\nexport interface MenuIds {\n\tbaseItemIds: number[];\n\tcategoryIds: number[];\n\titemIds: number[];\n}\n\ntype MenuIdRows = {\n\tcategories: Pick<Category, \"category_id\">[];\n\tbaseItems: Pick<BaseItem, \"base_item_id\" | \"category_id\">[];\n\titems: Pick<ItemVariant, \"item_id\" | \"base_item_id\">[];\n};\n\n/**\n * Menu display order: curated `sort_index` first, unset rows after it.\n *\n * `nullsFirst: false` is the load-bearing half. A franchise pins only the few\n * things it cares about and leaves everything else NULL, so nulls have to fall\n * to the bottom where the tiebreak column (name, or price for variants) sorts\n * them — otherwise the unpinned majority would sit above the curated rows.\n */\nconst ORDER_CURATED = { ascending: true, nullsFirst: false } as const;\n\n/** Sorts last, so an unset sort_index yields to whatever the caller tiebreaks on. */\nconst UNSORTED = Number.MAX_SAFE_INTEGER;\n\n/**\n * The in-memory half of ORDER_CURATED, for rows Postgres can't order for us: a\n * rule's options are one list to the customer but live in two tables\n * (rule_items, rule_components), and their display names sit a join further out\n * on items/components, which PostgREST can't order an embed by.\n */\nfunction byCuratedThenName(\n\ta: { sort_index?: number | null; name: string },\n\tb: { sort_index?: number | null; name: string },\n): number {\n\tconst delta = (a.sort_index ?? UNSORTED) - (b.sort_index ?? UNSORTED);\n\treturn delta !== 0 ? delta : a.name.localeCompare(b.name);\n}\n\nexport async function getMenu(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texclusions: MenuIds = emptyMenuExclusions(),\n\tuseBehaviors = false,\n): Promise<Menu> {\n\tconst categories = await getCategories(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\texclusions.categoryIds,\n\t);\n\n\tif (categories.length === 0) {\n\t\treturn buildMenu([], [], [], [], []);\n\t}\n\n\tconst baseItems = await getBaseItems(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\texclusions.baseItemIds,\n\t\tcategories,\n\t);\n\n\tif (baseItems.length === 0) {\n\t\treturn buildMenu([], [], categories, [], []);\n\t}\n\n\tconst items = await getItems(supabase, baseItems, exclusions.itemIds);\n\tconst components = await getComponents(supabase, items, useBehaviors);\n\tconst rules = await getRules(supabase, baseItems, categories, items, useBehaviors);\n\n\treturn buildMenu(items, baseItems, categories, components, rules);\n}\n\nexport async function getCateringBaseItemIds(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n): Promise<number[]> {\n\tconst { data: categories } = await supabase\n\t\t.from(\"categories\")\n\t\t.select(\"category_id\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.eq(\"is_catering\", true)\n\t\t.throwOnError();\n\n\tif (!categories || categories.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst { data } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.in(\"category_id\", categories.map((category) => category.category_id))\n\t\t.throwOnError();\n\treturn data.map((row) => row.base_item_id);\n}\n\n/**\n * Attaches targeted promotions to menu cards without mutating the menu.\n * Order-wide promos intentionally stay out of menu display pricing.\n */\nexport function fitPromosToMenu(\n\tmenu: Menu,\n\tactivePromotions: Promotion[],\n): Menu {\n\tconst categoryPromoIds = new Map<number, number>();\n\tconst getDisplayPrice = (price: number, promo: Promotion) => {\n\t\tif (promo.promotion_type_id === 1) {\n\t\t\treturn Math.max(0, Math.floor(price * (1 - promo.discount_value / 100)));\n\t\t}\n\t\tif (promo.promotion_type_id === 2) {\n\t\t\treturn Math.max(0, price - promo.discount_value);\n\t\t}\n\t\treturn price;\n\t};\n\n\tconst items = menu.items.map((item) => {\n\t\t// Bulk discount promos (quantity-dependent sigmoid) attach to the item but never set a fixed display_price\n\t\tconst bulkPromo = activePromotions.find((promo) => {\n\t\t\tif (!promo.is_active || promo.id == null || !promo.discount_target_ids?.length || !promo.min_purchase_quantity) return false;\n\t\t\tconst targets = promo.discount_target_ids;\n\t\t\treturn (\n\t\t\t\t(promo.discount_target_type === \"category\" && targets.includes(item.category_id)) ||\n\t\t\t\t(promo.discount_target_type === \"item\" && targets.includes(item.base_item_id))\n\t\t\t);\n\t\t});\n\n\t\tlet itemPromoId: number | undefined = bulkPromo?.id;\n\n\t\tconst variants = item.variants.map((variant) => {\n\t\t\tlet bestPromo: Promotion | undefined;\n\t\t\tlet bestDisplayPrice = variant.price;\n\n\t\t\tfor (const promo of activePromotions) {\n\t\t\t\tif (\n\t\t\t\t\t!promo.is_active ||\n\t\t\t\t\tpromo.id == null ||\n\t\t\t\t\t!promo.discount_target_ids?.length ||\n\t\t\t\t\tpromo.min_purchase_amount || // order-level threshold — shown via modal, not baked into menu price\n\t\t\t\t\tpromo.min_purchase_quantity // quantity-dependent — handled above, no fixed display_price\n\t\t\t\t) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst targets = promo.discount_target_ids;\n\t\t\t\tconst applies =\n\t\t\t\t\t(promo.discount_target_type === \"category\" &&\n\t\t\t\t\t\ttargets.includes(item.category_id)) ||\n\t\t\t\t\t(promo.discount_target_type === \"item\" &&\n\t\t\t\t\t\ttargets.includes(item.base_item_id)) ||\n\t\t\t\t\t(promo.discount_target_type === \"item_variant\" &&\n\t\t\t\t\t\ttargets.includes(variant.item_id));\n\n\t\t\t\tif (!applies) continue;\n\n\t\t\t\tconst displayPrice = getDisplayPrice(variant.price, promo);\n\t\t\t\tif (displayPrice < bestDisplayPrice) {\n\t\t\t\t\tbestPromo = promo;\n\t\t\t\t\tbestDisplayPrice = displayPrice;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!bestPromo || bestDisplayPrice >= variant.price) {\n\t\t\t\treturn variant;\n\t\t\t}\n\n\t\t\titemPromoId ??= bestPromo.id!;\n\t\t\tif (bestPromo.discount_target_type === \"category\") {\n\t\t\t\tcategoryPromoIds.set(item.category_id, bestPromo.id!);\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\t...variant,\n\t\t\t\tactive_promo_id: bestPromo.id!,\n\t\t\t\tdisplay_price: bestDisplayPrice,\n\t\t\t};\n\t\t});\n\n\t\treturn { ...item, variants, active_promo_id: itemPromoId };\n\t});\n\n\tconst categories = menu.categories.map((category) => {\n\t\tconst { active_promo_id, ...cleanCategory } = category;\n\t\tvoid active_promo_id;\n\n\t\tconst promoId = categoryPromoIds.get(cleanCategory.category_id);\n\n\t\treturn promoId == null\n\t\t\t? cleanCategory\n\t\t\t: { ...cleanCategory, active_promo_id: promoId };\n\t});\n\n\treturn {\n\t\t...menu,\n\t\titems,\n\t\tcategories,\n\t};\n}\n\nexport async function getMenuExclusions(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tlocationId: number,\n\tplatform: Platforms,\n): Promise<MenuIds> {\n\tvoid locationId;\n\n\tconst menuIds = await getMenuIds(supabase, franchiseId);\n\tconst activeAssignments = await getActiveVisibilityAssignments(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\tplatform,\n\t);\n\n\tif (activeAssignments.length === 0) {\n\t\treturn emptyMenuExclusions();\n\t}\n\n\tconst categoryEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.category_id,\n\t);\n\tconst baseItemEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.base_item_id,\n\t);\n\tconst itemEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.item_id,\n\t);\n\n\tconst baseItemById = new Map(\n\t\tmenuIds.baseItems.map((baseItem) => [baseItem.base_item_id, baseItem]),\n\t);\n\tconst itemById = new Map(menuIds.items.map((item) => [item.item_id, item]));\n\n\tconst isCategoryIncluded = (categoryId: number) =>\n\t\tcategoryEffects.get(categoryId) !== \"exclude\";\n\n\tconst isBaseItemIncluded = (baseItemId: number) => {\n\t\tconst effect = baseItemEffects.get(baseItemId);\n\t\tif (effect === \"include\") return true;\n\t\tif (effect === \"exclude\") return false;\n\n\t\tconst categoryId = baseItemById.get(baseItemId)?.category_id;\n\t\treturn categoryId == null || isCategoryIncluded(categoryId);\n\t};\n\n\tconst isItemIncluded = (itemId: number): boolean => {\n\t\tconst effect = itemEffects.get(itemId);\n\t\tif (effect === \"include\") return true;\n\t\tif (effect === \"exclude\") return false;\n\n\t\tconst item = itemById.get(itemId);\n\t\tif (item?.base_item_id == null) return true;\n\n\t\treturn isBaseItemIncluded(item.base_item_id);\n\t};\n\n\tconst includedItems = new Set(\n\t\tmenuIds.items\n\t\t\t.filter((item) => isItemIncluded(item.item_id))\n\t\t\t.map((item) => item.item_id),\n\t);\n\tconst includedBaseItems = new Set(\n\t\tmenuIds.baseItems\n\t\t\t.filter((baseItem) => isBaseItemIncluded(baseItem.base_item_id))\n\t\t\t.map((baseItem) => baseItem.base_item_id),\n\t);\n\n\tfor (const item of menuIds.items) {\n\t\tif (includedItems.has(item.item_id) && item.base_item_id != null) {\n\t\t\tincludedBaseItems.add(item.base_item_id);\n\t\t}\n\t}\n\n\tconst includedCategories = new Set(\n\t\tmenuIds.categories\n\t\t\t.filter((category) => isCategoryIncluded(category.category_id))\n\t\t\t.map((category) => category.category_id),\n\t);\n\n\tfor (const baseItem of menuIds.baseItems) {\n\t\tif (includedBaseItems.has(baseItem.base_item_id)) {\n\t\t\tincludedCategories.add(baseItem.category_id);\n\t\t}\n\t}\n\n\treturn {\n\t\tcategoryIds: menuIds.categories\n\t\t\t.map((category) => category.category_id)\n\t\t\t.filter((categoryId) => !includedCategories.has(categoryId)),\n\t\tbaseItemIds: menuIds.baseItems\n\t\t\t.map((baseItem) => baseItem.base_item_id)\n\t\t\t.filter((baseItemId) => !includedBaseItems.has(baseItemId)),\n\t\titemIds: menuIds.items\n\t\t\t.map((item) => item.item_id)\n\t\t\t.filter((itemId) => !includedItems.has(itemId)),\n\t};\n}\n\nexport function getNameFromId(\n\tid: number,\n\ttype: \"category\" | \"item\" | \"item_variant\",\n\tmenu: Partial<Menu>,\n): string {\n\tif (type === \"category\") {\n\t\tconst category = menu.categories?.find(\n\t\t\t(category) => category.category_id === id,\n\t\t);\n\n\t\tif (category?.name) {\n\t\t\treturn category.name;\n\t\t}\n\t} else if (type === \"item\") {\n\t\tconst item = menu.items?.find((item: any) => item.base_item_id === id);\n\n\t\tif (item?.name) {\n\t\t\treturn item.name;\n\t\t}\n\t} else if (type === \"item_variant\") {\n\t\tconst item = menu.items?.find((item: any) =>\n\t\t\titem.variants.find((variant: any) => variant.item_id === id),\n\t\t);\n\n\t\tif (item?.name) {\n\t\t\treturn item.name;\n\t\t}\n\t}\n\n\treturn id.toString(); // default to showing the item id\n}\n\nfunction emptyMenuExclusions(): MenuIds {\n\treturn {\n\t\tcategoryIds: [],\n\t\tbaseItemIds: [],\n\t\titemIds: [],\n\t};\n}\n\nasync function getMenuIds(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n): Promise<MenuIdRows> {\n\tconst { data: categories, error: categoriesError } = await supabase\n\t\t.from(\"categories\")\n\t\t.select(\"category_id\")\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (categoriesError) {\n\t\tthrow categoriesError;\n\t}\n\n\tconst { data: baseItems, error: baseItemsError } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id, category_id\")\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (baseItemsError) {\n\t\tthrow baseItemsError;\n\t}\n\n\tconst baseItemIds = (baseItems ?? []).map((item) => item.base_item_id);\n\tconst { data: items, error: itemsError } =\n\t\tbaseItemIds.length > 0\n\t\t\t? await supabase\n\t\t\t\t.from(\"items\")\n\t\t\t\t.select(\"item_id, base_item_id\")\n\t\t\t\t.in(\"base_item_id\", baseItemIds)\n\t\t\t: { data: [], error: null };\n\n\tif (itemsError) {\n\t\tthrow itemsError;\n\t}\n\n\treturn {\n\t\tcategories: categories ?? [],\n\t\tbaseItems: baseItems ?? [],\n\t\titems: items ?? [],\n\t};\n}\n\nasync function getActiveVisibilityAssignments(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tplatform: Platforms,\n): Promise<VisibilityConditionGroupAssignment[]> {\n\tconst { data, error } = await supabase\n\t\t.from(\"visibility_condition_group_assignments\")\n\t\t.select(\"*, visibility_condition_groups (*)\")\n\t\t.eq(\"visibility_condition_groups.franchise_id\", franchiseId)\n\t\t.eq(\"visibility_condition_groups.platform\", platform);\n\n\tif (error || !data) {\n\t\treturn [];\n\t}\n\n\tconst context = buildVisibilityEvaluationContext({ platform });\n\n\treturn (data as VisibilityConditionGroupAssignment[]).filter((assignment) => {\n\t\tconst group = assignment.visibility_condition_groups;\n\n\t\tif (!group?.condition || group.platform !== context.platform) {\n\t\t\treturn false;\n\t\t}\n\n\t\ttry {\n\t\t\treturn evaluateVisibilityCondition(group.condition, context);\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t});\n}\n\nfunction buildTargetEffectMap(\n\tassignments: VisibilityConditionGroupAssignment[],\n\tselector: (\n\t\tassignment: VisibilityConditionGroupAssignment,\n\t) => number | null | undefined,\n): Map<number, VisibilityEffect> {\n\tconst effectsByTarget = new Map<number, VisibilityEffect[]>();\n\n\tfor (const assignment of assignments) {\n\t\tconst targetId = selector(assignment);\n\t\tif (targetId == null) continue;\n\n\t\teffectsByTarget.set(targetId, [\n\t\t\t...(effectsByTarget.get(targetId) ?? []),\n\t\t\tassignment.effect,\n\t\t]);\n\t}\n\n\treturn new Map(\n\t\tArray.from(effectsByTarget.entries()).map(([targetId, effects]) => [\n\t\t\ttargetId,\n\t\t\teffects.includes(\"exclude\") ? \"exclude\" : \"include\",\n\t\t]),\n\t);\n}\n\nasync function getCategories(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texcludedIds?: number[],\n): Promise<Category[]> {\n\tlet categoriesQuery = supabase\n\t\t.from(\"categories\")\n\t\t.select(\"*\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"name\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\tcategoriesQuery = categoriesQuery.not(\n\t\t\t\"category_id\",\n\t\t\t\"in\",\n\t\t\tcsvFormat(excludedIds),\n\t\t);\n\t}\n\n\tconst { data: categories, error: categoriesError } = await categoriesQuery;\n\n\tif (categoriesError) {\n\t\tthrow categoriesError;\n\t}\n\n\treturn categories ?? [];\n}\n\nasync function getBaseItems(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texcludedIds?: number[],\n\tcategories?: Category[],\n): Promise<BaseItem[]> {\n\tif (categories && categories.length === 0) {\n\t\treturn [];\n\t}\n\n\tlet baseItemsQuery = supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"*\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"name\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\tbaseItemsQuery = baseItemsQuery.not(\n\t\t\t\"base_item_id\",\n\t\t\t\"in\",\n\t\t\tcsvFormat(excludedIds),\n\t\t);\n\t}\n\n\tif (categories) {\n\t\tbaseItemsQuery = baseItemsQuery.in(\n\t\t\t\"category_id\",\n\t\t\tcategories.map((category) => category.category_id),\n\t\t);\n\t}\n\n\tconst { data: baseItems, error: baseItemsError } = await baseItemsQuery;\n\n\tif (baseItemsError) {\n\t\tthrow baseItemsError;\n\t}\n\n\tif (!baseItems || baseItems.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst { data: images, error: imagesError } = await supabase\n\t\t.from(\"base_item_images\")\n\t\t.select(\"*\")\n\t\t.in(\n\t\t\t\"base_item_id\",\n\t\t\tbaseItems.map((baseItem) => baseItem.base_item_id),\n\t\t)\n\t\t.order(\"sort_index\", { ascending: true })\n\t\t.order(\"image_id\", { ascending: true });\n\n\tif (imagesError) {\n\t\tthrow imagesError;\n\t}\n\n\treturn attachImages(baseItems, images ?? []);\n}\n\n/**\n * Group images onto their base items. `images` must already be in the order\n * callers should see them (sort_index, then image_id) — grouping preserves it,\n * so every render site can take `images[0]` as the primary image.\n */\nexport function attachImages<T extends { base_item_id: number }>(\n\tbaseItems: T[],\n\timages: BaseItemImage[],\n): Array<T & { images: BaseItemImage[] }> {\n\tconst byBaseItemId = new Map<number, BaseItemImage[]>();\n\tfor (const image of images) {\n\t\tconst group = byBaseItemId.get(image.base_item_id);\n\t\tif (group) group.push(image);\n\t\telse byBaseItemId.set(image.base_item_id, [image]);\n\t}\n\n\treturn baseItems.map((baseItem) => ({\n\t\t...baseItem,\n\t\timages: byBaseItemId.get(baseItem.base_item_id) ?? [],\n\t}));\n}\n\nexport function menuImageUrl(\n\timage?: BaseItemImage | null,\n\tabsolute = false,\n): string | null {\n\tif (!image) return null;\n\tif (image.local_key && !absolute) return `/images/items/${image.local_key}`;\n\tconst path = image.processed_path ?? image.raw_path;\n\tif (!path) return null;\n\treturn `${SUPABASE_URL}/storage/v1/object/public/product-images/${path}`;\n}\n\nasync function getItems(\n\tsupabase: SupabaseClient,\n\tbaseItems: BaseItem[],\n\texcludedIds?: number[],\n): Promise<ItemVariant[]> {\n\tif (baseItems && baseItems.length === 0) {\n\t\treturn [];\n\t}\n\n\t// the scheme rides along so the prebuilt menu carries it — no runtime fetch\n\t// on the item page just to know how a pizza can be cut\n\t//\n\t// Price, not name, is the tiebreak: variants are usually sizes, and sorting\n\t// those alphabetically reads \"Large, Medium, Small\".\n\tlet itemsQuery = supabase\n\t\t.from(\"items\")\n\t\t.select(\"*, portion_scheme:portion_schemes(*)\")\n\t\t.in(\n\t\t\t\"base_item_id\",\n\t\t\tbaseItems.map((baseItem) => baseItem.base_item_id),\n\t\t)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"price\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\titemsQuery = itemsQuery.not(\"item_id\", \"in\", csvFormat(excludedIds));\n\t}\n\n\tconst { data: items, error: itemsError } = await itemsQuery;\n\n\tif (itemsError) {\n\t\tthrow itemsError;\n\t}\n\n\treturn items ?? [];\n}\n\nexport async function getComponents(\n\tsupabase: SupabaseClient,\n\titems: ItemVariant[] | number[],\n\tuseBehaviors = false,\n): Promise<Component[]> {\n\tconst itemIds = items.length > 0 && typeof items[0] === \"number\"\n\t\t? (items as number[])\n\t\t: (items as ItemVariant[]).map((item) => item.item_id);\n\n\tif (itemIds.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst select = useBehaviors\n\t\t? \"components(*), item_id, servings_per_item, behavior_id, component_quantity_behaviors(headcount_threshold, starting_amount, step_size, step_interval, maximum)\"\n\t\t: \"components(*), item_id, servings_per_item\";\n\n\tconst { data: components } = await supabase\n\t\t.from(\"item_components\")\n\t\t.select(select)\n\t\t.in(\"item_id\", itemIds)\n\t\t.throwOnError();\n\n\treturn components.map((component: any) => ({\n\t\tservings_per_item: component.servings_per_item,\n\t\tis_base: component.components?.is_base,\n\t\tserving_weight: component.components?.serving_weight,\n\t\tcomponent_type_id: component.components?.component_type_id,\n\t\tid: component.components?.id,\n\t\tname: component.components?.name,\n\t\titem_id: component.item_id,\n\t\t...(useBehaviors && { behavior: component.component_quantity_behaviors ?? undefined }),\n\t}));\n}\n\n/**\n * A rule's options — components and items — as one keyed list. The key is what\n * `portionKey` builds on and what `weightedUpcharges` prices, so the picker and\n * the server's repricing read option prices through this same function.\n *\n * The two tables share one `sort_index` sequence, so a rule that mixes them\n * interleaves rather than always putting every component ahead of every item.\n */\nexport function ruleOptionDefs(rule: Rule) {\n\treturn [\n\t\t...(rule.components ?? []).map((c) => ({\n\t\t\tkey: `component:${c.component_id}`,\n\t\t\tid: c.component_id,\n\t\t\ttype: \"component\" as const,\n\t\t\tname: c.name,\n\t\t\tbasePrice: c.upcharge_price || 0,\n\t\t\tdescription: c.description,\n\t\t\tsort_index: c.sort_index ?? null,\n\t\t})),\n\t\t...(rule.items ?? []).map((i) => ({\n\t\t\tkey: `item:${i.item_id}`,\n\t\t\tid: i.item_id,\n\t\t\ttype: \"item\" as const,\n\t\t\tname: i.name,\n\t\t\tbasePrice: i.upcharge_price || 0,\n\t\t\tdescription: i.description,\n\t\t\tsort_index: i.sort_index ?? null,\n\t\t})),\n\t].sort(byCuratedThenName);\n}\n\n/**\n * Rules attach at item, base-item, or category level; `ruleIds` fetches them by\n * primary key instead, for when you already hold the ids (repricing an order's\n * selections server-side).\n */\nexport async function getRules(\n\tsupabase: SupabaseClient,\n\tbaseItems?: BaseItem[],\n\tcategories?: Category[],\n\titems?: ItemVariant[],\n\tuseBehaviors = false,\n\truleIds?: number[],\n): Promise<Rule[]> {\n\tconst itemIds = items?.map((item) => item.item_id) ?? [];\n\tconst baseItemIds = baseItems?.map((baseItem) => baseItem.base_item_id) ?? [];\n\tconst categoryIds = categories?.map((category) => category.category_id) ?? [];\n\n\tif (\n\t\titemIds.length === 0 &&\n\t\tbaseItemIds.length === 0 &&\n\t\tcategoryIds.length === 0 &&\n\t\t!ruleIds?.length\n\t) {\n\t\treturn [];\n\t}\n\n\tconst orFilter = [\n\t\t`item_id.in.(${itemIds.length > 0 ? itemIds.join(\",\") : -1})`,\n\t\t`base_item_id.in.(${baseItemIds.length > 0 ? baseItemIds.join(\",\") : -1})`,\n\t\t`category_id.in.(${categoryIds.length > 0 ? categoryIds.join(\",\") : -1})`,\n\t\t...(ruleIds?.length ? [`rule_id.in.(${ruleIds.join(\",\")})`] : []),\n\t].join(\",\");\n\n\tconst behaviorSelect = useBehaviors\n\t\t? \"component_quantity_behaviors(headcount_threshold, starting_amount, step_size, step_interval, maximum)\"\n\t\t: null;\n\n\tconst ruleComponentsSelect = [\n\t\t\"component_id, upcharge_price, sort_index, components(name, description, serving_weight)\",\n\t\tbehaviorSelect,\n\t].filter(Boolean).join(\", \");\n\n\tconst ruleItemsSelect = [\n\t\t\"item_id, upcharge_price, sort_index, items(item_id, name, description)\",\n\t\tbehaviorSelect,\n\t].filter(Boolean).join(\", \");\n\n\tconst { data: ruleData, error: rulesError } = await supabase\n\t\t.from(\"item_rules\")\n\t\t.select(\n\t\t\t`\n rule_id,\n category_id,\n item_id,\n base_item_id,\n group_name,\n min_selection,\n max_selection,\n included_quantity,\n is_portionable,\n sort_index,\n rule_components (${ruleComponentsSelect}),\n rule_items (${ruleItemsSelect})\n `,\n\t\t)\n\t\t.or(orFilter)\n\t\t// rule_id, not group_name: alphabetical rule groups (\"Choose a protein\"\n\t\t// before \"Select two sides\") is noise, so an unpinned rule falls back to\n\t\t// the order it was created in.\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"rule_id\", ORDER_CURATED);\n\n\tif (rulesError) {\n\t\tthrow rulesError;\n\t}\n\n\treturn (ruleData ?? []).map((rule: any) => ({\n\t\trule_id: rule.rule_id,\n\t\tgroup_name: rule.group_name,\n\t\tmin_selection: rule.min_selection,\n\t\tmax_selection: rule.max_selection,\n\t\tincluded_quantity: rule.included_quantity,\n\t\tis_portionable: rule.is_portionable,\n\t\tcategory_id: rule.category_id,\n\t\titem_id: rule.item_id,\n\t\tbase_item_id: rule.base_item_id,\n\t\tsort_index: rule.sort_index ?? null,\n\n\t\t// Sorted here rather than in the query because the display name lives on\n\t\t// the joined components/items row. Every caller that renders these arrays\n\t\t// straight — the legacy customization modals, a site's server-rendered\n\t\t// item page — inherits the order without touching its own code.\n\t\tcomponents: (rule.rule_components ?? [])\n\t\t\t.map((component: any) => ({\n\t\t\t\tcomponent_id: component.component_id,\n\t\t\t\tupcharge_price: component.upcharge_price ?? 0,\n\t\t\t\tname: component.components?.name ?? \"\",\n\t\t\t\tdescription: component.components?.description ?? undefined,\n\t\t\t\tserving_weight: component.components?.serving_weight ?? undefined,\n\t\t\t\tsort_index: component.sort_index ?? null,\n\t\t\t\t...(useBehaviors && { behavior: component.component_quantity_behaviors ?? undefined }),\n\t\t\t}))\n\t\t\t.sort(byCuratedThenName),\n\n\t\titems: (rule.rule_items ?? [])\n\t\t\t.map((item: any) => ({\n\t\t\t\titem_id: item.item_id,\n\t\t\t\tname: item.items?.name ?? \"\",\n\t\t\t\tdescription: item.items?.description ?? undefined,\n\t\t\t\tupcharge_price: item.upcharge_price ?? 0,\n\t\t\t\tsort_index: item.sort_index ?? null,\n\t\t\t\t...(useBehaviors && { behavior: item.component_quantity_behaviors ?? undefined }),\n\t\t\t}))\n\t\t\t.sort(byCuratedThenName),\n\t}));\n}\n\nfunction buildMenu(\n\titems: ItemVariant[],\n\tbaseItems: BaseItem[],\n\tcategories: Category[],\n\tcomponents: Component[],\n\trules: Rule[],\n): Menu {\n\tconst menu: Menu = {\n\t\titems: [],\n\t\tcategories: [],\n\t\trules,\n\t\tcomponents,\n\t};\n\n\tmenu.items = baseItems.map((baseItem) => {\n\t\tconst categoryRules = rules.filter((rule) => rule.category_id === baseItem.category_id);\n\t\tconst baseItemRules = rules.filter((rule) => rule.base_item_id === baseItem.base_item_id);\n\t\tconst category = categories.find((c) => c.category_id === baseItem.category_id);\n\t\treturn {\n\t\t\t...baseItem,\n\t\t\tis_catering: category?.is_catering ?? false,\n\t\t\tvariants: items\n\t\t\t\t.filter((item) => item.base_item_id === baseItem.base_item_id)\n\t\t\t\t.map((item) => ({\n\t\t\t\t\t...item,\n\t\t\t\t\tcomponents: components.filter(\n\t\t\t\t\t\t(component) => component.item_id === item.item_id,\n\t\t\t\t\t),\n\t\t\t\t\trules: [\n\t\t\t\t\t\t...categoryRules,\n\t\t\t\t\t\t...baseItemRules,\n\t\t\t\t\t\t...rules.filter((rule) => rule.item_id === item.item_id),\n\t\t\t\t\t],\n\t\t\t\t})),\n\t\t\trules: baseItemRules,\n\t\t};\n\t});\n\n\tmenu.categories = categories.map((category) => ({\n\t\t...category,\n\t\trules: rules.filter((rule) => rule.category_id === category.category_id),\n\t}));\n\n\treturn menu;\n}\n\nexport function getBaseItemFromVariant(\n\tmenu: MenuItem[],\n\tvariant: ItemVariant,\n\tbaseItemId?: number,\n): MenuItem | undefined {\n\tif (baseItemId != null) {\n\t\treturn menu.find((i) => i.base_item_id === baseItemId);\n\t}\n\treturn menu.find((i) => i.variants.some((v) => v.item_id === variant.item_id));\n}\n\nexport function getMenuItemByVariantId(menu: MenuItem[], variantId: number): MenuItem | undefined {\n\treturn menu.find((item) =>\n\t\titem.variants?.some((v) => v.item_id === variantId)\n\t);\n}\n\nexport function filterBySchedule(\n\tpromos: Promotion[],\n\tplatform: Platforms,\n\tlocationId: number | undefined,\n\tnow: Date = new Date(),\n): Promotion[] {\n\treturn promos.filter((promo) => {\n\t\tif (promo.start_date && new Date(promo.start_date) > now) return false;\n\t\tif (promo.end_date && new Date(promo.end_date) < now) return false;\n\n\t\tif (promo.platforms && promo.platforms.length > 0) {\n\t\t\tif (!promo.platforms.includes(platform)) return false;\n\t\t}\n\n\t\tif (!promo.is_automatic) return false;\n\n\t\tif (promo.location_id != null && locationId !== undefined) {\n\t\t\tif (promo.location_id !== locationId) return false;\n\t\t} else if (promo.location_id != null && locationId === undefined) {\n\t\t\treturn false;\n\t\t}\n\n\t\tif (promo.schedule_details_json) {\n\t\t\tconst { interval, days, start_time, end_time } =\n\t\t\t\tpromo.schedule_details_json;\n\n\t\t\tif (interval === \"weekly\" && days && !days.includes(getDay(now) + 1)) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tconst dayStart = startOfDay(now);\n\t\t\tif (start_time && isBefore(now, parse(start_time, \"HH:mm\", dayStart))) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (end_time && isAfter(now, parse(end_time, \"HH:mm\", dayStart))) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t});\n}\n\nexport async function getActivePromotions(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tplatform: Platforms,\n\tlocationId?: number,\n): Promise<Promotion[]> {\n\tconst { data, error } = await supabase\n\t\t.from(\"promotions\")\n\t\t.select(\"*\")\n\t\t.eq(\"is_active\", true)\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (error) throw error;\n\n\treturn filterBySchedule((data || []) as Promotion[], platform, locationId);\n}\n","export { menuImageUrl } from \"@zorgo/universal/utils/menuUtils\";\nexport type { BaseItemImage } from \"@zorgo/universal/interfaces\";\n\n/**\n * The variant widths prebuild emits, for menu item images and site-authored\n * photos alike. The loader below and the encoder in internal/images.ts read the\n * same list, so a requested width can never snap to a rung that was never\n * written. 1920 is the top rung because a full-bleed hero on a retina laptop\n * asks for it; `withoutEnlargement` means a smaller source just stops early.\n */\nexport const IMAGE_WIDTHS = [384, 640, 1080, 1920] as const;\n\n/** @deprecated The ladder is no longer item-only — use {@link IMAGE_WIDTHS}. */\nexport const ITEM_IMAGE_WIDTHS = IMAGE_WIDTHS;\n\n/** Public prefix for everything prebuild encodes. */\nexport const IMAGE_PREFIX = \"/images/\";\n\n/** Public prefix menuImageUrl returns for a localized item image. */\nexport const ITEM_IMAGE_PREFIX = \"/images/items/\";\n\n/**\n * next/image custom loader (`images.loaderFile` in a scaffolded site's\n * next.config.ts).\n *\n * An extension-less path under `/images/` is prebuild's contract for \"this has\n * a variant ladder\" — it gets the nearest rung that covers the requested width.\n * That covers both menu item images (`/images/items/<key>`) and the site's own\n * photos (`/images/food`, encoded from `assets/images/food.JPG`).\n *\n * Everything else passes through untouched and is served as-is: a bucket URL\n * for an item prebuild could not localize, the prebuilt logo, a video poster —\n * anything that still carries a file extension.\n *\n * Sync and pure by contract: next/image calls it during render for every srcset\n * candidate.\n */\nexport default function imageLoader({\n\tsrc,\n\twidth,\n}: {\n\tsrc: string;\n\twidth: number;\n}): string {\n\tif (!src.startsWith(IMAGE_PREFIX) || /\\.[a-z0-9]+$/i.test(src)) return src;\n\tconst rung =\n\t\tIMAGE_WIDTHS.find((candidate) => candidate >= width) ??\n\t\tIMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];\n\treturn `${src}-${rung}.avif`;\n}\n\nexport { imageLoader, imageLoader as itemImageLoader };\n","import type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport type { ItemVariant, Location, MenuItem } from \"@zorgo/universal/interfaces\";\nimport { wallTime } from \"@zorgo/universal/utils/locationOrderingAvailability\";\n\n/** Franchise-level facts for the Restaurant node. Everything but `name` is\n * optional so the schema degrades gracefully when a field is missing. */\nexport interface RestaurantInfo {\n\tname: string;\n\turl?: string;\n\timage?: string;\n\tdescription?: string;\n\tservesCuisine?: string[];\n\t/** Public locations from `lib/generated/locations.ts`. One location's facts\n\t * land on the Restaurant node itself; several become linked branches. */\n\tlocations?: Location[];\n}\n\nconst DAYS = [\n\t\"Monday\",\n\t\"Tuesday\",\n\t\"Wednesday\",\n\t\"Thursday\",\n\t\"Friday\",\n\t\"Saturday\",\n\t\"Sunday\",\n] as const;\n\n/** Stable node ids so the parent and its branches can reference each other. */\nfunction restaurantId(info: RestaurantInfo) {\n\treturn `${info.url ?? \"\"}#restaurant`;\n}\nfunction branchId(info: RestaurantInfo, location: Location) {\n\treturn `${info.url ?? \"\"}#location-${location.location_id}`;\n}\n\n/**\n * One OpeningHoursSpecification per distinct weekly window, with the days that\n * share it grouped into `dayOfWeek`. Days with no hours set are simply absent,\n * which is how schema.org expresses \"closed\".\n */\nexport function openingHoursSpecification(location: Location) {\n\tconst byWindow = new Map<string, string[]>();\n\tfor (const day of DAYS) {\n\t\tconst key = day.toLowerCase();\n\t\tconst open = location[`${key}_open` as keyof Location] as string | undefined;\n\t\tconst close = location[`${key}_close` as keyof Location] as string | undefined;\n\t\tif (!open || !close) continue;\n\t\tconst window = `${wallTime(open)}|${wallTime(close)}`;\n\t\tbyWindow.set(window, [...(byWindow.get(window) ?? []), `https://schema.org/${day}`]);\n\t}\n\n\treturn [...byWindow].map(([window, dayOfWeek]) => {\n\t\tconst [opens, closes] = window.split(\"|\");\n\t\treturn { \"@type\": \"OpeningHoursSpecification\", dayOfWeek, opens, closes };\n\t});\n}\n\n/**\n * `123 Main St, Springfield, IL 62704` — street, city, two-letter state, ZIP,\n * with an optional +4 and an optional trailing country. Anchored at both ends\n * and deliberately strict: everything before the last two commas is the street,\n * so a unit (\"… Ste 4, Springfield, IL 62704\") still parses.\n */\nconst US_ADDRESS = /^(.+),\\s*([^,]+),\\s*([A-Z]{2})\\s+(\\d{5}(?:-\\d{4})?)(?:,\\s*USA?)?$/;\n\n/**\n * schema.org accepts `address` as either Text or a PostalAddress node, but\n * Google's LocalBusiness rich results only read the structured form — a bare\n * string means the branch nodes never qualify.\n *\n * `address` is still one staff-typed column, so this parses rather than reads,\n * and it only parses what it can prove: a string that does not match the US\n * shape exactly falls back to the Text form it has always shipped. A wrong\n * `addressRegion` in structured data is worse than no `addressRegion` — it is a\n * fact we are asserting to Google — so the regex declines rather than guesses.\n *\n * ponytail: US-only, because every franchise is. Drop the fallback and read real\n * columns the day `locations` stores the parts separately.\n */\nfunction postalAddress(address: string): Record<string, unknown> | string {\n\tconst match = US_ADDRESS.exec(address.trim());\n\tif (!match) return address;\n\tconst [, streetAddress, addressLocality, addressRegion, postalCode] = match;\n\t// Unreachable — all four groups are required by the pattern — but under\n\t// noUncheckedIndexedAccess a capture is `string | undefined`, and declining\n\t// is already what this function does when it cannot prove the shape.\n\tif (!streetAddress || !addressLocality || !addressRegion || !postalCode) return address;\n\treturn {\n\t\t\"@type\": \"PostalAddress\",\n\t\tstreetAddress: streetAddress.trim(),\n\t\taddressLocality: addressLocality.trim(),\n\t\taddressRegion,\n\t\tpostalCode,\n\t\taddressCountry: \"US\",\n\t};\n}\n\n/** address / geo / telephone / hours — the facts shared by the single-location\n * Restaurant node and every branch node. */\nfunction locationFacts(location: Location) {\n\tconst facts: Record<string, unknown> = {};\n\tif (location.address) facts.address = postalAddress(location.address);\n\tif (location.phone) facts.telephone = location.phone;\n\tif (location.lat != null && location.lng != null) {\n\t\tfacts.geo = {\n\t\t\t\"@type\": \"GeoCoordinates\",\n\t\t\tlatitude: location.lat,\n\t\t\tlongitude: location.lng,\n\t\t};\n\t}\n\tconst hours = openingHoursSpecification(location);\n\tif (hours.length) facts.openingHoursSpecification = hours;\n\treturn facts;\n}\n\n/**\n * The branch node for one location, for that location's page. Carries the same\n * address/geo/hours facts as the parent and links back to it via\n * `parentOrganization`. Pure — safe to run at build time.\n */\nexport function buildLocationJsonLd(location: Location, info: RestaurantInfo) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\t\"@id\": branchId(info, location),\n\t\tname: `${info.name} — ${location.name}`,\n\t\tparentOrganization: { \"@id\": restaurantId(info) },\n\t\t...locationFacts(location),\n\t};\n\tif (info.image) node.image = info.image;\n\treturn node;\n}\n\n// cents -> \"5.49\" (schema.org Offer.price is a string in major currency units)\nfunction priceString(cents: number): string {\n\treturn (cents / 100).toFixed(2);\n}\n\nfunction variantOffer(variant: ItemVariant) {\n\treturn {\n\t\t\"@type\": \"Offer\",\n\t\tprice: priceString(variant.display_price ?? variant.price),\n\t\tpriceCurrency: \"USD\",\n\t};\n}\n\nfunction menuItemNode(item: MenuItem) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@type\": \"MenuItem\",\n\t\tname: item.name,\n\t};\n\tif (item.description) node.description = item.description;\n\t// one Offer for a single price, an array when the item has size/variant prices\n\tconst offers = item.variants.map(variantOffer);\n\tif (offers.length === 1) node.offers = offers[0];\n\telse if (offers.length > 1) node.offers = offers;\n\treturn node;\n}\n\n/**\n * Builds a standalone schema.org JSON-LD object for a single menu item, for\n * embedding in that item's `/menu/[slug]` page `<head>`. Reuses `menuItemNode`\n * (the same node shape used inside the Restaurant menu) and adds the\n * `@context` a top-level node needs. Pure — safe to run at build time.\n */\nexport function buildMenuItemJsonLd(item: MenuItem) {\n\treturn { \"@context\": \"https://schema.org\", ...menuItemNode(item) };\n}\n\n/**\n * Builds a schema.org Restaurant JSON-LD object from a prebuilt Menu and\n * franchise info. Categories become MenuSections; the items in each become\n * MenuItems. Pure — same inputs, same output — so it can run at prebuild time\n * and be embedded straight into the site's <head>.\n */\nexport function buildRestaurantJsonLd(menu: Menu, info: RestaurantInfo) {\n\tconst hasMenuSection = menu.categories.map((category) => {\n\t\tconst section: Record<string, unknown> = {\n\t\t\t\"@type\": \"MenuSection\",\n\t\t\tname: category.name,\n\t\t};\n\t\tconst items = menu.items\n\t\t\t.filter((item) => item.category_id === category.category_id)\n\t\t\t.map(menuItemNode);\n\t\tif (items.length) section.hasMenuItem = items;\n\t\treturn section;\n\t});\n\n\tconst restaurant: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\tname: info.name,\n\t};\n\tif (info.url) restaurant.url = info.url;\n\tif (info.image) restaurant.image = info.image;\n\tif (info.description) restaurant.description = info.description;\n\tif (info.servesCuisine?.length) restaurant.servesCuisine = info.servesCuisine;\n\n\t// A single-location franchise *is* its storefront, so its facts belong on\n\t// the Restaurant node. Several locations get referenced as branches instead\n\t// — the full branch nodes live on the location pages, not duplicated here.\n\tconst locations = info.locations ?? [];\n\tif (locations.length === 1) {\n\t\tObject.assign(restaurant, locationFacts(locations[0]!));\n\t} else if (locations.length > 1) {\n\t\trestaurant[\"@id\"] = restaurantId(info);\n\t\trestaurant.subOrganization = locations.map((location) => ({\n\t\t\t\"@type\": \"Restaurant\",\n\t\t\t\"@id\": branchId(info, location),\n\t\t\tname: location.name,\n\t\t}));\n\t}\n\n\trestaurant.hasMenu = {\n\t\t\"@type\": \"Menu\",\n\t\tname: \"Menu\",\n\t\thasMenuSection,\n\t};\n\n\treturn restaurant;\n}\n"],"mappings":"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","names":["Image"]}
1
+ {"version":3,"sources":["../src/components/server/item-page.tsx","../../zorgo/universal/constants.ts","../../zorgo/universal/utils/menuSlugs.ts","../../zorgo/universal/utils/menuUtils.ts","../src/images.ts","../src/seo.ts"],"sourcesContent":["import type { Metadata } from \"next\";\nimport type { ReactNode } from \"react\";\nimport Image from \"next/image\";\nimport type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport { baseItemIdFromSlug, menuItemSlug } from \"../../slugs\";\nimport { ITEM_IMAGE_PREFIX, menuImageUrl } from \"../../images\";\nimport { buildMenuItemJsonLd } from \"../../seo\";\nimport { ClientItemForm } from \"../client/ItemForm/ClientItemForm\";\n\n// Single source of truth for resolving the item a slug points at — shared by\n// every export here so the lookup never drifts. Returns undefined for an\n// unknown slug; callers degrade gracefully (the page 404s downstream).\nfunction itemFromSlug(menu: Menu, slug: string) {\n\tconst baseItemId = baseItemIdFromSlug(slug);\n\treturn menu.items.find((item) => item.base_item_id === baseItemId);\n}\n\n/** generateStaticParams for /menu/[slug] — every item in the prebuilt menu. */\nexport function menuItemStaticParams(menu: Menu) {\n\treturn menu.items.map((item) => ({\n\t\tslug: menuItemSlug(item.name, item.base_item_id),\n\t}));\n}\n\n/**\n * generateMetadata for /menu/[slug]: per-item title/description/OG so each item\n * ranks on its own long-tail terms. title is just the item name; the root\n * layout's title template wraps it as `<name> | Brand`, and its metadataBase\n * resolves OG URLs, so nothing absolute is hardcoded here.\n */\nexport function menuItemMetadata(menu: Menu, slug: string): Metadata {\n\tconst item = itemFromSlug(menu, slug);\n\tif (!item) return {};\n\n\tconst description =\n\t\titem.description ?? `Order ${item.name} online for pickup or delivery.`;\n\tconst image = menuImageUrl(item.images?.[0], true);\n\n\treturn {\n\t\ttitle: item.name,\n\t\tdescription,\n\t\topenGraph: {\n\t\t\ttitle: item.name,\n\t\t\tdescription,\n\t\t\t// Next's typed OpenGraph union has no \"product\"; \"website\" is the\n\t\t\t// closest valid value for a standalone item page.\n\t\t\ttype: \"website\",\n\t\t\t...(image ? { images: [image] } : {}),\n\t\t},\n\t};\n}\n\n/**\n * The whole /menu/[slug] body, owned by the package so a package bump updates\n * every scaffolded site. Server Component: the hero image and JSON-LD are\n * resolved from the prebuilt menu at build time and land in the static HTML,\n * while the interactive half sits behind ClientItemForm.\n */\nexport function ItemCustomizationView({\n\tmenu,\n\tslug,\n\tchildren,\n}: {\n\tmenu: Menu;\n\tslug: string;\n\t/**\n\t * The interactive half. Defaults to the package's form; pass a site's own\n\t * client component to keep a custom customization UI and still get the\n\t * server-rendered hero image, JSON-LD, and metadata from here.\n\t */\n\tchildren?: ReactNode;\n}) {\n\tconst item = itemFromSlug(menu, slug);\n\tconst heroImageUrl = menuImageUrl(item?.images?.[0]);\n\n\treturn (\n\t\t<div className=\"p-8 md:p-16 max-w-4xl mx-auto flex flex-col gap-8\">\n\t\t\t{item && (\n\t\t\t\t<script\n\t\t\t\t\ttype=\"application/ld+json\"\n\t\t\t\t\tdangerouslySetInnerHTML={{\n\t\t\t\t\t\t__html: JSON.stringify(buildMenuItemJsonLd(item)),\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{heroImageUrl && (\n\t\t\t\t<Image\n\t\t\t\t\tsrc={heroImageUrl}\n\t\t\t\t\talt={item!.name}\n\t\t\t\t\twidth={1080}\n\t\t\t\t\theight={608}\n\t\t\t\t\tpriority\n\t\t\t\t\t// An image prebuild could not localize is served straight from\n\t\t\t\t\t// the bucket, where no width variants exist.\n\t\t\t\t\tunoptimized={!heroImageUrl.startsWith(ITEM_IMAGE_PREFIX)}\n\t\t\t\t\t// Full width up to the container's max-w-4xl (56rem/896px); the\n\t\t\t\t\t// loader snaps that to the 1080 rung and narrower screens to 640/384.\n\t\t\t\t\tsizes=\"(min-width: 896px) 896px, 100vw\"\n\t\t\t\t\tclassName=\"w-full aspect-video object-cover rounded-2xl bg-background-recessed\"\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{children ?? (\n\t\t\t\t<ClientItemForm baseItemId={baseItemIdFromSlug(slug)} />\n\t\t\t)}\n\t\t</div>\n\t);\n}\n","// Env first, hardcoded production second — the same shape POS and kiosk already\n// use in their own config/environment.ts. Both bundlers inline these by literal\n// name (Next NEXT_PUBLIC_*, Expo EXPO_PUBLIC_*), so the lookups have to stay\n// written out; a destructured or computed read is not substituted. Without this\n// the two vars zorgo_site/.env already declares were dead, and pointing any app\n// at a Supabase preview branch meant editing this file.\nexport const SUPABASE_URL =\n\tprocess.env.NEXT_PUBLIC_SUPABASE_URL ||\n\tprocess.env.EXPO_PUBLIC_SUPABASE_URL ||\n\t\"https://dhnpoxorllfdobwftkyb.supabase.co\";\n\nexport const SUPABASE_ANON_KEY =\n\tprocess.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||\n\tprocess.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ||\n\t\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRobnBveG9ybGxmZG9id2Z0a3liIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzkwNTQxMDgsImV4cCI6MjA1NDYzMDEwOH0.zI8PwbgKwijHZ1QmxJqmaPrFvg02w1vOY2Qcs7SddzI\";\nexport const ZORGO_PURPLE = \"rgb(153,67,233)\";\n\nexport const expoGoGoogleClientId =\n\t\"959654249401-dm6nbi27rj605ul1nkt8o2imb8f7daff.apps.googleusercontent.com\";\n\n// replace instances of API_BASE_URL with env enum\nexport enum Environments {\n\tProduction = \"https://api.zorgotech.com\",\n\tStaging = \"https://staging.zorgo.ai\",\n\tDevelopment = \"http://localhost:3000\",\n}\n\nexport const API_BASE_URL =\n\tprocess.env.NODE_ENV === \"production\"\n\t\t? Environments.Production\n\t\t: Environments.Development;\n\n/** Emails allowed to access developer-only metrics (e.g. /performance/developer). */\nexport const DEVELOPER_EMAILS = [\"clawsorgo@gmail.com\", \"gmandwee@gmail.com\"] as const;\n\nexport function isDeveloperEmail(email?: string | null): boolean {\n\treturn !!email && (DEVELOPER_EMAILS as readonly string[]).includes(email);\n}\n\nexport const ORDER_STORE_CONSTANTS = {\n\tDEFAULTS: {\n\t\tPREP_MINUTES: 20, // default amount of time for prep\n\t\tPAST_HOURS: 24, // hours into the past orders are fetched for\n\t\tFUTURE_HOURS: 72 // hours into the future orders are fetched for \n\t},\n\tORDER_STATUS: {\n\t\tSTAGING: 10,\n\t\tPENDING: 1,\n\t\tCOMPLETED: 2,\n\t\tCANCELLED: 4,\n\t\tPAID: 6,\n\t\tPARTIALLY_PAID: 7,\n\t\tKITCHEN_DONE: 9,\n\t\tCONFIRMED: 11,\n\t\tPREPARING: 12,\n\t\tREQUESTED: 13,\n\t},\n\tPOS_METHOD_ID: 3,\n\t/**\n\t * Internal QA accounts — their orders are excluded from dashboard order queries.\n\t * Matched on dev/QA emails (kcjayd@, clawsorgo@, gmandwee@, aaiden798@, *@zorgo.*),\n\t * the \"c d\" throwaway persona, and known test names (casey donegan, aiden alazo, …).\n\t * ponytail: hardcoded snapshot — new test accounts need re-running the query.\n\t * Promote to an `is_test` column on `customers` if this list keeps growing.\n\t */\n\tTEST_CUSTOMER_IDS: [\n\t\t85, 90, 91, 93, 95, 96, 97, 203, 238, 240, 241, 244, 245, 246, 317, 318, 319, 322,\n\t\t579, 580, 658, 1526, 2567, 2573, 2703, 2725, 2737, 2739, 2740, 2741, 2742, 2744,\n\t\t2746, 2747, 2749, 2751, 2752, 2753, 2754, 2755, 2767, 2768, 2778, 2779, 2780, 2781,\n\t\t2783, 2814, 2816, 2818, 2819, 2820, 2821, 2822, 2823, 2824, 2835, 2836, 2851, 2853,\n\t\t2859, 2871, 2882, 2883, 2887, 2888, 2889, 2891, 2892, 2893, 2894, 2895, 2896, 2897,\n\t\t2898, 2899, 2900, 2901, 2902, 2903, 2904, 2905, 2907, 2908, 2919, 2921, 2922, 2923,\n\t\t2924, 2925, 2926, 2927, 2946, 3017, 3018, 3022, 3024, 3025, 3026, 3027, 3028, 3029,\n\t\t3030, 3031, 3082, 3091, 3092, 3093,\n\t],\n\tORDER_TYPE: {\n\t\tDINE_IN: 1,\n\t\tTAKEOUT: 2,\n\t\tDELIVERY: 3\n\t},\n\t/** KDS edit highlight duration after a POS modification. */\n\tMODIFICATION_HIGHLIGHT_MS: 8_000,\n\t/** POS order realtime: refetch + recycle postgres_changes channel on this cadence. */\n\tLISTENER_SYNC_INTERVAL_MS: 60_000,\n\tORDER_SELECT: `\n\t*,\n\n\tcharges (\n\t\t*\n\t),\n\n\tlocations (name),\n\n\tcustomers\n\t (customer_id, first_name, last_name, phone_number, email),\n\n\torder_items\n\t (\n\t\torder_item_id, item_id, quantity, price_at_order, order_id, comments, status, applied_promo_id, discount_amount,\n\t\titems (\n\t\t\titem_id, name, price,\n\t\t\tportion_schemes (id, franchise_id, geometry, name, min_divisions, max_divisions),\n\t\t\tbase_items (\n\t\t\t\tbase_item_id, category_id, name, per_person,\n\t\t\t\tcategories (category_id, name, is_catering)\n\t\t\t)\n\t\t),\n\t\torder_item_selected_items (\n\t\t\torder_item_id, rule_id, item_id, quantity, upcharge_price, portion,\n\t\t\titems (\n\t\t\t\titem_id, name, price,\n\t\t\t\tbase_items (\n\t\t\t\t\tbase_item_id, category_id, name,\n\t\t\t\t\tcategories (category_id, name, is_catering)\n\t\t\t\t),\n\t\t\t\titem_components (\n\t\t\t\t\tservings_per_item,\n\t\t\t\t\tcomponent_quantity_behaviors (headcount_threshold, starting_amount, step_size, step_interval, maximum),\n\t\t\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit)\n\t\t\t\t)\n\t\t\t),\n\t\t\titem_rules (rule_id, group_name)\n\t\t),\n\t\torder_item_selected_components (\n\t\t\torder_item_id, rule_id, component_id, quantity, upcharge_price, portion,\n\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit), item_rules (rule_id, group_name)\n\t\t),\n\t\torder_item_components (\n\t\t\torder_item_id, component_id, quantity_servings, is_base, portion,\n\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit)\n\t\t)\n\t)\n`,\n};\n\nexport const CHARGE_STATUSES = {\n\tSUCCESS: 1,\n\tPENDING: 2,\n\tREFUNDED: 3,\n\tVOIDED: 4,\n\tPAYMENT_QUEUED: 5,\n\tFAILED: 6\n}\n","import { supabase } from \"./supabase\";\n\nexport interface MenuSlugParam {\n\tslug: string;\n}\n\nexport interface BaseMenuSlugRecord {\n\tbaseItemId: number;\n\tname: string;\n\tslug: string;\n\tdescription: string | null;\n}\n\nfunction requireApiKey(apiKey?: string): string {\n\tconst resolvedApiKey = apiKey ?? process.env.ZORGO_API_KEY;\n\tif (!resolvedApiKey) {\n\t\tthrow new Error(\"ZORGO_API_KEY is not set\");\n\t}\n\treturn resolvedApiKey;\n}\n\nexport function menuItemNameToSlug(name: string): string {\n\treturn name\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9\\s-]/g, \"\")\n\t\t.replace(/\\s+/g, \"-\")\n\t\t.trim();\n}\n\n/** Builds the `/menu/[slug]` route segment for an item, e.g. \"chicken-burrito-42\". */\nexport function menuItemSlug(name: string, baseItemId: number): string {\n\treturn `${menuItemNameToSlug(name)}-${baseItemId}`;\n}\n\n/** Parses the trailing numeric base_item_id off a `menuItemSlug` result. */\nexport function baseItemIdFromSlug(slug: string): number {\n\tconst match = slug.match(/-(\\d+)$/);\n\tif (!match) {\n\t\tthrow new Error(`No base_item_id found in slug: ${slug}`);\n\t}\n\treturn Number(match[1]);\n}\n\nexport function buildSlugParam(name: string): MenuSlugParam {\n\treturn { slug: menuItemNameToSlug(name) };\n}\n\nexport function buildSlugParamsFromNames(names: string[]): MenuSlugParam[] {\n\treturn names.map(buildSlugParam);\n}\n\nfunction assertUniqueSlugs(items: Array<{ name: string; slug: string }>): void {\n\tconst duplicates = new Map<string, string[]>();\n\n\tfor (const item of items) {\n\t\tconst matches = duplicates.get(item.slug) ?? [];\n\t\tmatches.push(item.name);\n\t\tduplicates.set(item.slug, matches);\n\t}\n\n\tconst conflicts = Array.from(duplicates.entries()).filter(\n\t\t([, names]) => names.length > 1,\n\t);\n\tif (conflicts.length === 0) return;\n\n\tconst message = conflicts\n\t\t.map(([slug, names]) => `\"${slug}\" => ${names.join(\", \")}`)\n\t\t.join(\"; \");\n\tthrow new Error(`Duplicate menu slugs detected: ${message}`);\n}\n\nasync function getFranchiseIdFromApiKey(apiKey?: string): Promise<number> {\n\tconst keyValue = requireApiKey(apiKey);\n\tconst { data, error } = await supabase\n\t\t.from(\"api_keys\")\n\t\t.select(\"franchise_id\")\n\t\t.eq(\"key_value\", keyValue)\n\t\t.eq(\"is_active\", true)\n\t\t.single();\n\n\tif (error || !data?.franchise_id) {\n\t\tthrow new Error(\"Trouble finding franchise for API key\");\n\t}\n\n\treturn data.franchise_id;\n}\n\nexport async function getBaseMenuSlugRecordsByApiKey(\n\tapiKey?: string,\n\toptions: { assertUniqueSlugs?: boolean } = {},\n): Promise<BaseMenuSlugRecord[]> {\n\tconst { assertUniqueSlugs: shouldAssertUniqueSlugs = true } = options;\n\tconst franchiseId = await getFranchiseIdFromApiKey(apiKey);\n\tconst { data, error } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id, name, description\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"base_item_id\", { ascending: true });\n\n\tif (error || !data) {\n\t\tthrow new Error(\"Trouble loading base menu items for franchise\");\n\t}\n\n\tconst records = data\n\t\t.filter((item) => typeof item.name === \"string\" && item.name.length > 0)\n\t\t.map((item) => ({\n\t\t\tbaseItemId: item.base_item_id,\n\t\t\tname: item.name,\n\t\t\tslug: menuItemNameToSlug(item.name),\n\t\t\tdescription: item.description ?? null,\n\t\t}));\n\n\tif (shouldAssertUniqueSlugs) {\n\t\tassertUniqueSlugs(records);\n\t}\n\treturn records;\n}\n\nexport async function getBaseMenuSlugParamsByApiKey(\n\tapiKey?: string,\n): Promise<MenuSlugParam[]> {\n\tconst records = await getBaseMenuSlugRecordsByApiKey(apiKey);\n\treturn records.map(({ slug }) => ({ slug }));\n}\n\nexport async function getBaseMenuSlugRecordBySlug(\n\tslug: string,\n\tapiKey?: string,\n): Promise<BaseMenuSlugRecord | null> {\n\tconst records = await getBaseMenuSlugRecordsByApiKey(apiKey);\n\treturn records.find((record) => record.slug === slug) ?? null;\n}\n","import { SupabaseClient } from \"@supabase/supabase-js\";\nimport { getDay, isAfter, isBefore, parse, startOfDay } from \"date-fns\";\nimport { SUPABASE_URL } from \"../constants\";\nimport {\n\tBaseItem,\n\tBaseItemImage,\n\tCategory,\n\tComponent,\n\tItemVariant,\n\tMenuItem,\n\tPlatforms,\n\tPromotion,\n\tRule,\n\tVisibilityConditionGroupAssignment,\n\tVisibilityEffect,\n} from \"../interfaces\";\nimport { csvFormat } from \"./misc\";\nimport {\n\tbuildVisibilityEvaluationContext,\n\tevaluateVisibilityCondition,\n} from \"./visibilityConditions\";\n\nexport interface Menu {\n\titems: MenuItem[];\n\tcategories: Category[];\n\tcomponents: Component[];\n\trules: Rule[];\n}\n\nexport interface MenuIds {\n\tbaseItemIds: number[];\n\tcategoryIds: number[];\n\titemIds: number[];\n}\n\ntype MenuIdRows = {\n\tcategories: Pick<Category, \"category_id\">[];\n\tbaseItems: Pick<BaseItem, \"base_item_id\" | \"category_id\">[];\n\titems: Pick<ItemVariant, \"item_id\" | \"base_item_id\">[];\n};\n\n/**\n * Menu display order: curated `sort_index` first, unset rows after it.\n *\n * `nullsFirst: false` is the load-bearing half. A franchise pins only the few\n * things it cares about and leaves everything else NULL, so nulls have to fall\n * to the bottom where the tiebreak column (name, or price for variants) sorts\n * them — otherwise the unpinned majority would sit above the curated rows.\n */\nconst ORDER_CURATED = { ascending: true, nullsFirst: false } as const;\n\n/** Sorts last, so an unset sort_index yields to whatever the caller tiebreaks on. */\nconst UNSORTED = Number.MAX_SAFE_INTEGER;\n\n/**\n * The in-memory half of ORDER_CURATED, for rows Postgres can't order for us: a\n * rule's options are one list to the customer but live in two tables\n * (rule_items, rule_components), and their display names sit a join further out\n * on items/components, which PostgREST can't order an embed by.\n */\nfunction byCuratedThenName(\n\ta: { sort_index?: number | null; name: string },\n\tb: { sort_index?: number | null; name: string },\n): number {\n\tconst delta = (a.sort_index ?? UNSORTED) - (b.sort_index ?? UNSORTED);\n\treturn delta !== 0 ? delta : a.name.localeCompare(b.name);\n}\n\nexport async function getMenu(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texclusions: MenuIds = emptyMenuExclusions(),\n\tuseBehaviors = false,\n): Promise<Menu> {\n\tconst categories = await getCategories(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\texclusions.categoryIds,\n\t);\n\n\tif (categories.length === 0) {\n\t\treturn buildMenu([], [], [], [], []);\n\t}\n\n\tconst baseItems = await getBaseItems(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\texclusions.baseItemIds,\n\t\tcategories,\n\t);\n\n\tif (baseItems.length === 0) {\n\t\treturn buildMenu([], [], categories, [], []);\n\t}\n\n\tconst items = await getItems(supabase, baseItems, exclusions.itemIds);\n\tconst components = await getComponents(supabase, items, useBehaviors);\n\tconst rules = await getRules(supabase, baseItems, categories, items, useBehaviors);\n\n\treturn buildMenu(items, baseItems, categories, components, rules);\n}\n\nexport async function getCateringBaseItemIds(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n): Promise<number[]> {\n\tconst { data: categories } = await supabase\n\t\t.from(\"categories\")\n\t\t.select(\"category_id\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.eq(\"is_catering\", true)\n\t\t.throwOnError();\n\n\tif (!categories || categories.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst { data } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.in(\"category_id\", categories.map((category) => category.category_id))\n\t\t.throwOnError();\n\treturn data.map((row) => row.base_item_id);\n}\n\n/**\n * Attaches targeted promotions to menu cards without mutating the menu.\n * Order-wide promos intentionally stay out of menu display pricing.\n */\nexport function fitPromosToMenu(\n\tmenu: Menu,\n\tactivePromotions: Promotion[],\n): Menu {\n\tconst categoryPromoIds = new Map<number, number>();\n\tconst getDisplayPrice = (price: number, promo: Promotion) => {\n\t\tif (promo.promotion_type_id === 1) {\n\t\t\treturn Math.max(0, Math.floor(price * (1 - promo.discount_value / 100)));\n\t\t}\n\t\tif (promo.promotion_type_id === 2) {\n\t\t\treturn Math.max(0, price - promo.discount_value);\n\t\t}\n\t\treturn price;\n\t};\n\n\tconst items = menu.items.map((item) => {\n\t\t// Bulk discount promos (quantity-dependent sigmoid) attach to the item but never set a fixed display_price\n\t\tconst bulkPromo = activePromotions.find((promo) => {\n\t\t\tif (!promo.is_active || promo.id == null || !promo.discount_target_ids?.length || !promo.min_purchase_quantity) return false;\n\t\t\tconst targets = promo.discount_target_ids;\n\t\t\treturn (\n\t\t\t\t(promo.discount_target_type === \"category\" && targets.includes(item.category_id)) ||\n\t\t\t\t(promo.discount_target_type === \"item\" && targets.includes(item.base_item_id))\n\t\t\t);\n\t\t});\n\n\t\tlet itemPromoId: number | undefined = bulkPromo?.id;\n\n\t\tconst variants = item.variants.map((variant) => {\n\t\t\tlet bestPromo: Promotion | undefined;\n\t\t\tlet bestDisplayPrice = variant.price;\n\n\t\t\tfor (const promo of activePromotions) {\n\t\t\t\tif (\n\t\t\t\t\t!promo.is_active ||\n\t\t\t\t\tpromo.id == null ||\n\t\t\t\t\t!promo.discount_target_ids?.length ||\n\t\t\t\t\tpromo.min_purchase_amount || // order-level threshold — shown via modal, not baked into menu price\n\t\t\t\t\tpromo.min_purchase_quantity // quantity-dependent — handled above, no fixed display_price\n\t\t\t\t) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst targets = promo.discount_target_ids;\n\t\t\t\tconst applies =\n\t\t\t\t\t(promo.discount_target_type === \"category\" &&\n\t\t\t\t\t\ttargets.includes(item.category_id)) ||\n\t\t\t\t\t(promo.discount_target_type === \"item\" &&\n\t\t\t\t\t\ttargets.includes(item.base_item_id)) ||\n\t\t\t\t\t(promo.discount_target_type === \"item_variant\" &&\n\t\t\t\t\t\ttargets.includes(variant.item_id));\n\n\t\t\t\tif (!applies) continue;\n\n\t\t\t\tconst displayPrice = getDisplayPrice(variant.price, promo);\n\t\t\t\tif (displayPrice < bestDisplayPrice) {\n\t\t\t\t\tbestPromo = promo;\n\t\t\t\t\tbestDisplayPrice = displayPrice;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!bestPromo || bestDisplayPrice >= variant.price) {\n\t\t\t\treturn variant;\n\t\t\t}\n\n\t\t\titemPromoId ??= bestPromo.id!;\n\t\t\tif (bestPromo.discount_target_type === \"category\") {\n\t\t\t\tcategoryPromoIds.set(item.category_id, bestPromo.id!);\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\t...variant,\n\t\t\t\tactive_promo_id: bestPromo.id!,\n\t\t\t\tdisplay_price: bestDisplayPrice,\n\t\t\t};\n\t\t});\n\n\t\treturn { ...item, variants, active_promo_id: itemPromoId };\n\t});\n\n\tconst categories = menu.categories.map((category) => {\n\t\tconst { active_promo_id, ...cleanCategory } = category;\n\t\tvoid active_promo_id;\n\n\t\tconst promoId = categoryPromoIds.get(cleanCategory.category_id);\n\n\t\treturn promoId == null\n\t\t\t? cleanCategory\n\t\t\t: { ...cleanCategory, active_promo_id: promoId };\n\t});\n\n\treturn {\n\t\t...menu,\n\t\titems,\n\t\tcategories,\n\t};\n}\n\nexport async function getMenuExclusions(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tlocationId: number,\n\tplatform: Platforms,\n): Promise<MenuIds> {\n\tvoid locationId;\n\n\tconst menuIds = await getMenuIds(supabase, franchiseId);\n\tconst activeAssignments = await getActiveVisibilityAssignments(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\tplatform,\n\t);\n\n\tif (activeAssignments.length === 0) {\n\t\treturn emptyMenuExclusions();\n\t}\n\n\tconst categoryEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.category_id,\n\t);\n\tconst baseItemEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.base_item_id,\n\t);\n\tconst itemEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.item_id,\n\t);\n\n\tconst baseItemById = new Map(\n\t\tmenuIds.baseItems.map((baseItem) => [baseItem.base_item_id, baseItem]),\n\t);\n\tconst itemById = new Map(menuIds.items.map((item) => [item.item_id, item]));\n\n\tconst isCategoryIncluded = (categoryId: number) =>\n\t\tcategoryEffects.get(categoryId) !== \"exclude\";\n\n\tconst isBaseItemIncluded = (baseItemId: number) => {\n\t\tconst effect = baseItemEffects.get(baseItemId);\n\t\tif (effect === \"include\") return true;\n\t\tif (effect === \"exclude\") return false;\n\n\t\tconst categoryId = baseItemById.get(baseItemId)?.category_id;\n\t\treturn categoryId == null || isCategoryIncluded(categoryId);\n\t};\n\n\tconst isItemIncluded = (itemId: number): boolean => {\n\t\tconst effect = itemEffects.get(itemId);\n\t\tif (effect === \"include\") return true;\n\t\tif (effect === \"exclude\") return false;\n\n\t\tconst item = itemById.get(itemId);\n\t\tif (item?.base_item_id == null) return true;\n\n\t\treturn isBaseItemIncluded(item.base_item_id);\n\t};\n\n\tconst includedItems = new Set(\n\t\tmenuIds.items\n\t\t\t.filter((item) => isItemIncluded(item.item_id))\n\t\t\t.map((item) => item.item_id),\n\t);\n\tconst includedBaseItems = new Set(\n\t\tmenuIds.baseItems\n\t\t\t.filter((baseItem) => isBaseItemIncluded(baseItem.base_item_id))\n\t\t\t.map((baseItem) => baseItem.base_item_id),\n\t);\n\n\tfor (const item of menuIds.items) {\n\t\tif (includedItems.has(item.item_id) && item.base_item_id != null) {\n\t\t\tincludedBaseItems.add(item.base_item_id);\n\t\t}\n\t}\n\n\tconst includedCategories = new Set(\n\t\tmenuIds.categories\n\t\t\t.filter((category) => isCategoryIncluded(category.category_id))\n\t\t\t.map((category) => category.category_id),\n\t);\n\n\tfor (const baseItem of menuIds.baseItems) {\n\t\tif (includedBaseItems.has(baseItem.base_item_id)) {\n\t\t\tincludedCategories.add(baseItem.category_id);\n\t\t}\n\t}\n\n\treturn {\n\t\tcategoryIds: menuIds.categories\n\t\t\t.map((category) => category.category_id)\n\t\t\t.filter((categoryId) => !includedCategories.has(categoryId)),\n\t\tbaseItemIds: menuIds.baseItems\n\t\t\t.map((baseItem) => baseItem.base_item_id)\n\t\t\t.filter((baseItemId) => !includedBaseItems.has(baseItemId)),\n\t\titemIds: menuIds.items\n\t\t\t.map((item) => item.item_id)\n\t\t\t.filter((itemId) => !includedItems.has(itemId)),\n\t};\n}\n\nexport function getNameFromId(\n\tid: number,\n\ttype: \"category\" | \"item\" | \"item_variant\",\n\tmenu: Partial<Menu>,\n): string {\n\tif (type === \"category\") {\n\t\tconst category = menu.categories?.find(\n\t\t\t(category) => category.category_id === id,\n\t\t);\n\n\t\tif (category?.name) {\n\t\t\treturn category.name;\n\t\t}\n\t} else if (type === \"item\") {\n\t\tconst item = menu.items?.find((item: any) => item.base_item_id === id);\n\n\t\tif (item?.name) {\n\t\t\treturn item.name;\n\t\t}\n\t} else if (type === \"item_variant\") {\n\t\tconst item = menu.items?.find((item: any) =>\n\t\t\titem.variants.find((variant: any) => variant.item_id === id),\n\t\t);\n\n\t\tif (item?.name) {\n\t\t\treturn item.name;\n\t\t}\n\t}\n\n\treturn id.toString(); // default to showing the item id\n}\n\nfunction emptyMenuExclusions(): MenuIds {\n\treturn {\n\t\tcategoryIds: [],\n\t\tbaseItemIds: [],\n\t\titemIds: [],\n\t};\n}\n\nasync function getMenuIds(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n): Promise<MenuIdRows> {\n\tconst { data: categories, error: categoriesError } = await supabase\n\t\t.from(\"categories\")\n\t\t.select(\"category_id\")\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (categoriesError) {\n\t\tthrow categoriesError;\n\t}\n\n\tconst { data: baseItems, error: baseItemsError } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id, category_id\")\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (baseItemsError) {\n\t\tthrow baseItemsError;\n\t}\n\n\tconst baseItemIds = (baseItems ?? []).map((item) => item.base_item_id);\n\tconst { data: items, error: itemsError } =\n\t\tbaseItemIds.length > 0\n\t\t\t? await supabase\n\t\t\t\t.from(\"items\")\n\t\t\t\t.select(\"item_id, base_item_id\")\n\t\t\t\t.in(\"base_item_id\", baseItemIds)\n\t\t\t: { data: [], error: null };\n\n\tif (itemsError) {\n\t\tthrow itemsError;\n\t}\n\n\treturn {\n\t\tcategories: categories ?? [],\n\t\tbaseItems: baseItems ?? [],\n\t\titems: items ?? [],\n\t};\n}\n\nasync function getActiveVisibilityAssignments(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tplatform: Platforms,\n): Promise<VisibilityConditionGroupAssignment[]> {\n\tconst { data, error } = await supabase\n\t\t.from(\"visibility_condition_group_assignments\")\n\t\t.select(\"*, visibility_condition_groups (*)\")\n\t\t.eq(\"visibility_condition_groups.franchise_id\", franchiseId)\n\t\t.eq(\"visibility_condition_groups.platform\", platform);\n\n\tif (error || !data) {\n\t\treturn [];\n\t}\n\n\tconst context = buildVisibilityEvaluationContext({ platform });\n\n\treturn (data as VisibilityConditionGroupAssignment[]).filter((assignment) => {\n\t\tconst group = assignment.visibility_condition_groups;\n\n\t\tif (!group?.condition || group.platform !== context.platform) {\n\t\t\treturn false;\n\t\t}\n\n\t\ttry {\n\t\t\treturn evaluateVisibilityCondition(group.condition, context);\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t});\n}\n\nfunction buildTargetEffectMap(\n\tassignments: VisibilityConditionGroupAssignment[],\n\tselector: (\n\t\tassignment: VisibilityConditionGroupAssignment,\n\t) => number | null | undefined,\n): Map<number, VisibilityEffect> {\n\tconst effectsByTarget = new Map<number, VisibilityEffect[]>();\n\n\tfor (const assignment of assignments) {\n\t\tconst targetId = selector(assignment);\n\t\tif (targetId == null) continue;\n\n\t\teffectsByTarget.set(targetId, [\n\t\t\t...(effectsByTarget.get(targetId) ?? []),\n\t\t\tassignment.effect,\n\t\t]);\n\t}\n\n\treturn new Map(\n\t\tArray.from(effectsByTarget.entries()).map(([targetId, effects]) => [\n\t\t\ttargetId,\n\t\t\teffects.includes(\"exclude\") ? \"exclude\" : \"include\",\n\t\t]),\n\t);\n}\n\nasync function getCategories(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texcludedIds?: number[],\n): Promise<Category[]> {\n\tlet categoriesQuery = supabase\n\t\t.from(\"categories\")\n\t\t.select(\"*\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"name\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\tcategoriesQuery = categoriesQuery.not(\n\t\t\t\"category_id\",\n\t\t\t\"in\",\n\t\t\tcsvFormat(excludedIds),\n\t\t);\n\t}\n\n\tconst { data: categories, error: categoriesError } = await categoriesQuery;\n\n\tif (categoriesError) {\n\t\tthrow categoriesError;\n\t}\n\n\treturn categories ?? [];\n}\n\nasync function getBaseItems(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texcludedIds?: number[],\n\tcategories?: Category[],\n): Promise<BaseItem[]> {\n\tif (categories && categories.length === 0) {\n\t\treturn [];\n\t}\n\n\tlet baseItemsQuery = supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"*\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"name\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\tbaseItemsQuery = baseItemsQuery.not(\n\t\t\t\"base_item_id\",\n\t\t\t\"in\",\n\t\t\tcsvFormat(excludedIds),\n\t\t);\n\t}\n\n\tif (categories) {\n\t\tbaseItemsQuery = baseItemsQuery.in(\n\t\t\t\"category_id\",\n\t\t\tcategories.map((category) => category.category_id),\n\t\t);\n\t}\n\n\tconst { data: baseItems, error: baseItemsError } = await baseItemsQuery;\n\n\tif (baseItemsError) {\n\t\tthrow baseItemsError;\n\t}\n\n\tif (!baseItems || baseItems.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst { data: images, error: imagesError } = await supabase\n\t\t.from(\"base_item_images\")\n\t\t.select(\"*\")\n\t\t.in(\n\t\t\t\"base_item_id\",\n\t\t\tbaseItems.map((baseItem) => baseItem.base_item_id),\n\t\t)\n\t\t.order(\"sort_index\", { ascending: true })\n\t\t.order(\"image_id\", { ascending: true });\n\n\tif (imagesError) {\n\t\tthrow imagesError;\n\t}\n\n\treturn attachImages(baseItems, images ?? []);\n}\n\n/**\n * Group images onto their base items. `images` must already be in the order\n * callers should see them (sort_index, then image_id) — grouping preserves it,\n * so every render site can take `images[0]` as the primary image.\n */\nexport function attachImages<T extends { base_item_id: number }>(\n\tbaseItems: T[],\n\timages: BaseItemImage[],\n): Array<T & { images: BaseItemImage[] }> {\n\tconst byBaseItemId = new Map<number, BaseItemImage[]>();\n\tfor (const image of images) {\n\t\tconst group = byBaseItemId.get(image.base_item_id);\n\t\tif (group) group.push(image);\n\t\telse byBaseItemId.set(image.base_item_id, [image]);\n\t}\n\n\treturn baseItems.map((baseItem) => ({\n\t\t...baseItem,\n\t\timages: byBaseItemId.get(baseItem.base_item_id) ?? [],\n\t}));\n}\n\nexport function menuImageUrl(\n\timage?: BaseItemImage | null,\n\tabsolute = false,\n): string | null {\n\tif (!image) return null;\n\tif (image.local_key && !absolute) return `/images/items/${image.local_key}`;\n\tconst path = image.processed_path ?? image.raw_path;\n\tif (!path) return null;\n\treturn `${SUPABASE_URL}/storage/v1/object/public/product-images/${path}`;\n}\n\nasync function getItems(\n\tsupabase: SupabaseClient,\n\tbaseItems: BaseItem[],\n\texcludedIds?: number[],\n): Promise<ItemVariant[]> {\n\tif (baseItems && baseItems.length === 0) {\n\t\treturn [];\n\t}\n\n\t// the scheme rides along so the prebuilt menu carries it — no runtime fetch\n\t// on the item page just to know how a pizza can be cut\n\t//\n\t// Price, not name, is the tiebreak: variants are usually sizes, and sorting\n\t// those alphabetically reads \"Large, Medium, Small\".\n\tlet itemsQuery = supabase\n\t\t.from(\"items\")\n\t\t.select(\"*, portion_scheme:portion_schemes(*)\")\n\t\t.in(\n\t\t\t\"base_item_id\",\n\t\t\tbaseItems.map((baseItem) => baseItem.base_item_id),\n\t\t)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"price\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\titemsQuery = itemsQuery.not(\"item_id\", \"in\", csvFormat(excludedIds));\n\t}\n\n\tconst { data: items, error: itemsError } = await itemsQuery;\n\n\tif (itemsError) {\n\t\tthrow itemsError;\n\t}\n\n\treturn items ?? [];\n}\n\nexport async function getComponents(\n\tsupabase: SupabaseClient,\n\titems: ItemVariant[] | number[],\n\tuseBehaviors = false,\n): Promise<Component[]> {\n\tconst itemIds = items.length > 0 && typeof items[0] === \"number\"\n\t\t? (items as number[])\n\t\t: (items as ItemVariant[]).map((item) => item.item_id);\n\n\tif (itemIds.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst select = useBehaviors\n\t\t? \"components(*), item_id, servings_per_item, behavior_id, component_quantity_behaviors(headcount_threshold, starting_amount, step_size, step_interval, maximum)\"\n\t\t: \"components(*), item_id, servings_per_item\";\n\n\tconst { data: components } = await supabase\n\t\t.from(\"item_components\")\n\t\t.select(select)\n\t\t.in(\"item_id\", itemIds)\n\t\t.throwOnError();\n\n\treturn components.map((component: any) => ({\n\t\tservings_per_item: component.servings_per_item,\n\t\tis_base: component.components?.is_base,\n\t\tserving_weight: component.components?.serving_weight,\n\t\tcomponent_type_id: component.components?.component_type_id,\n\t\tid: component.components?.id,\n\t\tname: component.components?.name,\n\t\titem_id: component.item_id,\n\t\t...(useBehaviors && { behavior: component.component_quantity_behaviors ?? undefined }),\n\t}));\n}\n\n/**\n * A rule's options — components and items — as one keyed list. The key is what\n * `portionKey` builds on and what `weightedUpcharges` prices, so the picker and\n * the server's repricing read option prices through this same function.\n *\n * The two tables share one `sort_index` sequence, so a rule that mixes them\n * interleaves rather than always putting every component ahead of every item.\n */\nexport function ruleOptionDefs(rule: Rule) {\n\treturn [\n\t\t...(rule.components ?? []).map((c) => ({\n\t\t\tkey: `component:${c.component_id}`,\n\t\t\tid: c.component_id,\n\t\t\ttype: \"component\" as const,\n\t\t\tname: c.name,\n\t\t\tbasePrice: c.upcharge_price || 0,\n\t\t\tdescription: c.description,\n\t\t\tsort_index: c.sort_index ?? null,\n\t\t})),\n\t\t...(rule.items ?? []).map((i) => ({\n\t\t\tkey: `item:${i.item_id}`,\n\t\t\tid: i.item_id,\n\t\t\ttype: \"item\" as const,\n\t\t\tname: i.name,\n\t\t\tbasePrice: i.upcharge_price || 0,\n\t\t\tdescription: i.description,\n\t\t\tsort_index: i.sort_index ?? null,\n\t\t})),\n\t].sort(byCuratedThenName);\n}\n\n/**\n * Rules attach at item, base-item, or category level; `ruleIds` fetches them by\n * primary key instead, for when you already hold the ids (repricing an order's\n * selections server-side).\n */\nexport async function getRules(\n\tsupabase: SupabaseClient,\n\tbaseItems?: BaseItem[],\n\tcategories?: Category[],\n\titems?: ItemVariant[],\n\tuseBehaviors = false,\n\truleIds?: number[],\n): Promise<Rule[]> {\n\tconst itemIds = items?.map((item) => item.item_id) ?? [];\n\tconst baseItemIds = baseItems?.map((baseItem) => baseItem.base_item_id) ?? [];\n\tconst categoryIds = categories?.map((category) => category.category_id) ?? [];\n\n\tif (\n\t\titemIds.length === 0 &&\n\t\tbaseItemIds.length === 0 &&\n\t\tcategoryIds.length === 0 &&\n\t\t!ruleIds?.length\n\t) {\n\t\treturn [];\n\t}\n\n\tconst orFilter = [\n\t\t`item_id.in.(${itemIds.length > 0 ? itemIds.join(\",\") : -1})`,\n\t\t`base_item_id.in.(${baseItemIds.length > 0 ? baseItemIds.join(\",\") : -1})`,\n\t\t`category_id.in.(${categoryIds.length > 0 ? categoryIds.join(\",\") : -1})`,\n\t\t...(ruleIds?.length ? [`rule_id.in.(${ruleIds.join(\",\")})`] : []),\n\t].join(\",\");\n\n\tconst behaviorSelect = useBehaviors\n\t\t? \"component_quantity_behaviors(headcount_threshold, starting_amount, step_size, step_interval, maximum)\"\n\t\t: null;\n\n\tconst ruleComponentsSelect = [\n\t\t\"component_id, upcharge_price, sort_index, components(name, description, serving_weight)\",\n\t\tbehaviorSelect,\n\t].filter(Boolean).join(\", \");\n\n\tconst ruleItemsSelect = [\n\t\t\"item_id, upcharge_price, sort_index, items(item_id, name, description)\",\n\t\tbehaviorSelect,\n\t].filter(Boolean).join(\", \");\n\n\tconst { data: ruleData, error: rulesError } = await supabase\n\t\t.from(\"item_rules\")\n\t\t.select(\n\t\t\t`\n rule_id,\n category_id,\n item_id,\n base_item_id,\n group_name,\n min_selection,\n max_selection,\n included_quantity,\n is_portionable,\n sort_index,\n rule_components (${ruleComponentsSelect}),\n rule_items (${ruleItemsSelect})\n `,\n\t\t)\n\t\t.or(orFilter)\n\t\t// rule_id, not group_name: alphabetical rule groups (\"Choose a protein\"\n\t\t// before \"Select two sides\") is noise, so an unpinned rule falls back to\n\t\t// the order it was created in.\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"rule_id\", ORDER_CURATED);\n\n\tif (rulesError) {\n\t\tthrow rulesError;\n\t}\n\n\treturn (ruleData ?? []).map((rule: any) => ({\n\t\trule_id: rule.rule_id,\n\t\tgroup_name: rule.group_name,\n\t\tmin_selection: rule.min_selection,\n\t\tmax_selection: rule.max_selection,\n\t\tincluded_quantity: rule.included_quantity,\n\t\tis_portionable: rule.is_portionable,\n\t\tcategory_id: rule.category_id,\n\t\titem_id: rule.item_id,\n\t\tbase_item_id: rule.base_item_id,\n\t\tsort_index: rule.sort_index ?? null,\n\n\t\t// Sorted here rather than in the query because the display name lives on\n\t\t// the joined components/items row. Every caller that renders these arrays\n\t\t// straight — the legacy customization modals, a site's server-rendered\n\t\t// item page — inherits the order without touching its own code.\n\t\tcomponents: (rule.rule_components ?? [])\n\t\t\t.map((component: any) => ({\n\t\t\t\tcomponent_id: component.component_id,\n\t\t\t\tupcharge_price: component.upcharge_price ?? 0,\n\t\t\t\tname: component.components?.name ?? \"\",\n\t\t\t\tdescription: component.components?.description ?? undefined,\n\t\t\t\tserving_weight: component.components?.serving_weight ?? undefined,\n\t\t\t\tsort_index: component.sort_index ?? null,\n\t\t\t\t...(useBehaviors && { behavior: component.component_quantity_behaviors ?? undefined }),\n\t\t\t}))\n\t\t\t.sort(byCuratedThenName),\n\n\t\titems: (rule.rule_items ?? [])\n\t\t\t.map((item: any) => ({\n\t\t\t\titem_id: item.item_id,\n\t\t\t\tname: item.items?.name ?? \"\",\n\t\t\t\tdescription: item.items?.description ?? undefined,\n\t\t\t\tupcharge_price: item.upcharge_price ?? 0,\n\t\t\t\tsort_index: item.sort_index ?? null,\n\t\t\t\t...(useBehaviors && { behavior: item.component_quantity_behaviors ?? undefined }),\n\t\t\t}))\n\t\t\t.sort(byCuratedThenName),\n\t}));\n}\n\nfunction buildMenu(\n\titems: ItemVariant[],\n\tbaseItems: BaseItem[],\n\tcategories: Category[],\n\tcomponents: Component[],\n\trules: Rule[],\n): Menu {\n\tconst menu: Menu = {\n\t\titems: [],\n\t\tcategories: [],\n\t\trules,\n\t\tcomponents,\n\t};\n\n\tmenu.items = baseItems.map((baseItem) => {\n\t\tconst categoryRules = rules.filter((rule) => rule.category_id === baseItem.category_id);\n\t\tconst baseItemRules = rules.filter((rule) => rule.base_item_id === baseItem.base_item_id);\n\t\tconst category = categories.find((c) => c.category_id === baseItem.category_id);\n\t\treturn {\n\t\t\t...baseItem,\n\t\t\tis_catering: category?.is_catering ?? false,\n\t\t\tvariants: items\n\t\t\t\t.filter((item) => item.base_item_id === baseItem.base_item_id)\n\t\t\t\t.map((item) => ({\n\t\t\t\t\t...item,\n\t\t\t\t\tcomponents: components.filter(\n\t\t\t\t\t\t(component) => component.item_id === item.item_id,\n\t\t\t\t\t),\n\t\t\t\t\trules: [\n\t\t\t\t\t\t...categoryRules,\n\t\t\t\t\t\t...baseItemRules,\n\t\t\t\t\t\t...rules.filter((rule) => rule.item_id === item.item_id),\n\t\t\t\t\t],\n\t\t\t\t})),\n\t\t\trules: baseItemRules,\n\t\t};\n\t});\n\n\tmenu.categories = categories.map((category) => ({\n\t\t...category,\n\t\trules: rules.filter((rule) => rule.category_id === category.category_id),\n\t}));\n\n\treturn menu;\n}\n\nexport function getBaseItemFromVariant(\n\tmenu: MenuItem[],\n\tvariant: ItemVariant,\n\tbaseItemId?: number,\n): MenuItem | undefined {\n\tif (baseItemId != null) {\n\t\treturn menu.find((i) => i.base_item_id === baseItemId);\n\t}\n\treturn menu.find((i) => i.variants.some((v) => v.item_id === variant.item_id));\n}\n\nexport function getMenuItemByVariantId(menu: MenuItem[], variantId: number): MenuItem | undefined {\n\treturn menu.find((item) =>\n\t\titem.variants?.some((v) => v.item_id === variantId)\n\t);\n}\n\nexport function filterBySchedule(\n\tpromos: Promotion[],\n\tplatform: Platforms,\n\tlocationId: number | undefined,\n\tnow: Date = new Date(),\n): Promotion[] {\n\treturn promos.filter((promo) => {\n\t\tif (promo.start_date && new Date(promo.start_date) > now) return false;\n\t\tif (promo.end_date && new Date(promo.end_date) < now) return false;\n\n\t\tif (promo.platforms && promo.platforms.length > 0) {\n\t\t\tif (!promo.platforms.includes(platform)) return false;\n\t\t}\n\n\t\tif (!promo.is_automatic) return false;\n\n\t\tif (promo.location_id != null && locationId !== undefined) {\n\t\t\tif (promo.location_id !== locationId) return false;\n\t\t} else if (promo.location_id != null && locationId === undefined) {\n\t\t\treturn false;\n\t\t}\n\n\t\tif (promo.schedule_details_json) {\n\t\t\tconst { interval, days, start_time, end_time } =\n\t\t\t\tpromo.schedule_details_json;\n\n\t\t\tif (interval === \"weekly\" && days && !days.includes(getDay(now) + 1)) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tconst dayStart = startOfDay(now);\n\t\t\tif (start_time && isBefore(now, parse(start_time, \"HH:mm\", dayStart))) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (end_time && isAfter(now, parse(end_time, \"HH:mm\", dayStart))) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t});\n}\n\nexport async function getActivePromotions(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tplatform: Platforms,\n\tlocationId?: number,\n): Promise<Promotion[]> {\n\tconst { data, error } = await supabase\n\t\t.from(\"promotions\")\n\t\t.select(\"*\")\n\t\t.eq(\"is_active\", true)\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (error) throw error;\n\n\treturn filterBySchedule((data || []) as Promotion[], platform, locationId);\n}\n","export { menuImageUrl } from \"@zorgo/universal/utils/menuUtils\";\nexport type { BaseItemImage } from \"@zorgo/universal/interfaces\";\n\n/**\n * The variant widths prebuild emits, for menu item images and site-authored\n * photos alike. The loader below and the encoder in internal/images.ts read the\n * same list, so a requested width can never snap to a rung that was never\n * written. 1920 is the top rung because a full-bleed hero on a retina laptop\n * asks for it; `withoutEnlargement` means a smaller source just stops early.\n */\nexport const IMAGE_WIDTHS = [384, 640, 1080, 1920] as const;\n\n/** @deprecated The ladder is no longer item-only — use {@link IMAGE_WIDTHS}. */\nexport const ITEM_IMAGE_WIDTHS = IMAGE_WIDTHS;\n\n/** Public prefix for everything prebuild encodes. */\nexport const IMAGE_PREFIX = \"/images/\";\n\n/** Public prefix menuImageUrl returns for a localized item image. */\nexport const ITEM_IMAGE_PREFIX = \"/images/items/\";\n\n/**\n * next/image custom loader (`images.loaderFile` in a scaffolded site's\n * next.config.ts).\n *\n * An extension-less path under `/images/` is prebuild's contract for \"this has\n * a variant ladder\" — it gets the nearest rung that covers the requested width.\n * That covers both menu item images (`/images/items/<key>`) and the site's own\n * photos (`/images/food`, encoded from `assets/images/food.JPG`).\n *\n * Everything else passes through untouched and is served as-is: a bucket URL\n * for an item prebuild could not localize, the prebuilt logo, a video poster —\n * anything that still carries a file extension.\n *\n * Sync and pure by contract: next/image calls it during render for every srcset\n * candidate.\n */\nexport default function imageLoader({\n\tsrc,\n\twidth,\n}: {\n\tsrc: string;\n\twidth: number;\n}): string {\n\tif (!src.startsWith(IMAGE_PREFIX) || /\\.[a-z0-9]+$/i.test(src)) return src;\n\tconst rung =\n\t\tIMAGE_WIDTHS.find((candidate) => candidate >= width) ??\n\t\tIMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];\n\treturn `${src}-${rung}.avif`;\n}\n\nexport { imageLoader, imageLoader as itemImageLoader };\n","import type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport type { ItemVariant, Location, MenuItem } from \"@zorgo/universal/interfaces\";\nimport { wallTime } from \"@zorgo/universal/utils/locationOrderingAvailability\";\n\n/** Franchise-level facts for the Restaurant node. Everything but `name` is\n * optional so the schema degrades gracefully when a field is missing. */\nexport interface RestaurantInfo {\n\tname: string;\n\turl?: string;\n\timage?: string;\n\tdescription?: string;\n\tservesCuisine?: string[];\n\t/** Public locations from `lib/generated/locations.ts`. One location's facts\n\t * land on the Restaurant node itself; several become linked branches. */\n\tlocations?: Location[];\n}\n\nconst DAYS = [\n\t\"Monday\",\n\t\"Tuesday\",\n\t\"Wednesday\",\n\t\"Thursday\",\n\t\"Friday\",\n\t\"Saturday\",\n\t\"Sunday\",\n] as const;\n\n/** Stable node ids so the parent and its branches can reference each other. */\nfunction restaurantId(info: RestaurantInfo) {\n\treturn `${info.url ?? \"\"}#restaurant`;\n}\nfunction branchId(info: RestaurantInfo, location: Location) {\n\treturn `${info.url ?? \"\"}#location-${location.location_id}`;\n}\n\n/**\n * One OpeningHoursSpecification per distinct weekly window, with the days that\n * share it grouped into `dayOfWeek`. Days with no hours set are simply absent,\n * which is how schema.org expresses \"closed\".\n */\nexport function openingHoursSpecification(location: Location) {\n\tconst byWindow = new Map<string, string[]>();\n\tfor (const day of DAYS) {\n\t\tconst key = day.toLowerCase();\n\t\tconst open = location[`${key}_open` as keyof Location] as string | undefined;\n\t\tconst close = location[`${key}_close` as keyof Location] as string | undefined;\n\t\tif (!open || !close) continue;\n\t\tconst window = `${wallTime(open)}|${wallTime(close)}`;\n\t\tbyWindow.set(window, [...(byWindow.get(window) ?? []), `https://schema.org/${day}`]);\n\t}\n\n\treturn [...byWindow].map(([window, dayOfWeek]) => {\n\t\tconst [opens, closes] = window.split(\"|\");\n\t\treturn { \"@type\": \"OpeningHoursSpecification\", dayOfWeek, opens, closes };\n\t});\n}\n\n/**\n * `123 Main St, Springfield, IL 62704` — street, city, two-letter state, ZIP,\n * with an optional +4 and an optional trailing country. Anchored at both ends\n * and deliberately strict: everything before the last two commas is the street,\n * so a unit (\"… Ste 4, Springfield, IL 62704\") still parses.\n */\nconst US_ADDRESS = /^(.+),\\s*([^,]+),\\s*([A-Z]{2})\\s+(\\d{5}(?:-\\d{4})?)(?:,\\s*USA?)?$/;\n\n/**\n * schema.org accepts `address` as either Text or a PostalAddress node, but\n * Google's LocalBusiness rich results only read the structured form — a bare\n * string means the branch nodes never qualify.\n *\n * `address` is still one staff-typed column, so this parses rather than reads,\n * and it only parses what it can prove: a string that does not match the US\n * shape exactly falls back to the Text form it has always shipped. A wrong\n * `addressRegion` in structured data is worse than no `addressRegion` — it is a\n * fact we are asserting to Google — so the regex declines rather than guesses.\n *\n * ponytail: US-only, because every franchise is. Drop the fallback and read real\n * columns the day `locations` stores the parts separately.\n */\nfunction postalAddress(address: string): Record<string, unknown> | string {\n\tconst match = US_ADDRESS.exec(address.trim());\n\tif (!match) return address;\n\tconst [, streetAddress, addressLocality, addressRegion, postalCode] = match;\n\t// Unreachable — all four groups are required by the pattern — but under\n\t// noUncheckedIndexedAccess a capture is `string | undefined`, and declining\n\t// is already what this function does when it cannot prove the shape.\n\tif (!streetAddress || !addressLocality || !addressRegion || !postalCode) return address;\n\treturn {\n\t\t\"@type\": \"PostalAddress\",\n\t\tstreetAddress: streetAddress.trim(),\n\t\taddressLocality: addressLocality.trim(),\n\t\taddressRegion,\n\t\tpostalCode,\n\t\taddressCountry: \"US\",\n\t};\n}\n\n/** address / geo / telephone / hours — the facts shared by the single-location\n * Restaurant node and every branch node. */\nfunction locationFacts(location: Location) {\n\tconst facts: Record<string, unknown> = {};\n\tif (location.address) facts.address = postalAddress(location.address);\n\tif (location.phone) facts.telephone = location.phone;\n\tif (location.lat != null && location.lng != null) {\n\t\tfacts.geo = {\n\t\t\t\"@type\": \"GeoCoordinates\",\n\t\t\tlatitude: location.lat,\n\t\t\tlongitude: location.lng,\n\t\t};\n\t}\n\tconst hours = openingHoursSpecification(location);\n\tif (hours.length) facts.openingHoursSpecification = hours;\n\treturn facts;\n}\n\n/**\n * The branch node for one location, for that location's page. Carries the same\n * address/geo/hours facts as the parent and links back to it via\n * `parentOrganization`. Pure — safe to run at build time.\n */\nexport function buildLocationJsonLd(location: Location, info: RestaurantInfo) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\t\"@id\": branchId(info, location),\n\t\tname: `${info.name} — ${location.name}`,\n\t\tparentOrganization: { \"@id\": restaurantId(info) },\n\t\t...locationFacts(location),\n\t};\n\tif (info.image) node.image = info.image;\n\treturn node;\n}\n\n// cents -> \"5.49\" (schema.org Offer.price is a string in major currency units)\nfunction priceString(cents: number): string {\n\treturn (cents / 100).toFixed(2);\n}\n\nfunction variantOffer(variant: ItemVariant) {\n\treturn {\n\t\t\"@type\": \"Offer\",\n\t\tprice: priceString(variant.display_price ?? variant.price),\n\t\tpriceCurrency: \"USD\",\n\t};\n}\n\nfunction menuItemNode(item: MenuItem) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@type\": \"MenuItem\",\n\t\tname: item.name,\n\t};\n\tif (item.description) node.description = item.description;\n\t// one Offer for a single price, an array when the item has size/variant prices\n\tconst offers = item.variants.map(variantOffer);\n\tif (offers.length === 1) node.offers = offers[0];\n\telse if (offers.length > 1) node.offers = offers;\n\treturn node;\n}\n\n/**\n * Builds a standalone schema.org JSON-LD object for a single menu item, for\n * embedding in that item's `/menu/[slug]` page `<head>`. Reuses `menuItemNode`\n * (the same node shape used inside the Restaurant menu) and adds the\n * `@context` a top-level node needs. Pure — safe to run at build time.\n */\nexport function buildMenuItemJsonLd(item: MenuItem) {\n\treturn { \"@context\": \"https://schema.org\", ...menuItemNode(item) };\n}\n\n/**\n * Builds a schema.org Restaurant JSON-LD object from a prebuilt Menu and\n * franchise info. Categories become MenuSections; the items in each become\n * MenuItems. Pure — same inputs, same output — so it can run at prebuild time\n * and be embedded straight into the site's <head>.\n */\nexport function buildRestaurantJsonLd(menu: Menu, info: RestaurantInfo) {\n\tconst hasMenuSection = menu.categories.map((category) => {\n\t\tconst section: Record<string, unknown> = {\n\t\t\t\"@type\": \"MenuSection\",\n\t\t\tname: category.name,\n\t\t};\n\t\tconst items = menu.items\n\t\t\t.filter((item) => item.category_id === category.category_id)\n\t\t\t.map(menuItemNode);\n\t\tif (items.length) section.hasMenuItem = items;\n\t\treturn section;\n\t});\n\n\tconst restaurant: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\tname: info.name,\n\t};\n\tif (info.url) restaurant.url = info.url;\n\tif (info.image) restaurant.image = info.image;\n\tif (info.description) restaurant.description = info.description;\n\tif (info.servesCuisine?.length) restaurant.servesCuisine = info.servesCuisine;\n\n\t// A single-location franchise *is* its storefront, so its facts belong on\n\t// the Restaurant node. Several locations get referenced as branches instead\n\t// — the full branch nodes live on the location pages, not duplicated here.\n\tconst locations = info.locations ?? [];\n\tif (locations.length === 1) {\n\t\tObject.assign(restaurant, locationFacts(locations[0]!));\n\t} else if (locations.length > 1) {\n\t\trestaurant[\"@id\"] = restaurantId(info);\n\t\trestaurant.subOrganization = locations.map((location) => ({\n\t\t\t\"@type\": \"Restaurant\",\n\t\t\t\"@id\": branchId(info, location),\n\t\t\tname: location.name,\n\t\t}));\n\t}\n\n\trestaurant.hasMenu = {\n\t\t\"@type\": \"Menu\",\n\t\tname: \"Menu\",\n\t\thasMenuSection,\n\t};\n\n\treturn restaurant;\n}\n"],"mappings":"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","names":["Image"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/components/server/item-page.tsx","../../zorgo/universal/constants.ts","../../zorgo/universal/utils/menuSlugs.ts","../../zorgo/universal/utils/menuUtils.ts","../src/images.ts","../src/seo.ts"],"sourcesContent":["import type { Metadata } from \"next\";\nimport type { ReactNode } from \"react\";\nimport Image from \"next/image\";\nimport type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport { baseItemIdFromSlug, menuItemSlug } from \"../../slugs\";\nimport { ITEM_IMAGE_PREFIX, menuImageUrl } from \"../../images\";\nimport { buildMenuItemJsonLd } from \"../../seo\";\nimport { ClientItemForm } from \"../client/ItemForm/ClientItemForm\";\n\n// Single source of truth for resolving the item a slug points at — shared by\n// every export here so the lookup never drifts. Returns undefined for an\n// unknown slug; callers degrade gracefully (the page 404s downstream).\nfunction itemFromSlug(menu: Menu, slug: string) {\n\tconst baseItemId = baseItemIdFromSlug(slug);\n\treturn menu.items.find((item) => item.base_item_id === baseItemId);\n}\n\n/** generateStaticParams for /menu/[slug] — every item in the prebuilt menu. */\nexport function menuItemStaticParams(menu: Menu) {\n\treturn menu.items.map((item) => ({\n\t\tslug: menuItemSlug(item.name, item.base_item_id),\n\t}));\n}\n\n/**\n * generateMetadata for /menu/[slug]: per-item title/description/OG so each item\n * ranks on its own long-tail terms. title is just the item name; the root\n * layout's title template wraps it as `<name> | Brand`, and its metadataBase\n * resolves OG URLs, so nothing absolute is hardcoded here.\n */\nexport function menuItemMetadata(menu: Menu, slug: string): Metadata {\n\tconst item = itemFromSlug(menu, slug);\n\tif (!item) return {};\n\n\tconst description =\n\t\titem.description ?? `Order ${item.name} online for pickup or delivery.`;\n\tconst image = menuImageUrl(item.images?.[0], true);\n\n\treturn {\n\t\ttitle: item.name,\n\t\tdescription,\n\t\topenGraph: {\n\t\t\ttitle: item.name,\n\t\t\tdescription,\n\t\t\t// Next's typed OpenGraph union has no \"product\"; \"website\" is the\n\t\t\t// closest valid value for a standalone item page.\n\t\t\ttype: \"website\",\n\t\t\t...(image ? { images: [image] } : {}),\n\t\t},\n\t};\n}\n\n/**\n * The whole /menu/[slug] body, owned by the package so a package bump updates\n * every scaffolded site. Server Component: the hero image and JSON-LD are\n * resolved from the prebuilt menu at build time and land in the static HTML,\n * while the interactive half sits behind ClientItemForm.\n */\nexport function ItemCustomizationView({\n\tmenu,\n\tslug,\n\tchildren,\n}: {\n\tmenu: Menu;\n\tslug: string;\n\t/**\n\t * The interactive half. Defaults to the package's form; pass a site's own\n\t * client component to keep a custom customization UI and still get the\n\t * server-rendered hero image, JSON-LD, and metadata from here.\n\t */\n\tchildren?: ReactNode;\n}) {\n\tconst item = itemFromSlug(menu, slug);\n\tconst heroImageUrl = menuImageUrl(item?.images?.[0]);\n\n\treturn (\n\t\t<div className=\"p-8 md:p-16 max-w-4xl mx-auto flex flex-col gap-8\">\n\t\t\t{item && (\n\t\t\t\t<script\n\t\t\t\t\ttype=\"application/ld+json\"\n\t\t\t\t\tdangerouslySetInnerHTML={{\n\t\t\t\t\t\t__html: JSON.stringify(buildMenuItemJsonLd(item)),\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{heroImageUrl && (\n\t\t\t\t<Image\n\t\t\t\t\tsrc={heroImageUrl}\n\t\t\t\t\talt={item!.name}\n\t\t\t\t\twidth={1080}\n\t\t\t\t\theight={608}\n\t\t\t\t\tpriority\n\t\t\t\t\t// An image prebuild could not localize is served straight from\n\t\t\t\t\t// the bucket, where no width variants exist.\n\t\t\t\t\tunoptimized={!heroImageUrl.startsWith(ITEM_IMAGE_PREFIX)}\n\t\t\t\t\t// Full width up to the container's max-w-4xl (56rem/896px); the\n\t\t\t\t\t// loader snaps that to the 1080 rung and narrower screens to 640/384.\n\t\t\t\t\tsizes=\"(min-width: 896px) 896px, 100vw\"\n\t\t\t\t\tclassName=\"w-full aspect-video object-cover rounded-2xl bg-background-recessed\"\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{children ?? (\n\t\t\t\t<ClientItemForm baseItemId={baseItemIdFromSlug(slug)} />\n\t\t\t)}\n\t\t</div>\n\t);\n}\n","// Env first, hardcoded production second — the same shape POS and kiosk already\n// use in their own config/environment.ts. Both bundlers inline these by literal\n// name (Next NEXT_PUBLIC_*, Expo EXPO_PUBLIC_*), so the lookups have to stay\n// written out; a destructured or computed read is not substituted. Without this\n// the two vars zorgo_site/.env already declares were dead, and pointing any app\n// at a Supabase preview branch meant editing this file.\nexport const SUPABASE_URL =\n\tprocess.env.NEXT_PUBLIC_SUPABASE_URL ||\n\tprocess.env.EXPO_PUBLIC_SUPABASE_URL ||\n\t\"https://dhnpoxorllfdobwftkyb.supabase.co\";\n\nexport const SUPABASE_ANON_KEY =\n\tprocess.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||\n\tprocess.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ||\n\t\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRobnBveG9ybGxmZG9id2Z0a3liIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzkwNTQxMDgsImV4cCI6MjA1NDYzMDEwOH0.zI8PwbgKwijHZ1QmxJqmaPrFvg02w1vOY2Qcs7SddzI\";\nexport const ZORGO_PURPLE = \"rgb(153,67,233)\";\n\nexport const expoGoGoogleClientId =\n\t\"959654249401-dm6nbi27rj605ul1nkt8o2imb8f7daff.apps.googleusercontent.com\";\n\n// replace instances of API_BASE_URL with env enum\nexport enum Environments {\n\tProduction = \"https://api.zorgotech.com\",\n\tStaging = \"https://staging.zorgo.ai\",\n\tDevelopment = \"http://localhost:3000\",\n}\n\nexport const API_BASE_URL =\n\tprocess.env.NODE_ENV === \"production\"\n\t\t? Environments.Production\n\t\t: Environments.Development;\n\n/** Emails allowed to access developer-only metrics (e.g. /performance/developer). */\nexport const DEVELOPER_EMAILS = [\"clawsorgo@gmail.com\", \"gmandwee@gmail.com\"] as const;\n\nexport function isDeveloperEmail(email?: string | null): boolean {\n\treturn !!email && (DEVELOPER_EMAILS as readonly string[]).includes(email);\n}\n\nexport const ORDER_STORE_CONSTANTS = {\n\tDEFAULTS: {\n\t\tPREP_MINUTES: 20, // default amount of time for prep\n\t\tPAST_HOURS: 24, // hours into the past orders are fetched for\n\t\tFUTURE_HOURS: 72 // hours into the future orders are fetched for \n\t},\n\tORDER_STATUS: {\n\t\tSTAGING: 10,\n\t\tPENDING: 1,\n\t\tCOMPLETED: 2,\n\t\tCANCELLED: 4,\n\t\tPAID: 6,\n\t\tPARTIALLY_PAID: 7,\n\t\tKITCHEN_DONE: 9,\n\t\tCONFIRMED: 11,\n\t\tPREPARING: 12,\n\t\tREQUESTED: 13,\n\t},\n\tPOS_METHOD_ID: 3,\n\t/**\n\t * Internal QA accounts — their orders are excluded from dashboard order queries.\n\t * Matched on dev/QA emails (kcjayd@, clawsorgo@, gmandwee@, aaiden798@, *@zorgo.*),\n\t * the \"c d\" throwaway persona, and known test names (casey donegan, aiden alazo, …).\n\t * ponytail: hardcoded snapshot — new test accounts need re-running the query.\n\t * Promote to an `is_test` column on `customers` if this list keeps growing.\n\t */\n\tTEST_CUSTOMER_IDS: [\n\t\t85, 90, 91, 93, 95, 96, 97, 203, 238, 240, 241, 244, 245, 246, 317, 318, 319, 322,\n\t\t579, 580, 658, 1526, 2567, 2573, 2703, 2725, 2737, 2739, 2740, 2741, 2742, 2744,\n\t\t2746, 2747, 2749, 2751, 2752, 2753, 2754, 2755, 2767, 2768, 2778, 2779, 2780, 2781,\n\t\t2783, 2814, 2816, 2818, 2819, 2820, 2821, 2822, 2823, 2824, 2835, 2836, 2851, 2853,\n\t\t2859, 2871, 2882, 2883, 2887, 2888, 2889, 2891, 2892, 2893, 2894, 2895, 2896, 2897,\n\t\t2898, 2899, 2900, 2901, 2902, 2903, 2904, 2905, 2907, 2908, 2919, 2921, 2922, 2923,\n\t\t2924, 2925, 2926, 2927, 2946, 3017, 3018, 3022, 3024, 3025, 3026, 3027, 3028, 3029,\n\t\t3030, 3031, 3082, 3091, 3092, 3093,\n\t],\n\tORDER_TYPE: {\n\t\tDINE_IN: 1,\n\t\tTAKEOUT: 2,\n\t\tDELIVERY: 3\n\t},\n\t/** KDS edit highlight duration after a POS modification. */\n\tMODIFICATION_HIGHLIGHT_MS: 8_000,\n\t/** POS order realtime: refetch + recycle postgres_changes channel on this cadence. */\n\tLISTENER_SYNC_INTERVAL_MS: 60_000,\n\tORDER_SELECT: `\n\t*,\n\n\tcharges (\n\t\t*\n\t),\n\n\tlocations (name),\n\n\tcustomers\n\t (customer_id, first_name, last_name, phone_number, email),\n\n\torder_items\n\t (\n\t\torder_item_id, item_id, quantity, price_at_order, order_id, comments, status, applied_promo_id, discount_amount,\n\t\titems (\n\t\t\titem_id, name, price,\n\t\t\tportion_schemes (id, franchise_id, geometry, name, min_divisions, max_divisions),\n\t\t\tbase_items (\n\t\t\t\tbase_item_id, category_id, name,\n\t\t\t\tcategories (category_id, name, is_catering)\n\t\t\t)\n\t\t),\n\t\torder_item_selected_items (\n\t\t\torder_item_id, rule_id, item_id, quantity, upcharge_price, portion,\n\t\t\titems (\n\t\t\t\titem_id, name, price,\n\t\t\t\tbase_items (\n\t\t\t\t\tbase_item_id, category_id, name,\n\t\t\t\t\tcategories (category_id, name, is_catering)\n\t\t\t\t),\n\t\t\t\titem_components (\n\t\t\t\t\tservings_per_item,\n\t\t\t\t\tcomponent_quantity_behaviors (headcount_threshold, starting_amount, step_size, step_interval, maximum),\n\t\t\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit)\n\t\t\t\t)\n\t\t\t),\n\t\t\titem_rules (rule_id, group_name)\n\t\t),\n\t\torder_item_selected_components (\n\t\t\torder_item_id, rule_id, component_id, quantity, upcharge_price, portion,\n\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit), item_rules (rule_id, group_name)\n\t\t),\n\t\torder_item_components (\n\t\t\torder_item_id, component_id, quantity_servings, is_base, portion,\n\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit)\n\t\t)\n\t)\n`,\n};\n\nexport const CHARGE_STATUSES = {\n\tSUCCESS: 1,\n\tPENDING: 2,\n\tREFUNDED: 3,\n\tVOIDED: 4,\n\tPAYMENT_QUEUED: 5,\n\tFAILED: 6\n}\n","import { supabase } from \"./supabase\";\n\nexport interface MenuSlugParam {\n\tslug: string;\n}\n\nexport interface BaseMenuSlugRecord {\n\tbaseItemId: number;\n\tname: string;\n\tslug: string;\n\tdescription: string | null;\n}\n\nfunction requireApiKey(apiKey?: string): string {\n\tconst resolvedApiKey = apiKey ?? process.env.ZORGO_API_KEY;\n\tif (!resolvedApiKey) {\n\t\tthrow new Error(\"ZORGO_API_KEY is not set\");\n\t}\n\treturn resolvedApiKey;\n}\n\nexport function menuItemNameToSlug(name: string): string {\n\treturn name\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9\\s-]/g, \"\")\n\t\t.replace(/\\s+/g, \"-\")\n\t\t.trim();\n}\n\n/** Builds the `/menu/[slug]` route segment for an item, e.g. \"chicken-burrito-42\". */\nexport function menuItemSlug(name: string, baseItemId: number): string {\n\treturn `${menuItemNameToSlug(name)}-${baseItemId}`;\n}\n\n/** Parses the trailing numeric base_item_id off a `menuItemSlug` result. */\nexport function baseItemIdFromSlug(slug: string): number {\n\tconst match = slug.match(/-(\\d+)$/);\n\tif (!match) {\n\t\tthrow new Error(`No base_item_id found in slug: ${slug}`);\n\t}\n\treturn Number(match[1]);\n}\n\nexport function buildSlugParam(name: string): MenuSlugParam {\n\treturn { slug: menuItemNameToSlug(name) };\n}\n\nexport function buildSlugParamsFromNames(names: string[]): MenuSlugParam[] {\n\treturn names.map(buildSlugParam);\n}\n\nfunction assertUniqueSlugs(items: Array<{ name: string; slug: string }>): void {\n\tconst duplicates = new Map<string, string[]>();\n\n\tfor (const item of items) {\n\t\tconst matches = duplicates.get(item.slug) ?? [];\n\t\tmatches.push(item.name);\n\t\tduplicates.set(item.slug, matches);\n\t}\n\n\tconst conflicts = Array.from(duplicates.entries()).filter(\n\t\t([, names]) => names.length > 1,\n\t);\n\tif (conflicts.length === 0) return;\n\n\tconst message = conflicts\n\t\t.map(([slug, names]) => `\"${slug}\" => ${names.join(\", \")}`)\n\t\t.join(\"; \");\n\tthrow new Error(`Duplicate menu slugs detected: ${message}`);\n}\n\nasync function getFranchiseIdFromApiKey(apiKey?: string): Promise<number> {\n\tconst keyValue = requireApiKey(apiKey);\n\tconst { data, error } = await supabase\n\t\t.from(\"api_keys\")\n\t\t.select(\"franchise_id\")\n\t\t.eq(\"key_value\", keyValue)\n\t\t.eq(\"is_active\", true)\n\t\t.single();\n\n\tif (error || !data?.franchise_id) {\n\t\tthrow new Error(\"Trouble finding franchise for API key\");\n\t}\n\n\treturn data.franchise_id;\n}\n\nexport async function getBaseMenuSlugRecordsByApiKey(\n\tapiKey?: string,\n\toptions: { assertUniqueSlugs?: boolean } = {},\n): Promise<BaseMenuSlugRecord[]> {\n\tconst { assertUniqueSlugs: shouldAssertUniqueSlugs = true } = options;\n\tconst franchiseId = await getFranchiseIdFromApiKey(apiKey);\n\tconst { data, error } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id, name, description\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"base_item_id\", { ascending: true });\n\n\tif (error || !data) {\n\t\tthrow new Error(\"Trouble loading base menu items for franchise\");\n\t}\n\n\tconst records = data\n\t\t.filter((item) => typeof item.name === \"string\" && item.name.length > 0)\n\t\t.map((item) => ({\n\t\t\tbaseItemId: item.base_item_id,\n\t\t\tname: item.name,\n\t\t\tslug: menuItemNameToSlug(item.name),\n\t\t\tdescription: item.description ?? null,\n\t\t}));\n\n\tif (shouldAssertUniqueSlugs) {\n\t\tassertUniqueSlugs(records);\n\t}\n\treturn records;\n}\n\nexport async function getBaseMenuSlugParamsByApiKey(\n\tapiKey?: string,\n): Promise<MenuSlugParam[]> {\n\tconst records = await getBaseMenuSlugRecordsByApiKey(apiKey);\n\treturn records.map(({ slug }) => ({ slug }));\n}\n\nexport async function getBaseMenuSlugRecordBySlug(\n\tslug: string,\n\tapiKey?: string,\n): Promise<BaseMenuSlugRecord | null> {\n\tconst records = await getBaseMenuSlugRecordsByApiKey(apiKey);\n\treturn records.find((record) => record.slug === slug) ?? null;\n}\n","import { SupabaseClient } from \"@supabase/supabase-js\";\nimport { getDay, isAfter, isBefore, parse, startOfDay } from \"date-fns\";\nimport { SUPABASE_URL } from \"../constants\";\nimport {\n\tBaseItem,\n\tBaseItemImage,\n\tCategory,\n\tComponent,\n\tItemVariant,\n\tMenuItem,\n\tPlatforms,\n\tPromotion,\n\tRule,\n\tVisibilityConditionGroupAssignment,\n\tVisibilityEffect,\n} from \"../interfaces\";\nimport { csvFormat } from \"./misc\";\nimport {\n\tbuildVisibilityEvaluationContext,\n\tevaluateVisibilityCondition,\n} from \"./visibilityConditions\";\n\nexport interface Menu {\n\titems: MenuItem[];\n\tcategories: Category[];\n\tcomponents: Component[];\n\trules: Rule[];\n}\n\nexport interface MenuIds {\n\tbaseItemIds: number[];\n\tcategoryIds: number[];\n\titemIds: number[];\n}\n\ntype MenuIdRows = {\n\tcategories: Pick<Category, \"category_id\">[];\n\tbaseItems: Pick<BaseItem, \"base_item_id\" | \"category_id\">[];\n\titems: Pick<ItemVariant, \"item_id\" | \"base_item_id\">[];\n};\n\n/**\n * Menu display order: curated `sort_index` first, unset rows after it.\n *\n * `nullsFirst: false` is the load-bearing half. A franchise pins only the few\n * things it cares about and leaves everything else NULL, so nulls have to fall\n * to the bottom where the tiebreak column (name, or price for variants) sorts\n * them — otherwise the unpinned majority would sit above the curated rows.\n */\nconst ORDER_CURATED = { ascending: true, nullsFirst: false } as const;\n\n/** Sorts last, so an unset sort_index yields to whatever the caller tiebreaks on. */\nconst UNSORTED = Number.MAX_SAFE_INTEGER;\n\n/**\n * The in-memory half of ORDER_CURATED, for rows Postgres can't order for us: a\n * rule's options are one list to the customer but live in two tables\n * (rule_items, rule_components), and their display names sit a join further out\n * on items/components, which PostgREST can't order an embed by.\n */\nfunction byCuratedThenName(\n\ta: { sort_index?: number | null; name: string },\n\tb: { sort_index?: number | null; name: string },\n): number {\n\tconst delta = (a.sort_index ?? UNSORTED) - (b.sort_index ?? UNSORTED);\n\treturn delta !== 0 ? delta : a.name.localeCompare(b.name);\n}\n\nexport async function getMenu(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texclusions: MenuIds = emptyMenuExclusions(),\n\tuseBehaviors = false,\n): Promise<Menu> {\n\tconst categories = await getCategories(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\texclusions.categoryIds,\n\t);\n\n\tif (categories.length === 0) {\n\t\treturn buildMenu([], [], [], [], []);\n\t}\n\n\tconst baseItems = await getBaseItems(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\texclusions.baseItemIds,\n\t\tcategories,\n\t);\n\n\tif (baseItems.length === 0) {\n\t\treturn buildMenu([], [], categories, [], []);\n\t}\n\n\tconst items = await getItems(supabase, baseItems, exclusions.itemIds);\n\tconst components = await getComponents(supabase, items, useBehaviors);\n\tconst rules = await getRules(supabase, baseItems, categories, items, useBehaviors);\n\n\treturn buildMenu(items, baseItems, categories, components, rules);\n}\n\nexport async function getCateringBaseItemIds(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n): Promise<number[]> {\n\tconst { data: categories } = await supabase\n\t\t.from(\"categories\")\n\t\t.select(\"category_id\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.eq(\"is_catering\", true)\n\t\t.throwOnError();\n\n\tif (!categories || categories.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst { data } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.in(\"category_id\", categories.map((category) => category.category_id))\n\t\t.throwOnError();\n\treturn data.map((row) => row.base_item_id);\n}\n\n/**\n * Attaches targeted promotions to menu cards without mutating the menu.\n * Order-wide promos intentionally stay out of menu display pricing.\n */\nexport function fitPromosToMenu(\n\tmenu: Menu,\n\tactivePromotions: Promotion[],\n): Menu {\n\tconst categoryPromoIds = new Map<number, number>();\n\tconst getDisplayPrice = (price: number, promo: Promotion) => {\n\t\tif (promo.promotion_type_id === 1) {\n\t\t\treturn Math.max(0, Math.floor(price * (1 - promo.discount_value / 100)));\n\t\t}\n\t\tif (promo.promotion_type_id === 2) {\n\t\t\treturn Math.max(0, price - promo.discount_value);\n\t\t}\n\t\treturn price;\n\t};\n\n\tconst items = menu.items.map((item) => {\n\t\t// Bulk discount promos (quantity-dependent sigmoid) attach to the item but never set a fixed display_price\n\t\tconst bulkPromo = activePromotions.find((promo) => {\n\t\t\tif (!promo.is_active || promo.id == null || !promo.discount_target_ids?.length || !promo.min_purchase_quantity) return false;\n\t\t\tconst targets = promo.discount_target_ids;\n\t\t\treturn (\n\t\t\t\t(promo.discount_target_type === \"category\" && targets.includes(item.category_id)) ||\n\t\t\t\t(promo.discount_target_type === \"item\" && targets.includes(item.base_item_id))\n\t\t\t);\n\t\t});\n\n\t\tlet itemPromoId: number | undefined = bulkPromo?.id;\n\n\t\tconst variants = item.variants.map((variant) => {\n\t\t\tlet bestPromo: Promotion | undefined;\n\t\t\tlet bestDisplayPrice = variant.price;\n\n\t\t\tfor (const promo of activePromotions) {\n\t\t\t\tif (\n\t\t\t\t\t!promo.is_active ||\n\t\t\t\t\tpromo.id == null ||\n\t\t\t\t\t!promo.discount_target_ids?.length ||\n\t\t\t\t\tpromo.min_purchase_amount || // order-level threshold — shown via modal, not baked into menu price\n\t\t\t\t\tpromo.min_purchase_quantity // quantity-dependent — handled above, no fixed display_price\n\t\t\t\t) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst targets = promo.discount_target_ids;\n\t\t\t\tconst applies =\n\t\t\t\t\t(promo.discount_target_type === \"category\" &&\n\t\t\t\t\t\ttargets.includes(item.category_id)) ||\n\t\t\t\t\t(promo.discount_target_type === \"item\" &&\n\t\t\t\t\t\ttargets.includes(item.base_item_id)) ||\n\t\t\t\t\t(promo.discount_target_type === \"item_variant\" &&\n\t\t\t\t\t\ttargets.includes(variant.item_id));\n\n\t\t\t\tif (!applies) continue;\n\n\t\t\t\tconst displayPrice = getDisplayPrice(variant.price, promo);\n\t\t\t\tif (displayPrice < bestDisplayPrice) {\n\t\t\t\t\tbestPromo = promo;\n\t\t\t\t\tbestDisplayPrice = displayPrice;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!bestPromo || bestDisplayPrice >= variant.price) {\n\t\t\t\treturn variant;\n\t\t\t}\n\n\t\t\titemPromoId ??= bestPromo.id!;\n\t\t\tif (bestPromo.discount_target_type === \"category\") {\n\t\t\t\tcategoryPromoIds.set(item.category_id, bestPromo.id!);\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\t...variant,\n\t\t\t\tactive_promo_id: bestPromo.id!,\n\t\t\t\tdisplay_price: bestDisplayPrice,\n\t\t\t};\n\t\t});\n\n\t\treturn { ...item, variants, active_promo_id: itemPromoId };\n\t});\n\n\tconst categories = menu.categories.map((category) => {\n\t\tconst { active_promo_id, ...cleanCategory } = category;\n\t\tvoid active_promo_id;\n\n\t\tconst promoId = categoryPromoIds.get(cleanCategory.category_id);\n\n\t\treturn promoId == null\n\t\t\t? cleanCategory\n\t\t\t: { ...cleanCategory, active_promo_id: promoId };\n\t});\n\n\treturn {\n\t\t...menu,\n\t\titems,\n\t\tcategories,\n\t};\n}\n\nexport async function getMenuExclusions(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tlocationId: number,\n\tplatform: Platforms,\n): Promise<MenuIds> {\n\tvoid locationId;\n\n\tconst menuIds = await getMenuIds(supabase, franchiseId);\n\tconst activeAssignments = await getActiveVisibilityAssignments(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\tplatform,\n\t);\n\n\tif (activeAssignments.length === 0) {\n\t\treturn emptyMenuExclusions();\n\t}\n\n\tconst categoryEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.category_id,\n\t);\n\tconst baseItemEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.base_item_id,\n\t);\n\tconst itemEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.item_id,\n\t);\n\n\tconst baseItemById = new Map(\n\t\tmenuIds.baseItems.map((baseItem) => [baseItem.base_item_id, baseItem]),\n\t);\n\tconst itemById = new Map(menuIds.items.map((item) => [item.item_id, item]));\n\n\tconst isCategoryIncluded = (categoryId: number) =>\n\t\tcategoryEffects.get(categoryId) !== \"exclude\";\n\n\tconst isBaseItemIncluded = (baseItemId: number) => {\n\t\tconst effect = baseItemEffects.get(baseItemId);\n\t\tif (effect === \"include\") return true;\n\t\tif (effect === \"exclude\") return false;\n\n\t\tconst categoryId = baseItemById.get(baseItemId)?.category_id;\n\t\treturn categoryId == null || isCategoryIncluded(categoryId);\n\t};\n\n\tconst isItemIncluded = (itemId: number): boolean => {\n\t\tconst effect = itemEffects.get(itemId);\n\t\tif (effect === \"include\") return true;\n\t\tif (effect === \"exclude\") return false;\n\n\t\tconst item = itemById.get(itemId);\n\t\tif (item?.base_item_id == null) return true;\n\n\t\treturn isBaseItemIncluded(item.base_item_id);\n\t};\n\n\tconst includedItems = new Set(\n\t\tmenuIds.items\n\t\t\t.filter((item) => isItemIncluded(item.item_id))\n\t\t\t.map((item) => item.item_id),\n\t);\n\tconst includedBaseItems = new Set(\n\t\tmenuIds.baseItems\n\t\t\t.filter((baseItem) => isBaseItemIncluded(baseItem.base_item_id))\n\t\t\t.map((baseItem) => baseItem.base_item_id),\n\t);\n\n\tfor (const item of menuIds.items) {\n\t\tif (includedItems.has(item.item_id) && item.base_item_id != null) {\n\t\t\tincludedBaseItems.add(item.base_item_id);\n\t\t}\n\t}\n\n\tconst includedCategories = new Set(\n\t\tmenuIds.categories\n\t\t\t.filter((category) => isCategoryIncluded(category.category_id))\n\t\t\t.map((category) => category.category_id),\n\t);\n\n\tfor (const baseItem of menuIds.baseItems) {\n\t\tif (includedBaseItems.has(baseItem.base_item_id)) {\n\t\t\tincludedCategories.add(baseItem.category_id);\n\t\t}\n\t}\n\n\treturn {\n\t\tcategoryIds: menuIds.categories\n\t\t\t.map((category) => category.category_id)\n\t\t\t.filter((categoryId) => !includedCategories.has(categoryId)),\n\t\tbaseItemIds: menuIds.baseItems\n\t\t\t.map((baseItem) => baseItem.base_item_id)\n\t\t\t.filter((baseItemId) => !includedBaseItems.has(baseItemId)),\n\t\titemIds: menuIds.items\n\t\t\t.map((item) => item.item_id)\n\t\t\t.filter((itemId) => !includedItems.has(itemId)),\n\t};\n}\n\nexport function getNameFromId(\n\tid: number,\n\ttype: \"category\" | \"item\" | \"item_variant\",\n\tmenu: Partial<Menu>,\n): string {\n\tif (type === \"category\") {\n\t\tconst category = menu.categories?.find(\n\t\t\t(category) => category.category_id === id,\n\t\t);\n\n\t\tif (category?.name) {\n\t\t\treturn category.name;\n\t\t}\n\t} else if (type === \"item\") {\n\t\tconst item = menu.items?.find((item: any) => item.base_item_id === id);\n\n\t\tif (item?.name) {\n\t\t\treturn item.name;\n\t\t}\n\t} else if (type === \"item_variant\") {\n\t\tconst item = menu.items?.find((item: any) =>\n\t\t\titem.variants.find((variant: any) => variant.item_id === id),\n\t\t);\n\n\t\tif (item?.name) {\n\t\t\treturn item.name;\n\t\t}\n\t}\n\n\treturn id.toString(); // default to showing the item id\n}\n\nfunction emptyMenuExclusions(): MenuIds {\n\treturn {\n\t\tcategoryIds: [],\n\t\tbaseItemIds: [],\n\t\titemIds: [],\n\t};\n}\n\nasync function getMenuIds(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n): Promise<MenuIdRows> {\n\tconst { data: categories, error: categoriesError } = await supabase\n\t\t.from(\"categories\")\n\t\t.select(\"category_id\")\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (categoriesError) {\n\t\tthrow categoriesError;\n\t}\n\n\tconst { data: baseItems, error: baseItemsError } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id, category_id\")\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (baseItemsError) {\n\t\tthrow baseItemsError;\n\t}\n\n\tconst baseItemIds = (baseItems ?? []).map((item) => item.base_item_id);\n\tconst { data: items, error: itemsError } =\n\t\tbaseItemIds.length > 0\n\t\t\t? await supabase\n\t\t\t\t.from(\"items\")\n\t\t\t\t.select(\"item_id, base_item_id\")\n\t\t\t\t.in(\"base_item_id\", baseItemIds)\n\t\t\t: { data: [], error: null };\n\n\tif (itemsError) {\n\t\tthrow itemsError;\n\t}\n\n\treturn {\n\t\tcategories: categories ?? [],\n\t\tbaseItems: baseItems ?? [],\n\t\titems: items ?? [],\n\t};\n}\n\nasync function getActiveVisibilityAssignments(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tplatform: Platforms,\n): Promise<VisibilityConditionGroupAssignment[]> {\n\tconst { data, error } = await supabase\n\t\t.from(\"visibility_condition_group_assignments\")\n\t\t.select(\"*, visibility_condition_groups (*)\")\n\t\t.eq(\"visibility_condition_groups.franchise_id\", franchiseId)\n\t\t.eq(\"visibility_condition_groups.platform\", platform);\n\n\tif (error || !data) {\n\t\treturn [];\n\t}\n\n\tconst context = buildVisibilityEvaluationContext({ platform });\n\n\treturn (data as VisibilityConditionGroupAssignment[]).filter((assignment) => {\n\t\tconst group = assignment.visibility_condition_groups;\n\n\t\tif (!group?.condition || group.platform !== context.platform) {\n\t\t\treturn false;\n\t\t}\n\n\t\ttry {\n\t\t\treturn evaluateVisibilityCondition(group.condition, context);\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t});\n}\n\nfunction buildTargetEffectMap(\n\tassignments: VisibilityConditionGroupAssignment[],\n\tselector: (\n\t\tassignment: VisibilityConditionGroupAssignment,\n\t) => number | null | undefined,\n): Map<number, VisibilityEffect> {\n\tconst effectsByTarget = new Map<number, VisibilityEffect[]>();\n\n\tfor (const assignment of assignments) {\n\t\tconst targetId = selector(assignment);\n\t\tif (targetId == null) continue;\n\n\t\teffectsByTarget.set(targetId, [\n\t\t\t...(effectsByTarget.get(targetId) ?? []),\n\t\t\tassignment.effect,\n\t\t]);\n\t}\n\n\treturn new Map(\n\t\tArray.from(effectsByTarget.entries()).map(([targetId, effects]) => [\n\t\t\ttargetId,\n\t\t\teffects.includes(\"exclude\") ? \"exclude\" : \"include\",\n\t\t]),\n\t);\n}\n\nasync function getCategories(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texcludedIds?: number[],\n): Promise<Category[]> {\n\tlet categoriesQuery = supabase\n\t\t.from(\"categories\")\n\t\t.select(\"*\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"name\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\tcategoriesQuery = categoriesQuery.not(\n\t\t\t\"category_id\",\n\t\t\t\"in\",\n\t\t\tcsvFormat(excludedIds),\n\t\t);\n\t}\n\n\tconst { data: categories, error: categoriesError } = await categoriesQuery;\n\n\tif (categoriesError) {\n\t\tthrow categoriesError;\n\t}\n\n\treturn categories ?? [];\n}\n\nasync function getBaseItems(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texcludedIds?: number[],\n\tcategories?: Category[],\n): Promise<BaseItem[]> {\n\tif (categories && categories.length === 0) {\n\t\treturn [];\n\t}\n\n\tlet baseItemsQuery = supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"*\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"name\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\tbaseItemsQuery = baseItemsQuery.not(\n\t\t\t\"base_item_id\",\n\t\t\t\"in\",\n\t\t\tcsvFormat(excludedIds),\n\t\t);\n\t}\n\n\tif (categories) {\n\t\tbaseItemsQuery = baseItemsQuery.in(\n\t\t\t\"category_id\",\n\t\t\tcategories.map((category) => category.category_id),\n\t\t);\n\t}\n\n\tconst { data: baseItems, error: baseItemsError } = await baseItemsQuery;\n\n\tif (baseItemsError) {\n\t\tthrow baseItemsError;\n\t}\n\n\tif (!baseItems || baseItems.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst { data: images, error: imagesError } = await supabase\n\t\t.from(\"base_item_images\")\n\t\t.select(\"*\")\n\t\t.in(\n\t\t\t\"base_item_id\",\n\t\t\tbaseItems.map((baseItem) => baseItem.base_item_id),\n\t\t)\n\t\t.order(\"sort_index\", { ascending: true })\n\t\t.order(\"image_id\", { ascending: true });\n\n\tif (imagesError) {\n\t\tthrow imagesError;\n\t}\n\n\treturn attachImages(baseItems, images ?? []);\n}\n\n/**\n * Group images onto their base items. `images` must already be in the order\n * callers should see them (sort_index, then image_id) — grouping preserves it,\n * so every render site can take `images[0]` as the primary image.\n */\nexport function attachImages<T extends { base_item_id: number }>(\n\tbaseItems: T[],\n\timages: BaseItemImage[],\n): Array<T & { images: BaseItemImage[] }> {\n\tconst byBaseItemId = new Map<number, BaseItemImage[]>();\n\tfor (const image of images) {\n\t\tconst group = byBaseItemId.get(image.base_item_id);\n\t\tif (group) group.push(image);\n\t\telse byBaseItemId.set(image.base_item_id, [image]);\n\t}\n\n\treturn baseItems.map((baseItem) => ({\n\t\t...baseItem,\n\t\timages: byBaseItemId.get(baseItem.base_item_id) ?? [],\n\t}));\n}\n\nexport function menuImageUrl(\n\timage?: BaseItemImage | null,\n\tabsolute = false,\n): string | null {\n\tif (!image) return null;\n\tif (image.local_key && !absolute) return `/images/items/${image.local_key}`;\n\tconst path = image.processed_path ?? image.raw_path;\n\tif (!path) return null;\n\treturn `${SUPABASE_URL}/storage/v1/object/public/product-images/${path}`;\n}\n\nasync function getItems(\n\tsupabase: SupabaseClient,\n\tbaseItems: BaseItem[],\n\texcludedIds?: number[],\n): Promise<ItemVariant[]> {\n\tif (baseItems && baseItems.length === 0) {\n\t\treturn [];\n\t}\n\n\t// the scheme rides along so the prebuilt menu carries it — no runtime fetch\n\t// on the item page just to know how a pizza can be cut\n\t//\n\t// Price, not name, is the tiebreak: variants are usually sizes, and sorting\n\t// those alphabetically reads \"Large, Medium, Small\".\n\tlet itemsQuery = supabase\n\t\t.from(\"items\")\n\t\t.select(\"*, portion_scheme:portion_schemes(*)\")\n\t\t.in(\n\t\t\t\"base_item_id\",\n\t\t\tbaseItems.map((baseItem) => baseItem.base_item_id),\n\t\t)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"price\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\titemsQuery = itemsQuery.not(\"item_id\", \"in\", csvFormat(excludedIds));\n\t}\n\n\tconst { data: items, error: itemsError } = await itemsQuery;\n\n\tif (itemsError) {\n\t\tthrow itemsError;\n\t}\n\n\treturn items ?? [];\n}\n\nexport async function getComponents(\n\tsupabase: SupabaseClient,\n\titems: ItemVariant[] | number[],\n\tuseBehaviors = false,\n): Promise<Component[]> {\n\tconst itemIds = items.length > 0 && typeof items[0] === \"number\"\n\t\t? (items as number[])\n\t\t: (items as ItemVariant[]).map((item) => item.item_id);\n\n\tif (itemIds.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst select = useBehaviors\n\t\t? \"components(*), item_id, servings_per_item, behavior_id, component_quantity_behaviors(headcount_threshold, starting_amount, step_size, step_interval, maximum)\"\n\t\t: \"components(*), item_id, servings_per_item\";\n\n\tconst { data: components } = await supabase\n\t\t.from(\"item_components\")\n\t\t.select(select)\n\t\t.in(\"item_id\", itemIds)\n\t\t.throwOnError();\n\n\treturn components.map((component: any) => ({\n\t\tservings_per_item: component.servings_per_item,\n\t\tis_base: component.components?.is_base,\n\t\tserving_weight: component.components?.serving_weight,\n\t\tcomponent_type_id: component.components?.component_type_id,\n\t\tid: component.components?.id,\n\t\tname: component.components?.name,\n\t\titem_id: component.item_id,\n\t\t...(useBehaviors && { behavior: component.component_quantity_behaviors ?? undefined }),\n\t}));\n}\n\n/**\n * A rule's options — components and items — as one keyed list. The key is what\n * `portionKey` builds on and what `weightedUpcharges` prices, so the picker and\n * the server's repricing read option prices through this same function.\n *\n * The two tables share one `sort_index` sequence, so a rule that mixes them\n * interleaves rather than always putting every component ahead of every item.\n */\nexport function ruleOptionDefs(rule: Rule) {\n\treturn [\n\t\t...(rule.components ?? []).map((c) => ({\n\t\t\tkey: `component:${c.component_id}`,\n\t\t\tid: c.component_id,\n\t\t\ttype: \"component\" as const,\n\t\t\tname: c.name,\n\t\t\tbasePrice: c.upcharge_price || 0,\n\t\t\tdescription: c.description,\n\t\t\tsort_index: c.sort_index ?? null,\n\t\t})),\n\t\t...(rule.items ?? []).map((i) => ({\n\t\t\tkey: `item:${i.item_id}`,\n\t\t\tid: i.item_id,\n\t\t\ttype: \"item\" as const,\n\t\t\tname: i.name,\n\t\t\tbasePrice: i.upcharge_price || 0,\n\t\t\tdescription: i.description,\n\t\t\tsort_index: i.sort_index ?? null,\n\t\t})),\n\t].sort(byCuratedThenName);\n}\n\n/**\n * Rules attach at item, base-item, or category level; `ruleIds` fetches them by\n * primary key instead, for when you already hold the ids (repricing an order's\n * selections server-side).\n */\nexport async function getRules(\n\tsupabase: SupabaseClient,\n\tbaseItems?: BaseItem[],\n\tcategories?: Category[],\n\titems?: ItemVariant[],\n\tuseBehaviors = false,\n\truleIds?: number[],\n): Promise<Rule[]> {\n\tconst itemIds = items?.map((item) => item.item_id) ?? [];\n\tconst baseItemIds = baseItems?.map((baseItem) => baseItem.base_item_id) ?? [];\n\tconst categoryIds = categories?.map((category) => category.category_id) ?? [];\n\n\tif (\n\t\titemIds.length === 0 &&\n\t\tbaseItemIds.length === 0 &&\n\t\tcategoryIds.length === 0 &&\n\t\t!ruleIds?.length\n\t) {\n\t\treturn [];\n\t}\n\n\tconst orFilter = [\n\t\t`item_id.in.(${itemIds.length > 0 ? itemIds.join(\",\") : -1})`,\n\t\t`base_item_id.in.(${baseItemIds.length > 0 ? baseItemIds.join(\",\") : -1})`,\n\t\t`category_id.in.(${categoryIds.length > 0 ? categoryIds.join(\",\") : -1})`,\n\t\t...(ruleIds?.length ? [`rule_id.in.(${ruleIds.join(\",\")})`] : []),\n\t].join(\",\");\n\n\tconst behaviorSelect = useBehaviors\n\t\t? \"component_quantity_behaviors(headcount_threshold, starting_amount, step_size, step_interval, maximum)\"\n\t\t: null;\n\n\tconst ruleComponentsSelect = [\n\t\t\"component_id, upcharge_price, sort_index, components(name, description, serving_weight)\",\n\t\tbehaviorSelect,\n\t].filter(Boolean).join(\", \");\n\n\tconst ruleItemsSelect = [\n\t\t\"item_id, upcharge_price, sort_index, items(item_id, name, description)\",\n\t\tbehaviorSelect,\n\t].filter(Boolean).join(\", \");\n\n\tconst { data: ruleData, error: rulesError } = await supabase\n\t\t.from(\"item_rules\")\n\t\t.select(\n\t\t\t`\n rule_id,\n category_id,\n item_id,\n base_item_id,\n group_name,\n min_selection,\n max_selection,\n included_quantity,\n is_portionable,\n sort_index,\n rule_components (${ruleComponentsSelect}),\n rule_items (${ruleItemsSelect})\n `,\n\t\t)\n\t\t.or(orFilter)\n\t\t// rule_id, not group_name: alphabetical rule groups (\"Choose a protein\"\n\t\t// before \"Select two sides\") is noise, so an unpinned rule falls back to\n\t\t// the order it was created in.\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"rule_id\", ORDER_CURATED);\n\n\tif (rulesError) {\n\t\tthrow rulesError;\n\t}\n\n\treturn (ruleData ?? []).map((rule: any) => ({\n\t\trule_id: rule.rule_id,\n\t\tgroup_name: rule.group_name,\n\t\tmin_selection: rule.min_selection,\n\t\tmax_selection: rule.max_selection,\n\t\tincluded_quantity: rule.included_quantity,\n\t\tis_portionable: rule.is_portionable,\n\t\tcategory_id: rule.category_id,\n\t\titem_id: rule.item_id,\n\t\tbase_item_id: rule.base_item_id,\n\t\tsort_index: rule.sort_index ?? null,\n\n\t\t// Sorted here rather than in the query because the display name lives on\n\t\t// the joined components/items row. Every caller that renders these arrays\n\t\t// straight — the legacy customization modals, a site's server-rendered\n\t\t// item page — inherits the order without touching its own code.\n\t\tcomponents: (rule.rule_components ?? [])\n\t\t\t.map((component: any) => ({\n\t\t\t\tcomponent_id: component.component_id,\n\t\t\t\tupcharge_price: component.upcharge_price ?? 0,\n\t\t\t\tname: component.components?.name ?? \"\",\n\t\t\t\tdescription: component.components?.description ?? undefined,\n\t\t\t\tserving_weight: component.components?.serving_weight ?? undefined,\n\t\t\t\tsort_index: component.sort_index ?? null,\n\t\t\t\t...(useBehaviors && { behavior: component.component_quantity_behaviors ?? undefined }),\n\t\t\t}))\n\t\t\t.sort(byCuratedThenName),\n\n\t\titems: (rule.rule_items ?? [])\n\t\t\t.map((item: any) => ({\n\t\t\t\titem_id: item.item_id,\n\t\t\t\tname: item.items?.name ?? \"\",\n\t\t\t\tdescription: item.items?.description ?? undefined,\n\t\t\t\tupcharge_price: item.upcharge_price ?? 0,\n\t\t\t\tsort_index: item.sort_index ?? null,\n\t\t\t\t...(useBehaviors && { behavior: item.component_quantity_behaviors ?? undefined }),\n\t\t\t}))\n\t\t\t.sort(byCuratedThenName),\n\t}));\n}\n\nfunction buildMenu(\n\titems: ItemVariant[],\n\tbaseItems: BaseItem[],\n\tcategories: Category[],\n\tcomponents: Component[],\n\trules: Rule[],\n): Menu {\n\tconst menu: Menu = {\n\t\titems: [],\n\t\tcategories: [],\n\t\trules,\n\t\tcomponents,\n\t};\n\n\tmenu.items = baseItems.map((baseItem) => {\n\t\tconst categoryRules = rules.filter((rule) => rule.category_id === baseItem.category_id);\n\t\tconst baseItemRules = rules.filter((rule) => rule.base_item_id === baseItem.base_item_id);\n\t\tconst category = categories.find((c) => c.category_id === baseItem.category_id);\n\t\treturn {\n\t\t\t...baseItem,\n\t\t\tis_catering: category?.is_catering ?? false,\n\t\t\tvariants: items\n\t\t\t\t.filter((item) => item.base_item_id === baseItem.base_item_id)\n\t\t\t\t.map((item) => ({\n\t\t\t\t\t...item,\n\t\t\t\t\tcomponents: components.filter(\n\t\t\t\t\t\t(component) => component.item_id === item.item_id,\n\t\t\t\t\t),\n\t\t\t\t\trules: [\n\t\t\t\t\t\t...categoryRules,\n\t\t\t\t\t\t...baseItemRules,\n\t\t\t\t\t\t...rules.filter((rule) => rule.item_id === item.item_id),\n\t\t\t\t\t],\n\t\t\t\t})),\n\t\t\trules: baseItemRules,\n\t\t};\n\t});\n\n\tmenu.categories = categories.map((category) => ({\n\t\t...category,\n\t\trules: rules.filter((rule) => rule.category_id === category.category_id),\n\t}));\n\n\treturn menu;\n}\n\nexport function getBaseItemFromVariant(\n\tmenu: MenuItem[],\n\tvariant: ItemVariant,\n\tbaseItemId?: number,\n): MenuItem | undefined {\n\tif (baseItemId != null) {\n\t\treturn menu.find((i) => i.base_item_id === baseItemId);\n\t}\n\treturn menu.find((i) => i.variants.some((v) => v.item_id === variant.item_id));\n}\n\nexport function getMenuItemByVariantId(menu: MenuItem[], variantId: number): MenuItem | undefined {\n\treturn menu.find((item) =>\n\t\titem.variants?.some((v) => v.item_id === variantId)\n\t);\n}\n\nexport function filterBySchedule(\n\tpromos: Promotion[],\n\tplatform: Platforms,\n\tlocationId: number | undefined,\n\tnow: Date = new Date(),\n): Promotion[] {\n\treturn promos.filter((promo) => {\n\t\tif (promo.start_date && new Date(promo.start_date) > now) return false;\n\t\tif (promo.end_date && new Date(promo.end_date) < now) return false;\n\n\t\tif (promo.platforms && promo.platforms.length > 0) {\n\t\t\tif (!promo.platforms.includes(platform)) return false;\n\t\t}\n\n\t\tif (!promo.is_automatic) return false;\n\n\t\tif (promo.location_id != null && locationId !== undefined) {\n\t\t\tif (promo.location_id !== locationId) return false;\n\t\t} else if (promo.location_id != null && locationId === undefined) {\n\t\t\treturn false;\n\t\t}\n\n\t\tif (promo.schedule_details_json) {\n\t\t\tconst { interval, days, start_time, end_time } =\n\t\t\t\tpromo.schedule_details_json;\n\n\t\t\tif (interval === \"weekly\" && days && !days.includes(getDay(now) + 1)) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tconst dayStart = startOfDay(now);\n\t\t\tif (start_time && isBefore(now, parse(start_time, \"HH:mm\", dayStart))) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (end_time && isAfter(now, parse(end_time, \"HH:mm\", dayStart))) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t});\n}\n\nexport async function getActivePromotions(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tplatform: Platforms,\n\tlocationId?: number,\n): Promise<Promotion[]> {\n\tconst { data, error } = await supabase\n\t\t.from(\"promotions\")\n\t\t.select(\"*\")\n\t\t.eq(\"is_active\", true)\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (error) throw error;\n\n\treturn filterBySchedule((data || []) as Promotion[], platform, locationId);\n}\n","export { menuImageUrl } from \"@zorgo/universal/utils/menuUtils\";\nexport type { BaseItemImage } from \"@zorgo/universal/interfaces\";\n\n/**\n * The variant widths prebuild emits, for menu item images and site-authored\n * photos alike. The loader below and the encoder in internal/images.ts read the\n * same list, so a requested width can never snap to a rung that was never\n * written. 1920 is the top rung because a full-bleed hero on a retina laptop\n * asks for it; `withoutEnlargement` means a smaller source just stops early.\n */\nexport const IMAGE_WIDTHS = [384, 640, 1080, 1920] as const;\n\n/** @deprecated The ladder is no longer item-only — use {@link IMAGE_WIDTHS}. */\nexport const ITEM_IMAGE_WIDTHS = IMAGE_WIDTHS;\n\n/** Public prefix for everything prebuild encodes. */\nexport const IMAGE_PREFIX = \"/images/\";\n\n/** Public prefix menuImageUrl returns for a localized item image. */\nexport const ITEM_IMAGE_PREFIX = \"/images/items/\";\n\n/**\n * next/image custom loader (`images.loaderFile` in a scaffolded site's\n * next.config.ts).\n *\n * An extension-less path under `/images/` is prebuild's contract for \"this has\n * a variant ladder\" — it gets the nearest rung that covers the requested width.\n * That covers both menu item images (`/images/items/<key>`) and the site's own\n * photos (`/images/food`, encoded from `assets/images/food.JPG`).\n *\n * Everything else passes through untouched and is served as-is: a bucket URL\n * for an item prebuild could not localize, the prebuilt logo, a video poster —\n * anything that still carries a file extension.\n *\n * Sync and pure by contract: next/image calls it during render for every srcset\n * candidate.\n */\nexport default function imageLoader({\n\tsrc,\n\twidth,\n}: {\n\tsrc: string;\n\twidth: number;\n}): string {\n\tif (!src.startsWith(IMAGE_PREFIX) || /\\.[a-z0-9]+$/i.test(src)) return src;\n\tconst rung =\n\t\tIMAGE_WIDTHS.find((candidate) => candidate >= width) ??\n\t\tIMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];\n\treturn `${src}-${rung}.avif`;\n}\n\nexport { imageLoader, imageLoader as itemImageLoader };\n","import type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport type { ItemVariant, Location, MenuItem } from \"@zorgo/universal/interfaces\";\nimport { wallTime } from \"@zorgo/universal/utils/locationOrderingAvailability\";\n\n/** Franchise-level facts for the Restaurant node. Everything but `name` is\n * optional so the schema degrades gracefully when a field is missing. */\nexport interface RestaurantInfo {\n\tname: string;\n\turl?: string;\n\timage?: string;\n\tdescription?: string;\n\tservesCuisine?: string[];\n\t/** Public locations from `lib/generated/locations.ts`. One location's facts\n\t * land on the Restaurant node itself; several become linked branches. */\n\tlocations?: Location[];\n}\n\nconst DAYS = [\n\t\"Monday\",\n\t\"Tuesday\",\n\t\"Wednesday\",\n\t\"Thursday\",\n\t\"Friday\",\n\t\"Saturday\",\n\t\"Sunday\",\n] as const;\n\n/** Stable node ids so the parent and its branches can reference each other. */\nfunction restaurantId(info: RestaurantInfo) {\n\treturn `${info.url ?? \"\"}#restaurant`;\n}\nfunction branchId(info: RestaurantInfo, location: Location) {\n\treturn `${info.url ?? \"\"}#location-${location.location_id}`;\n}\n\n/**\n * One OpeningHoursSpecification per distinct weekly window, with the days that\n * share it grouped into `dayOfWeek`. Days with no hours set are simply absent,\n * which is how schema.org expresses \"closed\".\n */\nexport function openingHoursSpecification(location: Location) {\n\tconst byWindow = new Map<string, string[]>();\n\tfor (const day of DAYS) {\n\t\tconst key = day.toLowerCase();\n\t\tconst open = location[`${key}_open` as keyof Location] as string | undefined;\n\t\tconst close = location[`${key}_close` as keyof Location] as string | undefined;\n\t\tif (!open || !close) continue;\n\t\tconst window = `${wallTime(open)}|${wallTime(close)}`;\n\t\tbyWindow.set(window, [...(byWindow.get(window) ?? []), `https://schema.org/${day}`]);\n\t}\n\n\treturn [...byWindow].map(([window, dayOfWeek]) => {\n\t\tconst [opens, closes] = window.split(\"|\");\n\t\treturn { \"@type\": \"OpeningHoursSpecification\", dayOfWeek, opens, closes };\n\t});\n}\n\n/**\n * `123 Main St, Springfield, IL 62704` — street, city, two-letter state, ZIP,\n * with an optional +4 and an optional trailing country. Anchored at both ends\n * and deliberately strict: everything before the last two commas is the street,\n * so a unit (\"… Ste 4, Springfield, IL 62704\") still parses.\n */\nconst US_ADDRESS = /^(.+),\\s*([^,]+),\\s*([A-Z]{2})\\s+(\\d{5}(?:-\\d{4})?)(?:,\\s*USA?)?$/;\n\n/**\n * schema.org accepts `address` as either Text or a PostalAddress node, but\n * Google's LocalBusiness rich results only read the structured form — a bare\n * string means the branch nodes never qualify.\n *\n * `address` is still one staff-typed column, so this parses rather than reads,\n * and it only parses what it can prove: a string that does not match the US\n * shape exactly falls back to the Text form it has always shipped. A wrong\n * `addressRegion` in structured data is worse than no `addressRegion` — it is a\n * fact we are asserting to Google — so the regex declines rather than guesses.\n *\n * ponytail: US-only, because every franchise is. Drop the fallback and read real\n * columns the day `locations` stores the parts separately.\n */\nfunction postalAddress(address: string): Record<string, unknown> | string {\n\tconst match = US_ADDRESS.exec(address.trim());\n\tif (!match) return address;\n\tconst [, streetAddress, addressLocality, addressRegion, postalCode] = match;\n\t// Unreachable — all four groups are required by the pattern — but under\n\t// noUncheckedIndexedAccess a capture is `string | undefined`, and declining\n\t// is already what this function does when it cannot prove the shape.\n\tif (!streetAddress || !addressLocality || !addressRegion || !postalCode) return address;\n\treturn {\n\t\t\"@type\": \"PostalAddress\",\n\t\tstreetAddress: streetAddress.trim(),\n\t\taddressLocality: addressLocality.trim(),\n\t\taddressRegion,\n\t\tpostalCode,\n\t\taddressCountry: \"US\",\n\t};\n}\n\n/** address / geo / telephone / hours — the facts shared by the single-location\n * Restaurant node and every branch node. */\nfunction locationFacts(location: Location) {\n\tconst facts: Record<string, unknown> = {};\n\tif (location.address) facts.address = postalAddress(location.address);\n\tif (location.phone) facts.telephone = location.phone;\n\tif (location.lat != null && location.lng != null) {\n\t\tfacts.geo = {\n\t\t\t\"@type\": \"GeoCoordinates\",\n\t\t\tlatitude: location.lat,\n\t\t\tlongitude: location.lng,\n\t\t};\n\t}\n\tconst hours = openingHoursSpecification(location);\n\tif (hours.length) facts.openingHoursSpecification = hours;\n\treturn facts;\n}\n\n/**\n * The branch node for one location, for that location's page. Carries the same\n * address/geo/hours facts as the parent and links back to it via\n * `parentOrganization`. Pure — safe to run at build time.\n */\nexport function buildLocationJsonLd(location: Location, info: RestaurantInfo) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\t\"@id\": branchId(info, location),\n\t\tname: `${info.name} — ${location.name}`,\n\t\tparentOrganization: { \"@id\": restaurantId(info) },\n\t\t...locationFacts(location),\n\t};\n\tif (info.image) node.image = info.image;\n\treturn node;\n}\n\n// cents -> \"5.49\" (schema.org Offer.price is a string in major currency units)\nfunction priceString(cents: number): string {\n\treturn (cents / 100).toFixed(2);\n}\n\nfunction variantOffer(variant: ItemVariant) {\n\treturn {\n\t\t\"@type\": \"Offer\",\n\t\tprice: priceString(variant.display_price ?? variant.price),\n\t\tpriceCurrency: \"USD\",\n\t};\n}\n\nfunction menuItemNode(item: MenuItem) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@type\": \"MenuItem\",\n\t\tname: item.name,\n\t};\n\tif (item.description) node.description = item.description;\n\t// one Offer for a single price, an array when the item has size/variant prices\n\tconst offers = item.variants.map(variantOffer);\n\tif (offers.length === 1) node.offers = offers[0];\n\telse if (offers.length > 1) node.offers = offers;\n\treturn node;\n}\n\n/**\n * Builds a standalone schema.org JSON-LD object for a single menu item, for\n * embedding in that item's `/menu/[slug]` page `<head>`. Reuses `menuItemNode`\n * (the same node shape used inside the Restaurant menu) and adds the\n * `@context` a top-level node needs. Pure — safe to run at build time.\n */\nexport function buildMenuItemJsonLd(item: MenuItem) {\n\treturn { \"@context\": \"https://schema.org\", ...menuItemNode(item) };\n}\n\n/**\n * Builds a schema.org Restaurant JSON-LD object from a prebuilt Menu and\n * franchise info. Categories become MenuSections; the items in each become\n * MenuItems. Pure — same inputs, same output — so it can run at prebuild time\n * and be embedded straight into the site's <head>.\n */\nexport function buildRestaurantJsonLd(menu: Menu, info: RestaurantInfo) {\n\tconst hasMenuSection = menu.categories.map((category) => {\n\t\tconst section: Record<string, unknown> = {\n\t\t\t\"@type\": \"MenuSection\",\n\t\t\tname: category.name,\n\t\t};\n\t\tconst items = menu.items\n\t\t\t.filter((item) => item.category_id === category.category_id)\n\t\t\t.map(menuItemNode);\n\t\tif (items.length) section.hasMenuItem = items;\n\t\treturn section;\n\t});\n\n\tconst restaurant: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\tname: info.name,\n\t};\n\tif (info.url) restaurant.url = info.url;\n\tif (info.image) restaurant.image = info.image;\n\tif (info.description) restaurant.description = info.description;\n\tif (info.servesCuisine?.length) restaurant.servesCuisine = info.servesCuisine;\n\n\t// A single-location franchise *is* its storefront, so its facts belong on\n\t// the Restaurant node. Several locations get referenced as branches instead\n\t// — the full branch nodes live on the location pages, not duplicated here.\n\tconst locations = info.locations ?? [];\n\tif (locations.length === 1) {\n\t\tObject.assign(restaurant, locationFacts(locations[0]!));\n\t} else if (locations.length > 1) {\n\t\trestaurant[\"@id\"] = restaurantId(info);\n\t\trestaurant.subOrganization = locations.map((location) => ({\n\t\t\t\"@type\": \"Restaurant\",\n\t\t\t\"@id\": branchId(info, location),\n\t\t\tname: location.name,\n\t\t}));\n\t}\n\n\trestaurant.hasMenu = {\n\t\t\"@type\": \"Menu\",\n\t\tname: \"Menu\",\n\t\thasMenuSection,\n\t};\n\n\treturn restaurant;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/components/server/item-page.tsx","../../zorgo/universal/constants.ts","../../zorgo/universal/utils/menuSlugs.ts","../../zorgo/universal/utils/menuUtils.ts","../src/images.ts","../src/seo.ts"],"sourcesContent":["import type { Metadata } from \"next\";\nimport type { ReactNode } from \"react\";\nimport Image from \"next/image\";\nimport type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport { baseItemIdFromSlug, menuItemSlug } from \"../../slugs\";\nimport { ITEM_IMAGE_PREFIX, menuImageUrl } from \"../../images\";\nimport { buildMenuItemJsonLd } from \"../../seo\";\nimport { ClientItemForm } from \"../client/ItemForm/ClientItemForm\";\n\n// Single source of truth for resolving the item a slug points at — shared by\n// every export here so the lookup never drifts. Returns undefined for an\n// unknown slug; callers degrade gracefully (the page 404s downstream).\nfunction itemFromSlug(menu: Menu, slug: string) {\n\tconst baseItemId = baseItemIdFromSlug(slug);\n\treturn menu.items.find((item) => item.base_item_id === baseItemId);\n}\n\n/** generateStaticParams for /menu/[slug] — every item in the prebuilt menu. */\nexport function menuItemStaticParams(menu: Menu) {\n\treturn menu.items.map((item) => ({\n\t\tslug: menuItemSlug(item.name, item.base_item_id),\n\t}));\n}\n\n/**\n * generateMetadata for /menu/[slug]: per-item title/description/OG so each item\n * ranks on its own long-tail terms. title is just the item name; the root\n * layout's title template wraps it as `<name> | Brand`, and its metadataBase\n * resolves OG URLs, so nothing absolute is hardcoded here.\n */\nexport function menuItemMetadata(menu: Menu, slug: string): Metadata {\n\tconst item = itemFromSlug(menu, slug);\n\tif (!item) return {};\n\n\tconst description =\n\t\titem.description ?? `Order ${item.name} online for pickup or delivery.`;\n\tconst image = menuImageUrl(item.images?.[0], true);\n\n\treturn {\n\t\ttitle: item.name,\n\t\tdescription,\n\t\topenGraph: {\n\t\t\ttitle: item.name,\n\t\t\tdescription,\n\t\t\t// Next's typed OpenGraph union has no \"product\"; \"website\" is the\n\t\t\t// closest valid value for a standalone item page.\n\t\t\ttype: \"website\",\n\t\t\t...(image ? { images: [image] } : {}),\n\t\t},\n\t};\n}\n\n/**\n * The whole /menu/[slug] body, owned by the package so a package bump updates\n * every scaffolded site. Server Component: the hero image and JSON-LD are\n * resolved from the prebuilt menu at build time and land in the static HTML,\n * while the interactive half sits behind ClientItemForm.\n */\nexport function ItemCustomizationView({\n\tmenu,\n\tslug,\n\tchildren,\n}: {\n\tmenu: Menu;\n\tslug: string;\n\t/**\n\t * The interactive half. Defaults to the package's form; pass a site's own\n\t * client component to keep a custom customization UI and still get the\n\t * server-rendered hero image, JSON-LD, and metadata from here.\n\t */\n\tchildren?: ReactNode;\n}) {\n\tconst item = itemFromSlug(menu, slug);\n\tconst heroImageUrl = menuImageUrl(item?.images?.[0]);\n\n\treturn (\n\t\t<div className=\"p-8 md:p-16 max-w-4xl mx-auto flex flex-col gap-8\">\n\t\t\t{item && (\n\t\t\t\t<script\n\t\t\t\t\ttype=\"application/ld+json\"\n\t\t\t\t\tdangerouslySetInnerHTML={{\n\t\t\t\t\t\t__html: JSON.stringify(buildMenuItemJsonLd(item)),\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{heroImageUrl && (\n\t\t\t\t<Image\n\t\t\t\t\tsrc={heroImageUrl}\n\t\t\t\t\talt={item!.name}\n\t\t\t\t\twidth={1080}\n\t\t\t\t\theight={608}\n\t\t\t\t\tpriority\n\t\t\t\t\t// An image prebuild could not localize is served straight from\n\t\t\t\t\t// the bucket, where no width variants exist.\n\t\t\t\t\tunoptimized={!heroImageUrl.startsWith(ITEM_IMAGE_PREFIX)}\n\t\t\t\t\t// Full width up to the container's max-w-4xl (56rem/896px); the\n\t\t\t\t\t// loader snaps that to the 1080 rung and narrower screens to 640/384.\n\t\t\t\t\tsizes=\"(min-width: 896px) 896px, 100vw\"\n\t\t\t\t\tclassName=\"w-full aspect-video object-cover rounded-2xl bg-background-recessed\"\n\t\t\t\t/>\n\t\t\t)}\n\t\t\t{children ?? (\n\t\t\t\t<ClientItemForm baseItemId={baseItemIdFromSlug(slug)} />\n\t\t\t)}\n\t\t</div>\n\t);\n}\n","// Env first, hardcoded production second — the same shape POS and kiosk already\n// use in their own config/environment.ts. Both bundlers inline these by literal\n// name (Next NEXT_PUBLIC_*, Expo EXPO_PUBLIC_*), so the lookups have to stay\n// written out; a destructured or computed read is not substituted. Without this\n// the two vars zorgo_site/.env already declares were dead, and pointing any app\n// at a Supabase preview branch meant editing this file.\nexport const SUPABASE_URL =\n\tprocess.env.NEXT_PUBLIC_SUPABASE_URL ||\n\tprocess.env.EXPO_PUBLIC_SUPABASE_URL ||\n\t\"https://dhnpoxorllfdobwftkyb.supabase.co\";\n\nexport const SUPABASE_ANON_KEY =\n\tprocess.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||\n\tprocess.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ||\n\t\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRobnBveG9ybGxmZG9id2Z0a3liIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzkwNTQxMDgsImV4cCI6MjA1NDYzMDEwOH0.zI8PwbgKwijHZ1QmxJqmaPrFvg02w1vOY2Qcs7SddzI\";\nexport const ZORGO_PURPLE = \"rgb(153,67,233)\";\n\nexport const expoGoGoogleClientId =\n\t\"959654249401-dm6nbi27rj605ul1nkt8o2imb8f7daff.apps.googleusercontent.com\";\n\n// replace instances of API_BASE_URL with env enum\nexport enum Environments {\n\tProduction = \"https://api.zorgotech.com\",\n\tStaging = \"https://staging.zorgo.ai\",\n\tDevelopment = \"http://localhost:3000\",\n}\n\nexport const API_BASE_URL =\n\tprocess.env.NODE_ENV === \"production\"\n\t\t? Environments.Production\n\t\t: Environments.Development;\n\n/** Emails allowed to access developer-only metrics (e.g. /performance/developer). */\nexport const DEVELOPER_EMAILS = [\"clawsorgo@gmail.com\", \"gmandwee@gmail.com\"] as const;\n\nexport function isDeveloperEmail(email?: string | null): boolean {\n\treturn !!email && (DEVELOPER_EMAILS as readonly string[]).includes(email);\n}\n\nexport const ORDER_STORE_CONSTANTS = {\n\tDEFAULTS: {\n\t\tPREP_MINUTES: 20, // default amount of time for prep\n\t\tPAST_HOURS: 24, // hours into the past orders are fetched for\n\t\tFUTURE_HOURS: 72 // hours into the future orders are fetched for \n\t},\n\tORDER_STATUS: {\n\t\tSTAGING: 10,\n\t\tPENDING: 1,\n\t\tCOMPLETED: 2,\n\t\tCANCELLED: 4,\n\t\tPAID: 6,\n\t\tPARTIALLY_PAID: 7,\n\t\tKITCHEN_DONE: 9,\n\t\tCONFIRMED: 11,\n\t\tPREPARING: 12,\n\t\tREQUESTED: 13,\n\t},\n\tPOS_METHOD_ID: 3,\n\t/**\n\t * Internal QA accounts — their orders are excluded from dashboard order queries.\n\t * Matched on dev/QA emails (kcjayd@, clawsorgo@, gmandwee@, aaiden798@, *@zorgo.*),\n\t * the \"c d\" throwaway persona, and known test names (casey donegan, aiden alazo, …).\n\t * ponytail: hardcoded snapshot — new test accounts need re-running the query.\n\t * Promote to an `is_test` column on `customers` if this list keeps growing.\n\t */\n\tTEST_CUSTOMER_IDS: [\n\t\t85, 90, 91, 93, 95, 96, 97, 203, 238, 240, 241, 244, 245, 246, 317, 318, 319, 322,\n\t\t579, 580, 658, 1526, 2567, 2573, 2703, 2725, 2737, 2739, 2740, 2741, 2742, 2744,\n\t\t2746, 2747, 2749, 2751, 2752, 2753, 2754, 2755, 2767, 2768, 2778, 2779, 2780, 2781,\n\t\t2783, 2814, 2816, 2818, 2819, 2820, 2821, 2822, 2823, 2824, 2835, 2836, 2851, 2853,\n\t\t2859, 2871, 2882, 2883, 2887, 2888, 2889, 2891, 2892, 2893, 2894, 2895, 2896, 2897,\n\t\t2898, 2899, 2900, 2901, 2902, 2903, 2904, 2905, 2907, 2908, 2919, 2921, 2922, 2923,\n\t\t2924, 2925, 2926, 2927, 2946, 3017, 3018, 3022, 3024, 3025, 3026, 3027, 3028, 3029,\n\t\t3030, 3031, 3082, 3091, 3092, 3093,\n\t],\n\tORDER_TYPE: {\n\t\tDINE_IN: 1,\n\t\tTAKEOUT: 2,\n\t\tDELIVERY: 3\n\t},\n\t/** KDS edit highlight duration after a POS modification. */\n\tMODIFICATION_HIGHLIGHT_MS: 8_000,\n\t/** POS order realtime: refetch + recycle postgres_changes channel on this cadence. */\n\tLISTENER_SYNC_INTERVAL_MS: 60_000,\n\tORDER_SELECT: `\n\t*,\n\n\tcharges (\n\t\t*\n\t),\n\n\tlocations (name),\n\n\tcustomers\n\t (customer_id, first_name, last_name, phone_number, email),\n\n\torder_items\n\t (\n\t\torder_item_id, item_id, quantity, price_at_order, order_id, comments, status, applied_promo_id, discount_amount,\n\t\titems (\n\t\t\titem_id, name, price,\n\t\t\tportion_schemes (id, franchise_id, geometry, name, min_divisions, max_divisions),\n\t\t\tbase_items (\n\t\t\t\tbase_item_id, category_id, name, per_person,\n\t\t\t\tcategories (category_id, name, is_catering)\n\t\t\t)\n\t\t),\n\t\torder_item_selected_items (\n\t\t\torder_item_id, rule_id, item_id, quantity, upcharge_price, portion,\n\t\t\titems (\n\t\t\t\titem_id, name, price,\n\t\t\t\tbase_items (\n\t\t\t\t\tbase_item_id, category_id, name,\n\t\t\t\t\tcategories (category_id, name, is_catering)\n\t\t\t\t),\n\t\t\t\titem_components (\n\t\t\t\t\tservings_per_item,\n\t\t\t\t\tcomponent_quantity_behaviors (headcount_threshold, starting_amount, step_size, step_interval, maximum),\n\t\t\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit)\n\t\t\t\t)\n\t\t\t),\n\t\t\titem_rules (rule_id, group_name)\n\t\t),\n\t\torder_item_selected_components (\n\t\t\torder_item_id, rule_id, component_id, quantity, upcharge_price, portion,\n\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit), item_rules (rule_id, group_name)\n\t\t),\n\t\torder_item_components (\n\t\t\torder_item_id, component_id, quantity_servings, is_base, portion,\n\t\t\tcomponents (id, name, component_type_id, serving_weight, measurement_type, display_unit)\n\t\t)\n\t)\n`,\n};\n\nexport const CHARGE_STATUSES = {\n\tSUCCESS: 1,\n\tPENDING: 2,\n\tREFUNDED: 3,\n\tVOIDED: 4,\n\tPAYMENT_QUEUED: 5,\n\tFAILED: 6\n}\n","import { supabase } from \"./supabase\";\n\nexport interface MenuSlugParam {\n\tslug: string;\n}\n\nexport interface BaseMenuSlugRecord {\n\tbaseItemId: number;\n\tname: string;\n\tslug: string;\n\tdescription: string | null;\n}\n\nfunction requireApiKey(apiKey?: string): string {\n\tconst resolvedApiKey = apiKey ?? process.env.ZORGO_API_KEY;\n\tif (!resolvedApiKey) {\n\t\tthrow new Error(\"ZORGO_API_KEY is not set\");\n\t}\n\treturn resolvedApiKey;\n}\n\nexport function menuItemNameToSlug(name: string): string {\n\treturn name\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9\\s-]/g, \"\")\n\t\t.replace(/\\s+/g, \"-\")\n\t\t.trim();\n}\n\n/** Builds the `/menu/[slug]` route segment for an item, e.g. \"chicken-burrito-42\". */\nexport function menuItemSlug(name: string, baseItemId: number): string {\n\treturn `${menuItemNameToSlug(name)}-${baseItemId}`;\n}\n\n/** Parses the trailing numeric base_item_id off a `menuItemSlug` result. */\nexport function baseItemIdFromSlug(slug: string): number {\n\tconst match = slug.match(/-(\\d+)$/);\n\tif (!match) {\n\t\tthrow new Error(`No base_item_id found in slug: ${slug}`);\n\t}\n\treturn Number(match[1]);\n}\n\nexport function buildSlugParam(name: string): MenuSlugParam {\n\treturn { slug: menuItemNameToSlug(name) };\n}\n\nexport function buildSlugParamsFromNames(names: string[]): MenuSlugParam[] {\n\treturn names.map(buildSlugParam);\n}\n\nfunction assertUniqueSlugs(items: Array<{ name: string; slug: string }>): void {\n\tconst duplicates = new Map<string, string[]>();\n\n\tfor (const item of items) {\n\t\tconst matches = duplicates.get(item.slug) ?? [];\n\t\tmatches.push(item.name);\n\t\tduplicates.set(item.slug, matches);\n\t}\n\n\tconst conflicts = Array.from(duplicates.entries()).filter(\n\t\t([, names]) => names.length > 1,\n\t);\n\tif (conflicts.length === 0) return;\n\n\tconst message = conflicts\n\t\t.map(([slug, names]) => `\"${slug}\" => ${names.join(\", \")}`)\n\t\t.join(\"; \");\n\tthrow new Error(`Duplicate menu slugs detected: ${message}`);\n}\n\nasync function getFranchiseIdFromApiKey(apiKey?: string): Promise<number> {\n\tconst keyValue = requireApiKey(apiKey);\n\tconst { data, error } = await supabase\n\t\t.from(\"api_keys\")\n\t\t.select(\"franchise_id\")\n\t\t.eq(\"key_value\", keyValue)\n\t\t.eq(\"is_active\", true)\n\t\t.single();\n\n\tif (error || !data?.franchise_id) {\n\t\tthrow new Error(\"Trouble finding franchise for API key\");\n\t}\n\n\treturn data.franchise_id;\n}\n\nexport async function getBaseMenuSlugRecordsByApiKey(\n\tapiKey?: string,\n\toptions: { assertUniqueSlugs?: boolean } = {},\n): Promise<BaseMenuSlugRecord[]> {\n\tconst { assertUniqueSlugs: shouldAssertUniqueSlugs = true } = options;\n\tconst franchiseId = await getFranchiseIdFromApiKey(apiKey);\n\tconst { data, error } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id, name, description\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"base_item_id\", { ascending: true });\n\n\tif (error || !data) {\n\t\tthrow new Error(\"Trouble loading base menu items for franchise\");\n\t}\n\n\tconst records = data\n\t\t.filter((item) => typeof item.name === \"string\" && item.name.length > 0)\n\t\t.map((item) => ({\n\t\t\tbaseItemId: item.base_item_id,\n\t\t\tname: item.name,\n\t\t\tslug: menuItemNameToSlug(item.name),\n\t\t\tdescription: item.description ?? null,\n\t\t}));\n\n\tif (shouldAssertUniqueSlugs) {\n\t\tassertUniqueSlugs(records);\n\t}\n\treturn records;\n}\n\nexport async function getBaseMenuSlugParamsByApiKey(\n\tapiKey?: string,\n): Promise<MenuSlugParam[]> {\n\tconst records = await getBaseMenuSlugRecordsByApiKey(apiKey);\n\treturn records.map(({ slug }) => ({ slug }));\n}\n\nexport async function getBaseMenuSlugRecordBySlug(\n\tslug: string,\n\tapiKey?: string,\n): Promise<BaseMenuSlugRecord | null> {\n\tconst records = await getBaseMenuSlugRecordsByApiKey(apiKey);\n\treturn records.find((record) => record.slug === slug) ?? null;\n}\n","import { SupabaseClient } from \"@supabase/supabase-js\";\nimport { getDay, isAfter, isBefore, parse, startOfDay } from \"date-fns\";\nimport { SUPABASE_URL } from \"../constants\";\nimport {\n\tBaseItem,\n\tBaseItemImage,\n\tCategory,\n\tComponent,\n\tItemVariant,\n\tMenuItem,\n\tPlatforms,\n\tPromotion,\n\tRule,\n\tVisibilityConditionGroupAssignment,\n\tVisibilityEffect,\n} from \"../interfaces\";\nimport { csvFormat } from \"./misc\";\nimport {\n\tbuildVisibilityEvaluationContext,\n\tevaluateVisibilityCondition,\n} from \"./visibilityConditions\";\n\nexport interface Menu {\n\titems: MenuItem[];\n\tcategories: Category[];\n\tcomponents: Component[];\n\trules: Rule[];\n}\n\nexport interface MenuIds {\n\tbaseItemIds: number[];\n\tcategoryIds: number[];\n\titemIds: number[];\n}\n\ntype MenuIdRows = {\n\tcategories: Pick<Category, \"category_id\">[];\n\tbaseItems: Pick<BaseItem, \"base_item_id\" | \"category_id\">[];\n\titems: Pick<ItemVariant, \"item_id\" | \"base_item_id\">[];\n};\n\n/**\n * Menu display order: curated `sort_index` first, unset rows after it.\n *\n * `nullsFirst: false` is the load-bearing half. A franchise pins only the few\n * things it cares about and leaves everything else NULL, so nulls have to fall\n * to the bottom where the tiebreak column (name, or price for variants) sorts\n * them — otherwise the unpinned majority would sit above the curated rows.\n */\nconst ORDER_CURATED = { ascending: true, nullsFirst: false } as const;\n\n/** Sorts last, so an unset sort_index yields to whatever the caller tiebreaks on. */\nconst UNSORTED = Number.MAX_SAFE_INTEGER;\n\n/**\n * The in-memory half of ORDER_CURATED, for rows Postgres can't order for us: a\n * rule's options are one list to the customer but live in two tables\n * (rule_items, rule_components), and their display names sit a join further out\n * on items/components, which PostgREST can't order an embed by.\n */\nfunction byCuratedThenName(\n\ta: { sort_index?: number | null; name: string },\n\tb: { sort_index?: number | null; name: string },\n): number {\n\tconst delta = (a.sort_index ?? UNSORTED) - (b.sort_index ?? UNSORTED);\n\treturn delta !== 0 ? delta : a.name.localeCompare(b.name);\n}\n\nexport async function getMenu(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texclusions: MenuIds = emptyMenuExclusions(),\n\tuseBehaviors = false,\n): Promise<Menu> {\n\tconst categories = await getCategories(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\texclusions.categoryIds,\n\t);\n\n\tif (categories.length === 0) {\n\t\treturn buildMenu([], [], [], [], []);\n\t}\n\n\tconst baseItems = await getBaseItems(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\texclusions.baseItemIds,\n\t\tcategories,\n\t);\n\n\tif (baseItems.length === 0) {\n\t\treturn buildMenu([], [], categories, [], []);\n\t}\n\n\tconst items = await getItems(supabase, baseItems, exclusions.itemIds);\n\tconst components = await getComponents(supabase, items, useBehaviors);\n\tconst rules = await getRules(supabase, baseItems, categories, items, useBehaviors);\n\n\treturn buildMenu(items, baseItems, categories, components, rules);\n}\n\nexport async function getCateringBaseItemIds(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n): Promise<number[]> {\n\tconst { data: categories } = await supabase\n\t\t.from(\"categories\")\n\t\t.select(\"category_id\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.eq(\"is_catering\", true)\n\t\t.throwOnError();\n\n\tif (!categories || categories.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst { data } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.in(\"category_id\", categories.map((category) => category.category_id))\n\t\t.throwOnError();\n\treturn data.map((row) => row.base_item_id);\n}\n\n/**\n * Attaches targeted promotions to menu cards without mutating the menu.\n * Order-wide promos intentionally stay out of menu display pricing.\n */\nexport function fitPromosToMenu(\n\tmenu: Menu,\n\tactivePromotions: Promotion[],\n): Menu {\n\tconst categoryPromoIds = new Map<number, number>();\n\tconst getDisplayPrice = (price: number, promo: Promotion) => {\n\t\tif (promo.promotion_type_id === 1) {\n\t\t\treturn Math.max(0, Math.floor(price * (1 - promo.discount_value / 100)));\n\t\t}\n\t\tif (promo.promotion_type_id === 2) {\n\t\t\treturn Math.max(0, price - promo.discount_value);\n\t\t}\n\t\treturn price;\n\t};\n\n\tconst items = menu.items.map((item) => {\n\t\t// Bulk discount promos (quantity-dependent sigmoid) attach to the item but never set a fixed display_price\n\t\tconst bulkPromo = activePromotions.find((promo) => {\n\t\t\tif (!promo.is_active || promo.id == null || !promo.discount_target_ids?.length || !promo.min_purchase_quantity) return false;\n\t\t\tconst targets = promo.discount_target_ids;\n\t\t\treturn (\n\t\t\t\t(promo.discount_target_type === \"category\" && targets.includes(item.category_id)) ||\n\t\t\t\t(promo.discount_target_type === \"item\" && targets.includes(item.base_item_id))\n\t\t\t);\n\t\t});\n\n\t\tlet itemPromoId: number | undefined = bulkPromo?.id;\n\n\t\tconst variants = item.variants.map((variant) => {\n\t\t\tlet bestPromo: Promotion | undefined;\n\t\t\tlet bestDisplayPrice = variant.price;\n\n\t\t\tfor (const promo of activePromotions) {\n\t\t\t\tif (\n\t\t\t\t\t!promo.is_active ||\n\t\t\t\t\tpromo.id == null ||\n\t\t\t\t\t!promo.discount_target_ids?.length ||\n\t\t\t\t\tpromo.min_purchase_amount || // order-level threshold — shown via modal, not baked into menu price\n\t\t\t\t\tpromo.min_purchase_quantity // quantity-dependent — handled above, no fixed display_price\n\t\t\t\t) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst targets = promo.discount_target_ids;\n\t\t\t\tconst applies =\n\t\t\t\t\t(promo.discount_target_type === \"category\" &&\n\t\t\t\t\t\ttargets.includes(item.category_id)) ||\n\t\t\t\t\t(promo.discount_target_type === \"item\" &&\n\t\t\t\t\t\ttargets.includes(item.base_item_id)) ||\n\t\t\t\t\t(promo.discount_target_type === \"item_variant\" &&\n\t\t\t\t\t\ttargets.includes(variant.item_id));\n\n\t\t\t\tif (!applies) continue;\n\n\t\t\t\tconst displayPrice = getDisplayPrice(variant.price, promo);\n\t\t\t\tif (displayPrice < bestDisplayPrice) {\n\t\t\t\t\tbestPromo = promo;\n\t\t\t\t\tbestDisplayPrice = displayPrice;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!bestPromo || bestDisplayPrice >= variant.price) {\n\t\t\t\treturn variant;\n\t\t\t}\n\n\t\t\titemPromoId ??= bestPromo.id!;\n\t\t\tif (bestPromo.discount_target_type === \"category\") {\n\t\t\t\tcategoryPromoIds.set(item.category_id, bestPromo.id!);\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\t...variant,\n\t\t\t\tactive_promo_id: bestPromo.id!,\n\t\t\t\tdisplay_price: bestDisplayPrice,\n\t\t\t};\n\t\t});\n\n\t\treturn { ...item, variants, active_promo_id: itemPromoId };\n\t});\n\n\tconst categories = menu.categories.map((category) => {\n\t\tconst { active_promo_id, ...cleanCategory } = category;\n\t\tvoid active_promo_id;\n\n\t\tconst promoId = categoryPromoIds.get(cleanCategory.category_id);\n\n\t\treturn promoId == null\n\t\t\t? cleanCategory\n\t\t\t: { ...cleanCategory, active_promo_id: promoId };\n\t});\n\n\treturn {\n\t\t...menu,\n\t\titems,\n\t\tcategories,\n\t};\n}\n\nexport async function getMenuExclusions(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tlocationId: number,\n\tplatform: Platforms,\n): Promise<MenuIds> {\n\tvoid locationId;\n\n\tconst menuIds = await getMenuIds(supabase, franchiseId);\n\tconst activeAssignments = await getActiveVisibilityAssignments(\n\t\tsupabase,\n\t\tfranchiseId,\n\t\tplatform,\n\t);\n\n\tif (activeAssignments.length === 0) {\n\t\treturn emptyMenuExclusions();\n\t}\n\n\tconst categoryEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.category_id,\n\t);\n\tconst baseItemEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.base_item_id,\n\t);\n\tconst itemEffects = buildTargetEffectMap(\n\t\tactiveAssignments,\n\t\t(assignment) => assignment.item_id,\n\t);\n\n\tconst baseItemById = new Map(\n\t\tmenuIds.baseItems.map((baseItem) => [baseItem.base_item_id, baseItem]),\n\t);\n\tconst itemById = new Map(menuIds.items.map((item) => [item.item_id, item]));\n\n\tconst isCategoryIncluded = (categoryId: number) =>\n\t\tcategoryEffects.get(categoryId) !== \"exclude\";\n\n\tconst isBaseItemIncluded = (baseItemId: number) => {\n\t\tconst effect = baseItemEffects.get(baseItemId);\n\t\tif (effect === \"include\") return true;\n\t\tif (effect === \"exclude\") return false;\n\n\t\tconst categoryId = baseItemById.get(baseItemId)?.category_id;\n\t\treturn categoryId == null || isCategoryIncluded(categoryId);\n\t};\n\n\tconst isItemIncluded = (itemId: number): boolean => {\n\t\tconst effect = itemEffects.get(itemId);\n\t\tif (effect === \"include\") return true;\n\t\tif (effect === \"exclude\") return false;\n\n\t\tconst item = itemById.get(itemId);\n\t\tif (item?.base_item_id == null) return true;\n\n\t\treturn isBaseItemIncluded(item.base_item_id);\n\t};\n\n\tconst includedItems = new Set(\n\t\tmenuIds.items\n\t\t\t.filter((item) => isItemIncluded(item.item_id))\n\t\t\t.map((item) => item.item_id),\n\t);\n\tconst includedBaseItems = new Set(\n\t\tmenuIds.baseItems\n\t\t\t.filter((baseItem) => isBaseItemIncluded(baseItem.base_item_id))\n\t\t\t.map((baseItem) => baseItem.base_item_id),\n\t);\n\n\tfor (const item of menuIds.items) {\n\t\tif (includedItems.has(item.item_id) && item.base_item_id != null) {\n\t\t\tincludedBaseItems.add(item.base_item_id);\n\t\t}\n\t}\n\n\tconst includedCategories = new Set(\n\t\tmenuIds.categories\n\t\t\t.filter((category) => isCategoryIncluded(category.category_id))\n\t\t\t.map((category) => category.category_id),\n\t);\n\n\tfor (const baseItem of menuIds.baseItems) {\n\t\tif (includedBaseItems.has(baseItem.base_item_id)) {\n\t\t\tincludedCategories.add(baseItem.category_id);\n\t\t}\n\t}\n\n\treturn {\n\t\tcategoryIds: menuIds.categories\n\t\t\t.map((category) => category.category_id)\n\t\t\t.filter((categoryId) => !includedCategories.has(categoryId)),\n\t\tbaseItemIds: menuIds.baseItems\n\t\t\t.map((baseItem) => baseItem.base_item_id)\n\t\t\t.filter((baseItemId) => !includedBaseItems.has(baseItemId)),\n\t\titemIds: menuIds.items\n\t\t\t.map((item) => item.item_id)\n\t\t\t.filter((itemId) => !includedItems.has(itemId)),\n\t};\n}\n\nexport function getNameFromId(\n\tid: number,\n\ttype: \"category\" | \"item\" | \"item_variant\",\n\tmenu: Partial<Menu>,\n): string {\n\tif (type === \"category\") {\n\t\tconst category = menu.categories?.find(\n\t\t\t(category) => category.category_id === id,\n\t\t);\n\n\t\tif (category?.name) {\n\t\t\treturn category.name;\n\t\t}\n\t} else if (type === \"item\") {\n\t\tconst item = menu.items?.find((item: any) => item.base_item_id === id);\n\n\t\tif (item?.name) {\n\t\t\treturn item.name;\n\t\t}\n\t} else if (type === \"item_variant\") {\n\t\tconst item = menu.items?.find((item: any) =>\n\t\t\titem.variants.find((variant: any) => variant.item_id === id),\n\t\t);\n\n\t\tif (item?.name) {\n\t\t\treturn item.name;\n\t\t}\n\t}\n\n\treturn id.toString(); // default to showing the item id\n}\n\nfunction emptyMenuExclusions(): MenuIds {\n\treturn {\n\t\tcategoryIds: [],\n\t\tbaseItemIds: [],\n\t\titemIds: [],\n\t};\n}\n\nasync function getMenuIds(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n): Promise<MenuIdRows> {\n\tconst { data: categories, error: categoriesError } = await supabase\n\t\t.from(\"categories\")\n\t\t.select(\"category_id\")\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (categoriesError) {\n\t\tthrow categoriesError;\n\t}\n\n\tconst { data: baseItems, error: baseItemsError } = await supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"base_item_id, category_id\")\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (baseItemsError) {\n\t\tthrow baseItemsError;\n\t}\n\n\tconst baseItemIds = (baseItems ?? []).map((item) => item.base_item_id);\n\tconst { data: items, error: itemsError } =\n\t\tbaseItemIds.length > 0\n\t\t\t? await supabase\n\t\t\t\t.from(\"items\")\n\t\t\t\t.select(\"item_id, base_item_id\")\n\t\t\t\t.in(\"base_item_id\", baseItemIds)\n\t\t\t: { data: [], error: null };\n\n\tif (itemsError) {\n\t\tthrow itemsError;\n\t}\n\n\treturn {\n\t\tcategories: categories ?? [],\n\t\tbaseItems: baseItems ?? [],\n\t\titems: items ?? [],\n\t};\n}\n\nasync function getActiveVisibilityAssignments(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tplatform: Platforms,\n): Promise<VisibilityConditionGroupAssignment[]> {\n\tconst { data, error } = await supabase\n\t\t.from(\"visibility_condition_group_assignments\")\n\t\t.select(\"*, visibility_condition_groups (*)\")\n\t\t.eq(\"visibility_condition_groups.franchise_id\", franchiseId)\n\t\t.eq(\"visibility_condition_groups.platform\", platform);\n\n\tif (error || !data) {\n\t\treturn [];\n\t}\n\n\tconst context = buildVisibilityEvaluationContext({ platform });\n\n\treturn (data as VisibilityConditionGroupAssignment[]).filter((assignment) => {\n\t\tconst group = assignment.visibility_condition_groups;\n\n\t\tif (!group?.condition || group.platform !== context.platform) {\n\t\t\treturn false;\n\t\t}\n\n\t\ttry {\n\t\t\treturn evaluateVisibilityCondition(group.condition, context);\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t});\n}\n\nfunction buildTargetEffectMap(\n\tassignments: VisibilityConditionGroupAssignment[],\n\tselector: (\n\t\tassignment: VisibilityConditionGroupAssignment,\n\t) => number | null | undefined,\n): Map<number, VisibilityEffect> {\n\tconst effectsByTarget = new Map<number, VisibilityEffect[]>();\n\n\tfor (const assignment of assignments) {\n\t\tconst targetId = selector(assignment);\n\t\tif (targetId == null) continue;\n\n\t\teffectsByTarget.set(targetId, [\n\t\t\t...(effectsByTarget.get(targetId) ?? []),\n\t\t\tassignment.effect,\n\t\t]);\n\t}\n\n\treturn new Map(\n\t\tArray.from(effectsByTarget.entries()).map(([targetId, effects]) => [\n\t\t\ttargetId,\n\t\t\teffects.includes(\"exclude\") ? \"exclude\" : \"include\",\n\t\t]),\n\t);\n}\n\nasync function getCategories(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texcludedIds?: number[],\n): Promise<Category[]> {\n\tlet categoriesQuery = supabase\n\t\t.from(\"categories\")\n\t\t.select(\"*\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"name\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\tcategoriesQuery = categoriesQuery.not(\n\t\t\t\"category_id\",\n\t\t\t\"in\",\n\t\t\tcsvFormat(excludedIds),\n\t\t);\n\t}\n\n\tconst { data: categories, error: categoriesError } = await categoriesQuery;\n\n\tif (categoriesError) {\n\t\tthrow categoriesError;\n\t}\n\n\treturn categories ?? [];\n}\n\nasync function getBaseItems(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\texcludedIds?: number[],\n\tcategories?: Category[],\n): Promise<BaseItem[]> {\n\tif (categories && categories.length === 0) {\n\t\treturn [];\n\t}\n\n\tlet baseItemsQuery = supabase\n\t\t.from(\"base_items\")\n\t\t.select(\"*\")\n\t\t.eq(\"franchise_id\", franchiseId)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"name\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\tbaseItemsQuery = baseItemsQuery.not(\n\t\t\t\"base_item_id\",\n\t\t\t\"in\",\n\t\t\tcsvFormat(excludedIds),\n\t\t);\n\t}\n\n\tif (categories) {\n\t\tbaseItemsQuery = baseItemsQuery.in(\n\t\t\t\"category_id\",\n\t\t\tcategories.map((category) => category.category_id),\n\t\t);\n\t}\n\n\tconst { data: baseItems, error: baseItemsError } = await baseItemsQuery;\n\n\tif (baseItemsError) {\n\t\tthrow baseItemsError;\n\t}\n\n\tif (!baseItems || baseItems.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst { data: images, error: imagesError } = await supabase\n\t\t.from(\"base_item_images\")\n\t\t.select(\"*\")\n\t\t.in(\n\t\t\t\"base_item_id\",\n\t\t\tbaseItems.map((baseItem) => baseItem.base_item_id),\n\t\t)\n\t\t.order(\"sort_index\", { ascending: true })\n\t\t.order(\"image_id\", { ascending: true });\n\n\tif (imagesError) {\n\t\tthrow imagesError;\n\t}\n\n\treturn attachImages(baseItems, images ?? []);\n}\n\n/**\n * Group images onto their base items. `images` must already be in the order\n * callers should see them (sort_index, then image_id) — grouping preserves it,\n * so every render site can take `images[0]` as the primary image.\n */\nexport function attachImages<T extends { base_item_id: number }>(\n\tbaseItems: T[],\n\timages: BaseItemImage[],\n): Array<T & { images: BaseItemImage[] }> {\n\tconst byBaseItemId = new Map<number, BaseItemImage[]>();\n\tfor (const image of images) {\n\t\tconst group = byBaseItemId.get(image.base_item_id);\n\t\tif (group) group.push(image);\n\t\telse byBaseItemId.set(image.base_item_id, [image]);\n\t}\n\n\treturn baseItems.map((baseItem) => ({\n\t\t...baseItem,\n\t\timages: byBaseItemId.get(baseItem.base_item_id) ?? [],\n\t}));\n}\n\nexport function menuImageUrl(\n\timage?: BaseItemImage | null,\n\tabsolute = false,\n): string | null {\n\tif (!image) return null;\n\tif (image.local_key && !absolute) return `/images/items/${image.local_key}`;\n\tconst path = image.processed_path ?? image.raw_path;\n\tif (!path) return null;\n\treturn `${SUPABASE_URL}/storage/v1/object/public/product-images/${path}`;\n}\n\nasync function getItems(\n\tsupabase: SupabaseClient,\n\tbaseItems: BaseItem[],\n\texcludedIds?: number[],\n): Promise<ItemVariant[]> {\n\tif (baseItems && baseItems.length === 0) {\n\t\treturn [];\n\t}\n\n\t// the scheme rides along so the prebuilt menu carries it — no runtime fetch\n\t// on the item page just to know how a pizza can be cut\n\t//\n\t// Price, not name, is the tiebreak: variants are usually sizes, and sorting\n\t// those alphabetically reads \"Large, Medium, Small\".\n\tlet itemsQuery = supabase\n\t\t.from(\"items\")\n\t\t.select(\"*, portion_scheme:portion_schemes(*)\")\n\t\t.in(\n\t\t\t\"base_item_id\",\n\t\t\tbaseItems.map((baseItem) => baseItem.base_item_id),\n\t\t)\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"price\", ORDER_CURATED);\n\n\tif (excludedIds && excludedIds.length > 0) {\n\t\titemsQuery = itemsQuery.not(\"item_id\", \"in\", csvFormat(excludedIds));\n\t}\n\n\tconst { data: items, error: itemsError } = await itemsQuery;\n\n\tif (itemsError) {\n\t\tthrow itemsError;\n\t}\n\n\treturn items ?? [];\n}\n\nexport async function getComponents(\n\tsupabase: SupabaseClient,\n\titems: ItemVariant[] | number[],\n\tuseBehaviors = false,\n): Promise<Component[]> {\n\tconst itemIds = items.length > 0 && typeof items[0] === \"number\"\n\t\t? (items as number[])\n\t\t: (items as ItemVariant[]).map((item) => item.item_id);\n\n\tif (itemIds.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst select = useBehaviors\n\t\t? \"components(*), item_id, servings_per_item, behavior_id, component_quantity_behaviors(headcount_threshold, starting_amount, step_size, step_interval, maximum)\"\n\t\t: \"components(*), item_id, servings_per_item\";\n\n\tconst { data: components } = await supabase\n\t\t.from(\"item_components\")\n\t\t.select(select)\n\t\t.in(\"item_id\", itemIds)\n\t\t.throwOnError();\n\n\treturn components.map((component: any) => ({\n\t\tservings_per_item: component.servings_per_item,\n\t\tis_base: component.components?.is_base,\n\t\tserving_weight: component.components?.serving_weight,\n\t\tcomponent_type_id: component.components?.component_type_id,\n\t\tid: component.components?.id,\n\t\tname: component.components?.name,\n\t\titem_id: component.item_id,\n\t\t...(useBehaviors && { behavior: component.component_quantity_behaviors ?? undefined }),\n\t}));\n}\n\n/**\n * A rule's options — components and items — as one keyed list. The key is what\n * `portionKey` builds on and what `weightedUpcharges` prices, so the picker and\n * the server's repricing read option prices through this same function.\n *\n * The two tables share one `sort_index` sequence, so a rule that mixes them\n * interleaves rather than always putting every component ahead of every item.\n */\nexport function ruleOptionDefs(rule: Rule) {\n\treturn [\n\t\t...(rule.components ?? []).map((c) => ({\n\t\t\tkey: `component:${c.component_id}`,\n\t\t\tid: c.component_id,\n\t\t\ttype: \"component\" as const,\n\t\t\tname: c.name,\n\t\t\tbasePrice: c.upcharge_price || 0,\n\t\t\tdescription: c.description,\n\t\t\tsort_index: c.sort_index ?? null,\n\t\t})),\n\t\t...(rule.items ?? []).map((i) => ({\n\t\t\tkey: `item:${i.item_id}`,\n\t\t\tid: i.item_id,\n\t\t\ttype: \"item\" as const,\n\t\t\tname: i.name,\n\t\t\tbasePrice: i.upcharge_price || 0,\n\t\t\tdescription: i.description,\n\t\t\tsort_index: i.sort_index ?? null,\n\t\t})),\n\t].sort(byCuratedThenName);\n}\n\n/**\n * Rules attach at item, base-item, or category level; `ruleIds` fetches them by\n * primary key instead, for when you already hold the ids (repricing an order's\n * selections server-side).\n */\nexport async function getRules(\n\tsupabase: SupabaseClient,\n\tbaseItems?: BaseItem[],\n\tcategories?: Category[],\n\titems?: ItemVariant[],\n\tuseBehaviors = false,\n\truleIds?: number[],\n): Promise<Rule[]> {\n\tconst itemIds = items?.map((item) => item.item_id) ?? [];\n\tconst baseItemIds = baseItems?.map((baseItem) => baseItem.base_item_id) ?? [];\n\tconst categoryIds = categories?.map((category) => category.category_id) ?? [];\n\n\tif (\n\t\titemIds.length === 0 &&\n\t\tbaseItemIds.length === 0 &&\n\t\tcategoryIds.length === 0 &&\n\t\t!ruleIds?.length\n\t) {\n\t\treturn [];\n\t}\n\n\tconst orFilter = [\n\t\t`item_id.in.(${itemIds.length > 0 ? itemIds.join(\",\") : -1})`,\n\t\t`base_item_id.in.(${baseItemIds.length > 0 ? baseItemIds.join(\",\") : -1})`,\n\t\t`category_id.in.(${categoryIds.length > 0 ? categoryIds.join(\",\") : -1})`,\n\t\t...(ruleIds?.length ? [`rule_id.in.(${ruleIds.join(\",\")})`] : []),\n\t].join(\",\");\n\n\tconst behaviorSelect = useBehaviors\n\t\t? \"component_quantity_behaviors(headcount_threshold, starting_amount, step_size, step_interval, maximum)\"\n\t\t: null;\n\n\tconst ruleComponentsSelect = [\n\t\t\"component_id, upcharge_price, sort_index, components(name, description, serving_weight)\",\n\t\tbehaviorSelect,\n\t].filter(Boolean).join(\", \");\n\n\tconst ruleItemsSelect = [\n\t\t\"item_id, upcharge_price, sort_index, items(item_id, name, description)\",\n\t\tbehaviorSelect,\n\t].filter(Boolean).join(\", \");\n\n\tconst { data: ruleData, error: rulesError } = await supabase\n\t\t.from(\"item_rules\")\n\t\t.select(\n\t\t\t`\n rule_id,\n category_id,\n item_id,\n base_item_id,\n group_name,\n min_selection,\n max_selection,\n included_quantity,\n is_portionable,\n sort_index,\n rule_components (${ruleComponentsSelect}),\n rule_items (${ruleItemsSelect})\n `,\n\t\t)\n\t\t.or(orFilter)\n\t\t// rule_id, not group_name: alphabetical rule groups (\"Choose a protein\"\n\t\t// before \"Select two sides\") is noise, so an unpinned rule falls back to\n\t\t// the order it was created in.\n\t\t.order(\"sort_index\", ORDER_CURATED)\n\t\t.order(\"rule_id\", ORDER_CURATED);\n\n\tif (rulesError) {\n\t\tthrow rulesError;\n\t}\n\n\treturn (ruleData ?? []).map((rule: any) => ({\n\t\trule_id: rule.rule_id,\n\t\tgroup_name: rule.group_name,\n\t\tmin_selection: rule.min_selection,\n\t\tmax_selection: rule.max_selection,\n\t\tincluded_quantity: rule.included_quantity,\n\t\tis_portionable: rule.is_portionable,\n\t\tcategory_id: rule.category_id,\n\t\titem_id: rule.item_id,\n\t\tbase_item_id: rule.base_item_id,\n\t\tsort_index: rule.sort_index ?? null,\n\n\t\t// Sorted here rather than in the query because the display name lives on\n\t\t// the joined components/items row. Every caller that renders these arrays\n\t\t// straight — the legacy customization modals, a site's server-rendered\n\t\t// item page — inherits the order without touching its own code.\n\t\tcomponents: (rule.rule_components ?? [])\n\t\t\t.map((component: any) => ({\n\t\t\t\tcomponent_id: component.component_id,\n\t\t\t\tupcharge_price: component.upcharge_price ?? 0,\n\t\t\t\tname: component.components?.name ?? \"\",\n\t\t\t\tdescription: component.components?.description ?? undefined,\n\t\t\t\tserving_weight: component.components?.serving_weight ?? undefined,\n\t\t\t\tsort_index: component.sort_index ?? null,\n\t\t\t\t...(useBehaviors && { behavior: component.component_quantity_behaviors ?? undefined }),\n\t\t\t}))\n\t\t\t.sort(byCuratedThenName),\n\n\t\titems: (rule.rule_items ?? [])\n\t\t\t.map((item: any) => ({\n\t\t\t\titem_id: item.item_id,\n\t\t\t\tname: item.items?.name ?? \"\",\n\t\t\t\tdescription: item.items?.description ?? undefined,\n\t\t\t\tupcharge_price: item.upcharge_price ?? 0,\n\t\t\t\tsort_index: item.sort_index ?? null,\n\t\t\t\t...(useBehaviors && { behavior: item.component_quantity_behaviors ?? undefined }),\n\t\t\t}))\n\t\t\t.sort(byCuratedThenName),\n\t}));\n}\n\nfunction buildMenu(\n\titems: ItemVariant[],\n\tbaseItems: BaseItem[],\n\tcategories: Category[],\n\tcomponents: Component[],\n\trules: Rule[],\n): Menu {\n\tconst menu: Menu = {\n\t\titems: [],\n\t\tcategories: [],\n\t\trules,\n\t\tcomponents,\n\t};\n\n\tmenu.items = baseItems.map((baseItem) => {\n\t\tconst categoryRules = rules.filter((rule) => rule.category_id === baseItem.category_id);\n\t\tconst baseItemRules = rules.filter((rule) => rule.base_item_id === baseItem.base_item_id);\n\t\tconst category = categories.find((c) => c.category_id === baseItem.category_id);\n\t\treturn {\n\t\t\t...baseItem,\n\t\t\tis_catering: category?.is_catering ?? false,\n\t\t\tvariants: items\n\t\t\t\t.filter((item) => item.base_item_id === baseItem.base_item_id)\n\t\t\t\t.map((item) => ({\n\t\t\t\t\t...item,\n\t\t\t\t\tcomponents: components.filter(\n\t\t\t\t\t\t(component) => component.item_id === item.item_id,\n\t\t\t\t\t),\n\t\t\t\t\trules: [\n\t\t\t\t\t\t...categoryRules,\n\t\t\t\t\t\t...baseItemRules,\n\t\t\t\t\t\t...rules.filter((rule) => rule.item_id === item.item_id),\n\t\t\t\t\t],\n\t\t\t\t})),\n\t\t\trules: baseItemRules,\n\t\t};\n\t});\n\n\tmenu.categories = categories.map((category) => ({\n\t\t...category,\n\t\trules: rules.filter((rule) => rule.category_id === category.category_id),\n\t}));\n\n\treturn menu;\n}\n\nexport function getBaseItemFromVariant(\n\tmenu: MenuItem[],\n\tvariant: ItemVariant,\n\tbaseItemId?: number,\n): MenuItem | undefined {\n\tif (baseItemId != null) {\n\t\treturn menu.find((i) => i.base_item_id === baseItemId);\n\t}\n\treturn menu.find((i) => i.variants.some((v) => v.item_id === variant.item_id));\n}\n\nexport function getMenuItemByVariantId(menu: MenuItem[], variantId: number): MenuItem | undefined {\n\treturn menu.find((item) =>\n\t\titem.variants?.some((v) => v.item_id === variantId)\n\t);\n}\n\nexport function filterBySchedule(\n\tpromos: Promotion[],\n\tplatform: Platforms,\n\tlocationId: number | undefined,\n\tnow: Date = new Date(),\n): Promotion[] {\n\treturn promos.filter((promo) => {\n\t\tif (promo.start_date && new Date(promo.start_date) > now) return false;\n\t\tif (promo.end_date && new Date(promo.end_date) < now) return false;\n\n\t\tif (promo.platforms && promo.platforms.length > 0) {\n\t\t\tif (!promo.platforms.includes(platform)) return false;\n\t\t}\n\n\t\tif (!promo.is_automatic) return false;\n\n\t\tif (promo.location_id != null && locationId !== undefined) {\n\t\t\tif (promo.location_id !== locationId) return false;\n\t\t} else if (promo.location_id != null && locationId === undefined) {\n\t\t\treturn false;\n\t\t}\n\n\t\tif (promo.schedule_details_json) {\n\t\t\tconst { interval, days, start_time, end_time } =\n\t\t\t\tpromo.schedule_details_json;\n\n\t\t\tif (interval === \"weekly\" && days && !days.includes(getDay(now) + 1)) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tconst dayStart = startOfDay(now);\n\t\t\tif (start_time && isBefore(now, parse(start_time, \"HH:mm\", dayStart))) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif (end_time && isAfter(now, parse(end_time, \"HH:mm\", dayStart))) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t});\n}\n\nexport async function getActivePromotions(\n\tsupabase: SupabaseClient,\n\tfranchiseId: number,\n\tplatform: Platforms,\n\tlocationId?: number,\n): Promise<Promotion[]> {\n\tconst { data, error } = await supabase\n\t\t.from(\"promotions\")\n\t\t.select(\"*\")\n\t\t.eq(\"is_active\", true)\n\t\t.eq(\"franchise_id\", franchiseId);\n\n\tif (error) throw error;\n\n\treturn filterBySchedule((data || []) as Promotion[], platform, locationId);\n}\n","export { menuImageUrl } from \"@zorgo/universal/utils/menuUtils\";\nexport type { BaseItemImage } from \"@zorgo/universal/interfaces\";\n\n/**\n * The variant widths prebuild emits, for menu item images and site-authored\n * photos alike. The loader below and the encoder in internal/images.ts read the\n * same list, so a requested width can never snap to a rung that was never\n * written. 1920 is the top rung because a full-bleed hero on a retina laptop\n * asks for it; `withoutEnlargement` means a smaller source just stops early.\n */\nexport const IMAGE_WIDTHS = [384, 640, 1080, 1920] as const;\n\n/** @deprecated The ladder is no longer item-only — use {@link IMAGE_WIDTHS}. */\nexport const ITEM_IMAGE_WIDTHS = IMAGE_WIDTHS;\n\n/** Public prefix for everything prebuild encodes. */\nexport const IMAGE_PREFIX = \"/images/\";\n\n/** Public prefix menuImageUrl returns for a localized item image. */\nexport const ITEM_IMAGE_PREFIX = \"/images/items/\";\n\n/**\n * next/image custom loader (`images.loaderFile` in a scaffolded site's\n * next.config.ts).\n *\n * An extension-less path under `/images/` is prebuild's contract for \"this has\n * a variant ladder\" — it gets the nearest rung that covers the requested width.\n * That covers both menu item images (`/images/items/<key>`) and the site's own\n * photos (`/images/food`, encoded from `assets/images/food.JPG`).\n *\n * Everything else passes through untouched and is served as-is: a bucket URL\n * for an item prebuild could not localize, the prebuilt logo, a video poster —\n * anything that still carries a file extension.\n *\n * Sync and pure by contract: next/image calls it during render for every srcset\n * candidate.\n */\nexport default function imageLoader({\n\tsrc,\n\twidth,\n}: {\n\tsrc: string;\n\twidth: number;\n}): string {\n\tif (!src.startsWith(IMAGE_PREFIX) || /\\.[a-z0-9]+$/i.test(src)) return src;\n\tconst rung =\n\t\tIMAGE_WIDTHS.find((candidate) => candidate >= width) ??\n\t\tIMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];\n\treturn `${src}-${rung}.avif`;\n}\n\nexport { imageLoader, imageLoader as itemImageLoader };\n","import type { Menu } from \"@zorgo/universal/utils/menuUtils\";\nimport type { ItemVariant, Location, MenuItem } from \"@zorgo/universal/interfaces\";\nimport { wallTime } from \"@zorgo/universal/utils/locationOrderingAvailability\";\n\n/** Franchise-level facts for the Restaurant node. Everything but `name` is\n * optional so the schema degrades gracefully when a field is missing. */\nexport interface RestaurantInfo {\n\tname: string;\n\turl?: string;\n\timage?: string;\n\tdescription?: string;\n\tservesCuisine?: string[];\n\t/** Public locations from `lib/generated/locations.ts`. One location's facts\n\t * land on the Restaurant node itself; several become linked branches. */\n\tlocations?: Location[];\n}\n\nconst DAYS = [\n\t\"Monday\",\n\t\"Tuesday\",\n\t\"Wednesday\",\n\t\"Thursday\",\n\t\"Friday\",\n\t\"Saturday\",\n\t\"Sunday\",\n] as const;\n\n/** Stable node ids so the parent and its branches can reference each other. */\nfunction restaurantId(info: RestaurantInfo) {\n\treturn `${info.url ?? \"\"}#restaurant`;\n}\nfunction branchId(info: RestaurantInfo, location: Location) {\n\treturn `${info.url ?? \"\"}#location-${location.location_id}`;\n}\n\n/**\n * One OpeningHoursSpecification per distinct weekly window, with the days that\n * share it grouped into `dayOfWeek`. Days with no hours set are simply absent,\n * which is how schema.org expresses \"closed\".\n */\nexport function openingHoursSpecification(location: Location) {\n\tconst byWindow = new Map<string, string[]>();\n\tfor (const day of DAYS) {\n\t\tconst key = day.toLowerCase();\n\t\tconst open = location[`${key}_open` as keyof Location] as string | undefined;\n\t\tconst close = location[`${key}_close` as keyof Location] as string | undefined;\n\t\tif (!open || !close) continue;\n\t\tconst window = `${wallTime(open)}|${wallTime(close)}`;\n\t\tbyWindow.set(window, [...(byWindow.get(window) ?? []), `https://schema.org/${day}`]);\n\t}\n\n\treturn [...byWindow].map(([window, dayOfWeek]) => {\n\t\tconst [opens, closes] = window.split(\"|\");\n\t\treturn { \"@type\": \"OpeningHoursSpecification\", dayOfWeek, opens, closes };\n\t});\n}\n\n/**\n * `123 Main St, Springfield, IL 62704` — street, city, two-letter state, ZIP,\n * with an optional +4 and an optional trailing country. Anchored at both ends\n * and deliberately strict: everything before the last two commas is the street,\n * so a unit (\"… Ste 4, Springfield, IL 62704\") still parses.\n */\nconst US_ADDRESS = /^(.+),\\s*([^,]+),\\s*([A-Z]{2})\\s+(\\d{5}(?:-\\d{4})?)(?:,\\s*USA?)?$/;\n\n/**\n * schema.org accepts `address` as either Text or a PostalAddress node, but\n * Google's LocalBusiness rich results only read the structured form — a bare\n * string means the branch nodes never qualify.\n *\n * `address` is still one staff-typed column, so this parses rather than reads,\n * and it only parses what it can prove: a string that does not match the US\n * shape exactly falls back to the Text form it has always shipped. A wrong\n * `addressRegion` in structured data is worse than no `addressRegion` — it is a\n * fact we are asserting to Google — so the regex declines rather than guesses.\n *\n * ponytail: US-only, because every franchise is. Drop the fallback and read real\n * columns the day `locations` stores the parts separately.\n */\nfunction postalAddress(address: string): Record<string, unknown> | string {\n\tconst match = US_ADDRESS.exec(address.trim());\n\tif (!match) return address;\n\tconst [, streetAddress, addressLocality, addressRegion, postalCode] = match;\n\t// Unreachable — all four groups are required by the pattern — but under\n\t// noUncheckedIndexedAccess a capture is `string | undefined`, and declining\n\t// is already what this function does when it cannot prove the shape.\n\tif (!streetAddress || !addressLocality || !addressRegion || !postalCode) return address;\n\treturn {\n\t\t\"@type\": \"PostalAddress\",\n\t\tstreetAddress: streetAddress.trim(),\n\t\taddressLocality: addressLocality.trim(),\n\t\taddressRegion,\n\t\tpostalCode,\n\t\taddressCountry: \"US\",\n\t};\n}\n\n/** address / geo / telephone / hours — the facts shared by the single-location\n * Restaurant node and every branch node. */\nfunction locationFacts(location: Location) {\n\tconst facts: Record<string, unknown> = {};\n\tif (location.address) facts.address = postalAddress(location.address);\n\tif (location.phone) facts.telephone = location.phone;\n\tif (location.lat != null && location.lng != null) {\n\t\tfacts.geo = {\n\t\t\t\"@type\": \"GeoCoordinates\",\n\t\t\tlatitude: location.lat,\n\t\t\tlongitude: location.lng,\n\t\t};\n\t}\n\tconst hours = openingHoursSpecification(location);\n\tif (hours.length) facts.openingHoursSpecification = hours;\n\treturn facts;\n}\n\n/**\n * The branch node for one location, for that location's page. Carries the same\n * address/geo/hours facts as the parent and links back to it via\n * `parentOrganization`. Pure — safe to run at build time.\n */\nexport function buildLocationJsonLd(location: Location, info: RestaurantInfo) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\t\"@id\": branchId(info, location),\n\t\tname: `${info.name} — ${location.name}`,\n\t\tparentOrganization: { \"@id\": restaurantId(info) },\n\t\t...locationFacts(location),\n\t};\n\tif (info.image) node.image = info.image;\n\treturn node;\n}\n\n// cents -> \"5.49\" (schema.org Offer.price is a string in major currency units)\nfunction priceString(cents: number): string {\n\treturn (cents / 100).toFixed(2);\n}\n\nfunction variantOffer(variant: ItemVariant) {\n\treturn {\n\t\t\"@type\": \"Offer\",\n\t\tprice: priceString(variant.display_price ?? variant.price),\n\t\tpriceCurrency: \"USD\",\n\t};\n}\n\nfunction menuItemNode(item: MenuItem) {\n\tconst node: Record<string, unknown> = {\n\t\t\"@type\": \"MenuItem\",\n\t\tname: item.name,\n\t};\n\tif (item.description) node.description = item.description;\n\t// one Offer for a single price, an array when the item has size/variant prices\n\tconst offers = item.variants.map(variantOffer);\n\tif (offers.length === 1) node.offers = offers[0];\n\telse if (offers.length > 1) node.offers = offers;\n\treturn node;\n}\n\n/**\n * Builds a standalone schema.org JSON-LD object for a single menu item, for\n * embedding in that item's `/menu/[slug]` page `<head>`. Reuses `menuItemNode`\n * (the same node shape used inside the Restaurant menu) and adds the\n * `@context` a top-level node needs. Pure — safe to run at build time.\n */\nexport function buildMenuItemJsonLd(item: MenuItem) {\n\treturn { \"@context\": \"https://schema.org\", ...menuItemNode(item) };\n}\n\n/**\n * Builds a schema.org Restaurant JSON-LD object from a prebuilt Menu and\n * franchise info. Categories become MenuSections; the items in each become\n * MenuItems. Pure — same inputs, same output — so it can run at prebuild time\n * and be embedded straight into the site's <head>.\n */\nexport function buildRestaurantJsonLd(menu: Menu, info: RestaurantInfo) {\n\tconst hasMenuSection = menu.categories.map((category) => {\n\t\tconst section: Record<string, unknown> = {\n\t\t\t\"@type\": \"MenuSection\",\n\t\t\tname: category.name,\n\t\t};\n\t\tconst items = menu.items\n\t\t\t.filter((item) => item.category_id === category.category_id)\n\t\t\t.map(menuItemNode);\n\t\tif (items.length) section.hasMenuItem = items;\n\t\treturn section;\n\t});\n\n\tconst restaurant: Record<string, unknown> = {\n\t\t\"@context\": \"https://schema.org\",\n\t\t\"@type\": \"Restaurant\",\n\t\tname: info.name,\n\t};\n\tif (info.url) restaurant.url = info.url;\n\tif (info.image) restaurant.image = info.image;\n\tif (info.description) restaurant.description = info.description;\n\tif (info.servesCuisine?.length) restaurant.servesCuisine = info.servesCuisine;\n\n\t// A single-location franchise *is* its storefront, so its facts belong on\n\t// the Restaurant node. Several locations get referenced as branches instead\n\t// — the full branch nodes live on the location pages, not duplicated here.\n\tconst locations = info.locations ?? [];\n\tif (locations.length === 1) {\n\t\tObject.assign(restaurant, locationFacts(locations[0]!));\n\t} else if (locations.length > 1) {\n\t\trestaurant[\"@id\"] = restaurantId(info);\n\t\trestaurant.subOrganization = locations.map((location) => ({\n\t\t\t\"@type\": \"Restaurant\",\n\t\t\t\"@id\": branchId(info, location),\n\t\t\tname: location.name,\n\t\t}));\n\t}\n\n\trestaurant.hasMenu = {\n\t\t\"@type\": \"Menu\",\n\t\tname: \"Menu\",\n\t\thasMenuSection,\n\t};\n\n\treturn restaurant;\n}\n"],"mappings":"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","names":[]}