@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
@@ -1,97 +1,849 @@
1
- // Tier-5 bundle: Wix Spaces clone — signed-in member's device-local restaurant basket.
2
- // Restaurant Orders is an owner-capable API. A client-side filter cannot keep records
3
- // private because the raw response has already reached the member device, so this worker
4
- // never calls that surface. A customer deployment may replace the explicit handoff copy
5
- // with a member-authorized site endpoint once one is configured.
1
+ // Tier-5 bundle: restaurant order screen — the customer's Wix cart, pickup or delivery,
2
+ // the Wix-hosted checkout, and (signed in) their placed restaurant orders.
3
+ //
4
+ // Wix's visitor flow (_shared/restaurant-ordering.ts): an ENABLED operation → its enabled
5
+ // fulfillment methods → the eCom current cart (Cart V2) → the cart's address (required even
6
+ // for pickup) → its delivery method → a redirect session for the cart id, opened in-app. The
7
+ // checkout path is the Stores cart's (`payCashierOrder` + `@himalaya/wix-redirects`; the hosted
8
+ // Spaces shell opens it as its member through `_shared/hosted-checkout.ts`). The cart is the
9
+ // site's single eCom cart, so it lists every line Wix will charge, not only the dishes.
10
+ //
11
+ // History reads the member's eCom orders the way the member Orders screen does (`searchOrders`
12
+ // filtered to the signed-in email, the member-scope cache rules of `_shared/member-orders.ts`)
13
+ // and keeps the orders with a Restaurants line. The owner-capable Restaurants Orders API is never
14
+ // called from a member device. `orders` + `emptyText` stay the list contract the Spaces clone binds.
6
15
  import { defineService } from "@himalaya/state";
7
- import { memberAccess } from "../_shared/member-access.js";
8
- import { localPreferences } from "../_shared/local-preferences.js";
16
+ import {
17
+ ecomApplicationErrorCode,
18
+ isOrdersForbiddenError,
19
+ estimateCurrentCartV2,
20
+ getCurrentCartV2,
21
+ removeLineItemsFromCurrentCartV2,
22
+ searchOrders,
23
+ setDeliveryMethodForCurrentCartV2,
24
+ updateCurrentCartV2DeliveryAddress,
25
+ updateLineItemsInCurrentCartV2,
26
+ type WixCartV2,
27
+ type WixCartV2LineItem,
28
+ type WixOrder,
29
+ } from "@himalaya/wix-ecom";
30
+ import type { WixFulfillmentMethod, WixFulfillmentTimeSlot, WixOperation, WixRestaurantsAddress } from "@himalaya/wix-restaurants";
31
+ import { createRedirectSession } from "@himalaya/wix-redirects";
32
+ import { payCashierOrder } from "@himalaya/checkout";
33
+ import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
34
+ import {
35
+ RESTAURANTS_ORDERS_APP_ID, cartCurrency, decimal, deliveryCode, firstSlots, isRestaurantLine, lineQuantity,
36
+ loadOfferedMethods, loadOrderingOperation, money, pickupCartAddress, slotFor, type FulfillmentType,
37
+ } from "../_shared/restaurant-ordering.js";
38
+ import { hostedCheckoutOpener, hostedSitePageOpener, isHostedWorker } from "../_shared/hosted-checkout.js";
39
+ import { assertOrderMember, orderAccessDenied, orderMember, ownedOrderCache, type OrderMember } from "../_shared/member-orders.js";
40
+ import { formatDateMedium, formatWeekdayDateTime } from "../_shared/engine-intl.js";
9
41
  import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
10
42
 
11
- interface BasketRow { id: string; name: string; priceLine: string; quantity: number; subtitle: string }
12
- interface OrderRow { id: string; name: string; subtitle: string }
43
+ interface LineRow {
44
+ id: string;
45
+ name: string;
46
+ /** The media helper's accessible name for the dish image. */
47
+ title: string;
48
+ subtitle: string;
49
+ quantity: number;
50
+ quantityLabel: string;
51
+ total: string;
52
+ image: string;
53
+ initial: string;
54
+ hasImage: boolean;
55
+ noImage: boolean;
56
+ }
57
+
58
+ interface OrderRow { id: string; name: string; subtitle: string; status: string; detailScreenId: string }
59
+
13
60
  interface S {
14
61
  status: "loading" | "ready" | "error";
62
+ errorText: string;
63
+ isLoading: boolean;
64
+ notLoading: boolean;
65
+ // Ordering
66
+ orderingOpen: boolean;
67
+ orderingClosed: boolean;
68
+ notClosed: boolean;
69
+ /** Why no operation can be ordered from: closed, a cart that mixes locations, or details that
70
+ * could not be refreshed. Each has its own banner, whose TITLE is catalog copy (translatable);
71
+ * the worker only chooses which shows, and supplies the message. */
72
+ closedKind: "" | "closed" | "mixed" | "refresh";
73
+ hideClosedBanner: boolean;
74
+ hideMixedBanner: boolean;
75
+ hideRefreshBanner: boolean;
76
+ closedText: string;
77
+ // Cart
78
+ items: LineRow[];
79
+ containsOtherItems: boolean;
80
+ hasItems: boolean;
81
+ noItems: boolean;
82
+ showEmptyCart: boolean;
83
+ hideEmptyCart: boolean;
84
+ subtotal: string;
85
+ totalLine: string;
86
+ noTotalLine: boolean;
87
+ // Fulfillment
88
+ fulfillment: "" | FulfillmentType;
89
+ hasPickup: boolean;
90
+ noPickup: boolean;
91
+ hasDelivery: boolean;
92
+ noDelivery: boolean;
93
+ pickupSelected: boolean;
94
+ pickupNotSelected: boolean;
95
+ deliverySelected: boolean;
96
+ deliveryNotSelected: boolean;
97
+ pickupLabel: string;
98
+ pickupDetail: string;
99
+ deliveryLabel: string;
100
+ deliveryDetail: string;
101
+ hideFulfillment: boolean;
102
+ hideAddress: boolean;
103
+ addressLine: string;
104
+ city: string;
105
+ postalCode: string;
106
+ subdivision: string;
107
+ country: string;
108
+ deliveryTerms: { addressKey: string; minOrderPrice?: string; fee?: string } | null;
109
+ // Minimum order / delivery area
110
+ belowMinimum: boolean;
111
+ minimumText: string;
112
+ noMinimumText: boolean;
113
+ outsideArea: boolean;
114
+ notOutsideArea: boolean;
115
+ hideCheckout: boolean;
116
+ checkoutHint: string;
117
+ // History
15
118
  orders: OrderRow[];
16
- basketItems: BasketRow[];
17
- basketCount: number;
18
- basketLabel: string;
19
- emptyText: string;
20
119
  needsSignIn: boolean;
21
120
  noSignIn: boolean;
22
- hasBasket: boolean;
23
- noBasket: boolean;
121
+ hasOrders: boolean;
122
+ noOrders: boolean;
123
+ emptyText: string;
124
+ onSite: boolean;
125
+ notOnSite: boolean;
126
+ /** Back-compat list flags (the Spaces clone binds `orders`/`emptyText`). */
24
127
  hasActiveOrder: boolean;
25
128
  noActiveOrder: boolean;
26
129
  errored: boolean;
27
130
  notErrored: boolean;
28
131
  }
