@zorgo/next 1.1.6 → 1.3.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.
- package/dist/cli.js +344 -1
- package/dist/cli.js.map +1 -0
- package/dist/cli.mjs +344 -1
- package/dist/cli.mjs.map +1 -0
- package/dist/components.d.mts +315 -87
- package/dist/components.d.ts +315 -87
- package/dist/components.js +1216 -820
- package/dist/components.js.map +1 -1
- package/dist/components.mjs +1195 -832
- package/dist/components.mjs.map +1 -1
- package/dist/images.d.mts +36 -0
- package/dist/images.d.ts +36 -0
- package/dist/images.js +62 -0
- package/dist/images.js.map +1 -0
- package/dist/images.mjs +32 -0
- package/dist/images.mjs.map +1 -0
- package/dist/index.d.mts +175 -6
- package/dist/index.d.ts +175 -6
- package/dist/index.js +162 -1
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +162 -1
- package/dist/index.mjs.map +1 -0
- package/dist/item-page.d.mts +151 -0
- package/dist/item-page.d.ts +151 -0
- package/dist/item-page.js +166 -0
- package/dist/item-page.js.map +1 -0
- package/dist/item-page.mjs +129 -0
- package/dist/item-page.mjs.map +1 -0
- package/dist/seo.d.mts +31 -1
- package/dist/seo.d.ts +31 -1
- package/dist/server.js +541 -1
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +504 -1
- package/dist/server.mjs.map +1 -0
- package/dist/slugs.js.map +1 -1
- package/dist/slugs.mjs.map +1 -1
- package/dist/tree/app/components/ClientItemForm.tsx +7 -0
- package/dist/tree/app/components/Header.tsx +3 -0
- package/dist/tree/app/components/ModalRegistry.tsx +3 -2
- package/dist/tree/app/components/index.ts +2 -1
- package/dist/tree/app/globals.css.gen.ts +1 -0
- package/dist/tree/app/menu/[slug]/page.tsx +16 -48
- package/dist/tree/gitignore +1 -0
- package/dist/tree/image-loader.ts +4 -0
- package/dist/tree/next.config.ts +12 -0
- package/package.json +11 -1
- package/dist/cli.d.mts +0 -9
- package/dist/cli.d.ts +0 -9
- package/dist/tree/app/menu/[slug]/ItemCustomizationClient.tsx +0 -18
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface BaseItemImage {
|
|
2
|
+
image_id: number;
|
|
3
|
+
base_item_id: number;
|
|
4
|
+
alias: string;
|
|
5
|
+
raw_path?: string | null;
|
|
6
|
+
processed_path?: string | null;
|
|
7
|
+
sort_index?: number;
|
|
8
|
+
local_key?: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare function menuImageUrl(image?: BaseItemImage | null, absolute?: boolean): string | null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The variant widths prebuild emits. The loader below and the encoder in
|
|
15
|
+
* internal/itemImages.ts read the same list, so a requested width can never
|
|
16
|
+
* snap to a rung that was never written.
|
|
17
|
+
*/
|
|
18
|
+
declare const ITEM_IMAGE_WIDTHS: readonly [384, 640, 1080];
|
|
19
|
+
/** Public prefix menuImageUrl returns for a localized image. */
|
|
20
|
+
declare const ITEM_IMAGE_PREFIX = "/images/items/";
|
|
21
|
+
/**
|
|
22
|
+
* next/image custom loader (`images.loaderFile` in a scaffolded site's
|
|
23
|
+
* next.config.ts). Prebuilt images get the nearest rung that covers the
|
|
24
|
+
* requested width; everything else — a bucket URL for an image prebuild could
|
|
25
|
+
* not localize, the logo, any site-authored asset — passes through untouched
|
|
26
|
+
* and is simply served unoptimized.
|
|
27
|
+
*
|
|
28
|
+
* Sync and pure by contract: next/image calls it during render for every
|
|
29
|
+
* srcset candidate.
|
|
30
|
+
*/
|
|
31
|
+
declare function itemImageLoader({ src, width, }: {
|
|
32
|
+
src: string;
|
|
33
|
+
width: number;
|
|
34
|
+
}): string;
|
|
35
|
+
|
|
36
|
+
export { type BaseItemImage, ITEM_IMAGE_PREFIX, ITEM_IMAGE_WIDTHS, itemImageLoader as default, itemImageLoader, menuImageUrl };
|
package/dist/images.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface BaseItemImage {
|
|
2
|
+
image_id: number;
|
|
3
|
+
base_item_id: number;
|
|
4
|
+
alias: string;
|
|
5
|
+
raw_path?: string | null;
|
|
6
|
+
processed_path?: string | null;
|
|
7
|
+
sort_index?: number;
|
|
8
|
+
local_key?: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare function menuImageUrl(image?: BaseItemImage | null, absolute?: boolean): string | null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The variant widths prebuild emits. The loader below and the encoder in
|
|
15
|
+
* internal/itemImages.ts read the same list, so a requested width can never
|
|
16
|
+
* snap to a rung that was never written.
|
|
17
|
+
*/
|
|
18
|
+
declare const ITEM_IMAGE_WIDTHS: readonly [384, 640, 1080];
|
|
19
|
+
/** Public prefix menuImageUrl returns for a localized image. */
|
|
20
|
+
declare const ITEM_IMAGE_PREFIX = "/images/items/";
|
|
21
|
+
/**
|
|
22
|
+
* next/image custom loader (`images.loaderFile` in a scaffolded site's
|
|
23
|
+
* next.config.ts). Prebuilt images get the nearest rung that covers the
|
|
24
|
+
* requested width; everything else — a bucket URL for an image prebuild could
|
|
25
|
+
* not localize, the logo, any site-authored asset — passes through untouched
|
|
26
|
+
* and is simply served unoptimized.
|
|
27
|
+
*
|
|
28
|
+
* Sync and pure by contract: next/image calls it during render for every
|
|
29
|
+
* srcset candidate.
|
|
30
|
+
*/
|
|
31
|
+
declare function itemImageLoader({ src, width, }: {
|
|
32
|
+
src: string;
|
|
33
|
+
width: number;
|
|
34
|
+
}): string;
|
|
35
|
+
|
|
36
|
+
export { type BaseItemImage, ITEM_IMAGE_PREFIX, ITEM_IMAGE_WIDTHS, itemImageLoader as default, itemImageLoader, menuImageUrl };
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/images.ts
|
|
21
|
+
var images_exports = {};
|
|
22
|
+
__export(images_exports, {
|
|
23
|
+
ITEM_IMAGE_PREFIX: () => ITEM_IMAGE_PREFIX,
|
|
24
|
+
ITEM_IMAGE_WIDTHS: () => ITEM_IMAGE_WIDTHS,
|
|
25
|
+
default: () => itemImageLoader,
|
|
26
|
+
itemImageLoader: () => itemImageLoader,
|
|
27
|
+
menuImageUrl: () => menuImageUrl
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(images_exports);
|
|
30
|
+
|
|
31
|
+
// ../zorgo/universal/constants.ts
|
|
32
|
+
var SUPABASE_URL = "https://dhnpoxorllfdobwftkyb.supabase.co";
|
|
33
|
+
var API_BASE_URL = process.env.NODE_ENV === "production" ? "https://api.zorgotech.com" : "http://localhost:3000";
|
|
34
|
+
|
|
35
|
+
// ../zorgo/universal/utils/menuUtils.ts
|
|
36
|
+
function menuImageUrl(image, absolute = false) {
|
|
37
|
+
if (!image) return null;
|
|
38
|
+
if (image.local_key && !absolute) return `/images/items/${image.local_key}`;
|
|
39
|
+
const path = image.processed_path ?? image.raw_path;
|
|
40
|
+
if (!path) return null;
|
|
41
|
+
return `${SUPABASE_URL}/storage/v1/object/public/product-images/${path}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/images.ts
|
|
45
|
+
var ITEM_IMAGE_WIDTHS = [384, 640, 1080];
|
|
46
|
+
var ITEM_IMAGE_PREFIX = "/images/items/";
|
|
47
|
+
function itemImageLoader({
|
|
48
|
+
src,
|
|
49
|
+
width
|
|
50
|
+
}) {
|
|
51
|
+
if (!src.startsWith(ITEM_IMAGE_PREFIX)) return src;
|
|
52
|
+
const rung = ITEM_IMAGE_WIDTHS.find((candidate) => candidate >= width) ?? ITEM_IMAGE_WIDTHS[ITEM_IMAGE_WIDTHS.length - 1];
|
|
53
|
+
return `${src}-${rung}.avif`;
|
|
54
|
+
}
|
|
55
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
56
|
+
0 && (module.exports = {
|
|
57
|
+
ITEM_IMAGE_PREFIX,
|
|
58
|
+
ITEM_IMAGE_WIDTHS,
|
|
59
|
+
itemImageLoader,
|
|
60
|
+
menuImageUrl
|
|
61
|
+
});
|
|
62
|
+
//# sourceMappingURL=images.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/images.ts","../../zorgo/universal/constants.ts","../../zorgo/universal/utils/menuUtils.ts"],"sourcesContent":["export { menuImageUrl } from \"@zorgo/universal/utils/menuUtils\";\nexport type { BaseItemImage } from \"@zorgo/universal/interfaces\";\n\n/**\n * The variant widths prebuild emits. The loader below and the encoder in\n * internal/itemImages.ts read the same list, so a requested width can never\n * snap to a rung that was never written.\n */\nexport const ITEM_IMAGE_WIDTHS = [384, 640, 1080] as const;\n\n/** Public prefix menuImageUrl returns for a localized 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). Prebuilt images get the nearest rung that covers the\n * requested width; everything else — a bucket URL for an image prebuild could\n * not localize, the logo, any site-authored asset — passes through untouched\n * and is simply served unoptimized.\n *\n * Sync and pure by contract: next/image calls it during render for every\n * srcset candidate.\n */\nexport default function itemImageLoader({\n\tsrc,\n\twidth,\n}: {\n\tsrc: string;\n\twidth: number;\n}): string {\n\tif (!src.startsWith(ITEM_IMAGE_PREFIX)) return src;\n\tconst rung =\n\t\tITEM_IMAGE_WIDTHS.find((candidate) => candidate >= width) ??\n\t\tITEM_IMAGE_WIDTHS[ITEM_IMAGE_WIDTHS.length - 1];\n\treturn `${src}-${rung}.avif`;\n}\n\nexport { itemImageLoader };\n","export const SUPABASE_URL = \"https://dhnpoxorllfdobwftkyb.supabase.co\";\n\nexport const 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\nexport const API_BASE_URL =\n\tprocess.env.NODE_ENV === \"production\"\n\t\t? \"https://api.zorgotech.com\"\n\t\t: \"http://localhost:3000\";\n\n\n// replace instances of API_BASE_URL with env enum\nexport enum Environments {\n\tProduction = \"https://api.zorgotech.com\",\n\tStaging = \"http://api.zorgo.ai\",\n\tDevelopment = \"http://localhost:3000\",\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},\n\tPOS_METHOD_ID: 3,\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),\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\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 } = 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.not(\"per_person\", \"is\", null)\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\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\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\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\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 */\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})),\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})),\n\t];\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, components(name, description, serving_weight)\",\n\t\tbehaviorSelect,\n\t].filter(Boolean).join(\", \");\n\n\tconst ruleItemsSelect = [\n\t\t\"item_id, upcharge_price, 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 rule_components (${ruleComponentsSelect}),\n rule_items (${ruleItemsSelect})\n `,\n\t\t)\n\t\t.or(orFilter);\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\n\t\tcomponents: (rule.rule_components ?? []).map((component: any) => ({\n\t\t\tcomponent_id: component.component_id,\n\t\t\tupcharge_price: component.upcharge_price ?? 0,\n\t\t\tname: component.components?.name ?? \"\",\n\t\t\tdescription: component.components?.description ?? undefined,\n\t\t\tserving_weight: component.components?.serving_weight ?? undefined,\n\t\t\t...(useBehaviors && { behavior: component.component_quantity_behaviors ?? undefined }),\n\t\t})),\n\n\t\titems: (rule.rule_items ?? []).map((item: any) => ({\n\t\t\titem_id: item.item_id,\n\t\t\tname: item.items?.name ?? \"\",\n\t\t\tdescription: item.items?.description ?? undefined,\n\t\t\tupcharge_price: item.upcharge_price ?? 0,\n\t\t\t...(useBehaviors && { behavior: item.component_quantity_behaviors ?? undefined }),\n\t\t})),\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\treturn {\n\t\t\tis_catering: (baseItem as any).per_person !== null,\n\t\t\t...baseItem,\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"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAO,IAAM,eAAe;AASrB,IAAM,eACZ,QAAQ,IAAI,aAAa,eACtB,8BACA;;;AC8gBG,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;;;AF3hBO,IAAM,oBAAoB,CAAC,KAAK,KAAK,IAAI;AAGzC,IAAM,oBAAoB;AAYlB,SAAR,gBAAiC;AAAA,EACvC;AAAA,EACA;AACD,GAGW;AACV,MAAI,CAAC,IAAI,WAAW,iBAAiB,EAAG,QAAO;AAC/C,QAAM,OACL,kBAAkB,KAAK,CAAC,cAAc,aAAa,KAAK,KACxD,kBAAkB,kBAAkB,SAAS,CAAC;AAC/C,SAAO,GAAG,GAAG,IAAI,IAAI;AACtB;","names":[]}
|
package/dist/images.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ../zorgo/universal/constants.ts
|
|
2
|
+
var SUPABASE_URL = "https://dhnpoxorllfdobwftkyb.supabase.co";
|
|
3
|
+
var API_BASE_URL = process.env.NODE_ENV === "production" ? "https://api.zorgotech.com" : "http://localhost:3000";
|
|
4
|
+
|
|
5
|
+
// ../zorgo/universal/utils/menuUtils.ts
|
|
6
|
+
function menuImageUrl(image, absolute = false) {
|
|
7
|
+
if (!image) return null;
|
|
8
|
+
if (image.local_key && !absolute) return `/images/items/${image.local_key}`;
|
|
9
|
+
const path = image.processed_path ?? image.raw_path;
|
|
10
|
+
if (!path) return null;
|
|
11
|
+
return `${SUPABASE_URL}/storage/v1/object/public/product-images/${path}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// src/images.ts
|
|
15
|
+
var ITEM_IMAGE_WIDTHS = [384, 640, 1080];
|
|
16
|
+
var ITEM_IMAGE_PREFIX = "/images/items/";
|
|
17
|
+
function itemImageLoader({
|
|
18
|
+
src,
|
|
19
|
+
width
|
|
20
|
+
}) {
|
|
21
|
+
if (!src.startsWith(ITEM_IMAGE_PREFIX)) return src;
|
|
22
|
+
const rung = ITEM_IMAGE_WIDTHS.find((candidate) => candidate >= width) ?? ITEM_IMAGE_WIDTHS[ITEM_IMAGE_WIDTHS.length - 1];
|
|
23
|
+
return `${src}-${rung}.avif`;
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
ITEM_IMAGE_PREFIX,
|
|
27
|
+
ITEM_IMAGE_WIDTHS,
|
|
28
|
+
itemImageLoader as default,
|
|
29
|
+
itemImageLoader,
|
|
30
|
+
menuImageUrl
|
|
31
|
+
};
|
|
32
|
+
//# sourceMappingURL=images.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../zorgo/universal/constants.ts","../../zorgo/universal/utils/menuUtils.ts","../src/images.ts"],"sourcesContent":["export const SUPABASE_URL = \"https://dhnpoxorllfdobwftkyb.supabase.co\";\n\nexport const 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\nexport const API_BASE_URL =\n\tprocess.env.NODE_ENV === \"production\"\n\t\t? \"https://api.zorgotech.com\"\n\t\t: \"http://localhost:3000\";\n\n\n// replace instances of API_BASE_URL with env enum\nexport enum Environments {\n\tProduction = \"https://api.zorgotech.com\",\n\tStaging = \"http://api.zorgo.ai\",\n\tDevelopment = \"http://localhost:3000\",\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},\n\tPOS_METHOD_ID: 3,\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),\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\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 } = 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.not(\"per_person\", \"is\", null)\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\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\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\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\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 */\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})),\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})),\n\t];\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, components(name, description, serving_weight)\",\n\t\tbehaviorSelect,\n\t].filter(Boolean).join(\", \");\n\n\tconst ruleItemsSelect = [\n\t\t\"item_id, upcharge_price, 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 rule_components (${ruleComponentsSelect}),\n rule_items (${ruleItemsSelect})\n `,\n\t\t)\n\t\t.or(orFilter);\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\n\t\tcomponents: (rule.rule_components ?? []).map((component: any) => ({\n\t\t\tcomponent_id: component.component_id,\n\t\t\tupcharge_price: component.upcharge_price ?? 0,\n\t\t\tname: component.components?.name ?? \"\",\n\t\t\tdescription: component.components?.description ?? undefined,\n\t\t\tserving_weight: component.components?.serving_weight ?? undefined,\n\t\t\t...(useBehaviors && { behavior: component.component_quantity_behaviors ?? undefined }),\n\t\t})),\n\n\t\titems: (rule.rule_items ?? []).map((item: any) => ({\n\t\t\titem_id: item.item_id,\n\t\t\tname: item.items?.name ?? \"\",\n\t\t\tdescription: item.items?.description ?? undefined,\n\t\t\tupcharge_price: item.upcharge_price ?? 0,\n\t\t\t...(useBehaviors && { behavior: item.component_quantity_behaviors ?? undefined }),\n\t\t})),\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\treturn {\n\t\t\tis_catering: (baseItem as any).per_person !== null,\n\t\t\t...baseItem,\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. The loader below and the encoder in\n * internal/itemImages.ts read the same list, so a requested width can never\n * snap to a rung that was never written.\n */\nexport const ITEM_IMAGE_WIDTHS = [384, 640, 1080] as const;\n\n/** Public prefix menuImageUrl returns for a localized 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). Prebuilt images get the nearest rung that covers the\n * requested width; everything else — a bucket URL for an image prebuild could\n * not localize, the logo, any site-authored asset — passes through untouched\n * and is simply served unoptimized.\n *\n * Sync and pure by contract: next/image calls it during render for every\n * srcset candidate.\n */\nexport default function itemImageLoader({\n\tsrc,\n\twidth,\n}: {\n\tsrc: string;\n\twidth: number;\n}): string {\n\tif (!src.startsWith(ITEM_IMAGE_PREFIX)) return src;\n\tconst rung =\n\t\tITEM_IMAGE_WIDTHS.find((candidate) => candidate >= width) ??\n\t\tITEM_IMAGE_WIDTHS[ITEM_IMAGE_WIDTHS.length - 1];\n\treturn `${src}-${rung}.avif`;\n}\n\nexport { itemImageLoader };\n"],"mappings":";AAAO,IAAM,eAAe;AASrB,IAAM,eACZ,QAAQ,IAAI,aAAa,eACtB,8BACA;;;AC8gBG,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;;;AC3hBO,IAAM,oBAAoB,CAAC,KAAK,KAAK,IAAI;AAGzC,IAAM,oBAAoB;AAYlB,SAAR,gBAAiC;AAAA,EACvC;AAAA,EACA;AACD,GAGW;AACV,MAAI,CAAC,IAAI,WAAW,iBAAiB,EAAG,QAAO;AAC/C,QAAM,OACL,kBAAkB,KAAK,CAAC,cAAc,aAAa,KAAK,KACxD,kBAAkB,kBAAkB,SAAS,CAAC;AAC/C,SAAO,GAAG,GAAG,IAAI,IAAI;AACtB;","names":[]}
|
package/dist/index.d.mts
CHANGED
|
@@ -2,8 +2,45 @@ import * as _stripe_stripe_js from '@stripe/stripe-js';
|
|
|
2
2
|
import { StripeElementsOptions } from '@stripe/stripe-js';
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import React__default, { RefObject } from 'react';
|
|
5
|
-
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
6
5
|
import { z } from 'zod';
|
|
6
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How the section picker draws itself. Closed set — each value needs a renderer,
|
|
10
|
+
* so adding one is a code change (and an `alter type portion_geometry add value`).
|
|
11
|
+
*
|
|
12
|
+
* Section order is fixed per geometry, and consecutive indices must be spatially
|
|
13
|
+
* adjacent so any divisor of the division count partitions cleanly (sections 1–3
|
|
14
|
+
* of 6 are exactly section 1 of 2). Without that, switching a customer from
|
|
15
|
+
* sixths to halves has no defined answer.
|
|
16
|
+
*
|
|
17
|
+
* - `radial` — contiguous arcs from 12 o'clock, counter-clockwise
|
|
18
|
+
* - `grid` — column-major: down the left column, then the next column
|
|
19
|
+
* - `linear` — one end to the other
|
|
20
|
+
* - `count` — equal shares of a countable quantity, no geometry
|
|
21
|
+
*/
|
|
22
|
+
declare const PortionGeometrySchema: z.ZodEnum<{
|
|
23
|
+
radial: "radial";
|
|
24
|
+
grid: "grid";
|
|
25
|
+
linear: "linear";
|
|
26
|
+
count: "count";
|
|
27
|
+
}>;
|
|
28
|
+
type PortionGeometry = z.infer<typeof PortionGeometrySchema>;
|
|
29
|
+
/** A row of `portion_schemes` — a franchise-owned, franchise-named menu entity. */
|
|
30
|
+
declare const PortionSchemeSchema: z.ZodObject<{
|
|
31
|
+
id: z.ZodNumber;
|
|
32
|
+
franchise_id: z.ZodNumber;
|
|
33
|
+
geometry: z.ZodEnum<{
|
|
34
|
+
radial: "radial";
|
|
35
|
+
grid: "grid";
|
|
36
|
+
linear: "linear";
|
|
37
|
+
count: "count";
|
|
38
|
+
}>;
|
|
39
|
+
name: z.ZodString;
|
|
40
|
+
min_divisions: z.ZodDefault<z.ZodNumber>;
|
|
41
|
+
max_divisions: z.ZodNumber;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
type PortionScheme = z.infer<typeof PortionSchemeSchema>;
|
|
7
44
|
|
|
8
45
|
type LocationStatus = "active" | "permanently_closed";
|
|
9
46
|
interface Location {
|
|
@@ -53,16 +90,27 @@ interface ItemVariant {
|
|
|
53
90
|
components?: Component[];
|
|
54
91
|
base_item_id?: number;
|
|
55
92
|
rules?: Rule[];
|
|
93
|
+
portion_scheme_id?: number | null;
|
|
94
|
+
portion_scheme?: PortionScheme | null;
|
|
56
95
|
display_price?: number;
|
|
57
96
|
active_promo_id?: number;
|
|
58
97
|
}
|
|
98
|
+
interface BaseItemImage {
|
|
99
|
+
image_id: number;
|
|
100
|
+
base_item_id: number;
|
|
101
|
+
alias: string;
|
|
102
|
+
raw_path?: string | null;
|
|
103
|
+
processed_path?: string | null;
|
|
104
|
+
sort_index?: number;
|
|
105
|
+
local_key?: string | null;
|
|
106
|
+
}
|
|
59
107
|
interface MenuItem {
|
|
60
108
|
base_item_id: number;
|
|
61
109
|
category_id: number;
|
|
62
110
|
franchise_id: number;
|
|
63
111
|
name: string;
|
|
64
112
|
per_person?: boolean | null;
|
|
65
|
-
|
|
113
|
+
images?: BaseItemImage[];
|
|
66
114
|
variants: ItemVariant[];
|
|
67
115
|
rules: Rule[];
|
|
68
116
|
description?: string | null;
|
|
@@ -100,6 +148,7 @@ interface Rule {
|
|
|
100
148
|
min_selection: number;
|
|
101
149
|
max_selection?: number;
|
|
102
150
|
included_quantity: number;
|
|
151
|
+
is_portionable?: boolean;
|
|
103
152
|
items?: RuleItem[];
|
|
104
153
|
components?: RuleComponent[];
|
|
105
154
|
}
|
|
@@ -130,14 +179,25 @@ interface CustomizationOption {
|
|
|
130
179
|
selected: boolean;
|
|
131
180
|
disabled: boolean;
|
|
132
181
|
toggle: () => void;
|
|
182
|
+
/** Section this option currently targets; null when editing the whole item. */
|
|
183
|
+
portion?: string | null;
|
|
184
|
+
/** Section indices this option already covers, for shading the picker. */
|
|
185
|
+
coveredSections?: number[];
|
|
186
|
+
/** Already applied to the whole item, so individual sections are moot. */
|
|
187
|
+
coveredWhole?: boolean;
|
|
133
188
|
}
|
|
134
189
|
interface CustomizationRule extends Rule {
|
|
135
190
|
rule_id: number;
|
|
136
191
|
max: number;
|
|
192
|
+
/** Weighted, not a row count: two halves of one topping count as 1. */
|
|
137
193
|
selectedCount: number;
|
|
138
194
|
satisfied: boolean;
|
|
195
|
+
/** "Choose 1. Select up to 5. 1 included" — the rule's limits in words. */
|
|
196
|
+
subtext: string;
|
|
139
197
|
message: () => string | null;
|
|
140
198
|
options: CustomizationOption[];
|
|
199
|
+
/** This rule can be applied per-section under the item's scheme. */
|
|
200
|
+
portionable?: boolean;
|
|
141
201
|
}
|
|
142
202
|
interface OrderItem {
|
|
143
203
|
item_id: number;
|
|
@@ -155,6 +215,7 @@ interface OrderItem {
|
|
|
155
215
|
components?: OrderComponent[];
|
|
156
216
|
selections?: RuleSelection[];
|
|
157
217
|
comments?: string;
|
|
218
|
+
portion_scheme?: PortionScheme | null;
|
|
158
219
|
status?: "removed" | "modified";
|
|
159
220
|
applied_promo_id?: number;
|
|
160
221
|
discount_amount?: number;
|
|
@@ -163,6 +224,7 @@ interface OrderComponent extends Component {
|
|
|
163
224
|
quantity_servings: number;
|
|
164
225
|
upcharge?: number;
|
|
165
226
|
rule_id?: number;
|
|
227
|
+
portion?: string | null;
|
|
166
228
|
}
|
|
167
229
|
interface RuleSelection {
|
|
168
230
|
rule_id: number;
|
|
@@ -177,6 +239,7 @@ interface Selection {
|
|
|
177
239
|
serving_price?: number;
|
|
178
240
|
name: string;
|
|
179
241
|
status?: "removed" | "modified" | "added";
|
|
242
|
+
portion?: string | null;
|
|
180
243
|
component_type_id?: number;
|
|
181
244
|
serving_weight?: number;
|
|
182
245
|
measurement_type?: "weight" | "count";
|
|
@@ -383,7 +446,6 @@ declare function useMenu(): {
|
|
|
383
446
|
items: MenuItem[];
|
|
384
447
|
allItems: MenuItem[];
|
|
385
448
|
categories: Category[];
|
|
386
|
-
getItemImageUrl: (imagePath?: string | null) => string | null;
|
|
387
449
|
};
|
|
388
450
|
/**
|
|
389
451
|
* Seeds a store with prebuilt data (see the app's prebuild script) during the
|
|
@@ -432,11 +494,27 @@ declare function useItemCustomization(baseItemId: number, seed?: OrderItem, edit
|
|
|
432
494
|
unmetRule: CustomizationRule | null;
|
|
433
495
|
canSubmit: boolean;
|
|
434
496
|
submit: () => OrderItem | null;
|
|
497
|
+
portionScheme: {
|
|
498
|
+
id: number;
|
|
499
|
+
franchise_id: number;
|
|
500
|
+
geometry: "radial" | "grid" | "linear" | "count";
|
|
501
|
+
name: string;
|
|
502
|
+
min_divisions: number;
|
|
503
|
+
max_divisions: number;
|
|
504
|
+
} | null;
|
|
505
|
+
placeOption: (ruleId: number, baseKey: string, portion: string | null, max?: number) => void;
|
|
506
|
+
clearOption: (ruleId: number, baseKey: string) => void;
|
|
507
|
+
recutSelections: (next: number) => void;
|
|
435
508
|
quantity: number;
|
|
436
|
-
setQuantity:
|
|
509
|
+
setQuantity: (next: number | ((current: number) => number)) => void;
|
|
437
510
|
comments: string;
|
|
438
511
|
setComments: React.Dispatch<React.SetStateAction<string>>;
|
|
439
|
-
|
|
512
|
+
/**
|
|
513
|
+
* Set a component's servings for the whole item, or for one section when
|
|
514
|
+
* `portion` is given. 0 removes it — "no cheese", or "no cheese on the
|
|
515
|
+
* left half".
|
|
516
|
+
*/
|
|
517
|
+
setComponentServings: (componentId: number, qty: number, portion?: string | null) => void;
|
|
440
518
|
price: number;
|
|
441
519
|
salePrice: number | undefined;
|
|
442
520
|
isEditMode: boolean;
|
|
@@ -1105,4 +1183,95 @@ declare function useFormValues(): Record<string, unknown>;
|
|
|
1105
1183
|
|
|
1106
1184
|
declare function useFormValidity(): boolean;
|
|
1107
1185
|
|
|
1108
|
-
|
|
1186
|
+
declare function parsePortion(portion: string): {
|
|
1187
|
+
index: number;
|
|
1188
|
+
divisions: number;
|
|
1189
|
+
};
|
|
1190
|
+
/** A section's share of the whole. No portion = the whole item = 1. */
|
|
1191
|
+
declare function portionWeight(portion?: string | null): number;
|
|
1192
|
+
/**
|
|
1193
|
+
* Selection keys carry their portion as a suffix: `component:123@1/2`. A bare
|
|
1194
|
+
* key is the whole item, which is what every pre-portion key already is — so
|
|
1195
|
+
* existing state parses unchanged.
|
|
1196
|
+
*/
|
|
1197
|
+
declare function portionKey(baseKey: string, portion?: string | null): string;
|
|
1198
|
+
declare function parsePortionKey(key: string): {
|
|
1199
|
+
baseKey: string;
|
|
1200
|
+
portion: string | null;
|
|
1201
|
+
};
|
|
1202
|
+
/**
|
|
1203
|
+
* A section's share of a whole-item upcharge, in cents. Rounds up, so covering
|
|
1204
|
+
* every section can cost a cent or two more than the whole — which is why a
|
|
1205
|
+
* selection covering everything collapses to a single portion-less row instead.
|
|
1206
|
+
*/
|
|
1207
|
+
declare function splitUpcharge(cents: number, divisions: number): number;
|
|
1208
|
+
/** Every division count a scheme allows, for the picker's cut selector. */
|
|
1209
|
+
declare function divisionsFor(scheme: Pick<PortionScheme, "min_divisions" | "max_divisions">): number[];
|
|
1210
|
+
/**
|
|
1211
|
+
* The one place a section index becomes words. The picker, the cart line, the
|
|
1212
|
+
* receipt and the KDS ticket all call this on the same scheme, so the index is
|
|
1213
|
+
* the only token the UI and the data share.
|
|
1214
|
+
*/
|
|
1215
|
+
declare function portionLabel(portion: string | null | undefined, scheme: Pick<PortionScheme, "geometry">): string;
|
|
1216
|
+
/**
|
|
1217
|
+
* Expand per-section overrides on one keyed entity into one row per section —
|
|
1218
|
+
* how "no cheese on the left half" becomes a 0-serving row for that section and
|
|
1219
|
+
* a normal row for the rest. No overrides, or the same value on every section,
|
|
1220
|
+
* is a single whole-item row (§3 collapse).
|
|
1221
|
+
*/
|
|
1222
|
+
declare function portionRows(baseKey: string, values: Record<string, number>, fallback: number): {
|
|
1223
|
+
portion: string | null;
|
|
1224
|
+
value: number;
|
|
1225
|
+
}[];
|
|
1226
|
+
/** Weighted count of a rule's selections: two halves of one topping count as 1. */
|
|
1227
|
+
declare function selectionWeight(keys: string[]): number;
|
|
1228
|
+
/**
|
|
1229
|
+
* Spend `included_quantity` on selection keys in pick order. A topping is a
|
|
1230
|
+
* topping: it takes one included slot no matter how little of the pizza it covers,
|
|
1231
|
+
* so putting pepperoni on one half never leaves half a free slot behind to
|
|
1232
|
+
* discount everything else — and its rows share that one slot, so two halves of
|
|
1233
|
+
* pepperoni consume one included topping, not two. With no portions this is
|
|
1234
|
+
* exactly the old positional rule (`position < included ? 0 : basePrice`).
|
|
1235
|
+
*/
|
|
1236
|
+
declare function weightedUpcharges(keys: string[], basePriceOf: (baseKey: string) => number, included: number): Map<string, number>;
|
|
1237
|
+
/**
|
|
1238
|
+
* Re-express a portion under a different cut, for when the customer switches
|
|
1239
|
+
* from halves to quarters mid-order. Returns null when neither count divides the
|
|
1240
|
+
* other (halves → thirds), meaning the selection can't be carried over.
|
|
1241
|
+
*/
|
|
1242
|
+
declare function remapPortion(portion: string, nextDivisions: number): string[] | null;
|
|
1243
|
+
/**
|
|
1244
|
+
* Positional section wording, geometry-free — deliberately not `portionLabel`.
|
|
1245
|
+
* Derived words ("Left", "Top left") only exist for a few geometry/division
|
|
1246
|
+
* pairs and need the scheme; a receipt has to say something for every cut.
|
|
1247
|
+
*/
|
|
1248
|
+
declare function sectionLabel(portion?: string | null): string;
|
|
1249
|
+
/** A text glyph for a section, or null when unicode has no character for it. */
|
|
1250
|
+
declare function portionGlyph(portion: string | null | undefined, geometry?: PortionGeometry | null): string | null;
|
|
1251
|
+
interface PortionGroup {
|
|
1252
|
+
portion: string | null;
|
|
1253
|
+
label: string;
|
|
1254
|
+
glyph: string | null;
|
|
1255
|
+
/** The item's rules, each carrying only the selections that land on this section. */
|
|
1256
|
+
selections: RuleSelection[];
|
|
1257
|
+
removals: OrderComponent[];
|
|
1258
|
+
/**
|
|
1259
|
+
* Components present on this section. Whole-item components are the base
|
|
1260
|
+
* recipe — implied by the item's name — so only portioned ones land here,
|
|
1261
|
+
* where "pepperoni on the left" is the whole point of the line.
|
|
1262
|
+
*/
|
|
1263
|
+
additions: OrderComponent[];
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* One group per covered section: the whole-item group first, then sections in
|
|
1267
|
+
* index order, rules keeping their original order inside each. An item with no
|
|
1268
|
+
* portions anywhere yields exactly one whole-item group, so a surface can render
|
|
1269
|
+
* this unconditionally and show the heading only when `portion_scheme` is set.
|
|
1270
|
+
*
|
|
1271
|
+
* It groups and nothing else — every selection it is handed comes back out. What
|
|
1272
|
+
* counts as worth printing is the surface's call, not this function's.
|
|
1273
|
+
*/
|
|
1274
|
+
declare function groupItemPortions(item: Pick<OrderItem, "selections" | "components" | "portion_scheme">): PortionGroup[];
|
|
1275
|
+
declare function radialSectionPath(index: number, divisions: number, r?: number): string;
|
|
1276
|
+
|
|
1277
|
+
export { type Category, CheckboxField, ClickableField, ConditionalRenderer, type Customer, type CustomizationOption, type CustomizationRule, DateField, DateTime, DateTimeRange, DisclaimerText, DiscreteNumberField, DynamicFormShell, EmailField, FieldError, FieldLabel, GuestCountField, Header, IncrementField, type ItemVariant, type Location, LocationField, type Menu, type MenuItem, NameFields, type Order, type OrderComponent, type OrderItem, PhoneField, Platforms, type PortionGroup, PerPersonSummary as PricePreview, type Promotion, type RecommendationRequest, type RecommendationResult, type RecommendationStrategy, type Rule, type RuleComponent, type RuleItem, type RuleSelection, SectionLabel, type Selection, SliderField, SubmitButton, TextField, type TimeConstraints, TimeField, divisionsFor, groupItemPortions, parsePortion, parsePortionKey, portionGlyph, portionKey, portionLabel, portionRows, portionWeight, radialSectionPath, remapPortion, sectionLabel, selectionWeight, setZorgoBoot, setZorgoPathnameHook, splitUpcharge, useCart, useCateringConfig, useCustomer, useField, useFormStatus, useFormValidity, useFormValues, useHydrateMenu, useHydrateStore, useItemCustomization, useLocation, useMenu, useModal, usePayment, usePromotions, useRecommendations, useRegisterField, weightedUpcharges };
|