@designcrowd/library.brand-page 6.2.1 → 6.2.2
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.
- package/lib/src/{CreatedOnBrandCrowd-de-de-CtvPtYF6.js → CreatedOnBrandCrowd-de-de-7P2gNr-R.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-eGF-EjAX.js → CreatedOnBrandCrowd-en-us-Dl7BymW6.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-CSemsOtT.js → CreatedOnBrandCrowd-es-es-CO4FvWVw.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-DzylX-AT.js → CreatedOnBrandCrowd-fr-ca-DHNqakQu.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-DzylX-AT.js → CreatedOnBrandCrowd-fr-fr-DHNqakQu.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-QMUxXQsE.js → CreatedOnBrandCrowd-pt-br-CGUC-ZbP.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-QMUxXQsE.js → CreatedOnBrandCrowd-pt-pt-CGUC-ZbP.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-M7OoJt-1.js → CreatedOnDcom-de-de-Dvds-Vmy.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-D7hMM0AI.js → CreatedOnDcom-en-us-fd3C-8Tp.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-CLw-BLp2.js → CreatedOnDcom-es-es-DTXZW35-.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-BRu_kmeu.js → CreatedOnDcom-fr-ca-DRCYOKPs.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-IMnEv_yM.js → CreatedOnDcom-fr-fr-DpIB8x2x.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-Bt6v1p27.js → CreatedOnDcom-pt-br-Esnxy8wX.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-BVsFJIJg.js → CreatedOnDcom-pt-pt-CaiVHFXR.js} +1 -1
- package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +7 -1
- package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +6 -0
- package/lib/src/brand-page-maker/composables/useEventTracker.d.ts +3 -0
- package/lib/src/brand-page-maker/composables/useEventTracker.js +10 -0
- package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +2 -4
- package/lib/src/{bundled-translations-KY0aSmMp.js → bundled-translations-BYUfWBT3.js} +9 -9
- package/lib/src/{bundled-translations.de-DE-BIFi4bYj.js → bundled-translations.de-DE-MmzUu1tt.js} +13 -13
- package/lib/src/{bundled-translations.es-ES-CHlKuPLO.js → bundled-translations.es-ES-D-tG33XL.js} +1 -1
- package/lib/src/{bundled-translations.fr-CA-DR1fvm8e.js → bundled-translations.fr-CA-CNc1H1h3.js} +1 -1
- package/lib/src/{bundled-translations.fr-FR-DnpWVIQg.js → bundled-translations.fr-FR-DX0-yepT.js} +1 -1
- package/lib/src/{bundled-translations.pt-BR-p8szTOiT.js → bundled-translations.pt-BR-ih1-LRgy.js} +9 -9
- package/lib/src/{bundled-translations.pt-PT-DEk39NhW.js → bundled-translations.pt-PT-Dq2CmD1p.js} +1 -1
- package/lib/src/bundles/bundled-translations.de-DE.json +3 -1
- package/lib/src/bundles/bundled-translations.es-ES.json +3 -1
- package/lib/src/bundles/bundled-translations.fr-CA.json +3 -1
- package/lib/src/bundles/bundled-translations.fr-FR.json +3 -1
- package/lib/src/bundles/bundled-translations.json +3 -1
- package/lib/src/bundles/bundled-translations.pt-BR.json +3 -1
- package/lib/src/bundles/bundled-translations.pt-PT.json +3 -1
- package/lib/src/composables/useTranslate.d.ts +1 -1
- package/lib/src/composables/useTranslate.js +1 -1
- package/lib/src/css/library.brand-page-brandCrowd.min.css +4 -4
- package/lib/src/css/library.brand-page-brandPage.min.css +4 -4
- package/lib/src/css/library.brand-page-designCom.min.css +4 -4
- package/lib/src/css/library.brand-page-preview.min.css +4 -4
- package/lib/src/index.mjs +2 -2
- package/lib/src/{lib-B38VVCSw.js → lib-V7aqfuiB.js} +18106 -17965
- package/lib/src/metadata-builder/builders/item-id-utils.d.ts +2 -0
- package/lib/src/metadata-builder/builders/item-id-utils.js +8 -2
- package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +8 -1
- package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +6 -0
- package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItem.mixin.d.ts +1 -0
- package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +5 -0
- package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +5 -0
- package/lib/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.d.ts +9 -1
- package/lib/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.js +31 -9
- package/lib/src/shared/logo-settings/components/EditLogoSettings.mixin.d.ts +1 -0
- package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +5 -0
- package/lib/src/shared/page-navigation/store/page-navigation-store.d.ts +2 -1
- package/lib/src/shared/page-navigation/store/page-navigation-store.js +22 -9
- package/lib/src/shared/page-settings/components/new-page-modal/NewPageModal.mixin.d.ts +7 -1
- package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +10 -1
- package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.js +12 -35
- package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +6 -0
- package/lib/src/shared/page-settings/duplicate-page.util.d.ts +4 -0
- package/lib/src/shared/page-settings/duplicate-page.util.js +82 -0
- package/lib/src/shared/page-settings/models.d.ts +1 -0
- package/lib/src/shared/page-settings/store/page-settings-store.d.ts +5 -0
- package/lib/src/shared/page-settings/store/page-settings-store.js +90 -7
- package/lib/src/shared/page-settings/utils.d.ts +3 -0
- package/lib/src/shared/page-settings/utils.js +6 -0
- package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +1 -0
- package/lib/src/shared/side-bar/components/SideBarTabButton.mixin.d.ts +1 -0
- package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +1 -0
- package/lib/src/shared/template-settings/components/TemplatesSettings.mixin.d.ts +1 -0
- package/lib/src/tracker.mjs +1 -1
- package/lib/src/{useEventTracker-C5Ql5p7u.js → useEventTracker-DwPTmzpa.js} +13 -3
- package/package.json +1 -1
- package/src/brand-page-maker/composables/useEventTracker.ts +13 -0
- package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +2 -4
- package/src/bundles/bundled-translations.de-DE.json +3 -1
- package/src/bundles/bundled-translations.es-ES.json +3 -1
- package/src/bundles/bundled-translations.fr-CA.json +3 -1
- package/src/bundles/bundled-translations.fr-FR.json +3 -1
- package/src/bundles/bundled-translations.json +3 -1
- package/src/bundles/bundled-translations.pt-BR.json +3 -1
- package/src/bundles/bundled-translations.pt-PT.json +3 -1
- package/src/composables/useTranslate.ts +2 -1
- package/src/metadata-builder/builders/item-id-utils.ts +9 -2
- package/src/shared/ecommerce-settings/components/product-config-field/ProductConfigOption.vue +0 -2
- package/src/shared/ecommerce-settings/util/refreshDuplicatedMetadata.ts +32 -10
- package/src/shared/page-navigation/store/page-navigation-store.ts +41 -13
- package/src/shared/page-settings/components/page-settings/NestedDraggable.vue +3 -0
- package/src/shared/page-settings/components/page-settings/PageElement.vue +2 -0
- package/src/shared/page-settings/components/page-settings/PageSettingMenu.test.ts +105 -0
- package/src/shared/page-settings/components/page-settings/PageSettingMenu.vue +16 -0
- package/src/shared/page-settings/components/page-settings/PageSettings.duplicate.test.ts +108 -0
- package/src/shared/page-settings/components/page-settings/PageSettings.mixin.ts +22 -50
- package/src/shared/page-settings/components/page-settings/PageSettings.vue +1 -0
- package/src/shared/page-settings/components/page-settings/i18n/page-settings.de-DE.json +3 -1
- package/src/shared/page-settings/components/page-settings/i18n/page-settings.es-ES.json +3 -1
- package/src/shared/page-settings/components/page-settings/i18n/page-settings.fr-CA.json +3 -1
- package/src/shared/page-settings/components/page-settings/i18n/page-settings.fr-FR.json +3 -1
- package/src/shared/page-settings/components/page-settings/i18n/page-settings.json +3 -1
- package/src/shared/page-settings/components/page-settings/i18n/page-settings.pt-BR.json +3 -1
- package/src/shared/page-settings/components/page-settings/i18n/page-settings.pt-PT.json +3 -1
- package/src/shared/page-settings/duplicate-page.util.test.ts +382 -0
- package/src/shared/page-settings/duplicate-page.util.ts +124 -0
- package/src/shared/page-settings/models.ts +1 -0
- package/src/shared/page-settings/store/page-settings-store.test.ts +512 -1
- package/src/shared/page-settings/store/page-settings-store.ts +145 -11
- package/src/shared/page-settings/utils.ts +10 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { setLocaleAsync } from '@src/composables/useTranslate';
|
|
4
|
+
import {
|
|
5
|
+
getDuplicatePageTitle,
|
|
6
|
+
duplicatePageContents,
|
|
7
|
+
} from './duplicate-page.util';
|
|
8
|
+
import {
|
|
9
|
+
getPageTitleError,
|
|
10
|
+
MAX_PAGE_TITLE_LENGTH,
|
|
11
|
+
MAX_SUBPAGE_TITLE_LENGTH,
|
|
12
|
+
} from './utils';
|
|
13
|
+
import type { ContentItem } from '../content-settings/models';
|
|
14
|
+
import type { Content } from '@brand-page';
|
|
15
|
+
|
|
16
|
+
const createContentItem = (
|
|
17
|
+
id: string,
|
|
18
|
+
type = 'text-section',
|
|
19
|
+
content: Record<string, unknown> = {},
|
|
20
|
+
): ContentItem<Content> =>
|
|
21
|
+
({
|
|
22
|
+
id,
|
|
23
|
+
type,
|
|
24
|
+
settings: {
|
|
25
|
+
content: { id, type, ...content },
|
|
26
|
+
fields: {},
|
|
27
|
+
},
|
|
28
|
+
}) as unknown as ContentItem<Content>;
|
|
29
|
+
|
|
30
|
+
// The title is composed through i18next, so the real locale has to be loaded -
|
|
31
|
+
// stubbing the affix would stop these tests covering the length arithmetic.
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
await setLocaleAsync('en-US');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('getDuplicatePageTitle', () => {
|
|
37
|
+
it('suffixes a root page title with Copy', () => {
|
|
38
|
+
expect(getDuplicatePageTitle('About', [], false)).toBe('About Copy');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('leaves punctuation in the title alone', () => {
|
|
42
|
+
// The title is user content going through i18next interpolation, which
|
|
43
|
+
// escapes by default - a page called "Tom & Jerry" would come back as
|
|
44
|
+
// "Tom & Jerry".
|
|
45
|
+
expect(getDuplicatePageTitle("Tom & Jerry's", [], false)).toBe(
|
|
46
|
+
"Tom & Jerry's Copy",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps a number that belongs to the original title', () => {
|
|
51
|
+
// "Portfolio 1 Copy" in the design - the 1 is part of the source name, not
|
|
52
|
+
// a duplicate index.
|
|
53
|
+
expect(getDuplicatePageTitle('Portfolio 1', [], true)).toBe(
|
|
54
|
+
'Portfolio 1 Copy',
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('indexes only when a sibling already holds the title', () => {
|
|
59
|
+
expect(getDuplicatePageTitle('About', ['About', 'About Copy'], false)).toBe(
|
|
60
|
+
'About Copy 2',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
getDuplicatePageTitle(
|
|
65
|
+
'About',
|
|
66
|
+
['About', 'About Copy', 'About Copy 2'],
|
|
67
|
+
false,
|
|
68
|
+
),
|
|
69
|
+
).toBe('About Copy 3');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('stacks rather than re-indexing when duplicating a copy', () => {
|
|
73
|
+
// Stripping a trailing "Copy" would mangle a page genuinely named
|
|
74
|
+
// "Copy Writing".
|
|
75
|
+
expect(getDuplicatePageTitle('About Copy', ['About Copy'], false)).toBe(
|
|
76
|
+
'About Copy Copy',
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps truncated copies distinct so the index is never lost', () => {
|
|
81
|
+
// Truncation trims the base, never the finished title - slicing the tail
|
|
82
|
+
// would cut the index and let two copies collapse to the same name.
|
|
83
|
+
const sourceTitle = 'B'.repeat(MAX_SUBPAGE_TITLE_LENGTH);
|
|
84
|
+
const siblings: string[] = [];
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < 5; i += 1) {
|
|
87
|
+
const next = getDuplicatePageTitle(sourceTitle, siblings, true);
|
|
88
|
+
|
|
89
|
+
expect(siblings).not.toContain(next);
|
|
90
|
+
expect(getPageTitleError(next, true)).toBeNull();
|
|
91
|
+
|
|
92
|
+
siblings.push(next);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('truncates the base so a long root title stays within the limit', () => {
|
|
97
|
+
const sourceTitle = 'A'.repeat(MAX_PAGE_TITLE_LENGTH);
|
|
98
|
+
|
|
99
|
+
const result = getDuplicatePageTitle(sourceTitle, [], false);
|
|
100
|
+
|
|
101
|
+
expect(result.length).toBeLessThanOrEqual(MAX_PAGE_TITLE_LENGTH);
|
|
102
|
+
expect(result.endsWith('Copy')).toBe(true);
|
|
103
|
+
expect(result).not.toContain(' ');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('applies the shorter sub-page limit', () => {
|
|
107
|
+
const sourceTitle = 'B'.repeat(MAX_SUBPAGE_TITLE_LENGTH);
|
|
108
|
+
|
|
109
|
+
const result = getDuplicatePageTitle(sourceTitle, [], true);
|
|
110
|
+
|
|
111
|
+
expect(result.length).toBeLessThanOrEqual(MAX_SUBPAGE_TITLE_LENGTH);
|
|
112
|
+
expect(result.endsWith('Copy')).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('handles a source that already exceeds the limit', () => {
|
|
116
|
+
const sourceTitle = 'C'.repeat(MAX_PAGE_TITLE_LENGTH + 20);
|
|
117
|
+
|
|
118
|
+
const result = getDuplicatePageTitle(sourceTitle, [], false);
|
|
119
|
+
|
|
120
|
+
expect(result.length).toBeLessThanOrEqual(MAX_PAGE_TITLE_LENGTH);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it.each([
|
|
124
|
+
['About', false],
|
|
125
|
+
['Portfolio 1', true],
|
|
126
|
+
['A'.repeat(MAX_PAGE_TITLE_LENGTH), false],
|
|
127
|
+
['B'.repeat(MAX_SUBPAGE_TITLE_LENGTH), true],
|
|
128
|
+
['C'.repeat(MAX_PAGE_TITLE_LENGTH + 20), false],
|
|
129
|
+
['Contact Us Today Please', true],
|
|
130
|
+
])(
|
|
131
|
+
'never produces a title the editor would flag: %s (isSubPage: %s)',
|
|
132
|
+
(sourceTitle, isSubPage) => {
|
|
133
|
+
const result = getDuplicatePageTitle(
|
|
134
|
+
sourceTitle as string,
|
|
135
|
+
[],
|
|
136
|
+
isSubPage as boolean,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(getPageTitleError(result, isSubPage as boolean)).toBeNull();
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
it('always returns a title no sibling already holds', () => {
|
|
144
|
+
// N siblings can block at most N candidates, so the search is bounded at
|
|
145
|
+
// N+1 and can never fall back onto a name that is already in use.
|
|
146
|
+
const taken = Array.from(
|
|
147
|
+
{ length: 200 },
|
|
148
|
+
(_, i) => `About Copy${i === 0 ? '' : ` ${i + 1}`}`,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const result = getDuplicatePageTitle('About', taken, false);
|
|
152
|
+
|
|
153
|
+
expect(taken).not.toContain(result);
|
|
154
|
+
expect(getPageTitleError(result, false)).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('duplicatePageContents', () => {
|
|
159
|
+
it('mints a fresh id and keeps item.id and content.id in step', () => {
|
|
160
|
+
const contents = [createContentItem('a'), createContentItem('b')];
|
|
161
|
+
|
|
162
|
+
const result = duplicatePageContents(contents);
|
|
163
|
+
|
|
164
|
+
result.forEach((item, index) => {
|
|
165
|
+
expect(item.id).not.toBe(contents[index].id);
|
|
166
|
+
expect(item.id).toBe(item.settings.content.id);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(result[0].id).not.toBe(result[1].id);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('deep clones so the source is untouched', () => {
|
|
173
|
+
const contents = [
|
|
174
|
+
createContentItem('a', 'text-section', { text: 'original' }),
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
const result = duplicatePageContents(contents);
|
|
178
|
+
|
|
179
|
+
(result[0].settings.content as unknown as { text: string }).text =
|
|
180
|
+
'changed';
|
|
181
|
+
|
|
182
|
+
expect(
|
|
183
|
+
(contents[0].settings.content as unknown as { text: string }).text,
|
|
184
|
+
).toBe('original');
|
|
185
|
+
expect(contents[0].id).toBe('a');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('rotates every shop id the api keys a global row on', () => {
|
|
189
|
+
// sku_key is globally unique in the brand-page-api, and SKUs cross-reference
|
|
190
|
+
// product options by id - so product/variant/option/sku ids AND skuKey all
|
|
191
|
+
// have to rotate together or the copy collides with its source on upsert.
|
|
192
|
+
const contents = [
|
|
193
|
+
createContentItem('shop', 'shop-section', {
|
|
194
|
+
products: [
|
|
195
|
+
{
|
|
196
|
+
id: 'product-1',
|
|
197
|
+
name: 'Tee',
|
|
198
|
+
variant: [
|
|
199
|
+
{
|
|
200
|
+
id: 'variant-1',
|
|
201
|
+
name: 'Size',
|
|
202
|
+
options: [{ id: 'option-1', name: 'S', price: 10 }],
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
skus: [
|
|
206
|
+
{
|
|
207
|
+
id: 'sku-1',
|
|
208
|
+
skuKey: 'tee-option-1-abc',
|
|
209
|
+
price: 10,
|
|
210
|
+
variant: [
|
|
211
|
+
{
|
|
212
|
+
id: 'skuvariant-1',
|
|
213
|
+
name: 'Size',
|
|
214
|
+
option: { id: 'option-1', name: 'S', price: 10 },
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
}),
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
const result = duplicatePageContents(contents);
|
|
225
|
+
|
|
226
|
+
const product = (
|
|
227
|
+
result[0].settings.content as unknown as {
|
|
228
|
+
products: {
|
|
229
|
+
id: string;
|
|
230
|
+
variant: { id: string; options: { id: string }[] }[];
|
|
231
|
+
skus: {
|
|
232
|
+
id: string;
|
|
233
|
+
skuKey: string;
|
|
234
|
+
variant: { id: string; option: { id: string } }[];
|
|
235
|
+
}[];
|
|
236
|
+
}[];
|
|
237
|
+
}
|
|
238
|
+
).products[0];
|
|
239
|
+
|
|
240
|
+
expect(product.id).not.toBe('product-1');
|
|
241
|
+
expect(product.variant[0].id).not.toBe('variant-1');
|
|
242
|
+
expect(product.skus[0].id).not.toBe('sku-1');
|
|
243
|
+
expect(product.skus[0].skuKey).not.toBe('tee-option-1-abc');
|
|
244
|
+
|
|
245
|
+
// The option id is cross-referenced: the sku must point at the product's
|
|
246
|
+
// NEW option id, not a second freshly-minted one.
|
|
247
|
+
const newOptionId = product.variant[0].options[0].id;
|
|
248
|
+
|
|
249
|
+
expect(newOptionId).not.toBe('option-1');
|
|
250
|
+
expect(product.skus[0].variant[0].option.id).toBe(newOptionId);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('rotates payment-option ids on request-payment content', () => {
|
|
254
|
+
// brand_page_product is keyed on the payment-option id, so a verbatim copy
|
|
255
|
+
// would adopt the source's product rows on its next payment upsert.
|
|
256
|
+
const contents = [
|
|
257
|
+
createContentItem('pay', 'request-payment', {
|
|
258
|
+
requestPaymentConfig: {
|
|
259
|
+
paymentOptions: [{ id: 'option-1' }, { id: 'option-2' }],
|
|
260
|
+
paymentConfig: { id: 'stripe-connect-1' },
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
const result = duplicatePageContents(contents);
|
|
266
|
+
|
|
267
|
+
const config = (
|
|
268
|
+
result[0].settings.content as unknown as {
|
|
269
|
+
requestPaymentConfig: {
|
|
270
|
+
paymentOptions: { id: string }[];
|
|
271
|
+
paymentConfig: { id: string };
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
).requestPaymentConfig;
|
|
275
|
+
|
|
276
|
+
expect(config.paymentOptions.map((o) => o.id)).not.toContain('option-1');
|
|
277
|
+
expect(config.paymentOptions[0].id).not.toBe(config.paymentOptions[1].id);
|
|
278
|
+
|
|
279
|
+
// Same owner, so the copy keeps taking payments - unlike site duplication,
|
|
280
|
+
// which drops the connection because the owner changes.
|
|
281
|
+
expect(config.paymentConfig.id).toBe('stripe-connect-1');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('rotates payment-option ids on support-me content', () => {
|
|
285
|
+
const contents = [
|
|
286
|
+
createContentItem('tip', 'support-me', {
|
|
287
|
+
supportMeConfig: {
|
|
288
|
+
paymentOptions: [{ id: 'option-1' }],
|
|
289
|
+
paymentConfig: { id: 'stripe-connect-1' },
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
const result = duplicatePageContents(contents);
|
|
295
|
+
|
|
296
|
+
const config = (
|
|
297
|
+
result[0].settings.content as unknown as {
|
|
298
|
+
supportMeConfig: {
|
|
299
|
+
paymentOptions: { id: string }[];
|
|
300
|
+
paymentConfig: { id: string };
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
).supportMeConfig;
|
|
304
|
+
|
|
305
|
+
expect(config.paymentOptions[0].id).not.toBe('option-1');
|
|
306
|
+
expect(config.paymentConfig.id).toBe('stripe-connect-1');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('does NOT re-mint paymentConfig.id', () => {
|
|
310
|
+
// A backend Stripe Connect linkage, not a client id - minting a new one
|
|
311
|
+
// yields a section that can never take payment.
|
|
312
|
+
const contents = [
|
|
313
|
+
createContentItem('shop', 'shop-section', {
|
|
314
|
+
products: [],
|
|
315
|
+
shopPaymentConfig: { paymentConfig: { id: 'stripe-connect-1' } },
|
|
316
|
+
}),
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
const result = duplicatePageContents(contents);
|
|
320
|
+
|
|
321
|
+
expect(
|
|
322
|
+
(
|
|
323
|
+
result[0].settings.content as unknown as {
|
|
324
|
+
shopPaymentConfig: { paymentConfig: { id: string } };
|
|
325
|
+
}
|
|
326
|
+
).shopPaymentConfig.paymentConfig.id,
|
|
327
|
+
).toBe('stripe-connect-1');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('renews nested list item ids', () => {
|
|
331
|
+
const contents = [
|
|
332
|
+
createContentItem('links', 'links-section', {
|
|
333
|
+
items: [{ id: 'link-1' }, { id: 'link-2' }],
|
|
334
|
+
}),
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
const result = duplicatePageContents(contents);
|
|
338
|
+
|
|
339
|
+
const ids = (
|
|
340
|
+
result[0].settings.content as unknown as { items: { id: string }[] }
|
|
341
|
+
).items.map((item) => item.id);
|
|
342
|
+
|
|
343
|
+
// The AI builder is shown these ids and its executor matches on them, so a
|
|
344
|
+
// copy must not repeat the source's.
|
|
345
|
+
expect(ids).not.toContain('link-1');
|
|
346
|
+
expect(ids).not.toContain('link-2');
|
|
347
|
+
expect(ids[0]).not.toEqual(ids[1]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('renews item ids across every field the AI is shown', () => {
|
|
351
|
+
const contents = [
|
|
352
|
+
createContentItem('gallery', 'image-gallery', {
|
|
353
|
+
images: [{ id: 'image-1' }],
|
|
354
|
+
textItems: [{ id: 'text-1' }],
|
|
355
|
+
}),
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
const result = duplicatePageContents(contents);
|
|
359
|
+
const content = result[0].settings.content as unknown as {
|
|
360
|
+
images: { id: string }[];
|
|
361
|
+
textItems: { id: string }[];
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
expect(content.images[0].id).not.toEqual('image-1');
|
|
365
|
+
expect(content.textItems[0].id).not.toEqual('text-1');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('leaves an item without an id alone', () => {
|
|
369
|
+
const contents = [
|
|
370
|
+
createContentItem('links', 'links-section', {
|
|
371
|
+
items: [{ url: 'https://example.com' }],
|
|
372
|
+
}),
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
const result = duplicatePageContents(contents);
|
|
376
|
+
|
|
377
|
+
expect(
|
|
378
|
+
(result[0].settings.content as unknown as { items: { id?: string }[] })
|
|
379
|
+
.items[0].id,
|
|
380
|
+
).toBeUndefined();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import cloneDeep from 'lodash.clonedeep';
|
|
2
|
+
import { v4 as uuid } from 'uuid';
|
|
3
|
+
|
|
4
|
+
import type { Content } from '@brand-page';
|
|
5
|
+
import { rotateMonetizationIds } from '../ecommerce-settings/util/refreshDuplicatedMetadata';
|
|
6
|
+
import {
|
|
7
|
+
generateItemId,
|
|
8
|
+
ITEM_ID_BEARING_FIELDS,
|
|
9
|
+
} from '@src/metadata-builder/builders/item-id-utils';
|
|
10
|
+
import type { ContentItem } from '../content-settings/models';
|
|
11
|
+
import { pageSettingsTr } from '@src/composables/useTranslate';
|
|
12
|
+
import { MAX_PAGE_TITLE_LENGTH, MAX_SUBPAGE_TITLE_LENGTH } from './utils';
|
|
13
|
+
|
|
14
|
+
const composeTitle = (baseTitle: string, index: number): string => {
|
|
15
|
+
const copyTitle = pageSettingsTr('duplicatePageTitle', {
|
|
16
|
+
title: baseTitle,
|
|
17
|
+
// A page title is text, not markup, and i18next escapes interpolated values
|
|
18
|
+
// by default - "Tom & Jerry" would come back as "Tom & Jerry".
|
|
19
|
+
interpolation: { escapeValue: false },
|
|
20
|
+
}).trim();
|
|
21
|
+
|
|
22
|
+
return index > 1 ? `${copyTitle} ${index}` : copyTitle;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// The localised affix may sit either side of the title and is not a fixed
|
|
26
|
+
// length, so fit by trimming the base and re-composing rather than by slicing
|
|
27
|
+
// the finished string - that would cut the affix off in some locales.
|
|
28
|
+
const composeTitleWithinLimit = (
|
|
29
|
+
baseTitle: string,
|
|
30
|
+
index: number,
|
|
31
|
+
maxLength: number,
|
|
32
|
+
): string => {
|
|
33
|
+
const candidate = composeTitle(baseTitle, index);
|
|
34
|
+
const overflow = candidate.length - maxLength;
|
|
35
|
+
|
|
36
|
+
if (overflow <= 0) {
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const trimmedBase = baseTitle
|
|
41
|
+
.slice(0, Math.max(0, baseTitle.length - overflow))
|
|
42
|
+
.trimEnd();
|
|
43
|
+
const trimmedCandidate = composeTitle(trimmedBase, index);
|
|
44
|
+
|
|
45
|
+
if (trimmedCandidate.length <= maxLength) {
|
|
46
|
+
return trimmedCandidate;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A locale whose affix alone exceeds the limit: keep trimming the base rather
|
|
50
|
+
// than slicing the finished title, which would cut the trailing index and let
|
|
51
|
+
// two indexes collapse to the same name.
|
|
52
|
+
const affixLength = composeTitle('', index).length;
|
|
53
|
+
|
|
54
|
+
return composeTitle(
|
|
55
|
+
baseTitle.slice(0, Math.max(0, maxLength - affixLength)).trimEnd(),
|
|
56
|
+
index,
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const getDuplicatePageTitle = (
|
|
61
|
+
sourceTitle: string,
|
|
62
|
+
siblingTitles: string[],
|
|
63
|
+
isSubPage: boolean,
|
|
64
|
+
): string => {
|
|
65
|
+
const maxLength = isSubPage
|
|
66
|
+
? MAX_SUBPAGE_TITLE_LENGTH
|
|
67
|
+
: MAX_PAGE_TITLE_LENGTH;
|
|
68
|
+
|
|
69
|
+
const takenTitles = new Set(siblingTitles);
|
|
70
|
+
|
|
71
|
+
// N siblings block at most N candidates, so one of the first N+1 is free.
|
|
72
|
+
const lastIndex = siblingTitles.length + 1;
|
|
73
|
+
|
|
74
|
+
for (let index = 1; index < lastIndex; index += 1) {
|
|
75
|
+
const candidate = composeTitleWithinLimit(sourceTitle, index, maxLength);
|
|
76
|
+
|
|
77
|
+
if (!takenTitles.has(candidate)) {
|
|
78
|
+
return candidate;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return composeTitleWithinLimit(sourceTitle, lastIndex, maxLength);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// These ids are what the AI builder is shown and what its executor matches on
|
|
86
|
+
// (see ensureBrandPageItemIds), so a copy sharing them would put two identical
|
|
87
|
+
// ids in front of it.
|
|
88
|
+
const renewItemIds = (content: Content): void => {
|
|
89
|
+
const fields = content as unknown as Record<string, unknown>;
|
|
90
|
+
|
|
91
|
+
for (const field of ITEM_ID_BEARING_FIELDS) {
|
|
92
|
+
const items = fields[field];
|
|
93
|
+
|
|
94
|
+
if (!Array.isArray(items)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const item of items as { id?: string }[]) {
|
|
99
|
+
if (item.id) {
|
|
100
|
+
item.id = generateItemId();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const duplicatePageContents = (
|
|
107
|
+
contents: ContentItem<Content>[],
|
|
108
|
+
): ContentItem<Content>[] =>
|
|
109
|
+
cloneDeep(contents).map((item) => {
|
|
110
|
+
// ContentItem.id is re-derived from content.id on every reload
|
|
111
|
+
// (content-settings.util: mapBrandPageContentToContentSettingsItem), so the
|
|
112
|
+
// two must be minted together or itemStates drifts until the next load.
|
|
113
|
+
const newId = uuid();
|
|
114
|
+
|
|
115
|
+
item.id = newId;
|
|
116
|
+
item.settings.content.id = newId;
|
|
117
|
+
|
|
118
|
+
// Ids only. The copy keeps the source's payment connection - same owner, so it should carry on
|
|
119
|
+
// taking payments; it is website duplication that disconnects.
|
|
120
|
+
rotateMonetizationIds(item.settings.content);
|
|
121
|
+
renewItemIds(item.settings.content);
|
|
122
|
+
|
|
123
|
+
return item;
|
|
124
|
+
});
|