@designcrowd/library.brand-page 6.1.7 → 6.1.8-ast-header

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 (115) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-Dh3Paq4X.js → CreatedOnBrandCrowd-de-de-CmXTpyAA.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-DLWYV4xe.js → CreatedOnBrandCrowd-en-us-FUfcPskv.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-CcjzRbm_.js → CreatedOnBrandCrowd-es-es-BkRUovXn.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-UuUNeB_L.js → CreatedOnBrandCrowd-fr-ca-xsLZEhPK.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-UuUNeB_L.js → CreatedOnBrandCrowd-fr-fr-xsLZEhPK.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-ZVYZ5AnV.js → CreatedOnBrandCrowd-pt-br-BJ4DkilI.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-ZVYZ5AnV.js → CreatedOnBrandCrowd-pt-pt-BJ4DkilI.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-BPVyqOPL.js → CreatedOnDcom-de-de-D7f4iceH.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-BUSRV01e.js → CreatedOnDcom-en-us-BHbqZcVl.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-BuABJnvQ.js → CreatedOnDcom-es-es-BT_eFhYg.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-sjHceLRY.js → CreatedOnDcom-fr-ca-DCWCvJTS.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-D_qGXL3E.js → CreatedOnDcom-fr-fr-DpLretAn.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-D3Sb_YEi.js → CreatedOnDcom-pt-br-DmyQqot9.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-BI47Hv58.js → CreatedOnDcom-pt-pt-CRLWHfnY.js} +1 -1
  15. package/lib/src/brand-page/components/page-footer/PageFooter.mixin.d.ts +0 -1
  16. package/lib/src/brand-page/components/page-footer/PageFooter.mixin.js +1 -4
  17. package/lib/src/brand-page/utils/route.util.d.ts +12 -0
  18. package/lib/src/brand-page/utils/route.util.js +56 -0
  19. package/lib/src/brand-page-maker/components/index.d.ts +1 -0
  20. package/lib/src/brand-page-maker/components/index.js +1 -0
  21. package/lib/src/bundled-translations-BlUyLdcg.js +179 -0
  22. package/lib/src/bundled-translations.de-DE-DZm_F46B.js +179 -0
  23. package/lib/src/bundled-translations.es-ES-DiOMEwZz.js +179 -0
  24. package/lib/src/bundled-translations.fr-CA-BPBNxV69.js +179 -0
  25. package/lib/src/bundled-translations.fr-FR-bCNdWkJW.js +179 -0
  26. package/lib/src/bundled-translations.pt-BR-CC6okekB.js +179 -0
  27. package/lib/src/bundled-translations.pt-PT-CSgMSLFn.js +179 -0
  28. package/lib/src/bundles/bundled-translations.de-DE.json +5 -4
  29. package/lib/src/bundles/bundled-translations.es-ES.json +5 -4
  30. package/lib/src/bundles/bundled-translations.fr-CA.json +5 -4
  31. package/lib/src/bundles/bundled-translations.fr-FR.json +5 -4
  32. package/lib/src/bundles/bundled-translations.json +9 -4
  33. package/lib/src/bundles/bundled-translations.pt-BR.json +5 -4
  34. package/lib/src/bundles/bundled-translations.pt-PT.json +5 -4
  35. package/lib/src/css/library.brand-page-brandCrowd.min.css +159 -222
  36. package/lib/src/css/library.brand-page-brandPage.min.css +159 -222
  37. package/lib/src/css/library.brand-page-designCom.min.css +159 -222
  38. package/lib/src/css/library.brand-page-preview.min.css +159 -222
  39. package/lib/src/index.d.ts +1 -1
  40. package/lib/src/index.mjs +110 -109
  41. package/lib/src/{lib-BBX3eBci.js → lib-CZMytXbN.js} +19842 -19669
  42. package/lib/src/lib.js +1 -1
  43. package/lib/src/shared/components/live-preview-modal/live-preview-modal.utils.d.ts +4 -0
  44. package/lib/src/shared/components/live-preview-modal/live-preview-modal.utils.js +3 -0
  45. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +3 -1
  46. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.js +23 -11
  47. package/package.json +1 -1
  48. package/src/brand-page/components/button/Button.vue +41 -18
  49. package/src/brand-page/components/button/Button.vue.test.ts +211 -0
  50. package/src/brand-page/components/images-section/ImagesSection.test.ts +91 -0
  51. package/src/brand-page/components/images-section/ImagesSection.vue +29 -10
  52. package/src/brand-page/components/page-footer/PageFooter.mixin.ts +1 -4
  53. package/src/brand-page/components/page-footer/PageFooter.vue +0 -1
  54. package/src/brand-page/components/payment/Payment.vue +0 -3
  55. package/src/brand-page/components/payment/request-payment/RequestPayment.vue +0 -1
  56. package/src/brand-page/components/payment/support-me/SupportMe.vue +0 -3
  57. package/src/brand-page/components/save-contact/SaveContactWidget.vue +1 -0
  58. package/src/brand-page/components/save-contact/popup-card/PopupCard.vue +1 -0
  59. package/src/brand-page/components/shop/CartModal.test.ts +51 -0
  60. package/src/brand-page/components/shop/CartModal.vue +1 -0
  61. package/src/brand-page/components/shop/FeaturedDisplay.vue +8 -3
  62. package/src/brand-page/components/shop/Shop.vue +5 -5
  63. package/src/brand-page/components/shop/Shop.vue.test.ts +104 -2
  64. package/src/brand-page/components/social-links/SocialLinks.vue +1 -9
  65. package/src/brand-page/components/social-links/SocialLinks.vue.test.ts +28 -8
  66. package/src/brand-page/components/social-links/SocialLinksItems.vue +6 -2
  67. package/src/brand-page/components/testimonial-section/TestimonialSection.vue +2 -2
  68. package/src/brand-page/components/video/Video.vue +0 -3
  69. package/src/brand-page/utils/route.util.test.ts +116 -0
  70. package/src/brand-page/utils/route.util.ts +82 -0
  71. package/src/brand-page-maker/components/action-bar/ActionBarNavigation.test.ts +23 -0
  72. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.preview-gate.test.ts +68 -3
  73. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.vue +1 -0
  74. package/src/brand-page-maker/components/index.ts +1 -0
  75. package/src/brand-page-maker/components/preview-modal/PreviewModalWithState.test.ts +61 -41
  76. package/src/brand-page-maker/components/preview-modal/PreviewModalWithState.vue +37 -42
  77. package/src/bundles/bundled-translations.de-DE.json +5 -4
  78. package/src/bundles/bundled-translations.es-ES.json +5 -4
  79. package/src/bundles/bundled-translations.fr-CA.json +5 -4
  80. package/src/bundles/bundled-translations.fr-FR.json +5 -4
  81. package/src/bundles/bundled-translations.json +9 -4
  82. package/src/bundles/bundled-translations.pt-BR.json +5 -4
  83. package/src/bundles/bundled-translations.pt-PT.json +5 -4
  84. package/src/lib.ts +1 -0
  85. package/src/{brand-page-maker/components/preview-modal/PreviewModal.test.ts → shared/components/live-preview-modal/LivePreviewModal.test.ts} +96 -3
  86. package/src/{brand-page-maker/components/preview-modal/PreviewModal.vue → shared/components/live-preview-modal/LivePreviewModal.vue} +103 -89
  87. package/src/shared/components/live-preview-modal/i18n/preview-modal.de-DE.json +7 -0
  88. package/src/shared/components/live-preview-modal/i18n/preview-modal.es-ES.json +7 -0
  89. package/src/shared/components/live-preview-modal/i18n/preview-modal.fr-CA.json +7 -0
  90. package/src/shared/components/live-preview-modal/i18n/preview-modal.fr-FR.json +7 -0
  91. package/src/shared/components/live-preview-modal/i18n/preview-modal.json +7 -0
  92. package/src/shared/components/live-preview-modal/i18n/preview-modal.pt-BR.json +7 -0
  93. package/src/shared/components/live-preview-modal/i18n/preview-modal.pt-PT.json +7 -0
  94. package/src/shared/components/live-preview-modal/live-preview-modal.utils.ts +11 -0
  95. package/src/shared/ecommerce-settings/store/cart-store.test.ts +38 -0
  96. package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.vue +205 -0
  97. package/src/shared/page-settings/components/page-settings/PageSettings.header-settings-panel.test.ts +97 -0
  98. package/src/shared/page-settings/components/page-settings/PageSettings.mixin.ts +27 -12
  99. package/src/shared/page-settings/components/page-settings/PageSettings.vue +113 -92
  100. package/src/shared/page-settings/components/page-settings/i18n/page-settings.json +4 -0
  101. package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.vue +1 -57
  102. package/lib/src/bundled-translations-D42tE0D2.js +0 -179
  103. package/lib/src/bundled-translations.de-DE-D9aWGgFt.js +0 -179
  104. package/lib/src/bundled-translations.es-ES-lk4B0xQ7.js +0 -179
  105. package/lib/src/bundled-translations.fr-CA-DhOBNt6q.js +0 -179
  106. package/lib/src/bundled-translations.fr-FR-CnpafQeG.js +0 -179
  107. package/lib/src/bundled-translations.pt-BR-C4PnKrd9.js +0 -179
  108. package/lib/src/bundled-translations.pt-PT-CfKDVQC-.js +0 -179
  109. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.de-DE.json +0 -6
  110. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.es-ES.json +0 -6
  111. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.fr-CA.json +0 -6
  112. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.fr-FR.json +0 -6
  113. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.json +0 -6
  114. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.pt-BR.json +0 -6
  115. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.pt-PT.json +0 -6
