@wix/himalaya-cli 0.1144.0 → 0.1146.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 (74) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/comments-v1/src/extensions.d.ts +4 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/forum/src/extensions.d.ts +10 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-comments.mjs +5 -2
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-forum.mjs +13 -0
  5. package/dist/cli.mjs +0 -3
  6. package/guides/sdk-reference.json +13 -1
  7. package/package.json +2 -2
  8. package/templates/beauty-salon/dev/index.ts +62 -3
  9. package/templates/beauty-salon/tier5-src/_shared/rich-body.ts +10 -4
  10. package/templates/beauty-salon/tier5-src/himi-blog-post/index.ts +12 -2
  11. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +35 -4
  12. package/templates/beauty-salon-owner/tier5-src/_shared/rich-body.ts +10 -4
  13. package/templates/branded-lite/dev/index.ts +273 -12
  14. package/templates/branded-lite/tier5-src/_shared/rich-body.ts +10 -4
  15. package/templates/branded-lite/tier5-src/himi-blog-post/index.ts +12 -2
  16. package/templates/branded-lite/tier5-src/himi-branded-lite-cms-collection/index.ts +43 -3
  17. package/templates/branded-lite/tier5-src/himi-branded-lite-faq/index.ts +69 -13
  18. package/templates/branded-lite/tier5-src/himi-branded-lite-feed/index.ts +23 -3
  19. package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +40 -3
  20. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +35 -4
  21. package/templates/branded-lite/tier5-src/wix-spaces-forum-post/index.ts +29 -5
  22. package/templates/community-club/dev/index.ts +135 -5
  23. package/templates/community-club/tier5-src/_shared/rich-body.ts +10 -4
  24. package/templates/community-club/tier5-src/himi-blog-post/index.ts +12 -2
  25. package/templates/community-club/tier5-src/himi-branded-lite-cms-collection/index.ts +43 -3
  26. package/templates/community-club/tier5-src/himi-branded-lite-feed/index.ts +23 -3
  27. package/templates/community-club/tier5-src/himi-events-detail/index.ts +40 -3
  28. package/templates/community-club-owner/tier5-src/_shared/rich-body.ts +10 -4
  29. package/templates/events-meetups/dev/index.ts +91 -4
  30. package/templates/events-meetups/tier5-src/_shared/rich-body.ts +10 -4
  31. package/templates/events-meetups/tier5-src/himi-blog-post/index.ts +12 -2
  32. package/templates/events-meetups/tier5-src/himi-branded-lite-feed/index.ts +23 -3
  33. package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +40 -3
  34. package/templates/events-meetups-owner/tier5-src/_shared/rich-body.ts +10 -4
  35. package/templates/gym-fitness/dev/index.ts +92 -4
  36. package/templates/gym-fitness/tier5-src/_shared/rich-body.ts +10 -4
  37. package/templates/gym-fitness/tier5-src/himi-blog-post/index.ts +12 -2
  38. package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +40 -3
  39. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +35 -4
  40. package/templates/gym-fitness-owner/tier5-src/_shared/rich-body.ts +10 -4
  41. package/templates/nonprofit-cause/dev/index.ts +91 -4
  42. package/templates/nonprofit-cause/tier5-src/_shared/rich-body.ts +10 -4
  43. package/templates/nonprofit-cause/tier5-src/himi-blog-post/index.ts +12 -2
  44. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-feed/index.ts +23 -3
  45. package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +40 -3
  46. package/templates/nonprofit-cause-owner/tier5-src/_shared/rich-body.ts +10 -4
  47. package/templates/owner-lite/tier5-src/_shared/rich-body.ts +10 -4
  48. package/templates/podcast-creator/dev/index.ts +91 -4
  49. package/templates/podcast-creator/tier5-src/_shared/rich-body.ts +10 -4
  50. package/templates/podcast-creator/tier5-src/himi-blog-post/index.ts +12 -2
  51. package/templates/podcast-creator/tier5-src/himi-branded-lite-feed/index.ts +23 -3
  52. package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +40 -3
  53. package/templates/podcast-creator-owner/tier5-src/_shared/rich-body.ts +10 -4
  54. package/templates/portfolio-creative/dev/index.ts +92 -4
  55. package/templates/portfolio-creative/tier5-src/_shared/rich-body.ts +10 -4
  56. package/templates/portfolio-creative/tier5-src/himi-blog-post/index.ts +12 -2
  57. package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +40 -3
  58. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +35 -4
  59. package/templates/portfolio-creative-owner/tier5-src/_shared/rich-body.ts +10 -4
  60. package/templates/restaurant/dev/index.ts +60 -2
  61. package/templates/restaurant/tier5-src/_shared/rich-body.ts +10 -4
  62. package/templates/restaurant/tier5-src/himi-blog-post/index.ts +12 -2
  63. package/templates/restaurant/tier5-src/himi-events-detail/index.ts +40 -3
  64. package/templates/restaurant-owner/tier5-src/_shared/rich-body.ts +10 -4
  65. package/templates/retail-store/dev/index.ts +62 -3
  66. package/templates/retail-store/tier5-src/_shared/rich-body.ts +10 -4
  67. package/templates/retail-store/tier5-src/himi-blog-post/index.ts +12 -2
  68. package/templates/retail-store/tier5-src/himi-store-product/index.ts +35 -4
  69. package/templates/retail-store-owner/tier5-src/_shared/rich-body.ts +10 -4
  70. package/templates/yoga-studio/dev/index.ts +60 -2
  71. package/templates/yoga-studio/tier5-src/_shared/rich-body.ts +10 -4
  72. package/templates/yoga-studio/tier5-src/himi-blog-post/index.ts +12 -2
  73. package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +40 -3
  74. package/templates/yoga-studio-owner/tier5-src/_shared/rich-body.ts +10 -4
