@wix/himalaya-cli 0.1527.0 → 0.1529.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.
Files changed (61) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/ecom/src/extensions.d.ts +144 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/restaurants/src/extensions.d.ts +93 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +27 -5
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +19 -3
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +27 -5
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-ecom.mjs +58 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-restaurants.mjs +23 -0
  8. package/dist/cli.mjs +5844 -4606
  9. package/fixtures/wix-canned.json +83 -0
  10. package/guides/hhp.md +39 -0
  11. package/guides/himi-json-output.md +67 -1
  12. package/guides/himi-mcp.md +41 -0
  13. package/guides/iap-products.md +88 -23
  14. package/package.json +2 -2
  15. package/templates/beauty-salon/config.ts +12 -1
  16. package/templates/beauty-salon/tier5-src/_shared/himi-restaurants.ts +12 -5
  17. package/templates/beauty-salon/tier5-src/_shared/restaurant-ordering.ts +201 -0
  18. package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +6 -0
  19. package/templates/branded-lite/config.ts +12 -1
  20. package/templates/branded-lite/dev/index.ts +1278 -180
  21. package/templates/branded-lite/tier5-src/_shared/himi-restaurants.ts +12 -5
  22. package/templates/branded-lite/tier5-src/_shared/restaurant-ordering.ts +201 -0
  23. package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +6 -0
  24. package/templates/branded-lite/tier5-src/himi-branded-lite-menu-item/index.ts +144 -77
  25. package/templates/branded-lite/tier5-src/himi-branded-lite-menus/index.ts +16 -5
  26. package/templates/branded-lite/tier5-src/himi-branded-lite-reservations/index.ts +1 -1
  27. package/templates/branded-lite/tier5-src/wix-spaces-food-order-detail/index.ts +76 -14
  28. package/templates/branded-lite/tier5-src/wix-spaces-food-orders/index.ts +807 -55
  29. package/templates/community-club/config.ts +12 -1
  30. package/templates/community-club/tier5-src/_shared/himi-restaurants.ts +12 -5
  31. package/templates/events-meetups/config.ts +12 -1
  32. package/templates/events-meetups/tier5-src/_shared/himi-restaurants.ts +12 -5
  33. package/templates/gym-fitness/config.ts +12 -1
  34. package/templates/gym-fitness/tier5-src/_shared/himi-restaurants.ts +12 -5
  35. package/templates/gym-fitness/tier5-src/_shared/restaurant-ordering.ts +201 -0
  36. package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +6 -0
  37. package/templates/nonprofit-cause/config.ts +12 -1
  38. package/templates/nonprofit-cause/tier5-src/_shared/himi-restaurants.ts +12 -5
  39. package/templates/podcast-creator/config.ts +12 -1
  40. package/templates/podcast-creator/tier5-src/_shared/himi-restaurants.ts +12 -5
  41. package/templates/portfolio-creative/config.ts +12 -1
  42. package/templates/portfolio-creative/tier5-src/_shared/himi-restaurants.ts +12 -5
  43. package/templates/portfolio-creative/tier5-src/_shared/restaurant-ordering.ts +201 -0
  44. package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +6 -0
  45. package/templates/restaurant/README.md +1 -1
  46. package/templates/restaurant/config.ts +12 -1
  47. package/templates/restaurant/dev/index.ts +1373 -275
  48. package/templates/restaurant/himalaya.template.json +2 -1
  49. package/templates/restaurant/tier5-src/_shared/himi-restaurants.ts +12 -5
  50. package/templates/restaurant/tier5-src/_shared/member-orders.ts +8 -0
  51. package/templates/restaurant/tier5-src/_shared/restaurant-ordering.ts +201 -0
  52. package/templates/restaurant/tier5-src/himi-branded-lite-menu-item/index.ts +144 -77
  53. package/templates/restaurant/tier5-src/himi-branded-lite-menus/index.ts +16 -5
  54. package/templates/restaurant/tier5-src/wix-spaces-food-order-detail/index.ts +76 -14
  55. package/templates/restaurant/tier5-src/wix-spaces-food-orders/index.ts +807 -55
  56. package/templates/retail-store/config.ts +12 -1
  57. package/templates/retail-store/tier5-src/_shared/himi-restaurants.ts +12 -5
  58. package/templates/retail-store/tier5-src/_shared/restaurant-ordering.ts +201 -0
  59. package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +6 -0
  60. package/templates/yoga-studio/config.ts +12 -1
  61. package/templates/yoga-studio/tier5-src/_shared/himi-restaurants.ts +12 -5
@@ -133,15 +133,18 @@ export function itemLabels(value: unknown, limit = 3): string[] {
133
133
  }
134
134
 
