@wix/himalaya-cli 0.1568.0 → 0.1570.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 (168) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/l10n-strings.d.ts +3 -2
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/screens/src/business-contact-service.d.ts +17 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/screens/src/business-contact.d.ts +73 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/screens/src/index.d.ts +3 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/screens/src/invite-target.d.ts +1 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/screens/src/invite.d.ts +148 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +558 -84
  8. package/dist/cli.mjs +21211 -20843
  9. package/dist/preview-assets.generated.bin +0 -0
  10. package/guides/app-config.md +10 -0
  11. package/guides/app-icon.md +20 -1
  12. package/guides/getting-started.md +5 -0
  13. package/guides/himi-json-output.md +7 -0
  14. package/guides/himi-skill.md +5 -0
  15. package/guides/one-app-to-himi-app.md +159 -4
  16. package/guides/skill.md +5 -0
  17. package/guides/splash-screens.md +156 -5
  18. package/guides/tier-5-add-a-screen.md +20 -0
  19. package/package.json +2 -2
  20. package/templates/beauty-salon/config.ts +17 -0
  21. package/templates/beauty-salon/dev/index.ts +478 -0
  22. package/templates/beauty-salon/himalaya.content.json +6 -0
  23. package/templates/beauty-salon/splash.json +3 -0
  24. package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +17 -6
  25. package/templates/beauty-salon/tier5-src/_shared/rich-body.ts +44 -0
  26. package/templates/beauty-salon/tier5-src/business-contact/index.ts +6 -0
  27. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +13 -9
  28. package/templates/beauty-salon/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  29. package/templates/beauty-salon-owner/config.ts +8 -0
  30. package/templates/beauty-salon-owner/dev/index.ts +1 -0
  31. package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +17 -6
  32. package/templates/beauty-salon-owner/tier5-src/_shared/rich-body.ts +44 -0
  33. package/templates/branded-lite/config.ts +17 -0
  34. package/templates/branded-lite/dev/index.ts +541 -1
  35. package/templates/branded-lite/himalaya.content.json +6 -0
  36. package/templates/branded-lite/splash.json +3 -0
  37. package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +17 -6
  38. package/templates/branded-lite/tier5-src/_shared/rich-body.ts +44 -0
  39. package/templates/branded-lite/tier5-src/business-contact/index.ts +6 -0
  40. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +13 -9
  41. package/templates/branded-lite/tier5-src/himi-branded-lite-group/index.ts +20 -3
  42. package/templates/branded-lite/tier5-src/himi-branded-lite-groups/index.ts +18 -2
  43. package/templates/branded-lite/tier5-src/himi-branded-lite-notifsettings/index.ts +39 -11
  44. package/templates/branded-lite/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  45. package/templates/brandy/tier5-src/_shared/himi-copy.ts +17 -6
  46. package/templates/community-club/config.ts +17 -0
  47. package/templates/community-club/dev/index.ts +480 -1
  48. package/templates/community-club/himalaya.content.json +6 -0
  49. package/templates/community-club/splash.json +3 -0
  50. package/templates/community-club/tier5-src/_shared/himi-copy.ts +17 -6
  51. package/templates/community-club/tier5-src/_shared/rich-body.ts +44 -0
  52. package/templates/community-club/tier5-src/business-contact/index.ts +6 -0
  53. package/templates/community-club/tier5-src/himi-branded-lite-group/index.ts +20 -3
  54. package/templates/community-club/tier5-src/himi-branded-lite-groups/index.ts +18 -2
  55. package/templates/community-club/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  56. package/templates/community-club-owner/config.ts +8 -0
  57. package/templates/community-club-owner/dev/index.ts +1 -0
  58. package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +17 -6
  59. package/templates/community-club-owner/tier5-src/_shared/rich-body.ts +44 -0
  60. package/templates/events-meetups/config.ts +17 -0
  61. package/templates/events-meetups/dev/index.ts +480 -1
  62. package/templates/events-meetups/himalaya.content.json +6 -0
  63. package/templates/events-meetups/splash.json +3 -0
  64. package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +17 -6
  65. package/templates/events-meetups/tier5-src/_shared/rich-body.ts +44 -0
  66. package/templates/events-meetups/tier5-src/business-contact/index.ts +6 -0
  67. package/templates/events-meetups/tier5-src/himi-branded-lite-group/index.ts +20 -3
  68. package/templates/events-meetups/tier5-src/himi-branded-lite-groups/index.ts +18 -2
  69. package/templates/events-meetups/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  70. package/templates/events-meetups-owner/config.ts +8 -0
  71. package/templates/events-meetups-owner/dev/index.ts +1 -0
  72. package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +17 -6
  73. package/templates/events-meetups-owner/tier5-src/_shared/rich-body.ts +44 -0
  74. package/templates/gym-fitness/config.ts +17 -0
  75. package/templates/gym-fitness/dev/index.ts +478 -0
  76. package/templates/gym-fitness/himalaya.content.json +6 -0
  77. package/templates/gym-fitness/splash.json +3 -0
  78. package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +17 -6
  79. package/templates/gym-fitness/tier5-src/_shared/rich-body.ts +44 -0
  80. package/templates/gym-fitness/tier5-src/business-contact/index.ts +6 -0
  81. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +13 -9
  82. package/templates/gym-fitness/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  83. package/templates/gym-fitness-owner/config.ts +8 -0
  84. package/templates/gym-fitness-owner/dev/index.ts +1 -0
  85. package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +17 -6
  86. package/templates/gym-fitness-owner/tier5-src/_shared/rich-body.ts +44 -0
  87. package/templates/himi-chat/dev/index.ts +1 -0
  88. package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +17 -6
  89. package/templates/nonprofit-cause/config.ts +17 -0
  90. package/templates/nonprofit-cause/dev/index.ts +480 -1
  91. package/templates/nonprofit-cause/himalaya.content.json +6 -0
  92. package/templates/nonprofit-cause/splash.json +3 -0
  93. package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +17 -6
  94. package/templates/nonprofit-cause/tier5-src/_shared/rich-body.ts +44 -0
  95. package/templates/nonprofit-cause/tier5-src/business-contact/index.ts +6 -0
  96. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-group/index.ts +20 -3
  97. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-groups/index.ts +18 -2
  98. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  99. package/templates/nonprofit-cause-owner/config.ts +8 -0
  100. package/templates/nonprofit-cause-owner/dev/index.ts +1 -0
  101. package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +17 -6
  102. package/templates/nonprofit-cause-owner/tier5-src/_shared/rich-body.ts +44 -0
  103. package/templates/owner-lite/config.ts +8 -0
  104. package/templates/owner-lite/dev/index.ts +1 -0
  105. package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +17 -6
  106. package/templates/owner-lite/tier5-src/_shared/rich-body.ts +44 -0
  107. package/templates/podcast-creator/config.ts +17 -0
  108. package/templates/podcast-creator/dev/index.ts +450 -0
  109. package/templates/podcast-creator/himalaya.content.json +6 -0
  110. package/templates/podcast-creator/splash.json +3 -0
  111. package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +17 -6
  112. package/templates/podcast-creator/tier5-src/_shared/rich-body.ts +44 -0
  113. package/templates/podcast-creator/tier5-src/business-contact/index.ts +6 -0
  114. package/templates/podcast-creator/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  115. package/templates/podcast-creator-owner/config.ts +8 -0
  116. package/templates/podcast-creator-owner/dev/index.ts +1 -0
  117. package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +17 -6
  118. package/templates/podcast-creator-owner/tier5-src/_shared/rich-body.ts +44 -0
  119. package/templates/portfolio-creative/config.ts +17 -0
  120. package/templates/portfolio-creative/dev/index.ts +450 -0
  121. package/templates/portfolio-creative/himalaya.content.json +6 -0
  122. package/templates/portfolio-creative/splash.json +3 -0
  123. package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +17 -6
  124. package/templates/portfolio-creative/tier5-src/_shared/rich-body.ts +44 -0
  125. package/templates/portfolio-creative/tier5-src/business-contact/index.ts +6 -0
  126. package/templates/portfolio-creative/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  127. package/templates/portfolio-creative-owner/config.ts +8 -0
  128. package/templates/portfolio-creative-owner/dev/index.ts +1 -0
  129. package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +17 -6
  130. package/templates/portfolio-creative-owner/tier5-src/_shared/rich-body.ts +44 -0
  131. package/templates/restaurant/config.ts +17 -0
  132. package/templates/restaurant/dev/index.ts +450 -0
  133. package/templates/restaurant/himalaya.content.json +6 -0
  134. package/templates/restaurant/splash.json +3 -0
  135. package/templates/restaurant/tier5-src/_shared/himi-copy.ts +17 -6
  136. package/templates/restaurant/tier5-src/_shared/rich-body.ts +44 -0
  137. package/templates/restaurant/tier5-src/business-contact/index.ts +6 -0
  138. package/templates/restaurant/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  139. package/templates/restaurant-owner/config.ts +8 -0
  140. package/templates/restaurant-owner/dev/index.ts +1 -0
  141. package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +17 -6
  142. package/templates/restaurant-owner/tier5-src/_shared/rich-body.ts +44 -0
  143. package/templates/retail-store/config.ts +17 -0
  144. package/templates/retail-store/dev/index.ts +450 -0
  145. package/templates/retail-store/himalaya.content.json +6 -0
  146. package/templates/retail-store/splash.json +3 -0
  147. package/templates/retail-store/tier5-src/_shared/himi-copy.ts +17 -6
  148. package/templates/retail-store/tier5-src/_shared/rich-body.ts +44 -0
  149. package/templates/retail-store/tier5-src/business-contact/index.ts +6 -0
  150. package/templates/retail-store/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  151. package/templates/retail-store-owner/config.ts +8 -0
  152. package/templates/retail-store-owner/dev/index.ts +1 -0
  153. package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +17 -6
  154. package/templates/retail-store-owner/tier5-src/_shared/rich-body.ts +44 -0
  155. package/templates/surface-showcase/dev/index.ts +1 -0
  156. package/templates/yoga-studio/config.ts +17 -0
  157. package/templates/yoga-studio/dev/index.ts +478 -0
  158. package/templates/yoga-studio/himalaya.content.json +6 -0
  159. package/templates/yoga-studio/splash.json +3 -0
  160. package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +17 -6
  161. package/templates/yoga-studio/tier5-src/_shared/rich-body.ts +44 -0
  162. package/templates/yoga-studio/tier5-src/business-contact/index.ts +6 -0
  163. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +13 -9
  164. package/templates/yoga-studio/tier5-src/himi-branded-lite-splash/index.ts +35 -0
  165. package/templates/yoga-studio-owner/config.ts +8 -0
  166. package/templates/yoga-studio-owner/dev/index.ts +1 -0
  167. package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +17 -6
  168. package/templates/yoga-studio-owner/tier5-src/_shared/rich-body.ts +44 -0
