@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.
Files changed (59) 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 +2425 -1541
  9. package/fixtures/wix-canned.json +83 -0
  10. package/guides/hhp.md +39 -0
  11. package/guides/himi-json-output.md +22 -1
  12. package/package.json +2 -2
  13. package/templates/beauty-salon/config.ts +12 -1
  14. package/templates/beauty-salon/tier5-src/_shared/himi-restaurants.ts +12 -5
  15. package/templates/beauty-salon/tier5-src/_shared/restaurant-ordering.ts +201 -0
  16. package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +6 -0
  17. package/templates/branded-lite/config.ts +12 -1
  18. package/templates/branded-lite/dev/index.ts +1278 -180
  19. package/templates/branded-lite/tier5-src/_shared/himi-restaurants.ts +12 -5
  20. package/templates/branded-lite/tier5-src/_shared/restaurant-ordering.ts +201 -0
  21. package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +6 -0
  22. package/templates/branded-lite/tier5-src/himi-branded-lite-menu-item/index.ts +144 -77
  23. package/templates/branded-lite/tier5-src/himi-branded-lite-menus/index.ts +16 -5
  24. package/templates/branded-lite/tier5-src/himi-branded-lite-reservations/index.ts +1 -1
  25. package/templates/branded-lite/tier5-src/wix-spaces-food-order-detail/index.ts +76 -14
  26. package/templates/branded-lite/tier5-src/wix-spaces-food-orders/index.ts +807 -55
  27. package/templates/community-club/config.ts +12 -1
  28. package/templates/community-club/tier5-src/_shared/himi-restaurants.ts +12 -5
  29. package/templates/events-meetups/config.ts +12 -1
  30. package/templates/events-meetups/tier5-src/_shared/himi-restaurants.ts +12 -5
  31. package/templates/gym-fitness/config.ts +12 -1
  32. package/templates/gym-fitness/tier5-src/_shared/himi-restaurants.ts +12 -5
  33. package/templates/gym-fitness/tier5-src/_shared/restaurant-ordering.ts +201 -0
  34. package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +6 -0
  35. package/templates/nonprofit-cause/config.ts +12 -1
  36. package/templates/nonprofit-cause/tier5-src/_shared/himi-restaurants.ts +12 -5
  37. package/templates/podcast-creator/config.ts +12 -1
  38. package/templates/podcast-creator/tier5-src/_shared/himi-restaurants.ts +12 -5
  39. package/templates/portfolio-creative/config.ts +12 -1
  40. package/templates/portfolio-creative/tier5-src/_shared/himi-restaurants.ts +12 -5
  41. package/templates/portfolio-creative/tier5-src/_shared/restaurant-ordering.ts +201 -0
  42. package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +6 -0
  43. package/templates/restaurant/README.md +1 -1
  44. package/templates/restaurant/config.ts +12 -1
  45. package/templates/restaurant/dev/index.ts +1373 -275
  46. package/templates/restaurant/himalaya.template.json +2 -1
  47. package/templates/restaurant/tier5-src/_shared/himi-restaurants.ts +12 -5
  48. package/templates/restaurant/tier5-src/_shared/member-orders.ts +8 -0
  49. package/templates/restaurant/tier5-src/_shared/restaurant-ordering.ts +201 -0
  50. package/templates/restaurant/tier5-src/himi-branded-lite-menu-item/index.ts +144 -77
  51. package/templates/restaurant/tier5-src/himi-branded-lite-menus/index.ts +16 -5
  52. package/templates/restaurant/tier5-src/wix-spaces-food-order-detail/index.ts +76 -14
  53. package/templates/restaurant/tier5-src/wix-spaces-food-orders/index.ts +807 -55
  54. package/templates/retail-store/config.ts +12 -1
  55. package/templates/retail-store/tier5-src/_shared/himi-restaurants.ts +12 -5
  56. package/templates/retail-store/tier5-src/_shared/restaurant-ordering.ts +201 -0
  57. package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +6 -0
  58. package/templates/yoga-studio/config.ts +12 -1
  59. package/templates/yoga-studio/tier5-src/_shared/himi-restaurants.ts +12 -5
