@wix/himalaya-cli 0.1172.0 → 0.1174.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 (68) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/extensions.d.ts +13 -3
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/products-v3.d.ts +13 -1
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-stores.mjs +10 -3
  4. package/dist/cli.mjs +38 -16
  5. package/dist/preview-assets.generated.bin +0 -0
  6. package/guides/sdk-reference.json +15 -3
  7. package/package.json +2 -2
  8. package/templates/beauty-salon-owner/dev/index.ts +172 -6
  9. package/templates/beauty-salon-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  10. package/templates/beauty-salon-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  11. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  12. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  13. package/templates/community-club-owner/dev/index.ts +257 -8
  14. package/templates/community-club-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  15. package/templates/community-club-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  16. package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  17. package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  18. package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  19. package/templates/events-meetups-owner/dev/index.ts +87 -4
  20. package/templates/events-meetups-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  21. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  22. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  23. package/templates/gym-fitness-owner/dev/index.ts +257 -8
  24. package/templates/gym-fitness-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  25. package/templates/gym-fitness-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  26. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  27. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  28. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  29. package/templates/nonprofit-cause-owner/dev/index.ts +87 -4
  30. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  31. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  32. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  33. package/templates/owner-lite/dev/index.ts +512 -17
  34. package/templates/owner-lite/tier5-src/_shared/owner-editor-value.ts +71 -5
  35. package/templates/owner-lite/tier5-src/_shared/owner-rich-guard.ts +1 -9
  36. package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  37. package/templates/owner-lite/tier5-src/himi-owner-lite-faq-question-form/index.ts +77 -10
  38. package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  39. package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  40. package/templates/podcast-creator-owner/dev/index.ts +257 -8
  41. package/templates/podcast-creator-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  42. package/templates/podcast-creator-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  43. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  44. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  45. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  46. package/templates/portfolio-creative-owner/dev/index.ts +172 -6
  47. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  48. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  49. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  50. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  51. package/templates/restaurant-owner/dev/index.ts +257 -8
  52. package/templates/restaurant-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  53. package/templates/restaurant-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  54. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  55. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  56. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  57. package/templates/retail-store-owner/dev/index.ts +172 -6
  58. package/templates/retail-store-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  59. package/templates/retail-store-owner/tier5-src/_shared/owner-rich-guard.ts +1 -9
  60. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  61. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +130 -27
  62. package/templates/yoga-studio-owner/dev/index.ts +87 -4
  63. package/templates/yoga-studio-owner/tier5-src/_shared/owner-editor-value.ts +71 -5
  64. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +117 -51
  65. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +17 -42
  66. package/templates/events-meetups-owner/tier5-src/_shared/owner-rich-guard.ts +0 -35
  67. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-rich-guard.ts +0 -35
  68. package/templates/yoga-studio-owner/tier5-src/_shared/owner-rich-guard.ts +0 -35
@@ -2,7 +2,7 @@
2
2
  // this file adds the read-only previews the forms show for a locked body, and the FAQ's legacy DraftJS answer.
3
3
  // Inside the platform source this import is a relative path, so node test runs resolve it; an app's copy
4
4
  // imports "@himalaya/wix-rich-content".
5
- import { htmlIsTrivial, isPlainRicos } from "@himalaya/wix-rich-content";
5
+ import { htmlIsTrivial } from "@himalaya/wix-rich-content";
6
6
 
7
7
  export { htmlIsTrivial };
8
8
 
@@ -16,14 +16,6 @@ export function htmlPreviewText(html: string | null | undefined): string {
16
16
  .split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
17
17
  }
18
18
 
19
- /** True when a plain text field can round-trip this Ricos document: no document styling, and
20
- * only paragraphs of undecorated text whose paragraph data is the wire default (Events v3 sends
21
- * `{ textStyle: { textAlignment: "AUTO" }, indentation: 0 }` on every paragraph). Anything else —
22
- * a heading, list, link, bold run, media node, alignment or indent — is rich, and so is a missing
23
- * document (callers decide what an absent body means). */
24
- export const ricosIsPlain = (doc: { nodes?: unknown; documentStyle?: unknown } | null | undefined): boolean =>
25
- !!doc && isPlainRicos(doc);
26
-
27
19
  /** Preview text for a legacy DraftJS document: its `blocks[].text` joined by a blank line, or ""
28
20
  * when the payload is not DraftJS JSON (never a throw). */
