@wix/himalaya-cli 0.1550.0 → 0.1552.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 (65) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +3 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-contract.d.ts +2 -2
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +24 -2
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/order-refs.d.ts +12 -3
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/forms/src/extensions.d.ts +83 -1
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +146 -23
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +8 -5
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +147 -24
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-forms.mjs +19 -0
  10. package/dist/cli.mjs +2489 -2173
  11. package/fixtures/wix-canned.json +14 -7
  12. package/guides/getting-started.md +17 -1
  13. package/guides/headless-loop.md +7 -1
  14. package/guides/himi-skill.md +17 -1
  15. package/guides/himi-test.md +95 -7
  16. package/guides/iap-pricing.md +1 -1
  17. package/guides/iap-products.md +2 -2
  18. package/guides/iap-screen-adoption.md +1 -1
  19. package/guides/index.json +1 -1
  20. package/guides/qa-builds.md +56 -1
  21. package/guides/sdk-reference.json +82 -4
  22. package/guides/skill.md +16 -0
  23. package/package.json +2 -2
  24. package/templates/beauty-salon/config.ts +13 -0
  25. package/templates/beauty-salon/tier5-src/_shared/iap-attendance-purchase.ts +1 -1
  26. package/templates/beauty-salon/tier5-src/_shared/iap-worker.ts +1 -1
  27. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +1 -1
  28. package/templates/branded-lite/config.ts +13 -0
  29. package/templates/branded-lite/dev/index.ts +1338 -2011
  30. package/templates/branded-lite/tier5-src/_shared/branded-form.ts +262 -0
  31. package/templates/branded-lite/tier5-src/_shared/iap-attendance-purchase.ts +1 -1
  32. package/templates/branded-lite/tier5-src/_shared/iap-worker.ts +1 -1
  33. package/templates/branded-lite/tier5-src/himi-branded-lite-form/index.ts +399 -0
  34. package/templates/branded-lite/tier5-src/himi-branded-lite-forms/index.ts +22 -7
  35. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +1 -1
  36. package/templates/community-club/config.ts +13 -0
  37. package/templates/community-club/tier5-src/_shared/iap-attendance-purchase.ts +1 -1
  38. package/templates/community-club/tier5-src/_shared/iap-worker.ts +1 -1
  39. package/templates/events-meetups/config.ts +13 -0
  40. package/templates/events-meetups/tier5-src/_shared/iap-attendance-purchase.ts +1 -1
  41. package/templates/events-meetups/tier5-src/_shared/iap-worker.ts +1 -1
  42. package/templates/gym-fitness/config.ts +13 -0
  43. package/templates/gym-fitness/tier5-src/_shared/iap-attendance-purchase.ts +1 -1
  44. package/templates/gym-fitness/tier5-src/_shared/iap-worker.ts +1 -1
  45. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +1 -1
  46. package/templates/nonprofit-cause/config.ts +13 -0
  47. package/templates/nonprofit-cause/tier5-src/_shared/iap-attendance-purchase.ts +1 -1
  48. package/templates/nonprofit-cause/tier5-src/_shared/iap-worker.ts +1 -1
  49. package/templates/podcast-creator/config.ts +13 -0
  50. package/templates/podcast-creator/tier5-src/_shared/iap-attendance-purchase.ts +1 -1
  51. package/templates/podcast-creator/tier5-src/_shared/iap-worker.ts +1 -1
  52. package/templates/portfolio-creative/config.ts +13 -0
  53. package/templates/portfolio-creative/tier5-src/_shared/iap-attendance-purchase.ts +1 -1
  54. package/templates/portfolio-creative/tier5-src/_shared/iap-worker.ts +1 -1
  55. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +1 -1
  56. package/templates/restaurant/config.ts +13 -0
  57. package/templates/restaurant/tier5-src/_shared/iap-attendance-purchase.ts +1 -1
  58. package/templates/restaurant/tier5-src/_shared/iap-worker.ts +1 -1
  59. package/templates/retail-store/config.ts +13 -0
  60. package/templates/retail-store/tier5-src/_shared/iap-worker.ts +1 -1
  61. package/templates/retail-store/tier5-src/himi-store-product/index.ts +1 -1
  62. package/templates/yoga-studio/config.ts +13 -0
  63. package/templates/yoga-studio/tier5-src/_shared/iap-attendance-purchase.ts +1 -1
  64. package/templates/yoga-studio/tier5-src/_shared/iap-worker.ts +1 -1
  65. package/templates/branded-lite/tier5-src/wix-spaces-form-detail/index.ts +0 -30