@@ -2093,5 +2093,88 @@
2093
2093
  "POST /ecom/v1/checkouts/chk-0001/create-order": {
2094
2094
  "orderId": "order-0001",
2095
2095
  "paymentGatewayOrderId": "payment-0001"
2096
+ },
2097
+ "GET /ecom/v2/carts/current": {},
2098
+ "GET /restaurants-operations/v1/operations": {
2099
+ "operations": [
2100
+ {
2101
+ "id": "op-0001",
2102
+ "name": "Ordering page",
2103
+ "default": true,
2104
+ "onlineOrderingStatus": "ENABLED",
2105
+ "defaultFulfillmentType": "PICKUP",
2106
+ "fulfillmentIds": [
2107
+ "fm-pickup-0001",
2108
+ "fm-delivery-0001"
2109
+ ]
2110
+ }
2111
+ ]
2112
+ },
2113
+ "GET /fulfillment-methods/v1/fulfillment-methods": {
2114
+ "fulfillmentMethods": [
2115
+ {
2116
+ "id": "fm-pickup-0001",
2117
+ "type": "PICKUP",
2118
+ "name": "Pickup",
2119
+ "enabled": true,
2120
+ "minOrderPrice": "0",
2121
+ "pickupOptions": {
2122
+ "address": {
2123
+ "country": "US",
2124
+ "subdivision": "US-CA",
2125
+ "city": "San Francisco",
2126
+ "formattedAddress": "500 Example St, San Francisco, CA"
2127
+ }
2128
+ }
2129
+ },
2130
+ {
2131
+ "id": "fm-delivery-0001",
2132
+ "type": "DELIVERY",
2133
+ "name": "Delivery Area #1",
2134
+ "enabled": true,
2135
+ "fee": "0",
2136
+ "minOrderPrice": "0",
2137
+ "deliveryOptions": {
2138
+ "deliveryTimeInMinutes": 30
2139
+ }
2140
+ }
2141
+ ],
2142
+ "pagingMetadata": {
2143
+ "count": 2,
2144
+ "hasNext": false
2145
+ }
2146
+ },
2147
+ "GET /restaurants-operations/v1/operations/op-0001/first-available-time-slot-per-fulfillment-type": {
2148
+ "timeslotsPerFulfillmentType": [
2149
+ {
2150
+ "fulfilmentType": "DELIVERY",
2151
+ "timeSlot": {
2152
+ "startTime": "2026-10-29T11:18:00Z",
2153
+ "endTime": "2026-10-29T11:18:00Z",
2154
+ "orderSchedulingType": "ASAP"
2155
+ },
2156
+ "fulfillmentInfo": [
2157
+ {
2158
+ "fee": "0",
2159
+ "minOrderPrice": "0",
2160
+ "maxTime": 60
2161
+ }
2162
+ ]
2163
+ },
2164
+ {
2165
+ "fulfilmentType": "PICKUP",
2166
+ "timeSlot": {
2167
+ "startTime": "2026-10-29T10:48:00Z",
2168
+ "endTime": "2026-10-29T10:48:00Z",
2169
+ "orderSchedulingType": "ASAP"
2170
+ },
2171
+ "fulfillmentInfo": [
2172
+ {
2173
+ "minOrderPrice": "0",
2174
+ "maxTime": 30
2175
+ }
2176
+ ]
2177
+ }
2178
+ ]
2096
2179
  }
2097
2180
  }
package/guides/hhp.md CHANGED
@@ -195,6 +195,45 @@ and auth flows retain their existing transports and network-fixture seams.
195
195
  The studio preview adds compact, medium, and expanded presets. Existing worker state,
196
196
  action, and SDK inspection remain available through the dev drawer.
197
197
 
