@designcrowd/library.brand-page 6.2.3 → 6.2.4

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 (112) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-Cn0M-eZ1.js → CreatedOnBrandCrowd-de-de-ystrO2LW.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-DRLMSDZr.js → CreatedOnBrandCrowd-en-us-BU7hNzTZ.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-BglTu73U.js → CreatedOnBrandCrowd-es-es-CbuxneQ3.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-Bj6YZ_el.js → CreatedOnBrandCrowd-fr-ca-BuNUGOFr.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-Bj6YZ_el.js → CreatedOnBrandCrowd-fr-fr-BuNUGOFr.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-DEoOmQ1F.js → CreatedOnBrandCrowd-pt-br-BehL88mc.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-DEoOmQ1F.js → CreatedOnBrandCrowd-pt-pt-BehL88mc.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-BZ7gTt4Z.js → CreatedOnDcom-de-de-CMxjFZt9.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-CD6Km5sH.js → CreatedOnDcom-en-us-2Cf6WBvx.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-CNpZHvEl.js → CreatedOnDcom-es-es-CATNnbTq.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-URS-ydJ1.js → CreatedOnDcom-fr-ca-DQtMfB-a.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-D2AVeA3H.js → CreatedOnDcom-fr-fr-C1_FWOQf.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-C32frnFs.js → CreatedOnDcom-pt-br-C0QOCW0L.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-TDYGjRXS.js → CreatedOnDcom-pt-pt-lHD-p6o4.js} +1 -1
  15. package/lib/src/brand-page/models/brand-page-context.model.d.ts +5 -0
  16. package/lib/src/brand-page/models/brand-page-context.model.js +8 -1
  17. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +21 -0
  18. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +21 -0
  19. package/lib/src/brand-page-maker/components/preview/Preview.mixin.d.ts +7 -0
  20. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +21 -0
  21. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +22 -1
  22. package/lib/src/brand-page-maker/components/seo-settings/UrlSettings.mixin.d.ts +21 -0
  23. package/lib/src/brand-page-maker/components/seo-settings/UseExistingDomainModal.mixin.d.ts +21 -0
  24. package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.d.ts +21 -0
  25. package/lib/src/brand-page-maker/components/upgrade-to-premium-button/UpgradeToPremiumButton.mixin.d.ts +21 -0
  26. package/lib/src/brand-page-maker/composables/ai-chat/ai-builder-experiment.d.ts +10 -0
  27. package/lib/src/brand-page-maker/composables/ai-chat/ai-builder-experiment.js +11 -0
  28. package/lib/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.js +14 -4
  29. package/lib/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.d.ts +9 -2
  30. package/lib/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.js +20 -0
  31. package/lib/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.d.ts +21 -0
  32. package/lib/src/clients/ai-assistant-client.d.ts +1 -0
  33. package/lib/src/clients/ai-assistant-client.js +1 -0
  34. package/lib/src/index.mjs +224 -223
  35. package/lib/src/{lib-Dm9AuOJL.js → lib-CqHUUPUW.js} +21920 -21691
  36. package/lib/src/metadata-builder/builders/global-font-propagation.d.ts +41 -0
  37. package/lib/src/metadata-builder/builders/global-font-propagation.js +146 -0
  38. package/lib/src/metadata-builder/builders/global-styles-builder.js +29 -1
  39. package/lib/src/metadata-builder/builders/header-builder.js +8 -0
  40. package/lib/src/metadata-builder/builders/reorder-pages-builder.d.ts +13 -0
  41. package/lib/src/metadata-builder/builders/reorder-pages-builder.js +49 -0
  42. package/lib/src/metadata-builder/builders/section-builders/index.d.ts +1 -0
  43. package/lib/src/metadata-builder/builders/section-builders/index.js +1 -0
  44. package/lib/src/metadata-builder/builders/section-builders/support-me-builder.d.ts +8 -0
  45. package/lib/src/metadata-builder/builders/section-builders/support-me-builder.js +25 -0
  46. package/lib/src/metadata-builder/builders/section-builders/text-section-builder.js +5 -2
  47. package/lib/src/metadata-builder/builders/section-field-maps.js +15 -0
  48. package/lib/src/metadata-builder/builders/section-field-utils.d.ts +6 -1
  49. package/lib/src/metadata-builder/builders/section-field-utils.js +48 -12
  50. package/lib/src/metadata-builder/builders/section-rules/support-me.rules.d.ts +2 -0
  51. package/lib/src/metadata-builder/builders/section-rules/support-me.rules.js +17 -0
  52. package/lib/src/metadata-builder/helpers/rte.helpers.d.ts +9 -0
  53. package/lib/src/metadata-builder/helpers/rte.helpers.js +60 -1
  54. package/lib/src/metadata-builder/metadata-executor.js +3 -1
  55. package/lib/src/metadata-builder/models.d.ts +2 -0
  56. package/lib/src/metadata-builder/models.js +2 -0
  57. package/lib/src/metadata-builder/parsers/ai-toolcall-parser.d.ts +1 -0
  58. package/lib/src/metadata-builder/parsers/ai-toolcall-parser.js +4 -0
  59. package/lib/src/metadata-builder/parsers/section-tool-arguments.d.ts +14 -2
  60. package/lib/src/shared/content-settings/components/GenericTextInput.mixin.d.ts +1 -1
  61. package/lib/src/shared/content-settings/components/email-field/EmailField.mixin.d.ts +1 -1
  62. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +21 -0
  63. package/lib/src/shared/content-settings/components/tel-field/TelField.mixin.d.ts +1 -1
  64. package/lib/src/shared/content-settings/components/text-field/TextField.mixin.d.ts +1 -1
  65. package/lib/src/shared/content-settings/components/textarea-field/TextareaField.mixin.d.ts +1 -1
  66. package/lib/src/shared/content-settings/components/url-field/UrlField.mixin.d.ts +1 -1
  67. package/lib/src/shared/content-settings/components/youtube-video-field/YoutubeVideoField.mixin.d.ts +1 -1
  68. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +21 -0
  69. package/lib/src/shared/page-settings/components/delete-page-modal/DeletePageModal.mixin.d.ts +1 -1
  70. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +21 -0
  71. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +21 -0
  72. package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +28 -0
  73. package/lib/src/shared/side-bar/components/SideBar.mixin.d.ts +21 -0
  74. package/lib/src/shared/style-settings/button-style-preset.d.ts +12 -0
  75. package/lib/src/shared/style-settings/button-style-preset.js +39 -0
  76. package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +21 -0
  77. package/lib/src/shared/style-settings/store/style-settings-store.js +6 -32
  78. package/lib/src/utils/minify-brand-page-metadata.d.ts +9 -0
  79. package/lib/src/utils/minify-brand-page-metadata.js +23 -1
  80. package/package.json +1 -12
  81. package/src/brand-page/models/brand-page-context.model.ts +14 -1
  82. package/src/brand-page-maker/components/ai-website-builder/AiWebsiteBuilder.vue +1 -1
  83. package/src/brand-page-maker/composables/ai-chat/ai-builder-experiment.test.ts +82 -0
  84. package/src/brand-page-maker/composables/ai-chat/ai-builder-experiment.ts +19 -0
  85. package/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.ts +18 -4
  86. package/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.ts +30 -0
  87. package/src/clients/ai-assistant-client.ts +1 -0
  88. package/src/metadata-builder/builders/global-font-propagation.test.ts +401 -0
  89. package/src/metadata-builder/builders/global-font-propagation.ts +238 -0
  90. package/src/metadata-builder/builders/global-styles-builder.test.ts +107 -0
  91. package/src/metadata-builder/builders/global-styles-builder.ts +38 -1
  92. package/src/metadata-builder/builders/header-builder.test.ts +45 -0
  93. package/src/metadata-builder/builders/header-builder.ts +20 -0
  94. package/src/metadata-builder/builders/reorder-pages-builder.ts +80 -0
  95. package/src/metadata-builder/builders/section-builders/index.ts +1 -0
  96. package/src/metadata-builder/builders/section-builders/support-me-builder.test.ts +177 -0
  97. package/src/metadata-builder/builders/section-builders/support-me-builder.ts +43 -0
  98. package/src/metadata-builder/builders/section-builders/text-section-builder.ts +5 -2
  99. package/src/metadata-builder/builders/section-field-maps.ts +18 -0
  100. package/src/metadata-builder/builders/section-field-utils.test.ts +83 -0
  101. package/src/metadata-builder/builders/section-field-utils.ts +69 -18
  102. package/src/metadata-builder/builders/section-rules/support-me.rules.ts +39 -0
  103. package/src/metadata-builder/helpers/rte.helpers.ts +82 -1
  104. package/src/metadata-builder/metadata-executor.ts +3 -0
  105. package/src/metadata-builder/models.ts +2 -0
  106. package/src/metadata-builder/parsers/ai-toolcall-parser.ts +4 -0
  107. package/src/metadata-builder/parsers/section-tool-arguments.ts +14 -1
  108. package/src/shared/style-settings/button-style-preset.test.ts +67 -0
  109. package/src/shared/style-settings/button-style-preset.ts +56 -0
  110. package/src/shared/style-settings/store/style-settings-store.ts +9 -38
  111. package/src/utils/minify-brand-page-metadata.test.ts +166 -1
  112. package/src/utils/minify-brand-page-metadata.ts +31 -1
