@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":["../../zorgo/universal/constants.ts","../../zorgo/universal/utils/menuUtils.ts","../src/images.ts"],"sourcesContent":["// 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 { 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"],"mappings":";AAMO,IAAM,eACZ,QAAQ,IAAI,4BACZ,QAAQ,IAAI,4BACZ;AAEM,IAAM,oBACZ,QAAQ,IAAI,iCACZ,QAAQ,IAAI,iCACZ;AAaM,IAAM,eACZ,QAAQ,IAAI,aAAa,eACtB,+CACA;;;ACsBJ,IAAM,WAAW,OAAO;AAghBjB,SAAS,aACf,OACA,WAAW,OACK;AAChB,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,MAAM,aAAa,CAAC,SAAU,QAAO,iBAAiB,MAAM,SAAS;AACzE,QAAM,OAAO,MAAM,kBAAkB,MAAM;AAC3C,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,GAAG,YAAY,4CAA4C,IAAI;AACvE;;;ACnkBO,IAAM,eAAe,CAAC,KAAK,KAAK,MAAM,IAAI;AAG1C,IAAM,oBAAoB;AAG1B,IAAM,eAAe;AAGrB,IAAM,oBAAoB;AAkBlB,SAAR,YAA6B;AAAA,EACnC;AAAA,EACA;AACD,GAGW;AACV,MAAI,CAAC,IAAI,WAAW,YAAY,KAAK,gBAAgB,KAAK,GAAG,EAAG,QAAO;AACvE,QAAM,OACL,aAAa,KAAK,CAAC,cAAc,aAAa,KAAK,KACnD,aAAa,aAAa,SAAS,CAAC;AACrC,SAAO,GAAG,GAAG,IAAI,IAAI;AACtB;","names":[]}
1
+ {"version":3,"sources":["../../zorgo/universal/constants.ts","../../zorgo/universal/utils/menuUtils.ts","../src/images.ts"],"sourcesContent":["// 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 { 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"],"mappings":";AAMO,IAAM,eACZ,QAAQ,IAAI,4BACZ,QAAQ,IAAI,4BACZ;AAEM,IAAM,oBACZ,QAAQ,IAAI,iCACZ,QAAQ,IAAI,iCACZ;AAaM,IAAM,eACZ,QAAQ,IAAI,aAAa,eACtB,+CACA;;;ACsBJ,IAAM,WAAW,OAAO;AAghBjB,SAAS,aACf,OACA,WAAW,OACK;AAChB,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,MAAM,aAAa,CAAC,SAAU,QAAO,iBAAiB,MAAM,SAAS;AACzE,QAAM,OAAO,MAAM,kBAAkB,MAAM;AAC3C,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,GAAG,YAAY,4CAA4C,IAAI;AACvE;;;ACnkBO,IAAM,eAAe,CAAC,KAAK,KAAK,MAAM,IAAI;AAG1C,IAAM,oBAAoB;AAG1B,IAAM,eAAe;AAGrB,IAAM,oBAAoB;AAkBlB,SAAR,YAA6B;AAAA,EACnC;AAAA,EACA;AACD,GAGW;AACV,MAAI,CAAC,IAAI,WAAW,YAAY,KAAK,gBAAgB,KAAK,GAAG,EAAG,QAAO;AACvE,QAAM,OACL,aAAa,KAAK,CAAC,cAAc,aAAa,KAAK,KACnD,aAAa,aAAa,SAAS,CAAC;AACrC,SAAO,GAAG,GAAG,IAAI,IAAI;AACtB;","names":[]}
package/dist/index.d.mts CHANGED
@@ -1392,6 +1392,8 @@ interface ItemizedAddOn {
1392
1392
  interface ItemizedLine {
1393
1393
  name: string;
1394
1394
  quantity: number;
1395
+ /** The item's price before any selection upcharges. */
1396
+ basePrice: number;
1395
1397
  /** One unit priced the way the line is: base + selection upcharges. */
1396
1398
  unitPrice: number;
1397
1399
  /** unitPrice * quantity — equals calculateItemPrice(item). */
@@ -1425,6 +1427,9 @@ interface ItemizedOrder {
1425
1427
  */
1426
1428
  declare function itemizeOrder(order: Order, promotion?: Promotion, tip?: number): ItemizedOrder;
1427
1429
 
1430
+ declare function formatCateringLineLabel(line: ItemizedLine): string;
1431
+ declare function formatAddOnPrice(addOn: ItemizedAddOn, perPerson?: boolean | null): string;
1432
+
1428
1433
  interface OrderItemizedClasses {
1429
1434
  root: string;
1430
1435
  line: string;
@@ -1447,8 +1452,6 @@ declare const ORDER_ITEMIZED_DEFAULT_ITEM_CLASSES: OrderItemizedClasses;
1447
1452
  * dist/components.mjs, so a class named only in the defaults never reaches it).
1448
1453
  */
1449
1454
  declare function mergeOrderItemizedItemClasses(overrides?: OrderItemizedItemClasses): OrderItemizedClasses;
1450
- declare function formatCateringLineLabel(line: ItemizedLine): string;
1451
- declare function formatAddOnPrice(addOn: ItemizedAddOn, perPerson?: boolean | null): string;
1452
1455
  interface OrderItemizedProps {
1453
1456
  order: Order;
1454
1457
  promotion?: Promotion;
package/dist/index.d.ts CHANGED
@@ -1392,6 +1392,8 @@ interface ItemizedAddOn {
1392
1392
  interface ItemizedLine {
1393
1393
  name: string;
1394
1394
  quantity: number;
1395
+ /** The item's price before any selection upcharges. */
1396
+ basePrice: number;
1395
1397
  /** One unit priced the way the line is: base + selection upcharges. */
1396
1398
  unitPrice: number;
1397
1399
  /** unitPrice * quantity — equals calculateItemPrice(item). */
@@ -1425,6 +1427,9 @@ interface ItemizedOrder {
1425
1427
  */
1426
1428
  declare function itemizeOrder(order: Order, promotion?: Promotion, tip?: number): ItemizedOrder;
1427
1429
 
1430
+ declare function formatCateringLineLabel(line: ItemizedLine): string;
1431
+ declare function formatAddOnPrice(addOn: ItemizedAddOn, perPerson?: boolean | null): string;
1432
+
1428
1433
  interface OrderItemizedClasses {
1429
1434
  root: string;
1430
1435
  line: string;
@@ -1447,8 +1452,6 @@ declare const ORDER_ITEMIZED_DEFAULT_ITEM_CLASSES: OrderItemizedClasses;
1447
1452
  * dist/components.mjs, so a class named only in the defaults never reaches it).
1448
1453
  */
1449
1454
  declare function mergeOrderItemizedItemClasses(overrides?: OrderItemizedItemClasses): OrderItemizedClasses;
1450
- declare function formatCateringLineLabel(line: ItemizedLine): string;
1451
- declare function formatAddOnPrice(addOn: ItemizedAddOn, perPerson?: boolean | null): string;
1452
1455
  interface OrderItemizedProps {
1453
1456
  order: Order;
1454
1457
  promotion?: Promotion;