@wix/himalaya-cli 0.1614.0 → 0.1615.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/cli.mjs +1 -0
- package/package.json +2 -2
- package/template-icons/salon-spa.png +0 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
- package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/salon-spa/README.md +53 -0
- package/templates/salon-spa/config.ts +272 -0
- package/templates/salon-spa/dev/index.ts +19893 -0
- package/templates/salon-spa/himalaya.content.json +49 -0
- package/templates/salon-spa/himalaya.template.json +14 -0
- package/templates/salon-spa/icon.png +0 -0
- package/templates/salon-spa/platforms.json +1 -0
- package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
- package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
- package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
- package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
- package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
- package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
- package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
- package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
- package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
- package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
- package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
- package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
- package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
- package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
- package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
- package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
- package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
- package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
- package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
- package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
- package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
- package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
- package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
- package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
- package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
- package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
- package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
- package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
- package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
- package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
- package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
- package/templates/salon-spa/tokens.json +341 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
- 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
|
+
" ": " ", "&": "&", "<": "<", ">": ">", """: '"', "'": "'", "'": "'",
|
|
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
|
+
}
|