@wix/himalaya-cli 0.1614.0 → 0.1616.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 (106) hide show
  1. package/dist/cli.mjs +2 -0
  2. package/package.json +2 -2
  3. package/template-icons/salon-spa.png +0 -0
  4. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  5. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  6. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  7. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  8. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  9. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  10. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  11. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  12. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  13. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  14. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  15. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  16. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  18. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  19. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  20. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  21. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  22. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  23. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  24. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  25. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  26. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  27. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  28. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  29. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  30. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  31. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  32. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  33. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  34. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  35. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  36. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  39. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  40. package/templates/salon-spa/README.md +53 -0
  41. package/templates/salon-spa/config.ts +272 -0
  42. package/templates/salon-spa/dev/index.ts +19893 -0
  43. package/templates/salon-spa/himalaya.content.json +49 -0
  44. package/templates/salon-spa/himalaya.template.json +14 -0
  45. package/templates/salon-spa/icon.png +0 -0
  46. package/templates/salon-spa/platforms.json +1 -0
  47. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  48. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  49. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  50. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  51. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  52. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  53. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  54. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  55. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  56. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  57. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  58. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  59. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  60. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  61. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  62. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  63. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  64. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  65. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  66. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  67. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  68. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  69. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  70. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  71. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  72. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  73. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  74. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  75. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  76. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  77. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  78. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  79. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  80. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  81. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  82. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  83. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  84. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  85. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  86. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  87. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  88. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  89. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  90. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  91. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  92. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  93. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  94. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  95. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  96. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  97. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  98. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  99. package/templates/salon-spa/tokens.json +341 -0
  100. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  101. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  102. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  103. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  104. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  105. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  106. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
