@designcrowd/library.brand-page 6.2.2 → 6.2.3-ast-faq

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 (147) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-7P2gNr-R.js → CreatedOnBrandCrowd-de-de-BRisMzFY.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-Dl7BymW6.js → CreatedOnBrandCrowd-en-us-CDDsL9GX.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-CO4FvWVw.js → CreatedOnBrandCrowd-es-es-BkZrEo4j.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-DHNqakQu.js → CreatedOnBrandCrowd-fr-ca-sx_ury7R.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-DHNqakQu.js → CreatedOnBrandCrowd-fr-fr-sx_ury7R.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-CGUC-ZbP.js → CreatedOnBrandCrowd-pt-br-DCWiAfkU.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-CGUC-ZbP.js → CreatedOnBrandCrowd-pt-pt-DCWiAfkU.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-Dvds-Vmy.js → CreatedOnDcom-de-de-C1S7f22L.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-fd3C-8Tp.js → CreatedOnDcom-en-us-DgTlWaXE.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-DTXZW35-.js → CreatedOnDcom-es-es-CxSQJe_V.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-DRCYOKPs.js → CreatedOnDcom-fr-ca-Bbam2tt6.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-DpIB8x2x.js → CreatedOnDcom-fr-fr-Cb0XDRPL.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-Esnxy8wX.js → CreatedOnDcom-pt-br-BjdX3-E3.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-CaiVHFXR.js → CreatedOnDcom-pt-pt-BXpbfopB.js} +1 -1
  15. package/lib/src/brand-page/constants/misc.constants.d.ts +8 -0
  16. package/lib/src/brand-page/constants/misc.constants.js +7 -0
  17. package/lib/src/brand-page/constants/styles.constants.d.ts +2 -0
  18. package/lib/src/brand-page/constants/styles.constants.js +2 -0
  19. package/lib/src/brand-page/models/brand-page-context.model.d.ts +0 -2
  20. package/lib/src/brand-page/models/brand-page-context.model.js +0 -12
  21. package/lib/src/brand-page/models/content.model.d.ts +1 -0
  22. package/lib/src/brand-page/models/content.model.js +1 -0
  23. package/lib/src/brand-page/models/index.d.ts +19 -0
  24. package/lib/src/brand-page/utils/content-callback-util.d.ts +2 -1
  25. package/lib/src/brand-page/utils/content-callback-util.js +3 -0
  26. package/lib/src/brand-page/utils/model.util.d.ts +2 -1
  27. package/lib/src/brand-page/utils/model.util.js +3 -0
  28. package/lib/src/brand-page/utils/view-model.util.d.ts +2 -0
  29. package/lib/src/brand-page/utils/view-model.util.js +24 -0
  30. package/lib/src/brand-page/view-models.d.ts +9 -2
  31. package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.d.ts +0 -1
  32. package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.js +0 -9
  33. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +10 -42
  34. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +10 -42
  35. package/lib/src/brand-page-maker/components/preview/Preview.mixin.d.ts +0 -14
  36. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +0 -42
  37. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +0 -42
  38. package/lib/src/brand-page-maker/components/seo-settings/UrlSettings.mixin.d.ts +0 -42
  39. package/lib/src/brand-page-maker/components/seo-settings/UseExistingDomainModal.mixin.d.ts +0 -42
  40. package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.d.ts +0 -42
  41. package/lib/src/brand-page-maker/components/upgrade-to-premium-button/UpgradeToPremiumButton.mixin.d.ts +0 -42
  42. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.d.ts +13 -0
  43. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +64 -30
  44. package/lib/src/brand-page-maker/constants/config-settings-constant.d.ts +2 -1
  45. package/lib/src/brand-page-maker/constants/config-settings-constant.js +101 -2
  46. package/lib/src/brand-page-maker/convertors/faq-display-style.convertor.d.ts +3 -0
  47. package/lib/src/brand-page-maker/convertors/faq-display-style.convertor.js +47 -0
  48. package/lib/src/brand-page-maker/models/content-settings-configs.d.ts +22 -1
  49. package/lib/src/brand-page-maker/models/content-settings-configs.js +1 -0
  50. package/lib/src/brand-page-maker/models/content-settings.d.ts +4 -1
  51. package/lib/src/brand-page-maker/models/content-settings.js +45 -1
  52. package/lib/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.d.ts +0 -42
  53. package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +1 -0
  54. package/lib/src/brand-page-maker/utils/apply-color-strategy.js +47 -17
  55. package/lib/src/brand-page-maker/utils/rich-text-editor.util.d.ts +6 -0
  56. package/lib/src/brand-page-maker/utils/rich-text-editor.util.js +24 -1
  57. package/lib/src/brand-page-maker/validators/field.validator.js +2 -0
  58. package/lib/src/bundled-translations-DY9YvuL-.js +179 -0
  59. package/lib/src/bundles/bundled-translations.json +20 -2
  60. package/lib/src/composables/useTranslate.d.ts +1 -1
  61. package/lib/src/composables/useTranslate.js +1 -1
  62. package/lib/src/css/library.brand-page-brandCrowd.min.css +143 -36
  63. package/lib/src/css/library.brand-page-brandPage.min.css +143 -36
  64. package/lib/src/css/library.brand-page-designCom.min.css +143 -36
  65. package/lib/src/css/library.brand-page-preview.min.css +143 -36
  66. package/lib/src/index.mjs +159 -158
  67. package/lib/src/{lib-V7aqfuiB.js → lib-DwpSAbLZ.js} +26889 -26178
  68. package/lib/src/metadata-builder/parsers/section-tool-arguments.d.ts +1 -1
  69. package/lib/src/shared/color-strategies/color-strategies.js +4 -0
  70. package/lib/src/shared/color-strategies/rte-field-pairs.d.ts +12 -0
  71. package/lib/src/shared/color-strategies/rte-field-pairs.js +10 -0
  72. package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +10 -0
  73. package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +11 -1
  74. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItem.mixin.d.ts +10 -0
  75. package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +10 -0
  76. package/lib/src/shared/content-settings/components/focused-content-item-settings/FocusedContentItemSettings.mixin.d.ts +10 -0
  77. package/lib/src/shared/content-settings/components/items-field/ItemsField.mixin.d.ts +14 -1
  78. package/lib/src/shared/content-settings/components/items-field/ItemsField.mixin.js +11 -1
  79. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +0 -42
  80. package/lib/src/shared/content-settings/components/textarea-field/TextareaField.mixin.d.ts +12 -0
  81. package/lib/src/shared/content-settings/components/textarea-field/TextareaField.mixin.js +5 -0
  82. package/lib/src/shared/content-settings/models.d.ts +6 -0
  83. package/lib/src/shared/content-settings/store/content-settings-store.d.ts +14 -1
  84. package/lib/src/shared/content-settings/store/content-settings-store.js +24 -2
  85. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +0 -42
  86. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +10 -42
  87. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +10 -42
  88. package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +0 -56
  89. package/lib/src/shared/side-bar/components/SideBar.mixin.d.ts +0 -42
  90. package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +0 -42
  91. package/package.json +2 -2
  92. package/src/brand-page/components/faq-section/FaqSection.test.ts +292 -0
  93. package/src/brand-page/components/faq-section/FaqSection.vue +299 -0
  94. package/src/brand-page/components/page/PageComponent.vue +2 -0
  95. package/src/brand-page/components/section-heading/SectionHeading.vue +52 -0
  96. package/src/brand-page/components/text-wrapper/TextWrapper.test.ts +33 -0
  97. package/src/brand-page/constants/misc.constants.ts +12 -0
  98. package/src/brand-page/constants/styles.constants.ts +2 -0
  99. package/src/brand-page/models/brand-page-context.model.ts +0 -23
  100. package/src/brand-page/models/content.model.ts +1 -0
  101. package/src/brand-page/models/index.ts +21 -0
  102. package/src/brand-page/utils/content-callback-util.ts +5 -0
  103. package/src/brand-page/utils/model.util.ts +7 -0
  104. package/src/brand-page/utils/view-model.util.test.ts +96 -0
  105. package/src/brand-page/utils/view-model.util.ts +49 -0
  106. package/src/brand-page/view-models.ts +12 -1
  107. package/src/brand-page-maker/components/action-bar/ActionBar.mixin.ts +0 -10
  108. package/src/brand-page-maker/components/action-bar/ActionBarNavigation.test.ts +8 -49
  109. package/src/brand-page-maker/components/action-bar/ActionBarNavigation.vue +1 -1
  110. package/src/brand-page-maker/components/rich-text-editor/RichTextEditor.test.ts +165 -0
  111. package/src/brand-page-maker/components/rich-text-editor/RichTextEditor.vue +37 -49
  112. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +87 -30
  113. package/src/brand-page-maker/constants/config-settings-constant.ts +108 -0
  114. package/src/brand-page-maker/constants/faq-section-config.test.ts +151 -0
  115. package/src/brand-page-maker/convertors/faq-display-style.convertor.ts +59 -0
  116. package/src/brand-page-maker/convertors/i18n/converter.json +4 -1
  117. package/src/brand-page-maker/models/content-settings-configs.ts +24 -0
  118. package/src/brand-page-maker/models/content-settings.ts +57 -0
  119. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +2 -0
  120. package/src/brand-page-maker/utils/__snapshots__/apply-color-strategy.golden.test.ts.snap +4063 -895
  121. package/src/brand-page-maker/utils/apply-color-strategy.test.ts +104 -2
  122. package/src/brand-page-maker/utils/apply-color-strategy.ts +77 -19
  123. package/src/brand-page-maker/utils/rich-text-editor.util.test.ts +110 -0
  124. package/src/brand-page-maker/utils/rich-text-editor.util.ts +66 -0
  125. package/src/brand-page-maker/validators/field.validator.ts +2 -0
  126. package/src/bundles/bundled-translations.json +20 -2
  127. package/src/composables/useTranslate.ts +2 -1
  128. package/src/metadata-builder/builders/item-id-utils.test.ts +24 -0
  129. package/src/metadata-builder/parsers/section-tool-arguments.ts +2 -1
  130. package/src/metadata-builder/parsers/section-tool-map.test.ts +12 -0
  131. package/src/shared/color-strategies/color-strategies.ts +4 -0
  132. package/src/shared/color-strategies/rte-field-pairs.ts +23 -0
  133. package/src/shared/content-settings/components/content-settings/i18n/content-settings.json +17 -1
  134. package/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.vue +3 -0
  135. package/src/shared/content-settings/components/items-field/ItemsField.mixin.ts +11 -1
  136. package/src/shared/content-settings/components/items-field/ItemsField.vue +8 -2
  137. package/src/shared/content-settings/components/list-items-field/ListItemsField.test.ts +429 -0
  138. package/src/shared/content-settings/components/list-items-field/ListItemsField.vue +262 -0
  139. package/src/shared/content-settings/components/textarea-field/TextareaField.mixin.ts +6 -0
  140. package/src/shared/content-settings/components/textarea-field/TextareaField.vue +1 -0
  141. package/src/shared/content-settings/models.ts +7 -0
  142. package/src/shared/content-settings/store/content-settings-store.test.ts +70 -0
  143. package/src/shared/content-settings/store/content-settings-store.ts +37 -3
  144. package/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.test.ts +19 -0
  145. package/src/shared/page-settings/store/page-settings-store.test.ts +0 -71
  146. package/src/utils/minify-brand-page-metadata.test.ts +35 -0
  147. package/lib/src/bundled-translations-BYUfWBT3.js +0 -179