@@ -0,0 +1,399 @@
1
+ // himi-branded-lite-form — the Branded Lite Form screen: one Wix Form a visitor fills
2
+ // out and sends.
3
+ //
4
+ // Reads the form (getForm) and renders its fields in layout order, one page at a time
5
+ // (_shared/branded-form.ts parses the schema). A signed-in member gets email, names and
6
+ // phone filled in from their member record, matched by each field's contact mapping.
7
+ // Send = the app's own checks → validateFormSubmission (Wix's checks, nothing created) →
8
+ // createSubmission, opted into the offline outbox. Server errors land in each field's own
9
+ // slot; a failed send keeps every value and says so by the button.
10
+ //
11
+ // A form with a field the app cannot fill, or a conditional rule, shows a notice that
12
+ // opens the website instead (a hidden required field would fail the whole send). A form
13
+ // whose deadline passed shows a closed notice. Replaces wix-spaces-form-detail for this
14
+ // app only: the Spaces clone fixture still uses that worker.
15
+ import { classifyLoadError, defineService } from "@himalaya/state";
16
+ import { open as openUrl } from "@himalaya/open-url";
17
+ import { createSubmission, getForm, validateFormSubmission } from "@himalaya/wix-forms";
18
+ import { getMyMember } from "@himalaya/wix-members";
19
+ import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
20
+ import { detailInputId, presence, presenceAfterError, type PresenceFlags } from "../_shared/detail-presence.js";
21
+ import { memberAccess } from "../_shared/member-access.js";
22
+ import { formatDateMedium } from "../_shared/engine-intl.js";
23
+ import {
24
+ EMPTY_VALUE, isoDate, localProblems, parseForm, serverProblems, submissionValues,
25
+ type FieldValue, type FormFieldModel, type FormModel,
26
+ } from "../_shared/branded-form.js";
27
+
28
+ interface OptionRow { id: string; target: string; value: string; label: string; selected: boolean }
29
+ interface FieldRow {
30
+ id: string; target: string;
31
+ label: string; hideLabel: boolean; help: string; hideHelp: boolean; placeholder: string;
32
+ checkLabel: string; displayText: string;
33
+ value: string; dateText: string; hideDateValue: boolean; hideDatePlaceholder: boolean; checked: boolean;
34
+ error: string; noError: boolean;
35
+ /** The error line under a control with no error slot of its own (date, choice, checkbox). */
36
+ noErrorLine: boolean;
37
+ options: OptionRow[];
38
+ // hiddenWhen hides on TRUE: one "not" flag per field kind.
39
+ notText: boolean; notArea: boolean; notEmail: boolean; notPhone: boolean; notNumber: boolean; notUrl: boolean;
40
+ notDate: boolean; notCheck: boolean; notChoice: boolean; notMulti: boolean; notDisplay: boolean;
41
+ }
42
+
43
+ interface S extends PresenceFlags {
44
+ status: "loading" | "ready";
45
+ title: string;
46
+ rows: FieldRow[];
47
+ pageCaption: string; noPageCaption: boolean;
48
+ primaryLabel: string; backLabel: string; hideSingle: boolean; hidePair: boolean;
49
+ sending: boolean;
50
+ sendError: string; noSendError: boolean;
51
+ /** The fields and the footer: hidden unless the form is there, fillable, open and not sent. */
52
+ hideForm: boolean;
53
+ hideUnsupported: boolean; hideClosed: boolean;
54
+ /** No site URL (hosted mode): the notice keeps its sentence and drops the link. */
55
+ hideOpenWebsite: boolean;
56
+ hideDone: boolean; doneTitle: string; doneBody: string;
57
+ hideOfflineError: boolean; hideServerError: boolean;
58
+ dateSheetOpen: boolean; dateSheetTitle: string;
59
+ /** The sheet's date (unix seconds) — the picker shows it and Done writes it. */
60
+ dateDraft: number;
61
+ }
62
+
63
+ const BASE: S = {
64
+ status: "loading", title: "", rows: [], pageCaption: "", noPageCaption: true,
65
+ primaryLabel: "", backLabel: "", hideSingle: true, hidePair: true, sending: false, sendError: "", noSendError: true,
66
+ hideForm: true, hideUnsupported: true, hideClosed: true, hideOpenWebsite: true, hideDone: true, doneTitle: "", doneBody: "",
67
+ hideOfflineError: true, hideServerError: true, dateSheetOpen: false, dateSheetTitle: "", dateDraft: 0,
68
+ ...presence("loading"),
69
+ };
70
+
71
+ // The worker is the source of truth for what the visitor typed: a keystroke updates this
72
+ // map and returns the state unchanged, so typing never re-renders the field list.
73
+ let form: FormModel | undefined;
74
+ let values: Record<string, FieldValue> = {};
75
+ let page = 0;
76
+ let problems: Record<string, string> = {};
77
+ let siteUrl = "";
78
+ let dateTarget = "";
79
+ let dateDraft = 0;
80
+ /** Done writes only a date the visitor picked: Android's standalone DatePicker does not read
81
+ * `valuePath` and opens blank, so an untouched Done must not write a date nobody saw. */
82
+ let datePicked = false;
83
+
84
+ const valueOf = (target: string): FieldValue => values[target] ?? EMPTY_VALUE;
85
+ const pageFields = (): FormFieldModel[] => (form?.pages[page] ?? []).map((id) => form!.fields[id]).filter(Boolean);
86
+ const allFields = (): FormFieldModel[] => (form ? form.pages.flat().map((id) => form!.fields[id]).filter(Boolean) : []);
87
+ const lastPage = (): boolean => !form || page >= form.pages.length - 1;
88
+
89
+ function problemText(f: FormFieldModel, type: string): string {
90
+ switch (type) {
91
+ case "required":
92
+ return f.kind === "checkbox" ? copyParam("form.errorCheck", "Check this box to continue.")
93
+ : f.kind === "choice" || f.kind === "multi" ? copyParam("form.errorChoose", "Choose an option.")
94
+ : copyParam("form.errorRequired", "Enter a value.");
95
+ case "email": return copyParam("form.errorEmail", "Enter a valid email.");
96
+ case "number": return copyParam("form.errorNumber", "Enter a number.");
97
+ case "url": return copyParam("form.errorUrl", "Enter a valid link.");
98
+ case "REQUIRED_VALUE_ERROR": return problemText(f, "required");
99
+ case "FORMAT_ERROR":
100
+ return f.kind === "email" ? problemText(f, "email") : f.kind === "phone" ? copyParam("form.errorPhone", "Enter a valid phone number.")
101
+ : f.kind === "url" ? problemText(f, "url") : copyParam("form.errorInvalid", "Check this value.");
102
+ default: return copyParam("form.errorInvalid", "Check this value.");
103
+ }
104
+ }
105
+
106
+ function dateDisplay(iso: string): string {
107
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
108
+ return m ? formatDateMedium(new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))) : iso;
109
+ }
110
+
111
+ function row(f: FormFieldModel): FieldRow {
112
+ const v = valueOf(f.target);
113
+ const error = problems[f.target] ? problemText(f, problems[f.target]) : "";
114
+ const marked = f.label ? `${f.label}${f.required ? " *" : ""}` : "";
115
+ // A checkbox's label sits beside its box, and a text block is its own text: neither has
116
+ // a label line above it.
117
+ const label = f.kind === "checkbox" || f.kind === "display" ? "" : marked;
118
+ return {
119
+ id: f.id, target: f.target,
120
+ label, hideLabel: !label, help: f.help, hideHelp: !f.help,
121
+ checkLabel: f.kind === "checkbox" ? marked : "", displayText: f.kind === "display" ? f.label : "",
122
+ placeholder: f.kind === "date" ? (f.placeholder || copyParam("form.datePlaceholder", "Choose a date")) : f.placeholder,
123
+ value: v.text, dateText: dateDisplay(v.text), hideDateValue: !v.text, hideDatePlaceholder: !!v.text, checked: v.checked,
124
+ error, noError: !error,
125
+ noErrorLine: !error || !(f.kind === "date" || f.kind === "checkbox" || f.kind === "choice" || f.kind === "multi"),
126
+ options: f.options.map((o, i) => ({ id: `${f.id}_${i}`, target: f.target, value: o.value, label: o.label, selected: v.selected.includes(o.value) })),
127
+ notText: f.kind !== "text", notArea: f.kind !== "area", notEmail: f.kind !== "email", notPhone: f.kind !== "phone",
128
+ notNumber: f.kind !== "number", notUrl: f.kind !== "url", notDate: f.kind !== "date", notCheck: f.kind !== "checkbox",
129
+ notChoice: f.kind !== "choice", notMulti: f.kind !== "multi", notDisplay: f.kind !== "display",
130
+ };
131
+ }
132
+
133
+ /** The visible state for the current form, page, values and problems. */
134
+ function view(s: S, patch: Partial<S> = {}): S {
135
+ if (!form) return { ...s, ...patch };
136
+ const pages = form.pages.length;
137
+ const fillable = !form.unsupported && !form.closed;
138
+ return {
139
+ ...s,
140
+ status: "ready",
141
+ title: form.title || copyParam("form.untitled", "Untitled form"),
142
+ rows: fillable ? pageFields().map(row) : [],
143
+ pageCaption: pages > 1 ? copyTemplate("form.pageCaption", "Page {page} of {total}", { page: String(page + 1), total: String(pages) }) : "",
144
+ noPageCaption: !fillable || pages < 2,
145
+ primaryLabel: lastPage() ? (form.submitText || copyParam("form.submitCta", "Submit")) : (form.nextText || copyParam("form.nextCta", "Next")),
146
+ backLabel: form.backText || copyParam("form.previousCta", "Back"),
147
+ hideSingle: !fillable || page > 0,
148
+ hidePair: !fillable || page === 0,
149
+ hideForm: !fillable,
150
+ hideUnsupported: !form.unsupported,
151
+ hideOpenWebsite: !siteUrl,
152
+ hideClosed: form.unsupported || !form.closed,
153
+ hideDone: true,
154
+ hideOfflineError: true, hideServerError: true,
155
+ ...presence("found"),
156
+ ...patch,
157
+ };
158
+ }
159
+
160
+ /** A signed-in member's own details by contact field, or `{}` for a visitor or a member read
161
+ * that fails. Fetched beside the form (not after it), so a member does not wait twice. */
162
+ async function memberDetails(): Promise<Record<string, string | undefined>> {
163
+ const access = await memberAccess().catch(() => ({ email: "", scope: "" }));
164
+ if (!access.email) return {};
165
+ let contact: { firstName?: string; lastName?: string; phones?: string[] } = {};
166
+ let loginEmail = access.email;
167
+ try {
168
+ const r = await getMyMember({ fieldsets: ["FULL"] }) as { member?: { loginEmail?: string; contact?: typeof contact } };
169
+ contact = r.member?.contact ?? {};
170
+ loginEmail = (r.member?.loginEmail || access.email).trim();
171
+ } catch { /* the email from the session is still the member's own */ }
172
+ return { EMAIL: loginEmail, FIRST_NAME: contact.firstName, LAST_NAME: contact.lastName, PHONE: contact.phones?.[0] };
173
+ }
174
+
175
+ /** The member's details, written into the empty fields they map to. */
176
+ function prefill(byField: Record<string, string | undefined>): void {
177
+ for (const f of allFields()) {
178
+ const known = byField[f.contactField]?.trim();
179
+ if (!known || valueOf(f.target).text) continue;
180
+ values[f.target] = { ...valueOf(f.target), text: known };
181
+ }
182
+ }
183
+
184
+ function setValue(target: string, next: Partial<FieldValue>): void {
185
+ values[target] = { ...valueOf(target), ...next };
186
+ }
187
+
188
+ function argOf(arg: unknown): Record<string, unknown> {
189
+ return arg && typeof arg === "object" ? arg as Record<string, unknown> : {};
190
+ }
191
+
192
+ function messageFor(err: unknown): Partial<S> {
193
+ const offline = (err as { code?: unknown })?.code === "offline_write_blocked" || classifyLoadError(err) === "offline";
194
+ return {
195
+ sending: false,
196
+ sendError: offline ? copyParam("form.offlineError", "Connect to the internet, then try again.") : copyParam("form.sendError", "Couldn't send the form. Try again."),
197
+ noSendError: false,
198
+ };
199
+ }
200
+
201
+ /** Keeps the problems that name a field the screen shows and moves to the first page with one;
202
+ * false when none does. A server error for a target the app does not render (a hidden field, an
203
+ * unknown path) cannot be shown in a field, so the caller says so by the button instead. */
204
+ function applyProblems(found: Record<string, string>): boolean {
205
+ const shown = new Set(allFields().map((f) => f.target));
206
+ problems = Object.fromEntries(Object.entries(found).filter(([target]) => shown.has(target)));
207
+ const targets = Object.keys(problems);
208
+ if (!targets.length || !form) return false;
209
+ const pageWithProblem = form.pages.findIndex((ids) => ids.some((id) => targets.includes(form!.fields[id]?.target ?? "")));
210
+ if (pageWithProblem >= 0) page = pageWithProblem;
211
+ return true;
212
+ }
213
+
214
+ /** A send in flight is numbered; a later load or send makes an earlier answer stale. */
215
+ let sendVersion = 0;
216
+
217
+ type SendRequest = Parameters<typeof createSubmission>[0];
218
+
219
+ /** Wix's own checks first: they answer per field and create nothing. A check that cannot
220
+ * run (offline, an outage) is not a reason to block the send. Then the send itself,
221
+ * opted into the offline outbox. */
222
+ async function send(version: number, request: SendRequest): Promise<void> {
223
+ let outcome: Record<string, unknown>;
224
+ try {
225
+ const check = await validateFormSubmission(request).catch(() => undefined);
226
+ const found = check && check.valid === false ? serverProblems(check) : {};
227
+ if (Object.keys(found).length) outcome = { outcome: "problems", problems: found };
228
+ else {
229
+ const sent = await createSubmission(request, { offline: "queue" });
230
+ outcome = { outcome: sent.status === "queued" ? "queued" : "sent" };
231
+ }
232
+ } catch (err) {
233
+ const found = serverProblems(err);
234
+ outcome = Object.keys(found).length
235
+ ? { outcome: "problems", problems: found }
236
+ : { outcome: "failed", error: { code: (err as { code?: unknown })?.code, message: String((err as Error)?.message ?? err) } };
237
+ }
238
+ await svc.finishSend({ version, ...outcome });
239
+ }
240
+
241
+ /** The form was sent (or queued): its done state outlives a repeat onAppear load. */
242
+ let completed = false;
243
+ let inflight: Promise<S> | undefined;
244
+ let inflightId = "";
245
+
246
+ async function loadForm(id: string): Promise<S> {
247
+ form = undefined; values = {}; page = 0; problems = {}; sendVersion += 1; completed = false;
248
+ if (!id) return { ...BASE, status: "ready", ...presence("missing") };
249
+ const member = memberDetails();
250
+ try {
251
+ const res = await getForm(id);
252
+ const parsed = parseForm(res);
253
+ if (!parsed.id || (res as { form?: { enabled?: boolean } }).form?.enabled === false) return { ...BASE, status: "ready", ...presence("missing") };
254
+ form = parsed;
255
+ prefill(await member);
256
+ return view({ ...BASE });
257
+ } catch (err) {
258
+ const p = presenceAfterError(err);
259
+ if (p.notFound) return { ...BASE, status: "ready", ...p };
260
+ const offline = classifyLoadError(err) === "offline";
261
+ return { ...BASE, status: "ready", ...p, hideLoadError: true, hideOfflineError: !offline, hideServerError: offline };
262
+ }
263
+ }
264
+
265
+ const svc = defineService<S, {
266
+ load: (s: S, arg?: unknown) => Promise<S>;
267
+ setText: (s: S, arg?: unknown) => S;
268
+ toggleCheck: (s: S, arg?: unknown) => S;
269
+ choose: (s: S, arg?: unknown) => S;
270
+ toggleOption: (s: S, arg?: unknown) => S;
271
+ openDate: (s: S, arg?: unknown) => S;
272
+ draftDate: (s: S, arg?: unknown) => S;
273
+ applyDate: (s: S) => S;
274
+ closeDate: (s: S) => S;
275
+ next: (s: S) => S;
276
+ finishSend: (s: S, arg?: unknown) => S;
277
+ back: (s: S) => S;
278
+ openWebsite: (s: S) => Promise<S>;
279
+ }>("himiBrandedForm", {
280
+ state: BASE,
281
+ actions: {
282
+ load: async (s, arg) => {
283
+ stashCopy(arg);
284
+ const a = argOf(arg);
285
+ if (typeof a.siteUrl === "string") siteUrl = a.siteUrl.trim();
286
+ const id = detailInputId();
287
+ // The boot load and the descriptor's onAppear both ask for the same form, and onAppear fires
288
+ // again when the screen comes back (a sheet or the in-app browser closes). The same form id
289
+ // keeps what the visitor typed and the page they are on; only a new id, or a retry after a
290
+ // failure (no form held), starts over.
291
+ // A form already sent keeps its thank-you state: re-opening the editable form would invite
292
+ // a second submission.
293
+ if (form && id && form.id === id) return completed ? s : view(s);
294
+ if (inflight && inflightId === id) return inflight;
295
+ inflightId = id;
296
+ inflight = loadForm(id);
297
+ try { return await inflight; } finally { inflight = undefined; }
298
+ },
299
+ // Keystrokes: the value lives in the worker; the state only changes to clear an error.
300
+ setText: (s, arg) => {
301
+ const a = argOf(arg);
302
+ const target = String(a.target ?? "");
303
+ const text = typeof a.value === "string" ? a.value : typeof a.text === "string" ? a.text : "";
304
+ if (!target) return s;
305
+ setValue(target, { text });
306
+ if (!problems[target]) return s;
307
+ delete problems[target];
308
+ return view(s);
309
+ },
310
+ toggleCheck: (s, arg) => {
311
+ const a = argOf(arg);
312
+ const target = String(a.target ?? "");
313
+ if (!target) return s;
314
+ setValue(target, { checked: typeof a.value === "boolean" ? a.value : !valueOf(target).checked });
315
+ delete problems[target];
316
+ return view(s);
317
+ },
318
+ choose: (s, arg) => {
319
+ const a = argOf(arg);
320
+ const target = String(a.target ?? ""), value = String(a.option ?? "");
321
+ if (!target || !value) return s;
322
+ setValue(target, { selected: [value] });
323
+ delete problems[target];
324
+ return view(s);
325
+ },
326
+ toggleOption: (s, arg) => {
327
+ const a = argOf(arg);
328
+ const target = String(a.target ?? ""), value = String(a.option ?? "");
329
+ if (!target || !value) return s;
330
+ const sel = valueOf(target).selected;
331
+ setValue(target, { selected: sel.includes(value) ? sel.filter((x) => x !== value) : [...sel, value] });
332
+ delete problems[target];
333
+ return view(s);
334
+ },
335
+ openDate: (s, arg) => {
336
+ const target = String(argOf(arg).target ?? "");
337
+ const f = allFields().find((x) => x.target === target);
338
+ if (!f || s.sending) return s;
339
+ dateTarget = target;
340
+ datePicked = false;
341
+ const iso = valueOf(target).text;
342
+ dateDraft = /^\d{4}-\d{2}-\d{2}$/.test(iso) ? Math.floor(new Date(`${iso}T12:00:00`).getTime() / 1000) : Math.floor(Date.now() / 1000);
343
+ return { ...s, dateSheetOpen: true, dateSheetTitle: f.label, dateDraft };
344
+ },
345
+ draftDate: (s, arg) => {
346
+ const v = Number(argOf(arg).value);
347
+ if (!Number.isFinite(v) || v <= 0) return s;
348
+ dateDraft = v;
349
+ datePicked = true;
350
+ return { ...s, dateDraft };
351
+ },
352
+ applyDate: (s) => {
353
+ if (dateTarget && dateDraft && datePicked) { setValue(dateTarget, { text: isoDate(dateDraft) }); delete problems[dateTarget]; }
354
+ return view({ ...s, dateSheetOpen: false });
355
+ },
356
+ closeDate: (s) => ({ ...s, dateSheetOpen: false }),
357
+ back: (s) => {
358
+ if (page === 0 || s.sending) return s;
359
+ page -= 1;
360
+ return view(s, { noSendError: true, sendError: "" });
361
+ },
362
+ // "Next" on an earlier page, "Submit" on the last one.
363
+ next: (s) => {
364
+ if (!form || s.sending) return s;
365
+ const local = localProblems(pageFields(), values);
366
+ if (applyProblems(local)) return view(s, { noSendError: true, sendError: "" });
367
+ if (!lastPage()) { page += 1; problems = {}; return view(s, { noSendError: true, sendError: "" }); }
368
+ const all = allFields();
369
+ const allLocal = localProblems(all, values);
370
+ if (applyProblems(allLocal)) return view(s, { noSendError: true, sendError: "" });
371
+ const version = ++sendVersion;
372
+ void send(version, { submission: { formId: form.id, submissions: submissionValues(all, values) } });
373
+ return { ...s, sending: true, noSendError: true, sendError: "" };
374
+ },
375
+ finishSend: (s, arg) => {
376
+ const r = argOf(arg) as { version?: unknown; outcome?: unknown; problems?: unknown; error?: unknown };
377
+ if (r.version !== sendVersion || !form) return s;
378
+ if (r.outcome === "problems") {
379
+ if (applyProblems(r.problems as Record<string, string>)) return view(s, { sending: false, noSendError: true, sendError: "" });
380
+ return view(s, { sending: false, noSendError: false, sendError: copyParam("form.checkError", "Some answers can't be sent. Check the form and try again.") });
381
+ }
382
+ if (r.outcome === "failed") return view(s, messageFor(r.error));
383
+ const queued = r.outcome === "queued";
384
+ completed = true;
385
+ return view(s, {
386
+ sending: false, hideForm: true, hideSingle: true, hidePair: true, hideDone: false, rows: [], noPageCaption: true,
387
+ doneTitle: queued ? copyParam("form.queuedTitle", "Form saved") : copyParam("form.sentTitle", "Thank you"),
388
+ doneBody: queued ? copyParam("form.queuedBody", "We'll send your form when you're back online.")
389
+ : (form.thankYou || copyParam("form.sentBody", "Thanks, we received your submission.")),
390
+ });
391
+ },
392
+ openWebsite: async (s) => {
393
+ if (siteUrl) { try { await openUrl(siteUrl, "in_app_browser"); } catch { /* the handoff is best-effort */ } }
394
+ return s;
395
+ },
396
+ },
397
+ });
398
+ svc.load();
399
+ export { svc };
@@ -5,8 +5,11 @@
5
5
  // visitor) and an empty one is a real empty state — never sample rows presented as