198
+ ## Design fidelity (`himi fidelity`)
199
+
200
+ `himi fidelity` answers one question per screen: does what shipped look like what was designed? It is repo-only for now and advisory. No CI gate reads it. Its readers are the authoring agent, before it calls an app done, and the PR reviewer.
201
+
202
+ Declare the mockup each screen was designed from in `hhp.json`:
203
+
204
+ ```json
205
+ { "version": "hhp/2", "screens": { "schedule": { "html": "screens/schedule.html", "mockup": "mockup/schedule.html" } } }
206
+ ```
207
+
208
+ A screen may also carry `params` (the navigation input a device opens it with, used by native capture, #4935) and `fidelity: { "warn": 80, "fail": 60 }`. Thresholds can also be set app-wide at the top level.
209
+
210
+ ```bash
211
+ himi fidelity --content <dir> [--screen a,b] [--variant light,dark,rtl,tablet] [--no-native] [--json] [--post-pr <n>]
212
+ ```
213
+
214
+ For each screen and variant (`light`, `dark`, `rtl`, `tablet`), the command does four things:
215
+
216
+ 1. **Captures the mockup.** It draws the page in Chromium at the variant's width. Dark mode is drawn by emulating `prefers-color-scheme`. RTL is drawn by setting `dir=rtl` on the page.
217
+ 2. **Captures the web render** from the app's recorded data, as `himi render` does by default. Native frames come through a capture seam. Until #4935 lands, the seam reports iOS and Android as skipped.
218
+ 3. **Scores mockup↔web** with ImageMagick SSIM, globally and per tile. The worst tile says where the screens differ most.
219
+ 4. **Asks a fresh model** two questions, using the repo's fidelity judge prompt. The model never ran in the session that wrote the app.
220
+ - **Design:** does WEB look like the MOCKUP? This judges the port.
221
+ - **Parity:** do IOS and ANDROID look like WEB? This judges the native renderers.
222
+
223
+ Each row gets a status:
224
+
225
+ | Status | When |
226
+ |---|---|
227
+ | **fail** | The judge answers `mismatch` to either question, or the mockup↔web score is below a `fail` threshold you set. |
228
+ | **warn** | `partial`, `cannot_tell`, a skipped surface, a mockup with no styling for the variant (`no_reference`), a missing mockup (`fidelity_mockup_missing`), or a score below `warn`. |
229
+ | **pass** | Anything else. |
230
+
231
+ The command exits 1 if any row fails. Output goes to `.himi/fidelity/<run>/`: `report.json` and one strip PNG per row. `--post-pr <n>` keeps one plain PR comment per app up to date. The comment opens with `<!-- himi-fidelity: app=<app> run=<id> -->`, and only your own comment is ever edited. It is never a review thread.
232
+
233
+ The pixel score has no default threshold, on purpose. On the repo's four HTML-first fixture apps, a blank page scores 87–95 against a mockup, while faithful ports score 69–93. Native chrome also shifts every row, so a native bug and its fix score within a point of each other. The judge is the gate, and the score points at where to look. Set thresholds only for a screen whose port is meant to be pixel-close.
234
+
235
+ Without the `claude` CLI, every verdict is `cannot_tell`, and no row passes. Outside the himalaya repo, the command refuses with `code: "fidelity_outside_repo"`.
236
+
198
237
  ## Qualification
199
238
 
200
239
  Automated tests cover v1
@@ -6,7 +6,7 @@ repurposed when human wording changes. New codes may be added; consumers must
6
6
  handle an unknown code as a failure, without parsing prose.
7
7
 
8
8
  **Rollout:** this contract is enforced for **`himi push`**, including `--only`,
9
- first, for **`himi store iap connect`**, for **`himi site member-app`**, and for the **`himi base44`** entity and function verbs. Other subcommands still have legacy failures without codes and are a
9
+ first, for **`himi store iap connect`**, for **`himi site member-app`**, for **`himi fidelity`**, and for the **`himi base44`** entity and function verbs. Other subcommands still have legacy failures without codes and are a
10
10
  follow-up migration. This is an additive change: existing `error`, `errors`,
11
11
  `message`, `hint`, status, body, and gate-specific fields stay as they were.
12
12
  Some legacy envelopes have no singular `error` or `hint`; consumers must not
@@ -394,6 +394,27 @@ On success (`ok: true`, stdout, exit 0) `capabilities[]` lists the ids the gener
394
394
  `--force` to overwrite it). A skipped file is not a failure. `orphaned[]`, when present, lists
395
395
  generated files for entities no longer declared. They are never deleted.
396
396
 
