@wix/himalaya-cli 0.1070.0 → 0.1072.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 (43) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/events/src/index.d.ts +21 -3
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-events.mjs +4 -2
  3. package/guides/sdk-reference.json +17 -5
  4. package/package.json +2 -2
  5. package/templates/beauty-salon-owner/dev/index.ts +64 -0
  6. package/templates/beauty-salon-owner/tier5-src/_shared/owner-rich-guard.ts +64 -0
  7. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +24 -5
  8. package/templates/community-club-owner/dev/index.ts +96 -0
  9. package/templates/community-club-owner/tier5-src/_shared/owner-rich-guard.ts +64 -0
  10. package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +44 -10
  11. package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +24 -5
  12. package/templates/events-meetups-owner/dev/index.ts +32 -0
  13. package/templates/events-meetups-owner/tier5-src/_shared/owner-rich-guard.ts +64 -0
  14. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +44 -10
  15. package/templates/gym-fitness-owner/dev/index.ts +96 -0
  16. package/templates/gym-fitness-owner/tier5-src/_shared/owner-rich-guard.ts +64 -0
  17. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +44 -10
  18. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +24 -5
  19. package/templates/nonprofit-cause-owner/dev/index.ts +32 -0
  20. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-rich-guard.ts +64 -0
  21. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +44 -10
  22. package/templates/owner-lite/dev/index.ts +192 -0
  23. package/templates/owner-lite/tier5-src/_shared/owner-rich-guard.ts +64 -0
  24. package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +44 -10
  25. package/templates/owner-lite/tier5-src/himi-owner-lite-faq-question-form/index.ts +18 -5
  26. package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +24 -5
  27. package/templates/podcast-creator-owner/dev/index.ts +96 -0
  28. package/templates/podcast-creator-owner/tier5-src/_shared/owner-rich-guard.ts +64 -0
  29. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +44 -10
  30. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +24 -5
  31. package/templates/portfolio-creative-owner/dev/index.ts +64 -0
  32. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-rich-guard.ts +64 -0
  33. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +24 -5
  34. package/templates/restaurant-owner/dev/index.ts +96 -0
  35. package/templates/restaurant-owner/tier5-src/_shared/owner-rich-guard.ts +64 -0
  36. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +44 -10
  37. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +24 -5
  38. package/templates/retail-store-owner/dev/index.ts +64 -0
  39. package/templates/retail-store-owner/tier5-src/_shared/owner-rich-guard.ts +64 -0
  40. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +24 -5
  41. package/templates/yoga-studio-owner/dev/index.ts +32 -0
  42. package/templates/yoga-studio-owner/tier5-src/_shared/owner-rich-guard.ts +64 -0
  43. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +44 -10
@@ -72939,6 +72939,35 @@ const SCREENS = runtimeSetting({
72939
72939
  }
72940
72940
  }
72941
72941
  },
72942
+ {
72943
+ "id": "product_form.description_note",
72944
+ "body": {
72945
+ "package": "himalaya",
72946
+ "type": "Text",
72947
+ "props": {
72948
+ "text": "$service:himiOwnerLiteProductForm.descriptionNote",
72949
+ "style": "footnote",
72950
+ "color": "textSecondary",
72951
+ "collapseWhenEmpty": true
72952
+ }
72953
+ }
72954
+ },
72955
+ {
72956
+ "id": "product_form.description_preview",
72957
+ "body": {
72958
+ "package": "himalaya",
72959
+ "type": "Text",
72960
+ "props": {
72961
+ "text": "$service:himiOwnerLiteProductForm.description",
72962
+ "style": "body",
72963
+ "maxLines": 8,
72964
+ "collapseWhenEmpty": true
72965
+ }
72966
+ },
72967
+ "modifiers": {
72968
+ "hiddenPath": "$service:himiOwnerLiteProductForm.descriptionEditable"
72969
+ }
72970
+ },
72942
72971
  {
72943
72972
  "id": "product_form.form",
72944
72973
  "body": {
@@ -73025,6 +73054,9 @@ const SCREENS = runtimeSetting({
73025
73054
  "multilineMaxLines": 3
73026
73055
  }
73027
73056
  }
73057
+ },
73058
+ "modifiers": {
73059
+ "hiddenPath": "$service:himiOwnerLiteProductForm.descriptionProtected"
73028
73060
  }
73029
73061
  }
73030
73062
  ],
@@ -73553,6 +73585,35 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
73553
73585
  }
73554
73586
  }
73555
73587
  },
