@wix/himalaya-cli 0.1475.0 → 0.1477.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 (51) hide show
  1. package/dist/cli.mjs +170 -63
  2. package/dist/preview-assets.generated.bin +0 -0
  3. package/guides/component-reference.json +3 -56
  4. package/guides/component-reference.md +5 -15
  5. package/guides/designing-himi-screens.md +9 -6
  6. package/guides/index.json +1 -1
  7. package/guides/qa-builds.md +47 -18
  8. package/guides/recipe-dynamic-list.md +10 -9
  9. package/guides/recipe-form-submit.md +12 -13
  10. package/guides/recipe-four-state.md +61 -21
  11. package/guides/recipe-search.md +11 -7
  12. package/guides/tier-5-add-a-screen.md +8 -2
  13. package/package.json +2 -2
  14. package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +21 -0
  15. package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +35 -0
  16. package/templates/beauty-salon/tier5-src/_shared/host-events.ts +45 -6
  17. package/templates/beauty-salon/tier5-src/himi-booking-bookings/index.ts +4 -0
  18. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +34 -13
  19. package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +21 -0
  20. package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +21 -0
  21. package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +35 -0
  22. package/templates/branded-lite/tier5-src/_shared/host-events.ts +45 -6
  23. package/templates/branded-lite/tier5-src/himi-booking-bookings/index.ts +4 -0
  24. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +34 -13
  25. package/templates/community-club/tier5-src/_shared/engine-intl.ts +21 -0
  26. package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +21 -0
  27. package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +21 -0
  28. package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +21 -0
  29. package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +21 -0
  30. package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +35 -0
  31. package/templates/gym-fitness/tier5-src/_shared/host-events.ts +45 -6
  32. package/templates/gym-fitness/tier5-src/himi-booking-bookings/index.ts +4 -0
  33. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +34 -13
  34. package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +21 -0
  35. package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +21 -0
  36. package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +21 -0
  37. package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +21 -0
  38. package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +21 -0
  39. package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +21 -0
  40. package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +21 -0
  41. package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +21 -0
  42. package/templates/restaurant/tier5-src/_shared/engine-intl.ts +21 -0
  43. package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +21 -0
  44. package/templates/retail-store/tier5-src/_shared/engine-intl.ts +21 -0
  45. package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +21 -0
  46. package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +21 -0
  47. package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +35 -0
  48. package/templates/yoga-studio/tier5-src/_shared/host-events.ts +45 -6
  49. package/templates/yoga-studio/tier5-src/himi-booking-bookings/index.ts +4 -0
  50. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +34 -13
  51. package/templates/yoga-studio-owner/tier5-src/_shared/engine-intl.ts +21 -0
@@ -3,8 +3,9 @@
3
3
  **The rule every recipe follows:** a screen that loads or writes data shows a
4
4
  **loading state**, a **classified error state**, and a **recovery action where the
5
5
  user can fix the failure** — retry for an offline or a server failure, and for a failed
6
- write; Sign in when the user is signed out; nothing for a permission failure, an empty result
7
- or a field error, where the same request gives the same answer. This recipe is the shared
6
+ write; Sign in when the user is signed out; the site's join flow for a non-member when
7
+ available; nothing for a permission failure, an empty result or a field error, where the same
8
+ request gives the same answer. This recipe is the shared
8
9
  idiom; the other recipes link here for it. Design guidance for the states:
9
10
  `himi docs designing-himi-screens` §5 ("Loading states", "Error states").
10
11
 
