@wix/himalaya-cli 0.1614.0 → 0.1616.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/cli.mjs +2 -0
  2. package/package.json +2 -2
  3. package/template-icons/salon-spa.png +0 -0
  4. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  5. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  6. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  7. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  8. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  9. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  10. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  11. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  12. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  13. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  14. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  15. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  16. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  18. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  19. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  20. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  21. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  22. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  23. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  24. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  25. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  26. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  27. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  28. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  29. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  30. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  31. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  32. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  33. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  34. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  35. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  36. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  39. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  40. package/templates/salon-spa/README.md +53 -0
  41. package/templates/salon-spa/config.ts +272 -0
  42. package/templates/salon-spa/dev/index.ts +19893 -0
  43. package/templates/salon-spa/himalaya.content.json +49 -0
  44. package/templates/salon-spa/himalaya.template.json +14 -0
  45. package/templates/salon-spa/icon.png +0 -0
  46. package/templates/salon-spa/platforms.json +1 -0
  47. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  48. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  49. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  50. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  51. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  52. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  53. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  54. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  55. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  56. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  57. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  58. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  59. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  60. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  61. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  62. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  63. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  64. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  65. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  66. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  67. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  68. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  69. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  70. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  71. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  72. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  73. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  74. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  75. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  76. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  77. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  78. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  79. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  80. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  81. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  82. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  83. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  84. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  85. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  86. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  87. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  88. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  89. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  90. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  91. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  92. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  93. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  94. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  95. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  96. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  97. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  98. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  99. package/templates/salon-spa/tokens.json +341 -0
  100. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  101. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  102. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  103. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  104. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  105. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  106. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
