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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-Lk8J1vHg.js → CreatedOnBrandCrowd-de-de-BFDE6VfS.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-BbXj5fb1.js → CreatedOnBrandCrowd-en-us-CRhl6-Cd.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-CjnqvYCi.js → CreatedOnBrandCrowd-es-es-VaP_-7dv.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-Dbjv4qpN.js → CreatedOnBrandCrowd-fr-ca-BySGqvWO.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-Dbjv4qpN.js → CreatedOnBrandCrowd-fr-fr-BySGqvWO.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-BZIOPEvL.js → CreatedOnBrandCrowd-pt-br-BHjXVcSk.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-BZIOPEvL.js → CreatedOnBrandCrowd-pt-pt-BHjXVcSk.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-aN4JMRDI.js → CreatedOnDcom-de-de-DIFO5IZY.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-f3VD6Pz4.js → CreatedOnDcom-en-us-CcpDa04T.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-CQhNdtRP.js → CreatedOnDcom-es-es-DHwY0lWp.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-CFsC_XoO.js → CreatedOnDcom-fr-ca-BP3P0hJL.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-J4ZtCBO9.js → CreatedOnDcom-fr-fr-Bai1giy3.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-C1yFugh_.js → CreatedOnDcom-pt-br-GgKiDrbK.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-ycOo5lDn.js → CreatedOnDcom-pt-pt-uuuxCYR3.js} +1 -1
  15. package/lib/src/PreviewModal-DY2aAN0a.js +19931 -0
  16. package/lib/src/brand-page/components/page-footer/PageFooter.mixin.d.ts +0 -1
  17. package/lib/src/brand-page/components/page-footer/PageFooter.mixin.js +1 -4
  18. package/lib/src/brand-page/models/brand-page-context.model.d.ts +2 -0
  19. package/lib/src/brand-page/models/brand-page-context.model.js +12 -0
  20. package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.d.ts +2 -0
  21. package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.js +14 -1
  22. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +134 -0
  23. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.js +50 -0
  24. package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +130 -0
  25. package/lib/src/brand-page-maker/components/navigation/Navigation.mixin.d.ts +1 -0
  26. package/lib/src/brand-page-maker/components/navigation/Navigation.mixin.js +3 -0
  27. package/lib/src/brand-page-maker/components/preview/Preview.mixin.d.ts +14 -0
  28. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +130 -0
  29. package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +130 -0
  30. package/lib/src/brand-page-maker/components/seo-settings/UrlSettings.mixin.d.ts +130 -0
  31. package/lib/src/brand-page-maker/components/seo-settings/UseExistingDomainModal.mixin.d.ts +130 -0
  32. package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.d.ts +130 -0
  33. package/lib/src/brand-page-maker/components/upgrade-to-premium-button/UpgradeToPremiumButton.mixin.d.ts +130 -0
  34. package/lib/src/brand-page-maker/composables/useEventTracker.d.ts +1 -0
  35. package/lib/src/brand-page-maker/composables/useEventTracker.js +1 -0
  36. package/lib/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.d.ts +132 -0
  37. package/lib/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.js +22 -6
  38. package/lib/src/bundled-translations-D42tE0D2.js +179 -0
  39. package/lib/src/bundled-translations.de-DE-D9aWGgFt.js +179 -0
  40. package/lib/src/bundled-translations.es-ES-lk4B0xQ7.js +179 -0
  41. package/lib/src/bundled-translations.fr-CA-DhOBNt6q.js +179 -0
  42. package/lib/src/bundled-translations.fr-FR-CnpafQeG.js +179 -0
  43. package/lib/src/bundled-translations.pt-BR-C4PnKrd9.js +179 -0
  44. package/lib/src/bundled-translations.pt-PT-CfKDVQC-.js +179 -0
  45. package/lib/src/bundles/bundled-translations.de-DE.json +5 -0
  46. package/lib/src/bundles/bundled-translations.es-ES.json +5 -0
  47. package/lib/src/bundles/bundled-translations.fr-CA.json +5 -0
  48. package/lib/src/bundles/bundled-translations.fr-FR.json +5 -0
  49. package/lib/src/bundles/bundled-translations.json +5 -0
  50. package/lib/src/bundles/bundled-translations.pt-BR.json +5 -0
  51. package/lib/src/bundles/bundled-translations.pt-PT.json +5 -0
  52. package/lib/src/clients/brand-page-client.d.ts +1 -0
  53. package/lib/src/composables/useTranslate.d.ts +2 -1
  54. package/lib/src/composables/useTranslate.js +2 -1
  55. package/lib/src/constants/brand-page-maker.d.ts +2 -0
  56. package/lib/src/constants/brand-page-maker.js +5 -0
  57. package/lib/src/constants/live-preview.constants.d.ts +15 -0
  58. package/lib/src/constants/live-preview.constants.js +15 -0
  59. package/lib/src/css/library.brand-page-brandCrowd.min.css +264 -121
  60. package/lib/src/css/library.brand-page-brandPage.min.css +268 -121
  61. package/lib/src/css/library.brand-page-designCom.min.css +264 -121
  62. package/lib/src/css/library.brand-page-preview.min.css +264 -121
  63. package/lib/src/index.d.ts +2 -1
  64. package/lib/src/index.mjs +102150 -225
  65. package/lib/src/lib.js +2 -1
  66. package/lib/src/preview-modal.d.ts +5 -0
  67. package/lib/src/preview-modal.js +12 -0
  68. package/lib/src/preview-modal.mjs +6 -0
  69. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +130 -0
  70. package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +130 -0
  71. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +130 -0
  72. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +130 -0
  73. package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +144 -0
  74. package/lib/src/shared/side-bar/components/SideBar.mixin.d.ts +130 -0
  75. package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +130 -0
  76. package/lib/src/tracker.mjs +1 -1
  77. package/lib/src/{useEventTracker-DmQ6AkNn.js → useEventTracker-Dmc2jFdN.js} +5 -4
  78. package/package.json +5 -1
  79. package/src/brand-page/components/button/Button.vue +11 -0
  80. package/src/brand-page/components/button/Button.vue.test.ts +90 -0
  81. package/src/brand-page/components/image-gallery/masonry/MasonryGallery.vue +1 -0
  82. package/src/brand-page/components/images-section/ImagesSection.vue +10 -0
  83. package/src/brand-page/components/media/Video.vue +60 -1
  84. package/src/brand-page/components/page-footer/PageFooter.mixin.ts +1 -4
  85. package/src/brand-page/components/page-footer/PageFooter.vue +0 -1
  86. package/src/brand-page/components/payment/Payment.vue +3 -0
  87. package/src/brand-page/components/payment/request-payment/RequestPayment.vue +1 -0
  88. package/src/brand-page/components/payment/support-me/SupportMe.vue +3 -0
  89. package/src/brand-page/components/shop/CartModal.vue +9 -0
  90. package/src/brand-page/components/shop/Shop.vue +19 -0
  91. package/src/brand-page/components/shop/Shop.vue.test.ts +182 -2
  92. package/src/brand-page/components/social-links/SocialLinks.vue +1 -9
  93. package/src/brand-page/components/social-links/SocialLinks.vue.test.ts +28 -8
  94. package/src/brand-page/components/social-links/SocialLinksItems.vue +6 -2
  95. package/src/brand-page/components/testimonial-section/TestimonialSection.vue +2 -2
  96. package/src/brand-page/components/video/Video.vue +3 -0
  97. package/src/brand-page/models/brand-page-context.model.ts +23 -0
  98. package/src/brand-page-maker/components/action-bar/ActionBar.mixin.ts +15 -0
  99. package/src/brand-page-maker/components/action-bar/ActionBarNavigation.test.ts +95 -0
  100. package/src/brand-page-maker/components/action-bar/ActionBarNavigation.vue +19 -4
  101. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.ts +57 -0
  102. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.preview-gate.test.ts +113 -0
  103. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.vue +26 -1
  104. package/src/brand-page-maker/components/navigation/Navigation.mixin.ts +3 -0
  105. package/src/brand-page-maker/components/navigation/Navigation.vue +1 -0
  106. package/src/brand-page-maker/components/preview-modal/PreviewModal.test.ts +162 -0
  107. package/src/brand-page-maker/components/preview-modal/PreviewModal.vue +347 -0
  108. package/src/brand-page-maker/components/preview-modal/PreviewModalWithState.test.ts +226 -0
  109. package/src/brand-page-maker/components/preview-modal/PreviewModalWithState.vue +149 -0
  110. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.de-DE.json +6 -0
  111. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.es-ES.json +6 -0
  112. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.fr-CA.json +6 -0
  113. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.fr-FR.json +6 -0
  114. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.json +6 -0
  115. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.pt-BR.json +6 -0
  116. package/src/brand-page-maker/components/preview-modal/i18n/preview-modal.pt-PT.json +6 -0
  117. package/src/brand-page-maker/components/rich-text-editor/RichTextEditor.vue +29 -17
  118. package/src/brand-page-maker/composables/useEventTracker.ts +1 -0
  119. package/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.save.test.ts +106 -0
  120. package/src/brand-page-maker/stores/brandpage-container/brand-page-container-store.ts +40 -8
  121. package/src/bundles/bundled-translations.de-DE.json +5 -0
  122. package/src/bundles/bundled-translations.es-ES.json +5 -0
  123. package/src/bundles/bundled-translations.fr-CA.json +5 -0
  124. package/src/bundles/bundled-translations.fr-FR.json +5 -0
  125. package/src/bundles/bundled-translations.json +5 -0
  126. package/src/bundles/bundled-translations.pt-BR.json +5 -0
  127. package/src/bundles/bundled-translations.pt-PT.json +5 -0
  128. package/src/clients/brand-page-client.ts +2 -0
  129. package/src/composables/useTranslate.ts +2 -0
  130. package/src/constants/brand-page-maker.ts +5 -0
  131. package/src/constants/live-preview.constants.ts +15 -0
  132. package/src/lib.ts +5 -1
  133. package/src/preview-modal.ts +14 -0
  134. package/src/shared/common/i18n/common.de-DE.json +1 -0
  135. package/src/shared/common/i18n/common.es-ES.json +1 -0
  136. package/src/shared/common/i18n/common.fr-CA.json +1 -0
  137. package/src/shared/common/i18n/common.fr-FR.json +1 -0
  138. package/src/shared/common/i18n/common.json +1 -0
  139. package/src/shared/common/i18n/common.pt-BR.json +1 -0
  140. package/src/shared/common/i18n/common.pt-PT.json +1 -0
  141. package/src/shared/ecommerce-settings/store/cart-store.test.ts +38 -0
  142. package/lib/src/bundled-translations-2HkRBbBD.js +0 -177
  143. package/lib/src/bundled-translations.de-DE-CL9ZEhU2.js +0 -177
  144. package/lib/src/bundled-translations.es-ES-uj7Sliu0.js +0 -177
  145. package/lib/src/bundled-translations.fr-CA-BMnBtr_w.js +0 -177
  146. package/lib/src/bundled-translations.fr-FR-9PEZedzq.js +0 -177
  147. package/lib/src/bundled-translations.pt-BR-B3oNDkoe.js +0 -177
  148. package/lib/src/bundled-translations.pt-PT-DM7Vsf-h.js +0 -177
  149. package/lib/src/lib-CVwyHyBz.js +0 -121538
