@pack/react 4.3.4 → 4.5.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/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/pack/pack-context.d.ts +2 -10
- package/dist/pack/pack-context.d.ts.map +1 -1
- package/dist/pack/pack-context.js +2 -2
- package/dist/pack/preview-toolbar.d.ts +47 -0
- package/dist/pack/preview-toolbar.d.ts.map +1 -0
- package/dist/pack/preview-toolbar.js +730 -0
- package/dist/use-customizer-shell.d.ts.map +1 -1
- package/dist/use-customizer-shell.js +20 -9
- package/dist/use-playbook-cart.d.ts +62 -0
- package/dist/use-playbook-cart.d.ts.map +1 -0
- package/dist/use-playbook-cart.js +87 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/pack/preview-toast.d.ts +0 -20
- package/dist/pack/preview-toast.d.ts.map +0 -1
- package/dist/pack/preview-toast.js +0 -140
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ import { usePackContext } from "./pack/pack-context";
|
|
|
4
4
|
import { useSiteSettings } from "./use-site-settings";
|
|
5
5
|
import { useLocale } from "./use-locale";
|
|
6
6
|
import { useOverlayScript } from "./use-overlay-script";
|
|
7
|
+
import { usePlaybookCart } from "./use-playbook-cart";
|
|
7
8
|
import { registerSection } from "./register-section";
|
|
8
9
|
import { registerStorefrontSettingsSchema } from "./register-storefront-settings-schema";
|
|
9
|
-
export { PackProvider, PreviewProvider, RenderSection, RenderSections, registerSection, registerStorefrontSettingsSchema, useLocale, useOverlayScript, usePackContext, useSections, useSiteSettings, };
|
|
10
|
+
export { PackProvider, PreviewProvider, RenderSection, RenderSections, registerSection, registerStorefrontSettingsSchema, useLocale, useOverlayScript, usePackContext, usePlaybookCart, useSections, useSiteSettings, };
|
|
11
|
+
export type { PlaybookCartAdd, PlaybookCartInput } from "./use-playbook-cart";
|
|
10
12
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,cAAc,EACd,WAAW,EACZ,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,gCAAgC,EAAE,MAAM,uCAAuC,CAAC;AAEzF,OAAO,EACL,YAAY,EACZ,eAAe,EACf,aAAa,EACb,cAAc,EACd,eAAe,EACf,gCAAgC,EAChC,SAAS,EACT,gBAAgB,EAChB,cAAc,EACd,WAAW,EACX,eAAe,GAChB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,cAAc,EACd,WAAW,EACZ,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,gCAAgC,EAAE,MAAM,uCAAuC,CAAC;AAEzF,OAAO,EACL,YAAY,EACZ,eAAe,EACf,aAAa,EACb,cAAc,EACd,eAAe,EACf,gCAAgC,EAChC,SAAS,EACT,gBAAgB,EAChB,cAAc,EACd,eAAe,EACf,WAAW,EACX,eAAe,GAChB,CAAC;AAEF,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { usePackContext } from "./pack/pack-context";
|
|
|
4
4
|
import { useSiteSettings } from "./use-site-settings";
|
|
5
5
|
import { useLocale } from "./use-locale";
|
|
6
6
|
import { useOverlayScript } from "./use-overlay-script";
|
|
7
|
+
import { usePlaybookCart } from "./use-playbook-cart";
|
|
7
8
|
import { registerSection } from "./register-section";
|
|
8
9
|
import { registerStorefrontSettingsSchema } from "./register-storefront-settings-schema";
|
|
9
|
-
export { PackProvider, PreviewProvider, RenderSection, RenderSections, registerSection, registerStorefrontSettingsSchema, useLocale, useOverlayScript, usePackContext, useSections, useSiteSettings, };
|
|
10
|
+
export { PackProvider, PreviewProvider, RenderSection, RenderSections, registerSection, registerStorefrontSettingsSchema, useLocale, useOverlayScript, usePackContext, usePlaybookCart, useSections, useSiteSettings, };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { type ReactNode } from "react";
|
|
2
|
+
import { type PreviewContext } from "./preview-toolbar";
|
|
2
3
|
import "./pack-debug";
|
|
3
4
|
type PackContextValue = {
|
|
4
5
|
isPreview: boolean;
|
|
@@ -7,16 +8,7 @@ type PackContextValue = {
|
|
|
7
8
|
src?: string;
|
|
8
9
|
version?: string;
|
|
9
10
|
};
|
|
10
|
-
previewContext?:
|
|
11
|
-
linkName?: string;
|
|
12
|
-
releaseId?: string;
|
|
13
|
-
releaseName?: string;
|
|
14
|
-
locale?: string;
|
|
15
|
-
pageDraft?: string;
|
|
16
|
-
siteSettingsDraft?: string;
|
|
17
|
-
testHandle?: string;
|
|
18
|
-
testVariantHandle?: string;
|
|
19
|
-
};
|
|
11
|
+
previewContext?: PreviewContext;
|
|
20
12
|
[key: string]: any;
|
|
21
13
|
};
|
|
22
14
|
siteSettings: any;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pack-context.d.ts","sourceRoot":"","sources":["../../src/pack/pack-context.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAKZ,KAAK,SAAS,EAEf,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"pack-context.d.ts","sourceRoot":"","sources":["../../src/pack/pack-context.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAKZ,KAAK,SAAS,EAEf,MAAM,OAAO,CAAC;AAEf,OAAO,EAAkB,KAAK,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGxE,OAAO,cAAc,CAAC;AAEtB,KAAK,gBAAgB,GAAG;IACtB,SAAS,EAAE,OAAO,CAAC;IACnB,cAAc,CAAC,EAAE;QACf,OAAO,CAAC,EAAE;YACR,GAAG,CAAC,EAAE,MAAM,CAAC;YACb,OAAO,CAAC,EAAE,MAAM,CAAC;SAClB,CAAC;QACF,cAAc,CAAC,EAAE,cAAc,CAAC;QAChC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;KACpB,CAAC;IACF,YAAY,EAAE,GAAG,CAAC;IAClB,eAAe,EAAE,CAAC,YAAY,EAAE,GAAG,KAAK,IAAI,CAAC;IAC7C,WAAW,EAAE,GAAG,CAAC;IACjB,cAAc,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,IAAI,CAAC;IACvC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,MAAM,EAAE;QACN,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,MAAM,CAAC;KACjB,GAAG,IAAI,CAAC;IACT,SAAS,EAAE,CACT,MAAM,EAAE;QACN,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,MAAM,CAAC;KACjB,GAAG,IAAI,KACL,IAAI,CAAC;CACX,CAAC;AAEF,eAAO,MAAM,WAAW,iCAUtB,CAAC;AAEH,eAAO,MAAM,cAAc,wBAAgC,CAAC;AAE5D,UAAU,gBAAgB;IACxB,QAAQ,EAAE,SAAS,CAAC;IACpB,YAAY,EAAE,GAAG,CAAC;IAClB,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,cAAc,CAAC,EAAE,gBAAgB,CAAC,gBAAgB,CAAC,CAAC;IACpD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,MAAM,CAAC,EAAE;QACP,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,MAAM,CAAC;KACjB,GAAG,IAAI,CAAC;IACT,WAAW,CAAC,EAAE,GAAG,CAAC;CACnB;AAUD,wBAAgB,YAAY,CAAC,EAC3B,QAAQ,EACR,oBAA4B,EAC5B,YAAY,EAAE,yBAAyB,EACvC,cAAc,EACd,kBAAuB,EACvB,MAAM,EAAE,aAAoB,GAC7B,EAAE,gBAAgB,qBAuDlB;AAED,eAAO,MAAM,eAAe,qBAAe,CAAC"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { createContext, useContext, useState, useMemo, useEffect, } from "react";
|
|
2
2
|
import { usePackTrack } from "./pack-analytics";
|
|
3
|
-
import {
|
|
3
|
+
import { PreviewToolbar } from "./preview-toolbar";
|
|
4
4
|
import { useOverlayScript } from "../use-overlay-script";
|
|
5
5
|
import { useCustomizerShell } from "../use-customizer-shell";
|
|
6
6
|
import "./pack-debug";
|
|
@@ -56,6 +56,6 @@ export function PackProvider({ children, isPreviewModeEnabled = false, siteSetti
|
|
|
56
56
|
return (React.createElement(PackContext.Provider, { value: value },
|
|
57
57
|
React.createElement(CustomizerShell, null),
|
|
58
58
|
children,
|
|
59
|
-
React.createElement(
|
|
59
|
+
React.createElement(PreviewToolbar, { isPreviewModeEnabled: !!isPreviewModeEnabled, environment: value.contentEnvironment, previewContext: customizerMeta?.previewContext })));
|
|
60
60
|
}
|
|
61
61
|
export const PreviewProvider = PackProvider;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type CSSProperties } from "react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
export interface PreviewContext {
|
|
4
|
+
linkName?: string;
|
|
5
|
+
releaseId?: string;
|
|
6
|
+
releaseName?: string;
|
|
7
|
+
locale?: string;
|
|
8
|
+
pageDraft?: string;
|
|
9
|
+
siteSettingsDraft?: string;
|
|
10
|
+
testHandle?: string;
|
|
11
|
+
testVariantHandle?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface PreviewToolbarProps {
|
|
14
|
+
isPreviewModeEnabled: boolean;
|
|
15
|
+
environment?: string;
|
|
16
|
+
previewContext?: PreviewContext;
|
|
17
|
+
}
|
|
18
|
+
export type PreviewDetail = {
|
|
19
|
+
label: string;
|
|
20
|
+
value: string;
|
|
21
|
+
};
|
|
22
|
+
type ToolbarEdge = "top" | "right" | "bottom" | "left";
|
|
23
|
+
type ToolbarRect = {
|
|
24
|
+
top: number;
|
|
25
|
+
left: number;
|
|
26
|
+
right: number;
|
|
27
|
+
bottom: number;
|
|
28
|
+
};
|
|
29
|
+
type ToolbarViewport = {
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
};
|
|
33
|
+
export declare function buildPreviewDetails(environment?: string, previewContext?: PreviewContext): PreviewDetail[];
|
|
34
|
+
export declare function buildPreviewSummary(environment?: string, previewContext?: PreviewContext): string;
|
|
35
|
+
export declare function getConstrainedPillPosition(position: {
|
|
36
|
+
left: number;
|
|
37
|
+
top: number;
|
|
38
|
+
}, viewport: ToolbarViewport): CSSProperties;
|
|
39
|
+
export declare function getPanelPosition(edge: ToolbarEdge, pillRect: ToolbarRect | null, viewport: ToolbarViewport): CSSProperties;
|
|
40
|
+
/**
|
|
41
|
+
* Thin gate so production storefront pages (preview off) mount no listeners,
|
|
42
|
+
* timers, storage reads, or effects. The top-window check is a hydration-safe
|
|
43
|
+
* client-only snapshot; it renders nothing on the server and during hydration.
|
|
44
|
+
*/
|
|
45
|
+
export declare const PreviewToolbar: ({ isPreviewModeEnabled, environment, previewContext, }: PreviewToolbarProps) => React.JSX.Element | null;
|
|
46
|
+
export {};
|
|
47
|
+
//# sourceMappingURL=preview-toolbar.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preview-toolbar.d.ts","sourceRoot":"","sources":["../../src/pack/preview-toolbar.tsx"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,aAAa,EAUnB,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAK/B,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,mBAAmB;IAClC,oBAAoB,EAAE,OAAO,CAAC;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAUF,KAAK,WAAW,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEvD,KAAK,WAAW,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAChF,KAAK,eAAe,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AA4CzD,wBAAgB,mBAAmB,CACjC,WAAW,CAAC,EAAE,MAAM,EACpB,cAAc,CAAC,EAAE,cAAc,GAC9B,aAAa,EAAE,CA2CjB;AAED,wBAAgB,mBAAmB,CACjC,WAAW,CAAC,EAAE,MAAM,EACpB,cAAc,CAAC,EAAE,cAAc,UAqBhC;AAsID,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,EACvC,QAAQ,EAAE,eAAe,GACxB,aAAa,CAOf;AA0DD,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,WAAW,EACjB,QAAQ,EAAE,WAAW,GAAG,IAAI,EAC5B,QAAQ,EAAE,eAAe,GACxB,aAAa,CA6Cf;AAorBD;;;;GAIG;AACH,eAAO,MAAM,cAAc,2DAIxB,mBAAmB,6BAerB,CAAC"}
|
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
4
|
+
const PREVIEW_TOOLBAR_DOCK_KEY = "pack-preview-toolbar-dock";
|
|
5
|
+
const TOOLBAR_EDGE_MARGIN = 16;
|
|
6
|
+
const TOOLBAR_PILL_SIZE = 48;
|
|
7
|
+
const TOOLBAR_PANEL_GAP = 12;
|
|
8
|
+
const TOOLBAR_DRAG_THRESHOLD = 4;
|
|
9
|
+
const TOOLBAR_PILL_TRANSITION_MS = 220;
|
|
10
|
+
const TOOLBAR_MIN_TAP_TARGET = 44;
|
|
11
|
+
const DEFAULT_TOOLBAR_DOCK = { edge: "bottom", offset: 0.5 };
|
|
12
|
+
const Eye = (props) => (React.createElement("svg", { width: 24, height: 24, viewBox: "0 0 24 24", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", ...props },
|
|
13
|
+
React.createElement("path", { fillRule: "evenodd", clipRule: "evenodd", d: "M2.75 12c0-.338.136-.905.493-1.591a7.858 7.858 0 0 1 1.64-2.108C6.374 6.926 8.694 5.75 12 5.75c3.308 0 5.627 1.176 7.116 2.551a7.86 7.86 0 0 1 1.64 2.108c.358.686.494 1.253.494 1.591 0 .338-.136.905-.493 1.591a7.86 7.86 0 0 1-1.64 2.108c-1.49 1.375-3.81 2.551-7.117 2.551-3.308 0-5.627-1.176-7.116-2.551a7.858 7.858 0 0 1-1.64-2.108c-.358-.686-.494-1.253-.494-1.591ZM12 4.25c-3.692 0-6.373 1.324-8.134 2.949a9.356 9.356 0 0 0-1.953 2.517c-.424.814-.663 1.622-.663 2.284 0 .662.24 1.47.663 2.284a9.356 9.356 0 0 0 1.953 2.517C5.627 18.426 8.308 19.75 12 19.75s6.373-1.324 8.134-2.949a9.356 9.356 0 0 0 1.953-2.517c.424-.814.663-1.622.663-2.284 0-.662-.24-1.47-.663-2.284a9.355 9.355 0 0 0-1.953-2.517C18.373 5.574 15.692 4.25 12 4.25ZM9.75 12a2.25 2.25 0 1 1 4.5 0 2.25 2.25 0 0 1-4.5 0ZM12 8.25a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5Z", fill: "currentColor" })));
|
|
14
|
+
function getPreviewContextLabel(previewContext) {
|
|
15
|
+
if (previewContext?.linkName) {
|
|
16
|
+
return `preview link: ${previewContext.linkName}`;
|
|
17
|
+
}
|
|
18
|
+
if (previewContext?.releaseName) {
|
|
19
|
+
return `content release: ${previewContext.releaseName}`;
|
|
20
|
+
}
|
|
21
|
+
return previewContext?.releaseId ? "content release" : undefined;
|
|
22
|
+
}
|
|
23
|
+
export function buildPreviewDetails(environment, previewContext) {
|
|
24
|
+
const details = [];
|
|
25
|
+
if (previewContext?.linkName) {
|
|
26
|
+
details.push({ label: "Preview link", value: previewContext.linkName });
|
|
27
|
+
}
|
|
28
|
+
if (previewContext?.releaseName || previewContext?.releaseId) {
|
|
29
|
+
details.push({
|
|
30
|
+
label: "Content release",
|
|
31
|
+
value: previewContext.releaseName || "Active",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (environment) {
|
|
35
|
+
details.push({ label: "Environment", value: environment });
|
|
36
|
+
}
|
|
37
|
+
if (previewContext?.locale) {
|
|
38
|
+
details.push({ label: "Locale", value: previewContext.locale });
|
|
39
|
+
}
|
|
40
|
+
// Backpack uses a lone `control` variant as a neutral preview fallback.
|
|
41
|
+
// Only describe an A/B test when the preview carries a complete test context.
|
|
42
|
+
if (previewContext?.testHandle && previewContext.testVariantHandle) {
|
|
43
|
+
details.push({
|
|
44
|
+
label: "A/B test",
|
|
45
|
+
value: `${previewContext.testHandle} · ${previewContext.testVariantHandle}`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (previewContext?.pageDraft) {
|
|
49
|
+
details.push({ label: "Page draft", value: previewContext.pageDraft });
|
|
50
|
+
}
|
|
51
|
+
if (previewContext?.siteSettingsDraft) {
|
|
52
|
+
details.push({
|
|
53
|
+
label: "Site settings",
|
|
54
|
+
value: previewContext.siteSettingsDraft,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return details;
|
|
58
|
+
}
|
|
59
|
+
export function buildPreviewSummary(environment, previewContext) {
|
|
60
|
+
const parts = [];
|
|
61
|
+
const contextLabel = getPreviewContextLabel(previewContext);
|
|
62
|
+
if (contextLabel)
|
|
63
|
+
parts.push(contextLabel);
|
|
64
|
+
if (environment)
|
|
65
|
+
parts.push(environment);
|
|
66
|
+
if (previewContext?.locale)
|
|
67
|
+
parts.push(previewContext.locale);
|
|
68
|
+
if (previewContext?.testHandle && previewContext.testVariantHandle) {
|
|
69
|
+
parts.push(`test: ${previewContext.testVariantHandle}`);
|
|
70
|
+
}
|
|
71
|
+
if (previewContext?.pageDraft) {
|
|
72
|
+
parts.push(`page: ${previewContext.pageDraft}`);
|
|
73
|
+
}
|
|
74
|
+
if (previewContext?.siteSettingsDraft) {
|
|
75
|
+
parts.push(`settings: ${previewContext.siteSettingsDraft}`);
|
|
76
|
+
}
|
|
77
|
+
// This is a tooltip and accessible label, not visible pill text. Keep the
|
|
78
|
+
// full context available without requiring the user to open the panel.
|
|
79
|
+
return parts.length > 0 ? parts.join(" · ") : "Preview mode";
|
|
80
|
+
}
|
|
81
|
+
// Whether this document is the top-level window is fixed for its lifetime, so
|
|
82
|
+
// there is nothing to subscribe to. `useSyncExternalStore` is used purely as a
|
|
83
|
+
// hydration-safe client-only gate: the server snapshot is `false`, and React
|
|
84
|
+
// re-renders once after hydration when the client snapshot differs.
|
|
85
|
+
function canShowPreviewToolbar() {
|
|
86
|
+
if (typeof window === "undefined")
|
|
87
|
+
return false;
|
|
88
|
+
try {
|
|
89
|
+
return window.self === window.top;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function subscribeToNothing() {
|
|
96
|
+
return () => { };
|
|
97
|
+
}
|
|
98
|
+
function getServerSnapshot() {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
function clamp(value, min, max) {
|
|
102
|
+
return Math.min(Math.max(value, min), max);
|
|
103
|
+
}
|
|
104
|
+
function isVerticalEdge(edge) {
|
|
105
|
+
return edge === "left" || edge === "right";
|
|
106
|
+
}
|
|
107
|
+
function getViewport() {
|
|
108
|
+
if (typeof window === "undefined")
|
|
109
|
+
return { width: 0, height: 0 };
|
|
110
|
+
return { width: window.innerWidth, height: window.innerHeight };
|
|
111
|
+
}
|
|
112
|
+
function getStoredDock() {
|
|
113
|
+
if (typeof window === "undefined")
|
|
114
|
+
return DEFAULT_TOOLBAR_DOCK;
|
|
115
|
+
try {
|
|
116
|
+
const raw = window.localStorage.getItem(PREVIEW_TOOLBAR_DOCK_KEY);
|
|
117
|
+
if (!raw)
|
|
118
|
+
return DEFAULT_TOOLBAR_DOCK;
|
|
119
|
+
const parsed = JSON.parse(raw);
|
|
120
|
+
const isEdge = parsed.edge === "top" ||
|
|
121
|
+
parsed.edge === "right" ||
|
|
122
|
+
parsed.edge === "bottom" ||
|
|
123
|
+
parsed.edge === "left";
|
|
124
|
+
if (isEdge &&
|
|
125
|
+
typeof parsed.offset === "number" &&
|
|
126
|
+
parsed.offset >= 0 &&
|
|
127
|
+
parsed.offset <= 1) {
|
|
128
|
+
return { edge: parsed.edge, offset: parsed.offset };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Ignore unavailable or malformed local storage.
|
|
133
|
+
}
|
|
134
|
+
return DEFAULT_TOOLBAR_DOCK;
|
|
135
|
+
}
|
|
136
|
+
function setStoredDock(dock) {
|
|
137
|
+
if (typeof window === "undefined")
|
|
138
|
+
return;
|
|
139
|
+
try {
|
|
140
|
+
window.localStorage.setItem(PREVIEW_TOOLBAR_DOCK_KEY, JSON.stringify(dock));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Dock persistence is optional.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function getDockedPosition(dock, viewport) {
|
|
147
|
+
const size = TOOLBAR_PILL_SIZE;
|
|
148
|
+
if (isVerticalEdge(dock.edge)) {
|
|
149
|
+
const left = dock.edge === "left" ? 0 : Math.max(0, viewport.width - size);
|
|
150
|
+
const top = clamp(dock.offset * viewport.height - size / 2, 0, Math.max(0, viewport.height - size));
|
|
151
|
+
return { left, top };
|
|
152
|
+
}
|
|
153
|
+
const top = dock.edge === "top" ? 0 : Math.max(0, viewport.height - size);
|
|
154
|
+
const left = clamp(dock.offset * viewport.width - size / 2, 0, Math.max(0, viewport.width - size));
|
|
155
|
+
return { left, top };
|
|
156
|
+
}
|
|
157
|
+
function getDragPosition(event, drag, viewport) {
|
|
158
|
+
const size = TOOLBAR_PILL_SIZE;
|
|
159
|
+
return {
|
|
160
|
+
left: clamp(event.clientX - drag.offsetX, 0, Math.max(0, viewport.width - size)),
|
|
161
|
+
top: clamp(event.clientY - drag.offsetY, 0, Math.max(0, viewport.height - size)),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const SAFE_AREA_PROPERTY = {
|
|
165
|
+
top: "--pack-preview-toolbar-safe-area-top",
|
|
166
|
+
right: "--pack-preview-toolbar-safe-area-right",
|
|
167
|
+
bottom: "--pack-preview-toolbar-safe-area-bottom",
|
|
168
|
+
left: "--pack-preview-toolbar-safe-area-left",
|
|
169
|
+
};
|
|
170
|
+
function getSafeAreaInset(edge) {
|
|
171
|
+
return `var(${SAFE_AREA_PROPERTY[edge]})`;
|
|
172
|
+
}
|
|
173
|
+
function getSafeAreaOffset(edge, offset) {
|
|
174
|
+
return `max(${getSafeAreaInset(edge)}, ${Math.max(0, offset)}px)`;
|
|
175
|
+
}
|
|
176
|
+
export function getConstrainedPillPosition(position, viewport) {
|
|
177
|
+
const size = TOOLBAR_PILL_SIZE;
|
|
178
|
+
return {
|
|
179
|
+
left: `clamp(${getSafeAreaInset("left")}, ${position.left}px, calc(${viewport.width}px - ${getSafeAreaInset("right")} - ${size}px))`,
|
|
180
|
+
top: `clamp(${getSafeAreaInset("top")}, ${position.top}px, calc(${viewport.height}px - ${getSafeAreaInset("bottom")} - ${size}px))`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function getNearestDock(centerX, centerY, viewport) {
|
|
184
|
+
const distances = [
|
|
185
|
+
{ edge: "left", distance: centerX },
|
|
186
|
+
{ edge: "right", distance: viewport.width - centerX },
|
|
187
|
+
{ edge: "top", distance: centerY },
|
|
188
|
+
{ edge: "bottom", distance: viewport.height - centerY },
|
|
189
|
+
];
|
|
190
|
+
const nearest = distances.reduce((best, current) => current.distance < best.distance ? current : best);
|
|
191
|
+
const offset = isVerticalEdge(nearest.edge)
|
|
192
|
+
? viewport.height > 0
|
|
193
|
+
? centerY / viewport.height
|
|
194
|
+
: 0.5
|
|
195
|
+
: viewport.width > 0
|
|
196
|
+
? centerX / viewport.width
|
|
197
|
+
: 0.5;
|
|
198
|
+
return { edge: nearest.edge, offset: clamp(offset, 0, 1) };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Resolves which side of one axis the panel anchors to, and how far from the
|
|
202
|
+
* viewport edge. When the pill is docked on this axis the panel sits on the
|
|
203
|
+
* far side of the pill plus a gap; otherwise it aligns with the pill's near
|
|
204
|
+
* side, in whichever half of the viewport the pill's center falls.
|
|
205
|
+
*/
|
|
206
|
+
function resolveAxisAnchor(dockEdge, [startEdge, endEdge], rectStart, rectEnd, viewportSize, gap) {
|
|
207
|
+
if (dockEdge === startEdge) {
|
|
208
|
+
return { edge: startEdge, offset: rectEnd + gap };
|
|
209
|
+
}
|
|
210
|
+
if (dockEdge === endEdge) {
|
|
211
|
+
return { edge: endEdge, offset: viewportSize - rectStart + gap };
|
|
212
|
+
}
|
|
213
|
+
const center = (rectStart + rectEnd) / 2;
|
|
214
|
+
if (center < viewportSize / 2) {
|
|
215
|
+
return { edge: startEdge, offset: rectStart };
|
|
216
|
+
}
|
|
217
|
+
return { edge: endEdge, offset: viewportSize - rectEnd };
|
|
218
|
+
}
|
|
219
|
+
export function getPanelPosition(edge, pillRect, viewport) {
|
|
220
|
+
const gap = TOOLBAR_PANEL_GAP;
|
|
221
|
+
// Before the pill has been measured, place the panel where a default
|
|
222
|
+
// bottom-docked pill would put it.
|
|
223
|
+
const rect = pillRect ?? {
|
|
224
|
+
left: 0,
|
|
225
|
+
top: viewport.height - TOOLBAR_PILL_SIZE,
|
|
226
|
+
right: TOOLBAR_PILL_SIZE,
|
|
227
|
+
bottom: viewport.height,
|
|
228
|
+
};
|
|
229
|
+
const horizontal = resolveAxisAnchor(pillRect ? edge : "bottom", ["left", "right"], rect.left, rect.right, viewport.width, gap);
|
|
230
|
+
const vertical = resolveAxisAnchor(pillRect ? edge : "bottom", ["top", "bottom"], rect.top, rect.bottom, viewport.height, gap);
|
|
231
|
+
const horizontalOffset = getSafeAreaOffset(horizontal.edge, horizontal.offset);
|
|
232
|
+
const verticalOffset = getSafeAreaOffset(vertical.edge, vertical.offset);
|
|
233
|
+
const oppositeHorizontalEdge = horizontal.edge === "left" ? "right" : "left";
|
|
234
|
+
const oppositeVerticalEdge = vertical.edge === "top" ? "bottom" : "top";
|
|
235
|
+
// Size against the measured viewport (window.innerWidth/innerHeight) rather
|
|
236
|
+
// than 100vw/100vh: on iOS Safari 100vh is the large viewport, which would
|
|
237
|
+
// let the panel extend under the browser's bottom bar.
|
|
238
|
+
return {
|
|
239
|
+
position: "fixed",
|
|
240
|
+
[horizontal.edge]: horizontalOffset,
|
|
241
|
+
[vertical.edge]: verticalOffset,
|
|
242
|
+
width: `min(360px, max(0px, calc(${viewport.width}px - ${horizontalOffset} - ${getSafeAreaInset(oppositeHorizontalEdge)})))`,
|
|
243
|
+
maxHeight: `max(0px, calc(${viewport.height}px - ${verticalOffset} - ${getSafeAreaInset(oppositeVerticalEdge)}))`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const PILL_SURFACE = "#1a1a1a";
|
|
247
|
+
const rootStyle = {
|
|
248
|
+
position: "fixed",
|
|
249
|
+
inset: 0,
|
|
250
|
+
zIndex: 2147483647,
|
|
251
|
+
pointerEvents: "none",
|
|
252
|
+
fontFamily: '"Inter", system-ui, sans-serif',
|
|
253
|
+
[SAFE_AREA_PROPERTY.top]: `max(${TOOLBAR_EDGE_MARGIN}px, env(safe-area-inset-top, 0px))`,
|
|
254
|
+
[SAFE_AREA_PROPERTY.right]: `max(${TOOLBAR_EDGE_MARGIN}px, env(safe-area-inset-right, 0px))`,
|
|
255
|
+
[SAFE_AREA_PROPERTY.bottom]: `max(${TOOLBAR_EDGE_MARGIN}px, env(safe-area-inset-bottom, 0px))`,
|
|
256
|
+
[SAFE_AREA_PROPERTY.left]: `max(${TOOLBAR_EDGE_MARGIN}px, env(safe-area-inset-left, 0px))`,
|
|
257
|
+
};
|
|
258
|
+
const pillStyle = {
|
|
259
|
+
position: "fixed",
|
|
260
|
+
pointerEvents: "auto",
|
|
261
|
+
display: "flex",
|
|
262
|
+
alignItems: "center",
|
|
263
|
+
justifyContent: "center",
|
|
264
|
+
width: TOOLBAR_PILL_SIZE,
|
|
265
|
+
height: TOOLBAR_PILL_SIZE,
|
|
266
|
+
padding: 0,
|
|
267
|
+
border: "1px solid rgba(255, 255, 255, 0.14)",
|
|
268
|
+
borderRadius: "50%",
|
|
269
|
+
backgroundColor: PILL_SURFACE,
|
|
270
|
+
color: "#fafafa",
|
|
271
|
+
cursor: "pointer",
|
|
272
|
+
touchAction: "none",
|
|
273
|
+
boxShadow: "0 10px 30px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.25)",
|
|
274
|
+
};
|
|
275
|
+
const panelStyle = {
|
|
276
|
+
pointerEvents: "auto",
|
|
277
|
+
overflowY: "auto",
|
|
278
|
+
backgroundColor: PILL_SURFACE,
|
|
279
|
+
color: "#fafafa",
|
|
280
|
+
border: "1px solid rgba(255, 255, 255, 0.12)",
|
|
281
|
+
borderRadius: 14,
|
|
282
|
+
boxShadow: "0 16px 50px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3)",
|
|
283
|
+
};
|
|
284
|
+
const panelHeaderStyle = {
|
|
285
|
+
display: "flex",
|
|
286
|
+
alignItems: "flex-start",
|
|
287
|
+
gap: 10,
|
|
288
|
+
padding: "14px 14px 12px",
|
|
289
|
+
};
|
|
290
|
+
const panelTitleStyle = {
|
|
291
|
+
margin: 0,
|
|
292
|
+
fontSize: 13,
|
|
293
|
+
fontWeight: 600,
|
|
294
|
+
lineHeight: "18px",
|
|
295
|
+
};
|
|
296
|
+
const panelSummaryStyle = {
|
|
297
|
+
margin: "3px 0 0",
|
|
298
|
+
fontSize: 12,
|
|
299
|
+
lineHeight: "16px",
|
|
300
|
+
color: "#a1a1aa",
|
|
301
|
+
overflowWrap: "anywhere",
|
|
302
|
+
};
|
|
303
|
+
const detailListStyle = {
|
|
304
|
+
display: "grid",
|
|
305
|
+
gap: 10,
|
|
306
|
+
margin: 0,
|
|
307
|
+
padding: "12px 14px 14px",
|
|
308
|
+
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
|
|
309
|
+
};
|
|
310
|
+
const detailRowStyle = {
|
|
311
|
+
display: "grid",
|
|
312
|
+
gridTemplateColumns: "minmax(88px, 0.8fr) minmax(0, 1.2fr)",
|
|
313
|
+
gap: 12,
|
|
314
|
+
alignItems: "baseline",
|
|
315
|
+
};
|
|
316
|
+
const detailLabelStyle = {
|
|
317
|
+
color: "#a1a1aa",
|
|
318
|
+
fontSize: 12,
|
|
319
|
+
lineHeight: "16px",
|
|
320
|
+
};
|
|
321
|
+
const detailValueStyle = {
|
|
322
|
+
margin: 0,
|
|
323
|
+
color: "#fafafa",
|
|
324
|
+
fontSize: 12,
|
|
325
|
+
lineHeight: "16px",
|
|
326
|
+
overflowWrap: "anywhere",
|
|
327
|
+
};
|
|
328
|
+
const panelFooterStyle = {
|
|
329
|
+
display: "flex",
|
|
330
|
+
alignItems: "center",
|
|
331
|
+
justifyContent: "flex-end",
|
|
332
|
+
gap: 8,
|
|
333
|
+
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
|
|
334
|
+
padding: "6px 14px",
|
|
335
|
+
};
|
|
336
|
+
const buttonStyle = {
|
|
337
|
+
display: "inline-flex",
|
|
338
|
+
alignItems: "center",
|
|
339
|
+
justifyContent: "center",
|
|
340
|
+
minHeight: TOOLBAR_MIN_TAP_TARGET,
|
|
341
|
+
minWidth: TOOLBAR_MIN_TAP_TARGET,
|
|
342
|
+
border: 0,
|
|
343
|
+
borderRadius: 8,
|
|
344
|
+
background: "transparent",
|
|
345
|
+
color: "#a1a1aa",
|
|
346
|
+
cursor: "pointer",
|
|
347
|
+
fontSize: 12,
|
|
348
|
+
fontWeight: 500,
|
|
349
|
+
lineHeight: "16px",
|
|
350
|
+
padding: "7px 10px",
|
|
351
|
+
};
|
|
352
|
+
const exitConfirmButtonStyle = {
|
|
353
|
+
...buttonStyle,
|
|
354
|
+
border: "1px solid rgba(239, 68, 68, 0.4)",
|
|
355
|
+
background: "rgba(239, 68, 68, 0.12)",
|
|
356
|
+
color: "#fca5a5",
|
|
357
|
+
fontWeight: 600,
|
|
358
|
+
padding: "6px 12px",
|
|
359
|
+
};
|
|
360
|
+
const toolbarStyles = `
|
|
361
|
+
.pack-preview-toolbar *,
|
|
362
|
+
.pack-preview-toolbar *::before,
|
|
363
|
+
.pack-preview-toolbar *::after { box-sizing: border-box; }
|
|
364
|
+
.pack-preview-toolbar button { font-family: inherit; }
|
|
365
|
+
.pack-preview-toolbar__pill { transition: background-color 120ms ease, box-shadow 160ms ease, left ${TOOLBAR_PILL_TRANSITION_MS}ms cubic-bezier(0.16, 1, 0.3, 1), top ${TOOLBAR_PILL_TRANSITION_MS}ms cubic-bezier(0.16, 1, 0.3, 1); }
|
|
366
|
+
.pack-preview-toolbar__pill:hover { background-color: #262626; }
|
|
367
|
+
.pack-preview-toolbar__pill--dragging { transition: none; box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55); }
|
|
368
|
+
.pack-preview-toolbar__panel { animation: pack-preview-panel-in 160ms cubic-bezier(0.16, 1, 0.3, 1); }
|
|
369
|
+
.pack-preview-toolbar__button:hover { background-color: rgba(255, 255, 255, 0.08); color: #fafafa; }
|
|
370
|
+
.pack-preview-toolbar__exit:hover { color: #fca5a5; }
|
|
371
|
+
.pack-preview-toolbar__exit-confirm:hover { background-color: rgba(239, 68, 68, 0.2); }
|
|
372
|
+
.pack-preview-toolbar button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
|
373
|
+
.pack-preview-toolbar button:focus:not(:focus-visible),
|
|
374
|
+
.pack-preview-toolbar__panel:focus { outline: none; }
|
|
375
|
+
@keyframes pack-preview-panel-in {
|
|
376
|
+
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
|
377
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
378
|
+
}
|
|
379
|
+
@media (prefers-reduced-motion: reduce) {
|
|
380
|
+
.pack-preview-toolbar__panel { animation: none; }
|
|
381
|
+
.pack-preview-toolbar__pill { transition: none; }
|
|
382
|
+
}
|
|
383
|
+
`;
|
|
384
|
+
function PreviewDetails({ details }) {
|
|
385
|
+
if (details.length === 0) {
|
|
386
|
+
return (React.createElement("dl", { style: detailListStyle },
|
|
387
|
+
React.createElement("div", { style: detailRowStyle },
|
|
388
|
+
React.createElement("dt", { style: detailLabelStyle }, "Content"),
|
|
389
|
+
React.createElement("dd", { style: detailValueStyle }, "Draft content"))));
|
|
390
|
+
}
|
|
391
|
+
return (React.createElement("dl", { style: detailListStyle }, details.map((detail) => (React.createElement("div", { key: detail.label, style: detailRowStyle },
|
|
392
|
+
React.createElement("dt", { style: detailLabelStyle }, detail.label),
|
|
393
|
+
React.createElement("dd", { style: detailValueStyle }, detail.value))))));
|
|
394
|
+
}
|
|
395
|
+
function PreviewPanel({ details, panelId, panelRef, position, onHide, }) {
|
|
396
|
+
const [isConfirmingExit, setIsConfirmingExit] = useState(false);
|
|
397
|
+
const [isExiting, setIsExiting] = useState(false);
|
|
398
|
+
const [exitError, setExitError] = useState(null);
|
|
399
|
+
const exitPreviewMode = async () => {
|
|
400
|
+
if (isExiting)
|
|
401
|
+
return;
|
|
402
|
+
setIsExiting(true);
|
|
403
|
+
setExitError(null);
|
|
404
|
+
try {
|
|
405
|
+
const response = await fetch("/api/edit", {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
408
|
+
});
|
|
409
|
+
if (!response.ok) {
|
|
410
|
+
throw new Error(`ERR_RE_API_ERROR: HTTP error! Status: ${response.status} Doc: https://docs.packdigital.com/err/ERR_RE_API_ERROR`);
|
|
411
|
+
}
|
|
412
|
+
window.location.reload();
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
console.error("Error exiting preview:", error.message);
|
|
416
|
+
setExitError("Couldn’t exit preview. Please try again.");
|
|
417
|
+
setIsExiting(false);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
return (React.createElement("div", { ref: panelRef, id: panelId, role: "region", "aria-label": "Preview details", tabIndex: -1, "data-pack-preview-toolbar-panel": true, className: "pack-preview-toolbar__panel", style: { ...panelStyle, ...position } },
|
|
421
|
+
React.createElement("div", { style: panelHeaderStyle },
|
|
422
|
+
React.createElement(Eye, { style: { width: 18, height: 18, minWidth: 18, marginTop: 1 } }),
|
|
423
|
+
React.createElement("p", { style: panelTitleStyle }, "Preview mode")),
|
|
424
|
+
React.createElement(PreviewDetails, { details: details }),
|
|
425
|
+
React.createElement("div", { style: panelFooterStyle }, isConfirmingExit ? (React.createElement("div", { style: { display: "grid", gap: 8, width: "100%" } },
|
|
426
|
+
exitError && (React.createElement("p", { style: { ...panelSummaryStyle, color: "#fca5a5" }, role: "alert" }, exitError)),
|
|
427
|
+
React.createElement("div", { style: {
|
|
428
|
+
display: "flex",
|
|
429
|
+
alignItems: "center",
|
|
430
|
+
justifyContent: "space-between",
|
|
431
|
+
gap: 8,
|
|
432
|
+
} },
|
|
433
|
+
React.createElement("span", { style: { color: "#a1a1aa", fontSize: 12 } }, "Exit preview mode?"),
|
|
434
|
+
React.createElement("div", { style: { display: "flex", gap: 6 } },
|
|
435
|
+
React.createElement("button", { type: "button", className: "pack-preview-toolbar__button", disabled: isExiting, onClick: () => {
|
|
436
|
+
setIsConfirmingExit(false);
|
|
437
|
+
setExitError(null);
|
|
438
|
+
}, style: { ...buttonStyle, opacity: isExiting ? 0.55 : 1 } }, "Cancel"),
|
|
439
|
+
React.createElement("button", { type: "button", className: "pack-preview-toolbar__exit-confirm", disabled: isExiting, onClick: exitPreviewMode, style: {
|
|
440
|
+
...exitConfirmButtonStyle,
|
|
441
|
+
opacity: isExiting ? 0.65 : 1,
|
|
442
|
+
} }, isExiting ? "Exiting…" : "Exit"))))) : (React.createElement(React.Fragment, null,
|
|
443
|
+
React.createElement("button", { type: "button", className: "pack-preview-toolbar__button", "data-pack-preview-toolbar-hide": true, title: "Hide the preview button until you reload or open another page", onClick: onHide, style: { ...buttonStyle, marginRight: "auto" } }, "Hide"),
|
|
444
|
+
React.createElement("button", { type: "button", className: "pack-preview-toolbar__exit", onClick: () => setIsConfirmingExit(true), style: buttonStyle }, "Exit preview"))))));
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* While the toolbar is hidden, watches for a client-side route change
|
|
448
|
+
* (pushState/replaceState from the storefront router, or back/forward) and
|
|
449
|
+
* resets the hidden flag. A full reload resets it naturally since it lives in
|
|
450
|
+
* component state. Nothing is patched while the toolbar is visible.
|
|
451
|
+
*/
|
|
452
|
+
function useUnhideOnNavigation(isHidden, setIsHidden) {
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
if (!isHidden)
|
|
455
|
+
return;
|
|
456
|
+
let active = true;
|
|
457
|
+
const hiddenAtPathname = window.location.pathname;
|
|
458
|
+
const unhideIfNavigated = () => {
|
|
459
|
+
if (active && window.location.pathname !== hiddenAtPathname) {
|
|
460
|
+
setIsHidden(false);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
const originalPushState = history.pushState;
|
|
464
|
+
const originalReplaceState = history.replaceState;
|
|
465
|
+
const patchedPushState = function pushState(...args) {
|
|
466
|
+
originalPushState.apply(this, args);
|
|
467
|
+
unhideIfNavigated();
|
|
468
|
+
};
|
|
469
|
+
const patchedReplaceState = function replaceState(...args) {
|
|
470
|
+
originalReplaceState.apply(this, args);
|
|
471
|
+
unhideIfNavigated();
|
|
472
|
+
};
|
|
473
|
+
history.pushState = patchedPushState;
|
|
474
|
+
history.replaceState = patchedReplaceState;
|
|
475
|
+
window.addEventListener("popstate", unhideIfNavigated);
|
|
476
|
+
return () => {
|
|
477
|
+
active = false;
|
|
478
|
+
window.removeEventListener("popstate", unhideIfNavigated);
|
|
479
|
+
// A later script may retain our wrapper. Leave its patch intact and make
|
|
480
|
+
// our retained callback inert after cleanup.
|
|
481
|
+
if (history.pushState === patchedPushState) {
|
|
482
|
+
history.pushState = originalPushState;
|
|
483
|
+
}
|
|
484
|
+
if (history.replaceState === patchedReplaceState) {
|
|
485
|
+
history.replaceState = originalReplaceState;
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}, [isHidden, setIsHidden]);
|
|
489
|
+
}
|
|
490
|
+
function isSameRect(a, b) {
|
|
491
|
+
return (a !== null &&
|
|
492
|
+
a.top === b.top &&
|
|
493
|
+
a.left === b.left &&
|
|
494
|
+
a.right === b.right &&
|
|
495
|
+
a.bottom === b.bottom);
|
|
496
|
+
}
|
|
497
|
+
function PreviewToolbarSurface({ environment, previewContext, }) {
|
|
498
|
+
const panelId = useId();
|
|
499
|
+
const rootRef = useRef(null);
|
|
500
|
+
const pillRef = useRef(null);
|
|
501
|
+
const panelRef = useRef(null);
|
|
502
|
+
const statusRef = useRef(null);
|
|
503
|
+
const previousFocusRef = useRef(null);
|
|
504
|
+
const dragStateRef = useRef(null);
|
|
505
|
+
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
|
506
|
+
const [isHidden, setIsHidden] = useState(false);
|
|
507
|
+
const [dock, setDock] = useState(getStoredDock);
|
|
508
|
+
const [viewport, setViewport] = useState(getViewport);
|
|
509
|
+
const [pillRect, setPillRect] = useState(null);
|
|
510
|
+
const [dragPosition, setDragPosition] = useState(null);
|
|
511
|
+
const isDragging = dragPosition !== null;
|
|
512
|
+
useEffect(() => {
|
|
513
|
+
const onResize = () => {
|
|
514
|
+
setViewport((current) => {
|
|
515
|
+
const next = getViewport();
|
|
516
|
+
return current.width === next.width && current.height === next.height
|
|
517
|
+
? current
|
|
518
|
+
: next;
|
|
519
|
+
});
|
|
520
|
+
};
|
|
521
|
+
window.addEventListener("resize", onResize);
|
|
522
|
+
return () => window.removeEventListener("resize", onResize);
|
|
523
|
+
}, []);
|
|
524
|
+
const measurePill = useCallback(() => {
|
|
525
|
+
const node = pillRef.current;
|
|
526
|
+
if (!node)
|
|
527
|
+
return;
|
|
528
|
+
const rect = node.getBoundingClientRect();
|
|
529
|
+
const next = {
|
|
530
|
+
top: rect.top,
|
|
531
|
+
left: rect.left,
|
|
532
|
+
right: rect.right,
|
|
533
|
+
bottom: rect.bottom,
|
|
534
|
+
};
|
|
535
|
+
setPillRect((current) => (isSameRect(current, next) ? current : next));
|
|
536
|
+
}, []);
|
|
537
|
+
// The panel is anchored to the pill's rendered rect, which the CSS safe-area
|
|
538
|
+
// clamp can shift away from the JS position. Measure only while the panel is
|
|
539
|
+
// open (it is closed during drags). A synchronous read right after a dock or
|
|
540
|
+
// viewport change captures the pre-transition position, so re-measure once
|
|
541
|
+
// the pill's left/top transition has had time to settle, and again on the
|
|
542
|
+
// transition events themselves for the case where the timer runs early.
|
|
543
|
+
useIsomorphicLayoutEffect(() => {
|
|
544
|
+
if (!isPanelOpen)
|
|
545
|
+
return;
|
|
546
|
+
measurePill();
|
|
547
|
+
const timeoutId = window.setTimeout(measurePill, TOOLBAR_PILL_TRANSITION_MS + 50);
|
|
548
|
+
return () => window.clearTimeout(timeoutId);
|
|
549
|
+
}, [isPanelOpen, dock, viewport, measurePill]);
|
|
550
|
+
useEffect(() => {
|
|
551
|
+
const node = pillRef.current;
|
|
552
|
+
if (!isPanelOpen || !node)
|
|
553
|
+
return;
|
|
554
|
+
const onTransitionSettled = (event) => {
|
|
555
|
+
if (event.propertyName === "left" || event.propertyName === "top") {
|
|
556
|
+
measurePill();
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
node.addEventListener("transitionend", onTransitionSettled);
|
|
560
|
+
node.addEventListener("transitioncancel", onTransitionSettled);
|
|
561
|
+
return () => {
|
|
562
|
+
node.removeEventListener("transitionend", onTransitionSettled);
|
|
563
|
+
node.removeEventListener("transitioncancel", onTransitionSettled);
|
|
564
|
+
};
|
|
565
|
+
}, [isPanelOpen, measurePill]);
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
if (!isPanelOpen)
|
|
568
|
+
return;
|
|
569
|
+
const handlePointerDown = (event) => {
|
|
570
|
+
const target = event.target;
|
|
571
|
+
if (target && !rootRef.current?.contains(target)) {
|
|
572
|
+
setIsPanelOpen(false);
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
// Only claim Escape while focus is inside the toolbar, so storefront
|
|
576
|
+
// dialogs and inputs keep their own Escape handling.
|
|
577
|
+
const handleKeyDown = (event) => {
|
|
578
|
+
if (event.key !== "Escape" || event.defaultPrevented)
|
|
579
|
+
return;
|
|
580
|
+
if (!rootRef.current?.contains(document.activeElement))
|
|
581
|
+
return;
|
|
582
|
+
setIsPanelOpen(false);
|
|
583
|
+
pillRef.current?.focus();
|
|
584
|
+
};
|
|
585
|
+
document.addEventListener("pointerdown", handlePointerDown, true);
|
|
586
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
587
|
+
panelRef.current?.focus();
|
|
588
|
+
return () => {
|
|
589
|
+
document.removeEventListener("pointerdown", handlePointerDown, true);
|
|
590
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
591
|
+
};
|
|
592
|
+
}, [isPanelOpen]);
|
|
593
|
+
const togglePanel = () => setIsPanelOpen((open) => !open);
|
|
594
|
+
const beginDrag = (event) => {
|
|
595
|
+
if (event.pointerType === "mouse" && event.button !== 0)
|
|
596
|
+
return;
|
|
597
|
+
const rect = pillRef.current?.getBoundingClientRect();
|
|
598
|
+
if (!rect)
|
|
599
|
+
return;
|
|
600
|
+
dragStateRef.current = {
|
|
601
|
+
pointerId: event.pointerId,
|
|
602
|
+
startX: event.clientX,
|
|
603
|
+
startY: event.clientY,
|
|
604
|
+
offsetX: event.clientX - rect.left,
|
|
605
|
+
offsetY: event.clientY - rect.top,
|
|
606
|
+
moved: false,
|
|
607
|
+
captured: false,
|
|
608
|
+
};
|
|
609
|
+
};
|
|
610
|
+
const updateDrag = (event) => {
|
|
611
|
+
const drag = dragStateRef.current;
|
|
612
|
+
if (!drag || drag.pointerId !== event.pointerId)
|
|
613
|
+
return;
|
|
614
|
+
const distance = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
|
|
615
|
+
if (!drag.moved && distance < TOOLBAR_DRAG_THRESHOLD)
|
|
616
|
+
return;
|
|
617
|
+
if (!drag.moved) {
|
|
618
|
+
drag.moved = true;
|
|
619
|
+
setIsPanelOpen(false);
|
|
620
|
+
try {
|
|
621
|
+
pillRef.current?.setPointerCapture(event.pointerId);
|
|
622
|
+
drag.captured = true;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
// Without capture, pointerup may go to the storefront. Cancel instead
|
|
626
|
+
// of leaving the pill stranded in its dragging state.
|
|
627
|
+
cancelDrag();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
setDragPosition(getDragPosition(event, drag, viewport));
|
|
632
|
+
};
|
|
633
|
+
// A pointer that never moved past the drag threshold is a tap: toggle here
|
|
634
|
+
// rather than in `onClick`, so a completed drag never has to suppress the
|
|
635
|
+
// click that may or may not follow it (touch drags produce no click).
|
|
636
|
+
const endDrag = (event) => {
|
|
637
|
+
const drag = dragStateRef.current;
|
|
638
|
+
if (!drag || drag.pointerId !== event.pointerId)
|
|
639
|
+
return;
|
|
640
|
+
dragStateRef.current = null;
|
|
641
|
+
if (drag.captured) {
|
|
642
|
+
try {
|
|
643
|
+
pillRef.current?.releasePointerCapture(event.pointerId);
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
// Capture is released implicitly on pointerup; ignore a stale id.
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (!drag.moved) {
|
|
650
|
+
togglePanel();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const position = getDragPosition(event, drag, viewport);
|
|
654
|
+
const nextDock = getNearestDock(position.left + TOOLBAR_PILL_SIZE / 2, position.top + TOOLBAR_PILL_SIZE / 2, viewport);
|
|
655
|
+
setDock(nextDock);
|
|
656
|
+
setStoredDock(nextDock);
|
|
657
|
+
setDragPosition(null);
|
|
658
|
+
};
|
|
659
|
+
const cancelDrag = () => {
|
|
660
|
+
dragStateRef.current = null;
|
|
661
|
+
setDragPosition(null);
|
|
662
|
+
};
|
|
663
|
+
// Pointer taps are handled in `endDrag`; `click` is only needed for
|
|
664
|
+
// keyboard and assistive-technology activation, which carries `detail === 0`.
|
|
665
|
+
const handlePillClick = (event) => {
|
|
666
|
+
if (event.detail === 0)
|
|
667
|
+
togglePanel();
|
|
668
|
+
};
|
|
669
|
+
const hideToolbar = () => {
|
|
670
|
+
const previousFocus = previousFocusRef.current;
|
|
671
|
+
if (previousFocus?.isConnected)
|
|
672
|
+
previousFocus.focus({ preventScroll: true });
|
|
673
|
+
if (!previousFocus || document.activeElement !== previousFocus) {
|
|
674
|
+
statusRef.current?.focus({ preventScroll: true });
|
|
675
|
+
}
|
|
676
|
+
setIsPanelOpen(false);
|
|
677
|
+
setIsHidden(true);
|
|
678
|
+
};
|
|
679
|
+
useUnhideOnNavigation(isHidden, setIsHidden);
|
|
680
|
+
const summary = buildPreviewSummary(environment, previewContext);
|
|
681
|
+
const details = buildPreviewDetails(environment, previewContext);
|
|
682
|
+
const pillPosition = dragPosition ?? getDockedPosition(dock, viewport);
|
|
683
|
+
const panelPosition = getPanelPosition(dock.edge, pillRect, viewport);
|
|
684
|
+
const constrainedPillPosition = getConstrainedPillPosition(pillPosition, viewport);
|
|
685
|
+
return (React.createElement(React.Fragment, null,
|
|
686
|
+
React.createElement("div", { ref: statusRef, role: "status", tabIndex: -1, style: {
|
|
687
|
+
position: "fixed",
|
|
688
|
+
width: 1,
|
|
689
|
+
height: 1,
|
|
690
|
+
overflow: "hidden",
|
|
691
|
+
clipPath: "inset(50%)",
|
|
692
|
+
whiteSpace: "nowrap",
|
|
693
|
+
} }, isHidden
|
|
694
|
+
? "Preview toolbar hidden. It returns when you open another page or reload."
|
|
695
|
+
: ""),
|
|
696
|
+
!isHidden && (React.createElement("div", { ref: rootRef, className: "pack-preview-toolbar", "data-pack-preview-toolbar": true, style: rootStyle, onFocusCapture: (event) => {
|
|
697
|
+
const previous = event.relatedTarget;
|
|
698
|
+
if (!event.currentTarget.contains(previous)) {
|
|
699
|
+
previousFocusRef.current =
|
|
700
|
+
previous instanceof HTMLElement &&
|
|
701
|
+
previous !== document.body &&
|
|
702
|
+
previous !== statusRef.current
|
|
703
|
+
? previous
|
|
704
|
+
: null;
|
|
705
|
+
}
|
|
706
|
+
} },
|
|
707
|
+
React.createElement("style", null, toolbarStyles),
|
|
708
|
+
isPanelOpen && (React.createElement(PreviewPanel, { details: details, panelId: panelId, panelRef: panelRef, position: panelPosition, onHide: hideToolbar })),
|
|
709
|
+
React.createElement("button", { ref: pillRef, type: "button", "data-pack-preview-toolbar-pill": true, className: isDragging
|
|
710
|
+
? "pack-preview-toolbar__pill pack-preview-toolbar__pill--dragging"
|
|
711
|
+
: "pack-preview-toolbar__pill", "aria-expanded": isPanelOpen, "aria-controls": isPanelOpen ? panelId : undefined, "aria-label": isPanelOpen
|
|
712
|
+
? "Close preview details"
|
|
713
|
+
: `Open preview details. Current context: ${summary}. Drag to reposition.`, title: summary, onPointerDown: beginDrag, onPointerMove: updateDrag, onPointerUp: endDrag, onPointerCancel: cancelDrag, onClick: handlePillClick, style: {
|
|
714
|
+
...pillStyle,
|
|
715
|
+
...constrainedPillPosition,
|
|
716
|
+
cursor: isDragging ? "grabbing" : "pointer",
|
|
717
|
+
} },
|
|
718
|
+
React.createElement(Eye, { style: { width: 20, height: 20 } }))))));
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Thin gate so production storefront pages (preview off) mount no listeners,
|
|
722
|
+
* timers, storage reads, or effects. The top-window check is a hydration-safe
|
|
723
|
+
* client-only snapshot; it renders nothing on the server and during hydration.
|
|
724
|
+
*/
|
|
725
|
+
export const PreviewToolbar = ({ isPreviewModeEnabled, environment, previewContext, }) => {
|
|
726
|
+
const canRenderToolbar = useSyncExternalStore(subscribeToNothing, canShowPreviewToolbar, getServerSnapshot);
|
|
727
|
+
if (!isPreviewModeEnabled || !canRenderToolbar)
|
|
728
|
+
return null;
|
|
729
|
+
return (React.createElement(PreviewToolbarSurface, { environment: environment, previewContext: previewContext }));
|
|
730
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-customizer-shell.d.ts","sourceRoot":"","sources":["../src/use-customizer-shell.tsx"],"names":[],"mappings":"AAeA,eAAO,MAAM,kBAAkB,
|
|
1
|
+
{"version":3,"file":"use-customizer-shell.d.ts","sourceRoot":"","sources":["../src/use-customizer-shell.tsx"],"names":[],"mappings":"AAeA,eAAO,MAAM,kBAAkB,YAkU9B,CAAC"}
|
|
@@ -183,24 +183,35 @@ export const useCustomizerShell = () => {
|
|
|
183
183
|
// so SET_CURRENT_ROUTE fires even between pages that both lack CMS content.
|
|
184
184
|
useEffect(() => {
|
|
185
185
|
setCurrentPath(window.location.pathname);
|
|
186
|
-
|
|
186
|
+
let active = true;
|
|
187
|
+
const updatePath = () => {
|
|
188
|
+
if (active)
|
|
189
|
+
setCurrentPath(window.location.pathname);
|
|
190
|
+
};
|
|
187
191
|
// Intercept pushState/replaceState for client-side routing (e.g. React Router)
|
|
188
|
-
const originalPushState = history.pushState
|
|
189
|
-
const originalReplaceState = history.replaceState
|
|
190
|
-
|
|
191
|
-
originalPushState(
|
|
192
|
+
const originalPushState = history.pushState;
|
|
193
|
+
const originalReplaceState = history.replaceState;
|
|
194
|
+
const patchedPushState = function (...args) {
|
|
195
|
+
originalPushState.apply(this, args);
|
|
192
196
|
updatePath();
|
|
193
197
|
};
|
|
194
|
-
|
|
195
|
-
originalReplaceState(
|
|
198
|
+
const patchedReplaceState = function (...args) {
|
|
199
|
+
originalReplaceState.apply(this, args);
|
|
196
200
|
updatePath();
|
|
197
201
|
};
|
|
202
|
+
history.pushState = patchedPushState;
|
|
203
|
+
history.replaceState = patchedReplaceState;
|
|
198
204
|
// Handle back/forward navigation
|
|
199
205
|
window.addEventListener("popstate", updatePath);
|
|
200
206
|
return () => {
|
|
207
|
+
active = false;
|
|
201
208
|
window.removeEventListener("popstate", updatePath);
|
|
202
|
-
history.pushState
|
|
203
|
-
|
|
209
|
+
if (history.pushState === patchedPushState) {
|
|
210
|
+
history.pushState = originalPushState;
|
|
211
|
+
}
|
|
212
|
+
if (history.replaceState === patchedReplaceState) {
|
|
213
|
+
history.replaceState = originalReplaceState;
|
|
214
|
+
}
|
|
204
215
|
};
|
|
205
216
|
}, []);
|
|
206
217
|
// Tell the parent window about the current route
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridges a storefront's own cart to Playbook's collection-grid "Add to cart".
|
|
3
|
+
*
|
|
4
|
+
* WHY A BRIDGE AND NOT A DIRECT WRITE. Playbook's storefront SDK adds a line on
|
|
5
|
+
* a Liquid theme through Shopify's AJAX Cart API (`/cart/add.js`). Hydrogen has
|
|
6
|
+
* no such endpoint, and the one thing Playbook must not do is write the line
|
|
7
|
+
* behind the storefront's back: it could add it through the Storefront API, but
|
|
8
|
+
* a Hydrogen cart's UI is rendered from React state that no third-party script
|
|
9
|
+
* can reach, so the line would land in Shopify while the cart icon still read
|
|
10
|
+
* zero and the drawer never opened. The shopper would click again. A correct
|
|
11
|
+
* backend behind a frontend that looks broken is worse than an honest link to
|
|
12
|
+
* the product page, which is what Playbook falls back to when no bridge exists.
|
|
13
|
+
*
|
|
14
|
+
* So the storefront — the only code that CAN update its own cart UI — supplies
|
|
15
|
+
* the write, and Playbook calls it. Registering this also wins on Liquid, where
|
|
16
|
+
* Playbook prefers a registered bridge over its own event fan-out, because the
|
|
17
|
+
* store's real drawer always beats a simulated one.
|
|
18
|
+
*
|
|
19
|
+
* OPT-IN, ALWAYS. Nothing here runs unless a storefront calls this hook. It is
|
|
20
|
+
* a `window` global, and a package installed by 50+ storefronts must never
|
|
21
|
+
* assign one on their behalf.
|
|
22
|
+
*
|
|
23
|
+
* const { linesAdd } = useCart();
|
|
24
|
+
* usePlaybookCart(async ({ variantId, quantity }) => {
|
|
25
|
+
* await linesAdd([{ merchandiseId: variantId, quantity }]);
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* THE OTHER HALF OF THIS CONTRACT LIVES IN PLAYBOOK, not here, and nothing in
|
|
29
|
+
* this repo can verify it: `sdk/experience/src/cart-add.ts` in
|
|
30
|
+
* `packdigital/playbook` defines the global's name, decides whether the button
|
|
31
|
+
* renders at all (`canAddToCart`), and treats a rejection as the shopper-facing
|
|
32
|
+
* failure state. Read that file before changing the shape below.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* What Playbook passes to the bridge.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately OPEN: Playbook owns this payload, and the bridge forwards every
|
|
38
|
+
* field it receives so a later addition there (a selling plan, line attributes)
|
|
39
|
+
* reaches the storefront without a release here.
|
|
40
|
+
*/
|
|
41
|
+
export interface PlaybookCartInput {
|
|
42
|
+
/** Numeric variant id as Playbook sends it; normalized to a GID before the
|
|
43
|
+
* storefront's callback sees it. */
|
|
44
|
+
variantId: string;
|
|
45
|
+
quantity: number;
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
/** The storefront's own add-to-cart. Resolve = added; throw = failed. */
|
|
49
|
+
export type PlaybookCartAdd = (input: PlaybookCartInput) => Promise<unknown> | unknown;
|
|
50
|
+
/**
|
|
51
|
+
* Accept either form and always hand the callback a variant GID. Playbook sends
|
|
52
|
+
* the legacy numeric id (its Liquid path needs that for `/cart/add.js`), which
|
|
53
|
+
* every Hydrogen cart API rejects.
|
|
54
|
+
*
|
|
55
|
+
* This is a globally-callable entry point, so it validates rather than coercing:
|
|
56
|
+
* an empty id would otherwise build the bare prefix `gid://shopify/…/`, and a
|
|
57
|
+
* PRODUCT gid would sail through as if it were a variant. Both fail far away
|
|
58
|
+
* from here, inside the cart mutation, with a message that doesn't name Playbook.
|
|
59
|
+
*/
|
|
60
|
+
export declare const toVariantGid: (variantId: string) => string;
|
|
61
|
+
export declare const usePlaybookCart: (addToCart: PlaybookCartAdd | null | undefined) => void;
|
|
62
|
+
//# sourceMappingURL=use-playbook-cart.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-playbook-cart.d.ts","sourceRoot":"","sources":["../src/use-playbook-cart.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;yCACqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,yEAAyE;AACzE,MAAM,MAAM,eAAe,GAAG,CAC5B,KAAK,EAAE,iBAAiB,KACrB,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;AAmBhC;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,cAAe,MAAM,KAAG,MAahD,CAAC;AAYF,eAAO,MAAM,eAAe,cACf,eAAe,GAAG,IAAI,GAAG,SAAS,KAC5C,IAqDF,CAAC"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
/** Shopify variant GID prefix — the form every Hydrogen cart API expects. */
|
|
3
|
+
const VARIANT_GID_PREFIX = "gid://shopify/ProductVariant/";
|
|
4
|
+
/**
|
|
5
|
+
* Accept either form and always hand the callback a variant GID. Playbook sends
|
|
6
|
+
* the legacy numeric id (its Liquid path needs that for `/cart/add.js`), which
|
|
7
|
+
* every Hydrogen cart API rejects.
|
|
8
|
+
*
|
|
9
|
+
* This is a globally-callable entry point, so it validates rather than coercing:
|
|
10
|
+
* an empty id would otherwise build the bare prefix `gid://shopify/…/`, and a
|
|
11
|
+
* PRODUCT gid would sail through as if it were a variant. Both fail far away
|
|
12
|
+
* from here, inside the cart mutation, with a message that doesn't name Playbook.
|
|
13
|
+
*/
|
|
14
|
+
export const toVariantGid = (variantId) => {
|
|
15
|
+
const id = String(variantId ?? "").trim();
|
|
16
|
+
if (!id)
|
|
17
|
+
throw new Error("[playbook] add-to-cart called with no variantId");
|
|
18
|
+
if (id.startsWith("gid://")) {
|
|
19
|
+
if (!id.startsWith(VARIANT_GID_PREFIX)) {
|
|
20
|
+
throw new Error(`[playbook] expected a ProductVariant gid, got "${id}"`);
|
|
21
|
+
}
|
|
22
|
+
return id;
|
|
23
|
+
}
|
|
24
|
+
if (!/^\d+$/.test(id)) {
|
|
25
|
+
throw new Error(`[playbook] variantId must be numeric or a gid, got "${id}"`);
|
|
26
|
+
}
|
|
27
|
+
return `${VARIANT_GID_PREFIX}${id}`;
|
|
28
|
+
};
|
|
29
|
+
/** Quantity arrives from a third-party script; a cart mutation should never see
|
|
30
|
+
* a fraction, a negative, or `NaN`. */
|
|
31
|
+
const toQuantity = (quantity) => {
|
|
32
|
+
const n = Number(quantity);
|
|
33
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
34
|
+
throw new Error(`[playbook] quantity must be a positive integer, got "${quantity}"`);
|
|
35
|
+
}
|
|
36
|
+
return n;
|
|
37
|
+
};
|
|
38
|
+
export const usePlaybookCart = (addToCart) => {
|
|
39
|
+
// The documented usage passes an INLINE arrow, so `addToCart` is a new
|
|
40
|
+
// identity every render. Keeping it in a ref means the global is registered
|
|
41
|
+
// once and the callback stays current, instead of the handler being torn down
|
|
42
|
+
// and reassigned on every render of the component that mounts the bridge.
|
|
43
|
+
const latest = useRef(addToCart);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
latest.current = addToCart;
|
|
46
|
+
}, [addToCart]);
|
|
47
|
+
const enabled = Boolean(addToCart);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!enabled)
|
|
50
|
+
return;
|
|
51
|
+
const host = window;
|
|
52
|
+
if (host.playbookAddToCart) {
|
|
53
|
+
// Two bridges racing means one silently wins and the other's cart is
|
|
54
|
+
// never used — worth a word in dev, where a storefront author can act on
|
|
55
|
+
// it. Not an throw: the page still works, and the later mount wins
|
|
56
|
+
// consistently.
|
|
57
|
+
console.warn("[playbook] window.playbookAddToCart was already registered; the most recent usePlaybookCart wins.");
|
|
58
|
+
}
|
|
59
|
+
// `async` so a SYNCHRONOUS throw from the storefront's callback (or from
|
|
60
|
+
// validation above) reaches Playbook as a REJECTION. Playbook awaits this
|
|
61
|
+
// and renders the shopper a failure state; a sync throw would escape that
|
|
62
|
+
// path entirely and the button would sit there having done nothing.
|
|
63
|
+
const handler = async (input) => {
|
|
64
|
+
const add = latest.current;
|
|
65
|
+
if (!add)
|
|
66
|
+
return;
|
|
67
|
+
// Spread FIRST so any field Playbook adds later rides along untouched;
|
|
68
|
+
// only the two we normalize are overridden.
|
|
69
|
+
return add({
|
|
70
|
+
...input,
|
|
71
|
+
variantId: toVariantGid(input.variantId),
|
|
72
|
+
quantity: toQuantity(input.quantity),
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
host.playbookAddToCart = handler;
|
|
76
|
+
return () => {
|
|
77
|
+
// Clear ONLY our own registration, by identity. React can mount the next
|
|
78
|
+
// instance before unmounting the previous one (a route transition, or
|
|
79
|
+
// StrictMode's double-invoke in dev), so an unconditional delete would
|
|
80
|
+
// remove the NEWER handler and leave the storefront with no bridge at
|
|
81
|
+
// all — the button silently degrading to navigation, on a store that had
|
|
82
|
+
// opted in.
|
|
83
|
+
if (host.playbookAddToCart === handler)
|
|
84
|
+
delete host.playbookAddToCart;
|
|
85
|
+
};
|
|
86
|
+
}, [enabled]);
|
|
87
|
+
};
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "4.
|
|
1
|
+
export const VERSION = "4.5.0";
|
package/package.json
CHANGED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
interface PreviewContext {
|
|
3
|
-
linkName?: string;
|
|
4
|
-
releaseId?: string;
|
|
5
|
-
releaseName?: string;
|
|
6
|
-
locale?: string;
|
|
7
|
-
pageDraft?: string;
|
|
8
|
-
siteSettingsDraft?: string;
|
|
9
|
-
testHandle?: string;
|
|
10
|
-
testVariantHandle?: string;
|
|
11
|
-
}
|
|
12
|
-
interface PreviewToastProps {
|
|
13
|
-
isPreviewModeEnabled: boolean;
|
|
14
|
-
environment?: string;
|
|
15
|
-
previewContext?: PreviewContext;
|
|
16
|
-
}
|
|
17
|
-
export declare const buildPreviewMessage: (environment?: string, previewContext?: PreviewContext) => string;
|
|
18
|
-
export declare const PreviewToast: ({ isPreviewModeEnabled, environment, previewContext, }: PreviewToastProps) => React.JSX.Element | null;
|
|
19
|
-
export {};
|
|
20
|
-
//# sourceMappingURL=preview-toast.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"preview-toast.d.ts","sourceRoot":"","sources":["../../src/pack/preview-toast.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAC;AAEnD,UAAU,cAAc;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,UAAU,iBAAiB;IACzB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAsBD,eAAO,MAAM,mBAAmB,iBAChB,MAAM,mBACH,cAAc,WAgDhC,CAAC;AAEF,eAAO,MAAM,YAAY,2DAItB,iBAAiB,6BAkInB,CAAC"}
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from "react";
|
|
2
|
-
const Eye = (props) => {
|
|
3
|
-
return (React.createElement("svg", { width: 24, height: 24, viewBox: "0 0 24 24", fill: "none", xmlns: "http://www.w3.org/2000/svg", ...props },
|
|
4
|
-
React.createElement("path", { fillRule: "evenodd", clipRule: "evenodd", d: "M2.75 12c0-.338.136-.905.493-1.591a7.858 7.858 0 0 1 1.64-2.108C6.374 6.926 8.694 5.75 12 5.75c3.308 0 5.627 1.176 7.116 2.551a7.86 7.86 0 0 1 1.64 2.108c.358.686.494 1.253.494 1.591 0 .338-.136.905-.493 1.591a7.86 7.86 0 0 1-1.64 2.108c-1.49 1.375-3.81 2.551-7.117 2.551-3.308 0-5.627-1.176-7.116-2.551a7.858 7.858 0 0 1-1.64-2.108c-.358-.686-.494-1.253-.494-1.591ZM12 4.25c-3.692 0-6.373 1.324-8.134 2.949a9.356 9.356 0 0 0-1.953 2.517c-.424.814-.663 1.622-.663 2.284 0 .662.24 1.47.663 2.284a9.356 9.356 0 0 0 1.953 2.517C5.627 18.426 8.308 19.75 12 19.75s6.373-1.324 8.134-2.949a9.356 9.356 0 0 0 1.953-2.517c.424-.814.663-1.622.663-2.284 0-.662-.24-1.47-.663-2.284a9.355 9.355 0 0 0-1.953-2.517C18.373 5.574 15.692 4.25 12 4.25ZM9.75 12a2.25 2.25 0 1 1 4.5 0 2.25 2.25 0 0 1-4.5 0ZM12 8.25a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5Z", fill: "currentColor" })));
|
|
5
|
-
};
|
|
6
|
-
export const buildPreviewMessage = (environment, previewContext) => {
|
|
7
|
-
const primaryContextParts = [];
|
|
8
|
-
const secondaryContextParts = [];
|
|
9
|
-
if (previewContext?.linkName) {
|
|
10
|
-
primaryContextParts.push(`preview link: ${previewContext.linkName}`);
|
|
11
|
-
}
|
|
12
|
-
else if (previewContext?.releaseName) {
|
|
13
|
-
primaryContextParts.push(`content release: ${previewContext.releaseName}`);
|
|
14
|
-
}
|
|
15
|
-
else if (previewContext?.releaseId) {
|
|
16
|
-
primaryContextParts.push("content release");
|
|
17
|
-
}
|
|
18
|
-
if (previewContext?.locale) {
|
|
19
|
-
secondaryContextParts.push(`locale: ${previewContext.locale}`);
|
|
20
|
-
}
|
|
21
|
-
if (previewContext?.pageDraft) {
|
|
22
|
-
secondaryContextParts.push(`page: ${previewContext.pageDraft}`);
|
|
23
|
-
}
|
|
24
|
-
if (previewContext?.siteSettingsDraft) {
|
|
25
|
-
secondaryContextParts.push(`settings: ${previewContext.siteSettingsDraft}`);
|
|
26
|
-
}
|
|
27
|
-
// Backpack uses a lone `control` variant as a neutral preview fallback.
|
|
28
|
-
// Only describe an A/B test when the preview carries a complete test context.
|
|
29
|
-
if (previewContext?.testHandle && previewContext.testVariantHandle) {
|
|
30
|
-
secondaryContextParts.push(`test: ${previewContext.testVariantHandle}`);
|
|
31
|
-
}
|
|
32
|
-
if (primaryContextParts.length === 0 && secondaryContextParts.length === 0) {
|
|
33
|
-
const baseMessage = "You are currently previewing your storefront with draft content";
|
|
34
|
-
return environment
|
|
35
|
-
? `${baseMessage} on ${environment} environment`
|
|
36
|
-
: baseMessage;
|
|
37
|
-
}
|
|
38
|
-
const parts = ["Previewing", ...primaryContextParts];
|
|
39
|
-
if (environment) {
|
|
40
|
-
parts.push(`${environment} environment`);
|
|
41
|
-
}
|
|
42
|
-
parts.push(...secondaryContextParts);
|
|
43
|
-
return parts.join(" • ");
|
|
44
|
-
};
|
|
45
|
-
export const PreviewToast = ({ isPreviewModeEnabled, environment, previewContext, }) => {
|
|
46
|
-
const [showToast, setShowToast] = useState(false);
|
|
47
|
-
const exitPreviewMode = async () => {
|
|
48
|
-
try {
|
|
49
|
-
const response = await fetch("/api/edit", {
|
|
50
|
-
method: "POST",
|
|
51
|
-
headers: {
|
|
52
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
if (!response.ok) {
|
|
56
|
-
throw new Error(`ERR_RE_API_ERROR: HTTP error! Status: ${response.status} Doc: https://docs.packdigital.com/err/ERR_RE_API_ERROR`);
|
|
57
|
-
}
|
|
58
|
-
window.location.reload();
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
console.error("Error exiting preview:", error.message);
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
const isIframe = window.self !== window.top;
|
|
66
|
-
if (isPreviewModeEnabled && !isIframe) {
|
|
67
|
-
setShowToast(true);
|
|
68
|
-
}
|
|
69
|
-
}, [isPreviewModeEnabled]);
|
|
70
|
-
if (showToast) {
|
|
71
|
-
return (React.createElement("div", { "data-pack-preview-toast": true, style: {
|
|
72
|
-
position: "fixed",
|
|
73
|
-
zIndex: "2147483647",
|
|
74
|
-
left: 0,
|
|
75
|
-
right: 0,
|
|
76
|
-
paddingLeft: "max(16px, env(safe-area-inset-left))",
|
|
77
|
-
paddingRight: "max(16px, env(safe-area-inset-right))",
|
|
78
|
-
bottom: "max(16px, env(safe-area-inset-bottom))",
|
|
79
|
-
width: "100%",
|
|
80
|
-
boxSizing: "border-box",
|
|
81
|
-
pointerEvents: "none",
|
|
82
|
-
} },
|
|
83
|
-
React.createElement("div", { "data-pack-preview-toast-content": true, style: {
|
|
84
|
-
margin: "0 auto",
|
|
85
|
-
pointerEvents: "auto",
|
|
86
|
-
minWidth: "min(640px, 100%)",
|
|
87
|
-
maxWidth: "min(960px, 100%)",
|
|
88
|
-
boxShadow: "0px 2px 10px 0px rgba(0, 0, 0, 0.10), 0px 0px 2px 0px rgba(0, 0, 0, 0.10)",
|
|
89
|
-
width: "fit-content",
|
|
90
|
-
boxSizing: "border-box",
|
|
91
|
-
padding: "8px 12px",
|
|
92
|
-
backgroundColor: "#1f2937",
|
|
93
|
-
color: "#f9fafb",
|
|
94
|
-
borderRadius: "8px",
|
|
95
|
-
display: "flex",
|
|
96
|
-
alignItems: "center",
|
|
97
|
-
fontFamily: '"Inter", sans-serif',
|
|
98
|
-
justifyContent: "space-between",
|
|
99
|
-
gap: "8px",
|
|
100
|
-
} },
|
|
101
|
-
React.createElement("div", { style: {
|
|
102
|
-
display: "flex",
|
|
103
|
-
alignItems: "center",
|
|
104
|
-
gap: "8px",
|
|
105
|
-
overflow: "hidden",
|
|
106
|
-
flex: "1 1 auto",
|
|
107
|
-
minWidth: 0,
|
|
108
|
-
} },
|
|
109
|
-
React.createElement(Eye, { style: { width: 16, height: 16, minWidth: 16 } }),
|
|
110
|
-
React.createElement("p", { style: {
|
|
111
|
-
flex: 1,
|
|
112
|
-
minWidth: 0,
|
|
113
|
-
margin: 0,
|
|
114
|
-
fontSize: "14px",
|
|
115
|
-
lineHeight: "20px",
|
|
116
|
-
display: "-webkit-box",
|
|
117
|
-
WebkitBoxOrient: "vertical",
|
|
118
|
-
WebkitLineClamp: 2,
|
|
119
|
-
overflow: "hidden",
|
|
120
|
-
overflowWrap: "anywhere",
|
|
121
|
-
} }, buildPreviewMessage(environment, previewContext))),
|
|
122
|
-
React.createElement("button", { type: "button", "aria-label": "Exit preview mode", onClick: exitPreviewMode, style: {
|
|
123
|
-
cursor: "pointer",
|
|
124
|
-
fontSize: 13,
|
|
125
|
-
fontWeight: "500",
|
|
126
|
-
flexShrink: "0",
|
|
127
|
-
minHeight: 44,
|
|
128
|
-
padding: "6px 8px",
|
|
129
|
-
border: 0,
|
|
130
|
-
borderRadius: 4,
|
|
131
|
-
background: "transparent",
|
|
132
|
-
color: "inherit",
|
|
133
|
-
fontFamily: "inherit",
|
|
134
|
-
textDecoration: "underline",
|
|
135
|
-
textUnderlineOffset: 2,
|
|
136
|
-
whiteSpace: "nowrap",
|
|
137
|
-
} }, "Exit preview"))));
|
|
138
|
-
}
|
|
139
|
-
return null;
|
|
140
|
-
};
|