@wix/himalaya-cli 0.1528.0 → 0.1530.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/index.d.ts +24 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/ecom/src/extensions.d.ts +144 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/restaurants/src/extensions.d.ts +93 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +27 -5
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +19 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +27 -5
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-ecom.mjs +58 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-restaurants.mjs +23 -0
- package/dist/cli.mjs +3110 -1993
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +83 -0
- package/guides/hhp.md +39 -0
- package/guides/himi-json-output.md +22 -1
- package/guides/release-notes.md +17 -0
- package/guides/templates.md +11 -1
- package/guides/tier-5-add-a-screen.md +4 -0
- package/package.json +2 -2
- package/templates/beauty-salon/config.ts +12 -1
- package/templates/beauty-salon/dev/index.ts +1767 -2698
- package/templates/beauty-salon/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/beauty-salon/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/beauty-salon/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/beauty-salon/tier5-src/himi-booking-bookings/index.ts +385 -74
- package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +6 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/branded-lite/config.ts +12 -1
- package/templates/branded-lite/dev/index.ts +4955 -4801
- package/templates/branded-lite/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/branded-lite/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/branded-lite/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/branded-lite/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/branded-lite/tier5-src/himi-booking-bookings/index.ts +385 -74
- package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +6 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-menu-item/index.ts +144 -77
- package/templates/branded-lite/tier5-src/himi-branded-lite-menus/index.ts +16 -5
- package/templates/branded-lite/tier5-src/himi-branded-lite-reservations/index.ts +1 -1
- package/templates/branded-lite/tier5-src/wix-spaces-food-order-detail/index.ts +76 -14
- package/templates/branded-lite/tier5-src/wix-spaces-food-orders/index.ts +807 -55
- package/templates/community-club/config.ts +12 -1
- package/templates/community-club/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/community-club/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/community-club/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/community-club/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/community-club-owner/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/events-meetups/config.ts +12 -1
- package/templates/events-meetups/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/events-meetups/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/events-meetups/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/events-meetups-owner/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/gym-fitness/config.ts +12 -1
- package/templates/gym-fitness/dev/index.ts +1753 -2697
- package/templates/gym-fitness/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/gym-fitness/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/gym-fitness/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/gym-fitness/tier5-src/himi-booking-bookings/index.ts +385 -74
- package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +6 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/nonprofit-cause/config.ts +12 -1
- package/templates/nonprofit-cause/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/nonprofit-cause/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/nonprofit-cause/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/nonprofit-cause-owner/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/owner-lite/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/podcast-creator/config.ts +12 -1
- package/templates/podcast-creator/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/podcast-creator/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/podcast-creator/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/podcast-creator-owner/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/portfolio-creative/config.ts +12 -1
- package/templates/portfolio-creative/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/portfolio-creative/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/portfolio-creative/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/portfolio-creative/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +6 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/restaurant/README.md +1 -1
- package/templates/restaurant/config.ts +12 -1
- package/templates/restaurant/dev/index.ts +1373 -275
- package/templates/restaurant/himalaya.template.json +2 -1
- package/templates/restaurant/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/restaurant/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/restaurant/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/restaurant/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/restaurant/tier5-src/_shared/member-orders.ts +8 -0
- package/templates/restaurant/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/restaurant/tier5-src/himi-branded-lite-menu-item/index.ts +144 -77
- package/templates/restaurant/tier5-src/himi-branded-lite-menus/index.ts +16 -5
- package/templates/restaurant/tier5-src/wix-spaces-food-order-detail/index.ts +76 -14
- package/templates/restaurant/tier5-src/wix-spaces-food-orders/index.ts +807 -55
- package/templates/restaurant-owner/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/retail-store/config.ts +12 -1
- package/templates/retail-store/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/retail-store/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/retail-store/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/retail-store/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/retail-store/tier5-src/_shared/restaurant-ordering.ts +201 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +6 -0
- package/templates/retail-store-owner/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/yoga-studio/config.ts +12 -1
- package/templates/yoga-studio/dev/index.ts +1755 -2699
- package/templates/yoga-studio/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +18 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-restaurants.ts +12 -5
- package/templates/yoga-studio/tier5-src/_shared/member-bookings.ts +50 -7
- package/templates/yoga-studio/tier5-src/himi-booking-bookings/index.ts +385 -74
- package/templates/yoga-studio-owner/tier5-src/_shared/collection-service.ts +23 -5
- package/templates/yoga-studio-owner/tier5-src/_shared/engine-intl.ts +18 -0
|
@@ -1,37 +1,64 @@
|
|
|
1
1
|
import { defineBackendCollection } from "../_shared/collection-service.js";
|
|
2
|
-
import { cancelBookingAsMember, isBookingPolicyViolation } from "@himalaya/wix-bookings";
|
|
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";
|
|
3
6
|
import { navigate } from "@himalaya/state";
|
|
4
|
-
import { copyParam } from "../_shared/himi-copy.js";
|
|
7
|
+
import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
|
|
5
8
|
import { memberAccess, assertMemberAccess, memberAccessChanged, memberAccessDenied, type MemberAccess } from "../_shared/member-access.js";
|
|
6
|
-
import { fetchMemberBookings, type MemberBookingRow } from "../_shared/member-bookings.js";
|
|
9
|
+
import { fetchMemberBookings, CANCELLED_STATUSES, type MemberBookingRow } from "../_shared/member-bookings.js";
|
|
7
10
|
import { emitHostEvent } from "../_shared/host-events.js";
|
|
8
|
-
import {
|
|
9
|
-
import { localPreferences } from "../_shared/local-preferences.js";
|
|
11
|
+
import { formatClock, formatDateMedium, formatMonthDay, formatWeekdayMonthDay, formatWeekdayMonthDayLong } from "../_shared/engine-intl.js";
|
|
10
12
|
import { parseWeeklyGoal, weeklyGoalProgress, weeklySessionsFromPages } from "../_shared/weekly-goal.js";
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
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";
|
|
22
30
|
let pageMember: MemberAccess | undefined;
|
|
23
|
-
let queryNow = ""; // set by the first-page fetch, never at boot (
|
|
24
|
-
|
|
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 = "";
|
|
25
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 = "";
|
|
26
47
|
// The weekly session goal (`provisioning.goals.weeklySessions`), from the onAppear `weeklyGoal`
|
|
27
48
|
// param — 0 when the app provisions none, which keeps the "This week" card hidden for good. The
|
|
28
49
|
// count comes from a dedicated read of this week's PAST bookings on each first-page fetch, NOT
|
|
29
|
-
// from `rows` (those follow the list's
|
|
50
|
+
// from `rows` (those follow the list's tab — Upcoming by default — and would read 0).
|
|
30
51
|
let weeklyGoal = 0;
|
|
31
52
|
/** The count of the page that APPLIED — set in `apply`, never by a fetch, so a superseded fetch
|
|
32
53
|
* (another member, a refresh) cannot overwrite it: it travels in that page's metadata and is
|
|
33
54
|
* dropped with it. */
|
|
34
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
|
+
|
|
35
62
|
/** This week's sessions from the PAST bookings, newest first (paged only while still inside this
|
|
36
63
|
* week). null when a read fails, so the card hides rather than claiming "0 of N". */
|
|
37
64
|
async function fetchWeeklySessionsDone(member: MemberAccess, now: number): Promise<number | null> {
|
|
@@ -43,48 +70,248 @@ async function fetchWeeklySessionsDone(member: MemberAccess, now: number): Promi
|
|
|
43
70
|
return null;
|
|
44
71
|
}
|
|
45
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
|
+
|
|
46
214
|
interface S {
|
|
47
|
-
status: "loading" | "ready" | "error"; rows: MemberBookingRow[];
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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;
|
|
53
228
|
weeklyLabel: string; weeklyRemainingLabel: string; weeklyPct: number; weeklyMet: boolean; hideTracker: boolean;
|
|
54
229
|
nextCursor?: string; complete?: boolean; loadingMore?: boolean; pageError?: boolean; total?: number;
|
|
55
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
|
+
|
|
56
236
|
function derive(s: S): S {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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);
|
|
62
246
|
// Member-only, and only once this week's count was actually read (a failed read hides the card
|
|
63
247
|
// rather than claiming "0 of N").
|
|
64
|
-
const weekly = weeklyGoal > 0 &&
|
|
65
|
-
|
|
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,
|
|
66
275
|
weeklyLabel: weekly?.label ?? "", weeklyRemainingLabel: weekly?.remainingLabel ?? "", weeklyPct: weekly?.fraction ?? 0,
|
|
67
|
-
weeklyMet: weekly?.met ?? false, hideTracker: !weekly
|
|
68
|
-
hideAgenda: hideSchedule || mode !== "agenda", hideCalendar: hideSchedule || mode === "agenda", hideMonth: mode !== "month",
|
|
69
|
-
decorations: [...counts].map(([date, n]) => ({ date, dotCount: Math.min(n, 3), dotColorToken: "accentPrimary" })),
|
|
70
|
-
dayEmptyText: dayRows.length ? "" : s.complete ? copyParam("bookings.emptyDay", "No bookings on this day.") : copyParam("bookings.emptyLoadedDay", "No bookings on this day in loaded results. Load more to check later pages."),
|
|
71
|
-
cancelPresented: !!cancelTarget, cancelTitle: cancelTarget ? `${cancelTarget.row.title} · ${cancelTarget.row.subtitle}` : "" };
|
|
276
|
+
weeklyMet: weekly?.met ?? false, hideTracker: !weekly };
|
|
72
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
|
+
|
|
73
282
|
const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings", {
|
|
74
283
|
onlineWrites: true,
|
|
284
|
+
// Offline, Cancel Booking says so in the confirm step, next to the button (the sheet stays open).
|
|
285
|
+
onlineWriteMessageFields: ["sheetMessage"],
|
|
75
286
|
restorePagesOnLoad: true,
|
|
76
|
-
state: derive({ status: "loading", rows: [],
|
|
77
|
-
errored: false, notErrored: true, noEmpty: true,
|
|
78
|
-
|
|
79
|
-
|
|
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: "" }),
|
|
80
297
|
onLoad(arg) {
|
|
81
298
|
const goal = parseWeeklyGoal((arg as Record<string, unknown> | undefined)?.weeklyGoal);
|
|
82
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;
|
|
83
307
|
},
|
|
84
308
|
source: {
|
|
85
309
|
id: row => row.id,
|
|
86
310
|
clearOnError: memberAccessDenied,
|
|
87
|
-
errorMessage: error =>
|
|
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
|
+
},
|
|
88
315
|
async fetch(criteria, cursor) {
|
|
89
316
|
const member = await memberAccess();
|
|
90
317
|
if (cursor && (!pageMember || pageMember.email !== member.email || pageMember.scope !== member.scope)) throw memberAccessChanged();
|
|
@@ -97,59 +324,143 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
|
|
|
97
324
|
},
|
|
98
325
|
apply(s, page) {
|
|
99
326
|
const member = { email: String(page.metadata?.email ?? ""), scope: String(page.metadata?.scope ?? "") };
|
|
100
|
-
|
|
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;
|
|
101
332
|
const weekly = page.metadata?.weeklyDone;
|
|
102
|
-
if (
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 });
|
|
109
347
|
},
|
|
110
348
|
actions: {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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); },
|
|
115
366
|
async rebook(s, arg) {
|
|
116
|
-
const member = pageMember, row = s.rows.find(row => row.id === (arg as { id?: string })?.id);
|
|
367
|
+
const member = pageMember, row = s.rows.find(row => row.id === ((arg as { id?: string })?.id || openId));
|
|
117
368
|
if (!member || !row?.serviceId) return s;
|
|
118
369
|
// A returned `_nav`, not `dispatch("himi.open")`: a worker's nav dispatch went to the flat
|
|
119
370
|
// native ActionDispatcher, which has no `himi.open` handler, so Rebook silently refreshed on
|
|
120
371
|
// every binary shipped before #3116. `_nav` routes through each host's nav router on all of them.
|
|
121
|
-
try { await assertMemberAccess(member); return navigate(s, { screenId: "himi_service", args: { id: row.serviceId } }); }
|
|
122
|
-
catch { await svc.refresh(); return undefined; }
|
|
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; }
|
|
123
374
|
},
|
|
124
|
-
//
|
|
375
|
+
// Step 2 of the sheet: confirm the cancel. Nothing is written until confirmCancel. An `{ id }`
|
|
376
|
+
// param (older callers) opens that booking first.
|
|
125
377
|
async cancel(s, arg) {
|
|
126
|
-
const
|
|
378
|
+
const id = (arg as { id?: string })?.id || openId;
|
|
379
|
+
const member = pageMember, row = s.rows.find(row => row.id === id);
|
|
127
380
|
if (!member || !row?.canCancel || Date.parse(row.start) <= Date.now() || cancelling) return s;
|
|
128
|
-
try { await assertMemberAccess(member);
|
|
129
|
-
catch { await svc.refresh(); return undefined; }
|
|
381
|
+
try { await assertMemberAccess(member); openId = row.id; confirming = true; sheetMessage = ""; cancelMember = member; return derive(s); }
|
|
382
|
+
catch { closeSheet(); await svc.refresh(); return undefined; }
|
|
130
383
|
},
|
|
131
|
-
|
|
384
|
+
keepBooking(s) { if (!cancelling) { confirming = false; sheetMessage = ""; } return derive(s); },
|
|
132
385
|
async confirmCancel(s): Promise<S | undefined> {
|
|
133
|
-
|
|
134
|
-
|
|
386
|
+
const row = openRow(s), member = cancelMember;
|
|
387
|
+
if (!row || !member || !confirming || cancelling) return s;
|
|
388
|
+
cancelling = true;
|
|
135
389
|
try {
|
|
136
|
-
await assertMemberAccess(
|
|
137
|
-
if (Date.parse(
|
|
138
|
-
await cancelBookingAsMember(
|
|
139
|
-
await assertMemberAccess(
|
|
140
|
-
|
|
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();
|
|
141
395
|
// Told to the app hosting this screen (an embed's `onAction`); dropped where nothing hosts it.
|
|
142
|
-
await emitHostEvent("booking.cancelled", { bookingId:
|
|
143
|
-
serviceName:
|
|
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 };
|
|
144
399
|
await svc.refresh();
|
|
145
|
-
return derive(
|
|
400
|
+
return derive(svc.getState() as unknown as S);
|
|
146
401
|
} catch (error) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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);
|
|
150
410
|
} finally { cancelling = false; }
|
|
151
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
|
+
},
|
|
152
443
|
},
|
|
153
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
|
+
|
|
154
465
|
svc.load();
|
|
155
466
|
export { svc };
|
|
@@ -12,11 +12,15 @@ export interface CollectionSaving {
|
|
|
12
12
|
type State = Record<string, unknown>;
|
|
13
13
|
interface BackendCollection {
|
|
14
14
|
restorePagesOnLoad?: boolean;
|
|
15
|
-
/** Sees the load action's argument (the screen's onAppear params) BEFORE the query runs.
|
|
16
|
-
|
|
15
|
+
/** Sees the load action's argument (the screen's onAppear params) BEFORE the query runs. A returned
|
|
16
|
+
* `{ filter, sort }` resets the criteria for that load (a screen that always opens on one view). */
|
|
17
|
+
onLoad?(arg: unknown): CollectionCriteriaReset | void;
|
|
17
18
|
controller: ReturnType<typeof createCollectionQuery<CollectionRow>>;
|
|
18
19
|
apply(state: State, page: CollectionQueryState<CollectionRow>): State;
|
|
19
20
|
}
|
|
21
|
+
/** A filter and a sort set together (`collectionCriteria`, `onLoad`). Values the collection's contract does
|
|
22
|
+
* not list are ignored. */
|
|
23
|
+
export interface CollectionCriteriaReset { filter?: string; sort?: string; query?: string }
|
|
20
24
|
type Reducer = (s: State, ...args: unknown[]) => State | undefined | Promise<State | undefined>;
|
|
21
25
|
const object = (x: unknown): State => x && typeof x === 'object' && !Array.isArray(x) ? x as State : {};
|
|
22
26
|
const str = (arg: unknown, key: string): string => typeof arg === 'string' ? arg : String(object(arg)[key] ?? '');
|
|
@@ -262,6 +266,16 @@ export function decorateCollection(name: string, def: { state: State; actions?:
|
|
|
262
266
|
if (backend && config.backendQuery && criteriaBefore !== criteriaKey()) return backend.controller.query(criteria()).then(() => undefined);
|
|
263
267
|
return derive(s);
|
|
264
268
|
};
|
|
269
|
+
/** Filter and sort in ONE criteria change, so a tab that sets both runs one query, not two. */
|
|
270
|
+
const setCriteria = (arg: unknown): void => {
|
|
271
|
+
const input = object(arg);
|
|
272
|
+
const sort = String(input.sort ?? '');
|
|
273
|
+
if (sort === 'default' || sort === 'az' || sort === 'za') prefs.sort = sort;
|
|
274
|
+
const filter = String(input.filter ?? '');
|
|
275
|
+
if (filter === 'all' || (filter.startsWith('value:') && (config.filterValues ?? []).includes(filter.slice(6)))) prefs.filter = filter;
|
|
276
|
+
// A screen with no search field clears a query an older screen persisted, or it narrows every view for good.
|
|
277
|
+
if (typeof input.query === 'string') prefs.query = input.query.slice(0, 256);
|
|
278
|
+
};
|
|
265
279
|
const criteria = () => ({ query: prefs.query, sort: prefs.sort, filter: prefs.filter, savedIds: prefs.filter === 'saved' ? prefs.savedIds : [] });
|
|
266
280
|
const criteriaKey = () => JSON.stringify(criteria());
|
|
267
281
|
actions.collectionDismissFilters = change(() => { filtersPresented = false; });
|
|
@@ -273,6 +287,7 @@ export function decorateCollection(name: string, def: { state: State; actions?:
|
|
|
273
287
|
actions.collectionSort = change(arg => { const value = str(arg, 'selectedId'); if (value === 'default' || value === 'az' || value === 'za') prefs.sort = value; });
|
|
274
288
|
actions.collectionFilter = change(arg => { const values = object(arg).selectedIds; prefs.filter = Array.isArray(values) && typeof values[0] === 'string' ? values[0] : 'all'; });
|
|
275
289
|
actions.collectionSearch = change(arg => { prefs.query = str(arg, 'value').slice(0, 256); });
|
|
290
|
+
actions.collectionCriteria = change(arg => setCriteria(arg));
|
|
276
291
|
actions.collectionViewName = change(arg => { viewName = str(arg, 'value').slice(0, 40); viewMessage = ''; });
|
|
277
292
|
actions.collectionRememberView = change(() => {
|
|
278
293
|
const name = viewName.trim();
|
|
@@ -305,7 +320,8 @@ export function decorateCollection(name: string, def: { state: State; actions?:
|
|
|
305
320
|
actions.collectionDataChanged = s => derive(backend.apply(s, backend.controller.getState()));
|
|
306
321
|
actions.load = (_s, arg) => {
|
|
307
322
|
stashCopy(arg); syncSaved(); applyNavigationCriteria(arg, _s);
|
|
308
|
-
backend.onLoad?.(arg);
|
|
323
|
+
const reset = backend.onLoad?.(arg);
|
|
324
|
+
if (reset) { setCriteria(reset); persisted.write(prefs); }
|
|
309
325
|
// A load of what the screen already shows is the screen coming back: it revalidates behind its rows.
|
|
310
326
|
return backend.controller.query(criteria(), backend.restorePagesOnLoad, true).then(() => undefined);
|
|
311
327
|
};
|
|
@@ -334,11 +350,13 @@ export function defineBackendCollection<S extends object, Row extends object>(na
|
|
|
334
350
|
register?: typeof register;
|
|
335
351
|
/** Explicit mutation preflight; unrelated collection services keep their current reducers. */
|
|
336
352
|
onlineWrites?: boolean;
|
|
353
|
+
/** Where the offline refusal is written (default `actionMessage`) — the field the screen shows next to the action. */
|
|
354
|
+
onlineWriteMessageFields?: readonly string[];
|
|
337
355
|
/** Revalidate already opened pages on navigation return; explicit refresh resets. */
|
|
338
356
|
restorePagesOnLoad?: boolean;
|
|
339
357
|
/** The load action's argument (onAppear params beyond `copy`), before the query runs — for a
|
|
340
358
|
* domain setting that rides the descriptor, e.g. My Bookings' `weeklyGoal`. */
|
|
341
|
-
onLoad?(arg: unknown): void;
|
|
359
|
+
onLoad?(arg: unknown): CollectionCriteriaReset | void;
|
|
342
360
|
state: S;
|
|
343
361
|
source: CollectionQuerySource<Row>;
|
|
344
362
|
apply(state: S, page: CollectionQueryState<Row>): S;
|
|
@@ -353,7 +371,7 @@ export function defineBackendCollection<S extends object, Row extends object>(na
|
|
|
353
371
|
controller: controller as unknown as BackendCollection['controller'],
|
|
354
372
|
apply: (state, page) => options.apply(state as S, page as unknown as CollectionQueryState<Row>) as State,
|
|
355
373
|
});
|
|
356
|
-
const registerService = options.onlineWrites ? withOnlineWrites(options.register ?? register, ['confirmCancel', 'confirmOperation', 'retryOperation', 'commitWrite'], { messageFields: ['actionMessage'] }) : (options.register ?? register);
|
|
374
|
+
const registerService = options.onlineWrites ? withOnlineWrites(options.register ?? register, ['confirmCancel', 'confirmOperation', 'retryOperation', 'commitWrite'], { messageFields: options.onlineWriteMessageFields ?? ['actionMessage'] }) : (options.register ?? register);
|
|
357
375
|
const handle = registerService(name, definition);
|
|
358
376
|
// Registration restored the state a cold boot saved: the page it showed is this run's, before the first load. Only
|
|
359
377
|
// when the service's own rows came back with it: a bundle that keeps them out of the restore (its `notHydrated`)
|