@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
|
@@ -345,7 +345,7 @@ export default defineApp({
|
|
|
345
345
|
// Your on-device database. It must declare every entity a worker reads or writes,
|
|
346
346
|
// or those calls fail closed — keep it in step with `local_database` above.
|
|
347
347
|
dataSchema: {
|
|
348
|
-
"version":
|
|
348
|
+
"version": 2,
|
|
349
349
|
"entities": [
|
|
350
350
|
{
|
|
351
351
|
"name": "products",
|
|
@@ -470,6 +470,17 @@ export default defineApp({
|
|
|
470
470
|
}
|
|
471
471
|
]
|
|
472
472
|
},
|
|
473
|
+
{
|
|
474
|
+
"name": "food_orders",
|
|
475
|
+
"encrypted": true,
|
|
476
|
+
"fields": [
|
|
477
|
+
{
|
|
478
|
+
"name": "id",
|
|
479
|
+
"type": "string",
|
|
480
|
+
"required": true
|
|
481
|
+
}
|
|
482
|
+
]
|
|
483
|
+
},
|
|
473
484
|
{
|
|
474
485
|
"name": "member_bookings",
|
|
475
486
|
"encrypted": true,
|
|
@@ -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`)
|
|
@@ -108,6 +108,24 @@ export function formatMonthDayLong(date: Date, locale = "en-US", timeZone?: stri
|
|
|
108
108
|
return `${MONTHS_LONG[date.getMonth()]} ${date.getDate()}`;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
const WEEKDAYS_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
|
|
112
|
+
|
|
113
|
+
/** `{ weekday: "short", month: "short", day: "numeric" }` — "Mon, Jan 5". */
|
|
114
|
+
export function formatWeekdayMonthDay(date: Date, locale = "en-US", timeZone?: string): string {
|
|
115
|
+
if (hasIntl()) {
|
|
116
|
+
return dateFormat(locale, { weekday: "short", month: "short", day: "numeric" }, timeZone).format(date);
|
|
117
|
+
}
|
|
118
|
+
return `${WEEKDAYS_SHORT[date.getDay()]}, ${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `{ weekday: "long", month: "long", day: "numeric" }` — "Monday, January 5". */
|
|
122
|
+
export function formatWeekdayMonthDayLong(date: Date, locale = "en-US", timeZone?: string): string {
|
|
123
|
+
if (hasIntl()) {
|
|
124
|
+
return dateFormat(locale, { weekday: "long", month: "long", day: "numeric" }, timeZone).format(date);
|
|
125
|
+
}
|
|
126
|
+
return `${WEEKDAYS_LONG[date.getDay()]}, ${MONTHS_LONG[date.getMonth()]} ${date.getDate()}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
111
129
|
/** `{ weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }`
|
|
112
130
|
* — "Mon, Jan 5, 3:07 PM". */
|
|
113
131
|
export function formatWeekdayDateTime(date: Date, locale = "en-US", timeZone?: string): string {
|
|
@@ -133,15 +133,18 @@ export function itemLabels(value: unknown, limit = 3): string[] {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/** Price-variant rows ({ name, priceLine }) from the item's loose
|
|
136
|
-
* `priceVariants.variants` payload — [] when the item has a single price.
|
|
137
|
-
|
|
136
|
+
* `priceVariants.variants` payload — [] when the item has a single price.
|
|
137
|
+
* `variantId` is Wix's own id for the variant (live rows carry `variantId`; older
|
|
138
|
+
* fixtures `id`), "" when the payload has none — a synthetic `variant-<n>` row id is
|
|
139
|
+
* only for the UI and must never be sent to Wix. `price` is the decimal price. */
|
|
140
|
+
export function itemPriceVariants(item: WixItem): Array<{ id: string; variantId: string; name: string; priceLine: string; price: string }> {
|
|
138
141
|
const raw = item as unknown as { priceVariants?: { variants?: unknown[] } };
|
|
139
142
|
const variants = raw.priceVariants?.variants;
|
|
140
143
|
if (!Array.isArray(variants)) return [];
|
|
141
|
-
const rows: Array<{ id: string; name: string; priceLine: string }> = [];
|
|
144
|
+
const rows: Array<{ id: string; variantId: string; name: string; priceLine: string; price: string }> = [];
|
|
142
145
|
for (const [index, entry] of variants.entries()) {
|
|
143
146
|
if (!entry || typeof entry !== "object") continue;
|
|
144
|
-
const node = entry as { id?: unknown; name?: unknown; priceInfo?: { price?: unknown; currency?: unknown } };
|
|
147
|
+
const node = entry as { id?: unknown; variantId?: unknown; name?: unknown; priceInfo?: { price?: unknown; currency?: unknown; formattedPrice?: unknown } };
|
|
145
148
|
const name = typeof node.name === "string" ? cleanTitle(node.name) : "";
|
|
146
149
|
const price = node.priceInfo?.price;
|
|
147
150
|
if (price == null || price === "" || Number.isNaN(Number(price))) continue;
|
|
@@ -152,7 +155,11 @@ export function itemPriceVariants(item: WixItem): Array<{ id: string; name: stri
|
|
|
152
155
|
priceLine = formatCurrency(Number(price), currency, "en");
|
|
153
156
|
} catch { /* keep the bare amount */ }
|
|
154
157
|
}
|
|
155
|
-
|
|
158
|
+
const formatted = typeof node.priceInfo?.formattedPrice === "string" ? node.priceInfo.formattedPrice.trim() : "";
|
|
159
|
+
if (formatted) priceLine = normalizeFormattedPrice(formatted);
|
|
160
|
+
const variantId = typeof node.variantId === "string" && node.variantId ? node.variantId
|
|
161
|
+
: typeof node.id === "string" && node.id ? node.id : "";
|
|
162
|
+
rows.push({ id: variantId || `variant-${index}`, variantId, name: name || "Option", priceLine, price: `${price}` });
|
|
156
163
|
}
|
|
157
164
|
return rows;
|
|
158
165
|
}
|
|
@@ -6,27 +6,69 @@ import { copyParam } from "./himi-copy.js";
|
|
|
6
6
|
import { formatWeekdayDateTime } from "./engine-intl.js";
|
|
7
7
|
import { dayKey } from "@himalaya/time";
|
|
8
8
|
|
|
9
|
+
/** The statuses that put a booking in the Canceled tab. Upcoming and Past exclude them, so each
|
|
10
|
+
* booking is in exactly one tab. */
|
|
11
|
+
export const CANCELLED_STATUSES = ["CANCELED", "CANCELLED", "DECLINED"];
|
|
12
|
+
|
|
9
13
|
export interface MemberBookingRow {
|
|
10
14
|
id: string; title: string; subtitle: string; status: string; revision: string;
|
|
11
15
|
start: string; end: string; day: string; serviceId: string;
|
|
12
16
|
canCancel: boolean; cannotCancel: boolean; cannotRebook: boolean;
|
|
17
|
+
// Added for the My Bookings redesign. Each is "" / 0 / false when the record has nothing for it.
|
|
18
|
+
/** The staff member (the slot's resource), "" when the booking names none. */
|
|
19
|
+
staff: string;
|
|
20
|
+
/** The location's name, else its address. */
|
|
21
|
+
placeName: string;
|
|
22
|
+
/** The location's formatted address. */
|
|
23
|
+
placeAddress: string;
|
|
24
|
+
/** `BUSINESS`, `CUSTOM` or `CUSTOMER` (the client's place), upper case. */
|
|
25
|
+
placeType: string;
|
|
26
|
+
participants: number;
|
|
27
|
+
/** Wix's raw payment status, upper case (`PAID`, `NOT_PAID`, …). */
|
|
28
|
+
paymentStatus: string;
|
|
29
|
+
/** Wix's raw booking status, upper case (`CONFIRMED`, `WAITING_LIST`, …). */
|
|
30
|
+
statusKey: string;
|
|
31
|
+
/** A course booking: it books a schedule, not a slot. */
|
|
32
|
+
isCourse: boolean;
|
|
33
|
+
courseStart: string;
|
|
34
|
+
courseEnd: string;
|
|
13
35
|
}
|
|
14
36
|
export function bookingStart(booking: WixBooking): string {
|
|
15
37
|
// Top-level instants are UTC. Slot dates may be LOCAL to slot.timezone.
|
|
16
38
|
const value = booking.startDate || booking.bookedEntity?.slot?.startDate || "";
|
|
17
39
|
return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
|
|
18
40
|
}
|
|
41
|
+
const instant = (value: unknown): string =>
|
|
42
|
+
typeof value === "string" && /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
|
|
43
|
+
const word = (value: unknown): string => typeof value === "string" ? value.trim() : "";
|
|
19
44
|
export function memberBookingRow(booking: WixBooking): MemberBookingRow {
|
|
20
45
|
const start = bookingStart(booking);
|
|
21
46
|
const entity = booking.bookedEntity;
|
|
47
|
+
const slot = entity?.slot, schedule = entity?.schedule;
|
|
22
48
|
const status = (booking.status || "").toUpperCase();
|
|
23
49
|
const revision = String((booking as WixBooking & { revision?: string | number }).revision ?? "");
|
|
24
|
-
|
|
25
|
-
const
|
|
50
|
+
// A course booking carries a schedule, not a slot: its service id is on the schedule.
|
|
51
|
+
const serviceId = slot?.serviceId || schedule?.serviceId || "";
|
|
52
|
+
const canCancel = !!booking.id && !!revision && Date.parse(start) > Date.now() && !CANCELLED_STATUSES.includes(status);
|
|
53
|
+
const location = slot?.location ?? schedule?.location;
|
|
54
|
+
const placeAddress = word(location?.formattedAddress);
|
|
55
|
+
const participants = Number(booking.totalParticipants);
|
|
26
56
|
return { id: booking.id, title: entity?.title || copyParam("bookings.booking", "Booking"),
|
|
27
57
|
subtitle: start ? formatWeekdayDateTime(new Date(start)) : copyParam("bookings.timeUnavailable", "Time unavailable"),
|
|
28
|
-
status: status.replace(/_/g, " "), revision, start,
|
|
29
|
-
|
|
58
|
+
status: status.replace(/_/g, " "), revision, start,
|
|
59
|
+
// Wix may carry the end only on the slot (same instant rule as the start).
|
|
60
|
+
end: booking.endDate || instant(slot?.endDate), day: start ? dayKey(new Date(start)) : "",
|
|
61
|
+
serviceId, canCancel, cannotCancel: !canCancel, cannotRebook: !serviceId,
|
|
62
|
+
staff: word(slot?.resource?.name),
|
|
63
|
+
placeName: word(location?.name) || placeAddress,
|
|
64
|
+
placeAddress,
|
|
65
|
+
placeType: word(location?.locationType).toUpperCase(),
|
|
66
|
+
participants: Number.isFinite(participants) && participants > 0 ? Math.floor(participants) : 0,
|
|
67
|
+
paymentStatus: word(booking.paymentStatus).toUpperCase(),
|
|
68
|
+
statusKey: status,
|
|
69
|
+
isCourse: !slot && !!schedule,
|
|
70
|
+
courseStart: instant(schedule?.firstSessionStart),
|
|
71
|
+
courseEnd: instant(schedule?.lastSessionEnd) };
|
|
30
72
|
}
|
|
31
73
|
export async function fetchMemberBookings(criteria: CollectionQuery, cursor: string, now: string, expected?: MemberAccess) {
|
|
32
74
|
const member = expected ?? await memberAccess();
|
|
@@ -37,9 +79,10 @@ export async function fetchMemberBookings(criteria: CollectionQuery, cursor: str
|
|
|
37
79
|
const filter: Record<string, unknown> = { "contactDetails.email": { $eq: member.email } };
|
|
38
80
|
if (criteria.query) filter.id = { $eq: criteria.query };
|
|
39
81
|
if (criteria.filter === "saved") filter.id = criteria.query ? { $in: criteria.savedIds.filter(id => id === criteria.query) } : { $in: criteria.savedIds };
|
|
40
|
-
if (criteria.filter === "value:Upcoming") { filter.startDate = { $gte: now }; filter.status = { $nin:
|
|
41
|
-
|
|
42
|
-
if (criteria.filter === "value:
|
|
82
|
+
if (criteria.filter === "value:Upcoming") { filter.startDate = { $gte: now }; filter.status = { $nin: CANCELLED_STATUSES }; }
|
|
83
|
+
// Past is what took place: a canceled or declined booking is in the Canceled tab only.
|
|
84
|
+
if (criteria.filter === "value:Past") { filter.startDate = { $lt: now }; filter.status = { $nin: CANCELLED_STATUSES }; }
|
|
85
|
+
if (criteria.filter === "value:Cancelled") filter.status = { $in: CANCELLED_STATUSES };
|
|
43
86
|
const response = await queryBookings({ query: { filter, paging: { limit: 50, offset: Number(cursor || 0) },
|
|
44
87
|
sort: [{ fieldName: "startDate", order: criteria.sort === "za" ? "DESC" : "ASC" }, { fieldName: "id", order: "ASC" }] } });
|
|
45
88
|
await assertMemberAccess(member);
|
|
@@ -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`)
|
|
@@ -108,6 +108,24 @@ export function formatMonthDayLong(date: Date, locale = "en-US", timeZone?: stri
|
|
|
108
108
|
return `${MONTHS_LONG[date.getMonth()]} ${date.getDate()}`;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
const WEEKDAYS_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
|
|
112
|
+
|
|
113
|
+
/** `{ weekday: "short", month: "short", day: "numeric" }` — "Mon, Jan 5". */
|
|
114
|
+
export function formatWeekdayMonthDay(date: Date, locale = "en-US", timeZone?: string): string {
|
|
115
|
+
if (hasIntl()) {
|
|
116
|
+
return dateFormat(locale, { weekday: "short", month: "short", day: "numeric" }, timeZone).format(date);
|
|
117
|
+
}
|
|
118
|
+
return `${WEEKDAYS_SHORT[date.getDay()]}, ${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `{ weekday: "long", month: "long", day: "numeric" }` — "Monday, January 5". */
|
|
122
|
+
export function formatWeekdayMonthDayLong(date: Date, locale = "en-US", timeZone?: string): string {
|
|
123
|
+
if (hasIntl()) {
|
|
124
|
+
return dateFormat(locale, { weekday: "long", month: "long", day: "numeric" }, timeZone).format(date);
|
|
125
|
+
}
|
|
126
|
+
return `${WEEKDAYS_LONG[date.getDay()]}, ${MONTHS_LONG[date.getMonth()]} ${date.getDate()}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
111
129
|
/** `{ weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }`
|
|
112
130
|
* — "Mon, Jan 5, 3:07 PM". */
|
|
113
131
|
export function formatWeekdayDateTime(date: Date, locale = "en-US", timeZone?: string): string {
|
|
@@ -317,7 +317,7 @@ export default defineApp({
|
|
|
317
317
|
// Your on-device database. It must declare every entity a worker reads or writes,
|
|
318
318
|
// or those calls fail closed — keep it in step with `local_database` above.
|
|
319
319
|
dataSchema: {
|
|
320
|
-
"version":
|
|
320
|
+
"version": 2,
|
|
321
321
|
"entities": [
|
|
322
322
|
{
|
|
323
323
|
"name": "products",
|
|
@@ -442,6 +442,17 @@ export default defineApp({
|
|
|
442
442
|
}
|
|
443
443
|
]
|
|
444
444
|
},
|
|
445
|
+
{
|
|
446
|
+
"name": "food_orders",
|
|
447
|
+
"encrypted": true,
|
|
448
|
+
"fields": [
|
|
449
|
+
{
|
|
450
|
+
"name": "id",
|
|
451
|
+
"type": "string",
|
|
452
|
+
"required": true
|
|
453
|
+
}
|
|
454
|
+
]
|
|
455
|
+
},
|
|
445
456
|
{
|
|
446
457
|
"name": "member_bookings",
|
|
447
458
|
"encrypted": true,
|
|
@@ -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`)
|
|
@@ -108,6 +108,24 @@ export function formatMonthDayLong(date: Date, locale = "en-US", timeZone?: stri
|
|
|
108
108
|
return `${MONTHS_LONG[date.getMonth()]} ${date.getDate()}`;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
const WEEKDAYS_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const;
|
|
112
|
+
|
|
113
|
+
/** `{ weekday: "short", month: "short", day: "numeric" }` — "Mon, Jan 5". */
|
|
114
|
+
export function formatWeekdayMonthDay(date: Date, locale = "en-US", timeZone?: string): string {
|
|
115
|
+
if (hasIntl()) {
|
|
116
|
+
return dateFormat(locale, { weekday: "short", month: "short", day: "numeric" }, timeZone).format(date);
|
|
117
|
+
}
|
|
118
|
+
return `${WEEKDAYS_SHORT[date.getDay()]}, ${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `{ weekday: "long", month: "long", day: "numeric" }` — "Monday, January 5". */
|
|
122
|
+
export function formatWeekdayMonthDayLong(date: Date, locale = "en-US", timeZone?: string): string {
|
|
123
|
+
if (hasIntl()) {
|
|
124
|
+
return dateFormat(locale, { weekday: "long", month: "long", day: "numeric" }, timeZone).format(date);
|
|
125
|
+
}
|
|
126
|
+
return `${WEEKDAYS_LONG[date.getDay()]}, ${MONTHS_LONG[date.getMonth()]} ${date.getDate()}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
111
129
|
/** `{ weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }`
|
|
112
130
|
* — "Mon, Jan 5, 3:07 PM". */
|
|
113
131
|
export function formatWeekdayDateTime(date: Date, locale = "en-US", timeZone?: string): string {
|
|
@@ -133,15 +133,18 @@ export function itemLabels(value: unknown, limit = 3): string[] {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/** Price-variant rows ({ name, priceLine }) from the item's loose
|
|
136
|
-
* `priceVariants.variants` payload — [] when the item has a single price.
|
|
137
|
-
|
|
136
|
+
* `priceVariants.variants` payload — [] when the item has a single price.
|
|
137
|
+
* `variantId` is Wix's own id for the variant (live rows carry `variantId`; older
|
|
138
|
+
* fixtures `id`), "" when the payload has none — a synthetic `variant-<n>` row id is
|
|
139
|
+
* only for the UI and must never be sent to Wix. `price` is the decimal price. */
|
|
140
|
+
export function itemPriceVariants(item: WixItem): Array<{ id: string; variantId: string; name: string; priceLine: string; price: string }> {
|
|
138
141
|
const raw = item as unknown as { priceVariants?: { variants?: unknown[] } };
|
|
139
142
|
const variants = raw.priceVariants?.variants;
|
|
140
143
|
if (!Array.isArray(variants)) return [];
|
|
141
|
-
const rows: Array<{ id: string; name: string; priceLine: string }> = [];
|
|
144
|
+
const rows: Array<{ id: string; variantId: string; name: string; priceLine: string; price: string }> = [];
|
|
142
145
|
for (const [index, entry] of variants.entries()) {
|
|
143
146
|
if (!entry || typeof entry !== "object") continue;
|
|
144
|
-
const node = entry as { id?: unknown; name?: unknown; priceInfo?: { price?: unknown; currency?: unknown } };
|
|
147
|
+
const node = entry as { id?: unknown; variantId?: unknown; name?: unknown; priceInfo?: { price?: unknown; currency?: unknown; formattedPrice?: unknown } };
|
|
145
148
|
const name = typeof node.name === "string" ? cleanTitle(node.name) : "";
|
|
146
149
|
const price = node.priceInfo?.price;
|
|
147
150
|
if (price == null || price === "" || Number.isNaN(Number(price))) continue;
|
|
@@ -152,7 +155,11 @@ export function itemPriceVariants(item: WixItem): Array<{ id: string; name: stri
|
|
|
152
155
|
priceLine = formatCurrency(Number(price), currency, "en");
|
|
153
156
|
} catch { /* keep the bare amount */ }
|
|
154
157
|
}
|
|
155
|
-
|
|
158
|
+
const formatted = typeof node.priceInfo?.formattedPrice === "string" ? node.priceInfo.formattedPrice.trim() : "";
|
|
159
|
+
if (formatted) priceLine = normalizeFormattedPrice(formatted);
|
|
160
|
+
const variantId = typeof node.variantId === "string" && node.variantId ? node.variantId
|
|
161
|
+
: typeof node.id === "string" && node.id ? node.id : "";
|
|
162
|
+
rows.push({ id: variantId || `variant-${index}`, variantId, name: name || "Option", priceLine, price: `${price}` });
|
|
156
163
|
}
|
|
157
164
|
return rows;
|
|
158
165
|
}
|
|
@@ -6,27 +6,69 @@ import { copyParam } from "./himi-copy.js";
|
|
|
6
6
|
import { formatWeekdayDateTime } from "./engine-intl.js";
|
|
7
7
|
import { dayKey } from "@himalaya/time";
|
|
8
8
|
|
|
9
|
+
/** The statuses that put a booking in the Canceled tab. Upcoming and Past exclude them, so each
|
|
10
|
+
* booking is in exactly one tab. */
|
|
11
|
+
export const CANCELLED_STATUSES = ["CANCELED", "CANCELLED", "DECLINED"];
|
|
12
|
+
|
|
9
13
|
export interface MemberBookingRow {
|
|
10
14
|
id: string; title: string; subtitle: string; status: string; revision: string;
|
|
11
15
|
start: string; end: string; day: string; serviceId: string;
|
|
12
16
|
canCancel: boolean; cannotCancel: boolean; cannotRebook: boolean;
|
|
17
|
+
// Added for the My Bookings redesign. Each is "" / 0 / false when the record has nothing for it.
|
|
18
|
+
/** The staff member (the slot's resource), "" when the booking names none. */
|
|
19
|
+
staff: string;
|
|
20
|
+
/** The location's name, else its address. */
|
|
21
|
+
placeName: string;
|
|
22
|
+
/** The location's formatted address. */
|
|
23
|
+
placeAddress: string;
|
|
24
|
+
/** `BUSINESS`, `CUSTOM` or `CUSTOMER` (the client's place), upper case. */
|
|
25
|
+
placeType: string;
|
|
26
|
+
participants: number;
|
|
27
|
+
/** Wix's raw payment status, upper case (`PAID`, `NOT_PAID`, …). */
|
|
28
|
+
paymentStatus: string;
|
|
29
|
+
/** Wix's raw booking status, upper case (`CONFIRMED`, `WAITING_LIST`, …). */
|
|
30
|
+
statusKey: string;
|
|
31
|
+
/** A course booking: it books a schedule, not a slot. */
|
|
32
|
+
isCourse: boolean;
|
|
33
|
+
courseStart: string;
|
|
34
|
+
courseEnd: string;
|
|
13
35
|
}
|
|
14
36
|
export function bookingStart(booking: WixBooking): string {
|
|
15
37
|
// Top-level instants are UTC. Slot dates may be LOCAL to slot.timezone.
|
|
16
38
|
const value = booking.startDate || booking.bookedEntity?.slot?.startDate || "";
|
|
17
39
|
return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
|
|
18
40
|
}
|
|
41
|
+
const instant = (value: unknown): string =>
|
|
42
|
+
typeof value === "string" && /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
|
|
43
|
+
const word = (value: unknown): string => typeof value === "string" ? value.trim() : "";
|
|
19
44
|
export function memberBookingRow(booking: WixBooking): MemberBookingRow {
|
|
20
45
|
const start = bookingStart(booking);
|
|
21
46
|
const entity = booking.bookedEntity;
|
|
47
|
+
const slot = entity?.slot, schedule = entity?.schedule;
|
|
22
48
|
const status = (booking.status || "").toUpperCase();
|
|
23
49
|
const revision = String((booking as WixBooking & { revision?: string | number }).revision ?? "");
|
|
24
|
-
|
|
25
|
-
const
|
|
50
|
+
// A course booking carries a schedule, not a slot: its service id is on the schedule.
|
|
51
|
+
const serviceId = slot?.serviceId || schedule?.serviceId || "";
|
|
52
|
+
const canCancel = !!booking.id && !!revision && Date.parse(start) > Date.now() && !CANCELLED_STATUSES.includes(status);
|
|
53
|
+
const location = slot?.location ?? schedule?.location;
|
|
54
|
+
const placeAddress = word(location?.formattedAddress);
|
|
55
|
+
const participants = Number(booking.totalParticipants);
|
|
26
56
|
return { id: booking.id, title: entity?.title || copyParam("bookings.booking", "Booking"),
|
|
27
57
|
subtitle: start ? formatWeekdayDateTime(new Date(start)) : copyParam("bookings.timeUnavailable", "Time unavailable"),
|
|
28
|
-
status: status.replace(/_/g, " "), revision, start,
|
|
29
|
-
|
|
58
|
+
status: status.replace(/_/g, " "), revision, start,
|
|
59
|
+
// Wix may carry the end only on the slot (same instant rule as the start).
|
|
60
|
+
end: booking.endDate || instant(slot?.endDate), day: start ? dayKey(new Date(start)) : "",
|
|
61
|
+
serviceId, canCancel, cannotCancel: !canCancel, cannotRebook: !serviceId,
|
|
62
|
+
staff: word(slot?.resource?.name),
|
|
63
|
+
placeName: word(location?.name) || placeAddress,
|
|
64
|
+
placeAddress,
|
|
65
|
+
placeType: word(location?.locationType).toUpperCase(),
|
|
66
|
+
participants: Number.isFinite(participants) && participants > 0 ? Math.floor(participants) : 0,
|
|
67
|
+
paymentStatus: word(booking.paymentStatus).toUpperCase(),
|
|
68
|
+
statusKey: status,
|
|
69
|
+
isCourse: !slot && !!schedule,
|
|
70
|
+
courseStart: instant(schedule?.firstSessionStart),
|
|
71
|
+
courseEnd: instant(schedule?.lastSessionEnd) };
|
|
30
72
|
}
|
|
31
73
|
export async function fetchMemberBookings(criteria: CollectionQuery, cursor: string, now: string, expected?: MemberAccess) {
|
|
32
74
|
const member = expected ?? await memberAccess();
|
|
@@ -37,9 +79,10 @@ export async function fetchMemberBookings(criteria: CollectionQuery, cursor: str
|
|
|
37
79
|
const filter: Record<string, unknown> = { "contactDetails.email": { $eq: member.email } };
|
|
38
80
|
if (criteria.query) filter.id = { $eq: criteria.query };
|
|
39
81
|
if (criteria.filter === "saved") filter.id = criteria.query ? { $in: criteria.savedIds.filter(id => id === criteria.query) } : { $in: criteria.savedIds };
|
|
40
|
-
if (criteria.filter === "value:Upcoming") { filter.startDate = { $gte: now }; filter.status = { $nin:
|
|
41
|
-
|
|
42
|
-
if (criteria.filter === "value:
|
|
82
|
+
if (criteria.filter === "value:Upcoming") { filter.startDate = { $gte: now }; filter.status = { $nin: CANCELLED_STATUSES }; }
|
|
83
|
+
// Past is what took place: a canceled or declined booking is in the Canceled tab only.
|
|
84
|
+
if (criteria.filter === "value:Past") { filter.startDate = { $lt: now }; filter.status = { $nin: CANCELLED_STATUSES }; }
|
|
85
|
+
if (criteria.filter === "value:Cancelled") filter.status = { $in: CANCELLED_STATUSES };
|
|
43
86
|
const response = await queryBookings({ query: { filter, paging: { limit: 50, offset: Number(cursor || 0) },
|
|
44
87
|
sort: [{ fieldName: "startDate", order: criteria.sort === "za" ? "DESC" : "ASC" }, { fieldName: "id", order: "ASC" }] } });
|
|
45
88
|
await assertMemberAccess(member);
|