@wix/himalaya-cli 0.1496.0 → 0.1498.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/attendance-classifier.d.ts +37 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/catalog-pricing-contract.d.ts +1 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/index.d.ts +2 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-contract.d.ts +11 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +27 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-contract.d.ts +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-verticals/attendance.d.ts +10 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-verticals/index.d.ts +1 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/index.d.ts +1 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/events/src/index.d.ts +33 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +346 -26
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +132 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +366 -48
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-events.mjs +5 -0
- package/dist/cli.mjs +817 -666
- package/guides/iap-products.md +7 -1
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +394 -0
- package/templates/beauty-salon/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/beauty-salon/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/beauty-salon/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +52 -7
- package/templates/beauty-salon-owner/dev/index.ts +571 -0
- package/templates/beauty-salon-owner/himalaya.content.json +6 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
- package/templates/branded-lite/dev/index.ts +1429 -278
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +46 -8
- package/templates/branded-lite/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/branded-lite/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/branded-lite/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/branded-lite/tier5-src/business-chat/index.ts +3 -2
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +52 -7
- package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/community-club/dev/index.ts +397 -2
- package/templates/community-club/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/community-club/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/community-club/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/community-club/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/community-club-owner/dev/index.ts +571 -0
- package/templates/community-club-owner/himalaya.content.json +6 -0
- package/templates/community-club-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/community-club-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/community-club-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/community-club-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
- package/templates/events-meetups/dev/index.ts +397 -2
- package/templates/events-meetups/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/events-meetups/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/events-meetups/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/events-meetups-owner/dev/index.ts +571 -0
- package/templates/events-meetups-owner/himalaya.content.json +6 -0
- package/templates/events-meetups-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/events-meetups-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
- package/templates/gym-fitness/dev/index.ts +828 -39
- package/templates/gym-fitness/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/gym-fitness/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/gym-fitness/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +52 -7
- package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/gym-fitness-owner/dev/index.ts +571 -0
- package/templates/gym-fitness-owner/himalaya.content.json +6 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
- package/templates/nonprofit-cause/dev/index.ts +397 -2
- package/templates/nonprofit-cause/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/nonprofit-cause/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/nonprofit-cause/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/nonprofit-cause-owner/dev/index.ts +571 -0
- package/templates/nonprofit-cause-owner/himalaya.content.json +6 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
- package/templates/owner-lite/dev/index.ts +571 -0
- package/templates/owner-lite/himalaya.content.json +6 -0
- package/templates/owner-lite/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/owner-lite/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/owner-lite/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/owner-lite/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
- package/templates/podcast-creator/dev/index.ts +397 -2
- package/templates/podcast-creator/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/podcast-creator/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/podcast-creator/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/podcast-creator-owner/dev/index.ts +571 -0
- package/templates/podcast-creator-owner/himalaya.content.json +6 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
- package/templates/portfolio-creative/dev/index.ts +397 -2
- package/templates/portfolio-creative/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/portfolio-creative/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/portfolio-creative/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/portfolio-creative-owner/dev/index.ts +571 -0
- package/templates/portfolio-creative-owner/himalaya.content.json +6 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
- package/templates/restaurant/dev/index.ts +397 -2
- package/templates/restaurant/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/restaurant/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/restaurant/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/restaurant/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/restaurant-owner/dev/index.ts +571 -0
- package/templates/restaurant-owner/himalaya.content.json +6 -0
- package/templates/restaurant-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/restaurant-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
- package/templates/retail-store-owner/dev/index.ts +571 -0
- package/templates/retail-store-owner/himalaya.content.json +6 -0
- package/templates/retail-store-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/retail-store-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
- package/templates/yoga-studio/dev/index.ts +828 -39
- package/templates/yoga-studio/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/yoga-studio/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/yoga-studio/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +52 -7
- package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +112 -21
- package/templates/yoga-studio-owner/dev/index.ts +571 -0
- package/templates/yoga-studio-owner/himalaya.content.json +6 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/owner-today-rollup.ts +307 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/owner-widget-reads.ts +23 -0
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-today/index.ts +8 -20
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-today-rollup/index.ts +121 -0
|
@@ -160,6 +160,13 @@ function safeAvatar(value: unknown): string {
|
|
|
160
160
|
return match && !/[@%]/.test(match[1]!) ? value : "";
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/** A card's picture, only a plain https URL. Wix sends a 200-point thumbnail; a card shows it about 270 wide on a
|
|
164
|
+
* dense screen, so a Wix media URL asks its image service for a larger copy instead of being blown up. */
|
|
165
|
+
function cardImage(value: unknown): string {
|
|
166
|
+
const url = safeAvatar(value);
|
|
167
|
+
return /^https:\/\/static\.wixstatic\.com\/media\//i.test(url) ? url.replace(/\/v1\/fit\/w_\d+,h_\d+,/i, "/v1/fit/w_800,h_800,") : url;
|
|
168
|
+
}
|
|
169
|
+
|
|
163
170
|
function initials(value: string): string {
|
|
164
171
|
const words = value.trim().split(/\s+/).filter(Boolean);
|
|
165
172
|
return words.length > 1 ? `${words[0]![0]}${words[words.length - 1]![0]}`.toUpperCase() : (words[0]?.slice(0, 2) ?? "").toUpperCase();
|
|
@@ -316,8 +323,10 @@ const isSafeLink = (url: string): boolean => SAFE_LINK.test(url) && !/[\u0000-\u
|
|
|
316
323
|
|
|
317
324
|
/** Owner tools (a product, a coupon, a scheduling link) reach a member as plain text: a title, detail
|
|
318
325
|
* lines and `Label: https://…` lines. On a phone that is a wall of text with a URL nobody can tap, so a
|
|
319
|
-
* link line becomes a button and a coupon code its own block. Only plain https links are lifted.
|
|
320
|
-
|
|
326
|
+
* link line becomes a button and a coupon code its own block. Only plain https links are lifted. A message shown
|
|
327
|
+
* without a card (an AI answer) has no coupon block, so `liftCoupon: false` leaves its code in the text. */
|
|
328
|
+
export function extractBusinessMessageExtras(markdown: string, options: { liftCoupon?: boolean } = {}): { text: string; links: { label: string; url: string }[]; couponCode: string } {
|
|
329
|
+
const liftCoupon = options.liftCoupon ?? true;
|
|
321
330
|
const kept: string[] = [];
|
|
322
331
|
const links: { label: string; url: string }[] = [];
|
|
323
332
|
let couponCode = "";
|
|
@@ -343,7 +352,7 @@ export function extractBusinessMessageExtras(markdown: string): { text: string;
|
|
|
343
352
|
}
|
|
344
353
|
}
|
|
345
354
|
const code = /^(.*?)[,;]?\s*\b[Cc]ode:\s*([A-Z0-9_-]{3,32})$/.exec(trimmed);
|
|
346
|
-
if (code && !couponCode) {
|
|
355
|
+
if (code && liftCoupon && !couponCode) {
|
|
347
356
|
couponCode = code[2]!;
|
|
348
357
|
if (code[1]!.trim()) kept.push(code[1]!.trim());
|
|
349
358
|
continue;
|
|
@@ -383,6 +392,14 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
383
392
|
noCoupon: boolean;
|
|
384
393
|
productSlug: string;
|
|
385
394
|
productName: string;
|
|
395
|
+
isCard: boolean;
|
|
396
|
+
notCard: boolean;
|
|
397
|
+
noSuggestions: boolean;
|
|
398
|
+
noActions: boolean;
|
|
399
|
+
cardTitle: string;
|
|
400
|
+
cardDetail: string;
|
|
401
|
+
/** A card with neither title nor detail (a file, a coupon with no line): its text block is hidden, not an empty gap. */
|
|
402
|
+
noCardText: boolean;
|
|
386
403
|
})[] {
|
|
387
404
|
const metadata = new Map<string, RecordValue>();
|
|
388
405
|
for (const value of raw) {
|
|
@@ -410,14 +427,15 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
410
427
|
: "";
|
|
411
428
|
if (key) previousDay = key;
|
|
412
429
|
|
|
413
|
-
const imageUrl = imagePart?.url || "";
|
|
414
430
|
const template = object(message.templateData);
|
|
431
|
+
// A product the owner sends from the Inbox carries its picture on the card itself (`templateData.image`).
|
|
432
|
+
const imageUrl = imagePart?.url || cardImage(template.image);
|
|
415
433
|
const standardEventText = message.messageType === "template/standard-event"
|
|
416
434
|
? [template.title, ...(Array.isArray(template.lines) ? template.lines : [])]
|
|
417
435
|
.filter((value): value is string => typeof value === "string" && !!value.trim())
|
|
418
436
|
.join("\n")
|
|
419
437
|
: "";
|
|
420
|
-
const bodyText =
|
|
438
|
+
const bodyText = imagePart?.url
|
|
421
439
|
? row.parts.filter((part) => part.kind === "text").map((part) => [part.text, ...(part.fields ?? []).map((field) => `${field.name}: ${field.value}`)].filter(Boolean).join("\n")).filter(Boolean).join("\n")
|
|
422
440
|
: standardEventText || row.plainText;
|
|
423
441
|
const senderAvatar = safeAvatar(image.url);
|
|
@@ -434,7 +452,8 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
434
452
|
const bodyRuns = htmlMessageRuns(message) ?? plainTextRuns;
|
|
435
453
|
const bodyMarkdown = markdownFromRuns(bodyRuns);
|
|
436
454
|
const buttons = messageButtons(message, row.id);
|
|
437
|
-
|
|
455
|
+
// An AI answer is never a card, and only a card shows the coupon block, so its code stays in its text.
|
|
456
|
+
const extras = mine ? { text: bodyMarkdown, links: [], couponCode: "" } : extractBusinessMessageExtras(bodyMarkdown, { liftCoupon: !isAiGenerated });
|
|
438
457
|
// A card is a title and detail lines: Wix sends events as one, and an owner's product, coupon or scheduling link
|
|
439
458
|
// arrives as plain text of the same shape. Its title leads in bold, and a coupon's title is its code, which
|
|
440
459
|
// the coupon block already shows.
|
|
@@ -447,10 +466,21 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
447
466
|
const titled = cardLike && extras.couponCode && first === extras.couponCode ? lines.slice(1).join("\n")
|
|
448
467
|
: cardLike && lines.length > 1 && !first.startsWith("**") ? `**${first}**\n${lines.slice(1).join("\n")}` : extras.text;
|
|
449
468
|
// A product card links to its store page; the slug lets the worker find the product and show its picture.
|
|
450
|
-
|
|
469
|
+
// The link is a line of the text or, from the Inbox's product tool, a template button.
|
|
470
|
+
// Only a card shows the picture, so an AI answer (never a card) does not look one up.
|
|
471
|
+
const productPage = cardLike && !isAiGenerated ? [...extras.links, ...articleActions(message, row.id)].map((link) => /^https:\/\/[^/?#]+(?:\/[^/?#]+)*?\/product-page\/([^/?#]+)/i.exec(link.url)?.[1]).find(Boolean) : undefined;
|
|
451
472
|
// A file, recording or video has no inline player here: it gets a button that opens it.
|
|
452
473
|
const attachmentLinks = row.parts.filter((part) => (part.kind === "file" || part.kind === "audio" || part.kind === "video") && !!part.url)
|
|
453
474
|
.map((part, at) => ({ id: `${row.id}:attachment-${at}`, label: (part.fileName || part.caption || `Open ${part.kind}`).slice(0, 40), url: part.url as string, style: "secondary" as const }));
|
|
475
|
+
// The card look (a framed box with a centred title) is for what an owner tool sends: a product, coupon, scheduling
|
|
476
|
+
// or pay link, an event, or a file. A coupon's title is its code, which the coupon block already shows.
|
|
477
|
+
// An AI answer is text with link pills, as on the site's Smart Chat, even when it is shaped like a card.
|
|
478
|
+
const isCard = !mine && !isAiGenerated && (cardLike || !!extras.couponCode || (attachmentLinks.length > 0 && !imageUrl));
|
|
479
|
+
const cardTitle = cardLike && !(extras.couponCode && first === extras.couponCode) ? first.replace(/^\*\*(.+)\*\*$/, "$1") : "";
|
|
480
|
+
// A product card shows its name and price, as on the site's Smart Chat; the "4 variants | In stock" summary the
|
|
481
|
+
// Inbox adds to the text is left out of it.
|
|
482
|
+
const cardLines = cardTitle ? lines.slice(1) : titled.split("\n");
|
|
483
|
+
const cardDetail = (productPage ? cardLines.filter((line) => !/^\s*\d+\s+variants?\b|\|\s*(?:in|out of)\s+stock\s*$/i.test(line)) : cardLines).join("\n");
|
|
454
484
|
const articles = [...articleActions(message, row.id), ...attachmentLinks, ...extras.links.map((link, at) => ({ id: `${row.id}:link-${at}`, label: link.label, url: link.url, style: /\b(view|buy|shop|book|pay|order|checkout|reserve|schedule)\b/i.test(link.label) ? "primary" as const : "secondary" as const }))]
|
|
455
485
|
.filter((action, at, all) => all.findIndex((other) => other.url === action.url) === at);
|
|
456
486
|
|
|
@@ -463,7 +493,8 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
463
493
|
senderInitials: typeof display.initials === "string" && display.initials.trim() ? display.initials.trim() : initials(senderName),
|
|
464
494
|
senderBadge: participantId && !mine ? isAiGenerated ? "AI assistant" : "Staff Response" : "",
|
|
465
495
|
imageUrl,
|
|
466
|
-
|
|
496
|
+
// A card's own picture is named by the card (a product's name), not as an anonymous attachment.
|
|
497
|
+
imageDescription: imagePart?.caption || (imageUrl && !imagePart?.url && first ? first.replace(/\*/g, "") : "Image attachment"),
|
|
467
498
|
noImage: !imageUrl,
|
|
468
499
|
bodyText,
|
|
469
500
|
bodyRuns,
|
|
@@ -472,6 +503,13 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
|
|
|
472
503
|
noCoupon: !extras.couponCode,
|
|
473
504
|
productSlug: productPage ?? "",
|
|
474
505
|
productName: productPage ? first : "",
|
|
506
|
+
isCard,
|
|
507
|
+
notCard: !isCard,
|
|
508
|
+
noSuggestions: buttons.length === 0,
|
|
509
|
+
noActions: articles.length === 0,
|
|
510
|
+
cardTitle,
|
|
511
|
+
noCardText: !cardTitle && !cardDetail.trim(),
|
|
512
|
+
cardDetail,
|
|
475
513
|
isAiGenerated,
|
|
476
514
|
suggestions: buttons,
|
|
477
515
|
articleActions: articles,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { copyParam } from "./himi-copy.js";
|
|
2
|
+
|
|
3
|
+
export interface AttendanceField {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
required: boolean;
|
|
8
|
+
value: string;
|
|
9
|
+
selectedIds: string[];
|
|
10
|
+
options: { id: string; label: string }[];
|
|
11
|
+
error: string;
|
|
12
|
+
maxLength?: number;
|
|
13
|
+
maxSize?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const object = (v: unknown): Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
|
|
17
|
+
const text = (v: unknown): string => typeof v === "string" ? v : "";
|
|
18
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
19
|
+
|
|
20
|
+
export function createAttendanceForm(prefix: string) {
|
|
21
|
+
let fields: AttendanceField[] = [];
|
|
22
|
+
let supported = false;
|
|
23
|
+
const label = (name: string) => copyParam(`${prefix}.${name}Label`, name === "firstName" ? "First name" : name === "lastName" ? "Last name" : "Email");
|
|
24
|
+
const field = (scope: string, name: string, input: Record<string, unknown> = {}): AttendanceField => ({
|
|
25
|
+
id: `${scope}:${name}`, label: text(input.label) || label(name), required: input.mandatory === true,
|
|
26
|
+
kind: name === "email" ? "email" : input.type === "NUMBER" ? "number" : input.type === "TEXT_ARRAY" ? "multiple" : Array.isArray(input.options) && input.options.length ? "choice" : "text",
|
|
27
|
+
value: "", selectedIds: [], error: "",
|
|
28
|
+
options: Array.isArray(input.options) ? input.options.filter((o): o is string => typeof o === "string").map((o) => ({ id: o, label: o })) : [],
|
|
29
|
+
...(typeof input.maxLength === "number" ? { maxLength: input.maxLength } : {}),
|
|
30
|
+
...(typeof input.maxSize === "number" ? { maxSize: input.maxSize } : {}),
|
|
31
|
+
});
|
|
32
|
+
const contact = (scope: string) => ["firstName", "lastName", "email"].map((name) => field(scope, name, { mandatory: true }));
|
|
33
|
+
|
|
34
|
+
function event(raw: unknown): void {
|
|
35
|
+
fields = contact("buyer");
|
|
36
|
+
const controls = object(raw).controls;
|
|
37
|
+
supported = Array.isArray(controls) && controls.length > 0;
|
|
38
|
+
const names = new Set<string>();
|
|
39
|
+
for (const control of Array.isArray(controls) ? controls : []) {
|
|
40
|
+
const c = object(control);
|
|
41
|
+
if (c.deleted === true) continue;
|
|
42
|
+
if (!Array.isArray(c.inputs) || !c.inputs.length) { supported = false; continue; }
|
|
43
|
+
for (const rawInput of c.inputs) {
|
|
44
|
+
const input = object(rawInput);
|
|
45
|
+
const name = text(input.name);
|
|
46
|
+
if (!name || names.has(name) || !["TEXT", "NUMBER", "TEXT_ARRAY"].includes(text(input.type)) || (input.type === "TEXT_ARRAY" && (!Array.isArray(input.options) || !input.options.length))) { supported = false; continue; }
|
|
47
|
+
names.add(name);
|
|
48
|
+
fields.push(field("guest", name, { ...input, mandatory: input.mandatory === true || ["firstName", "lastName", "email"].includes(name) }));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!["firstName", "lastName", "email"].every((name) => names.has(name))) supported = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function booking(service: unknown): void {
|
|
55
|
+
fields = contact("buyer");
|
|
56
|
+
const id = text(object(object(service).form).id);
|
|
57
|
+
// Custom intake schemas need their own reader; never silently omit their required fields.
|
|
58
|
+
supported = id === "00000000-0000-0000-0000-000000000000";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function change(arg: unknown): void {
|
|
62
|
+
const a = object(arg);
|
|
63
|
+
const f = fields.find((candidate) => candidate.id === a.id);
|
|
64
|
+
if (!f) return;
|
|
65
|
+
if (typeof a.option === "string") {
|
|
66
|
+
if (f.kind === "multiple") f.selectedIds = f.selectedIds.includes(a.option) ? f.selectedIds.filter((v) => v !== a.option) : [...f.selectedIds, a.option];
|
|
67
|
+
else f.value = a.option;
|
|
68
|
+
f.error = "";
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
f.value = text(a.value ?? a.text ?? a.selectedId);
|
|
72
|
+
f.selectedIds = Array.isArray(a.selectedIds) ? a.selectedIds.filter((v): v is string => typeof v === "string") : [];
|
|
73
|
+
f.error = "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function valid(f: AttendanceField): boolean {
|
|
77
|
+
const value = f.value.trim();
|
|
78
|
+
const values = f.kind === "multiple" ? f.selectedIds : value ? [value] : [];
|
|
79
|
+
if (!values.length) return !f.required;
|
|
80
|
+
if (f.options.length && values.some((v) => !f.options.some((o) => o.id === v))) return false;
|
|
81
|
+
if (f.maxSize !== undefined && values.length > f.maxSize) return false;
|
|
82
|
+
if (f.maxLength !== undefined && values.some((v) => v.length > f.maxLength!)) return false;
|
|
83
|
+
return f.kind === "email" ? EMAIL.test(value) : f.kind !== "number" || /^-?\d+(?:\.\d+)?$/.test(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validate(): boolean {
|
|
87
|
+
for (const f of fields) f.error = valid(f) ? "" : copyParam(`${prefix}.formInvalidText`, "Check this field.");
|
|
88
|
+
return supported && fields.every((f) => !f.error);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buyer(): Record<string, string> {
|
|
92
|
+
return Object.fromEntries(fields.filter((f) => f.id.startsWith("buyer:")).map((f) => [f.id.slice(6), f.value.trim()]));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ticket(definitionId: string) {
|
|
96
|
+
const inputValues = fields.filter((f) => f.id.startsWith("guest:") && (f.value.trim() || f.selectedIds.length)).map((f) => ({ inputName: f.id.slice(6), ...(f.kind === "multiple" ? { values: f.selectedIds.slice() } : { value: f.value.trim() }) }));
|
|
97
|
+
return { buyer: buyer(), guests: [{ ticketDefinitionId: definitionId, form: { inputValues } }] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
event, booking, change, validate, buyer, ticket,
|
|
102
|
+
supported: () => supported,
|
|
103
|
+
rows: () => fields.map((f) => ({ ...f, selectedIds: f.selectedIds.slice(), kind: f.kind === "text" && f.required ? "requiredText" : f.kind })),
|
|
104
|
+
clear: () => { for (const f of fields) { f.value = ""; f.selectedIds = []; f.error = ""; } },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { attendanceCheckoutRoute, availableTicketAttendanceOf, bookingAttendanceOf, classifyForApple, hostSessionFromGlobal, iapPriceFields, type BookingAttendance, type CatalogReference, type IapPollDirective, type TicketAttendance } from "@himalaya/iap";
|
|
2
|
+
import { bookingOrders, createIapCommerce, eventTicketOrders, type IapOrderItem } from "@himalaya/iap-checkout";
|
|
3
|
+
import { copyParam, copyTemplate } from "./himi-copy.js";
|
|
4
|
+
import { createAttendanceForm, type AttendanceField } from "./iap-attendance-form.js";
|
|
5
|
+
import { attendancePlatform } from "./iap-attendance.js";
|
|
6
|
+
import { iapPriceCopy, iapPurchaseCopy } from "./iap-worker.js";
|
|
7
|
+
|
|
8
|
+
export interface AttendancePurchaseFields {
|
|
9
|
+
iapBuyHidden: boolean;
|
|
10
|
+
iapBuyDisabled: boolean;
|
|
11
|
+
iapBuyLabel: string;
|
|
12
|
+
iapBusy: boolean;
|
|
13
|
+
iapPrice: string;
|
|
14
|
+
iapNote: string;
|
|
15
|
+
iapRetryHidden: boolean;
|
|
16
|
+
iapFormHidden: boolean;
|
|
17
|
+
iapFormError: string;
|
|
18
|
+
iapSignInHidden: boolean;
|
|
19
|
+
iapFields: AttendanceField[];
|
|
20
|
+
_poll?: IapPollDirective;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ATTENDANCE_INITIAL: AttendancePurchaseFields = {
|
|
24
|
+
iapBuyHidden: true, iapBuyDisabled: true, iapBuyLabel: "", iapBusy: false,
|
|
25
|
+
iapPrice: "", iapNote: "", iapRetryHidden: true, iapFormHidden: true, iapFormError: "", iapSignInHidden: true, iapFields: [],
|
|
26
|
+
};
|
|
27
|
+
export const ATTENDANCE_NOT_HYDRATED = [...Object.keys(ATTENDANCE_INITIAL), "_poll"] as (keyof AttendancePurchaseFields & string)[];
|
|
28
|
+
|
|
29
|
+
export function createAttendancePurchase(options: { kind: "bookingsService" | "eventsTicket"; prefix: string; pollAction: string; onChange(): void }) {
|
|
30
|
+
const { prefix } = options;
|
|
31
|
+
let form = createAttendanceForm(prefix);
|
|
32
|
+
const submittedForms = new Map<string, ReturnType<typeof createAttendanceForm>>();
|
|
33
|
+
const session = hostSessionFromGlobal();
|
|
34
|
+
const commerce = createIapCommerce({ adapters: [options.kind === "bookingsService" ? bookingOrders : eventTicketOrders], onChange: options.onChange });
|
|
35
|
+
let generation = session.current().generation;
|
|
36
|
+
let reference: CatalogReference | undefined;
|
|
37
|
+
let metadata: BookingAttendance | TicketAttendance | undefined;
|
|
38
|
+
let platform = "unknown";
|
|
39
|
+
let started = false;
|
|
40
|
+
let inFlight = false;
|
|
41
|
+
let formError = "";
|
|
42
|
+
let sequence = 0;
|
|
43
|
+
let formRevision = 0;
|
|
44
|
+
const attempts = new Map<string, string>();
|
|
45
|
+
session.subscribe(() => { syncSession(); if (reference) options.onChange(); });
|
|
46
|
+
|
|
47
|
+
function syncSession(): void {
|
|
48
|
+
const next = session.current().generation;
|
|
49
|
+
if (next === generation) return;
|
|
50
|
+
generation = next;
|
|
51
|
+
form.clear();
|
|
52
|
+
submittedForms.clear();
|
|
53
|
+
formError = "";
|
|
54
|
+
attempts.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function refresh(): Promise<void> {
|
|
58
|
+
const next = await attendancePlatform(publishPlatform);
|
|
59
|
+
if (next !== "unknown" || platform === "unknown") platform = next;
|
|
60
|
+
await resume();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function resume(): Promise<void> {
|
|
64
|
+
if (reference) await commerce.loadPrices([{ reference }]);
|
|
65
|
+
const first = !started;
|
|
66
|
+
started = true;
|
|
67
|
+
await (first ? commerce.start() : commerce.reconcile());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function publishPlatform(next: string): void {
|
|
71
|
+
if (next === "unknown" && platform !== "unknown") return;
|
|
72
|
+
platform = next;
|
|
73
|
+
void resume().then(options.onChange, options.onChange);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function show(): void {
|
|
77
|
+
void attendancePlatform(publishPlatform).then(publishPlatform, options.onChange);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function booking(id: string, service: unknown): Promise<void> {
|
|
81
|
+
syncSession();
|
|
82
|
+
reference = { kind: options.kind, id };
|
|
83
|
+
metadata = bookingAttendanceOf(service);
|
|
84
|
+
selectForm(id, (next) => next.booking(service));
|
|
85
|
+
formRevision++;
|
|
86
|
+
formError = "";
|
|
87
|
+
await show();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function ticket(id: string, event: unknown, available: unknown): Promise<void> {
|
|
91
|
+
syncSession();
|
|
92
|
+
reference = id ? { kind: options.kind, id } : undefined;
|
|
93
|
+
metadata = availableTicketAttendanceOf(event, available);
|
|
94
|
+
selectForm(id, (next) => next.event((event as { form?: unknown } | undefined)?.form));
|
|
95
|
+
formRevision++;
|
|
96
|
+
formError = "";
|
|
97
|
+
await show();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function selectForm(id: string, initialize: (next: ReturnType<typeof createAttendanceForm>) => void): void {
|
|
101
|
+
const key = `${options.kind}:${id}`;
|
|
102
|
+
const retained = submittedForms.get(key);
|
|
103
|
+
form = retained ?? createAttendanceForm(prefix);
|
|
104
|
+
if (!retained) initialize(form);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fields(selected = true, publishPoll = true): AttendancePurchaseFields {
|
|
108
|
+
syncSession();
|
|
109
|
+
if (!reference || !metadata) return ATTENDANCE_INITIAL;
|
|
110
|
+
const currency = metadata.currency ?? "";
|
|
111
|
+
const classified = classifyForApple(metadata, currency);
|
|
112
|
+
const candidate = platform === "ios" && classified === "appleIap";
|
|
113
|
+
const price = iapPriceFields(commerce.pricing.view(reference), iapPriceCopy(prefix));
|
|
114
|
+
const copy = { ...iapPurchaseCopy(prefix), buy: copyParam(`${prefix}.appleBuyCta`, options.kind === "bookingsService" ? "Book with Apple" : "Buy ticket with Apple"), buyAnother: copyParam(`${prefix}.buyAnotherCta`, options.kind === "bookingsService" ? "Book again" : "Buy another ticket") };
|
|
115
|
+
const purchase = commerce.purchaseFields(reference, copy);
|
|
116
|
+
const apple = candidate || (platform === "ios" && classified !== "wixPayments" && purchase.appleRoute);
|
|
117
|
+
const enabled = attendanceCheckoutRoute(metadata, currency, platform) === "appleIap";
|
|
118
|
+
const poll = publishPoll ? commerce.pollDirective(options.pollAction) : undefined;
|
|
119
|
+
const state = commerce.state();
|
|
120
|
+
for (const [target, attempt] of attempts) {
|
|
121
|
+
const key = `${reference.kind}:${reference.id}#${encodeURIComponent(attempt)}`;
|
|
122
|
+
if ((state.phase === "accessReady" && state.item === key) || state.payments.some((p) => p.item === key && p.access === "granted")) attempts.delete(target);
|
|
123
|
+
}
|
|
124
|
+
const formKey = `${reference.kind}:${reference.id}`;
|
|
125
|
+
if (![...attempts.keys()].some((target) => target.startsWith(`${formKey}:`))) submittedForms.delete(formKey);
|
|
126
|
+
const own = state.item?.startsWith(`${reference.kind}:${reference.id}#`);
|
|
127
|
+
const completed = state.phase === "accessReady" && own;
|
|
128
|
+
const member = session.current().kind === "member";
|
|
129
|
+
let note = "";
|
|
130
|
+
if (apple && !enabled) note = copy.unavailable;
|
|
131
|
+
else if (apple && !member) note = copy.signInRequired;
|
|
132
|
+
else if (apple && purchase.buyNote) note = purchase.buyNote;
|
|
133
|
+
else if (apple && !form.supported()) note = copyParam(`${prefix}.formUnavailableText`, "This registration form is not supported yet.");
|
|
134
|
+
else if (apple && completed) note = copyParam(`${prefix}.purchaseCompleteText`, options.kind === "bookingsService" ? "Your booking is confirmed." : "Your ticket is confirmed.");
|
|
135
|
+
else if (apple && own && state.phase === "cancelled") note = copyParam(`${prefix}.purchaseCancelledText`, "Purchase cancelled. Your place is not confirmed.");
|
|
136
|
+
return {
|
|
137
|
+
iapBuyHidden: !apple, iapBuyDisabled: !enabled || !selected || !member || !form.supported() || purchase.buyDisabled || inFlight,
|
|
138
|
+
iapBuyLabel: purchase.buyLabel || copy.buy, iapBusy: purchase.buyBusy || inFlight,
|
|
139
|
+
iapPrice: apple ? price.priceText : "", iapRetryHidden: !apple || (price.hidePriceRetry && commerce.scopeComplete()),
|
|
140
|
+
iapNote: note,
|
|
141
|
+
iapFormHidden: !apple || !selected || !form.supported(), iapFormError: formError,
|
|
142
|
+
iapSignInHidden: !apple || member || options.kind === "bookingsService",
|
|
143
|
+
iapFields: form.rows().map((f, index, rows) => ({ ...f,
|
|
144
|
+
id: `${generation}:${formRevision}:${f.id}`, fieldId: f.id,
|
|
145
|
+
errorHidden: !f.error,
|
|
146
|
+
options: f.options.map((o) => ({ ...o, fieldId: f.id, displayLabel: (f.kind === "multiple" ? f.selectedIds.includes(o.id) : f.value === o.id) ? `${o.label} ✓` : o.label })),
|
|
147
|
+
label: f.required ? `${f.label} *` : f.label,
|
|
148
|
+
inputLabel: copyTemplate(`${prefix}.fieldAccessibilityLabel`, "{section}: {field}", { section: f.id.startsWith("buyer:") ? copyParam(`${prefix}.buyerDetailsLabel`, "Buyer details") : copyParam(`${prefix}.guestDetailsLabel`, "Ticket holder details"), field: f.label }),
|
|
149
|
+
sectionLabel: index === 0 ? copyParam(`${prefix}.buyerDetailsLabel`, "Buyer details") : f.id.startsWith("guest:") && rows[index - 1].id.startsWith("buyer:") ? copyParam(`${prefix}.guestDetailsLabel`, "Ticket holder details") : "",
|
|
150
|
+
})), ...(poll ? { _poll: poll } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function buy(selection: { key: string; slot?: Record<string, unknown>; eventId?: string }): Promise<void> {
|
|
155
|
+
syncSession();
|
|
156
|
+
if (!reference || !metadata || fields(true, false).iapBuyDisabled || inFlight) return;
|
|
157
|
+
if (options.kind === "eventsTicket" && reference.id !== selection.key) return;
|
|
158
|
+
if (!form.validate()) { formError = copyParam(`${prefix}.formRequiredText`, "Complete the required details before purchasing."); return; }
|
|
159
|
+
if (session.current().kind !== "member") { formError = iapPurchaseCopy(prefix).signInRequired; return; }
|
|
160
|
+
const currentGeneration = generation;
|
|
161
|
+
const ref = reference;
|
|
162
|
+
submittedForms.set(`${ref.kind}:${ref.id}`, form);
|
|
163
|
+
const target = `${ref.kind}:${ref.id}:${selection.key}`;
|
|
164
|
+
let attemptId = attempts.get(target);
|
|
165
|
+
const state = commerce.state();
|
|
166
|
+
const attemptKey = attemptId ? `${ref.kind}:${ref.id}#${encodeURIComponent(attemptId)}` : "";
|
|
167
|
+
const completed = attemptId && ((state.phase === "accessReady" && state.item === attemptKey) || state.payments.some((p) => p.item === attemptKey && p.access === "granted"));
|
|
168
|
+
if (!attemptId || completed) { attemptId = `${Date.now().toString(36)}-${++sequence}-${Math.random().toString(36).slice(2)}`; attempts.set(target, attemptId); }
|
|
169
|
+
const item: IapOrderItem = { reference: ref, attemptId, quantity: 1, attendance: {
|
|
170
|
+
amount: metadata.amount!, currency: metadata.currency!,
|
|
171
|
+
...(options.kind === "bookingsService" ? { booking: { slot: selection.slot ?? {}, contactDetails: form.buyer() } } : { ticket: { eventId: selection.eventId ?? "", ...form.ticket(ref.id) } }),
|
|
172
|
+
} };
|
|
173
|
+
inFlight = true;
|
|
174
|
+
formError = "";
|
|
175
|
+
options.onChange();
|
|
176
|
+
try {
|
|
177
|
+
await commerce.buy(item);
|
|
178
|
+
const after = commerce.state();
|
|
179
|
+
if (after.phase === "accessReady" && after.item === `${ref.kind}:${ref.id}#${encodeURIComponent(attemptId)}`) attempts.delete(target);
|
|
180
|
+
}
|
|
181
|
+
finally { inFlight = false; syncSession(); if (generation !== currentGeneration) form.clear(); options.onChange(); }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function change(arg: unknown): void {
|
|
185
|
+
syncSession();
|
|
186
|
+
const state = commerce.state();
|
|
187
|
+
if (reference) {
|
|
188
|
+
for (const [target, attempt] of attempts) {
|
|
189
|
+
const item = `${reference.kind}:${reference.id}#${encodeURIComponent(attempt)}`;
|
|
190
|
+
const canCorrect = state.phase === "cancelled" || (state.phase === "refused" && (state.failure === "orderRefused" || state.failure === "productChanged"));
|
|
191
|
+
if (canCorrect && state.item === item) { attempts.delete(target); submittedForms.delete(`${reference.kind}:${reference.id}`); }
|
|
192
|
+
}
|
|
193
|
+
const referencePrefix = `${reference.kind}:${reference.id}:`;
|
|
194
|
+
if (inFlight || [...attempts.keys()].some((target) => target.startsWith(referencePrefix))) {
|
|
195
|
+
formError = copyParam(`${prefix}.detailsLockedText`, "Finish the current purchase before changing registration details.");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
form.change(arg);
|
|
200
|
+
formError = "";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
booking, ticket, fields, buy,
|
|
205
|
+
usesRegularPlatform: () => platform === "android" || platform === "web",
|
|
206
|
+
invalidate: () => { reference = undefined; metadata = undefined; options.onChange(); },
|
|
207
|
+
change,
|
|
208
|
+
refresh,
|
|
209
|
+
poll: async () => { syncSession(); await commerce.poll(); },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { get as appInfo } from "@himalaya/app-info";
|
|
2
|
+
import { attendanceCheckoutRoute, bookingAttendanceOf, ticketAttendanceOf } from "@himalaya/iap";
|
|
3
|
+
|
|
4
|
+
let knownPlatform: string | undefined;
|
|
5
|
+
|
|
6
|
+
export function attendancePlatform(onResolved?: (platform: string) => void): Promise<string> {
|
|
7
|
+
if (knownPlatform) return Promise.resolve(knownPlatform);
|
|
8
|
+
const info = appInfo().then((value) => value.platform, () => "unknown").then((platform) => {
|
|
9
|
+
if (platform !== "unknown") knownPlatform = platform;
|
|
10
|
+
return platform;
|
|
11
|
+
});
|
|
12
|
+
const host = globalThis as { setTimeout?: (fn: () => void, ms: number) => unknown; clearTimeout?: (timer: unknown) => void };
|
|
13
|
+
if (!host.setTimeout) {
|
|
14
|
+
void info.then((platform) => onResolved?.(platform));
|
|
15
|
+
return Promise.resolve("unknown");
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let timedOut = false;
|
|
19
|
+
const timer = host.setTimeout!(() => { timedOut = true; resolve("unknown"); }, 1500);
|
|
20
|
+
void info.then((platform) => { host.clearTimeout?.(timer); resolve(platform); if (timedOut) onResolved?.(platform); });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function mayUseRegularBookingCheckout(service: unknown): Promise<boolean> {
|
|
25
|
+
const metadata = bookingAttendanceOf(service);
|
|
26
|
+
if (metadata.priceType === "NO_FEE" || isZero(metadata.amount)) return true;
|
|
27
|
+
return attendanceCheckoutRoute(metadata, metadata.currency ?? "", await attendancePlatform()) === "regular";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function mayUseRegularTicketCheckout(event: unknown, ticket: { free?: boolean; price?: { amount?: string; currency?: string }; saleStatus?: string } | undefined): Promise<boolean> {
|
|
31
|
+
if (ticket?.free === true || isZero(ticket?.price?.amount)) return true;
|
|
32
|
+
const metadata = ticketAttendanceOf(event, { hidden: false, saleStatus: ticket?.saleStatus });
|
|
33
|
+
return attendanceCheckoutRoute(metadata, ticket?.price?.currency ?? "", await attendancePlatform()) === "regular";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isZero(value: unknown): boolean {
|
|
37
|
+
return typeof value === "string" && /^0+(?:\.0+)?$/.test(value);
|
|
38
|
+
}
|
|
@@ -14,6 +14,7 @@ type PendingIntent = BusinessChatIntent;
|
|
|
14
14
|
// parts, plain text or text runs: they would be re-sent each time for nothing.
|
|
15
15
|
type BusinessMessageRow = Pick<MessageRow,'id'|'sender'|'when'|'createdDate'|'mine'> & {
|
|
16
16
|
senderAvatar:string; senderName:string; senderInitials:string; senderBadge:string; imageUrl:string; imageDescription:string; noImage:boolean; bodyText:string; bodyMarkdown:string; suggestions:BusinessSuggestion[]; articleActions:{id:string;label:string;url:string;style:'primary'|'secondary'}[]; couponCode:string; noCoupon:boolean; notMine:boolean;
|
|
17
|
+
isCard:boolean; notCard:boolean; noSuggestions:boolean; noActions:boolean; cardTitle:string; cardDetail:string; noCardText:boolean;
|
|
17
18
|
dayLabel:string; dayHidden:boolean; startsDay:boolean; scrollAnchor:string;
|
|
18
19
|
/** Only the member's own row that the server has not returned yet. */
|
|
19
20
|
pending?:boolean;
|
|
@@ -67,7 +68,7 @@ function pendingRow(intent:BusinessChatIntent):BusinessMessageRow{
|
|
|
67
68
|
const when=intent.delivery==='sending'?'Sending…':intent.delivery==='unconfirmed'?'Not confirmed':messageTimeLabel(intent.createdDate);
|
|
68
69
|
return {id:intent.id,sender:'',when,createdDate:intent.createdDate,mine:true,
|
|
69
70
|
senderAvatar:'',senderName:intent.senderName??'',senderInitials:'',senderBadge:'',imageUrl:'',imageDescription:'',noImage:true,bodyText:intent.text,bodyMarkdown:intent.text,
|
|
70
|
-
suggestions:[],articleActions:[],couponCode:'',noCoupon:true,notMine:false,dayLabel:'',dayHidden:true,startsDay:false,scrollAnchor:`himi_business_chat.message_${intent.id}`,pending:true};
|
|
71
|
+
suggestions:[],articleActions:[],couponCode:'',noCoupon:true,notMine:false,isCard:false,notCard:true,noSuggestions:true,noActions:true,cardTitle:'',cardDetail:'',noCardText:true,dayLabel:'',dayHidden:true,startsDay:false,scrollAnchor:`himi_business_chat.message_${intent.id}`,pending:true};
|
|
71
72
|
}
|
|
72
73
|
function shape(s:State):State{const used=new Set(s.usedSuggestionIds);const messages=s.messages.map(row=>({...row,suggestions:(row.suggestions||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button)}));const quickReplies=(s.quickReplies||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button);const quickEnabled=quickReplies.some(button=>button.enabled);return {...s,messages,quickReplies,hasQuickReplies:quickEnabled,noQuickReplies:!quickEnabled,scrollAnchor:messages[messages.length-1]?.scrollAnchor||'',liveLabel:liveDescription(s.liveState,s.liveReason),hasActiveRoom:!!s.roomId,noActiveRoom:!s.roomId,hasHostedName:!!s.hostedName,noHostedName:!s.hostedName,isGuest:s.phase==='guest',notGuest:s.phase!=='guest',realtimeDiagnostic:`state=${s.liveState}; reason=${s.liveReason||'none'}; events=${s.realtimeEventCount}; lastType=${/^(message|messagesent)$/i.test(s.lastRealtimeEventType)?s.lastRealtimeEventType:'none'}`,errorText:s.error,loadingHidden:s.phase!=='loading'||!!s.error,noError:!s.error,noNotice:!s.notice,noMessages:!messages.length,hasMessages:!!messages.length,noEmpty:s.phase!=='ready'||!!messages.length,
|
|
73
74
|
notLoading:s.phase!=='loading',sendDisabled:!canSend(s,s.draft),sendEnabled:canSend(s,s.draft),noLive:!s.roomId||s.liveState==='connected'};}
|
|
@@ -131,7 +132,7 @@ function productImageFor(slug:string,name:string):Promise<string>{
|
|
|
131
132
|
}
|
|
132
133
|
function slimRow(row:Record<string,unknown>):BusinessMessageRow{
|
|
133
134
|
const pick=(...keys:string[])=>Object.fromEntries(keys.map(key=>[key,row[key]]));
|
|
134
|
-
return pick('id','sender','when','createdDate','mine','senderAvatar','senderName','senderInitials','senderBadge','imageUrl','imageDescription','noImage','bodyText','bodyMarkdown','suggestions','articleActions','couponCode','noCoupon','notMine','dayLabel','dayHidden','startsDay','scrollAnchor') as unknown as BusinessMessageRow;
|
|
135
|
+
return pick('id','sender','when','createdDate','mine','senderAvatar','senderName','senderInitials','senderBadge','imageUrl','imageDescription','noImage','bodyText','bodyMarkdown','suggestions','articleActions','couponCode','noCoupon','notMine','isCard','notCard','noSuggestions','noActions','cardTitle','cardDetail','noCardText','dayLabel','dayHidden','startsDay','scrollAnchor') as unknown as BusinessMessageRow;
|
|
135
136
|
}
|
|
136
137
|
async function presentMessages(existing:MessageRow[],raw:unknown[],memberId?:string):Promise<BusinessMessageRow[]>{
|
|
137
138
|
const rows=chronologicalMessages(existing,raw,memberId) as MessageRow[];
|