397
+ ## `himi fidelity` codes
398
+
399
+ `himi fidelity` failures print `ok: false` and `code`:
400
+
401
+ - `fidelity_rows_failed`: the run completed and at least one screen × variant row failed (a judge
402
+ `mismatch`, or a score below a threshold `hhp.json` sets). The report follows in the same
403
+ envelope. Exit 1.
404
+ - `fidelity_outside_repo`: the content directory is not inside a himalaya repo checkout, which
405
+ carries the fidelity judge; the command is repo-only for now. Exit 2.
406
+ - `fidelity_not_hhp`: the content directory has no `hhp.json`. Exit 2.
407
+ - `fidelity_unknown_screen`: `--screen` names a screen `hhp.json` does not declare. Exit 2.
408
+ - `fidelity_bad_flag`: a flag value is invalid (a missing content directory, an unknown
409
+ `--variant`, or a `--post-pr` that is not a PR number). Exit 2.
410
+ - `fidelity_post_failed`: `--post-pr` could not create or edit the PR comment. Exit 1.
411
+ - `fidelity_error`: any other failure before a report existed, such as an unreadable `hhp.json`.
412
+ Exit 1.
413
+
414
+ A row's own failure is not a command failure. A missing mockup gives that row
415
+ `error.code: "fidelity_mockup_missing"`, and a capture, score or strip that throws gives it
416
+ `error.code: "fidelity_row_failed"`. Both rows warn, and the other rows still run.
417
+
397
418
  ## Follow-ups
398
419
 
399
420
  - Migrate failure envelopes in subcommands other than `push`, keeping this contract.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1528.0",
3
+ "version": "0.1529.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -71,5 +71,5 @@
71
71
  "artifactId": "himalaya-cli"
72
72
  }
73
73
  },
74
- "falconPackageHash": "977ce0e45046b28615366bb368c7ceb90d7a1f9404024a58bee20404"
74
+ "falconPackageHash": "28b98bdc9fc68f9be64914a2b88e5593f330b1f1f8e51dc6b49bba20"
75
75
  }