135
135
  /** Price-variant rows ({ name, priceLine }) from the item's loose
136
- * `priceVariants.variants` payload — [] when the item has a single price. */
137
- export function itemPriceVariants(item: WixItem): Array<{ id: string; name: string; priceLine: string }> {
136
+ * `priceVariants.variants` payload — [] when the item has a single price.
137
+ * `variantId` is Wix's own id for the variant (live rows carry `variantId`; older
138
+ * fixtures `id`), "" when the payload has none — a synthetic `variant-<n>` row id is
139
+ * only for the UI and must never be sent to Wix. `price` is the decimal price. */
140
+ export function itemPriceVariants(item: WixItem): Array<{ id: string; variantId: string; name: string; priceLine: string; price: string }> {
138
141
  const raw = item as unknown as { priceVariants?: { variants?: unknown[] } };
139
142
  const variants = raw.priceVariants?.variants;
140
143
  if (!Array.isArray(variants)) return [];
141
- const rows: Array<{ id: string; name: string; priceLine: string }> = [];
144
+ const rows: Array<{ id: string; variantId: string; name: string; priceLine: string; price: string }> = [];
142
145
  for (const [index, entry] of variants.entries()) {
143
146
  if (!entry || typeof entry !== "object") continue;
144
- const node = entry as { id?: unknown; name?: unknown; priceInfo?: { price?: unknown; currency?: unknown } };
147
+ const node = entry as { id?: unknown; variantId?: unknown; name?: unknown; priceInfo?: { price?: unknown; currency?: unknown; formattedPrice?: unknown } };
145
148
  const name = typeof node.name === "string" ? cleanTitle(node.name) : "";
146
149
  const price = node.priceInfo?.price;
147
150
  if (price == null || price === "" || Number.isNaN(Number(price))) continue;
@@ -152,7 +155,11 @@ export function itemPriceVariants(item: WixItem): Array<{ id: string; name: stri
152
155
  priceLine = formatCurrency(Number(price), currency, "en");
153
156
  } catch { /* keep the bare amount */ }
154
157
  }
155
- rows.push({ id: typeof node.id === "string" && node.id ? node.id : `variant-${index}`, name: name || "Option", priceLine });
158
+ const formatted = typeof node.priceInfo?.formattedPrice === "string" ? node.priceInfo.formattedPrice.trim() : "";
159
+ if (formatted) priceLine = normalizeFormattedPrice(formatted);
160
+ const variantId = typeof node.variantId === "string" && node.variantId ? node.variantId
161
+ : typeof node.id === "string" && node.id ? node.id : "";
162
+ rows.push({ id: variantId || `variant-${index}`, variantId, name: name || "Option", priceLine, price: `${price}` });
156
163
  }
157
164
  return rows;
158
165
  }