@@ -13,6 +13,7 @@ import {
13
13
  SECTION_RAISED_LAYER,
14
14
  } from '@shared/color-strategies/color-strategies';
15
15
  import { pageStrategyAnchors } from '@shared/color-strategies/page-anchors';
16
+ import { RTE_ITEM_FIELD_PAIRS } from '@shared/color-strategies/rte-field-pairs';
16
17
  import type { ColorStrategy } from '@shared/color-strategies/models';
17
18
 
18
19
  const backgroundConvertor = {
@@ -1652,12 +1653,113 @@ describe('every colour field is bound or deliberately unbound (BP-5886)', () =>
1652
1653
  // `successMessageStyle` is a content-model field (contact-form's success
1653
1654
  // message) with no settings-config entry, so the config-driven applier can
1654
1655
  // never write it. Kept bound as documentation of intent; this pins that the
1655
- // phantom set never grows silently.
1656
+ // phantom set never grows silently. Item-level styles (each FAQ item's
1657
+ // `questionStyle`) are declared by RTE_ITEM_FIELD_PAIRS, not by a config field.
1658
+ const itemStyleFields = new Set(
1659
+ Object.values(RTE_ITEM_FIELD_PAIRS).flatMap((pairs) =>
1660
+ pairs.map((pair) => pair.styleField),
1661
+ ),
1662
+ );
1656
1663
  const phantoms = [
1657
1664
  ...Object.keys(FIELD_SLOT_MAP),
1658
1665
  ...Object.values(SECTION_RAISED_LAYER).flatMap((layer) => layer.content),
1659
- ].filter((name) => !configColourFields.has(name));
1666
+ ].filter(
1667
+ (name) => !configColourFields.has(name) && !itemStyleFields.has(name),
1668
+ );
1660
1669
 
1661
1670
  expect(new Set(phantoms)).toEqual(new Set(['successMessageStyle']));
1662
1671
  });