29
132
 
30
- function normalizeBasket(value: unknown): BasketRow[] {
31
- if (!Array.isArray(value)) return [];
32
- return value.flatMap((entry) => {
33
- const item = entry as Partial<BasketRow> | 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
- const priceLine = typeof item.priceLine === "string" ? item.priceLine.slice(0, 128) : "";
37
- return [{ id: item.id.slice(0, 256), name: item.name.slice(0, 256), priceLine, quantity,
38
- subtitle: [copyTemplate("foodOrders.quantityLine", "Qty {quantity}", { quantity }), priceLine].filter(Boolean).join(" · ") }];
39
- }).slice(0, 50);
133
+ // What load learned about ordering — kept beside the state, never rendered.
134
+ let operation: WixOperation | undefined;
135
+ let methods: WixFulfillmentMethod[] = [];
136
+ let slots: WixFulfillmentTimeSlot[] = [];
137
+ /** True once the customer picked pickup or delivery, or typed an address, on this screen. Until
138
+ * then the fulfillment shown is only a default, and a load takes the one the cart holds. */
139
+ let customerChose = false;
140
+ /** The member the screen's fulfillment and address were last loaded for ("" before the first load). */
141
+ let shownFor = "";
142
+ /** The `flowRevision` a pending checkout was tapped at; -1 when none is pending. */
143
+ let checkoutPendingAt = -1;
144
+ let flowRevision = 0;
145
+ let cartQueue: Promise<unknown> = Promise.resolve();
146
+
147
+ /** Who the order history belongs to: the email AND the account/business scope, as memberAccess
148
+ * defines it — the same email under another account is another member's history. */
149
+ function memberKey(member: { email: string; scope?: string }): string {
150
+ return `${member.email}\u0000${member.scope ?? ""}`;
40
151
  }
41
152
 
42
- const basket = localPreferences<BasketRow[]>("restaurant.basket", normalizeBasket);
153
+ /** Every action that reads and commits the cart — a load, a quantity change, a checkout — runs here,
154
+ * one at a time. Interleaved, whichever finished last committed a cart or an operation another one
155
+ * had already replaced (an older cart over a newer one, a closed operation over an open one). */
156
+ function serialized(work: () => Promise<S>): Promise<S> {
157
+ const task = cartQueue.then(work);
158
+ cartQueue = task.then(async (next) => {
159
+ // The runtime stores an action's answer a few microtasks after the action resolves. The next
160
+ // queued action waits for it, so it starts from this answer and not from the state before it.
161
+ for (let turn = 0; turn < 50 && latestState() !== next; turn++) await Promise.resolve();
162
+ }, () => undefined);
163
+ return task;
164
+ }
165
+ /** The last cart's currency and subtotal, for derives that run without a fresh cart. */
166
+ let lastCurrency = "";
167
+ let subtotalValue = 0;
168
+
169
+ const method = (type: string): WixFulfillmentMethod | undefined => methods.find((m) => m.type === type);
43
170
 
44
- function derive(state: S): S {
45
- const basketItems = normalizeBasket(basket.read());
46
- const basketCount = basketItems.reduce((total, item) => total + item.quantity, 0);
171
+ function deliveryAddressKey(state: S): string {
172
+ return JSON.stringify([state.addressLine.trim(), state.city.trim(), state.postalCode.trim(),
173
+ state.subdivision.trim().toUpperCase(), state.country.trim().toUpperCase()]);
174
+ }
175
+
176
+ function initialOf(name: string): string {
177
+ return (name.trim()[0] || "D").toUpperCase();
178
+ }
179
+
180
+ function lineRow(line: WixCartV2LineItem, index: number, currency: string): LineRow {
181
+ const quantity = lineQuantity(line);
182
+ const name = line.name?.translated || line.name?.original || copyParam("foodOrders.itemFallbackName", "Item");
183
+ const unit = line.pricing?.unitPrice?.amount;
184
+ const image = line.attributes?.image?.url ?? "";
185
+ return {
186
+ id: line.id || `line-${index}`,
187
+ name,
188
+ title: name,
189
+ subtitle: unit !== undefined ? copyTemplate("foodOrders.unitPrice", "{price} each", { price: money(unit, currency) }) : "",
190
+ quantity,
191
+ quantityLabel: `${quantity}`,
192
+ total: money(line.pricing?.totalPrice?.amount, currency),
193
+ image,
194
+ initial: initialOf(name),
195
+ hasImage: !!image,
196
+ noImage: !image,
197
+ };
198
+ }
199
+
200
+ function methodLabel(m: WixFulfillmentMethod | undefined, type: FulfillmentType, currency: string,
201
+ terms?: S["deliveryTerms"]): { label: string; detail: string } {
202
+ const base = type === "PICKUP" ? copyParam("foodOrders.pickupLabel", "Pickup") : copyParam("foodOrders.deliveryLabel", "Delivery");
203
+ if (!m) return { label: base, detail: "" };
204
+ const parts: string[] = [];
205
+ const slot = slotFor(slots, type);
206
+ const window = slot?.timeSlot;
207
+ if (window?.orderSchedulingType === "PREORDER" && window.startTime && !Number.isNaN(Date.parse(window.startTime))) {
208
+ parts.push(copyTemplate("foodOrders.earliest", "Earliest {time}", { time: formatWeekdayDateTime(new Date(window.startTime)) }));
209
+ } else {
210
+ const minutes = slot?.fulfillmentInfo?.[0]?.maxTime ?? (type === "DELIVERY" ? m.deliveryOptions?.deliveryTimeInMinutes : undefined);
211
+ if (minutes) parts.push(copyTemplate("foodOrders.readyIn", "About {minutes} min", { minutes }));
212
+ }
213
+ const multipleAreas = type === "DELIVERY" && methods.filter((candidate) => candidate.type === "DELIVERY").length > 1;
214
+ // A missing fee is 0 (restaurants SDK). Several areas: unknown until the address picks one.
215
+ const fee = terms ? (terms.fee ?? "0") : multipleAreas ? undefined : (m.fee ?? "0");
216
+ const minOrderPrice = terms?.minOrderPrice ?? (multipleAreas ? undefined : m.minOrderPrice);
217
+ if (type === "DELIVERY") parts.push(fee === undefined
218
+ ? copyParam("foodOrders.deliveryTermsPending", "Fee and minimum shown after checking your address")
219
+ : decimal(fee) > 0
220
+ ? copyTemplate("foodOrders.deliveryFee", "Fee {fee}", { fee: money(fee, currency) })
221
+ : copyParam("foodOrders.freeDelivery", "Free delivery"));
222
+ if (decimal(minOrderPrice) > 0) parts.push(copyTemplate("foodOrders.minimumShort", "Minimum {amount}", { amount: money(minOrderPrice, currency) }));
223
+ if (type === "PICKUP" && m.pickupOptions?.address?.formattedAddress) parts.push(m.pickupOptions.address.formattedAddress);
224
+ return { label: base, detail: parts.join(" · ") };
225
+ }
226
+
227
+ function derive(state: S, cart?: WixCartV2): S {
228
+ const currency = cartCurrency(cart) || lastCurrency;
229
+ if (cart) lastCurrency = currency;
230
+ const pickup = method("PICKUP"), delivery = method("DELIVERY");
231
+ const open = !!operation;
232
+ let fulfillment = state.fulfillment;
233
+ if (fulfillment && !method(fulfillment)) fulfillment = "";
234
+ if (!fulfillment) {
235
+ const preferred = operation?.defaultFulfillmentType;
236
+ fulfillment = preferred === "DELIVERY" && delivery ? "DELIVERY" : pickup ? "PICKUP" : delivery ? "DELIVERY" : "";
237
+ }
238
+ const chosen = fulfillment ? method(fulfillment) : undefined;
239
+ const subtotalAmount = cart ? decimal(cart.subtotal?.amount) : subtotalValue;
240
+ if (cart) subtotalValue = subtotalAmount;
241
+ const terms = state.deliveryTerms?.addressKey === deliveryAddressKey(state) ? state.deliveryTerms : null;
242
+ const multipleDeliveryAreas = methods.filter((candidate) => candidate.type === "DELIVERY").length > 1;
243
+ const minimum = fulfillment === "DELIVERY" && multipleDeliveryAreas
244
+ ? decimal(terms?.minOrderPrice) : decimal(chosen?.minOrderPrice);
245
+ const belowMinimum = state.items.length > 0 && minimum > 0 && subtotalAmount < minimum;
246
+ const ready = state.status === "ready";
247
+ const pickupText = methodLabel(pickup, "PICKUP", currency), deliveryText = methodLabel(delivery, "DELIVERY", currency, terms);
47
248
  const signedIn = !state.needsSignIn;
48
249
  return {
49
250
  ...state,
50
- basketItems,
51
- basketCount,
52
- basketLabel: basketCount ? copyTemplate("foodOrders.basketCount", "{count} {unit} saved on this device", { count: basketCount, unit: basketCount === 1 ? "item" : "items" }) : "",
53
- hasBasket: signedIn && basketItems.length > 0,
54
- noBasket: !signedIn || basketItems.length === 0,
251
+ fulfillment,
252
+ subtotal: cart ? money(cart.subtotal?.amount, currency) : state.subtotal,
253
+ orderingOpen: open,
254
+ orderingClosed: ready && !open,
255
+ notClosed: !(ready && !open),
256
+ hideClosedBanner: !(ready && !open && (state.closedKind === "closed" || state.closedKind === "")),
257
+ hideMixedBanner: !(ready && !open && state.closedKind === "mixed"),
258
+ hideRefreshBanner: !(ready && !open && state.closedKind === "refresh"),
259
+ hasItems: ready && state.items.length > 0,
260
+ noItems: !ready || state.items.length === 0,
261
+ showEmptyCart: ready && state.items.length === 0,
262
+ hideEmptyCart: !ready || state.items.length > 0,
263
+ noTotalLine: !state.totalLine,
264
+ hasPickup: !!pickup,
265
+ noPickup: !pickup,
266
+ hasDelivery: !!delivery,
267
+ noDelivery: !delivery,
268
+ pickupSelected: fulfillment === "PICKUP",
269
+ pickupNotSelected: fulfillment !== "PICKUP",
270
+ deliverySelected: fulfillment === "DELIVERY",
271
+ deliveryNotSelected: fulfillment !== "DELIVERY",
272
+ pickupLabel: pickupText.label,
273
+ pickupDetail: pickupText.detail,
274
+ deliveryLabel: deliveryText.label,
275
+ deliveryDetail: deliveryText.detail,
276
+ hideFulfillment: !ready || !open || state.items.length === 0 || (!pickup && !delivery),
277
+ hideAddress: !ready || !open || state.items.length === 0 || fulfillment !== "DELIVERY",
278
+ belowMinimum,
279
+ minimumText: belowMinimum
280
+ ? copyTemplate("foodOrders.belowMinimum", "The minimum order for {method} is {amount}. Add {missing} more to check out.", {
281
+ method: fulfillment === "DELIVERY" ? copyParam("foodOrders.deliveryLabel", "Delivery").toLowerCase() : copyParam("foodOrders.pickupLabel", "Pickup").toLowerCase(),
282
+ amount: money(minimum, currency),
283
+ missing: money(minimum - subtotalAmount, currency),
284
+ })
285
+ : "",
286
+ noMinimumText: !belowMinimum,
287
+ notOutsideArea: !state.outsideArea,
288
+ hideCheckout: !ready || !open || state.items.length === 0 || !chosen || belowMinimum || state.outsideArea || state.containsOtherItems || state.items.some((item) => item.quantity === 0),
289
+ checkoutHint: state.containsOtherItems
290
+ ? copyParam("foodOrders.otherItemsHint", "This cart also has store items. Remove them here to check out your food order.")
291
+ : state.items.some((item) => item.quantity === 0)
292
+ ? copyParam("foodOrders.unavailableItemHint", "A dish in your cart is unavailable. Remove it before checking out.")
293
+ : state.checkoutHint,
294
+ needsSignIn: state.needsSignIn,
295
+ noSignIn: !ready || !state.needsSignIn,
296
+ hasOrders: signedIn && state.orders.length > 0,
297
+ noOrders: !signedIn || state.orders.length === 0,
298
+ notOnSite: !state.onSite,
55
299
  hasActiveOrder: signedIn && state.orders.length > 0,
56
300
  noActiveOrder: !signedIn || state.orders.length === 0,
57
- notErrored: !state.errored,
301
+ isLoading: state.status === "loading",
302
+ notLoading: state.status !== "loading",
303
+ errored: state.status === "error",
304
+ notErrored: state.status !== "error",
58
305
  };
59
306
  }
60
307
 
61
308
  const initial: S = {
62
- status: "loading", orders: [], basketItems: [], basketCount: 0, basketLabel: "", emptyText: "",
63
- needsSignIn: true, noSignIn: false, hasBasket: false, noBasket: true,
309
+ status: "loading", errorText: "", isLoading: true, notLoading: false,
310
+ orderingOpen: false, orderingClosed: false, notClosed: true, closedKind: "", hideClosedBanner: true, hideMixedBanner: true, hideRefreshBanner: true, closedText: "",
311
+ items: [], containsOtherItems: false, hasItems: false, noItems: true, showEmptyCart: false, hideEmptyCart: true, subtotal: "", totalLine: "", noTotalLine: true,
312
+ fulfillment: "", hasPickup: false, noPickup: true, hasDelivery: false, noDelivery: true,
313
+ pickupSelected: false, pickupNotSelected: true, deliverySelected: false, deliveryNotSelected: true,
314
+ pickupLabel: "", pickupDetail: "", deliveryLabel: "", deliveryDetail: "", hideFulfillment: true, hideAddress: true,
315
+ addressLine: "", city: "", postalCode: "", subdivision: "", country: "", deliveryTerms: null,
316
+ belowMinimum: false, minimumText: "", noMinimumText: true, outsideArea: false, notOutsideArea: true, hideCheckout: true, checkoutHint: "",
317
+ orders: [], needsSignIn: true, noSignIn: true, hasOrders: false, noOrders: true, emptyText: "", onSite: false, notOnSite: true,
64
318
  hasActiveOrder: false, noActiveOrder: true, errored: false, notErrored: true,
65
319
  };
66
320
 
321
+ // ---- History: the member's placed restaurant orders ----
322
+
323
+ const CACHE = "food_orders";
324
+
325
+ function orderStatus(order: WixOrder): string {
326
+ const raw = order as WixOrder & { status?: string };
327
+ if (raw.status === "CANCELED") return copyParam("foodOrders.statusCanceled", "Canceled");
328
+ if (order.fulfillmentStatus === "FULFILLED") return copyParam("foodOrders.statusCompleted", "Completed");
329
+ if (order.paymentStatus === "NOT_PAID" || order.paymentStatus === "PENDING") return copyParam("foodOrders.statusAwaitingPayment", "Awaiting payment");
330
+ return copyParam("foodOrders.statusReceived", "Received");
331
+ }
332
+
333
+ function isRestaurantOrder(order: WixOrder): boolean {
334
+ return (order.lineItems ?? []).some((line) =>
335
+ (line as { catalogReference?: { appId?: string } }).catalogReference?.appId === RESTAURANTS_ORDERS_APP_ID);
336
+ }
337
+
338
+ function orderRow(order: WixOrder): OrderRow {
339
+ const count = (order.lineItems ?? []).reduce((sum, line) => sum + (line.quantity || 1), 0);
340
+ const created = order.createdDate ? new Date(order.createdDate) : undefined;
341
+ const status = orderStatus(order);
342
+ return {
343
+ id: order.id,
344
+ name: copyTemplate("foodOrders.orderTitle", "Order #{number}", { number: order.number || order.id }),
345
+ subtitle: [
346
+ created && !Number.isNaN(created.getTime()) ? formatDateMedium(created) : "",
347
+ count ? copyTemplate("foodOrders.itemCount", "{count} {unit}", { count, unit: count === 1 ? "item" : "items" }) : "",
348
+ order.priceSummary?.total?.formattedAmount ?? "",
349
+ status,
350
+ ].filter(Boolean).join(" · "),
351
+ status,
352
+ detailScreenId: "himi_food_order",
353
+ };
354
+ }
355
+
356
+ type CachedOrders = { kind: "loaded"; rows: OrderRow[] } | { kind: "unavailable" };
357
+
358
+ async function readCachedOrders(member: OrderMember): Promise<CachedOrders> {
359
+ if (!member.scope) return { kind: "unavailable" };
360
+ try {
361
+ const rows = await getAll(CACHE);
362
+ await assertOrderMember(member);
363
+ return { kind: "loaded", rows: rows.filter((row) => ownedOrderCache(row, member))
364
+ .map(({ memberOrderScope: _, ...row }) => row as unknown as OrderRow) };
365
+ } catch { return { kind: "unavailable" }; }
366
+ }
367
+
368
+ async function writeCachedOrders(rows: OrderRow[], member: OrderMember): Promise<void> {
369
+ if (!member.scope) return;
370
+ try {
371
+ for (const row of rows) {
372
+ await assertOrderMember(member);
373
+ await upsert(CACHE, { ...row, memberOrderScope: member.scope } as unknown as DbEntity);
374
+ }
375
+ } catch { /* capability not granted / identity changed → stop caching */ }
376
+ }
377
+
378
+ type Past = Pick<S, "orders" | "needsSignIn" | "emptyText" | "onSite">;
379
+
380
+ /** The history together with the member it was read for, so a caller can check that member is
381
+ * still the one signed in. An identity read beside it, in parallel, can already see the next one. */
382
+ async function historyOf(): Promise<{ past: Past; who: string }> {
383
+ const member = await orderMember();
384
+ return { past: await history(member), who: memberKey(member) };
385
+ }
386
+
387
+ async function history(known?: OrderMember): Promise<Past> {
388
+ const member = known ?? await orderMember();
389
+ if (!member.email) {
390
+ return { orders: [], needsSignIn: true, emptyText: copyParam("foodOrders.signInEmptyText", "Sign in to see your restaurant orders."), onSite: false };
391
+ }
392
+ try {
393
+ const orders: OrderRow[] = [];
394
+ const seenCursors = new Set<string>();
395
+ let cursor = "";
396
+ do {
397
+ const response = await searchOrders({ search: {
398
+ filter: { "buyerInfo.email": { $eq: member.email } },
399
+ cursorPaging: { limit: 100, ...(cursor ? { cursor } : {}) },
400
+ sort: [{ fieldName: "createdDate", order: "DESC" }],
401
+ } });
402
+ await assertOrderMember(member);
403
+ orders.push(...(response.orders ?? [])
404
+ .filter((order) => !!order.id && (order.buyerInfo?.email ?? "").trim().toLowerCase() === member.email && isRestaurantOrder(order))
405
+ .map(orderRow));
406
+ const next = (response.metadata as typeof response.metadata & { cursors?: { next?: string } } | undefined)?.cursors?.next ?? "";
407
+ if (!response.metadata?.hasNext) break;
408
+ if (!next || seenCursors.has(next)) throw new Error("Order history pagination stopped before the last page");
409
+ seenCursors.add(next);
410
+ cursor = next;
411
+ } while (cursor);
412
+ void writeCachedOrders(orders, member);
413
+ // Hosted as its member, Wix answers the member an EMPTY list: never claim "no orders" there.
414
+ const onSite = isHostedWorker() && orders.length === 0;
415
+ return {
416
+ orders, needsSignIn: false, onSite,
417
+ emptyText: onSite ? copyParam("foodOrders.onSiteText", "Your restaurant orders are on the site.")
418
+ : orders.length ? "" : copyParam("foodOrders.historyEmptyText", "No restaurant orders yet."),
419
+ };
420
+ } catch (error) {
421
+ // Hosted as its member, Wix can refuse this member session the orders search outright
422
+ // (403 READ_ORDER_FORBIDDEN). The site's own My Orders page is the way to them, as for an empty answer.
423
+ if (isHostedWorker() && isOrdersForbiddenError(error)) {
424
+ await assertOrderMember(member);
425
+ return { orders: [], needsSignIn: false, onSite: true,
426
+ emptyText: copyParam("foodOrders.onSiteText", "Your restaurant orders are on the site.") };
427
+ }
428
+ const cache: CachedOrders = orderAccessDenied(error) ? { kind: "unavailable" } : await readCachedOrders(member);
429
+ await assertOrderMember(member);
430
+ const cached = cache.kind === "loaded" ? cache.rows : [];
431
+ return {
432
+ orders: cached, needsSignIn: false, onSite: false,
433
+ emptyText: cached.length ? "" : copyParam("foodOrders.historyErrorText", "Your past orders could not be loaded."),
434
+ };
435
+ }
436
+ }
437
+
438
+ // ---- Cart ----
439
+
440
+ /** Everything the screen shows about the cart, from Wix's answer. */
441
+ function withCart(state: S, cart: WixCartV2, checkoutHint = state.checkoutHint): S {
442
+ const currency = cartCurrency(cart) || lastCurrency;
443
+ const items = (cart.lineItems ?? []).map((line, index) => lineRow(line, index, currency));
444
+ return derive({ ...state, status: "ready", errorText: "", items,
445
+ containsOtherItems: (cart.lineItems ?? []).some((line) => !isRestaurantLine(line)), totalLine: "", checkoutHint }, cart);
446
+ }
447
+
448
+ function cartOperationIds(cart: WixCartV2): Set<string> {
449
+ return new Set((cart.lineItems ?? []).filter(isRestaurantLine)
450
+ .map((line) => (line.source?.catalogReference?.options as { operationId?: unknown } | undefined)?.operationId)
451
+ .filter((id): id is string => typeof id === "string" && !!id));
452
+ }
453
+
454
+ /** What one ordering read learned. It is read into a value and applied only by a caller that is
455
+ * still current, so a slower, superseded read never overwrites the operation a newer one chose. */
456
+ interface Ordering { operation?: WixOperation; methods: WixFulfillmentMethod[]; slots: WixFulfillmentTimeSlot[] }
457
+
458
+ async function readOrdering(cart: WixCartV2): Promise<Ordering> {
459
+ const ids = cartOperationIds(cart);
460
+ const found = ids.size > 1 ? undefined : await loadOrderingOperation({ operationId: ids.values().next().value });
461
+ return {
462
+ operation: found,
463
+ methods: found ? await loadOfferedMethods(found) : [],
464
+ slots: found ? await firstSlots(found.id).catch(() => []) : [],
465
+ };
466
+ }
467
+
468
+ function applyOrdering(ordering: Ordering): void {
469
+ operation = ordering.operation; methods = ordering.methods; slots = ordering.slots;
470
+ }
471
+
472
+ function closedText(): string {
473
+ return copyParam("foodOrders.orderingClosed", "Online ordering is closed right now. You can still review your order.");
474
+ }
475
+
476
+ /** What the banner says while no operation applies to [cart] (both empty when one does). */
477
+ function orderingNote(cart: WixCartV2 | undefined, refreshFailed = false): Pick<S, "closedKind" | "closedText"> {
478
+ if (operation) return { closedKind: "", closedText: "" };
479
+ if (refreshFailed) return {
480
+ closedKind: "refresh",
481
+ closedText: copyParam("foodOrders.orderingRefreshFailed", "Your order was updated, but its pickup and delivery details could not be refreshed. Reopen this screen to check out."),
482
+ };
483
+ if (cart && cartOperationIds(cart).size > 1) return {
484
+ closedKind: "mixed",
485
+ closedText: copyParam("foodOrders.mixedLocations", "Your order has dishes from more than one restaurant location. Remove the dishes from one location to check out."),
486
+ };
487
+ return { closedKind: "closed", closedText: closedText() };
488
+ }
489
+
490
+ /** A checkout input changed (address, pickup/delivery): the last estimate, its total and its
491
+ * feedback ("Checkout opened", an outside-area banner) describe other inputs, so they go. */
492
+ const INPUTS_CHANGED = { totalLine: "", checkoutHint: "", outsideArea: false } as const;
493
+
494
+ /** The service's live state, for work that settles after a newer action changed it. Set once the
495
+ * service exists: reading `svc` inside its own initializer makes its type circular, which the
496
+ * off-repo typecheck reports as an implicit `any` (TS7022/TS7023). */
497
+ let latestState: () => S = () => derive(initial);
498
+
499
+ /** Re-reads ordering when [cart] no longer belongs to the operation on screen (another tab swapped a
500
+ * dish or location). A failed read clears ordering, so the old location's methods never stay on
501
+ * screen as if they applied; true means that happened and the banner should say so. */
502
+ async function refreshOrderingFor(cart: WixCartV2): Promise<boolean> {
503
+ const ids = cartOperationIds(cart);
504
+ if (ids.size === 1 && operation && ids.has(operation.id)) return false;
505
+ try {
506
+ applyOrdering(await readOrdering(cart));
507
+ return false;
508
+ } catch {
509
+ applyOrdering({ operation: undefined, methods: [], slots: [] });
510
+ return true;
511
+ }
512
+ }
513
+
514
+ function changeQuantity(id: string, delta: number, remove = false): Promise<S> {
515
+ // Actions can overlap. Serialize writes and calculate from Wix's current quantity,
516
+ // so two rapid taps never both send the same absolute newQuantity.
517
+ flowRevision++;
518
+ return serialized(async () => {
519
+ try {
520
+ const current = await getCurrentCartV2();
521
+ const line = (current.lineItems ?? []).find((item) => item.id === id);
522
+ // Gone from the cart — Wix can clear it once the hosted checkout opens: show the cart as it is.
523
+ if (!line) {
524
+ const refreshFailed = await refreshOrderingFor(current);
525
+ return withCart({ ...latestState(), deliveryTerms: null, ...orderingNote(current, refreshFailed) }, current, "");
526
+ }
527
+ const next = lineQuantity(line) + delta;
528
+ const deleted = remove || next < 1;
529
+ const response = deleted
530
+ ? await removeLineItemsFromCurrentCartV2([id])
531
+ : await updateLineItemsInCurrentCartV2([{ lineItemId: id, quantity: next }]);
532
+ const cart = response.cart ?? await getCurrentCartV2();
533
+ const hint = deleted ? copyParam("foodOrders.itemRemoved", "Removed from your order.") : "";
534
+ // The write already landed: a failed ordering refresh must not hide the updated cart.
535
+ const refreshFailed = await refreshOrderingFor(cart);
536
+ const latest = latestState();
537
+ return withCart({ ...latest, deliveryTerms: null, ...orderingNote(cart, refreshFailed) }, cart, hint);
538
+ } catch {
539
+ return derive({ ...(latestState()), checkoutHint: copyParam("foodOrders.quantityFailed", "Could not update your order. Try again.") });
540
+ }
541
+ });
542
+ }
543
+
544
+ function idOf(arg: unknown): string {
545
+ if (typeof arg === "string") return arg;
546
+ const id = (arg as { id?: unknown } | undefined)?.id;
547
+ return typeof id === "string" ? id : "";
548
+ }
549
+
550
+ function valueOf(arg: unknown): string {
551
+ if (typeof arg === "string") return arg;
552
+ const value = (arg as { value?: unknown } | undefined)?.value;
553
+ return typeof value === "string" ? value.slice(0, 200) : "";
554
+ }
555
+
556
+ /** The address the cart gets for [type]: the restaurant's country for pickup, the customer's
557
+ * street for delivery. Undefined when delivery is missing what Wix needs to place it. */
558
+ /** The pickup or delivery a checkout already wrote to the shared cart, for a screen whose
559
+ * customer has not chosen one: reopened after a cancelled checkout, it shows what the cart holds, so the next
560
+ * Checkout does not silently switch a delivery order to pickup. */
561
+ function savedFulfillment(cart: WixCartV2): Partial<S> {
562
+ const code = cart.deliveryInfo?.method?.code ?? "";
563
+ if (code.startsWith("PICKUP|")) return { fulfillment: "PICKUP" };
564
+ const address = cart.deliveryInfo?.address;
565
+ if (!code.startsWith("DELIVERY|") || !address?.addressLine) return {};
566
+ return {
567
+ fulfillment: "DELIVERY",
568
+ addressLine: address.addressLine,
569
+ city: address.city ?? "",
570
+ postalCode: address.postalCode ?? "",
571
+ subdivision: address.subdivision ?? "",
572
+ country: address.country ?? "",
573
+ };
574
+ }
575
+
576
+ function cartAddress(state: S, type: FulfillmentType): WixRestaurantsAddress | undefined {
577
+ if (type === "PICKUP") return pickupCartAddress(method("PICKUP"), state.country);
578
+ const address: WixRestaurantsAddress = {
579
+ addressLine: state.addressLine.trim(),
580
+ city: state.city.trim(),
581
+ postalCode: state.postalCode.trim(),
582
+ country: state.country.trim().toUpperCase(),
583
+ };
584
+ const subdivision = state.subdivision.trim().toUpperCase();
585
+ if (subdivision) address.subdivision = subdivision.includes("-") ? subdivision : `${address.country}-${subdivision}`;
586
+ if (!address.addressLine || !address.city || !address.country) return undefined;
587
+ return address;
588
+ }
589
+
590
+ /** What the checkout opens for: the lines, and the address and method the estimate was for. The
591
+ * cart is shared, so another tab can change either while the checkout is opening. */
592
+ function cartSignature(cart: WixCartV2): string {
593
+ return JSON.stringify({
594
+ lines: (cart.lineItems ?? []).map((line) => [line.id, lineQuantity(line)]),
595
+ address: cart.deliveryInfo?.address ?? null,
596
+ method: cart.deliveryInfo?.method?.code ?? "",
597
+ });
598
+ }
599
+
600
+ async function checkout(state: S, revision: number): Promise<S> {
601
+ const type = state.fulfillment;
602
+ if (!operation) return derive({ ...state, ...orderingNote(undefined) });
603
+ if (!type || !method(type)) return derive({ ...state, checkoutHint: copyParam("foodOrders.chooseFulfillment", "Choose pickup or delivery.") });
604
+ const cart = await getCurrentCartV2();
605
+ if (!(cart.lineItems ?? []).length || !cart.id) return withCart(state, cart, copyParam("foodOrders.checkoutEmpty", "Add a dish before checking out."));
606
+ if ((cart.lineItems ?? []).some((line) => !isRestaurantLine(line))) return withCart(state, cart);
607
+ const cartOps = cartOperationIds(cart);
608
+ if (cartOps.size !== 1 || !cartOps.has(operation.id)) {
609
+ return withCart(state, cart, copyParam("foodOrders.operationChanged", "The restaurant location changed. Review your order before checking out."));
610
+ }
611
+ if ((cart.lineItems ?? []).some((line) => lineQuantity(line) === 0)) return withCart(state, cart);
612
+ const address = cartAddress(state, type);
613
+ if (!address) {
614
+ return derive({ ...state, checkoutHint: type === "DELIVERY"
615
+ ? copyParam("foodOrders.addressNeeded", "Enter your street, city and country for delivery.")
616
+ : copyParam("foodOrders.pickupUnavailable", "Pickup isn't available right now.") });
617
+ }
618
+ // Multiple delivery areas can have different fees and minima. Only the slot for this address
619
+ // tells us which terms apply; never block against the first site's delivery method.
620
+ const fresh = await firstSlots(operation.id, type === "DELIVERY" ? address : undefined).catch(() => undefined);
621
+ // Fail closed: without the slots we cannot tell whether this type can be ordered now, and every
622
+ // step below writes to the shared cart.
623
+ if (!fresh) return derive({ ...state, checkoutHint: copyParam("foodOrders.slotsUnavailable", "Pickup and delivery times could not be checked. Try again.") });
624
+ // `fresh` is judged for THIS address and stays local: storing it in `slots` left an address's ETA on
625
+ // the Delivery row after the customer typed another address.
626
+ // Only orderable slots come back, so a type missing from the answer cannot be ordered now. Never
627
+ // send `<TYPE>|ASAP` for it. For delivery, a slot without an address but none for this one means
628
+ // the address is outside every area (verified live); none at all means delivery is closed now.
629
+ if (!slotFor(fresh, type)) {
630
+ // Ask again with no address, now: the slots read at load can predate delivery closing, and
631
+ // would then call a closed kitchen "outside the area". Unreadable → not called outside.
632
+ const general = type === "DELIVERY" ? await firstSlots(operation.id).catch(() => []) : [];
633
+ if (slotFor(general, "DELIVERY")) return derive({ ...state, outsideArea: true, checkoutHint: "" });
634
+ return derive({ ...state, outsideArea: false, checkoutHint: type === "DELIVERY"
635
+ ? copyParam("foodOrders.deliveryUnavailable", "Delivery isn't available right now.")
636
+ : copyParam("foodOrders.pickupUnavailable", "Pickup isn't available right now.") });
637
+ }
638
+ if (type === "DELIVERY") {
639
+ const info = fresh ? slotFor(fresh, type)?.fulfillmentInfo?.[0] : undefined;
640
+ state = { ...state, deliveryTerms: info
641
+ ? { addressKey: deliveryAddressKey(state), minOrderPrice: info.minOrderPrice, fee: info.fee }
642
+ : null };
643
+ }
644
+ const multipleDeliveryAreas = methods.filter((candidate) => candidate.type === "DELIVERY").length > 1;
645
+ const minimum = type === "DELIVERY" && multipleDeliveryAreas
646
+ ? decimal(state.deliveryTerms?.minOrderPrice) : decimal(method(type)?.minOrderPrice);
647
+ if (minimum > 0 && decimal(cart.subtotal?.amount) < minimum) return withCart(state, cart);
648
+ // The awaits above take network round trips. A flow the customer has since superseded (an
649
+ // address or method edit, a quantity change) must not write its captured address or method
650
+ // into the shared cart, so every cart write below is preceded by this check.
651
+ const superseded = () => flowRevision !== revision;
652
+ if (superseded()) return latestState();
653
+ try {
654
+ await updateCurrentCartV2DeliveryAddress(address);
655
+ } catch (error) {
656
+ if (ecomApplicationErrorCode(error) === "INVALID_ADDRESS_FORMAT") {
657
+ return derive({ ...state, checkoutHint: copyParam("foodOrders.addressInvalid", "Check the delivery address and try again.") });
658
+ }
659
+ throw error;
660
+ }
661
+ // The earliest slot for this address: ASAP when the kitchen can take it now, else its window.
662
+ if (superseded()) return latestState();
663
+ try {
664
+ await setDeliveryMethodForCurrentCartV2(deliveryCode(type, fresh ? slotFor(fresh, type) : undefined));
665
+ } catch (error) {
666
+ // The slot read above found this type for this address, so a refusal now means its window
667
+ // closed in between, not that the address is outside every area.
668
+ if (ecomApplicationErrorCode(error) === "DELIVERY_METHOD_NOT_FOUND") {
669
+ return derive({ ...state, outsideArea: false, checkoutHint: type === "DELIVERY"
670
+ ? copyParam("foodOrders.deliveryUnavailable", "Delivery isn't available right now.")
671
+ : copyParam("foodOrders.pickupUnavailable", "Pickup isn't available right now.") });
672
+ }
673
+ throw error;
674
+ }
675
+ if (superseded()) return latestState();
676
+ const estimate = await estimateCurrentCartV2();
677
+ const estimated = estimate.cart ?? cart;
678
+ if (!(estimated.lineItems ?? []).length) {
679
+ return withCart(state, estimated, copyParam("foodOrders.checkoutEmpty", "Add a dish before checking out."));
680
+ }
681
+ const currency = cartCurrency(estimated) || lastCurrency;
682
+ const totals = estimate.summary?.priceSummary;
683
+ const totalLine = totals?.total?.amount !== undefined
684
+ ? copyTemplate("foodOrders.totalLine", "Total {total} (delivery {delivery})", { total: money(totals.total.amount, currency), delivery: money(totals.delivery?.amount, currency) })
685
+ : "";
686
+ const blocking = (estimate.summary?.violations ?? []).find((violation) => violation.severity === "ERROR");
687
+ if (blocking) {
688
+ return { ...withCart({ ...state, outsideArea: false }, estimated), totalLine, noTotalLine: !totalLine,
689
+ checkoutHint: copyTemplate("foodOrders.checkoutBlocked", "Checkout isn't possible yet: {reason}", { reason: blocking.description || blocking.code || "" }) };
690
+ }
691
+ const checkoutId = cart.id;
692
+ // The lines checked above, with the address and method the estimate was for: a change to either
693
+ // while the checkout opens withdraws it. The estimate's own cart holds them; only when it leaves
694
+ // them out is the cart read again, and then the method must still be the type validated here.
695
+ if (superseded()) return latestState();
696
+ const held = estimated.deliveryInfo?.method?.code ? estimated.deliveryInfo : (await getCurrentCartV2()).deliveryInfo;
697
+ const before = cartSignature({ ...cart, deliveryInfo: held });
698
+ const sameType = (code: string | undefined) => (code ?? "").startsWith(`${type}|`);
699
+ let withdrawn = false;
700
+ const hosted = isHostedWorker();
701
+ const pay = await payCashierOrder({
702
+ appId: RESTAURANTS_ORDERS_APP_ID,
703
+ // No order is created in the app: the site's checkout creates it, so a site with no payment
704
+ // method set up still reaches its own checkout page, which says so. With no cashier order
705
+ // there is nothing for the wallet rail to settle, so the msid it would need is not read.
706
+ snapshotId: "",
707
+ msid: "",
708
+ mayCharge: async () => {
709
+ if (flowRevision !== revision) return false;
710
+ const now = await getCurrentCartV2();
711
+ const same = sameType(now.deliveryInfo?.method?.code) && cartSignature(now) === before;
712
+ if (!same) withdrawn = true;
713
+ return same;
714
+ },
715
+ ...(hosted ? { openCashier: hostedCheckoutOpener(checkoutId) } : {}),
716
+ cashierUrl: async () => flowRevision === revision ? (await createRedirectSession({
717
+ ecomCheckout: { checkoutId },
718
+ callbacks: { postFlowUrl: "himalaya-__HIMI_APP_SLUG__://screen/himi_food_orders" },
719
+ })).redirectSession?.fullUrl : undefined,
720
+ });
721
+ const next = { ...withCart({ ...state, outsideArea: false }, estimated), totalLine, noTotalLine: !totalLine };
722
+ if (pay.kind === "handoff" || pay.kind === "paid") {
723
+ // The cart stays until the order lands: the customer can still leave the checkout unpaid.
724
+ return { ...next, checkoutHint: copyParam("foodOrders.checkoutOpened", "Checkout opened. Finish your order on the restaurant's checkout page.") };
725
+ }
726
+ if (pay.kind === "cancelled") return { ...next, checkoutHint: copyParam("foodOrders.checkoutCancelled", "Payment cancelled. Your order is still here.") };
727
+ if (withdrawn) {
728
+ const now = await getCurrentCartV2().catch(() => estimated);
729
+ const refreshFailed = await refreshOrderingFor(now);
730
+ return withCart({ ...state, outsideArea: false, totalLine: "", noTotalLine: true, deliveryTerms: null, ...orderingNote(now, refreshFailed) }, now,
731
+ copyParam("foodOrders.checkoutChanged", "Your order changed while checkout was opening. Review it and tap Checkout again."));
732
+ }
733
+ return { ...next, checkoutHint: copyParam("foodOrders.checkoutFailed", "Checkout could not open. The restaurant may not take online payments yet.") };
734
+ }
735
+
67
736
  const svc = defineService<S, {
68
737
  load: (state: S, arg?: unknown) => Promise<S>;
69
- clearBasket: (state: S) => S;
738
+ increment: (state: S, arg?: unknown) => Promise<S>;
739
+ decrement: (state: S, arg?: unknown) => Promise<S>;
740
+ remove: (state: S, arg?: unknown) => Promise<S>;
741
+ choosePickup: (state: S) => S;
742
+ chooseDelivery: (state: S) => S;
743
+ setAddressLine: (state: S, arg?: unknown) => S;
744
+ setCity: (state: S, arg?: unknown) => S;
745
+ setPostalCode: (state: S, arg?: unknown) => S;
746
+ setSubdivision: (state: S, arg?: unknown) => S;
747
+ setCountry: (state: S, arg?: unknown) => S;
748
+ checkout: (state: S) => Promise<S>;
749
+ openOrdersOnSite: (state: S) => Promise<S>;
70
750
  }>("spacesFoodOrders", {
71
751
  state: derive(initial),
72
752
  actions: {
73
- load: async (state, arg) => {
753
+ load: (_state, arg) => {
754
+ flowRevision++; // a reload withdraws a checkout that has not opened yet
74
755
  stashCopy(arg);
75
- const member = await memberAccess();
76
- if (!member.email || !member.scope) {
77
- return derive({ ...state, status: "ready", orders: [], emptyText: "", needsSignIn: true, noSignIn: false, errored: false });
78
- }
79
- return derive({
80
- ...state,
81
- status: "ready",
82
- orders: [],
83
- emptyText: copyParam("foodOrders.trackingHandoff", "Live order tracking requires a secure connection configured by this business."),
84
- needsSignIn: false,
85
- noSignIn: true,
86
- errored: false,
756
+ // Serialized with quantity changes and checkout (the module-scope load and the screen's
757
+ // on-appear load queue too), and committed onto the state as it is when it finishes, so an
758
+ // address or fulfillment edit made meanwhile is kept.
759
+ return serialized(async () => {
760
+ try {
761
+ const [cart, first] = await Promise.all([getCurrentCartV2(), historyOf()]);
762
+ applyOrdering(await readOrdering(cart));
763
+ // The ordering reads take several round trips: a member who signed out (or another who
764
+ // signed in) meanwhile must not be shown the first member's orders.
765
+ const current = memberKey(await orderMember());
766
+ const past = current === first.who ? first.past : await history();
767
+ // The address and pickup/delivery choice are the member's own: when a member signs out or
768
+ // another signs in on this device, the screen drops them and takes the new cart's instead.
769
+ // A visitor who signs in keeps what they typed: it is the same person.
770
+ const previousMember = shownFor !== "" && !shownFor.startsWith("\u0000") ? shownFor : "";
771
+ const memberLeft = previousMember !== "" && current !== previousMember;
772
+ shownFor = current;
773
+ if (memberLeft) customerChose = false;
774
+ const latest = memberLeft
775
+ ? { ...latestState(), fulfillment: "" as const, addressLine: "", city: "", postalCode: "", subdivision: "", country: "", deliveryTerms: null }
776
+ : latestState();
777
+ // The delivery country starts as the restaurant's — the common case, and what Wix needs.
778
+ const country = latest.country || (method("PICKUP")?.pickupOptions?.address?.country ?? "");
779
+ // A choice or an address the customer made here wins; a defaulted pickup does not.
780
+ const saved = customerChose ? {} : savedFulfillment(cart);
781
+ return withCart({ ...latest, ...past, country, deliveryTerms: null, outsideArea: false,
782
+ ...orderingNote(cart), ...saved }, cart, "");
783
+ } catch {
784
+ // Re-read history against the current identity. The earlier parallel read may belong
785
+ // to a member who signed out while the cart or operations request was in flight.
786
+ const past = await history().catch(() => undefined);
787
+ applyOrdering({ operation: undefined, methods: [], slots: [] });
788
+ const latest = latestState();
789
+ return derive({ ...latest, ...past, status: "error", items: [], containsOtherItems: false,
790
+ deliveryTerms: null, checkoutHint: "", orders: past?.orders ?? [],
791
+ needsSignIn: past?.needsSignIn ?? true, onSite: past?.onSite ?? false,
792
+ emptyText: past?.emptyText ?? "", errorText: copyParam("foodOrders.loadError", "Your order could not be loaded.") });
793
+ }
87
794
  });
88
795
  },
89
- clearBasket: (state) => {
90
- if (!basket.available || state.needsSignIn) return state;
91
- basket.write([]);
92
- return derive({ ...state });
796
+ increment: (_state, arg) => changeQuantity(idOf(arg), 1),
797
+ decrement: (_state, arg) => changeQuantity(idOf(arg), -1),
798
+ remove: (_state, arg) => changeQuantity(idOf(arg), 0, true),
799
+ choosePickup: (state) => { flowRevision++; customerChose = true; return derive({ ...state, ...INPUTS_CHANGED, fulfillment: method("PICKUP") ? "PICKUP" : state.fulfillment }); },
800
+ chooseDelivery: (state) => { flowRevision++; customerChose = true; return derive({ ...state, ...INPUTS_CHANGED, fulfillment: method("DELIVERY") ? "DELIVERY" : state.fulfillment }); },
801
+ setAddressLine: (state, arg) => { flowRevision++; customerChose = true; return derive({ ...state, ...INPUTS_CHANGED, addressLine: valueOf(arg) }); },
802
+ setCity: (state, arg) => { flowRevision++; customerChose = true; return derive({ ...state, ...INPUTS_CHANGED, city: valueOf(arg) }); },
803
+ setPostalCode: (state, arg) => { flowRevision++; customerChose = true; return derive({ ...state, ...INPUTS_CHANGED, postalCode: valueOf(arg) }); },
804
+ setSubdivision: (state, arg) => { flowRevision++; customerChose = true; return derive({ ...state, ...INPUTS_CHANGED, subdivision: valueOf(arg) }); },
805
+ setCountry: (state, arg) => { flowRevision++; customerChose = true; return derive({ ...state, ...INPUTS_CHANGED, country: valueOf(arg) }); },
806
+ checkout: (state) => {
807
+ // A second tap for the same inputs is a double tap. After an edit (a new revision) the tap is a
808
+ // new checkout: it queues behind the superseded one, which withdraws at its next check.
809
+ const revision = flowRevision;
810
+ if (checkoutPendingAt === revision) return Promise.resolve(state);
811
+ checkoutPendingAt = revision;
812
+ return serialized(async () => {
813
+ // Built from the state when it runs, not when it was tapped: a load that ran first may have
814
+ // committed a newer cart, which this checkout's answer must not revert.
815
+ const current = latestState();
816
+ try {
817
+ const result = await checkout({ ...current, checkoutHint: "" }, revision);
818
+ return revision === flowRevision ? result : latestState();
819
+ } catch {
820
+ return revision === flowRevision
821
+ ? derive({ ...latestState(), checkoutHint: copyParam("foodOrders.checkoutFailed", "Checkout could not open. The restaurant may not take online payments yet.") })
822
+ : latestState();
823
+ } finally {
824
+ if (checkoutPendingAt === revision) checkoutPendingAt = -1;
825
+ }
826
+ });
827
+ },
828
+ // Hosted in a host shell: the member's orders live on the site's own My Orders page.
829
+ openOrdersOnSite: async (state) => {
830
+ if (!state.onSite) return state;
831
+ const member = await orderMember();
832
+ const opened = await hostedSitePageOpener({ page: "orders" })(async () => {
833
+ const now = await orderMember();
834
+ return now.email === member.email && now.scope === member.scope;
835
+ });
836
+ // A member who signed out or switched account while the host worked never inherits this
837
+ // answer (as himi-branded-lite-orders.openOnSite); the next load resets the screen for them.
838
+ if (memberKey(await orderMember()) !== memberKey(member)) return latestState();
839
+ // Committed onto the state as it is now: the host round trips above can outlast a quantity
840
+ // change or a load, whose answer this must not revert.
841
+ const latest = latestState();
842
+ return derive({ ...latest, emptyText: opened === false
843
+ ? copyParam("foodOrders.onSiteFailed", "The site's orders page could not be opened.") : latest.emptyText });
93
844
  },
94
845
  },
95
846
  });
847
+ latestState = () => svc.getState() as S;
96
848
  svc.load();
97
849
  export { svc };