@wix/himalaya-cli 0.1496.0 → 0.1498.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 (138) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/attendance-classifier.d.ts +37 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing-contract.d.ts +1 -1
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/index.d.ts +2 -1
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-contract.d.ts +11 -3
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +27 -3
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-contract.d.ts +1 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-verticals/attendance.d.ts +10 -0
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-verticals/index.d.ts +1 -0
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/index.d.ts +1 -1
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/events/src/index.d.ts +33 -2
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +346 -26
  12. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +132 -3
  13. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +366 -48
  14. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-events.mjs +5 -0
  15. package/dist/cli.mjs +817 -666
  16. package/guides/iap-products.md +7 -1
  17. package/package.json +2 -2
  18. package/templates/beauty-salon/dev/index.ts +394 -0
  19. package/templates/beauty-salon/tier5-src/_shared/iap-attendance-form.ts +106 -0
  20. package/templates/beauty-salon/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  21. package/templates/beauty-salon/tier5-src/_shared/iap-attendance.ts +38 -0
  22. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +52 -7
  23. package/templates/beauty-salon-owner/dev/index.ts +571 -0
  24. package/templates/beauty-salon-owner/himalaya.content.json +6 -0
  25. package/templates/beauty-salon-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  26. package/templates/beauty-salon-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  27. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  28. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  29. package/templates/branded-lite/dev/index.ts +1429 -278
  30. package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +46 -8
  31. package/templates/branded-lite/tier5-src/_shared/iap-attendance-form.ts +106 -0
  32. package/templates/branded-lite/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  33. package/templates/branded-lite/tier5-src/_shared/iap-attendance.ts +38 -0
  34. package/templates/branded-lite/tier5-src/business-chat/index.ts +3 -2
  35. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +52 -7
  36. package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +112 -21
  37. package/templates/community-club/dev/index.ts +397 -2
  38. package/templates/community-club/tier5-src/_shared/iap-attendance-form.ts +106 -0
  39. package/templates/community-club/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  40. package/templates/community-club/tier5-src/_shared/iap-attendance.ts +38 -0
  41. package/templates/community-club/tier5-src/himi-events-detail/index.ts +112 -21
  42. package/templates/community-club-owner/dev/index.ts +571 -0
  43. package/templates/community-club-owner/himalaya.content.json +6 -0
  44. package/templates/community-club-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  45. package/templates/community-club-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  46. package/templates/community-club-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  47. package/templates/community-club-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  48. package/templates/events-meetups/dev/index.ts +397 -2
  49. package/templates/events-meetups/tier5-src/_shared/iap-attendance-form.ts +106 -0
  50. package/templates/events-meetups/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  51. package/templates/events-meetups/tier5-src/_shared/iap-attendance.ts +38 -0
  52. package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +112 -21
  53. package/templates/events-meetups-owner/dev/index.ts +571 -0
  54. package/templates/events-meetups-owner/himalaya.content.json +6 -0
  55. package/templates/events-meetups-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  56. package/templates/events-meetups-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  57. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  58. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  59. package/templates/gym-fitness/dev/index.ts +828 -39
  60. package/templates/gym-fitness/tier5-src/_shared/iap-attendance-form.ts +106 -0
  61. package/templates/gym-fitness/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  62. package/templates/gym-fitness/tier5-src/_shared/iap-attendance.ts +38 -0
  63. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +52 -7
  64. package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +112 -21
  65. package/templates/gym-fitness-owner/dev/index.ts +571 -0
  66. package/templates/gym-fitness-owner/himalaya.content.json +6 -0
  67. package/templates/gym-fitness-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  68. package/templates/gym-fitness-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  69. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  70. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  71. package/templates/nonprofit-cause/dev/index.ts +397 -2
  72. package/templates/nonprofit-cause/tier5-src/_shared/iap-attendance-form.ts +106 -0
  73. package/templates/nonprofit-cause/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  74. package/templates/nonprofit-cause/tier5-src/_shared/iap-attendance.ts +38 -0
  75. package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +112 -21
  76. package/templates/nonprofit-cause-owner/dev/index.ts +571 -0
  77. package/templates/nonprofit-cause-owner/himalaya.content.json +6 -0
  78. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  79. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  80. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  81. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  82. package/templates/owner-lite/dev/index.ts +571 -0
  83. package/templates/owner-lite/himalaya.content.json +6 -0
  84. package/templates/owner-lite/tier5-src/_shared/owner-today-rollup.ts +307 -0
  85. package/templates/owner-lite/tier5-src/_shared/owner-widget-reads.ts +23 -0
  86. package/templates/owner-lite/tier5-src/himi-owner-lite-today/index.ts +8 -20
  87. package/templates/owner-lite/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  88. package/templates/podcast-creator/dev/index.ts +397 -2
  89. package/templates/podcast-creator/tier5-src/_shared/iap-attendance-form.ts +106 -0
  90. package/templates/podcast-creator/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  91. package/templates/podcast-creator/tier5-src/_shared/iap-attendance.ts +38 -0
  92. package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +112 -21
  93. package/templates/podcast-creator-owner/dev/index.ts +571 -0
  94. package/templates/podcast-creator-owner/himalaya.content.json +6 -0
  95. package/templates/podcast-creator-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  96. package/templates/podcast-creator-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  97. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  98. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  99. package/templates/portfolio-creative/dev/index.ts +397 -2
  100. package/templates/portfolio-creative/tier5-src/_shared/iap-attendance-form.ts +106 -0
  101. package/templates/portfolio-creative/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  102. package/templates/portfolio-creative/tier5-src/_shared/iap-attendance.ts +38 -0
  103. package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +112 -21
  104. package/templates/portfolio-creative-owner/dev/index.ts +571 -0
  105. package/templates/portfolio-creative-owner/himalaya.content.json +6 -0
  106. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  107. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  108. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  109. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  110. package/templates/restaurant/dev/index.ts +397 -2
  111. package/templates/restaurant/tier5-src/_shared/iap-attendance-form.ts +106 -0
  112. package/templates/restaurant/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  113. package/templates/restaurant/tier5-src/_shared/iap-attendance.ts +38 -0
  114. package/templates/restaurant/tier5-src/himi-events-detail/index.ts +112 -21
  115. package/templates/restaurant-owner/dev/index.ts +571 -0
  116. package/templates/restaurant-owner/himalaya.content.json +6 -0
  117. package/templates/restaurant-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  118. package/templates/restaurant-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  119. package/templates/restaurant-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  120. package/templates/restaurant-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  121. package/templates/retail-store-owner/dev/index.ts +571 -0
  122. package/templates/retail-store-owner/himalaya.content.json +6 -0
  123. package/templates/retail-store-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  124. package/templates/retail-store-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  125. package/templates/retail-store-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  126. package/templates/retail-store-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
  127. package/templates/yoga-studio/dev/index.ts +828 -39
  128. package/templates/yoga-studio/tier5-src/_shared/iap-attendance-form.ts +106 -0
  129. package/templates/yoga-studio/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  130. package/templates/yoga-studio/tier5-src/_shared/iap-attendance.ts +38 -0
  131. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +52 -7
  132. package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +112 -21
  133. package/templates/yoga-studio-owner/dev/index.ts +571 -0
  134. package/templates/yoga-studio-owner/himalaya.content.json +6 -0
  135. package/templates/yoga-studio-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
  136. package/templates/yoga-studio-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
  137. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
  138. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