1663
1672
  });
1673
+
1674
+ describe('buildColorStrategyValues — FAQ section', () => {
1675
+ const doc = (text: string) => ({
1676
+ type: 'doc',
1677
+ content: [
1678
+ {
1679
+ type: 'paragraph',
1680
+ content: [
1681
+ {
1682
+ type: 'text',
1683
+ text,
1684
+ marks: [{ type: 'textStyle', attrs: { color: '#123456' } }],
1685
+ },
1686
+ ],
1687
+ },
1688
+ ],
1689
+ });
1690
+
1691
+ const faq = () => ({
1692
+ type: 'faq-section',
1693
+ displayStyle: 'faq-centered',
1694
+ backgroundStyle: {},
1695
+ titleStyle: {},
1696
+ descriptionStyle: {},
1697
+ items: [
1698
+ {
1699
+ id: 'q1',
1700
+ question: doc('Q1'),
1701
+ questionStyle: { textStyleType: 'headings', backgroundColor: '#fff' },
1702
+ answer: 'Plain answer',
1703
+ answerStyle: { textStyleType: 'paragraphs' },
1704
+ },
1705
+ {
1706
+ id: 'q2',
1707
+ question: 'Plain question',
1708
+ questionStyle: { textStyleType: 'headings' },
1709
+ answer: doc('A2'),
1710
+ answerStyle: { textStyleType: 'paragraphs' },
1711
+ },
1712
+ ],
1713
+ });
1714
+
1715
+ it('repaints every item: its styles and its rich-text documents', () => {
1716
+ const config = CONTENT_SETTINGS_CONFIGS()['faq-section'];
1717
+ const content = faq();
1718
+ const values: Record<string, any> = buildColorStrategyValues(
1719
+ config,
1720
+ content,
1721
+ 'duotone',
1722
+ );
1723
+ const [q1, q2] = values.items;
1724
+ const questionRef = q1.questionStyle.color;
1725
+
1726
+ expect(values.backgroundStyle).toBeDefined();
1727
+ expect(questionRef).toMatch(/^var\(--palette-\d\)$/);
1728
+ expect(q1.answerStyle.color).toMatch(/^var\(--palette-\d\)$/);
1729
+ expect(q2.questionStyle.color).toBe(questionRef);
1730
+ // Text on the band never takes the band's own role.
1731
+ expect(questionRef).not.toBe(values.backgroundStyle?.backgroundColor);
1732
+ // The strategy owns the text treatment: background cleared, typography kept.
1733
+ expect('backgroundColor' in q1.questionStyle).toBe(false);
1734
+ expect(q1.questionStyle.textStyleType).toBe('headings');
1735
+ // RTE documents take the colour in their marks; plain text is left as text.
1736
+ expect(JSON.stringify(q1.question)).toContain(questionRef);
1737
+ expect(JSON.stringify(q1.question)).not.toContain('#123456');
1738
+ expect(q1.answer).toBe('Plain answer');
1739
+ expect(JSON.stringify(q2.answer)).toContain(q2.answerStyle.color);
1740
+ // A new list — never the stored one mutated.
1741
+ expect(values.items).not.toBe(content.items);
1742
+ expect(content.items[0].questionStyle.backgroundColor).toBe('#fff');
1743
+ });
1744
+
1745
+ it('asks the live editors to re-seed when an item document was replaced', () => {
1746
+ const updateItemValue = vi.fn();
1747
+ const markRteResync = vi.fn();
1748
+
1749
+ applyColorStrategyToItem(
1750
+ { updateItemValue },
1751
+ {
1752
+ id: 'faq-1',
1753
+ config: CONTENT_SETTINGS_CONFIGS()['faq-section'],
1754
+ content: faq(),
1755
+ strategyId: 'duotone',
1756
+ markRteResync,
1757
+ },
1758
+ );
1759
+
1760
+ expect(updateItemValue).toHaveBeenCalledWith(
1761
+ expect.objectContaining({ name: 'items' }),
1762
+ );
1763
+ expect(markRteResync).toHaveBeenCalledTimes(1);
1764
+ });
1765
+ });
@@ -15,7 +15,10 @@ import type {
15
15
  SectionLayers,
16
16
  StrategyAnchors,
17
17
  } from '@shared/color-strategies/models';