@@ -0,0 +1,201 @@
1
+ // Restaurants online ordering — Wix's visitor "Place an order from a custom storefront" flow,
2
+ // shared by the dish detail (himi-branded-lite-menu-item: add a dish to the Wix cart) and the
3
+ // order screen (wix-spaces-food-orders: cart → pickup/delivery → hosted checkout → history).
4
+ //
5
+ // The cart is the site's eCom CURRENT cart (Cart V2). It is the same cart the Stores cart screen
6
+ // reads (V1 and V2 answer the same cart id), so a restaurant line is told apart by its catalog
7
+ // app — Restaurants Orders — never assumed. Every call here is visitor-scope; the member's placed
8
+ // orders are read through the eCom orders search the member Orders screen already uses, never
9
+ // through the owner-capable Restaurants Orders API.
10
+ import {
11
+ getFirstAvailableTimeSlotPerFulfillmentType,
12
+ listFulfillmentMethods,
13
+ listOperations,
14
+ queryMenus,
15
+ querySections,
16
+ type WixFulfillmentMethod,
17
+ type WixFulfillmentTimeSlot,
18
+ type WixMenu,
19
+ type WixOperation,
20
+ type WixRestaurantsAddress,
21
+ type WixSection,
22
+ } from "@himalaya/wix-restaurants";
23
+ import type { WixCartV2, WixCartV2LineItem, WixCatalogReference } from "@himalaya/wix-ecom";
24
+ import { formatCurrency } from "../_shared/engine-intl.js";
25
+
26
+ /** Wix Restaurants Orders — the catalog app a restaurant cart line belongs to. */
27
+ export const RESTAURANTS_ORDERS_APP_ID = "9a5d83fd-8570-482e-81ab-cfa88942ee60";
28
+
29
+ export type FulfillmentType = "PICKUP" | "DELIVERY";
30
+
31
+ /** The operation a visitor orders from: an `ENABLED` one, the site's default first. None means
32
+ * online ordering is closed (`DISABLED`, `PAUSED_UNTIL`, or no operation at all). */
33
+ export function orderingOperation(operations: WixOperation[] | undefined, selection: { businessLocationId?: string; operationId?: string } = {}): WixOperation | undefined {
34
+ const open = (operations ?? []).filter((operation) => !!operation?.id && operation.onlineOrderingStatus === "ENABLED");
35
+ if (selection.operationId) return open.find((operation) => operation.id === selection.operationId);
36
+ if (selection.businessLocationId) return open.find((operation) => operation.businessLocationId === selection.businessLocationId);
37
+ return open.find((operation) => operation.default === true) ?? open[0];
38
+ }
39
+
40
+ export async function loadOrderingOperation(selection: { businessLocationId?: string; operationId?: string } = {}): Promise<WixOperation | undefined> {
41
+ return orderingOperation((await listOperations()).operations, selection);
42
+ }
43
+
44
+ /** The enabled pickup/delivery methods [operation] offers: the ones its `fulfillmentIds` name. An
45
+ * operation that names none falls back only to methods at its own business location — never to
46
+ * every method on the site, which on a multi-location site would offer another location's pickup. */
47
+ export function offeredMethods(operation: WixOperation, methods: WixFulfillmentMethod[] | undefined): WixFulfillmentMethod[] {
48
+ const ids = new Set(operation.fulfillmentIds ?? []);
49
+ const belongs = (method: WixFulfillmentMethod) => ids.size > 0
50
+ ? ids.has(method.id)
51
+ : !!operation.businessLocationId && method.businessLocationId === operation.businessLocationId;
52
+ return (methods ?? []).filter((method) =>
53
+ !!method?.id && method.enabled === true && (method.type === "PICKUP" || method.type === "DELIVERY") && belongs(method));
54
+ }
55
+
56
+ export async function loadOfferedMethods(operation: WixOperation): Promise<WixFulfillmentMethod[]> {
57
+ const all: WixFulfillmentMethod[] = [];
58
+ let cursor = "";
59
+ // A site has a handful of methods; five pages of 100 is a bound, not an expectation.
60
+ for (let page = 0; page < 5; page++) {
61
+ const response = await listFulfillmentMethods({ limit: 100, ...(cursor ? { cursor } : {}) });
62
+ all.push(...(response.fulfillmentMethods ?? []));
63
+ cursor = response.pagingMetadata?.hasNext ? response.pagingMetadata.cursors?.next ?? "" : "";
64
+ if (!cursor) break;
65
+ }
66
+ return offeredMethods(operation, all);
67
+ }
68
+
69
+ /** A decimal string from Wix ("6.99", "0") as a number; anything else is 0 (a missing fee is free). */
70
+ export function decimal(value: unknown): number {
71
+ const n = typeof value === "number" ? value : Number(typeof value === "string" ? value.trim() : NaN);
72
+ return Number.isFinite(n) ? n : 0;
73
+ }
74
+
75
+ /** Cart V2 money carries no formatted string: format the decimal with the cart's currency. */
76
+ export function money(amount: unknown, currency: string | undefined): string {
77
+ return formatCurrency(decimal(amount), (currency ?? "").trim() || "USD", "en");
78
+ }
79
+
80
+ export function cartCurrency(cart: WixCartV2 | undefined): string {
81
+ return cart?.businessInfo?.currencyCode || cart?.customerInfo?.currencyCode || "";
82
+ }
83
+
84
+ /** Where a dish is ordered from. Checkout validates the line against `menuSectionId`, so it must
85
+ * be a visible section that lists the dish, inside a visible menu that lists the section. */
86
+ export interface DishPlacement {
87
+ menuId: string;
88
+ sectionId: string;
89
+ businessLocationId?: string;
90
+ }
91
+
92
+ /** The section the dish was shown under wins when it qualifies; otherwise the first visible
93
+ * menu → section that lists it. None → the dish is on no orderable menu. */
94
+ export function pickPlacement(
95
+ dishId: string,
96
+ menus: WixMenu[],
97
+ sections: WixSection[],
98
+ hint: { menuId?: string; sectionId?: string } = {},
99
+ ): DishPlacement | undefined {
100
+ const listing = new Map(sections
101
+ .filter((section) => !!section?.id && section.visible !== false && (section.itemIds ?? []).includes(dishId))
102
+ .map((section) => [section.id, section]));
103
+ const candidates: DishPlacement[] = [];
104
+ for (const menu of menus) {
105
+ if (!menu?.id || menu.visible === false) continue;
106
+ for (const sectionId of menu.sectionIds ?? []) {
107
+ if (listing.has(sectionId)) candidates.push({ menuId: menu.id, sectionId, businessLocationId: menu.businessLocationId });
108
+ }
109
+ }
110
+ return candidates.find((c) => c.sectionId === hint.sectionId && (!hint.menuId || c.menuId === hint.menuId)) ?? candidates[0];
111
+ }
112
+
113
+ export async function loadDishPlacement(dishId: string, hint: { menuId?: string; sectionId?: string }): Promise<DishPlacement | undefined> {
114
+ const [sections, menus] = await Promise.all([
115
+ querySections({ paging: { limit: 100 } }),
116
+ queryMenus({ paging: { limit: 50 } }),
117
+ ]);
118
+ return pickPlacement(dishId, menus.menus ?? [], sections.sections ?? [], hint);
119
+ }
120
+
121
+ /** The catalog reference for one dish line. There is no `variantId` key in `options`. */
122
+ export function dishReference(dishId: string, operationId: string, placement: DishPlacement): WixCatalogReference {
123
+ return {
124
+ appId: RESTAURANTS_ORDERS_APP_ID,
125
+ catalogItemId: dishId,
126
+ options: {
127
+ operationId,
128
+ menuId: placement.menuId,
129
+ menuSectionId: placement.sectionId,
130
+ sectionId: placement.sectionId,
131
+ },
132
+ };
133
+ }
134
+
135
+ export function isRestaurantLine(line: WixCartV2LineItem | undefined): boolean {
136
+ return line?.source?.catalogReference?.appId === RESTAURANTS_ORDERS_APP_ID;
137
+ }
138
+
139
+ function lineVariantId(line: WixCartV2LineItem): string {
140
+ const variant = (line.source?.catalogReference?.options as { priceVariant?: { id?: unknown } } | undefined)?.priceVariant;
141
+ return typeof variant?.id === "string" ? variant.id : "";
142
+ }
143
+
144
+ /** The cart line Wix kept for [reference] — same dish, operation, menu, section and variant — or
145
+ * undefined when Wix dropped it. Wix can refuse an item and still answer 200, so the returned cart
146
+ * is the proof. The operation and menu matter: the same dish added from two restaurant locations
147
+ * is two lines, and only the one just added proves this add. */
148
+ export function addedLine(cart: WixCartV2 | undefined, reference: WixCatalogReference): WixCartV2LineItem | undefined {
149
+ type PlacementOptions = { operationId?: unknown; menuId?: unknown; menuSectionId?: unknown };
150
+ const options = (reference.options ?? {}) as PlacementOptions & { priceVariant?: { id?: unknown } };
151
+ const variantId = typeof options.priceVariant?.id === "string" ? options.priceVariant.id : "";
152
+ return (cart?.lineItems ?? []).find((line) => {
153
+ const ref = line.source?.catalogReference;
154
+ if (!ref || ref.appId !== reference.appId || ref.catalogItemId !== reference.catalogItemId) return false;
155
+ const lineOptions = (ref.options ?? {}) as PlacementOptions;
156
+ if (lineOptions.operationId !== options.operationId || lineOptions.menuId !== options.menuId) return false;
157
+ if (lineOptions.menuSectionId !== options.menuSectionId) return false;
158
+ if (lineVariantId(line) !== variantId) return false;
159
+ if (line.status === "OUT_OF_STOCK" || line.status === "REMOVED_FROM_CATALOG") return false;
160
+ const confirmed = line.quantityInfo?.confirmedQuantity;
161
+ return confirmed === undefined || confirmed > 0;
162
+ });
163
+ }
164
+
165
+ export function lineQuantity(line: WixCartV2LineItem): number {
166
+ const q = line.quantityInfo?.confirmedQuantity ?? line.quantityInfo?.requestedQuantity ?? 1;
167
+ return Number.isFinite(q) && q >= 0 ? Math.floor(q) : 0;
168
+ }
169
+
170
+ /** How many dishes the cart holds (restaurant lines only). */
171
+ export function restaurantItemCount(cart: WixCartV2 | undefined): number {
172
+ return (cart?.lineItems ?? []).filter(isRestaurantLine).reduce((total, line) => total + lineQuantity(line), 0);
173
+ }
174
+
175
+ export function slotFor(slots: WixFulfillmentTimeSlot[] | undefined, type: FulfillmentType): WixFulfillmentTimeSlot | undefined {
176
+ return (slots ?? []).find((slot) => slot?.fulfilmentType === type);
177
+ }
178
+
179
+ /** The `deliveryMethod.code` for [type]: `<TYPE>|ASAP`, or the slot's own window for a pre-order.
180
+ * An ASAP slot must be sent as ASAP — its start/end window is refused as `DELIVERY_METHOD_NOT_FOUND`. */
181
+ export function deliveryCode(type: FulfillmentType, slot?: WixFulfillmentTimeSlot): string {
182
+ const window = slot?.timeSlot;
183
+ if (window?.orderSchedulingType === "PREORDER") {
184
+ const start = Date.parse(window.startTime ?? ""), end = Date.parse(window.endTime ?? "");
185
+ if (Number.isFinite(start) && Number.isFinite(end)) return `${type}|${start}|${end}`;
186
+ }
187
+ return `${type}|ASAP`;
188
+ }
189
+
190
+ export async function firstSlots(operationId: string, deliveryAddress?: WixRestaurantsAddress): Promise<WixFulfillmentTimeSlot[]> {
191
+ return (await getFirstAvailableTimeSlotPerFulfillmentType(operationId, deliveryAddress)).timeslotsPerFulfillmentType ?? [];
192
+ }
193
+
194
+ /** The pickup address the cart needs before a pickup method can be set: the restaurant's own
195
+ * country (and region when Wix gives one) — Wix requires an address even for pickup. */
196
+ export function pickupCartAddress(method: WixFulfillmentMethod | undefined, fallbackCountry = ""): WixRestaurantsAddress | undefined {
197
+ const address = method?.pickupOptions?.address;
198
+ const country = (address?.country || fallbackCountry).trim();
199
+ if (!country) return undefined;
200
+ return { country, ...(address?.subdivision ? { subdivision: address.subdivision } : {}) };
201
+ }
@@ -1,6 +1,7 @@
1
1
  import { withOnlineWrites } from "../_shared/online-actions.js";