@@ -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), 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") };
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
+ }
@@ -19,6 +19,9 @@ import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
19
19
  import { emitHostEvent, hostEventInstant } from "../_shared/host-events.js";
20
20
  import { availabilityBeyond, slotIdAt } from "../_shared/himi-bookings.js";
21
21
  import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
22
+ import { mayUseRegularBookingCheckout } from "../_shared/iap-attendance.js";
23
+ import { ATTENDANCE_INITIAL, ATTENDANCE_NOT_HYDRATED, createAttendancePurchase, type AttendancePurchaseFields } from "../_shared/iap-attendance-purchase.js";
24
+ import { resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
22
25
 
23
26
  // Offline-first (data-offline): booking a slot is WRITE-BEHIND against the
24
27
  // `member_bookings` entity (the same rows the My Bookings worker reads). The
@@ -53,7 +56,7 @@ interface SlotRow {
53
56
  notSelected: boolean;
54
57
  }
55
58
 
56
- interface S {
59
+ interface S extends AttendancePurchaseFields {
57
60
  status: "loading" | "ready";
58
61
  id: string;
59
62
  serviceName: string;
@@ -132,6 +135,10 @@ interface DayOption {
132
135
  }
133
136
 
134
137
  let slotsById = new Map<string, WixBookingSlotFull>();
138
+ let regularCheckoutAllowed = false;
139
+ let regularServicePrice = "";
140
+ let lastReady: S | undefined;
141
+ const applePurchase = createAttendancePurchase({ kind: "bookingsService", prefix: "time", pollAction: "himiBookingTime.iapPoll", onChange: () => { void svc.iapPublish(); } });
135
142
  /** Every slot row in the window, across all days. Kept out of worker state so
136
143
  * the state the host receives holds one day's rows, not the whole window. */
137
144
  let allRows: SlotRow[] = [];
@@ -326,18 +333,24 @@ function withDay(s: S, day: string): S {
326
333
  }
327
334
 
328
335
  function derive(s: S): S {
336
+ const regular = regularCheckoutAllowed || applePurchase.usesRegularPlatform();
329
337
  // `hasSlots` is the WHOLE window (it drives the screen-level empty state); the
330
338
  // selected day having none is a per-day line, not an empty screen.
331
339
  const hasSlots = s.days.some((d) => d.hasSlots) || s.slots.length > 0;
332
340
  const hasDaySlots = s.slots.length > 0;
333
341
  const hasSelected = !!s.selectedSlotId;
334
342
  const hasSuccess = !!s.successMessage;
335
- const canBook = s.isMember && hasSelected && !s.bookingInFlight && !hasSuccess;
336
- const showChoosePrompt = s.isMember && hasSlots && !hasSelected && !s.bookingInFlight && !hasSuccess;
343
+ const canBook = regular && s.isMember && hasSelected && !s.bookingInFlight && !hasSuccess;
344
+ const showChoosePrompt = regular && s.isMember && hasSlots && !hasSelected && !s.bookingInFlight && !hasSuccess;
337
345
  const isRegistering = !!s.isRegistering;
338
- return {
346
+ const next: S = {
339
347
  ...s,
348
+ _poll: undefined,
349
+ servicePrice: regular ? regularServicePrice : "",
350
+ serviceLine: s.status === "ready" ? [s.serviceDuration, regular ? regularServicePrice : "", copyParam("time.availabilityWindow", "Available in the next 14 days")].filter(Boolean).join(" · ") : s.serviceLine,
351
+ message: regular && !regularCheckoutAllowed && s.message === copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") ? "" : s.message,
340
352
  hasSlots,
353
+ ...applePurchase.fields(hasSelected),
341
354
  hasSelected,
342
355
  noSelected: !hasSelected,
343
356
  hideSignIn: s.isMember || !hasSelected,
@@ -375,6 +388,8 @@ function derive(s: S): S {
375
388
  ? copyParam("time.authBodyRegister", "Create a member account, then book the selected time on this screen.")
376
389
  : copyParam("time.authBody", "You can browse times as a visitor. Booking is saved to a member account."),
377
390
  };
391
+ if (next.status === "ready") lastReady = next;
392
+ return next;
378
393
  }
379
394
 
380
395
  async function probeMember(s: S): Promise<S> {
@@ -499,8 +514,15 @@ const svc = defineService<S, {
499
514
  signIn: (s: S) => Promise<S>;
500
515
  register: (s: S) => Promise<S>;
501
516
  bookSelected: (s: S) => Promise<S>;
517
+ buyApple: (s: S) => Promise<S>;
518
+ setIapField: (s: S, arg?: unknown) => S;
519
+ iapPublish: (s: S) => S;
520
+ iapRefresh: (s: S) => Promise<S>;
521
+ iapPoll: (s: S) => Promise<S>;
502
522
  }>("himiBookingTime", {
523
+ notHydrated: [...ATTENDANCE_NOT_HYDRATED, "email", "password", "firstName", "lastName", "isMember", "canBook", "cannotBook"],
503
524
  state: derive({
525
+ ...ATTENDANCE_INITIAL,
504
526
  status: "loading",
505
527
  id,
506
528
  serviceName: "Choose a time",
@@ -555,6 +577,16 @@ const svc = defineService<S, {
555
577
  bookingsScreenId: "himi_bookings",
556
578
  }),
557
579
  actions: {
580
+ iapPublish: (s) => derive(lastReady ?? s),
581
+ iapRefresh: async (): Promise<S> => { await applePurchase.refresh(); return derive(svc.getState()); },
582
+ iapPoll: async (): Promise<S> => { await applePurchase.poll(); return derive(svc.getState()); },
583
+ setIapField: (s, arg) => { applePurchase.change(arg); return derive(s); },
584
+ buyApple: async (s: S): Promise<S> => {
585
+ const slot = slotsById.get(s.selectedSlotId);
586
+ if (!slot || !s.isMember) return derive(s);
587
+ await applePurchase.buy({ key: s.selectedSlotId, slot: slot as unknown as Record<string, unknown> });
588
+ return derive(svc.getState());
589
+ },
558
590
  load: async (s, arg): Promise<S> => {
559
591
  const revisionAtStart = selectionRevision;
560
592
  stashCopy(arg);
@@ -598,6 +630,10 @@ const svc = defineService<S, {
598
630
  }
599
631
  allRows = rows;
600
632
  const days = daysOf(rows);
633
+ const header = serviceHeaderFields(serviceRes.service);
634
+ regularServicePrice = header.price;
635
+ regularCheckoutAllowed = await mayUseRegularBookingCheckout(serviceRes.service);
636
+ await applePurchase.booking(s.id, serviceRes.service);
601
637
  // A reload runs in the background when the screen shows again. A time the customer
602
638
  // picked while it ran wins over the selection this load started from.
603
639
  const chosen: S = selectionRevision !== revisionAtStart ? (svc.getState?.() ?? next) : next;
@@ -617,7 +653,6 @@ const svc = defineService<S, {
617
653
  // otherwise open on the first day that has any.
618
654
  const keep = days.find((d) => d.id === chosen.selectedDay && d.hasSlots);
619
655
  const initialDay = startRow?.dayKey ?? startDay?.id ?? picked?.dayKey ?? keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
620
- const header = serviceHeaderFields(serviceRes.service);
621
656
  return derive(withDay({
622
657
  ...next,
623
658
  days,
@@ -628,13 +663,14 @@ const svc = defineService<S, {
628
663
  status: "ready",
629
664
  errored: false,
630
665
  serviceName: serviceRes.service?.name || copyParam("time.serviceNameFallback", "Choose a time"),
631
- serviceLine: [header.duration, header.price, copyParam("time.availabilityWindow", "Available in the next 14 days")].filter(Boolean).join(" · "),
666
+ serviceLine: [header.duration, regularCheckoutAllowed ? header.price : "", copyParam("time.availabilityWindow", "Available in the next 14 days")].filter(Boolean).join(" · "),
632
667
  serviceImage: header.image,
633
668
  hasServiceImage: !!header.image,
634
669
  noServiceImage: !header.image,
635
- servicePrice: header.price,
670
+ servicePrice: regularCheckoutAllowed ? header.price : "",
636
671
  serviceDuration: header.duration,
637
672
  serviceIsPaid: header.paid,
673
+ message: regularCheckoutAllowed || !applePurchase.fields(true, false).iapBuyHidden ? "" : copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable."),
638
674
  }, initialDay));
639
675
  } catch {
640
676
  slotsById = new Map();
@@ -727,6 +763,14 @@ const svc = defineService<S, {
727
763
  if (!s.selectedSlotId) return derive({ ...s, message: copyParam("time.bookNeedsSlot", "Choose one time first.") });
728
764
  const slot = slotsById.get(s.selectedSlotId);
729
765
  if (!slot) return derive({ ...s, message: copyParam("time.bookSlotGone", "That time is no longer available. Try again.") });
766
+ try {
767
+ const current = await getService(s.id);
768
+ regularCheckoutAllowed = await mayUseRegularBookingCheckout(current.service);
769
+ if (serviceHeaderFields(current.service).paid !== s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
770
+ } catch {
771
+ if (s.serviceIsPaid) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
772
+ }
773
+ if (!regularCheckoutAllowed) return derive({ ...s, message: copyParam("time.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
730
774
  // A confirmed booking is reported to the app hosting this screen (an embed's `onAction`), so a
731
775
  // host chat can say so and refresh what it shows. Dropped where nothing hosts the screen.
732
776
  // The booking's own instants carry an offset; a slot's may be LOCAL to its timezone, which
@@ -867,5 +911,6 @@ const svc = defineService<S, {
867
911
  },
868
912
  },
869
913
  });
914
+ resetUnhydratedKeysOnOlderRuntime(svc, { ...svc.getState(), ...ATTENDANCE_INITIAL, email: "", password: "", firstName: "", lastName: "", isMember: false, canBook: false, cannotBook: true }, [...ATTENDANCE_NOT_HYDRATED, "email", "password", "firstName", "lastName", "isMember", "canBook", "cannotBook"]);
870
915
  svc.load();
871
916
  export { svc };