18
- import { pairedRteTextField } from '@shared/color-strategies/rte-field-pairs';
18
+ import {
19
+ pairedRteTextField,
20
+ RTE_ITEM_FIELD_PAIRS,
21
+ } from '@shared/color-strategies/rte-field-pairs';
19
22
 
20
23
  // A section colour field, narrowed to what the macro reads. Config is declared `<any>`,
21
24
  // so this keeps the applier honest without fighting the union.
@@ -116,6 +119,23 @@ const sectionLayers = (
116
119
  };
117
120
  };
118
121
 
122
+ /**
123
+ * The strategy's text treatment for one text-style field: `color` set, text background
124
+ * cleared (matching the highlight strip in `setRteTextColor` — the toolbar writes both).
125
+ * The key is deleted rather than set undefined so `'backgroundColor' in style` checks
126
+ * downstream stay honest.
127
+ */
128
+ const withStrategyTextStyle = (
129
+ current: Record<string, unknown> | undefined,
130
+ ref: string,
131
+ ): Record<string, unknown> => {
132
+ const nextStyle: Record<string, unknown> = { ...(current ?? {}), color: ref };
133
+
134
+ delete nextStyle.backgroundColor;
135
+
136
+ return nextStyle;
137
+ };
138
+
119
139
  /**
120
140
  * Build the per-field colour writes a strategy stamps onto a section. Pure — reads the
121
141
  * section config + current content and returns a `{ fieldName: value }` partial. Each
@@ -170,18 +190,7 @@ export const buildColorStrategyValues = (
170
190
  | Record<string, unknown>
171
191
  | undefined;
172
192
 
173
- const nextStyle: Record<string, unknown> = {
174
- ...(current ?? {}),
175
- color: ref,
176
- };
177
-
178
- // A strategy owns the text treatment, so it clears the text background colour
179
- // too — matching the highlight strip in `setRteTextColor` (the toolbar writes
180
- // both). Delete the key rather than set it undefined so `'backgroundColor' in
181
- // style` checks downstream stay honest.
182
- delete nextStyle.backgroundColor;
183
-
184
- values[name] = nextStyle;
193
+ values[name] = withStrategyTextStyle(current, ref);
185
194
 
186
195
  // Also stamp the colour into the paired RTE document, if this field drives one.
187
196
  // `setRteTextColor` returns undefined for a plain string or legacy HTML RTE, so
@@ -219,9 +228,52 @@ export const buildColorStrategyValues = (
219
228
  values[name] = field.valueConvertor.convertBack(ref);
220
229
  });
221
230
 
231
+ // Item-level RTE fields (each FAQ question) carry their own style and document, so
232
+ // the section's field loop above never sees them. Same two halves, per item, written
233
+ // back as one new list.
234
+ const itemPairs =
235
+ typeof contentRecord.type === 'string'
236
+ ? (RTE_ITEM_FIELD_PAIRS[contentRecord.type] ?? [])
237
+ : [];
238
+
239
+ itemPairs.forEach((pair) => {
240
+ const ref = strategyRoleRefForField(
241
+ strategyId,
242
+ pair.styleField,
243
+ content,
244
+ context,
245
+ layers,
246
+ anchors,
247
+ );
248
+ const items = (values[pair.itemsField] ??
249
+ contentRecord[pair.itemsField]) as Record<string, unknown>[] | undefined;
250
+
251
+ if (!ref || !Array.isArray(items)) {
252
+ return;
253
+ }
254
+
255
+ values[pair.itemsField] = items.map((item) => {
256
+ const nextDoc = setRteTextColor(item[pair.textField], ref);
257
+
258
+ return {
259
+ ...item,
260
+ [pair.styleField]: withStrategyTextStyle(
261
+ item[pair.styleField] as Record<string, unknown> | undefined,
262
+ ref,
263
+ ),
264
+ ...(nextDoc ? { [pair.textField]: nextDoc } : {}),
265
+ };
266
+ });
267
+ });
268
+
222
269
  return values;
223
270
  };
224
271
 
272
+ const isRteDoc = (value: unknown): boolean =>
273
+ typeof value === 'object' &&
274
+ value !== null &&
275
+ (value as { type?: unknown }).type === 'doc';
276
+
225
277
  // Just the slice of the content-settings store the macro writes through. Keeps
226
278
  // this util free of a store import (and of the module cycle that would create).
227
279
  interface ItemValueWriter {
@@ -274,14 +326,20 @@ export const applyColorStrategyToItem = (
274
326
  store.updateItemValue({ id, name, value });
275
327
  });
276
328
 
277
- // A recoloured RTE field is emitted as a whole Tiptap document; the paired
278
- // style-object writes are plain objects. Only the former needs the live editor
279
- // to re-seed, so signal it just when a doc was among the writes.
329
+ // A recoloured RTE field is emitted as a whole Tiptap document (at the top level,
330
+ // or inside a rewritten item list); the paired style-object writes are plain
331
+ // objects. Only the former needs the live editor to re-seed, so signal it just
332
+ // when a doc was among the writes.
280
333
  const replacedRteDoc = Object.values(values).some(
281
334
  (value) =>
282
- typeof value === 'object' &&
283
- value !== null &&
284
- (value as { type?: unknown }).type === 'doc',
335
+ isRteDoc(value) ||
336
+ (Array.isArray(value) &&
337
+ value.some(
338
+ (item) =>
339
+ typeof item === 'object' &&
340
+ item !== null &&
341
+ Object.values(item).some(isRteDoc),
342
+ )),
285
343
  );
286
344
 
287
345
  if (replacedRteDoc) {
@@ -15,7 +15,9 @@ import {
15
15
  resolveToolbarTextColor,
16
16
  resolveInitialToolbarTextColor,
17
17
  transformToRTE,
18
+ switchAllContentToRTE,
18
19
  } from './rich-text-editor.util';
20
+ import { useExternalRichTextEditor } from '../composables/rich-text-editor/useRichTextEditor';
19
21
  import type { TextStyleDeclaration } from '@brand-page';
20
22
 
21
23
  describe('isStyleChanged', () => {
@@ -585,6 +587,114 @@ describe('transformToRTE — respects existing RTE marks on selection (BP-5886)'
585
587
  });
586
588
  });
587
589
 
590
+ describe('item-level RTE fields (FAQ questions and answers)', () => {
591
+ const styleStore = () =>
592
+ ({
593
+ textStyles: {
594
+ textStyleOptions: {
595
+ headings: { style: { fontFamily: 'Oswald', fontSize: '1.5em' } },
596
+ paragraphs: { style: { fontFamily: 'Lato', fontSize: '1em' } },
597
+ },
598
+ },
599
+ }) as any;
600
+
601
+ const faqPageStore = (items: any[]) =>
602
+ ({
603
+ pages: [
604
+ {
605
+ contents: [
606
+ {
607
+ id: 'faq-1',
608
+ type: 'faq-section',
609
+ settings: {
610
+ content: {
611
+ id: 'faq-1',
612
+ type: 'faq-section',
613
+ title: '',
614
+ titleStyle: {},
615
+ description: '',
616
+ descriptionStyle: {},
617
+ items,
618
+ },
619
+ },
620
+ },
621
+ ],
622
+ },
623
+ ],
624
+ }) as any;
625
+
626
+ const item = (id: string, questionStyle: any) => ({
627
+ id,
628
+ question: `Question ${id}`,
629
+ questionStyle,
630
+ answer: `Answer ${id}`,
631
+ answerStyle: { textStyleType: 'paragraphs' },
632
+ });
633
+
634
+ const itemsOf = (store: any) =>
635
+ store.pages[0].contents[0].settings.content.items;
636
+
637
+ const fontOf = (value: unknown): string | undefined =>
638
+ JSON.stringify(value).match(/"fontFamily":"([^"]+)"/)?.[1];
639
+
640
+ it("converts every item's plain text to RTE, each from its own style", () => {
641
+ const { externalEditor } = useExternalRichTextEditor();
642
+ const store = faqPageStore([
643
+ item('1', { textStyleType: 'headings' }),
644
+ item('2', { fontFamily: 'Georgia', fontSize: '2em' }), // unlinked
645
+ ]);
646
+
647
+ switchAllContentToRTE(store, externalEditor, styleStore());
648
+
649
+ const [first, second] = itemsOf(store);
650
+
651
+ expect(first.question.type).toBe('doc');
652
+ expect(fontOf(first.question)).toBe('Oswald');
653
+ expect(fontOf(first.answer)).toBe('Lato');
654
+ expect(fontOf(second.question)).toBe('Georgia');
655
+ externalEditor.destroy();
656
+ });
657
+
658
+ it('a global text-style change re-stamps only the items linked to it', () => {
659
+ const { externalEditor } = useExternalRichTextEditor();
660
+ const store = faqPageStore([
661
+ item('1', { textStyleType: 'headings' }),
662
+ item('2', { textStyleType: 'headings' }),
663
+ ]);
664
+
665
+ switchAllContentToRTE(store, externalEditor, styleStore());
666
+
667
+ const answerBefore = itemsOf(store)[0].answer;
668
+
669
+ transformToRTE(store, externalEditor, undefined, undefined, 'headings', {
670
+ fontFamily: 'Playfair Display',
671
+ } as TextStyleDeclaration);
672
+
673
+ const [first, second] = itemsOf(store);
674
+
675
+ expect(fontOf(first.question)).toBe('Playfair Display');
676
+ expect(fontOf(second.question)).toBe('Playfair Display');
677
+ expect(first.answer).toEqual(answerBefore);
678
+ externalEditor.destroy();
679
+ });
680
+
681
+ it('selecting the section leaves already-authored item documents alone', () => {
682
+ const { externalEditor } = useExternalRichTextEditor();
683
+ const store = faqPageStore([item('1', { textStyleType: 'headings' })]);
684
+
685
+ switchAllContentToRTE(store, externalEditor, styleStore());
686
+
687
+ const authored = itemsOf(store)[0].question;
688
+
689
+ transformToRTE(store, externalEditor, styleStore(), {
690
+ id: 'faq-1',
691
+ } as any);
692
+
693
+ expect(itemsOf(store)[0].question).toEqual(authored);
694
+ externalEditor.destroy();
695
+ });
696
+ });
697
+
588
698
  describe('sanitiseClientSideRTE', () => {
589
699
  it('removes forbidden tags entirely', () => {
590
700
  const input =
@@ -31,6 +31,7 @@ import createDomPurify, { type DOMPurify } from 'dompurify';
31
31
  import Color from 'color';
32
32
  import { isHrefPageId } from '../composables/rich-text-editor/hyperlink';
33
33
  import {
34
+ RTE_ITEM_FIELD_PAIRS,
34
35
  RTE_SECTION_FIELD_PAIRS,
35
36
  type RTEFieldPair,
36
37
  } from '@shared/color-strategies/rte-field-pairs';
@@ -547,6 +548,46 @@ const switchRTEField = (
547
548
  }
548
549
  };
549
550
 
551
+ // Items are visited as their own "content": each item carries the style that drives its
552
+ // text, so the section-field helpers apply unchanged.
553
+ const forEachRteItem = (
554
+ sectionType: string,
555
+ content: unknown,
556
+ visit: (item: RTESectionContent, pair: RTEFieldPair) => void,
557
+ ): void => {
558
+ for (const pair of RTE_ITEM_FIELD_PAIRS[sectionType] ?? []) {
559
+ const items = (content as Record<string, unknown>)[pair.itemsField];
560
+
561
+ if (Array.isArray(items)) {
562
+ items.forEach((item) => visit(item as RTESectionContent, pair));
563
+ }
564
+ }
565
+ };
566
+
567
+ /**
568
+ * Convert a new list item's plain text to RTE, as adding its section would. Goes through
569
+ * `transformRTEField` (element-first colour), so an item that copied a sibling's
570
+ * strategy-coloured style bakes that colour rather than the linked global one.
571
+ */
572
+ export const switchRteItemFieldsToRTE = (
573
+ sectionType: string,
574
+ item: Record<string, unknown>,
575
+ externalEditor: Editor,
576
+ uiStyleSettingsStore?: StyleSettingsState,
577
+ ): void => {
578
+ RTE_ITEM_FIELD_PAIRS[sectionType]?.forEach((pair) =>
579
+ transformRTEField(
580
+ item as unknown as RTESectionContent,
581
+ pair,
582
+ externalEditor,
583
+ uiStyleSettingsStore,
584
+ item as unknown as Content,
585
+ undefined,
586
+ undefined,
587
+ ),
588
+ );
589
+ };
590
+
550
591
  export const transformToRTE = (
551
592
  uiPageSettingStore: PageSettingsState,
552
593
  externalEditor: Editor,
@@ -575,6 +616,24 @@ export const transformToRTE = (
575
616
  }
576
617
  }
