@spindlecommerce/storefront-next 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +30 -2
- package/dist/client.d.ts +14 -0
- package/dist/client.js +52 -0
- package/dist/cookies.d.ts +4 -0
- package/dist/cookies.js +4 -0
- package/dist/images.d.ts +18 -0
- package/dist/images.js +51 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/preview.d.ts +25 -0
- package/dist/preview.js +21 -0
- package/dist/routes.d.ts +25 -0
- package/dist/routes.js +115 -0
- package/dist/storefront.d.ts +152 -0
- package/dist/storefront.js +117 -0
- package/dist/tags.d.ts +19 -0
- package/dist/tags.js +37 -0
- package/package.json +55 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The SpindleCommerce authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @spindlecommerce/storefront-next
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Next.js App Router integration for [SpindleCommerce](https://spindlecommerce.com) storefronts, on [`@spindlecommerce/storefront-client`](https://www.npmjs.com/package/@spindlecommerce/storefront-client).
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// lib/storefront.ts
|
|
7
|
+
import { createNextStorefront } from "@spindlecommerce/storefront-next";
|
|
8
|
+
|
|
9
|
+
export const storefront = createNextStorefront({
|
|
10
|
+
url: process.env.SPINDLE_API_URL!,
|
|
11
|
+
market: async () => ({ channel: "eu", locale: "en-GB", currency: "GBP" }),
|
|
12
|
+
});
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- **Reads** (`getRoute`, `getSite`, `getNewestProducts`, `getCategoryProducts`, `getLocalizedPath`, `getSitemap`) are cached under tags (`TAG`) and refetched after `revalidate` seconds (300). In draft mode they read fresh, with the preview token. `reader(tags)` gives a client for your own cached queries.
|
|
16
|
+
- **The cart** lives in an httpOnly cookie: `const cart = await storefront.cart()`, then `cart.add(lines)`, `cart.update(lineId, quantity)`, `cart.move({ locale, currency })` or `cart.checkout.placeOrder(…)`. Make changes in Server Actions or Route Handlers.
|
|
17
|
+
- **Routes** for the platform's preview and webhooks:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// app/api/draft/route.ts
|
|
21
|
+
export const GET = createDraftRoute(storefront, { channel: "eu" });
|
|
22
|
+
// app/api/draft/disable/route.ts
|
|
23
|
+
const route = createDraftDisableRoute();
|
|
24
|
+
export const GET = route.GET;
|
|
25
|
+
export const POST = route.POST;
|
|
26
|
+
// app/api/revalidate/route.ts: verifies x-spindle-signature with SPINDLE_REVALIDATE_SECRET
|
|
27
|
+
export const POST = createRevalidateRoute();
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **In the browser** (`@spindlecommerce/storefront-next/client`): `<VisualEditing adminOrigin bridgeUrl />` connects draft pages to the admin's editor, and `useCartCount()` and `notifyCartChanged()` keep a cart badge current without making pages dynamic.
|
|
31
|
+
- **Images** (`@spindlecommerce/storefront-next/images`): `imagePatterns({ apiUrl, mediaHosts })` for `images.remotePatterns`, and `isOptimizable(src, patterns)`.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connects the page to the admin's editor when framed there. A saved draft re-renders through
|
|
3
|
+
* router.refresh(); the bridge is told once the new content has committed. `localize` turns the
|
|
4
|
+
* admin's paths, which have no language, into this page's.
|
|
5
|
+
*/
|
|
6
|
+
export declare function VisualEditing({ adminOrigin, bridgeUrl, localize }: {
|
|
7
|
+
adminOrigin: string;
|
|
8
|
+
bridgeUrl: string;
|
|
9
|
+
localize?: (path: string) => string;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
11
|
+
/** After a cart change in this tab, so useCartCount reads the count again. */
|
|
12
|
+
export declare const notifyCartChanged: () => boolean;
|
|
13
|
+
/** The cart's item count, read from a cookie in the browser so pages stay cacheable. 0 on the server. */
|
|
14
|
+
export declare const useCartCount: () => number;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { visualEditingBridge } from "@spindlecommerce/storefront-client";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import Script from "next/script";
|
|
6
|
+
import { useEffect, useRef, useSyncExternalStore, useTransition } from "react";
|
|
7
|
+
import { CART_COUNT_COOKIE } from "./cookies.js";
|
|
8
|
+
/**
|
|
9
|
+
* Connects the page to the admin's editor when framed there. A saved draft re-renders through
|
|
10
|
+
* router.refresh(); the bridge is told once the new content has committed. `localize` turns the
|
|
11
|
+
* admin's paths, which have no language, into this page's.
|
|
12
|
+
*/
|
|
13
|
+
export function VisualEditing({ adminOrigin, bridgeUrl, localize }) {
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
const [refreshing, startTransition] = useTransition();
|
|
16
|
+
const awaitingRender = useRef(false);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
// Runs after the refreshed server components have been committed to the DOM.
|
|
19
|
+
if (refreshing || !awaitingRender.current)
|
|
20
|
+
return;
|
|
21
|
+
awaitingRender.current = false;
|
|
22
|
+
visualEditingBridge()?.rendered();
|
|
23
|
+
}, [refreshing]);
|
|
24
|
+
const connect = () => {
|
|
25
|
+
visualEditingBridge()?.init({
|
|
26
|
+
adminOrigin,
|
|
27
|
+
onRefresh: () => {
|
|
28
|
+
awaitingRender.current = true;
|
|
29
|
+
startTransition(() => router.refresh());
|
|
30
|
+
},
|
|
31
|
+
navigate: (path) => router.push(localize ? localize(path) : path),
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
return _jsx(Script, { src: bridgeUrl, strategy: "afterInteractive", onReady: connect });
|
|
35
|
+
}
|
|
36
|
+
const CART_CHANGED = "spindle:cart-changed";
|
|
37
|
+
const readCount = () => {
|
|
38
|
+
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${CART_COUNT_COOKIE}=(\\d+)`));
|
|
39
|
+
return match ? Number(match[1]) : 0;
|
|
40
|
+
};
|
|
41
|
+
const subscribe = (onChange) => {
|
|
42
|
+
window.addEventListener(CART_CHANGED, onChange);
|
|
43
|
+
window.addEventListener("focus", onChange);
|
|
44
|
+
return () => {
|
|
45
|
+
window.removeEventListener(CART_CHANGED, onChange);
|
|
46
|
+
window.removeEventListener("focus", onChange);
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
/** After a cart change in this tab, so useCartCount reads the count again. */
|
|
50
|
+
export const notifyCartChanged = () => window.dispatchEvent(new Event(CART_CHANGED));
|
|
51
|
+
/** The cart's item count, read from a cookie in the browser so pages stay cacheable. 0 on the server. */
|
|
52
|
+
export const useCartCount = () => useSyncExternalStore(subscribe, readCount, () => 0);
|
package/dist/cookies.js
ADDED
package/dist/images.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** next/image settings for the platform's media. No Next.js imports: next.config.ts loads this. */
|
|
2
|
+
export type ImagePattern = {
|
|
3
|
+
protocol: "http" | "https";
|
|
4
|
+
hostname: string;
|
|
5
|
+
port?: string;
|
|
6
|
+
pathname?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Remote patterns for the platform's media: uploads it stores itself, a Vercel Blob store, and
|
|
10
|
+
* `mediaHosts`. Those are URLs or bare hostnames, comma or space separated; a path limits a host
|
|
11
|
+
* to that prefix, and `*.` matches one subdomain level.
|
|
12
|
+
*/
|
|
13
|
+
export declare const imagePatterns: ({ apiUrl, mediaHosts }: {
|
|
14
|
+
apiUrl: string;
|
|
15
|
+
mediaHosts?: string;
|
|
16
|
+
}) => ImagePattern[];
|
|
17
|
+
/** Whether next/image may optimize this absolute URL with these patterns. */
|
|
18
|
+
export declare const isOptimizable: (src: string, patterns: ImagePattern[]) => boolean;
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** next/image settings for the platform's media. No Next.js imports: next.config.ts loads this. */
|
|
2
|
+
/**
|
|
3
|
+
* Remote patterns for the platform's media: uploads it stores itself, a Vercel Blob store, and
|
|
4
|
+
* `mediaHosts`. Those are URLs or bare hostnames, comma or space separated; a path limits a host
|
|
5
|
+
* to that prefix, and `*.` matches one subdomain level.
|
|
6
|
+
*/
|
|
7
|
+
export const imagePatterns = ({ apiUrl, mediaHosts = "" }) => {
|
|
8
|
+
const api = new URL(apiUrl);
|
|
9
|
+
const hosts = mediaHosts
|
|
10
|
+
.split(/[\s,]+/)
|
|
11
|
+
.filter(Boolean)
|
|
12
|
+
.flatMap((entry) => {
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(/^https?:\/\//.test(entry) ? entry : `https://${entry}`);
|
|
15
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
16
|
+
return [{ protocol: url.protocol === "http:" ? "http" : "https", hostname: url.hostname, port: url.port, ...(path ? { pathname: `${path}/**` } : {}) }];
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
return [
|
|
23
|
+
{ protocol: api.protocol === "https:" ? "https" : "http", hostname: api.hostname, port: api.port, pathname: `${api.pathname.replace(/\/+$/, "")}/media/**` },
|
|
24
|
+
{ protocol: "https", hostname: "*.public.blob.vercel-storage.com" },
|
|
25
|
+
...hosts,
|
|
26
|
+
];
|
|
27
|
+
};
|
|
28
|
+
/** The optimizer refuses local addresses (SSRF protection), so those are served as is. */
|
|
29
|
+
const isLocalHost = (host) => host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
|
|
30
|
+
/** Next.js host matching: `*.` is one subdomain level, `**.` any number. */
|
|
31
|
+
const hostMatches = (host, pattern) => {
|
|
32
|
+
if (pattern.startsWith("**."))
|
|
33
|
+
return host.endsWith(pattern.slice(2));
|
|
34
|
+
if (pattern.startsWith("*."))
|
|
35
|
+
return host.endsWith(pattern.slice(1)) && !host.slice(0, -(pattern.length - 1)).includes(".");
|
|
36
|
+
return host === pattern;
|
|
37
|
+
};
|
|
38
|
+
const matches = (url, pattern) => url.protocol === `${pattern.protocol}:` &&
|
|
39
|
+
hostMatches(url.hostname, pattern.hostname) &&
|
|
40
|
+
(!pattern.port || url.port === pattern.port) &&
|
|
41
|
+
(!pattern.pathname || url.pathname.startsWith(pattern.pathname.replace(/\*\*$/, "")));
|
|
42
|
+
/** Whether next/image may optimize this absolute URL with these patterns. */
|
|
43
|
+
export const isOptimizable = (src, patterns) => {
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(src);
|
|
46
|
+
return !isLocalHost(url.hostname) && patterns.some((p) => matches(url, p));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { CART_COOKIE, CART_COUNT_COOKIE } from "./cookies.ts";
|
|
2
|
+
export { PREVIEW_COOKIE } from "./preview.ts";
|
|
3
|
+
export { createDraftDisableRoute, createDraftRoute, createRevalidateRoute, type DraftRouteOptions } from "./routes.ts";
|
|
4
|
+
export { createNextStorefront, type Market, type NextStorefront, type NextStorefrontOptions, type Scope } from "./storefront.ts";
|
|
5
|
+
export { TAG, tagsFor } from "./tags.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { CART_COOKIE, CART_COUNT_COOKIE } from "./cookies.js";
|
|
2
|
+
export { PREVIEW_COOKIE } from "./preview.js";
|
|
3
|
+
export { createDraftDisableRoute, createDraftRoute, createRevalidateRoute } from "./routes.js";
|
|
4
|
+
export { createNextStorefront } from "./storefront.js";
|
|
5
|
+
export { TAG, tagsFor } from "./tags.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Cookies the preview route sets next to Next.js's own draft mode cookie. */
|
|
2
|
+
export declare const PREVIEW_COOKIE: {
|
|
3
|
+
readonly token: "spindle_preview_token";
|
|
4
|
+
/** The channel being previewed. The language is the path's prefix. */
|
|
5
|
+
readonly channel: "spindle_preview_channel";
|
|
6
|
+
/** The admin origin the visual editing bridge may talk to. */
|
|
7
|
+
readonly adminOrigin: "spindle_preview_admin";
|
|
8
|
+
};
|
|
9
|
+
/** Next.js's draft mode cookie, set by draftMode().enable(). */
|
|
10
|
+
export declare const BYPASS_COOKIE = "__prerender_bypass";
|
|
11
|
+
type CookieOptions = {
|
|
12
|
+
httpOnly: boolean;
|
|
13
|
+
path: string;
|
|
14
|
+
secure?: boolean;
|
|
15
|
+
sameSite: "lax" | "none";
|
|
16
|
+
partitioned?: boolean;
|
|
17
|
+
expires?: Date;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Over HTTPS, SameSite=None and Partitioned (CHIPS) so the cookies work when an admin on another
|
|
21
|
+
* site frames the storefront. Over HTTP (local development, all on localhost), SameSite=Lax.
|
|
22
|
+
*/
|
|
23
|
+
export declare const previewCookieOptions: (secure: boolean, expires?: Date) => CookieOptions;
|
|
24
|
+
export declare const isSecureRequest: (request: Request) => boolean;
|
|
25
|
+
export {};
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Cookies the preview route sets next to Next.js's own draft mode cookie. */
|
|
2
|
+
export const PREVIEW_COOKIE = {
|
|
3
|
+
token: "spindle_preview_token",
|
|
4
|
+
/** The channel being previewed. The language is the path's prefix. */
|
|
5
|
+
channel: "spindle_preview_channel",
|
|
6
|
+
/** The admin origin the visual editing bridge may talk to. */
|
|
7
|
+
adminOrigin: "spindle_preview_admin",
|
|
8
|
+
};
|
|
9
|
+
/** Next.js's draft mode cookie, set by draftMode().enable(). */
|
|
10
|
+
export const BYPASS_COOKIE = "__prerender_bypass";
|
|
11
|
+
/**
|
|
12
|
+
* Over HTTPS, SameSite=None and Partitioned (CHIPS) so the cookies work when an admin on another
|
|
13
|
+
* site frames the storefront. Over HTTP (local development, all on localhost), SameSite=Lax.
|
|
14
|
+
*/
|
|
15
|
+
export const previewCookieOptions = (secure, expires) => ({
|
|
16
|
+
httpOnly: true,
|
|
17
|
+
path: "/",
|
|
18
|
+
...(secure ? { secure: true, sameSite: "none", partitioned: true } : { sameSite: "lax" }),
|
|
19
|
+
...(expires ? { expires } : {}),
|
|
20
|
+
});
|
|
21
|
+
export const isSecureRequest = (request) => new URL(request.url).protocol === "https:" || request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() === "https";
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NextStorefront } from "./storefront.ts";
|
|
2
|
+
export type DraftRouteOptions = {
|
|
3
|
+
/** The channel to preview when the admin names none. */
|
|
4
|
+
channel: string;
|
|
5
|
+
/** Where the preview opens; by default `path`. Add the preview's language here if the storefront shows it in the URL. */
|
|
6
|
+
location?: (path: string, preview: {
|
|
7
|
+
locale: string | null;
|
|
8
|
+
channel: string | null;
|
|
9
|
+
}) => string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The preview route the admin frames: ?token=…&path=/summer-sale[&locale=de-DE][&channel=eu].
|
|
13
|
+
* Checks the token with the API, turns on draft mode and keeps the token in an httpOnly cookie.
|
|
14
|
+
*/
|
|
15
|
+
export declare const createDraftRoute: (storefront: Pick<NextStorefront, "previewSession">, options: DraftRouteOptions) => (request: Request) => Promise<Response>;
|
|
16
|
+
/** Leaves draft mode: GET ?path=/summer-sale, or a form POST with a `path` field. */
|
|
17
|
+
export declare const createDraftDisableRoute: () => {
|
|
18
|
+
GET: (request: Request) => Promise<Response>;
|
|
19
|
+
/** 303, so the browser follows with a GET. */
|
|
20
|
+
POST: (request: Request) => Promise<Response>;
|
|
21
|
+
};
|
|
22
|
+
/** The revalidate route: platform webhooks expire the cached reads they affect. `secret` defaults to SPINDLE_REVALIDATE_SECRET. */
|
|
23
|
+
export declare const createRevalidateRoute: (options?: {
|
|
24
|
+
secret?: string | null;
|
|
25
|
+
}) => (request: Request) => Promise<Response>;
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { changesFor, parseWebhookEvent, safeLocalPath, verifyWebhookSignature } from "@spindlecommerce/storefront-client";
|
|
2
|
+
import { revalidateTag } from "next/cache";
|
|
3
|
+
import { cookies, draftMode } from "next/headers";
|
|
4
|
+
import { BYPASS_COOKIE, isSecureRequest, PREVIEW_COOKIE, previewCookieOptions } from "./preview.js";
|
|
5
|
+
import { tagsFor } from "./tags.js";
|
|
6
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
7
|
+
const reject = (status, message) => new Response(`${message}\n`, { status, headers: { "content-type": "text/plain; charset=utf-8", ...NO_STORE } });
|
|
8
|
+
const json = (status, body) => Response.json(body, { status, headers: NO_STORE });
|
|
9
|
+
const LOCALE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
10
|
+
const CHANNEL_CODE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
11
|
+
const originOf = (value) => {
|
|
12
|
+
if (!value)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(value);
|
|
16
|
+
return /^https?:$/.test(url.protocol) ? url.origin : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The preview route the admin frames: ?token=…&path=/summer-sale[&locale=de-DE][&channel=eu].
|
|
24
|
+
* Checks the token with the API, turns on draft mode and keeps the token in an httpOnly cookie.
|
|
25
|
+
*/
|
|
26
|
+
export const createDraftRoute = (storefront, options) => async function GET(request) {
|
|
27
|
+
const params = new URL(request.url).searchParams;
|
|
28
|
+
const token = params.get("token");
|
|
29
|
+
const path = safeLocalPath(params.get("path") || "/");
|
|
30
|
+
const locale = params.get("locale")?.trim() || null;
|
|
31
|
+
const channel = params.get("channel")?.trim() || null;
|
|
32
|
+
if (!token)
|
|
33
|
+
return reject(401, "Missing preview token.");
|
|
34
|
+
if (!path)
|
|
35
|
+
return reject(400, "path must be a path on this storefront, e.g. /summer-sale.");
|
|
36
|
+
if (locale && !LOCALE.test(locale))
|
|
37
|
+
return reject(400, "Invalid locale.");
|
|
38
|
+
if (channel && !CHANNEL_CODE.test(channel))
|
|
39
|
+
return reject(400, "Invalid channel.");
|
|
40
|
+
let session;
|
|
41
|
+
try {
|
|
42
|
+
session = await storefront.previewSession(token, { channel: channel ?? options.channel, locale });
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.error("Preview session check failed", error);
|
|
46
|
+
return reject(401, "The preview could not be checked with the SpindleCommerce API. Try again from the admin.");
|
|
47
|
+
}
|
|
48
|
+
if (!session)
|
|
49
|
+
return reject(401, "The preview link is invalid or has expired. Open the preview again from the admin.");
|
|
50
|
+
(await draftMode()).enable();
|
|
51
|
+
const jar = await cookies();
|
|
52
|
+
const secure = isSecureRequest(request);
|
|
53
|
+
const expires = new Date(session.expiresAt);
|
|
54
|
+
const cookie = previewCookieOptions(secure, Number.isNaN(expires.getTime()) ? undefined : expires);
|
|
55
|
+
const keep = (name, value) => (value ? jar.set(name, value, cookie) : jar.delete({ name, path: cookie.path, ...(secure ? { secure: true, sameSite: "none", partitioned: true } : {}) }));
|
|
56
|
+
keep(PREVIEW_COOKIE.token, token);
|
|
57
|
+
keep(PREVIEW_COOKIE.channel, channel);
|
|
58
|
+
keep(PREVIEW_COOKIE.adminOrigin, originOf(session.adminOrigin));
|
|
59
|
+
if (secure) {
|
|
60
|
+
// Next.js does not partition its draft mode cookie. Partition it so it survives in browsers
|
|
61
|
+
// that block third-party cookies when the admin frames us.
|
|
62
|
+
const bypass = jar.get(BYPASS_COOKIE);
|
|
63
|
+
if (bypass?.value)
|
|
64
|
+
jar.set(BYPASS_COOKIE, bypass.value, previewCookieOptions(true));
|
|
65
|
+
}
|
|
66
|
+
// A relative Location keeps the frame on this origin, whatever host the request came in on.
|
|
67
|
+
const location = options.location ? options.location(path, { locale, channel }) : path;
|
|
68
|
+
return new Response(null, { status: 307, headers: { location, ...NO_STORE } });
|
|
69
|
+
};
|
|
70
|
+
/** Leaves draft mode: GET ?path=/summer-sale, or a form POST with a `path` field. */
|
|
71
|
+
export const createDraftDisableRoute = () => {
|
|
72
|
+
const exit = async (request, path, status) => {
|
|
73
|
+
(await draftMode()).disable();
|
|
74
|
+
const jar = await cookies();
|
|
75
|
+
const secure = isSecureRequest(request);
|
|
76
|
+
const attributes = secure ? { secure: true, sameSite: "none", partitioned: true } : {};
|
|
77
|
+
for (const name of Object.values(PREVIEW_COOKIE))
|
|
78
|
+
jar.delete({ name, path: "/", ...attributes });
|
|
79
|
+
// The partitioned draft mode cookie needs a partitioned deletion (see createDraftRoute).
|
|
80
|
+
if (secure)
|
|
81
|
+
jar.set(BYPASS_COOKIE, "", { ...previewCookieOptions(true), expires: new Date(0) });
|
|
82
|
+
return new Response(null, { status, headers: { location: safeLocalPath(path) ?? "/", ...NO_STORE } });
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
GET: (request) => exit(request, new URL(request.url).searchParams.get("path"), 307),
|
|
86
|
+
/** 303, so the browser follows with a GET. */
|
|
87
|
+
POST: async (request) => {
|
|
88
|
+
const form = await request.formData().catch(() => null);
|
|
89
|
+
const path = form?.get("path");
|
|
90
|
+
return exit(request, typeof path === "string" ? path : null, 303);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
95
|
+
/** The revalidate route: platform webhooks expire the cached reads they affect. `secret` defaults to SPINDLE_REVALIDATE_SECRET. */
|
|
96
|
+
export const createRevalidateRoute = (options = {}) => async function POST(request) {
|
|
97
|
+
const secret = options.secret ?? process.env.SPINDLE_REVALIDATE_SECRET;
|
|
98
|
+
if (!secret)
|
|
99
|
+
return json(503, { revalidated: false, error: "Revalidation is off: set SPINDLE_REVALIDATE_SECRET to the signing secret the admin showed for this storefront." });
|
|
100
|
+
if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES)
|
|
101
|
+
return json(413, { revalidated: false, error: "Body too large" });
|
|
102
|
+
// The signature covers the exact bytes sent: read the body as text and verify before parsing.
|
|
103
|
+
const body = await request.text();
|
|
104
|
+
if (!(await verifyWebhookSignature(secret, body, request.headers.get("x-spindle-signature"))))
|
|
105
|
+
return json(401, { revalidated: false, error: "Invalid or expired signature" });
|
|
106
|
+
const event = parseWebhookEvent(body);
|
|
107
|
+
if (!event)
|
|
108
|
+
return json(400, { revalidated: false, error: "Expected an event: { id, type, occurredAt, data }" });
|
|
109
|
+
// The type is read from the signed body; the x-spindle-event header is not signed.
|
|
110
|
+
const { changes, immediate } = changesFor(event);
|
|
111
|
+
const tags = tagsFor(changes);
|
|
112
|
+
for (const tag of tags)
|
|
113
|
+
revalidateTag(tag, immediate ? { expire: 0 } : "max");
|
|
114
|
+
return json(200, { revalidated: tags.length > 0, event: event.type, id: event.id ?? null, tags, immediate, now: Date.now() });
|
|
115
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { type CategoryTreeNode, type ContentNode, type ProductSort, type RouteResult, type StorefrontClient } from "@spindlecommerce/storefront-client";
|
|
3
|
+
/** The channel, language and currency a request shows. */
|
|
4
|
+
export type Market = {
|
|
5
|
+
channel: string;
|
|
6
|
+
locale: string | null;
|
|
7
|
+
currency: string | null;
|
|
8
|
+
};
|
|
9
|
+
/** The request's market, as draft mode and the preview cookies change it. */
|
|
10
|
+
export type Scope = Market & {
|
|
11
|
+
draft: boolean;
|
|
12
|
+
previewToken: string | null;
|
|
13
|
+
};
|
|
14
|
+
export type NextStorefrontOptions = {
|
|
15
|
+
/** The platform's URL. */
|
|
16
|
+
url: string;
|
|
17
|
+
/** The request's market, e.g. from the URL. Called once per request. */
|
|
18
|
+
market: () => Market | Promise<Market>;
|
|
19
|
+
/** Seconds a cached read lasts. Publishing expires reads sooner, through the revalidate route. */
|
|
20
|
+
revalidate?: number;
|
|
21
|
+
/** The content types of the header and footer pages. */
|
|
22
|
+
site?: {
|
|
23
|
+
header?: string;
|
|
24
|
+
footer?: string;
|
|
25
|
+
};
|
|
26
|
+
/** Whether a stored cart is still this storefront's, e.g. in one of its currencies. By default, while it is open. */
|
|
27
|
+
isUsableCart?: (cart: {
|
|
28
|
+
isOpen: boolean;
|
|
29
|
+
currencyCode: string | null;
|
|
30
|
+
}) => boolean;
|
|
31
|
+
};
|
|
32
|
+
type Root = {
|
|
33
|
+
id: string;
|
|
34
|
+
component: string;
|
|
35
|
+
};
|
|
36
|
+
/** The Storefront API for a Next.js App Router storefront. Server-side only. */
|
|
37
|
+
export declare const createNextStorefront: (options: NextStorefrontOptions) => {
|
|
38
|
+
url: string;
|
|
39
|
+
getScope: () => Promise<Scope>;
|
|
40
|
+
reader: (tags?: string[], language?: string) => Promise<StorefrontClient>;
|
|
41
|
+
privateClient: () => Promise<{
|
|
42
|
+
url: string;
|
|
43
|
+
scope: {
|
|
44
|
+
channel: string;
|
|
45
|
+
locale: string | null;
|
|
46
|
+
currency: string | null;
|
|
47
|
+
};
|
|
48
|
+
previewToken: string | null;
|
|
49
|
+
with: (changes: Partial<import("@spindlecommerce/storefront-client").StorefrontClientOptions>) => any;
|
|
50
|
+
query: <T>(document: string, variables?: Record<string, unknown>, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<T>;
|
|
51
|
+
mediaUrl: (src: string) => string;
|
|
52
|
+
route: (path: string, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<import("@spindlecommerce/storefront-client").RouteData | null>;
|
|
53
|
+
routeTemplate: (path: string, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<import("@spindlecommerce/storefront-client").TemplatePage | null>;
|
|
54
|
+
routeSections: (path: string, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<import("@spindlecommerce/storefront-client").BuilderPage | null>;
|
|
55
|
+
site: <Header extends {
|
|
56
|
+
id: string;
|
|
57
|
+
component: string;
|
|
58
|
+
} = ContentNode, Footer extends {
|
|
59
|
+
id: string;
|
|
60
|
+
component: string;
|
|
61
|
+
} = ContentNode>(types?: {
|
|
62
|
+
header?: string;
|
|
63
|
+
footer?: string;
|
|
64
|
+
}, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<import("@spindlecommerce/storefront-client").SiteData<Header, Footer>>;
|
|
65
|
+
newestProducts: (first: number, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<import("@spindlecommerce/storefront-client").ProductSummary[]>;
|
|
66
|
+
categoryProducts: (id: string, page: {
|
|
67
|
+
first: number;
|
|
68
|
+
after?: string | null;
|
|
69
|
+
sort?: ProductSort | null;
|
|
70
|
+
}, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<{
|
|
71
|
+
products: import("@spindlecommerce/storefront-client").ProductSummary[];
|
|
72
|
+
totalCount: number;
|
|
73
|
+
endCursor: string | null;
|
|
74
|
+
}>;
|
|
75
|
+
pages: (page: {
|
|
76
|
+
type?: string | null;
|
|
77
|
+
first: number;
|
|
78
|
+
after?: string | null;
|
|
79
|
+
}, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<import("@spindlecommerce/storefront-client").Connection<import("@spindlecommerce/storefront-client").PageEntry> | null>;
|
|
80
|
+
productSlugs: (after: string | null, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<import("@spindlecommerce/storefront-client").Connection<{
|
|
81
|
+
slug: string;
|
|
82
|
+
}> | null>;
|
|
83
|
+
categoryTree: (request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<CategoryTreeNode[]>;
|
|
84
|
+
localizedPath: (id: string, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<string | null>;
|
|
85
|
+
previewSession: (request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<import("@spindlecommerce/storefront-client").PreviewSession | null>;
|
|
86
|
+
countries: (request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<{
|
|
87
|
+
locale: string | null;
|
|
88
|
+
countries: import("@spindlecommerce/storefront-client").Country[];
|
|
89
|
+
}>;
|
|
90
|
+
guestOrder: (lookup: {
|
|
91
|
+
email: string;
|
|
92
|
+
number: string;
|
|
93
|
+
postalCode: string;
|
|
94
|
+
cartId: string;
|
|
95
|
+
}, request?: import("@spindlecommerce/storefront-client").RequestOptions) => Promise<{
|
|
96
|
+
order: import("@spindlecommerce/storefront-client").PlacedOrder;
|
|
97
|
+
paymentMethod: {
|
|
98
|
+
code: string;
|
|
99
|
+
name: string | null;
|
|
100
|
+
instructions: string | null;
|
|
101
|
+
} | null;
|
|
102
|
+
} | null>;
|
|
103
|
+
}>;
|
|
104
|
+
getRoute: (path: string) => Promise<RouteResult | null>;
|
|
105
|
+
getSite: <Header extends Root = ContentNode, Footer extends Root = ContentNode>() => Promise<import("@spindlecommerce/storefront-client").SiteData<Header, Footer>>;
|
|
106
|
+
getNewestProducts: (first: number) => Promise<import("@spindlecommerce/storefront-client").ProductSummary[]>;
|
|
107
|
+
getCategoryProducts: (id: string, first: number, after?: string | null, sort?: ProductSort | null) => Promise<{
|
|
108
|
+
products: import("@spindlecommerce/storefront-client").ProductSummary[];
|
|
109
|
+
totalCount: number;
|
|
110
|
+
endCursor: string | null;
|
|
111
|
+
}>;
|
|
112
|
+
getLocalizedPath: (id: string, language: string) => Promise<string | null>;
|
|
113
|
+
getSitemap: (type: string) => Promise<{
|
|
114
|
+
pages: import("@spindlecommerce/storefront-client").PageEntry[];
|
|
115
|
+
productSlugs: string[];
|
|
116
|
+
categoryPaths: string[];
|
|
117
|
+
}>;
|
|
118
|
+
previewSession: (token: string, scope: {
|
|
119
|
+
channel: string;
|
|
120
|
+
locale: string | null;
|
|
121
|
+
}) => Promise<import("@spindlecommerce/storefront-client").PreviewSession | null>;
|
|
122
|
+
cart: () => Promise<{
|
|
123
|
+
id: () => string | Promise<string | null> | null;
|
|
124
|
+
get: () => Promise<import("@spindlecommerce/storefront-client").Cart | null>;
|
|
125
|
+
add: (lines: {
|
|
126
|
+
variantId: string;
|
|
127
|
+
quantity: number;
|
|
128
|
+
}[]) => Promise<import("@spindlecommerce/storefront-client").Cart>;
|
|
129
|
+
update: (lineId: string, quantity: number) => Promise<import("@spindlecommerce/storefront-client").Cart>;
|
|
130
|
+
move: (to: {
|
|
131
|
+
locale: string;
|
|
132
|
+
currency: string;
|
|
133
|
+
}) => Promise<void>;
|
|
134
|
+
forget: () => void | Promise<void>;
|
|
135
|
+
checkout: {
|
|
136
|
+
get: () => Promise<import("@spindlecommerce/storefront-client").CheckoutCart | null>;
|
|
137
|
+
update: (input: import("@spindlecommerce/storefront-client").CartUpdateInput) => Promise<import("@spindlecommerce/storefront-client").CartChange>;
|
|
138
|
+
applyCoupon: (code: string) => Promise<import("@spindlecommerce/storefront-client").CartChange>;
|
|
139
|
+
removeCoupon: (code: string) => Promise<import("@spindlecommerce/storefront-client").CartChange>;
|
|
140
|
+
createPaymentSession: () => Promise<import("@spindlecommerce/storefront-client").PaymentSessionResult>;
|
|
141
|
+
placeOrder: (order: {
|
|
142
|
+
idempotencyKey: string;
|
|
143
|
+
payment?: {
|
|
144
|
+
token?: string;
|
|
145
|
+
};
|
|
146
|
+
input?: import("@spindlecommerce/storefront-client").CartUpdateInput;
|
|
147
|
+
}) => Promise<import("@spindlecommerce/storefront-client").PlaceOrderResult>;
|
|
148
|
+
};
|
|
149
|
+
}>;
|
|
150
|
+
};
|
|
151
|
+
export type NextStorefront = ReturnType<typeof createNextStorefront>;
|
|
152
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { allNodes, categoryPath, createCartSession, createStorefrontClient, isPreviewExpired, normalizePath, PreviewExpiredError, productPath, } from "@spindlecommerce/storefront-client";
|
|
3
|
+
import { cookies, draftMode } from "next/headers";
|
|
4
|
+
import { unstable_rethrow } from "next/navigation";
|
|
5
|
+
import { cache } from "react";
|
|
6
|
+
import { CART_COOKIE, CART_COUNT_COOKIE } from "./cookies.js";
|
|
7
|
+
import { PREVIEW_COOKIE } from "./preview.js";
|
|
8
|
+
import { TAG } from "./tags.js";
|
|
9
|
+
/** Only /products/… and /categories/… render a template; other URLs for them redirect. */
|
|
10
|
+
const templateKindOf = (path) => {
|
|
11
|
+
const normalized = normalizePath(path);
|
|
12
|
+
if (normalized.startsWith(productPath("")))
|
|
13
|
+
return "product";
|
|
14
|
+
if (normalized.startsWith(categoryPath("")))
|
|
15
|
+
return "category";
|
|
16
|
+
return null;
|
|
17
|
+
};
|
|
18
|
+
/** The cart's id in an httpOnly cookie, its item count in a readable one for the header. */
|
|
19
|
+
const cookieCartStore = {
|
|
20
|
+
get: async () => (await cookies()).get(CART_COOKIE)?.value ?? null,
|
|
21
|
+
set: async (cart) => {
|
|
22
|
+
const jar = await cookies();
|
|
23
|
+
const secure = process.env.NODE_ENV === "production";
|
|
24
|
+
const maxAge = 60 * 60 * 24 * 30;
|
|
25
|
+
jar.set(CART_COOKIE, cart.id, { httpOnly: true, sameSite: "lax", secure, path: "/", maxAge });
|
|
26
|
+
jar.set(CART_COUNT_COOKIE, String(cart.totalQuantity), { httpOnly: false, sameSite: "lax", secure, path: "/", maxAge });
|
|
27
|
+
},
|
|
28
|
+
clear: async () => {
|
|
29
|
+
const jar = await cookies();
|
|
30
|
+
jar.delete(CART_COOKIE);
|
|
31
|
+
jar.delete(CART_COUNT_COOKIE);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
/** The Storefront API for a Next.js App Router storefront. Server-side only. */
|
|
35
|
+
export const createNextStorefront = (options) => {
|
|
36
|
+
const revalidate = options.revalidate ?? 300;
|
|
37
|
+
const headerType = options.site?.header ?? "site_header";
|
|
38
|
+
const footerType = options.site?.footer ?? "site_footer";
|
|
39
|
+
/**
|
|
40
|
+
* Cookies are read only in draft mode, so published pages stay cacheable. Previewing another
|
|
41
|
+
* channel uses that channel's default language and currency.
|
|
42
|
+
*/
|
|
43
|
+
const getScope = cache(async () => {
|
|
44
|
+
const market = await options.market();
|
|
45
|
+
const { isEnabled } = await draftMode();
|
|
46
|
+
if (!isEnabled)
|
|
47
|
+
return { ...market, draft: false, previewToken: null };
|
|
48
|
+
const jar = await cookies();
|
|
49
|
+
const channel = jar.get(PREVIEW_COOKIE.channel)?.value || market.channel;
|
|
50
|
+
const own = channel === market.channel;
|
|
51
|
+
return { draft: true, previewToken: jar.get(PREVIEW_COOKIE.token)?.value || null, channel, locale: own ? market.locale : null, currency: own ? market.currency : null };
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* A client for reads, cached under `tags` outside draft mode and fetched fresh with the preview
|
|
55
|
+
* token in it. `language` replaces the market's, e.g. for hreflang alternates.
|
|
56
|
+
*/
|
|
57
|
+
const reader = async (tags = [], language) => {
|
|
58
|
+
const scope = await getScope();
|
|
59
|
+
const base = { url: options.url, channel: scope.channel, locale: language ?? scope.locale, currency: scope.currency };
|
|
60
|
+
if (!scope.draft)
|
|
61
|
+
return createStorefrontClient({ ...base, init: { next: { tags: [TAG.all, ...tags], revalidate } } });
|
|
62
|
+
if (!scope.previewToken)
|
|
63
|
+
throw new PreviewExpiredError();
|
|
64
|
+
return createStorefrontClient({ ...base, previewToken: scope.previewToken, init: { cache: "no-store" } });
|
|
65
|
+
};
|
|
66
|
+
/** A client for carts, checkout and other private data: never cached, never with the preview token. */
|
|
67
|
+
const privateClient = async () => {
|
|
68
|
+
const scope = await getScope();
|
|
69
|
+
return createStorefrontClient({ url: options.url, channel: scope.channel, locale: scope.locale, currency: scope.currency, init: { cache: "no-store" } });
|
|
70
|
+
};
|
|
71
|
+
/** Content a product or category page can do without: visitors get the page without it, editors the error. */
|
|
72
|
+
const optional = async (load, what) => {
|
|
73
|
+
try {
|
|
74
|
+
return await load();
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
unstable_rethrow(error);
|
|
78
|
+
if (isPreviewExpired(error) || (await getScope()).draft)
|
|
79
|
+
throw error;
|
|
80
|
+
console.error(`Could not load ${what}`, error);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
/** A storefront URL. Products and categories come with their template and own sections. */
|
|
85
|
+
const getRoute = cache(async (path) => {
|
|
86
|
+
const kind = templateKindOf(path);
|
|
87
|
+
const [route, template, sections] = await Promise.all([
|
|
88
|
+
reader([TAG.pages, TAG.catalog, TAG.path(path)]).then((client) => client.route(path)),
|
|
89
|
+
// Tagged with the catalog too, since a product's categories pick its template.
|
|
90
|
+
kind ? optional(async () => (await reader([TAG.templates(kind), TAG.pages, TAG.catalog])).routeTemplate(path), `the template for ${path}`) : null,
|
|
91
|
+
kind ? optional(async () => (await reader([TAG.sections(kind), TAG.pages, TAG.catalog])).routeSections(path), `the sections for ${path}`) : null,
|
|
92
|
+
]);
|
|
93
|
+
return route?.__typename === "Product" || route?.__typename === "Category" ? { ...route, template, sections } : route;
|
|
94
|
+
});
|
|
95
|
+
/** The header and footer, and the language and currency the API answers in. */
|
|
96
|
+
const getSite = cache(async () => (await reader([TAG.pages, TAG.catalog, TAG.singleton(headerType), TAG.singleton(footerType)])).site({ header: headerType, footer: footerType }));
|
|
97
|
+
const getNewestProducts = cache(async (first) => (await reader([TAG.catalog])).newestProducts(first));
|
|
98
|
+
/** A page of a category's products, its subcategories' included. */
|
|
99
|
+
const getCategoryProducts = cache(async (id, first, after = null, sort = null) => (await reader([TAG.catalog])).categoryProducts(id, { first, after, sort }));
|
|
100
|
+
/** A product's slug or category's path in another of the market's languages, for hreflang. */
|
|
101
|
+
const getLocalizedPath = async (id, language) => (await reader([TAG.catalog], language)).localizedPath(id);
|
|
102
|
+
/** Published pages of one content type, and every product and category. */
|
|
103
|
+
const getSitemap = async (type) => {
|
|
104
|
+
const flatten = (nodes) => nodes.flatMap((c) => [c.path, ...flatten(c.children ?? [])]);
|
|
105
|
+
const [pages, products, tree] = await Promise.all([
|
|
106
|
+
reader([TAG.pages]).then((client) => allNodes((after) => client.pages({ type, first: 100, after }))),
|
|
107
|
+
reader([TAG.catalog]).then((client) => allNodes((after) => client.productSlugs(after), 20)),
|
|
108
|
+
reader([TAG.catalog]).then((client) => client.categoryTree()),
|
|
109
|
+
]);
|
|
110
|
+
return { pages, productSlugs: products.map((p) => p.slug), categoryPaths: flatten(tree) };
|
|
111
|
+
};
|
|
112
|
+
/** Checks a preview token before draft mode is turned on; null when the API refuses it. */
|
|
113
|
+
const previewSession = (token, scope) => createStorefrontClient({ url: options.url, ...scope, previewToken: token, init: { cache: "no-store" } }).previewSession();
|
|
114
|
+
/** The shopper's cart and checkout, kept in cookies. Changes set cookies: make them in Server Actions or Route Handlers. */
|
|
115
|
+
const cart = async () => createCartSession(await privateClient(), cookieCartStore, { isUsable: options.isUsableCart });
|
|
116
|
+
return { url: options.url, getScope, reader, privateClient, getRoute, getSite, getNewestProducts, getCategoryProducts, getLocalizedPath, getSitemap, previewSession, cart };
|
|
117
|
+
};
|
package/dist/tags.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ContentChange, type TemplateKind } from "@spindlecommerce/storefront-client";
|
|
2
|
+
/** Cache tags on reads. The revalidate route maps webhook events onto them. */
|
|
3
|
+
export declare const TAG: {
|
|
4
|
+
/** Every read: component library changes expire everything. */
|
|
5
|
+
all: string;
|
|
6
|
+
/** Builder pages. One tag for all of them, since pages link to each other. */
|
|
7
|
+
pages: string;
|
|
8
|
+
/** Products and categories, including links and references to them in pages. */
|
|
9
|
+
catalog: string;
|
|
10
|
+
/** route(path) for one URL path. */
|
|
11
|
+
path: (path: string) => string;
|
|
12
|
+
/** A singleton page such as the site header. */
|
|
13
|
+
singleton: (type: string) => string;
|
|
14
|
+
/** Product or category pages, which hold the template they render with. */
|
|
15
|
+
templates: (kind: TemplateKind) => string;
|
|
16
|
+
/** Product or category pages' own sections. */
|
|
17
|
+
sections: (kind: TemplateKind) => string;
|
|
18
|
+
};
|
|
19
|
+
export declare const tagsFor: (changes: ContentChange[]) => string[];
|
package/dist/tags.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { normalizePath } from "@spindlecommerce/storefront-client";
|
|
2
|
+
/** Cache tags on reads. The revalidate route maps webhook events onto them. */
|
|
3
|
+
export const TAG = {
|
|
4
|
+
/** Every read: component library changes expire everything. */
|
|
5
|
+
all: "spindle",
|
|
6
|
+
/** Builder pages. One tag for all of them, since pages link to each other. */
|
|
7
|
+
pages: "spindle:pages",
|
|
8
|
+
/** Products and categories, including links and references to them in pages. */
|
|
9
|
+
catalog: "spindle:catalog",
|
|
10
|
+
/** route(path) for one URL path. */
|
|
11
|
+
path: (path) => `spindle:path:${normalizePath(path)}`.slice(0, 256),
|
|
12
|
+
/** A singleton page such as the site header. */
|
|
13
|
+
singleton: (type) => `spindle:singleton:${type}`.slice(0, 256),
|
|
14
|
+
/** Product or category pages, which hold the template they render with. */
|
|
15
|
+
templates: (kind) => `spindle:templates:${kind}`,
|
|
16
|
+
/** Product or category pages' own sections. */
|
|
17
|
+
sections: (kind) => `spindle:sections:${kind}`,
|
|
18
|
+
};
|
|
19
|
+
const tagFor = (change) => {
|
|
20
|
+
switch (change.kind) {
|
|
21
|
+
case "everything":
|
|
22
|
+
return TAG.all;
|
|
23
|
+
case "pages":
|
|
24
|
+
return TAG.pages;
|
|
25
|
+
case "catalog":
|
|
26
|
+
return TAG.catalog;
|
|
27
|
+
case "path":
|
|
28
|
+
return TAG.path(change.path);
|
|
29
|
+
case "templates":
|
|
30
|
+
return TAG.templates(change.for);
|
|
31
|
+
case "sections":
|
|
32
|
+
return TAG.sections(change.for);
|
|
33
|
+
case "singleton":
|
|
34
|
+
return TAG.singleton(change.type);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
export const tagsFor = (changes) => [...new Set(changes.map(tagFor))];
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spindlecommerce/storefront-next",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Next.js integration for SpindleCommerce storefronts: cached reads, carts in cookies, previews, visual editing and revalidation.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"spindlecommerce",
|
|
7
|
+
"storefront"
|
|
8
|
+
],
|
|
9
|
+
"homepage": "https://github.com/spindlecommerce/storefront-sdk/tree/main/packages/storefront-next#readme",
|
|
10
|
+
"bugs": "https://github.com/spindlecommerce/storefront-sdk/issues",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/spindlecommerce/storefront-sdk.git",
|
|
14
|
+
"directory": "packages/storefront-next"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./client": {
|
|
25
|
+
"types": "./dist/client.d.ts",
|
|
26
|
+
"default": "./dist/client.js"
|
|
27
|
+
},
|
|
28
|
+
"./images": {
|
|
29
|
+
"types": "./dist/images.d.ts",
|
|
30
|
+
"default": "./dist/images.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"server-only": "^0.0.1",
|
|
41
|
+
"@spindlecommerce/storefront-client": "^0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"next": "^16.0.0",
|
|
45
|
+
"react": "^19.0.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "26.6.5",
|
|
49
|
+
"@types/react": "19.3.0",
|
|
50
|
+
"next": "16.4.1",
|
|
51
|
+
"react": "19.3.1",
|
|
52
|
+
"react-dom": "19.3.1"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
56
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
57
|
+
}
|
|
6
58
|
}
|