2
2
  import { defineService as registerState } from "@himalaya/state";
3
3
  import { getStoreProduct } from "../_shared/himi-store.js";
4
+ import { RESTAURANTS_ORDERS_APP_ID } from "../_shared/restaurant-ordering.js";
4
5
  import { iapPriceCopy } from "../_shared/iap-worker.js";
5
6
  import { withIapPresentation } from "../_shared/iap-presentation.js";
6
7
  import { type IapCommerce, classifyCart } from "@himalaya/iap-checkout";
@@ -523,6 +524,11 @@ const svc = defineService<State, {
523
524
  const commerce = cartPricing;
524
525
  const items = lines.map(line => (line as WixCartLineItemDetail & { catalogReference?: Row["catalogReference"] }).catalogReference);
525
526
  if (items.some(item => !item?.catalogItemId || !item.appId)) return derive({ ...state, checkoutHint: iapPriceCopy("cart").purchaseUnavailable });
527
+ // Restaurant dishes share this Wix cart but check out on the food order screen; this checkout
528
+ // takes Stores products only, so say which lines are in the way rather than "unavailable".
529
+ if (items.some(item => item!.appId === RESTAURANTS_ORDERS_APP_ID)) {
530
+ return derive({ ...state, checkoutHint: copyParam("cart.restaurantItemsHint", "This cart also has restaurant dishes. Remove them here to check out your store items.") });
531
+ }
526
532
  await commerce.loadPrices(items.map((item, index) => ({ reference: { kind: "storesProduct", id: item!.catalogItemId }, commerceDisplayPrice: lines[index].price?.formattedAmount })));
527
533
  const classification = classifyCart(items.map((item, index) => ({ route: commerce.pricing.view({ kind: "storesProduct", id: item!.catalogItemId }).route, quantity: lines[index].quantity ?? 1 })));
528
534
  if (classification !== "regular") return derive({ ...state, checkoutHint: classification === "mixedRefused" ? copyParam("cart.iapSingleItemOnly", "Buy digital items individually, with quantity 1 and no options.") : iapPriceCopy("cart").purchaseUnavailable });
@@ -5,40 +5,31 @@
5
5
  // read; when the GET path 404s/errors on a drifted gateway the worker falls
6
6
  // back to the items QUERY (client-side find by id — correct whether or not the
7
7
  // gateway honored the filter). Only both failing lands in the error card.
8
+ //
9
+ // "Add to order" puts the dish in the site's real Wix cart (Restaurants online
10
+ // ordering, _shared/restaurant-ordering.ts) — never a device-local basket. It is
11
+ // offered only when the restaurant takes online orders now and the dish can be
12
+ // ordered as shown: a dish with options to pick (modifier groups) or a sold-out
13
+ // dish says so instead of adding the wrong thing.
8
14
  import { defineService } from "@himalaya/state";
9
- import { getItem, queryItems, type WixItem } from "@himalaya/wix-restaurants";
15
+ import { getItem, queryItems, type WixItem, type WixOperation } from "@himalaya/wix-restaurants";
16
+ import { addLineItemsToCurrentCartV2, ecomApplicationErrorCode, getCurrentCartV2 } from "@himalaya/wix-ecom";
10
17
  import { WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
11
18
  import { descriptionSnippet, itemImage, itemLabels, itemPriceLine, itemPriceVariants, unwrapItem } from "../_shared/himi-restaurants.js";
19
+ import { addedLine, dishReference, lineQuantity, loadDishPlacement, loadOrderingOperation, restaurantItemCount, type DishPlacement } from "../_shared/restaurant-ordering.js";
12
20
  import { cleanTitle, initials } from "../_shared/himi-text.js";
13
21
  import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
14
- import { localPreferences } from "../_shared/local-preferences.js";
15
-
16
- interface BasketItem {
17
- id: string;
18
- name: string;
19
- priceLine: string;
20
- quantity: number;
21
- }
22
22
 
23
23
  interface VariantRow {
24
24
  id: string;
25
+ /** Wix's own variant id ("" when the payload carries none — then it cannot be ordered). */
26
+ variantId: string;
25
27
  name: string;
26
28
  priceLine: string;
29
+ price: string;
27
30
  actionLabel: string;
28
31
  }
29
32
 
30
- function normalizeBasket(value: unknown): BasketItem[] {
31
- if (!Array.isArray(value)) return [];
32
- return value.flatMap((entry) => {
33
- const item = entry as Partial<BasketItem> | null;
34
- if (!item || typeof item.id !== "string" || !item.id || typeof item.name !== "string") return [];
35
- const quantity = Number.isFinite(item.quantity) ? Math.max(1, Math.min(99, Math.floor(item.quantity!))) : 1;
36
- return [{ id: item.id.slice(0, 256), name: item.name.slice(0, 256), priceLine: typeof item.priceLine === "string" ? item.priceLine.slice(0, 128) : "", quantity }];
37
- }).slice(0, 50);
38
- }
39
-
40
- const basket = localPreferences<BasketItem[]>("restaurant.basket", normalizeBasket);
41
-
42
33
  interface State {
43
34
  status: "loading" | "ready" | "error";
44
35
  id: string;
@@ -69,11 +60,32 @@ interface State {
69
60
  hasImage: boolean;
70
61
  noImage: boolean;
71
62
  hideContent: boolean;
72
- basketCount: number;
73
- basketLabel: string;
74
- basketMessage: string;
75
- canSaveBasket: boolean;
76
- cannotSaveBasket: boolean;
63
+ /** "Add to order" is offered (ordering open, dish orderable as shown). */
64
+ canOrder: boolean;
65
+ cannotOrder: boolean;
66
+ /** Why it is not offered: ordering closed, sold out, options to choose on the site, … */
67
+ orderNote: string;
68
+ noOrderNote: boolean;
69
+ /** Feedback for the last add. */
70
+ orderMessage: string;
71
+ /** Restaurant dishes in the cart, and the "View your order" entry. */
72
+ cartCount: number;
73
+ cartLabel: string;
74
+ hasCart: boolean;
75
+ noCart: boolean;
76
+ }
77
+
78
+ /** What load learned about ordering this dish — kept beside the state, never rendered. */
79
+ let operation: WixOperation | undefined;
80
+ let placement: DishPlacement | undefined;
81
+ let adding = false;
82
+ /** Bumped by every confirmed add: a load that an add overtook keeps the add's count and message. */
83
+ let addSeq = 0;
84
+ /** The service's live state (set once the service exists; see wix-spaces-food-orders). */
85
+ let latestState: () => State = () => { throw new Error("service not defined yet"); };
86
+
87
+ function navInput(): { id?: string; screenId?: string; menuId?: string; sectionId?: string } {
88
+ return (globalThis as { himalaya?: { input?: { id?: string; screenId?: string; menuId?: string; sectionId?: string } } }).himalaya?.input ?? {};
77
89
  }
78
90
 
79
91
  function inputId(): string {
@@ -81,14 +93,13 @@ function inputId(): string {
81
93
  // `id` nav param (himi.open { screenId: "himi_menu_item", id }) into worker input.
82
94
  // The screenId-slice below is #570 back-compat for pre-params releases with
83
95
  // baked `himi_menu_item_<id>` screenIds — do not author against this.
84
- const input = (globalThis as { himalaya?: { input?: { id?: string; screenId?: string } } }).himalaya?.input;
85
- if (input?.id) return input.id;
86
- return input?.screenId?.startsWith("himi_menu_item_") ? input.screenId.slice("himi_menu_item_".length) : "";
96
+ const input = navInput();
97
+ if (input.id) return input.id;
98
+ return input.screenId?.startsWith("himi_menu_item_") ? input.screenId.slice("himi_menu_item_".length) : "";
87
99
  }
88
100
 
89
101
  function derive(state: State): State {
90
102
  const empty = state.status === "ready" && !state.name;
91
- const basketCount = normalizeBasket(basket.read()).reduce((total, item) => total + item.quantity, 0);
92
103
  return {
93
104
  ...state,
94
105
  variants: state.variants.map((variant) => ({
@@ -105,25 +116,51 @@ function derive(state: State): State {
105
116
  hasImage: !!state.image,
106
117
  noImage: !state.image,
107
118
  hideContent: state.status !== "ready" || empty,
108
- basketCount,
109
- basketLabel: basketCount
110
- ? copyTemplate("menuItem.basketCount", "{count} {unit} in your basket", {
111
- count: basketCount,
112
- unit: basketCount === 1 ? "item" : "items",
119
+ cannotOrder: !state.canOrder,
120
+ noOrderNote: !state.orderNote,
121
+ cartLabel: state.cartCount
122
+ ? copyTemplate("menuItem.cartCount", "{count} {unit} in your order", {
123
+ count: state.cartCount,
124
+ unit: state.cartCount === 1 ? "dish" : "dishes",
113
125
  })
114
- : copyParam("menuItem.basketEmpty", "Your basket is empty"),
115
- canSaveBasket: basket.available,
116
- cannotSaveBasket: !basket.available,
126
+ : "",
127
+ hasCart: state.cartCount > 0,
128
+ noCart: state.cartCount === 0,
117
129
  };
118
130
  }
119
131
 
120
- function ready(state: State, item: WixItem | undefined): State {
132
+ const NOT_ORDERING = { canOrder: false, orderNote: "" } as const;
133
+
134
+ /** Whether this dish can be ordered from the app, and the note shown when it cannot. */
135
+ function orderability(item: WixItem, variants: VariantRow[], orderingOpen: boolean | "unknown"): Pick<State, "canOrder" | "orderNote"> {
136
+ const raw = item as unknown as { orderSettings?: { inStock?: unknown }; modifierGroups?: unknown; modifierGroupIds?: unknown };
137
+ if (orderingOpen === "unknown") {
138
+ return { canOrder: false, orderNote: copyParam("menuItem.orderingUnavailable", "Online ordering isn't available right now.") };
139
+ }
140
+ if (!orderingOpen) return { canOrder: false, orderNote: copyParam("menuItem.orderingClosed", "Online ordering is closed right now.") };
141
+ if (raw.orderSettings?.inStock === false) return { canOrder: false, orderNote: copyParam("menuItem.soldOut", "This dish is sold out.") };
142
+ const groups = [raw.modifierGroups, raw.modifierGroupIds].some((value) => Array.isArray(value) && value.length > 0);
143
+ // Options are chosen on the restaurant's website: adding the dish without them could
144
+ // order something the customer did not pick.
145
+ if (groups) return { canOrder: false, orderNote: copyParam("menuItem.optionsOnSite", "This dish has options. Order it on the restaurant's website.") };
146
+ // Wix's documented visitor cart flow does not define a price-variant selection. A storefront
147
+ // implementation suggests a key, but we cannot safely recover if Wix prices that line wrong
148
+ // and the cart refuses removal. Keep the choice visible, and send the customer to the site.
149
+ if (variants.length > 0) {
150
+ return { canOrder: false, orderNote: copyParam("menuItem.optionsOnSite", "This dish has options. Order it on the restaurant's website.") };
151
+ }
152
+ if (!placement) return { canOrder: false, orderNote: copyParam("menuItem.notOrderable", "This dish can't be ordered online.") };
153
+ return { canOrder: true, orderNote: "" };
154
+ }
155
+
156
+ function ready(state: State, item: WixItem | undefined, orderingOpen: boolean | "unknown", cartCount: number): State {
121
157
  if (!item || item.visible === false) {
122
158
  return derive({
123
159
  ...state, status: "ready", id: "", name: "", description: "", priceLine: "", image: "", initial: "",
124
160
  label1: "", label2: "", label3: "", noLabel1: true, noLabel2: true, noLabel3: true,
125
161
  variants: [], hasVariants: false, noVariants: true,
126
162
  selectedVariantId: "", selectedVariantLabel: "",
163
+ ...NOT_ORDERING, orderMessage: "", cartCount,
127
164
  emptyText: copyParam("menuItem.emptyText", "This dish is no longer on the menu."),
128
165
  });
129
166
  }
@@ -151,37 +188,61 @@ function ready(state: State, item: WixItem | undefined): State {
151
188
  selectedVariantLabel: "",
152
189
  image: itemImage((item as unknown as Record<string, unknown>).image, WIX_DETAIL_IMAGE),
153
190
  initial: initials(name),
191
+ ...orderability(item, variants, orderingOpen),
192
+ orderMessage: "",
193
+ cartCount,
154
194
  });
155
195
  }
156
196
 
157
- async function load(state: State, arg?: unknown): Promise<State> {
158
- stashCopy(arg);
159
- const id = inputId();
160
- if (!id) return ready(state, undefined);
197
+ async function loadDish(id: string): Promise<WixItem | undefined> {
161
198
  try {
162
199
  // The live GET path 200s with a WRAPPED payload (`{ item: {...} }`) while
163
200
  // the generated client types it bare — unwrap defensively (production R1:
164
201
  // the raw wrapper rendered a hollow "Dish" shell with loading/error/empty
165
202
  // all hidden). An unrecognizable 200 shape falls through to the query.
166
203
  const item = unwrapItem(await getItem(id));
167
- if (item) return ready(state, item);
204
+ if (item) return item;
168
205
  } catch {
169
206
  // Drifted GET path / transient failure → the query fallback below.
170
207
  }
171
208
  // Client-side find keeps this correct even when the gateway ignores the
172
- // filter shape.
209
+ // filter shape. A failure here throws to the error card.
210
+ const response = await queryItems({ filter: { id: { $eq: id } }, paging: { limit: 100 } });
211
+ return (response.items ?? []).find((candidate) => candidate.id === id);
212
+ }
213
+
214
+ async function load(state: State, arg?: unknown): Promise<State> {
215
+ stashCopy(arg);
216
+ const addAt = addSeq;
217
+ const id = inputId();
218
+ if (!id) return ready(state, undefined, false, 0);
219
+ const input = navInput();
220
+ // Ordering is best-effort beside the dish: a failed ordering read hides the
221
+ // button with a note, never the dish.
222
+ const where = loadDishPlacement(id, { menuId: input.menuId, sectionId: input.sectionId }).catch(() => undefined);
223
+ const count = getCurrentCartV2().then(restaurantItemCount, () => 0);
224
+ let item: WixItem | undefined;
173
225
  try {
174
- const response = await queryItems({ filter: { id: { $eq: id } }, paging: { limit: 100 } });
175
- return ready(state, (response.items ?? []).find((candidate) => candidate.id === id));
226
+ item = await loadDish(id);
176
227
  } catch {
177
228
  return derive({ ...state, status: "error" });
178
229
  }
230
+ const [found, cartCount] = await Promise.all([where, count]);
231
+ const { op, ok } = await loadOrderingOperation({ businessLocationId: found?.businessLocationId })
232
+ .then((selected) => ({ op: selected, ok: true }), () => ({ op: undefined, ok: false }));
233
+ operation = op;
234
+ placement = found;
235
+ const next = ready(state, item, ok ? !!op : "unknown", cartCount);
236
+ if (addSeq === addAt) return next;
237
+ // An add landed while this (re)load was in flight: its count and "Added" message are newer.
238
+ const latest = latestState();
239
+ return derive({ ...next, cartCount: latest.cartCount, orderMessage: latest.orderMessage });
179
240
  }
180
241
 
181
242
  const svc = defineService<State, {
182
243
  load: (state: State, arg?: unknown) => Promise<State>;
183
244
  selectVariant: (state: State, arg?: unknown) => State;
184
- addToBasket: (state: State) => State;
245
+ addToOrder: (state: State) => Promise<State>;
185
246
  }>("himiBrandedMenuItem", {
186
247
  state: derive({
187
248
  status: "loading",
@@ -211,11 +272,15 @@ const svc = defineService<State, {
211
272
  hasImage: false,
212
273
  noImage: true,
213
274
  hideContent: true,
214
- basketCount: 0,
215
- basketLabel: "",
216
- basketMessage: "",
217
- canSaveBasket: basket.available,
218
- cannotSaveBasket: !basket.available,
275
+ canOrder: false,
276
+ cannotOrder: true,
277
+ orderNote: "",
278
+ noOrderNote: true,
279
+ orderMessage: "",
280
+ cartCount: 0,
281
+ cartLabel: "",
282
+ hasCart: false,
283
+ noCart: true,
219
284
  }),
220
285
  actions: {
221
286
  load,
@@ -234,36 +299,38 @@ const svc = defineService<State, {
234
299
  name: selected.name,
235
300
  price: selected.priceLine,
236
301
  }),
237
- basketMessage: "",
302
+ orderMessage: "",
238
303
  });
239
304
  },
240
- addToBasket: (state) => {
241
- if (!basket.available) {
242
- return derive({ ...state, basketMessage: copyParam("menuItem.basketSignIn", "Sign in to save a basket for this business.") });
243
- }
305
+ addToOrder: async (state) => {
244
306
  const id = inputId();
245
- if (!id || !state.name || state.status !== "ready") return state;
246
- const selected = state.hasVariants
247
- ? state.variants.find((variant) => variant.id === state.selectedVariantId)
248
- : undefined;
249
- if (state.hasVariants && !selected) {
250
- return derive({ ...state, basketMessage: copyParam("menuItem.chooseVariantFirst", "Choose an option before adding this dish.") });
251
- }
252
- const basketId = selected ? `${id}::${selected.id}` : id;
253
- const basketName = selected ? `${state.name} — ${selected.name}` : state.name;
254
- const basketPrice = selected?.priceLine || state.priceLine;
255
- const current = basket.read();
256
- const existing = current.find((item) => item.id === basketId);
257
- const next = existing
258
- ? current.map((item) => item.id === basketId ? { ...item, quantity: Math.min(99, item.quantity + 1) } : item)
259
- : [...current, { id: basketId, name: basketName, priceLine: basketPrice, quantity: 1 }];
260
- basket.write(next);
261
- if (!basket.available) {
262
- return derive({ ...state, basketMessage: copyParam("menuItem.basketWriteFailed", "Your basket could not be saved. Try again.") });
307
+ if (adding || !state.canOrder || !operation || !placement || !id || state.status !== "ready") return state;
308
+ const reference = dishReference(id, operation.id, placement);
309
+ adding = true;
310
+ try {
311
+ const before = await getCurrentCartV2();
312
+ const prior = addedLine(before, reference);
313
+ const priorQuantity = prior ? lineQuantity(prior) : 0;
314
+ const cart = (await addLineItemsToCurrentCartV2([{ catalogReference: reference, quantity: 1 }])).cart ?? await getCurrentCartV2();
315
+ const line = addedLine(cart, reference);
316
+ if (!line || lineQuantity(line) <= priorQuantity) {
317
+ return derive({ ...latestState(), orderMessage: copyParam("menuItem.addRefused", "The restaurant couldn't add this dish right now.") });
318
+ }
319
+ addSeq++;
320
+ // Onto the live state: a reload while the add was in flight (ordering closed, say) stays.
321
+ return derive({ ...latestState(), orderMessage: copyParam("menuItem.addedToOrder", "Added to your order."), cartCount: restaurantItemCount(cart) });
322
+ } catch (error) {
323
+ const code = ecomApplicationErrorCode(error);
324
+ const orderMessage = code === "ITEM_NOT_FOUND_IN_CATALOG" || code === "INSUFFICIENT_INVENTORY"
325
+ ? copyParam("menuItem.noLongerOrderable", "This dish can't be ordered right now.")
326
+ : copyParam("menuItem.addFailed", "Could not add this dish. Try again.");
327
+ return derive({ ...latestState(), orderMessage });
328
+ } finally {
329
+ adding = false;
263
330
  }
264
- return derive({ ...state, basketMessage: copyParam("menuItem.basketAdded", "Added to your basket.") });
265
331
  },
266
332
  },
267
333
  });
268
334
 
335
+ latestState = () => svc.getState() as State;
269
336
  export { svc };