@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
@@ -126,6 +126,18 @@ export const IMAGE_RESOLUTION_PRESETS = {
126
126
  export type ImagesSectionContentDisplayStyle =
127
127
  (typeof IMAGES_SECTION_CONTENT_DISPLAY_STYLES)[number];
128
128
 
129
+ /** Centered and split are accordions; grid shows every answer. */
130
+ export const FAQ_DISPLAY_STYLES = Object.freeze({
131
+ centered: 'faq-centered',
132
+ split: 'faq-split',
133
+ grid: 'faq-grid',
134
+ } as const);
135
+
136
+ export type FaqDisplayStyle =
137
+ (typeof FAQ_DISPLAY_STYLES)[keyof typeof FAQ_DISPLAY_STYLES];
138
+
139
+ export const FAQ_MAX_ITEMS = 6;
140
+
129
141
  export const HEADER_CSRF_TOKEN = 'csrf-token';
130
142
 
131
143
  export const STYLE_THEME_KEYS = Object.freeze({
@@ -40,6 +40,8 @@ export const CONTENT_TEXT_STYLE_PROPERTY_KEYS = {
40
40
  buttonTextStyle: 'buttonTextStyle',
41
41
  copyrightStyle: 'copyrightStyle',
42
42
  successMessageStyle: 'successMessageStyle',
43
+ questionStyle: 'questionStyle',
44
+ answerStyle: 'answerStyle',
43
45
  } as const;
44
46
 
45
47
  export type ContentTextStylePropertyKey =
@@ -48,17 +48,10 @@ export interface AbTestResult {
48
48
 
49
49
  const AB_TEST_KEYS = {
50
50
  BP_3926_FREEMIUM_WATERMARK: 'bc-bp-3926-freemium-cta-in-website-editor',
51
- // Two separate live-preview experiments — bc-net sends only the one for the
52
- // current purchase state, and a user's value on each side is independent
53
- // (BP-5873). Keys must match `CampaignNames.LivePreview*Test` in bc-net.
54
- BP_5873_LIVE_PREVIEW_PREPURCHASE: 'bc-bp-5873-live-preview-prepurchase',
55
- BP_5873_LIVE_PREVIEW_POSTPURCHASE: 'bc-bp-5873-live-preview-postpurchase',
56
51
  };
57
52
 
58
53
  export interface AbTest {
59
54
  bp_3926_freemium_watermark?: AbTestResult;
60
- bp_5873_live_preview_prepurchase?: AbTestResult;
61
- bp_5873_live_preview_postpurchase?: AbTestResult;
62
55
  }
63
56
 
64
57
  export const MEDIA_SIZES = {
@@ -139,21 +132,5 @@ export const getAbTestResults = (abtestKeyValues: AbTest): AbTest => {
139
132
  );
140
133
  }
141
134
 
142
- // AB Test for BP-5873 - Live preview (pre- and post-purchase run separately)
143
-
144
- if (abtestKeyValues[AB_TEST_KEYS.BP_5873_LIVE_PREVIEW_PREPURCHASE]) {
145
- abTests.bp_5873_live_preview_prepurchase = getValueType(
146
- AB_TEST_KEYS.BP_5873_LIVE_PREVIEW_PREPURCHASE,
147
- abtestKeyValues[AB_TEST_KEYS.BP_5873_LIVE_PREVIEW_PREPURCHASE],
148
- );
149
- }
150
-
151
- if (abtestKeyValues[AB_TEST_KEYS.BP_5873_LIVE_PREVIEW_POSTPURCHASE]) {
152
- abTests.bp_5873_live_preview_postpurchase = getValueType(
153
- AB_TEST_KEYS.BP_5873_LIVE_PREVIEW_POSTPURCHASE,
154
- abtestKeyValues[AB_TEST_KEYS.BP_5873_LIVE_PREVIEW_POSTPURCHASE],
155
- );
156
- }
157
-
158
135
  return abTests;
159
136
  };
@@ -31,6 +31,7 @@ export const CONTENT_TYPES = {
31
31
  'shapes-section': 'shapes-section',
32
32
  'shop-section': 'shop-section',
33
33
  'booking-section': 'booking-section',
34
+ 'faq-section': 'faq-section',
34
35
  } as const;
35
36
 
36
37
  export type ContentType = keyof typeof CONTENT_TYPES;
@@ -672,6 +672,27 @@ export interface BookingSectionContent extends Content {
672
672
  buttonTextStyle: TextStyleDeclaration;
673
673
  }
674
674
 
675
+ export interface FaqItem {
676
+ /** Stable per-item identifier. See ImageItem.id. */
677
+ id: string;
678
+ question: RichTextValue;
679
+ /** Drives `question`, per item — like a section's `titleStyle` drives its title. */
680
+ questionStyle: TextStyleDeclaration;
681
+ answer: RichTextValue;
682
+ answerStyle: TextStyleDeclaration;
683
+ }
684
+
685
+ export interface FaqSectionContent extends Content {
686
+ type: 'faq-section';
687
+ title: RichTextValue;
688
+ description: RichTextValue;
689
+ displayStyle: string;
690
+ items: FaqItem[];
691
+ titleStyle: TextStyleDeclaration;
692
+ descriptionStyle: TextStyleDeclaration;
693
+ backgroundStyle: BrandPageCssStyle;
694
+ }
695
+
675
696
  export class PageModel {
676
697
  title: string;
677
698
  contents: Content[];
@@ -17,6 +17,7 @@ import type {
17
17
  ButtonContent,
18
18
  PageModel,
19
19
  BookingSectionContent,
20
+ FaqSectionContent,
20
21
  } from '../models';
21
22
 
22
23
  export type ContentCallback = {
@@ -39,6 +40,7 @@ export type ContentCallback = {
39
40
  shopSectionContentCallback: (content: ShopContent) => void;
40
41
  buttonContentCallback: (content: ButtonContent) => void;
41
42
  bookingSectionContentCallback: (content: BookingSectionContent) => void;
43
+ faqSectionContentCallback: (content: FaqSectionContent) => void;
42
44
  };
43
45
 
44
46
  export const forEachPageCallContentCallback = (
@@ -101,6 +103,9 @@ export const forEachPageCallContentCallback = (
101
103
  content as BookingSectionContent,
102
104
  );
103
105
  break;
106
+ case 'faq-section':
107
+ callbacks.faqSectionContentCallback(content as FaqSectionContent);
108
+ break;
104
109
  case 'general-link':
105
110
  case 'website':
106
111
  case 'email':
@@ -21,6 +21,7 @@ import {
21
21
  type ImageContent,
22
22
  type ImageGalleryContent,
23
23
  type BookingSectionContent,
24
+ type FaqSectionContent,
24
25
  } from '../models';
25
26
 
26
27
  export const isLogoContent = (content?: Content): content is LogoContent => {
@@ -97,6 +98,12 @@ export const isBookingContent = (
97
98
  return content?.type === 'booking-section';
98
99
  };
99
100
 
101
+ export const isFaqContent = (
102
+ content?: Content,
103
+ ): content is FaqSectionContent => {
104
+ return content?.type === 'faq-section';
105
+ };
106
+
100
107
  export const isAboutContent = (
101
108
  content?: Content,
102
109
  ): content is AboutSectionContent => {
@@ -1121,6 +1121,102 @@ describe('container-aware text colour defaults (view-model layer)', () => {
1121
1121
  });
1122
1122
  });
1123
1123
 
1124
+ describe('faq-section view model', () => {
1125
+ const faqContent = (overrides: Record<string, unknown> = {}): any => ({
1126
+ id: 'faq-1',
1127
+ type: 'faq-section',
1128
+ title: 'FAQ',
1129
+ description: '',
1130
+ displayStyle: 'faq-split',
1131
+ items: [
1132
+ {
1133
+ id: 'q1',
1134
+ question: 'Q1',
1135
+ questionStyle: { textStyleType: 'headings' },
1136
+ answer: 'A1',
1137
+ answerStyle: { textStyleType: 'paragraphs' },
1138
+ },
1139
+ {
1140
+ id: 'q2',
1141
+ question: 'Q2',
1142
+ questionStyle: { fontSize: '1.5rem' }, // detached
1143
+ answer: 'A2',
1144
+ answerStyle: { textStyleType: 'paragraphs' },
1145
+ },
1146
+ ],
1147
+ titleStyle: { textStyleType: 'titles' },
1148
+ descriptionStyle: { textStyleType: 'paragraphs' },
1149
+ backgroundStyle: {},
1150
+ topPadding: 32,
1151
+ bottomPadding: 32,
1152
+ ...overrides,
1153
+ });
1154
+
1155
+ test.each([
1156
+ [BRAND_PAGE_TYPES.default, true],
1157
+ [BRAND_PAGE_TYPES.brandContact, true],
1158
+ [BRAND_PAGE_TYPES.brandSite, false],
1159
+ ])('%s → isStacked %s', (brandPageType, isStacked) => {
1160
+ const viewModel: any = mapToViewModel(
1161
+ new PageStyles(),
1162
+ brandPageType,
1163
+ {},
1164
+ )(faqContent());
1165
+
1166
+ expect(viewModel.type).toBe('faq-section');
1167
+ expect(viewModel.isStacked).toBe(isStacked);
1168
+ expect(viewModel.items).toHaveLength(2);
1169
+ });
1170
+
1171
+ test('carries the maker panel’s open item only for its own content', () => {
1172
+ const map = (contentId: string): any =>
1173
+ mapToViewModel(new PageStyles(), BRAND_PAGE_TYPES.brandSite, {
1174
+ activeItemIdByContentId: { [contentId]: 'q2' },
1175
+ })(faqContent());
1176
+
1177
+ expect(map('faq-1').activeItemId).toBe('q2');
1178
+ expect(map('another-section').activeItemId).toBeUndefined();
1179
+ });
1180
+
1181
+ test("each item's question and answer follow their global text roles until detached", () => {
1182
+ const pageStyles = new PageStyles();
1183
+ const { headings, paragraphs } = pageStyles.textStyles.textStyleOptions;
1184
+
1185
+ headings.style.fontSize = '1.875rem';
1186
+ headings.style.color = '#00ff00';
1187
+ headings.isModified = true;
1188
+ paragraphs.style.color = '#0000ff';
1189
+
1190
+ const content = faqContent();
1191
+ const viewModel: any = mapToViewModel(
1192
+ pageStyles,
1193
+ BRAND_PAGE_TYPES.brandSite,
1194
+ {},
1195
+ )(content);
1196
+ const [linked, detached] = viewModel.items;
1197
+
1198
+ expect(linked.questionStyle.fontSize).toBe('1.875rem');
1199
+ expect(linked.questionStyle.color).toBe('#00ff00');
1200
+ expect(linked.answerStyle.color).toBe('#0000ff');
1201
+ expect(detached.questionStyle.fontSize).toBe('1.5rem');
1202
+ expect(detached.answerStyle.color).toBe('#0000ff');
1203
+ // Resolution copies; the stored item keeps only its link.
1204
+ expect(content.items[0].questionStyle).toEqual({
1205
+ textStyleType: 'headings',
1206
+ });
1207
+ });
1208
+
1209
+ test('content without items maps without throwing', () => {
1210
+ const viewModel: any = mapToViewModel(
1211
+ new PageStyles(),
1212
+ BRAND_PAGE_TYPES.brandSite,
1213
+ {},
1214
+ )(faqContent({ items: undefined }));
1215
+
1216
+ expect(viewModel.items).toEqual([]);
1217
+ });
1218
+ });
1219
+
1124
1220
  describe('mapToPageHeaderViewModel header button', () => {
1125
1221
  const headerButton: HeaderButton = {
1126
1222
  isVisible: true,
@@ -36,6 +36,7 @@ import type {
36
36
  LogoTemplateImageModel,
37
37
  ShopContent,
38
38
  BookingSectionContent,
39
+ FaqSectionContent,
39
40
  TextShapeItem,
40
41
  GettyVideoMedia,
41
42
  AbstractMedia,
@@ -56,6 +57,7 @@ import {
56
57
  BUTTON_LINK_TYPE,
57
58
  NavigationMenuStyles,
58
59
  type TextItem,
60
+ type FaqItem,
59
61
  } from '../models';
60
62
  import type {
61
63
  ImageViewModel,
@@ -84,6 +86,7 @@ import type {
84
86
  ShapesSectionViewModel,
85
87
  ShopViewModel,
86
88
  BookingSectionViewModel,
89
+ FaqSectionViewModel,
87
90
  GettyVideoViewModel,
88
91
  PageStylesViewModel,
89
92
  BackgroundStylesViewModel,
@@ -1038,6 +1041,42 @@ const mapToTestimonialSectionViewModel = (
1038
1041
  );
1039
1042
  };
1040
1043
 
1044
+ // An item's question/answer styles are CONTENT_TEXT_STYLE_PROPERTY_KEYS, so the section
1045
+ // resolver handles them when handed the item.
1046
+ const mapFaqItem = (item: FaqItem, styles: PageStyles): FaqItem =>
1047
+ applyViewModelTextStyles(
1048
+ {
1049
+ ...item,
1050
+ questionStyle: { ...item.questionStyle },
1051
+ answerStyle: { ...item.answerStyle },
1052
+ } as unknown as ContentViewModel,
1053
+ styles,
1054
+ ) as unknown as FaqItem;
1055
+
1056
+ const mapToFaqSectionViewModel = (
1057
+ content: FaqSectionContent,
1058
+ styles: PageStyles,
1059
+ brandPageType: BrandPageType,
1060
+ options: ViewModelMappingOptions,
1061
+ ): FaqSectionViewModel => {
1062
+ return applyViewModelTextStyles(
1063
+ {
1064
+ ...content,
1065
+ role: content.type,
1066
+ type: 'faq-section',
1067
+ // No `color` pre-seed — these styles are CONTENT_TEXT_STYLE_PROPERTY_KEYS
1068
+ // (see the testimonial mapper), resolved per item for the item styles.
1069
+ titleStyle: { ...content.titleStyle },
1070
+ descriptionStyle: { ...content.descriptionStyle },
1071
+ items: (content.items ?? []).map((item) => mapFaqItem(item, styles)),
1072
+ isFullWidth: true,
1073
+ isStacked: brandPageType !== BRAND_PAGE_TYPES.brandSite,
1074
+ activeItemId: options.activeItemIdByContentId?.[content.id],
1075
+ },
1076
+ styles,
1077
+ );
1078
+ };
1079
+
1041
1080
  const mapToSocialLinksViewModel = (
1042
1081
  content: SocialLinksContent,
1043
1082
  styles: PageStyles,
@@ -1511,6 +1550,14 @@ export const mapToViewModel =
1511
1550
  styles,
1512
1551
  );
1513
1552
 
1553
+ case 'faq-section':
1554
+ return mapToFaqSectionViewModel(
1555
+ content as FaqSectionContent,
1556
+ styles,
1557
+ brandPageType,
1558
+ options,
1559
+ );
1560
+
1514
1561
  case 'about-section':
1515
1562
  return mapToAboutSectionViewModel(
1516
1563
  content as AboutSectionContent,
@@ -1586,6 +1633,8 @@ interface ViewModelMappingOptions {
1586
1633
  previewImageUrl: string,
1587
1634
  assetId: string,
1588
1635
  ) => void;
1636
+ /** Maker-only UI state: content id → the item its settings panel has open. Never persisted. */
1637
+ activeItemIdByContentId?: Record<string, string | undefined>;
1589
1638
  }
1590
1639
 
1591
1640
  const mapBackgroundStylesToViewModel = (
@@ -35,6 +35,7 @@ import type {
35
35
  LogoTemplateImageModel,
36
36
  CookieOptions,
37
37
  BookingSectionContent,
38
+ FaqSectionContent,
38
39
  MediaTypes,
39
40
  TextStyles,
40
41
  BackgroundStyle,
@@ -67,7 +68,8 @@ export type ViewModelType =
67
68
  | 'social-links'
68
69
  | 'shapes-section'
69
70
  | 'shop-section'
70
- | 'booking-section';
71
+ | 'booking-section'
72
+ | 'faq-section';
71
73
 
72
74
  export interface ContentViewModel {
73
75
  id: string;
@@ -249,6 +251,15 @@ export interface BookingSectionViewModel
249
251
  isFullWidth: boolean;
250
252
  }
251
253
 
254
+ export interface FaqSectionViewModel
255
+ extends ContentViewModel, Omit<FaqSectionContent, 'type'> {
256
+ isFullWidth: boolean;
257
+ /** brand.page / brand.contact: split and grid always use their stacked layout. */
258
+ isStacked: boolean;
259
+ /** Maker-only: the item the settings panel has open, so the canvas shows it. */
260
+ activeItemId?: string;
261
+ }
262
+
252
263
  export interface PageHeaderViewModel extends Omit<
253
264
  PageHeaderDesktop | PageHeaderMobile,
254
265
  | 'logoImage'
@@ -17,16 +17,6 @@ export default defineComponent({
17
17
 
18
18
  return {
19
19
  isAdminMaker: computed(() => containerStore.isAdminMaker),
20
- // BP-5873: the Preview button is behind an AB test, and pre/post purchase are
21
- // two separate experiments. Pick the one for the current purchase state — a
22
- // user's value on the other side is independent and must not leak across.
23
- isLivePreviewEnabled: computed(() => {
24
- const experiment = containerStore.isPrePurchase
25
- ? containerStore.abTests.bp_5873_live_preview_prepurchase
26
- : containerStore.abTests.bp_5873_live_preview_postpurchase;
27
-
28
- return !!experiment?.isVariation;
29
- }),
30
20
  isSaving: computed(() => brandPageMakerStore.isSaving),
31
21
  isPublished: computed(() => brandPageMakerStore.isPublished),
32
22
  isPublishButtonEnabled: computed(
@@ -3,31 +3,13 @@ import { createPinia, setActivePinia } from 'pinia';
3
3
  import { shallowMount } from '@vue/test-utils';
4
4
  import ActionBarNavigation from './ActionBarNavigation.vue';
5
5
  import { useBrandPageContainerStore } from '@brand-page-maker/stores';
6
- import { getAbTestResults } from '@brand-page/models/brand-page-context.model';
7
-
8
- // Must match `CampaignNames.LivePreview*` in bc-net. The two experiments share
9
- // one set of variation values — they're told apart by campaign key, not value.
10
- const PREPURCHASE = 'bc-bp-5873-live-preview-prepurchase';
11
- const POSTPURCHASE = 'bc-bp-5873-live-preview-postpurchase';
12
- const VARIATION_1 = 'bp-5873-variation-1';
13
- const CONTROL = 'bp-5873-control';
14
6
 
15
7
  const mountBar = () => shallowMount(ActionBarNavigation);
16
8
 
17
9
  const hasPreviewButton = (wrapper: ReturnType<typeof mountBar>) =>
18
10
  wrapper.find('[data-test-preview-button]').exists();
19
11
 
20
- // The AB value arrives as the raw bc-net campaign string, so go through the real
21
- // parser rather than hand-building an AbTestResult — that's the contract under test.
22
- const setAbTest = (
23
- store: ReturnType<typeof useBrandPageContainerStore>,
24
- key: string,
25
- value: string,
26
- ) => {
27
- store.abTests = getAbTestResults({ [key]: value } as never);
28
- };
29
-
30
- describe('ActionBarNavigation — BP-5873 preview AB test', () => {
12
+ describe('ActionBarNavigation — Preview button', () => {
31
13
  let containerStore: ReturnType<typeof useBrandPageContainerStore>;
32
14
 
33
15
  beforeEach(() => {
@@ -35,39 +17,18 @@ describe('ActionBarNavigation — BP-5873 preview AB test', () => {
35
17
  containerStore = useBrandPageContainerStore();
36
18
  });
37
19
 
38
- test('hides Preview when no AB value has been sent', () => {
39
- expect(hasPreviewButton(mountBar())).toBe(false);
40
- });
41
-
42
- test('post-purchase: shows Preview on variation-1, hides it on control', () => {
43
- containerStore.isPrePurchase = false;
44
-
45
- setAbTest(containerStore, POSTPURCHASE, VARIATION_1);
46
- expect(hasPreviewButton(mountBar())).toBe(true);
47
-
48
- setAbTest(containerStore, POSTPURCHASE, CONTROL);
49
- expect(hasPreviewButton(mountBar())).toBe(false);
50
- });
51
-
52
- test('pre-purchase: shows Preview on variation-1, hides it on control', () => {
53
- containerStore.isPrePurchase = true;
20
+ test.each([
21
+ ['pre-purchase', true],
22
+ ['post-purchase', false],
23
+ ])('shows Preview to the customer %s', (_, isPrePurchase) => {
24
+ containerStore.isPrePurchase = isPrePurchase;
54
25
 
55
- setAbTest(containerStore, PREPURCHASE, VARIATION_1);
56
26
  expect(hasPreviewButton(mountBar())).toBe(true);
57
-
58
- setAbTest(containerStore, PREPURCHASE, CONTROL);
59
- expect(hasPreviewButton(mountBar())).toBe(false);
60
27
  });
61
28
 
62
- // Two separate experiments, so a value on one side must never satisfy the other —
63
- // otherwise a post-purchase variation would leak the button into pre-purchase.
64
- test('the two experiments never read each other', () => {
65
- containerStore.isPrePurchase = true;
66
- setAbTest(containerStore, POSTPURCHASE, VARIATION_1);
67
- expect(hasPreviewButton(mountBar())).toBe(false);
29
+ test('hides Preview in the admin maker', () => {
30
+ containerStore.isAdminMaker = true;
68
31
 
69
- containerStore.isPrePurchase = false;
70
- setAbTest(containerStore, PREPURCHASE, VARIATION_1);
71
32
  expect(hasPreviewButton(mountBar())).toBe(false);
72
33
  });
73
34
 
@@ -75,8 +36,6 @@ describe('ActionBarNavigation — BP-5873 preview AB test', () => {
75
36
  // Save drops out. Preview must be a real column like the rest — while it was content
76
37
  // width, Publish ate the whole remaining row on a published site.
77
38
  test('Preview shares the mobile row equally with the other buttons', () => {
78
- containerStore.isPrePurchase = false;
79
- setAbTest(containerStore, POSTPURCHASE, VARIATION_1);
80
39
  containerStore.brandPageStatus = 'Published';
81
40
 
82
41
  const wrapper = mountBar();
@@ -4,7 +4,7 @@
4
4
  class="tw-flex tw-items-center tw-border-t tw-border-solid tw-border-grayscale-600 tw-h-12 tw-px-2 lg:tw-px-0 lg:tw-h-auto lg:tw-border-none tw-gap-3 lg:tw-gap-5 tw-w-full lg:tw-justify-end tw-box-border"
5
5
  >
6
6
  <Button
7
- v-if="!isAdminMaker && isLivePreviewEnabled"
7
+ v-if="!isAdminMaker"
8
8
  :label="constantsTr('previewDisplayName')"
9
9
  variant="no-border"
10
10
  :white="true"
@@ -0,0 +1,165 @@
1
+ import { vi } from 'vitest';
2
+ import { flushPromises, mount } from '@vue/test-utils';
3
+ import { createPinia, setActivePinia } from 'pinia';
4
+ import RichTextEditor from './RichTextEditor.vue';
5
+ import { useUiContentSettingsStore } from '@shared/content-settings/store';
6
+
7
+ // The toolbar capitalises translated role names at setup; tests carry no bundles.
8
+ vi.mock('@src/composables/useTranslate', async (importOriginal) => ({
9
+ ...(await importOriginal<Record<string, unknown>>()),
10
+ styleSettingsTr: (key: string) => key,
11
+ commonTr: (key: string) => key,
12
+ }));
13
+
14
+ const doc = {
15
+ type: 'doc',
16
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hi' }] }],
17
+ };
18
+
19
+ const itemStyleTarget = {
20
+ itemsField: 'items',
21
+ index: 1,
22
+ styleField: 'questionStyle',
23
+ };
24
+
25
+ const mountEditor = (props: Record<string, unknown> = {}) =>
26
+ mount(RichTextEditor, {
27
+ props: {
28
+ id: 'faq-1-question',
29
+ name: 'question',
30
+ rawId: 'faq-1',
31
+ modelValue: doc,
32
+ ...props,
33
+ } as any,
34
+ global: {
35
+ directives: { clickOutside: {} },
36
+ stubs: { FontPicker: true },
37
+ },
38
+ });
39
+
40
+ const editorOf = (wrapper: ReturnType<typeof mountEditor>) =>
41
+ (wrapper.find('.ProseMirror').element as any).editor;
42
+
43
+ describe('RichTextEditor', () => {
44
+ beforeEach(() => {
45
+ setActivePinia(createPinia());
46
+ });
47
+
48
+ test('an item editor has the full section-title toolbar', () => {
49
+ const tags = mountEditor({ itemStyleTarget })
50
+ .find('.style-editor')
51
+ .findAll('[data-test-tag]')
52
+ .map((el) => el.attributes('data-test-tag'));
53
+
54
+ expect(tags).toEqual(
55
+ expect.arrayContaining([
56
+ 'text-style-dropdown',
57
+ 'font-style-dropdown',
58
+ 'font-size-stepper',
59
+ 'text-color-picker',
60
+ 'bold-toolbar',
61
+ 'link-toolbar',
62
+ ]),
63
+ );
64
+ });
65
+
66
+ test.each([
67
+ ['an item editor', { itemStyleTarget }, 'updateListItemTextStyles'],
68
+ ['a section field', {}, 'updateTextStyles'],
69
+ ])(
70
+ '%s writes its toolbar styles through %s',
71
+ async (_label, props, expectedAction) => {
72
+ const store = useUiContentSettingsStore();
73
+ const spies = {
74
+ updateListItemTextStyles: vi.spyOn(store, 'updateListItemTextStyles'),
75
+ updateTextStyles: vi.spyOn(store, 'updateTextStyles'),
76
+ };
77
+ const wrapper = mountEditor(props);
78
+
79
+ await flushPromises();
80
+ Object.values(spies).forEach((spy) => spy.mockClear());
81
+ await wrapper.find('[data-test-tag="bold-toolbar"]').trigger('click');
82
+
83
+ Object.entries(spies).forEach(([action, spy]) =>
84
+ expect(spy).toHaveBeenCalledTimes(action === expectedAction ? 1 : 0),
85
+ );
86
+
87
+ if (expectedAction === 'updateListItemTextStyles') {
88
+ expect(spies.updateListItemTextStyles).toHaveBeenCalledWith(
89
+ expect.objectContaining({
90
+ id: 'faq-1',
91
+ target: itemStyleTarget,
92
+ value: expect.objectContaining({ fontWeight: 'bold' }),
93
+ }),
94
+ );
95
+ }
96
+ },
97
+ );
98
+
99
+ test('picking a text style on an item editor links that item, not the section', async () => {
100
+ const store = useUiContentSettingsStore();
101
+ const updateListItemTextStyles = vi.spyOn(
102
+ store,
103
+ 'updateListItemTextStyles',
104
+ );
105
+ const updateTextStyles = vi.spyOn(store, 'updateTextStyles');
106
+ const wrapper = mountEditor({ itemStyleTarget });
107
+
108
+ await flushPromises();
109
+ updateTextStyles.mockClear();
110
+
111
+ const headings = wrapper
112
+ .findAll('.drop-down-item')
113
+ .find((el) => el.text().toLowerCase().includes('headings'));
114
+
115
+ await headings!.trigger('click');
116
+
117
+ expect(updateListItemTextStyles).toHaveBeenCalledWith(
118
+ expect.objectContaining({
119
+ target: itemStyleTarget,
120
+ value: expect.objectContaining({ textStyleType: 'headings' }),
121
+ }),
122
+ );
123
+ expect(updateTextStyles).not.toHaveBeenCalled();
124
+ });
125
+
126
+ test("a linked item editor's mount-time style sync lands on the item, as a hydration write", async () => {
127
+ const store = useUiContentSettingsStore();
128
+ const updateListItemTextStyles = vi.spyOn(
129
+ store,
130
+ 'updateListItemTextStyles',
131
+ );
132
+ const updateTextStyles = vi.spyOn(store, 'updateTextStyles');
133
+
134
+ mountEditor({ itemStyleTarget, styles: { textStyleType: 'headings' } });
135
+ await flushPromises();
136
+
137
+ expect(updateListItemTextStyles).toHaveBeenCalledWith(
138
+ expect.objectContaining({
139
+ target: itemStyleTarget,
140
+ isHydrationWrite: true,
141
+ }),
142
+ );
143
+ expect(updateTextStyles).not.toHaveBeenCalled();
144
+ });
145
+
146
+ // The toolbar has taken focus by the time its command runs; tagging that
147
+ // write as hydration would fold it into the previous undo entry.
148
+ test('a toolbar command on a selection emits a user write, not a hydration write', async () => {
149
+ const wrapper = mountEditor();
150
+
151
+ await flushPromises();
152
+
153
+ const editor = editorOf(wrapper);
154
+
155
+ editor.commands.selectAll();
156
+ editor.commands.blur();
157
+ await wrapper.find('[data-test-tag="bold-toolbar"]').trigger('click');
158
+
159
+ const emits = wrapper.emitted('update:modelValue') ?? [];
160
+ const [value, meta] = emits[emits.length - 1] as [any, any];
161
+
162
+ expect(JSON.stringify(value)).toContain('"bold"');
163
+ expect(meta.isHydrationWrite).toBe(false);
164
+ });
165
+ });