6
6
  // the customer's own. Samples appear only in the host's explicit sample preview
7
7
  // (_shared/branded-load-state.ts).
8
+ import { classifyLoadError } from "@himalaya/state";
8
9
  import { defineService } from "../_shared/collection-service.js";
9
10
  import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
11
+ import { copyParam, stashCopy } from "../_shared/himi-copy.js";
12
+ import { hasInputFields } from "../_shared/branded-form.js";
10
13
  import { queryForms, type WixForm } from "@himalaya/wix-forms";
11
14
 
12
15
  interface Row { id: string; title: string; subtitle: string }
@@ -17,6 +20,9 @@ interface S extends LoadFlags {
17
20
  * query failed. A failed query must never render the sentence that claims the
18
21
  * customer has no content. */
19
22
  notMockEmpty: boolean; notMockUnavailable: boolean;
23
+ /** The list's own states (the screen has no collection chrome): EmptyState only for a
24
+ * real empty answer, one ErrorState per failure class. */
25
+ noEmpty: boolean; hideOfflineError: boolean; hideServerError: boolean; hideSignInError: boolean;
20
26
  }
21
27
 
22
28
  /** The Wix Forms namespace the Forms app owns. Booking/event registration forms live
@@ -24,7 +30,7 @@ interface S extends LoadFlags {
24
30
  const FORMS_NAMESPACE = "wix.form_app.form";
25
31
 
26
32
  function norm(f: WixForm): Row {
27
- const title = (f.name || "Form").trim() || "Form";
33
+ const title = (f.name || "").trim() || copyParam("forms.untitled", "Untitled form");
28
34
  // The subtitle used to be "5 fields · Published" — a form-builder metric and a publishing
29
35
  // status, both back-office facts ([branded-lite-qa] #3077). A visitor sees the form's own
30
36
  // description when it has one, and nothing otherwise.
@@ -49,18 +55,27 @@ const COPY: LoadCopy<Row> = {
49
55
  sample: () => MOCK.map((r) => ({ ...r })),
50
56
  };
51
57
 
52
- const svc = defineService<S, { load: (s: S) => Promise<S> }>("himiBrandedForms", {
53
- state: { ...LOADING, forms: [], emptyText: "" },
58
+ const NO_STATES = { noEmpty: true, hideOfflineError: true, hideServerError: true, hideSignInError: true };
59
+
60
+ const svc = defineService<S, { load: (s: S, arg?: unknown) => Promise<S> }>("himiBrandedForms", {
61
+ state: { ...LOADING, forms: [], emptyText: "", ...NO_STATES },
54
62
  actions: {
55
- load: async (s) => {
63
+ load: async (s, arg) => {
64
+ stashCopy(arg);
56
65
  try {
57
66
  const r = await queryForms({ filter: { namespace: FORMS_NAMESPACE }, paging: { limit: 50 } });
58
67
  // A visitor can only fill a live form: a disabled or draft one used to be listed with a
59
68
  // "Disabled"/"Draft" label; now that the row carries no back-office status, it is not listed.
60
- const forms = (r.forms ?? []).filter((f) => f.enabled !== false && !/^DRAFT$/i.test(String(f.status ?? ""))).map(norm);
61
- return { ...s, ...loaded("forms", forms, COPY) };
69
+ const forms = (r.forms ?? [])
70
+ .filter((f) => f.enabled !== false && !/^DRAFT$/i.test(String(f.status ?? "")) && hasInputFields(f))
71
+ .map(norm);
72
+ const next = loaded("forms", forms, COPY);
73
+ return { ...s, ...next, ...NO_STATES, noEmpty: next.forms.length > 0 || next.isMock };
62
74
  } catch (err) {
63
- return { ...s, ...loadFailed("forms", err, COPY) };
75
+ const next = loadFailed("forms", err, COPY);
76
+ // One state per class: offline and server failures offer Retry; a refused visitor offers Sign in.
77
+ const kind = next.signInRequired ? "signIn" : classifyLoadError(err) === "offline" ? "offline" : "server";
78
+ return { ...s, ...next, ...NO_STATES, hideOfflineError: next.isMock || kind !== "offline", hideServerError: next.isMock || kind !== "server", hideSignInError: next.isMock || kind !== "signIn" };
64
79
  }
65
80
  },
66
81
  },
@@ -481,7 +481,7 @@ function applePurchase(state: S): IapPurchaseFields {
481
481
  const id = inputId();
482
482
  const loaded = !!id && lastLoadedId === id;
483
483
  const sellable = SELLS_THROUGH_APPLE && loaded && lastProduct?.productType?.toLowerCase() === "digital" && !state.optionsUnknown && state.optionGroups.length === 0;
484
- const copy = { ...iapPurchaseCopy("product"), buy: copyParam("product.buyCta", "Buy") };
484
+ const copy = iapPurchaseCopy("product");
485
485
  const fields = sellable ? productPricing.purchaseFields({ kind: "storesProduct", id }, copy) : NOT_APPLE;
486
486
  return fields.appleRoute ? fields : NOT_APPLE;
487
487
  }
@@ -38,6 +38,19 @@ export default defineApp({
38
38
  sync: "http://localhost:8190/v1/sync",
39
39
  },
40
40
  capabilities: ["live_activity","push","share","secure_storage","open_url","calendar","local_database","wix_api","wix_platform","app_info","iap","release_control","clipboard","theme"],
41
+ cache: {
42
+ "rules": [
43
+ {
44
+ "method": "POST",
45
+ "pathPrefix": "/form-submission-service/v4/submissions",
46
+ "cacheable": false,
47
+ "ttl": 0,
48
+ "swr": 0,
49
+ "tag": "forms",
50
+ "queueOffline": true
51
+ }
52
+ ]
53
+ },
41
54
  designTokensPath: "./tokens.json",
42
55
  navigation: {
43
56
  "navKind": "responsive",
@@ -111,7 +111,7 @@ export function createAttendancePurchase(options: { kind: "bookingsService" | "e
111
111
  const classified = classifyForApple(metadata, currency);
112
112
  const candidate = platform === "ios" && classified === "appleIap";
113
113
  const price = iapPriceFields(commerce.pricing.view(reference), iapPriceCopy(prefix));
114
- const copy = { ...iapPurchaseCopy(prefix), buy: copyParam(`${prefix}.appleBuyCta`, options.kind === "bookingsService" ? "Book with Apple" : "Buy ticket with Apple"), buyAnother: copyParam(`${prefix}.buyAnotherCta`, options.kind === "bookingsService" ? "Book again" : "Buy another ticket") };
114
+ const copy = iapPurchaseCopy(prefix);
115
115
  const purchase = commerce.purchaseFields(reference, copy);
116
116
  const apple = candidate || (platform === "ios" && classified !== "wixPayments" && purchase.appleRoute);
117
117
  const enabled = attendanceCheckoutRoute(metadata, currency, platform) === "appleIap";
@@ -20,7 +20,7 @@ export function iapPriceCopy(prefix: string): IapPriceCopy {
20
20
  * `iapPurchaseFields`. Customer copy only: no backend names, ids or diagnostics. */
