@wix/himalaya-cli 0.1613.0 → 0.1615.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/ui/src/index.d.ts +53 -13
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +3 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +31 -5
- package/dist/cli.mjs +1638 -1330
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +4 -0
- package/guides/component-reference.json +1 -1
- package/guides/component-reference.md +3 -0
- package/guides/designing-himi-screens.md +4 -1
- package/guides/hhp.md +2 -2
- package/guides/sdk-reference.json +28 -4
- package/package.json +2 -2
- package/template-icons/salon-spa.png +0 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/branded-lite/dev/index.ts +192 -0
- package/templates/branded-lite/l10n/en.json +1 -1
- package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
- package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/salon-spa/README.md +53 -0
- package/templates/salon-spa/config.ts +272 -0
- package/templates/salon-spa/dev/index.ts +19893 -0
- package/templates/salon-spa/himalaya.content.json +49 -0
- package/templates/salon-spa/himalaya.template.json +14 -0
- package/templates/salon-spa/icon.png +0 -0
- package/templates/salon-spa/platforms.json +1 -0
- package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
- package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
- package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
- package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
- package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
- package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
- package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
- package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
- package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
- package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
- package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
- package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
- package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
- package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
- package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
- package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
- package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
- package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
- package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
- package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
- package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
- package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
- package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
- package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
- package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
- package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
- package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
- package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
- package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
- package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
- package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
- package/templates/salon-spa/tokens.json +341 -0
- package/templates/yoga-studio/dev/index.ts +192 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// Visitors browse every Wix Bookings service of the site, grouped by category under a small
|
|
4
4
|
// title, in the business's own order (the order of the site's Book Online page). The category
|
|
5
5
|
// chips are anchors, not filters: a tap scrolls the list to its category's title, and the chip
|
|
6
|
-
// of the category at the top of the list lights up while the member scrolls.
|
|
6
|
+
// of the category at the top of the list lights up while the member scrolls. Opened with a
|
|
7
|
+
// `category` nav param (a category tile), the list starts at that category. A name search
|
|
7
8
|
// keeps the grouping. With two or more locations a pin limits the list to the services offered
|
|
8
9
|
// at one location; the choice is shared with the Agenda and Choose Time screens. Each row
|
|
9
10
|
// carries what the service record has: photo, name, tagline, a Class or Course tag, and
|
|
@@ -449,10 +450,57 @@ let activeCategory = "";
|
|
|
449
450
|
let activeChosen = false;
|
|
450
451
|
let scrollTarget = "";
|
|
451
452
|
let jumps = 0;
|
|
453
|
+
/** This worker run's own mark on its jump values. A renderer remembers the value it last jumped
|
|
454
|
+
* for, and a screen restored from an earlier visit can bring that memory back: a value that
|
|
455
|
+
* repeats across runs ("cat:x|1") would then be taken as done. Not in the state, so the boot
|
|
456
|
+
* state (which a release hashes) does not change with the clock. */
|
|
457
|
+
const RUN = Date.now().toString(36);
|
|
452
458
|
let quietUntil = 0;
|
|
453
459
|
/** How long a jump the worker asked for takes to settle: a top-row report inside it is the jump's
|
|
454
460
|
* own (the renderers suppress those, this is the belt to their braces). */
|
|
455
461
|
const JUMP_SETTLE_MS = 900;
|
|
462
|
+
/** The category this list was opened on (the `category` nav param: a category's Wix id, its
|
|
463
|
+
* `name:<name>` key, or its name), e.g. from a category tile. Its chip lights and the list jumps
|
|
464
|
+
* to its title once that group shows, and once more when this run's live page lands. After that
|
|
465
|
+
* — or as soon as the member scrolls or taps a chip — the member leads.
|
|
466
|
+
*
|
|
467
|
+
* Armed by the screen's own `load` (its onAppear), never by the boot request: the iOS and
|
|
468
|
+
* Android lists act on a jump that CHANGES while they are on screen (a chip tap), and drop one
|
|
469
|
+
* that is already in the state when they mount. The second jump is for a screen restored from
|
|
470
|
+
* its last visit: the host puts back that visit's scroll position after the first one. */
|
|
471
|
+
let pendingCategory = "";
|
|
472
|
+
let bootLoad = true;
|
|
473
|
+
/** This run's own live read has landed (a restored snapshot's rows do not count). */
|
|
474
|
+
let liveLanded = false;
|
|
475
|
+
let jumpedFromCache = false;
|
|
476
|
+
/** The `category` param the list last started at, and that visit's `categoryActivation` (a
|
|
477
|
+
* nonce a category tap sends, so a second tap on the SAME category is a new visit too). */
|
|
478
|
+
let openedFor = "";
|
|
479
|
+
let openedActivation = "";
|
|
480
|
+
function inputParam(key: "category" | "categoryActivation"): string {
|
|
481
|
+
const value = (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input?.[key];
|
|
482
|
+
return typeof value === "string" ? value.trim().slice(0, 200) : "";
|
|
483
|
+
}
|
|
484
|
+
const inputCategory = (): string => inputParam("category");
|
|
485
|
+
/** Read on every `load`, not once at boot: a pooled worker is handed the next screen's input just
|
|
486
|
+
* before its `load` runs (the web host keeps one services worker for every push). The same param
|
|
487
|
+
* again is the screen coming back (a pop, a refresh), which keeps the member's place. */
|
|
488
|
+
function armCategory(): boolean {
|
|
489
|
+
const next = inputCategory();
|
|
490
|
+
const activation = inputParam("categoryActivation");
|
|
491
|
+
if (next === openedFor && activation === openedActivation) return false;
|
|
492
|
+
openedFor = next;
|
|
493
|
+
openedActivation = activation;
|
|
494
|
+
pendingCategory = next;
|
|
495
|
+
jumpedFromCache = false;
|
|
496
|
+
// A new visit is a new run for a pooled worker: the last visit's live page is not this one's,
|
|
497
|
+
// so the jump that waits for "this run's live read" waits for the read this load starts.
|
|
498
|
+
liveLanded = false;
|
|
499
|
+
// An open with no category ("See all") starts at the top: the last visit's lit chip and its
|
|
500
|
+
// jump do not carry over to it.
|
|
501
|
+
if (!next) { activeCategory = ""; activeChosen = false; scrollTarget = ""; }
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
456
504
|
|
|
457
505
|
// ---- Location ------------------------------------------------------------------------
|
|
458
506
|
/** The member's choice (shared with Agenda and Choose Time through the on-device store). */
|
|
@@ -500,6 +548,17 @@ function display(s: S): S {
|
|
|
500
548
|
...section.rows.map((row) => ({ ...row, isSection: false, notSection: true, sectionTitle: "" })),
|
|
501
549
|
]);
|
|
502
550
|
const chips = titled && sections.length >= 2 ? sections.map((section) => ({ id: section.id, label: section.title })) : [];
|
|
551
|
+
const live = liveLanded && !loading;
|
|
552
|
+
if (pendingCategory && chips.length && (live || !jumpedFromCache)) {
|
|
553
|
+
const opened = chips.find((chip) => chip.id === sectionIdOf(pendingCategory) || chip.label === pendingCategory);
|
|
554
|
+
if (opened) {
|
|
555
|
+
activeCategory = opened.id;
|
|
556
|
+
activeChosen = true;
|
|
557
|
+
scrollTarget = `${opened.id}|${RUN}.${++jumps}`;
|
|
558
|
+
quietUntil = Date.now() + JUMP_SETTLE_MS;
|
|
559
|
+
if (live) pendingCategory = ""; else jumpedFromCache = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
503
562
|
// No chips (a reload in flight, a single group): the lit chip waits for the list to come back.
|
|
504
563
|
if (chips.length && (!activeChosen || !chips.some((chip) => chip.id === activeCategory))) activeCategory = chips[0]!.id;
|
|
505
564
|
const target = scrollTarget.split("|")[0];
|
|
@@ -553,9 +612,15 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
553
612
|
register: registerServices,
|
|
554
613
|
restorePagesOnLoad: true,
|
|
555
614
|
onLoad() {
|
|
615
|
+
let reset: { query: string } | undefined;
|
|
616
|
+
if (bootLoad) bootLoad = false;
|
|
617
|
+
// A new visit at a category drops a search the member left on this list: a saved "massage"
|
|
618
|
+
// would hide the category the tile opened, and its jump would never land.
|
|
619
|
+
else if (armCategory() && pendingCategory) reset = { query: "" };
|
|
556
620
|
// Copy is stashed before this hook. Repaint restored labels immediately, even
|
|
557
621
|
// when the background query holds the old page through a slow or failed read.
|
|
558
622
|
(svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
|
|
623
|
+
return reset;
|
|
559
624
|
},
|
|
560
625
|
state: { status: "loading", query: "", all: [], results: [], listRows: [], emptyText: "", countLabel: "", errored: false,
|
|
561
626
|
notErrored: true, isLoading: true, notLoading: false, hasResults: false, noEmpty: true,
|
|
@@ -639,9 +704,10 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
639
704
|
?? ((p as { args?: { selectedIds?: unknown } } | undefined)?.args?.selectedIds);
|
|
640
705
|
const picked = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : activeCategory;
|
|
641
706
|
if (!(s.categoryChips ?? []).some((chip) => chip.id === picked)) return s;
|
|
707
|
+
pendingCategory = "";
|
|
642
708
|
activeCategory = picked;
|
|
643
709
|
activeChosen = true;
|
|
644
|
-
scrollTarget = `${picked}|${++jumps}`;
|
|
710
|
+
scrollTarget = `${picked}|${RUN}.${++jumps}`;
|
|
645
711
|
quietUntil = Date.now() + JUMP_SETTLE_MS;
|
|
646
712
|
return display(s);
|
|
647
713
|
},
|
|
@@ -653,6 +719,7 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
653
719
|
if (!rows.some((row) => !row.isSection)) return s;
|
|
654
720
|
const section = sectionOfRow(rows, argOf(p, "id"));
|
|
655
721
|
if (!section || !(s.categoryChips ?? []).some((chip) => chip.id === section)) return s;
|
|
722
|
+
pendingCategory = "";
|
|
656
723
|
activeCategory = section;
|
|
657
724
|
activeChosen = true;
|
|
658
725
|
// The member has moved on from the last jump: a re-render never scrolls back to it.
|
|
@@ -688,7 +755,7 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
|
|
|
688
755
|
// Ended label the restored row for the same course showed, until a current pack replaces it.
|
|
689
756
|
const shown = new Map((restoredEndedBridge && Array.isArray(s.results) ? s.results : []).map((row) => [row.id, row.endedText]));
|
|
690
757
|
const rows = page.items.map((item) => withRowDefaults(item, shown.get(item.id)));
|
|
691
|
-
if (page.metadata?.fromCache === false && !loading && !errored) restoredEndedBridge = false;
|
|
758
|
+
if (page.metadata?.fromCache === false && !loading && !errored) { restoredEndedBridge = false; liveLanded = true; }
|
|
692
759
|
const filtered = page.criteria.filter.startsWith("value:");
|
|
693
760
|
const hasQuery = !!page.criteria.query.trim();
|
|
694
761
|
// The menu offers every location of the whole catalog: a settled live read replaces it, the
|
|
@@ -326,6 +326,9 @@ function inputId(): string {
|
|
|
326
326
|
* not just the first, because the boot load and the screen's own `load` can overlap. */
|
|
327
327
|
/** Counts the customer's picks. A load that sees it move ran while they picked a time. */
|
|
328
328
|
let selectionRevision = 0;
|
|
329
|
+
// Each guest tap that asks the session first: only the newest answers, so two quick taps whose
|
|
330
|
+
// reads come back out of order never pick the older time or open a second sign-in.
|
|
331
|
+
let sessionTaps = 0;
|
|
329
332
|
|
|
330
333
|
let pendingStart = (() => {
|
|
331
334
|
const start = (globalThis as { himalaya?: { input?: { start?: unknown } } }).himalaya?.input?.start;
|
|
@@ -958,12 +961,13 @@ let dayRevision = 0;
|
|
|
958
961
|
/** True while "Check Next Availability" searches; a second tap waits for the first. */
|
|
959
962
|
let checkingNextLock = false;
|
|
960
963
|
|
|
961
|
-
/** `{ slotId, signInScreenId }` from a pill tap (or a bare slot id). */
|
|
962
|
-
function tapped(arg: unknown): { slotId: string; signInScreenId: string } {
|
|
963
|
-
if (typeof arg === "string") return { slotId: arg, signInScreenId: "" };
|
|
964
|
-
const a = (arg ?? {}) as { slotId?: unknown; id?: unknown; signInScreenId?: unknown };
|
|
964
|
+
/** `{ slotId, signInScreenId, signInPresentation }` from a pill tap (or a bare slot id). */
|
|
965
|
+
function tapped(arg: unknown): { slotId: string; signInScreenId: string; signInPresentation: "" | "sheet" | "full_screen" } {
|
|
966
|
+
if (typeof arg === "string") return { slotId: arg, signInScreenId: "", signInPresentation: "" };
|
|
967
|
+
const a = (arg ?? {}) as { slotId?: unknown; id?: unknown; signInScreenId?: unknown; signInPresentation?: unknown };
|
|
965
968
|
const slotId = typeof a.slotId === "string" ? a.slotId : typeof a.id === "string" ? a.id : "";
|
|
966
|
-
|
|
969
|
+
const presentation = a.signInPresentation === "sheet" || a.signInPresentation === "full_screen" ? a.signInPresentation : "";
|
|
970
|
+
return { slotId, signInScreenId: typeof a.signInScreenId === "string" ? a.signInScreenId : "", signInPresentation: presentation };
|
|
967
971
|
}
|
|
968
972
|
|
|
969
973
|
/** Select a bookable time, as a tap does: the pick-storage rules and the selection revision. */
|
|
@@ -1304,12 +1308,40 @@ const svc = defineService<S, {
|
|
|
1304
1308
|
// taps it again. Where the regular checkout is not allowed, the tap selects the time for the
|
|
1305
1309
|
// in-app purchase button.
|
|
1306
1310
|
pickSlot: async (s, arg): Promise<S | undefined> => {
|
|
1307
|
-
const { slotId, signInScreenId } = tapped(arg);
|
|
1311
|
+
const { slotId, signInScreenId, signInPresentation } = tapped(arg);
|
|
1308
1312
|
if (bookingLock || s.bookingInFlight || !allRows.some((row) => row.id === slotId && row.selectable)) return derive(s);
|
|
1309
1313
|
const selected = selectSlotIn(s, slotId);
|
|
1310
|
-
|
|
1314
|
+
// A modal sign-in leaves this screen in place: a host need not run its onAppear when the
|
|
1315
|
+
// sheet closes, and the reload that refreshes `isMember` can still be running. So ask the
|
|
1316
|
+
// session before opening the sheet again, or a member's tap would only reopen it. Only the
|
|
1317
|
+
// session's own identity counts here, never getMyData: that can still answer with a stale
|
|
1318
|
+
// site or owner profile for a visitor. A member found here gets the pick and the "tap again"
|
|
1319
|
+
// note, as after a reload, and books on the next tap: that tap passes the booking lock and
|
|
1320
|
+
// the offline guard with the member state, which this await would let a second tap skip.
|
|
1321
|
+
if (!selected.isMember && signInScreenId && signInPresentation) {
|
|
1322
|
+
const mine = ++sessionTaps;
|
|
1323
|
+
const email = await signedInEmail();
|
|
1324
|
+
if (mine !== sessionTaps) return undefined;
|
|
1325
|
+
// From the state as it is NOW: a reload may have landed during the await. The pick
|
|
1326
|
+
// stays only while that time is still bookable.
|
|
1327
|
+
const now = svc.getState?.() ?? s;
|
|
1328
|
+
const open = allRows.some((row) => row.id === slotId && row.selectable);
|
|
1329
|
+
if (email) {
|
|
1330
|
+
return derive({ ...(open ? selectSlotIn(now, slotId) : now), isMember: true, identityLine: email, email,
|
|
1331
|
+
message: open ? copyParam("time.signedInTapAgain", "Signed in. Tap the time again to book it.") : "" });
|
|
1332
|
+
}
|
|
1333
|
+
// Still a guest: the sign-in opens over the newest state; a time gone meanwhile opens nothing.
|
|
1334
|
+
if (!open) return derive(now);
|
|
1335
|
+
return navigate(selectSlotIn(now, slotId), { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", intent: "book", modal: "1" } });
|
|
1336
|
+
}
|
|
1337
|
+
if (!selected.isMember) {
|
|
1311
1338
|
// Branded Lite opens its sign-in screen; other apps show the sign-in card on this screen.
|
|
1312
|
-
|
|
1339
|
+
// With a presentation it opens as a modal (`modal`: the sign-in worker closes it after a
|
|
1340
|
+
// sign-in), and this screen loads again under it with the guest's pick kept.
|
|
1341
|
+
if (!signInScreenId) return selected;
|
|
1342
|
+
return navigate(selected, signInPresentation
|
|
1343
|
+
? { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", intent: "book", modal: "1" } }
|
|
1344
|
+
: { screenId: signInScreenId, args: { mode: "signin", intent: "book" } });
|
|
1313
1345
|
}
|
|
1314
1346
|
const regular = regularCheckoutAllowed || applePurchase.usesRegularPlatform();
|
|
1315
1347
|
if (!regular && !waitlistIds.has(slotId)) {
|
|
@@ -650,6 +650,9 @@ async function settled(next: S | undefined): Promise<S | undefined> {
|
|
|
650
650
|
|
|
651
651
|
/** How the app opens its sign-in screen, from the appear params ("" = push). */
|
|
652
652
|
let signInPresentation: "" | "sheet" | "full_screen" = "";
|
|
653
|
+
/** The app's sign-in screen (`signInScreenId` appear param): Branded Lite's `himi_sign_in` by
|
|
654
|
+
* default; an app with no such screen names its own (salon-spa: the Profile tab, `himi_account`). */
|
|
655
|
+
let signInScreenId = "himi_sign_in";
|
|
653
656
|
|
|
654
657
|
const svc = defineService<S, {
|
|
655
658
|
load: (s: S, p?: unknown) => Promise<S | undefined>;
|
|
@@ -678,6 +681,8 @@ const svc = defineService<S, {
|
|
|
678
681
|
stashCopy(p);
|
|
679
682
|
const presentation = argOf(p, "signInPresentation");
|
|
680
683
|
if (presentation === "sheet" || presentation === "full_screen") signInPresentation = presentation;
|
|
684
|
+
const signInScreen = argOf(p, "signInScreenId");
|
|
685
|
+
if (/^[a-z][a-z0-9_]{0,63}$/.test(signInScreen)) signInScreenId = signInScreen;
|
|
681
686
|
const fw = Number(argOf(p, "firstWeekday") || s.firstWeekday);
|
|
682
687
|
const today = dayKey(new Date());
|
|
683
688
|
// First appearance: open on this week. A re-appearance keeps the member's place and
|
|
@@ -878,8 +883,8 @@ const svc = defineService<S, {
|
|
|
878
883
|
busy = false;
|
|
879
884
|
// As a modal it only closes, and this screen loads again (its re-appearance reload).
|
|
880
885
|
return navigate(s, signInPresentation
|
|
881
|
-
? { screenId:
|
|
882
|
-
: { screenId:
|
|
886
|
+
? { screenId: signInScreenId, presentation: signInPresentation, args: { mode: "signin", modal: "1" } }
|
|
887
|
+
: { screenId: signInScreenId, args: { mode: "signin", returnScreenId: "himi_agenda" } });
|
|
883
888
|
}
|
|
884
889
|
const name = await memberName();
|
|
885
890
|
const current = await memberAccess().catch(() => undefined);
|
|
@@ -9,7 +9,14 @@ import { withIapPresentation } from "../_shared/iap-presentation.js";
|
|
|
9
9
|
import { queryServices, type WixService } from "@himalaya/wix-bookings";
|
|
10
10
|
import { getPost, queryPosts, type WixPost } from "@himalaya/wix-blog";
|
|
11
11
|
import { queryGroups, type WixGroup } from "@himalaya/wix-groups";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
listMyParticipations,
|
|
14
|
+
participationsByProgram,
|
|
15
|
+
queryJoinApplications,
|
|
16
|
+
queryPrograms,
|
|
17
|
+
type WixProgram,
|
|
18
|
+
} from "@himalaya/wix-online-programs";
|
|
19
|
+
import { getMyMember } from "@himalaya/wix-members";
|
|
13
20
|
import { listFeedItems, type WixFeedItem, type WixFeedProfile } from "@himalaya/wix-social-feed";
|
|
14
21
|
import { queryItems as queryMenuItems, querySections as queryMenuSections } from "@himalaya/wix-restaurants";
|
|
15
22
|
import { normalizeEvent, queryEvents } from "../_shared/himi-events.js";
|
|
@@ -166,6 +173,13 @@ interface ProgramCard {
|
|
|
166
173
|
noImage: boolean;
|
|
167
174
|
}
|
|
168
175
|
|
|
176
|
+
/** A member's own program state, kept separate from the visitor catalog cards. */
|
|
177
|
+
interface MemberProgramCard extends ProgramCard {
|
|
178
|
+
stateLabel: string;
|
|
179
|
+
progressFraction: number;
|
|
180
|
+
hideProgress: boolean;
|
|
181
|
+
}
|
|
182
|
+
|
|
169
183
|
/** A communityPulse latest-activity Carousel card (design §4.5). */
|
|
170
184
|
interface FeedHighlight {
|
|
171
185
|
id: string;
|
|
@@ -308,6 +322,8 @@ interface State {
|
|
|
308
322
|
noNextEvent: boolean;
|
|
309
323
|
homePrograms: ProgramCard[];
|
|
310
324
|
noHomePrograms: boolean;
|
|
325
|
+
memberPrograms: MemberProgramCard[];
|
|
326
|
+
noMemberPrograms: boolean;
|
|
311
327
|
/** The videos module's carousel — the newest ready Wix Video videos, the channels
|
|
312
328
|
* taking turns. Guarded on `videos`; same silent-fail contract. */
|
|
313
329
|
homeVideos: (WixVideoCard & CaptionFlags)[];
|
|
@@ -549,6 +565,7 @@ interface ExtrasSignal {
|
|
|
549
565
|
upcomingEvents: EventEntry[];
|
|
550
566
|
nextEventCards: NextEventCard[];
|
|
551
567
|
homePrograms: ProgramCard[];
|
|
568
|
+
memberPrograms: MemberProgramCard[];
|
|
552
569
|
homeVideos: (WixVideoCard & CaptionFlags)[];
|
|
553
570
|
feedHighlights: FeedHighlight[];
|
|
554
571
|
memberAvatars: CoachAvatar[];
|
|
@@ -559,8 +576,17 @@ interface ExtrasSignal {
|
|
|
559
576
|
menuCategories: MenuCategory[];
|
|
560
577
|
}
|
|
561
578
|
|
|
579
|
+
let homeProgramCandidates: { generation: number; programs: WixProgram[] } = { generation: 0, programs: [] };
|
|
580
|
+
/** A finite number from a progress field, or `fallback` when the read left it out or sent junk. */
|
|
581
|
+
function finiteOr(value: unknown, fallback: number): number {
|
|
582
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** How many microtask turns `memberProgramsLoaded` waits for refresh's own result to land (see the action). */
|
|
586
|
+
const MEMBER_PROGRAMS_APPLY_RETRIES = 50;
|
|
587
|
+
|
|
562
588
|
function emptyExtrasSignal(): ExtrasSignal {
|
|
563
|
-
return { upcomingEvents: [], nextEventCards: [], homePrograms: [], homeVideos: [], feedHighlights: [], memberAvatars: [], groupCards: [], announcementTitle: "", postCards: [], popularDishes: [], menuCategories: [] };
|
|
589
|
+
return { upcomingEvents: [], nextEventCards: [], homePrograms: [], memberPrograms: [], homeVideos: [], feedHighlights: [], memberAvatars: [], groupCards: [], announcementTitle: "", postCards: [], popularDishes: [], menuCategories: [] };
|
|
564
590
|
}
|
|
565
591
|
|
|
566
592
|
/** Cover URL probe for a blog post — the himi-blog-feed imageUrl fallbacks
|
|
@@ -670,13 +696,13 @@ async function fetchUpcomingEvents(): Promise<{ entries: EventEntry[]; nextEvent
|
|
|
670
696
|
}
|
|
671
697
|
}
|
|
672
698
|
|
|
673
|
-
async function fetchHomePrograms(): Promise<ProgramCard[]> {
|
|
699
|
+
async function fetchHomePrograms(): Promise<{ cards: ProgramCard[]; programs: WixProgram[] }> {
|
|
674
700
|
try {
|
|
675
701
|
const response = await queryPrograms({ cursorPaging: { limit: 12 } });
|
|
676
|
-
|
|
702
|
+
const programs = (response.programs ?? [])
|
|
677
703
|
.filter((p: WixProgram) => ((p.state ?? p.status ?? "").toUpperCase()) !== "DRAFT")
|
|
678
|
-
.slice(0,
|
|
679
|
-
|
|
704
|
+
.slice(0, 12);
|
|
705
|
+
const cards = programs.slice(0, 6).map((p: WixProgram) => {
|
|
680
706
|
const desc = (p as { description?: unknown }).description;
|
|
681
707
|
const nestedTitle = desc && typeof desc === "object" ? (desc as { title?: string }).title : undefined;
|
|
682
708
|
const name = cleanTitle(p.settings?.title ?? nestedTitle ?? p.title) || copyParam("home.programFallbackName", "Program");
|
|
@@ -692,7 +718,112 @@ async function fetchHomePrograms(): Promise<ProgramCard[]> {
|
|
|
692
718
|
noImage: !image,
|
|
693
719
|
};
|
|
694
720
|
});
|
|
721
|
+
return { cards, programs };
|
|
695
722
|
} catch {
|
|
723
|
+
return { cards: [], programs: [] };
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Member state is best-effort Home enrichment: visitors and unsupported member sessions
|
|
728
|
+
* collapse the rail, while the public Programs section remains available. */
|
|
729
|
+
async function fetchMemberProgramCards(programs: WixProgram[]): Promise<MemberProgramCard[]> {
|
|
730
|
+
try {
|
|
731
|
+
const member = await memberAccess();
|
|
732
|
+
if (!member.email) return [];
|
|
733
|
+
const memberId = (await getMyMember()).member?.id;
|
|
734
|
+
if (!memberId) return [];
|
|
735
|
+
|
|
736
|
+
const [participantResult, applicationResult] = await Promise.allSettled([
|
|
737
|
+
listMyParticipations(memberId, { maxPages: 2 }),
|
|
738
|
+
queryJoinApplications({ memberId, limit: 100 }),
|
|
739
|
+
]);
|
|
740
|
+
// These are independent member reads. An unverified participant-query permission
|
|
741
|
+
// must not also hide applications that the member-scoped endpoint can return.
|
|
742
|
+
const participants = participantResult.status === "fulfilled" ? participantResult.value : [];
|
|
743
|
+
const applications = applicationResult.status === "fulfilled" ? applicationResult.value : { joinApplications: [] };
|
|
744
|
+
const participation = participationsByProgram(participants);
|
|
745
|
+
// An application that already became a participant keeps its status and carries `participantId`
|
|
746
|
+
// (the SDK's PENDING_JOIN_APPLICATION_STATUSES note): the participation is the standing, and a member
|
|
747
|
+
// who later left must not read as "Approved" from the application that once admitted them. Newest
|
|
748
|
+
// first, one per program, and only programs with no participation of their own — so the admitted
|
|
749
|
+
// rows a joined member accumulates never take the lookup slots of a real pending request.
|
|
750
|
+
const applicationByProgram = new Map<string, { status?: string }>();
|
|
751
|
+
for (const application of applications.joinApplications ?? []) {
|
|
752
|
+
if (!application.programId || application.participantId || participation.has(application.programId)) continue;
|
|
753
|
+
if (!applicationByProgram.has(application.programId)) applicationByProgram.set(application.programId, application);
|
|
754
|
+
}
|
|
755
|
+
// The public catalog preview is intentionally capped at 12. Resolve the
|
|
756
|
+
// member's six most recent enrolled programs and six open applications
|
|
757
|
+
// outside that window in one filtered query so both remain visible.
|
|
758
|
+
const knownIds = new Set(programs.map((program) => program.id).filter((id): id is string => !!id));
|
|
759
|
+
const participantProgramIds = [...participation.keys()].slice(0, 6);
|
|
760
|
+
const applicationProgramIds = [...applicationByProgram.keys()].filter((id) => !participantProgramIds.includes(id)).slice(0, 6);
|
|
761
|
+
const candidateIds = [...participantProgramIds, ...applicationProgramIds].slice(0, 12);
|
|
762
|
+
const missingIds = candidateIds.filter((id) => !knownIds.has(id));
|
|
763
|
+
const extraPrograms = missingIds.length
|
|
764
|
+
? await queryPrograms({ filter: { id: { $in: missingIds } }, cursorPaging: { limit: missingIds.length } })
|
|
765
|
+
.then((response) => response.programs ?? []).catch(() => [])
|
|
766
|
+
: [];
|
|
767
|
+
const memberPrograms = [...programs, ...extraPrograms.filter((program) => !!program.id && !knownIds.has(program.id))];
|
|
768
|
+
await assertMemberAccess(member);
|
|
769
|
+
|
|
770
|
+
return memberPrograms.flatMap((program) => {
|
|
771
|
+
if (!program.id) return [];
|
|
772
|
+
const participant = participation.get(program.id);
|
|
773
|
+
const application = applicationByProgram.get(program.id);
|
|
774
|
+
if (!participant && !application) return [];
|
|
775
|
+
const desc = (program as { description?: unknown }).description;
|
|
776
|
+
const nestedTitle = desc && typeof desc === "object" ? (desc as { title?: string }).title : undefined;
|
|
777
|
+
const name = cleanTitle(program.settings?.title ?? nestedTitle ?? program.title) || copyParam("home.programFallbackName", "Program");
|
|
778
|
+
const image = programImage(program);
|
|
779
|
+
// Progress may be absent (a `fields` the read did not ask for, or a program with no steps): the
|
|
780
|
+
// bar then sits at 0 and the label comes from `progressStatus`, never from NaN arithmetic.
|
|
781
|
+
const available = finiteOr(participant?.progress?.totalStepsAvailable, 0);
|
|
782
|
+
const completed = finiteOr(participant?.progress?.totalStepsCompleted, 0);
|
|
783
|
+
const percentage = finiteOr(participant?.progress?.completionPercentage, Number.NaN);
|
|
784
|
+
const hasProgress = available > 0 || Number.isFinite(percentage);
|
|
785
|
+
const rawFraction = available > 0 ? completed / available : Number.isFinite(percentage) ? percentage / 100 : 0;
|
|
786
|
+
const progressFraction = Math.max(0, Math.min(1, rawFraction));
|
|
787
|
+
let stateLabel: string;
|
|
788
|
+
if (participant) {
|
|
789
|
+
const enrollmentStatus = (participant.enrollmentStatus ?? "").toUpperCase();
|
|
790
|
+
const progressStatus = (participant.progressStatus ?? "").toUpperCase();
|
|
791
|
+
stateLabel = enrollmentStatus === "SUSPENDED"
|
|
792
|
+
? copyParam("home.memberProgramSuspended", "Access paused")
|
|
793
|
+
: progressStatus === "COMPLETED" || enrollmentStatus === "COMPLETED"
|
|
794
|
+
? copyParam("home.memberProgramCompleted", "Completed")
|
|
795
|
+
: progressStatus === "FAILED"
|
|
796
|
+
? copyParam("home.memberProgramFailed", "Failed")
|
|
797
|
+
: progressStatus === "IN_PROGRESS" || (progressStatus !== "NOT_STARTED" && progressFraction > 0)
|
|
798
|
+
? copyParam("home.memberProgramInProgress", "In progress")
|
|
799
|
+
: copyParam("home.memberProgramNotStarted", "Not started");
|
|
800
|
+
} else {
|
|
801
|
+
const status = (application?.status ?? "").toUpperCase();
|
|
802
|
+
stateLabel = status === "INVITED"
|
|
803
|
+
? copyParam("home.memberProgramInvited", "Invitation")
|
|
804
|
+
: status === "APPROVED"
|
|
805
|
+
? copyParam("home.memberProgramApproved", "Approved")
|
|
806
|
+
: status === "SUSPENDED"
|
|
807
|
+
? copyParam("home.memberProgramSuspended", "Access paused")
|
|
808
|
+
: copyParam("home.memberProgramPending", "Request pending");
|
|
809
|
+
}
|
|
810
|
+
return [{
|
|
811
|
+
id: program.id,
|
|
812
|
+
name,
|
|
813
|
+
description: "",
|
|
814
|
+
image,
|
|
815
|
+
initial: initials(name),
|
|
816
|
+
hasImage: !!image,
|
|
817
|
+
noImage: !image,
|
|
818
|
+
stateLabel,
|
|
819
|
+
progressFraction,
|
|
820
|
+
hideProgress: !hasProgress || !participant || (participant.enrollmentStatus ?? "").toUpperCase() === "SUSPENDED",
|
|
821
|
+
}];
|
|
822
|
+
});
|
|
823
|
+
} catch (error) {
|
|
824
|
+
// The member rail is optional; never let a member-state refusal blank public Home.
|
|
825
|
+
const status = (error as { status?: unknown } | null)?.status ?? /\bHTTP (\d{3})\b/.exec(String(error))?.[1];
|
|
826
|
+
console.warn("[home.member-programs] member state unavailable", status !== undefined ? `HTTP ${String(status)}` : (error as { name?: string } | null)?.name ?? "unknown");
|
|
696
827
|
return [];
|
|
697
828
|
}
|
|
698
829
|
}
|
|
@@ -747,7 +878,7 @@ function groupCard(group: WixGroup): GroupCard {
|
|
|
747
878
|
};
|
|
748
879
|
}
|
|
749
880
|
|
|
750
|
-
async function fetchExtrasSignal(arg: unknown): Promise<ExtrasSignal> {
|
|
881
|
+
async function fetchExtrasSignal(arg: unknown, generation: number): Promise<ExtrasSignal> {
|
|
751
882
|
const verticals = parseVerticals(arg);
|
|
752
883
|
const extras = emptyExtrasSignal();
|
|
753
884
|
const jobs: Promise<void>[] = [];
|
|
@@ -758,7 +889,12 @@ async function fetchExtrasSignal(arg: unknown): Promise<ExtrasSignal> {
|
|
|
758
889
|
}));
|
|
759
890
|
}
|
|
760
891
|
if (verticals.includes("programs")) {
|
|
761
|
-
jobs.push(fetchHomePrograms().then((cards) => {
|
|
892
|
+
jobs.push(fetchHomePrograms().then(({ cards, programs }) => {
|
|
893
|
+
extras.homePrograms = cards;
|
|
894
|
+
// An older refresh can finish this query after a newer one. Keep its
|
|
895
|
+
// candidates from replacing the current generation's member-read input.
|
|
896
|
+
if (generation === refreshGen) homeProgramCandidates = { generation, programs };
|
|
897
|
+
}));
|
|
762
898
|
}
|
|
763
899
|
if (verticals.includes("videos") && !videoSurfaceRefused) {
|
|
764
900
|
const caption = videoCaptionOf(paramValue(arg, "videoCaption"));
|
|
@@ -883,6 +1019,7 @@ function derive(state: State): State {
|
|
|
883
1019
|
noUpcomingEvents: state.upcomingEvents.length === 0,
|
|
884
1020
|
noNextEvent: state.nextEventCards.length === 0,
|
|
885
1021
|
noHomePrograms: state.homePrograms.length === 0,
|
|
1022
|
+
noMemberPrograms: state.memberPrograms.length === 0,
|
|
886
1023
|
noHomeVideos: state.homeVideos.length === 0,
|
|
887
1024
|
noFeedHighlights: state.feedHighlights.length === 0,
|
|
888
1025
|
noMemberAvatars: state.memberAvatars.length === 0,
|
|
@@ -977,6 +1114,8 @@ function baseState(): State {
|
|
|
977
1114
|
noNextEvent: true,
|
|
978
1115
|
homePrograms: [],
|
|
979
1116
|
noHomePrograms: true,
|
|
1117
|
+
memberPrograms: [],
|
|
1118
|
+
noMemberPrograms: true,
|
|
980
1119
|
homeVideos: [],
|
|
981
1120
|
noHomeVideos: true,
|
|
982
1121
|
feedHighlights: [],
|
|
@@ -1026,7 +1165,7 @@ async function refreshPublic(state: State, arg?: unknown): Promise<State | undef
|
|
|
1026
1165
|
// failure just collapses their blocks), so a store outage cannot blank them
|
|
1027
1166
|
// and vice versa. paramValue() stashes the appear params on first read, so the
|
|
1028
1167
|
// arg-less fallback below still resolves the stashed verticals.
|
|
1029
|
-
const [bookings, extras, saved] = await Promise.all([fetchBookingsSignal(arg), fetchExtrasSignal(arg), fetchSavedSignal(arg)]);
|
|
1168
|
+
const [bookings, extras, saved] = await Promise.all([fetchBookingsSignal(arg), fetchExtrasSignal(arg, gen), fetchSavedSignal(arg)]);
|
|
1030
1169
|
if (gen !== refreshGen) return undefined;
|
|
1031
1170
|
// A screen that declares no "store" vertical renders no products rail, so there is
|
|
1032
1171
|
// nothing to fetch and nothing that could be "not loaded" — skip the call outright
|
|
@@ -1186,8 +1325,17 @@ async function refresh(state: State, arg?: unknown): Promise<State | undefined>
|
|
|
1186
1325
|
void refreshPersonal(generation, arg);
|
|
1187
1326
|
const publicState = await refreshPublic(state, arg);
|
|
1188
1327
|
if (!publicState || generation !== personalGeneration) return undefined;
|
|
1328
|
+
// The member-only read runs after public Home has landed. It is deliberately not awaited:
|
|
1329
|
+
// a slow member endpoint must not hold the public catalog or hero behind authentication.
|
|
1330
|
+
const programCandidates = homeProgramCandidates;
|
|
1331
|
+
if (programCandidates.generation === refreshGen && parseVerticals(arg).includes("programs")) {
|
|
1332
|
+
void fetchMemberProgramCards(programCandidates.programs).then((rows) => {
|
|
1333
|
+
void svc.memberProgramsLoaded({ generation: programCandidates.generation, rows });
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1189
1336
|
const current = svc.getState();
|
|
1190
|
-
return derive({ ...publicState,
|
|
1337
|
+
return derive({ ...publicState, memberPrograms: current.memberPrograms, noMemberPrograms: current.noMemberPrograms,
|
|
1338
|
+
nextBooking: current.nextBooking, hideNextBooking: current.hideNextBooking,
|
|
1191
1339
|
memberProgressLabel: current.memberProgressLabel, memberProgressFraction: current.memberProgressFraction,
|
|
1192
1340
|
hideMemberProgress: current.hideMemberProgress, personalError: current.personalError, hidePersonalError: current.hidePersonalError,
|
|
1193
1341
|
// The weekly goal is PERSONAL (member bookings); the public refresh resets it, so keep it.
|
|
@@ -1214,6 +1362,7 @@ const svc = defineService<State, {
|
|
|
1214
1362
|
refresh: (state: State, arg?: unknown) => Promise<State | undefined>;
|
|
1215
1363
|
dismissOnboarding: (state: State) => State;
|
|
1216
1364
|
clearPersonal: (state: State) => State;
|
|
1365
|
+
memberProgramsLoaded: (state: State, arg: { generation: number; rows: MemberProgramCard[]; attempt?: number }) => State | undefined;
|
|
1217
1366
|
openShopCategory: (state: State, arg?: unknown) => State;
|
|
1218
1367
|
personalLoaded: (state: State, arg: { generation: number; rows: MemberBookingRow[]; progress: { label: string; fraction: number }; error: boolean; weekly: { count: number; fraction: number; label: string } | null }) => State;
|
|
1219
1368
|
categoriesLoaded: (state: State, arg: { generation: number; categories: StoreCategoryCard[] }) => State | undefined;
|
|
@@ -1221,10 +1370,27 @@ const svc = defineService<State, {
|
|
|
1221
1370
|
state: baseState(),
|
|
1222
1371
|
// The host's site is read fresh at every boot: a snapshot of the last session's site must not
|
|
1223
1372
|
// decide this one's verticals or name (an owner installs or removes one; another site opens).
|
|
1224
|
-
notHydrated: ["hosted", "hostedName", "hostedCards", "homeCategories", "noHomeCategories"],
|
|
1373
|
+
notHydrated: ["hosted", "hostedName", "hostedCards", "homeCategories", "noHomeCategories", "memberPrograms", "noMemberPrograms"],
|
|
1374
|
+
// A member's standing never reaches the on-device snapshot either: the next boot may be another member's.
|
|
1375
|
+
notPersisted: ["memberPrograms", "noMemberPrograms"],
|
|
1225
1376
|
actions: {
|
|
1226
|
-
clearPersonal: state => derive({ ...state, nextBooking: [], hideNextBooking: true, memberProgressLabel: "", memberProgressFraction: 0, hideMemberProgress: true, personalError: false, hidePersonalError: true,
|
|
1377
|
+
clearPersonal: state => derive({ ...state, nextBooking: [], hideNextBooking: true, memberProgressLabel: "", memberProgressFraction: 0, hideMemberProgress: true, memberPrograms: [], noMemberPrograms: true, personalError: false, hidePersonalError: true,
|
|
1227
1378
|
weeklyClassCount: 0, weeklyClassGoalFraction: 0, weeklyGoalLabel: "" }),
|
|
1379
|
+
// The member read starts inside `refresh` and, for a visitor (identity answers synchronously),
|
|
1380
|
+
// resolves within the same microtask turn — BEFORE the runtime has applied refresh's own result.
|
|
1381
|
+
// The `state` handed here is then refresh's PRE-state (status "loading", every rail empty), and
|
|
1382
|
+
// spreading it wrote a loading home over the ready one; the description-projection test caught
|
|
1383
|
+
// exactly that. So an early arrival re-queues itself (a microtask at a time, bounded) until the
|
|
1384
|
+
// state it would spread is the applied one. A stale generation keeps the current state
|
|
1385
|
+
// (`undefined`), never its own stale snapshot.
|
|
1386
|
+
memberProgramsLoaded: (state, arg) => {
|
|
1387
|
+
if (arg.generation !== refreshGen) return undefined;
|
|
1388
|
+
if (state.status === "loading" && (arg.attempt ?? 0) < MEMBER_PROGRAMS_APPLY_RETRIES) {
|
|
1389
|
+
void Promise.resolve().then(() => svc.memberProgramsLoaded({ ...arg, attempt: (arg.attempt ?? 0) + 1 }));
|
|
1390
|
+
return undefined;
|
|
1391
|
+
}
|
|
1392
|
+
return derive({ ...state, memberPrograms: arg.rows, noMemberPrograms: arg.rows.length === 0 });
|
|
1393
|
+
},
|
|
1228
1394
|
openShopCategory: (state, arg) => {
|
|
1229
1395
|
const category = String((arg as { category?: unknown } | undefined)?.category ?? "");
|
|
1230
1396
|
if (!category) return state;
|
|
@@ -35,11 +35,14 @@ export interface LoadFlags {
|
|
|
35
35
|
notMock: boolean;
|
|
36
36
|
notMockEmpty: boolean;
|
|
37
37
|
notMockUnavailable: boolean;
|
|
38
|
+
/** hiddenWhen gate for the empty state: false ONLY when the site answered with no rows
|
|
39
|
+
* (`notMockEmpty` is true then — it gates the sample banner, not the empty state). */
|
|
40
|
+
hideEmpty: boolean;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export const LOADING: LoadFlags = {
|
|
41
44
|
status: "loading", error: "", signInRequired: false, notErrored: true, notLoading: false, busy: true, noSignIn: true,
|
|
42
|
-
isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true,
|
|
45
|
+
isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true, hideEmpty: true,
|
|
43
46
|
};
|
|
44
47
|
|
|
45
48
|
const READY: LoadFlags = { ...LOADING, status: "ready", notLoading: true, busy: false };
|
|
@@ -78,7 +81,7 @@ type Rows<K extends string, T> = { [P in K]: T[] } & { emptyText: string } & Loa
|
|
|
78
81
|
export function loaded<K extends string, T>(key: K, rows: T[], copy: LoadCopy<T>): Rows<K, T> {
|
|
79
82
|
if (rows.length) return { ...READY, [key]: rows, emptyText: "" } as Rows<K, T>;
|
|
80
83
|
if (samplesRequested()) return sampleRows(key, copy, "empty");
|
|
81
|
-
return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty) } as Rows<K, T>;
|
|
84
|
+
return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty), hideEmpty: false } as Rows<K, T>;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/** A query threw. Never rows the customer did not create. */
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { dispatch } from "@himalaya/dispatch";
|
|
2
2
|
// Relative for node resolution (see himi-store.ts note); rewritten off-repo.
|
|
3
3
|
import { displayName, getMyData, initials, type WixMyData } from "@himalaya/wix-users";
|
|
4
|
+
import { getMyMember } from "@himalaya/wix-members";
|
|
5
|
+
import { wixMediaUrl } from "@himalaya/wix-media";
|
|
4
6
|
import { formatDateMedium, formatWeekdayDateTime } from "../_shared/engine-intl.js";
|
|
5
7
|
|
|
6
8
|
export interface AccountProfile {
|
|
@@ -82,6 +84,31 @@ function profileFromData(data: WixMyData, fallbackEmail = ""): AccountProfile {
|
|
|
82
84
|
};
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/** The signed-in site member's name and photo from their member record, or undefined when it
|
|
88
|
+
* cannot be read or names nobody (the caller then shows the email). */
|
|
89
|
+
async function siteMemberProfile(email: string): Promise<AccountProfile | undefined> {
|
|
90
|
+
try {
|
|
91
|
+
const member = (await getMyMember({ fieldsets: ["FULL"] }))?.member;
|
|
92
|
+
const name = [member?.contact?.firstName, member?.contact?.lastName].map((part) => (part ?? "").trim()).filter(Boolean).join(" ")
|
|
93
|
+
|| (member?.profile?.nickname ?? "").trim();
|
|
94
|
+
if (!name) return undefined;
|
|
95
|
+
const image = wixMediaUrl(member?.profile?.photo?.url) || wixMediaUrl(member?.profile?.photo?.id) || "";
|
|
96
|
+
return {
|
|
97
|
+
isMember: true,
|
|
98
|
+
identityLine: "Signed in",
|
|
99
|
+
detailLine: email || name,
|
|
100
|
+
memberName: name,
|
|
101
|
+
memberEmail: email || member?.loginEmail || "",
|
|
102
|
+
memberImage: image,
|
|
103
|
+
memberInitials: initialsFromName(name) || "M",
|
|
104
|
+
hasMemberImage: !!image,
|
|
105
|
+
noMemberImage: !image,
|
|
106
|
+
};
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
export function guestProfile(detailLine: string): AccountProfile {
|
|
86
113
|
return {
|
|
87
114
|
isMember: false,
|
|
@@ -118,6 +145,10 @@ export async function loadMemberProfile(guestDetailLine: string): Promise<Accoun
|
|
|
118
145
|
const data = await getMyData();
|
|
119
146
|
return profileFromData(data, storedEmail);
|
|
120
147
|
} catch {
|
|
148
|
+
// The account profile is an owner endpoint: the web host refuses it, and a site member need
|
|
149
|
+
// not have one. The member's own record (the Members API) still names them.
|
|
150
|
+
const member = await siteMemberProfile(storedEmail);
|
|
151
|
+
if (member) return member;
|
|
121
152
|
return {
|
|
122
153
|
isMember: true,
|
|
123
154
|
identityLine: "Signed in",
|