@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.
Files changed (130) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/ui/src/index.d.ts +53 -13
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +3 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +31 -5
  4. package/dist/cli.mjs +1638 -1330
  5. package/dist/preview-assets.generated.bin +0 -0
  6. package/fixtures/wix-canned.json +4 -0
  7. package/guides/component-reference.json +1 -1
  8. package/guides/component-reference.md +3 -0
  9. package/guides/designing-himi-screens.md +4 -1
  10. package/guides/hhp.md +2 -2
  11. package/guides/sdk-reference.json +28 -4
  12. package/package.json +2 -2
  13. package/template-icons/salon-spa.png +0 -0
  14. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  15. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  16. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  17. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  18. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  19. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  20. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  21. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +178 -12
  22. package/templates/branded-lite/dev/index.ts +192 -0
  23. package/templates/branded-lite/l10n/en.json +1 -1
  24. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  25. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  26. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  27. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  28. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  29. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  30. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  31. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  32. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
  33. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  34. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  35. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
  36. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
  39. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  40. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  41. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  42. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  43. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  44. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  45. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  46. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
  47. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  48. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  49. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
  50. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  51. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  52. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
  53. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  54. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  55. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
  56. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  57. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  58. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
  59. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  60. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  61. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
  62. package/templates/salon-spa/README.md +53 -0
  63. package/templates/salon-spa/config.ts +272 -0
  64. package/templates/salon-spa/dev/index.ts +19893 -0
  65. package/templates/salon-spa/himalaya.content.json +49 -0
  66. package/templates/salon-spa/himalaya.template.json +14 -0
  67. package/templates/salon-spa/icon.png +0 -0
  68. package/templates/salon-spa/platforms.json +1 -0
  69. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  70. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  71. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  72. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  73. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  74. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  75. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  76. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  77. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  78. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  79. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  80. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  81. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  82. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  83. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  84. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  85. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  86. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  87. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  88. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  89. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  90. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  91. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  92. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  93. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  94. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  95. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  96. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  97. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  98. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  99. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  100. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  101. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  102. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  103. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  104. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  105. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  106. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  107. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  108. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  109. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  110. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  111. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  112. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  113. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  114. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  115. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  116. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  117. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  118. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  119. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  120. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  121. package/templates/salon-spa/tokens.json +341 -0
  122. package/templates/yoga-studio/dev/index.ts +192 -0
  123. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  124. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  125. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  126. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  127. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  128. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  129. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  130. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
@@ -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 { queryPrograms, type WixProgram } from "@himalaya/wix-online-programs";
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
- return (response.programs ?? [])
702
+ const programs = (response.programs ?? [])
677
703
  .filter((p: WixProgram) => ((p.state ?? p.status ?? "").toUpperCase()) !== "DRAFT")
