@designcrowd/library.brand-page 6.1.3 → 6.1.4-ast-prepurchase

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 (149) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-Lk8J1vHg.js → CreatedOnBrandCrowd-de-de-BFDE6VfS.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-BbXj5fb1.js → CreatedOnBrandCrowd-en-us-CRhl6-Cd.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-CjnqvYCi.js → CreatedOnBrandCrowd-es-es-VaP_-7dv.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-Dbjv4qpN.js → CreatedOnBrandCrowd-fr-ca-BySGqvWO.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-Dbjv4qpN.js → CreatedOnBrandCrowd-fr-fr-BySGqvWO.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-BZIOPEvL.js → CreatedOnBrandCrowd-pt-br-BHjXVcSk.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-BZIOPEvL.js → CreatedOnBrandCrowd-pt-pt-BHjXVcSk.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-aN4JMRDI.js → CreatedOnDcom-de-de-DIFO5IZY.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-f3VD6Pz4.js → CreatedOnDcom-en-us-CcpDa04T.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-CQhNdtRP.js → CreatedOnDcom-es-es-DHwY0lWp.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-CFsC_XoO.js → CreatedOnDcom-fr-ca-BP3P0hJL.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-J4ZtCBO9.js → CreatedOnDcom-fr-fr-Bai1giy3.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-C1yFugh_.js → CreatedOnDcom-pt-br-GgKiDrbK.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-ycOo5lDn.js → CreatedOnDcom-pt-pt-uuuxCYR3.js} +1 -1
  15. package/lib/src/PreviewModal-DY2aAN0a.js +19931 -0
  16. package/lib/src/brand-page/components/page-footer/PageFooter.mixin.d.ts +0 -1
  17. package/lib/src/brand-page/components/page-footer/PageFooter.mixin.js +1 -4
  18. package/lib/src/brand-page/models/brand-page-context.model.d.ts +2 -0
  19. package/lib/src/brand-page/models/brand-page-context.model.js +12 -0
  20. package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.d.ts +2 -0
  21. package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.js +14 -1
  22. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +134 -0
  23. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.js +50 -0
  24. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +130 -0
  25. package/lib/src/brand-page-maker/components/navigation/Navigation.mixin.d.ts +1 -0
  26. package/lib/src/brand-page-maker/components/navigation/Navigation.mixin.js +3 -0
  27. package/lib/src/brand-page-maker/components/preview/Preview.mixin.d.ts +14 -0
  28. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +130 -0
  29. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +130 -0
  30. package/lib/src/brand-page-maker/components/seo-settings/UrlSettings.mixin.d.ts +130 -0
  31. package/lib/src/brand-page-maker/components/seo-settings/UseExistingDomainModal.mixin.d.ts +130 -0
  32. package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.d.ts +130 -0
  33. package/lib/src/brand-page-maker/components/upgrade-to-premium-button/UpgradeToPremiumButton.mixin.d.ts +130 -0
  34. package/lib/src/brand-page-maker/composables/useEventTracker.d.ts +1 -0
  35. package/lib/src/brand-page-maker/composables/useEventTracker.js +1 -0
  36. package/lib/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.d.ts +132 -0
  37. package/lib/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.js +22 -6
  38. package/lib/src/bundled-translations-D42tE0D2.js +179 -0
  39. package/lib/src/bundled-translations.de-DE-D9aWGgFt.js +179 -0
  40. package/lib/src/bundled-translations.es-ES-lk4B0xQ7.js +179 -0
  41. package/lib/src/bundled-translations.fr-CA-DhOBNt6q.js +179 -0
  42. package/lib/src/bundled-translations.fr-FR-CnpafQeG.js +179 -0
  43. package/lib/src/bundled-translations.pt-BR-C4PnKrd9.js +179 -0
  44. package/lib/src/bundled-translations.pt-PT-CfKDVQC-.js +179 -0
  45. package/lib/src/bundles/bundled-translations.de-DE.json +5 -0
  46. package/lib/src/bundles/bundled-translations.es-ES.json +5 -0
  47. package/lib/src/bundles/bundled-translations.fr-CA.json +5 -0
  48. package/lib/src/bundles/bundled-translations.fr-FR.json +5 -0
  49. package/lib/src/bundles/bundled-translations.json +5 -0
  50. package/lib/src/bundles/bundled-translations.pt-BR.json +5 -0
  51. package/lib/src/bundles/bundled-translations.pt-PT.json +5 -0
  52. package/lib/src/clients/brand-page-client.d.ts +1 -0
  53. package/lib/src/composables/useTranslate.d.ts +2 -1
  54. package/lib/src/composables/useTranslate.js +2 -1
  55. package/lib/src/constants/brand-page-maker.d.ts +2 -0
  56. package/lib/src/constants/brand-page-maker.js +5 -0
  57. package/lib/src/constants/live-preview.constants.d.ts +15 -0
  58. package/lib/src/constants/live-preview.constants.js +15 -0
  59. package/lib/src/css/library.brand-page-brandCrowd.min.css +264 -121
  60. package/lib/src/css/library.brand-page-brandPage.min.css +268 -121
  61. package/lib/src/css/library.brand-page-designCom.min.css +264 -121
  62. package/lib/src/css/library.brand-page-preview.min.css +264 -121
  63. package/lib/src/index.d.ts +2 -1
  64. package/lib/src/index.mjs +102150 -225
  65. package/lib/src/lib.js +2 -1
  66. package/lib/src/preview-modal.d.ts +5 -0
  67. package/lib/src/preview-modal.js +12 -0
  68. package/lib/src/preview-modal.mjs +6 -0
  69. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +130 -0
  70. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +130 -0
  71. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +130 -0
  72. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +130 -0
  73. package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +144 -0
  74. package/lib/src/shared/side-bar/components/SideBar.mixin.d.ts +130 -0
  75. package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +130 -0
  76. package/lib/src/tracker.mjs +1 -1
  77. package/lib/src/{useEventTracker-DmQ6AkNn.js → useEventTracker-Dmc2jFdN.js} +5 -4
  78. package/package.json +5 -1
  79. package/src/brand-page/components/button/Button.vue +11 -0
  80. package/src/brand-page/components/button/Button.vue.test.ts +90 -0
  81. package/src/brand-page/components/image-gallery/masonry/MasonryGallery.vue +1 -0
  82. package/src/brand-page/components/images-section/ImagesSection.vue +10 -0
  83. package/src/brand-page/components/media/Video.vue +60 -1
  84. package/src/brand-page/components/page-footer/PageFooter.mixin.ts +1 -4
  85. package/src/brand-page/components/page-footer/PageFooter.vue +0 -1
  86. package/src/brand-page/components/payment/Payment.vue +3 -0
  87. package/src/brand-page/components/payment/request-payment/RequestPayment.vue +1 -0
  88. package/src/brand-page/components/payment/support-me/SupportMe.vue +3 -0
  89. package/src/brand-page/components/shop/CartModal.vue +9 -0
  90. package/src/brand-page/components/shop/Shop.vue +19 -0
  91. package/src/brand-page/components/shop/Shop.vue.test.ts +182 -2
  92. package/src/brand-page/components/social-links/SocialLinks.vue +1 -9
  93. package/src/brand-page/components/social-links/SocialLinks.vue.test.ts +28 -8
  94. package/src/brand-page/components/social-links/SocialLinksItems.vue +6 -2
  95. package/src/brand-page/components/testimonial-section/TestimonialSection.vue +2 -2
  96. package/src/brand-page/components/video/Video.vue +3 -0
  97. package/src/brand-page/models/brand-page-context.model.ts +23 -0
  98. package/src/brand-page-maker/components/action-bar/ActionBar.mixin.ts +15 -0
  99. package/src/brand-page-maker/components/action-bar/ActionBarNavigation.test.ts +95 -0
  100. package/src/brand-page-maker/components/action-bar/ActionBarNavigation.vue +19 -4
  101. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.ts +57 -0
  102. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.preview-gate.test.ts +113 -0
  103. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.vue +26 -1
  104. package/src/brand-page-maker/components/navigation/Navigation.mixin.ts +3 -0
  105. package/src/brand-page-maker/components/navigation/Navigation.vue +1 -0
  106. package/src/brand-page-maker/components/preview-modal/PreviewModal.test.ts +162 -0
  107. package/src/brand-page-maker/components/preview-modal/PreviewModal.vue +347 -0
  108. package/src/brand-page-maker/components/preview-modal/PreviewModalWithState.test.ts +226 -0
  109. package/src/brand-page-maker/components/preview-modal/PreviewModalWithState.vue +149 -0
  110. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.de-DE.json +6 -0
  111. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.es-ES.json +6 -0
  112. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.fr-CA.json +6 -0
  113. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.fr-FR.json +6 -0
  114. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.json +6 -0
  115. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.pt-BR.json +6 -0
  116. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.pt-PT.json +6 -0
  117. package/src/brand-page-maker/components/rich-text-editor/RichTextEditor.vue +29 -17
  118. package/src/brand-page-maker/composables/useEventTracker.ts +1 -0
  119. package/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.save.test.ts +106 -0
  120. package/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.ts +40 -8
  121. package/src/bundles/bundled-translations.de-DE.json +5 -0
  122. package/src/bundles/bundled-translations.es-ES.json +5 -0
  123. package/src/bundles/bundled-translations.fr-CA.json +5 -0
  124. package/src/bundles/bundled-translations.fr-FR.json +5 -0
  125. package/src/bundles/bundled-translations.json +5 -0
  126. package/src/bundles/bundled-translations.pt-BR.json +5 -0
  127. package/src/bundles/bundled-translations.pt-PT.json +5 -0
  128. package/src/clients/brand-page-client.ts +2 -0
  129. package/src/composables/useTranslate.ts +2 -0
  130. package/src/constants/brand-page-maker.ts +5 -0
  131. package/src/constants/live-preview.constants.ts +15 -0
  132. package/src/lib.ts +5 -1
  133. package/src/preview-modal.ts +14 -0
  134. package/src/shared/common/i18n/common.de-DE.json +1 -0
  135. package/src/shared/common/i18n/common.es-ES.json +1 -0
  136. package/src/shared/common/i18n/common.fr-CA.json +1 -0
  137. package/src/shared/common/i18n/common.fr-FR.json +1 -0
  138. package/src/shared/common/i18n/common.json +1 -0
  139. package/src/shared/common/i18n/common.pt-BR.json +1 -0
  140. package/src/shared/common/i18n/common.pt-PT.json +1 -0
  141. package/src/shared/ecommerce-settings/store/cart-store.test.ts +38 -0
  142. package/lib/src/bundled-translations-2HkRBbBD.js +0 -177
  143. package/lib/src/bundled-translations.de-DE-CL9ZEhU2.js +0 -177
  144. package/lib/src/bundled-translations.es-ES-uj7Sliu0.js +0 -177
  145. package/lib/src/bundled-translations.fr-CA-BMnBtr_w.js +0 -177
  146. package/lib/src/bundled-translations.fr-FR-9PEZedzq.js +0 -177
  147. package/lib/src/bundled-translations.pt-BR-B3oNDkoe.js +0 -177
  148. package/lib/src/bundled-translations.pt-PT-DM7Vsf-h.js +0 -177
  149. package/lib/src/lib-CVwyHyBz.js +0 -121538