@@ -31,12 +31,9 @@
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. -->
36
34
  <li
37
35
  v-for="paymentOption in paymentOptions"
38
36
  :key="`payment-option-${paymentOption.id}`"
39
- data-preview-interactive
40
37
  class="payment-option tw-flex tw-flex-row tw-flex-wrap tw-justify-center tw-text-sm tw-my-0"
41
38
  :class="{
42
39
  'payment-option--selected': selectedPaymentOptionIds.some(
@@ -6,6 +6,7 @@
6
6
  <div
7
7
  :style="data.buttonPositionStyle"
8
8
  class="tw-w-full tw-max-w-md tw-px-4 tw-pointer-events-auto tw-z-50 tw-flex tw-justify-center"
9
+ data-preview-block
9
10
  >
10
11
  <Button
11
12
  :view-model="getButtonViewModel"
@@ -40,6 +40,7 @@
40
40
  'btn-bc tw-uppercase tw-bg-primary-500': !context.isDesignCom,
41
41
  'btn-dcom tw-rounded-md tw-font-bold': context.isDesignCom,
42
42
  }"
43
+ data-preview-block
43
44
  @click="onStartFree"
44
45
  >
45
46
  {{ saveContactWidgetTr('startForFree') }}
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createPinia, setActivePinia } from 'pinia';
3
+ import { mount } from '@vue/test-utils';
4
+ import CartModal from './CartModal.vue';
5
+ import { useCartStore } from '@shared/ecommerce-settings/store/cart-settings-store';
6
+ import { useShopConfigStore } from '@shared/ecommerce-settings/store/shop-settings-store';
7
+
8
+ // Preview-mode checkout is blocked in two independent places today: Shop.vue's own
9
+ // isPreviewMode(context) guard around mounting PaymentModal, and this attribute, which
10
+ // PreviewSurface.vue intercepts at the frame level before the click ever reaches Vue.
11
+ // Without it, the checkout button in a customer preview was live and clickable, relying
12
+ // solely on the JS guard elsewhere to make it a no-op.
13
+ describe('CartModal — preview-mode checkout guard', () => {
14
+ const seedCartWithOneItem = () => {
15
+ useShopConfigStore().products = [
16
+ {
17
+ id: 'product-1',
18
+ name: 'Mug',
19
+ description: '',
20
+ imageUrl: '',
21
+ defaultPrice: 10,
22
+ skus: [{ skuKey: 'sku-1' } as any],
23
+ variant: [],
24
+ hasVariants: false,
25
+ enableNotes: false,
26
+ },
27
+ ];
28
+ useCartStore().cartItems = [
29
+ {
30
+ skuKey: 'sku-1',
31
+ productSkuId: 'sku-1',
32
+ productId: 'product-1',
33
+ quantity: 1,
34
+ unitPrice: 10,
35
+ },
36
+ ];
37
+ };
38
+
39
+ it('marks the checkout button as a blocked side-effect for the preview frame', () => {
40
+ setActivePinia(createPinia());
41
+ seedCartWithOneItem();
42
+
43
+ const wrapper = mount(CartModal, {
44
+ props: { buttonStyle: {} as any },
45
+ });
46
+
47
+ const checkoutButton = wrapper.get('[data-test-tag="add-btn"]');
48
+
49
+ expect(checkoutButton.attributes()).toHaveProperty('data-preview-block');
50
+ });
51
+ });
@@ -203,6 +203,7 @@
203
203
  variant="primary"