29
21
  export function draftjsText(raw: string | null | undefined): string {
@@ -4,8 +4,7 @@
4
4
  // screen (himiOwnerLitePost.publish). Bound by buildForm ($service:himiOwnerLitePostForm.*).
5
5
 
6
6
  import { postBodyText, plainPostBody, postCoverUrl } from "../_shared/owner-post-content.js";
7
- import { editorValue } from "../_shared/owner-editor-value.js";
8
- import { parseRicosJson } from "@himalaya/wix-rich-content";
7
+ import { editorSubmitValue, queuedDocument, editorChanged, resetHydratedEditor } from "../_shared/owner-editor-value.js";
9
8
  import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
10
9
  import { wixCall } from "@himalaya/wix-site-media";
11
10
  import { wixMediaUrl, WIX_DETAIL_IMAGE } from "../_shared/himi-store.js";
@@ -43,15 +42,10 @@ interface PostFormState {
43
42
  bodyJson: string;
44
43
  /** The "Post details" sheet (excerpt + cover) is open. */
45
44
  detailsOpen: boolean;
46
- /** The editor's on_change fired since the body was last loaded or saved. The bag ALWAYS holds a
47
- * document once the editor is ready — the engine's own serialization of the seed, which is not
48
- * byte-equal to it — so only this says the owner actually edited the body. */
45
+ /** The editor gate (`EditorGate` in _shared/owner-editor-value.ts): the editor's on_change fired since
46
+ * the body was loaded or saved, and (edit mode) the editor is hidden until this run's load lands. */
49
47
  bodyDirty: boolean;
50
- /** Edit mode, until `load` lands: the descriptor hides the editor, because a provisional editor
51
- * the owner typed into keeps that text when the real document arrives (the seed latch). */
52
48
  bodyPending: boolean;
53
- /** When a save last wrote the body (ms), so its own trailing on_change does not undo "Post updated.". */
54
- bodySavedAt: number;
55
49
  coverUrl: string;
56
50
  originalCoverUrl: string;
57
51
  coverPreview: string;
@@ -104,9 +98,7 @@ function boolString(value: unknown, fallback: string): string {
104
98
  return fallback;
105
99
  }
106
100
  /** What every load that publishes a document resets: the editor may show it, nothing is edited yet. */
107
- const BODY_LOADED = { bodyPending: false, bodyDirty: false, bodySavedAt: 0, detailsOpen: false } as const;
108
- /** How long after a body save its own debounced on_change may still arrive (the arm waits 500 ms). */
109
- const TRAILING_CHANGE_MS = 1500;
101
+ const BODY_LOADED = { bodyPending: false, bodyDirty: false, detailsOpen: false } as const;
110
102
  const valueOf = (arg: unknown): string =>
111
103
  String(typeof arg === "string" ? arg : (arg as { value?: unknown; text?: unknown } | undefined)?.value ?? (arg as { text?: unknown } | undefined)?.text ?? "");
112
104
  const docJson = (doc: unknown): string => (doc ? JSON.stringify(doc) : "");
@@ -128,7 +120,6 @@ function shape(s: Partial<PostFormState>): PostFormState {
128
120
  detailsOpen: s.detailsOpen ?? false,
129
121
  bodyDirty: s.bodyDirty ?? false,
130
122
  bodyPending: s.bodyPending ?? false,
131
- bodySavedAt: s.bodySavedAt ?? 0,
132
123
  coverUrl: s.coverUrl ?? "",
133
124
  originalCoverUrl: s.originalCoverUrl ?? "",
134
125
  coverPreview: wixMediaUrl(s.coverUrl ?? "", WIX_DETAIL_IMAGE),
@@ -244,13 +235,8 @@ const svc = defineService<PostFormState, {
244
235
  setExcerpt: (s, arg) => shape({ ...s, excerpt: valueOf(arg), error: "" }),
245
236
  detailsOpen: (s) => shape({ ...s, detailsOpen: true }),
246
237
  detailsClose: (s) => shape({ ...s, detailsOpen: false }),
247
- // The editor's debounced `on_change` carries `args.dirty` only; the document stays in the form
248
- // bag. A body edited after a save is no longer saved, so drop the stale confirmation — unless
249
- // this is the trailing on_change of the very edit that save wrote.
250
- bodyChanged: (s) => {
251
- const trailing = s.status === "saved" && s.bodySavedAt > 0 && Date.now() - s.bodySavedAt < TRAILING_CHANGE_MS;
252
- return shape({ ...s, bodyDirty: true, ...(trailing ? {} : { savedMessage: "", status: s.status === "saved" ? "idle" : s.status }) });
253
- },
238
+ // The editor's `on_change` carries `args.dirty` only; the document stays in the form bag.
239
+ bodyChanged: (s) => shape({ ...s, bodyDirty: true, ...editorChanged(s) }),
254
240
  submit: async (s, values) => {
255
241
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
256
242
  const v = values ?? {};
@@ -277,14 +263,15 @@ const svc = defineService<PostFormState, {
277
263
  let bodyRaw = "";
278
264
  let bodyTooLarge = false;
279
265
  if (replay) {
280
- const queued = typeof v.richContentJson === "string" ? parseRicosJson(v.richContentJson) : null;
281
- if (queued) { richContent = queued as unknown as WixRichContent; bodyRaw = String(v.richContentJson); }
266
+ const queued = queuedDocument(v.richContentJson);
267
+ // A queued body that no longer reads (or is past the limits) writes NOTHING, and says so.
268
+ if (queued.kind === "invalid" || queued.kind === "too_large") return shape({ ...s, status: "error", error: copyParam("ownerPostForm.queuedBodyError", "The post body saved offline could not be read, so nothing was written. Edit it again."), savedMessage: "" });
269
+ if (queued.kind === "doc") { richContent = queued.doc as unknown as WixRichContent; bodyRaw = queued.json; }
282
270
  else if (v.preserveBody === "false") { richContent = plainPostBody(String(v.body ?? "")); bodyRaw = docJson(richContent); }
283
271
  } else {
284
- // Only an edit the editor reported counts: before load lands the editor is hidden (a body
285
- // here was typed into the provisional empty editor), and an untouched bag holds the engine's
286
- // re-serialization of the seed, which must not be written back.
287
- const ev = s.bodyPending || !s.bodyDirty ? { kind: "absent" as const } : editorValue(v.body, s.bodyJson);
272
+ // Only an edit the editor reported counts (EditorGate): before load lands the editor is hidden,
273
+ // and an untouched bag holds the engine's re-serialization of the seed.
274
+ const ev = editorSubmitValue(v.body, s.bodyJson, { pending: s.bodyPending, dirty: s.bodyDirty });
288
275
  if (ev.kind === "invalid") return shape({ ...s, status: "error", error: copyParam("ownerPostForm.bodyInvalid", "Couldn’t read the post body from the editor. Nothing was saved; try again."), savedMessage: "" });
289
276
  if (ev.kind === "doc") { richContent = ev.doc as unknown as WixRichContent; bodyRaw = ev.json; }
290
277
  // A body past the rich-content limits is not written (every viewer would show its
@@ -308,7 +295,7 @@ const svc = defineService<PostFormState, {
308
295
  const savedState = (id: string, savedMessage: string) => shape({
309
296
  ...s, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
310
297
  title, excerpt, body: postBodyText(kept), richContent: kept, bodyJson: bodyEdited ? bodyRaw : s.bodyJson,
311
- bodyDirty: bodyEdited ? false : s.bodyDirty, bodySavedAt: bodyEdited ? Date.now() : 0,
298
+ bodyDirty: bodyEdited ? false : s.bodyDirty,
312
299
  coverUrl, originalCoverUrl: coverUrl, isFeatured: isFeatured ? "true" : "false",
313
300
  status: "saved", savedMessage, savedId: id,
314
301
  error: bodyTooLarge ? copyParam("ownerPostForm.bodyTooLarge", "The post body is too long to save, so it was left as it was. Your other changes were saved.") : "",
@@ -366,20 +353,8 @@ const svc = defineService<PostFormState, {
366
353
  },
367
354
  });
368
355
 
369
- // [rico] #3371 — adopter contract (#3368's review): the editor seeds ONCE, from the first
370
- // document `bodyJson` holds. A cold boot hydrates this service from its last persisted snapshot, so
371
- // that first value would be the document as it was when this screen last ran — and a Save after
372
- // typing would write it over whatever the post holds now. Blank the hydrated seed before anything
373
- // renders: the editor stays provisional (writes nothing) until `load` publishes the fresh document.
374
- // In place, at boot, because no action has run yet; the state object is the runtime's own merge.
375
- {
376
- const booted = (svc as { getState?: () => Partial<PostFormState> | undefined }).getState?.();
377
- if (booted && typeof booted === "object") {
378
- if (booted.bodyJson) booted.bodyJson = "";
379
- // Nor does it carry this run's UI over: the editor waits for this run's load, the sheet is shut.
380
- booted.bodyPending = startMode === "edit";
381
- booted.detailsOpen = false;
382
- }
383
- }
356
+ // [rico] #3371 — adopter contract: the editor seeds once, so a cold boot must not seed it with the last
357
+ // run's document. Nor does it carry this run's UI over: the details sheet is shut.
358
+ resetHydratedEditor(svc, { json: "bodyJson", pending: "bodyPending", dirty: "bodyDirty" }, startMode === "edit", { detailsOpen: false });
384
359
 
385
360
  export { svc };
@@ -17,6 +17,10 @@ import {
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
19
  import { htmlIsTrivial, htmlPreviewText } from "../_shared/owner-rich-guard.js";
20
+ import { postBodyText, plainPostBody } from "../_shared/owner-post-content.js";
21
+ import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, resetHydratedEditor } from "../_shared/owner-editor-value.js";
22
+ import { parseRicosJson } from "@himalaya/wix-rich-content";
23
+ import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
20
24
  import { ownerLoadError, isBridgeUnavailable, isOwnerPreviewDemo, shouldQueueOwnerWrite } from "../_shared/owner-load-error.js";
21
25
  import { DEMO_PRODUCTS, DEMO_NOTE, DEMO_SAVE_NOTE, demoRefusalPatch, editablePrice } from "../_shared/owner-preview-demo.js";
22
26
  import { stashCopy, copyParam } from "../_shared/himi-copy.js";
@@ -45,12 +49,27 @@ interface ProductFormState {
45
49
  name: string;
46
50
  price: string;
47
51
  sku: string;
52
+ /** The description as text: the plain field's value, the locked preview, and the cached row's mirror. */
48
53
  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. */
54
+ /** The description as loaded (or last saved). The plain field sends `description` only when the
55
+ * submitted value differs from it, so an untouched body is never re-written. */
51
56
  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. */
57
+ /** How THIS record's description is edited:
58
+ * "editor" — the RichContentEditor, seeded with `descriptionJson`: a Catalog-V3 product (its Ricos
59
+ * document, or trivial HTML as paragraphs), and sample data;
60
+ * "plain" — the plain text field: a new product, a V1 product with trivial HTML;
61
+ * "locked" — neither: V1 rich HTML, or a V3 product whose Ricos document did not come back. */
62
+ descriptionMode: "editor" | "plain" | "locked";
63
+ /** The V3 Ricos description as a JSON string: the editor's seed ("" when none, and until this run's
64
+ * load lands — see the boot reset below). Re-based to the saved document after a save. */
65
+ descriptionJson: string;
66
+ descriptionFieldHidden: boolean; // the plain text field (bag key `descriptionText`)
67
+ descriptionEditorHidden: boolean; // the RichContentEditor (bag key `description`)
68
+ /** The editor gate (`EditorGate` in _shared/owner-editor-value.ts): edited since load/save, and
69
+ * hidden until this run's load lands. */
70
+ descriptionDirty: boolean;
71
+ descriptionPending: boolean;
72
+ /** Derived from the mode: "locked" shows the preview + `descriptionNote`, and refuses an edit. */
54
73
  descriptionProtected: boolean;
55
74
  descriptionEditable: boolean;
56
75
  descriptionNote: string;
@@ -111,10 +130,15 @@ function priceOf(p: WixProduct): string {
111
130
  }
112
131
 
113
132
 
133
+ /** What every load that publishes a description resets: the editor may show it, nothing is edited yet. */
134
+ const DESCRIPTION_LOADED = { descriptionPending: false, descriptionDirty: false } as const;
135
+ const docJson = (doc: unknown): string => (doc ? JSON.stringify(doc) : "");
136
+
114
137
  function shape(s: Partial<ProductFormState>): ProductFormState {
115
138
  const mode: Mode = s.mode ?? "create";
116
139
  const id = typeof s.id === "string" ? s.id : "";
117
140
  const savedId = typeof s.savedId === "string" ? s.savedId : id;
141
+ const dMode = s.descriptionMode ?? (mode === "edit" ? "editor" : "plain");
118
142
  return {
119
143
  mode,
120
144
  id,
@@ -124,9 +148,15 @@ function shape(s: Partial<ProductFormState>): ProductFormState {
124
148
  sku: s.sku ?? "",
125
149
  description: s.description ?? "",
126
150
  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.") : "",
151
+ descriptionMode: dMode,
152
+ descriptionJson: s.descriptionJson ?? "",
153
+ descriptionFieldHidden: dMode !== "plain",
154
+ descriptionEditorHidden: dMode !== "editor",
155
+ descriptionDirty: s.descriptionDirty ?? false,
156
+ descriptionPending: s.descriptionPending ?? false,
157
+ descriptionProtected: dMode === "locked",
158
+ descriptionEditable: dMode !== "locked",
159
+ descriptionNote: dMode === "locked" ? 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.") : "",
130
160
  visible: s.visible ?? "true",
131
161
  status: s.status ?? "idle",
132
162
  error: s.error ?? "",
@@ -151,12 +181,19 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
151
181
  return shape({ ...s, ...demoRefusalPatch(copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE)) });
152
182
  }
153
183
 
184
+ // The RichContentEditor's image tool (the editor's upload contract). The component picks and PUTs
185
+ // the bytes; this service mints the signed Wix Media URL with the worker's own wixApi, so no token
186
+ // reaches the page. Registered BEFORE the form's service on purpose: the worker test harnesses treat a
187
+ // bundle's LAST defineService as its service.
188
+ ricosUploadService("himiOwnerLiteProductFormUploads");
189
+
154
190
  const svc = defineService<ProductFormState, {
155
191
  load: (s: ProductFormState, arg?: unknown) => Promise<ProductFormState>;
192
+ descriptionChanged: (s: ProductFormState, arg?: unknown) => ProductFormState;
156
193
  submit: (s: ProductFormState, values?: Record<string, unknown>) => Promise<ProductFormState>;
157
194
  remove: (s: ProductFormState) => Promise<ProductFormState>;
158
195
  }>("himiOwnerLiteProductForm", {
159
- state: shape({ mode: startMode, id: initialInput.id ?? "" }),
196
+ state: shape({ mode: startMode, id: initialInput.id ?? "", descriptionPending: startMode === "edit" }),
160
197
  actions: {
161
198
  load: async (s, arg) => {
162
199
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
@@ -164,13 +201,14 @@ const svc = defineService<ProductFormState, {
164
201
  // bakes no id/mode); the onAppear `id` param is the #570 suffixed-build
165
202
  // fallback. An id ⇒ edit; no id anywhere ⇒ the create shell.
166
203
  const id = navInputId() || idFrom(arg, s.id);
167
- if (!id) return shape({ ...s, mode: "create", id: "", status: "idle", error: "", demoNote: isOwnerPreviewDemo() ? copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) : "" });
204
+ if (!id) return shape({ ...s, ...DESCRIPTION_LOADED, mode: "create", id: "", descriptionMode: "plain", descriptionJson: "", status: "idle", error: "", demoNote: isOwnerPreviewDemo() ? copyParam("ownerCommon.demoSaveNote", DEMO_SAVE_NOTE) : "" });
168
205
  if (isOwnerPreviewDemo()) {
169
206
  const d = DEMO_PRODUCTS.find((p) => p.id === id);
170
207
  if (d) {
171
208
  return shape({
172
209
  ...s, mode: "edit", id, heading: copyParam("ownerProductForm.headingEdit", "Edit product"),
173
210
  name: d.name, price: editablePrice(d.price), sku: d.sku, description: d.description,
211
+ ...DESCRIPTION_LOADED, descriptionMode: "editor", descriptionJson: docJson(plainPostBody(d.description)),
174
212
  visible: d.visible ? "true" : "false",
175
213
  status: "idle", error: "", savedMessage: "", demoNote: copyParam("ownerCommon.demoNote", DEMO_NOTE),
176
214
  });
@@ -179,7 +217,9 @@ const svc = defineService<ProductFormState, {
179
217
  return shape({ ...s, status: "error", error: "Sample item not found." });
180
218
  }
181
219
  try {
182
- const p = (await getProduct(id)).product as WixProduct;
220
+ // `richDescription` (Catalog V3): the Ricos description beside the HTML one. An SDK without the
221
+ // option ignores it, and the key is absent: the form then keeps the plain/locked split.
222
+ const p = (await (getProduct as (id: string, opts?: { richDescription?: boolean }) => ReturnType<typeof getProduct>)(id, { richDescription: true })).product as WixProduct;
183
223
  if (!p || !(p as any).id) return shape({ ...s, mode: "edit", id, status: "error", error: copyParam("ownerProductForm.errorNotFound", "Product not found") });
184
224
  return shape({
185
225
  ...s,
@@ -189,7 +229,19 @@ const svc = defineService<ProductFormState, {
189
229
  name: (p as any).name ?? "",
190
230
  price: priceOf(p),
191
231
  sku: (p as any).sku ?? "",
192
- ...((html: string) => ({ description: htmlPreviewText(html), originalDescription: htmlPreviewText(html), descriptionProtected: !htmlIsTrivial(html) }))(String((p as any).description ?? "")),
232
+ ...((html: string, rich: unknown) => {
233
+ // `richDescription` is present only on a Catalog V3 site. V3 is edited in the RichContentEditor;
234
+ // a V3 product whose Ricos document did not come back is edited only when its HTML is trivial
235
+ // (seeded as paragraphs), else locked — fail closed. V1 keeps the plain/locked split: the
236
+ // editor writes Ricos, and a V1 description is HTML.
237
+ const doc = rich === undefined || rich === null ? null : parseRicosJson(rich);
238
+ const hasDoc = !!doc && doc.nodes.length > 0;
239
+ const text = htmlPreviewText(html);
240
+ const descriptionMode = rich === undefined ? (htmlIsTrivial(html) ? "plain" as const : "locked" as const)
241
+ : hasDoc || htmlIsTrivial(html) ? "editor" as const : "locked" as const;
242
+ const descriptionJson = descriptionMode !== "editor" ? "" : hasDoc ? JSON.stringify(rich) : text ? docJson(plainPostBody(text)) : "";
243
+ return { ...DESCRIPTION_LOADED, description: text, originalDescription: text, descriptionMode, descriptionJson };
244
+ })(String((p as any).description ?? ""), (p as { richDescription?: unknown }).richDescription),
193
245
  visible: (p as any).visible !== false ? "true" : "false",
194
246
  status: "idle",
195
247
  error: "",
@@ -202,8 +254,11 @@ const svc = defineService<ProductFormState, {
202
254
  return shape({
203
255
  ...s, mode: "edit", id, heading: "Edit product",
204
256
  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",
257
+ description: String(cached.description ?? ""), originalDescription: String(cached.description ?? ""), ...DESCRIPTION_LOADED,
258
+ // A row cached with its document reopens in the editor. An older row has none, so its verdict
259
+ // decides: plain when it was plain, else locked (it carries no verdict either: fail closed).
260
+ ...(cached.descriptionJson ? { descriptionMode: "editor" as const, descriptionJson: String(cached.descriptionJson) }
261
+ : { descriptionMode: cached.descriptionProtected === "false" ? "plain" as const : "locked" as const, descriptionJson: "" }),
207
262
  visible: cached.visible === "false" ? "false" : "true",
208
263
  status: "idle", error: "", savedMessage: "",
209
264
  });
@@ -214,6 +269,7 @@ const svc = defineService<ProductFormState, {
214
269
  return shape({
215
270
  ...s, mode: "edit", id, heading: copyParam("ownerProductForm.headingEdit", "Edit product"),
216
271
  name: d.name, price: editablePrice(d.price), sku: d.sku, description: d.description,
272
+ ...DESCRIPTION_LOADED, descriptionMode: "editor", descriptionJson: docJson(plainPostBody(d.description)),
217
273
  visible: d.visible ? "true" : "false",
218
274
  status: "idle", error: "", savedMessage: "", demoNote: copyParam("ownerCommon.demoNote", DEMO_NOTE),
219
275
  });
@@ -222,6 +278,8 @@ const svc = defineService<ProductFormState, {
222
278
  return shape({ ...s, mode: "edit", id, status: "error", error: ownerLoadError(e) });
223
279
  }
224
280
  },
281
+ // The editor's on_change carries `args.dirty` only; the document stays in the form bag.
282
+ descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, ...editorChanged(s) }),
225
283
  submit: async (s, values) => {
226
284
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
227
285
  const v = values ?? {};
@@ -230,10 +288,34 @@ const svc = defineService<ProductFormState, {
230
288
  // "12,50" (comma-decimal keypad) is accepted and written as 12.50.
231
289
  const price = editablePrice(rawPrice);
232
290
  const sku = String(v.sku ?? s.sku).trim();
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.") });
291
+ // The description, per record mode. A queue replay (a FRESH worker) says so with
292
+ // `descriptionEdited`; this bundle's V3 editor items add the document as `descriptionJson`.
293
+ const replay = typeof v.descriptionEdited === "string";
294
+ let richDescription: { nodes: unknown[] } | null = null; // the V3 Ricos description to WRITE
295
+ let docRaw = "";
296
+ let tooLarge = false;
297
+ let description = s.description; // the plain text (V1 / create), or the mirror
298
+ let descriptionEdited = false; // write the PLAIN description (V1 / create)
299
+ if (replay) {
300
+ const queued = queuedDocument(v.descriptionJson);
301
+ // A queued document that no longer reads (or is past the limits) writes NOTHING, and says so.
302
+ if (queued.kind === "invalid" || queued.kind === "too_large") return shape({ ...s, status: "error", savedMessage: "", error: copyParam("ownerProductForm.queuedDescriptionError", "The description saved offline could not be read, so nothing was written. Edit it again.") });
303
+ if (queued.kind === "doc") { richDescription = queued.doc as { nodes: unknown[] }; docRaw = queued.json; }
304
+ else if (v.descriptionEdited === "true") { description = String(v.description ?? "").trim(); descriptionEdited = true; }
305
+ } else if (s.descriptionMode === "editor") {
306
+ // Only an edit the editor reported counts (EditorGate). Absent (the editor never became ready):
307
+ // save the rest and leave the description as it is.
308
+ const ev = editorSubmitValue(v.description, s.descriptionJson, { pending: s.descriptionPending, dirty: s.descriptionDirty });
309
+ if (ev.kind === "invalid") return shape({ ...s, status: "error", savedMessage: "", error: copyParam("ownerProductForm.editorReadError", "Couldn’t read the description from the editor. Nothing was saved; try again.") });
310
+ if (ev.kind === "doc") { richDescription = ev.doc as { nodes: unknown[] }; docRaw = ev.json; }
311
+ tooLarge = ev.kind === "too_large";
312
+ } else {
313
+ // Plain or locked: the plain text field, under its own bag key.
314
+ description = String(v.descriptionText ?? s.description).trim();
315
+ descriptionEdited = description !== s.originalDescription.trim();
316
+ if (descriptionEdited && s.descriptionMode === "locked") 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.") });
317
+ }
318
+ if (richDescription) description = postBodyText(richDescription as never);
237
319
  const visible = boolString(v.visible, s.visible) === "true";
238
320
  if (!name) return shape({ ...s, status: "error", error: copyParam("ownerProductForm.nameRequired", "Name is required"), savedMessage: "" });
239
321
  const priceNum = Number(price);
@@ -244,23 +326,41 @@ const svc = defineService<ProductFormState, {
244
326
  return shape({ ...s, name, price, sku, description, visible: visible ? "true" : "false", status: "error", error: copyParam("ownerProductForm.priceInvalid", "Enter a price of 0 or more."), savedMessage: "" });
245
327
  }
246
328
 
247
- const write: WixProductWrite = {
329
+ // A local structural type for `richDescription`: templates compile against the PUBLISHED SDK.
330
+ const write: WixProductWrite & { richDescription?: { nodes: unknown[] } } = {
248
331
  name,
249
332
  priceData: { price: priceNum, currency: CURRENCY },
250
333
  visible,
251
334
  ...(sku ? { sku } : {}),
252
335
  ...(descriptionEdited ? { description } : {}),
336
+ ...(richDescription ? { richDescription } : {}),
253
337
  ...(s.mode === "edit" && s.id ? {} : { productType: "physical" }),
254
338
  };
339
+ // The document the record now holds, when known: an offline reopen seeds the editor with it.
340
+ const loadedJson = s.descriptionMode === "editor" && !s.descriptionPending ? s.descriptionJson : "";
341
+ const heldJson = docRaw || loadedJson;
342
+ // The cache's verdict comes from the CONTENT (a rolled-back bundle reads it to decide whether its
343
+ // plain field may overwrite the body): from the document when known; plain only when the record
344
+ // loaded as plain, or a new product's plain text. Before load lands it stays protected.
345
+ const heldDoc = richDescription ?? (loadedJson ? parseRicosJson(loadedJson) : null);
346
+ const knownPlain = s.descriptionMode === "plain" || (s.descriptionMode === "editor" && !s.descriptionPending && s.descriptionJson === "");
347
+ const descriptionProtected = cachedRichVerdict(heldDoc as never, knownPlain);
255
348
 
256
349
  // Optimistic local write — the list repaints from cache immediately (offline or not).
257
- const localRow = (id: string): DbEntity => ({ id, name, price, sku, description, descriptionProtected: s.descriptionProtected ? "true" : "false", visible: visible ? "true" : "false", statusLabel: visible ? "Visible" : "Hidden" });
350
+ const localRow = (id: string): DbEntity => ({ id, name, price, sku, description, descriptionProtected, ...(heldJson ? { descriptionJson: heldJson } : {}), visible: visible ? "true" : "false", statusLabel: visible ? "Visible" : "Hidden" });
351
+ const wrote = richDescription !== null;
352
+ const savedDescription = {
353
+ description, originalDescription: descriptionEdited || wrote ? description : s.originalDescription,
354
+ ...(docRaw ? { descriptionJson: docRaw } : {}),
355
+ descriptionDirty: wrote ? false : s.descriptionDirty,
356
+ error: tooLarge ? copyParam("ownerProductForm.descriptionTooLarge", "The description is too long to save, so it was left as it was. Your other changes were saved.") : "",
357
+ };
258
358
  const localId = s.mode === "edit" && s.id ? s.id : `local-${Date.now()}`;
259
359
  await cachePut(localRow(localId));
260
360
  try {
261
361
  let id = s.id;
262
362
  if (s.mode === "edit" && s.id) {
263
- await updateProduct(s.id, write);
363
+ await updateProduct(s.id, write as WixProductWrite);
264
364
  } else {
265
365
  const r = await createProduct(write);
266
366
  id = r.product?.id || localId;
@@ -275,11 +375,9 @@ const svc = defineService<ProductFormState, {
275
375
  name,
276
376
  price,
277
377
  sku,
278
- description,
279
- originalDescription: descriptionEdited ? description : s.originalDescription,
280
378
  visible: visible ? "true" : "false",
281
379
  status: "saved",
282
- error: "",
380
+ ...savedDescription,
283
381
  savedMessage: copyParam("ownerProductForm.savedMessage", "Product saved."),
284
382
  savedId: id,
285
383
  });
@@ -291,11 +389,13 @@ const svc = defineService<ProductFormState, {
291
389
  if (s.demoNote) return demoRefusal(s);
292
390
  // Real device offline: the optimistic cache row already shows; queue the
293
391
  // write to replay (WriteQueue, idempotent) when connectivity returns.
294
- await queueWrite("tier5.himiOwnerLiteProductForm.submit", { id: s.id, name, price, sku, description, descriptionEdited: String(descriptionEdited), visible: String(visible), mode: s.mode });
392
+ // A document rides as `descriptionJson` with descriptionEdited "false", so a bundle rolled back
393
+ // past this change leaves the description rather than flattening it to text.
394
+ await queueWrite("tier5.himiOwnerLiteProductForm.submit", { id: s.id, name, price, sku, description, visible: String(visible), mode: s.mode,
395
+ ...(docRaw ? { descriptionJson: docRaw, descriptionEdited: "false" } : { descriptionEdited: String(descriptionEdited) }) });
295
396
  return shape({
296
- ...s, mode: "edit", id: localId, name, price, sku, description,
297
- originalDescription: descriptionEdited ? description : s.originalDescription,
298
- visible: visible ? "true" : "false", status: "saved", error: "",
397
+ ...s, mode: "edit", id: localId, name, price, sku, ...savedDescription,
398
+ visible: visible ? "true" : "false", status: "saved",
299
399
  savedMessage: copyParam("ownerProductForm.savedOffline", "Saved offline — will sync when you reconnect."),
300
400
  savedId: localId,
301
401
  });
@@ -323,4 +423,7 @@ const svc = defineService<ProductFormState, {
323
423
  },
324
424
  });
325
425
 
426
+ // Adopter contract: the editor seeds once, so a cold boot must not seed it with the last run's document.
427
+ resetHydratedEditor(svc, { json: "descriptionJson", pending: "descriptionPending", dirty: "descriptionDirty" }, startMode === "edit");
428
+
326
429
  export { svc };