@wix/himalaya-cli 0.1145.0 → 0.1147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/comments-v1/src/extensions.d.ts +4 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/forum/src/extensions.d.ts +10 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-comments.mjs +5 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-forum.mjs +13 -0
- package/dist/cli.mjs +75 -19
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/headless-loop.md +34 -0
- package/guides/himi-mcp.md +2 -0
- package/guides/sdk-reference.json +13 -1
- package/guides/tier-5-add-a-screen.md +4 -2
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +62 -3
- package/templates/beauty-salon/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/beauty-salon/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +35 -4
- package/templates/beauty-salon-owner/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-home/index.ts +6 -1
- package/templates/branded-lite/dev/index.ts +273 -12
- package/templates/branded-lite/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/branded-lite/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-cms-collection/index.ts +43 -3
- package/templates/branded-lite/tier5-src/himi-branded-lite-faq/index.ts +69 -13
- package/templates/branded-lite/tier5-src/himi-branded-lite-feed/index.ts +23 -3
- package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/branded-lite/tier5-src/himi-events-detail/index.ts +40 -3
- package/templates/branded-lite/tier5-src/himi-store-product/index.ts +35 -4
- package/templates/branded-lite/tier5-src/wix-spaces-forum-post/index.ts +29 -5
- package/templates/community-club/dev/index.ts +135 -5
- package/templates/community-club/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/community-club/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/community-club/tier5-src/himi-branded-lite-cms-collection/index.ts +43 -3
- package/templates/community-club/tier5-src/himi-branded-lite-feed/index.ts +23 -3
- package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/community-club/tier5-src/himi-events-detail/index.ts +40 -3
- package/templates/community-club-owner/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/community-club-owner/tier5-src/himi-owner-lite-home/index.ts +6 -1
- package/templates/events-meetups/dev/index.ts +91 -4
- package/templates/events-meetups/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/events-meetups/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/events-meetups/tier5-src/himi-branded-lite-feed/index.ts +23 -3
- package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/events-meetups/tier5-src/himi-events-detail/index.ts +40 -3
- package/templates/events-meetups-owner/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-home/index.ts +6 -1
- package/templates/gym-fitness/dev/index.ts +92 -4
- package/templates/gym-fitness/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/gym-fitness/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/gym-fitness/tier5-src/himi-events-detail/index.ts +40 -3
- package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +35 -4
- package/templates/gym-fitness-owner/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-home/index.ts +6 -1
- package/templates/nonprofit-cause/dev/index.ts +91 -4
- package/templates/nonprofit-cause/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/nonprofit-cause/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-feed/index.ts +23 -3
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/nonprofit-cause/tier5-src/himi-events-detail/index.ts +40 -3
- package/templates/nonprofit-cause-owner/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-home/index.ts +6 -1
- package/templates/owner-lite/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/owner-lite/tier5-src/himi-owner-lite-home/index.ts +6 -1
- package/templates/podcast-creator/dev/index.ts +91 -4
- package/templates/podcast-creator/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/podcast-creator/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/podcast-creator/tier5-src/himi-branded-lite-feed/index.ts +23 -3
- package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/podcast-creator/tier5-src/himi-events-detail/index.ts +40 -3
- package/templates/podcast-creator-owner/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-home/index.ts +6 -1
- package/templates/portfolio-creative/dev/index.ts +92 -4
- package/templates/portfolio-creative/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/portfolio-creative/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/portfolio-creative/tier5-src/himi-events-detail/index.ts +40 -3
- package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +35 -4
- package/templates/portfolio-creative-owner/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-home/index.ts +6 -1
- package/templates/restaurant/dev/index.ts +60 -2
- package/templates/restaurant/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/restaurant/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/restaurant/tier5-src/himi-events-detail/index.ts +40 -3
- package/templates/restaurant-owner/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-home/index.ts +6 -1
- package/templates/retail-store/dev/index.ts +62 -3
- package/templates/retail-store/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/retail-store/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/retail-store/tier5-src/himi-store-product/index.ts +35 -4
- package/templates/retail-store-owner/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-home/index.ts +6 -1
- package/templates/yoga-studio/dev/index.ts +60 -2
- package/templates/yoga-studio/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/yoga-studio/tier5-src/himi-blog-post/index.ts +12 -2
- package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +6 -1
- package/templates/yoga-studio/tier5-src/himi-events-detail/index.ts +40 -3
- package/templates/yoga-studio-owner/tier5-src/_shared/rich-body.ts +10 -4
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-home/index.ts +6 -1
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
|
|
11
11
|
import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
|
|
12
12
|
import { formatDateMedium } from "../_shared/engine-intl.js";
|
|
13
|
+
import { openRichLink } from "../_shared/rich-content-link.js";
|
|
14
|
+
import { richBody, ROW_IMAGE_WIDTH } from "../_shared/rich-body.js";
|
|
13
15
|
|
|
14
16
|
// Offline-first (data-offline): a collection's items are a public read-through against
|
|
15
17
|
// the on-device `cms_items` store. Rows are cached per collection (the persisted key is
|
|
@@ -23,9 +25,10 @@ async function readCached(collectionId: string): Promise<ItemRow[]> {
|
|
|
23
25
|
try {
|
|
24
26
|
const prefix = `${collectionId}::`;
|
|
25
27
|
const rows = await getAll(ENTITY);
|
|
28
|
+
// A row cached before [rico] #3372 carries no document fields: it shows its plain text.
|
|
26
29
|
return rows
|
|
27
30
|
.filter((r) => typeof r.id === "string" && r.id.startsWith(prefix))
|
|
28
|
-
.map((r) => ({ ...(r as unknown as ItemRow), id: (r.id as string).slice(prefix.length) }));
|
|
31
|
+
.map((r) => ({ bodyJson: "", hasBodyJson: false, bodyText: "", ...(r as unknown as Partial<ItemRow>), id: (r.id as string).slice(prefix.length) }) as ItemRow);
|
|
29
32
|
} catch { return []; }
|
|
30
33
|
}
|
|
31
34
|
async function writeCached(collectionId: string, rows: ItemRow[]): Promise<void> {
|
|
@@ -41,6 +44,13 @@ interface ItemRow {
|
|
|
41
44
|
initial: string;
|
|
42
45
|
hasImage: boolean;
|
|
43
46
|
noImage: boolean;
|
|
47
|
+
/** [rico] #3372 — the item's first RICH_CONTENT field as ONE document string for the row's RichContent; ""
|
|
48
|
+
* when the collection has none, the item left it empty, or the encoder rejected it. */
|
|
49
|
+
bodyJson: string;
|
|
50
|
+
/** bodyJson !== "" — gates the row's plain text, because RichContent draws nothing for "". */
|
|
51
|
+
hasBodyJson: boolean;
|
|
52
|
+
/** That field's plain text: the RichContent fallback. */
|
|
53
|
+
bodyText: string;
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
interface State {
|
|
@@ -86,10 +96,26 @@ function dateValue(value: unknown): string {
|
|
|
86
96
|
return Number.isNaN(date.getTime()) ? raw : formatDateMedium(date);
|
|
87
97
|
}
|
|
88
98
|
|
|
99
|
+
/** [rico] #3372 — RICH_TEXT is HTML: its text, with the tags removed and the common entities decoded, so a
|
|
100
|
+
* row never shows raw markup. A block or line-break tag separates words; an inline one (`W<i>ix</i>`) joins. */
|
|
101
|
+
export function stripHtml(html: string): string {
|
|
102
|
+
return html
|
|
103
|
+
.replace(/<\/?(?:p|div|br|li|ul|ol|h[1-6]|tr|td|th|blockquote|section|article)\b[^>]*>/gi, " ")
|
|
104
|
+
.replace(/<[^>]+>/g, "")
|
|
105
|
+
.replace(/ /g, " ")
|
|
106
|
+
.replace(/</g, "<")
|
|
107
|
+
.replace(/>/g, ">")
|
|
108
|
+
.replace(/"/g, "\"")
|
|
109
|
+
.replace(/'/g, "'")
|
|
110
|
+
.replace(/&/g, "&")
|
|
111
|
+
.replace(/\s+/g, " ")
|
|
112
|
+
.trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
function displayValue(value: unknown, type = ""): string {
|
|
90
116
|
if (value == null) return "";
|
|
91
117
|
if (type.includes("DATE")) return dateValue(value);
|
|
92
|
-
if (typeof value === "string") return value.trim();
|
|
118
|
+
if (typeof value === "string") return type.includes("RICH_TEXT") ? stripHtml(value) : value.trim();
|
|
93
119
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
94
120
|
if (Array.isArray(value)) return value.map((entry) => displayValue(entry)).filter(Boolean).slice(0, 3).join(", ");
|
|
95
121
|
if (typeof value === "object") {
|
|
@@ -116,13 +142,16 @@ function imageValue(data: Record<string, unknown>, fields: CmsFieldDefinition[])
|
|
|
116
142
|
function itemRow(item: WixDataItem, collection: CmsCollectionDefinition, index: number): ItemRow {
|
|
117
143
|
const data = item.data ?? {};
|
|
118
144
|
const primary = displayValue(data[collection.displayField], collection.fields.find((field) => field.key === collection.displayField)?.type);
|
|
145
|
+
// A RICH_CONTENT field renders as the row's document, never as a detail line.
|
|
119
146
|
const details = collection.fields
|
|
120
|
-
.filter((field) => field.key !== collection.displayField && !field.type.includes("IMAGE"))
|
|
147
|
+
.filter((field) => field.key !== collection.displayField && !field.type.includes("IMAGE") && !field.type.includes("RICH_CONTENT"))
|
|
121
148
|
.map((field) => ({ label: field.displayName, value: displayValue(data[field.key], field.type) }))
|
|
122
149
|
.filter((entry) => entry.value)
|
|
123
150
|
.slice(0, 2);
|
|
124
151
|
const title = primary || copyTemplate("cmsCollection.itemFallbackTitle", "Item {n}", { n: index + 1 });
|
|
125
152
|
const image = imageValue(data, collection.fields);
|
|
153
|
+
const rich = collection.fields.find((field) => field.type.includes("RICH_CONTENT"));
|
|
154
|
+
const { bodyJson, bodyText } = richBody(rich ? data[rich.key] : null, "", ROW_IMAGE_WIDTH);
|
|
126
155
|
return {
|
|
127
156
|
id: item.id || String(data._id ?? `${collection.id}-${index}`),
|
|
128
157
|
title,
|
|
@@ -132,6 +161,9 @@ function itemRow(item: WixDataItem, collection: CmsCollectionDefinition, index:
|
|
|
132
161
|
initial: (title[0] || "C").toUpperCase(),
|
|
133
162
|
hasImage: Boolean(image),
|
|
134
163
|
noImage: !image,
|
|
164
|
+
bodyJson,
|
|
165
|
+
hasBodyJson: bodyJson !== "",
|
|
166
|
+
bodyText,
|
|
135
167
|
};
|
|
136
168
|
}
|
|
137
169
|
|
|
@@ -202,6 +234,8 @@ let loadingCollectionId = "";
|
|
|
202
234
|
const svc = defineService<State, {
|
|
203
235
|
load: (state: State, arg?: unknown) => Promise<State>;
|
|
204
236
|
search: (state: State, arg?: unknown) => State;
|
|
237
|
+
/** [rico] #3372 — a row RichContent's on_link: `{ url }` of the tapped link. */
|
|
238
|
+
openLink: (state: State, arg?: unknown) => Promise<State>;
|
|
205
239
|
}>("himiBrandedCmsCollection", {
|
|
206
240
|
state: initial,
|
|
207
241
|
actions: {
|
|
@@ -255,6 +289,12 @@ const svc = defineService<State, {
|
|
|
255
289
|
: state.all;
|
|
256
290
|
return { ...state, rows, noRows: rows.length === 0, noEmpty: rows.length > 0, emptyText: query ? copyParam("cmsCollection.searchEmptyText", "No items match your search.") : copyParam("cmsCollection.emptyText", "This collection has no published items yet.") };
|
|
257
291
|
},
|
|
292
|
+
// Only http(s), mailto and tel links reach open_url; the collection has no message line, so a refused
|
|
293
|
+
// link simply does nothing.
|
|
294
|
+
openLink: async (state, arg) => {
|
|
295
|
+
await openRichLink(arg);
|
|
296
|
+
return state;
|
|
297
|
+
},
|
|
258
298
|
},
|
|
259
299
|
});
|
|
260
300
|
|
|
@@ -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
|
-
|
|
217
|
-
|
|
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
|
|
258
|
+
contentFormat,
|
|
227
259
|
});
|
|
228
|
-
rows.push(...(response.questionEntries ?? [])
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -508,7 +508,12 @@ async function fetchBookingsSignal(arg: unknown): Promise<BookingsSignal> {
|
|
|
508
508
|
try {
|
|
509
509
|
const response = await queryServices({ query: { paging: { limit: 50 } } });
|
|
510
510
|
return bookingsSignal(response.services ?? []);
|
|
511
|
-
} catch {
|
|
511
|
+
} catch (e) {
|
|
512
|
+
// The block collapses without a word on screen, so say why where an agent looks:
|
|
513
|
+
// the DEV panel's Console tab and `himi logs`. The status only — a Wix failure's
|
|
514
|
+
// message carries part of the upstream body, which may hold personal data.
|
|
515
|
+
const status = (e as { status?: unknown } | null)?.status ?? /\bHTTP (\d{3})\b/.exec(String(e))?.[1];
|
|
516
|
+
console.warn("[home.bookings] services query failed; showcase hidden", status !== undefined ? `HTTP ${String(status)}` : "");
|
|
512
517
|
return emptyBookingsSignal();
|
|
513
518
|
}
|
|
514
519
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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?:
|
|
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
|
|
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();
|