577
618
 
619
+ const isNewContent = !!newContent && newContent.id === contentItem.id;
620
+
621
+ forEachRteItem(
622
+ contentItem.type,
623
+ contentItem.settings.content,
624
+ (item, pair) =>
625
+ transformRTEField(
626
+ item,
627
+ pair,
628
+ externalEditor,
629
+ uiStyleSettingsStore,
630
+ // An unlinked item of the section being added bakes its own style.
631
+ isNewContent ? (item as unknown as Content) : undefined,
632
+ type,
633
+ newStyle,
634
+ ),
635
+ );
636
+
578
637
  if (contentItem.type === 'text') {
579
638
  const textContent = contentItem.settings.content as TextContent;
580
639
 
@@ -699,6 +758,13 @@ export const switchAllContentToRTE = (
699
758
  }
700
759
  }
701
760
 
761
+ forEachRteItem(
762
+ contentItem.type,
763
+ contentItem.settings.content,
764
+ (item, pair) =>
765
+ switchRTEField(item, pair, externalEditor, uiStyleSettingsStore),
766
+ );
767
+
702
768
  if (contentItem.type === 'text') {
703
769
  const textContent = contentItem.settings.content as TextContent;
704
770
 
@@ -161,6 +161,7 @@ export const validatorDict: Record<ItemFieldType, Validator> = {
161
161
  'color-replacements': dummyValidator,
162
162
  'testimonial-items': dummyValidator,
163
163
  'text-items': dummyValidator,
164
+ 'list-items': dummyValidator,
164
165
  'page-dropdown': dummyValidator,
165
166
  shape: dummyValidator,
166
167
  'logo-selection': dummyValidator,
@@ -204,6 +205,7 @@ export const adminValidatorsDict: Record<ItemFieldType, Validator> = {
204
205
  'color-replacements': dummyValidator,
205
206
  'testimonial-items': dummyValidator,
206
207
  'text-items': dummyValidator,
208
+ 'list-items': dummyValidator,
207
209
  'page-dropdown': dummyValidator,
208
210
  shape: dummyValidator,
209
211
  'logo-selection': dummyValidator,
@@ -1262,7 +1262,10 @@
1262
1262
  "medium": "Medium",
1263
1263
  "large": "Large",
1264
1264
  "bigImage": "Big Image",
1265
- "columns": "Columns"
1265
+ "columns": "Columns",
1266
+ "faqCenteredAccordion": "Centered accordion",
1267
+ "faqSplitAccordion": "Split accordion",
1268
+ "faqGrid": "Grid"
1266
1269
  },
1267
1270
  "model": {
1268
1271
  "defaultBackgroundColor": "Default background color",
@@ -1640,7 +1643,22 @@
1640
1643
  "enterTextHerePlaceholder": "Enter text here...",
1641
1644
  "bookNowLabel": "Book Now",
1642
1645
  "shippingLabel": "Enable Shipping Address",
1643
- "shippingPlaceholder": "Require customers to enter a shipping address during checkout."
1646
+ "shippingPlaceholder": "Require customers to enter a shipping address during checkout.",
1647
+ "faqSectionDisplayName": "FAQ",
1648
+ "faqItemsDisplayName": "Items",
1649
+ "defaultFaqTitle": "Frequently Asked Questions",
1650
+ "defaultFaqDescription": "Find answers to common questions about our products, services, or business",
1651
+ "defaultFaqQuestion": "What services do you offer?",
1652
+ "defaultFaqAnswer": "We provide a range of products and services tailored to meet your needs. Contact us to learn more about how we can help.",
1653
+ "faqNewQuestion": "New question?",
1654
+ "faqNewAnswer": "Add the answer here.",
1655
+ "faqQuestionTitle": "Question",
1656
+ "faqAnswerTitle": "Answer",
1657
+ "faqQuestionPlaceholder": "Enter a question",
1658
+ "faqAnswerPlaceholder": "Enter the answer",
1659
+ "listItemsAddItem": "Add item",
1660
+ "listItemsMaxReached": "Max {{max}} items reached",
1661
+ "listItemsDeleteLabel": "Delete item"
1644
1662
  },
1645
1663
  "formFields": {
1646
1664
  "field": "Field",
@@ -121,7 +121,8 @@ const converterTr = (key) => tr(`converter.${key}`);
121
121
  const backgroundSettingsTr = (key) => tr(`backgroundSettings.${key}`);
122
122
  const colorPickerTr = (key) => tr(`colorPicker.${key}`);
123
123
  const colorReplacementFieldTr = (key) => tr(`colorReplacementField.${key}`);
124
- const contentSettingsTr = (key) => tr(`contentSettings.${key}`);
124
+ const contentSettingsTr = (key, valuesToInterpolate = {}) =>
125
+ tr(`contentSettings.${key}`, valuesToInterpolate);
125
126
  const formFieldsTr = (key) => tr(`formFields.${key}`);
126
127
  const genericTextInputTr = (key) => tr(`genericTextInput.${key}`);
127
128
  const imageFieldTr = (key) => tr(`imageField.${key}`);
@@ -182,3 +182,27 @@ describe('ensureBrandPageItemIds', () => {
182
182
  ).not.toThrow();
183
183
  });
184
184
  });