21
21
  export function iapPurchaseCopy(prefix: string): IapPurchaseCopy {
22
22
  return {
23
- buy: copyParam(`${prefix}.buyCta`, "Get access"),
23
+ buy: copyParam(`${prefix}.buyCta`, "Buy now"),
24
24
  processing: copyParam(`${prefix}.purchaseProcessingText`, "Processing your purchase…"),
25
25
  awaitingApproval: copyParam(`${prefix}.purchaseAwaitingApprovalText`, "Waiting for approval"),
26
26
  unavailable: copyParam(`${prefix}.purchaseTemporarilyUnavailableText`, "Purchasing is temporarily unavailable."),
@@ -38,6 +38,19 @@ export default defineApp({
38
38
  sync: "http://localhost:8190/v1/sync",
39
39
  },
40
40
  capabilities: ["live_activity","push","share","secure_storage","open_url","calendar","local_database","wix_api","wix_platform","app_info","iap","release_control","clipboard","theme"],
41
+ cache: {
42
+ "rules": [
43
+ {
44
+ "method": "POST",
45
+ "pathPrefix": "/form-submission-service/v4/submissions",
46
+ "cacheable": false,
47
+ "ttl": 0,
48
+ "swr": 0,
49
+ "tag": "forms",
50
+ "queueOffline": true
51
+ }
52
+ ]
53
+ },
41
54
  designTokensPath: "./tokens.json",
42
55
  navigation: {
43
56
  "navKind": "responsive",
@@ -111,7 +111,7 @@ export function createAttendancePurchase(options: { kind: "bookingsService" | "e
111
111
  const classified = classifyForApple(metadata, currency);
112
112
  const candidate = platform === "ios" && classified === "appleIap";
113
113
  const price = iapPriceFields(commerce.pricing.view(reference), iapPriceCopy(prefix));
114
- const copy = { ...iapPurchaseCopy(prefix), buy: copyParam(`${prefix}.appleBuyCta`, options.kind === "bookingsService" ? "Book with Apple" : "Buy ticket with Apple"), buyAnother: copyParam(`${prefix}.buyAnotherCta`, options.kind === "bookingsService" ? "Book again" : "Buy another ticket") };
114
+ const copy = iapPurchaseCopy(prefix);
115
115
  const purchase = commerce.purchaseFields(reference, copy);
116
116
  const apple = candidate || (platform === "ios" && classified !== "wixPayments" && purchase.appleRoute);
117
117
  const enabled = attendanceCheckoutRoute(metadata, currency, platform) === "appleIap";
@@ -20,7 +20,7 @@ export function iapPriceCopy(prefix: string): IapPriceCopy {
20
20
  * `iapPurchaseFields`. Customer copy only: no backend names, ids or diagnostics. */
21
21
  export function iapPurchaseCopy(prefix: string): IapPurchaseCopy {
22
22
  return {
23
- buy: copyParam(`${prefix}.buyCta`, "Get access"),
23
+ buy: copyParam(`${prefix}.buyCta`, "Buy now"),
24
24
  processing: copyParam(`${prefix}.purchaseProcessingText`, "Processing your purchase…"),
25
25
  awaitingApproval: copyParam(`${prefix}.purchaseAwaitingApprovalText`, "Waiting for approval"),
26
26
  unavailable: copyParam(`${prefix}.purchaseTemporarilyUnavailableText`, "Purchasing is temporarily unavailable."),
@@ -38,6 +38,19 @@ export default defineApp({
38
38
  sync: "http://localhost:8190/v1/sync",
39
39
  },
40
40
  capabilities: ["live_activity","push","share","secure_storage","open_url","calendar","local_database","wix_api","wix_platform","app_info","iap","release_control","clipboard","motion","theme"],
41
+ cache: {
42
+ "rules": [
43
+ {
44
+ "method": "POST",
45
+ "pathPrefix": "/form-submission-service/v4/submissions",
46
+ "cacheable": false,
47
+ "ttl": 0,
48
+ "swr": 0,
49
+ "tag": "forms",
50
+ "queueOffline": true
51
+ }
52
+ ]
53
+ },
41
54
  designTokensPath: "./tokens.json",
42
55
  navigation: {
43
56
  "navKind": "responsive",
@@ -111,7 +111,7 @@ export function createAttendancePurchase(options: { kind: "bookingsService" | "e
111
111
  const classified = classifyForApple(metadata, currency);
112
112
  const candidate = platform === "ios" && classified === "appleIap";
113
113
  const price = iapPriceFields(commerce.pricing.view(reference), iapPriceCopy(prefix));
114
- const copy = { ...iapPurchaseCopy(prefix), buy: copyParam(`${prefix}.appleBuyCta`, options.kind === "bookingsService" ? "Book with Apple" : "Buy ticket with Apple"), buyAnother: copyParam(`${prefix}.buyAnotherCta`, options.kind === "bookingsService" ? "Book again" : "Buy another ticket") };
114
+ const copy = iapPurchaseCopy(prefix);
115
115
  const purchase = commerce.purchaseFields(reference, copy);
116
116
  const apple = candidate || (platform === "ios" && classified !== "wixPayments" && purchase.appleRoute);
117
117
  const enabled = attendanceCheckoutRoute(metadata, currency, platform) === "appleIap";
@@ -20,7 +20,7 @@ export function iapPriceCopy(prefix: string): IapPriceCopy {
20
20
  * `iapPurchaseFields`. Customer copy only: no backend names, ids or diagnostics. */
21
21
  export function iapPurchaseCopy(prefix: string): IapPurchaseCopy {
22
22
  return {
23
- buy: copyParam(`${prefix}.buyCta`, "Get access"),
23
+ buy: copyParam(`${prefix}.buyCta`, "Buy now"),
24
24
  processing: copyParam(`${prefix}.purchaseProcessingText`, "Processing your purchase…"),
25
25
  awaitingApproval: copyParam(`${prefix}.purchaseAwaitingApprovalText`, "Waiting for approval"),
26
26
  unavailable: copyParam(`${prefix}.purchaseTemporarilyUnavailableText`, "Purchasing is temporarily unavailable."),
@@ -490,7 +490,7 @@ function applePurchase(state: S): IapPurchaseFields {
490
490
  const id = inputId();
491
491
  const loaded = !!id && lastLoadedId === id;
492
492
  const sellable = SELLS_THROUGH_APPLE && loaded && lastProduct?.productType?.toLowerCase() === "digital" && !state.optionsUnknown && state.optionGroups.length === 0;
493
- const copy = { ...iapPurchaseCopy("product"), buy: copyParam("product.buyCta", "Buy") };
493
+ const copy = iapPurchaseCopy("product");
494
494
  const fields = sellable ? productPricing.purchaseFields({ kind: "storesProduct", id }, copy) : NOT_APPLE;
495
495
  return fields.appleRoute ? fields : NOT_APPLE;
496
496
  }
@@ -38,6 +38,19 @@ export default defineApp({
38
38
  sync: "http://localhost:8190/v1/sync",
39
39
  },
40
40
  capabilities: ["live_activity","push","share","secure_storage","open_url","calendar","local_database","wix_api","wix_platform","app_info","iap","release_control","clipboard","theme"],
41
+ cache: {
42
+ "rules": [
43
+ {
44
+ "method": "POST",
45
+ "pathPrefix": "/form-submission-service/v4/submissions",
46
+ "cacheable": false,
47
+ "ttl": 0,
48
+ "swr": 0,
49
+ "tag": "forms",
50
+ "queueOffline": true
51
+ }
52
+ ]
53
+ },
41
54
  designTokensPath: "./tokens.json",
42
55
  navigation: {
43
56
  "navKind": "responsive",
@@ -111,7 +111,7 @@ export function createAttendancePurchase(options: { kind: "bookingsService" | "e
111
111
  const classified = classifyForApple(metadata, currency);
112
112
  const candidate = platform === "ios" && classified === "appleIap";
113
113
  const price = iapPriceFields(commerce.pricing.view(reference), iapPriceCopy(prefix));
114
- const copy = { ...iapPurchaseCopy(prefix), buy: copyParam(`${prefix}.appleBuyCta`, options.kind === "bookingsService" ? "Book with Apple" : "Buy ticket with Apple"), buyAnother: copyParam(`${prefix}.buyAnotherCta`, options.kind === "bookingsService" ? "Book again" : "Buy another ticket") };
114
+ const copy = iapPurchaseCopy(prefix);
115
115
  const purchase = commerce.purchaseFields(reference, copy);
116
116
  const apple = candidate || (platform === "ios" && classified !== "wixPayments" && purchase.appleRoute);
117
117
  const enabled = attendanceCheckoutRoute(metadata, currency, platform) === "appleIap";