204
204
  :attrs="{
205
205
  'data-test-tag': 'add-btn',
206
+ 'data-preview-block': true,
206
207
  }"
207
208
  @click="onShowPaymentModal"
208
209
  />
@@ -118,11 +118,16 @@ const onShowProductDetail = (product) => {
118
118
  min-width: calc(theme(width.1/6) - theme(spacing.2));
119
119
  }
120
120
 
121
+ /* Mobile: flex column so the container's `tw-gap-2` is the single gap source, the
122
+ same in the editor and on the rendered site. `.device--mobile` = maker frame; the
123
+ media query = published site + live preview, which have no such class. */
121
124
  .device--mobile .payment-options {
122
- @apply md:tw-block;
125
+ @apply md:tw-flex md:tw-flex-col;
123
126
  }
124
127
 
125
- .device--mobile .payment-option {
126
- @apply tw-my-2;
128
+ @media (max-width: 767px) {
129
+ .payment-options {
130
+ @apply tw-flex tw-flex-col;
131
+ }
127
132
  }
128
133
  </style>
@@ -63,14 +63,9 @@
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. -->
70
66
  <button
71
67
  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"
72
68
  :style="buttonStyle"
73
- data-preview-interactive
74
69
  @click="onShowCartModal"
75
70
  >
76
71
  <TextWrapper :text-style="buttonTextStyle" :text="viewCartText" />
@@ -232,6 +227,11 @@ const onShowSuccess = () => {
232
227
  };
233
228
 
234
229
  const onShowPaymentModal = () => {
230
+ // Safety-critical: mounting PaymentModal creates a real cart + Stripe intent.
231
+ if (isPreviewMode(props.context)) {
232
+ return;
233
+ }
234
+
235
235
  data.isCartModalVisible = false;
236
236
  data.isPaymentModalVisible = true;
237
237
  };
@@ -1,7 +1,8 @@
1
1
  import { vi } from 'vitest';
2
- import { shallowMount } from '@vue/test-utils';
2
+ import { flushPromises, mount, shallowMount } from '@vue/test-utils';
3
3
  import Shop from './Shop.vue';
4
4
  import PaymentModal from './PaymentModal.vue';
5
+ import CartModal from './CartModal.vue';
5
6
  import { createPinia, setActivePinia } from 'pinia';
6
7
  import {
7
8
  BRAND_PAGE_TYPES,
@@ -10,6 +11,9 @@ import {
10
11
  type BrandPageContext,
11
12
  } from '@brand-page/models';
12
13
  import type { ShopViewModel } from '@brand-page/view-models';
14
+ import brandPageClient from '../../clients/brand-page.client';
15
+ import { useShopConfigStore } from '@shared/ecommerce-settings/store/shop-settings-store';
16
+ import { useCartStore } from '@shared/ecommerce-settings/store/cart-settings-store';
13
17
 
14
18
  vi.mock('../../clients/brand-page.client', () => ({
15
19
  default: {
@@ -22,7 +26,9 @@ vi.mock('@brand-page/utils/raygun.util', () => ({
22
26
  default: { safeSendRaygunError: vi.fn() },
23
27
  }));
24
28
 
25
- const createContext = (): BrandPageContext => ({
29
+ const createContext = (
30
+ overrides: Partial<BrandPageContext> = {},
31
+ ): BrandPageContext => ({
26
32
  brandPageSlug: 'test',
27
33
  brandPageToken: '',
28
34
  isMobile: false,
@@ -45,6 +51,7 @@ const createContext = (): BrandPageContext => ({
45
51
  isCapturingPreview: false,
46
52
  recaptchaSiteKey: undefined,
47
53
  nav: { navItems: [] },
54
+ ...overrides,
48
55
  });
49
56
 
50
57
  const PRODUCT = {
@@ -207,3 +214,98 @@ describe('PaymentModal', () => {
207
214
  expect((flatCss as any).style).toBeUndefined();
208
215
  });
209
216
  });
217
+
218
+ // BP-5961 SAFETY-CRITICAL: in any preview the shop is browsable but checkout must be
219
+ // hard-blocked. `/api/cart/createCart` and `/api/stripe/createPaymentIntent` are
220
+ // UNGUARDED on the backend and the preview carries a real Stripe key, so this guard
221
+ // in `Shop.vue.onShowPaymentModal` is the SOLE thing preventing a real charge.
222
+ describe('Shop checkout hard-block (BP-5961)', () => {
223
+ beforeEach(() => {
224
+ setActivePinia(createPinia());
225
+ vi.clearAllMocks();
226
+ });
227
+
228
+ // PaymentModal stays REAL (its onMounted → createBrandPageCart is what we assert on);
229
+ // CartModal stays REAL so we can emit `on-show-payment` from it; everything heavy is
230
+ // stubbed. Stubbing CartModal's `Modal` to `true` also drops its slot content, so we
231
+ // don't need to stub NumberStepper/Icon/etc.
232
+ const HARD_BLOCK_STUBS = {
233
+ HelloBar: true,
234
+ ButtonStyleWrapper: true,
235
+ TextWrapper: true,
236
+ TextDisplay: true,
237
+ ThumbnailDisplay: true,
238
+ FeaturedDisplay: true,
239
+ ProductDetailModal: true,
240
+ Modal: true,
241
+ Loader: true,
242
+ Button: true,
243
+ RequestPaymentStripe: true,
244
+ };
245
+
246
+ const seedShopWithCartItem = () => {
247
+ const shopStore = useShopConfigStore();
248
+
249
+ shopStore.setPaymentConfig({ id: 'pc-1' } as any);
250
+ shopStore.addProduct({
251
+ id: '1',
252
+ name: 'Test',
253
+ hasVariants: false,
254
+ skus: [{ id: 'sku-1', skuKey: 'sku-1', price: 10, variant: [] }],
255
+ } as any);
256
+
257
+ useCartStore().cartItems.push({
258
+ productId: '1',
259
+ productSkuId: 'sku-1',
260
+ skuKey: 'sku-1',
261
+ unitPrice: 10,
262
+ quantity: 1,
263
+ });
264
+ };
265
+
266
+ const mountShopForCheckout = (context: BrandPageContext) => {
267
+ seedShopWithCartItem();
268
+
269
+ return mount(Shop, {
270
+ props: {
271
+ context,
272
+ viewModel: createViewModel({ products: [PRODUCT] }),
273
+ contentIndex: 0,
274
+ },
275
+ global: { stubs: HARD_BLOCK_STUBS },
276
+ });
277
+ };
278
+
279
+ const triggerCheckout = async (wrapper: ReturnType<typeof mount>) => {
280
+ // The cart's Proceed button emits this; going through the emit exercises the real
281
+ // `onShowPaymentModal` guard rather than reaching into component internals.
282
+ wrapper.findComponent(CartModal).vm.$emit('on-show-payment');
283
+ await flushPromises();
284
+ };
285
+
286
+ it.each([
287
+ ['customer live preview', { isPreview: false, isLivePreview: true }],
288
+ ['editor preview', { isPreview: true, isLivePreview: false }],
289
+ ])(
290
+ 'no-ops checkout — PaymentModal never mounts and createBrandPageCart is never called (%s)',
291
+ async (_label, flags) => {
292
+ const wrapper = mountShopForCheckout(createContext(flags));
293
+
294
+ await triggerCheckout(wrapper);
295
+
296
+ expect(wrapper.findComponent(PaymentModal).exists()).toBe(false);
297
+ expect(brandPageClient.createBrandPageCart).not.toHaveBeenCalled();
298
+ },
299
+ );
300
+
301
+ it('published site is UNCHANGED — PaymentModal mounts and createBrandPageCart is called', async () => {
302
+ const wrapper = mountShopForCheckout(
303
+ createContext({ isPreview: false, isLivePreview: false }),
304
+ );
305
+
306
+ await triggerCheckout(wrapper);
307
+
308
+ expect(wrapper.findComponent(PaymentModal).exists()).toBe(true);
309
+ expect(brandPageClient.createBrandPageCart).toHaveBeenCalledTimes(1);
310
+ });
311
+ });
@@ -17,7 +17,6 @@
17
17
  />
18
18
  <SocialLinksItems
19
19
  :id="id"
20
- :is-preview="isPreview"
21
20
  :container-style="iconContainerStyle"
22
21
  :icon-style="iconStyle"
23
22
  :links="links"
@@ -33,10 +32,7 @@ import TextWrapper from '../text-wrapper/TextWrapper.vue';
33
32
  import type { SocialLinksViewModel } from 'src/brand-page/view-models';
34
33
  import { SOCIAL_LINKS_ICON_DISPLAY_STYLES } from '@brand-page/models';
35
34
  import type { BrandPageContext, LinksItem } from '@brand-page/models';
36
- import {
37
- contentPaddingStyle as toContentPaddingStyle,
38
- isPreviewMode,
39
- } from '@brand-page/utils';
35
+ import { contentPaddingStyle as toContentPaddingStyle } from '@brand-page/utils';
40
36
  import { computed } from 'vue';
41
37
 
42
38
  const props = withDefaults(
@@ -89,10 +85,6 @@ const id = computed((): string => {
89
85
  return props.viewModel.id;
90
86
  });
91
87
 
92
- const isPreview = computed((): boolean => {
93
- return isPreviewMode(props.context);
94
- });
95
-
96
88
  const links = computed((): LinksItem[] => {
97
89
  return props.viewModel.links;
98
90
  });
@@ -115,7 +115,6 @@ describe('Social Links', () => {
115
115
  id: faker.datatype.uuid(),
116
116
  containerStyle: {},
117
117
  iconStyle: {},
118
- isPreview: false,
119
118
  links: [
120
119
  {
121
120
  id: 'social-link-facebook',
@@ -137,16 +136,13 @@ describe('Social Links', () => {
137
136
  expect(anchor.attributes()['href']).toBe(undefined);
138
137
  });
139
138
 
140
- test('href attribute should match url', () => {
141
- const context = createTestBrandPageContext();
142
-
143
- const wrapper = shallowMount(SocialLinksItems, {
139
+ const mountWithContext = (context: BrandPageContext) =>
140
+ shallowMount(SocialLinksItems, {
144
141
  propsData: {
145
142
  context,
146
143
  id: faker.datatype.uuid(),
147
144
  containerStyle: {},
148
145
  iconStyle: {},
149
- isPreview: false,
150
146
  links: [
151
147
  {
152
148
  id: 'social-link-facebook',
@@ -163,8 +159,32 @@ describe('Social Links', () => {
163
159
  },
164
160
  });
165
161
 
166
- const anchor = wrapper.find('.c-social-links__links-container li a');
162
+ const hrefOf = (wrapper: ReturnType<typeof shallowMount>) =>
163
+ wrapper.find('.c-social-links__links-container li a').attributes()['href'];
164
+
165
+ // Published site: no preview flags — the real href renders. (unchanged)
166
+ test('href attribute should match url', () => {
167
+ expect(hrefOf(mountWithContext(createTestBrandPageContext()))).toBe(
168
+ 'https://www.fb.com',
169
+ );
170
+ });
171
+
172
+ // Editor/maker canvas: href stripped so a designer can't click out. (unchanged)
173
+ test('editor preview (context.isPreview) strips the href', () => {
174
+ const context = { ...createTestBrandPageContext(), isPreview: true };
175
+
176
+ expect(hrefOf(mountWithContext(context))).toBe(undefined);
177
+ });
178
+
179
+ // Customer live preview: href now RENDERS (BP-5959) so internal links resolve
180
+ // in-frame; external ones are blocked by the web-renderer preview gate instead.
181
+ test('live preview (context.isLivePreview) renders the href', () => {
182
+ const context = {
183
+ ...createTestBrandPageContext(),
184
+ isPreview: false,
185
+ isLivePreview: true,
186
+ };
167
187
 
168
- expect(anchor.attributes()['href']).toBe('https://www.fb.com');
188
+ expect(hrefOf(mountWithContext(context))).toBe('https://www.fb.com');
169
189
  });
170
190
  });
@@ -45,7 +45,6 @@ interface Props {
45
45
  id: string;
46
46
  containerStyle: BrandPageCssStyle;
47
47
  iconStyle: BrandPageCssStyle;
48
- isPreview: boolean;
49
48
  links: LinksItem[];
50
49
  context: BrandPageContext;
51
50
  contentIndex: number | undefined;
@@ -62,7 +61,12 @@ const isUrl = computed(() => {
62
61
  });
63
62
 
64
63
  const getValidInput = (value: string): string | undefined => {
65
- if (props.isPreview || !value) return undefined;
64
+ // Strip the href in the EDITOR preview only (`context.isPreview`), so a
65
+ // designer can't click out of the canvas. In the customer LIVE preview
66
+ // (`context.isLivePreview`) the href stays so internal links resolve in-frame
67
+ // (see web-renderer resolveInternalPreviewPaths); external ones get blocked by
68
+ // the preview gate anyway. (BP-5959)
69
+ if (props.context.isPreview || !value) return undefined;
66
70
 
67
71
  if (isEmail(value)) {
68
72
  return `mailto:${value}`;
@@ -33,7 +33,7 @@
33
33
  :hide-bullets-on-mobile="false"
34
34
  >
35
35
  <template #arrow-left>
36
- <div id="button-prev" data-preview-interactive>
36
+ <div id="button-prev">
37
37
  <Icon
38
38
  classes="sm:tw-hidden tw--mb-1 tw-mr-2 tw-cursor-pointer"
39
39
  name="chevron-left"
@@ -120,7 +120,7 @@
120
120
  </div>
121
121
  </template>
122
122
  <template #arrow-right>
123
- <div id="button-next" data-preview-interactive>
123
+ <div id="button-next">
124
124
  <Icon
125
125
  classes="sm:tw-hidden tw--mb-1 tw-ml-2 tw-cursor-pointer"
126
126
  name="chevron-right"
@@ -8,10 +8,7 @@
8
8
  :class="{ 'tw-pb-4': isCollapsed, 'tw-pb-0.5': !isCollapsed }"
9
9
  :style="containerStyle"
10
10
  >
11
- <!-- Video rolls its own header, so the `collapsible` allow-list entry can't
12
- reach it. Expanding is viewing, not acting. -->
13
11
  <div
14
- data-preview-interactive
15
12
  class="tw-relative tw-text-center tw-w-full hover:tw-cursor-pointer"
16
13
  :class="{ 'tw-mb-4': !isCollapsed }"
17
14
  @click="onHeaderToggleClick"
@@ -0,0 +1,116 @@
1
+ import { findNavItemByOwnSiteUrl } from './route.util';
2
+ import type { NavItemViewModel } from '@brand-page/view-models';
3
+
4
+ const navItems = [
5
+ { pageId: '1', relativePath: '', routeName: 'home', isRoot: true },
6
+ {
7
+ pageId: '2',
8
+ relativePath: 'abb',
9
+ routeName: 'abb',
10
+ nestedNavItems: [
11
+ { pageId: '4', relativePath: 'abb/nested', routeName: 'nested' },
12
+ ],
13
+ },
14
+ { pageId: '3', relativePath: 'about-us', routeName: 'about-us' },
15
+ ] as unknown as NavItemViewModel[];
16
+
17
+ const resolve = (url: string | undefined, overrides = {}) =>
18
+ findNavItemByOwnSiteUrl({
19
+ url,
20
+ navItems,
21
+ brandPageType: 'brandContact',
22
+ brandPageSlug: 'cs',
23
+ ...overrides,
24
+ });
25
+
26
+ describe('findNavItemByOwnSiteUrl', () => {
27
+ describe('on the product domain (multi-tenant, so the slug decides)', () => {
28
+ it('resolves a typed URL for one of our own pages', () => {
29
+ expect(resolve('https://uat2.brand.contact/cs/abb')?.routeName).toBe(
30
+ 'abb',
31
+ );
32
+ });
33
+
34
+ it('resolves the slug on its own to the home page', () => {
35
+ expect(resolve('https://uat2.brand.contact/cs')?.routeName).toBe('home');
36
+ });
37
+
38
+ it('matches our slug case-insensitively', () => {
39
+ expect(resolve('https://uat2.brand.contact/CS/abb')?.routeName).toBe(
40
+ 'abb',
41
+ );
42
+ });
43
+
44
+ // The whole point of the slug gate: another site lives on the same host.
45
+ it('does NOT resolve another site on the same host', () => {
46
+ expect(
47
+ resolve('https://uat2.brand.contact/othercard/abb'),
48
+ ).toBeUndefined();
49
+ });
50
+
51
+ it('does NOT resolve a look-alike host', () => {
52
+ expect(resolve('https://evilbrand.contact/cs/abb')).toBeUndefined();
53
+ });
54
+
55
+ it('does NOT resolve the product domain of a different website type', () => {
56
+ expect(resolve('https://uat2.brand.site/cs/abb')).toBeUndefined();
57
+ });
58
+
59
+ it('does NOT resolve a page this site does not have', () => {
60
+ expect(resolve('https://uat2.brand.contact/cs/nope')).toBeUndefined();
61
+ });
62
+
63
+ it('resolves a nested page by its parent/child path', () => {
64
+ expect(
65
+ resolve('https://uat2.brand.contact/cs/abb/nested')?.routeName,
66
+ ).toBe('nested');
67
+ });
68
+
69
+ // A route location carries neither, so converting would silently drop them.
70
+ it.each([
71
+ ['a query string', 'https://uat2.brand.contact/cs/abb?promo=x'],
72
+ ['a hash', 'https://uat2.brand.contact/cs/abb#pricing'],
73
+ ])('leaves a URL with %s to the plain anchor', (_label, url) => {
74
+ expect(resolve(url)).toBeUndefined();
75
+ });
76
+
77
+ it('does NOT resolve when the slug is only whitespace', () => {
78
+ expect(
79
+ resolve('https://uat2.brand.contact/cs/abb', { brandPageSlug: ' ' }),
80
+ ).toBeUndefined();
81
+ });
82
+ });
83
+
84
+ describe('on the site custom domain (whole-host, no slug segment)', () => {
85
+ const onCustomDomain = (url: string) =>
86
+ resolve(url, { customDomain: 'mycard.com' });
87
+
88
+ it('resolves a path without a slug segment', () => {
89
+ expect(onCustomDomain('https://mycard.com/about-us')?.routeName).toBe(
90
+ 'about-us',
91
+ );
92
+ });
93
+
94
+ it('resolves the bare domain to home', () => {
95
+ expect(onCustomDomain('https://mycard.com')?.routeName).toBe('home');
96
+ });
97
+
98
+ it('ignores a www prefix either way', () => {
99
+ expect(onCustomDomain('https://www.mycard.com/abb')?.routeName).toBe(
100
+ 'abb',
101
+ );
102
+ });
103
+ });
104
+
105
+ describe('leaves everything else alone', () => {
106
+ it.each([
107
+ ['an external site', 'https://example.com/abb'],
108
+ ['a mailto', 'mailto:hi@mycard.com'],
109
+ ['a tel', 'tel:+61400000000'],
110
+ ['an unparseable href', '/abb'],
111
+ ['nothing', undefined],
112
+ ])('does not resolve %s', (_label, url) => {
113
+ expect(resolve(url as string | undefined)).toBeUndefined();
114
+ });
115
+ });
116
+ });
@@ -1,4 +1,5 @@
1
1
  import * as constants from '../constants';
2
+ import type { BrandPageType } from '../models/brand-page-context.model';
2
3
  import type { NavItemViewModel } from '../view-models';
3
4
 
4
5
  export const buildRelativePathFromParams = (params: {
@@ -83,6 +84,87 @@ export const findNavItemByPageId = (
83
84
  return undefined;
84
85
  };
85
86
 
87
+ // Keep in sync with web-renderer's `constants/host.constant` + `getProductHostForType`.
88
+ const PRODUCT_HOST_BY_BRAND_PAGE_TYPE: Record<BrandPageType, string> = {
89
+ default: 'brand.page',
90
+ brandSite: 'brand.site',
91
+ brandContact: 'brand.contact',
92
+ };
93
+
94
+ const bareHost = (hostname: string): string =>
95
+ hostname.replace(/^www\./i, '').toLowerCase();
96
+
97
+ // A typed URL can still address a page of THIS site: whole-host on the site's own
98
+ // custom domain, or under its slug on the multi-tenant product domain.
99
+ export const findNavItemByOwnSiteUrl = ({
100
+ url,
101
+ navItems,
102
+ brandPageType,
103
+ brandPageSlug,
104
+ customDomain,
105
+ }: {
106
+ url: string | undefined;
107
+ navItems: NavItemViewModel[];
108
+ brandPageType: BrandPageType;
109
+ brandPageSlug: string;
110
+ customDomain?: string;
111
+ }): NavItemViewModel | undefined => {
112
+ if (!url) {
113
+ return undefined;
114
+ }
115
+
116
+ let parsed: URL;
117
+
118
+ try {
119
+ parsed = new URL(url);
120
+ } catch {
121
+ return undefined;
122
+ }
123
+
124
+ // A route location carries neither, so converting would silently drop them — leave
125
+ // the author's exact URL to the plain anchor instead.
126
+ if (parsed.search || parsed.hash) {
127
+ return undefined;
128
+ }
129
+
130
+ const host = bareHost(parsed.hostname);
131
+ const segments = parsed.pathname.split('/').filter(Boolean);
132
+ const productHost = PRODUCT_HOST_BY_BRAND_PAGE_TYPE[brandPageType];
133
+ // Required by type and always set by in-repo callers, but this now runs on the
134
+ // published-site render path (Button.vue) — degrade gracefully rather than throw
135
+ // if a cross-repo caller ever omits it.
136
+ const slug = brandPageSlug?.trim() ?? '';
137
+
138
+ let pagePath: string | undefined;
139
+
140
+ if (customDomain && host === bareHost(customDomain)) {
141
+ pagePath = segments.join('/');
142
+ } else if (
143
+ productHost &&
144
+ (host === productHost || host.endsWith(`.${productHost}`)) &&
145
+ slug &&
146
+ segments[0]?.toLowerCase() === slug.toLowerCase()
147
+ ) {
148
+ pagePath = segments.slice(1).join('/');
149
+ }
150
+
151
+ if (pagePath === undefined) {
152
+ return undefined;
153
+ }
154
+
155
+ return pagePath
156
+ ? findNavItemByRelativePath(navItems, pagePath)
157
+ : navItems.find((item) => item.isRoot);
158
+ };
159
+
160
+ // Shared by every caller that resolves a nav item then navigates to it
161
+ // (Button.vue, ImagesSection.vue) — one place to turn "which page" into "where to
162
+ // route", so a resolved nav item is never hand-unpacked into a route object twice.
163
+ export const toRouteLocation = (navItem?: NavItemViewModel) => ({
164
+ name: navItem?.routeName || '',
165
+ params: navItem?.routeParams || {},
166
+ });
167
+
86
168
  export const findNavItemByRelativePath = (
87
169
  navItems: NavItemViewModel[],
88
170
  relativePath: string,
@@ -7,6 +7,7 @@ import { getAbTestResults } from '@brand-page/models/brand-page-context.model';
7
7
 
8
8
  // Must match `CampaignNames.LivePreview*` in bc-net. The two experiments share
9
9
  // one set of variation values — they're told apart by campaign key, not value.
10
+ const PREPURCHASE = 'bc-bp-5873-live-preview-prepurchase';
10
11
  const POSTPURCHASE = 'bc-bp-5873-live-preview-postpurchase';
11
12
  const VARIATION_1 = 'bp-5873-variation-1';
12
13
  const CONTROL = 'bp-5873-control';
@@ -48,6 +49,28 @@ describe('ActionBarNavigation — BP-5873 preview AB test', () => {
48
49
  expect(hasPreviewButton(mountBar())).toBe(false);
49
50
  });
50
51
 
52
+ test('pre-purchase: shows Preview on variation-1, hides it on control', () => {
53
+ containerStore.isPrePurchase = true;
54
+
55
+ setAbTest(containerStore, PREPURCHASE, VARIATION_1);
56
+ expect(hasPreviewButton(mountBar())).toBe(true);
57
+
58
+ setAbTest(containerStore, PREPURCHASE, CONTROL);
59
+ expect(hasPreviewButton(mountBar())).toBe(false);
60
+ });
61
+
62
+ // Two separate experiments, so a value on one side must never satisfy the other —
63
+ // otherwise a post-purchase variation would leak the button into pre-purchase.
64
+ test('the two experiments never read each other', () => {
65
+ containerStore.isPrePurchase = true;
66
+ setAbTest(containerStore, POSTPURCHASE, VARIATION_1);
67
+ expect(hasPreviewButton(mountBar())).toBe(false);
68
+
69
+ containerStore.isPrePurchase = false;
70
+ setAbTest(containerStore, PREPURCHASE, VARIATION_1);
71
+ expect(hasPreviewButton(mountBar())).toBe(false);
72
+ });
73
+
51
74
  // The mobile nav row is equal columns: 3 with Save, 2 once the site is published and
52
75
  // Save drops out. Preview must be a real column like the rest — while it was content
53
76
  // width, Publish ate the whole remaining row on a published site.