@@ -0,0 +1,41 @@
1
+ import type { JSONContent } from '@tiptap/vue-3';
2
+ import type { BrandPageModel } from '../../brand-page/models';
3
+ import type { UpdateGlobalStylesArguments } from '../parsers/ai-toolcall-parser';
4
+ /**
5
+ * Propagation of AI global font-family changes into existing section content.
6
+ *
7
+ * A `tool_update_global_styles` call merges new per-category font families into
8
+ * `styles.textStyles.textStyleOptions` — but only content whose style is still
9
+ * bound to a preset (`textStyleType`) follows that change. Template-authored
10
+ * content carries detached `*Style` objects AND inline `font-family` marks in
11
+ * its RTE HTML, both of which override the preset at render time, so a global
12
+ * font change was invisible on most templates (BP-5780 ISSUE-01).
13
+ *
14
+ * This module walks every page's contents and, for each text field whose
15
+ * resolved category received a new family, aligns the detached style object and
16
+ * rewrites the RTE field's inline font-family marks. Preset-bound styles are NOT
17
+ * detached — they already follow the preset; only their RTE inline marks are
18
+ * rewritten (the marks would otherwise still win over the preset).
19
+ *
20
+ * RTE fields come in two shapes: legacy inline HTML strings (rewritten through
21
+ * the headless Tiptap editor) and the migrated Tiptap JSON document (rewritten
22
+ * in place — `textStyle` mark attrs). Missing the JSON shape left every mark on
23
+ * the old family while the `*Style` objects moved (BP-6079).
24
+ */
25
+ export type GlobalFontCategory = 'titles' | 'headings' | 'paragraphs' | 'buttons';
26
+ /** Extract the per-category families this AI call actually changes. */
27
+ export declare const collectChangedFontFamilies: (textStyles: UpdateGlobalStylesArguments["textStyles"]) => Partial<Record<GlobalFontCategory, string>>;
28
+ export type ApplyFontToRTE = (text: string, fontFamily: string) => string;
29
+ /**
30
+ * Rewrite the `fontFamily` attr of every `textStyle` mark in a Tiptap document.
31
+ * Only marks that already carry a family are touched: a run without one inherits
32
+ * the (already updated) `*Style` / preset at render, so adding a mark would pin
33
+ * it needlessly.
34
+ */
35
+ export declare const applyFontToTiptapDoc: (doc: JSONContent, fontFamily: string) => JSONContent;
36
+ /**
37
+ * Walk every page's contents and propagate the changed families.
38
+ * `applyFont` is injectable for tests; the default rewrites inline marks via
39
+ * the external Tiptap editor.
40
+ */
41
+ export declare const propagateFontFamiliesToContents: (model: BrandPageModel, changed: Partial<Record<GlobalFontCategory, string>>, applyFont?: ApplyFontToRTE) => void;
@@ -0,0 +1,146 @@
1
+ import { hasRTEContent } from '../../brand-page-maker/utils';
2
+ import { useExternalRichTextEditor } from '../../brand-page-maker/composables/rich-text-editor/useRichTextEditor';
3
+ import { applyStyleToRTEText } from '../helpers/rte.helpers';
4
+ import { isTiptapDocument } from '../parsers/section-tool-arguments';
5
+ /**
6
+ * Default category per text field when its style is detached (no `textStyleType`).
7
+ * Mirrors the AI service's slot→category stamping (titles←h1, headings←h2-h4,
8
+ * paragraphs←body, buttons←button). `title` upgrades to `titles` on banner
9
+ * sections (the hero headline) via `resolveFieldCategory`.
10
+ */
11
+ const FIELD_DEFAULT_CATEGORY = {
12
+ title: 'headings',
13
+ heading: 'headings',
14
+ name: 'headings',
15
+ description: 'paragraphs',
16
+ paragraph: 'paragraphs',
17
+ text: 'paragraphs',
18
+ testimonial: 'paragraphs',
19
+ position: 'paragraphs',
20
+ question: 'headings',
21
+ answer: 'paragraphs',
22
+ buttonText: 'buttons',
23
+ successMessage: 'paragraphs',
24
+ };
25
+ const CATEGORIES = [
26
+ 'titles',
27
+ 'headings',
28
+ 'paragraphs',
29
+ 'buttons',
30
+ ];
31
+ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
32
+ /** Extract the per-category families this AI call actually changes. */
33
+ export const collectChangedFontFamilies = (textStyles) => {
34
+ const changed = {};
35
+ if (!textStyles)
36
+ return changed;
37
+ for (const category of CATEGORIES) {
38
+ const family = textStyles.textStyleOptions?.[category]?.style?.fontFamily;
39
+ if (typeof family === 'string' && family.trim()) {
40
+ changed[category] = family;
41
+ }
42
+ }
43
+ // Base text family covers paragraphs when no explicit paragraphs entry came in.
44
+ if (!changed.paragraphs &&
45
+ typeof textStyles.textFontFamily === 'string' &&
46
+ textStyles.textFontFamily.trim()) {
47
+ changed.paragraphs = textStyles.textFontFamily;
48
+ }
49
+ return changed;
50
+ };
51
+ const resolveFieldCategory = (fieldName, style, content) => {
52
+ const preset = isPlainObject(style)
53
+ ? style.textStyleType
54
+ : undefined;
55
+ if (preset && CATEGORIES.includes(preset))
56
+ return preset;
57
+ if (fieldName === 'title' && content.type === 'banner-section') {
58
+ return 'titles';
59
+ }
60
+ return FIELD_DEFAULT_CATEGORY[fieldName];
61
+ };
62
+ /** `testimonialsStyle` → `testimonial`, `titleStyle` → `title`. */
63
+ const styleKeyToFieldName = (styleKey) => {
64
+ const base = styleKey.slice(0, -'Style'.length);
65
+ return base.endsWith('s') && !(base in FIELD_DEFAULT_CATEGORY)
66
+ ? base.slice(0, -1)
67
+ : base;
68
+ };
69
+ /**
70
+ * Rewrite the `fontFamily` attr of every `textStyle` mark in a Tiptap document.
71
+ * Only marks that already carry a family are touched: a run without one inherits
72
+ * the (already updated) `*Style` / preset at render, so adding a mark would pin
73
+ * it needlessly.
74
+ */
75
+ export const applyFontToTiptapDoc = (doc, fontFamily) => {
76
+ const visit = (node) => {
77
+ const marks = node.marks?.map((mark) => mark.type === 'textStyle' && mark.attrs?.fontFamily
78
+ ? { ...mark, attrs: { ...mark.attrs, fontFamily } }
79
+ : mark);
80
+ const content = node.content?.map(visit);
81
+ return {
82
+ ...node,
83
+ ...(marks ? { marks } : {}),
84
+ ...(content ? { content } : {}),
85
+ };
86
+ };
87
+ return visit(doc);
88
+ };
89
+ const walkRecord = (record, content, changed, applyFont) => {
90
+ for (const [key, value] of Object.entries(record)) {
91
+ if (key.endsWith('Style') && isPlainObject(value)) {
92
+ // Detached style objects carry their own fontFamily override — align it.
93
+ // Preset-bound styles keep following the preset (never detach here).
94
+ const category = resolveFieldCategory(styleKeyToFieldName(key), value, content);
95
+ const family = category ? changed[category] : undefined;
96
+ if (family && !value.textStyleType) {
97
+ value.fontFamily =
98
+ family;
99
+ }
100
+ }
101
+ else if (key in FIELD_DEFAULT_CATEGORY && hasRTEContent(value)) {
102
+ // Inline RTE marks override every style layer at render — rewrite them.
103
+ const category = resolveFieldCategory(key, record[`${key}Style`], content);
104
+ const family = category ? changed[category] : undefined;
105
+ if (family) {
106
+ record[key] = isTiptapDocument(value)
107
+ ? applyFontToTiptapDoc(value, family)
108
+ : applyFont(value, family);
109
+ }
110
+ }
111
+ else if (Array.isArray(value)) {
112
+ for (const item of value) {
113
+ if (isPlainObject(item)) {
114
+ walkRecord(item, content, changed, applyFont);
115
+ }
116
+ }
117
+ }
118
+ }
119
+ };
120
+ /**
121
+ * Walk every page's contents and propagate the changed families.
122
+ * `applyFont` is injectable for tests; the default rewrites inline marks via
123
+ * the external Tiptap editor.
124
+ */
125
+ export const propagateFontFamiliesToContents = (model, changed, applyFont) => {
126
+ if (!Object.keys(changed).length)
127
+ return;
128
+ let editor;
129
+ const apply = applyFont ??
130
+ ((text, fontFamily) => {
131
+ editor = editor ?? useExternalRichTextEditor().externalEditor;
132
+ return applyStyleToRTEText(editor, text, {
133
+ fontFamily,
134
+ });
135
+ });
136
+ try {
137
+ for (const page of model.pages) {
138
+ for (const content of page.contents) {
139
+ walkRecord(content, content, changed, apply);
140
+ }
141
+ }
142
+ }
143
+ finally {
144
+ editor?.destroy();
145
+ }
146
+ };
@@ -1,6 +1,8 @@
1
1
  import {} from '../../brand-page/models';
