@wix/himalaya-cli 0.1528.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.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/ecom/src/extensions.d.ts +144 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/restaurants/src/extensions.d.ts +93 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +27 -5
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +19 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +27 -5
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-ecom.mjs +58 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-restaurants.mjs +23 -0
- package/dist/cli.mjs +2425 -1541
- package/fixtures/wix-canned.json +83 -0
- package/guides/hhp.md +39 -0
- package/guides/himi-json-output.md +22 -1
- package/package.json +2 -2
- package/templates/beauty-salon/config.ts +12 -1
- package/templates/beauty-salon/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/beauty-salon/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +6 -0
- package/templates/branded-lite/config.ts +12 -1
- package/templates/branded-lite/dev/index.ts +1278 -180
- package/templates/branded-lite/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/branded-lite/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +6 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-menu-item/index.ts +144 -77
- package/templates/branded-lite/tier5-src/himi-branded-lite-menus/index.ts +16 -5
- package/templates/branded-lite/tier5-src/himi-branded-lite-reservations/index.ts +1 -1
- package/templates/branded-lite/tier5-src/wix-spaces-food-order-detail/index.ts +76 -14
- package/templates/branded-lite/tier5-src/wix-spaces-food-orders/index.ts +807 -55
- package/templates/community-club/config.ts +12 -1
- package/templates/community-club/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/events-meetups/config.ts +12 -1
- package/templates/events-meetups/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/gym-fitness/config.ts +12 -1
- package/templates/gym-fitness/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/gym-fitness/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +6 -0
- package/templates/nonprofit-cause/config.ts +12 -1
- package/templates/nonprofit-cause/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/podcast-creator/config.ts +12 -1
- package/templates/podcast-creator/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/portfolio-creative/config.ts +12 -1
- package/templates/portfolio-creative/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/portfolio-creative/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +6 -0
- package/templates/restaurant/README.md +1 -1
- package/templates/restaurant/config.ts +12 -1
- package/templates/restaurant/dev/index.ts +1373 -275
- package/templates/restaurant/himalaya.template.json +2 -1
- package/templates/restaurant/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/restaurant/tier5-src/_shared/member-orders.ts +8 -0
- package/templates/restaurant/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/restaurant/tier5-src/himi-branded-lite-menu-item/index.ts +144 -77
- package/templates/restaurant/tier5-src/himi-branded-lite-menus/index.ts +16 -5
- package/templates/restaurant/tier5-src/wix-spaces-food-order-detail/index.ts +76 -14
- package/templates/restaurant/tier5-src/wix-spaces-food-orders/index.ts +807 -55
- package/templates/retail-store/config.ts +12 -1
- package/templates/retail-store/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/retail-store/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +6 -0
- package/templates/yoga-studio/config.ts +12 -1
- package/templates/yoga-studio/tier5-src/_shared/himi-restaurants.ts +12 -5
|
@@ -1,97 +1,849 @@
|
|
|
1
|
-
// Tier-5 bundle:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
8
|
-
|
|
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
|
|
12
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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",
|
|
63
|
-
|
|
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
|
-
|
|
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:
|
|
753
|
+
load: (_state, arg) => {
|
|
754
|
+
flowRevision++; // a reload withdraws a checkout that has not opened yet
|
|
74
755
|
stashCopy(arg);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 };
|