@wix/himalaya-cli 0.1559.0 → 0.1561.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 (82) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +3 -1
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +2 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/events/src/index.d.ts +60 -7
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-events.mjs +11 -5
  5. package/dist/cli.mjs +191 -60
  6. package/dist/preview-assets.generated.bin +0 -0
  7. package/guides/component-reference.json +3 -2
  8. package/guides/component-reference.md +3 -1
  9. package/guides/one-app-to-himi-app.md +30 -5
  10. package/guides/sdk-reference.json +7 -7
  11. package/package.json +2 -2
  12. package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +18 -0
  13. package/templates/beauty-salon/tier5-src/_shared/himi-events.ts +7 -0
  14. package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +18 -0
  15. package/templates/beauty-salon-owner/tier5-src/_shared/himi-events.ts +7 -0
  16. package/templates/branded-lite/dev/index.ts +2390 -851
  17. package/templates/branded-lite/himalaya.content.json +1 -1
  18. package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +18 -0
  19. package/templates/branded-lite/tier5-src/_shared/himi-events.ts +7 -0
  20. package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +803 -361
  21. package/templates/community-club/dev/index.ts +2387 -848
  22. package/templates/community-club/himalaya.content.json +1 -1
  23. package/templates/community-club/tier5-src/_shared/engine-intl.ts +18 -0
  24. package/templates/community-club/tier5-src/_shared/himi-events.ts +7 -0
  25. package/templates/community-club/tier5-src/himi-events-detail/index.ts +803 -361
  26. package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +18 -0
  27. package/templates/community-club-owner/tier5-src/_shared/himi-events.ts +7 -0
  28. package/templates/events-meetups/dev/index.ts +2387 -848
  29. package/templates/events-meetups/himalaya.content.json +1 -1
  30. package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +18 -0
  31. package/templates/events-meetups/tier5-src/_shared/himi-events.ts +7 -0
  32. package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +803 -361
  33. package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +18 -0
  34. package/templates/events-meetups-owner/tier5-src/_shared/himi-events.ts +7 -0
  35. package/templates/gym-fitness/dev/index.ts +2392 -853
  36. package/templates/gym-fitness/himalaya.content.json +1 -1
  37. package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +18 -0
  38. package/templates/gym-fitness/tier5-src/_shared/himi-events.ts +7 -0
  39. package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +803 -361
  40. package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +18 -0
  41. package/templates/gym-fitness-owner/tier5-src/_shared/himi-events.ts +7 -0
  42. package/templates/nonprofit-cause/dev/index.ts +2387 -848
  43. package/templates/nonprofit-cause/himalaya.content.json +1 -1
  44. package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +18 -0
  45. package/templates/nonprofit-cause/tier5-src/_shared/himi-events.ts +7 -0
  46. package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +803 -361
  47. package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +18 -0
  48. package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-events.ts +7 -0
  49. package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +18 -0
  50. package/templates/owner-lite/tier5-src/_shared/himi-events.ts +7 -0
  51. package/templates/podcast-creator/dev/index.ts +2388 -849
  52. package/templates/podcast-creator/himalaya.content.json +1 -1
  53. package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +18 -0
  54. package/templates/podcast-creator/tier5-src/_shared/himi-events.ts +7 -0
  55. package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +803 -361
  56. package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +18 -0
  57. package/templates/podcast-creator-owner/tier5-src/_shared/himi-events.ts +7 -0
  58. package/templates/portfolio-creative/dev/index.ts +2375 -836
  59. package/templates/portfolio-creative/himalaya.content.json +1 -1
  60. package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +18 -0
  61. package/templates/portfolio-creative/tier5-src/_shared/himi-events.ts +7 -0
  62. package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +803 -361
  63. package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +18 -0
  64. package/templates/portfolio-creative-owner/tier5-src/_shared/himi-events.ts +7 -0
  65. package/templates/restaurant/dev/index.ts +2388 -849
  66. package/templates/restaurant/himalaya.content.json +1 -1
  67. package/templates/restaurant/tier5-src/_shared/engine-intl.ts +18 -0
  68. package/templates/restaurant/tier5-src/_shared/himi-events.ts +7 -0
  69. package/templates/restaurant/tier5-src/himi-events-detail/index.ts +803 -361
  70. package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +18 -0
  71. package/templates/restaurant-owner/tier5-src/_shared/himi-events.ts +7 -0
  72. package/templates/retail-store/tier5-src/_shared/engine-intl.ts +18 -0
  73. package/templates/retail-store/tier5-src/_shared/himi-events.ts +7 -0
  74. package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +18 -0
  75. package/templates/retail-store-owner/tier5-src/_shared/himi-events.ts +7 -0
  76. package/templates/yoga-studio/dev/index.ts +2403 -864
  77. package/templates/yoga-studio/himalaya.content.json +1 -1
  78. package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +18 -0
  79. package/templates/yoga-studio/tier5-src/_shared/himi-events.ts +7 -0
  80. package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +803 -361
  81. package/templates/yoga-studio-owner/tier5-src/_shared/engine-intl.ts +18 -0
  82. package/templates/yoga-studio-owner/tier5-src/_shared/himi-events.ts +7 -0
@@ -1,7 +1,9 @@
1
1
  // Tier-5 bundle: himi-events — event detail.
2
- import { formatCurrency } from "../_shared/engine-intl.js";
2
+ import { calendarDay, formatClock, formatCurrency, formatWeekdayDateTime, formatWeekdayMonthDay, formatTimeZoneLabel } from "../_shared/engine-intl.js";
3
3
  import { defineService as registerService } from "@himalaya/state";
4
+ import { dispatch } from "@himalaya/dispatch";
4
5
  import { withOnlineWrites } from "../_shared/online-actions.js";
6
+ import { hostedSitePageOpener, isHostedWorker } from "../_shared/hosted-checkout.js";
5
7
  import { createEvent, getPermissionStatus, requestPermission } from "@himalaya/calendar";
6
8
  import { open } from "@himalaya/open-url";
7
9
  import { share as shareSheet } from "@himalaya/share";
@@ -10,70 +12,61 @@ import {
10
12
  checkoutReservation,
11
13
  createTicketReservation,
12
14
  getEvent,
13
- getEventGuestList,
14
15
  queryAvailableTickets,
15
- queryEvents,
16
16
  type WixAvailableTicket,
17
17
  type WixEventFieldset,
18
18
  type WixEvent,
19
- type WixGuestRsvp,
20
- type WixGuestTicket,
21
19
  } from "@himalaya/wix-events";
22
20
  import { session as wixPlatformSession } from "@himalaya/wix-platform";
23
21
  import { payCashierOrder } from "@himalaya/checkout";
24
22
  import { get, upsert, type DbEntity } from "@himalaya/local-db";
25
23
  import {
26
24
  currentInputId,
25
+ forgetRsvp,
27
26
  eventDescription,
28
- eventEndMillis,
29
27
  eventImage,
30
28
  eventLocation,
29
+ eventAddress,
31
30
  eventMapUrl,
32
31
  eventStartMillis,
33
32
  eventWhen,
34
33
  normalizeEvent,
35
- titleCase,
36
34
  type EventRow,
37
35
  } from "../_shared/himi-events";
38
- import { initials } from "../_shared/himi-text.js";
39
- import { hostedSitePageOpener, isHostedWorker } from "../_shared/hosted-checkout.js";
36
+ import { stashCopy, copyParam } from "../_shared/himi-copy.js";
37
+ import { openRichLink } from "../_shared/rich-content-link.js";
38
+ import { richBody } from "../_shared/rich-body.js";
40
39
  import { mayUseRegularTicketCheckout } from "../_shared/iap-attendance.js";
41
40
  import { ATTENDANCE_INITIAL, ATTENDANCE_NOT_HYDRATED, createAttendancePurchase, type AttendancePurchaseFields } from "../_shared/iap-attendance-purchase.js";
42
41
  import { resetUnhydratedKeysOnOlderRuntime } from "../_shared/iap-worker.js";
43
42
 
44
43
  let ticketCheckoutAllowed = false;
44
+ let ticketCheckoutDenied = false;
45
45
  let paymentEvent: unknown;
46
46
  let paymentTickets = new Map<string, WixAvailableTicket>();
47
47
  let ticketSelectionVersion = 0;
48
+ let ticketCheckoutInFlight = false;
49
+ let ticketSelectionQueue: Promise<void> = Promise.resolve();
50
+ let ticketSelectionState: S | undefined;
48
51
  let lastReady: S | undefined;
52
+ let cancelRsvpInFlight = false;
49
53
  const applePurchase = createAttendancePurchase({ kind: "eventsTicket", prefix: "event", pollAction: "himiEventDetail.iapPoll", onChange: () => { void svc.iapPublish(); } });