@@ -5,12 +5,20 @@ import {
5
5
  type WixCategory,
6
6
  type WixQuestionEntry,
7
7
  } from "@himalaya/wix-faq";
8
+ import { openRichLink } from "../_shared/rich-content-link.js";
9
+ import { richBody, ROW_IMAGE_WIDTH } from "../_shared/rich-body.js";
8
10
 
9
11
  interface FaqQuestion {
10
12
  id: string;
11
13
  categoryId: string;
12
14
  question: string;
15
+ /** The PLAIN_TEXT answer: the RichContent fallback, and what shows with no trusted document. */
13
16
  answer: string;
17
+ /** [rico] #3372 — the RICH_CONTENT answer as ONE document string, used ONLY when its text matches the plain
18
+ * answer (a legacy DraftJS record converts unreliably); "" otherwise. */
19
+ answerJson: string;
20
+ /** answerJson !== "" — gates the plain answer, because RichContent draws nothing for "". */
21
+ hasAnswerJson: boolean;
14
22
  sortOrder?: number;
15
23
  status: string;
16
24
  }
@@ -25,6 +33,8 @@ interface QuestionRow {
25
33
  id: string;
26
34
  title: string;
27
35
  answer: string;
36
+ answerJson: string;
37
+ hasAnswerJson: boolean;
28
38
  expanded: boolean;
29
39
  }
30
40
 
@@ -94,6 +104,9 @@ function parseStaticFaq(raw: unknown): FaqQuestion[] {
94
104
  categoryId: STATIC_CATEGORY.id,
95
105
  question,
96
106
  answer,
107
+ // The site's own pairs are plain strings: there is no document to render.
108
+ answerJson: "",
109
+ hasAnswerJson: false,
97
110
  sortOrder: out.length,
98
111
  status: "VISIBLE",
99
112
  });
@@ -129,6 +142,8 @@ function sectionsFor(state: Pick<FaqState, "categories" | "questions" | "query"
129
142
  id: question.id,
130
143
  title: question.question,
131
144
  answer: question.answer,
145
+ answerJson: question.answerJson,
146
+ hasAnswerJson: question.hasAnswerJson,
132
147
  expanded: expanded.has(question.id),
133
148
  });
134
149
  const sections = state.categories
@@ -213,8 +228,25 @@ async function allCategories(): Promise<FaqCategory[]> {
213
228
  return uniqueById(rows);
214
229
  }
215
230
 