@@ -0,0 +1,70 @@
1
+ // Hosted checkout. A Branded Lite hosted in a host shell (Spaces) pays as the member the host
2
+ // lent it: Headless `createRedirectSession` does not take a Member instance, so the HOST opens the site's
3
+ // checkout signed in as that member. The worker names only its ecom checkout; the host reads Wix's
4
+ // checkout URL, mints a one-time member session and opens the site's `createSessionCookie` → checkout.
5
+ // The session token never reaches this worker.
6
+ //
7
+ // Classic Wix Events tickets and classic Pricing Plans have no ecom checkout: for those the
8
+ // worker names the page by id (`himi.host.memberSitePage`) and the host opens the site's own event or
9
+ // plans page the same way, signed in — where the member picks and pays on the site. A member's orders
10
+ // are the same (Wix lists them to no member identity), so My orders opens the site's own page too.
11
+
12
+ import { dispatch } from "@himalaya/dispatch";
13
+ import { hostSiteFromInput } from "./branded-lite-hosted.js";
14
+
15
+ /** The host verbs (registered by the host shell, never by the app itself). The first prepares the
16
+ * checkout and answers a one-time ticket; the second opens it. Two steps so the caller's charge guard
17
+ * is re-read after the host's network round trips and right before the checkout opens — the prepared
18
+ * URL (which carries the member's session token) stays in the host; only the ticket crosses here. */
19
+ export const HOSTED_CHECKOUT_ACTION = "himi.host.memberCheckout";
20
+ export const HOSTED_CHECKOUT_OPEN_ACTION = "himi.host.memberCheckout.open";
21
+ /** Prepares a site PAGE (not an ecom checkout) the same way; opened with [HOSTED_CHECKOUT_OPEN_ACTION]. */
22
+ export const HOSTED_SITE_PAGE_ACTION = "himi.host.memberSitePage";
23
+
24
+ /** A site page a hosted worker may ask its host for — ids only, never a URL: the host reads the URL
25
+ * from Wix itself and decides whether the member may be signed in there. */
26
+ export type HostedSitePage = { page: "event"; eventId: string } | { page: "plans"; planId?: string } | { page: "orders" };
27
+
28
+ /** Whether this worker runs inside a host that opened a site for it. */
29
+ export function isHostedWorker(): boolean {
30
+ return hostSiteFromInput((globalThis as { himalaya?: { input?: unknown } }).himalaya?.input) !== null;
31
+ }
32
+
33
+ type HostAnswer = { opened?: unknown; ticket?: unknown; payload?: { opened?: unknown; ticket?: unknown } } | null;
34
+ type HostOpener = (stillMayCharge: () => Promise<boolean>) => Promise<boolean | "withdrawn">;
35
+
36
+ /** Prepare with [action] [params] → re-read the guard → open by ticket. A host without the verbs, or one
37
+ * that refused (Wix named no page, an unsafe URL, no account session), → false, never a throw. */
38
+ function hostOpener(action: string, params: Record<string, string>): HostOpener {
39
+ return async (stillMayCharge) => {
40
+ try {
41
+ const prepared = await dispatch(action, params) as HostAnswer;
42
+ const ticket = prepared?.ticket ?? prepared?.payload?.ticket;
43
+ if (typeof ticket !== "string" || !ticket) return false;
44
+ // An unopened ticket simply expires in the host: nothing to cancel.
45
+ if (!(await stillMayCharge())) return "withdrawn";
46
+ const result = await dispatch(HOSTED_CHECKOUT_OPEN_ACTION, { ticket }) as HostAnswer;
47
+ const opened = result?.opened ?? result?.payload?.opened;
48
+ return opened === true || opened === "true";
49
+ } catch {
50
+ return false;
51
+ }
52
+ };
53
+ }
54
+
55
+ /** `payCashierOrder`'s `openCashier` for a hosted app: the host prepares [checkoutId]'s checkout as the
56
+ * lent member, the caller's guard is re-read, and only then does the host open it. Resolves whether it
57
+ * opened ("withdrawn" when the guard said no); a host without the verbs (or that refused) → false. */
58
+ export function hostedCheckoutOpener(checkoutId: string): HostOpener {
59
+ return hostOpener(HOSTED_CHECKOUT_ACTION, { checkoutId });
60
+ }
61
+
62
+ /** The site's own [page] opened by the host, signed in as the lent member where the host deems it safe
63
+ * (a visitor otherwise) — the same two steps and answers as [hostedCheckoutOpener]. */
64
+ export function hostedSitePageOpener(page: HostedSitePage): HostOpener {
65
+ const params: Record<string, string> = page.page === "event"
66
+ ? { page: "event", eventId: page.eventId }
67
+ : page.page === "orders" ? { page: "orders" }
68
+ : { page: "plans", ...(page.planId ? { planId: page.planId } : {}) };
69
+ return hostOpener(HOSTED_SITE_PAGE_ACTION, params);
70
+ }
@@ -0,0 +1,106 @@
1
+ import { copyParam } from "./himi-copy.js";
2
+
3
+ export interface AttendanceField {
4
+ id: string;
5
+ label: string;
6
+ kind: string;
7
+ required: boolean;
8
+ value: string;
9
+ selectedIds: string[];
10
+ options: { id: string; label: string }[];
11
+ error: string;
12
+ maxLength?: number;
13
+ maxSize?: number;
14
+ }
15
+
16
+ const object = (v: unknown): Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
17
+ const text = (v: unknown): string => typeof v === "string" ? v : "";
18
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
19
+
20
+ export function createAttendanceForm(prefix: string) {
21
+ let fields: AttendanceField[] = [];
22
+ let supported = false;
23
+ const label = (name: string) => copyParam(`${prefix}.${name}Label`, name === "firstName" ? "First name" : name === "lastName" ? "Last name" : "Email");
24
+ const field = (scope: string, name: string, input: Record<string, unknown> = {}): AttendanceField => ({
25
+ id: `${scope}:${name}`, label: text(input.label) || label(name), required: input.mandatory === true,
26
+ kind: name === "email" ? "email" : input.type === "NUMBER" ? "number" : input.type === "TEXT_ARRAY" ? "multiple" : Array.isArray(input.options) && input.options.length ? "choice" : "text",
27
+ value: "", selectedIds: [], error: "",
28
+ options: Array.isArray(input.options) ? input.options.filter((o): o is string => typeof o === "string").map((o) => ({ id: o, label: o })) : [],
29
+ ...(typeof input.maxLength === "number" ? { maxLength: input.maxLength } : {}),
30
+ ...(typeof input.maxSize === "number" ? { maxSize: input.maxSize } : {}),
31
+ });
32
+ const contact = (scope: string) => ["firstName", "lastName", "email"].map((name) => field(scope, name, { mandatory: true }));
33
+
34
+ function event(raw: unknown): void {
35
+ fields = contact("buyer");
36
+ const controls = object(raw).controls;
37
+ supported = Array.isArray(controls) && controls.length > 0;
38
+ const names = new Set<string>();
39
+ for (const control of Array.isArray(controls) ? controls : []) {
40
+ const c = object(control);
41
+ if (c.deleted === true) continue;
42
+ if (!Array.isArray(c.inputs) || !c.inputs.length) { supported = false; continue; }
43
+ for (const rawInput of c.inputs) {
44
+ const input = object(rawInput);
45
+ const name = text(input.name);
46
+ if (!name || names.has(name) || !["TEXT", "NUMBER", "TEXT_ARRAY"].includes(text(input.type)) || (input.type === "TEXT_ARRAY" && (!Array.isArray(input.options) || !input.options.length))) { supported = false; continue; }
47
+ names.add(name);
48
+ fields.push(field("guest", name, { ...input, mandatory: input.mandatory === true || ["firstName", "lastName", "email"].includes(name) }));
49
+ }
50
+ }
51
+ if (!["firstName", "lastName", "email"].every((name) => names.has(name))) supported = false;
52
+ }
53
+
54
+ function booking(service: unknown): void {
55
+ fields = contact("buyer");
56
+ const id = text(object(object(service).form).id);
57
+ // Custom intake schemas need their own reader; never silently omit their required fields.
58
+ supported = id === "00000000-0000-0000-0000-000000000000";
59
+ }
60
+
61
+ function change(arg: unknown): void {
62
+ const a = object(arg);
63
+ const f = fields.find((candidate) => candidate.id === a.id);
64
+ if (!f) return;
65
+ if (typeof a.option === "string") {
66
+ if (f.kind === "multiple") f.selectedIds = f.selectedIds.includes(a.option) ? f.selectedIds.filter((v) => v !== a.option) : [...f.selectedIds, a.option];
67
+ else f.value = a.option;
68
+ f.error = "";
69
+ return;
70
+ }
71
+ f.value = text(a.value ?? a.text ?? a.selectedId);
72
+ f.selectedIds = Array.isArray(a.selectedIds) ? a.selectedIds.filter((v): v is string => typeof v === "string") : [];
73
+ f.error = "";
74
+ }
75
+
76
+ function valid(f: AttendanceField): boolean {
77
+ const value = f.value.trim();
78
+ const values = f.kind === "multiple" ? f.selectedIds : value ? [value] : [];
79
+ if (!values.length) return !f.required;
80
+ if (f.options.length && values.some((v) => !f.options.some((o) => o.id === v))) return false;
81
+ if (f.maxSize !== undefined && values.length > f.maxSize) return false;
82
+ if (f.maxLength !== undefined && values.some((v) => v.length > f.maxLength!)) return false;
83
+ return f.kind === "email" ? EMAIL.test(value) : f.kind !== "number" || /^-?\d+(?:\.\d+)?$/.test(value);
84
+ }
85
+
86
+ function validate(): boolean {
87
+ for (const f of fields) f.error = valid(f) ? "" : copyParam(`${prefix}.formInvalidText`, "Check this field.");
88
+ return supported && fields.every((f) => !f.error);
89
+ }
90
+
91
+ function buyer(): Record<string, string> {
92
+ return Object.fromEntries(fields.filter((f) => f.id.startsWith("buyer:")).map((f) => [f.id.slice(6), f.value.trim()]));
93
+ }
94
+
95
+ function ticket(definitionId: string) {
96
+ const inputValues = fields.filter((f) => f.id.startsWith("guest:") && (f.value.trim() || f.selectedIds.length)).map((f) => ({ inputName: f.id.slice(6), ...(f.kind === "multiple" ? { values: f.selectedIds.slice() } : { value: f.value.trim() }) }));
97
+ return { buyer: buyer(), guests: [{ ticketDefinitionId: definitionId, form: { inputValues } }] };
98
+ }
99
+
100
+ return {
101
+ event, booking, change, validate, buyer, ticket,
102
+ supported: () => supported,
103
+ rows: () => fields.map((f) => ({ ...f, selectedIds: f.selectedIds.slice(), kind: f.kind === "text" && f.required ? "requiredText" : f.kind })),
104
+ clear: () => { for (const f of fields) { f.value = ""; f.selectedIds = []; f.error = ""; } },
105
+ };
106
+ }
@@ -0,0 +1,211 @@
1
+ import { attendanceCheckoutRoute, availableTicketAttendanceOf, bookingAttendanceOf, classifyForApple, hostSessionFromGlobal, iapPriceFields, type BookingAttendance, type CatalogReference, type IapPollDirective, type TicketAttendance } from "@himalaya/iap";
2
+ import { bookingOrders, createIapCommerce, eventTicketOrders, type IapOrderItem } from "@himalaya/iap-checkout";
3
+ import { copyParam, copyTemplate } from "./himi-copy.js";
4
+ import { createAttendanceForm, type AttendanceField } from "./iap-attendance-form.js";
5
+ import { attendancePlatform } from "./iap-attendance.js";
6
+ import { iapPriceCopy, iapPurchaseCopy } from "./iap-worker.js";
7
+
8
+ export interface AttendancePurchaseFields {
9
+ iapBuyHidden: boolean;
10
+ iapBuyDisabled: boolean;
11
+ iapBuyLabel: string;
12
+ iapBusy: boolean;
13
+ iapPrice: string;
14
+ iapNote: string;
15
+ iapRetryHidden: boolean;
16
+ iapFormHidden: boolean;
17
+ iapFormError: string;
18
+ iapSignInHidden: boolean;
19
+ iapFields: AttendanceField[];
20
+ _poll?: IapPollDirective;
21
+ }
22
+
23
+ export const ATTENDANCE_INITIAL: AttendancePurchaseFields = {
24
+ iapBuyHidden: true, iapBuyDisabled: true, iapBuyLabel: "", iapBusy: false,
25
+ iapPrice: "", iapNote: "", iapRetryHidden: true, iapFormHidden: true, iapFormError: "", iapSignInHidden: true, iapFields: [],
26
+ };
27
+ export const ATTENDANCE_NOT_HYDRATED = [...Object.keys(ATTENDANCE_INITIAL), "_poll"] as (keyof AttendancePurchaseFields & string)[];
28
+
29
+ export function createAttendancePurchase(options: { kind: "bookingsService" | "eventsTicket"; prefix: string; pollAction: string; onChange(): void }) {
30
+ const { prefix } = options;
31
+ let form = createAttendanceForm(prefix);
32
+ const submittedForms = new Map<string, ReturnType<typeof createAttendanceForm>>();
33
+ const session = hostSessionFromGlobal();
34
+ const commerce = createIapCommerce({ adapters: [options.kind === "bookingsService" ? bookingOrders : eventTicketOrders], onChange: options.onChange });
35
+ let generation = session.current().generation;
36
+ let reference: CatalogReference | undefined;
37
+ let metadata: BookingAttendance | TicketAttendance | undefined;
38
+ let platform = "unknown";
39
+ let started = false;
40
+ let inFlight = false;
41
+ let formError = "";
42
+ let sequence = 0;
43
+ let formRevision = 0;
44
+ const attempts = new Map<string, string>();
45
+ session.subscribe(() => { syncSession(); if (reference) options.onChange(); });
46
+
47
+ function syncSession(): void {
48
+ const next = session.current().generation;
49
+ if (next === generation) return;
50
+ generation = next;
51
+ form.clear();
52
+ submittedForms.clear();
53
+ formError = "";
54
+ attempts.clear();
55
+ }
56
+
57
+ async function refresh(): Promise<void> {
58
+ const next = await attendancePlatform(publishPlatform);
59
+ if (next !== "unknown" || platform === "unknown") platform = next;
60
+ await resume();
61
+ }
62
+
63
+ async function resume(): Promise<void> {
64
+ if (reference) await commerce.loadPrices([{ reference }]);
65
+ const first = !started;
66
+ started = true;
67
+ await (first ? commerce.start() : commerce.reconcile());
68
+ }
69
+
70
+ function publishPlatform(next: string): void {
71
+ if (next === "unknown" && platform !== "unknown") return;
72
+ platform = next;
73
+ void resume().then(options.onChange, options.onChange);
74
+ }
75
+
76
+ function show(): void {
77
+ void attendancePlatform(publishPlatform).then(publishPlatform, options.onChange);
78
+ }
79
+
80
+ async function booking(id: string, service: unknown): Promise<void> {
81
+ syncSession();
82
+ reference = { kind: options.kind, id };
83
+ metadata = bookingAttendanceOf(service);
84
+ selectForm(id, (next) => next.booking(service));
85
+ formRevision++;
86
+ formError = "";
87
+ await show();
88
+ }
89
+
90
+ async function ticket(id: string, event: unknown, available: unknown): Promise<void> {
91
+ syncSession();
92
+ reference = id ? { kind: options.kind, id } : undefined;
93
+ metadata = availableTicketAttendanceOf(event, available);
94
+ selectForm(id, (next) => next.event((event as { form?: unknown } | undefined)?.form));
95
+ formRevision++;
96
+ formError = "";
97
+ await show();
98
+ }
99
+
100
+ function selectForm(id: string, initialize: (next: ReturnType<typeof createAttendanceForm>) => void): void {
101
+ const key = `${options.kind}:${id}`;
102
+ const retained = submittedForms.get(key);
103
+ form = retained ?? createAttendanceForm(prefix);
104
+ if (!retained) initialize(form);
105
+ }
106
+
107
+ function fields(selected = true, publishPoll = true): AttendancePurchaseFields {
108
+ syncSession();
109
+ if (!reference || !metadata) return ATTENDANCE_INITIAL;
110
+ const currency = metadata.currency ?? "";
111
+ const classified = classifyForApple(metadata, currency);
112
+ const candidate = platform === "ios" && classified === "appleIap";
113
+ const price = iapPriceFields(commerce.pricing.view(reference), iapPriceCopy(prefix));
114
+ const copy = iapPurchaseCopy(prefix);
115
+ const purchase = commerce.purchaseFields(reference, copy);
116
+ const apple = candidate || (platform === "ios" && classified !== "wixPayments" && purchase.appleRoute);
117
+ const enabled = attendanceCheckoutRoute(metadata, currency, platform) === "appleIap";
118
+ const poll = publishPoll ? commerce.pollDirective(options.pollAction) : undefined;
119
+ const state = commerce.state();
120
+ for (const [target, attempt] of attempts) {
121
+ const key = `${reference.kind}:${reference.id}#${encodeURIComponent(attempt)}`;
122
+ if ((state.phase === "accessReady" && state.item === key) || state.payments.some((p) => p.item === key && p.access === "granted")) attempts.delete(target);
123
+ }
124
+ const formKey = `${reference.kind}:${reference.id}`;
125
+ if (![...attempts.keys()].some((target) => target.startsWith(`${formKey}:`))) submittedForms.delete(formKey);
126
+ const own = state.item?.startsWith(`${reference.kind}:${reference.id}#`);
127
+ const completed = state.phase === "accessReady" && own;
128
+ const member = session.current().kind === "member";
129
+ let note = "";
130
+ if (apple && !enabled) note = copy.unavailable;
131
+ else if (apple && !member) note = copy.signInRequired;
132
+ else if (apple && purchase.buyNote) note = purchase.buyNote;
133
+ else if (apple && !form.supported()) note = copyParam(`${prefix}.formUnavailableText`, "This registration form is not supported yet.");
134
+ else if (apple && completed) note = copyParam(`${prefix}.purchaseCompleteText`, options.kind === "bookingsService" ? "Your booking is confirmed." : "Your ticket is confirmed.");
135
+ else if (apple && own && state.phase === "cancelled") note = copyParam(`${prefix}.purchaseCancelledText`, "Purchase cancelled. Your place is not confirmed.");
136
+ return {
137
+ iapBuyHidden: !apple, iapBuyDisabled: !enabled || !selected || !member || !form.supported() || purchase.buyDisabled || inFlight,
138
+ iapBuyLabel: purchase.buyLabel || copy.buy, iapBusy: purchase.buyBusy || inFlight,
139
+ iapPrice: apple ? price.priceText : "", iapRetryHidden: !apple || (price.hidePriceRetry && commerce.scopeComplete()),
140
+ iapNote: note,
141
+ iapFormHidden: !apple || !selected || !form.supported(), iapFormError: formError,
142
+ iapSignInHidden: !apple || member || options.kind === "bookingsService",
143
+ iapFields: form.rows().map((f, index, rows) => ({ ...f,
144
+ id: `${generation}:${formRevision}:${f.id}`, fieldId: f.id,
145
+ errorHidden: !f.error,
146
+ options: f.options.map((o) => ({ ...o, fieldId: f.id, displayLabel: (f.kind === "multiple" ? f.selectedIds.includes(o.id) : f.value === o.id) ? `${o.label} ✓` : o.label })),
147
+ label: f.required ? `${f.label} *` : f.label,
148
+ inputLabel: copyTemplate(`${prefix}.fieldAccessibilityLabel`, "{section}: {field}", { section: f.id.startsWith("buyer:") ? copyParam(`${prefix}.buyerDetailsLabel`, "Buyer details") : copyParam(`${prefix}.guestDetailsLabel`, "Ticket holder details"), field: f.label }),
149
+ sectionLabel: index === 0 ? copyParam(`${prefix}.buyerDetailsLabel`, "Buyer details") : f.id.startsWith("guest:") && rows[index - 1].id.startsWith("buyer:") ? copyParam(`${prefix}.guestDetailsLabel`, "Ticket holder details") : "",
150
+ })), ...(poll ? { _poll: poll } : {}),
151
+ };
152
+ }
153
+
154
+ async function buy(selection: { key: string; slot?: Record<string, unknown>; eventId?: string }): Promise<void> {
155
+ syncSession();
156
+ if (!reference || !metadata || fields(true, false).iapBuyDisabled || inFlight) return;
157
+ if (options.kind === "eventsTicket" && reference.id !== selection.key) return;
158
+ if (!form.validate()) { formError = copyParam(`${prefix}.formRequiredText`, "Complete the required details before purchasing."); return; }
159
+ if (session.current().kind !== "member") { formError = iapPurchaseCopy(prefix).signInRequired; return; }
160
+ const currentGeneration = generation;
161
+ const ref = reference;
162
+ submittedForms.set(`${ref.kind}:${ref.id}`, form);
163
+ const target = `${ref.kind}:${ref.id}:${selection.key}`;
164
+ let attemptId = attempts.get(target);
165
+ const state = commerce.state();
166
+ const attemptKey = attemptId ? `${ref.kind}:${ref.id}#${encodeURIComponent(attemptId)}` : "";
167
+ const completed = attemptId && ((state.phase === "accessReady" && state.item === attemptKey) || state.payments.some((p) => p.item === attemptKey && p.access === "granted"));
168
+ if (!attemptId || completed) { attemptId = `${Date.now().toString(36)}-${++sequence}-${Math.random().toString(36).slice(2)}`; attempts.set(target, attemptId); }
169
+ const item: IapOrderItem = { reference: ref, attemptId, quantity: 1, attendance: {
170
+ amount: metadata.amount!, currency: metadata.currency!,
171
+ ...(options.kind === "bookingsService" ? { booking: { slot: selection.slot ?? {}, contactDetails: form.buyer() } } : { ticket: { eventId: selection.eventId ?? "", ...form.ticket(ref.id) } }),
172
+ } };
173
+ inFlight = true;
174
+ formError = "";
175
+ options.onChange();
176
+ try {
177
+ await commerce.buy(item);
178
+ const after = commerce.state();
179
+ if (after.phase === "accessReady" && after.item === `${ref.kind}:${ref.id}#${encodeURIComponent(attemptId)}`) attempts.delete(target);
180
+ }
181
+ finally { inFlight = false; syncSession(); if (generation !== currentGeneration) form.clear(); options.onChange(); }
182
+ }
183
+
184
+ function change(arg: unknown): void {
185
+ syncSession();
186
+ const state = commerce.state();
187
+ if (reference) {
188
+ for (const [target, attempt] of attempts) {
189
+ const item = `${reference.kind}:${reference.id}#${encodeURIComponent(attempt)}`;
190
+ const canCorrect = state.phase === "cancelled" || (state.phase === "refused" && (state.failure === "orderRefused" || state.failure === "productChanged"));
191
+ if (canCorrect && state.item === item) { attempts.delete(target); submittedForms.delete(`${reference.kind}:${reference.id}`); }
192
+ }
193
+ const referencePrefix = `${reference.kind}:${reference.id}:`;
194
+ if (inFlight || [...attempts.keys()].some((target) => target.startsWith(referencePrefix))) {
195
+ formError = copyParam(`${prefix}.detailsLockedText`, "Finish the current purchase before changing registration details.");
196
+ return;
197
+ }
198
+ }
199
+ form.change(arg);
200
+ formError = "";
201
+ }
202
+
203
+ return {
204
+ booking, ticket, fields, buy,
205
+ usesRegularPlatform: () => platform === "android" || platform === "web",
206
+ invalidate: () => { reference = undefined; metadata = undefined; options.onChange(); },
207
+ change,
208
+ refresh,
209
+ poll: async () => { syncSession(); await commerce.poll(); },
210
+ };
211
+ }
@@ -0,0 +1,38 @@
1
+ import { get as appInfo } from "@himalaya/app-info";
2
+ import { attendanceCheckoutRoute, bookingAttendanceOf, ticketAttendanceOf } from "@himalaya/iap";
3
+
4
+ let knownPlatform: string | undefined;
5
+
6
+ export function attendancePlatform(onResolved?: (platform: string) => void): Promise<string> {
7
+ if (knownPlatform) return Promise.resolve(knownPlatform);
8
+ const info = appInfo().then((value) => value.platform, () => "unknown").then((platform) => {
9
+ if (platform !== "unknown") knownPlatform = platform;
10
+ return platform;
11
+ });
12
+ const host = globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown; clearTimeout?: (timer: unknown) => void };
13
+ if (!host.setTimeout) {
14
+ void info.then((platform) => onResolved?.(platform));
15
+ return Promise.resolve("unknown");
16
+ }
17
+ return new Promise((resolve) => {
18
+ let timedOut = false;
19
+ const timer = host.setTimeout!(() => { timedOut = true; resolve("unknown"); }, 1500);
20
+ void info.then((platform) => { host.clearTimeout?.(timer); resolve(platform); if (timedOut) onResolved?.(platform); });
21
+ });
22
+ }
23
+
24
+ export async function mayUseRegularBookingCheckout(service: unknown): Promise<boolean> {
25
+ const metadata = bookingAttendanceOf(service);
26
+ if (metadata.priceType === "NO_FEE" || isZero(metadata.amount)) return true;
27
+ return attendanceCheckoutRoute(metadata, metadata.currency ?? "", await attendancePlatform()) === "regular";
28
+ }
29
+
30
+ export async function mayUseRegularTicketCheckout(event: unknown, ticket: { free?: boolean; price?: { amount?: string; currency?: string }; saleStatus?: string } | undefined): Promise<boolean> {
31
+ if (ticket?.free === true || isZero(ticket?.price?.amount)) return true;
32
+ const metadata = ticketAttendanceOf(event, { hidden: false, saleStatus: ticket?.saleStatus });
33
+ return attendanceCheckoutRoute(metadata, ticket?.price?.currency ?? "", await attendancePlatform()) === "regular";
34
+ }
35
+
36
+ function isZero(value: unknown): boolean {
37
+ return typeof value === "string" && /^0+(?:\.0+)?$/.test(value);
38
+ }
@@ -0,0 +1,125 @@
1
+ // Worker-side glue for the shared IAP layer (`@himalaya/iap` catalog pricing and
2
+ // `@himalaya/iap-checkout` → createIapCommerce), shared by every screen that shows or sells an
3
+ // IAP-backed item. Mapping, Apple prices, payment guards, the host scope and the sale live in the
4
+ // SDK; this only supplies the copy and the hydration reset.
5
+ import { hostSessionFromGlobal, type IapPriceCopy } from "@himalaya/iap";
6
+ import type { IapCommerce, IapPurchaseCopy, IapRestoreResult } from "@himalaya/iap-checkout";
7
+ import { paymentSetupMessage } from "./payment-setup.js";
8
+ import { copyParam, copyTemplate } from "./himi-copy.js";
9
+
10
+ /** Price labels from the screen's copy pack (`<prefix>.priceLoadingText`, …). */
11
+ export function iapPriceCopy(prefix: string): IapPriceCopy {
12
+ return {
13
+ loading: copyParam(`${prefix}.priceLoadingText`, "Loading price…"),
14
+ unavailable: copyParam(`${prefix}.priceUnavailableText`, "Price unavailable"),
15
+ oneTimePurchase: copyParam(`${prefix}.oneTimePurchaseLabel`, "One-time purchase"),
16
+ purchaseUnavailable: copyParam(`${prefix}.purchaseUnavailableText`, "Purchasing is not available yet."),
17
+ };
18
+ }
19
+
20
+ /** App Store purchase labels from the screen's copy pack (`<prefix>.buyCta`, …), for
21
+ * `iapPurchaseFields`. Customer copy only: no backend names, ids or diagnostics. */
22
+ export function iapPurchaseCopy(prefix: string): IapPurchaseCopy {
23
+ return {
24
+ buy: copyParam(`${prefix}.buyCta`, "Buy now"),
25
+ processing: copyParam(`${prefix}.purchaseProcessingText`, "Processing your purchase…"),
26
+ awaitingApproval: copyParam(`${prefix}.purchaseAwaitingApprovalText`, "Waiting for approval"),
27
+ unavailable: copyParam(`${prefix}.purchaseTemporarilyUnavailableText`, "Purchasing is temporarily unavailable."),
28
+ signInRequired: copyParam(`${prefix}.purchaseSignInText`, "Sign in to buy."),
29
+ sitePaymentUnavailable: paymentSetupMessage(prefix),
30
+ retry: copyParam(`${prefix}.purchaseRetryText`, "Something went wrong. Try again."),
31
+ priceChanged: copyParam(`${prefix}.purchasePriceChangedText`, "The price changed. Check it and try again."),
32
+ support: copyParam(`${prefix}.purchaseSupportText`, "We couldn't finish this purchase. Contact support."),
33
+ };
34
+ }
35
+
36
+ /** The Restore purchases control (App Review 3.1.1) a screen binds. */
37
+ export interface IapRestoreFields {
38
+ restoreHidden: boolean;
39
+ restoreLabel: string;
40
+ restoreDisabled: boolean;
41
+ restoreMessage: string;
42
+ }
43
+
44
+ /** Keep these out of hydration: a restore's answer belongs to the run that got it. */
45
+ export const IAP_RESTORE_KEYS = ["restoreHidden", "restoreLabel", "restoreDisabled", "restoreMessage"] as const;
46
+
47
+ /** The control before the screen knows it sells through Apple. */
48
+ export const NO_IAP_RESTORE: IapRestoreFields = { restoreHidden: true, restoreLabel: "", restoreDisabled: false, restoreMessage: "" };
49
+
50
+ export interface IapRestoreControl {
51
+ /** `sellsThroughApple` is the screen's own signal: its item's App Store route, or Account's
52
+ * purchase readiness. */
53
+ fields(sellsThroughApple: boolean): IapRestoreFields;
54
+ /** Runs the shared `commerce.restore()`, repainting as it starts and when it answers. A tap while
55
+ * one runs is ignored. */
56
+ run(): Promise<void>;
57
+ }
58
+
59
+ /** Copy from the screen's pack (`<prefix>.restorePurchasesCta`, …). The restore itself, and the access
60
+ * re-read after it, are the shared commerce's. */
61
+ export function createIapRestore(
62
+ commerce: Pick<IapCommerce, "restore">,
63
+ options: { prefix: string; repaint(): void; /** The session generation; defaults to the host's. */ generation?: () => number },
64
+ ): IapRestoreControl {
65
+ const generation = options.generation ?? (() => hostSessionFromGlobal().current().generation);
66
+ let running = false;
67
+ let message = "";
68
+ let messageGeneration = -1;
69
+
70
+ function say(text: string, forGeneration: number): void {
71
+ message = text;
72
+ messageGeneration = forGeneration;
73
+ }
74
+
75
+ async function run(): Promise<void> {
76
+ if (running) return;
77
+ running = true;
78
+ const startedFor = generation();
79
+ say(copyParam(`${options.prefix}.restoringText`, "Restoring…"), startedFor);
80
+ options.repaint();
81
+ // Stamped with the session the tap was for: an answer landing after a sign-out is never shown.
82
+ say(restoreMessage(await restored(commerce), options.prefix), startedFor);
83
+ running = false;
84
+ options.repaint();
85
+ }
86
+
87
+ return {
88
+ fields: (sellsThroughApple) => ({
89
+ restoreHidden: !sellsThroughApple,
90
+ restoreLabel: copyParam(`${options.prefix}.restorePurchasesCta`, "Restore purchases"),
91
+ restoreDisabled: running,
92
+ // The answer was for the person signed in when it ran; another session never inherits it.
93
+ restoreMessage: sellsThroughApple && messageGeneration === generation() ? message : "",
94
+ }),
95
+ run,
96
+ };
97
+ }
98
+
99
+ function restored(commerce: Pick<IapCommerce, "restore">): Promise<IapRestoreResult> {
100
+ return commerce.restore().catch(() => ({ status: "failed", code: "storeUnavailable" }) as const);
101
+ }
102
+
103
+ function restoreMessage(result: IapRestoreResult, prefix: string): string {
104
+ if (result.status === "needsSignIn") return copyParam(`${prefix}.restoreSignInText`, "Sign in to restore your purchases.");
105
+ if (result.status === "failed") return result.code === "sessionChanged" ? "" : copyParam(`${prefix}.restoreFailedText`, "Couldn't reach the App Store. Try again.");
106
+ // Every part that happened is said: a recovery never hides a refusal or a purchase still pending.
107
+ const parts = [
108
+ result.recovered === 1 ? copyParam(`${prefix}.restoredOneText`, "Restored 1 purchase.") : "",
109
+ result.recovered > 1 ? copyTemplate(`${prefix}.restoredManyText`, "Restored {count} purchases.", { count: result.recovered }) : "",
110
+ // Held unfinished because Wix refused it (another member's purchase, say).
111
+ result.refused > 0 ? copyParam(`${prefix}.restoreRefusedText`, "A purchase on this Apple ID couldn't be added to this account. Contact support.") : "",
112
+ result.pending > 0 ? copyParam(`${prefix}.restorePendingText`, "A purchase is still being confirmed. Try again in a moment.") : "",
113
+ ].filter(Boolean);
114
+ return parts.length ? parts.join(" ") : copyParam(`${prefix}.restoreUpToDateText`, "Your purchases are up to date.");
115
+ }
116
+
117
+ /** What `notHydrated` does, for a runtime that ignores the option (a binary older than it, still
118
+ * served new bundles): put the listed keys back to their declared values before any action runs,
119
+ * so a persisted price or an enabled purchase action never paints as this run's. */
120
+ export function resetUnhydratedKeysOnOlderRuntime<S extends object>(svc: { getState(): S }, declared: S, keys: readonly (keyof S & string)[]): void {
121
+ if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut === true) return;
122
+ const booted = svc.getState();
123
+ if (!booted || typeof booted !== "object") return;
124
+ for (const key of keys) booted[key] = declared[key];
125
+ }
@@ -0,0 +1,38 @@
1
+ /** Small local preferences only; never entity records or credentials. The host
2
+ * overwrites these reserved input fields from its authenticated account/site.
3
+ * Older hosts and visitors deliberately stay session-only. */
4
+ export interface PreferenceStore {
5
+ getItem(key: string): string | null;
6
+ setItem(key: string, value: string): void;
7
+ }
8
+ export interface PreferenceRuntime { input?: Record<string, unknown>; storage?: PreferenceStore; }
9
+ export function localPreferences<T>(name: string, normalize: (value: unknown) => T, runtime?: PreferenceRuntime) {
10
+ const host = runtime ?? (globalThis as { himalaya?: PreferenceRuntime }).himalaya;
11
+ const account = host?.input?.__himiPreferenceAccount;
12
+ const business = host?.input?.__himiPreferenceBusiness;
13
+ const valid = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) && value !== 'default';
14
+ const key = valid(account) && valid(business) && host?.storage
15
+ ? `himi.preferences.v1.${account}.${encodeURIComponent(business)}.${name}` : '';
16
+ let available = !!key;
17
+ return {
18
+ get available() { return available; },
19
+ read(): T {
20
+ if (!key) return normalize(undefined);
21
+ try {
22
+ const raw = host!.storage!.getItem(key);
23
+ if (!raw || raw.length > 32768) return normalize(undefined);
24
+ const data = JSON.parse(raw);
25
+ return normalize(data?.version === 1 ? data.value : undefined);
26
+ } catch { available = false; return normalize(undefined); }
27
+ },
28
+ write(value: T): void {
29
+ if (!key) return;
30
+ try {
31
+ const raw = JSON.stringify({ version: 1, value: normalize(value) });
32
+ if (raw.length > 32768) { available = false; return; }
33
+ host!.storage!.setItem(key, raw);
34
+ available = host!.storage!.getItem(key) === raw;
35
+ } catch { available = false; }
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,52 @@
1
+ import { dispatch } from "@himalaya/dispatch";
2
+ import { getMyData } from "@himalaya/wix-users";
3
+
4
+ export interface MemberAccess { email: string; scope: string; }
5
+ export function memberAccessChanged(): Error {
6
+ const error = new Error("Member access changed"); error.name = "MemberAccessChanged"; return error;
7
+ }
8
+ export async function memberAccess(): Promise<MemberAccess> {
9
+ let email = "";
10
+ let identityUnavailable = false;
11
+ try {
12
+ const value = await dispatch("himi.wix.memberIdentity") as { email?: unknown; payload?: { email?: unknown } } | null;
13
+ const raw = value?.email ?? value?.payload?.email;
14
+ email = typeof raw === "string" ? raw.trim().toLowerCase() : "";
15
+ } catch { identityUnavailable = true; }
16
+ // Older native hosts predate the memberIdentity action but can still expose
17
+ // the authenticated profile through wix-users. Only a missing action uses
18
+ // that rung: an explicit empty current-session response remains a visitor.
19
+ if (!email && identityUnavailable) {
20
+ try {
21
+ const profile = await getMyData();
22
+ const raw = profile.email ?? profile.contactEmail;
23
+ email = typeof raw === "string" ? raw.trim().toLowerCase() : "";
24
+ } catch { /* A visitor or an unavailable legacy profile remains anonymous. */ }
25
+ }
26
+ const input = (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input;
27
+ const account = input?.__himiPreferenceAccount, business = input?.__himiPreferenceBusiness;
28
+ const valid = (v: unknown): v is string => typeof v === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(v) && v !== "default";
29
+ // These fields are overwritten by authenticated hosts. Older binaries stay online-only.
30
+ return { email, scope: email && valid(account) && valid(business) ? JSON.stringify([account, business, email]) : "" };
31
+ }
32
+ export async function assertMemberAccess(member: MemberAccess): Promise<void> {
33
+ const current = await memberAccess();
34
+ if (!member.email || current.email !== member.email || current.scope !== member.scope) throw memberAccessChanged();
35
+ }
36
+ export function memberAccessDenied(error: unknown): boolean {
37
+ return (error as { name?: string } | null)?.name === "MemberAccessChanged" || /(?:HTTP\s*(?:401|403)|READ_ORDER_FORBIDDEN|permission denied|forbidden)/i.test(String(error));
38
+ }
39
+
40
+ /** Did this write fail because the member changed (signed out, or another account)? Then
41
+ * the screen reloads for whoever is signed in now. A 401/403 for the SAME member is only a
42
+ * failed write: the caller keeps what the member typed and says so. */
43
+ export async function memberChanged(member: MemberAccess | undefined, error: unknown): Promise<boolean> {
44
+ if ((error as { name?: string } | null)?.name === "MemberAccessChanged") return true;
45
+ if (!member || !memberAccessDenied(error)) return false;
46
+ try {
47
+ await assertMemberAccess(member);
48
+ return false;
49
+ } catch {
50
+ return true;
51
+ }
52
+ }