@akinon/pz-theme 2.0.58-rc.0 → 2.0.58
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/CHANGELOG.md +11 -31
- package/package.json +4 -4
- package/src/blocks/button-block.tsx +23 -1
- package/src/blocks/link-block.tsx +21 -10
- package/src/blocks/panel-block.tsx +67 -237
- package/src/chrome/__tests__/theme-config-source.test.ts +53 -7
- package/src/chrome/placeholder-slugs.ts +41 -0
- package/src/chrome/theme-config-source.ts +17 -2
- package/src/components/with-designer-features.tsx +49 -2
- package/src/hooks/use-block-action.ts +209 -0
- package/src/index.ts +51 -1
- package/src/overlays/__tests__/modal-preview-host.test.tsx +316 -0
- package/src/overlays/__tests__/modal-registry.test.ts +205 -0
- package/src/overlays/__tests__/modal-triggers.test.tsx +272 -0
- package/src/overlays/__tests__/overlay-bus.test.tsx +197 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +234 -0
- package/src/overlays/modal-dom-bridge.ts +104 -0
- package/src/overlays/modal-frequency.ts +104 -0
- package/src/overlays/modal-host-beacon.tsx +35 -0
- package/src/overlays/modal-preview-host.tsx +159 -0
- package/src/overlays/modal-registry.ts +289 -0
- package/src/overlays/overlay-bus.ts +214 -0
- package/src/overlays/overlay-surface.tsx +386 -0
- package/src/overlays/theme-modal-mount.tsx +179 -0
- package/src/overlays/theme-modals.tsx +82 -0
- package/src/overlays/use-editor-modals.ts +109 -0
- package/src/overlays/use-modal-auto-trigger.ts +187 -0
- package/src/overlays/use-modal-preview.ts +127 -0
- package/src/overlays/use-overlay-breakpoint.ts +68 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useOverlay } from '../hooks/use-overlay';
|
|
6
|
+
import { useVisibilityContext } from '../hooks/use-visibility-context';
|
|
7
|
+
import { useBrowseMode } from '../browse-mode';
|
|
8
|
+
import { evaluateVisibilityRules } from '../utils/visibility-rules';
|
|
9
|
+
import { closeModal, useModalOpen } from './overlay-bus';
|
|
10
|
+
import { ThemeModal } from './modal-registry';
|
|
11
|
+
import {
|
|
12
|
+
CentredModalSurface,
|
|
13
|
+
LayeredOverlaySurface,
|
|
14
|
+
OverlayCloseButton
|
|
15
|
+
} from './overlay-surface';
|
|
16
|
+
import { useEditorKnowsModal } from './use-editor-modals';
|
|
17
|
+
import { useModalPreview } from './use-modal-preview';
|
|
18
|
+
import { useOverlayBreakpoint } from './use-overlay-breakpoint';
|
|
19
|
+
import { useModalAutoTrigger } from './use-modal-auto-trigger';
|
|
20
|
+
|
|
21
|
+
interface ThemeModalMountProps {
|
|
22
|
+
modal: ThemeModal;
|
|
23
|
+
/** The composition, already rendered on the server. */
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* Where this mount's registry entry came from.
|
|
27
|
+
*
|
|
28
|
+
* A `saved` mount stands down while the editor is attached and knows this
|
|
29
|
+
* modal, because the editor's copy is the authority then — it carries chrome
|
|
30
|
+
* the designer is changing right now, and may describe a modal the backend
|
|
31
|
+
* has never heard of. `ModalPreviewHost` renders an `editor` mount in its
|
|
32
|
+
* place. Without the distinction the replacement would read "the editor owns
|
|
33
|
+
* this" about itself and both would stand down, leaving nothing.
|
|
34
|
+
*/
|
|
35
|
+
origin?: 'saved' | 'editor';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isDesignerWindow = () =>
|
|
39
|
+
typeof window !== 'undefined' &&
|
|
40
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner === true;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One composed modal: the bus decides whether it is open, `useOverlay` decides
|
|
44
|
+
* what being open costs the page, and `overlay-surface` draws it.
|
|
45
|
+
*
|
|
46
|
+
* The children arrive already rendered by the server and are mounted only
|
|
47
|
+
* while the modal is open. That is the whole reason the host can afford to
|
|
48
|
+
* declare every modal a theme has: a closed one costs the page nothing but the
|
|
49
|
+
* bytes of its markup, and never appears in the accessibility tree, in the tab
|
|
50
|
+
* order, or as duplicate content behind the page.
|
|
51
|
+
*/
|
|
52
|
+
const ThemeModalMount = ({
|
|
53
|
+
modal,
|
|
54
|
+
children,
|
|
55
|
+
origin = 'saved'
|
|
56
|
+
}: ThemeModalMountProps) => {
|
|
57
|
+
const isOpen = useModalOpen(modal.id);
|
|
58
|
+
const breakpoint = useOverlayBreakpoint();
|
|
59
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Held open by the editor. This mount then stands down entirely and
|
|
63
|
+
* `ModalPreviewHost` draws the surface instead, from the live entry rather
|
|
64
|
+
* than from the saved one.
|
|
65
|
+
*
|
|
66
|
+
* Standing down is not an optimisation: both would render a
|
|
67
|
+
* `ThemePlaceholderWrapper` for the same slug, and two wrappers claiming one
|
|
68
|
+
* slug means the region registers twice and the editor sees the modal's
|
|
69
|
+
* contents doubled.
|
|
70
|
+
*/
|
|
71
|
+
const isPreviewing = useModalPreview(modal.id);
|
|
72
|
+
const editorOwnsModal = useEditorKnowsModal(modal.id);
|
|
73
|
+
const isDesigner = isDesignerWindow();
|
|
74
|
+
const isBrowseMode = useBrowseMode();
|
|
75
|
+
const isEditing = isDesigner && !isBrowseMode;
|
|
76
|
+
|
|
77
|
+
const visibilityContext = useVisibilityContext(breakpoint, {
|
|
78
|
+
isDesigner: isEditing
|
|
79
|
+
});
|
|
80
|
+
const existsHere = evaluateVisibilityRules(
|
|
81
|
+
modal.visibilityRules,
|
|
82
|
+
visibilityContext
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A modal that is not on this page must not be left open by a route change
|
|
87
|
+
* that carried it out of scope — the bus is module state and survives
|
|
88
|
+
* client-side navigation, so nothing else would close it and the shopper
|
|
89
|
+
* would be looking at an overlay belonging to a page they have left.
|
|
90
|
+
*/
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!existsHere && isOpen) closeModal(modal.id);
|
|
93
|
+
}, [existsHere, isOpen, modal.id]);
|
|
94
|
+
|
|
95
|
+
useModalAutoTrigger({
|
|
96
|
+
modal,
|
|
97
|
+
enabled: existsHere && !isEditing,
|
|
98
|
+
visibilityContext
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Superseded by the editor's own copy of this modal. See `origin`.
|
|
102
|
+
const supersededByEditor = origin === 'saved' && editorOwnsModal;
|
|
103
|
+
|
|
104
|
+
const opened = !isPreviewing && !supersededByEditor && isOpen && existsHere;
|
|
105
|
+
const { chrome, presentation } = modal;
|
|
106
|
+
const isCentred = presentation === 'modal';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* In the canvas the overlay is fully inert: it never steals focus into the
|
|
110
|
+
* page the designer is styling, never freezes it, never claims `aria-modal`
|
|
111
|
+
* and never answers Escape. The primitive does all of that off one flag.
|
|
112
|
+
*/
|
|
113
|
+
const overlay = useOverlay<HTMLDivElement>({
|
|
114
|
+
open: opened,
|
|
115
|
+
onClose: () => closeModal(modal.id),
|
|
116
|
+
isDesigner: isEditing,
|
|
117
|
+
// A centred modal really does cover the page. The others make the claim
|
|
118
|
+
// only behind a backdrop, because with the page still clickable it would
|
|
119
|
+
// be a lie.
|
|
120
|
+
modal: isCentred ? true : chrome.showBackdrop,
|
|
121
|
+
lockScroll: chrome.lockScroll && (isCentred || chrome.showBackdrop),
|
|
122
|
+
closeOnEscape: chrome.closeOnEscape,
|
|
123
|
+
fallbackFocusRef: rootRef
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!opened) return null;
|
|
127
|
+
|
|
128
|
+
const dismiss = () => closeModal(modal.id);
|
|
129
|
+
|
|
130
|
+
const header = chrome.showClose ? (
|
|
131
|
+
<OverlayCloseButton label={chrome.closeLabel} onClick={dismiss} />
|
|
132
|
+
) : null;
|
|
133
|
+
|
|
134
|
+
const ariaLabel = chrome.ariaLabel || modal.name;
|
|
135
|
+
|
|
136
|
+
const surface = isCentred ? (
|
|
137
|
+
<CentredModalSurface
|
|
138
|
+
overlayProps={overlay.overlayProps}
|
|
139
|
+
ariaLabel={ariaLabel}
|
|
140
|
+
closeOnOverlay={chrome.closeOnBackdrop}
|
|
141
|
+
onDismiss={dismiss}
|
|
142
|
+
width={chrome.width}
|
|
143
|
+
header={header}
|
|
144
|
+
>
|
|
145
|
+
{children}
|
|
146
|
+
</CentredModalSurface>
|
|
147
|
+
) : (
|
|
148
|
+
<LayeredOverlaySurface
|
|
149
|
+
presentation={presentation}
|
|
150
|
+
overlayProps={overlay.overlayProps}
|
|
151
|
+
ariaLabel={ariaLabel}
|
|
152
|
+
showBackdrop={chrome.showBackdrop}
|
|
153
|
+
closeOnOverlay={chrome.closeOnBackdrop}
|
|
154
|
+
onDismiss={dismiss}
|
|
155
|
+
width={chrome.width}
|
|
156
|
+
panelSide={chrome.panelSide}
|
|
157
|
+
sheetHeight={chrome.sheetHeight}
|
|
158
|
+
header={header}
|
|
159
|
+
>
|
|
160
|
+
{children}
|
|
161
|
+
</LayeredOverlaySurface>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div
|
|
166
|
+
ref={rootRef}
|
|
167
|
+
// The id a trigger's `aria-controls` points at. It exists only while the
|
|
168
|
+
// modal is mounted, which is why the trigger only claims the reference
|
|
169
|
+
// while the modal is open.
|
|
170
|
+
id={`theme-modal-${modal.id}`}
|
|
171
|
+
data-theme-modal={modal.id}
|
|
172
|
+
data-theme-modal-state="open"
|
|
173
|
+
>
|
|
174
|
+
{surface}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export default ThemeModalMount;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import ThemePlaceholder from '../theme-placeholder';
|
|
4
|
+
import { getThemeConfig } from '../chrome/theme-config-source';
|
|
5
|
+
import ThemeModalMount from './theme-modal-mount';
|
|
6
|
+
import { ModalHostBeacon } from './modal-host-beacon';
|
|
7
|
+
import ModalPreviewHost from './modal-preview-host';
|
|
8
|
+
|
|
9
|
+
interface ThemeModalsProps {
|
|
10
|
+
/**
|
|
11
|
+
* Commerce data the composed blocks read, built with the same
|
|
12
|
+
* `chrome/page-context` helpers a ChromeSlot uses. A modal mounted in the
|
|
13
|
+
* root layout has no page of its own, so a brand that wants product-aware
|
|
14
|
+
* modals passes the context down from the page that has one.
|
|
15
|
+
*/
|
|
16
|
+
pageContext?: Record<string, unknown> | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every composed modal this theme has, mounted once for the page.
|
|
21
|
+
*
|
|
22
|
+
* Mount it in the brand's ROOT LAYOUT, next to the footer ChromeSlot:
|
|
23
|
+
*
|
|
24
|
+
* <ThemeModals />
|
|
25
|
+
*
|
|
26
|
+
* It is an async server component, so — exactly like `ChromeSlot` — it cannot
|
|
27
|
+
* be used inside a client page. The layout is also the right home for a
|
|
28
|
+
* different reason: a modal is opened from the header as often as from the
|
|
29
|
+
* body, so it must outlive any one page's tree.
|
|
30
|
+
*
|
|
31
|
+
* Cost when a theme has no modals is a single object read from the
|
|
32
|
+
* `theme-config` widget that every page already fetches and memoizes, and this
|
|
33
|
+
* renders nothing at all.
|
|
34
|
+
*/
|
|
35
|
+
export default async function ThemeModals({
|
|
36
|
+
pageContext = null
|
|
37
|
+
}: ThemeModalsProps) {
|
|
38
|
+
const { modals, dataSources, settings } = await getThemeConfig();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every modal is fetched with the page, closed, and its content is mounted
|
|
42
|
+
* only when it opens.
|
|
43
|
+
*
|
|
44
|
+
* Route scoping is deliberately NOT applied here. `visibilityRules` read the
|
|
45
|
+
* pathname, the auth state and the breakpoint, and the layout this renders
|
|
46
|
+
* in has none of those reliably — deciding server-side would bake one page's
|
|
47
|
+
* answer into every page. The mount evaluates them client-side, exactly the
|
|
48
|
+
* way sections already do.
|
|
49
|
+
*
|
|
50
|
+
* The cost is therefore two widget reads per modal per page. That is fine
|
|
51
|
+
* for the handful of modals a theme actually has and would not be for
|
|
52
|
+
* twenty; when a theme gets there, the fix is server-side route scoping on
|
|
53
|
+
* the registry entry, not a client-side fetch that would have to reimplement
|
|
54
|
+
* the v1 schema fallback in `theme-placeholder.tsx`.
|
|
55
|
+
*/
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
{/* Tells a trigger that the host exists, so an unmounted host is a
|
|
59
|
+
named warning instead of a button that does nothing. */}
|
|
60
|
+
<ModalHostBeacon />
|
|
61
|
+
{modals.map((modal) => (
|
|
62
|
+
<ThemeModalMount key={modal.id} modal={modal}>
|
|
63
|
+
<ThemePlaceholder slug={modal.slug} pageContext={pageContext} />
|
|
64
|
+
</ThemeModalMount>
|
|
65
|
+
))}
|
|
66
|
+
{/*
|
|
67
|
+
Rendered even when the theme has no saved modals, which is precisely
|
|
68
|
+
when it matters most: the first modal a designer creates exists only in
|
|
69
|
+
the editor, and returning early on an empty registry would leave them
|
|
70
|
+
composing against a canvas that can never show it.
|
|
71
|
+
|
|
72
|
+
Costs a shopper nothing — it renders null unless the editor is holding
|
|
73
|
+
a modal open, and only the editor can ask.
|
|
74
|
+
*/}
|
|
75
|
+
<ModalPreviewHost
|
|
76
|
+
dataSources={dataSources}
|
|
77
|
+
themeSettings={settings}
|
|
78
|
+
pageContext={pageContext}
|
|
79
|
+
/>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { parseEditorMessage } from '../editor-bridge';
|
|
6
|
+
import { readThemeModals, ThemeModal } from './modal-registry';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The modal registry as the EDITOR currently has it.
|
|
10
|
+
*
|
|
11
|
+
* The editor already sends its whole theme on `LOAD_THEME` and every
|
|
12
|
+
* `UPDATE_THEME`, modals included; this reads the field the preview used to
|
|
13
|
+
* throw away. Without it the registry could only ever come from
|
|
14
|
+
* `theme-config`, which made composed modals behave unlike everything else on
|
|
15
|
+
* the page: a section appears as you add it, but a modal did not exist until
|
|
16
|
+
* it was saved AND the preview was reloaded by hand — and a trigger pointed at
|
|
17
|
+
* one simply did nothing, with no way to tell a missing modal from a broken
|
|
18
|
+
* trigger.
|
|
19
|
+
*
|
|
20
|
+
* Module state, like the overlay bus and for the same reason: several mounts
|
|
21
|
+
* across different React roots must agree on one answer.
|
|
22
|
+
*/
|
|
23
|
+
let editorModals: ThemeModal[] = [];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Serialized form of the registry. `UPDATE_THEME` arrives on every keystroke
|
|
27
|
+
* anywhere in the theme, and almost none of those touch a modal — comparing
|
|
28
|
+
* here keeps the whole modal layer from re-rendering at typing speed.
|
|
29
|
+
*/
|
|
30
|
+
let signature = '';
|
|
31
|
+
|
|
32
|
+
const listeners = new Set<() => void>();
|
|
33
|
+
|
|
34
|
+
const setEditorModals = (modals: ThemeModal[]) => {
|
|
35
|
+
const next = JSON.stringify(modals);
|
|
36
|
+
if (next === signature) return;
|
|
37
|
+
|
|
38
|
+
signature = next;
|
|
39
|
+
editorModals = modals;
|
|
40
|
+
listeners.forEach((listener) => listener());
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
let listening = false;
|
|
44
|
+
|
|
45
|
+
const startListening = () => {
|
|
46
|
+
if (listening || typeof window === 'undefined') return;
|
|
47
|
+
listening = true;
|
|
48
|
+
|
|
49
|
+
window.addEventListener('message', (event: MessageEvent) => {
|
|
50
|
+
const message = parseEditorMessage(event);
|
|
51
|
+
if (!message) return;
|
|
52
|
+
if (message.type !== 'LOAD_THEME' && message.type !== 'UPDATE_THEME') {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const theme = (message.data as { theme?: { modals?: unknown } } | undefined)
|
|
57
|
+
?.theme;
|
|
58
|
+
if (!theme) return;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read through the same normaliser the saved registry uses, so a modal
|
|
62
|
+
* cannot behave one way while being composed and another once published.
|
|
63
|
+
* A theme with no modals correctly yields an empty registry, which hands
|
|
64
|
+
* every modal back to the saved mounts.
|
|
65
|
+
*/
|
|
66
|
+
setEditorModals(readThemeModals(theme.modals));
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Stable empty registry: a fresh `[]` each render would loop the subscriber. */
|
|
71
|
+
const NO_MODALS: ThemeModal[] = [];
|
|
72
|
+
|
|
73
|
+
export const useEditorModals = (): ThemeModal[] => {
|
|
74
|
+
/**
|
|
75
|
+
* Empty on the server and on the first client render, so hydration matches;
|
|
76
|
+
* nothing can have arrived before the listener below is installed.
|
|
77
|
+
*/
|
|
78
|
+
const [modals, setModals] = useState<ThemeModal[]>(NO_MODALS);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
startListening();
|
|
82
|
+
|
|
83
|
+
const listener = () => setModals(editorModals);
|
|
84
|
+
listeners.add(listener);
|
|
85
|
+
// A frame may have landed between render and this effect.
|
|
86
|
+
listener();
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
listeners.delete(listener);
|
|
90
|
+
};
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
return modals;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether the editor is attached AND knows this modal — in which case its copy
|
|
98
|
+
* supersedes whatever `theme-config` last saved.
|
|
99
|
+
*/
|
|
100
|
+
export const useEditorKnowsModal = (id: string): boolean =>
|
|
101
|
+
useEditorModals().some((modal) => modal.id === id);
|
|
102
|
+
|
|
103
|
+
/** Test-only: drop the registry and the listener. */
|
|
104
|
+
export const __resetEditorModalsForTests = (): void => {
|
|
105
|
+
editorModals = [];
|
|
106
|
+
signature = '';
|
|
107
|
+
listeners.clear();
|
|
108
|
+
listening = false;
|
|
109
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { VisibilityRuleContext } from '../utils/visibility-rules';
|
|
6
|
+
import { getOpenModalIds, openModal } from './overlay-bus';
|
|
7
|
+
import { canAutoOpen, markAutoOpened } from './modal-frequency';
|
|
8
|
+
import { ThemeModal } from './modal-registry';
|
|
9
|
+
|
|
10
|
+
interface UseModalAutoTriggerOptions {
|
|
11
|
+
modal: ThemeModal;
|
|
12
|
+
/** False in the designer and wherever the modal does not belong. */
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
visibilityContext: VisibilityRuleContext;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_DELAY_MS = 3000;
|
|
18
|
+
const DEFAULT_SCROLL_PERCENT = 50;
|
|
19
|
+
const DEFAULT_IDLE_MS = 15000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Opens a modal without anyone clicking anything.
|
|
23
|
+
*
|
|
24
|
+
* Every branch below fires from an EFFECT, never during render. A modal opened
|
|
25
|
+
* while rendering would put a focus trap and an `aria-modal` claim into the
|
|
26
|
+
* server markup, and the client — which starts from an empty page-load state —
|
|
27
|
+
* would hydrate into a different tree. `overlay-bus.ts` backs this up by
|
|
28
|
+
* always reporting closed on the server, so a mistake here fails as a visible
|
|
29
|
+
* hydration warning rather than a subtly broken page.
|
|
30
|
+
*/
|
|
31
|
+
export const useModalAutoTrigger = ({
|
|
32
|
+
modal,
|
|
33
|
+
enabled,
|
|
34
|
+
visibilityContext
|
|
35
|
+
}: UseModalAutoTriggerOptions): void => {
|
|
36
|
+
const trigger = modal.autoTrigger;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Auth state is 'loading' on the first client render and settles a beat
|
|
40
|
+
* later. A rule saying "guests only" would be unsatisfied at exactly the
|
|
41
|
+
* moment a page-load trigger fires, so the effect waits rather than deciding
|
|
42
|
+
* against information it does not have yet.
|
|
43
|
+
*/
|
|
44
|
+
const authSettled = visibilityContext.authState !== 'loading';
|
|
45
|
+
|
|
46
|
+
// Read inside the handlers rather than captured, so a re-render does not
|
|
47
|
+
// rearm a timer that is already counting.
|
|
48
|
+
const stateRef = useRef({ modal, enabled, authSettled });
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
stateRef.current = { modal, enabled, authSettled };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const armed = Boolean(trigger) && enabled && authSettled;
|
|
54
|
+
const event = trigger?.event;
|
|
55
|
+
const frequency = trigger?.frequency || 'once-per-session';
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!armed || !event || event === 'none') return undefined;
|
|
59
|
+
if (typeof window === 'undefined') return undefined;
|
|
60
|
+
|
|
61
|
+
let done = false;
|
|
62
|
+
|
|
63
|
+
const fire = () => {
|
|
64
|
+
if (done) return;
|
|
65
|
+
|
|
66
|
+
const current = stateRef.current;
|
|
67
|
+
if (!current.enabled) return;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Never interrupt. A shopper who opened the size guide, or is inside
|
|
71
|
+
* the mini basket, did not ask for a newsletter over the top of it —
|
|
72
|
+
* and stacking two dialogs also puts two focus traps on the page, which
|
|
73
|
+
* is the exact failure `use-overlay.ts` exists to prevent.
|
|
74
|
+
*/
|
|
75
|
+
if (getOpenModalIds().length > 0) return;
|
|
76
|
+
|
|
77
|
+
if (!canAutoOpen(current.modal.id, frequency)) return;
|
|
78
|
+
|
|
79
|
+
done = true;
|
|
80
|
+
markAutoOpened(current.modal.id, frequency);
|
|
81
|
+
openModal(current.modal.id);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (event === 'page-load') {
|
|
85
|
+
// Still a task rather than an inline call: firing inside the effect body
|
|
86
|
+
// on mount would open before the first paint has settled.
|
|
87
|
+
const id = window.setTimeout(fire, 0);
|
|
88
|
+
|
|
89
|
+
return () => window.clearTimeout(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (event === 'delay') {
|
|
93
|
+
const id = window.setTimeout(
|
|
94
|
+
fire,
|
|
95
|
+
trigger?.delayMs ?? DEFAULT_DELAY_MS
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return () => window.clearTimeout(id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (event === 'idle') {
|
|
102
|
+
const wait = trigger?.delayMs ?? DEFAULT_IDLE_MS;
|
|
103
|
+
let id = window.setTimeout(fire, wait);
|
|
104
|
+
|
|
105
|
+
const restart = () => {
|
|
106
|
+
window.clearTimeout(id);
|
|
107
|
+
id = window.setTimeout(fire, wait);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const events = ['pointerdown', 'keydown', 'scroll', 'pointermove'];
|
|
111
|
+
events.forEach((name) =>
|
|
112
|
+
window.addEventListener(name, restart, { passive: true })
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return () => {
|
|
116
|
+
window.clearTimeout(id);
|
|
117
|
+
events.forEach((name) => window.removeEventListener(name, restart));
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (event === 'scroll-depth') {
|
|
122
|
+
const target = trigger?.scrollPercent ?? DEFAULT_SCROLL_PERCENT;
|
|
123
|
+
|
|
124
|
+
const check = () => {
|
|
125
|
+
const scrollable =
|
|
126
|
+
document.documentElement.scrollHeight - window.innerHeight;
|
|
127
|
+
// A page shorter than the viewport can never reach a depth, and
|
|
128
|
+
// dividing by zero would report Infinity and fire immediately.
|
|
129
|
+
if (scrollable <= 0) return;
|
|
130
|
+
|
|
131
|
+
const percent = (window.scrollY / scrollable) * 100;
|
|
132
|
+
if (percent >= target) fire();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
window.addEventListener('scroll', check, { passive: true });
|
|
136
|
+
check();
|
|
137
|
+
|
|
138
|
+
return () => window.removeEventListener('scroll', check);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (event === 'exit-intent') {
|
|
142
|
+
// Pointer leaving through the TOP of the viewport, which is the only
|
|
143
|
+
// direction that means "heading for the tab bar". A touch device has no
|
|
144
|
+
// such gesture and simply never fires this.
|
|
145
|
+
const handle = (nativeEvent: MouseEvent) => {
|
|
146
|
+
if (nativeEvent.clientY > 0) return;
|
|
147
|
+
if (nativeEvent.relatedTarget) return;
|
|
148
|
+
|
|
149
|
+
fire();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
document.addEventListener('mouseout', handle);
|
|
153
|
+
|
|
154
|
+
return () => document.removeEventListener('mouseout', handle);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (event === 'element-visible') {
|
|
158
|
+
const anchorId = trigger?.anchorId;
|
|
159
|
+
if (!anchorId || typeof IntersectionObserver === 'undefined') {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const element = document.getElementById(anchorId);
|
|
164
|
+
if (!element) return undefined;
|
|
165
|
+
|
|
166
|
+
const observer = new IntersectionObserver(
|
|
167
|
+
(entries) => {
|
|
168
|
+
if (entries.some((entry) => entry.isIntersecting)) fire();
|
|
169
|
+
},
|
|
170
|
+
{ threshold: 0.1 }
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
observer.observe(element);
|
|
174
|
+
|
|
175
|
+
return () => observer.disconnect();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return undefined;
|
|
179
|
+
}, [
|
|
180
|
+
armed,
|
|
181
|
+
event,
|
|
182
|
+
frequency,
|
|
183
|
+
trigger?.delayMs,
|
|
184
|
+
trigger?.scrollPercent,
|
|
185
|
+
trigger?.anchorId
|
|
186
|
+
]);
|
|
187
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { parseEditorMessage } from '../editor-bridge';
|
|
6
|
+
import { readThemeModal, ThemeModal } from './modal-registry';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The modal the editor is holding open in the canvas, as the editor currently
|
|
10
|
+
* has it — not as the server last saved it.
|
|
11
|
+
*
|
|
12
|
+
* The whole entry travels, not just an id, and that is the point. A modal being
|
|
13
|
+
* composed may not exist on the server at all (it was created a moment ago and
|
|
14
|
+
* nothing has been saved), and one that does exist is being restyled: its
|
|
15
|
+
* presentation, width and dismissal switches change under the designer's hands.
|
|
16
|
+
* Holding open a server-rendered surface would mean every one of those edits
|
|
17
|
+
* needed a save and a reload before it could be seen, which is not an editing
|
|
18
|
+
* loop anyone would use.
|
|
19
|
+
*
|
|
20
|
+
* Module state rather than component state for the same reason the overlay bus
|
|
21
|
+
* is: several consumers must agree on ONE answer, or two surfaces cover the
|
|
22
|
+
* canvas at once.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately NOT routed through the overlay bus. The bus is the shopper's
|
|
25
|
+
* page state; this is an editing mode. Keeping them apart is what lets the
|
|
26
|
+
* canvas hold a modal open with every dismissal inert while a real `openModal`
|
|
27
|
+
* on the same page still behaves like a real modal.
|
|
28
|
+
*/
|
|
29
|
+
let heldModal: ThemeModal | null = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Serialized form of what is held, so a repeated frame does not re-render and
|
|
33
|
+
* a changed one always does. Comparing ids would miss chrome edits — the exact
|
|
34
|
+
* updates this channel exists to deliver.
|
|
35
|
+
*/
|
|
36
|
+
let heldSignature = '';
|
|
37
|
+
|
|
38
|
+
const listeners = new Set<() => void>();
|
|
39
|
+
|
|
40
|
+
const setHeldModal = (modal: ThemeModal | null) => {
|
|
41
|
+
const signature = modal ? JSON.stringify(modal) : '';
|
|
42
|
+
if (signature === heldSignature) return;
|
|
43
|
+
|
|
44
|
+
heldSignature = signature;
|
|
45
|
+
heldModal = modal;
|
|
46
|
+
listeners.forEach((listener) => listener());
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let listening = false;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One document listener for the whole page, installed on first use.
|
|
53
|
+
*
|
|
54
|
+
* Per-mount listeners would mean N handlers parsing every frame the editor
|
|
55
|
+
* sends, on a channel that already carries every style keystroke.
|
|
56
|
+
*/
|
|
57
|
+
const startListening = () => {
|
|
58
|
+
if (listening || typeof window === 'undefined') return;
|
|
59
|
+
listening = true;
|
|
60
|
+
|
|
61
|
+
window.addEventListener('message', (event: MessageEvent) => {
|
|
62
|
+
const message = parseEditorMessage(event);
|
|
63
|
+
if (!message) return;
|
|
64
|
+
|
|
65
|
+
if (message.type === 'PREVIEW_OPEN_MODAL') {
|
|
66
|
+
const data = message.data as
|
|
67
|
+
| { modalId?: string; modal?: unknown }
|
|
68
|
+
| undefined;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `modal` is what the editor sends. The `modalId`-only fallback keeps a
|
|
72
|
+
* mismatched pair of builds usable rather than silently dead: the canvas
|
|
73
|
+
* shows the surface with default chrome instead of nothing at all.
|
|
74
|
+
*/
|
|
75
|
+
const modal =
|
|
76
|
+
readThemeModal(data?.modal) ??
|
|
77
|
+
(data?.modalId
|
|
78
|
+
? readThemeModal({ id: data.modalId, name: data.modalId })
|
|
79
|
+
: null);
|
|
80
|
+
|
|
81
|
+
setHeldModal(modal);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (message.type === 'PREVIEW_CLOSE_MODAL') {
|
|
86
|
+
setHeldModal(null);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The modal the editor is holding open, or null. */
|
|
92
|
+
export const useHeldModal = (): ThemeModal | null => {
|
|
93
|
+
/**
|
|
94
|
+
* Starts null on both sides. Nothing can have arrived before the first
|
|
95
|
+
* render — the listener is installed in the effect below — so the server and
|
|
96
|
+
* the first client render agree, and the effect picks up anything that lands
|
|
97
|
+
* between the two.
|
|
98
|
+
*/
|
|
99
|
+
const [modal, setModal] = useState<ThemeModal | null>(null);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
startListening();
|
|
103
|
+
|
|
104
|
+
const listener = () => setModal(heldModal);
|
|
105
|
+
listeners.add(listener);
|
|
106
|
+
// The frame may have arrived between render and this effect.
|
|
107
|
+
listener();
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
listeners.delete(listener);
|
|
111
|
+
};
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
return modal;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** True while the editor is holding THIS modal open in the canvas. */
|
|
118
|
+
export const useModalPreview = (modalId: string): boolean =>
|
|
119
|
+
useHeldModal()?.id === modalId;
|
|
120
|
+
|
|
121
|
+
/** Test-only: drop the listener registry and the held modal. */
|
|
122
|
+
export const __resetModalPreviewForTests = (): void => {
|
|
123
|
+
heldModal = null;
|
|
124
|
+
heldSignature = '';
|
|
125
|
+
listeners.clear();
|
|
126
|
+
listening = false;
|
|
127
|
+
};
|