216
- async function allQuestions(): Promise<FaqQuestion[]> {
217
- const rows: FaqQuestion[] = [];
231
+ /** The answer's text with every whitespace run collapsed to one space: PLAIN_TEXT and a document's plain text
232
+ * may separate blocks differently ("\n" vs "\n\n"), and that is not a garbled conversion, but a space inside
233
+ * the text is content ("New York" is not "NewYork"). */
234
+ const normalizeSpace = (text: string): string => text.replace(/\s+/g, " ").trim();
235
+
236
+ /** [rico] #3372 — the RICH_CONTENT answer as a document string, trusted ONLY when its text matches the
237
+ * PLAIN_TEXT answer. Wix converts a legacy DraftJS answer to Ricos unreliably, and the plain answer is
238
+ * the one a visitor has always been shown, so a document that disagrees with it is dropped. */
239
+ function richAnswer(plain: string, rich: unknown): string {
240
+ // richBody encodes FIRST, so a document past the viewer's limits (too deep included) is rejected without an
241
+ // unbounded plain-text walk, and gives "". Answers render in a list, so their media are list-sized.
242
+ const { bodyJson, bodyText } = richBody(rich, "", ROW_IMAGE_WIDTH);
243
+ return bodyJson && normalizeSpace(bodyText) === normalizeSpace(plain) ? bodyJson : "";
244
+ }
245
+
246
+ /** Every question entry in one content format, following THAT query's own cursors: a cursor belongs to the
247
+ * request that returned it, so the two formats never share one. */
248
+ async function entriesIn(contentFormat: "PLAIN_TEXT" | "RICH_CONTENT"): Promise<WixQuestionEntry[]> {
249
+ const rows: WixQuestionEntry[] = [];
218
250
  const seen = new Set<string>();
219
251
  let cursor = "";
220
252
  do {
@@ -223,23 +255,41 @@ async function allQuestions(): Promise<FaqQuestion[]> {
223
255
  cursorPaging: { limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) },
224
256
  filter: { status: { $in: ["VISIBLE", "UNKNOWN"] } },
225
257
  },
226
- contentFormat: "PLAIN_TEXT",
258
+ contentFormat,
227
259
  });
228
- rows.push(...(response.questionEntries ?? []).map((question: WixQuestionEntry) => ({
229
- id: question.id ?? "",
230
- categoryId: question.categoryId ?? "",
231
- question: (question.question ?? "").trim(),
232
- answer: (question.plainText ?? "").trim(),
233
- ...(typeof question.sortOrder === "number" ? { sortOrder: question.sortOrder } : {}),
234
- status: question.status ?? "UNKNOWN",
235
- })).filter((question) =>
236
- question.id && question.question && question.status.toUpperCase() !== "HIDDEN",
237
- ));
260
+ rows.push(...(response.questionEntries ?? []));
238
261
  const next = response.pagingMetadata?.cursors?.next ?? "";
239
262
  if (!next || seen.has(next)) break;
240
263
  seen.add(next);
241
264
  cursor = next;
242
265
  } while (cursor);
266
+ return rows;
267
+ }
268
+
269
+ async function allQuestions(): Promise<FaqQuestion[]> {
270
+ // Both formats are read in parallel, each paged on its own. PLAIN_TEXT is the list; a RICH_CONTENT read
271
+ // that fails only costs the documents — every answer keeps its plain text.
272
+ const [plain, rich] = await Promise.all([
273
+ entriesIn("PLAIN_TEXT"),
274
+ entriesIn("RICH_CONTENT").catch(() => [] as WixQuestionEntry[]),
275
+ ]);
276
+ const richById = new Map(rich.map((question: WixQuestionEntry) => [question.id ?? "", question.richContent]));
277
+ const rows = plain.map((question: WixQuestionEntry) => {
278
+ const answer = (question.plainText ?? "").trim();
279
+ const answerJson = richAnswer(answer, richById.get(question.id ?? ""));
280
+ return {
281
+ id: question.id ?? "",
282
+ categoryId: question.categoryId ?? "",
283
+ question: (question.question ?? "").trim(),
284
+ answer,
285
+ answerJson,
286
+ hasAnswerJson: answerJson !== "",
287
+ ...(typeof question.sortOrder === "number" ? { sortOrder: question.sortOrder } : {}),
288
+ status: question.status ?? "UNKNOWN",
289
+ };
290
+ }).filter((question) =>
291
+ question.id && question.question && question.status.toUpperCase() !== "HIDDEN",
292
+ );
243
293
  return uniqueById(rows);
244
294
  }
245
295
 
@@ -308,6 +358,12 @@ const svc = defineService("himiBrandedFaq", {
308
358
  : [...state.expandedIds, id];
309
359
  return commit(ready({ ...state, expandedIds }));
310
360
  },
361
+ // [rico] #3372 — an answer RichContent's on_link: `{ url }` of the tapped link. Only http(s), mailto and
362
+ // tel links reach open_url; the FAQ has no message line, so a refused link simply does nothing.
363
+ openLink: async (state: FaqState, arg?: unknown) => {
364
+ await openRichLink(arg);
365
+ return state;
366
+ },
311
367
  },
312
368
  });
313
369
 
@@ -23,6 +23,8 @@ import { signedInEmail } from "../_shared/himi-account";
23
23
  import { initials } from "../_shared/himi-text.js";
24
24
  import { copyParam, stashCopy } from "../_shared/himi-copy.js";
25
25
  import { formatMonthDay } from "../_shared/engine-intl.js";
