← Files WixARCHIVED FILE
skills/wix-headless-templates/restaurants/app/wix/restaurants/menu-core.ts
15.9 KB · Oct 8, 2026 · 12:02 UTC
// Menu rules and DTO mapping — transport-agnostic, imported by BOTH transports: ./menu.ts (the
// SDK, managed Astro and React) and the REST twin in templates/restaurants/rest/menu.ts (fetch,
// a static site or a port to another language). The hierarchy is Menu → Sections → Items wired by
// ID ARRAYS: display structure and order live in menu.sectionIds / section.itemIds, never in a
// list response's order — the tree is stitched here in id-array order and dangling ids dropped.
// The modifier rule engine (rule type, single-select, pre-selection, validation, sold-out from a
// required group, duplicate ids, live price) mirrors Wix's own ordering code and lives here once.
// A raw entity carries `_id` (SDK) or `id` (REST); every mapper accepts both. Imports are
// type-only so a strip to JS emits no imports.
import type {
MenuData,
MenuItem,
MenuItemLabel,
MenuItemVariant,
MenuModifier,
MenuModifierGroup,
MenuSection,
ModifierRuleType,
OrderSelection,
SelectionPrice,
SelectionValidation,
SiteMoney,
} from "./types";
/** A raw Menus V1 entity as either transport returns it. */
export type Raw = Record<string, any>;
/** Media value + size → https URL. Injected: the SDK transport scales through @wix/sdk, REST through the URL form. */
export type ImgSrc = (value: any, width: number, height: number) => string;
/** An entity's id, or a bare id string (item.labels may be string ids or { _id } refs). */
export const rawId = (raw: Raw | string | undefined | null): string =>
typeof raw === "string" ? raw : (raw?._id ?? raw?.id ?? "");
/** Index raw entities by id (either spelling). */
export const byId = (arr: Raw[]): Map<string, Raw> => new Map(arr.map((e) => [rawId(e), e]));
const unique = (ids: string[]): string[] => [...new Set(ids.filter(Boolean))];
/** Split an id array for `?ids=…` reads — 100 per call keeps the query string well under the limit. */
export function chunk<T>(arr: T[], size = 100): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
/** The next page's cursor of a list response (pagingMetadata.cursors.next), null on the last page. */
export const nextCursor = (res: Raw | null | undefined): string | null => res?.pagingMetadata?.cursors?.next || null;
// ---- money -------------------------------------------------------------------------------------------
/**
* The site's currency + locale from the eCommerce settings' BUSINESS_INFO (ecommerceSettings.
* businessInfo.currency / .locale.language / .locale.country), the read Wix's own menus code makes.
* Nothing is assumed: an unreadable response yields currency "" and every price formats to "".
*/
export function toSiteMoney(settingsRes: Raw | null | undefined): SiteMoney {
const info: Raw = settingsRes?.ecommerceSettings?.businessInfo ?? {};
const language = info.locale?.language ?? "";
const country = info.locale?.country ?? "";
return { currency: info.currency ?? "", locale: language ? (country ? `${language}-${country}` : language) : "" };
}
/**
* Format a decimal amount in the site's currency and locale. Prefers a platform-formatted string
* when the API sent one; "" when the amount is missing or the currency is unknown — never an
* invented symbol, never a USD default.
*/
export function formatPrice(amount: string | number | null | undefined, money: SiteMoney, platformFormatted?: string | null): string {
if (platformFormatted) return platformFormatted;
if (amount == null || amount === "" || !money.currency) return "";
const n = Number(amount);
if (!Number.isFinite(n)) return "";
try {
return new Intl.NumberFormat(money.locale || undefined, { style: "currency", currency: money.currency }).format(n);
} catch {
try {
return new Intl.NumberFormat(undefined, { style: "currency", currency: money.currency }).format(n);
} catch {
return `${n.toFixed(2)} ${money.currency}`;
}
}
}
// ---- id collection for the second and third reads ---------------------------------------------
/** The variant ids the items reference — the second read's `variantIds`. */
export function collectVariantIds(items: Raw[]): string[] {
return unique(items.flatMap((i) => ((i.priceVariants?.variants ?? []) as Raw[]).map((v) => v.variantId)));
}
/** The modifier-group ids the items reference — the second read's `modifierGroupIds`. */
export function collectGroupIds(items: Raw[]): string[] {
return unique(items.flatMap((i) => ((i.modifierGroups ?? []) as Raw[]).map(rawId)));
}
/** The modifier ids the groups reference — the third read's `modifierIds`. */
export function collectModifierIds(groups: Raw[]): string[] {
return unique(groups.flatMap((g) => ((g.modifiers ?? []) as Raw[]).map(rawId)));
}
/** Whether any item carries a label — the labels list is read only then. */
export function anyLabels(items: Raw[]): boolean {
return items.some((i) => ((i.labels ?? []) as Raw[]).length > 0);
}
// ---- modifier rule engine (mirrors Wix's ordering utils) ----------------------------------------
/** required && min 1 && max 1 — the one rule rendered as radios; a new pick replaces the old. */
export const isSingleSelectRule = (rule: { required: boolean; minSelections: number; maxSelections: number | null }): boolean =>
rule.required && rule.minSelections === 1 && rule.maxSelections === 1;
/** The rule's shape, in Wix's precedence (NO_LIMIT → CHOOSE_ONE → CHOOSE_X → AT_LEAST_ONE → AT_LEAST_X → UP_TO_X → BETWEEN). */
export function ruleType(rule: { required: boolean; minSelections: number; maxSelections: number | null }): ModifierRuleType {
const { required, minSelections: min, maxSelections: max } = rule;
if (!required && min < 1 && (max ? max < 1 : true)) return "NO_LIMIT";
if ((required && min === 1 && max === 1) || (!required && min === 0 && max === 1)) return "CHOOSE_ONE";
if (required && max && min === max) return "CHOOSE_X";
if (required && min === 1 && max == null) return "CHOOSE_AT_LEAST_ONE";
if (required && min > 1 && max == null) return "CHOOSE_AT_LEAST_X";
if (!required && min === 0 && max && max > 1) return "CHOOSE_UP_TO_X";
if (required && max && min > 0 && max > min) return "CHOOSE_BETWEEN_X_AND_Y";
return "NO_LIMIT";
}
/** Human wording of a group's rule for the sheet ("Choose 1", "Choose up to 3", …). */
export function ruleLabel(group: MenuModifierGroup): string {
const { minSelections: min, maxSelections: max } = group;
switch (group.rule) {
case "CHOOSE_ONE": return group.required ? "Choose 1" : "Choose up to 1";
case "CHOOSE_X": return `Choose ${min}`;
case "CHOOSE_AT_LEAST_ONE": return "Choose at least 1";
case "CHOOSE_AT_LEAST_X": return `Choose at least ${min}`;
case "CHOOSE_UP_TO_X": return `Choose up to ${max}`;
case "CHOOSE_BETWEEN_X_AND_Y": return `Choose ${min} to ${max}`;
default: return "Optional";
}
}
/** A required group whose in-stock modifiers can't reach minSelections makes the whole item unorderable. */
export function requiredGroupOutOfStock(groups: MenuModifierGroup[]): boolean {
return groups.some((g) => g.required && g.modifiers.filter((m) => m.inStock).length < Math.max(g.minSelections, 0));
}
/** The cheapest variant — the default the sheet opens on; null for a flat-priced item. */
export function defaultVariantId(item: Pick<MenuItem, "variants">): string | null {
if (!item.variants.length) return null;
return item.variants.reduce((cheapest, v) => (Number(v.priceAmount) < Number(cheapest.priceAmount) ? v : cheapest)).variantId;
}
/** The selection a sheet opens with: preSelected && inStock modifiers (single-select takes the first), the cheapest variant, no note. */
export function initialSelection(item: Pick<MenuItem, "variants" | "modifierGroups">): OrderSelection {
const modifiers: Record<string, string[]> = {};
for (const g of item.modifierGroups) {
const pre = g.modifiers.filter((m) => m.preSelected && m.inStock).map((m) => m.key);
modifiers[g.id] = g.singleSelect ? pre.slice(0, 1) : pre;
}
return { variantId: defaultVariantId(item), modifiers, specialRequest: "" };
}
/** A new selection with `key` toggled in `groupId` — single-select replaces, multi-select toggles. Pure. */
export function toggleModifier(item: Pick<MenuItem, "modifierGroups">, selection: OrderSelection, groupId: string, key: string): OrderSelection {
const group = item.modifierGroups.find((g) => g.id === groupId);
const current = selection.modifiers[groupId] ?? [];
const next = group?.singleSelect ? [key] : current.includes(key) ? current.filter((k) => k !== key) : [...current, key];
return { ...selection, modifiers: { ...selection.modifiers, [groupId]: next } };
}
/** Wix's checks per group: required → at least one; count >= min; count <= max when a max is set. */
export function validateSelection(item: Pick<MenuItem, "modifierGroups">, selection: OrderSelection): SelectionValidation {
const errors: Record<string, string> = {};
for (const g of item.modifierGroups) {
const count = (selection.modifiers[g.id] ?? []).length;
if (g.required && count === 0) errors[g.id] = g.minSelections > 1 ? `Choose at least ${g.minSelections}` : "Choose one";
else if (count < g.minSelections) errors[g.id] = `Choose at least ${g.minSelections}`;
else if (g.maxSelections && count > g.maxSelections) errors[g.id] = `Choose up to ${g.maxSelections}`;
}
return { ok: Object.keys(errors).length === 0, errors };
}
/** The modifiers a selection names, resolved by KEY within their group (duplicate ids stay distinct). */
export function selectedModifiers(item: Pick<MenuItem, "modifierGroups">, selection: OrderSelection): { group: MenuModifierGroup; modifiers: MenuModifier[] }[] {
return item.modifierGroups
.map((group) => {
const keys = selection.modifiers[group.id] ?? [];
return { group, modifiers: keys.map((k) => group.modifiers.find((m) => m.key === k)).filter((m): m is MenuModifier => !!m) };
})
.filter((g) => g.modifiers.length > 0);
}
/** (base + variant + modifier charges) × quantity, formatted in the menu's money. */
export function itemPrice(item: Pick<MenuItem, "priceAmount" | "variants" | "modifierGroups">, selection: OrderSelection, quantity: number, money: SiteMoney): SelectionPrice {
const base = Number(item.priceAmount ?? 0) || 0;
const variant = Number(item.variants.find((v) => v.variantId === selection.variantId)?.priceAmount ?? 0) || 0;
const modifiers = selectedModifiers(item, selection).reduce((sum, g) => sum + g.modifiers.reduce((s, m) => s + (Number(m.additionalChargeAmount) || 0), 0), 0);
const amount = (base + variant + modifiers) * Math.max(quantity, 0);
return { amount, formatted: formatPrice(amount, money) };
}
/** True when the dish needs a sheet before adding (a variant, a modifier group, or a note to collect). */
export const needsSelection = (item: Pick<MenuItem, "variants" | "modifierGroups" | "acceptsSpecialRequests">): boolean =>
item.variants.length > 0 || item.modifierGroups.length > 0 || item.acceptsSpecialRequests;
// ---- DTO mapping -----------------------------------------------------------------------------------------
export interface MenuLookups {
variantById: Map<string, Raw>;
groupById: Map<string, Raw>;
modifierById: Map<string, Raw>;
labelById: Map<string, Raw>;
}
export function toModifierGroup(g: Raw, lookups: MenuLookups, money: SiteMoney): MenuModifierGroup {
const rule = {
required: g.rule?.required === true,
minSelections: g.rule?.minSelections ?? 0,
maxSelections: g.rule?.maxSelections ?? null,
};
return {
id: rawId(g),
name: g.name ?? "",
...rule,
rule: ruleType(rule),
singleSelect: isSingleSelectRule(rule),
// The group-level ref carries preSelected + the up-charge; the modifier entity carries name + inStock.
modifiers: ((g.modifiers ?? []) as Raw[]).map((m, index): MenuModifier => {
const id = rawId(m);
const entity = lookups.modifierById.get(id);
const charge = m.additionalChargeInfo?.additionalCharge ?? "0";
return {
id,
key: `${id}~${index}`,
name: entity?.name ?? "",
preSelected: m.preSelected === true,
additionalCharge: Number(charge) > 0 ? formatPrice(charge, money, m.additionalChargeInfo?.formattedAdditionalCharge) : "",
additionalChargeAmount: String(charge),
inStock: entity?.inStock !== false,
};
}),
};
}
export function toItem(raw: Raw, lookups: MenuLookups, imgSrc: ImgSrc, money: SiteMoney): MenuItem {
const variants: MenuItemVariant[] = ((raw.priceVariants?.variants ?? []) as Raw[])
.map((v): MenuItemVariant => ({
variantId: v.variantId ?? "",
name: lookups.variantById.get(v.variantId)?.name ?? "",
price: formatPrice(v.priceInfo?.price, money, v.priceInfo?.formattedPrice),
priceAmount: v.priceInfo?.price ?? "0",
}))
.filter((v) => v.variantId);
const modifierGroups: MenuModifierGroup[] = ((raw.modifierGroups ?? []) as Raw[])
.map((ref) => lookups.groupById.get(rawId(ref)))
.filter((g): g is Raw => !!g)
.map((g) => toModifierGroup(g, lookups, money));
const labels: MenuItemLabel[] = ((raw.labels ?? []) as (Raw | string)[])
.map((ref) => lookups.labelById.get(rawId(ref)))
.filter((l): l is Raw => !!l)
.map((l): MenuItemLabel => ({ id: rawId(l), name: l.name ?? "", iconUrl: imgSrc(l.icon, 48, 48) }));
const priceAmount: string | null = raw.priceInfo?.price ?? null;
const price = priceAmount === null ? null : formatPrice(priceAmount, money, raw.priceInfo?.formattedPrice);
const marketPrice = priceAmount === null && variants.length === 0;
const inStock = raw.orderSettings?.inStock !== false;
const soldOut = !inStock || requiredGroupOutOfStock(modifierGroups);
const imageUrl = imgSrc(raw.image, 800, 800);
return {
id: rawId(raw),
name: raw.name ?? "",
description: raw.description ?? "",
price,
priceAmount,
marketPrice,
variants,
imageUrl,
gallery: [imageUrl, ...((raw.additionalImages ?? []) as any[]).map((m) => imgSrc(m, 800, 800))].filter(Boolean),
labels,
modifierGroups,
inStock,
soldOut,
orderable: !marketPrice && !soldOut,
acceptsSpecialRequests: raw.orderSettings?.acceptSpecialRequests === true,
featured: raw.featured === true,
};
}
export function toSection(raw: Raw, itemById: Map<string, MenuItem>, imgSrc: ImgSrc): MenuSection {
return {
id: rawId(raw),
name: raw.name ?? "",
description: raw.description ?? "",
imageUrl: imgSrc(raw.image, 800, 800),
items: ((raw.itemIds ?? []) as string[]).map((iid) => itemById.get(iid)).filter((i): i is MenuItem => !!i),
};
}
export function toMenu(raw: Raw, sectionById: Map<string, Raw>, itemById: Map<string, MenuItem>, imgSrc: ImgSrc, money: SiteMoney): MenuData {
return {
id: rawId(raw),
name: raw.name ?? "",
description: raw.description ?? "",
slug: raw.urlQueryParam ?? "",
sections: ((raw.sectionIds ?? []) as string[])
.map((sid) => sectionById.get(sid))
.filter((s): s is Raw => !!s)
.map((s) => toSection(s, itemById, imgSrc)),
money,
};
}
/** The seven list responses' entity arrays, as either transport returns them, plus the site money. */
export interface MenuTreeParts {
menus: Raw[];
sections: Raw[];
items: Raw[];
variants: Raw[];
groups: Raw[];
modifiers: Raw[];
labels: Raw[];
money: SiteMoney;
}
/** The display-ordered tree: visible menus → their sections → their items, each item enriched. [] when no menus. */
export function assembleMenus(parts: MenuTreeParts, imgSrc: ImgSrc): MenuData[] {
if (!parts.menus.length) return [];
const lookups: MenuLookups = {
variantById: byId(parts.variants),
groupById: byId(parts.groups),
modifierById: byId(parts.modifiers),
labelById: byId(parts.labels),
};
const sectionById = byId(parts.sections);
const itemById = new Map<string, MenuItem>(parts.items.map((i) => [rawId(i), toItem(i, lookups, imgSrc, parts.money)]));
return parts.menus.map((m) => toMenu(m, sectionById, itemById, imgSrc, parts.money));
}
SHA-256: c59319287ce4bf4230b67cb85facdf6643bd65ab354733d0d81ff82ff34ac645