@@ -0,0 +1,78 @@
1
+ // [rico] #3370 — an entity's Ricos body, prepared for a RichContent component and its plain-text fallback.
2
+ // One rule for every worker that renders a body (the blog post and the owner post detail today; #3372's
3
+ // verticals next), so they cannot drift apart.
4
+ // Inside the platform source this import is a relative path, so node test runs resolve it; an app's copy
5
+ // imports "@himalaya/wix-rich-content".
6
+ import { encodeRichContent, parseRicosJson, ricosToPlainText } from "@himalaya/wix-rich-content";
7
+
8
+ /** What a body-rendering worker publishes: `<name>Json` for `documentJsonPath`, and the plain text. */
9
+ export interface RichBody {
10
+ /** The document as ONE JSON string (media sized, disallowed links dropped); "" when there is no document
11
+ * or the encoder rejected it. RichContent draws nothing for "", so a screen gates its plain text on it. */
12
+ bodyJson: string;
13
+ /** The plain text: the document's own text when it has any, else `fallback`. */
14
+ bodyText: string;
15
+ }
16
+
17
+ /** [rico] #3372 — the media width for a document drawn in a LIST row (a feed post, a comment, a CMS item): the
18
+ * list-image size (WIX_LIST_IMAGE), so a scrolling list never downloads every image at detail size. */
19
+ export const ROW_IMAGE_WIDTH = 1000;
20
+
21
+ /** Prepares `richContent` (a Ricos document, as a Wix API returns it, or its JSON string). No document, or one
22
+ * that is not a document, gives `{ bodyJson: "", bodyText: fallback }`. A document past the viewer's limits
23
+ * gives bodyJson "" and its plain text — except one rejected as too deep, which is not walked again (the
24
+ * plain-text walk has no depth limit), so `fallback` stands in for it. `imageWidth` sizes its media (default
25
+ * the 1400 px detail budget). */
26
+ export function richBody(richContent: unknown, fallback = "", imageWidth = 1400): RichBody {
27
+ const doc = parseRicosJson(richContent);
28
+ if (!doc) return { bodyJson: "", bodyText: fallback };
29
+ // 1400 px by default — the detail-image budget, which is also the encoder's maximum; list rows pass
30
+ // ROW_IMAGE_WIDTH.
31
+ const { json, rejected } = encodeRichContent(doc, { imageWidth });
32
+ const text = rejected === "too_deep" ? "" : ricosToPlainText(doc);
33
+ return { bodyJson: json, bodyText: text || fallback };
34
+ }
35
+
36
+ /** A Wix description may be Ricos (including serialized JSON), HTML or ordinary text.
37
+ * Keep rich documents on the existing bounded encoder path. Non-document JSON and
38
+ * clearly malformed serialization use an independent fallback. Ambiguous bracketed
39
+ * prose stays readable; this does not attempt to repair arbitrary JSON syntax. */
40
+ export function descriptionBody(value: unknown, fallback = "", imageWidth = 1400): RichBody {
41
+ if (typeof value !== "string") return richBody(value, fallback, imageWidth);
42
+ const text = value.trim();
43
+ if (/^[{[]/.test(text)) {
44
+ try {
45
+ const parsed: unknown = JSON.parse(text);
46
+ // Passing the parsed value avoids parsing a valid Ricos document twice; a
47
+ // different JSON structure is rejected by the same existing document check.
48
+ if (parsed && typeof parsed === "object") return richBody(parsed, fallback, imageWidth);
49
+ } catch {
50
+ const objectLike = /^\{\s*(?:["'}]|nodes\b)/.test(text);
51
+ // Only reject scalar-looking malformed arrays when their syntax is clearly
52
+ // unfinished (or contains JS's non-JSON `undefined`). Balanced bracketed
53
+ // prose such as "[1,000]" and "[12:30, 13:30]" is not JSON and stays readable.
54
+ const arrayTokens = text.replace(/"(?:\\.|[^"\\])*"/g, "")
55
+ .replace(/\b(?:true|false|null|undefined)\b/g, "")
56
+ .replace(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g, "");
57
+ const arrayLike = /^\[\s*[{"]/.test(text)
58
+ || (text.startsWith("[") && /^[\s[\]{},:]*$/.test(arrayTokens)
59
+ && (/,\s*]?$/.test(text) || /\bundefined\b/.test(text) || !text.endsWith("]")));
60
+ if (objectLike || arrayLike) return { bodyJson: "", bodyText: fallback };
61
+ }
62
+ }
63
+ // Legacy HTML descriptions stay text. Preserve paragraph breaks and ordinary prose's
64
+ // bare '<' (e.g. "ages < 8"), without relying on browser-only DOM APIs in workers.
65
+ const plain = text
66
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "")
67
+ .replace(/<br\s*\/?\s*>/gi, "\n")
68
+ .replace(/<\/(?:p|div|h[1-6]|li)\s*>/gi, "\n\n")
69
+ .replace(/<\/?[a-z][^>]*>/gi, "")
70
+ .replace(/&(?:nbsp|amp|lt|gt|quot|apos|#39);/gi, (entity) => ({
71
+ "&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&apos;": "'", "&#39;": "'",
72
+ })[entity.toLowerCase()] ?? entity)
73
+ .replace(/[\t\r ]+/g, " ")
74
+ .replace(/ *\n */g, "\n")
75
+ .replace(/\n{3,}/g, "\n\n")
76
+ .trim();
77
+ return { bodyJson: "", bodyText: plain || fallback };
78
+ }
@@ -0,0 +1,34 @@
1
+ // [rico] #3370 — a RichContent `on_link` tap reaches the worker as `{ url }` (the renderers merge the tapped
2
+ // link into the action's args). Only http(s) with a host, mailto and tel ever reach the OS; anything
3
+ // else is dropped here, before any dispatch. The renderers already refuse other schemes, but the action
4
+ // arrives here as plain data, so the worker checks it again rather than trusting whoever sent it.
5
+ import { open, defaultMode } from "@himalaya/open-url";
6
+
7
+ /** The viewer's link rule, verbatim (`LINK` in @himalaya/wix-rich-content's display.ts, which the package does
8
+ * not export); a parity test pins this copy to the original. */
9
+ const LINK = /^(https?:\/\/[^\s/?#]\S*|mailto:\S+|tel:\S+)$/i;
10
+ /** ASCII whitespace only, as the viewer trims a link before applying the rule. */
11
+ const trimAscii = (s: string): string => s.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, "");
12
+
13
+ /** The url of a link tap (`{ url }`, `{ args: { url } }` or a bare string), or "" when it is missing or
14
+ * not an http(s), mailto or tel link. */
15
+ export function richLinkUrl(arg: unknown): string {
16
+ const a = arg as { url?: unknown; args?: { url?: unknown } } | null | undefined;
17
+ const raw = typeof arg === "string" ? arg
18
+ : typeof a?.url === "string" ? a.url
19
+ : typeof a?.args?.url === "string" ? a.args.url : "";
20
+ const url = trimAscii(raw);
21
+ return LINK.test(url) ? url : "";
22
+ }
23
+
24
+ /** Opens a link tap through `capability.open_url` (http(s) in the in-app browser, mailto/tel in the system
25
+ * handler). False when the link is blocked, nothing took it, or the host threw. */
26
+ export async function openRichLink(arg: unknown): Promise<boolean> {
27
+ const url = richLinkUrl(arg);
28
+ if (!url) return false;
29
+ try {
30
+ return (await open(url, defaultMode(url))).opened;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
@@ -0,0 +1,107 @@
1
+ // The weekly session goal (`provisioning.goals.weeklySessions`) over a member's own bookings —
2
+ // shared by the home weekly-goal Gauge (himi-branded-lite-home) and the My Bookings "This week"
3
+ // card (himi-booking-bookings), so the two can never count differently.
4
+ //
5
+ // WHAT COUNTS as a session toward the goal: a CONFIRMED booking whose start falls in the CURRENT
6
+ // calendar week (Monday 00:00 local → now) and is already in the past. An allow-list, not a
7
+ // deny-list: CREATED (checkout never completed), PENDING / PENDING_APPROVAL / PENDING_CHECKOUT,
8
+ // WAITING_LIST, CANCELED and DECLINED — and any status Wix adds later — do not count. That is
9
+ // "booked and it happened", not verified
10
+ // attendance: the member-scoped queryBookings row carries no attendance/check-in field (the Wix
11
+ // Attendance API is owner-scoped), so a no-show still counts. Future bookings this week do not
12
+ // count — they have not happened yet.
13
+ //
14
+ // DETERMINISM: nothing here runs at worker boot; every clock read is an argument. PURE (no network,
15
+ // no bridge) so the rules are unit-tested directly; the read lives with its caller.
16
+
17
+ import type { MemberBookingRow } from "./member-bookings.js";
18
+ import { copyParam, copyTemplate } from "./himi-copy.js";
19
+
20
+ export const MAX_WEEKLY_SESSION_GOAL = 21;
21
+
22
+ /** The one status that means the session was booked and held (as memberBookingRow normalizes
23
+ * it: upper-case, `_` → space). */
24
+ const COUNTS_AS_SESSION = /^CONFIRMED$/;
25
+
26
+ /** At most this many pages of past bookings are read for one week's count. */
27
+ export const MAX_WEEKLY_PAGES = 4;
28
+
29
+ /** The onAppear `weeklyGoal` param → a goal in [1, 21], or 0 = no goal provisioned. */
30
+ export function parseWeeklyGoal(raw: unknown): number {
31
+ const n = typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() !== "" ? Number(raw) : NaN;
32
+ return Number.isInteger(n) && n >= 1 ? Math.min(n, MAX_WEEKLY_SESSION_GOAL) : 0;
33
+ }
34
+
35
+ /** Monday 00:00 local of `now`'s week, epoch ms. */
36
+ export function weekStartMs(now: number): number {
37
+ const d = new Date(now);
38
+ d.setHours(0, 0, 0, 0);
39
+ d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
40
+ return d.getTime();
41
+ }
42
+
43
+ /** Sessions toward the goal among `rows` — see the file header for what counts. */
44
+ export function weeklySessionsDone(rows: readonly MemberBookingRow[], now: number): number {
45
+ const from = weekStartMs(now);
46
+ const seen = new Set<string>();
47
+ for (const row of rows) {
48
+ const start = Date.parse(row.start);
49
+ if (!Number.isFinite(start) || start < from || start >= now) continue;
50
+ if (!COUNTS_AS_SESSION.test((row.status || "").trim().toUpperCase())) continue;
51
+ seen.add(row.id || `${row.start}|${row.title}`);
52
+ }
53
+ return seen.size;
54
+ }
55
+
56
+ export interface BookingsPage {
57
+ items: MemberBookingRow[];
58
+ nextCursor?: string;
59
+ }
60
+
61
+ /** Sessions toward the goal from PAST bookings read newest-first: `first` is the first page, and
62
+ * `next(cursor)` reads more only while the oldest row so far is still inside this week (a page of
63
+ * 50 almost always reaches back before Monday). Bounded to MAX_WEEKLY_PAGES reads; a failing
64
+ * read rejects, so the caller hides the count rather than under-counting. */
65
+ export async function weeklySessionsFromPages(
66
+ first: BookingsPage,
67
+ next: (cursor: string) => Promise<BookingsPage>,
68
+ now: number,
69
+ ): Promise<number> {
70
+ const from = weekStartMs(now);
71
+ const rows = [...first.items];
72
+ let page = first;
73
+ for (let read = 1; read < MAX_WEEKLY_PAGES && page.nextCursor; read++) {
74
+ const oldest = page.items[page.items.length - 1];
75
+ if (oldest && Date.parse(oldest.start) < from) break;
76
+ page = await next(page.nextCursor);
77
+ rows.push(...page.items);
78
+ }
79
+ return weeklySessionsDone(rows, now);
80
+ }
81
+
82
+ export interface WeeklyGoalProgress {
83
+ done: number;
84
+ goal: number;
85
+ /** 0..1 — the Gauge / ProgressBar fill. */
86
+ fraction: number;
87
+ met: boolean;
88
+ /** "1 of 2 sessions this week" */
89
+ label: string;
90
+ /** "1 more to reach your weekly goal" / "Weekly goal reached. Great consistency." */
91
+ remainingLabel: string;
92
+ }
93
+
94
+ export function weeklyGoalProgress(done: number, goal: number): WeeklyGoalProgress {
95
+ const safeGoal = Math.max(1, goal);
96
+ const met = done >= safeGoal;
97
+ return {
98
+ done,
99
+ goal: safeGoal,
100
+ fraction: Math.min(done / safeGoal, 1),
101
+ met,
102
+ label: copyTemplate("weeklyGoal.progress", "{done} of {goal} sessions this week", { done, goal: safeGoal }),
103
+ remainingLabel: met
104
+ ? copyParam("weeklyGoal.met", "Weekly goal reached. Great consistency.")
105
+ : copyTemplate("weeklyGoal.remaining", "{count} more to reach your weekly goal", { count: safeGoal - done }),
106
+ };
107
+ }
@@ -0,0 +1,146 @@
1
+ // Shared Wix Bookings service-price formatter for himi workers (#816).
2
+ //
3
+ // One door for turning a Wix Bookings SERVICE payload into a display price
4
+ // string, so the four consuming workers (himi-booking-services,
5
+ // himi-booking-service, himi-booking-time, himi-branded-lite-home) can never
6
+ // drift apart again. Each used to read `payment.price ?? service.price` and
7
+ // default the currency to "USD" — but the live Bookings v2 payload nests the
8
+ // fixed price under `payment.fixed.price.{value,currency}` (Money; `value` is a
9
+ // decimal string), so on a real ILS site the amount rendered blank (the flat
10
+ // fields are absent) or, once decoded, in the wrong "$" currency (the ₪ was
11
+ // dropped). This helper reads the v2 nested shape FIRST and derives the currency
12
+ // FROM THE RECORD, falling back to the legacy/flat shapes for older payloads.
13
+ //
14
+ // Pure + dependency-free (structural `unknown` input, no SDK import) so it bundles
15
+ // into every worker and gen-templates copies it verbatim into each tier5-src.
16
+
17
+ import { formatCurrency } from "../_shared/engine-intl.js";
18
+ /** Coerce a number or numeric string to a finite number, else undefined. */
19
+ function numberOf(value: unknown): number | undefined {
20
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
21
+ if (typeof value === "string" && value.trim()) {
22
+ const n = Number(value);
23
+ return Number.isNaN(n) ? undefined : n;
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ /** Non-empty trimmed string, else "". */
29
+ function stringOf(value: unknown): string {
30
+ return typeof value === "string" ? value.trim() : "";
31
+ }
32
+
33
+ type LooseService = {
34
+ payment?: {
35
+ rateType?: unknown;
36
+ fixed?: { price?: { value?: unknown; amount?: unknown; currency?: unknown } };
37
+ price?: unknown;
38
+ currency?: unknown;
39
+ };
40
+ price?: unknown;
41
+ currency?: unknown;
42
+ };
43
+
44
+ /**
45
+ * The numeric price amount for a service, following the same v2-first priority
46
+ * as {@link formatServicePrice}. Returns undefined when there is no usable
47
+ * amount or the rate type is non-FIXED — callers use it for "is this a paid
48
+ * service?" decisions so the flag never disagrees with the rendered price.
49
+ */
50
+ export function serviceAmount(service: unknown): number | undefined {
51
+ const s = (service ?? {}) as LooseService;
52
+ const payment = s.payment ?? {};
53
+
54
+ // A declared non-FIXED rate type (VARIED/CUSTOM/NO_FEE) carries no single fixed
55
+ // amount. An ABSENT rateType (legacy/flat payloads) is NOT treated as non-FIXED.
56
+ const rateType = stringOf(payment.rateType).toUpperCase();
57
+ if (rateType && rateType !== "FIXED") return undefined;
58
+
59
+ const fixed = payment.fixed?.price;
60
+ return numberOf(fixed?.value ?? fixed?.amount ?? payment.price ?? s.price);
61
+ }
62
+
63
+ /**
64
+ * The currency for a service, derived FROM THE RECORD (v2 nested first, then
65
+ * legacy nested, then top-level). "USD" is only the absolute last resort when
66
+ * no currency is present anywhere — it never overrides one the record carries.
67
+ */
68
+ export function serviceCurrency(service: unknown): string {
69
+ const s = (service ?? {}) as LooseService;
70
+ const payment = s.payment ?? {};
71
+ return (
72
+ stringOf(payment.fixed?.price?.currency) ||
73
+ stringOf(payment.currency) ||
74
+ stringOf(s.currency) ||
75
+ "USD"
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Whether money is due for this service — a MONEY decision, deliberately not the same
81
+ * predicate as "do we have a number to display".
82
+ *
83
+ * `serviceAmount` returns undefined for any non-FIXED rate type, which is right for
84
+ * display (there is no single figure to render) and wrong for routing: a VARIED or CUSTOM
85
+ * service is chargeable, it just has no fixed figure. Deriving "is this paid?" from
86
+ * `serviceAmount() > 0` therefore sent such a booking down the FREE branch — booked, never
87
+ * charged. `NO_FEE` is the one non-FIXED type that really is free.
88
+ *
89
+ * Fails toward charging: the checkout path already degrades gracefully when nothing is
90
+ * actually owed (no `paymentGatewayOrderId` ⇒ it completes the booking and stops), so a
91
+ * false positive costs nothing while a false negative gives the service away.
92
+ */
93
+ export function serviceIsChargeable(service: unknown): boolean {
94
+ const s = (service ?? {}) as LooseService;
95
+ const rateType = stringOf(s.payment?.rateType).toUpperCase();
96
+ if (rateType === "NO_FEE") return false;
97
+ if (rateType && rateType !== "FIXED") return true; // VARIED / CUSTOM: priced, just not fixed
98
+ const amount = serviceAmount(service);
99
+ return amount != null && amount > 0;
100
+ }
101
+
102
+ /**
103
+ * Format one Bookings v2 Money value (`{ value, currency }`, `value` a decimal string) — a
104
+ * price, a deposit, a VARIED minimum, a cancellation fee. A whole amount drops its cents
105
+ * ("₪90", not "₪90.00"), as the service screen shows it. "" when it carries no usable
106
+ * amount. The currency comes from the Money itself, then from `fallbackCurrency`.
107
+ */
108
+ export function formatServiceMoney(money: unknown, fallbackCurrency = "USD"): string {
109
+ const m = (money ?? {}) as { value?: unknown; amount?: unknown; currency?: unknown };
110
+ const amount = numberOf(m.value ?? m.amount);
111
+ if (amount == null) return "";
112
+ const currency = stringOf(m.currency) || fallbackCurrency;
113
+ try {
114
+ return formatCurrency(amount, currency, undefined, Number.isInteger(amount) ? 0 : undefined);
115
+ } catch {
116
+ return `${amount}`;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Format a Wix Bookings service's price for display, e.g. "₪111" / "$20" / "$19.50".
122
+ *
123
+ * Reads, in order of preference:
124
+ * 1. Bookings v2 fixed price — `payment.fixed.price.{value,currency}` (the
125
+ * live shape; `value`/`amount` is a decimal string, currency is real).
126
+ * 2. Legacy nested — `payment.price` / `payment.currency`.
127
+ * 3. Top-level — `service.price` / `service.currency`.
128
+ *
129
+ * The currency is ALWAYS derived from the record; "USD" is only the absolute
130
+ * last resort when no currency is present anywhere (so behavior is unchanged
131
+ * when the data simply carries none). A non-FIXED `rateType`
132
+ * (VARIED/CUSTOM/NO_FEE) has no single amount to show → returns "".
133
+ * Returns "" (never throws) when there is no usable numeric amount.
134
+ */
135
+ export function formatServicePrice(service: unknown): string {
136
+ const amount = serviceAmount(service);
137
+ if (amount == null) return "";
138
+ const currency = serviceCurrency(service);
139
+ try {
140
+ // A whole amount drops its cents ("₪70", not "₪70.00"), as formatServiceMoney and the service
141
+ // screen show it, so the same service reads the same price on every screen.
142
+ return formatCurrency(amount, currency, undefined, Number.isInteger(amount) ? 0 : undefined);
143
+ } catch {
144
+ return `${amount}`;
145
+ }
146
+ }