@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
package/lib/src/lib.js CHANGED
@@ -19,7 +19,7 @@ export { default as brandPageClient } from './clients/brand-page-client';
19
19
  export { FabricRenderClient } from './clients/fabric-render-client';
20
20
  export { WebSocketGatewayClient, } from './clients/websocket-gateway-client';
21
21
  // components
22
- export { BrandPageMaker, SeoPageSettings, UrlSettings, GoogleAnalytics, PublishBrandPageModalWithState, SensitiveContent, UseExistingDomainModal, } from './brand-page-maker/components';
22
+ export { BrandPageMaker, SeoPageSettings, UrlSettings, GoogleAnalytics, PublishBrandPageModalWithState, PreviewModalWithState, SensitiveContent, UseExistingDomainModal, } from './brand-page-maker/components';
23
23
  import LogoRender from './brand-page/components/logo/LogoRender.vue';
24
24
  export { LogoRender };
25
25
  export { default as Page } from './brand-page/components/page/Page.vue';
@@ -0,0 +1,4 @@
1
+ import type { BrandPageType } from '@brand-page';
2
+ import type { UiDeviceMode } from '@shared/models';
3
+ export type LivePreviewCta = 'publish' | 'edit';
4
+ export declare const getDefaultPreviewDeviceMode: (brandPageType: BrandPageType) => UiDeviceMode;
@@ -0,0 +1,3 @@
1
+ // brandSite is desktop-first; others mobile-first. Type-only BrandPageType import keeps
2
+ // the `/preview-modal` subpath free of the renderer runtime graph.
3
+ export const getDefaultPreviewDeviceMode = (brandPageType) => (brandPageType === 'brandSite' ? 'desktop' : 'mobile');
@@ -5008,6 +5008,7 @@ declare const _default: import("vue").DefineComponent<{}, {
5008
5008
  sendAiSendPromptEvent: (ctaLocation: string) => void;
5009
5009
  sendAiStopGenerationEvent: (ctaLocation: string) => void;
5010
5010
  };
