@wix/himalaya-cli 0.1614.0 → 0.1615.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/cli.mjs +1 -0
  2. package/package.json +2 -2
  3. package/template-icons/salon-spa.png +0 -0
  4. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  5. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  6. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  7. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  8. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  9. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  10. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  11. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  12. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  13. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  14. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  15. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  16. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  18. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  19. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  20. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  21. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  22. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  23. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  24. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  25. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  26. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  27. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  28. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  29. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  30. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  31. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  32. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  33. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  34. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  35. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  36. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  39. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  40. package/templates/salon-spa/README.md +53 -0
  41. package/templates/salon-spa/config.ts +272 -0
  42. package/templates/salon-spa/dev/index.ts +19893 -0
  43. package/templates/salon-spa/himalaya.content.json +49 -0
  44. package/templates/salon-spa/himalaya.template.json +14 -0
  45. package/templates/salon-spa/icon.png +0 -0
  46. package/templates/salon-spa/platforms.json +1 -0
  47. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  48. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  49. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  50. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  51. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  52. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  53. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  54. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  55. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  56. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  57. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  58. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  59. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  60. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  61. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  62. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  63. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  64. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  65. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  66. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  67. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  68. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  69. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  70. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  71. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  72. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  73. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  74. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  75. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  76. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  77. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  78. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  79. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  80. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  81. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  82. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  83. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  84. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  85. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  86. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  87. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  88. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  89. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  90. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  91. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  92. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  93. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  94. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  95. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  96. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  97. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  98. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  99. package/templates/salon-spa/tokens.json +341 -0
  100. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  101. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  102. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  103. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  104. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  105. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  106. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
@@ -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. A name search
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
- return { slotId, signInScreenId: typeof a.signInScreenId === "string" ? a.signInScreenId : "" };
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
- if (!s.isMember) {
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
- return signInScreenId ? navigate(selected, { screenId: signInScreenId, args: { mode: "signin", intent: "book" } }) : selected;
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: "himi_sign_in", presentation: signInPresentation, args: { mode: "signin", modal: "1" } }
882
- : { screenId: "himi_sign_in", args: { mode: "signin", returnScreenId: "himi_agenda" } });
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);
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",