185
+
186
+ describe('ensureBrandPageItemIds — FAQ items', () => {
187
+ it('fills a missing FAQ item id and keeps an existing one', () => {
188
+ const model = {
189
+ pages: [
190
+ {
191
+ contents: [
192
+ {
193
+ type: 'faq-section',
194
+ items: [{ id: 'keep', question: 'Q1' }, { question: 'Q2' }],
195
+ },
196
+ ],
197
+ },
198
+ ],
199
+ } as unknown as BrandPageModel;
200
+
201
+ ensureBrandPageItemIds(model);
202
+
203
+ const faqItems = (model.pages[0].contents[0] as any).items;
204
+
205
+ expect(faqItems[0].id).toBe('keep');
206
+ expect(faqItems[1].id).toMatch(UUID_RE);
207
+ });
208
+ });
@@ -18,7 +18,8 @@ type UnsupportedContentType =
18
18
  | 'map'
19
19
  | 'request-payment'
20
20
  | 'support-me'
21
- | 'shop-section';
21
+ | 'shop-section'
22
+ | 'faq-section';
22
23
 
23
24
  export type SectionType = Exclude<ContentType, UnsupportedContentType>;
24
25
 
@@ -0,0 +1,12 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { SECTION_TOOL_MAP } from './ai-toolcall-parser';
3
+
4
+ describe('SECTION_TOOL_MAP', () => {
5
+ test('no AI tool builds an FAQ section — FAQ is not AI-supported', () => {
6
+ const sectionTypes = Object.values(SECTION_TOOL_MAP).map(
7
+ (tool) => tool.sectionType as string,
8
+ );
9
+
10
+ expect(sectionTypes).not.toContain('faq-section');
11
+ });
12
+ });
@@ -272,6 +272,10 @@ export const FIELD_SLOT_MAP: Record<string, ColorSlot> = {
272
272
  nameStyle: 'onSurface',
273
273
  positionStyle: 'onSurface',
274
274
  testimonialStyle: 'onSurface',
275
+ // Per-item styles of each FAQ item (RTE_ITEM_FIELD_PAIRS). The rows sit on the
276
+ // section band — there is no raised card.
277
+ questionStyle: 'onSurface',
278
+ answerStyle: 'onSurface',
275
279
  };