50
-
51
- async function priceTicket(event: unknown, id: string, version: number): Promise<boolean> {
52
- const available = paymentTickets.get(id);
54
+ async function priceTicket(event: unknown, id: string, version: number, ticket = paymentTickets.get(id)): Promise<boolean> {
53
55
  if (version !== ticketSelectionVersion) return false;
54
- await applePurchase.ticket(id, event, available);
56
+ await applePurchase.ticket(id, event, ticket);
55
57
  return version === ticketSelectionVersion;
56
58
  }
57
-
58
- /** A hosted signed-in event-page open is in flight (see openRegistration). */
59
59
  let hostedOpenInFlight = false;
60
- import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
61
- import { openRichLink } from "../_shared/rich-content-link.js";
62
- import { richBody } from "../_shared/rich-body.js";
63
-
64
- interface MapMarker {
65
- latitude: number;
66
- longitude: number;
67
- label?: string;
68
- }
69
-
70
- interface AttendeeAvatar {
71
- initials: string;
72
- }
73
60
 
74
61
  /** One buyer-visible ticket tier. */
75
62
  interface TicketTierRow {
76
63
  id: string;
64
+ ticketDefinitionId?: string;
65
+ pricingOptionId?: string;
66
+ guestPrice?: string;
67
+ minPrice?: number;
68
+ minimumPriceLabel?: string;
69
+ isGuestPrice?: boolean;
77
70
  name: string;
78
71
  /** Formatted price, or the free label. */
79
72
  priceLine: string;
@@ -83,6 +76,15 @@ interface TicketTierRow {
83
76
  /** Selection is rendered by the row itself (bound to `$item.isSelected`). */
84
77
  isSelected: boolean;
85
78
  notSelected: boolean;
79
+ quantity?: number;
80
+ feeNote?: string;
81
+ description?: string;
82
+ policy?: string;
83
+ priceAmount?: number;
84
+ maxQuantity?: number;
85
+ rowLimit?: number;
86
+ noPolicy?: boolean;
87
+ notAvailable?: boolean;
86
88
  }
87
89
 
88
90
  /** Registration CTA kind derived from the event's registration/eventType. */
@@ -110,7 +112,6 @@ interface S extends AttendancePurchaseFields {
110
112
  status: "loading" | "ready";
111
113
  id: string;
112
114
  title: string;
113
- initial: string;
114
115
  when: string;
115
116
  location: string;
116
117
  /** The plain-text description: RichContent's fallback, the calendar notes, and what shows with no document. */
@@ -124,17 +125,62 @@ interface S extends AttendancePurchaseFields {
124
125
  hasImage: boolean;
125
126
  noImage: boolean;
126
127
  mapUrl: string;
127
- noMap: boolean;
128
- /** Inline schematic map — a 0/1-marker set from the event's geo coordinates. */
129
- mapMarkers: MapMarker[];
130
- hasMapMarkers: boolean;
131
- noMapMarkers: boolean;
132
- /** Attendees ("N going") from the guest list — best-effort (the guest-list
133
- * endpoint is owner-scoped; a visitor rung just collapses the strip). */
134
- goingLabel: string;
135
- attendeeAvatars: AttendeeAvatar[];
136
- hasAttendees: boolean;
137
- noAttendees: boolean;
128
+ place: string;
129
+ placeIcon: string;
130
+ noPlace: boolean;
131
+ hidePlaceStatic: boolean;
132
+ price: string;
133
+ zone: string;
134
+ noPrice: boolean;
135
+ hideMapTap: boolean;
136
+ shortDescription: string;
137
+ noShortDescription: boolean;
138
+ noDescription: boolean;
139
+ noDate: boolean;
140
+ dateNotTappable: boolean;
141
+ hideDateStatic: boolean;
142
+ registrationState: string;
143
+ footerLabel: string;
144
+ footerAction: string;
145
+ footerCaption: string;
146
+ hideFooter: boolean;
147
+ noFooterButton: boolean;
148
+ noFooterCaption: boolean;
149
+ notice: string;
150
+ exploreLabel: string;
151
+ noNotice: boolean;
152
+ rsvpLabel: string;
153
+ rsvpId: string;
154
+ rsvpState: "none" | "going" | "waitlist";
155
+ hideRsvpChip: boolean;
156
+ hideGoingChip: boolean;
157
+ hideWaitlistedChip: boolean;
158
+ hideCancelRsvp: boolean;
159
+ hideCancelConfirm: boolean;
160
+ hideRsvpOptions: boolean;
161
+ ticketSheetOpen: boolean;
162
+ rsvpActionsOpen: boolean;
163
+ cancelConfirmOpen: boolean;
164
+ ticketTotal: string;
165
+ ticketCountLabel: string;
166
+ checkoutDisabled: boolean;
167
+ schedule: Array<{ time: string; name: string; detail: string }>;
168
+ fullSchedule: Array<{ time: string; name: string; detail: string }>;
169
+ noSchedule: boolean;
170
+ noFullSchedule: boolean;
171
+ moreDates: Array<{ id: string; date: string }>;
172
+ allDates: Array<{ id: string; date: string }>;
173
+ noMoreDates: boolean;
174
+ noAllDates: boolean;
175
+ policies: Array<{ name: string; text: string }>;
176
+ noPolicies: boolean;
177
+ hideWaitlist: boolean;
178
+ hideScheduled: boolean;
179
+ memberOnly: boolean;
180
+ hideMemberRsvp: boolean;
181
+ hideMemberWaitlist: boolean;
182
+ hideMemberTickets: boolean;
183
+ hideMemberScheduled: boolean;
138
184
  /** CTA variant flags — exactly one of {RSVP, tickets, external} shows, or
139
185
  * none for NO_REGISTRATION. Unknown/absent registration keeps RSVP (the
140
186
  * historical default). */
@@ -148,6 +194,10 @@ interface S extends AttendancePurchaseFields {
148
194
  /** Buyer-visible ticket tiers for a ticketed event (empty otherwise). Loaded
149
195
  * alongside the event so the price is on screen BEFORE the CTA is tapped. */
150
196
  ticketTiers: TicketTierRow[];
197
+ ticketCurrency: string;
198
+ ticketLimitPerOrder: number;
199
+ ticketPurchaseBlocked: boolean;
200
+ regularTicketsHidden: boolean;
151
201
  hasTiers: boolean;
152
202
  noTiers: boolean;
153
203
  /** More than one tier ⇒ the picker is worth showing; exactly one ⇒ the CTA
@@ -157,8 +207,6 @@ interface S extends AttendancePurchaseFields {
157
207
  /** The tier the CTA will reserve. Defaults to the cheapest on-sale tier so the
158
208
  * button is never dead, and follows the buyer's tap after that. */
159
209
  selectedTierId: string;
160
- ticketPurchaseBlocked: boolean;
161
- regularTicketsHidden: boolean;
162
210
  /** "General · $25" — echoed next to the CTA so the choice is visible. */
163
211
  selectedTierLabel: string;
164
212
  /** External/ticketed registration URL when the payload carries one. */
@@ -169,9 +217,9 @@ interface S extends AttendancePurchaseFields {
169
217
  shareUrl: string;
170
218
  calendarStart: number;
171
219
  calendarEnd: number;
172
- actionMessage: string;
220
+ snackbarMessage: string;
221
+ noSnackbar: boolean;
173
222
  isAddingCalendar: boolean;
174
- statusLabel: string;
175
223
  emptyText: string;
176
224
  errored: boolean;
177
225
  notErrored: boolean;
@@ -199,7 +247,8 @@ function tierPrice(t: WixAvailableTicket): string {
199
247
  }
200
248
 
201
249
  function tierAvailable(t: WixAvailableTicket): boolean {
202
- return !!t.id && !/SALE_SCHEDULED|SALE_ENDED|CANCELED|SOLD_OUT/i.test(t.saleStatus ?? "");
250
+ const salesDetails = (t as WixAvailableTicket & { salesDetails?: { soldOut?: boolean } }).salesDetails;
251
+ return !!t.id && !salesDetails?.soldOut && !/SALE_SCHEDULED|SALE_ENDED|CANCELED|SOLD_OUT/i.test(t.saleStatus ?? "");
203
252
  }
204
253
 
205
254
  /** Map the buyer-visible tiers onto rows, cheapest first (so the default pick is
@@ -207,75 +256,155 @@ function tierAvailable(t: WixAvailableTicket): boolean {
207
256
  function tierRows(defs: WixAvailableTicket[], selectedId: string): TicketTierRow[] {
208
257
  return defs
209
258
  .filter((t) => !!t.id)
259
+ .flatMap((t) => {
260
+ const raw = t as WixAvailableTicket & { pricingOptions?: Array<{ id?: string; optionId?: string; name?: string; price?: { amount?: string; value?: string; currency?: string } }>; pricingMethod?: { type?: string; pricingOptions?: { optionDetails?: Array<{ id?: string; optionId?: string; name?: string; price?: { amount?: string; value?: string; currency?: string } }> } } };
261
+ const options = raw.pricingOptions ?? raw.pricingMethod?.pricingOptions?.optionDetails ?? [];
262
+ return options.length ? options.map((option) => ({ ...t, id: `${t.id}:${option.optionId ?? option.id}`, name: [t.name, option.name].filter(Boolean).join(" · "), price: { ...t.price, amount: option.price?.amount ?? option.price?.value ?? t.price?.amount, currency: option.price?.currency ?? t.price?.currency }, __optionId: option.optionId ?? option.id })) : [t as WixAvailableTicket & { __optionId?: string }];
263
+ })
210
264
  .slice()
211
- .sort((a, b) => Number(a.price?.amount ?? 0) - Number(b.price?.amount ?? 0))
265
+ .sort((a, b) => Number(a.orderIndex ?? 0) - Number(b.orderIndex ?? 0))
212
266
  .map((t) => {
213
267
  const available = tierAvailable(t);
214
268
  const selected = t.id === selectedId;
269
+ const originalId = t.id?.split(":")[0] ?? "";
270
+ const optionId = (t as WixAvailableTicket & { __optionId?: string }).__optionId;
271
+ const raw = t as WixAvailableTicket & { pricingMethod?: { type?: string; pricingType?: string; guestPrice?: { value?: string } }; pricing?: { pricingType?: string } };
272
+ const isGuestPrice = raw.pricingMethod?.guestPrice != null || /guestPrice/i.test(raw.pricingMethod?.type ?? raw.pricingMethod?.pricingType ?? raw.pricing?.pricingType ?? "");
273
+ const minPrice = Number(raw.pricingMethod?.guestPrice?.value ?? 0);
215
274
  return {
216
275
  id: t.id ?? "",
276
+ ticketDefinitionId: originalId,
277
+ pricingOptionId: optionId,
278
+ guestPrice: "",
279
+ minPrice,
217
280
  name: (t.name || copyParam("event.tierFallbackName", "Ticket")).trim(),
218
281
  priceLine: tierPrice(t),
219
- unavailableNote: available ? "" : copyParam("event.tierOffSale", "Not on sale"),
282
+ unavailableNote: available ? "" : /SALE_SCHEDULED/i.test(t.saleStatus ?? "") ? copyParam("event.onSaleFrom", "On sale {date}").replace("{date}", t.salePeriod?.startDate ? formatWeekdayDateTime(new Date(t.salePeriod.startDate)) : "") : copyParam("event.soldOut", "Sold out"),
220
283
  available,
221
284
  isSelected: selected,
222
285
  notSelected: !selected,
286
+ priceAmount: Number(t.price?.amount ?? 0),
287
+ ...(isGuestPrice ? { priceLine: detailCopy("event.payWhatYouWant", "Pay what you want") } : {}),
288
+ ...(isGuestPrice ? { priceAmount: minPrice, isGuestPrice: true } : {}),
289
+ ...(isGuestPrice ? { minimumPriceLabel: detailCopy("event.minPrice", "Minimum {price}").replace("{price}", formatCurrency(minPrice, t.price?.currency || "USD", "en")) } : {}),
223
290
  };
224
291
  });
225
292
  }
226
293
 
227
- /** Best-effort tier read — never sinks the screen if the tier endpoint fails. */
228
- async function loadTiers(eventId: string): Promise<{ rows: TicketTierRow[]; tickets: Map<string, WixAvailableTicket> }> {
229
- try {
230
- const response = await queryAvailableTickets(eventId);
231
- const defs = response.definitions ?? [];
294
+ function loadedTiers(defs: WixAvailableTicket[]): { rows: TicketTierRow[]; tickets: Map<string, WixAvailableTicket> } {
232
295
  const tickets = new Map(defs.filter((t) => t.id).map((t) => [t.id!, t]));
233
296
  const cheapestAvailable = defs
234
297
  .filter(tierAvailable)
235
298
  .slice()
236
299
  .sort((a, b) => Number(a.price?.amount ?? 0) - Number(b.price?.amount ?? 0))[0];
237
- return { rows: tierRows(defs, cheapestAvailable?.id ?? ""), tickets };
300
+ const rows = tierRows(defs, cheapestAvailable?.id ?? "").map((row) => {
301
+ const ticket = tickets.get(row.ticketDefinitionId ?? row.id);
302
+ if (!ticket) return { ...row, quantity: 0, description: "", feeNote: "", policy: "", noPolicy: true, notAvailable: !row.available, notGuestPrice: !row.isGuestPrice, maxQuantity: 0 };
303
+ const policy = (ticket as unknown as { policy?: string; policyText?: string }).policyText || (ticket as unknown as { policy?: string }).policy || "";
304
+ return { ...row, quantity: 0, description: ticket.description || "", feeNote: ((ticket as unknown as { wixFeeConfig?: { type?: string }; feeType?: string }).wixFeeConfig?.type ?? (ticket as unknown as { feeType?: string }).feeType) === "FEE_ADDED_AT_CHECKOUT" ? detailCopy("event.serviceFee", "+ ticket service fee") : "", policy, noPolicy: !policy, notAvailable: !row.available, notGuestPrice: !row.isGuestPrice, priceAmount: row.priceAmount ?? Number(ticket.price?.amount ?? 0), rowLimit: row.available ? ticket.limitPerCheckout ?? 99 : 0, maxQuantity: row.available ? ticket.limitPerCheckout ?? 99 : 0 };
305
+ });
306
+ return { rows, tickets };
307
+ }
308
+
309
+ /** Classify the buyer's selected option or bid, rather than the definition's cheapest price. */
310
+ function selectedPaymentTicket(row: TicketTierRow, tickets = paymentTickets): WixAvailableTicket | undefined {
311
+ const ticket = tickets.get(row.ticketDefinitionId ?? row.id);
312
+ if (!ticket) return;
313
+ const priced = tierRows([ticket], row.id).find((next) => next.id === row.id);
314
+ if (!priced) return;
315
+ const amount = priced.isGuestPrice ? Number(row.guestPrice || priced.minPrice || 0) : priced.priceAmount ?? 0;
316
+ return { ...ticket, free: amount === 0, price: { ...ticket.price, amount: String(amount) } };
317
+ }
318
+
319
+ /** Best-effort tier read — never sinks the screen if the tier endpoint fails. */
320
+ async function loadTiers(eventId: string): Promise<{ rows: TicketTierRow[]; tickets: Map<string, WixAvailableTicket> }> {
321
+ try {
322
+ return loadedTiers((await queryAvailableTickets(eventId)).definitions ?? []);
238
323
  } catch {
239
324
  return { rows: [], tickets: new Map() };
240
325
  }
241
326
  }
242
327
 
243
328
  function derive(s: S): S {
244
- const regular = ticketCheckoutAllowed || applePurchase.usesRegularPlatform();
329
+ // A price refresh can publish a ready snapshot captured before checkout's
330
+ // guard denied the selection. Keep that denial visible until a new selection
331
+ // or load clears it, including across delayed StoreKit publications.
332
+ const snackbarMessage = s.snackbarMessage || (ticketCheckoutDenied ? copyParam("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") : "");
245
333
  const hasContent = !!s.title;
246
- const hasMapMarkers = s.mapMarkers.length > 0;
247
- const hasAttendees = s.attendeeAvatars.length > 0;
248
- const purchase = applePurchase.fields(!!s.selectedTierId);
249
- const tiers = s.ticketTiers.map((t) => ({ ...t, priceLine: regular ? tierPrice(paymentTickets.get(t.id) ?? {}) : t.id === s.selectedTierId ? purchase.iapPrice : "" }));
334
+ const count = s.ticketTiers.reduce((n, row) => n + (row.quantity ?? 0), 0);
335
+ const total = s.ticketTiers.reduce((n, row) => n + (row.isGuestPrice ? Number(row.guestPrice || row.minPrice || 0) : row.priceAmount ?? 0) * (row.quantity ?? 0), 0);
336
+ const ticketTiers = s.ticketTiers.map((row) => {
337
+ const definitionId = row.ticketDefinitionId ?? row.id;
338
+ const sameDefinitionElsewhere = s.ticketTiers.reduce((n, other) => n + ((other.ticketDefinitionId ?? other.id) === definitionId && other.id !== row.id ? other.quantity ?? 0 : 0), 0);
339
+ const selectedElsewhere = s.ticketTiers.reduce((n, other) => n + (other.id === row.id ? 0 : other.quantity ?? 0), 0);
340
+ return { ...row, maxQuantity: Math.max(0, Math.min((row.rowLimit ?? 99) - sameDefinitionElsewhere, s.ticketLimitPerOrder - selectedElsewhere)) };
341
+ });
342
+ const chosenRows = s.ticketTiers.filter((row) => (row.quantity ?? 0) > 0);
343
+ const singleTicketSelected = chosenRows.length === 1 && chosenRows[0]!.quantity === 1;
344
+ const purchase = applePurchase.fields(singleTicketSelected);
345
+ const regularRoute = ticketCheckoutAllowed || applePurchase.usesRegularPlatform();
250
346
  const next = {
251
347
  ...s,
252
- _poll: undefined,
348
+ ticketTiers,
253
349
  ...purchase,
254
- actionMessage: regular && !ticketCheckoutAllowed && s.actionMessage === copyParam("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") ? "" : s.actionMessage,
255
- ticketTiers: tiers,
256
- regularTicketsHidden: s.registrationKind !== "tickets" || !purchase.iapBuyHidden,
257
- ticketPurchaseBlocked: s.registrationKind === "tickets" && !regular,
350
+ iapBuyHidden: !singleTicketSelected || purchase.iapBuyHidden,
351
+ // The shared attendance purchase produces a one-shot native poll directive.
352
+ // Let it through once; its commerce adapter consumes it, so later derives
353
+ // cannot replay it from a cached ready state.
354
+ regularTicketsHidden: s.registrationKind !== "tickets" || (singleTicketSelected && !purchase.iapBuyHidden),
355
+ ticketPurchaseBlocked: ticketCheckoutDenied || (s.registrationKind === "tickets" && !regularRoute && purchase.iapBuyHidden),
258
356
  hasDescriptionJson: s.descriptionJson !== "",
259
- noMap: !s.mapUrl,
260
- hasMapMarkers,
261
- noMapMarkers: !hasMapMarkers,
262
- hasAttendees,
263
- noAttendees: !hasAttendees,
264
- showRsvp: s.registrationKind === "rsvp",
265
- hideRsvp: s.registrationKind !== "rsvp",
357
+ noPlace: !s.place,
358
+ noDate: !s.when,
359
+ dateNotTappable: !s.calendarStart,
360
+ hideDateStatic: !s.when || !!s.calendarStart,
361
+ hidePlaceStatic: !s.place || !!s.mapUrl,
362
+ noPrice: !s.price,
363
+ hideMapTap: !s.place || !s.mapUrl,
364
+ noShortDescription: !s.shortDescription || s.shortDescription.trim() === s.description.trim(),
365
+ noDescription: !s.description && !s.descriptionJson,
366
+ showRsvp: s.registrationState === "rsvp",
367
+ hideRsvp: s.registrationState !== "rsvp" || s.memberOnly,
266
368
  hasTiers: s.ticketTiers.length > 0,
267
369
  noTiers: s.ticketTiers.length === 0,
268
370
  // One tier is not a choice — showing a single-row "picker" would be noise.
269
371
  showTierPicker: s.ticketTiers.length > 1,
270
372
  hideTierPicker: s.ticketTiers.length <= 1,
271
373
  selectedTierLabel: (() => {
272
- const picked = tiers.find((t) => t.id === s.selectedTierId);
374
+ const picked = s.ticketTiers.find((t) => t.id === s.selectedTierId);
273
375
  return picked ? [picked.name, picked.priceLine].filter(Boolean).join(" · ") : "";
274
376
  })(),
275
- showTickets: s.registrationKind === "tickets",
276
- hideTickets: s.registrationKind !== "tickets",
277
- showExternal: s.registrationKind === "external",
278
- hideExternal: s.registrationKind !== "external",
377
+ showTickets: s.registrationState === "tickets",
378
+ hideTickets: s.registrationState !== "tickets" || s.memberOnly,
379
+ showExternal: s.registrationState === "external",
380
+ hideExternal: s.registrationState !== "external" || s.memberOnly,
381
+ noNotice: !s.notice,
382
+ snackbarMessage,
383
+ noSnackbar: !snackbarMessage,
384
+ hideWaitlist: s.registrationState !== "waitlist" || s.memberOnly,
385
+ hideScheduled: s.registrationState !== "scheduled",
386
+ memberOnly: s.memberOnly,
387
+ hideMemberRsvp: !s.memberOnly || s.registrationState !== "rsvp",
388
+ hideMemberWaitlist: !s.memberOnly || s.registrationState !== "waitlist",
389
+ hideMemberTickets: !s.memberOnly || s.registrationState !== "tickets",
390
+ // Scheduled registration uses the shared disabled footer, including for members.
391
+ hideMemberScheduled: true,
392
+ hideRsvpChip: s.rsvpState !== "waitlist",
393
+ hideGoingChip: s.rsvpState !== "going",
394
+ hideWaitlistedChip: s.rsvpState !== "waitlist",
395
+ hideCancelRsvp: !s.rsvpId,
396
+ hideCancelConfirm: !s.cancelConfirmOpen,
397
+ hideRsvpOptions: s.cancelConfirmOpen,
398
+ noFooterButton: !s.footerLabel,
399
+ noFooterCaption: !s.footerCaption,
400
+ hideFooter: s.status !== "ready" || s.errored || !s.footerLabel || s.rsvpState !== "none",
401
+ checkoutDisabled: count === 0 || count > s.ticketLimitPerOrder || ticketCheckoutInFlight || !ticketCheckoutAllowed || s.ticketTiers.some((row) => row.isGuestPrice && (row.quantity ?? 0) > 0 && (!String(row.guestPrice ?? "").trim() || Number(row.guestPrice) < (row.minPrice ?? 0) || !Number.isFinite(Number(row.guestPrice)))) || s.ticketTiers.some((row) => (row.quantity ?? 0) > (row.maxQuantity ?? 99)),
402
+ ticketTotal: formatCurrency(total, s.ticketCurrency || "USD", "en"),
403
+ ticketCountLabel: copyParam("event.ticketCount", "{n} tickets, plus fees").replace("{n}", String(count)),
404
+ noSchedule: s.schedule.length === 0,
405
+ noFullSchedule: s.fullSchedule.length === 0,
406
+ noMoreDates: s.moreDates.length === 0,
407
+ noAllDates: s.allDates.length === 0,
279
408
  isLoading: s.status === "loading",
280
409
  notLoading: s.status !== "loading",
281
410
  notErrored: !s.errored,
@@ -286,7 +415,6 @@ function derive(s: S): S {
286
415
  ? copyParam("event.errorText", "Event could not be loaded.")
287
416
  : copyParam("event.notFoundText", "Event not found.")),
288
417
  };
289
- if (next.status === "ready") lastReady = next;
290
418
  return next;
291
419
  }
292
420
 
@@ -320,20 +448,120 @@ function calendarFallbackUrl(s: S, end: number): string {
320
448
 
321
449
  /** The Events v3 fieldsets the detail reads (see `WixEventFieldset`). DETAILS carries mainImage and
322
450
  * shortDescription: REGISTRATION fills eventType, which skips the query enrichment that used to supply them. */
323
- export const EVENT_DETAIL_FIELDSETS: WixEventFieldset[] = ["DETAILS", "TEXTS", "REGISTRATION", "URLS", "FORM"];
451
+ export const EVENT_DETAIL_FIELDSETS: WixEventFieldset[] = ["DETAILS", "TEXTS", "REGISTRATION", "URLS", "FORM", "AGENDA"];
324
452
 
325
- /** Map the decoded registration/eventType onto the CTA variant. The decoded
453
+ /** Map registration status and decoded eventType onto the CTA variant. The decoded
326
454
  * `eventType` already folds registration.type / registration.initialType /
327
- * the legacy flat `type` (decodeEvent, #478). Unknown ⇒ RSVP (historical). */
455
+ * the legacy flat `type` (decodeEvent, #478). Without a recognized type or
456
+ * status there is no registration action to offer. */
328
457
  export function registrationKindOf(e: WixEvent): RegistrationKind {
329
- const raw = e as unknown as { registration?: { type?: string; initialType?: string } };
330
- const value = (e.eventType ?? raw.registration?.type ?? raw.registration?.initialType ?? "").toUpperCase();
331
- if (!value) return "rsvp";
332
- if (value.includes("NO_REGISTRATION")) return "none";
458
+ const raw = e as unknown as { registration?: { type?: string; initialType?: string; status?: string } };
459
+ const value = (raw.registration?.type ?? e.eventType ?? raw.registration?.initialType ?? "").toUpperCase();
460
+ if (value === "NONE" || value.includes("NO_REGISTRATION")) return "none";
461
+ // Open statuses select the footer action, so they must also select the
462
+ // inventory read and action guards when type metadata is absent or stale.
463
+ switch (raw.registration?.status?.toUpperCase()) {
464
+ case "OPEN_TICKETS": return "tickets";
465
+ case "OPEN_EXTERNAL": return "external";
466
+ case "OPEN_RSVP":
467
+ case "OPEN_RSVP_WAITLIST_ONLY":
468
+ case "SCHEDULED_RSVP": return "rsvp";
469
+ }
333
470
  if (value.includes("EXTERNAL")) return "external";
334
471
  if (value.includes("TICKET")) return "tickets";
335
472
  if (value.includes("RSVP")) return "rsvp";
336
- return "rsvp";
473
+ return "none";
474
+ }
475
+
476
+ function detailCopy(key: string, fallback: string): string { return copyParam(key, fallback); }
477
+
478
+ function detailDate(e: WixEvent): { line: string; zone: string; start: number; end: number } {
479
+ const raw = e as unknown as {
480
+ dateAndTimeSettings?: { startDate?: string; endDate?: string; dateAndTimeTbd?: boolean; dateAndTimeTbdMessage?: string; timeZoneId?: string; showTimeZone?: boolean; hideEndDate?: boolean };
481
+ scheduling?: { startDate?: string; endDate?: string; timeZoneId?: string; tbd?: string; endDateHidden?: boolean; config?: { startDate?: string; endDate?: string; timeZoneId?: string } };
482
+ };
483
+ const settings = raw.dateAndTimeSettings ?? {};
484
+ const schedule = raw.scheduling;
485
+ const startValue = settings.startDate ?? schedule?.config?.startDate ?? schedule?.startDate;
486
+ const endValue = settings.endDate ?? schedule?.config?.endDate ?? schedule?.endDate;
487
+ const timeZoneId = settings.timeZoneId ?? schedule?.config?.timeZoneId ?? schedule?.timeZoneId;
488
+ const start = startValue ? new Date(startValue) : null;
489
+ const end = endValue ? new Date(endValue) : null;
490
+ const validStart = start && !Number.isNaN(start.getTime());
491
+ if (settings.dateAndTimeTbd || (!validStart && schedule?.tbd)) return { line: settings.dateAndTimeTbdMessage || schedule?.tbd || detailCopy("event.dateTbd", "Date to be announced"), zone: "", start: 0, end: 0 };
492
+ if (!validStart) return { line: "", zone: "", start: 0, end: 0 };
493
+ const zone = timeZoneId;
494
+ const format = (d: Date): string => formatWeekdayDateTime(d, "en-US", zone);
495
+ const a = format(start);
496
+ const b = end && !Number.isNaN(end.getTime()) ? format(end) : "";
497
+ const parts = (date: Date) => {
498
+ const time = formatClock(date, "en-US", zone);
499
+ const clock = time.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i);
500
+ return { day: formatWeekdayMonthDay(date, "en-US", zone), hour: clock?.[1] ?? "", minute: clock?.[2] ?? "", period: clock?.[3] ?? "", time, calendarDay: calendarDay(date, zone) };
501
+ };
502
+ const startParts = parts(start);
503
+ const endParts = end && !Number.isNaN(end.getTime()) ? parts(end) : undefined;
504
+ const startDisplayDay = startParts.day.replace(/, \d{4}$/, "");
505
+ const hideEndDate = settings.hideEndDate ?? schedule?.endDateHidden;
506
+ const line = hideEndDate || !endParts ? `${startDisplayDay} at ${startParts.time}` : startParts.calendarDay === endParts.calendarDay
507
+ ? `${startDisplayDay} · ${startParts.hour}:${startParts.minute}${startParts.period !== endParts.period ? ` ${startParts.period}` : ""} – ${endParts.time}`
508
+ : `${a} – ${b}`;
509
+ let zoneLine = "";
510
+ if (settings.showTimeZone && zone) zoneLine = formatTimeZoneLabel(start, zone, "en");
511
+ return { line, zone: zoneLine, start: start.getTime(), end: end?.getTime() ?? start.getTime() + 3600000 };
512
+ }
513
+
514
+ type EventDetailMoney = { formattedValue?: string; value?: string; currency?: string };
515
+
516
+ function detailMoney(money?: EventDetailMoney): string {
517
+ if (money?.formattedValue) return money.formattedValue;
518
+ const value = money?.value;
519
+ if (value == null || String(value).trim() === "" || !money?.currency || !Number.isFinite(Number(value))) return "";
520
+ return formatCurrency(Number(value), money.currency, "en");
521
+ }
522
+
523
+ function detailPlace(e: WixEvent): { value: string; mapUrl: string; hideMapTap: boolean; icon: string } {
524
+ const raw = e as unknown as { location?: { type?: string; name?: string; locationTbd?: boolean; address?: { formattedAddress?: string; formatted?: string } | string } };
525
+ const place = raw.location;
526
+ const type = (place?.type ?? "").toUpperCase();
527
+ if (place?.locationTbd) return { value: place.name || detailCopy("event.locationTbd", "Location to be announced"), mapUrl: "", hideMapTap: true, icon: "location" };
528
+ if (type === "ONLINE") return { value: detailCopy("event.onlineEvent", "Online event"), mapUrl: "", hideMapTap: true, icon: "globe" };
529
+ const address = eventAddress({ ...e, location: { ...e.location, name: "" } } as WixEvent);
530
+ const value = [place?.name, address].filter(Boolean).join(", ") || place?.name || "";
531
+ const geo = (e.location as unknown as { coordinates?: { latitude?: number; longitude?: number; lat?: number; lng?: number }; location?: { latitude?: number; longitude?: number; lat?: number; lng?: number } })?.coordinates
532
+ ?? (e.location as unknown as { location?: { latitude?: number; longitude?: number; lat?: number; lng?: number } })?.location;
533
+ const hasCoordinates = typeof (geo?.latitude ?? geo?.lat) === "number" && typeof (geo?.longitude ?? geo?.lng) === "number";
534
+ const mapped = address || hasCoordinates ? eventMapUrl(e) : "";
535
+ return { value, mapUrl: mapped, hideMapTap: !mapped, icon: "location" };
536
+ }
537
+
538
+ function eventStatus(e: WixEvent): string {
539
+ const raw = e as unknown as { status?: string };
540
+ return (raw.status ?? e.eventStatus ?? "").toUpperCase();
541
+ }
542
+
543
+ function registrationUi(e: WixEvent, now: number): { state: string; label: string; action: string; caption: string; notice: string } {
544
+ const raw = e as unknown as { registration?: { status?: string; registrationPaused?: boolean; registrationDisabled?: boolean; allowedGuestTypes?: string; rsvp?: { startDate?: string }; tickets?: { soldOut?: boolean }; external?: { url?: string } }; form?: { messages?: { registrationClosed?: { message?: string; exploreEventsActionLabel?: string }; ticketsUnavailable?: { message?: string; exploreEventsActionLabel?: string } } } };
545
+ const r = raw.registration ?? {};
546
+ const status = eventStatus(e);
547
+ const type = registrationKindOf(e);
548
+ if (status === "CANCELED") return { state: "notice", label: "", action: "", caption: "", notice: detailCopy("event.canceledNotice", "This event was canceled.") };
549
+ if (status === "ENDED") return { state: "notice", label: "", action: "", caption: "", notice: detailCopy("event.endedNotice", "This event has ended.") };
550
+ if (type === "none" || r.registrationDisabled) return { state: "none", label: "", action: "", caption: "", notice: "" };
551
+ const ownerClosed = raw.form?.messages?.registrationClosed?.message;
552
+ const ownerSold = raw.form?.messages?.ticketsUnavailable?.message;
553
+ if (["CLOSED_AUTOMATICALLY", "CLOSED_MANUALLY"].includes(r.status || "") || r.registrationPaused) return { state: "notice", label: "", action: "", caption: "", notice: ownerClosed || detailCopy("event.closedNotice", "Registration is closed.") };
554
+ if (type === "tickets" && r.tickets?.soldOut) return { state: "notice", label: "", action: "", caption: "", notice: ownerSold || detailCopy("event.soldOutNotice", "Tickets are sold out.") };
555
+ if (r.status === "SCHEDULED_RSVP") return { state: "scheduled", label: detailCopy("event.rsvpCta", "RSVP"), action: "", caption: detailCopy("event.opensCaption", "RSVP opens {date}.").replace("{date}", r.rsvp?.startDate ? formatWeekdayDateTime(new Date(r.rsvp.startDate)) : ""), notice: "" };
556
+ if (r.status === "OPEN_RSVP_WAITLIST_ONLY") return { state: "waitlist", label: detailCopy("event.waitlistCta", "Join Waitlist"), action: "himi.open", caption: detailCopy("event.fullCaption", "This event is full."), notice: "" };
557
+ if (r.status === "OPEN_TICKETS" || type === "tickets") return { state: "tickets", label: detailCopy("event.ticketsCta", "Buy Tickets"), action: "openTickets", caption: "", notice: "" };
558
+ if (r.status === "OPEN_EXTERNAL" || type === "external") return registrationUrlOf(e)
559
+ ? { state: "external", label: detailCopy("event.externalCta", "Register"), action: "openRegistration", caption: "", notice: "" }
560
+ : { state: "none", label: "", action: "", caption: "", notice: "" };
561
+ if (type === "rsvp" && r.status === "OPEN_RSVP") return { state: "rsvp", label: detailCopy("event.rsvpCta", "RSVP"), action: "himi.open", caption: "", notice: "" };
562
+ if (r.status === "OPEN_RSVP" || (type === "rsvp" && !r.status)) return { state: "rsvp", label: detailCopy("event.rsvpCta", "RSVP"), action: "himi.open", caption: "", notice: "" };
563
+ void now;
564
+ return { state: "none", label: "", action: "", caption: "", notice: "" };
337
565
  }
338
566
 
339
567
  /** External registration URL / event page URL from the raw payload ("" when absent). */
@@ -365,48 +593,6 @@ export function eventPageUrlOf(e: WixEvent): string {
365
593
  return "";
366
594
  }
367
595
 
368
- /** Geo coordinates → a 0/1-entry marker set for the inline schematic map. */
369
- export function eventMapMarkers(e: WixEvent): MapMarker[] {
370
- const loc = e.location as unknown as {
371
- name?: string;
372
- location?: { latitude?: number; longitude?: number; lat?: number; lng?: number };
373
- coordinates?: { latitude?: number; longitude?: number; lat?: number; lng?: number };
374
- } | undefined;
375
- const geo = loc?.coordinates ?? loc?.location;
376
- const latitude = geo?.latitude ?? geo?.lat;
377
- const longitude = geo?.longitude ?? geo?.lng;
378
- if (typeof latitude !== "number" || typeof longitude !== "number") return [];
379
- return [{ latitude, longitude, ...(loc?.name ? { label: loc.name } : {}) }];
380
- }
381
-
382
- /** Best-effort attendee summary: "N going" + up-to-6 initials for the avatar
383
- * strip. The guest-list endpoint is owner-scoped — a visitor rung throws and
384
- * the caller collapses the strip (never fails the screen). */
385
- async function attendeeSummary(eventId: string): Promise<{ goingLabel: string; avatars: AttendeeAvatar[] }> {
386
- const nameOf = (g: WixGuestRsvp | WixGuestTicket): string =>
387
- [g.firstName, g.lastName].filter(Boolean).join(" ").trim();
388
- try {
389
- let list = await getEventGuestList(eventId, { ticketed: false });
390
- if (!(list.rsvps?.length || list.total)) {
391
- list = await getEventGuestList(eventId, { ticketed: true });
392
- }
393
- const guests: Array<WixGuestRsvp | WixGuestTicket> = list.rsvps?.length ? list.rsvps : (list.tickets ?? []);
394
- const going = guests
395
- .filter((g) => {
396
- const status = (g.status ?? "").toUpperCase();
397
- return !status || ["YES", "ARRIVED", "PAID", "FREE", "OFFLINE_PENDING", "WAITING"].includes(status);
398
- });
399
- const total = list.facets?.status?.total ?? list.total ?? going.length;
400
- if (!total) return { goingLabel: "", avatars: [] };
401
- return {
402
- goingLabel: copyTemplate("event.goingLabel", "{count} going", { count: total }),
403
- avatars: going.slice(0, 6).map((g) => ({ initials: initials(nameOf(g) || g.email || "Guest") })),
404
- };
405
- } catch {
406
- return { goingLabel: "", avatars: [] };
407
- }
408
- }
409
-
410
596
  /** The description pair: the v3 Ricos `description` for RichContent, and its plain text. A string description
411
597
  * (or none) keeps `eventDescription`'s legacy read, with no document. */
412
598
  function eventBody(e: WixEvent): { description: string; descriptionJson: string } {
@@ -415,33 +601,41 @@ function eventBody(e: WixEvent): { description: string; descriptionJson: string
415
601
  return { description: bodyText, descriptionJson: bodyJson };
416
602
  }
417
603
 
418
- /** getEvent returns the slim BASE fieldset (no `registration`); enrich from the
419
- * richer query when the type is unknown — best-effort, find-by-id. */
420
- async function enrichRegistration(id: string, e: WixEvent): Promise<WixEvent> {
421
- if (e.eventType) return e;
604
+ type WixCall = (method: string, path: string, body?: unknown) => Promise<unknown>;
605
+ function wixCall(): WixCall | undefined { return (globalThis as { himalaya?: { wixApi?: { call?: WixCall } } }).himalaya?.wixApi?.call; }
606
+ type OptionalRead = { ok: true; value: Record<string, unknown> } | { ok: false; code: "unavailable" };
607
+ async function bestEffort(method: string, path: string, body?: unknown): Promise<OptionalRead> {
422
608
  try {
423
- const list = await queryEvents({ limit: 100 });
424
- const match = (list.events ?? []).find((candidate) => candidate.id === id);
425
- if (!match) return e;
426
- // [rico] #3372 — the query's rows are read without TEXTS, so they carry no Ricos `description`: keep the
427
- // one this detail fetched, or the enrichment would silently drop the document on every real site.
428
- const description = (e as unknown as { description?: unknown }).description;
429
- return description === undefined ? match : ({ ...match, description } as unknown as WixEvent);
609
+ const call = wixCall();
610
+ return call ? { ok: true, value: await call(method, path, body) as Record<string, unknown> } : { ok: false, code: "unavailable" };
430
611
  } catch {
431
- return e;
612
+ return { ok: false, code: "unavailable" };
432
613
  }
433
614
  }
615
+ async function signedInMember(): Promise<boolean> {
616
+ try { const identity = await dispatch("himi.wix.memberIdentity") as { email?: string } | null; return !!identity?.email; } catch { return false; }
617
+ }
434
618
 
435
- const defineService = withOnlineWrites(registerService, ["buyApple", "openRegistration"], {
619
+ const defineService = withOnlineWrites(registerService, ["buyApple", "openRegistration", "checkout", "cancelRsvp"], {
436
620
  derive,
437
- messageFields: ["actionMessage"],
621
+ messageFields: ["snackbarMessage"],
438
622
  requiresConnectivity: (action, state) => action !== "openRegistration"
439
623
  || (state.registrationKind === "tickets" && !!state.id && !isHostedWorker()),
440
624
  });
441
- const svc = defineService<S, {
625
+ type DetailActions = {
442
626
  load: (s: S, arg?: unknown) => Promise<S | undefined>;
443
627
  openMap: (s: S) => Promise<S>;
444
- selectTier: (s: S, arg?: unknown) => Promise<S | undefined>;
628
+ openTickets: (s: S) => Promise<S | undefined>;
629
+ closeTickets: (s: S) => S;
630
+ setQuantity: (s: S, arg?: unknown) => Promise<S | undefined>;
631
+ setGuestPrice: (s: S, arg?: unknown) => S;
632
+ checkout: (s: S) => Promise<S | undefined>;
633
+ openRsvpActions: (s: S) => S;
634
+ closeRsvpActions: (s: S) => S;
635
+ beginCancelRsvp: (s: S) => S;
636
+ keepRsvp: (s: S) => S;
637
+ cancelRsvp: (s: S) => Promise<S>;
638
+ noop: (s: S) => S;
445
639
  openRegistration: (s: S) => Promise<S>;
446
640
  share: (s: S) => Promise<S>;
447
641
  addToCalendar: (s: S) => Promise<S>;
@@ -452,16 +646,65 @@ const svc = defineService<S, {
452
646
  iapPublish: (s: S) => S;
453
647
  iapRefresh: (s: S) => Promise<S>;
454
648
  iapPoll: (s: S) => Promise<S>;
455
- }>("himiEventDetail", {
456
- notHydrated: [...ATTENDANCE_NOT_HYDRATED, "ticketPurchaseBlocked"],
649
+ };
650
+ const svc: ReturnType<typeof defineService<S, DetailActions>> = defineService<S, DetailActions>("himiEventDetail", {
651
+ notHydrated: [...ATTENDANCE_NOT_HYDRATED, "ticketPurchaseBlocked", "ticketSheetOpen", "rsvpActionsOpen", "cancelConfirmOpen", "rsvpId", "rsvpState", "rsvpLabel"],
457
652
  state: derive({
458
653
  ...ATTENDANCE_INITIAL,
459
654
  status: "loading",
460
655
  id: inputId(),
461
656
  title: "",
462
- initial: "E",
463
657
  when: "",
464
658
  location: "",
659
+ place: "",
660
+ placeIcon: "location",
661
+ noPlace: true,
662
+ hidePlaceStatic: true,
663
+ price: "",
664
+ zone: "",
665
+ noPrice: true,
666
+ hideMapTap: true,
667
+ shortDescription: "",
668
+ noShortDescription: true,
669
+ noDescription: true,
670
+ noDate: true,
671
+ dateNotTappable: true,
672
+ hideDateStatic: true,
673
+ registrationState: "none",
674
+ footerLabel: "",
675
+ footerAction: "",
676
+ footerCaption: "",
677
+ hideFooter: true,
678
+ noFooterButton: true,
679
+ noFooterCaption: true,
680
+ notice: "",
681
+ exploreLabel: detailCopy("event.exploreCta", "Explore Events"),
682
+ noNotice: true,
683
+ rsvpLabel: "",
684
+ rsvpId: "",
685
+ rsvpState: "none",
686
+ hideRsvpChip: true,
687
+ hideGoingChip: true,
688
+ hideWaitlistedChip: true,
689
+ hideCancelRsvp: true,
690
+ hideCancelConfirm: true,
691
+ hideRsvpOptions: false,
692
+ ticketSheetOpen: false,
693
+ rsvpActionsOpen: false,
694
+ cancelConfirmOpen: false,
695
+ ticketTotal: formatCurrency(0, "USD", "en"),
696
+ ticketCountLabel: copyParam("event.ticketCount", "{n} tickets, plus fees").replace("{n}", "0"),
697
+ checkoutDisabled: true,
698
+ schedule: [],
699
+ fullSchedule: [],
700
+ noSchedule: true,
701
+ noFullSchedule: true,
702
+ moreDates: [],
703
+ allDates: [],
704
+ noMoreDates: true,
705
+ noAllDates: true,
706
+ policies: [],
707
+ noPolicies: true,
465
708
  description: "",
466
709
  descriptionJson: "",
467
710
  hasDescriptionJson: false,
@@ -469,24 +712,25 @@ const svc = defineService<S, {
469
712
  hasImage: false,
470
713
  noImage: true,
471
714
  mapUrl: "",
472
- noMap: true,
473
- mapMarkers: [],
474
- hasMapMarkers: false,
475
- noMapMarkers: true,
476
- goingLabel: "",
477
- attendeeAvatars: [],
478
- hasAttendees: false,
479
- noAttendees: true,
480
715
  registrationKind: "rsvp",
481
716
  ticketPurchaseBlocked: true,
482
- regularTicketsHidden: true,
483
717
  showRsvp: true,
484
718
  hideRsvp: false,
485
719
  showTickets: false,
486
720
  hideTickets: true,
487
721
  showExternal: false,
488
722
  hideExternal: true,
723
+ hideScheduled: true,
724
+ hideWaitlist: true,
725
+ memberOnly: false,
726
+ hideMemberRsvp: true,
727
+ hideMemberWaitlist: true,
728
+ hideMemberTickets: true,
729
+ hideMemberScheduled: true,
489
730
  ticketTiers: [],
731
+ ticketCurrency: "USD",
732
+ ticketLimitPerOrder: 99,
733
+ regularTicketsHidden: true,
490
734
  hasTiers: false,
491
735
  noTiers: true,
492
736
  showTierPicker: false,
@@ -498,9 +742,9 @@ const svc = defineService<S, {
498
742
  shareUrl: "",
499
743
  calendarStart: 0,
500
744
  calendarEnd: 0,
501
- actionMessage: "",
745
+ snackbarMessage: "",
746
+ noSnackbar: true,
502
747
  isAddingCalendar: false,
503
- statusLabel: "",
504
748
  emptyText: "",
505
749
  errored: false,
506
750
  notErrored: true,
@@ -509,18 +753,285 @@ const svc = defineService<S, {
509
753
  noEmpty: true,
510
754
  }),
511
755
  actions: {
512
- iapPublish: (s) => derive(lastReady ?? s),
756
+ openTickets: async (s) => {
757
+ if (s.ticketTiers.length) return derive({ ...s, ticketSheetOpen: true });
758
+ const version = ticketSelectionVersion;
759
+ const loaded = await loadTiers(s.id);
760
+ const current = svc.getState();
761
+ if (version !== ticketSelectionVersion || current.id !== s.id) return;
762
+ if (!loaded.rows.length) return derive({ ...current, ticketSheetOpen: false, snackbarMessage: detailCopy("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
763
+ paymentTickets = loaded.tickets;
764
+ ticketSelectionState = derive({ ...current, ticketTiers: loaded.rows.map((row) => ({ ...row, rowLimit: Math.min(row.rowLimit ?? 99, s.ticketLimitPerOrder) })), ticketCurrency: loaded.tickets.values().next().value?.price?.currency || s.ticketCurrency, ticketSheetOpen: true, snackbarMessage: "" });
765
+ lastReady = ticketSelectionState;
766
+ return ticketSelectionState;
767
+ },
768
+ closeTickets: (s) => derive({ ...s, ticketSheetOpen: false }),
769
+ openRsvpActions: (s) => derive({ ...s, rsvpActionsOpen: true, cancelConfirmOpen: false, snackbarMessage: "" }),
770
+ closeRsvpActions: (s) => derive({ ...s, rsvpActionsOpen: false, cancelConfirmOpen: false }),
771
+ beginCancelRsvp: (s) => derive({ ...s, cancelConfirmOpen: !!s.rsvpId, snackbarMessage: "" }),
772
+ keepRsvp: (s) => derive({ ...s, cancelConfirmOpen: false }),
773
+ cancelRsvp: async (s): Promise<S> => {
774
+ if (!s.rsvpId || !s.id || cancelRsvpInFlight) return derive({ ...s, rsvpActionsOpen: false, cancelConfirmOpen: false });
775
+ cancelRsvpInFlight = true;
776
+ try {
777
+ const call = wixCall();
778
+ if (!call) throw new Error("Wix API is unavailable");
779
+ await call("DELETE", `/events/v2/rsvps/${encodeURIComponent(s.rsvpId)}`);
780
+ forgetRsvp(s.id);
781
+ return derive({
782
+ ...s,
783
+ rsvpId: "",
784
+ rsvpState: "none",
785
+ rsvpLabel: "",
786
+ rsvpActionsOpen: false,
787
+ cancelConfirmOpen: false,
788
+ snackbarMessage: copyParam("event.rsvpCanceled", "Your RSVP is canceled."),
789
+ });
790
+ } catch {
791
+ return derive({
792
+ ...s,
793
+ rsvpActionsOpen: false,
794
+ cancelConfirmOpen: false,
795
+ snackbarMessage: copyParam("event.cancelFailed", "Couldn't cancel your RSVP. Try again."),
796
+ });
797
+ } finally {
798
+ cancelRsvpInFlight = false;
799
+ }
800
+ },
801
+ noop: (s) => derive(s),
802
+ setQuantity: async (inputState, arg): Promise<S | undefined> => {
803
+ if (ticketCheckoutInFlight) return derive(inputState);
804
+ const input = arg as { id?: string; value?: number; quantity?: number } | undefined;
805
+ const id = typeof input === "string" ? input : input?.id;
806
+ const next = Number(input?.value ?? input?.quantity ?? 0);
807
+ if (!id || !Number.isFinite(next)) return derive(inputState);
808
+ // Invalidate the previous Apple form synchronously with the row action.
809
+ // A purchase tapped before the async route/price checks finish must not
810
+ // submit details belonging to the ticket that was selected previously.
811
+ const version = ++ticketSelectionVersion;
812
+ ticketCheckoutDenied = false;
813
+ applePurchase.invalidate();
814
+ ticketSelectionQueue = ticketSelectionQueue.catch(() => {}).then(async () => {
815
+ if (ticketCheckoutInFlight) return;
816
+ // Worker actions can overlap while capability checks await. Start each
817
+ // update from the last selection so edits to independent rows compose.
818
+ const serviceState = svc.getState();
819
+ const base = serviceState.status === "ready" && serviceState.id === inputState.id ? { ...inputState, ...serviceState } : inputState;
820
+ const s = ticketSelectionState?.status === "ready" && ticketSelectionState.id === base.id
821
+ ? { ...base, selectedTierId: ticketSelectionState.selectedTierId, ticketTiers: ticketSelectionState.ticketTiers }
822
+ : base;
823
+ const ticketTiers = s.ticketTiers.map((row) => row.id !== id || !row.available ? row : { ...row, quantity: Math.max(0, Math.min(row.maxQuantity ?? 99, Math.floor(next))) });
824
+ const chosen = ticketTiers.filter((row) => (row.quantity ?? 0) > 0);
825
+ let selectedTierId = s.selectedTierId;
826
+ if (chosen.length === 1) selectedTierId = chosen[0]!.id;
827
+ else if (chosen.length === 0 && id === selectedTierId) selectedTierId = ticketTiers.find((row) => row.available)?.id ?? "";
828
+ const selected = ticketTiers.find((row) => row.id === selectedTierId);
829
+ let regularAllowed: boolean;
830
+ if (chosen.length <= 1 && (chosen.length === 0 || chosen[0]!.quantity === 1)) {
831
+ regularAllowed = await mayUseRegularTicketCheckout(paymentEvent, selected ? selectedPaymentTicket(selected) : undefined);
832
+ } else {
833
+ regularAllowed = (await Promise.all(chosen.map((row) => mayUseRegularTicketCheckout(paymentEvent, selectedPaymentTicket(row))))).every(Boolean);
834
+ }
835
+ if (version === ticketSelectionVersion) ticketCheckoutAllowed = regularAllowed;
836
+ const selectionState = derive({ ...s, selectedTierId, ticketTiers: ticketTiers.map((row) => ({ ...row, isSelected: row.id === selectedTierId, notSelected: row.id !== selectedTierId })) });
837
+ ticketSelectionState = selectionState;
838
+ if (selectionState.status === "ready") lastReady = selectionState;
839
+ if (version === ticketSelectionVersion && selectedTierId && chosen.length === 1 && !regularAllowed) {
840
+ const ticketId = selected?.ticketDefinitionId ?? selectedTierId;
841
+ if (!await priceTicket(paymentEvent, ticketId, version, selected ? selectedPaymentTicket(selected) : undefined)) return;
842
+ }
843
+ // Keep edits and sheet state made while route and price checks awaited.
844
+ const currentServiceState = svc.getState();
845
+ const latest = currentServiceState.status === "ready" && currentServiceState.id === s.id ? { ...s, ...currentServiceState } : s;
846
+ const quantities = new Map(ticketTiers.map((row) => [row.id, row.quantity ?? 0]));
847
+ const latestRows = latest.ticketTiers.map((row) => ({ ...row, quantity: quantities.get(row.id) ?? row.quantity ?? 0, isSelected: row.id === selectedTierId, notSelected: row.id !== selectedTierId }));
848
+ ticketSelectionState = derive({ ...latest, selectedTierId, ticketTiers: latestRows });
849
+ if (ticketSelectionState.status === "ready") lastReady = ticketSelectionState;
850
+ });
851
+ await ticketSelectionQueue;
852
+ return ticketSelectionState;
853
+ },
854
+ setGuestPrice: (s, arg) => {
855
+ if (ticketCheckoutInFlight) return derive(s);
856
+ const input = arg as { id?: string; value?: string; text?: string } | undefined;
857
+ const id = input?.id;
858
+ const value = String(input?.value ?? input?.text ?? "").replace(/[^\d.]/g, "");
859
+ if (!id || !/^\d*(?:\.\d{0,2})?$/.test(value)) return derive(s);
860
+ ticketSelectionState = derive({ ...s, ticketTiers: s.ticketTiers.map((row) => row.id === id ? { ...row, guestPrice: value, priceAmount: Number(value || row.minPrice || 0) } : row) });
861
+ if (ticketSelectionState.status === "ready") lastReady = ticketSelectionState;
862
+ return ticketSelectionState;
863
+ },
864
+ checkout: async (initial) => {
865
+ if (ticketCheckoutInFlight) return derive(initial);
866
+ // A Stepper action can still be awaiting its IAP/price route check when
867
+ // Checkout is tapped. Wait until the selection queue stops growing, then
868
+ // take the service's latest state before validating or reserving.
869
+ while (true) {
870
+ const pendingSelection = ticketSelectionQueue;
871
+ await pendingSelection;
872
+ if (pendingSelection === ticketSelectionQueue) break;
873
+ }
874
+ if (ticketCheckoutInFlight) return derive(svc.getState());
875
+ const current = svc.getState();
876
+ let s = ticketSelectionState?.status === "ready" && ticketSelectionState.id === initial.id
877
+ ? ticketSelectionState
878
+ : current.status === "ready" && current.id === initial.id ? current : initial;
879
+ const quantities = s.ticketTiers.filter((row) => row.available && (row.quantity ?? 0) > 0).map((row) => ({ ticketDefinitionId: row.ticketDefinitionId ?? row.id, quantity: row.quantity ?? 0, ...(row.isGuestPrice ? { priceOverride: row.guestPrice } : {}), ...(row.pricingOptionId ? { ticketDetails: [{ pricingOptionId: row.pricingOptionId }] } : {}) }));
880
+ const guestPriceInvalid = s.ticketTiers.some((row) => row.isGuestPrice && (row.quantity ?? 0) > 0 && (!String(row.guestPrice ?? "").trim() || !Number.isFinite(Number(row.guestPrice)) || Number(row.guestPrice) < (row.minPrice ?? 0)));
881
+ const aggregateInvalid = s.ticketTiers.some((row) => {
882
+ const definitionId = row.ticketDefinitionId ?? row.id;
883
+ const definitionQuantity = s.ticketTiers.reduce((sum, other) => sum + ((other.ticketDefinitionId ?? other.id) === definitionId ? other.quantity ?? 0 : 0), 0);
884
+ return (row.quantity ?? 0) > (row.maxQuantity ?? 99) || definitionQuantity > (row.rowLimit ?? 99);
885
+ });
886
+ if (ticketCheckoutInFlight) return derive(s);
887
+ if (quantities.length === 0) return derive({ ...s, snackbarMessage: detailCopy("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
888
+ if (guestPriceInvalid || aggregateInvalid || quantities.reduce((sum, row) => sum + row.quantity, 0) > s.ticketLimitPerOrder) return derive(s);
889
+ ticketCheckoutInFlight = true;
890
+ let reservationId = "";
891
+ let result: S | undefined;
892
+ try {
893
+ // Re-read ticket definitions immediately before checkout. A free ticket
894
+ // can become paid (or an IAP ticket) while this screen is open; in that
895
+ // case the regular Events reservation API must not run.
896
+ const latestResponse = await queryAvailableTickets(s.id);
897
+ const latestDefinitions = latestResponse.definitions ?? [];
898
+ const fresh = loadedTiers(latestDefinitions);
899
+ const latestTickets = fresh.tickets;
900
+ const previousSelected = s.ticketTiers.filter((row) => (row.quantity ?? 0) > 0);
901
+ const previousRows = new Map(s.ticketTiers.map((row) => [row.id, row]));
902
+ const refreshedByDefinition = new Map<string, number>();
903
+ let refreshedCount = 0;
904
+ const refreshedRows = fresh.rows.map((row) => {
905
+ const previous = previousRows.get(row.id);
906
+ const definitionId = row.ticketDefinitionId ?? row.id;
907
+ const rowLimit = Math.min(row.rowLimit ?? 99, s.ticketLimitPerOrder);
908
+ const used = refreshedByDefinition.get(definitionId) ?? 0;
909
+ const quantity = row.available ? Math.max(0, Math.min(previous?.quantity ?? 0, rowLimit - used, s.ticketLimitPerOrder - refreshedCount)) : 0;
910
+ refreshedByDefinition.set(definitionId, used + quantity);
911
+ refreshedCount += quantity;
912
+ return { ...row, rowLimit,
913
+ quantity, guestPrice: previous?.guestPrice ?? "",
914
+ priceAmount: row.isGuestPrice ? Number(previous?.guestPrice || row.minPrice || 0) : row.priceAmount,
915
+ isSelected: row.id === s.selectedTierId, notSelected: row.id !== s.selectedTierId };
916
+ });
917
+ const termsChanged = previousSelected.some((previous) => {
918
+ const row = refreshedRows.find((next) => next.id === previous.id);
919
+ return !row || row.priceAmount !== previous.priceAmount || row.minPrice !== previous.minPrice
920
+ || row.isGuestPrice !== previous.isGuestPrice || row.available !== previous.available
921
+ || row.rowLimit !== previous.rowLimit || row.quantity !== previous.quantity
922
+ || latestTickets.get(previous.ticketDefinitionId ?? previous.id)?.price?.currency !== paymentTickets.get(previous.ticketDefinitionId ?? previous.id)?.price?.currency;
923
+ });
924
+ const current = svc.getState();
925
+ s = { ...s, ...(current.status === "ready" && current.id === s.id ? current : {}), ticketTiers: refreshedRows,
926
+ ticketCurrency: latestTickets.values().next().value?.price?.currency || s.ticketCurrency };
927
+ const selectedRows = refreshedRows.filter((row) => (row.quantity ?? 0) > 0);
928
+ const latestSelected = selectedRows.map((row) => selectedPaymentTicket(row, latestTickets));
929
+ const routesAllowed = selectedRows.length === previousSelected.length
930
+ && latestSelected.every((ticket) => !!ticket && tierAvailable(ticket))
931
+ && (await Promise.all(latestSelected.map((ticket) => mayUseRegularTicketCheckout(paymentEvent, ticket)))).every(Boolean);
932
+ if (!routesAllowed) {
933
+ paymentTickets = latestTickets;
934
+ ticketCheckoutAllowed = false;
935
+ ticketCheckoutDenied = true;
936
+ if (selectedRows.length === 1 && selectedRows[0]!.quantity === 1) {
937
+ await priceTicket(paymentEvent, selectedRows[0]!.ticketDefinitionId ?? selectedRows[0]!.id, ticketSelectionVersion, latestSelected[0]);
938
+ }
939
+ const current = svc.getState();
940
+ const latest = current.status === "ready" && current.id === s.id ? { ...s, ...current } : s;
941
+ result = derive({ ...latest, ticketTiers: refreshedRows, ticketCurrency: s.ticketCurrency, snackbarMessage: copyParam("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
942
+ ticketSelectionState = result;
943
+ if (result.status === "ready") lastReady = result;
944
+ } else {
945
+ paymentTickets = latestTickets;
946
+ ticketCheckoutAllowed = true;
947
+ ticketCheckoutDenied = false;
948
+ if (termsChanged) {
949
+ ticketCheckoutInFlight = false;
950
+ result = derive({ ...s, snackbarMessage: detailCopy("event.ticketsChanged", "Ticket details changed. Review your selection and try again.") });
951
+ ticketSelectionState = result;
952
+ lastReady = result;
953
+ } else if (isHostedWorker()) {
954
+ if (hostedOpenInFlight) result = derive(svc.getState());
955
+ else {
956
+ hostedOpenInFlight = true;
957
+ try {
958
+ const opened = await hostedSitePageOpener({ page: "event", eventId: s.id })(async () => {
959
+ try {
960
+ const fresh = await queryAvailableTickets(s.id);
961
+ const byId = new Map((fresh.definitions ?? []).filter((ticket) => ticket.id).map((ticket) => [ticket.id!, ticket]));
962
+ const freshSelected = selectedRows.map((row) => selectedPaymentTicket(row, byId));
963
+ const stillAllowed = freshSelected.every((ticket) => !!ticket && tierAvailable(ticket))
964
+ && (await Promise.all(freshSelected.map((ticket) => mayUseRegularTicketCheckout(paymentEvent, ticket)))).every(Boolean);
965
+ if (stillAllowed) paymentTickets = byId;
966
+ return stillAllowed;
967
+ } catch { return false; }
968
+ });
969
+ const latestServiceState = svc.getState();
970
+ const latest = latestServiceState.status === "ready" && latestServiceState.id === s.id ? { ...s, ...latestServiceState } : s;
971
+ result = opened === true
972
+ ? { ...latest, ticketSheetOpen: false, snackbarMessage: detailCopy("event.checkoutOpened", "Complete checkout in the browser to get your tickets.") }
973
+ : opened === "withdrawn" ? latest : { ...latest, snackbarMessage: detailCopy("event.registrationOpenFailed", "Could not open the registration page.") };
974
+ } finally { hostedOpenInFlight = false; }
975
+ }
976
+ } else {
977
+ const held = await createTicketReservation(s.id, quantities);
978
+ if (!held.id) throw new Error("Reservation did not return an id");
979
+ reservationId = held.id;
980
+ const placed = await checkoutReservation(s.id, held.id);
981
+ const snapshotId = placed.order?.snapshotId ?? "";
982
+ if (!snapshotId) result = { ...s, ticketSheetOpen: false, snackbarMessage: detailCopy("event.ticketIssued", "You're in — your ticket is confirmed.") };
983
+ else {
984
+ const msid = await wixPlatformSession.signedInstanceStatus().then((st) => st.msid ?? "").catch(() => "");
985
+ const pay = await payCashierOrder({ appId: WIX_EVENTS_APP_ID, snapshotId, msid, amount: placed.order?.totalPrice?.amount, currency: placed.order?.totalPrice?.currency, cashierUrl: async () => (await createRedirectSession({ eventsCheckout: { reservationId: held.id! } })).redirectSession?.fullUrl });
986
+ if (pay.kind === "paid") result = { ...s, ticketSheetOpen: false, snackbarMessage: detailCopy("event.ticketIssued", "You're in — your ticket is confirmed.") };
987
+ else if (pay.kind === "handoff") result = { ...s, ticketSheetOpen: false, snackbarMessage: detailCopy("event.checkoutOpened", "Complete checkout in the browser to get your tickets.") };
988
+ else if (pay.kind === "cancelled") result = { ...s, snackbarMessage: detailCopy("event.ticketCancelled", "Payment cancelled — no ticket was issued.") };
989
+ else throw new Error("Payment could not be completed");
990
+ }
991
+ }
992
+ }
993
+ } catch {
994
+ try {
995
+ // A public event page is not pinned to this selection or price. Only
996
+ // continue in hosted checkout when a reservation already holds it.
997
+ const params = reservationId ? { reservationId } : undefined;
998
+ if (params) {
999
+ const redirect = await createRedirectSession({ eventsCheckout: params });
1000
+ const url = redirect.redirectSession?.fullUrl;
1001
+ if (url) {
1002
+ const opened = await open(url, "in_app_browser");
1003
+ result = { ...s, ticketSheetOpen: false, snackbarMessage: opened.opened ? detailCopy("event.checkoutOpened", "Complete checkout in the browser to get your tickets.") : detailCopy("event.registrationOpenFailed", "Could not open the registration page.") };
1004
+ }
1005
+ }
1006
+ } catch { /* report below */ }
1007
+ if (!result) result = { ...s, snackbarMessage: detailCopy("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") };
1008
+ } finally { ticketCheckoutInFlight = false; }
1009
+ return derive(result!);
1010
+ },
1011
+ iapPublish: (s) => {
1012
+ // Prefer the live ready state so an asynchronous StoreKit publication
1013
+ // cannot overwrite a newer selection or checkout snackbar with the
1014
+ // original load snapshot.
1015
+ const next = derive(s.status === "ready" ? s : lastReady ?? s);
1016
+ if (next.status === "ready") lastReady = next;
1017
+ return next;
1018
+ },
513
1019
  iapRefresh: async (): Promise<S> => { await applePurchase.refresh(); return derive(svc.getState()); },
514
1020
  iapPoll: async (): Promise<S> => { await applePurchase.poll(); return derive(svc.getState()); },
515
1021
  setIapField: (s, arg) => { applePurchase.change(arg); return derive(s); },
516
1022
  buyApple: async (s: S): Promise<S> => {
517
- if (!s.selectedTierId || !s.ticketTiers.some((t) => t.id === s.selectedTierId && t.available)) return derive(s);
518
- await applePurchase.buy({ key: s.selectedTierId, eventId: s.id });
1023
+ const selected = s.ticketTiers.find((t) => t.id === s.selectedTierId);
1024
+ if (!selected || !selected.available || selected.quantity !== 1 || s.ticketTiers.some((t) => t.id !== s.selectedTierId && (t.quantity ?? 0) > 0)) return derive(s);
1025
+ await applePurchase.buy({ key: selected.ticketDefinitionId ?? selected.id, eventId: s.id });
519
1026
  return derive(svc.getState());
520
1027
  },
521
1028
  load: async (s: S, arg?: unknown): Promise<S | undefined> => {
522
1029
  const selectionVersion = ++ticketSelectionVersion;
523
1030
  ticketCheckoutAllowed = false;
1031
+ ticketCheckoutDenied = false;
1032
+ ticketSelectionState = undefined;
1033
+ lastReady = undefined;
1034
+ s = { ...s, ticketSheetOpen: false, rsvpActionsOpen: false, cancelConfirmOpen: false, rsvpId: "", rsvpState: "none", rsvpLabel: "" };
524
1035
  applePurchase.invalidate();
525
1036
  stashCopy(arg);
526
1037
  const id = s.id || inputId();
@@ -531,72 +1042,164 @@ const svc = defineService<S, {
531
1042
  // URLS adds `eventPageUrl` — the registration fallback and the share link. Without URLS a
532
1043
  // ticket flow that cannot complete in-app had no page to send the buyer to.
533
1044
  const res = await getEvent(id, { fields: EVENT_DETAIL_FIELDSETS });
534
- let e = ((res as unknown as { event?: WixEvent }).event ?? res) as WixEvent;
535
- e = await enrichRegistration(id, e);
536
- const attendees = await attendeeSummary(id);
1045
+ const e = ((res as unknown as { event?: WixEvent }).event ?? res) as WixEvent;
537
1046
  // Ticketed events load their tiers with the event, so the price is on
538
1047
  // screen BEFORE the CTA is tapped. Best-effort: a tier read that fails
539
1048
  // leaves the picker collapsed and the CTA falls back to its own read.
540
1049
  const kind = registrationKindOf(e);
541
1050
  const loaded = kind === "tickets" ? await loadTiers(id) : { rows: [], tickets: new Map<string, WixAvailableTicket>() };
542
- const tiers = loaded.rows;
1051
+ const orderLimit = Number((e as unknown as { registration?: { tickets?: { ticketLimitPerOrder?: number } } }).registration?.tickets?.ticketLimitPerOrder ?? 99);
1052
+ const tiers = loaded.rows.map((row) => ({ ...row, rowLimit: Math.min(row.rowLimit ?? 99, orderLimit), maxQuantity: Math.min(row.maxQuantity ?? 99, orderLimit) }));
543
1053
  const selectedTierId = tiers.find((t) => t.isSelected)?.id ?? tiers.find((t) => t.available)?.id ?? "";
544
- const regular = kind !== "tickets" || await mayUseRegularTicketCheckout(e, loaded.tickets.get(selectedTierId));
1054
+ const selectedTicketId = tiers.find((row) => row.id === selectedTierId)?.ticketDefinitionId ?? selectedTierId;
1055
+ const regular = kind !== "tickets" || await mayUseRegularTicketCheckout(e, loaded.tickets.get(selectedTicketId));
545
1056
  if (selectionVersion !== ticketSelectionVersion) return undefined;
546
1057
  paymentEvent = e;
547
1058
  paymentTickets = loaded.tickets;
548
1059
  ticketCheckoutAllowed = regular;
549
- if (kind === "tickets" && !await priceTicket(e, selectedTierId, selectionVersion)) return undefined;
1060
+ if (kind === "tickets" && !await priceTicket(e, selectedTicketId, selectionVersion)) return undefined;
550
1061
  // Write-through: cache the fetched row (same EventRow shape as the list) so a
551
1062
  // later offline open of this detail can fall back to it.
552
1063
  await writeCachedRow(normalizeEvent(e));
553
1064
  const image = eventImage(e);
554
- const title = e.title || "Event";
555
- return derive({
1065
+ const title = e.title || "";
1066
+ const dates = detailDate(e);
1067
+ const place = detailPlace(e);
1068
+ const registration = registrationUi(e, Date.now());
1069
+ const rawEvent = e as unknown as { shortDescription?: string; agendaSettings?: { enabled?: boolean }; dateAndTimeSettings?: { recurrenceStatus?: string; recurringEvents?: { categoryId?: string } }; registration?: { allowedGuestTypes?: string; tickets?: { lowestPrice?: EventDetailMoney; highestPrice?: EventDetailMoney } } };
1070
+ const lowest = detailMoney(rawEvent.registration?.tickets?.lowestPrice);
1071
+ const highest = detailMoney(rawEvent.registration?.tickets?.highestPrice);
1072
+ const price = kind === "tickets" && lowest ? (highest && highest !== lowest ? detailCopy("event.priceFrom", "From {price}").replace("{price}", lowest) : lowest) : "";
1073
+ const memberProbeNeeded = kind === "rsvp" || rawEvent.registration?.allowedGuestTypes === "MEMBER";
1074
+ const isMember = memberProbeNeeded ? await signedInMember() : false;
1075
+ const [scheduleResponse, policyResponse, rsvpResponse] = await Promise.all([
1076
+ rawEvent.agendaSettings?.enabled ? bestEffort("GET", `/events/v1/schedule?eventId=${encodeURIComponent(id)}`) : undefined,
1077
+ bestEffort("POST", "/events-policies/v2/policies/query", { query: { filter: { eventId: { $eq: id } }, paging: { limit: 100 } } }),
1078
+ kind === "rsvp" && isMember ? bestEffort("GET", `/events/v2/rsvps/current?eventId=${encodeURIComponent(id)}`) : undefined,
1079
+ ]);
1080
+ let optionalReadFailed = [scheduleResponse, policyResponse, rsvpResponse].some((response) => response?.ok === false);
1081
+ const scheduleRaw = (scheduleResponse?.ok ? scheduleResponse.value : undefined) as { agenda?: Array<Record<string, unknown>>; schedule?: Array<Record<string, unknown>>; items?: Array<Record<string, unknown>>; agendaItems?: Array<Record<string, unknown>> } | undefined;
1082
+ const fullSchedule = (scheduleRaw?.agenda ?? scheduleRaw?.agendaItems ?? scheduleRaw?.schedule ?? scheduleRaw?.items ?? []).map((item) => {
1083
+ const slot = item.timeSlot as { start?: string; end?: string; timeZoneId?: string } | undefined;
1084
+ const start = String(slot?.start ?? item.startTime ?? item.startDate ?? item.time ?? "");
1085
+ const date = start ? new Date(start) : undefined;
1086
+ return {
1087
+ time: date && !Number.isNaN(date.getTime()) ? formatWeekdayDateTime(date, "en-US", slot?.timeZoneId) : start,
1088
+ name: String(item.name ?? item.title ?? ""),
1089
+ detail: String(item.stageName ?? item.stage ?? item.host ?? ""),
1090
+ };
1091
+ });
1092
+ const scheduleItems = fullSchedule.slice(0, 3);
1093
+ const rawPolicies = (((policyResponse?.ok ? policyResponse.value : undefined) as { policies?: Array<Record<string, unknown>> } | undefined)?.policies ?? []);
1094
+ const policies = rawPolicies.map((policy) => ({ name: String(policy.name ?? ""), text: String(policy.body ?? "") })).filter((policy) => policy.name || policy.text);
1095
+ const rsvpPayload = (rsvpResponse?.ok ? rsvpResponse.value : undefined) as { rsvps?: Array<{ id?: string; status?: string }>; rsvp?: { id?: string; status?: string } } | undefined;
1096
+ const rsvps = rsvpPayload?.rsvps ?? (rsvpPayload?.rsvp ? [rsvpPayload.rsvp] : []);
1097
+ const heldRsvp = rsvps.find((r) => ["YES", "WAITLIST", "WAITING"].includes((r.status ?? "").toUpperCase()));
1098
+ const rsvpState = !heldRsvp ? "none" : /(WAITLIST|WAITING)/i.test(heldRsvp.status ?? "") ? "waitlist" : "going";
1099
+ const rsvpLabel = rsvpState === "waitlist" ? detailCopy("event.waitlisted", "You're on the waitlist") : rsvpState === "going" ? detailCopy("event.going", "You're going") : "";
1100
+ const memberOnly = rawEvent.registration?.allowedGuestTypes === "MEMBER" && !isMember && ["rsvp", "waitlist", "tickets", "scheduled"].includes(registration.state);
1101
+ const registrationRaw = rawEvent.registration as { status?: string; registrationPaused?: boolean; tickets?: { soldOut?: boolean } } | undefined;
1102
+ const registrationClosed = !!registrationRaw?.registrationPaused || ["CLOSED_AUTOMATICALLY", "CLOSED_MANUALLY"].includes(registrationRaw?.status ?? "");
1103
+ const soldOut = kind === "tickets" && !!registrationRaw?.tickets?.soldOut;
1104
+ const formMessages = (e as unknown as { form?: { messages?: { registrationClosed?: { exploreEventsActionLabel?: string }; ticketsUnavailable?: { exploreEventsActionLabel?: string } } } }).form?.messages;
1105
+ const exploreLabel = registrationClosed
1106
+ ? formMessages?.registrationClosed?.exploreEventsActionLabel
1107
+ : soldOut ? formMessages?.ticketsUnavailable?.exploreEventsActionLabel : undefined;
1108
+ let moreDates: Array<{ id: string; date: string }> = [];
1109
+ const recurrence = rawEvent.dateAndTimeSettings?.recurringEvents;
1110
+ if (recurrence?.categoryId && rawEvent.dateAndTimeSettings?.recurrenceStatus !== "ONE_TIME") {
1111
+ try {
1112
+ const series: WixEvent[] = [];
1113
+ // The Events V3 category endpoint is the supported scoped series read.
1114
+ // Page in bounded batches, since a single 100-row response can omit later dates.
1115
+ for (let offset = 0; offset < 1000; offset += 100) {
1116
+ const page = await bestEffort("GET", `/events/v3/events/category/${encodeURIComponent(recurrence.categoryId)}?paging.limit=100&paging.offset=${offset}&fields=DETAILS&fields=TEXTS&fields=REGISTRATION`);
1117
+ if (!page.ok) { optionalReadFailed = true; break; }
1118
+ const pageEvents = (page.value.events ?? []) as WixEvent[];
1119
+ series.push(...pageEvents);
1120
+ if (pageEvents.length < 100) break;
1121
+ }
1122
+ const now = Date.now();
1123
+ moreDates = series.filter((item) => {
1124
+ const itemRaw = item as unknown as { status?: string; dateAndTimeSettings?: { recurringEvents?: { categoryId?: string } } };
1125
+ const start = eventStartMillis(item);
1126
+ return item.id !== id && itemRaw.dateAndTimeSettings?.recurringEvents?.categoryId === recurrence.categoryId && !["ENDED", "CANCELED", "DRAFT"].includes((itemRaw.status ?? "").toUpperCase()) && (start == null || start >= now);
1127
+ }).sort((a, b) => (eventStartMillis(a) ?? 0) - (eventStartMillis(b) ?? 0)).map((item) => ({ id: item.id ?? "", date: eventWhen(item) })).filter((item) => !!item.id);
1128
+ } catch { moreDates = []; optionalReadFailed = true; }
1129
+ }
1130
+ const readyState = derive({
556
1131
  ...s,
557
1132
  status: "ready",
558
1133
  errored: false,
559
1134
  id,
560
1135
  ticketTiers: tiers,
1136
+ ticketCurrency: (e as unknown as { registration?: { tickets?: { currency?: string } } }).registration?.tickets?.currency || loaded.tickets.values().next().value?.price?.currency || "USD",
1137
+ ticketLimitPerOrder: orderLimit,
561
1138
  // Default the pick to the cheapest AVAILABLE tier so the CTA is never
562
1139
  // dead; the buyer's tap overrides it from there.
563
1140
  selectedTierId,
564
1141
  title,
565
- initial: (title.trim()[0] || "E").toUpperCase(),
566
- when: eventWhen(e),
1142
+ when: dates.line,
567
1143
  location: eventLocation(e),
1144
+ place: place.value,
1145
+ placeIcon: place.icon,
1146
+ noPlace: !place.value,
1147
+ hidePlaceStatic: !place.value || !!place.mapUrl,
1148
+ price,
1149
+ zone: dates.zone,
1150
+ mapUrl: place.mapUrl,
1151
+ shortDescription: rawEvent.shortDescription || "",
1152
+ noShortDescription: !rawEvent.shortDescription || rawEvent.shortDescription === eventBody(e).description,
1153
+ noDescription: !eventBody(e).description && !eventBody(e).descriptionJson,
1154
+ noDate: !dates.line,
1155
+ registrationState: registration.state,
1156
+ footerLabel: registration.label,
1157
+ footerAction: registration.action,
1158
+ footerCaption: memberOnly ? (kind === "tickets" ? detailCopy("event.membersOnlyTickets", "Only members can buy tickets.") : detailCopy("event.membersOnlyRsvp", "Only members can RSVP.")) : registration.caption,
1159
+ memberOnly,
1160
+ notice: registration.notice,
1161
+ exploreLabel: exploreLabel || detailCopy("event.exploreCta", "Explore Events"),
1162
+ rsvpLabel,
1163
+ rsvpId: heldRsvp?.id ?? "",
1164
+ rsvpState,
1165
+ schedule: scheduleItems,
1166
+ fullSchedule: fullSchedule.slice(3),
1167
+ noSchedule: scheduleItems.length === 0,
1168
+ policies,
1169
+ noPolicies: policies.length === 0,
1170
+ moreDates: moreDates.slice(0, 3),
1171
+ allDates: moreDates.slice(3),
1172
+ noMoreDates: moreDates.length === 0,
568
1173
  // The Ricos description when the event has one (its own text is the plain copy); the legacy
569
1174
  // string fields otherwise.
570
1175
  ...eventBody(e),
571
1176
  image,
572
1177
  hasImage: !!image,
573
1178
  noImage: !image,
574
- mapUrl: eventMapUrl(e),
575
- mapMarkers: eventMapMarkers(e),
576
- goingLabel: attendees.goingLabel,
577
- attendeeAvatars: attendees.avatars,
1179
+ hideMapTap: place.hideMapTap,
578
1180
  registrationKind: registrationKindOf(e),
579
1181
  registrationUrl: registrationUrlOf(e),
580
1182
  eventSlug: eventSlugOf(e),
581
1183
  shareUrl: eventPageUrlOf(e),
582
- calendarStart: eventStartMillis(e) ?? 0,
583
- calendarEnd: eventEndMillis(e) ?? 0,
584
- actionMessage: ticketCheckoutAllowed || !applePurchase.fields(true, false).iapBuyHidden ? "" : copyParam("event.purchaseUnavailable", "Purchasing is temporarily unavailable."),
585
- statusLabel: titleCase(e.eventStatus) || titleCase((e as unknown as { status?: string }).status),
1184
+ calendarStart: dates.start,
1185
+ calendarEnd: dates.end,
1186
+ snackbarMessage: optionalReadFailed ? detailCopy("event.detailsUnavailable", "Some event details couldn't load. Try again.") : "",
586
1187
  });
587
- } catch {
1188
+ ticketSelectionState = readyState;
1189
+ lastReady = readyState;
1190
+ return readyState;
1191
+ } catch (error) {
588
1192
  // Offline / fetch failure: fall back to the last-synced cached row before erroring.
589
1193
  if (selectionVersion !== ticketSelectionVersion) return undefined;
590
1194
  const cached = await readCachedRow(id);
591
1195
  if (cached) {
592
1196
  const title = cached.title || "Event";
593
- return derive({
1197
+ const cachedState = derive({
594
1198
  ...s,
595
1199
  status: "ready",
596
1200
  errored: false,
597
1201
  id,
598
1202
  title,
599
- initial: (title.trim()[0] || "E").toUpperCase(),
600
1203
  when: cached.dateLine,
601
1204
  location: cached.location,
602
1205
  // The cached list row carries no description: an offline retry of THIS event keeps the pair it
@@ -605,193 +1208,31 @@ const svc = defineService<S, {
605
1208
  image: cached.image,
606
1209
  hasImage: !!cached.image,
607
1210
  noImage: !cached.image,
608
- statusLabel: titleCase(cached.status),
609
1211
  });
1212
+ lastReady = cachedState;
1213
+ return cachedState;
610
1214
  }
611
- return derive({ ...s, status: "ready", errored: true, title: "" });
1215
+ const errorState = derive({ ...s, status: "ready", errored: true, title: "" });
1216
+ lastReady = errorState;
1217
+ return errorState;
612
1218
  }
613
1219
  },
614
1220
  openMap: async (s) => {
615
- if (!s.mapUrl) return derive({ ...s, actionMessage: copyParam("event.noMapMessage", "This event does not have a mapped location yet.") });
1221
+ if (!s.mapUrl) return derive({ ...s, snackbarMessage: copyParam("event.noMapMessage", "This event does not have a mapped location yet.") });
616
1222
  try {
617
1223
  const result = await open(s.mapUrl, "external_app");
618
- return derive({ ...s, actionMessage: result.opened ? "" : copyParam("event.mapOpenFailed", "Could not open maps on this device.") });
1224
+ return derive({ ...s, snackbarMessage: result.opened ? "" : copyParam("event.mapOpenFailed", "Could not open maps on this device.") });
619
1225
  } catch {
620
- return derive({ ...s, actionMessage: copyParam("event.mapOpenFailed", "Could not open maps on this device.") });
1226
+ return derive({ ...s, snackbarMessage: copyParam("event.mapOpenFailed", "Could not open maps on this device.") });
621
1227
  }
622
1228
  },
623
- selectTier: async (s: S, arg?: unknown): Promise<S | undefined> => {
624
- const id = typeof arg === "string" ? arg : (arg as { id?: unknown } | undefined)?.id;
625
- const wanted = typeof id === "string" ? id : "";
626
- const tier = s.ticketTiers.find((t) => t.id === wanted);
627
- // Ignore a tap on an off-sale row rather than letting the CTA promise a
628
- // ticket it cannot reserve.
629
- if (!tier || !tier.available) return s;
630
- const selectionVersion = ++ticketSelectionVersion;
631
- ticketCheckoutAllowed = false;
632
- applePurchase.invalidate();
633
- const regular = await mayUseRegularTicketCheckout(paymentEvent, paymentTickets.get(wanted));
634
- if (selectionVersion !== ticketSelectionVersion) return undefined;
635
- ticketCheckoutAllowed = regular;
636
- if (!await priceTicket(paymentEvent, wanted, selectionVersion)) return undefined;
637
- return derive({
638
- ...s,
639
- selectedTierId: wanted,
640
- ticketTiers: s.ticketTiers.map((t) => ({ ...t, isSelected: t.id === wanted, notSelected: t.id !== wanted })),
641
- // A new pick invalidates whatever the last attempt said.
642
- actionMessage: ticketCheckoutAllowed || !applePurchase.fields(true, false).iapBuyHidden ? "" : copyParam("event.purchaseUnavailable", "Purchasing is temporarily unavailable."),
643
- });
644
- },
645
1229
  openRegistration: async (s): Promise<S> => {
646
- let eventPageAllowed = true;
647
- let checkoutEvent: WixEvent | undefined;
648
- if (s.registrationKind === "tickets") {
649
- try {
650
- const current = await getEvent(s.id, { fields: EVENT_DETAIL_FIELDSETS });
651
- const event = ((current as unknown as { event?: WixEvent }).event ?? current) as WixEvent;
652
- checkoutEvent = event;
653
- eventPageAllowed = await mayUseRegularTicketCheckout(event, undefined);
654
- ticketCheckoutAllowed = eventPageAllowed;
655
- if (!ticketCheckoutAllowed) {
656
- const tickets = await queryAvailableTickets(s.id);
657
- const selected = (tickets.definitions ?? []).find((ticket) => ticket.id === s.selectedTierId);
658
- ticketCheckoutAllowed = await mayUseRegularTicketCheckout(event, selected);
659
- }
660
- if (!ticketCheckoutAllowed) return derive({ ...s, actionMessage: copyParam("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
661
- } catch { return derive({ ...s, actionMessage: copyParam("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") }); }
662
- }
663
- // Phase 2c — NATIVE ticket purchase. Reserve the cheapest available tier,
664
- // turn the hold into an order, then settle it on the shared in-app pay rail
665
- // (@wix/himalaya/checkout): wallet where the app has one, in-app Wix cashier
666
- // otherwise. A FREE ticket comes back with no `snapshotId` and is already
667
- // confirmed — no payment step at all, which the hosted redirect made
668
- // needlessly heavy.
669
- //
670
- // Every failure degrades one rung at a time, never to a dead button:
671
- // native chain → hosted eventsCheckout (pinned to the SAME reservation
672
- // when we got one, else the slug) → the event page / registration URL.
673
- // Hosted inside a host shell (Spaces) neither in-app rung can finish: the classic
674
- // `checkoutReservation` needs a buyer + guest form this screen does not collect, and the
675
- // redirect session needs a headless OAuth client a hosted app does not have. Skip both —
676
- // the native chain would also leave a ticket reservation held for nothing — and open the
677
- // event's own page, where the member registers on the site: signed in by the host where it can
678
- // (`himi.host.memberSitePage`), else as a visitor.
679
- if (s.registrationKind === "tickets" && s.id && !isHostedWorker()) {
680
- let reservationId = "";
681
- try {
682
- // Buy what the buyer PICKED. `load` seeds the pick with the cheapest
683
- // available tier so the CTA is never dead, and `selectTier` follows the
684
- // tap after that — silently charging for the cheapest tier when a buyer
685
- // had chosen another one would be the wrong kind of "helpful".
686
- //
687
- // The re-read is deliberate: the tier list on screen may be seconds or
688
- // minutes old, and a tier can sell out in between. Re-reading also
689
- // covers the case where `load`'s best-effort tier read failed and the
690
- // picker is collapsed.
691
- const tickets = await queryAvailableTickets(s.id);
692
- const live = (tickets.definitions ?? []).filter(tierAvailable);
693
- // A recovered first read must show the offer before it can be sold.
694
- if (live.length > 0 && !s.selectedTierId && s.ticketTiers.length === 0) return svc.load();
695
- const tier = live.find((t) => t.id === s.selectedTierId);
696
- if (!tier) return derive({ ...s, actionMessage: copyParam("event.ticketChanged", "That ticket is no longer available. Choose a ticket again.") });
697
- ticketCheckoutAllowed = await mayUseRegularTicketCheckout(checkoutEvent, tier);
698
- if (!ticketCheckoutAllowed) return derive({ ...s, actionMessage: copyParam("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
699
- if (tier?.id) {
700
- const held = await createTicketReservation(s.id, [{ ticketDefinitionId: tier.id, quantity: 1 }]);
701
- reservationId = held.id ?? "";
702
- if (reservationId) {
703
- const placed = await checkoutReservation(s.id, reservationId);
704
- const order = placed.order;
705
- const snapshotId = order?.snapshotId ?? "";
706
- if (!snapshotId) {
707
- // Nothing to charge ⇒ the ticket is already issued.
708
- return derive({ ...s, actionMessage: copyParam("event.ticketIssued", "You're in — your ticket is confirmed.") });
709
- }
710
- const msid = await wixPlatformSession
711
- .signedInstanceStatus()
712
- .then((st) => st.msid ?? "")
713
- .catch(() => "");
714
- const pay = await payCashierOrder({
715
- appId: WIX_EVENTS_APP_ID,
716
- snapshotId,
717
- msid,
718
- amount: order?.totalPrice?.amount,
719
- currency: order?.totalPrice?.currency,
720
- // Wallet-less apps keep the hosted events checkout, IN-APP — and
721
- // pinned to the reservation we already hold, so the buyer doesn't
722
- // restart tier selection from the slug.
723
- cashierUrl: async () => {
724
- const redirect = await createRedirectSession({ eventsCheckout: { reservationId } });
725
- return redirect.redirectSession?.fullUrl;
726
- },
727
- });
728
- if (pay.kind === "paid") {
729
- return derive({ ...s, actionMessage: copyParam("event.ticketIssued", "You're in — your ticket is confirmed.") });
730
- }
731
- if (pay.kind === "handoff") return derive({ ...s, actionMessage: copyParam("event.checkoutOpened", "Complete checkout in the browser to get your tickets.") });
732
- if (pay.kind === "cancelled") {
733
- return derive({ ...s, actionMessage: copyParam("event.ticketCancelled", "Payment cancelled — no ticket was issued.") });
734
- }
735
- }
736
- }
737
- } catch {
738
- // fall through to the hosted rail below
739
- }
740
- // Hosted eventsCheckout — pinned to the live reservation when we have one.
741
- if (!eventPageAllowed && !reservationId) return derive({ ...s, actionMessage: copyParam("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
742
- try {
743
- const params = reservationId ? { reservationId } : s.eventSlug ? { eventSlug: s.eventSlug } : undefined;
744
- if (params) {
745
- const redirect = await createRedirectSession({ eventsCheckout: params });
746
- const url = redirect.redirectSession?.fullUrl;
747
- if (url) {
748
- const result = await open(url, "in_app_browser");
749
- return derive({
750
- ...s,
751
- actionMessage: result.opened
752
- ? copyParam("event.checkoutOpened", "Complete checkout in the browser to get your tickets.")
753
- : copyParam("event.registrationOpenFailed", "Could not open the registration page."),
754
- });
755
- }
756
- }
757
- } catch {
758
- // fall through to registrationUrl
759
- }
760
- }
761
- // Hosted: the HOST opens the event's own page signed in as the lent member — ids only; it
762
- // reads the page URL from Wix and refuses (a custom domain → a visitor page; no published Events
763
- // page → nothing) on its own. Nothing here can change between its prepare and its open (the event
764
- // is fixed and the page is where the member chooses and pays), so the guard has nothing to re-read.
765
- // A host that refused or failed leaves the visitor page below.
766
- if (!eventPageAllowed) return derive({ ...s, actionMessage: copyParam("event.purchaseUnavailable", "Purchasing is temporarily unavailable.") });
767
- if (isHostedWorker() && s.id && (s.registrationKind === "tickets" || s.registrationKind === "rsvp")) {
768
- // One signed-in open at a time: the host keeps a single one-time ticket, so a second tap would
769
- // replace the first tap's ticket and drop that tap into the signed-OUT visitor page below.
770
- if (hostedOpenInFlight) return s;
771
- hostedOpenInFlight = true;
772
- let opened: boolean | "withdrawn";
773
- try {
774
- opened = await hostedSitePageOpener({ page: "event", eventId: s.id })(async () => true);
775
- } finally {
776
- hostedOpenInFlight = false;
777
- }
778
- if (opened === true) {
779
- return derive({
780
- ...s,
781
- actionMessage: s.registrationKind === "tickets"
782
- ? copyParam("event.checkoutOpened", "Complete checkout in the browser to get your tickets.")
783
- : "",
784
- });
785
- }
786
- }
787
- if (!s.registrationUrl) {
788
- return derive({ ...s, actionMessage: copyParam("event.registrationOnPage", "Registration for this event happens on the event page.") });
789
- }
1230
+ if (s.registrationKind !== "external" || !s.registrationUrl) return derive(s);
790
1231
  try {
791
1232
  const result = await open(s.registrationUrl, "in_app_browser");
792
- return derive({ ...s, actionMessage: result.opened ? "" : copyParam("event.registrationOpenFailed", "Could not open the registration page.") });
1233
+ return derive({ ...s, snackbarMessage: result.opened ? "" : copyParam("event.registrationOpenFailed", "Could not open the registration page.") });
793
1234
  } catch {
794
- return derive({ ...s, actionMessage: copyParam("event.registrationOpenFailed", "Could not open the registration page.") });
1235
+ return derive({ ...s, snackbarMessage: copyParam("event.registrationOpenFailed", "Could not open the registration page.") });
795
1236
  }
796
1237
  },
797
1238
  share: async (s) => {
@@ -801,11 +1242,11 @@ const svc = defineService<S, {
801
1242
  await shareSheet({ subject: s.title, text, ...(s.shareUrl ? { url: s.shareUrl } : {}) });
802
1243
  return s;
803
1244
  } catch {
804
- return derive({ ...s, actionMessage: copyParam("event.shareUnavailable", "Sharing is not available on this device.") });
1245
+ return derive({ ...s, snackbarMessage: copyParam("event.shareUnavailable", "Sharing is not available on this device.") });
805
1246
  }
806
1247
  },
807
1248
  addToCalendar: async (s) => {
808
- if (!s.calendarStart) return derive({ ...s, actionMessage: copyParam("event.noDateMessage", "This event does not have a date yet.") });
1249
+ if (!s.calendarStart) return derive({ ...s, rsvpActionsOpen: false, snackbarMessage: copyParam("event.noDateMessage", "This event does not have a date yet.") });
809
1250
  const end = s.calendarEnd > s.calendarStart ? s.calendarEnd : s.calendarStart + 60 * 60 * 1000;
810
1251
  try {
811
1252
  let permission = await getPermissionStatus();
@@ -814,7 +1255,7 @@ const svc = defineService<S, {
814
1255
  }
815
1256
  if (permission !== "granted" && permission !== "limited") {
816
1257
  const fallback = await open(calendarFallbackUrl(s, end), "in_app_browser");
817
- return derive({ ...s, actionMessage: fallback.opened ? copyParam("event.calendarOpened", "Opened calendar.") : copyParam("event.calendarPermissionDenied", "Calendar permission was not granted.") });
1258
+ return derive({ ...s, rsvpActionsOpen: false, snackbarMessage: fallback.opened ? copyParam("event.calendarOpened", "Opened calendar.") : copyParam("event.calendarPermissionDenied", "Calendar permission was not granted.") });
818
1259
  }
819
1260
  const created = await createEvent({
820
1261
  title: s.title || "Event",
@@ -825,25 +1266,26 @@ const svc = defineService<S, {
825
1266
  });
826
1267
  if (!created.id) {
827
1268
  const fallback = await open(calendarFallbackUrl(s, end), "in_app_browser");
828
- return derive({ ...s, actionMessage: fallback.opened ? copyParam("event.calendarOpened", "Opened calendar.") : copyParam("event.calendarAddFailed", "Could not add this event to calendar."), isAddingCalendar: false });
1269
+ return derive({ ...s, rsvpActionsOpen: false, snackbarMessage: fallback.opened ? copyParam("event.calendarOpened", "Opened calendar.") : copyParam("event.calendarAddFailed", "Could not add this event to calendar."), isAddingCalendar: false });
829
1270
  }
830
1271
  return derive({
831
1272
  ...s,
832
- actionMessage: copyParam("event.calendarAdded", "Added to calendar."),
1273
+ rsvpActionsOpen: false,
1274
+ snackbarMessage: copyParam("event.calendarAdded", "Added to calendar."),
833
1275
  isAddingCalendar: false,
834
1276
  });
835
1277
  } catch {
836
- return derive({ ...s, actionMessage: copyParam("event.calendarAddFailed", "Could not add this event to calendar."), isAddingCalendar: false });
1278
+ return derive({ ...s, rsvpActionsOpen: false, snackbarMessage: copyParam("event.calendarAddFailed", "Could not add this event to calendar."), isAddingCalendar: false });
837
1279
  }
838
1280
  },
839
1281
  // Only http(s), mailto and tel links reach open_url; a refused link, or one nothing opened, says so, and a
840
1282
  // link that opens clears an earlier message.
841
1283
  openLink: async (s, arg) => derive({
842
1284
  ...s,
843
- actionMessage: (await openRichLink(arg)) ? "" : copyParam("event.linkOpenFailed", "This link could not be opened."),
1285
+ snackbarMessage: (await openRichLink(arg)) ? "" : copyParam("event.linkOpenFailed", "This link could not be opened."),
844
1286
  }),
845
1287
  },
846
1288
  });
847
- resetUnhydratedKeysOnOlderRuntime(svc, { ...svc.getState(), ...ATTENDANCE_INITIAL, ticketPurchaseBlocked: true }, [...ATTENDANCE_NOT_HYDRATED, "ticketPurchaseBlocked"]);
1289
+ resetUnhydratedKeysOnOlderRuntime(svc, { ...svc.getState(), ...ATTENDANCE_INITIAL, ticketPurchaseBlocked: true, ticketSheetOpen: false, rsvpActionsOpen: false, cancelConfirmOpen: false, rsvpId: "", rsvpState: "none", rsvpLabel: "" }, [...ATTENDANCE_NOT_HYDRATED, "ticketPurchaseBlocked", "ticketSheetOpen", "rsvpActionsOpen", "cancelConfirmOpen", "rsvpId", "rsvpState", "rsvpLabel"]);
848
1290
  svc.load();
849
1291
  export { svc };