@@ -32,3 +32,47 @@ export function richBody(richContent: unknown, fallback = "", imageWidth = 1400)
32
32
  const text = rejected === "too_deep" ? "" : ricosToPlainText(doc);
33
33
  return { bodyJson: json, bodyText: text || fallback };
34
34
  }
35
+
36
+ /** A Wix description may be Ricos (including serialized JSON), HTML or ordinary text.
37
+ * Keep rich documents on the existing bounded encoder path. Non-document JSON and
38
+ * clearly malformed serialization use an independent fallback. Ambiguous bracketed
39
+ * prose stays readable; this does not attempt to repair arbitrary JSON syntax. */
40
+ export function descriptionBody(value: unknown, fallback = "", imageWidth = 1400): RichBody {
41
+ if (typeof value !== "string") return richBody(value, fallback, imageWidth);
42
+ const text = value.trim();
43
+ if (/^[{[]/.test(text)) {
44
+ try {
45
+ const parsed: unknown = JSON.parse(text);
46
+ // Passing the parsed value avoids parsing a valid Ricos document twice; a
47
+ // different JSON structure is rejected by the same existing document check.
48
+ if (parsed && typeof parsed === "object") return richBody(parsed, fallback, imageWidth);
49
+ } catch {
50
+ const objectLike = /^\{\s*(?:["'}]|nodes\b)/.test(text);
51
+ // Only reject scalar-looking malformed arrays when their syntax is clearly
52
+ // unfinished (or contains JS's non-JSON `undefined`). Balanced bracketed
53
+ // prose such as "[1,000]" and "[12:30, 13:30]" is not JSON and stays readable.
54
+ const arrayTokens = text.replace(/"(?:\\.|[^"\\])*"/g, "")
55
+ .replace(/\b(?:true|false|null|undefined)\b/g, "")
56
+ .replace(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g, "");
57
+ const arrayLike = /^\[\s*[{"]/.test(text)
58
+ || (text.startsWith("[") && /^[\s[\]{},:]*$/.test(arrayTokens)
59
+ && (/,\s*]?$/.test(text) || /\bundefined\b/.test(text) || !text.endsWith("]")));
60
+ if (objectLike || arrayLike) return { bodyJson: "", bodyText: fallback };
61
+ }
62
+ }
63
+ // Legacy HTML descriptions stay text. Preserve paragraph breaks and ordinary prose's
64
+ // bare '<' (e.g. "ages < 8"), without relying on browser-only DOM APIs in workers.
65
+ const plain = text
66
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "")
67
+ .replace(/<br\s*\/?\s*>/gi, "\n")
68
+ .replace(/<\/(?:p|div|h[1-6]|li)\s*>/gi, "\n\n")
69
+ .replace(/<\/?[a-z][^>]*>/gi, "")
70
+ .replace(/&(?:nbsp|amp|lt|gt|quot|apos|#39);/gi, (entity) => ({
71
+ "&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&apos;": "'", "&#39;": "'",
72
+ })[entity.toLowerCase()] ?? entity)
73
+ .replace(/[\t\r ]+/g, " ")
74
+ .replace(/ *\n */g, "\n")
75
+ .replace(/\n{3,}/g, "\n\n")
76
+ .trim();
77
+ return { bodyJson: "", bodyText: plain || fallback };
78
+ }
@@ -0,0 +1,6 @@
1
+ import { createBusinessContactService } from "@himalaya/screens";
2
+
3
+ // Contact values and the optional public total are build-time source evidence.
4
+ // No owner API, device location, or cross-site source URL is read by this worker.
5
+ const svc = createBusinessContactService();
6
+ export { svc };
@@ -9,6 +9,8 @@ import { stashCopy, copyParam, copyTemplate, copyPlural } from "../_shared/himi-
9
9
  import { get, type DbEntity } from "@himalaya/local-db";
10
10
  import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-media";
11
11
  import { formatClock, formatDateMedium, formatMonthDay } from "../_shared/engine-intl.js";
12
+ import { descriptionBody } from "../_shared/rich-body.js";
13
+ import { openRichLink } from "../_shared/rich-content-link.js";
12
14
 
13
15
  // Offline-first (data-offline): the service detail is a public read-through. On a
14
16
  // fetch failure we fall back to the last-synced row for this service written by
@@ -119,6 +121,8 @@ interface S {
119
121
  bookableOnline: boolean;
120
122
  notBookableOnline: boolean;
121
123
  about: string;
124
+ aboutJson: string;
125
+ hasAboutJson: boolean;
122
126
  noAbout: boolean;
123
127
  policyLines: PolicyLine[];
124
128
  noPolicy: boolean;
@@ -431,13 +435,6 @@ function serviceShareUrl(service: WixService): string {
431
435
  return "";
432
436
  }
433
437
 
434
- function plain(text: string | undefined): string {
435
- return stringOf(text)
436
- .replace(/<[^>]*>/g, " ")
437
- .replace(/\s+/g, " ")
438
- .trim();
439
- }
440
-
441
438
  // ---- Class and course ------------------------------------------------------------
442
439
 
443
440
  const DAY_MS = 86_400_000;
@@ -603,7 +600,7 @@ function derive(s: S): S {
603
600
  noCourseCtaFull: !s.courseCtaFull,
604
601
  noCourseCtaClosed: !s.courseCtaClosed,
605
602
  notBookableOnline: !s.bookableOnline,
606
- noAbout: !s.about,
603
+ noAbout: !s.about && !s.aboutJson,
607
604
  noPolicy: s.policyLines.length === 0,
608
605
  policyCollapsed: !s.policyExpanded,
609
606
  noFacts: !s.courseDates && !s.durationText && !s.spotsText && s.locations.length === 0 && !s.online && !s.staffText && !s.payText,
@@ -617,6 +614,7 @@ function derive(s: S): S {
617
614
  }
618
615
 
619
616
  function withService(s: S, service: WixService): S {
617
+ const body = descriptionBody(service.description);
620
618
  const serviceId = stringOf(service.id) || s.id;
621
619
  const name = stringOf(service.name) || "Service";
622
620
  const price = priceLine(service);
@@ -667,7 +665,9 @@ function withService(s: S, service: WixService): S {
667
665
  paySub: pay.sub,
668
666
  requestToBook: service.onlineBooking?.requireManualApproval === true,
669
667
  bookableOnline: service.onlineBooking?.enabled !== false,
670
- about: plain(service.description),
668
+ about: body.bodyText,
669
+ aboutJson: body.bodyJson,
670
+ hasAboutJson: body.bodyJson !== "",
671
671
  policyLines: policyLines(service.bookingPolicy, stringOf(service.type).toUpperCase()),
672
672
  shareUrl: serviceShareUrl(service),
673
673
  // WS-C — canonical param-less target; the descriptor's choose-time CTA
@@ -696,6 +696,7 @@ const id = inputId();
696
696
  const svc = defineService<S, {
697
697
  load: (s: S, arg?: unknown) => Promise<S>;
698
698
  share: (s: S) => Promise<S>;
699
+ openLink: (s: S, arg?: unknown) => Promise<undefined>;
699
700
  togglePolicy: (s: S) => S;
700
701
  toggleSchedule: (s: S) => S;
701
702
  openDirections: (s: S, arg?: unknown) => Promise<S>;
@@ -775,6 +776,8 @@ const svc = defineService<S, {
775
776
  bookableOnline: true,
776
777
  notBookableOnline: false,
777
778
  about: "",
779
+ aboutJson: "",
780
+ hasAboutJson: false,
778
781
  noAbout: true,
779
782
  policyLines: [],
780
783
  noPolicy: true,
@@ -824,6 +827,7 @@ const svc = defineService<S, {
824
827
  return derive({ ...start, status: "ready", errored: true, name: "" });
825
828
  }
826
829
  },
830
+ openLink: async (_state, arg) => { await openRichLink(arg); return undefined; },
827
831
  share: async (s) => {
828
832
  if (!s.name) return s;
829
833
  const text = [s.name, s.tagline].filter(Boolean).join(" — ");
@@ -9,6 +9,8 @@ import { WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
9
9
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
10
10
  import { get as dbGet, upsert as dbUpsert, type DbEntity } from "@himalaya/local-db";
11
11
  import { isOfflineFailure, writeMayHaveLanded } from "../_shared/owner-load-error";
12
+ import { descriptionBody } from "../_shared/rich-body.js";
13
+ import { openRichLink } from "../_shared/rich-content-link.js";
12
14
 
13
15
  interface MemberAvatar {
14
16
  initials: string;
@@ -42,7 +44,8 @@ async function cachePutMembership(state: State, joined: boolean): Promise<void>
42
44
  noImage: state.noImage,
43
45
  detailScreenId: "himi_group",
44
46
  };
45
- await dbUpsert(ENTITY, { ...existing, id: state.id, joined: joined ? "true" : "false" });
47
+ await dbUpsert(ENTITY, { ...existing, id: state.id, description: state.description,
48
+ descriptionJson: state.descriptionJson, joined: joined ? "true" : "false" });
46
49
  } catch { /* capability not granted */ }
47
50
  }
48
51
 
@@ -51,6 +54,8 @@ interface State {
51
54
  id: string;
52
55
  name: string;
53
56
  description: string;
57
+ descriptionJson: string;
58
+ hasDescriptionJson: boolean;
54
59
  privacy: string;
55
60
  memberCount: string;
56
61
  /** Member avatar strip (photo or initials per member) — best-effort: the
@@ -132,6 +137,8 @@ const initial: State = {
132
137
  id: "",
133
138
  name: "",
134
139
  description: "",
140
+ descriptionJson: "",
141
+ hasDescriptionJson: false,
135
142
  privacy: "",
136
143
  memberCount: "",
137
144
  memberAvatars: [],
@@ -162,6 +169,7 @@ const svc = defineService<State, {
162
169
  load: (state: State, arg?: unknown) => Promise<State>;
163
170
  join: (state: State) => Promise<State>;
164
171
  leave: (state: State) => Promise<State>;
172
+ openLink: (state: State, arg?: unknown) => Promise<undefined>;
165
173
  }>("himiBrandedGroup", {
166
174
  state: initial,
167
175
  actions: {
@@ -179,12 +187,16 @@ const svc = defineService<State, {
179
187
  const privacyLabel = privacy[0].toUpperCase() + privacy.slice(1);
180
188
  const signedIn = Boolean(await signedInEmail());
181
189
  const memberAvatars = await memberAvatarsOf(id);
190
+ const body = descriptionBody(group.description, descriptionBody(group.teaser,
191
+ copyParam("group.descriptionFallback", "Join this group to connect with other members.")).bodyText);
182
192
  const loaded: State = {
183
193
  ...state,
184
194
  status: "ready",
185
195
  id,
186
196
  name,
187
- description: group.description || group.teaser || copyParam("group.descriptionFallback", "Join this group to connect with other members."),
197
+ description: body.bodyText,
198
+ descriptionJson: body.bodyJson,
199
+ hasDescriptionJson: body.bodyJson !== "",
188
200
  privacy: privacyLabel,
189
201
  memberCount: count == null ? "" : `${count} ${count === 1 ? "member" : "members"}`,
190
202
  memberAvatars,
@@ -209,12 +221,16 @@ const svc = defineService<State, {
209
221
  const name = (String(cached.title ?? cached.name ?? "Group")).trim() || "Group";
210
222
  const image = String(cached.image ?? "");
211
223
  const signedIn = Boolean(await signedInEmail());
224
+ const body = descriptionBody(cached.descriptionJson || cached.description || cached.subtitle,
225
+ copyParam("group.descriptionFallback", "Join this group to connect with other members."));
212
226
  const loaded: State = {
213
227
  ...state,
214
228
  status: "ready",
215
229
  id,
216
230
  name,
217
- description: String(cached.subtitle ?? cached.description ?? "Join this group to connect with other members."),
231
+ description: body.bodyText,
232
+ descriptionJson: body.bodyJson,
233
+ hasDescriptionJson: body.bodyJson !== "",
218
234
  privacy: "Unknown",
219
235
  memberCount: String(cached.meta ?? ""),
220
236
  image,
@@ -233,6 +249,7 @@ const svc = defineService<State, {
233
249
  return { ...state, id, status: "error", isLoading: false, notLoading: true, isErrored: true, notErrored: false, hideContent: true };
234
250
  }
235
251
  },
252
+ openLink: async (_state, arg) => { await openRichLink(arg); return undefined; },
236
253
  join: async (state) => {
237
254
  const signedIn = Boolean(await signedInEmail());
238
255
  if (!signedIn) return membershipState({ ...state, message: copyParam("group.joinSignInMessage", "Sign in from Profile to join groups.") }, false, false);
@@ -3,6 +3,7 @@ import { queryGroups, type WixGroup } from "@himalaya/wix-groups";
3
3
  import { groupImage } from "../_shared/himi-media.js";
4
4
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
5
5
  import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
6
+ import { descriptionBody, ROW_IMAGE_WIDTH } from "../_shared/rich-body.js";
6
7
 
7
8
  // Offline-first (data-offline): the groups list is a public read-through — paint the
8
9
  // last-synced rows from the on-device store first (instant, works offline), then
@@ -13,7 +14,11 @@ import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
13
14
  // seam that keeps this shared bundle safe across every consuming app.
14
15
  const ENTITY = "groups";
15
16
  async function readCached(): Promise<GroupRow[]> {
16
- try { return (await getAll(ENTITY)) as unknown as GroupRow[]; } catch { return []; }
17
+ try {
18
+ const rows = (await getAll(ENTITY)) as unknown as GroupRow[];
19
+ return rows.map((row) => ({ ...row, subtitle: snippet(descriptionBody(row.subtitle).bodyText
20
+ || descriptionBody(row.descriptionJson || row.description).bodyText) }));
21
+ } catch { return []; }
17
22
  }
18
23
  async function writeCached(rows: GroupRow[]): Promise<void> {
19
24
  try { for (const r of rows) await upsert(ENTITY, r as unknown as DbEntity); } catch { /* capability not granted → online-only */ }
@@ -23,6 +28,8 @@ interface GroupRow {
23
28
  id: string;
24
29
  title: string;
25
30
  subtitle: string;
31
+ description: string;
32
+ descriptionJson: string;
26
33
  meta: string;
27
34
  image: string;
28
35
  initial: string;
@@ -45,14 +52,23 @@ interface State {
45
52
  noEmpty: boolean;
46
53
  }
47
54
 
55
+ function snippet(value: string): string {
56
+ const text = value.replace(/\s+/g, " ");
57
+ return text.length > 140 ? `${text.slice(0, 139).trimEnd()}…` : text;
58
+ }
59
+
48
60
  function groupRow(group: WixGroup): GroupRow {
49
61
  const title = (group.name || copyParam("groups.groupFallbackName", "Group")).trim();
50
62
  const count = group.membersCount ?? group.memberCount;
51
63
  const image = groupImage(group);
64
+ const teaser = descriptionBody(group.teaser).bodyText;
65
+ const body = descriptionBody(group.description, teaser, ROW_IMAGE_WIDTH);
52
66
  return {
53
67
  id: group.id || title,
54
68
  title,
55
- subtitle: group.teaser || group.description || copyParam("groups.subtitleFallback", "Join the conversation."),
69
+ subtitle: snippet(teaser) || snippet(body.bodyText) || copyParam("groups.subtitleFallback", "Join the conversation."),
70
+ description: body.bodyText,
71
+ descriptionJson: body.bodyJson,
56
72
  meta: count == null ? "" : `${count} ${count === 1 ? "member" : "members"}`,
57
73
  image,
58
74
  initial: (title[0] || "G").toUpperCase(),
@@ -9,6 +9,7 @@ import { withOnlineWrites } from "../_shared/online-actions.js";
9
9
  import { defineService as registerService } from "@himalaya/state";
10
10
  import { withEmptyFlags } from "../_shared/empty-flags.js";
11
11
  import { stashCopy } from "../_shared/himi-copy.js";
12
+ import { memberAccess, assertMemberAccess, type MemberAccess } from "../_shared/member-access.js";
12
13
  import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
13
14
  import {
14
15
  getNotificationTopics,
@@ -33,6 +34,7 @@ interface Row { id: string; name: string; subtitle: string; note: string; on: bo
33
34
  /** Inverse of `locked` (hiddenWhen hides on TRUE) and the plain On/Off a locked row shows. */
34
35
  notLocked: boolean; stateLabel: string }
35
36
  interface S extends LoadFlags {
37
+ preferencesMember: MemberAccess | null;
36
38
  topics: Row[]; emptyText: string; actionMessage: string;
37
39
  isMock: boolean; notMock: boolean; /** #2849 — hiddenWhen() hides on TRUE, so these are inverted: the empty-state banner
38
40
  * shows only when the site really has none, and the unavailable one only when the
@@ -94,32 +96,55 @@ const COPY: LoadCopy<Row> = {
94
96
  };
95
97
 
96
98
  const defineService = withOnlineWrites(registerService, ["toggle"], { messageFields: ["actionMessage"] });
99
+ const INITIAL = { ...LOADING, topics: [] as Row[], emptyText: "", actionMessage: "", preferencesMember: null };
100
+ const signInRequired = () => new Error("Unauthenticated: sign in to manage notifications");
101
+ let loadVersion = 0;
97
102
  const svc = defineService<S, {
98
- load: (s: S, arg?: unknown) => Promise<S>;
99
- toggle: (s: S, p?: { id?: string; value?: unknown }) => Promise<S>;
103
+ prepare: (s: S) => S;
104
+ load: (s: S, arg?: unknown) => Promise<S | undefined>;
105
+ toggle: (s: S, p?: { id?: string; value?: unknown }) => Promise<S | undefined>;
100
106
  }>("himiBrandedNotifSettings", withEmptyFlags({
101
- state: { ...LOADING, topics: [], emptyText: "", actionMessage: "" },
107
+ state: INITIAL,
102
108
  actions: {
103
- load: async (s, arg) => {
109
+ prepare: (s) => ({ ...s, ...INITIAL }),
110
+ load: async (s, arg): Promise<S | undefined> => {
104
111
  stashCopy(arg);
112
+ const version = ++loadVersion;
113
+ await svc.prepare();
105
114
  try {
115
+ const member = await memberAccess();
116
+ if (!member.email) throw signInRequired();
106
117
  const [topicsRes, prefsRes] = await Promise.all([
107
118
  getNotificationTopics(),
108
- getMyNotificationPreferences().catch(() => ({} as Awaited<ReturnType<typeof getMyNotificationPreferences>>)),
119
+ getMyNotificationPreferences(),
109
120
  ]);
121
+ await assertMemberAccess(member);
110
122
  const prefs = readPreferences(prefsRes);
111
123
  const topics = readTopics(topicsRes).map((t) => norm(t, prefs));
112
- return { ...s, ...loaded("topics", topics, COPY), actionMessage: "" };
113
- } catch (err) {
114
- return { ...s, ...loadFailed("topics", err, COPY) };
124
+ return version === loadVersion ? { ...s, ...loaded("topics", topics, COPY), preferencesMember: member, actionMessage: "" } : undefined;
125
+ } catch {
126
+ const member = await memberAccess();
127
+ // An authenticated denial is not repaired by an endless sign-in loop.
128
+ const failure = member.email ? new Error(COPY.error) : signInRequired();
129
+ return version === loadVersion ? { ...s, ...loadFailed("topics", failure, COPY), preferencesMember: null, actionMessage: "" } : undefined;
115
130
  }
116
131
  },
117
132
  // Flip one topic's On/Off. Optimistic (updates the row immediately); the real
118
133
  // upsert is best-effort and a no-op on sample rows.
119
- toggle: async (s, p) => {
134
+ toggle: async (s, p): Promise<S | undefined> => {
120
135
  const id = p?.id;
121
136
  const target = s.topics.find((r) => r.id === id);
122
- if (!id || !target || target.locked) return s;
137
+ if (s.busy || !id || !target || target.locked) return undefined;
138
+ if (!s.isMock) {
139
+ try {
140
+ if (!s.preferencesMember) return undefined;
141
+ await assertMemberAccess(s.preferencesMember);
142
+ if (svc.getState().preferencesMember !== s.preferencesMember) return undefined;
143
+ } catch {
144
+ await svc.load();
145
+ return undefined;
146
+ }
147
+ }
123
148
  let nextOn = true;
124
149
  const topics = s.topics.map((r) => {
125
150
  if (r.id !== id) return r;
@@ -136,10 +161,13 @@ const svc = defineService<S, {
136
161
  { topicId: id, channel: CHANNEL, stateData: { state: muteStatusFor(nextOn) } },
137
162
  ]);
138
163
  } catch { /* keep the optimistic state */ }
164
+ try { await assertMemberAccess(s.preferencesMember!); }
165
+ catch { await svc.load(); return undefined; }
139
166
  }
140
- return { ...s, topics };
167
+ return svc.getState().preferencesMember === s.preferencesMember ? { ...svc.getState(), topics } : undefined;
141
168
  },
142
169
  },
143
170
  }, { topics: "noTopics" }));
171
+ svc.prepare();
144
172
  svc.load();
145
173
  export { svc };
@@ -0,0 +1,35 @@
1
+ import { defineService } from "@himalaya/state";
2
+
3
+ // Native renderers animate; this worker only publishes the requested visibility.
4
+ // The host waits for presentation, bounded by the original startup deadline.
5
+ const input = (globalThis as unknown as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input;
6
+ interface State {
7
+ title: string;
8
+ caption: string;
9
+ iconHidden: boolean;
10
+ presentationPending: boolean;
11
+ errorText?: string;
12
+ customLogoHidden?: boolean;
13
+ backgroundImageUrl?: string;
14
+ }
15
+ export const splash = defineService<State, { present: (state: State) => State }>("splash", {
16
+ state: {
17
+ title: typeof input?.title === "string" ? input.title : "",
18
+ caption: typeof input?.caption === "string" ? input.caption : "",
19
+ iconHidden: false,
20
+ presentationPending: true,
21
+ // A malformed OTA toggle must not release the native cover as a successful
22
+ // presentation. The host's existing deadline remains the failure fallback.
23
+ ...(input?.showIcon !== undefined && typeof input.showIcon !== "boolean"
24
+ ? { errorText: "Splash showIcon must be a boolean" } : {}),
25
+ ...(typeof input?.backgroundImageUrl === "string" ? { backgroundImageUrl: input.backgroundImageUrl } : {}),
26
+ ...(input?.customLogo === true ? { customLogoHidden: true } : {}),
27
+ },
28
+ actions: {
29
+ present: (state) => state.errorText ? state : ({ ...state,
30
+ iconHidden: input?.showIcon === false || input?.customLogo === true,
31
+ ...(input?.customLogo === true ? { customLogoHidden: input?.showIcon === false } : {}),
32
+ presentationPending: false,
33
+ }),
34
+ },
35
+ });
@@ -1,8 +1,8 @@
1
1
  // Copy-as-configuration for WORKER-emitted strings (`himi docs designing-himi-screens`,
2
2
  // §"Copy is configuration"): a worker never hardcodes user-facing copy — the
3
3
  // descriptor bakes the provisioned copy-pack overrides relevant to this worker
4
- // into its onAppear action params as a `copy` JSON object (key → override
5
- // string, computed from the app contract at descriptor-build time), and the
4
+ // into its onAppear action params as a `copy` JSON object (customer strings or
5
+ // source-matched stock translations, computed at descriptor-build time), and the
6
6
  // worker reads every user-facing string through `copyParam(key, fallback)`.
7
7
  //
8
8
  // STASHED (the stashed appear-params idiom, himi-branded-lite-home): the
@@ -16,9 +16,13 @@
16
16
  // every worker that emits user-facing copy, and copied verbatim into the
17
17
  // generated templates' tier5-src/_shared/.
18
18
 
19
- let stashedCopy: Record<string, string> = {};
19
+ /** Customer strings override every read; translated stock is valid only for its
20
+ * original English fallback. Some existing keys serve more than one message. */
21
+ export type CopyPackValue = string | { source: string; translation: string };
20
22
 
21
- /** Absorb the `copy` onAppear param (a JSON object of key → override string)
23
+ let stashedCopy: Record<string, CopyPackValue> = Object.create(null);
24
+
25
+ /** Absorb the `copy` onAppear param (customer strings or stock translation records)
22
26
  * from an action arg. Call first in every action that can receive onAppear
23
27
  * params (load/refresh). Non-copy args and malformed packs are ignored — the
24
28
  * stock fallbacks keep rendering. */
@@ -29,9 +33,15 @@ export function stashCopy(arg: unknown): void {
29
33
  try {
30
34
  const parsed = JSON.parse(raw) as unknown;
31
35
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
32
- const next: Record<string, string> = {};
36
+ const next: Record<string, CopyPackValue> = Object.create(null);
33
37
  for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
34
38
  if (typeof value === "string") next[key] = value;
39
+ else if (value && typeof value === "object" && !Array.isArray(value)) {
40
+ const row = value as Record<string, unknown>;
41
+ if (typeof row.source === "string" && typeof row.translation === "string") {
42
+ next[key] = { source: row.source, translation: row.translation };
43
+ }
44
+ }
35
45
  }
36
46
  stashedCopy = next;
37
47
  } catch {
@@ -43,7 +53,8 @@ export function stashCopy(arg: unknown): void {
43
53
  * pack / no such key reached this worker. */
44
54
  export function copyParam(key: string, fallback: string): string {
45
55
  const value = stashedCopy[key];
46
- return typeof value === "string" && value !== "" ? value : fallback;
56
+ if (typeof value === "string") return value !== "" ? value : fallback;
57
+ return value?.source === fallback && value.translation !== "" ? value.translation : fallback;
47
58
  }
48
59
 
49
60
  /** Interpolate `{name}` placeholders in a (possibly overridden) template:
@@ -52,6 +52,16 @@ export default defineApp({
52
52
  ]
53
53
  },
54
54
  designTokensPath: "./tokens.json",
55
+ // Native fallback; the T5 splash descriptor owns the editable launch appearance.
56
+ splash: {
57
+ "enabled": true,
58
+ "backgroundColor": "accentPrimary",
59
+ "logoAsset": "branded_lite_app_icon",
60
+ "logoWidth": 192,
61
+ "layout": "logoOnly",
62
+ "minVisibleMs": 0,
63
+ "maxHoldMs": 5000
64
+ },
55
65
  navigation: {
56
66
  "navKind": "responsive",
57
67
  "scheme": "himalaya-__HIMI_APP_SLUG__",
@@ -71,6 +81,13 @@ export default defineApp({
71
81
  "parentScreenId": "himi_account",
72
82
  "tabId": "profile"
73
83
  },
84
+ {
85
+ "screenId": "himi_business_contact",
86
+ "isPublic": true,
87
+ "paramKeys": [],
88
+ "parentScreenId": "himi_account",
89
+ "tabId": "profile"
90
+ },
74
91
  {
75
92
  "screenId": "himi_appearance",
76
93
  "isPublic": true,