276
280
 
277
281
  /**
@@ -21,6 +21,10 @@ export const RTE_SECTION_FIELD_PAIRS: Record<string, RTEFieldPair[]> = {
21
21
  { textField: 'title', styleField: 'titleStyle' },
22
22
  { textField: 'description', styleField: 'descriptionStyle' },
23
23
  ],
24
+ 'faq-section': [
25
+ { textField: 'title', styleField: 'titleStyle' },
26
+ { textField: 'description', styleField: 'descriptionStyle' },
27
+ ],
24
28
  'images-section': [
25
29
  { textField: 'title', styleField: 'titleStyle' },
26
30
  { textField: 'description', styleField: 'descriptionStyle' },
@@ -60,6 +64,25 @@ export const RTE_SECTION_FIELD_PAIRS: Record<string, RTEFieldPair[]> = {
60
64
  ],
61
65
  };
62
66
 
67
+ /**
68
+ * RTE fields that live on each entry of an item list, each driven by a style field
69
+ * on the SAME item — every question is its own title-like field. The rewrap walks and
70
+ * the colour strategies visit every item for these.
71
+ *
72
+ * The Text block's `textItems` is not listed: its walk has its own legacy fallbacks
73
+ * (single-text mode, section-level `textStyle`) in `transformToRTE`.
74
+ */
75
+ export interface RTEItemFieldPair extends RTEFieldPair {
76
+ itemsField: string;
77
+ }
78
+
79
+ export const RTE_ITEM_FIELD_PAIRS: Record<string, RTEItemFieldPair[]> = {
80
+ 'faq-section': [
81
+ { itemsField: 'items', textField: 'question', styleField: 'questionStyle' },
82
+ { itemsField: 'items', textField: 'answer', styleField: 'answerStyle' },
83
+ ],
84
+ };
85
+
63
86
  /**
64
87
  * The `text` section is absent from the map above: on brandSite its RTE lives
65
88
  * per-item in `textItems[]`, which the editor walks separately. On
@@ -206,6 +206,22 @@
206
206
  "enterTextHerePlaceholder": "Enter text here...",
207
207
  "bookNowLabel": "Book Now",
208
208
  "shippingLabel": "Enable Shipping Address",
209
- "shippingPlaceholder": "Require customers to enter a shipping address during checkout."
209
+ "shippingPlaceholder": "Require customers to enter a shipping address during checkout.",
210
+
211
+ "faqSectionDisplayName": "FAQ",
212
+ "faqItemsDisplayName": "Items",
213
+ "defaultFaqTitle": "Frequently Asked Questions",
214
+ "defaultFaqDescription": "Find answers to common questions about our products, services, or business",
215
+ "defaultFaqQuestion": "What services do you offer?",
216
+ "defaultFaqAnswer": "We provide a range of products and services tailored to meet your needs. Contact us to learn more about how we can help.",
217
+ "faqNewQuestion": "New question?",
218
+ "faqNewAnswer": "Add the answer here.",
219
+ "faqQuestionTitle": "Question",
220
+ "faqAnswerTitle": "Answer",
221
+ "faqQuestionPlaceholder": "Enter a question",
222
+ "faqAnswerPlaceholder": "Enter the answer",
223
+ "listItemsAddItem": "Add item",
224
+ "listItemsMaxReached": "Max {{max}} items reached",
225
+ "listItemsDeleteLabel": "Delete item"
210
226
  }
211
227
  }