2
2
  import { BaseMetadataBuilder, } from '../models';
3
3
  import { GLOBAL_STYLE_CHECKS, TEXT_STYLE_OPTION_KEYS, } from './section-rules/global-styles.rules';
4
+ import { collectChangedFontFamilies, propagateFontFamiliesToContents, } from './global-font-propagation';
5
+ import { buttonStylePresetCss } from '../../shared/style-settings/button-style-preset';
4
6
  export class GlobalStylesBuilder extends BaseMetadataBuilder {
5
7
  constructor(request, logger) {
6
8
  super(request, logger);
@@ -30,6 +32,21 @@ export class GlobalStylesBuilder extends BaseMetadataBuilder {
30
32
  }
31
33
  if (args.textStyles) {
32
34
  this.applyTextStyles(model, args.textStyles);
35
+ // A category font change only reaches preset-bound content by itself;
36
+ // detached section styles and inline RTE font marks would still render
37
+ // the old family. Propagate so the change is actually visible (BP-5780).
38
+ const changedFamilies = collectChangedFontFamilies(args.textStyles);
39
+ if (Object.keys(changedFamilies).length) {
40
+ try {
41
+ propagateFontFamiliesToContents(model, changedFamilies);
42
+ }
43
+ catch (error) {
44
+ this.log({
45
+ message: 'Global font propagation to existing contents failed — presets still updated',
46
+ error,
47
+ });
48
+ }
49
+ }
33
50
  }
34
51
  if (args.buttonStyles) {
35
52
  this.applyButtonStyles(model, args.buttonStyles);
@@ -74,9 +91,20 @@ export class GlobalStylesBuilder extends BaseMetadataBuilder {
74
91
  if (buttonStyles.iconStyle) {
75
92
  bs.iconStyle = { ...bs.iconStyle, ...buttonStyles.iconStyle };
76
93
  }
77
- // Merge selectedButtonStyleId
94
+ // Merge selectedButtonStyleId and apply that preset's shape and shadow the way the
95
+ // editor's picker does: the site renders buttons from `style`, so the id alone is
96
+ // invisible (BP-6082). The preset owns the shape keys; colours from `style` stay.
78
97
  if (buttonStyles.selectedButtonStyleId !== undefined) {
79
98
  bs.selectedButtonStyleId = buttonStyles.selectedButtonStyleId;
99
+ const presetCss = buttonStylePresetCss(buttonStyles.selectedButtonStyleId, bs.style);
100
+ if (presetCss) {
101
+ bs.style = { ...bs.style, ...presetCss };
102
+ }
103
+ else {
104
+ this.log({
105
+ message: `Unknown selectedButtonStyleId ${buttonStyles.selectedButtonStyleId} — id stored, button shape unchanged`,
106
+ });
107
+ }
80
108
  }
81
109
  }
82
110
  applyBackgroundStyles(model, backgroundStyles) {
@@ -34,6 +34,14 @@ const applyViewportHeaderChanges = (header, overrides, textStyleOptions) => {
34
34
  if (overrides.link_text_font !== undefined && header.navigation) {
35
35
  header.navigation.defaultButtonStyle = applyDetachedStyleOverride(header.navigation.defaultButtonStyle, { fontFamily: overrides.link_text_font }, textStyleOptions);
36
36
  }
37
+ // The hamburger overlay renders the same nav links from its own styles, so the
38
+ // menu font has to reach them too or the overlay keeps the old family (BP-6079).
39
+ if (overrides.link_text_font !== undefined &&
40
+ header.navigation?.hamburgerMenuStyle) {
41
+ const menu = header.navigation.hamburgerMenuStyle;
42
+ menu.defaultButtonStyle = applyDetachedStyleOverride(menu.defaultButtonStyle, { fontFamily: overrides.link_text_font }, textStyleOptions);
43
+ menu.selectedButtonStyle = applyDetachedStyleOverride(menu.selectedButtonStyle, { fontFamily: overrides.link_text_font }, textStyleOptions);
44
+ }
37
45
  if (overrides.link_bg_color !== undefined && header.navigation) {
38
46
  header.navigation.defaultButtonStyle = {
39
47
  ...header.navigation.defaultButtonStyle,
@@ -0,0 +1,13 @@
1
+ import type { BrandPageModel } from '../../brand-page/models';
2
+ import { BaseMetadataBuilder, type BuilderResult, type MetadataBuilderRequest } from '../models';
3
+ import type { Logger } from '../logger';
4
+ export interface ReorderPagesPayload {
5
+ pageOrder: string[];
6
+ }
7
+ export declare class ReorderPagesBuilder extends BaseMetadataBuilder<ReorderPagesPayload> {
8
+ constructor(request: MetadataBuilderRequest<ReorderPagesPayload>, logger?: Logger);
9
+ validateAsync(): Promise<{
10
+ errors: string[];
11
+ }>;
12
+ buildAsync(model: BrandPageModel): Promise<BuilderResult>;
13
+ }
@@ -0,0 +1,49 @@
1
+ import { BaseMetadataBuilder, } from '../models';
2
+ export class ReorderPagesBuilder extends BaseMetadataBuilder {
3
+ constructor(request, logger) {
4
+ super(request, logger);
5
+ }
6
+ async validateAsync() {
7
+ const errors = [];
8
+ const payload = this.request.payload;
9
+ if (!payload?.pageOrder?.length) {
10
+ errors.push('page_order is required for reorder_pages');
11
+ }
12
+ return { errors };
13
+ }
14
+ async buildAsync(model) {
15
+ this.log('Build called');
16
+ const validation = await this.validateAsync();
17
+ if (validation.errors.length) {
18
+ return { request: this.request, model, errors: validation.errors };
19
+ }
20
+ const { pageOrder } = this.request.payload;
21
+ // Build pageId → routeId lookup from nav.routes
22
+ const pageIdToRouteId = new Map();
23
+ for (const route of model.nav.routes) {
24
+ pageIdToRouteId.set(route.pageId, route.id);
25
+ }
26
+ // Build new nav.orders from the flat page_order (array of pageIds)
27
+ const newOrders = [];
28
+ const seenRouteIds = new Set();
29
+ for (const pageId of pageOrder) {
30
+ const routeId = pageIdToRouteId.get(pageId);
31
+ if (!routeId)
32
+ continue;
33
+ newOrders.push({ routeId, subRoutes: [] });
34
+ seenRouteIds.add(routeId);
35
+ }
36
+ // Safety: append any routes not mentioned (don't lose pages)
37
+ for (const route of model.nav.routes) {
38
+ const routeId = pageIdToRouteId.get(route.pageId);
39
+ if (routeId && !seenRouteIds.has(routeId)) {
40
+ newOrders.push({ routeId, subRoutes: [] });
41
+ }
42
+ }
43
+ model.nav.orders = newOrders;
44
+ this.log({
45
+ message: `Reordered pages: ${pageOrder.join(', ')}`,
46
+ });
47
+ return { request: this.request, model };
48
+ }
49
+ }
@@ -1,6 +1,7 @@
1
1
  export { BaseSectionBuilder, type SectionBuilderPayload, } from './base-section-builder';
2
2
  export { BannerSectionBuilder } from './banner-section-builder';
3
3
  export { BookingSectionBuilder } from './booking-section-builder';
4
+ export { SupportMeBuilder } from './support-me-builder';
4
5
  export { AboutSectionBuilder } from './about-section-builder';
5
6
  export { ContactFormBuilder } from './contact-form-builder';
6
7
  export { DividerSectionBuilder } from './divider-section-builder';
@@ -1,6 +1,7 @@
1
1
  export { BaseSectionBuilder, } from './base-section-builder';
2
2
  export { BannerSectionBuilder } from './banner-section-builder';
3
3
  export { BookingSectionBuilder } from './booking-section-builder';
4
+ export { SupportMeBuilder } from './support-me-builder';
4
5
  export { AboutSectionBuilder } from './about-section-builder';
5
6
  export { ContactFormBuilder } from './contact-form-builder';
6
7
  export { DividerSectionBuilder } from './divider-section-builder';
@@ -0,0 +1,8 @@
1
+ import type { BrandPageModel, SupportMeContent } from '../../../brand-page/models';
2
+ import type { BuilderResult } from '../../models';
3
+ import { BaseSectionBuilder } from './base-section-builder';
4
+ import { validateSupportMe } from '../section-rules/support-me.rules';
5
+ export declare class SupportMeBuilder extends BaseSectionBuilder<SupportMeContent> {
6
+ protected get sectionValidate(): typeof validateSupportMe;
7
+ buildAsync(model: BrandPageModel): Promise<BuilderResult>;
8
+ }
@@ -0,0 +1,25 @@
1
+ import { applyScalarFields, applyStyleFields } from '../section-field-utils';
2
+ import { BaseSectionBuilder } from './base-section-builder';
3
+ import { validateSupportMe } from '../section-rules/support-me.rules';
4
+ export class SupportMeBuilder extends BaseSectionBuilder {
5
+ get sectionValidate() {
6
+ return validateSupportMe;
7
+ }
8
+ async buildAsync(model) {
9
+ this.log('Build called');
10
+ const validation = await this.validateAsync();
11
+ if (validation.errors.length) {
12
+ return { request: this.request, model, errors: validation.errors };
13
+ }
14
+ const resolved = this.resolveAndRepositionContent(model);
15
+ if ('error' in resolved)
16
+ return resolved.error;
17
+ const content = resolved.content;
18
+ // title/description/success_message are plain-text fields (no RTE support),
19
+ // so scalar application covers all content fields. supportMeConfig (payment
20
+ // options) is intentionally not AI-editable — it comes from the factory.
21
+ applyScalarFields(content, this.args, this.sectionType);
22
+ applyStyleFields(content, this.args, this.sectionType, this.getGlobalTextStyles(model));
23
+ return { request: this.request, model };
24
+ }
25
+ }
@@ -135,14 +135,17 @@ export class TextSectionBuilder extends BaseSectionBuilder {
135
135
  applyTextItems(content, args.items, textStyleOptions);
136
136
  }
137
137
  else if (this.mode === 'create') {
138
- if (args.items && Array.isArray(args.items)) {
138
+ // An EMPTY items array must not shadow the single text field — the AI emits
139
+ // `items: []` alongside `text` and the section rendered blank (QA 2026-07-05,
140
+ // T06: items=[] set textItems=[] and `text` was silently discarded).
141
+ if (args.items && Array.isArray(args.items) && args.items.length > 0) {
139
142
  applyTextItems(content, args.items, textStyleOptions);
140
143
  }
141
144
  else {
142
145
  applySingleTextField(content, args, textStyleOptions);
143
146
  }
144
147
  }
145
- else if ((args.items && Array.isArray(args.items)) ||
148
+ else if ((args.items && Array.isArray(args.items) && args.items.length > 0) ||
146
149
  args._remove_item_ids?.length) {
147
150
  // Update: id-based add / edit / move / remove in a single pass.
148
151
  mergeTextItems(content, args.items ?? [], args._remove_item_ids, textStyleOptions);
@@ -131,9 +131,16 @@ const socialButtonFields = defineFieldMap({
131
131
  title: 'title',
132
132
  url: 'url',
133
133
  });
134
+ const supportMeFields = defineFieldMap({
135
+ title: 'title',
136
+ description: 'description',
137
+ success_message: 'successMessage',
138
+ display_style: 'displayStyle',
139
+ });
134
140
  export const SECTION_FIELD_MAPS = {
135
141
  'banner-section': bannerFields,
136
142
  'booking-section': bookingFields,
143
+ 'support-me': supportMeFields,
137
144
  'about-section': aboutFields,
138
145
  'testimonial-section': testimonialFields,
139
146
  'images-section': imagesSectionFields,
@@ -208,6 +215,13 @@ const mailingListStyleFields = defineFieldMap({
208
215
  title_style: 'titleStyle',
209
216
  button_text_style: 'buttonTextStyle',
210
217
  });
218
+ const supportMeStyleFields = defineFieldMap({
219
+ title_style: 'titleStyle',
220
+ heading_style: 'headingStyle',
221
+ description_style: 'descriptionStyle',
222
+ item_text_style: 'itemTextStyle',
223
+ button_text_style: 'buttonTextStyle',
224
+ });
211
225
  const socialLinksStyleFields = defineFieldMap({
212
226
  icon_fill_style: 'iconFillStyles',
213
227
  icon_background_style: 'iconBackgroundStyles',
@@ -215,6 +229,7 @@ const socialLinksStyleFields = defineFieldMap({
215
229
  export const STYLE_FIELD_MAPS = {
216
230
  'banner-section': bannerStyleFields,
217
231
  'booking-section': bookingStyleFields,
232
+ 'support-me': supportMeStyleFields,
218
233
  'about-section': aboutStyleFields,
219
234
  text: textStyleFields,
220
235
  'testimonial-section': testimonialStyleFields,
@@ -53,5 +53,10 @@ export declare const resolveGlobalPresetIntoStyle: (style: object | undefined, t
53
53
  * updates without re-resolving a preset.
54
54
  */
55
55
  export declare const applyDetachedStyleOverride: <T extends object>(current: T | undefined, updates: Partial<T>, textStyleOptions: TextStyleOptions | undefined) => T;
56
- export declare const applyStyleFields: (content: Content, args: SectionToolArguments, sectionType: string, textStyleOptions?: TextStyleOptions) => void;
56
+ /**
57
+ * Rewrites the stored RTE HTML's inline colour marks. Injectable for tests;
58
+ * the default uses the external Tiptap editor (font-propagation precedent).
59
+ */
60
+ export type ApplyColorToRTE = (html: string, color: string) => string;
61
+ export declare const applyStyleFields: (content: Content, args: SectionToolArguments, sectionType: string, textStyleOptions?: TextStyleOptions, applyColor?: ApplyColorToRTE) => void;
57
62
  export {};
@@ -1,5 +1,8 @@
1
1
  import { BUTTON_LINK_TYPE } from '../../brand-page/models';
2
2
  import { isTiptapDocument, } from '../parsers/section-tool-arguments';
3
+ import { hasRTEContent } from '../../brand-page-maker/utils';
4
+ import { useExternalRichTextEditor } from '../../brand-page-maker/composables/rich-text-editor/useRichTextEditor';
5
+ import { applyStyleToRTEText } from '../helpers/rte.helpers';
3
6
  import { getSectionFieldMap, getStyleFieldMap } from './section-field-maps';
4
7
  const isUrlField = (aiKey) => aiKey.endsWith('_url');
5
8
  export const isValidUrl = (value) => {
@@ -117,22 +120,55 @@ export const applyDetachedStyleOverride = (current, updates, textStyleOptions) =
117
120
  ...resolveGlobalPresetIntoStyle(current, textStyleOptions),
118
121
  ...updates,
119
122
  });
120
- export const applyStyleFields = (content, args, sectionType, textStyleOptions) => {
123
+ export const applyStyleFields = (content, args, sectionType, textStyleOptions, applyColor) => {
121
124
  const styleMap = getStyleFieldMap(sectionType);
122
125
  if (!styleMap)
123
126
  return;
124
127
  const record = content;
125
- for (const [aiKey, modelKey] of Object.entries(styleMap)) {
126
- if (!(aiKey in args) || !args[aiKey] || typeof args[aiKey] !== 'object')
127
- continue;
128
- const aiStyle = args[aiKey];
129
- const filtered = {};
130
- for (const [k, v] of Object.entries(aiStyle)) {
131
- const normalized = normalizeStyleValue(k, v);
132
- if (normalized !== undefined)
133
- filtered[k] = normalized;
128
+ let editor;
129
+ const applyColorToHtml = applyColor ??
130
+ ((html, color) => {
131
+ editor = editor ?? useExternalRichTextEditor().externalEditor;
132
+ return applyStyleToRTEText(editor, html, {
133
+ color,
134
+ });
135
+ });
136
+ try {
137
+ for (const [aiKey, modelKey] of Object.entries(styleMap)) {
138
+ if (!(aiKey in args) || !args[aiKey] || typeof args[aiKey] !== 'object')
139
+ continue;
140
+ const aiStyle = args[aiKey];
141
+ const filtered = {};
142
+ for (const [k, v] of Object.entries(aiStyle)) {
143
+ const normalized = normalizeStyleValue(k, v);
144
+ if (normalized !== undefined)
145
+ filtered[k] = normalized;
146
+ }
147
+ const resolved = resolveGlobalPresetIntoStyle(content[modelKey], textStyleOptions);
148
+ record[modelKey] = { ...resolved, ...filtered, textStyleType: undefined };
149
+ // A colour patch without new text leaves the field's EXISTING inline
150
+ // textStyle marks in place — and inline marks override every style layer
151
+ // at render, so the old colour survives on a recoloured band (invisible
152
+ // text, AI QA 2026-07-05 rerun). Propagate the new colour into the
153
+ // stored HTML, mirroring global-font-propagation for font families.
154
+ const textKey = modelKey.endsWith('Style')
155
+ ? modelKey.slice(0, -'Style'.length)
156
+ : undefined;
157
+ const textAiKey = aiKey.endsWith('_style')
158
+ ? aiKey.slice(0, -'_style'.length)
159
+ : undefined;
160
+ const newColor = filtered.color;
161
+ if (textKey &&
162
+ typeof newColor === 'string' &&
163
+ newColor &&
164
+ !(textAiKey && textAiKey in args) &&
165
+ typeof record[textKey] === 'string' &&
166
+ hasRTEContent(record[textKey])) {
167
+ record[textKey] = applyColorToHtml(record[textKey], newColor);
168
+ }
134
169
  }
135
- const resolved = resolveGlobalPresetIntoStyle(content[modelKey], textStyleOptions);
136
- record[modelKey] = { ...resolved, ...filtered, textStyleType: undefined };
170
+ }
171
+ finally {
172
+ editor?.destroy();
137
173
  }
138
174
  };
@@ -0,0 +1,2 @@
1
+ import { type ValidationContext } from '../../sanitizers/section-args-validator';
2
+ export declare function validateSupportMe(args: Record<string, unknown>, ctx: ValidationContext): string[];
@@ -0,0 +1,17 @@
1
+ import { MAX_TITLE_LENGTH, MAX_DESCRIPTION_LENGTH, } from '../../../brand-page/constants/font.constants';
2
+ import { DISPLAY_STYLE_PREVIEW, DISPLAY_STYLE_COLLAPSED, } from '../../../brand-page/constants';
3
+ import { enumCheck, runChecks, styleCheck, textCheck, } from '../../sanitizers/section-args-validator';
4
+ const CHECKS = [
5
+ textCheck('title', MAX_TITLE_LENGTH),
6
+ textCheck('description', MAX_DESCRIPTION_LENGTH),
7
+ textCheck('success_message', MAX_DESCRIPTION_LENGTH),
8
+ styleCheck('title_style'),
9
+ styleCheck('heading_style'),
10
+ styleCheck('description_style'),
11
+ styleCheck('item_text_style'),
12
+ styleCheck('button_text_style'),
13
+ enumCheck('display_style', [DISPLAY_STYLE_PREVIEW, DISPLAY_STYLE_COLLAPSED], 'support-me'),
14
+ ];
15
+ export function validateSupportMe(args, ctx) {
16
+ return runChecks(CHECKS, args, ctx);
17
+ }
@@ -1,3 +1,12 @@
1
+ import type { TextStyleDeclaration } from '../../brand-page/models/styles.model';
2
+ import type { Editor } from '@tiptap/vue-3';
3
+ /**
4
+ * Applies a TextStyleDeclaration to RTE HTML by loading it into the given
5
+ * (external/headless) Tiptap editor and rewriting inline marks via command
6
+ * chains. DOM-bound — client-only callers (font propagation, section style
7
+ * fields).
8
+ */
9
+ export declare const applyStyleToRTEText: (externalEditor: Editor, contentText: string, newContentStyle: TextStyleDeclaration) => string;
1
10
  /**
2
11
  * Converts a Tiptap JSON document (ProseMirror document model) to an HTML
3
12
  * string via Tiptap's schema-driven static renderer (renderRteToHtml →
@@ -1,5 +1,64 @@
1
- import { renderRteToHtml } from '../../brand-page-maker/utils';
1
+ import { renderRteToHtml, getRTEColor } from '../../brand-page-maker/utils';
2
+ import { getRenderText } from '../../brand-page/utils';
3
+ import { ESCAPED_NEWLINE_CHARACTER } from '../../constants/ui';
2
4
  import { isTiptapDocument } from '../parsers/section-tool-arguments';
5
+ /**
6
+ * Applies a TextStyleDeclaration to RTE HTML by loading it into the given
7
+ * (external/headless) Tiptap editor and rewriting inline marks via command
8
+ * chains. DOM-bound — client-only callers (font propagation, section style
9
+ * fields).
10
+ */
11
+ export const applyStyleToRTEText = (externalEditor, contentText, newContentStyle) => {
12
+ if (!contentText) {
13
+ return contentText;
14
+ }
15
+ const content = contentText.includes(ESCAPED_NEWLINE_CHARACTER)
16
+ ? getRenderText(contentText)
17
+ : contentText;
18
+ externalEditor.commands.setContent(content);
19
+ let chain = externalEditor.chain().selectAll();
20
+ if (newContentStyle?.fontFamily) {
21
+ chain = chain.setFontFamily(newContentStyle.fontFamily);
22
+ }
23
+ if (newContentStyle?.fontSize) {
24
+ chain = chain.setFontSize(newContentStyle.fontSize);
25
+ }
26
+ if (newContentStyle?.letterSpacing) {
27
+ chain = chain.setLetterSpacing(newContentStyle.letterSpacing);
28
+ }
29
+ if (newContentStyle?.lineHeight) {
30
+ chain = chain.setLineHeight(newContentStyle.lineHeight);
31
+ }
32
+ if (newContentStyle?.color) {
33
+ chain = chain.setMark('textStyle', {
34
+ color: getRTEColor(newContentStyle.color, 'text'),
35
+ });
36
+ }
37
+ if ('backgroundColor' in (newContentStyle || {})) {
38
+ chain = newContentStyle.backgroundColor
39
+ ? chain.setMark('highlight', {
40
+ color: getRTEColor(newContentStyle.backgroundColor, 'background'),
41
+ })
42
+ : chain.unsetHighlight();
43
+ }
44
+ if (newContentStyle?.textAlign) {
45
+ chain = chain.setTextAlign(newContentStyle.textAlign);
46
+ }
47
+ const hasStrike = newContentStyle?.textDecoration?.includes('line-through');
48
+ const hasUnderline = newContentStyle?.textDecoration?.includes('underline');
49
+ chain = hasStrike ? chain.setStrike() : chain.unsetStrike();
50
+ chain = hasUnderline ? chain.setUnderline() : chain.unsetUnderline();
51
+ chain =
52
+ newContentStyle?.fontWeight === 'bold'
53
+ ? chain.setBold()
54
+ : chain.unsetBold();
55
+ chain =
56
+ newContentStyle?.fontStyle === 'italic'
57
+ ? chain.setItalic()
58
+ : chain.unsetItalic();
59
+ chain.run();
60
+ return externalEditor.getHTML();
61
+ };
3
62
  /**
4
63
  * Converts a Tiptap JSON document (ProseMirror document model) to an HTML
5
64
  * string via Tiptap's schema-driven static renderer (renderRteToHtml →
@@ -2,7 +2,7 @@ import cloneDeep from 'lodash.clonedeep';
2
2
  import {} from '../brand-page/models';
3
3
  import { referencifyModelColors } from '../brand-page/utils/color-palette-referencify.util';
4
4
  import { BUILDER_TYPES, } from './models';
5
- import { BannerSectionBuilder, BookingSectionBuilder, AboutSectionBuilder, ContactFormBuilder, DividerSectionBuilder, TextSectionBuilder, TestimonialSectionBuilder, ImagesSectionBuilder, ShapesSectionBuilder, SocialLinksBuilder, ImageGalleryBuilder, ProfileSectionBuilder, MailingListSectionBuilder, ButtonSectionBuilder, DeleteSectionBuilder, MoveSectionBuilder, } from './builders/section-builders';
5
+ import { BannerSectionBuilder, BookingSectionBuilder, SupportMeBuilder, AboutSectionBuilder, ContactFormBuilder, DividerSectionBuilder, TextSectionBuilder, TestimonialSectionBuilder, ImagesSectionBuilder, ShapesSectionBuilder, SocialLinksBuilder, ImageGalleryBuilder, ProfileSectionBuilder, MailingListSectionBuilder, ButtonSectionBuilder, DeleteSectionBuilder, MoveSectionBuilder, } from './builders/section-builders';
6
6
  import { AddPageBuilder } from './builders/add-page-builder';
7
7
  import { RemovePageBuilder } from './builders/remove-page-builder';
8
8
  import { RenamePageBuilder } from './builders/rename-page-builder';
@@ -17,6 +17,7 @@ const getBuilderRegistry = () => ({
17
17
  // Section builders (create)
18
18
  [BUILDER_TYPES.CREATE_BANNER_SECTION]: BannerSectionBuilder,
19
19
  [BUILDER_TYPES.CREATE_BOOKING_SECTION]: BookingSectionBuilder,
20
+ [BUILDER_TYPES.CREATE_SUPPORT_ME]: SupportMeBuilder,
20
21
  [BUILDER_TYPES.CREATE_ABOUT_SECTION]: AboutSectionBuilder,
21
22
  [BUILDER_TYPES.CREATE_CONTACT_FORM]: ContactFormBuilder,
22
23
  [BUILDER_TYPES.CREATE_DIVIDER_SECTION]: DividerSectionBuilder,
@@ -43,6 +44,7 @@ const getBuilderRegistry = () => ({
43
44
  // Section builders (update)
44
45
  [BUILDER_TYPES.UPDATE_BANNER_SECTION]: BannerSectionBuilder,
45
46
  [BUILDER_TYPES.UPDATE_BOOKING_SECTION]: BookingSectionBuilder,
47
+ [BUILDER_TYPES.UPDATE_SUPPORT_ME]: SupportMeBuilder,
46
48
  [BUILDER_TYPES.UPDATE_ABOUT_SECTION]: AboutSectionBuilder,
47
49
  [BUILDER_TYPES.UPDATE_CONTACT_FORM]: ContactFormBuilder,
48
50
  [BUILDER_TYPES.UPDATE_DIVIDER_SECTION]: DividerSectionBuilder,