26
+ import { openRichLink } from "../_shared/rich-content-link.js";
27
+ import { richBody, ROW_IMAGE_WIDTH } from "../_shared/rich-body.js";
26
28
 
27
29
  interface FeedRow {
28
30
  id: string;
@@ -33,8 +35,15 @@ interface FeedRow {
33
35
  avatar: string;
34
36
  hasAvatar: boolean;
35
37
  noAvatar: boolean;
38
+ /** The post's plain text: RichContent's fallback, and what shows for a post with no Ricos document. */
36
39
  text: string;
37
- /** First inline IMAGE node in the post content ("" for text-only posts). */
40
+ /** [rico] #3372 — a Ricos post's `content` as ONE document string for the row's RichContent; "" for a
41
+ * DraftJS-blocks post, a lean one, or a document the encoder rejected. */
42
+ bodyJson: string;
43
+ /** bodyJson !== "" — gates the row's plain text, because RichContent draws nothing for "". */
44
+ hasBodyJson: boolean;
45
+ /** First inline IMAGE node in the post content ("" for text-only posts). Shown only for a row with no
46
+ * document: RichContent draws the document's images itself. */
38
47
  image: string;
39
48
  hasImage: boolean;
40
49
  noImage: boolean;
@@ -161,7 +170,10 @@ function feedRow(item: WixFeedItem, profiles: WixFeedProfile[]): FeedRow {
161
170
  const author = authorName(item, profiles);
162
171
  const count = Array.isArray(item.reactionsList) ? item.reactionsList.length : 0;
163
172
  const avatar = authorAvatar(item, profiles);
164
- const image = contentImage(item);
173
+ // A Ricos post's plain text is the whole document's (every node level), not feedText's one-level read.
174
+ const { bodyJson, bodyText: text } = richBody(item.content, feedText(item), ROW_IMAGE_WIDTH);
175
+ // A Ricos post draws its own images in RichContent, so the separate row image would repeat the first one.
176
+ const image = bodyJson ? "" : contentImage(item);
165
177
  return {
166
178
  id: item.feedItemId || "",
167
179
  author,
@@ -169,7 +181,9 @@ function feedRow(item: WixFeedItem, profiles: WixFeedProfile[]): FeedRow {
169
181
  avatar,
170
182
  hasAvatar: !!avatar,
171
183
  noAvatar: !avatar,
172
- text: feedText(item),
184
+ text,
185
+ bodyJson,
186
+ hasBodyJson: bodyJson !== "",
173
187
  image,
174
188
  hasImage: !!image,
175
189
  noImage: !image,
@@ -292,6 +306,8 @@ const svc = defineService<State, {
292
306
  filter: (state: State, arg?: unknown) => State;
293
307
  post: (state: State, arg?: unknown) => Promise<State>;
294
308
  react: (state: State, arg?: unknown) => Promise<State>;
309
+ /** [rico] #3372 — a row RichContent's on_link: `{ url }` of the tapped link. */
310
+ openLink: (state: State, arg?: unknown) => Promise<State>;
295
311
  }>("himiBrandedFeed", {
296
312
  state: derive({
297
313
  status: "loading",
@@ -367,6 +383,10 @@ const svc = defineService<State, {
367
383
  return state;
368
384
  }
369
385
  },
386
+ // Only http(s), mailto and tel links reach open_url; one that does not open says so in the message line.
387
+ openLink: async (state, arg) => ((await openRichLink(arg))
388
+ ? state
389
+ : { ...state, message: copyParam("feed.linkOpenFailed", "This link could not be opened.") }),
370
390
  },
371
391
  });
372
392
 
@@ -35,6 +35,8 @@ import {
35
35
  } from "../_shared/himi-events";
36
36
  import { initials } from "../_shared/himi-text.js";
37
37
  import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
38
+ import { openRichLink } from "../_shared/rich-content-link.js";
39
+ import { richBody } from "../_shared/rich-body.js";
38
40
 
39
41
  interface MapMarker {
40
42
  latitude: number;
@@ -88,7 +90,13 @@ interface S {
88
90
  initial: string;
89
91
  when: string;
90
92
  location: string;
93
+ /** The plain-text description: RichContent's fallback, the calendar notes, and what shows with no document. */
91
94
  description: string;
95
+ /** [rico] #3372 — the v3 Ricos `description` (read with `fields=TEXTS`) as ONE document string for
96
+ * RichContent; "" when the event has none or the encoder rejected it. */
97
+ descriptionJson: string;
98
+ /** descriptionJson !== "" — gates the plain text, because RichContent draws nothing for "". */
99
+ hasDescriptionJson: boolean;
92
100
  image: string;
93
101
  hasImage: boolean;
94
102
  noImage: boolean;
@@ -212,6 +220,7 @@ function derive(s: S): S {
212
220
  const hasAttendees = s.attendeeAvatars.length > 0;
213
221
  return {
214
222
  ...s,
223
+ hasDescriptionJson: s.descriptionJson !== "",
215
224
  noMap: !s.mapUrl,
216
225
  hasMapMarkers,
217
226
  noMapMarkers: !hasMapMarkers,
@@ -357,6 +366,14 @@ async function attendeeSummary(eventId: string): Promise<{ goingLabel: string; a
357
366
  }
358
367
  }
359
368
 
369
+ /** The description pair: the v3 Ricos `description` for RichContent, and its plain text. A string description
370
+ * (or none) keeps `eventDescription`'s legacy read, with no document. */
371
+ function eventBody(e: WixEvent): { description: string; descriptionJson: string } {
372
+ const legacy = eventDescription(e);
373
+ const { bodyJson, bodyText } = richBody((e as unknown as { description?: unknown }).description, legacy);
374
+ return { description: bodyText, descriptionJson: bodyJson };
375
+ }
376
+
360
377
  /** getEvent returns the slim BASE fieldset (no `registration`); enrich from the
361
378
  * richer query when the type is unknown — best-effort, find-by-id. */
362
379
  async function enrichRegistration(id: string, e: WixEvent): Promise<WixEvent> {
@@ -364,7 +381,11 @@ async function enrichRegistration(id: string, e: WixEvent): Promise<WixEvent> {
364
381
  try {
365
382
  const list = await queryEvents({ limit: 100 });
366
383
  const match = (list.events ?? []).find((candidate) => candidate.id === id);
367
- return match ?? e;
384
+ if (!match) return e;
385
+ // [rico] #3372 — the query's rows are read without TEXTS, so they carry no Ricos `description`: keep the
386
+ // one this detail fetched, or the enrichment would silently drop the document on every real site.
387
+ const description = (e as unknown as { description?: unknown }).description;
388
+ return description === undefined ? match : ({ ...match, description } as unknown as WixEvent);
368
389
  } catch {
369
390
  return e;
370
391
  }
@@ -377,6 +398,8 @@ const svc = defineService<S, {
377
398
  openRegistration: (s: S) => Promise<S>;
378
399
  share: (s: S) => Promise<S>;
379
400
  addToCalendar: (s: S) => Promise<S>;
401
+ /** [rico] #3372 — RichContent's on_link: `{ url }` of the tapped link. */
402
+ openLink: (s: S, arg?: unknown) => Promise<S>;
380
403
  }>("himiEventDetail", {
381
404
  state: derive({
382
405
  status: "loading",
@@ -386,6 +409,8 @@ const svc = defineService<S, {
386
409
  when: "",
387
410
  location: "",
388
411
  description: "",
412
+ descriptionJson: "",
413
+ hasDescriptionJson: false,
389
414
  image: "",
390
415
  hasImage: false,
391
416
  noImage: true,
@@ -433,7 +458,8 @@ const svc = defineService<S, {
433
458
  const id = s.id || inputId();
434
459
  if (!id) return derive({ ...s, status: "ready", errored: true });
435
460
  try {
436
- const res = await getEvent(id);
461
+ // TEXTS adds the Ricos `description` to the base fieldset (a bare read returns none).
462
+ const res = await getEvent(id, { fields: ["TEXTS"] });
437
463
  let e = ((res as unknown as { event?: WixEvent }).event ?? res) as WixEvent;
438
464
  e = await enrichRegistration(id, e);
439
465
  const attendees = await attendeeSummary(id);
@@ -460,7 +486,9 @@ const svc = defineService<S, {
460
486
  initial: (title.trim()[0] || "E").toUpperCase(),
461
487
  when: eventWhen(e),
462
488
  location: eventLocation(e),
463
- description: eventDescription(e),
489
+ // The Ricos description when the event has one (its own text is the plain copy); the legacy
490
+ // string fields otherwise.
491
+ ...eventBody(e),
464
492
  image,
465
493
  hasImage: !!image,
466
494
  noImage: !image,
@@ -491,6 +519,9 @@ const svc = defineService<S, {
491
519
  initial: (title.trim()[0] || "E").toUpperCase(),
492
520
  when: cached.dateLine,
493
521
  location: cached.location,
522
+ // The cached list row carries no description: an offline retry of THIS event keeps the pair it
523
+ // already shows, and any other event's is cleared together, so neither half can linger alone.
524
+ ...(s.id === id ? {} : { description: "", descriptionJson: "" }),
494
525
  image: cached.image,
495
526
  hasImage: !!cached.image,
496
527
  noImage: !cached.image,
@@ -665,6 +696,12 @@ const svc = defineService<S, {
665
696
  return derive({ ...s, actionMessage: copyParam("event.calendarAddFailed", "Could not add this event to calendar."), isAddingCalendar: false });
666
697
  }
667
698
  },
699
+ // Only http(s), mailto and tel links reach open_url; a refused link, or one nothing opened, says so, and a
700
+ // link that opens clears an earlier message.
701
+ openLink: async (s, arg) => derive({
702
+ ...s,
703
+ actionMessage: (await openRichLink(arg)) ? "" : copyParam("event.linkOpenFailed", "This link could not be opened."),
704
+ }),
668
705
  },
669
706
  });
670
707
  svc.load();
@@ -10,6 +10,8 @@ import { cleanTitle } from "../_shared/himi-text.js";
10
10
  import { stashCopy, copyParam, copyTemplate } from "../_shared/himi-copy.js";
11
11
  import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
12
12
  import { isProductSaved, toggleSavedProduct } from "../_shared/himi-store-saved.js";
13
+ import { openRichLink } from "../_shared/rich-content-link.js";
14
+ import { richBody } from "../_shared/rich-body.js";
13
15
 
14
16
  // Offline-first (data-offline): the detail is a public READ-THROUGH — when the live
15
17
  // product fetch fails (offline) fall back to the last-synced row the catalog cached
@@ -120,11 +122,27 @@ function optionsHint(groups: StoreOption[], selection: StoreOptionSelection): st
120
122
  return copyTemplate("product.optionsChoose", "Choose {options}", { options: missing.join(", ") });
121
123
  }
122
124
 
125
+ /** The description pair. V3's `description` is a Ricos OBJECT (the HTML `plainText` would throw on it): it
126
+ * becomes the RichContent document, and `plainDescription` (or the document's own text) the plain copy. A V1
127
+ * `description` is an HTML string: stripped text, no document. */
128
+ function productBody(p: StoreProduct): { description: string; descriptionJson: string } {
129
+ const raw = (p as { description?: unknown }).description;
130
+ const html = plainText(p.plainDescription || (typeof raw === "string" ? raw : ""));
131
+ const { bodyJson, bodyText } = richBody(typeof raw === "string" ? null : raw, html);
132
+ return { description: html || bodyText, descriptionJson: bodyJson };
133
+ }
134
+
123
135
  interface S {
124
136
  status: "loading" | "ready" | "error";
125
137
  name: string;
126
138
  price: string;
139
+ /** The plain-text description: RichContent's fallback, and what shows with no document. */
127
140
  description: string;
141
+ /** [rico] #3372 — a V3 product's Ricos `description` (read with `fields=DESCRIPTION`) as ONE document string
142
+ * for RichContent; "" for a V1 HTML description, none, or a document the encoder rejected. */
143
+ descriptionJson: string;
144
+ /** descriptionJson !== "" — gates the plain text, because RichContent draws nothing for "". */
145
+ hasDescriptionJson: boolean;
128
146
  image: string;
129
147
  /** FULL media gallery (media.items[] with the main image first) — the detail
130
148
  * screen renders a Carousel over it when 2+ images exist. */
@@ -253,6 +271,7 @@ function derive(s: S): S {
253
271
  const overflow = groups.length > OPTION_SLOTS;
254
272
  return {
255
273
  ...s,
274
+ hasDescriptionJson: s.descriptionJson !== "",
256
275
  ...slotsOf(groups, s.selection),
257
276
  hasOptions: groups.length > 0,
258
277
  noOptions: groups.length === 0,
@@ -296,12 +315,16 @@ const svc = defineService<S, {
296
315
  setCartCount: (s: S, arg?: unknown) => S;
297
316
  share: (s: S) => Promise<S>;
298
317
  toggleSave: (s: S) => S;
318
+ /** [rico] #3372 — RichContent's on_link: `{ url }` of the tapped link. */
319
+ openLink: (s: S, arg?: unknown) => Promise<S>;
299
320
  }>("himiStoreProduct", {
300
321
  state: derive({
301
322
  status: "loading",
302
323
  name: "",
303
324
  price: "",
304
325
  description: "",
326
+ descriptionJson: "",
327
+ hasDescriptionJson: false,
305
328
  image: "",
306
329
  gallery: [],
307
330
  hasGallery: false,
@@ -354,7 +377,7 @@ const svc = defineService<S, {
354
377
  if (!id) {
355
378
  lastProduct = undefined;
356
379
  lastLoadedId = undefined;
357
- return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.notFoundText", "Product not found.") });
380
+ return derive({ ...s, status: "ready", quantity: 1, optionGroups: [], selection: {}, optionsUnknown: false, descriptionJson: "", emptyText: copyParam("product.notFoundText", "Product not found.") });
358
381
  }
359
382
  // The cart count only feeds the top-bar badge: ask for it NOW, beside the product, instead
360
383
  // of after it — it used to add a whole cart round trip before the page could paint.
@@ -365,7 +388,8 @@ const svc = defineService<S, {
365
388
  void cartCount.then((n) => { cartKnown = n; }, () => {});
366
389
  // The live product read goes out FIRST too — the local-db read below must never delay it.
367
390
  // Settled into a value so a failure is handled where it is awaited, never unhandled.
368
- const live = getStoreProduct(id).then(
391
+ // DESCRIPTION widens the default fieldset with the V3 Ricos description (a V1 catalog ignores it).
392
+ const live = getStoreProduct(id, ["DESCRIPTION"]).then(
369
393
  (r) => ({ ok: true as const, r }),
370
394
  () => ({ ok: false as const }),
371
395
  );
@@ -424,7 +448,7 @@ const svc = defineService<S, {
424
448
  optionsUnknown: false,
425
449
  name,
426
450
  price: productPrice(p),
427
- description: plainText(p.plainDescription || p.description),
451
+ ...productBody(p),
428
452
  image,
429
453
  gallery: productGallery(p),
430
454
  initial: (name[0] || "#").toUpperCase(),
@@ -455,6 +479,7 @@ const svc = defineService<S, {
455
479
  name,
456
480
  price: String(synced.subtitle ?? ""),
457
481
  description: "",
482
+ descriptionJson: "",
458
483
  image,
459
484
  initial: String(synced.initial ?? "") || (name[0] || "#").toUpperCase(),
460
485
  cartHint: copyParam("product.cartHintOffline", "Reconnect to choose options and add this to your cart."),
@@ -464,7 +489,7 @@ const svc = defineService<S, {
464
489
  }
465
490
  lastProduct = undefined;
466
491
  lastLoadedId = undefined;
467
- return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, emptyText: copyParam("product.errorText", "Product could not load.") });
492
+ return derive({ ...s, status: "error", optionGroups: [], selection: {}, optionsUnknown: false, descriptionJson: "", emptyText: copyParam("product.errorText", "Product could not load.") });
468
493
  },
469
494
  // The stepper fires onChange with { value } on every tap; clamp 1–99 and
470
495
  // ignore unparsable/garbage args instead of silently zeroing the quantity.
@@ -519,6 +544,7 @@ const svc = defineService<S, {
519
544
  name,
520
545
  price: String(row.subtitle ?? ""),
521
546
  description: "",
547
+ descriptionJson: "",
522
548
  image: String(row.image ?? ""),
523
549
  gallery: [],
524
550
  initial: String(row.initial ?? "") || (name[0] || "#").toUpperCase(),
@@ -605,6 +631,11 @@ const svc = defineService<S, {
605
631
  return derive({ ...s, added: false, cartHint: copyParam("product.shareUnavailable", "Sharing is not available on this device.") });
606
632
  }
607
633
  },
634
+ // Only http(s), mailto and tel links reach open_url; a refused link, or one nothing opened, says so in the
635
+ // hint line (`added: false`, as for share, so it never renders inside the add-to-cart confirmation).
636
+ openLink: async (s, arg) => ((await openRichLink(arg))
637
+ ? s
638
+ : derive({ ...s, added: false, cartHint: copyParam("product.linkOpenFailed", "This link could not be opened.") })),
608
639
  },
609
640
  });
610
641
 
@@ -2,10 +2,22 @@
2
2
  // himalaya.input.id carries the post id; fetched via getPost. "Like"/"Unlike" react to the
3
3
  // post and "Post Comment" creates a real comment, via the Wix Forum + comments APIs.
4
4
  import { defineService } from "@himalaya/state";
5
- import { getPost, reactToPost, removePostReaction, createForumComment, type WixPost } from "@himalaya/wix-forum";
5
+ import { getPost, reactToPost, removePostReaction, createForumComment, forumPostContent, type WixPost } from "@himalaya/wix-forum";
6
6
  import { presence, presenceAfterError, detailInputId, type PresenceFlags } from "../_shared/detail-presence.js";
7
+ import { openRichLink } from "../_shared/rich-content-link.js";
8
+ import { richBody } from "../_shared/rich-body.js";
7
9
 
8
- interface S extends PresenceFlags { status: "loading" | "ready"; id: string; title: string; author: string; subtitle: string; body: string; initial: string; likeHint: string; commentText: string; commentHint: string }
10
+ interface S extends PresenceFlags {
11
+ status: "loading" | "ready"; id: string; title: string; author: string; subtitle: string;
12
+ /** The plain-text body (`contentText`): RichContent's fallback, and what shows when there is no document. */
13
+ body: string;
14
+ /** [rico] #3372 — the post's Ricos `content` as ONE document string for RichContent; "" when it has none or
15
+ * the encoder rejected it. */
16
+ bodyJson: string;
17
+ /** bodyJson !== "" — gates the plain text, because RichContent draws nothing for "". */
18
+ hasBodyJson: boolean;
19
+ initial: string; likeHint: string; commentText: string; commentHint: string;
20
+ }
9
21
 
10
22
  function plain(html?: string): string {
11
23
  return (html || "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
@@ -20,8 +32,10 @@ const svc = defineService<S, {
20
32
  unlike: (s: S) => Promise<S>;
21
33
  setComment: (s: S, a?: unknown) => S;
22
34
  comment: (s: S) => Promise<S>;
35
+ /** [rico] #3372 — RichContent's on_link: `{ url }` of the tapped link. */
36
+ openLink: (s: S, a?: unknown) => Promise<S>;
23
37
  }>("spacesForumPost", {
24
- state: { status: "loading", id: "", title: "", author: "", subtitle: "", body: "", initial: "", likeHint: "", commentText: "", commentHint: "", ...presence("loading") },
38
+ state: { status: "loading", id: "", title: "", author: "", subtitle: "", body: "", bodyJson: "", hasBodyJson: false, initial: "", likeHint: "", commentText: "", commentHint: "", ...presence("loading") },
25
39
  actions: {
26
40
  load: async (s) => {
27
41
  const id = detailInputId();
@@ -29,13 +43,21 @@ const svc = defineService<S, {
29
43
  try {
30
44
  const res = await getPost(id);
31
45
  const p = ((res as unknown as { post?: WixPost }).post) ?? (res as unknown as WixPost);
32
- const pp = p as unknown as { title?: string; contentText?: string; content?: string; writer?: { name?: string }; commentCount?: number; viewCount?: number };
46
+ const pp = p as unknown as { title?: string; contentText?: string; content?: unknown; writer?: { name?: string }; commentCount?: number; viewCount?: number };
33
47
  const author = pp.writer?.name || "Member";
34
48
  const bits: string[] = [];
35
49
  if (pp.commentCount != null) bits.push(`${pp.commentCount} comments`);
36
50
  if (pp.viewCount != null) bits.push(`${pp.viewCount} views`);
51
+ const doc = forumPostContent(p);
52
+ // A string `content` is legacy HTML only when it is not a serialized Ricos document, which must never
53
+ // surface as raw JSON text.
54
+ const legacy = plain(pp.contentText || (typeof pp.content === "string" && !doc ? pp.content : ""));
55
+ // The Ricos `content`, drawn by RichContent; bodyJson "" (none, or rejected) shows the plain body instead,
56
+ // which is the document's own text when there is no contentText.
57
+ const { bodyJson, bodyText } = richBody(doc, legacy);
58
+ const body = legacy || bodyText || "No content.";
37
59
  return { status: "ready", id, title: pp.title || "Post", author, subtitle: bits.join(" · "),
38
- body: plain(pp.contentText || pp.content) || "No content.", initial: (author[0] || "#").toUpperCase(),
60
+ body, bodyJson, hasBodyJson: bodyJson !== "", initial: (author[0] || "#").toUpperCase(),
39
61
  likeHint: "", commentText: "", commentHint: "", ...presence("found") };
40
62
  } catch (e) { return { ...s, status: "ready", id, ...presenceAfterError(e) }; }
41
63
  },
@@ -59,6 +81,8 @@ const svc = defineService<S, {
59
81
  try { await createForumComment(s.id, text); return { ...s, commentText: "", commentHint: "Comment posted ✓" }; }
60
82
  catch { return { ...s, commentHint: "Couldn't post comment — try again." }; }
61
83
  },
84
+ // Only http(s), mailto and tel links reach open_url; one that does not open says so here.
85
+ openLink: async (s, a) => ((await openRichLink(a)) ? s : { ...s, commentHint: "This link could not be opened." }),
62
86
  },
63
87
  });
64
88
  svc.load();