@usequeek/theme-kit 0.1.0
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/README.md +37 -0
- package/api/collections.ts +93 -0
- package/api/orders.ts +23 -0
- package/apps/analytics-events.ts +32 -0
- package/apps/apps.css +613 -0
- package/apps/follow-card.tsx +192 -0
- package/apps/gtag.ts +19 -0
- package/apps/subscribe-form.tsx +124 -0
- package/client.ts +52 -0
- package/components/auth/auth-email-login-step.tsx +46 -0
- package/components/auth/auth-email-otp-step.tsx +54 -0
- package/components/auth/auth-email-otp-verify-step.tsx +82 -0
- package/components/auth/auth-email-register-step.tsx +59 -0
- package/components/auth/auth-flow.tsx +26 -0
- package/components/auth/auth-signup-step.tsx +53 -0
- package/components/blog/category-filter.tsx +92 -0
- package/components/blog/index.ts +12 -0
- package/components/blog/pagination.tsx +73 -0
- package/components/blog/post-categories.tsx +42 -0
- package/components/blog/post-meta.tsx +49 -0
- package/components/blog/related-posts.tsx +54 -0
- package/components/blog/share-buttons.tsx +89 -0
- package/components/cart/cart-panel-controller.tsx +67 -0
- package/components/cart/default-cart-shell.tsx +207 -0
- package/components/checkout/checkout-contact.tsx +84 -0
- package/components/checkout/default-checkout-shell.tsx +177 -0
- package/components/checkout/policy-links.tsx +98 -0
- package/components/image.tsx +149 -0
- package/components/markdown.tsx +54 -0
- package/components/product-media-frame.tsx +56 -0
- package/components/variant-picker.css +84 -0
- package/components/variant-picker.tsx +107 -0
- package/design-token-preview.tsx +81 -0
- package/edit-mode-active.tsx +324 -0
- package/edit-mode.tsx +80 -0
- package/hooks/use-address-autocomplete.ts +62 -0
- package/hooks/use-auth-flow.tsx +346 -0
- package/hooks/use-auth.ts +17 -0
- package/hooks/use-cart.ts +14 -0
- package/hooks/use-categories.ts +112 -0
- package/hooks/use-collection-params.ts +67 -0
- package/hooks/use-href.ts +15 -0
- package/hooks/use-posts.ts +75 -0
- package/hooks/use-product-detail.ts +69 -0
- package/hooks/use-product-questions.ts +77 -0
- package/hooks/use-product-sections.ts +165 -0
- package/hooks/use-products.ts +216 -0
- package/hooks/use-related-products.ts +56 -0
- package/hooks/use-resolve-coordinates.ts +48 -0
- package/hooks/use-reviews.ts +167 -0
- package/hooks/use-search.ts +55 -0
- package/hooks/use-shop-cart.ts +31 -0
- package/hooks/use-shop-params.ts +68 -0
- package/hooks/use-shop.ts +186 -0
- package/hooks/use-variant-selection.ts +98 -0
- package/index.ts +49 -0
- package/package.json +41 -0
- package/page-renderer.tsx +365 -0
- package/provider.tsx +91 -0
- package/sdk/client.ts +221 -0
- package/sdk/error-handler.ts +37 -0
- package/sdk/platform.ts +78 -0
- package/sdk/queek-client.ts +104 -0
- package/sdk/reauth-guard.ts +27 -0
- package/sdk/server-store-client.ts +165 -0
- package/shared-blocks/button.tsx +41 -0
- package/shared-blocks/callout.tsx +35 -0
- package/shared-blocks/content-default.tsx +23 -0
- package/shared-blocks/core-blocks.css +1792 -0
- package/shared-blocks/divider.tsx +17 -0
- package/shared-blocks/embed.tsx +40 -0
- package/shared-blocks/faq.tsx +44 -0
- package/shared-blocks/image.tsx +29 -0
- package/shared-blocks/index.ts +24 -0
- package/shared-blocks/manifest.ts +76 -0
- package/shared-blocks/product-qa.tsx +203 -0
- package/shared-blocks/quote.tsx +20 -0
- package/shared-blocks/reviews.tsx +436 -0
- package/shared-blocks/table.tsx +45 -0
- package/shared-blocks/video.tsx +60 -0
- package/stores/address-modal-store.ts +15 -0
- package/stores/auth-modal-store.ts +17 -0
- package/stores/cart-panel-store.ts +17 -0
- package/stores/cart-store.ts +172 -0
- package/stores/checkout-store.ts +167 -0
- package/stores/edit-preview-store.ts +61 -0
- package/stores/location-store.ts +41 -0
- package/stores/orders-sheet-store.ts +21 -0
- package/stores/policy-modal-store.ts +15 -0
- package/stores/product-modal-store.ts +18 -0
- package/stores/search-modal-store.ts +17 -0
- package/stores/toast-store.ts +46 -0
- package/stores/user-store.ts +88 -0
- package/theme-context.tsx +28 -0
- package/theme-variant-resolver.ts +44 -0
- package/types/block.ts +292 -0
- package/types/cart.ts +21 -0
- package/types/media.ts +55 -0
- package/types/menu.ts +27 -0
- package/types/order.ts +119 -0
- package/types/page.ts +86 -0
- package/types/product.ts +279 -0
- package/types/theme.ts +380 -0
- package/types/vendor.ts +243 -0
- package/utils/addon-selection.ts +31 -0
- package/utils/benefit-icon.tsx +145 -0
- package/utils/brand-palette.ts +64 -0
- package/utils/brand.ts +518 -0
- package/utils/contact-location.ts +44 -0
- package/utils/format.ts +64 -0
- package/utils/hero-content.ts +35 -0
- package/utils/image-srcset.ts +157 -0
- package/utils/markdown.tsx +237 -0
- package/utils/match-variant.ts +31 -0
- package/utils/menu-link.ts +41 -0
- package/utils/placeholder-resolver.ts +184 -0
- package/utils/product-media.ts +77 -0
- package/utils/product-normalizer.ts +323 -0
- package/utils/vendor-host.ts +173 -0
- package/utils/video-embed.ts +38 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Block } from './types/block';
|
|
4
|
+
import { useEditPreviewStore } from './stores/edit-preview-store';
|
|
5
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
6
|
+
import { useRouter } from 'next/navigation';
|
|
7
|
+
import { toJpeg } from 'html-to-image';
|
|
8
|
+
|
|
9
|
+
const MESSAGE_SOURCE = 'queek-storefront';
|
|
10
|
+
const MESSAGE_SOURCE_PARENT = 'queek-merchant';
|
|
11
|
+
|
|
12
|
+
// Longest edge of the captured JPEG, in CSS px. Keeps the resulting
|
|
13
|
+
// data-URI small (~50-150KB) so it can ride the qee chat POST body inline.
|
|
14
|
+
// TODO: upload the capture to media (media.usequeek.com) and send a URL
|
|
15
|
+
// instead of a data-URI once resume-persistence of attachments is needed.
|
|
16
|
+
const CAPTURE_MAX_EDGE = 1000;
|
|
17
|
+
|
|
18
|
+
async function captureBlock(el: HTMLElement): Promise<string> {
|
|
19
|
+
const rect = el.getBoundingClientRect();
|
|
20
|
+
const longest = Math.max(rect.width, rect.height) || 1;
|
|
21
|
+
const pixelRatio = Math.min(1, CAPTURE_MAX_EDGE / longest);
|
|
22
|
+
return toJpeg(el, {
|
|
23
|
+
quality: 0.85,
|
|
24
|
+
pixelRatio,
|
|
25
|
+
// Re-fetch cross-origin (CDN) images so html-to-image inlines them as
|
|
26
|
+
// data-URLs before rasterising — media.usequeek.com sends
|
|
27
|
+
// `access-control-allow-origin: *`, so the canvas stays untainted.
|
|
28
|
+
cacheBust: true,
|
|
29
|
+
fetchRequestInit: { mode: 'cors' },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let selectedIndex: number | null = null;
|
|
34
|
+
const listeners = new Set<(idx: number | null) => void>();
|
|
35
|
+
|
|
36
|
+
function setSelectedIndex(next: number | null): void {
|
|
37
|
+
selectedIndex = next;
|
|
38
|
+
listeners.forEach((fn) => fn(next));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function useSelectedIndex(): number | null {
|
|
42
|
+
const [value, setValue] = useState<number | null>(selectedIndex);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
listeners.add(setValue);
|
|
45
|
+
return () => {
|
|
46
|
+
listeners.delete(setValue);
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Which sidebar tab the merchant is on — streamed in from the parent so the
|
|
53
|
+
// per-block overlay can offer the *other* one ("Section" while on AI, "AI"
|
|
54
|
+
// everywhere else) without the storefront needing its own tab state.
|
|
55
|
+
let activeTabIsAi = false;
|
|
56
|
+
const tabListeners = new Set<(isAi: boolean) => void>();
|
|
57
|
+
|
|
58
|
+
function setActiveTabIsAi(next: boolean): void {
|
|
59
|
+
activeTabIsAi = next;
|
|
60
|
+
tabListeners.forEach((fn) => fn(next));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function useActiveTabIsAi(): boolean {
|
|
64
|
+
const [value, setValue] = useState(activeTabIsAi);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
tabListeners.add(setValue);
|
|
67
|
+
return () => {
|
|
68
|
+
tabListeners.delete(setValue);
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function postBlockMessage(payload: Record<string, unknown>): void {
|
|
75
|
+
if (typeof window === 'undefined') return;
|
|
76
|
+
|
|
77
|
+
// `window.parent !== window` is true for the web dashboard's real
|
|
78
|
+
// `<iframe>` embed. The Queek mobile app instead loads the storefront as a
|
|
79
|
+
// top-level document inside a WebView, so that check alone left it with no
|
|
80
|
+
// way to receive block:click/block:add/block:delete — the storefront
|
|
81
|
+
// simply never posted. Mobile appends `&host=native` to the preview URL to
|
|
82
|
+
// opt in explicitly; the web embed doesn't set it and keeps working as
|
|
83
|
+
// before either way.
|
|
84
|
+
const isEmbedded = window.parent !== window || new URLSearchParams(location.search).get('host') === 'native';
|
|
85
|
+
if (!isEmbedded) return;
|
|
86
|
+
|
|
87
|
+
// Target origin '*' is fine — payload is non-sensitive block metadata;
|
|
88
|
+
// the merchant filters by `source` on receive.
|
|
89
|
+
window.parent.postMessage({ source: MESSAGE_SOURCE, ...payload }, '*');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let parentListenerInstalled = false;
|
|
93
|
+
function ensureParentListener(): void {
|
|
94
|
+
if (parentListenerInstalled || typeof window === 'undefined') return;
|
|
95
|
+
parentListenerInstalled = true;
|
|
96
|
+
window.addEventListener('message', (e: MessageEvent) => {
|
|
97
|
+
const data = e.data;
|
|
98
|
+
if (!data || typeof data !== 'object' || data.source !== MESSAGE_SOURCE_PARENT) return;
|
|
99
|
+
if (data.type === 'block:select') {
|
|
100
|
+
setSelectedIndex(typeof data.index === 'number' ? data.index : null);
|
|
101
|
+
} else if (data.type === 'tab:active') {
|
|
102
|
+
setActiveTabIsAi(Boolean(data.isAi));
|
|
103
|
+
} else if (data.type === 'blocks:set') {
|
|
104
|
+
// The merchant's current draft sections, whole. One message covers a field
|
|
105
|
+
// edit, a reorder, an insert and a delete — no index arithmetic to drift.
|
|
106
|
+
if (Array.isArray(data.blocks)) useEditPreviewStore.getState().setBlocks(data.blocks as Block[]);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
// This whole module is a next/dynamic chunk, fetched separately from the
|
|
110
|
+
// main storefront bundle — it can finish loading AFTER the merchant's
|
|
111
|
+
// iframe `onLoad` (which fires on the MAIN document's load, unrelated to
|
|
112
|
+
// this chunk) already broadcast block:select/tab:active once. A message
|
|
113
|
+
// sent before this listener existed is just gone — postMessage doesn't
|
|
114
|
+
// queue for a not-yet-registered listener. Announcing readiness here lets
|
|
115
|
+
// the merchant re-send the current state on its own schedule instead of
|
|
116
|
+
// guessing ours, closing that race instead of hoping onLoad wins it.
|
|
117
|
+
postBlockMessage({ type: 'ready' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function ActiveBlock({
|
|
121
|
+
index,
|
|
122
|
+
type,
|
|
123
|
+
variant,
|
|
124
|
+
children,
|
|
125
|
+
}: {
|
|
126
|
+
index: number;
|
|
127
|
+
type: string;
|
|
128
|
+
variant?: string;
|
|
129
|
+
children: ReactNode;
|
|
130
|
+
}): JSX.Element {
|
|
131
|
+
const elRef = useRef<HTMLDivElement>(null);
|
|
132
|
+
const selected = useSelectedIndex();
|
|
133
|
+
const isSelected = selected === index;
|
|
134
|
+
const isAiTab = useActiveTabIsAi();
|
|
135
|
+
const [capturing, setCapturing] = useState(false);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
ensureParentListener();
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!isSelected || !elRef.current) return;
|
|
143
|
+
elRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
144
|
+
}, [isSelected]);
|
|
145
|
+
|
|
146
|
+
// Screenshot THIS section locally (edit mode only — the whole component tree
|
|
147
|
+
// only renders inside the editor iframe) and hand the JPEG data-URI to the
|
|
148
|
+
// merchant, which turns it into a pending qee-chat attachment.
|
|
149
|
+
const captureToQee = async (): Promise<void> => {
|
|
150
|
+
const el = elRef.current;
|
|
151
|
+
if (!el || capturing) return;
|
|
152
|
+
setCapturing(true);
|
|
153
|
+
try {
|
|
154
|
+
const dataUrl = await captureBlock(el);
|
|
155
|
+
postBlockMessage({ type: 'block:captured', index, dataUrl });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
postBlockMessage({
|
|
158
|
+
type: 'block:captured',
|
|
159
|
+
index,
|
|
160
|
+
error: err instanceof Error ? err.message : 'Capture failed',
|
|
161
|
+
});
|
|
162
|
+
} finally {
|
|
163
|
+
setCapturing(false);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
ref={elRef}
|
|
170
|
+
data-qk-block-index={index}
|
|
171
|
+
data-qk-block-type={type}
|
|
172
|
+
data-qk-selected={isSelected ? '1' : undefined}
|
|
173
|
+
className="qk-editable-block"
|
|
174
|
+
onClick={(e) => {
|
|
175
|
+
e.stopPropagation();
|
|
176
|
+
postBlockMessage({ type: 'block:click', index, blockType: type, variant });
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
{children}
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
className="qk-editable-capture"
|
|
183
|
+
title="Send this section to qee"
|
|
184
|
+
disabled={capturing}
|
|
185
|
+
onClick={(e) => {
|
|
186
|
+
e.stopPropagation();
|
|
187
|
+
void captureToQee();
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
{capturing ? (
|
|
191
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
192
|
+
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" strokeOpacity="0.25" />
|
|
193
|
+
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
194
|
+
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="0.7s" repeatCount="indefinite" />
|
|
195
|
+
</path>
|
|
196
|
+
</svg>
|
|
197
|
+
) : (
|
|
198
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
199
|
+
<path d="M4 8.5A1.5 1.5 0 0 1 5.5 7h1.2a1.5 1.5 0 0 0 1.25-.67l.6-.9A1.5 1.5 0 0 1 10.8 4.8h2.4a1.5 1.5 0 0 1 1.25.67l.6.9A1.5 1.5 0 0 0 16.3 7h1.2A1.5 1.5 0 0 1 19 8.5v8A1.5 1.5 0 0 1 17.5 18h-11A1.5 1.5 0 0 1 5 16.5v-8Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
|
|
200
|
+
<circle cx="12" cy="12" r="2.75" stroke="currentColor" strokeWidth="2" />
|
|
201
|
+
</svg>
|
|
202
|
+
)}
|
|
203
|
+
</button>
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
className="qk-editable-add"
|
|
207
|
+
title="Add section below"
|
|
208
|
+
onClick={(e) => {
|
|
209
|
+
e.stopPropagation();
|
|
210
|
+
postBlockMessage({ type: 'block:add', insertIndex: index + 1 });
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
+
|
|
214
|
+
</button>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
className="qk-editable-delete"
|
|
218
|
+
title="Delete this section"
|
|
219
|
+
onClick={(e) => {
|
|
220
|
+
e.stopPropagation();
|
|
221
|
+
postBlockMessage({ type: 'block:delete', index });
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
225
|
+
<path d="M5 7h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
226
|
+
<path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
227
|
+
<path d="M6.5 7 7.2 18.2A1.5 1.5 0 0 0 8.7 19.6h6.6a1.5 1.5 0 0 0 1.5-1.4L17.5 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
228
|
+
<path d="M10 10.5v6M14 10.5v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
229
|
+
</svg>
|
|
230
|
+
</button>
|
|
231
|
+
{isAiTab ? (
|
|
232
|
+
// Only shown on the AI tab: the reverse direction (Sections -> AI)
|
|
233
|
+
// already exists as the capture button above, which does strictly
|
|
234
|
+
// more (attaches a screenshot) — a second "go to AI" affordance here
|
|
235
|
+
// would just be a redundant, confusing near-duplicate of it.
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
className="qk-editable-editsection"
|
|
239
|
+
title="Edit this section's settings"
|
|
240
|
+
onClick={(e) => {
|
|
241
|
+
e.stopPropagation();
|
|
242
|
+
postBlockMessage({ type: 'block:editSection', index });
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
246
|
+
<rect x="4" y="4" width="16" height="6" rx="1.5" stroke="currentColor" strokeWidth="2" />
|
|
247
|
+
<rect x="4" y="14" width="16" height="6" rx="1.5" stroke="currentColor" strokeWidth="2" />
|
|
248
|
+
</svg>
|
|
249
|
+
</button>
|
|
250
|
+
) : null}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function ActiveRegion({
|
|
256
|
+
region,
|
|
257
|
+
children,
|
|
258
|
+
}: {
|
|
259
|
+
region: string;
|
|
260
|
+
children: ReactNode;
|
|
261
|
+
}): JSX.Element {
|
|
262
|
+
// `display: contents` (see storefront.css) — this wrapper draws no box, so it
|
|
263
|
+
// never disturbs the theme's sticky header / flex footer layout. The click
|
|
264
|
+
// still fires because the element stays in the DOM event path; hover outline
|
|
265
|
+
// is applied to the wrapped child via CSS.
|
|
266
|
+
return (
|
|
267
|
+
<div
|
|
268
|
+
data-qk-region={region}
|
|
269
|
+
className="qk-editable-region"
|
|
270
|
+
onClick={(e) => {
|
|
271
|
+
e.stopPropagation();
|
|
272
|
+
// The announcement bar lives inside the header region; every theme marks
|
|
273
|
+
// its container with a `*-announcement` class, so a click there
|
|
274
|
+
// deep-links straight to the Announcement editor. The `:not` excludes
|
|
275
|
+
// the `*-nav--has-announcement` modifier the <header> itself carries
|
|
276
|
+
// when the bar is enabled — otherwise plain nav clicks would match too.
|
|
277
|
+
const target =
|
|
278
|
+
region === 'header' &&
|
|
279
|
+
(e.target as HTMLElement | null)?.closest?.(
|
|
280
|
+
'[class*="announcement"]:not([class*="has-announcement"])',
|
|
281
|
+
)
|
|
282
|
+
? 'announcement'
|
|
283
|
+
: region;
|
|
284
|
+
postBlockMessage({ type: 'region:click', region: target });
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
{children}
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function ActiveBridge(): null {
|
|
293
|
+
const router = useRouter();
|
|
294
|
+
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
// Suppress in-iframe navigation while editing — clicks on links and
|
|
297
|
+
// form submits inside the preview should not navigate the frame away.
|
|
298
|
+
const onClick = (e: MouseEvent) => {
|
|
299
|
+
const a = (e.target as HTMLElement | null)?.closest?.('a');
|
|
300
|
+
if (a) e.preventDefault();
|
|
301
|
+
};
|
|
302
|
+
const onSubmit = (e: Event) => e.preventDefault();
|
|
303
|
+
document.addEventListener('click', onClick, true);
|
|
304
|
+
document.addEventListener('submit', onSubmit, true);
|
|
305
|
+
|
|
306
|
+
const onMessage = (e: MessageEvent) => {
|
|
307
|
+
const data = e.data;
|
|
308
|
+
if (!data || typeof data !== 'object' || data.source !== MESSAGE_SOURCE_PARENT) return;
|
|
309
|
+
if (data.type === 'refresh') {
|
|
310
|
+
useEditPreviewStore.getState().markRefreshRequested();
|
|
311
|
+
router.refresh();
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
window.addEventListener('message', onMessage);
|
|
315
|
+
|
|
316
|
+
return () => {
|
|
317
|
+
document.removeEventListener('click', onClick, true);
|
|
318
|
+
document.removeEventListener('submit', onSubmit, true);
|
|
319
|
+
window.removeEventListener('message', onMessage);
|
|
320
|
+
};
|
|
321
|
+
}, [router]);
|
|
322
|
+
|
|
323
|
+
return null;
|
|
324
|
+
}
|
package/edit-mode.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
4
|
+
import dynamic from 'next/dynamic';
|
|
5
|
+
|
|
6
|
+
const QK_EDIT_FLAG = 'edit';
|
|
7
|
+
|
|
8
|
+
// Lightweight: reads `?edit=1` once on mount. Always shipped to the
|
|
9
|
+
// storefront bundle, but cheap (one URLSearchParams parse) and adds no
|
|
10
|
+
// runtime overhead for public visitors.
|
|
11
|
+
export function useEditMode(): boolean {
|
|
12
|
+
const [isEdit, setIsEdit] = useState(false);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (typeof window === 'undefined') return;
|
|
16
|
+
setIsEdit(new URLSearchParams(window.location.search).get(QK_EDIT_FLAG) === '1');
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
return isEdit;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The heavy edit-mode chunk (postMessage bridge, scroll-into-view, link
|
|
23
|
+
// suppression, router.refresh) is split into edit-mode-active.tsx and only
|
|
24
|
+
// loaded for users with `?edit=1`. Public visitors never download it.
|
|
25
|
+
const ActiveBlock = dynamic(
|
|
26
|
+
() => import('./edit-mode-active').then((m) => m.ActiveBlock),
|
|
27
|
+
{ ssr: false, loading: () => null },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const ActiveBridge = dynamic(
|
|
31
|
+
() => import('./edit-mode-active').then((m) => m.ActiveBridge),
|
|
32
|
+
{ ssr: false, loading: () => null },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const ActiveRegion = dynamic(
|
|
36
|
+
() => import('./edit-mode-active').then((m) => m.ActiveRegion),
|
|
37
|
+
{ ssr: false, loading: () => null },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export function EditableBlock({
|
|
41
|
+
index,
|
|
42
|
+
type,
|
|
43
|
+
variant,
|
|
44
|
+
enabled,
|
|
45
|
+
children,
|
|
46
|
+
}: {
|
|
47
|
+
index: number;
|
|
48
|
+
type: string;
|
|
49
|
+
variant?: string;
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
children: ReactNode;
|
|
52
|
+
}): JSX.Element {
|
|
53
|
+
if (!enabled) return <>{children}</>;
|
|
54
|
+
return (
|
|
55
|
+
<ActiveBlock index={index} type={type} variant={variant}>
|
|
56
|
+
{children}
|
|
57
|
+
</ActiveBlock>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Static-region click-to-edit (header / footer). Self-gates on `?edit=1` like
|
|
62
|
+
// EditModeBridge, so public visitors render children untouched and never load
|
|
63
|
+
// the active chunk.
|
|
64
|
+
export function EditableRegion({
|
|
65
|
+
region,
|
|
66
|
+
children,
|
|
67
|
+
}: {
|
|
68
|
+
region: string;
|
|
69
|
+
children: ReactNode;
|
|
70
|
+
}): JSX.Element {
|
|
71
|
+
const isEdit = useEditMode();
|
|
72
|
+
if (!isEdit) return <>{children}</>;
|
|
73
|
+
return <ActiveRegion region={region}>{children}</ActiveRegion>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function EditModeBridge(): JSX.Element | null {
|
|
77
|
+
const isEdit = useEditMode();
|
|
78
|
+
if (!isEdit) return null;
|
|
79
|
+
return <ActiveBridge />;
|
|
80
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useStorefront } from '../provider';
|
|
5
|
+
import { getQueekClient } from '../sdk/queek-client';
|
|
6
|
+
|
|
7
|
+
export interface AddressSuggestion {
|
|
8
|
+
description: string;
|
|
9
|
+
place_id: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEBOUNCE_MS = 300;
|
|
13
|
+
|
|
14
|
+
export function useAddressAutocomplete(query: string): {
|
|
15
|
+
suggestions: AddressSuggestion[];
|
|
16
|
+
loading: boolean;
|
|
17
|
+
} {
|
|
18
|
+
const { vendor } = useStorefront();
|
|
19
|
+
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!query || query.trim().length < 2) {
|
|
24
|
+
setSuggestions([]);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
setLoading(true);
|
|
31
|
+
const client = getQueekClient(vendor.slug ?? undefined);
|
|
32
|
+
const path = `/client/address/autocomplete?query=${encodeURIComponent(query.trim())}`;
|
|
33
|
+
|
|
34
|
+
client
|
|
35
|
+
.get<AddressSuggestion[] | { data: AddressSuggestion[] }>(path)
|
|
36
|
+
.then((res) => {
|
|
37
|
+
if (cancelled) return;
|
|
38
|
+
// Backend returns a bare array (not wrapped in envelope). SDK returns
|
|
39
|
+
// whatever the server sent — handle both shapes defensively.
|
|
40
|
+
const list: unknown = Array.isArray(res)
|
|
41
|
+
? res
|
|
42
|
+
: Array.isArray((res as { data?: unknown })?.data)
|
|
43
|
+
? (res as { data: AddressSuggestion[] }).data
|
|
44
|
+
: [];
|
|
45
|
+
setSuggestions(list as AddressSuggestion[]);
|
|
46
|
+
})
|
|
47
|
+
.catch(() => {
|
|
48
|
+
if (!cancelled) setSuggestions([]);
|
|
49
|
+
})
|
|
50
|
+
.finally(() => {
|
|
51
|
+
if (!cancelled) setLoading(false);
|
|
52
|
+
});
|
|
53
|
+
}, DEBOUNCE_MS);
|
|
54
|
+
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
};
|
|
59
|
+
}, [query, vendor.slug]);
|
|
60
|
+
|
|
61
|
+
return { suggestions, loading };
|
|
62
|
+
}
|