@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,466 @@
1
+ import { defineBackendCollection } from "../_shared/collection-service.js";
2
+ import { cancelBookingAsMember, isBookingPolicyViolation, queryServices, type WixService } from "@himalaya/wix-bookings";
3
+ import { createEvent, getPermissionStatus, requestPermission } from "@himalaya/calendar";
4
+ import { open } from "@himalaya/open-url";
5
+ import { wixMediaUrl, WIX_LIST_IMAGE } from "@himalaya/wix-media";
6
+ import { navigate } from "@himalaya/state";
7
+ import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
8
+ import { memberAccess, assertMemberAccess, memberAccessChanged, memberAccessDenied, type MemberAccess } from "../_shared/member-access.js";
9
+ import { fetchMemberBookings, CANCELLED_STATUSES, type MemberBookingRow } from "../_shared/member-bookings.js";
10
+ import { emitHostEvent } from "../_shared/host-events.js";
11
+ import { formatClock, formatDateMedium, formatMonthDay, formatWeekdayMonthDay, formatWeekdayMonthDayLong } from "../_shared/engine-intl.js";
12
+ import { parseWeeklyGoal, weeklyGoalProgress, weeklySessionsFromPages } from "../_shared/weekly-goal.js";
13
+
14
+ // My Bookings: Upcoming / Past / Canceled tabs over the member's own bookings, a booking sheet with
15
+ // the details and the actions (add to calendar, directions, Book Again, Cancel Booking with a
16
+ // confirm step), and a non-modal result sheet. The list is the shared backend collection (paging,
17
+ // the backend filter and sort); the tab sets the filter and the sort together.
18
+
19
+ type Tab = "upcoming" | "past" | "cancelled";
20
+ // `query: ""` — the screen has no search field: a booking-ID search the old toolbar persisted must
21
+ // not narrow every tab with nothing on screen to clear it.
22
+ const TABS: Record<Tab, { filter: string; sort: string; query: string }> = {
23
+ upcoming: { filter: "value:Upcoming", sort: "default", query: "" },
24
+ past: { filter: "value:Past", sort: "za", query: "" },
25
+ cancelled: { filter: "value:Cancelled", sort: "za", query: "" },
26
+ };
27
+ const runtime = () => (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya;
28
+
29
+ let tab: Tab = "upcoming";
30
+ let pageMember: MemberAccess | undefined;
31
+ let queryNow = ""; // set by the first-page fetch, never at boot (the `nondeterministic-initial-state` lint)
32
+ /** A member's page is on screen (the tabs show). Kept while a tab switch loads. */
33
+ let memberShown = false;
34
+ /** The last failure was an access refusal: the sign-in state shows, not the error state. */
35
+ let lastErrorDenied = false;
36
+ /** The booking whose sheet is open, and whether the sheet is on its confirm-cancel step. */
37
+ let openId = "";
38
+ let confirming = false;
39
+ let sheetMessage = "";
40
+ let cancelling = false;
41
+ let cancelMember: MemberAccess | undefined;
42
+ /** The result sheet (non-modal): a done action (check icon) or a failure / info (info icon). */
43
+ let result: { text: string; done: boolean } | null = null;
44
+ /** A deep link's `bookingId`, applied once to the first page that loads after it arrives. */
45
+ let pendingBookingId = "";
46
+ let consumedBookingId = "";
47
+ // The weekly session goal (`provisioning.goals.weeklySessions`), from the onAppear `weeklyGoal`
48
+ // param — 0 when the app provisions none, which keeps the "This week" card hidden for good. The
49
+ // count comes from a dedicated read of this week's PAST bookings on each first-page fetch, NOT
50
+ // from `rows` (those follow the list's tab — Upcoming by default — and would read 0).
51
+ let weeklyGoal = 0;
52
+ /** The count of the page that APPLIED — set in `apply`, never by a fetch, so a superseded fetch
53
+ * (another member, a refresh) cannot overwrite it: it travels in that page's metadata and is
54
+ * dropped with it. */
55
+ let weeklyDone: number | null = null;
56
+
57
+ /** Service photos by service id ("" = the service has none). The booking record has no photo, so
58
+ * one `queryServices` call per page fetches the ones not yet known. Kept for the worker's life. */
59
+ const photos = new Map<string, string>();
60
+ const photosInFlight = new Set<string>();
61
+
62
+ /** This week's sessions from the PAST bookings, newest first (paged only while still inside this
63
+ * week). null when a read fails, so the card hides rather than claiming "0 of N". */
64
+ async function fetchWeeklySessionsDone(member: MemberAccess, now: number): Promise<number | null> {
65
+ try {
66
+ const nowIso = new Date(now).toISOString();
67
+ const read = (cursor: string) => fetchMemberBookings({ query: "", filter: "value:Past", sort: "za", savedIds: [] }, cursor, nowIso, member);
68
+ return await weeklySessionsFromPages(await read(""), read, now);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function serviceImage(service: WixService): string {
75
+ const media = service.media ?? {};
76
+ for (const item of [media.mainMedia, ...(media.items ?? []), media.coverMedia]) {
77
+ const url = wixMediaUrl(item?.image?.url, WIX_LIST_IMAGE);
78
+ if (url) return url;
79
+ }
80
+ // Older records carry the photo in other shapes; the service screen reads them the same way.
81
+ return legacyImageUrl(service);
82
+ }
83
+
84
+ function legacyImageUrl(value: unknown, depth = 0): string {
85
+ if (depth > 4) return "";
86
+ if (typeof value === "string") return wixMediaUrl(value, WIX_LIST_IMAGE);
87
+ if (!value || typeof value !== "object") return "";
88
+ if (Array.isArray(value)) {
89
+ for (const item of value) {
90
+ const url = legacyImageUrl(item, depth + 1);
91
+ if (url) return url;
92
+ }
93
+ return "";
94
+ }
95
+ const node = value as Record<string, unknown>;
96
+ for (const direct of [node.url, node.src, node.imageUrl, node.imageSrc]) {
97
+ const url = wixMediaUrl(direct, WIX_LIST_IMAGE);
98
+ if (url) return url;
99
+ }
100
+ for (const key of ["image", "thumbnail", "mediaItem", "mainMedia", "main", "media", "items", "photos", "gallery", "coverImage"]) {
101
+ const url = legacyImageUrl(node[key], depth + 1);
102
+ if (url) return url;
103
+ }
104
+ return "";
105
+ }
106
+
107
+ /** Fetch the photos of the services on this page that are not known yet: one call, at most 50 ids.
108
+ * A failure keeps the placeholders and never fails the list. */
109
+ function loadPhotos(rows: MemberBookingRow[]): void {
110
+ const ids = [...new Set(rows.map(row => row.serviceId).filter(id => id && !photos.has(id) && !photosInFlight.has(id)))].slice(0, 50);
111
+ if (!ids.length) return;
112
+ ids.forEach(id => photosInFlight.add(id));
113
+ queryServices({ query: { filter: { id: { $in: ids } }, paging: { limit: 50 } } } as Parameters<typeof queryServices>[0])
114
+ .then(response => {
115
+ const found = new Map((response.services ?? []).map(service => [service.id, serviceImage(service)] as const));
116
+ ids.forEach(id => photos.set(id, found.get(id) ?? ""));
117
+ ids.forEach(id => photosInFlight.delete(id));
118
+ if (found.size) svc.collectionDataChanged();
119
+ })
120
+ .catch(() => { ids.forEach(id => photosInFlight.delete(id)); });
121
+ }
122
+
123
+ // ---- Projection -------------------------------------------------------------------
124
+
125
+ const cancelledStatus = (row: MemberBookingRow) => CANCELLED_STATUSES.includes(row.statusKey);
126
+ const date = (iso: string) => new Date(iso);
127
+
128
+ function tagOf(row: MemberBookingRow): string {
129
+ switch (row.statusKey) {
130
+ // Wix answers PENDING_APPROVAL; PENDING is kept for older records.
131
+ case "PENDING": case "PENDING_APPROVAL": return copyParam("bookings.statusPending", "Pending approval");
132
+ case "WAITING_LIST": return copyParam("bookings.statusWaitlist", "Waitlist");
133
+ case "CANCELED": case "CANCELLED": return copyParam("bookings.statusCancelled", "Canceled");
134
+ case "DECLINED": return copyParam("bookings.statusDeclined", "Declined");
135
+ }
136
+ return row.isCourse ? copyParam("bookings.coursePrefix", "Course") : "";
137
+ }
138
+
139
+ function placeOf(row: MemberBookingRow): string {
140
+ return row.placeType === "CUSTOMER" ? copyParam("bookingCommon.clientPlace", "Client's place") : row.placeName;
141
+ }
142
+
143
+ function courseRange(row: MemberBookingRow, last: (d: Date) => string): string {
144
+ if (!row.courseStart) return "";
145
+ return row.courseEnd ? `${formatMonthDay(date(row.courseStart))} – ${last(date(row.courseEnd))}` : formatMonthDay(date(row.courseStart));
146
+ }
147
+
148
+ /** "Tue, Nov 4 · 10:00 AM", or "Course · Nov 10 – Dec 15" for a course. */
149
+ function whenOf(row: MemberBookingRow): string {
150
+ if (row.isCourse && row.courseStart) return `${copyParam("bookings.coursePrefix", "Course")} · ${courseRange(row, formatMonthDay)}`;
151
+ return row.start ? `${formatWeekdayMonthDay(date(row.start))} · ${formatClock(date(row.start))}` : row.subtitle;
152
+ }
153
+
154
+ function staffLine(row: MemberBookingRow): string {
155
+ return row.staff ? copyTemplate("bookings.withStaff", "with {staff}", { staff: row.staff }) : "";
156
+ }
157
+
158
+ /** "10:00 – 11:15 AM" (one meridiem when both share it), "11:30 AM – 12:45 PM" otherwise. */
159
+ function timeRange(start: Date, end: Date): string {
160
+ const a = formatClock(start), b = formatClock(end);
161
+ const ma = /\s(\S+)$/.exec(a)?.[1], mb = /\s(\S+)$/.exec(b)?.[1];
162
+ return ma && ma === mb ? `${a.slice(0, -ma.length).trim()} – ${b}` : `${a} – ${b}`;
163
+ }
164
+
165
+ function durationText(minutes: number): string {
166
+ const h = Math.floor(minutes / 60), m = minutes % 60;
167
+ const hours = h ? copyTemplate("bookings.durationHours", "{n} hr", { n: h }) : "";
168
+ const mins = m ? copyTemplate("bookings.durationMinutes", "{n} min", { n: m }) : "";
169
+ return [hours, mins].filter(Boolean).join(" ");
170
+ }
171
+
172
+ /** The sheet's second date line: "10:00 – 11:15 AM · 1 hr 15 min". */
173
+ function timeLine(row: MemberBookingRow): string {
174
+ if (row.isCourse || !row.start) return "";
175
+ const start = date(row.start), end = row.end ? date(row.end) : null;
176
+ if (!end || !Number.isFinite(end.getTime()) || end <= start) return formatClock(start);
177
+ const minutes = Math.round((end.getTime() - start.getTime()) / 60000);
178
+ return `${timeRange(start, end)} · ${durationText(minutes)}`;
179
+ }
180
+
181
+ function dateLine(row: MemberBookingRow): string {
182
+ if (row.isCourse) return courseRange(row, formatDateMedium);
183
+ return row.start ? formatWeekdayMonthDayLong(date(row.start)) : row.subtitle;
184
+ }
185
+
186
+ function paymentOf(row: MemberBookingRow): string {
187
+ switch (row.paymentStatus) {
188
+ case "PAID": return copyParam("bookings.payPaid", "Paid");
189
+ case "NOT_PAID": return copyParam("bookings.payNotPaid", "Not paid yet");
190
+ case "PARTIALLY_PAID": return copyParam("bookings.payPartly", "Partly paid");
191
+ case "REFUNDED": return copyParam("bookings.payRefunded", "Refunded");
192
+ }
193
+ return "";
194
+ }
195
+
196
+ function noticeOf(row: MemberBookingRow): string {
197
+ if (row.statusKey === "PENDING" || row.statusKey === "PENDING_APPROVAL") return copyParam("bookings.pendingNotice", "The business must approve this booking. You get an email when they reply.");
198
+ if (row.statusKey === "WAITING_LIST") return copyParam("bookings.waitlistNotice", "You are on the waitlist. You get an email if a spot opens.");
199
+ return "";
200
+ }
201
+
202
+ export interface BookingListRow extends MemberBookingRow {
203
+ image: string; hasImage: boolean; noImage: boolean; when: string; detail: string; tag: string; noTag: boolean; a11y: string;
204
+ }
205
+
206
+ function listRow(row: MemberBookingRow): BookingListRow {
207
+ const image = photos.get(row.serviceId) ?? "";
208
+ const tag = tagOf(row), place = placeOf(row), staff = staffLine(row);
209
+ const fullWhen = row.isCourse ? dateLine(row) : row.start ? `${formatWeekdayMonthDayLong(date(row.start))}, ${formatClock(date(row.start))}` : row.subtitle;
210
+ return { ...row, image, hasImage: !!image, noImage: !image, when: whenOf(row), detail: [staff, place].filter(Boolean).join(" · "),
211
+ tag, noTag: !tag, a11y: [row.title, fullWhen, staff, place, tag].filter(Boolean).join(", ") };
212
+ }
213
+
214
+ interface S {
215
+ status: "loading" | "ready" | "error"; rows: MemberBookingRow[]; bookingRows: BookingListRow[];
216
+ isMember: boolean; isLoading: boolean; notLoading: boolean; errored: boolean; notErrored: boolean; noEmpty: boolean;
217
+ tab: Tab; hideTabs: boolean; noRows: boolean; hideSignIn: boolean; hideHelp: boolean;
218
+ hideEmptyUpcoming: boolean; hideEmptyPast: boolean; hideEmptyCancelled: boolean;
219
+ sheetOpen: boolean; sheetConfirming: boolean; sheetNotConfirming: boolean;
220
+ sheetTitle: string; sheetImage: string; sheetHasImage: boolean; sheetNoImage: boolean; sheetTag: string; sheetNoTag: boolean;
221
+ sheetDate: string; sheetTime: string; sheetCanAddToCalendar: boolean; sheetCannotAddToCalendar: boolean;
222
+ sheetStaff: string; sheetNoStaff: boolean; sheetPlace: string; sheetAddress: string; sheetNoPlace: boolean;
223
+ sheetHasDirections: boolean; sheetNoDirections: boolean; sheetHidePlainPlace: boolean; sheetDirectionsLabel: string;
224
+ sheetParticipants: string; sheetNoParticipants: boolean; sheetPayment: string; sheetNoPayment: boolean;
225
+ sheetNotice: string; sheetNoNotice: boolean; sheetCanRebook: boolean; sheetCannotRebook: boolean;
226
+ sheetCanCancel: boolean; sheetCannotCancel: boolean; sheetLine: string; sheetMessage: string; cancelling: boolean;
227
+ resultOpen: boolean; resultText: string; resultDone: boolean; resultNotDone: boolean;
228
+ weeklyLabel: string; weeklyRemainingLabel: string; weeklyPct: number; weeklyMet: boolean; hideTracker: boolean;
229
+ nextCursor?: string; complete?: boolean; loadingMore?: boolean; pageError?: boolean; total?: number;
230
+ }
231
+
232
+ const openRow = (s: Pick<S, "rows">): MemberBookingRow | undefined => openId ? s.rows.find(row => row.id === openId) : undefined;
233
+ const canAddToCalendar = (row: MemberBookingRow) => !row.isCourse && !!row.start && Date.parse(row.start) > Date.now() && !cancelledStatus(row);
234
+ const hasDirections = (row: MemberBookingRow) => !!row.placeAddress && row.placeType !== "CUSTOMER";
235
+
236
+ function derive(s: S): S {
237
+ const loading = s.status === "loading", errored = s.status === "error";
238
+ const rowsShown = s.rows.length > 0;
239
+ // A row that left the list (a refresh, another tab) closes its sheet.
240
+ if (openId && !openRow(s) && !loading && !cancelling) { openId = ""; confirming = false; sheetMessage = ""; }
241
+ const row = openRow(s);
242
+ const listed = row ? listRow(row) : undefined;
243
+ const place = row ? placeOf(row) : "";
244
+ const showSignIn = !loading && ((s.status === "ready" && !s.isMember) || (errored && lastErrorDenied && !rowsShown));
245
+ const emptyFor = (t: Tab) => !(s.status === "ready" && s.isMember && !rowsShown && tab === t);
246
+ // Member-only, and only once this week's count was actually read (a failed read hides the card
247
+ // rather than claiming "0 of N").
248
+ const weekly = weeklyGoal > 0 && memberShown && weeklyDone !== null ? weeklyGoalProgress(weeklyDone, weeklyGoal) : null;
249
+ const participants = row && row.participants > 1 ? copyTemplate("bookings.participants", "{count} participants", { count: row.participants }) : "";
250
+ const payment = row ? paymentOf(row) : "", notice = row ? noticeOf(row) : "";
251
+ const canCancel = !!row && row.canCancel && Date.parse(row.start) > Date.now();
252
+ return { ...s, tab, bookingRows: s.rows.map(listRow),
253
+ isLoading: loading, notLoading: !(loading && !rowsShown), errored, notErrored: !(errored && !lastErrorDenied && !rowsShown), noEmpty: true,
254
+ hideTabs: !memberShown, noRows: !rowsShown, hideSignIn: !showSignIn,
255
+ hideHelp: (loading && !rowsShown) || (errored && !lastErrorDenied && !rowsShown),
256
+ hideEmptyUpcoming: emptyFor("upcoming"), hideEmptyPast: emptyFor("past"), hideEmptyCancelled: emptyFor("cancelled"),
257
+ sheetOpen: !!row, sheetConfirming: !!row && confirming, sheetNotConfirming: !row || !confirming,
258
+ sheetTitle: row?.title ?? "", sheetImage: listed?.image ?? "", sheetHasImage: !!listed?.hasImage, sheetNoImage: !listed?.hasImage,
259
+ sheetTag: listed?.tag ?? "", sheetNoTag: !listed?.tag,
260
+ sheetDate: row ? dateLine(row) : "", sheetTime: row ? timeLine(row) : "",
261
+ sheetCanAddToCalendar: !!row && canAddToCalendar(row), sheetCannotAddToCalendar: !row || !canAddToCalendar(row),
262
+ sheetStaff: row ? staffLine(row) : "", sheetNoStaff: !row?.staff,
263
+ sheetPlace: place, sheetNoPlace: !place,
264
+ // The address under the name only when the row shows a name of its own (never the client's place).
265
+ sheetAddress: row && row.placeType !== "CUSTOMER" && row.placeName !== row.placeAddress ? row.placeAddress : "",
266
+ sheetHasDirections: !!row && hasDirections(row), sheetNoDirections: !row || !hasDirections(row),
267
+ sheetHidePlainPlace: !place || (!!row && hasDirections(row)),
268
+ sheetDirectionsLabel: place ? copyTemplate("bookings.directionsLabel", "Get directions to {place}", { place }) : "",
269
+ sheetParticipants: participants, sheetNoParticipants: !participants,
270
+ sheetPayment: payment, sheetNoPayment: !payment, sheetNotice: notice, sheetNoNotice: !notice,
271
+ sheetCanRebook: !!row?.serviceId, sheetCannotRebook: !row?.serviceId,
272
+ sheetCanCancel: canCancel, sheetCannotCancel: !canCancel,
273
+ sheetLine: row ? [row.title, whenOf(row)].join(" · ") : "", sheetMessage, cancelling,
274
+ resultOpen: !!result, resultText: result?.text ?? "", resultDone: !!result?.done, resultNotDone: !result?.done,
275
+ weeklyLabel: weekly?.label ?? "", weeklyRemainingLabel: weekly?.remainingLabel ?? "", weeklyPct: weekly?.fraction ?? 0,
276
+ weeklyMet: weekly?.met ?? false, hideTracker: !weekly };
277
+ }
278
+
279
+ const closeSheet = () => { openId = ""; confirming = false; sheetMessage = ""; };
280
+ const showResult = (s: S, text: string, done: boolean): S => { closeSheet(); result = { text, done }; return derive(s); };
281
+
282
+ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings", {
283
+ onlineWrites: true,
284
+ // Offline, Cancel Booking says so in the confirm step, next to the button (the sheet stays open).
285
+ onlineWriteMessageFields: ["sheetMessage"],
286
+ restorePagesOnLoad: true,
287
+ state: derive({ status: "loading", rows: [], bookingRows: [], isMember: false, isLoading: true, notLoading: false,
288
+ errored: false, notErrored: true, noEmpty: true, tab: "upcoming", hideTabs: true, noRows: true, hideSignIn: true, hideHelp: true,
289
+ hideEmptyUpcoming: true, hideEmptyPast: true, hideEmptyCancelled: true,
290
+ sheetOpen: false, sheetConfirming: false, sheetNotConfirming: true, sheetTitle: "", sheetImage: "", sheetHasImage: false, sheetNoImage: true,
291
+ sheetTag: "", sheetNoTag: true, sheetDate: "", sheetTime: "", sheetCanAddToCalendar: false, sheetCannotAddToCalendar: true,
292
+ sheetStaff: "", sheetNoStaff: true, sheetPlace: "", sheetAddress: "", sheetNoPlace: true, sheetHasDirections: false, sheetNoDirections: true, sheetHidePlainPlace: true,
293
+ sheetDirectionsLabel: "", sheetParticipants: "", sheetNoParticipants: true, sheetPayment: "", sheetNoPayment: true,
294
+ sheetNotice: "", sheetNoNotice: true, sheetCanRebook: false, sheetCannotRebook: true, sheetCanCancel: false, sheetCannotCancel: true,
295
+ sheetLine: "", sheetMessage: "", cancelling: false, resultOpen: false, resultText: "", resultDone: false, resultNotDone: true,
296
+ weeklyLabel: "", weeklyRemainingLabel: "", weeklyPct: 0, weeklyMet: false, hideTracker: true, nextCursor: "" }),
297
+ onLoad(arg) {
298
+ const goal = parseWeeklyGoal((arg as Record<string, unknown> | undefined)?.weeklyGoal);
299
+ if (goal) weeklyGoal = goal;
300
+ // A deep link (the home "next booking", the place summary) names a booking: open its sheet once
301
+ // its row is on the first page. `date` alone does nothing now (there is no day view).
302
+ const linked = runtime()?.input?.bookingId;
303
+ if (typeof linked === "string" && linked && linked !== consumedBookingId) pendingBookingId = linked;
304
+ // The screen opens on Upcoming every time, not on the last persisted filter.
305
+ tab = "upcoming";
306
+ return TABS.upcoming;
307
+ },
308
+ source: {
309
+ id: row => row.id,
310
+ clearOnError: memberAccessDenied,
311
+ errorMessage: error => {
312
+ lastErrorDenied = memberAccessDenied(error);
313
+ return lastErrorDenied ? copyParam("bookings.accessError", "Bookings are unavailable for this account. Sign in again.") : copyParam("bookings.errorText", "Bookings could not be loaded.");
314
+ },
315
+ async fetch(criteria, cursor) {
316
+ const member = await memberAccess();
317
+ if (cursor && (!pageMember || pageMember.email !== member.email || pageMember.scope !== member.scope)) throw memberAccessChanged();
318
+ if (!cursor || !queryNow) queryNow = new Date().toISOString();
319
+ const page = await fetchMemberBookings(criteria, cursor, queryNow, member);
320
+ if (cursor) return page;
321
+ const weekly = weeklyGoal > 0 && member.email ? await fetchWeeklySessionsDone(member, Date.now()) : null;
322
+ return { ...page, metadata: { ...page.metadata, weeklyDone: weekly } };
323
+ },
324
+ },
325
+ apply(s, page) {
326
+ const member = { email: String(page.metadata?.email ?? ""), scope: String(page.metadata?.scope ?? "") };
327
+ const ready = page.status === "ready", errored = page.status === "error", loading = page.status === "loading";
328
+ // A change of member (or sign-out) drops everything that belonged to the last one.
329
+ if (ready && !page.loadingMore && pageMember && pageMember.email !== member.email) { closeSheet(); result = null; }
330
+ if ((loading && !page.items.length) || (ready && !member.email)) { pageMember = undefined; weeklyDone = null; }
331
+ if (ready || page.loadingMore) lastErrorDenied = false;
332
+ const weekly = page.metadata?.weeklyDone;
333
+ if (ready && !page.loadingMore && (typeof weekly === "number" || weekly === null)) weeklyDone = weekly;
334
+ if (ready && !page.loadingMore && !page.pageError) pageMember = member;
335
+ if (ready && !page.loadingMore && !page.provisional) memberShown = !!member.email;
336
+ if (errored && lastErrorDenied) { memberShown = false; closeSheet(); }
337
+ if (ready && !page.loadingMore && !page.provisional) {
338
+ loadPhotos(page.items);
339
+ if (pendingBookingId) {
340
+ if (page.items.some(row => row.id === pendingBookingId)) { openId = pendingBookingId; confirming = false; sheetMessage = ""; }
341
+ consumedBookingId = pendingBookingId; pendingBookingId = "";
342
+ }
343
+ }
344
+ return derive({ ...s, status: loading ? "loading" : errored ? "error" : "ready", rows: page.items,
345
+ isMember: loading ? s.isMember : !!member.email,
346
+ nextCursor: page.nextCursor, complete: page.complete, total: page.total, loadingMore: page.loadingMore, pageError: page.pageError });
347
+ },
348
+ actions: {
349
+ async selectTab(s, arg) {
350
+ const value = (arg as { selectedId?: string })?.selectedId;
351
+ if (value !== "upcoming" && value !== "past" && value !== "cancelled") return s;
352
+ if (value === tab) return derive(s);
353
+ tab = value; closeSheet();
354
+ // Filter and sort in one criteria change: one query, through the collection's own path.
355
+ await svc.collectionCriteria(TABS[value]);
356
+ return undefined;
357
+ },
358
+ openBooking(s, arg) {
359
+ const id = (arg as { id?: string })?.id;
360
+ if (!id || !s.rows.some(row => row.id === id)) return s;
361
+ openId = id; confirming = false; sheetMessage = "";
362
+ return derive(s);
363
+ },
364
+ closeBooking(s) { if (!cancelling) closeSheet(); return derive(s); },
365
+ closeResult(s) { result = null; return derive(s); },
366
+ async rebook(s, arg) {
367
+ const member = pageMember, row = s.rows.find(row => row.id === ((arg as { id?: string })?.id || openId));
368
+ if (!member || !row?.serviceId) return s;
369
+ // A returned `_nav`, not `dispatch("himi.open")`: a worker's nav dispatch went to the flat
370
+ // native ActionDispatcher, which has no `himi.open` handler, so Rebook silently refreshed on
371
+ // every binary shipped before #3116. `_nav` routes through each host's nav router on all of them.
372
+ try { await assertMemberAccess(member); closeSheet(); return navigate(derive(s), { screenId: "himi_service", args: { id: row.serviceId } }); }
373
+ catch { closeSheet(); await svc.refresh(); return undefined; }
374
+ },
375
+ // Step 2 of the sheet: confirm the cancel. Nothing is written until confirmCancel. An `{ id }`
376
+ // param (older callers) opens that booking first.
377
+ async cancel(s, arg) {
378
+ const id = (arg as { id?: string })?.id || openId;
379
+ const member = pageMember, row = s.rows.find(row => row.id === id);
380
+ if (!member || !row?.canCancel || Date.parse(row.start) <= Date.now() || cancelling) return s;
381
+ try { await assertMemberAccess(member); openId = row.id; confirming = true; sheetMessage = ""; cancelMember = member; return derive(s); }
382
+ catch { closeSheet(); await svc.refresh(); return undefined; }
383
+ },
384
+ keepBooking(s) { if (!cancelling) { confirming = false; sheetMessage = ""; } return derive(s); },
385
+ async confirmCancel(s): Promise<S | undefined> {
386
+ const row = openRow(s), member = cancelMember;
387
+ if (!row || !member || !confirming || cancelling) return s;
388
+ cancelling = true;
389
+ try {
390
+ await assertMemberAccess(member);
391
+ if (Date.parse(row.start) <= Date.now()) throw new Error("Booking already started");
392
+ await cancelBookingAsMember(row.id, row.revision);
393
+ await assertMemberAccess(member);
394
+ cancelling = false; closeSheet();
395
+ // Told to the app hosting this screen (an embed's `onAction`); dropped where nothing hosts it.
396
+ await emitHostEvent("booking.cancelled", { bookingId: row.id, serviceId: row.serviceId,
397
+ serviceName: row.title, start: row.start, end: row.end });
398
+ result = { text: copyParam("bookings.cancelDone", "Booking canceled."), done: true };
399
+ await svc.refresh();
400
+ return derive(svc.getState() as unknown as S);
401
+ } catch (error) {
402
+ cancelling = false;
403
+ try { await assertMemberAccess(member); } catch { closeSheet(); await svc.refresh(); return undefined; }
404
+ if (memberAccessDenied(error)) { closeSheet(); await svc.refresh(); return undefined; }
405
+ // The refusal stays next to the action, in step 2.
406
+ sheetMessage = isBookingPolicyViolation(error)
407
+ ? copyParam("bookings.cancelPolicy", "It's too late to cancel online. Contact the business to change this booking.")
408
+ : copyParam("bookings.cancelFailed", "Couldn't cancel the booking. Try again.");
409
+ return derive(s);
410
+ } finally { cancelling = false; }
411
+ },
412
+ async addToCalendar(s): Promise<S> {
413
+ const row = openRow(s);
414
+ if (!row || !canAddToCalendar(row)) return s;
415
+ const start = Date.parse(row.start);
416
+ const endAt = Date.parse(row.end);
417
+ const end = Number.isFinite(endAt) && endAt > start ? endAt : start + 60 * 60 * 1000;
418
+ const place = placeOf(row);
419
+ const location = [row.placeType === "CUSTOMER" ? "" : place, row.placeType === "CUSTOMER" || row.placeName === row.placeAddress ? "" : row.placeAddress].filter(Boolean).join(", ");
420
+ const notes = staffLine(row);
421
+ const opened = copyParam("bookings.calendarOpened", "Opened your calendar.");
422
+ const failed = copyParam("bookings.calendarFailed", "Couldn't add the booking to your calendar.");
423
+ const fallback = async () => (await open(calendarFallbackUrl(row.title, start, end, location, notes), "in_app_browser")).opened;
424
+ try {
425
+ let permission = await getPermissionStatus();
426
+ if (permission !== "granted" && permission !== "limited") permission = await requestPermission("writeOnly");
427
+ if (permission !== "granted" && permission !== "limited") return showResult(s, (await fallback()) ? opened : failed, false);
428
+ const created = await createEvent({ title: row.title, start, end, ...(location ? { location } : {}), ...(notes ? { notes } : {}) });
429
+ if (!created.id) return showResult(s, (await fallback()) ? opened : failed, false);
430
+ return showResult(s, copyParam("bookings.calendarAdded", "Added to your calendar."), true);
431
+ } catch {
432
+ return showResult(s, failed, false);
433
+ }
434
+ },
435
+ async openDirections(s): Promise<S> {
436
+ const row = openRow(s);
437
+ if (!row || !hasDirections(row)) return s;
438
+ try {
439
+ if ((await open(directionsUrl(row.placeAddress), "in_app_browser")).opened) return s;
440
+ } catch { /* the result sheet says so below */ }
441
+ return showResult(s, copyParam("bookings.mapsFailed", "Couldn't open maps."), false);
442
+ },
443
+ },
444
+ });
445
+
446
+ function calendarStamp(ms: number): string {
447
+ return new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
448
+ }
449
+
450
+ /** The Google Calendar "render" (TEMPLATE) fallback URL, built with encodeURIComponent — NOT
451
+ * URLSearchParams, which the JavaScriptCore worker engine on iOS/macOS and the wrist's QuickJS-ng do
452
+ * not provide (see `calendarFallbackUrl` in himi-events-detail, the pattern this copies). */
453
+ function calendarFallbackUrl(title: string, start: number, end: number, location: string, details: string): string {
454
+ const parts = ["action=TEMPLATE", `text=${encodeURIComponent(title || "Booking")}`, `dates=${calendarStamp(start)}/${calendarStamp(end)}`];
455
+ if (location) parts.push(`location=${encodeURIComponent(location)}`);
456
+ if (details) parts.push(`details=${encodeURIComponent(details)}`);
457
+ return `https://calendar.google.com/calendar/render?${parts.join("&")}`;
458
+ }
459
+
460
+ /** A maps search for the address — the same URL the service screen opens. */
461
+ function directionsUrl(address: string): string {
462
+ return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
463
+ }
464
+
465
+ svc.load();
466
+ export { svc };
@@ -0,0 +1,103 @@
1
+ // Tier-5 bundle: himi-booking — saved (favorite) services.
2
+ //
3
+ // Lists the services saved with the heart on the service detail (himi-booking-service's
4
+ // toggleSaved). Saves live in the app's durable on-device storage (_shared/himi-booking-saved.ts),
5
+ // so the list paints offline and survives relaunches; they do not sync between devices. A row
6
+ // keeps the price and duration it was saved with — the detail screen shows the current ones.
7
+ import { defineService } from "@himalaya/state";
8
+ import { withEmptyFlags } from "../_shared/empty-flags.js";
9
+ import { readSavedServices, removeSavedService, type SavedService } from "../_shared/himi-booking-saved.js";
10
+ import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
11
+
12
+ interface Row extends Omit<SavedService, "course"> {
13
+ /** "true" for a course, else "": a nav param is a string. */
14
+ course: string;
15
+ hasImage: boolean;
16
+ noImage: boolean;
17
+ /** "Facials · 1 hr" — the facts that are present, joined. */
18
+ meta: string;
19
+ noPrice: boolean;
20
+ /** Spoken labels of the row's two buttons. */
21
+ bookA11y: string;
22
+ removeA11y: string;
23
+ }
24
+ interface S {
25
+ status: "loading" | "ready" | "error";
26
+ rows: Row[];
27
+ error: string;
28
+ /** "" unless the last Remove failed (the list stays). */
29
+ message: string;
30
+ noMessage: boolean;
31
+ countLabel: string;
32
+ isLoading: boolean;
33
+ notLoading: boolean;
34
+ notErrored: boolean;
35
+ noEmpty: boolean;
36
+ }
37
+
38
+ function countLabel(n: number): string {
39
+ if (!n) return "";
40
+ return n === 1
41
+ ? copyParam("savedServices.countOne", "1 saved service")
42
+ : copyTemplate("savedServices.countMany", "{count} saved services", { count: n });
43
+ }
44
+
45
+ function shape(s: Pick<S, "status" | "rows" | "error"> & { message?: string }): S {
46
+ const message = s.message ?? "";
47
+ return {
48
+ ...s,
49
+ message,
50
+ noMessage: !message,
51
+ isLoading: s.status === "loading",
52
+ notLoading: s.status !== "loading",
53
+ notErrored: s.status !== "error",
54
+ noEmpty: s.status !== "ready" || s.rows.length > 0,
55
+ countLabel: s.status === "ready" ? countLabel(s.rows.length) : "",
56
+ };
57
+ }
58
+
59
+ function loadRows(): Row[] {
60
+ return readSavedServices().map((row) => ({
61
+ ...row,
62
+ // The Book button's `course` nav param ("true" or empty) for the time screen's course guard.
63
+ course: row.course ? "true" : "",
64
+ hasImage: !!row.image,
65
+ noImage: !row.image,
66
+ meta: [row.category, row.duration].filter(Boolean).join(" · "),
67
+ noPrice: !row.price,
68
+ bookA11y: copyTemplate("savedServices.bookLabel", "Book {name}", { name: row.name }),
69
+ removeA11y: copyTemplate("savedServices.removeLabel", "Remove {name} from favorites", { name: row.name }),
70
+ }));
71
+ }
72
+
73
+ function failed(s: S): S {
74
+ return shape({ ...s, status: "error", error: copyParam("savedServices.error", "Could not read your favorites. Try again.") });
75
+ }
76
+
77
+ const svc = defineService<S, {
78
+ load: (s: S, arg?: unknown) => S;
79
+ remove: (s: S, arg?: unknown) => S;
80
+ }>("himiBookingSaved", withEmptyFlags({
81
+ state: shape({ status: "loading", rows: [], error: "" }),
82
+ actions: {
83
+ // Every appearance: a heart tapped on the detail since the last visit must show here.
84
+ load: (s, arg) => {
85
+ stashCopy(arg);
86
+ try {
87
+ return shape({ status: "ready", rows: loadRows(), error: "" });
88
+ } catch { return failed(s); }
89
+ },
90
+ remove: (s, arg) => {
91
+ const a = arg as { id?: unknown; args?: { id?: unknown } } | string | undefined;
92
+ const id = typeof a === "string" ? a : String(a?.id ?? a?.args?.id ?? "");
93
+ if (!id) return s;
94
+ try {
95
+ removeSavedService(id);
96
+ return shape({ status: "ready", rows: loadRows(), error: "" });
97
+ } catch {
98
+ return shape({ ...s, message: copyParam("savedServices.removeError", "Could not remove it. Try again.") });
99
+ }
100
+ },
101
+ },
102
+ }, { rows: "noRows" }));
103
+ export { svc };