73588
+ {
73589
+ "id": "product_form.description_note",
73590
+ "body": {
73591
+ "package": "himalaya",
73592
+ "type": "Text",
73593
+ "props": {
73594
+ "text": "$service:himiOwnerLiteProductForm.descriptionNote",
73595
+ "style": "footnote",
73596
+ "color": "textSecondary",
73597
+ "collapseWhenEmpty": true
73598
+ }
73599
+ }
73600
+ },
73601
+ {
73602
+ "id": "product_form.description_preview",
73603
+ "body": {
73604
+ "package": "himalaya",
73605
+ "type": "Text",
73606
+ "props": {
73607
+ "text": "$service:himiOwnerLiteProductForm.description",
73608
+ "style": "body",
73609
+ "maxLines": 8,
73610
+ "collapseWhenEmpty": true
73611
+ }
73612
+ },
73613
+ "modifiers": {
73614
+ "hiddenPath": "$service:himiOwnerLiteProductForm.descriptionEditable"
73615
+ }
73616
+ },
73556
73617
  {
73557
73618
  "id": "product_form.form",
73558
73619
  "body": {
@@ -73639,6 +73700,9 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
73639
73700
  "multilineMaxLines": 3
73640
73701
  }
73641
73702
  }
73703
+ },
73704
+ "modifiers": {
73705
+ "hiddenPath": "$service:himiOwnerLiteProductForm.descriptionProtected"
73642
73706
  }
73643
73707
  }
73644
73708
  ],
@@ -73836,6 +73900,35 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
73836
73900
  }
73837
73901
  }
73838
73902
  },
73903
+ {
73904
+ "id": "event_form.description_note",
73905
+ "body": {
73906
+ "package": "himalaya",
73907
+ "type": "Text",
73908
+ "props": {
73909
+ "text": "$service:himiOwnerLiteEventForm.descriptionNote",
73910
+ "style": "footnote",
73911
+ "color": "textSecondary",
73912
+ "collapseWhenEmpty": true
73913
+ }
73914
+ }
73915
+ },
73916
+ {
73917
+ "id": "event_form.description_preview",
73918
+ "body": {
73919
+ "package": "himalaya",
73920
+ "type": "Text",
73921
+ "props": {
73922
+ "text": "$service:himiOwnerLiteEventForm.description",
73923
+ "style": "body",
73924
+ "maxLines": 8,
73925
+ "collapseWhenEmpty": true
73926
+ }
73927
+ },
73928
+ "modifiers": {
73929
+ "hiddenPath": "$service:himiOwnerLiteEventForm.descriptionEditable"
73930
+ }
73931
+ },
73839
73932
  {
73840
73933
  "id": "event_form.form",
73841
73934
  "body": {
@@ -73880,6 +73973,9 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
73880
73973
  "multilineMaxLines": 3
73881
73974
  }
73882
73975
  }
73976
+ },
73977
+ "modifiers": {
73978
+ "hiddenPath": "$service:himiOwnerLiteEventForm.descriptionProtected"
73883
73979
  }
73884
73980
  }
73885
73981
  ],
