@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.
- package/dist/cli.mjs +170 -63
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/component-reference.json +3 -56
- package/guides/component-reference.md +5 -15
- package/guides/designing-himi-screens.md +9 -6
- package/guides/index.json +1 -1
- package/guides/qa-builds.md +47 -18
- package/guides/recipe-dynamic-list.md +10 -9
- package/guides/recipe-form-submit.md +12 -13
- package/guides/recipe-four-state.md +61 -21
- package/guides/recipe-search.md +11 -7
- package/guides/tier-5-add-a-screen.md +8 -2
- package/package.json +2 -2
- package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +35 -0
- package/templates/beauty-salon/tier5-src/_shared/host-events.ts +45 -6
- package/templates/beauty-salon/tier5-src/himi-booking-bookings/index.ts +4 -0
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +34 -13
- package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +35 -0
- package/templates/branded-lite/tier5-src/_shared/host-events.ts +45 -6
- package/templates/branded-lite/tier5-src/himi-booking-bookings/index.ts +4 -0
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +34 -13
- package/templates/community-club/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +35 -0
- package/templates/gym-fitness/tier5-src/_shared/host-events.ts +45 -6
- package/templates/gym-fitness/tier5-src/himi-booking-bookings/index.ts +4 -0
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +34 -13
- package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/restaurant/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/retail-store/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +35 -0
- package/templates/yoga-studio/tier5-src/_shared/host-events.ts +45 -6
- package/templates/yoga-studio/tier5-src/himi-booking-bookings/index.ts +4 -0
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +34 -13
- 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;
|
|
7
|
-
|
|
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
|
-
|
|
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 (
|
|
75
|
-
if (
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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.
|
|
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
|
|
168
|
-
sign-in `EmptyState` calls
|
|
169
|
-
|
|
170
|
-
|
|
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` (
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
package/guides/recipe-search.md
CHANGED
|
@@ -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,
|
|
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
|
|
265
|
-
|
|
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 === "
|
|
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
|
|
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.
|
|
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": "
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|