@@ -22,7 +23,7 @@ more than half model error, and the reference apps (`apps/himi-owner-lite`,
22
23
  ## 1. One status field, a classified error, derived flags
23
24
 
24
25
  ```ts
25
- type ErrorKind = "" | "offline" | "server" | "signIn" | "permission";
26
+ type ErrorKind = "" | "offline" | "server" | "signIn" | "membership" | "permission";
26
27
 
27
28
  interface State {
28
29
  status: "loading" | "ready" | "error";
@@ -34,6 +35,7 @@ interface State {
34
35
  hideOfflineError: boolean; // one EmptyState per error class …
35
36
  hideServerError: boolean;
36
37
  hideSignInError: boolean;
38
+ hideMembershipError: boolean;
37
39
  hidePermissionError: boolean;
38
40
  hideRefreshError: boolean; // … and a Banner when a refresh fails over rows we kept
39
41
  }
@@ -50,6 +52,7 @@ function shape(s: Partial<State>): State {
50
52
  hideOfflineError: !(blank && errorKind === "offline"),
51
53
  hideServerError: !(blank && errorKind === "server"),
52
54
  hideSignInError: !(blank && errorKind === "signIn"),
55
+ hideMembershipError: !(blank && errorKind === "membership"),
53
56
  hidePermissionError: !(blank && errorKind === "permission"),
54
57
  // Only a failure a retry can fix gets the banner over kept rows.
55
58
  hideRefreshError: !(!blank && (errorKind === "offline" || errorKind === "server")),
@@ -66,31 +69,49 @@ const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRoute
66
69
  // A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
67
70
  // Android `site account is not the bound site …`.
68
71
  const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site/i;
72
+ // The account is not a member of this site: iOS `NotSiteMember(metaSiteId: …)`,
73
+ // Android `not a member of site …`. NOT `WDE0027` — that is Wix Data's generic
74
+ // permission refusal, which a signed-in member and a visitor both get.
75
+ const NOT_MEMBER = /\bNotSiteMember\b|\bnot a member of site\b/i;
69
76
 
70
- function classifyError(e: unknown): ErrorKind {
77
+ function classifyError(e: unknown, authState: "signedIn" | "signedOut" | "unknown" = "unknown"): ErrorKind {
71
78
  const msg = String((e as Error)?.message ?? e ?? "");
72
- const code = String((e as { code?: unknown })?.code ?? "");
79
+ // Some bridges put the Wix code in the response body rather than Error.code.
80
+ const code = String((e as { code?: unknown })?.code ?? /"(?:code|error)"\s*:\s*"([^"]+)"/.exec(msg)?.[1] ?? "");
73
81
  const status = Number(STATUS.exec(msg)?.[1] ?? 0);
74
- if (status === 401) return "signIn"; // signed out (or the session expired)
75
- if (status === 403) return "permission"; // signed in, without access
76
- if (SIGNED_OUT.test(msg)) return "signIn";
82
+ if (code === "AccountSessionExpired" || code === "webwix_account_signed_out" || SIGNED_OUT.test(msg)) return "signIn";
83
+ if (code === "NotSiteMember" || NOT_MEMBER.test(msg)) return "membership";
84
+ // A 401 is a missing or expired session — unless the host knows the user is signed
85
+ // in, when it is a policy refusal. Never "server": a retry sends the same 401 back.
86
+ if (status === 401) return authState === "signedIn" ? "permission" : "signIn";
87
+ // A 403 is a missing permission — but a visitor the host knows is signed out gets
88
+ // `403 WDE0027` on member-only data too, and signing in is what fixes that.
89
+ if (status === 403) return authState === "signedOut" ? "signIn" : "permission";
77
90
  if (code === "offline_write_blocked") return "offline"; // a write refused before it left
78
91
  // A web call that never reached Wix arrives as
79
92
  // `HTTP 502: {"error":"webwix_direct_failed","detail":"Failed to fetch"}` — judge its detail.
80
- if (status && !msg.includes("webwix_direct_failed")) return "server";
81
- if (TRANSPORT.test(msg)) return "offline";
93
+ // The top-level 502 is the bridge wrapper, not the underlying transport result.
94
+ const detail = msg.includes("webwix_direct_failed")
95
+ ? /"detail"\s*:\s*"([^"]+)"/.exec(msg)?.[1] ?? msg
96
+ : msg;
97
+ if (status && detail === msg) return "server";
98
+ if (TRANSPORT.test(detail)) return "offline";
82
99
  return "server"; // anything else — a bug, a malformed row — is not the user's connection
83
100
  }
84
101
 
102
+ // App-specific host integration. If the host does not expose auth state here,
103
+ // keep "unknown": a 401 then shows Sign in, which is what a session that ended needs.
104
+ function currentAuthState(): "signedIn" | "signedOut" | "unknown" { return "unknown"; }
105
+
85
106
  async function load(s: State): Promise<State> {
86
107
  try {
87
108
  const items = (await fetchRows()).map(normalize);
88
109
  return shape({ status: "ready", items });
89
110
  } catch (e) {
90
- const errorKind = classifyError(e);
111
+ const errorKind = classifyError(e, currentAuthState()); // use host/session state when available
91
112
  // Keep the rows: a refresh that fails must not blank a screen that had content —
92
113
  // unless access is gone, when the rows must not stay on screen either.
93
- const lostAccess = errorKind === "signIn" || errorKind === "permission";
114
+ const lostAccess = errorKind === "signIn" || errorKind === "membership" || errorKind === "permission";
94
115
  return shape({ ...s, items: lostAccess ? [] : s.items, status: "error", errorKind });
95
116
  }
96
117
  }
@@ -140,9 +161,16 @@ only when the screen shows the user something different for it.
140
161
  "body": { "package": "himalaya", "type": "EmptyState",
141
162
  "props": { "title": "Sign in to view products",
142
163
  "subtitle": "Your session has ended.",
143
- "action": { "actionId": "tier5.products.signIn" }, // a worker action that dispatches himi.wix.signInDirect
164
+ "action": { "actionId": "tier5.products.openSignIn" },
144
165
  "actionLabel": "Sign in" } },
145
166
  "modifiers": { "hiddenPath": "$service:products.hideSignInError" } },
167
+ { "id": "error.membership",
168
+ "body": { "package": "himalaya", "type": "EmptyState",
169
+ "props": { "title": "Join this site to view products",
170
+ "subtitle": "Use this site's join flow, or ask the owner for access.",
171
+ "action": { "actionId": "tier5.products.openJoin" },
172
+ "actionLabel": "Join this site" } },
173
+ "modifiers": { "hiddenPath": "$service:products.hideMembershipError" } },
146
174
  { "id": "error.permission", // no action: retrying gives the same answer
147
175
  "body": { "package": "himalaya", "type": "EmptyState",
148
176
  "props": { "title": "You need permission to view this screen",
@@ -164,10 +192,21 @@ only when the screen shows the user something different for it.
164
192
  the binding reads without negation.
165
193
  - Ready state is simply the data itself (the list rows, the detail fields) — it
166
194
  needs no dedicated marker.
167
- - **Sign in is a worker action.** A renderer cannot dispatch `himi.wix.*`, so the
168
- sign-in `EmptyState` calls `tier5.products.signIn`, which the worker defines:
169
- it dispatches `himi.wix.signInDirect` and then reloads
170
- (`himi docs wix-login-integration`).
195
+ - **Sign in uses a real auth flow.** A renderer cannot dispatch `himi.wix.*`, so
196
+ the sign-in `EmptyState` calls an app action that opens its configured auth gate
197
+ or sign-in screen, then reloads after authentication. Do not dispatch
198
+ `himi.wix.signInDirect` from this button: it requires the email and password
199
+ collected by a credential form (`himi docs wix-login-integration`).
200
+ - **Membership is not sign-in.** `NotSiteMember` (Android: `not a member of
201
+ site`) means the account must join this site. Route it to the membership state;
202
+ wire its action to the app's join/registration UI, or omit the action when no
203
+ such flow exists. A `403 WDE0027` is NOT membership: Wix Data returns it to a
204
+ signed-in member without access too, so it stays a permission error.
205
+ - **A 403 is a permission error** — the user lacks access and signing in again
206
+ changes nothing — unless the host's auth state says the user is signed out: a
207
+ visitor gets `403 WDE0027` on member-only data, and signing in fixes it. A 401 is
208
+ the session that ended — unless the host says the user is signed in, when it is a
209
+ permission refusal.
171
210
  - **Loading is a `Skeleton` shaped like the loaded content** when you know the
172
211
  layout — the same rows at about the same size. `StatefulSection` or
173
212
  `ActivityIndicator` are fine where they fit better. A plain `"Loading…"` `Text`
@@ -194,7 +233,7 @@ only when the screen shows the user something different for it.
194
233
  | loading | initial state (`status: "loading"`) | content-shaped skeleton; NO retry |
195
234
  | error, nothing on screen | `catch` in the first load | the class's `EmptyState`: user-facing copy + a recovery action where one exists |
196
235
  | error, rows on screen | `catch` in a refresh (offline or server) | rows stay; a `Banner` with **Try again** above them |
197
- | sign-in or permission | `catch` in any load | rows go; the class's `EmptyState` (Sign in, or no action) |
236
+ | sign-in, membership or permission | `catch` in any load | rows go; the class's `EmptyState` (auth flow, join flow, or no action) |
198
237
  | empty | derived: ready + 0 rows | say what *would* fill it, and how |
199
238
  | ready | successful fetch | the content |
200
239
 
@@ -227,9 +266,10 @@ only when the screen shows the user something different for it.
227
266
  (`dev/net-mocks.json`, CMS seed rows). `himi render <screenId> --no-mocks` shows
228
267
  the initial frame — the loading state — and
229
268
  `--state '{"products":{"status":"error","errorKind":"offline","hideOfflineError":false,…}}'`
230
- forces an error class without the network. A `dev/net-mocks.json` rule that
231
- answers 401, 403 or 500 previews the sign-in, permission and server classes
232
- through the real `classifyError`.
269
+ forces an error class without the network. A `dev/net-mocks.json` rule reaches
270
+ the worker only as `Wix <status> for …` — the hermetic crawl drops the response
271
+ body — so a 401, 403 or 500 rule previews the sign-in, permission and server
272
+ classes. Preview the membership class with `--state`.
233
273
 
234
274
  ## See also
235
275
 
@@ -66,7 +66,7 @@ not say why there are no rows.
66
66
  | Results | Matching rows, result count, and the available sort/filter controls. |
67
67
  | No matches | An `EmptyState` explaining that the current query or filters found nothing; offer a way to adjust them, and Clear only where the field is controlled (the native slot) — the inline `SearchField` is uncontrolled, so Clear there empties the query but not the field. |
68
68
  | Empty catalog | Explain that the catalog itself has no items yet, independent of the query; provide a useful browse/back action. |
69
- | Error | Classified as in `himi docs recipe-four-state`: an icon-less `EmptyState` per class with user-facing copy, Try again only for offline and server, Sign in for sign-in, no action for permission. Retain the query and controls, and keep it distinct from no matches. |
69
+ | Error | Classified as in `himi docs recipe-four-state`: an icon-less `EmptyState` per class with user-facing copy, Try again only for offline and server, the app's auth flow for sign-in, the site's join flow for `NotSiteMember`, and no action for permission. Retain the query and controls, and keep it distinct from no matches. |
70
70
 
71
71
  ### Native slot and old-client fallback
72
72
 
@@ -172,9 +172,9 @@ interface PostsState {
172
172
  query: string;
173
173
  hideNoPosts: boolean; // the EmptyState for "loaded, and there is nothing at all"
174
174
  hideNoMatches: boolean; // the EmptyState for "there are posts, none match the query"
175
- errorKind: "" | "offline" | "server" | "signIn" | "permission"; // classifyError(e)
175
+ errorKind: "" | "offline" | "server" | "signIn" | "membership" | "permission"; // classifyError(e, currentAuthState())
176
176
  hideLoading: boolean; hideOfflineError: boolean; hideServerError: boolean;
177
- hideSignInError: boolean; hidePermissionError: boolean;
177
+ hideSignInError: boolean; hideMembershipError: boolean; hidePermissionError: boolean;
178
178
  hideRefreshError: boolean; // hiddenPath flags, as in recipe-four-state
179
179
  }
180
180
 
@@ -219,7 +219,7 @@ clear: (s) => { currentQuery = ""; return shape({ ...s, query: "", items: s.all
219
219
  ```
220
220
 
221
221
  - `load()` fetches once, fills `all`, and sets `items = visible(all, currentQuery)`.
222
- On a `signIn` or `permission` failure it clears **`all` as well as `items`** —
222
+ On a `signIn`, `membership` or `permission` failure it clears **`all` as well as `items`** —
223
223
  `search`, `clear` and `retry` rebuild rows from `all`, so rows left there come
224
224
  back on the next keystroke.
225
225
  - The shipped Blog v3 `WixPost` type includes `categoryIds` and both Wix-image and
@@ -261,8 +261,11 @@ A searchable list loads data, so it carries the four states of
261
261
  while the first `load()` runs. The search field is usable at once.
262
262
  - **Error, nothing loaded** — one `EmptyState` per class `classifyError` can
263
263
  return, **no icon**, user-facing copy; offline and server get **Try again** →
264
- `tier5.posts.retry`, sign-in gets **Sign in**, permission gets no action. The
265
- `catch` sets `errorKind` from `classifyError(e)`; it never shows `e.message`.
264
+ `tier5.posts.retry`, sign-in opens the app's configured auth flow, `NotSiteMember`
265
+ opens the site's join flow when the app provides one, and permission gets no
266
+ action. The `catch` sets `errorKind` from `classifyError(e, currentAuthState())`
267
+ (both from `himi docs recipe-four-state`); it never
268
+ shows `e.message`.
266
269
  - **Retry keeps the query.** `retry` re-runs `load()` and then re-applies
267
270
  `currentQuery` — not `s.query`, which is the query from when the retry
268
271
  STARTED — so what the user typed during the fetch is not lost.
@@ -273,7 +276,8 @@ A searchable list loads data, so it carries the four states of
273
276
  retry: async (s) => {
274
277
  const next = await load(s); // classified on failure
275
278
  // Lost access clears the rows; any other failure keeps them for the Banner.
276
- const lostAccess = next.errorKind === "signIn" || next.errorKind === "permission";
279
+ const lostAccess = next.errorKind === "signIn" || next.errorKind === "membership"
280
+ || next.errorKind === "permission";
277
281
  const all = lostAccess ? [] : next.all;
278
282
  return shape({ ...next, all, query: currentQuery, items: visible(all, currentQuery) });
279
283
  },
@@ -112,10 +112,16 @@ The first arg after `state` is the action's payload object: the descriptor's `Ac
112
112
  | `Rating` | `onChange` | `{ value }` — the 1-based star index |
113
113
  | `ColorPicker` | `onChange` | `{ value }` — the hex string |
114
114
  | `DatePicker` | `onChange` | `{ value }` — unix **seconds**, string-encoded |
115
- | `OtpInput` | `onChange`, `onComplete` | `{ value }` — the assembled code (dispatched on React Native today; the other renderers show the boxes read-only) |
116
- | `ChipsInput` | `onRemove` | `{ value }` — the removed chip |
115
+ | `OtpInput` | `onChange`, `onComplete` | `{ value }` — the assembled code: `onChange` on every edit, `onComplete` once, on the edit that fills the last box |
116
+ | `ChipsInput` | `onAdd`, `onRemove` | `{ value }` — the new token (trimmed; committed by Return or a comma, and the field clears) / the removed chip. Append the token to `valuesPath` to show it |
117
117
  | `Button` / `ListItem` | `action` / `onTap` | exactly the descriptor's `params` (no injected value) |
118
118
 
119
+ For a controlled `OtpInput`, bind `valuePath` and have `onChange` store `payload.value` in that
120
+ bound field. An `onComplete` reducer can then clear it to empty the boxes. Service subscriptions
121
+ report **changes**: returning an already-empty bound field without first storing the user's edit
122
+ is a no-op and does not reset the local input. A completion-only input can omit `valuePath` and
123
+ submit the assembled code directly from `payload.value`.
124
+
119
125
  The payload rides in **`args`**. Some arms also mirror it into `params`; that is permitted only
120
126
  for string-encoded values, because `Action.params` is `map<string,string>` in the proto. Every
121
127
  host merges `{...params, ...args}` (args winning) before invoking the reducer, so a worker sees
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1475.0",
3
+ "version": "0.1477.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": "224e56d2071ce0050cf65a861313895b2147862685f52d1f6b8abd95"
74
+ "falconPackageHash": "c8bc671d48c378f2cea33ccdf2dd61f759b3920ebb50a7c0a1c87a1d"
75
75
  }
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
444
444
  }
445
445
  return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
446
446
  }
447
+
448
+ /**
449
+ * [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
450
+ * where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
451
+ * Callers fall back to the device zone on NaN, as the formatters above do.
452
+ */
453
+ export function zoneOffsetAt(at: number, timeZone: string): number {
454
+ if (hasIntl() && Number.isFinite(at)) {
455
+ try {
456
+ const parts = new Intl.DateTimeFormat("en-US", {
457
+ timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
458
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
459
+ }).formatToParts(new Date(at));
460
+ const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
461
+ return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
462
+ } catch {
463
+ /* a zone this engine does not know: NaN below */
464
+ }
465
+ }
466
+ return NaN;
467
+ }
@@ -16,3 +16,38 @@ export function categoryName(value: unknown): string {
16
16
  const name = (value as { name?: unknown }).name;
17
17
  return typeof name === "string" ? name.trim() : "";
18
18
  }
19
+
20
+ import { zoneOffsetAt } from "./engine-intl.js";
21
+
22
+ const HAS_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
23
+ const LOCAL_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/;
24
+
25
+ /** The instant (ms) a slot starts. Wix may list a slot's `startDate` as a LOCAL wall time with its
26
+ * `timezone` beside it ("2026-11-02T18:30:00" in America/New_York is 23:30Z); a bare `Date.parse`
27
+ * would read that in the device's zone. So a local time is placed in the slot's own zone when this
28
+ * engine has Intl and knows the zone, and read in the device's zone only when it cannot be. */
29
+ export function slotStartInstant(slot: { startDate?: string; timezone?: unknown }): number {
30
+ const start = slot.startDate ?? "";
31
+ if (!start) return NaN;
32
+ const local = LOCAL_TIME.exec(start);
33
+ if (HAS_OFFSET.test(start) || !local || typeof slot.timezone !== "string" || !slot.timezone) return Date.parse(start);
34
+ const wall = Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +(local[6] ?? 0));
35
+ const offset = zoneOffsetAt(wall, slot.timezone);
36
+ if (!Number.isFinite(offset)) return Date.parse(start);
37
+ // Once more at the corrected instant, so a wall time beside a DST change lands on the right side.
38
+ const again = zoneOffsetAt(wall - offset, slot.timezone);
39
+ return wall - (Number.isFinite(again) ? again : offset);
40
+ }
41
+
42
+ /** The slot a host asked to open at (`himi_time` input `start`, an ISO 8601 instant): the id of the
43
+ * slot that starts at that same instant, or "" when none does (taken since, or out of the window).
44
+ * Compared as instants, so `…T16:30:00Z` finds a slot listed as `…T18:30:00+02:00`, or as a local
45
+ * `…T18:30:00` beside `timezone: "Asia/Jerusalem"`. */
46
+ export function slotIdAt(slots: Iterable<[string, { startDate?: string; timezone?: unknown }]>, start: string): string {
47
+ const at = start ? Date.parse(start) : NaN;
48
+ if (!Number.isFinite(at)) return "";
49
+ for (const [id, slot] of slots) {
50
+ if (slotStartInstant(slot) === at) return id;
51
+ }
52
+ return "";
53
+ }
@@ -1,26 +1,65 @@
1
1
  // Host events — what a member screen tells the app hosting it about something the member did
2
- // (a booking confirmed), so a host such as Symphony can say so in its chat and refresh what it shows.
2
+ // (a booking confirmed or cancelled), so a host such as Symphony can say so in its chat and refresh
3
+ // what it shows.
3
4
  //
4
5
  // A worker dispatches `host.event` with `{ type, ...fields }` (string values only: a dispatch carries
5
6
  // one string params map). An embed (`HimalayaEmbed`) hands it to the host's `onAction` as an action
6
7
  // with that id; anywhere else nothing handles it and it is dropped. It is a notice, never a request:
7
8
  // the host owns what happens next, and the worker never waits on it or depends on its answer.
9
+ //
10
+ // The contract (hosts parse exactly this — Symphony's `PlaceHostEvent`):
11
+ // type booking.created | booking.cancelled | plan.used
12
+ // serviceName the class / service title (bookings) planName the plan's name (plan.used)
13
+ // start, end ISO 8601 instants WITH an offset (`Z` or `+02:00`); a local time is left out
14
+ // remaining sessions left on the plan, when known (a whole number)
15
+ // bookingId, serviceId, planId when known
8
16
 
9
17
  import { dispatch } from "@himalaya/dispatch";
10
18
 
11
19
  export const HOST_EVENT_ACTION_ID = "host.event";
12
20
 
13
21
  /** The events a member screen reports. */
14
- export type HostEventType = "booking.created";
22
+ export type HostEventType = "booking.created" | "booking.cancelled" | "plan.used";
15
23
 
16
- /** Tell the host about [type]. Never throws: a host that does not listen must not break the screen. */
17
- export async function emitHostEvent(type: HostEventType, fields: Record<string, string | undefined> = {}): Promise<void> {
24
+ export interface HostEventFields {
25
+ bookingId?: string;
26
+ serviceId?: string;
27
+ serviceName?: string;
28
+ planId?: string;
29
+ planName?: string;
30
+ start?: string;
31
+ end?: string;
32
+ remaining?: number | string;
33
+ }
34
+
35
+ const WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i;
36
+
37
+ /** [value] when it is an ISO 8601 instant that names its offset, else "". A slot's LOCAL wall time
38
+ * ("2026-11-02T18:30:00") would be read in the host's zone, i.e. a wrong hour, so it is left out. */
39
+ export function hostEventInstant(value: unknown): string {
40
+ return typeof value === "string" && WITH_OFFSET.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
41
+ }
42
+
43
+ /** The `host.event` params for [type] and [fields]: unset and empty fields are left out, times
44
+ * without an offset are left out, and `remaining` is a whole number ≥ 0 or nothing. */
45
+ export function hostEventParams(type: HostEventType, fields: HostEventFields = {}): Record<string, string> {
18
46
  const params: Record<string, string> = { type };
19
47
  for (const [key, value] of Object.entries(fields)) {
20
- if (typeof value === "string" && value !== "") params[key] = value;
48
+ let out = "";
49
+ if (key === "start" || key === "end") out = hostEventInstant(value);
50
+ else if (key === "remaining") {
51
+ const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
52
+ out = Number.isInteger(n) && n >= 0 ? String(n) : "";
53
+ } else if (typeof value === "string") out = value.trim();
54
+ if (out) params[key] = out;
21
55
  }
56
+ return params;
57
+ }
58
+
59
+ /** Tell the host about [type]. Never throws: a host that does not listen must not break the screen. */
60
+ export async function emitHostEvent(type: HostEventType, fields: HostEventFields = {}): Promise<void> {
22
61
  try {
23
- await dispatch(HOST_EVENT_ACTION_ID, params);
62
+ await dispatch(HOST_EVENT_ACTION_ID, hostEventParams(type, fields));
24
63
  } catch {
25
64
  // Not embedded, or the host has no handler: nothing to tell.
26
65
  }
@@ -4,6 +4,7 @@ import { navigate } from "@himalaya/state";
4
4
  import { copyParam } from "../_shared/himi-copy.js";
5
5
  import { memberAccess, assertMemberAccess, memberAccessChanged, memberAccessDenied, type MemberAccess } from "../_shared/member-access.js";
6
6
  import { fetchMemberBookings, type MemberBookingRow } from "../_shared/member-bookings.js";
7
+ import { emitHostEvent } from "../_shared/host-events.js";
7
8
  import { dayKey, dayLabel } from "@himalaya/time";
8
9
  import { localPreferences } from "../_shared/local-preferences.js";
9
10
  import { parseWeeklyGoal, weeklyGoalProgress, weeklySessionsFromPages } from "../_shared/weekly-goal.js";
@@ -136,6 +137,9 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
136
137
  await cancelBookingAsMember(target.row.id, target.row.revision);
137
138
  await assertMemberAccess(target.member);
138
139
  cancelTarget = undefined;
140
+ // Told to the app hosting this screen (an embed's `onAction`); dropped where nothing hosts it.
141
+ await emitHostEvent("booking.cancelled", { bookingId: target.row.id, serviceId: target.row.serviceId,
142
+ serviceName: target.row.title, start: target.row.start, end: target.row.end });
139
143
  await svc.refresh();
140
144
  return derive({ ...(svc.getState() as unknown as S), actionMessage: copyParam("bookings.cancelDone", "Booking cancelled.") });
141
145
  } catch (error) {
@@ -16,7 +16,8 @@ import { formatServicePrice, serviceIsChargeable } from "../_shared/wix-service-
16
16
  import { stashCopy, copyParam } from "../_shared/himi-copy.js";
17
17
  import { upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "@himalaya/local-db";
18
18
  import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
19
- import { emitHostEvent } from "../_shared/host-events.js";
19
+ import { emitHostEvent, hostEventInstant } from "../_shared/host-events.js";
20
+ import { slotIdAt } from "../_shared/himi-bookings.js";
20
21
  import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
21
22
 
22
23
  // Offline-first (data-offline): booking a slot is WRITE-BEHIND against the
@@ -147,12 +148,23 @@ function inputId(): string {
147
148
  // `id` nav param (himi.open { screenId: "himi_time", id }) into worker input.
148
149
  // The screenId-slice below is #570 back-compat for pre-params releases with
149
150
  // baked `himi_time_<id>` screenIds — do not author against this.
150
- const input = (globalThis as { himalaya?: { input?: { id?: unknown; screenId?: unknown } } }).himalaya?.input;
151
+ // `serviceId` is the same thing under the name a HOST uses to open this screen (the deep-entry
152
+ // contract: Branded Lite README, "Opening at a booking").
153
+ const input = (globalThis as { himalaya?: { input?: { id?: unknown; serviceId?: unknown; screenId?: unknown } } }).himalaya?.input;
151
154
  if (typeof input?.id === "string" && input.id) return input.id;
155
+ if (typeof input?.serviceId === "string" && input.serviceId) return input.serviceId;
152
156
  const screenId = typeof input?.screenId === "string" ? input.screenId : "";
153
157
  return screenId.startsWith("himi_time_") ? screenId.slice("himi_time_".length) : "";
154
158
  }
155
159
 
160
+ /** The time a host opened this screen at (`himalaya.input.start`, ISO 8601), or "". Every load
161
+ * preselects that slot (or opens on its day) until the member picks a day or a time themselves —
162
+ * not just the first, because the boot load and the screen's own `load` can overlap. */
163
+ let pendingStart = (() => {
164
+ const start = (globalThis as { himalaya?: { input?: { start?: unknown } } }).himalaya?.input?.start;
165
+ return typeof start === "string" && Number.isFinite(Date.parse(start)) ? start : "";
166
+ })();
167
+
156
168
  function strOf(arg: unknown): string {
157
169
  if (typeof arg === "string") return arg;
158
170
  const a = arg as { value?: string; text?: string } | undefined;
@@ -534,15 +546,20 @@ const svc = defineService<S, {
534
546
  const days = daysOf(rows);
535
547
  // Keep the customer's day across a reload when it still has times;
536
548
  // otherwise open on the first day that has any.
549
+ // A host's `start` (deep entry): that slot preselected, or — taken since — its day.
550
+ const wantedStart = pendingStart;
551
+ const startRow = wantedStart ? rows.find((r) => r.id === slotIdAt(slotsById, wantedStart)) : undefined;
552
+ // The requested day even with no time left on it: "taken since" opens there, nothing selected.
553
+ const startDay = wantedStart ? days.find((d) => d.id === dayKey(wantedStart)) : undefined;
537
554
  const keep = days.find((d) => d.id === next.selectedDay && d.hasSlots);
538
- const initialDay = keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
539
- const selectionAlive = slotsById.has(next.selectedSlotId);
555
+ const initialDay = startRow?.dayKey ?? startDay?.id ?? keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
556
+ const selectionAlive = !!startRow || slotsById.has(next.selectedSlotId);
540
557
  const header = serviceHeaderFields(serviceRes.service);
541
558
  return derive(withDay({
542
559
  ...next,
543
560
  days,
544
- selectedSlotId: selectionAlive ? next.selectedSlotId : "",
545
- selectedLine: selectionAlive ? next.selectedLine : "",
561
+ selectedSlotId: startRow ? startRow.id : selectionAlive ? next.selectedSlotId : "",
562
+ selectedLine: startRow ? [startRow.day, startRow.title, startRow.subtitle].filter(Boolean).join(" · ") : selectionAlive ? next.selectedLine : "",
546
563
  status: "ready",
547
564
  errored: false,
548
565
  serviceName: serviceRes.service?.name || copyParam("time.serviceNameFallback", "Choose a time"),
@@ -567,6 +584,7 @@ const svc = defineService<S, {
567
584
  selectDay: (s, arg) => {
568
585
  const day = chosenDay(arg);
569
586
  if (!day || !s.days.some((d) => d.id === day)) return derive(s);
587
+ pendingStart = "";
570
588
  return derive(withDay(s, day));
571
589
  },
572
590
  refreshMember: (s, arg) => { stashCopy(arg); return probeMember(s); },
@@ -575,6 +593,7 @@ const svc = defineService<S, {
575
593
  ? arg
576
594
  : ((arg as { id?: string; slotId?: string })?.slotId ?? (arg as { id?: string })?.id ?? "");
577
595
  const selected = allRows.find((slot) => slot.id === slotId);
596
+ if (selected) pendingStart = "";
578
597
  return derive({
579
598
  ...s,
580
599
  selectedSlotId: selected ? slotId : s.selectedSlotId,
@@ -638,12 +657,14 @@ const svc = defineService<S, {
638
657
  if (!slot) return derive({ ...s, message: copyParam("time.bookSlotGone", "That time is no longer available. Try again.") });
639
658
  // A confirmed booking is reported to the app hosting this screen (an embed's `onAction`), so a
640
659
  // host chat can say so and refresh what it shows. Dropped where nothing hosts the screen.
641
- const reportBooked = (bookingId: string | undefined) => emitHostEvent("booking.created", {
642
- bookingId,
660
+ // The booking's own instants carry an offset; a slot's may be LOCAL to its timezone, which
661
+ // `emitHostEvent` leaves out rather than let the host read it in its own zone.
662
+ const reportBooked = (booking: { id?: string; startDate?: string; endDate?: string } | undefined) => emitHostEvent("booking.created", {
663
+ bookingId: booking?.id,
643
664
  serviceId: s.id,
644
665
  serviceName: s.serviceName,
645
- start: slot.startDate,
646
- end: slot.endDate,
666
+ start: hostEventInstant(booking?.startDate) || slot.startDate,
667
+ end: hostEventInstant(booking?.endDate) || slot.endDate,
647
668
  });
648
669
  // PAID service — FULLY NATIVE in-app payment (mirrors Wix's RN apps): NO
649
670
  // hosted-checkout WebView / browser redirect. The chain is
@@ -679,7 +700,7 @@ const svc = defineService<S, {
679
700
  const orderRes = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
680
701
  const snapshotId = orderRes.paymentGatewayOrderId ?? "";
681
702
  if (!snapshotId && !hosted) {
682
- await reportBooked(bookingId);
703
+ await reportBooked(created.booking);
683
704
  return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
684
705
  }
685
706
  // 4. Collect payment IN-APP — wallet when this app has one wired, otherwise
@@ -701,7 +722,7 @@ const svc = defineService<S, {
701
722
  },
702
723
  });
703
724
  if (pay.kind === "paid") {
704
- await reportBooked(bookingId);
725
+ await reportBooked(created.booking);
705
726
  return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
706
727
  }
707
728
  if (pay.kind === "handoff") {
@@ -740,7 +761,7 @@ const svc = defineService<S, {
740
761
  // Reconcile the optimistic local id with the server-assigned booking id.
741
762
  const realId = created.booking?.id;
742
763
  if (realId && realId !== localId) { await cacheDrop(localId); await cachePut(localRow(realId)); }
743
- await reportBooked(realId);
764
+ await reportBooked(created.booking);
744
765
  return derive({
745
766
  ...pending,
746
767
  bookingInFlight: false,
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
444
444
  }
445
445
  return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
446
446
  }
447
+
448
+ /**
449
+ * [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
450
+ * where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
451
+ * Callers fall back to the device zone on NaN, as the formatters above do.
452
+ */
453
+ export function zoneOffsetAt(at: number, timeZone: string): number {
454
+ if (hasIntl() && Number.isFinite(at)) {
455
+ try {
456
+ const parts = new Intl.DateTimeFormat("en-US", {
457
+ timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
458
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
459
+ }).formatToParts(new Date(at));
460
+ const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
461
+ return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
462
+ } catch {
463
+ /* a zone this engine does not know: NaN below */
464
+ }
465
+ }
466
+ return NaN;
467
+ }
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
444
444
  }
445
445
  return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
446
446
  }
447
+
448
+ /**
449
+ * [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
450
+ * where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
451
+ * Callers fall back to the device zone on NaN, as the formatters above do.
452
+ */
453
+ export function zoneOffsetAt(at: number, timeZone: string): number {
454
+ if (hasIntl() && Number.isFinite(at)) {
455
+ try {
456
+ const parts = new Intl.DateTimeFormat("en-US", {
457
+ timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
458
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
459
+ }).formatToParts(new Date(at));
460
+ const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
461
+ return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
462
+ } catch {
463
+ /* a zone this engine does not know: NaN below */
464
+ }
465
+ }
466
+ return NaN;
467
+ }
@@ -16,3 +16,38 @@ export function categoryName(value: unknown): string {
16
16
  const name = (value as { name?: unknown }).name;
17
17
  return typeof name === "string" ? name.trim() : "";
18
18
  }
19
+
20
+ import { zoneOffsetAt } from "./engine-intl.js";
21
+
22
+ const HAS_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
23
+ const LOCAL_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/;
24
+
25
+ /** The instant (ms) a slot starts. Wix may list a slot's `startDate` as a LOCAL wall time with its
26
+ * `timezone` beside it ("2026-11-02T18:30:00" in America/New_York is 23:30Z); a bare `Date.parse`
27
+ * would read that in the device's zone. So a local time is placed in the slot's own zone when this
28
+ * engine has Intl and knows the zone, and read in the device's zone only when it cannot be. */
29
+ export function slotStartInstant(slot: { startDate?: string; timezone?: unknown }): number {
30
+ const start = slot.startDate ?? "";
31
+ if (!start) return NaN;
32
+ const local = LOCAL_TIME.exec(start);
33
+ if (HAS_OFFSET.test(start) || !local || typeof slot.timezone !== "string" || !slot.timezone) return Date.parse(start);
34
+ const wall = Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +(local[6] ?? 0));
35
+ const offset = zoneOffsetAt(wall, slot.timezone);
36
+ if (!Number.isFinite(offset)) return Date.parse(start);
37
+ // Once more at the corrected instant, so a wall time beside a DST change lands on the right side.
38
+ const again = zoneOffsetAt(wall - offset, slot.timezone);
39
+ return wall - (Number.isFinite(again) ? again : offset);
40
+ }
41
+
42
+ /** The slot a host asked to open at (`himi_time` input `start`, an ISO 8601 instant): the id of the
43
+ * slot that starts at that same instant, or "" when none does (taken since, or out of the window).
44
+ * Compared as instants, so `…T16:30:00Z` finds a slot listed as `…T18:30:00+02:00`, or as a local
45
+ * `…T18:30:00` beside `timezone: "Asia/Jerusalem"`. */
46
+ export function slotIdAt(slots: Iterable<[string, { startDate?: string; timezone?: unknown }]>, start: string): string {
47
+ const at = start ? Date.parse(start) : NaN;
48
+ if (!Number.isFinite(at)) return "";
49
+ for (const [id, slot] of slots) {
50
+ if (slotStartInstant(slot) === at) return id;
51
+ }
52
+ return "";
53
+ }