@@ -0,0 +1,64 @@
1
+ // [rico] #3363 — may a plain owner form round-trip this body? Ricos uses owner-post-content's
2
+ // hasRichBody; these cover the Stores HTML description and the FAQ's legacy DraftJS answer.
3
+ // [rico] #3365 moves htmlIsTrivial into @himalaya/wix-rich-content with these exact semantics.
4
+
5
+ /** A tag opener (`<p`, `</p`, `<!--`), as opposed to a bare `<` in prose ("cups < 8 cm"). */
6
+ const TAG = /<[a-z!/?]/i;
7
+
8
+ /** True when a plain text field can hold this HTML without losing anything: empty, bare text
9
+ * (no tag — a lone `<` in prose is text), or exactly one attribute-less `<p>…</p>` with no
10
+ * inner tags. */
11
+ export function htmlIsTrivial(html: string | null | undefined): boolean {
12
+ const s = (html ?? "").trim();
13
+ return !s || !TAG.test(s) || /^<p>[^<>]*<\/p>$/.test(s);
14
+ }
15
+
16
+ const ENTITIES: Record<string, string> = { "&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"", "&#39;": "'" };
17
+
18
+ /** Read-only preview text for an HTML body: block closes become lines, tags are stripped (a bare
19
+ * `<` in prose is kept), the basic entities decode, and blank lines drop. */
20
+ export function htmlPreviewText(html: string | null | undefined): string {
21
+ return (html ?? "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li|div|blockquote|pre|tr)>/gi, "\n")
22
+ .replace(/<\/?[a-z!?][^>]*>/gi, "").replace(/&(nbsp|amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m] ?? m)
23
+ .split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
24
+ }
25
+
26
+ type RicosNode = { type?: unknown; nodes?: unknown; textData?: { decorations?: unknown }; paragraphData?: unknown; [key: string]: unknown };
27
+ const onlyKeys = (o: object, allowed: string[]) => Object.keys(o).every((k) => allowed.includes(k));
28
+ const emptyObject = (v: unknown) => v === undefined || (!!v && typeof v === "object" && Object.keys(v as object).length === 0);
29
+
30
+ /** The paragraph styling a PLAIN paragraph still carries on the Ricos wire (Events v3 sends
31
+ * `{ textStyle: { textAlignment: "AUTO" }, indentation: 0 }` on every paragraph). */
32
+ function defaultParagraphData(pd: unknown): boolean {
33
+ if (pd === undefined) return true;
34
+ if (!pd || typeof pd !== "object" || !onlyKeys(pd, ["textStyle", "indentation"])) return false;
35
+ const { textStyle, indentation } = pd as { textStyle?: unknown; indentation?: unknown };
36
+ if (indentation !== undefined && indentation !== 0) return false;
37
+ if (textStyle === undefined) return true;
38
+ if (!textStyle || typeof textStyle !== "object" || !onlyKeys(textStyle, ["textAlignment"])) return false;
39
+ const align = (textStyle as { textAlignment?: unknown }).textAlignment;
40
+ return align === undefined || align === "AUTO";
41
+ }
42
+
43
+ /** True when a plain text field can round-trip this Ricos document: no document styling, and
44
+ * only paragraphs of undecorated text whose paragraph data is the wire default. Anything else —
45
+ * a heading, list, link, bold run, media node, alignment or indent — is rich. */
46
+ export function ricosIsPlain(doc: { nodes?: unknown; documentStyle?: unknown } | null | undefined): boolean {
47
+ if (!doc || !emptyObject(doc.documentStyle)) return false;
48
+ const nodes = Array.isArray(doc.nodes) ? (doc.nodes as RicosNode[]) : [];
49
+ return nodes.every((n) => !!n && typeof n === "object" && n.type === "PARAGRAPH"
50
+ && onlyKeys(n, ["type", "id", "nodes", "paragraphData"]) && defaultParagraphData(n.paragraphData)
51
+ && (Array.isArray(n.nodes) ? (n.nodes as RicosNode[]) : []).every((t) => !!t && typeof t === "object" && t.type === "TEXT"
52
+ && onlyKeys(t, ["type", "id", "textData", "nodes"]) && (!Array.isArray(t.nodes) || t.nodes.length === 0)
53
+ && !(Array.isArray(t.textData?.decorations) && t.textData.decorations.length)));
54
+ }
55
+
56
+ /** Preview text for a legacy DraftJS document: its `blocks[].text` joined by a blank line, or ""
57
+ * when the payload is not DraftJS JSON (never a throw). */
58
+ export function draftjsText(raw: string | null | undefined): string {
59
+ try {
60
+ const doc = JSON.parse(raw ?? "") as { blocks?: Array<{ text?: unknown }> } | null;
61
+ const blocks = Array.isArray(doc?.blocks) ? doc.blocks : [];
62
+ return blocks.map((b) => (typeof b?.text === "string" ? b.text : "")).join("\n\n").trim();
63
+ } catch { return ""; }
64
+ }
@@ -8,7 +8,11 @@
8
8
 
9
9
  import { defineService } from "@himalaya/state";
10
10
  import { getEvent, updateEvent, type WixEvent } from "@himalaya/wix-events";
11
- import { normalizeEvent, eventDescription } from "../_shared/himi-events.js";
11
+ import { normalizeEvent } from "../_shared/himi-events.js";
12
+ import { postBodyText, plainPostBody } from "../_shared/owner-post-content.js";
13
+ // Not owner-post-content's hasRichBody: Events v3 sends default paragraphData on EVERY paragraph
14
+ // (which hasRichBody reads as formatting) and a documentStyle it never looks at.
15
+ import { ricosIsPlain } from "../_shared/owner-rich-guard.js";
12
16
  import { ownerLoadError, isBridgeUnavailable, isOwnerPreviewDemo, shouldQueueOwnerWrite } from "../_shared/owner-load-error.js";
13
17
  import { DEMO_EVENTS, DEMO_NOTE, DEMO_SAVE_NOTE, demoRefusalPatch } from "../_shared/owner-preview-demo.js";
14
18
  import { stashCopy, copyParam } from "../_shared/himi-copy.js";
@@ -35,7 +39,17 @@ interface EventFormState {
35
39
  id: string;
36
40
  heading: string;
37
41
  title: string;
42
+ /** [rico] #3363 — the Events v3 Ricos `description` as text (read with `fields=TEXTS`). While
43
+ * `descriptionProtected`, it is a read-only preview. */
38
44
  description: string;
45
+ /** The description as loaded (or last saved). A save writes `description` only when the
46
+ * submitted value differs from it, so an untouched body is never re-written. */
47
+ originalDescription: string;
48
+ /** A Ricos description with formatting or media the plain field would flatten: it loads locked,
49
+ * the descriptor shows a preview + `descriptionNote`, and an edit is refused. */
50
+ descriptionProtected: boolean;
51
+ descriptionEditable: boolean;
52
+ descriptionNote: string;
39
53
  when: string;
40
54
  location: string;
41
55
  status: "idle" | "loading" | "saving" | "saved" | "error";
@@ -94,6 +108,10 @@ function shape(s: Partial<EventFormState>): EventFormState {
94
108
  heading: s.heading ?? copyParam("ownerEventForm.headingEdit", "Edit event"),
95
109
  title: s.title ?? "",
96
110
  description: s.description ?? "",
111
+ originalDescription: s.originalDescription ?? s.description ?? "",
112
+ descriptionProtected: s.descriptionProtected ?? false,
113
+ descriptionEditable: !(s.descriptionProtected ?? false),
114
+ descriptionNote: s.descriptionProtected ? copyParam("ownerEventForm.richDescriptionNote", "This description has formatting or media. Edit it in your site dashboard to keep that content intact; the title can be changed here.") : "",
97
115
  when: s.when ?? "—",
98
116
  location: s.location ?? "—",
99
117
  status: s.status ?? "idle",
@@ -145,7 +163,8 @@ const svc = defineService<EventFormState, {
145
163
  return shape({ ...s, status: "error", error: "Sample item not found." });
146
164
  }
147
165
  try {
148
- const res = await getEvent(id);
166
+ // `TEXTS` is the v3 fieldset that returns the Ricos `description`; a bare GET omits it.
167
+ const res = await getEvent(id, { fields: ["TEXTS"] });
149
168
  const ev = (res.event ?? res) as WixEvent;
150
169
  if (!ev || !(ev as any).id) {
151
170
  return shape({ ...s, mode, id, status: "error", error: copyParam("ownerEventForm.errorNotFound", "Event not found") });
@@ -157,7 +176,14 @@ const svc = defineService<EventFormState, {
157
176
  id,
158
177
  heading: copyParam("ownerEventForm.headingEdit", "Edit event"),
159
178
  title: ev.title ?? "",
160
- description: (eventDescription(ev) || "").trim(),
179
+ // The v3 Ricos `description` only: `shortDescription` and `about` are separate fields
180
+ // this form never writes, so showing them here would invite an edit that lands elsewhere.
181
+ ...(() => {
182
+ const raw = (ev as unknown as { description?: unknown }).description;
183
+ const doc = raw && typeof raw === "object" && Array.isArray((raw as { nodes?: unknown }).nodes) ? raw as Parameters<typeof postBodyText>[0] : null;
184
+ const text = doc ? postBodyText(doc) : typeof raw === "string" ? raw.trim() : "";
185
+ return { description: text, originalDescription: text, descriptionProtected: doc ? !ricosIsPlain(doc as { nodes?: unknown; documentStyle?: unknown }) : false };
186
+ })(),
161
187
  when: row.dateLine || "—",
162
188
  location: row.location || "—",
163
189
  status: "idle",
@@ -172,6 +198,8 @@ const svc = defineService<EventFormState, {
172
198
  ...s, mode, id, heading: "Edit event",
173
199
  title: String(cached.title ?? cached.name ?? ""),
174
200
  description: String(cached.description ?? ""),
201
+ // An older cached row carries no verdict, so it stays locked: fail closed.
202
+ originalDescription: String(cached.description ?? ""), descriptionProtected: cached.descriptionProtected !== "false",
175
203
  when: String(cached.when ?? cached.dateLine ?? "—"),
176
204
  location: String(cached.location ?? "—"),
177
205
  status: "idle", error: "", savedMessage: "",
@@ -195,18 +223,23 @@ const svc = defineService<EventFormState, {
195
223
  const v = values ?? {};
196
224
  const title = String(v.title ?? s.title).trim();
197
225
  const description = String(v.description ?? s.description).trim();
226
+ // A queue replay boots a FRESH worker (empty baseline), so it carries the original verdict.
227
+ const descriptionEdited = typeof v.descriptionEdited === "string" ? v.descriptionEdited === "true" : description !== s.originalDescription.trim();
228
+ if (descriptionEdited && s.descriptionProtected) return shape({ ...s, status: "error", savedMessage: "", error: copyParam("ownerEventForm.richDescriptionError", "This description has formatting or media. Edit it in your site dashboard to keep that content intact.") });
198
229
  if (!title) return shape({ ...s, status: "error", error: copyParam("ownerEventForm.titleRequired", "Title is required"), savedMessage: "" });
199
230
  if (!s.id) return shape({ ...s, status: "error", error: copyParam("ownerEventForm.errorNoEvent", "No event to edit."), savedMessage: "" });
200
231
 
201
232
  // updateEvent(id, event) wraps the body as { event } internally, so pass the
202
- // event fields directly. Only the text the form can safely edit is written.
203
- const event: any = {
204
- title,
205
- ...(description ? { description } : {}),
233
+ // event fields directly. Only the text the form can safely edit is written: the
234
+ // description only when edited, and always as a Ricos document (never a string).
235
+ // A local structural type, not WixEventWrite: templates compile against the
236
+ // PUBLISHED SDK until its next release.
237
+ const event: { title: string; description?: { nodes: unknown[] } } = {
238
+ title, ...(descriptionEdited ? { description: description ? plainPostBody(description) as { nodes: unknown[] } : { nodes: [] } } : {}),
206
239
  };
207
240
 
208
241
  // Optimistic local write — the detail/list repaint from cache immediately.
209
- const localRow: DbEntity = { id: s.id, name: title, title, description, when: s.when, location: s.location };
242
+ const localRow: DbEntity = { id: s.id, name: title, title, description, descriptionProtected: String(s.descriptionProtected), when: s.when, location: s.location };
210
243
  await cachePut(localRow);
211
244
  try {
212
245
  await updateEvent(s.id, event);
@@ -217,6 +250,7 @@ const svc = defineService<EventFormState, {
217
250
  heading: copyParam("ownerEventForm.headingEdit", "Edit event"),
218
251
  title,
219
252
  description,
253
+ originalDescription: descriptionEdited ? description : s.originalDescription,
220
254
  status: "saved",
221
255
  error: "",
222
256
  savedMessage: copyParam("ownerEventForm.savedMessage", "Event saved."),
@@ -230,10 +264,10 @@ const svc = defineService<EventFormState, {
230
264
  if (s.demoNote) return demoRefusal(s);
231
265
  // Real device offline: the optimistic cache row already shows; queue the
232
266
  // write to replay (WriteQueue, idempotent) when connectivity returns.
233
- await queueWrite("tier5.himiOwnerLiteEventForm.submit", { id: s.id, title, description });
267
+ await queueWrite("tier5.himiOwnerLiteEventForm.submit", { id: s.id, title, description, descriptionEdited: String(descriptionEdited) });
234
268
  return shape({
235
269
  ...s, mode: "edit", id: s.id, heading: copyParam("ownerEventForm.headingEdit", "Edit event"),
236
- title, description, status: "saved", error: "",
270
+ title, description, originalDescription: descriptionEdited ? description : s.originalDescription, status: "saved", error: "",
237
271
  savedMessage: copyParam("ownerEventForm.savedOffline", "Saved offline — will sync when you reconnect."),
238
272
  savedId: s.id,
239
273
  });
@@ -16,7 +16,7 @@ import {
16
16
  type WixProductWrite,
17
17
  } from "@himalaya/wix-stores";
18
18
  import { get as dbGet, upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "../_shared/owner-local-db.js";
19
- import { plainText } from "../_shared/himi-store.js";
19
+ import { htmlIsTrivial, htmlPreviewText } from "../_shared/owner-rich-guard.js";
20
20
  import { ownerLoadError, isBridgeUnavailable, isOwnerPreviewDemo, shouldQueueOwnerWrite } from "../_shared/owner-load-error.js";
21
21
  import { DEMO_PRODUCTS, DEMO_NOTE, DEMO_SAVE_NOTE, demoRefusalPatch, editablePrice } from "../_shared/owner-preview-demo.js";
22
22
  import { stashCopy, copyParam } from "../_shared/himi-copy.js";
@@ -46,6 +46,14 @@ interface ProductFormState {
46
46
  price: string;
47
47
  sku: string;
48
48
  description: string;
49
+ /** [rico] #3363 — the description as loaded (or last saved). A save sends `description` only
50
+ * when the submitted value differs from it, so an untouched body is never re-written. */
51
+ originalDescription: string;
52
+ /** A rich HTML description (more than one bare `<p>`) the plain field would flatten: it loads
53
+ * locked, the descriptor shows `description` as a preview + `descriptionNote`, and an edit is refused. */
54
+ descriptionProtected: boolean;
55
+ descriptionEditable: boolean;
56
+ descriptionNote: string;
49
57
  visible: string;
50
58
  status: "idle" | "loading" | "saving" | "saved" | "error";
51
59
  error: string;
@@ -115,6 +123,10 @@ function shape(s: Partial<ProductFormState>): ProductFormState {
115
123
  price: s.price ?? "",
116
124
  sku: s.sku ?? "",
117
125
  description: s.description ?? "",
126
+ originalDescription: s.originalDescription ?? s.description ?? "",
127
+ descriptionProtected: s.descriptionProtected ?? false,
128
+ descriptionEditable: !(s.descriptionProtected ?? false),
129
+ descriptionNote: s.descriptionProtected ? copyParam("ownerProductForm.richDescriptionNote", "This description has formatting or media. Edit it in your site dashboard to keep that content intact; name, price and SKU can be changed here.") : "",
118
130
  visible: s.visible ?? "true",
119
131
  status: s.status ?? "idle",
120
132
  error: s.error ?? "",
@@ -177,7 +189,7 @@ const svc = defineService<ProductFormState, {
177
189
  name: (p as any).name ?? "",
178
190
  price: priceOf(p),
179
191
  sku: (p as any).sku ?? "",
180
- description: plainText((p as any).description || ""),
192
+ ...((html: string) => ({ description: htmlPreviewText(html), originalDescription: htmlPreviewText(html), descriptionProtected: !htmlIsTrivial(html) }))(String((p as any).description ?? "")),
181
193
  visible: (p as any).visible !== false ? "true" : "false",
182
194
  status: "idle",
183
195
  error: "",
@@ -190,6 +202,8 @@ const svc = defineService<ProductFormState, {
190
202
  return shape({
191
203
  ...s, mode: "edit", id, heading: "Edit product",
192
204
  name: String(cached.name ?? ""), price: editablePrice(String(cached.price ?? "")), sku: String(cached.sku ?? ""),
205
+ // An older cached row carries no verdict, so it stays locked: fail closed.
206
+ description: String(cached.description ?? ""), originalDescription: String(cached.description ?? ""), descriptionProtected: cached.descriptionProtected !== "false",
193
207
  visible: cached.visible === "false" ? "false" : "true",
194
208
  status: "idle", error: "", savedMessage: "",
195
209
  });
@@ -217,6 +231,9 @@ const svc = defineService<ProductFormState, {
217
231
  const price = editablePrice(rawPrice);
218
232
  const sku = String(v.sku ?? s.sku).trim();
219
233
  const description = String(v.description ?? s.description).trim();
234
+ // A queue replay boots a FRESH worker (empty baseline), so it carries the original verdict.
235
+ const descriptionEdited = typeof v.descriptionEdited === "string" ? v.descriptionEdited === "true" : description !== s.originalDescription.trim();
236
+ if (descriptionEdited && s.descriptionProtected) return shape({ ...s, status: "error", savedMessage: "", error: copyParam("ownerProductForm.richDescriptionError", "This description has formatting or media. Edit it in your site dashboard to keep that content intact.") });
220
237
  const visible = boolString(v.visible, s.visible) === "true";
221
238
  if (!name) return shape({ ...s, status: "error", error: copyParam("ownerProductForm.nameRequired", "Name is required"), savedMessage: "" });
222
239
  const priceNum = Number(price);
@@ -232,12 +249,12 @@ const svc = defineService<ProductFormState, {
232
249
  priceData: { price: priceNum, currency: CURRENCY },
233
250
  visible,
234
251
  ...(sku ? { sku } : {}),
235
- ...(description ? { description } : {}),
252
+ ...(descriptionEdited ? { description } : {}),
236
253
  ...(s.mode === "edit" && s.id ? {} : { productType: "physical" }),
237
254
  };
238
255
 
239
256
  // Optimistic local write — the list repaints from cache immediately (offline or not).
240
- const localRow = (id: string): DbEntity => ({ id, name, price, sku, visible: visible ? "true" : "false", statusLabel: visible ? "Visible" : "Hidden" });
257
+ const localRow = (id: string): DbEntity => ({ id, name, price, sku, description, descriptionProtected: s.descriptionProtected ? "true" : "false", visible: visible ? "true" : "false", statusLabel: visible ? "Visible" : "Hidden" });
241
258
  const localId = s.mode === "edit" && s.id ? s.id : `local-${Date.now()}`;
242
259
  await cachePut(localRow(localId));
243
260
  try {
@@ -259,6 +276,7 @@ const svc = defineService<ProductFormState, {
259
276
  price,
260
277
  sku,
261
278
  description,
279
+ originalDescription: descriptionEdited ? description : s.originalDescription,
262
280
  visible: visible ? "true" : "false",
263
281
  status: "saved",
264
282
  error: "",
@@ -273,9 +291,10 @@ const svc = defineService<ProductFormState, {
273
291
  if (s.demoNote) return demoRefusal(s);
274
292
  // Real device offline: the optimistic cache row already shows; queue the
275
293
  // write to replay (WriteQueue, idempotent) when connectivity returns.
276
- await queueWrite("tier5.himiOwnerLiteProductForm.submit", { id: s.id, name, price, sku, visible: String(visible), mode: s.mode });
294
+ await queueWrite("tier5.himiOwnerLiteProductForm.submit", { id: s.id, name, price, sku, description, descriptionEdited: String(descriptionEdited), visible: String(visible), mode: s.mode });
277
295
  return shape({
278
296
  ...s, mode: "edit", id: localId, name, price, sku, description,
297
+ originalDescription: descriptionEdited ? description : s.originalDescription,
279
298
  visible: visible ? "true" : "false", status: "saved", error: "",
280
299
  savedMessage: copyParam("ownerProductForm.savedOffline", "Saved offline — will sync when you reconnect."),
281
300
  savedId: localId,
@@ -73953,6 +73953,35 @@ const SCREENS = runtimeSetting({
73953
73953
  }
73954
73954
  }
73955
73955
  },
73956
+ {
73957
+ "id": "product_form.description_note",
73958
+ "body": {
73959
+ "package": "himalaya",
73960
+ "type": "Text",
73961
+ "props": {
73962
+ "text": "$service:himiOwnerLiteProductForm.descriptionNote",
73963
+ "style": "footnote",
73964
+ "color": "textSecondary",
73965
+ "collapseWhenEmpty": true
73966
+ }
73967
+ }
73968
+ },
73969
+ {
73970
+ "id": "product_form.description_preview",
73971
+ "body": {
73972
+ "package": "himalaya",
73973
+ "type": "Text",
73974
+ "props": {
73975
+ "text": "$service:himiOwnerLiteProductForm.description",
73976
+ "style": "body",
73977
+ "maxLines": 8,
73978
+ "collapseWhenEmpty": true
73979
+ }
73980
+ },
73981
+ "modifiers": {
73982
+ "hiddenPath": "$service:himiOwnerLiteProductForm.descriptionEditable"
73983
+ }
73984
+ },
73956
73985
  {
73957
73986
  "id": "product_form.form",
73958
73987
  "body": {
@@ -74039,6 +74068,9 @@ const SCREENS = runtimeSetting({
74039
74068
  "multilineMaxLines": 3
74040
74069
  }
74041
74070
  }
74071
+ },
74072
+ "modifiers": {
74073
+ "hiddenPath": "$service:himiOwnerLiteProductForm.descriptionProtected"
74042
74074
  }
74043
74075
  }
74044
74076
  ],
@@ -74566,6 +74598,35 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
74566
74598
  }
74567
74599
  }
74568
74600
  },
74601
+ {
74602
+ "id": "product_form.description_note",
74603
+ "body": {
74604
+ "package": "himalaya",
74605
+ "type": "Text",
74606
+ "props": {
74607
+ "text": "$service:himiOwnerLiteProductForm.descriptionNote",
74608
+ "style": "footnote",
74609
+ "color": "textSecondary",
74610
+ "collapseWhenEmpty": true
74611
+ }
74612
+ }
74613
+ },
74614
+ {
74615
+ "id": "product_form.description_preview",
74616
+ "body": {
74617
+ "package": "himalaya",
74618
+ "type": "Text",
74619
+ "props": {
74620
+ "text": "$service:himiOwnerLiteProductForm.description",
74621
+ "style": "body",
74622
+ "maxLines": 8,
74623
+ "collapseWhenEmpty": true
74624
+ }
74625
+ },
74626
+ "modifiers": {
74627
+ "hiddenPath": "$service:himiOwnerLiteProductForm.descriptionEditable"
74628
+ }
74629
+ },
74569
74630
  {
74570
74631
  "id": "product_form.form",
74571
74632
  "body": {
@@ -74652,6 +74713,9 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
74652
74713
  "multilineMaxLines": 3
74653
74714
  }
74654
74715
  }
74716
+ },
74717
+ "modifiers": {
74718
+ "hiddenPath": "$service:himiOwnerLiteProductForm.descriptionProtected"
74655
74719
  }
74656
74720
  }
74657
74721
  ],
@@ -0,0 +1,64 @@
1
+ // [rico] #3363 — may a plain owner form round-trip this body? Ricos uses owner-post-content's
2
+ // hasRichBody; these cover the Stores HTML description and the FAQ's legacy DraftJS answer.
3
+ // [rico] #3365 moves htmlIsTrivial into @himalaya/wix-rich-content with these exact semantics.
4
+
5
+ /** A tag opener (`<p`, `</p`, `<!--`), as opposed to a bare `<` in prose ("cups < 8 cm"). */
6
+ const TAG = /<[a-z!/?]/i;
7
+
8
+ /** True when a plain text field can hold this HTML without losing anything: empty, bare text
9
+ * (no tag — a lone `<` in prose is text), or exactly one attribute-less `<p>…</p>` with no
10
+ * inner tags. */
11
+ export function htmlIsTrivial(html: string | null | undefined): boolean {
12
+ const s = (html ?? "").trim();
13
+ return !s || !TAG.test(s) || /^<p>[^<>]*<\/p>$/.test(s);
14
+ }
15
+
16
+ const ENTITIES: Record<string, string> = { "&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"", "&#39;": "'" };
17
+
18
+ /** Read-only preview text for an HTML body: block closes become lines, tags are stripped (a bare
19
+ * `<` in prose is kept), the basic entities decode, and blank lines drop. */
20
+ export function htmlPreviewText(html: string | null | undefined): string {
21
+ return (html ?? "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li|div|blockquote|pre|tr)>/gi, "\n")
22
+ .replace(/<\/?[a-z!?][^>]*>/gi, "").replace(/&(nbsp|amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m] ?? m)
23
+ .split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
24
+ }
25
+
26
+ type RicosNode = { type?: unknown; nodes?: unknown; textData?: { decorations?: unknown }; paragraphData?: unknown; [key: string]: unknown };
27
+ const onlyKeys = (o: object, allowed: string[]) => Object.keys(o).every((k) => allowed.includes(k));
28
+ const emptyObject = (v: unknown) => v === undefined || (!!v && typeof v === "object" && Object.keys(v as object).length === 0);
29
+
30
+ /** The paragraph styling a PLAIN paragraph still carries on the Ricos wire (Events v3 sends
31
+ * `{ textStyle: { textAlignment: "AUTO" }, indentation: 0 }` on every paragraph). */
32
+ function defaultParagraphData(pd: unknown): boolean {
33
+ if (pd === undefined) return true;
34
+ if (!pd || typeof pd !== "object" || !onlyKeys(pd, ["textStyle", "indentation"])) return false;
35
+ const { textStyle, indentation } = pd as { textStyle?: unknown; indentation?: unknown };
36
+ if (indentation !== undefined && indentation !== 0) return false;
37
+ if (textStyle === undefined) return true;
38
+ if (!textStyle || typeof textStyle !== "object" || !onlyKeys(textStyle, ["textAlignment"])) return false;
39
+ const align = (textStyle as { textAlignment?: unknown }).textAlignment;
40
+ return align === undefined || align === "AUTO";
41
+ }
42
+
43
+ /** True when a plain text field can round-trip this Ricos document: no document styling, and
44
+ * only paragraphs of undecorated text whose paragraph data is the wire default. Anything else —
45
+ * a heading, list, link, bold run, media node, alignment or indent — is rich. */
46
+ export function ricosIsPlain(doc: { nodes?: unknown; documentStyle?: unknown } | null | undefined): boolean {
47
+ if (!doc || !emptyObject(doc.documentStyle)) return false;
48
+ const nodes = Array.isArray(doc.nodes) ? (doc.nodes as RicosNode[]) : [];
49
+ return nodes.every((n) => !!n && typeof n === "object" && n.type === "PARAGRAPH"
50
+ && onlyKeys(n, ["type", "id", "nodes", "paragraphData"]) && defaultParagraphData(n.paragraphData)
51
+ && (Array.isArray(n.nodes) ? (n.nodes as RicosNode[]) : []).every((t) => !!t && typeof t === "object" && t.type === "TEXT"
52
+ && onlyKeys(t, ["type", "id", "textData", "nodes"]) && (!Array.isArray(t.nodes) || t.nodes.length === 0)
53
+ && !(Array.isArray(t.textData?.decorations) && t.textData.decorations.length)));
54
+ }
55
+
56
+ /** Preview text for a legacy DraftJS document: its `blocks[].text` joined by a blank line, or ""
57
+ * when the payload is not DraftJS JSON (never a throw). */
58
+ export function draftjsText(raw: string | null | undefined): string {
59
+ try {
60
+ const doc = JSON.parse(raw ?? "") as { blocks?: Array<{ text?: unknown }> } | null;
61
+ const blocks = Array.isArray(doc?.blocks) ? doc.blocks : [];
62
+ return blocks.map((b) => (typeof b?.text === "string" ? b.text : "")).join("\n\n").trim();
63
+ } catch { return ""; }
64
+ }