@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.
- package/lib/src/{CreatedOnBrandCrowd-de-de-Dh3Paq4X.js → CreatedOnBrandCrowd-de-de-CmXTpyAA.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-DLWYV4xe.js → CreatedOnBrandCrowd-en-us-FUfcPskv.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-CcjzRbm_.js → CreatedOnBrandCrowd-es-es-BkRUovXn.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-UuUNeB_L.js → CreatedOnBrandCrowd-fr-ca-xsLZEhPK.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-UuUNeB_L.js → CreatedOnBrandCrowd-fr-fr-xsLZEhPK.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-ZVYZ5AnV.js → CreatedOnBrandCrowd-pt-br-BJ4DkilI.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-ZVYZ5AnV.js → CreatedOnBrandCrowd-pt-pt-BJ4DkilI.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-BPVyqOPL.js → CreatedOnDcom-de-de-D7f4iceH.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-BUSRV01e.js → CreatedOnDcom-en-us-BHbqZcVl.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-BuABJnvQ.js → CreatedOnDcom-es-es-BT_eFhYg.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-sjHceLRY.js → CreatedOnDcom-fr-ca-DCWCvJTS.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-D_qGXL3E.js → CreatedOnDcom-fr-fr-DpLretAn.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-D3Sb_YEi.js → CreatedOnDcom-pt-br-DmyQqot9.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-BI47Hv58.js → CreatedOnDcom-pt-pt-CRLWHfnY.js} +1 -1
- package/lib/src/brand-page/components/page-footer/PageFooter.mixin.d.ts +0 -1
- package/lib/src/brand-page/components/page-footer/PageFooter.mixin.js +1 -4
- package/lib/src/brand-page/utils/route.util.d.ts +12 -0
- package/lib/src/brand-page/utils/route.util.js +56 -0
- package/lib/src/brand-page-maker/components/index.d.ts +1 -0
- package/lib/src/brand-page-maker/components/index.js +1 -0
- package/lib/src/bundled-translations-BlUyLdcg.js +179 -0
- package/lib/src/bundled-translations.de-DE-DZm_F46B.js +179 -0
- package/lib/src/bundled-translations.es-ES-DiOMEwZz.js +179 -0
- package/lib/src/bundled-translations.fr-CA-BPBNxV69.js +179 -0
- package/lib/src/bundled-translations.fr-FR-bCNdWkJW.js +179 -0
- package/lib/src/bundled-translations.pt-BR-CC6okekB.js +179 -0
- package/lib/src/bundled-translations.pt-PT-CSgMSLFn.js +179 -0
- package/lib/src/bundles/bundled-translations.de-DE.json +5 -4
- package/lib/src/bundles/bundled-translations.es-ES.json +5 -4
- package/lib/src/bundles/bundled-translations.fr-CA.json +5 -4
- package/lib/src/bundles/bundled-translations.fr-FR.json +5 -4
- package/lib/src/bundles/bundled-translations.json +9 -4
- package/lib/src/bundles/bundled-translations.pt-BR.json +5 -4
- package/lib/src/bundles/bundled-translations.pt-PT.json +5 -4
- package/lib/src/css/library.brand-page-brandCrowd.min.css +159 -222
- package/lib/src/css/library.brand-page-brandPage.min.css +159 -222
- package/lib/src/css/library.brand-page-designCom.min.css +159 -222
- package/lib/src/css/library.brand-page-preview.min.css +159 -222
- package/lib/src/index.d.ts +1 -1
- package/lib/src/index.mjs +110 -109
- package/lib/src/{lib-BBX3eBci.js → lib-CZMytXbN.js} +19842 -19669
- package/lib/src/lib.js +1 -1
- package/lib/src/shared/components/live-preview-modal/live-preview-modal.utils.d.ts +4 -0
- package/lib/src/shared/components/live-preview-modal/live-preview-modal.utils.js +3 -0
- package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +3 -1
- package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.js +23 -11
- package/package.json +1 -1
- package/src/brand-page/components/button/Button.vue +41 -18
- package/src/brand-page/components/button/Button.vue.test.ts +211 -0
- package/src/brand-page/components/images-section/ImagesSection.test.ts +91 -0
- package/src/brand-page/components/images-section/ImagesSection.vue +29 -10
- package/src/brand-page/components/page-footer/PageFooter.mixin.ts +1 -4
- package/src/brand-page/components/page-footer/PageFooter.vue +0 -1
- package/src/brand-page/components/payment/Payment.vue +0 -3
- package/src/brand-page/components/payment/request-payment/RequestPayment.vue +0 -1
- package/src/brand-page/components/payment/support-me/SupportMe.vue +0 -3
- package/src/brand-page/components/save-contact/SaveContactWidget.vue +1 -0
- package/src/brand-page/components/save-contact/popup-card/PopupCard.vue +1 -0
- package/src/brand-page/components/shop/CartModal.test.ts +51 -0
- package/src/brand-page/components/shop/CartModal.vue +1 -0
- package/src/brand-page/components/shop/FeaturedDisplay.vue +8 -3
- package/src/brand-page/components/shop/Shop.vue +5 -5
- package/src/brand-page/components/shop/Shop.vue.test.ts +104 -2
- package/src/brand-page/components/social-links/SocialLinks.vue +1 -9
- package/src/brand-page/components/social-links/SocialLinks.vue.test.ts +28 -8
- package/src/brand-page/components/social-links/SocialLinksItems.vue +6 -2
- package/src/brand-page/components/testimonial-section/TestimonialSection.vue +2 -2
- package/src/brand-page/components/video/Video.vue +0 -3
- package/src/brand-page/utils/route.util.test.ts +116 -0
- package/src/brand-page/utils/route.util.ts +82 -0
- package/src/brand-page-maker/components/action-bar/ActionBarNavigation.test.ts +23 -0
- package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.preview-gate.test.ts +68 -3
- package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.vue +1 -0
- package/src/brand-page-maker/components/index.ts +1 -0
- package/src/brand-page-maker/components/preview-modal/PreviewModalWithState.test.ts +61 -41
- package/src/brand-page-maker/components/preview-modal/PreviewModalWithState.vue +37 -42
- package/src/bundles/bundled-translations.de-DE.json +5 -4
- package/src/bundles/bundled-translations.es-ES.json +5 -4
- package/src/bundles/bundled-translations.fr-CA.json +5 -4
- package/src/bundles/bundled-translations.fr-FR.json +5 -4
- package/src/bundles/bundled-translations.json +9 -4
- package/src/bundles/bundled-translations.pt-BR.json +5 -4
- package/src/bundles/bundled-translations.pt-PT.json +5 -4
- package/src/lib.ts +1 -0
- package/src/{brand-page-maker/components/preview-modal/PreviewModal.test.ts → shared/components/live-preview-modal/LivePreviewModal.test.ts} +96 -3
- package/src/{brand-page-maker/components/preview-modal/PreviewModal.vue → shared/components/live-preview-modal/LivePreviewModal.vue} +103 -89
- package/src/shared/components/live-preview-modal/i18n/preview-modal.de-DE.json +7 -0
- package/src/shared/components/live-preview-modal/i18n/preview-modal.es-ES.json +7 -0
- package/src/shared/components/live-preview-modal/i18n/preview-modal.fr-CA.json +7 -0
- package/src/shared/components/live-preview-modal/i18n/preview-modal.fr-FR.json +7 -0
- package/src/shared/components/live-preview-modal/i18n/preview-modal.json +7 -0
- package/src/shared/components/live-preview-modal/i18n/preview-modal.pt-BR.json +7 -0
- package/src/shared/components/live-preview-modal/i18n/preview-modal.pt-PT.json +7 -0
- package/src/shared/components/live-preview-modal/live-preview-modal.utils.ts +11 -0
- package/src/shared/ecommerce-settings/store/cart-store.test.ts +38 -0
- package/src/shared/page-settings/components/header-settings-panel/HeaderSettingsPanel.vue +205 -0
- package/src/shared/page-settings/components/page-settings/PageSettings.header-settings-panel.test.ts +97 -0
- package/src/shared/page-settings/components/page-settings/PageSettings.mixin.ts +27 -12
- package/src/shared/page-settings/components/page-settings/PageSettings.vue +113 -92
- package/src/shared/page-settings/components/page-settings/i18n/page-settings.json +4 -0
- package/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.vue +1 -57
- package/lib/src/bundled-translations-D42tE0D2.js +0 -179
- package/lib/src/bundled-translations.de-DE-D9aWGgFt.js +0 -179
- package/lib/src/bundled-translations.es-ES-lk4B0xQ7.js +0 -179
- package/lib/src/bundled-translations.fr-CA-DhOBNt6q.js +0 -179
- package/lib/src/bundled-translations.fr-FR-CnpafQeG.js +0 -179
- package/lib/src/bundled-translations.pt-BR-C4PnKrd9.js +0 -179
- package/lib/src/bundled-translations.pt-PT-CfKDVQC-.js +0 -179
- package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.de-DE.json +0 -6
- package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.es-ES.json +0 -6
- package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.fr-CA.json +0 -6
- package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.fr-FR.json +0 -6
- package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.json +0 -6
- package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.pt-BR.json +0 -6
- 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(
|
|
@@ -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
|
+
});
|
|
@@ -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-
|
|
125
|
+
@apply md:tw-flex md:tw-flex-col;
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
|
|
126
|
-
|
|
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 = (
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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.
|