@@ -1,4 +1,4 @@
1
- import { A as N, E as O, G as _, P as A, a as I, S as a, T as S, U as s, u as C } from "./useEventTracker-DmQ6AkNn.js";
1
+ import { A as N, E as O, G as _, P as A, a as I, S as a, T as S, U as s, u as C } from "./useEventTracker-Dmc2jFdN.js";
2
2
  export {
3
3
  N as AI_CTA_LOCATIONS,
4
4
  O as EVENTS,
@@ -1,4 +1,4 @@
1
- import { shallowRef as A } from "vue";
1
+ import { shallowRef as I } from "vue";
2
2
  const E = {
3
3
  EXPERIMENT_STARTED: "$experiment_started",
4
4
  WEBSITE_MAKER_ADD_CONTENT: "website_maker_add_content",
@@ -53,6 +53,7 @@ const E = {
53
53
  ADD_CONTENT: "add_content",
54
54
  EDITOR_PUBLISH_TOP: "editor_publish_top",
55
55
  EDITOR_PUBLISH_BOTTOM: "editor_publish_bottom",
56
+ EDITOR_PREVIEW_PUBLISH: "editor_preview_publish",
56
57
  SLUG_CHANGE_MAKER_SIDEBAR_SETTINGS: "slug_change_maker_sidebar_settings",
57
58
  SLUG_CHANGE_MANAGE_PAGE: "slug_change_manage_page",
58
59
  GOOGLE_ADSENSE: "google_adsense",
@@ -82,18 +83,18 @@ const E = {
82
83
  PREMIUM: "premium",
83
84
  EXIT: "exit",
84
85
  FREEMIUM: "freemium"
85
- }, i = A(), n = A({
86
+ }, i = I(), n = I({
86
87
  upsellOpenEventProps: {},
87
88
  domainUpsellOpenEventProps: {},
88
89
  publishOpenEventProps: {}
89
- }), l = A([]), _ = (o, I, e = !1, t = "maker_editor") => {
90
+ }), l = I([]), _ = (o, A, e = !1, t = "maker_editor") => {
90
91
  l.value.forEach((s) => {
91
92
  s({
92
93
  scope: t,
93
94
  eventName: o,
94
95
  eventProps: {
95
96
  ...e ? {} : i.value,
96
- ...I
97
+ ...A
97
98
  }
98
99
  });
99
100
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@designcrowd/library.brand-page",
3
3
  "description": "A collection of Maker/Designer applications",
4
- "version": "6.1.3",
4
+ "version": "6.1.4-ast-prepurchase",
5
5
  "author": "Design.com Eng",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",
@@ -19,6 +19,10 @@
19
19
  "types": "./lib/src/tracker.d.ts",
20
20
  "import": "./lib/src/tracker.mjs"
21
21
  },
22
+ "./preview-modal": {
23
+ "types": "./lib/src/preview-modal.d.ts",
24
+ "import": "./lib/src/preview-modal.mjs"
25
+ },
22
26
  "./lib/src/css/*": "./lib/src/css/*",
23
27
  "./package.json": "./package.json"
24
28
  },
@@ -62,6 +62,7 @@
62
62
  :class="[innerClass, buttonStateClass]"
63
63
  :style="buttonStyle"
64
64
  rel="noopener"
65
+ :data-preview-interactive="previewInteractive"
65
66
  @click="onClick"
66
67
  >
67
68
  <ButtonIcon
@@ -89,6 +90,7 @@
89
90
  </template>
90
91
  </ButtonStyleWrapper>
91
92
  <HashRouteModal
93
+ data-component="button-image-modal"
92
94
  mode="image"
93
95
  :is-hash-route-enabled="false"
94
96
  :visible="isModalVisible"
@@ -149,6 +151,15 @@ const isModalImageUrlValid = computed((): boolean =>
149
151
  isURL(props.viewModel.modalImageUrl),
150
152
  );
151
153
 
154
+ // The live preview is inert by default, so the "Open Design" button needs an
155
+ // explicit opt-in to stay clickable. Only the modal-image action qualifies —
156
+ // external/mailto/tel buttons stay inert in preview. Returns null (not false) so
157
+ // Vue omits the attribute rather than rendering "false", which would still match
158
+ // the gate's selector.
159
+ const previewInteractive = computed((): '' | null =>
160
+ isModalUrl.value && isModalImageUrlValid.value ? '' : null,
161
+ );
162
+
152
163
  const isBrushStrokeIcon = computed(
153
164
  (): boolean =>
154
165
  props.viewModel.styles.selectedButtonStyleId === 'BUTTON_STYLE_19',
@@ -173,3 +173,93 @@ describe('Button - page link "back to top" behaviour (BP-5834)', () => {
173
173
  expect(scrollToSpy).not.toHaveBeenCalled();
174
174
  });
175
175
  });
176
+
177
+ describe('Button - "Open Design" image modal in live preview (BP-5955)', () => {
178
+ beforeEach(() => {
179
+ setActivePinia(createPinia());
180
+ });
181
+
182
+ const mountModalButton = async (overrides: Partial<ButtonViewModel> = {}) => {
183
+ const router = createTestRouter();
184
+
185
+ router.push(OTHER_PAGE_PATH);
186
+ await router.isReady();
187
+
188
+ return mount(Button, {
189
+ propsData: {
190
+ context: createTestBrandPageContext(),
191
+ viewModel: createTestButtonViewModel({
192
+ buttonLinkType: 'url',
193
+ clickActionField: 'modalImageUrl',
194
+ modalImageUrl: 'https://assets.example.com/design.png',
195
+ ...overrides,
196
+ }),
197
+ },
198
+ global: {
199
+ plugins: [router],
200
+ },
201
+ });
202
+ };
203
+
204
+ test('opts the anchor into the preview gate for a valid modal image (data-preview-interactive="")', async () => {
205
+ const wrapper = await mountModalButton();
206
+
207
+ expect(wrapper.get('a').attributes('data-preview-interactive')).toBe('');
208
+ });
209
+
210
+ test('omits the opt-in attribute entirely for a url-action button (never renders "false")', async () => {
211
+ const wrapper = await mountModalButton({
212
+ clickActionField: 'url',
213
+ url: 'https://example.com',
214
+ modalImageUrl: '',
215
+ });
216
+
217
+ expect(
218
+ wrapper.get('a').attributes('data-preview-interactive'),
219
+ ).toBeUndefined();
220
+ });
221
+
222
+ test('omits the opt-in attribute when the modal image url is invalid', async () => {
223
+ const wrapper = await mountModalButton({ modalImageUrl: 'not-a-url' });
224
+
225
+ expect(
226
+ wrapper.get('a').attributes('data-preview-interactive'),
227
+ ).toBeUndefined();
228
+ });
229
+
230
+ // Doubles as the fallthrough-attr runtime check: the data-component must actually
231
+ // land on a queryable element (Modal's v-show root keeps it in the DOM).
232
+ test('anchors the popup with data-component="button-image-modal" for the dismissal allow-list', async () => {
233
+ const wrapper = await mountModalButton();
234
+
235
+ expect(wrapper.find('[data-component="button-image-modal"]').exists()).toBe(
236
+ true,
237
+ );
238
+ });
239
+
240
+ test('opens the image popup on click and closes it on dismiss', async () => {
241
+ const wrapper = await mountModalButton();
242
+
243
+ expect(
244
+ wrapper
245
+ .get('[data-component="button-image-modal"]')
246
+ .attributes('aria-hidden'),
247
+ ).toBe('true');
248
+
249
+ await wrapper.get('a').trigger('click');
250
+
251
+ expect(
252
+ wrapper
253
+ .get('[data-component="button-image-modal"]')
254
+ .attributes('aria-hidden'),
255
+ ).toBe('false');
256
+
257
+ await wrapper.get('[data-test-modal-close-btn]').trigger('click');
258
+
259
+ expect(
260
+ wrapper
261
+ .get('[data-component="button-image-modal"]')
262
+ .attributes('aria-hidden'),
263
+ ).toBe('true');
264
+ });
265
+ });
@@ -2,6 +2,7 @@
2
2
  <Masonry
3
3
  v-if="items.length > 0"
4
4
  ref="masonryRef"
5
+ data-component="masonry-gallery"
5
6
  :gap="5"
6
7
  :column-width="columnWidth"
7
8
  :items="items"
@@ -13,6 +13,7 @@
13
13
  :class="{
14
14
  'tw-cursor-pointer': shouldShowCursor(item),
15
15
  }"
16
+ :data-preview-interactive="previewInteractive(item)"
16
17
  @click="shouldBeClickable(item) ? onSelectImage(item) : undefined"
17
18
  />
18
19
  <FallbackImage
@@ -29,6 +30,7 @@
29
30
  :alt="itemImageAltText(item)"
30
31
  :context="context"
31
32
  :content-index="contentIndex"
33
+ :data-preview-interactive="previewInteractive(item)"
32
34
  @click="shouldBeClickable(item) ? onSelectImage(item) : undefined"
33
35
  />
34
36
  <Placeholder
@@ -222,6 +224,14 @@ const shouldBeClickable = (item: TextMediaItem): boolean => {
222
224
  );
223
225
  };
224
226
 
227
+ // The live preview is inert by default, so a clickable image needs an explicit
228
+ // opt-in. It mirrors `shouldBeClickable` exactly: whatever an image does on the live
229
+ // site, it does in the preview — the lightbox, or opening its link in a new tab.
230
+ // Returns null (not false) so Vue omits the attribute rather than rendering "false",
231
+ // which would still match the gate's selector.
232
+ const previewInteractive = (item: TextMediaItem): '' | null =>
233
+ shouldBeClickable(item) ? '' : null;
234
+
225
235
  const onImageModalClose = () => {
226
236
  imageModalSelectedImageIndex.value = undefined;
227
237
  };
@@ -1,9 +1,13 @@
1
1
  <template>
2
2
  <div v-if="!context.options?.renderVideoAsImage" :class="videoClasses">
3
+ <!-- Poster paints the thumbnail immediately instead of a blank box while the video
4
+ streams. `autoplay` uses isPreview, not isPreviewMode, on purpose: the live
5
+ preview must play like the published site. -->
3
6
  <video
4
7
  ref="videoElement"
5
8
  class="tw-w-full tw-h-full tw-object-cover"
6
9
  :src="media?.videoUrl"
10
+ :poster="media?.previewImageUrl"
7
11
  :autoplay="!context.isPreview"
8
12
  muted
9
13
  loop
@@ -11,6 +15,7 @@
11
15
  playsinline
12
16
  preload="auto"
13
17
  @canplay="attemptAutoplay"
18
+ @error="scheduleVideoRetry"
14
19
  />
15
20
  </div>
16
21
  <FallbackImage
@@ -25,7 +30,7 @@
25
30
  />
26
31
  </template>
27
32
  <script setup lang="ts">
28
- import { nextTick, onMounted, ref, watch } from 'vue';
33
+ import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
29
34
  import { type BrandPageContext, type GettyVideoViewModel } from '../../../lib';
30
35
  import FallbackImage from '../fallback-image/FallbackImage.vue';
31
36
 
@@ -45,6 +50,60 @@ const props = withDefaults(defineProps<Props>(), {
45
50
 
46
51
  const videoElement = ref<HTMLVideoElement>();
47
52
 
53
+ // The video URL is derived from the asset token, and that address only exists once
54
+ // Getty has finished copying the asset into public storage. A video added moments
55
+ // ago is still copying, so the first load 404s and the customer gets a blank box
56
+ // until they refresh. Retry instead — published pages hit this too, not only preview.
57
+ //
58
+ // Spread wide on purpose: a measured copy was still not ready after 30s, so short
59
+ // intervals only burn attempts. Backing off to ~4 minutes total costs six 404s.
60
+ const RETRY_DELAYS_MS = [5000, 10000, 20000, 40000, 60000, 120000];
61
+
62
+ let retryCount = 0;
63
+ let retryTimer: ReturnType<typeof setTimeout> | undefined;
64
+
65
+ const clearRetry = () => {
66
+ if (retryTimer) {
67
+ clearTimeout(retryTimer);
68
+ retryTimer = undefined;
69
+ }
70
+ };
71
+
72
+ const scheduleVideoRetry = () => {
73
+ const delay = RETRY_DELAYS_MS[retryCount];
74
+
75
+ if (delay === undefined) {
76
+ return;
77
+ }
78
+
79
+ retryCount += 1;
80
+ clearRetry();
81
+
82
+ retryTimer = setTimeout(() => {
83
+ const element = videoElement.value;
84
+
85
+ if (!element || !props.media?.videoUrl) {
86
+ return;
87
+ }
88
+
89
+ // CloudFront caches 404s briefly, so a plain reload would keep serving the miss.
90
+ // The buster is only on retries; the first (normal) load stays cacheable.
91
+ element.src = `${props.media.videoUrl}?retry=${retryCount}`;
92
+ element.load();
93
+ }, delay);
94
+ };
95
+
96
+ // A different video means a fresh budget.
97
+ watch(
98
+ () => props.media?.videoUrl,
99
+ () => {
100
+ retryCount = 0;
101
+ clearRetry();
102
+ },
103
+ );
104
+
105
+ onUnmounted(clearRetry);
106
+
48
107
  const attemptAutoplay = async () => {
49
108
  if (
50
109
  props.context.options?.renderVideoAsImage ||
@@ -2,7 +2,7 @@ import { defineComponent } from 'vue';
2
2
  import type { PageFooterViewModel } from '@brand-page/view-models';
3
3
  import type { PropType } from 'vue';
4
4
  import type { BrandPageContext } from '@brand-page/models';
5
- import { getRenderText, isPreviewMode } from '@brand-page/utils';
5
+ import { getRenderText } from '@brand-page/utils';
6
6
  import { FOOTER_DISPLAY } from '../../constants';
7
7
 
8
8
  export default defineComponent({
@@ -27,9 +27,6 @@ export default defineComponent({
27
27
  },
28
28
  emits: ['on-logo-click', 'footer-container-click'],
29
29
  computed: {
30
- isInPreviewMode(): boolean {
31
- return isPreviewMode(this.context);
32
- },
33
30
  containerStyle(): BrandPageCssStyle {
34
31
  return this.viewModel.backgroundStyle;
35
32
  },
@@ -46,7 +46,6 @@
46
46
  :icon-style="socialLinksIconStyle"
47
47
  :is-stack-layout-on-mobile="false"
48
48
  :links="viewModel.socialLinks"
49
- :is-preview="isInPreviewMode"
50
49
  :context="context"
51
50
  />
52
51
  <!-- end social links -->
@@ -2,7 +2,10 @@
2
2
  <section>
3
3
  <div v-if="showPaymentBlock" class="tw-p-4" :style="containerStyle">
4
4
  <!-- Start incomplete config state -->
5
+ <!-- Only the header opts in — pay/checkout below stay inert. Payment rolls its own
6
+ header, so the `collapsible` allow-list entry can't reach it. -->
5
7
  <div
8
+ data-preview-interactive
6
9
  class="tw-relative tw-text-center tw-w-full hover:tw-cursor-pointer"
7
10
  :class="{ 'tw-mb-4': !isCollapsed }"
8
11
  @click="onHeaderToggleClick"
@@ -37,6 +37,7 @@
37
37
  'tw-justify-center tw-flex': isFeaturedDisplayStyle,
38
38
  }"
39
39
  :style="getPaymentOptionButtonStyleById(paymentOption.id)"
40
+ data-preview-interactive
40
41
  @click="onPaymentOptionSelect(paymentOption.id)"
41
42
  >
42
43
  <div
@@ -31,9 +31,12 @@
31
31
  'tw-grid-cols-1': paymentOptions.length < 2,
32
32
  }"
33
33
  >
34
+ <!-- Picking an amount is local state only — no cart, no charge. Marked per
35
+ control, not on the root, which would expose pay/checkout too. -->
34
36
  <li
35
37
  v-for="paymentOption in paymentOptions"
36
38
  :key="`payment-option-${paymentOption.id}`"
39
+ data-preview-interactive
37
40
  class="payment-option tw-flex tw-flex-row tw-flex-wrap tw-justify-center tw-text-sm tw-my-0"
38
41
  :class="{
39
42
  'payment-option--selected': selectedPaymentOptionIds.some(
@@ -197,7 +197,12 @@
197
197
  }"
198
198
  @click="onClose"
199
199
  />
200
+ <!-- Hidden in the live preview: checkout is hard-blocked upstream
201
+ (`Shop.vue.onShowPaymentModal` bails on `isPreviewMode`), so a
202
+ dead Proceed button would only mislead. The checkout "preview
203
+ prompt" UI that replaces it is deferred (needs Figma). -->
200
204
  <Button
205
+ v-if="!props.isCheckoutBlocked"
201
206
  classes="tw-px-8 tw-w-full"
202
207
  :label="btnLabel"
203
208
  variant="primary"
@@ -247,10 +252,14 @@ import { calculatePrice } from '../../../utils/currency.util';
247
252
  interface Props {
248
253
  isVisible: boolean;
249
254
  buttonStyle: BrandPageCssStyle;
255
+ // True in the live preview: the cart is browsable but checkout is hard-blocked,
256
+ // so the Proceed button is hidden.
257
+ isCheckoutBlocked?: boolean;
250
258
  }
251
259
 
252
260
  const props = withDefaults(defineProps<Props>(), {
253
261
  isVisible: true,
262
+ isCheckoutBlocked: false,
254
263
  });
255
264
 
256
265
  const cartSettingsStore = useCartStore();
@@ -63,9 +63,14 @@
63
63
  :icon-color="viewModel.buttonStyles?.style?.backgroundColor"
64
64
  :no-margin="true"
65
65
  >
66
+ <!-- `data-preview-interactive`: opening the cart to look at it charges
67
+ nothing, so it stays clickable in the live preview even though the shop
68
+ is inert by default. Scoped to this button — everything inside the cart,
69
+ Checkout included, stays blocked. -->
66
70
  <button
67
71
  class="tw-mt-4 tw-block tw-w-full tw-border-0 tw-py-3 tw-select-none tw-text-base hover:tw-cursor-pointer tw-rounded"
68
72
  :style="buttonStyle"
73
+ data-preview-interactive
69
74
  @click="onShowCartModal"
70
75
  >
71
76
  <TextWrapper :text-style="buttonTextStyle" :text="viewCartText" />
@@ -83,8 +88,12 @@
83
88
  :label="shopTr('itemAddedToCart')"
84
89
  >
85
90
  <template #cta>
91
+ <!-- `data-preview-interactive`: the success-bar's View Cart opens the cart
92
+ to look at it (charges nothing), so it stays clickable in the live
93
+ preview — same opt-in as the main View Cart button above. -->
86
94
  <p
87
95
  data-component="shop"
96
+ data-preview-interactive
88
97
  class="tw-underline tw-cursor-pointer"
89
98
  @click="onShowCartModal"
90
99
  >
@@ -106,6 +115,7 @@
106
115
  class="modal-cart"
107
116
  :is-visible="data.isCartModalVisible"
108
117
  :button-style="buttonStyle"
118
+ :is-checkout-blocked="isPreviewMode(props.context)"
109
119
  @on-close="onCloseCart"
110
120
  @on-show-payment="onShowPaymentModal"
111
121
  />
@@ -227,6 +237,15 @@ const onShowSuccess = () => {
227
237
  };
228
238
 
229
239
  const onShowPaymentModal = () => {
240
+ // Safety-critical hard-block: `PaymentModal` mounts `createCart()` on mount, which
241
+ // POSTs a real cart and creates a Stripe payment intent against the live key the
242
+ // preview carries. In any preview the shop is browsable but checkout must never
243
+ // reach payment, so we bail before the modal can mount. Mirrors
244
+ // Payment.mixin `canMakePayment` (`!isPreviewMode(context) && ...`).
245
+ if (isPreviewMode(props.context)) {
246
+ return;
247
+ }
248
+
230
249
  data.isCartModalVisible = false;
231
250
  data.isPaymentModalVisible = true;
232
251
  };
@@ -1,7 +1,9 @@
1
1
  import { vi } from 'vitest';
2
- import { shallowMount } from '@vue/test-utils';
2
+ import { flushPromises, mount, shallowMount } from '@vue/test-utils';
3
+ import { Button } from '@designcrowd/fe-shared-lib';
3
4
  import Shop from './Shop.vue';
4
5
  import PaymentModal from './PaymentModal.vue';
6
+ import CartModal from './CartModal.vue';
5
7
  import { createPinia, setActivePinia } from 'pinia';
6
8
  import {
7
9
  BRAND_PAGE_TYPES,
@@ -10,6 +12,9 @@ import {
10
12
  type BrandPageContext,
11
13
  } from '@brand-page/models';
12
14
  import type { ShopViewModel } from '@brand-page/view-models';
15
+ import brandPageClient from '../../clients/brand-page.client';
16
+ import { useShopConfigStore } from '@shared/ecommerce-settings/store/shop-settings-store';
17
+ import { useCartStore } from '@shared/ecommerce-settings/store/cart-settings-store';
13
18
 
14
19
  vi.mock('../../clients/brand-page.client', () => ({
15
20
  default: {
@@ -22,7 +27,9 @@ vi.mock('@brand-page/utils/raygun.util', () => ({
22
27
  default: { safeSendRaygunError: vi.fn() },
23
28
  }));
24
29
 
25
- const createContext = (): BrandPageContext => ({
30
+ const createContext = (
31
+ overrides: Partial<BrandPageContext> = {},
32
+ ): BrandPageContext => ({
26
33
  brandPageSlug: 'test',
27
34
  brandPageToken: '',
28
35
  isMobile: false,
@@ -45,6 +52,7 @@ const createContext = (): BrandPageContext => ({
45
52
  isCapturingPreview: false,
46
53
  recaptchaSiteKey: undefined,
47
54
  nav: { navItems: [] },
55
+ ...overrides,
48
56
  });
49
57
 
50
58
  const PRODUCT = {
@@ -207,3 +215,175 @@ describe('PaymentModal', () => {
207
215
  expect((flatCss as any).style).toBeUndefined();
208
216
  });
209
217
  });
218
+
219
+ // BP-5961 SAFETY-CRITICAL: in any preview the shop is browsable but checkout must be
220
+ // hard-blocked. `/api/cart/createCart` and `/api/stripe/createPaymentIntent` are
221
+ // UNGUARDED on the backend and the preview carries a real Stripe key, so this guard
222
+ // in `Shop.vue.onShowPaymentModal` is the SOLE thing preventing a real charge.
223
+ describe('Shop checkout hard-block (BP-5961)', () => {
224
+ beforeEach(() => {
225
+ setActivePinia(createPinia());
226
+ vi.clearAllMocks();
227
+ });
228
+
229
+ // PaymentModal stays REAL (its onMounted → createBrandPageCart is what we assert on);
230
+ // CartModal stays REAL so we can emit `on-show-payment` from it; everything heavy is
231
+ // stubbed. Stubbing CartModal's `Modal` to `true` also drops its slot content, so we
232
+ // don't need to stub NumberStepper/Icon/etc.
233
+ const HARD_BLOCK_STUBS = {
234
+ HelloBar: true,
235
+ ButtonStyleWrapper: true,
236
+ TextWrapper: true,
237
+ TextDisplay: true,
238
+ ThumbnailDisplay: true,
239
+ FeaturedDisplay: true,
240
+ ProductDetailModal: true,
241
+ Modal: true,
242
+ Loader: true,
243
+ Button: true,
244
+ RequestPaymentStripe: true,
245
+ };
246
+
247
+ const seedShopWithCartItem = () => {
248
+ const shopStore = useShopConfigStore();
249
+
250
+ shopStore.setPaymentConfig({ id: 'pc-1' } as any);
251
+ shopStore.addProduct({
252
+ id: '1',
253
+ name: 'Test',
254
+ hasVariants: false,
255
+ skus: [{ id: 'sku-1', skuKey: 'sku-1', price: 10, variant: [] }],
256
+ } as any);
257
+
258
+ useCartStore().cartItems.push({
259
+ productId: '1',
260
+ productSkuId: 'sku-1',
261
+ skuKey: 'sku-1',
262
+ unitPrice: 10,
263
+ quantity: 1,
264
+ });
265
+ };
266
+
267
+ const mountShopForCheckout = (context: BrandPageContext) => {
268
+ seedShopWithCartItem();
269
+
270
+ return mount(Shop, {
271
+ props: {
272
+ context,
273
+ viewModel: createViewModel({ products: [PRODUCT] }),
274
+ contentIndex: 0,
275
+ },
276
+ global: { stubs: HARD_BLOCK_STUBS },
277
+ });
278
+ };
279
+
280
+ const triggerCheckout = async (wrapper: ReturnType<typeof mount>) => {
281
+ // The cart's Proceed button emits this; going through the emit exercises the real
282
+ // `onShowPaymentModal` guard rather than reaching into component internals.
283
+ wrapper.findComponent(CartModal).vm.$emit('on-show-payment');
284
+ await flushPromises();
285
+ };
286
+
287
+ it.each([
288
+ ['customer live preview', { isPreview: false, isLivePreview: true }],
289
+ ['editor preview', { isPreview: true, isLivePreview: false }],
290
+ ])(
291
+ 'no-ops checkout — PaymentModal never mounts and createBrandPageCart is never called (%s)',
292
+ async (_label, flags) => {
293
+ const wrapper = mountShopForCheckout(createContext(flags));
294
+
295
+ await triggerCheckout(wrapper);
296
+
297
+ expect(wrapper.findComponent(PaymentModal).exists()).toBe(false);
298
+ expect(brandPageClient.createBrandPageCart).not.toHaveBeenCalled();
299
+ },
300
+ );
301
+
302
+ it('published site is UNCHANGED — PaymentModal mounts and createBrandPageCart is called', async () => {
303
+ const wrapper = mountShopForCheckout(
304
+ createContext({ isPreview: false, isLivePreview: false }),
305
+ );
306
+
307
+ await triggerCheckout(wrapper);
308
+
309
+ expect(wrapper.findComponent(PaymentModal).exists()).toBe(true);
310
+ expect(brandPageClient.createBrandPageCart).toHaveBeenCalledTimes(1);
311
+ });
312
+
313
+ it('passes is-checkout-blocked to CartModal only in a preview', () => {
314
+ const previewWrapper = mountShopForCheckout(
315
+ createContext({ isPreview: false, isLivePreview: true }),
316
+ );
317
+
318
+ expect(
319
+ previewWrapper.findComponent(CartModal).props('isCheckoutBlocked'),
320
+ ).toBe(true);
321
+
322
+ setActivePinia(createPinia());
323
+
324
+ const publishedWrapper = mountShopForCheckout(
325
+ createContext({ isPreview: false, isLivePreview: false }),
326
+ );
327
+
328
+ expect(
329
+ publishedWrapper.findComponent(CartModal).props('isCheckoutBlocked'),
330
+ ).toBe(false);
331
+ });
332
+ });
333
+
334
+ // BP-5961: the cart is browsable in a preview, so it can hold items — the Proceed
335
+ // (checkout) button is what must disappear, driven by the new `isCheckoutBlocked` prop.
336
+ describe('CartModal Proceed button (BP-5961)', () => {
337
+ beforeEach(() => {
338
+ setActivePinia(createPinia());
339
+ });
340
+
341
+ const mountCartModal = (isCheckoutBlocked: boolean) => {
342
+ const shopStore = useShopConfigStore();
343
+
344
+ shopStore.addProduct({
345
+ id: '1',
346
+ name: 'Test',
347
+ hasVariants: false,
348
+ imageUrl: '',
349
+ skus: [{ id: 'sku-1', skuKey: 'sku-1', price: 10, variant: [] }],
350
+ } as any);
351
+
352
+ useCartStore().cartItems.push({
353
+ productId: '1',
354
+ productSkuId: 'sku-1',
355
+ skuKey: 'sku-1',
356
+ unitPrice: 10,
357
+ quantity: 1,
358
+ });
359
+
360
+ return mount(CartModal, {
361
+ props: { isVisible: true, buttonStyle: {} as any, isCheckoutBlocked },
362
+ global: {
363
+ stubs: {
364
+ // Render Modal's slots so the footer (with the Proceed button) is present.
365
+ Modal: {
366
+ template:
367
+ '<div><slot name="header" /><slot /><slot name="footer" /></div>',
368
+ },
369
+ NumberStepper: true,
370
+ Textarea: true,
371
+ Icon: true,
372
+ },
373
+ },
374
+ });
375
+ };
376
+
377
+ const findProceedButton = (wrapper: ReturnType<typeof mount>) =>
378
+ wrapper
379
+ .findAllComponents(Button)
380
+ .find((b) => (b.props('attrs') as any)?.['data-test-tag'] === 'add-btn');
381
+
382
+ it('hides the Proceed button when checkout is blocked', () => {
383
+ expect(findProceedButton(mountCartModal(true))).toBeUndefined();
384
+ });
385
+
386
+ it('shows the Proceed button when checkout is not blocked', () => {
387
+ expect(findProceedButton(mountCartModal(false))).toBeDefined();
388
+ });
389
+ });