5011
+ pageSettingsView: import("vue").Ref<"list" | "headerSettings", "list" | "headerSettings">;
5011
5012
  }, {
5012
5013
  isDeleteModalVisible: boolean;
5013
5014
  deleteModalPageId: string | undefined;
@@ -5025,7 +5026,6 @@ declare const _default: import("vue").DefineComponent<{}, {
5025
5026
  addPageLabel(): string;
5026
5027
  addPageButtonVariant(): string;
5027
5028
  contentListItems(): ContentItem<Content>[];
5028
- headerPageSectionStyleOptions(): PageSectionStyleOption[];
5029
5029
  footerPageSectionStyleOptions(): PageSectionStyleOption[];
5030
5030
  footerFontSettingsOptions(): PageSettingsFontSettingsOption[];
5031
5031
  showFooterSettings(): boolean;
@@ -5041,6 +5041,8 @@ declare const _default: import("vue").DefineComponent<{}, {
5041
5041
  onCancelDeletePage(): void;
5042
5042
  onPageTitleInput(pageId: string, title: string): void;
5043
5043
  scrollToPageSettingsSection(sectionName: PageSettingsSection): void;
5044
+ openHeaderSettingsPanel(): void;
5045
+ closeHeaderSettingsPanel(): void;
5044
5046
  onHideBrandPageLogoClickFromFooter(): Promise<void>;
5045
5047
  }, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
5046
5048
  export default _default;
@@ -160,6 +160,7 @@ export default defineComponent({
160
160
  onDraggingStart,
161
161
  onDraggingEnd,
162
162
  eventTracker,
163
+ pageSettingsView: ref('list'),
163
164
  };
164
165
  },
165
166
  data() {
@@ -191,17 +192,6 @@ export default defineComponent({
191
192
  contentListItems() {
192
193
  return this.contentSettingsStore.items;
193
194
  },
194
- headerPageSectionStyleOptions() {
195
- if (this.isBrandSite) {
196
- return [
197
- PAGE_SECTION_STYLE_OPTIONS.logo,
198
- PAGE_SECTION_STYLE_OPTIONS.logoSize,
199
- PAGE_SECTION_STYLE_OPTIONS.backgroundColor,
200
- PAGE_SECTION_STYLE_OPTIONS.headerDisplay,
201
- ];
202
- }
203
- return [PAGE_SECTION_STYLE_OPTIONS.headerDisplay];
204
- },
205
195
  footerPageSectionStyleOptions() {
206
196
  return [
207
197
  PAGE_SECTION_STYLE_OPTIONS.logo,
@@ -330,6 +320,13 @@ export default defineComponent({
330
320
  });
331
321
  },
332
322
  scrollToPageSettingsSection(sectionName) {
323
+ if (sectionName === PAGE_SETTINGS_SECTIONS.header) {
324
+ // Header settings live in their own dedicated panel now (BP-5854),
325
+ // not an inline CollapsiblePanel to scroll to.
326
+ this.pageSettingsView = 'headerSettings';
327
+ return;
328
+ }
329
+ this.pageSettingsView = 'list';
333
330
  this.$nextTick(() => {
334
331
  const $sideBarContent = document.querySelector(`#${SIDE_BAR_CONTENT_ID}`);
335
332
  if (!$sideBarContent) {
@@ -350,6 +347,21 @@ export default defineComponent({
350
347
  $sideBarContent.scrollTop = $el.offsetTop - $sidebarHeader.clientHeight;
351
348
  });
352
349
  },
350
+ openHeaderSettingsPanel() {
351
+ this.pageSettingsView = 'headerSettings';
352
+ },
353
+ closeHeaderSettingsPanel() {
354
+ this.pageSettingsView = 'list';
355
+ // Keep the store in sync so a repeat click on the header in the
356
+ // live-preview canvas (which sets selectedSectionName to the same
357
+ // value it already had) still re-fires the watcher and reopens
358
+ // this panel, instead of being a no-op.
359
+ if (this.pageSettingsStore.selectedSectionName) {
360
+ this.pageSettingsStore.setSelectedSectionName({
361
+ sectionName: undefined,
362
+ });
363
+ }
364
+ },
353
365
  async onHideBrandPageLogoClickFromFooter() {
354
366
  this.eventTracker.setUpsellBridgeState({
355
367
  cta_location: UPGRADE_CTA_LOCATIONS.REMOVE_WATERMARK_FOOTER_SETTINGS,
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.7",
4
+ "version": "6.1.8-ast-header",
5
5
  "author": "Design.com Eng",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <section class="tw-relative">
3
3
  <ButtonStyleWrapper
4
- v-if="isPageLink && (viewModel.pageSlug || viewModel.page)"
4
+ v-if="isInternalLink"
5
5
  class="bp-button tw-mb-4"
6
6
  :class="{ 'tw-min-h-14': !isPreview }"
7
7
  :selected-button-style-id="viewModel.styles.selectedButtonStyleId"
@@ -10,7 +10,7 @@
10
10
  <template #default="{ innerClass }">
11
11
  <router-link
12
12
  class="tw-no-underline"
13
- :to="{ name: routes.name, params: routes.routeParams }"
13
+ :to="routes"
14
14
  :class="{ 'tw-w-full': isBrushStrokeIcon }"
15
15
  @click="onPageLinkClick"
16
16
  >
@@ -89,6 +89,7 @@
89
89
  </template>
90
90
  </ButtonStyleWrapper>
91
91
  <HashRouteModal
92
+ data-component="button-image-modal"
92
93
  mode="image"
93
94
  :is-hash-route-enabled="false"
94
95
  :visible="isModalVisible"
@@ -106,8 +107,15 @@ import { useRouter } from 'vue-router';
106
107
  import { HashRouteModal } from '@designcrowd/fe-shared-lib';
107
108
  import { isEmail, isTel, isURL } from '../../../utils/validator.util';
108
109
  import type { BrandPageContext } from '@brand-page/models';
109
- import type { ButtonViewModel } from '@brand-page/view-models';
110
- import { findNavItemByPageId } from '@src/brand-page/utils/route.util';
110
+ import type {
111
+ ButtonViewModel,
112
+ NavItemViewModel,
113
+ } from '@brand-page/view-models';
114
+ import {
115
+ findNavItemByOwnSiteUrl,
116
+ findNavItemByPageId,
117
+ toRouteLocation,
118
+ } from '@src/brand-page/utils/route.util';
111
119
  import ButtonIcon from './ButtonIcon.vue';
112
120
  import ButtonStyleWrapper from './ButtonStyleWrapper.vue';
113
121
  import TextWrapper from '../text-wrapper/TextWrapper.vue';
@@ -214,16 +222,34 @@ const href = computed((): string | undefined => {
214
222
  return url.value;
215
223
  });
216
224
 
217
- const routes = computed(() => {
218
- const route = findNavItemByPageId(
219
- props.context.nav.navItems,
220
- props.viewModel.page,
221
- );
222
- return {
223
- name: route?.routeName || '',
224
- routeParams: route?.routeParams || {},
225
- };
226
- });
225
+ // A hand-typed URL that points at one of this site's own pages is routed in-app like a
226
+ // picked page rather than opened in a new tab. Needs the site's address in context, so
227
+ // it applies on the published site and in the live preview; the maker's render context
228
+ // carries no slug, so nothing converts in the editor.
229
+ const ownSiteNavItem = computed((): NavItemViewModel | undefined =>
230
+ isPageLink.value || isModalUrl.value
231
+ ? undefined
232
+ : findNavItemByOwnSiteUrl({
233
+ url: url.value,
234
+ navItems: props.context.nav.navItems,
235
+ brandPageType: props.context.brandPageType,
236
+ brandPageSlug: props.context.brandPageSlug,
237
+ customDomain: props.context.customDomain,
238
+ }),
239
+ );
240
+
241
+ const isInternalLink = computed((): boolean =>
242
+ isPageLink.value
243
+ ? Boolean(props.viewModel.pageSlug || props.viewModel.page)
244
+ : Boolean(ownSiteNavItem.value),
245
+ );
246
+
247
+ const routes = computed(() =>
248
+ toRouteLocation(
249
+ ownSiteNavItem.value ??
250
+ findNavItemByPageId(props.context.nav.navItems, props.viewModel.page),
251
+ ),
252
+ );
227
253
 
228
254
  const buttonStateClass = computed(() => ({
229
255
  'c-button--disabled':
@@ -241,10 +267,7 @@ const onPageLinkClick = (): void => {
241
267
  // never runs the global guard that scrolls to top, so trigger it explicitly.
242
268
  if (!router) return;
243
269
 
244
- const target = router.resolve({
245
- name: routes.value.name,
246
- params: routes.value.routeParams,
247
- });
270
+ const target = router.resolve(routes.value);
248
271
 
249
272
  if (target.fullPath === router.currentRoute.value.fullPath) {
250
273
  window.scrollTo(0, 0);
@@ -173,3 +173,214 @@ 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
+ // The Open Design trigger is a no-href anchor: it navigates nowhere, so the
205
+ // preview click-guard (a deny-list keyed on `data-preview-block`) has nothing to
206
+ // block here.
207
+ test('renders no data-preview-block guard on the modal button', async () => {
208
+ const wrapper = await mountModalButton();
209
+
210
+ expect(wrapper.get('a').attributes('data-preview-block')).toBeUndefined();
211
+ });
212
+
213
+ // The data-component must land on a queryable element (Modal's v-show root keeps
214
+ // it in the DOM). It no longer feeds an allow-list — the modal is dismissable
215
+ // because non-anchor clicks pass the gate freely.
216
+ test('anchors the popup with data-component="button-image-modal"', async () => {
217
+ const wrapper = await mountModalButton();
218
+
219
+ expect(wrapper.find('[data-component="button-image-modal"]').exists()).toBe(
220
+ true,
221
+ );
222
+ });
223
+
224
+ test('opens the image popup on click and closes it on dismiss', async () => {
225
+ const wrapper = await mountModalButton();
226
+
227
+ expect(
228
+ wrapper
229
+ .get('[data-component="button-image-modal"]')
230
+ .attributes('aria-hidden'),
231
+ ).toBe('true');
232
+
233
+ await wrapper.get('a').trigger('click');
234
+
235
+ expect(
236
+ wrapper
237
+ .get('[data-component="button-image-modal"]')
238
+ .attributes('aria-hidden'),
239
+ ).toBe('false');
240
+
241
+ await wrapper.get('[data-test-modal-close-btn]').trigger('click');
242
+
243
+ expect(
244
+ wrapper
245
+ .get('[data-component="button-image-modal"]')
246
+ .attributes('aria-hidden'),
247
+ ).toBe('true');
248
+ });
249
+ });
250
+
251
+ describe('Button - hand-typed own-site URL routes in-app', () => {
252
+ const mountUrlButton = async (url: string) => {
253
+ const router = createTestRouter();
254
+
255
+ router.push('/my-brand');
256
+ await router.isReady();
257
+
258
+ return mount(Button, {
259
+ propsData: {
260
+ context: createTestBrandPageContext(),
261
+ viewModel: createTestButtonViewModel({
262
+ url,
263
+ buttonLinkType: 'url',
264
+ clickActionField: 'url',
265
+ page: '',
266
+ }),
267
+ },
268
+ global: { plugins: [router] },
269
+ });
270
+ };
271
+
272
+ // The author picked "Link" and typed a URL, but it addresses one of their own
273
+ // pages — so it must navigate in-app, NOT open a new tab.
274
+ it('renders a router-link with no target for an own-site URL', async () => {
275
+ const wrapper = await mountUrlButton(
276
+ 'https://brand.site/my-brand/second-page',
277
+ );
278
+
279
+ expect(wrapper.findComponent(RouterLink).exists()).toBe(true);
280
+ expect(wrapper.get('a').attributes('target')).toBeUndefined();
281
+ });
282
+
283
+ it('still opens an external URL in a new tab', async () => {
284
+ const wrapper = await mountUrlButton('https://example.com/pricing');
285
+
286
+ expect(wrapper.findComponent(RouterLink).exists()).toBe(false);
287
+ expect(wrapper.get('a').attributes('target')).toBe('_blank');
288
+ });
289
+
290
+ it('still opens another site on the shared product host in a new tab', async () => {
291
+ const wrapper = await mountUrlButton(
292
+ 'https://brand.site/someone-else/second-page',
293
+ );
294
+
295
+ expect(wrapper.findComponent(RouterLink).exists()).toBe(false);
296
+ expect(wrapper.get('a').attributes('target')).toBe('_blank');
297
+ });
298
+ });
299
+
300
+ // The reported case: an author types their own site's page URL into a "Url" button.
301
+ // It must navigate in place on BOTH the published site and the live preview — a new tab
302
+ // is only ever correct for a genuinely external link.
303
+ describe('Button - typed own-site page URL behaves the same in preview and published', () => {
304
+ const SITE_SLUG = 'getty-video-banner';
305
+ const TYPED_URL = `https://uat2.brand.site/${SITE_SLUG}/page-2`;
306
+
307
+ const navItems = [
308
+ {
309
+ pageId: 'home',
310
+ slug: SITE_SLUG,
311
+ routeName: 'home-route',
312
+ routeParams: { slug: SITE_SLUG },
313
+ isRoot: true,
314
+ relativePath: '',
315
+ nestedNavItems: [],
316
+ },
317
+ {
318
+ pageId: 'p2',
319
+ slug: SITE_SLUG,
320
+ routeName: 'page-route',
321
+ routeParams: { slug: SITE_SLUG, pageSlug: 'page-2' },
322
+ isRoot: false,
323
+ relativePath: 'page-2',
324
+ nestedNavItems: [],
325
+ },
326
+ ] as unknown as NavItemViewModel[];
327
+
328
+ // Identical in every field the ownership check reads — which is the point.
329
+ const publishedContext = () => ({
330
+ ...createTestBrandPageContext(),
331
+ brandPageSlug: SITE_SLUG,
332
+ customDomain: undefined,
333
+ isPreview: false,
334
+ nav: { navItems },
335
+ });
336
+
337
+ const livePreviewContext = () => ({
338
+ ...publishedContext(),
339
+ isLivePreview: true,
340
+ });
341
+
342
+ const mountWith = async (context: object, url: string) => {
343
+ const router = createTestRouter();
344
+
345
+ router.push(`/${SITE_SLUG}`);
346
+ await router.isReady();
347
+
348
+ return mount(Button, {
349
+ propsData: {
350
+ context,
351
+ viewModel: createTestButtonViewModel({
352
+ url,
353
+ buttonLinkType: 'url',
354
+ clickActionField: 'url',
355
+ page: '',
356
+ }),
357
+ },
358
+ global: { plugins: [router] },
359
+ });
360
+ };
361
+
362
+ it.each([
363
+ ['published', publishedContext],
364
+ ['live preview', livePreviewContext],
365
+ ])('routes to page-2 in place on the %s site', async (_label, context) => {
366
+ const wrapper = await mountWith(context(), TYPED_URL);
367
+ const anchor = wrapper.get('a');
368
+
369
+ expect(wrapper.findComponent(RouterLink).exists()).toBe(true);
370
+ expect(anchor.attributes('target')).toBeUndefined();
371
+ expect(anchor.attributes('href')).toBe(`/${SITE_SLUG}/page-2`);
372
+ });
373
+
374
+ it.each([
375
+ ['published', publishedContext],
376
+ ['live preview', livePreviewContext],
377
+ ])(
378
+ 'still opens a genuinely external link in a new tab on the %s site',
379
+ async (_label, context) => {
380
+ const wrapper = await mountWith(context(), 'https://instagram.com/acme');
381
+
382
+ expect(wrapper.findComponent(RouterLink).exists()).toBe(false);
383
+ expect(wrapper.get('a').attributes('target')).toBe('_blank');
384
+ },
385
+ );
386
+ });
@@ -2,6 +2,7 @@ import type { TextImageSectionViewModel } from '@brand-page/view-models';
2
2
  import { type ImagesSectionContentDisplayStyle } from '../../constants';
3
3
  import { shallowMount } from '@vue/test-utils';
4
4
  import { vi } from 'vitest';
5
+ import { createRouter, createMemoryHistory } from 'vue-router';
5
6
  import ImagesSection from './ImagesSection.vue';
6
7
  import brandPageTestHelper from '@test-helpers/brand-page.tests-helper';
7
8
 
@@ -106,6 +107,96 @@ describe('brand site images section', () => {
106
107
  expect((wrapper.vm as any).imageModalSelectedImageIndex).toBeUndefined();
107
108
  });
108
109
 
110
+ test('onSelectImage routes an own-site image link in-app (same tab), not a new tab', () => {
111
+ // Arrange
112
+ const router = createRouter({
113
+ history: createMemoryHistory(),
114
+ routes: [
115
+ { path: '/', name: 'home', component: { template: '<div />' } },
116
+ {
117
+ path: '/:slug/:pageSlug',
118
+ name: 'page',
119
+ component: { template: '<div />' },
120
+ },
121
+ ],
122
+ });
123
+ const pushSpy = vi.spyOn(router, 'push');
124
+
125
+ const context = {
126
+ ...brandPageTestHelper.createTestBrandSiteContext(),
127
+ brandPageSlug: 'mysite',
128
+ pageTitle: '',
129
+ nav: {
130
+ navItems: [
131
+ {
132
+ relativePath: 'about',
133
+ routeName: 'page',
134
+ routeParams: { slug: 'mysite', pageSlug: 'about' },
135
+ nestedNavItems: [],
136
+ } as any,
137
+ ],
138
+ },
139
+ };
140
+
141
+ const itemWithOwnSiteLink = {
142
+ ...brandPageTestHelper.createTextImageSectionViewModelItems(1)[0],
143
+ imageLink: 'https://brand.site/mysite/about',
144
+ };
145
+
146
+ const viewModel: TextImageSectionViewModel = {
147
+ ...brandPageTestHelper.createTestTextImageSectionViewModel(),
148
+ enablePopup: false,
149
+ items: [itemWithOwnSiteLink],
150
+ };
151
+
152
+ const wrapper = shallowMount(ImagesSection, {
153
+ propsData: { context, viewModel },
154
+ global: { plugins: [router] },
155
+ });
156
+
157
+ // Act
158
+ (wrapper.vm as any).onSelectImage(itemWithOwnSiteLink);
159
+
160
+ // Assert
161
+ expect(pushSpy).toHaveBeenCalledWith({
162
+ name: 'page',
163
+ params: { slug: 'mysite', pageSlug: 'about' },
164
+ });
165
+ expect(mockWindowOpen).not.toHaveBeenCalled();
166
+ });
167
+
168
+ test('onSelectImage keeps an external image link inert in the live preview', () => {
169
+ // Arrange — external link, live customer preview: the gate only blocks anchors,
170
+ // so this JS navigation must be stopped at the source.
171
+ const context = {
172
+ ...brandPageTestHelper.createTestBrandSiteContext(),
173
+ isLivePreview: true,
174
+ pageTitle: '',
175
+ };
176
+
177
+ const itemWithExternalLink = {
178
+ ...brandPageTestHelper.createTextImageSectionViewModelItems(1)[0],
179
+ imageLink: 'https://example.com',
180
+ };
181
+
182
+ const viewModel: TextImageSectionViewModel = {
183
+ ...brandPageTestHelper.createTestTextImageSectionViewModel(),
184
+ enablePopup: false,
185
+ items: [itemWithExternalLink],
186
+ };
187
+
188
+ const wrapper = shallowMount(ImagesSection, {
189
+ propsData: { context, viewModel },
190
+ });
191
+
192
+ // Act
193
+ (wrapper.vm as any).onSelectImage(itemWithExternalLink);
194
+
195
+ // Assert
196
+ expect(mockWindowOpen).not.toHaveBeenCalled();
197
+ expect((wrapper.vm as any).imageModalSelectedImageIndex).toBeUndefined();
198
+ });
199
+
109
200
  test('onSelectImage should show popup when enablePopup is true and no imageLink', () => {
110
201
  // Arrange
111
202
  const context = {
@@ -13,7 +13,6 @@
13
13
  :class="{
14
14
  'tw-cursor-pointer': shouldShowCursor(item),
15
15
  }"
16
- :data-preview-interactive="previewInteractive(item)"
17
16
  @click="shouldBeClickable(item) ? onSelectImage(item) : undefined"
18
17
  />
19
18
  <FallbackImage
@@ -30,7 +29,6 @@
30
29
  :alt="itemImageAltText(item)"
31
30
  :context="context"
32
31
  :content-index="contentIndex"
33
- :data-preview-interactive="previewInteractive(item)"
34
32
  @click="shouldBeClickable(item) ? onSelectImage(item) : undefined"
35
33
  />
36
34
  <Placeholder
@@ -59,6 +57,7 @@
59
57
  </template>
60
58
  <script setup lang="ts">
61
59
  import { computed, reactive, ref, watch } from 'vue';
60
+ import { useRouter } from 'vue-router';
62
61
  import TextMediaSection from '../text-media-section/TextMediaSection.vue';
63
62
  import ImageGalleryModal from '../image-gallery/ImageGalleryModal.vue';
64
63
  import BpImage from '../image/Image.vue';
@@ -79,7 +78,12 @@ import { MEDIA_ONLY } from '../../constants';
79
78
  import {
80
79
  getImageAltText,
81
80
  getImagesSectionAspectRatioByDisplayStyle,
81
+ isPreviewMode,
82
82
  } from '@brand-page/utils';
83
+ import {
84
+ findNavItemByOwnSiteUrl,
85
+ toRouteLocation,
86
+ } from '@src/brand-page/utils/route.util';
83
87
  import isURL from 'validator/lib/isURL';
84
88
 
85
89
  const DEFAULT_CENTERED_ASPECT_RATIO = '2/1';
@@ -92,6 +96,8 @@ interface Props {
92
96
 
93
97
  const props = defineProps<Props>();
94
98
 
99
+ const router = useRouter();
100
+
95
101
  const imageModalSelectedImageIndex = ref<number | undefined>(undefined);
96
102
  const imagesBroken = reactive<Record<number, boolean>>({});
97
103
 
@@ -204,6 +210,27 @@ const showDisplayImageInFullWidth = computed((): boolean => {
204
210
  // Methods
205
211
  const onSelectImage = (item: TextMediaItem) => {
206
212
  if (item.imageLink && isURL(item.imageLink)) {
213
+ // A link to one of this site's own pages routes in-app (same tab), like a
214
+ // button/banner link — instead of the old always-new-tab `window.open`.
215
+ const ownSiteNavItem = findNavItemByOwnSiteUrl({
216
+ url: item.imageLink,
217
+ navItems: props.context.nav.navItems,
218
+ brandPageType: props.context.brandPageType,
219
+ brandPageSlug: props.context.brandPageSlug,
220
+ customDomain: props.context.customDomain,
221
+ });
222
+
223
+ if (ownSiteNavItem) {
224
+ router.push(toRouteLocation(ownSiteNavItem));
225
+ return;
226
+ }
227
+
228
+ // External link: new tab on the published site. The live preview only blocks
229
+ // anchors, so guard this JS navigation at the source — it stays inert there.
230
+ if (isPreviewMode(props.context)) {
231
+ return;
232
+ }
233
+
207
234
  window.open(item.imageLink, '_blank', 'noopener,noreferrer');
208
235
  return;
209
236
  }
@@ -224,14 +251,6 @@ const shouldBeClickable = (item: TextMediaItem): boolean => {
224
251
  );
225
252
  };
226
253
 
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
-
235
254
  const onImageModalClose = () => {
236
255
  imageModalSelectedImageIndex.value = undefined;
237
256
  };
@@ -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,10 +2,7 @@
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. -->
7
5
  <div
8
- data-preview-interactive
9
6
  class="tw-relative tw-text-center tw-w-full hover:tw-cursor-pointer"
10
7
  :class="{ 'tw-mb-4': !isCollapsed }"
11
8
  @click="onHeaderToggleClick"
@@ -37,7 +37,6 @@
37
37
  'tw-justify-center tw-flex': isFeaturedDisplayStyle,
38
38
  }"
39
39
  :style="getPaymentOptionButtonStyleById(paymentOption.id)"
40
- data-preview-interactive
41
40
  @click="onPaymentOptionSelect(paymentOption.id)"
42
41
  >
43
42
  <div