@@ -424,7 +424,7 @@ export default defineApp({
424
424
  // Your on-device database. It must declare every entity a worker reads or writes,
425
425
  // or those calls fail closed — keep it in step with `local_database` above.
426
426
  dataSchema: {
427
- "version": 1,
427
+ "version": 2,
428
428
  "entities": [
429
429
  {
430
430
  "name": "products",
@@ -549,6 +549,17 @@ export default defineApp({
549
549
  }
550
550
  ]
551
551
  },
552
+ {
553
+ "name": "food_orders",
554
+ "encrypted": true,
555
+ "fields": [
556
+ {
557
+ "name": "id",
558
+ "type": "string",
559
+ "required": true
560
+ }
561
+ ]
562
+ },
552
563
  {
553
564
  "name": "member_bookings",
554
565
  "encrypted": true,
@@ -133,15 +133,18 @@ export function itemLabels(value: unknown, limit = 3): string[] {
133
133
  }
134
134
 
135
135
  /** Price-variant rows ({ name, priceLine }) from the item's loose
136
- * `priceVariants.variants` payload — [] when the item has a single price. */
137
- export function itemPriceVariants(item: WixItem): Array<{ id: string; name: string; priceLine: string }> {
136
+ * `priceVariants.variants` payload — [] when the item has a single price.
137
+ * `variantId` is Wix's own id for the variant (live rows carry `variantId`; older
138
+ * fixtures `id`), "" when the payload has none — a synthetic `variant-<n>` row id is
139
+ * only for the UI and must never be sent to Wix. `price` is the decimal price. */
140
+ export function itemPriceVariants(item: WixItem): Array<{ id: string; variantId: string; name: string; priceLine: string; price: string }> {
138
141
  const raw = item as unknown as { priceVariants?: { variants?: unknown[] } };
139
142
  const variants = raw.priceVariants?.variants;
140
143
  if (!Array.isArray(variants)) return [];
141
- const rows: Array<{ id: string; name: string; priceLine: string }> = [];
144
+ const rows: Array<{ id: string; variantId: string; name: string; priceLine: string; price: string }> = [];
142
145
  for (const [index, entry] of variants.entries()) {
143
146
  if (!entry || typeof entry !== "object") continue;
144
- const node = entry as { id?: unknown; name?: unknown; priceInfo?: { price?: unknown; currency?: unknown } };
147
+ const node = entry as { id?: unknown; variantId?: unknown; name?: unknown; priceInfo?: { price?: unknown; currency?: unknown; formattedPrice?: unknown } };
145
148
  const name = typeof node.name === "string" ? cleanTitle(node.name) : "";
146
149
  const price = node.priceInfo?.price;
147
150
  if (price == null || price === "" || Number.isNaN(Number(price))) continue;
@@ -152,7 +155,11 @@ export function itemPriceVariants(item: WixItem): Array<{ id: string; name: stri
152
155
  priceLine = formatCurrency(Number(price), currency, "en");
153
156
  } catch { /* keep the bare amount */ }
154
157
  }
155
- rows.push({ id: typeof node.id === "string" && node.id ? node.id : `variant-${index}`, name: name || "Option", priceLine });
158
+ const formatted = typeof node.priceInfo?.formattedPrice === "string" ? node.priceInfo.formattedPrice.trim() : "";
159
+ if (formatted) priceLine = normalizeFormattedPrice(formatted);
160
+ const variantId = typeof node.variantId === "string" && node.variantId ? node.variantId
161
+ : typeof node.id === "string" && node.id ? node.id : "";
162
+ rows.push({ id: variantId || `variant-${index}`, variantId, name: name || "Option", priceLine, price: `${price}` });
156
163
  }
157
164
  return rows;
158
165
  }
@@ -0,0 +1,201 @@
1
+ // Restaurants online ordering — Wix's visitor "Place an order from a custom storefront" flow,
2
+ // shared by the dish detail (himi-branded-lite-menu-item: add a dish to the Wix cart) and the
3
+ // order screen (wix-spaces-food-orders: cart → pickup/delivery → hosted checkout → history).
4
+ //
5
+ // The cart is the site's eCom CURRENT cart (Cart V2). It is the same cart the Stores cart screen
6
+ // reads (V1 and V2 answer the same cart id), so a restaurant line is told apart by its catalog
7
+ // app — Restaurants Orders — never assumed. Every call here is visitor-scope; the member's placed
8
+ // orders are read through the eCom orders search the member Orders screen already uses, never
9
+ // through the owner-capable Restaurants Orders API.
10
+ import {
11
+ getFirstAvailableTimeSlotPerFulfillmentType,
12
+ listFulfillmentMethods,
13
+ listOperations,
14
+ queryMenus,
15
+ querySections,
16
+ type WixFulfillmentMethod,
17
+ type WixFulfillmentTimeSlot,
18
+ type WixMenu,
19
+ type WixOperation,
20
+ type WixRestaurantsAddress,
21
+ type WixSection,
22
+ } from "@himalaya/wix-restaurants";
23
+ import type { WixCartV2, WixCartV2LineItem, WixCatalogReference } from "@himalaya/wix-ecom";
24
+ import { formatCurrency } from "../_shared/engine-intl.js";
25
+
26
+ /** Wix Restaurants Orders — the catalog app a restaurant cart line belongs to. */
27
+ export const RESTAURANTS_ORDERS_APP_ID = "9a5d83fd-8570-482e-81ab-cfa88942ee60";
28
+
29
+ export type FulfillmentType = "PICKUP" | "DELIVERY";
30
+
31
+ /** The operation a visitor orders from: an `ENABLED` one, the site's default first. None means
32
+ * online ordering is closed (`DISABLED`, `PAUSED_UNTIL`, or no operation at all). */
33
+ export function orderingOperation(operations: WixOperation[] | undefined, selection: { businessLocationId?: string; operationId?: string } = {}): WixOperation | undefined {
34
+ const open = (operations ?? []).filter((operation) => !!operation?.id && operation.onlineOrderingStatus === "ENABLED");
35
+ if (selection.operationId) return open.find((operation) => operation.id === selection.operationId);
36
+ if (selection.businessLocationId) return open.find((operation) => operation.businessLocationId === selection.businessLocationId);
37
+ return open.find((operation) => operation.default === true) ?? open[0];
38
+ }
39
+
40
+ export async function loadOrderingOperation(selection: { businessLocationId?: string; operationId?: string } = {}): Promise<WixOperation | undefined> {
41
+ return orderingOperation((await listOperations()).operations, selection);
42
+ }
43
+
44
+ /** The enabled pickup/delivery methods [operation] offers: the ones its `fulfillmentIds` name. An
45
+ * operation that names none falls back only to methods at its own business location — never to
46
+ * every method on the site, which on a multi-location site would offer another location's pickup. */
47
+ export function offeredMethods(operation: WixOperation, methods: WixFulfillmentMethod[] | undefined): WixFulfillmentMethod[] {
48
+ const ids = new Set(operation.fulfillmentIds ?? []);
49
+ const belongs = (method: WixFulfillmentMethod) => ids.size > 0
50
+ ? ids.has(method.id)
51
+ : !!operation.businessLocationId && method.businessLocationId === operation.businessLocationId;
52
+ return (methods ?? []).filter((method) =>
53
+ !!method?.id && method.enabled === true && (method.type === "PICKUP" || method.type === "DELIVERY") && belongs(method));
54
+ }
55
+
56
+ export async function loadOfferedMethods(operation: WixOperation): Promise<WixFulfillmentMethod[]> {
57
+ const all: WixFulfillmentMethod[] = [];
58
+ let cursor = "";
59
+ // A site has a handful of methods; five pages of 100 is a bound, not an expectation.
60
+ for (let page = 0; page < 5; page++) {
61
+ const response = await listFulfillmentMethods({ limit: 100, ...(cursor ? { cursor } : {}) });
62
+ all.push(...(response.fulfillmentMethods ?? []));
63
+ cursor = response.pagingMetadata?.hasNext ? response.pagingMetadata.cursors?.next ?? "" : "";
64
+ if (!cursor) break;
65
+ }
66
+ return offeredMethods(operation, all);
67
+ }
68
+
69
+ /** A decimal string from Wix ("6.99", "0") as a number; anything else is 0 (a missing fee is free). */
70
+ export function decimal(value: unknown): number {
71
+ const n = typeof value === "number" ? value : Number(typeof value === "string" ? value.trim() : NaN);
72
+ return Number.isFinite(n) ? n : 0;
73
+ }
74
+
75
+ /** Cart V2 money carries no formatted string: format the decimal with the cart's currency. */
76
+ export function money(amount: unknown, currency: string | undefined): string {
77
+ return formatCurrency(decimal(amount), (currency ?? "").trim() || "USD", "en");
78
+ }
79
+
80
+ export function cartCurrency(cart: WixCartV2 | undefined): string {
81
+ return cart?.businessInfo?.currencyCode || cart?.customerInfo?.currencyCode || "";
82
+ }
83
+
84
+ /** Where a dish is ordered from. Checkout validates the line against `menuSectionId`, so it must
85
+ * be a visible section that lists the dish, inside a visible menu that lists the section. */
86
+ export interface DishPlacement {
87
+ menuId: string;
88
+ sectionId: string;
89
+ businessLocationId?: string;
90
+ }
91
+
92
+ /** The section the dish was shown under wins when it qualifies; otherwise the first visible
93
+ * menu → section that lists it. None → the dish is on no orderable menu. */
94
+ export function pickPlacement(
95
+ dishId: string,
96
+ menus: WixMenu[],
97
+ sections: WixSection[],
98
+ hint: { menuId?: string; sectionId?: string } = {},
99
+ ): DishPlacement | undefined {
100
+ const listing = new Map(sections
101
+ .filter((section) => !!section?.id && section.visible !== false && (section.itemIds ?? []).includes(dishId))
102
+ .map((section) => [section.id, section]));
103
+ const candidates: DishPlacement[] = [];
104
+ for (const menu of menus) {
105
+ if (!menu?.id || menu.visible === false) continue;
106
+ for (const sectionId of menu.sectionIds ?? []) {
107
+ if (listing.has(sectionId)) candidates.push({ menuId: menu.id, sectionId, businessLocationId: menu.businessLocationId });
108
+ }
109
+ }
110
+ return candidates.find((c) => c.sectionId === hint.sectionId && (!hint.menuId || c.menuId === hint.menuId)) ?? candidates[0];
111
+ }
112
+
113
+ export async function loadDishPlacement(dishId: string, hint: { menuId?: string; sectionId?: string }): Promise<DishPlacement | undefined> {
114
+ const [sections, menus] = await Promise.all([
115
+ querySections({ paging: { limit: 100 } }),
116
+ queryMenus({ paging: { limit: 50 } }),
117
+ ]);
118
+ return pickPlacement(dishId, menus.menus ?? [], sections.sections ?? [], hint);
119
+ }
120
+
121
+ /** The catalog reference for one dish line. There is no `variantId` key in `options`. */
122
+ export function dishReference(dishId: string, operationId: string, placement: DishPlacement): WixCatalogReference {
123
+ return {
124
+ appId: RESTAURANTS_ORDERS_APP_ID,
125
+ catalogItemId: dishId,
126
+ options: {
127
+ operationId,
128
+ menuId: placement.menuId,
129
+ menuSectionId: placement.sectionId,
130
+ sectionId: placement.sectionId,
131
+ },
132
+ };
133
+ }
134
+
135
+ export function isRestaurantLine(line: WixCartV2LineItem | undefined): boolean {
136
+ return line?.source?.catalogReference?.appId === RESTAURANTS_ORDERS_APP_ID;
137
+ }
138
+
139
+ function lineVariantId(line: WixCartV2LineItem): string {
140
+ const variant = (line.source?.catalogReference?.options as { priceVariant?: { id?: unknown } } | undefined)?.priceVariant;
141
+ return typeof variant?.id === "string" ? variant.id : "";
142
+ }
143
+
144
+ /** The cart line Wix kept for [reference] — same dish, operation, menu, section and variant — or
145
+ * undefined when Wix dropped it. Wix can refuse an item and still answer 200, so the returned cart
146
+ * is the proof. The operation and menu matter: the same dish added from two restaurant locations
147
+ * is two lines, and only the one just added proves this add. */
148
+ export function addedLine(cart: WixCartV2 | undefined, reference: WixCatalogReference): WixCartV2LineItem | undefined {
149
+ type PlacementOptions = { operationId?: unknown; menuId?: unknown; menuSectionId?: unknown };
150
+ const options = (reference.options ?? {}) as PlacementOptions & { priceVariant?: { id?: unknown } };
151
+ const variantId = typeof options.priceVariant?.id === "string" ? options.priceVariant.id : "";
152
+ return (cart?.lineItems ?? []).find((line) => {
153
+ const ref = line.source?.catalogReference;
154
+ if (!ref || ref.appId !== reference.appId || ref.catalogItemId !== reference.catalogItemId) return false;
155
+ const lineOptions = (ref.options ?? {}) as PlacementOptions;
156
+ if (lineOptions.operationId !== options.operationId || lineOptions.menuId !== options.menuId) return false;
157
+ if (lineOptions.menuSectionId !== options.menuSectionId) return false;
158
+ if (lineVariantId(line) !== variantId) return false;
159
+ if (line.status === "OUT_OF_STOCK" || line.status === "REMOVED_FROM_CATALOG") return false;
160
+ const confirmed = line.quantityInfo?.confirmedQuantity;
161
+ return confirmed === undefined || confirmed > 0;
162
+ });
163
+ }
164
+
165
+ export function lineQuantity(line: WixCartV2LineItem): number {
166
+ const q = line.quantityInfo?.confirmedQuantity ?? line.quantityInfo?.requestedQuantity ?? 1;
167
+ return Number.isFinite(q) && q >= 0 ? Math.floor(q) : 0;
168
+ }
169
+
170
+ /** How many dishes the cart holds (restaurant lines only). */
171
+ export function restaurantItemCount(cart: WixCartV2 | undefined): number {
172
+ return (cart?.lineItems ?? []).filter(isRestaurantLine).reduce((total, line) => total + lineQuantity(line), 0);
173
+ }
174
+
175
+ export function slotFor(slots: WixFulfillmentTimeSlot[] | undefined, type: FulfillmentType): WixFulfillmentTimeSlot | undefined {
176
+ return (slots ?? []).find((slot) => slot?.fulfilmentType === type);
177
+ }
178
+
179
+ /** The `deliveryMethod.code` for [type]: `<TYPE>|ASAP`, or the slot's own window for a pre-order.
180
+ * An ASAP slot must be sent as ASAP — its start/end window is refused as `DELIVERY_METHOD_NOT_FOUND`. */
181
+ export function deliveryCode(type: FulfillmentType, slot?: WixFulfillmentTimeSlot): string {
182
+ const window = slot?.timeSlot;
183
+ if (window?.orderSchedulingType === "PREORDER") {
184
+ const start = Date.parse(window.startTime ?? ""), end = Date.parse(window.endTime ?? "");
185
+ if (Number.isFinite(start) && Number.isFinite(end)) return `${type}|${start}|${end}`;
186
+ }
187
+ return `${type}|ASAP`;
188
+ }
189
+
190
+ export async function firstSlots(operationId: string, deliveryAddress?: WixRestaurantsAddress): Promise<WixFulfillmentTimeSlot[]> {
191
+ return (await getFirstAvailableTimeSlotPerFulfillmentType(operationId, deliveryAddress)).timeslotsPerFulfillmentType ?? [];
192
+ }
193
+
194
+ /** The pickup address the cart needs before a pickup method can be set: the restaurant's own
195
+ * country (and region when Wix gives one) — Wix requires an address even for pickup. */
196
+ export function pickupCartAddress(method: WixFulfillmentMethod | undefined, fallbackCountry = ""): WixRestaurantsAddress | undefined {
197
+ const address = method?.pickupOptions?.address;
198
+ const country = (address?.country || fallbackCountry).trim();
199
+ if (!country) return undefined;
200
+ return { country, ...(address?.subdivision ? { subdivision: address.subdivision } : {}) };
201
+ }
@@ -1,6 +1,7 @@
1
1
  import { withOnlineWrites } from "../_shared/online-actions.js";
2
2
  import { defineService as registerState } from "@himalaya/state";
3
3
  import { getStoreProduct } from "../_shared/himi-store.js";
4
+ import { RESTAURANTS_ORDERS_APP_ID } from "../_shared/restaurant-ordering.js";
4
5
  import { iapPriceCopy } from "../_shared/iap-worker.js";
5
6
  import { withIapPresentation } from "../_shared/iap-presentation.js";
6
7
  import { type IapCommerce, classifyCart } from "@himalaya/iap-checkout";
@@ -523,6 +524,11 @@ const svc = defineService<State, {
523
524
  const commerce = cartPricing;
524
525
  const items = lines.map(line => (line as WixCartLineItemDetail & { catalogReference?: Row["catalogReference"] }).catalogReference);
525
526
  if (items.some(item => !item?.catalogItemId || !item.appId)) return derive({ ...state, checkoutHint: iapPriceCopy("cart").purchaseUnavailable });
527
+ // Restaurant dishes share this Wix cart but check out on the food order screen; this checkout
528
+ // takes Stores products only, so say which lines are in the way rather than "unavailable".
529
+ if (items.some(item => item!.appId === RESTAURANTS_ORDERS_APP_ID)) {
530
+ return derive({ ...state, checkoutHint: copyParam("cart.restaurantItemsHint", "This cart also has restaurant dishes. Remove them here to check out your store items.") });
531
+ }
526
532
  await commerce.loadPrices(items.map((item, index) => ({ reference: { kind: "storesProduct", id: item!.catalogItemId }, commerceDisplayPrice: lines[index].price?.formattedAmount })));
527
533
  const classification = classifyCart(items.map((item, index) => ({ route: commerce.pricing.view({ kind: "storesProduct", id: item!.catalogItemId }).route, quantity: lines[index].quantity ?? 1 })));
528
534
  if (classification !== "regular") return derive({ ...state, checkoutHint: classification === "mixedRefused" ? copyParam("cart.iapSingleItemOnly", "Buy digital items individually, with quantity 1 and no options.") : iapPriceCopy("cart").purchaseUnavailable });
@@ -1033,7 +1033,7 @@ export default defineApp({
1033
1033
  // Your on-device database. It must declare every entity a worker reads or writes,
1034
1034
  // or those calls fail closed — keep it in step with `local_database` above.
1035
1035
  dataSchema: {
1036
- "version": 1,
1036
+ "version": 2,
1037
1037
  "entities": [
1038
1038
  {
1039
1039
  "name": "products",
@@ -1158,6 +1158,17 @@ export default defineApp({
1158
1158
  }
1159
1159
  ]
1160
1160
  },
1161
+ {
1162
+ "name": "food_orders",
1163
+ "encrypted": true,
1164
+ "fields": [
1165
+ {
1166
+ "name": "id",
1167
+ "type": "string",
1168
+ "required": true
1169
+ }
1170
+ ]
1171
+ },
1161
1172
  {
1162
1173
  "name": "member_bookings",
1163
1174
  "encrypted": true,