@@ -0,0 +1,162 @@
1
+ import { describe, test, expect, afterEach, vi } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import PreviewModal from './PreviewModal.vue';
4
+ import { LIVE_PREVIEW_MESSAGES } from '@src/constants/live-preview.constants';
5
+
6
+ vi.mock('@designcrowd/fe-shared-lib', () => ({
7
+ Button: { template: '<button />' },
8
+ Icon: { template: '<span />' },
9
+ Loader: { template: '<span />' },
10
+ }));
11
+
12
+ vi.mock('@src/composables/useTranslate', () => ({
13
+ actionBarTr: (key: string) => key,
14
+ commonTr: (key: string) => key,
15
+ constantsTr: (key: string) => key,
16
+ errorTr: (key: string) => key,
17
+ previewModalTr: (key: string) => key,
18
+ }));
19
+
20
+ const PREVIEW_URL = 'https://brand.site/_p/request?token=signed';
21
+
22
+ const mountModal = (props: Record<string, unknown> = {}) =>
23
+ mount(PreviewModal, {
24
+ attachTo: document.body,
25
+ props: { visible: true, previewUrl: PREVIEW_URL, ...props },
26
+ });
27
+
28
+ const loader = () => document.body.querySelector('[data-test-preview-loading]');
29
+ const frame = () =>
30
+ document.body.querySelector<HTMLIFrameElement>(
31
+ '[data-test-preview-iframe]',
32
+ ) as HTMLIFrameElement;
33
+
34
+ afterEach(() => {
35
+ document.body.innerHTML = '';
36
+ });
37
+
38
+ const postReady = () =>
39
+ window.dispatchEvent(
40
+ new MessageEvent('message', {
41
+ data: { type: LIVE_PREVIEW_MESSAGES.ready },
42
+ origin: 'https://brand.site',
43
+ source: frame().contentWindow,
44
+ }),
45
+ );
46
+
47
+ describe('PreviewModal', () => {
48
+ // `load` fires while a banner video still has no displayable frame, so revealing
49
+ // on it showed an empty banner. Only the preview's own `ready` reveals now.
50
+ test('holds the loader through frame load, and reveals on ready', async () => {
51
+ const wrapper = mountModal();
52
+
53
+ expect(loader()).not.toBeNull();
54
+ expect(frame().className).toContain('tw-opacity-0');
55
+
56
+ await frame().dispatchEvent(new Event('load'));
57
+ await wrapper.vm.$nextTick();
58
+
59
+ expect(loader()).not.toBeNull();
60
+ expect(frame().className).toContain('tw-opacity-0');
61
+
62
+ postReady();
63
+ await wrapper.vm.$nextTick();
64
+
65
+ expect(loader()).toBeNull();
66
+ expect(frame().className).not.toContain('tw-opacity-0');
67
+ });
68
+
69
+ // A preview that never reports must not strand the customer on a spinner.
70
+ test('reveals on the fallback timer when ready never arrives', async () => {
71
+ vi.useFakeTimers();
72
+
73
+ try {
74
+ const wrapper = mountModal();
75
+
76
+ await frame().dispatchEvent(new Event('load'));
77
+ await wrapper.vm.$nextTick();
78
+ expect(loader()).not.toBeNull();
79
+
80
+ await vi.advanceTimersByTimeAsync(15_000);
81
+ await wrapper.vm.$nextTick();
82
+
83
+ expect(loader()).toBeNull();
84
+ } finally {
85
+ vi.useRealTimers();
86
+ }
87
+ });
88
+
89
+ // Every open gets a freshly signed URL, so the reveal must reset each time — the
90
+ // new document has to report itself painted on its own account.
91
+ test('a new preview url puts the loader back', async () => {
92
+ const wrapper = mountModal();
93
+
94
+ await frame().dispatchEvent(new Event('load'));
95
+ postReady();
96
+ await wrapper.vm.$nextTick();
97
+ expect(loader()).toBeNull();
98
+
99
+ await wrapper.setProps({ previewUrl: `${PREVIEW_URL}-2` });
100
+
101
+ expect(loader()).not.toBeNull();
102
+ });
103
+
104
+ test('escape closes the preview', async () => {
105
+ const wrapper = mountModal();
106
+
107
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
108
+
109
+ expect(wrapper.emitted('close')).toHaveLength(1);
110
+ });
111
+
112
+ // Once focus is in the frame, Escape lands there instead and is posted back up.
113
+ test('escape from inside the frame closes the preview', async () => {
114
+ const wrapper = mountModal();
115
+
116
+ window.dispatchEvent(
117
+ new MessageEvent('message', {
118
+ data: { type: LIVE_PREVIEW_MESSAGES.escape },
119
+ origin: 'https://brand.site',
120
+ source: frame().contentWindow,
121
+ }),
122
+ );
123
+
124
+ expect(wrapper.emitted('close')).toHaveLength(1);
125
+ });
126
+
127
+ test('ignores a message from anywhere but the preview frame', async () => {
128
+ const wrapper = mountModal();
129
+
130
+ window.dispatchEvent(
131
+ new MessageEvent('message', {
132
+ data: { type: LIVE_PREVIEW_MESSAGES.escape },
133
+ origin: 'https://evil.example',
134
+ source: window,
135
+ }),
136
+ );
137
+
138
+ expect(wrapper.emitted('close')).toBeUndefined();
139
+ });
140
+
141
+ // Escape must not pull the preview out from under either.
142
+ test.each([['publishing'], ['flowModalOpen']])(
143
+ 'escape is ignored while %s',
144
+ (prop) => {
145
+ const wrapper = mountModal({ [prop]: true });
146
+
147
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
148
+
149
+ expect(wrapper.emitted('close')).toBeUndefined();
150
+ },
151
+ );
152
+
153
+ test('stops listening once closed', async () => {
154
+ const wrapper = mountModal();
155
+
156
+ await wrapper.setProps({ visible: false });
157
+
158
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
159
+
160
+ expect(wrapper.emitted('close')).toBeUndefined();
161
+ });
162
+ });
@@ -0,0 +1,347 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ v-if="visible"
5
+ data-component="preview-modal"
6
+ class="c-preview-modal tw-fixed tw-inset-0 tw-flex tw-flex-col tw-bg-grayscale-800"
7
+ :class="flowModalOpen ? 'tw-z-40' : 'tw-z-[1000]'"
8
+ >
9
+ <header
10
+ class="tw-flex tw-items-center tw-justify-between tw-h-14 tw-px-4 tw-bg-grayscale-800 tw-text-white tw-box-border tw-shrink-0"
11
+ >
12
+ <div
13
+ class="tw-flex tw-items-center"
14
+ :class="{ 'tw-w-1/3': showDeviceToggle }"
15
+ >
16
+ <button
17
+ type="button"
18
+ data-test-preview-close-button
19
+ class="c-preview-modal__close"
20
+ :class="{ 'tw-uppercase': !isDesignCom }"
21
+ @click="$emit('close')"
22
+ >
23
+ {{ commonTr('close') }}
24
+ </button>
25
+ </div>
26
+
27
+ <div
28
+ v-if="showDeviceToggle"
29
+ class="tw-flex tw-items-center tw-justify-center tw-gap-3 tw-w-1/3"
30
+ >
31
+ <button
32
+ type="button"
33
+ data-test-preview-device-desktop
34
+ class="c-preview-modal__device"
35
+ :class="{
36
+ 'c-preview-modal__device--active': deviceMode === 'desktop',
37
+ }"
38
+ :aria-pressed="deviceMode === 'desktop'"
39
+ @click="$emit('update:deviceMode', 'desktop')"
40
+ >
41
+ <Icon name="desktop" />
42
+ </button>
43
+ <button
44
+ type="button"
45
+ data-test-preview-device-mobile
46
+ class="c-preview-modal__device"
47
+ :class="{
48
+ 'c-preview-modal__device--active': deviceMode === 'mobile',
49
+ }"
50
+ :aria-pressed="deviceMode === 'mobile'"
51
+ @click="$emit('update:deviceMode', 'mobile')"
52
+ >
53
+ <Icon name="mobile" />
54
+ </button>
55
+ </div>
56
+
57
+ <div
58
+ class="tw-flex tw-items-center tw-justify-end tw-gap-3"
59
+ :class="{ 'tw-w-1/3': showDeviceToggle }"
60
+ >
61
+ <button
62
+ v-if="cta !== 'none'"
63
+ type="button"
64
+ data-test-preview-edit-button
65
+ class="c-preview-modal__chrome-btn"
66
+ :class="{ 'tw-uppercase': !isDesignCom }"
67
+ @click="$emit('edit')"
68
+ >
69
+ <Icon name="edit" class="tw-mr-2" />
70
+ {{ editLabel }}
71
+ </button>
72
+ <Button
73
+ v-if="cta === 'publish'"
74
+ :label="actionBarTr('publish')"
75
+ :variant="isDesignCom ? 'primary' : 'success'"
76
+ size="small-medium"
77
+ icon-left="arrow-up-underline"
78
+ data-test-preview-publish-button
79
+ @on-click.stop="$emit('publish')"
80
+ />
81
+ </div>
82
+ </header>
83
+
84
+ <!-- Preview surface -->
85
+ <div
86
+ class="tw-relative tw-flex-1 tw-flex tw-items-center tw-justify-center tw-overflow-auto tw-bg-black"
87
+ >
88
+ <div
89
+ v-if="errored"
90
+ class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-gap-4 tw-text-white"
91
+ data-test-preview-error
92
+ >
93
+ <p>{{ errorTr('brandPageDefaultDescriptionError') }}</p>
94
+ <Button
95
+ :label="previewModalTr('retry')"
96
+ variant="outline"
97
+ :white="true"
98
+ size="small-medium"
99
+ @on-click.stop="$emit('retry')"
100
+ />
101
+ </div>
102
+
103
+ <!-- Loads behind the loader, revealed once it has painted. -->
104
+ <iframe
105
+ v-else-if="previewUrl"
106
+ ref="iframe"
107
+ :src="previewUrl"
108
+ :title="constantsTr('previewDisplayName')"
109
+ class="c-preview-modal__frame tw-bg-white tw-border-0"
110
+ :class="{
111
+ 'c-preview-modal__frame--mobile': deviceMode === 'mobile',
112
+ 'c-preview-modal__frame--desktop': deviceMode === 'desktop',
113
+ 'tw-opacity-0': !frameReady,
114
+ }"
115
+ data-test-preview-iframe
116
+ @load="onFrameLoad"
117
+ />
118
+
119
+ <div
120
+ v-if="showLoader"
121
+ class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center tw-bg-black"
122
+ data-test-preview-loading
123
+ >
124
+ <Loader />
125
+ </div>
126
+ </div>
127
+
128
+ <!-- Blocks the whole modal with a spinner while a publish is in flight. -->
129
+ <div
130
+ v-if="publishing"
131
+ class="c-preview-modal__publishing"
132
+ data-test-preview-publishing
133
+ >
134
+ <Loader />
135
+ </div>
136
+ </div>
137
+ </Teleport>
138
+ </template>
139
+
140
+ <script setup lang="ts">
141
+ import { computed, onBeforeUnmount, ref, watch, type PropType } from 'vue';
142
+ import { Button, Icon, Loader } from '@designcrowd/fe-shared-lib';
143
+ // Labels reuse existing translated keys so no new Smartling strings are needed.
144
+ import {
145
+ actionBarTr,
146
+ commonTr,
147
+ constantsTr,
148
+ errorTr,
149
+ previewModalTr,
150
+ } from '@src/composables/useTranslate';
151
+ import { LIVE_PREVIEW_MESSAGES } from '@src/constants/live-preview.constants';
152
+ import type { UiDeviceMode } from '@shared/models';
153
+
154
+ // Full-viewport takeover, not the centred fe-shared-lib Modal — this is edge-to-edge.
155
+ const props = defineProps({
156
+ visible: { type: Boolean, default: false },
157
+ previewUrl: { type: String, default: '' },
158
+ // CTA per surface: editor = publish, pre-purchase = edit, manage = none.
159
+ cta: {
160
+ type: String as PropType<'publish' | 'edit' | 'none'>,
161
+ default: 'publish',
162
+ },
163
+ editLabel: { type: String, default: '' },
164
+ deviceMode: {
165
+ type: String as PropType<UiDeviceMode>,
166
+ default: 'desktop',
167
+ },
168
+ showDeviceToggle: { type: Boolean, default: true },
169
+ // BrandCrowd uppercases button labels, Design.com doesn't (BP-5957).
170
+ isDesignCom: { type: Boolean, default: false },
171
+ loading: { type: Boolean, default: false },
172
+ errored: { type: Boolean, default: false },
173
+ // Spinner on the Publish button while the publish flow saves + opens.
174
+ publishing: { type: Boolean, default: false },
175
+ // publish and upgrade should show on top of preview
176
+ flowModalOpen: { type: Boolean, default: false },
177
+ });
178
+
179
+ const emit = defineEmits<{
180
+ close: [];
181
+ edit: [];
182
+ publish: [];
183
+ retry: [];
184
+ 'update:deviceMode': [mode: UiDeviceMode];
185
+ }>();
186
+
187
+ const iframe = ref<HTMLIFrameElement | null>(null);
188
+ const frameReady = ref(false);
189
+ let revealFallbackTimer: ReturnType<typeof setTimeout> | undefined;
190
+
191
+ // The preview posts `ready` once it has actually painted. `load` is too early — media
192
+ // elements release it before they hold a frame — so it only starts a fallback timer,
193
+ // which covers a preview that never reports (older web-renderer, or a dropped
194
+ // message). Better a late reveal than a spinner that never clears.
195
+ // MUST stay above the preview page's own paint cap (12s in `preview.vue`), or this
196
+ // fires first and reveals a frame that was about to report itself painted.
197
+ const REVEAL_FALLBACK_MS = 15_000;
198
+
199
+ const clearRevealFallback = (): void => {
200
+ if (revealFallbackTimer) {
201
+ clearTimeout(revealFallbackTimer);
202
+ revealFallbackTimer = undefined;
203
+ }
204
+ };
205
+
206
+ const reveal = (): void => {
207
+ clearRevealFallback();
208
+ frameReady.value = true;
209
+ };
210
+
211
+ // Dropping the loader the moment the URL was known just swapped the spinner for a
212
+ // white box while the frame fetched, redirected and booted.
213
+ const showLoader = computed(
214
+ () =>
215
+ props.loading ||
216
+ (!props.errored && !!props.previewUrl && !frameReady.value),
217
+ );
218
+
219
+ const previewOrigin = computed(() => {
220
+ try {
221
+ return new URL(props.previewUrl).origin;
222
+ } catch {
223
+ return '';
224
+ }
225
+ });
226
+
227
+ const onFrameLoad = (): void => {
228
+ if (frameReady.value) {
229
+ return;
230
+ }
231
+
232
+ clearRevealFallback();
233
+ revealFallbackTimer = setTimeout(reveal, REVEAL_FALLBACK_MS);
234
+ };
235
+
236
+ const closePreview = (): void => {
237
+ // A publish in flight, or a modal stacked on top, owns the interaction.
238
+ if (props.publishing || props.flowModalOpen) {
239
+ return;
240
+ }
241
+
242
+ emit('close');
243
+ };
244
+
245
+ const onKeydown = (event: KeyboardEvent): void => {
246
+ if (event.key === 'Escape') {
247
+ closePreview();
248
+ }
249
+ };
250
+
251
+ // Escape reaches this document only while focus is outside the frame; once it is
252
+ // inside, the preview page posts it back up.
253
+ const onMessage = (event: MessageEvent): void => {
254
+ if (
255
+ !previewOrigin.value ||
256
+ event.origin !== previewOrigin.value ||
257
+ event.source !== iframe.value?.contentWindow
258
+ ) {
259
+ return;
260
+ }
261
+
262
+ if (event.data?.type === LIVE_PREVIEW_MESSAGES.ready) {
263
+ reveal();
264
+
265
+ return;
266
+ }
267
+
268
+ if (event.data?.type === LIVE_PREVIEW_MESSAGES.escape) {
269
+ closePreview();
270
+ }
271
+ };
272
+
273
+ const stopListening = (): void => {
274
+ window.removeEventListener('keydown', onKeydown);
275
+ window.removeEventListener('message', onMessage);
276
+ clearRevealFallback();
277
+ };
278
+
279
+ watch(
280
+ () => props.visible,
281
+ (isVisible) => {
282
+ if (isVisible) {
283
+ window.addEventListener('keydown', onKeydown);
284
+ window.addEventListener('message', onMessage);
285
+ } else {
286
+ frameReady.value = false;
287
+ stopListening();
288
+ }
289
+ },
290
+ // Immediate so a modal mounted already-visible still listens.
291
+ { immediate: true },
292
+ );
293
+
294
+ // Each open gets a freshly signed URL, so this fires every time — the frame must go
295
+ // back behind the loader until the new document reports itself painted.
296
+ watch(
297
+ () => props.previewUrl,
298
+ () => {
299
+ clearRevealFallback();
300
+ frameReady.value = false;
301
+ },
302
+ );
303
+
304
+ onBeforeUnmount(stopListening);
305
+ </script>
306
+
307
+ <style scoped>
308
+ .c-preview-modal__device {
309
+ @apply tw-flex tw-items-center tw-justify-center tw-w-8 tw-h-8 tw-rounded tw-text-grayscale-400 tw-transition-colors;
310
+ }
311
+
312
+ .c-preview-modal__device--active {
313
+ @apply tw-bg-grayscale-700 tw-text-white;
314
+ }
315
+
316
+ /* Dark chrome buttons in the header — must not compete with the Publish CTA.
317
+ Padding/size mirror fe-shared-lib's `small-medium`; the transparent border
318
+ matches its `tw-border-2` so these line up with Publish at 36px. `border-solid`
319
+ is required — preflight is off for these themes, so a width-only border
320
+ computes to zero (same reason every fe-shared-lib variant spells it out). */
321
+ .c-preview-modal__close,
322
+ .c-preview-modal__chrome-btn {
323
+ @apply tw-flex tw-items-center tw-px-4 tw-py-1.5 tw-border-2 tw-border-solid tw-border-transparent tw-rounded tw-bg-grayscale-700 tw-text-white tw-text-sm tw-font-bold hover:tw-bg-grayscale-600 tw-transition-colors;
324
+ }
325
+
326
+ .c-preview-modal__publishing {
327
+ @apply tw-absolute tw-inset-0 tw-z-10 tw-flex tw-items-center tw-justify-center tw-bg-black tw-bg-opacity-60;
328
+ }
329
+
330
+ .c-preview-modal__frame {
331
+ @apply tw-transition-all tw-duration-300 tw-ease-out;
332
+ }
333
+
334
+ .c-preview-modal__frame--desktop {
335
+ @apply tw-w-full tw-h-full;
336
+ }
337
+
338
+ /* Phone-frame the mobile preview, reusing the editor Device.vue geometry. */
339
+ .c-preview-modal__frame--mobile {
340
+ width: 358px;
341
+ height: 734px;
342
+ max-height: calc(100% - 4rem);
343
+ border-radius: 3rem;
344
+ box-shadow: 0 0 0 0.625rem #3a3a3a;
345
+ @apply tw-my-auto;
346
+ }
347
+ </style>