678
- .slice(0, 6)
679
- .map((p: WixProgram) => {
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) => { extras.homePrograms = 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, nextBooking: current.nextBooking, hideNextBooking: current.hideNextBooking,
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;
@@ -0,0 +1,53 @@
1
+ # __HIMI_APP_NAME__ — from the `salon-spa` template
2
+
3
+ A beauty salon / aesthetics clinic / day spa app over a real Wix site: a home with the signature-treatment rail, categories, an offer and the team; Categories with search; the shared Branded Lite booking flow in salon words — treatments by category, a treatment with a favorites heart, times by staff with checkout for paid treatments, My Bookings; Favorites; Our space (Pro Gallery); Message the salon (the Wix Inbox chat); About & visit. Boots as a VISITOR (the treatment menu paints with no login); booking, My Bookings and the chat open Branded Lite's sign-in form as a sheet.
4
+
5
+ Everything here is now yours to edit — screens in `dev/`, logic in `tier5-src/`,
6
+ identity and navigation in `config.ts`, look and feel in `tokens.json`.
7
+
8
+ ## Point at your Wix site
9
+
10
+ Your **Wix Headless `clientId` binds the data** — the visitor token minted from it already
11
+ names your site, so reads need nothing else. Set these before `himi build` / preview so calls
12
+ hit YOUR site (the placeholders are not a real backend):
13
+
14
+ export WIX_CLIENT_ID="<headless-oauth-client-id>"
15
+ export WIX_SITE_URL="https://your-site.wixsite.com/…"
16
+ export WIX_SITE_ID="<metasite-id>"
17
+
18
+ `WIX_SITE_ID` is the site that client belongs to. The member login takes it from the environment
19
+ while `WIX_CLIENT_ID` is the client the config uses. A native build needs it for member sign-up on
20
+ a CAPTCHA-protected site, push registration and in-app purchases.
21
+
22
+ Full details, including how to ship a binary a customer can install against their own
23
+ site: `himi docs branded-lite-site-binding`.
24
+
25
+ ## The loop
26
+
27
+ himi build # compile tier5-src/* -> dist/bundles
28
+ himi validate # screens, bindings and worker boot
29
+ himi render <screenId> --tree # render a screen right here, no device needed
30
+ himi test # exercise every action your screens declare
31
+ himi login --browser --target wix # sign in with your Wix account (once)
32
+ # no browser? export WIX_API_KEY, then himi login --wix --target wix
33
+ himi push --draft # publish a DRAFT release + get preview links
34
+
35
+ Build, validate, render and test run entirely on your machine. Only `login` and
36
+ `push` reach Wix. No per-app install is needed: the CLI ships the `@wix/himalaya`
37
+ runtime it compiles against.
38
+
39
+ ## Pattern index — where to look for each recipe
40
+
41
+ | Pattern | Screens |
42
+ |---|---|
43
+ | Data-derived categories (grid of round tiles) | salon_home categories, salon_treatments |
44
+ | Category deep link (an anchor into a shared list) | category tile → himi_services (category nav param) |
45
+ | Horizontal rails (bound LazyList) | salon_home signature treatments, team |
46
+ | Reusing a shared screen in your own words (stock-string copy rewrite) | himi_services, himi_service, himi_time, himi_bookings |
47
+ | Favorites on device (a heart on a shared detail) | himi_service heart → salon_favorites |
48
+ | Four-state screens (loading/empty/error/loaded) | every list screen |
49
+ | Gateway Pro Gallery covers + photo grid | himi_galleries → himi_gallery |
50
+ | Member chat with the business (Wix Inbox) | himi_business_chat |
51
+
52
+ Guides: `himi docs recipe-dynamic-list`, `himi docs tier-5-add-a-screen`,
53
+ `himi docs designing-himi-screens`. Seed references: `himi agent-init`.
@@ -0,0 +1,272 @@
1
+ // Your app's configuration — identity, auth, navigation, capabilities and tokens.
2
+ // Inspect the live values any time with `himi context`; see `himi docs app-config`.
3
+ import { defineApp } from "@himalaya/core";
4
+ import { WixConsumerAuthProvider } from "@himalaya/wix-auth";
5
+ import { ConsoleTelemetrySink } from "@himalaya/telemetry-console";
6
+
7
+ export default defineApp({
8
+ id: "__HIMI_APP_DOTID__",
9
+ name: "__HIMI_APP_NAME__",
10
+ bundleId: "__HIMI_APP_DOTID__",
11
+ applicationId: "__HIMI_APP_DOTID__",
12
+ // Placeholders — an over-the-air app never consumes these. Leave as-is.
13
+ schemaPackage: "@example/__HIMI_APP_SLUG__-schema",
14
+ schemaPackageRange: { min: 9900, max: 9909 },
15
+ // Placeholders — only a local server would read these. Leave as-is.
16
+ ports: { devServer: 8190, web: 5390 },
17
+ auth: WixConsumerAuthProvider({
18
+ // POINT THESE AT YOUR SITE — set WIX_CLIENT_ID (a public headless OAuth client id)
19
+ // and WIX_SITE_URL (your live site URL). The placeholders below are NOT a real
20
+ // backend; the app boots but reads nothing until you set them. The site needs
21
+ // Wix Bookings; Pro Gallery and the Wix Inbox chat are optional.
22
+ clientId: process.env.WIX_CLIENT_ID ?? "YOUR_WIX_CLIENT_ID",
23
+ siteUrl: process.env.WIX_SITE_URL ?? "https://your-site.example.com",
24
+ // The OAuth redirect must be ALLOWLISTED on the headless client in `clientId`.
25
+ redirectUri: process.env.WIX_REDIRECT_URI ?? "himalaya-__HIMI_APP_SLUG__://oauth/callback",
26
+ bootAsVisitor: true,
27
+ preserveVisitorOnSignOut: true,
28
+ }),
29
+ telemetry: ConsoleTelemetrySink(),
30
+ serverUrls: {
31
+ screen: "http://localhost:8190/v1",
32
+ actions: "http://localhost:8190/v1/actions",
33
+ sync: "http://localhost:8190/v1/sync",
34
+ },
35
+ capabilities: ["wix_api","local_database","open_url","share","secure_storage","wix_platform","app_info","wix_chat","calendar","iap","wix_cashier"],
36
+ designTokensPath: "./tokens.json",
37
+ navigation: {
38
+ "navKind": "responsive",
39
+ "scheme": "himalaya-__HIMI_APP_SLUG__",
40
+ "deepLinkPatterns": [],
41
+ "linkableScreens": [
42
+ {
43
+ "screenId": "salon_home",
44
+ "isPublic": true,
45
+ "paramKeys": [],
46
+ "parentScreenId": "",
47
+ "tabId": "home"
48
+ },
49
+ {
50
+ "screenId": "salon_treatments",
51
+ "isPublic": true,
52
+ "paramKeys": [],
53
+ "parentScreenId": "salon_home",
54
+ "tabId": "home"
55
+ },
56
+ {
57
+ "screenId": "himi_services",
58
+ "isPublic": true,
59
+ "paramKeys": [
60
+ "category",
61
+ "categoryActivation"
62
+ ],
63
+ "parentScreenId": "salon_home",
64
+ "tabId": "home"
65
+ },
66
+ {
67
+ "screenId": "himi_service",
68
+ "isPublic": true,
69
+ "paramKeys": [
70
+ "id",
71
+ "sessionId",
72
+ "start"
73
+ ],
74
+ "parentScreenId": "himi_services",
75
+ "tabId": "home"
76
+ },
77
+ {
78
+ "screenId": "himi_time",
79
+ "isPublic": true,
80
+ "paramKeys": [
81
+ "id",
82
+ "course",
83
+ "start",
84
+ "sessionId"
85
+ ],
86
+ "parentScreenId": "himi_service",
87
+ "tabId": "home"
88
+ },
89
+ {
90
+ "screenId": "himi_agenda",
91
+ "isPublic": true,
92
+ "paramKeys": [],
93
+ "parentScreenId": "himi_services",
94
+ "tabId": "home"
95
+ },
96
+ {
97
+ "screenId": "himi_galleries",
98
+ "isPublic": true,
99
+ "paramKeys": [],
100
+ "parentScreenId": "salon_home",
101
+ "tabId": "home"
102
+ },
103
+ {
104
+ "screenId": "himi_gallery",
105
+ "isPublic": true,
106
+ "paramKeys": [
107
+ "id",
108
+ "name",
109
+ "subtitle",
110
+ "source"
111
+ ],
112
+ "parentScreenId": "himi_galleries",
113
+ "tabId": "home"
114
+ },
115
+ {
116
+ "screenId": "salon_about",
117
+ "isPublic": true,
118
+ "paramKeys": [],
119
+ "parentScreenId": "salon_home",
120
+ "tabId": "home"
121
+ },
122
+ {
123
+ "screenId": "himi_bookings",
124
+ "isPublic": false,
125
+ "paramKeys": [],
126
+ "parentScreenId": "",
127
+ "tabId": "bookings"
128
+ },
129
+ {
130
+ "screenId": "salon_favorites",
131
+ "isPublic": true,
132
+ "paramKeys": [],
133
+ "parentScreenId": "",
134
+ "tabId": "favorites"
135
+ },
136
+ {
137
+ "screenId": "himi_account",
138
+ "isPublic": true,
139
+ "paramKeys": [],
140
+ "parentScreenId": "",
141
+ "tabId": "profile"
142
+ },
143
+ {
144
+ "screenId": "himi_sign_in",
145
+ "isPublic": true,
146
+ "paramKeys": [
147
+ "mode",
148
+ "intent",
149
+ "modal",
150
+ "returnTab",
151
+ "contextName",
152
+ "returnScreenId",
153
+ "returnId",
154
+ "returnChannelId",
155
+ "authContinuation"
156
+ ],
157
+ "parentScreenId": "himi_account",
158
+ "tabId": "profile"
159
+ },
160
+ {
161
+ "screenId": "himi_business_chat",
162
+ "isPublic": false,
163
+ "paramKeys": [],
164
+ "parentScreenId": "salon_home",
165
+ "tabId": "home"
166
+ }
167
+ ],
168
+ "tabBar": {
169
+ "presentation": "native",
170
+ "items": [
171
+ {
172
+ "id": "home",
173
+ "screenId": "salon_home",
174
+ "label": "Home",
175
+ "icon": "home",
176
+ "selectedIcon": "home_fill"
177
+ },
178
+ {
179
+ "id": "bookings",
180
+ "screenId": "himi_bookings",
181
+ "label": "Bookings",
182
+ "icon": "calendar"
183
+ },
184
+ {
185
+ "id": "favorites",
186
+ "screenId": "salon_favorites",
187
+ "label": "Favorites",
188
+ "icon": "heart",
189
+ "selectedIcon": "heart_fill"
190
+ },
191
+ {
192
+ "id": "profile",
193
+ "screenId": "himi_account",
194
+ "label": "Profile",
195
+ "icon": "person",
196
+ "selectedIcon": "person_fill"
197
+ }
198
+ ]
199
+ }
200
+ },
201
+ // This app talks to a language model (dual LLM / remote model).
202
+ agent: {
203
+ "onDevice": {
204
+ "ios": "foundation_models",
205
+ "android": "mediapipe"
206
+ },
207
+ "remote": {
208
+ "providerId": "anthropic",
209
+ "config": {
210
+ "model": "claude-haiku-4-5"
211
+ }
212
+ },
213
+ "privateModeToggle": false
214
+ },
215
+ entities: {},
216
+ // Your on-device database. It must declare every entity a worker reads or writes,
217
+ // or those calls fail closed — keep it in step with `local_database` above.
218
+ dataSchema: {
219
+ "version": 1,
220
+ "entities": [
221
+ {
222
+ "name": "services",
223
+ "encrypted": false,
224
+ "fields": [
225
+ {
226
+ "name": "id",
227
+ "type": "string",
228
+ "required": true
229
+ },
230
+ {
231
+ "name": "name",
232
+ "type": "string"
233
+ }
234
+ ]
235
+ },
236
+ {
237
+ "name": "booking_prefs",
238
+ "encrypted": false,
239
+ "fields": [
240
+ {
241
+ "name": "id",
242
+ "type": "string",
243
+ "required": true
244
+ },
245
+ {
246
+ "name": "locationId",
247
+ "type": "string"
248
+ },
249
+ {
250
+ "name": "locationLabel",
251
+ "type": "string"
252
+ }
253
+ ]
254
+ },
255
+ {
256
+ "name": "member_bookings",
257
+ "encrypted": true,
258
+ "fields": [
259
+ {
260
+ "name": "id",
261
+ "type": "string",
262
+ "required": true
263
+ },
264
+ {
265
+ "name": "serviceId",
266
+ "type": "string"
267
+ }
268
+ ]
269
+ }
270
+ ]
271
+ },
272
+ });