@wocart/storefront 1.5.0 → 1.7.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.
@@ -31,7 +31,7 @@ import { collectForwardedHeaders } from "../actions.js";
31
31
  * ```
32
32
  */
33
33
  export function createProxyEndpoint(config) {
34
- const { method, client, inputSchema, queryParams = [], pathParams = {}, csrfProtection = method === "POST", forwardAttribution = false, successStatus = 200, errorStatus, successBody, noContent = false, } = config;
34
+ const { method, client, inputSchema, queryParams = [], pathParams = {}, csrfProtection = method !== "GET", readInput, forwardAttribution = false, successStatus = 200, errorStatus, successBody, noContent = false, } = config;
35
35
  return async (context) => {
36
36
  const { request, params } = context;
37
37
  // CSRF Protection: reject cross-origin requests
@@ -41,7 +41,10 @@ export function createProxyEndpoint(config) {
41
41
  // Parse input based on method
42
42
  let input;
43
43
  try {
44
- if (method === "GET") {
44
+ if (readInput) {
45
+ input = readInput(request);
46
+ }
47
+ else if (method === "GET") {
45
48
  input = parseGetInput(request, params, queryParams, pathParams);
46
49
  }
47
50
  else {
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * The storefront's same-origin API routes, ready to export.
3
3
  *
4
- * Every theme needs the same eight routes between the shopper's browser and
5
- * the platform: OTP send and verify, cart validation, abandoned-checkout
6
- * tracking and its conversion, communes, and reviews (list and submit). What
4
+ * Every theme needs the same routes between the shopper's browser and the
5
+ * platform: OTP send and verify, cart validation, abandoned-checkout tracking
6
+ * and its conversion, communes, and reviews (list, find the order, send a
7
+ * photo or voice note, submit). What
7
8
  * each one must get right is decided here once — the cross-origin check on
8
9
  * every POST, the shopper's address forwarded wherever the server counts or
9
10
  * attributes per shopper, the input bounds, and the response shape the
@@ -47,7 +48,15 @@ export interface StorefrontRoutes<C extends AstroAPIContext> {
47
48
  readonly communes: StorefrontApiRoute<C>;
48
49
  /** GET, path param `productId`, query `limit`/`offset` → `{ data: Review[], total }`. */
49
50
  readonly reviewsList: StorefrontApiRoute<C>;
50
- /** POST a `ReviewInput` → 201 `{ success: true, data: { id } }`. */
51
+ /** POST `{ orderNumber, phone, turnstileToken? }` → `{ data: ReviewEligibility }`. */
52
+ readonly reviewEligibility: StorefrontApiRoute<C>;
53
+ /**
54
+ * PUT one file as the body, the grant in `X-Review-Grant` (and a voice
55
+ * note's length in `X-Media-Duration-Ms`) → 201 `{ data: ReviewUpload }`.
56
+ * The file streams on to the server unbuffered.
57
+ */
58
+ readonly reviewUpload: StorefrontApiRoute<C>;
59
+ /** POST a `ReviewInput` → 201 `{ success: true, data: { id, visibility } }`. */
51
60
  readonly reviewSubmit: StorefrontApiRoute<C>;
52
61
  }
53
62
  export declare const REVIEWS_PAGE_DEFAULT = 20;
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * The storefront's same-origin API routes, ready to export.
3
3
  *
4
- * Every theme needs the same eight routes between the shopper's browser and
5
- * the platform: OTP send and verify, cart validation, abandoned-checkout
6
- * tracking and its conversion, communes, and reviews (list and submit). What
4
+ * Every theme needs the same routes between the shopper's browser and the
5
+ * platform: OTP send and verify, cart validation, abandoned-checkout tracking
6
+ * and its conversion, communes, and reviews (list, find the order, send a
7
+ * photo or voice note, submit). What
7
8
  * each one must get right is decided here once — the cross-origin check on
8
9
  * every POST, the shopper's address forwarded wherever the server counts or
9
10
  * attributes per shopper, the input bounds, and the response shape the
@@ -65,11 +66,40 @@ const reviewsListInput = z.object({
65
66
  */
66
67
  const reviewSubmitInput = z.object({
67
68
  orderNumber: z.string().min(1).max(64),
69
+ phone: z.string().min(1).max(32).optional(),
68
70
  productId: z.string().min(1).max(200),
69
71
  rating: z.number(),
70
72
  title: z.string().max(500).optional(),
71
73
  body: z.string().max(5000),
74
+ grant: z.string().min(1).max(2048).optional(),
75
+ uploads: z.array(z.string().min(1).max(64)).max(8).optional(),
72
76
  });
77
+ const reviewEligibilityInput = z.object({
78
+ orderNumber: z.string().min(1).max(64),
79
+ phone: z.string().min(1).max(32),
80
+ turnstileToken: z.string().min(1).max(2048).optional(),
81
+ });
82
+ /** The file itself stays a stream; only the headers around it are checked here. */
83
+ const reviewUploadInput = z.object({
84
+ body: z.custom((value) => value instanceof ReadableStream, "No file was sent"),
85
+ grant: z.string().min(1).max(2048),
86
+ contentLength: z.number().int().positive().optional(),
87
+ durationMs: z.number().int().positive().max(600_000).optional(),
88
+ });
89
+ function readUpload(request) {
90
+ const length = Number(request.headers.get("Content-Length"));
91
+ const duration = Number(request.headers.get("X-Media-Duration-Ms"));
92
+ return {
93
+ body: request.body,
94
+ grant: request.headers.get("X-Review-Grant") ?? "",
95
+ ...(Number.isInteger(length) && length > 0 ? { contentLength: length } : {}),
96
+ ...(Number.isInteger(duration) && duration > 0 ? { durationMs: duration } : {}),
97
+ };
98
+ }
99
+ /** Drops the keys a form left undefined: the client's input type is exact. */
100
+ function defined(value) {
101
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
102
+ }
73
103
  export function createStorefrontRoutes(options) {
74
104
  const route = (config) => async (context) => {
75
105
  const { call, ...endpoint } = config;
@@ -130,12 +160,24 @@ export function createStorefrontRoutes(options) {
130
160
  data: await client.products.reviews.list(productId, { limit, offset }),
131
161
  }),
132
162
  }),
163
+ reviewEligibility: route({
164
+ method: "POST",
165
+ inputSchema: reviewEligibilityInput,
166
+ call: (client, input) => client.products.reviews.eligibility(defined(input)),
167
+ }),
168
+ reviewUpload: route({
169
+ method: "PUT",
170
+ readInput: readUpload,
171
+ inputSchema: reviewUploadInput,
172
+ successStatus: 201,
173
+ call: (client, input) => client.products.reviews.upload(defined(input)),
174
+ }),
133
175
  reviewSubmit: route({
134
176
  method: "POST",
135
177
  inputSchema: reviewSubmitInput,
136
178
  successStatus: 201,
137
179
  successBody: (data) => ({ success: true, data }),
138
- call: (client, { title, ...review }) => client.products.reviews.submit(title === undefined ? review : { ...review, title }),
180
+ call: (client, review) => client.products.reviews.submit(defined(review)),
139
181
  }),
140
182
  };
141
183
  }
package/dist/index.d.ts CHANGED
@@ -17,11 +17,11 @@ export { productImageSource } from "./utils/product-image.js";
17
17
  export type { ProductImageSourceInput } from "./utils/product-image.js";
18
18
  export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, stripTrackingParams, truncateAtWordBoundary, resolvePrefillText, buildWhatsAppHref, } from "./whatsapp.js";
19
19
  export type { PrefillToken, ResolvePrefillParams } from "./whatsapp.js";
20
- export { STOREFRONT_ROUTES, showsWidgetOn } from "./routes.js";
20
+ export { STOREFRONT_PAGE_PATHS, STOREFRONT_ROUTES, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
21
21
  export { servedLanguage } from "./locales.js";
22
22
  export { alternateLinks, localeRouting, localizeHref, resolveLocalePath } from "./locale-routing.js";
23
23
  export type { LocalePath, LocaleRouting } from "./locale-routing.js";
24
- export type { StorefrontRoute } from "./routes.js";
24
+ export type { StorefrontPageMatch, StorefrontPageType, StorefrontPathParams, StorefrontRoute } from "./routes.js";
25
25
  export { createBasketStore, createMemoryStorageAdapter, createLocalStorageAdapter, DEFAULT_BASKET_STORAGE_KEY, toOrderItems, } from "./basket.js";
26
26
  export type { BasketStore, BasketStorageAdapter, BasketStoreOptions, BasketListener, } from "./basket.js";
27
27
  export { createOtpMachine, createOtpTransport, mapOtpResponse, otpTransportFailure, } from "./otp.js";
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { formatMoney } from "./utils/money.js";
13
13
  export { optimizeImage, generateSrcSet, IMAGE_SIZES, getCardImage, getGalleryImage, getThumbnailImage, getHeroImage, getCategoryImage, getLogoImage, createImageHelpers, } from "./utils/image.js";
14
14
  export { productImageSource } from "./utils/product-image.js";
15
15
  export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, stripTrackingParams, truncateAtWordBoundary, resolvePrefillText, buildWhatsAppHref, } from "./whatsapp.js";
16
- export { STOREFRONT_ROUTES, showsWidgetOn } from "./routes.js";
16
+ export { STOREFRONT_PAGE_PATHS, STOREFRONT_ROUTES, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
17
17
  export { servedLanguage } from "./locales.js";
18
18
  export { alternateLinks, localeRouting, localizeHref, resolveLocalePath } from "./locale-routing.js";
19
19
  export { createBasketStore, createMemoryStorageAdapter, createLocalStorageAdapter, DEFAULT_BASKET_STORAGE_KEY, toOrderItems, } from "./basket.js";
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `@wocart/storefront/review-media` — what a theme's review form does in the
3
+ * browser before a photo or a voice note is sent, so it arrives small on a
4
+ * slow mobile connection:
5
+ *
6
+ * prepareReviewPhoto shrinks a photo to 1600 px at most and re-encodes it
7
+ * (WebP, else JPEG) — a phone photo of several MB
8
+ * becomes a few hundred KB, and its EXIF (the phone's
9
+ * location) is gone before it leaves the device
10
+ * createVoiceRecorder records speech at a low bitrate in what the phone
11
+ * records (WebM/Opus, MP4/AAC on iPhone), and stops by
12
+ * itself at the store's limit
13
+ *
14
+ * Browser-only, and kept off the main entry point: a theme that takes no
15
+ * media pays nothing for it. Each takes its browser dependencies as an
16
+ * optional last argument, for tests and unusual hosts.
17
+ */
18
+ export interface PhotoOptions {
19
+ /** Longest edge in pixels. Default 1600 (what the server keeps). */
20
+ maxEdge?: number;
21
+ /** Encoder quality, 0–1. Default 0.82. */
22
+ quality?: number;
23
+ }
24
+ /** The size a photo is drawn at: its own, or scaled so its longest edge is `maxEdge`. */
25
+ export declare function photoTargetSize(width: number, height: number, maxEdge: number): {
26
+ width: number;
27
+ height: number;
28
+ };
29
+ export interface PhotoDeps {
30
+ createImageBitmap: (image: Blob, options?: ImageBitmapOptions) => Promise<ImageBitmap>;
31
+ /** A canvas of the given size that can encode itself. */
32
+ createCanvas: (width: number, height: number) => {
33
+ getContext(type: "2d"): Pick<CanvasRenderingContext2D, "drawImage"> | null;
34
+ toBlob(type: string, quality: number): Promise<Blob | null>;
35
+ };
36
+ }
37
+ /**
38
+ * A photo ready to send: drawn upright (the camera's orientation applied),
39
+ * no larger than `maxEdge`, re-encoded as WebP (JPEG where WebP cannot be
40
+ * written). When the browser cannot do it, the photo is sent as it is — the
41
+ * server still converts it and drops its EXIF.
42
+ */
43
+ export declare function prepareReviewPhoto(photo: Blob, options?: PhotoOptions, deps?: PhotoDeps | null): Promise<Blob>;
44
+ /** Formats in the order a recorder is tried: Opus where it can be written, AAC on iPhone. */
45
+ export declare const VOICE_MIME_TYPES: readonly ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/mp4;codecs=mp4a.40.2", "audio/mp4", "audio/webm"];
46
+ /** The first format this browser can record, or null when it cannot record voice. */
47
+ export declare function pickVoiceMimeType(isTypeSupported: (type: string) => boolean): string | null;
48
+ export interface RecordedVoice {
49
+ blob: Blob;
50
+ /** As measured while recording; send it with the upload. */
51
+ durationMs: number;
52
+ mimeType: string;
53
+ }
54
+ export interface VoiceRecorderOptions {
55
+ /** Stops by itself after this many seconds (the store's limit). */
56
+ maxSeconds: number;
57
+ /** About every 250 ms while recording, with the time recorded so far. */
58
+ onTick?: (elapsedMs: number) => void;
59
+ /** When it stopped by itself at the limit. */
60
+ onLimit?: (voice: RecordedVoice) => void;
61
+ }
62
+ export interface VoiceRecorder {
63
+ /** Asks for the microphone and starts. Rejects when it is refused or unavailable. */
64
+ start(): Promise<void>;
65
+ /** Stops and gives the recording. */
66
+ stop(): Promise<RecordedVoice>;
67
+ /** Stops and throws the recording away. */
68
+ cancel(): void;
69
+ readonly state: "idle" | "recording" | "stopped";
70
+ }
71
+ export interface VoiceDeps {
72
+ getUserMedia: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
73
+ MediaRecorder: {
74
+ new (stream: MediaStream, options?: MediaRecorderOptions): MediaRecorder;
75
+ isTypeSupported(type: string): boolean;
76
+ };
77
+ now: () => number;
78
+ setInterval: (fn: () => void, ms: number) => unknown;
79
+ clearInterval: (id: unknown) => void;
80
+ }
81
+ /** Whether this browser can record a voice note at all (hide the record button when not). */
82
+ export declare function canRecordVoice(deps?: VoiceDeps | null): boolean;
83
+ /**
84
+ * A recorder for one voice note: speech at about 32 kb/s, mono, with the
85
+ * phone's echo cancellation and noise suppression — 60 s is about 240 KB.
86
+ */
87
+ export declare function createVoiceRecorder(options: VoiceRecorderOptions, deps?: VoiceDeps | null): VoiceRecorder;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * `@wocart/storefront/review-media` — what a theme's review form does in the
3
+ * browser before a photo or a voice note is sent, so it arrives small on a
4
+ * slow mobile connection:
5
+ *
6
+ * prepareReviewPhoto shrinks a photo to 1600 px at most and re-encodes it
7
+ * (WebP, else JPEG) — a phone photo of several MB
8
+ * becomes a few hundred KB, and its EXIF (the phone's
9
+ * location) is gone before it leaves the device
10
+ * createVoiceRecorder records speech at a low bitrate in what the phone
11
+ * records (WebM/Opus, MP4/AAC on iPhone), and stops by
12
+ * itself at the store's limit
13
+ *
14
+ * Browser-only, and kept off the main entry point: a theme that takes no
15
+ * media pays nothing for it. Each takes its browser dependencies as an
16
+ * optional last argument, for tests and unusual hosts.
17
+ */
18
+ /** The size a photo is drawn at: its own, or scaled so its longest edge is `maxEdge`. */
19
+ export function photoTargetSize(width, height, maxEdge) {
20
+ const longest = Math.max(width, height);
21
+ if (longest <= maxEdge || longest === 0)
22
+ return { width, height };
23
+ const scale = maxEdge / longest;
24
+ return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
25
+ }
26
+ function browserPhotoDeps() {
27
+ if (typeof createImageBitmap !== "function" || typeof document === "undefined")
28
+ return null;
29
+ return {
30
+ createImageBitmap: (image, options) => createImageBitmap(image, options),
31
+ createCanvas: (width, height) => {
32
+ const canvas = document.createElement("canvas");
33
+ canvas.width = width;
34
+ canvas.height = height;
35
+ return {
36
+ getContext: () => canvas.getContext("2d"),
37
+ toBlob: (type, quality) => new Promise((resolve) => canvas.toBlob(resolve, type, quality)),
38
+ };
39
+ },
40
+ };
41
+ }
42
+ /**
43
+ * A photo ready to send: drawn upright (the camera's orientation applied),
44
+ * no larger than `maxEdge`, re-encoded as WebP (JPEG where WebP cannot be
45
+ * written). When the browser cannot do it, the photo is sent as it is — the
46
+ * server still converts it and drops its EXIF.
47
+ */
48
+ export async function prepareReviewPhoto(photo, options = {}, deps = browserPhotoDeps()) {
49
+ if (!deps)
50
+ return photo;
51
+ const maxEdge = options.maxEdge ?? 1600;
52
+ const quality = options.quality ?? 0.82;
53
+ let bitmap;
54
+ try {
55
+ bitmap = await deps.createImageBitmap(photo, { imageOrientation: "from-image" });
56
+ }
57
+ catch {
58
+ return photo;
59
+ }
60
+ try {
61
+ const size = photoTargetSize(bitmap.width, bitmap.height, maxEdge);
62
+ const canvas = deps.createCanvas(size.width, size.height);
63
+ const context = canvas.getContext("2d");
64
+ if (!context)
65
+ return photo;
66
+ context.drawImage(bitmap, 0, 0, size.width, size.height);
67
+ for (const type of ["image/webp", "image/jpeg"]) {
68
+ const blob = await canvas.toBlob(type, quality);
69
+ if (blob && blob.type === type && blob.size > 0)
70
+ return blob;
71
+ }
72
+ return photo;
73
+ }
74
+ catch {
75
+ return photo;
76
+ }
77
+ finally {
78
+ bitmap.close?.();
79
+ }
80
+ }
81
+ /** Formats in the order a recorder is tried: Opus where it can be written, AAC on iPhone. */
82
+ export const VOICE_MIME_TYPES = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/mp4;codecs=mp4a.40.2", "audio/mp4", "audio/webm"];
83
+ /** The first format this browser can record, or null when it cannot record voice. */
84
+ export function pickVoiceMimeType(isTypeSupported) {
85
+ return VOICE_MIME_TYPES.find((type) => {
86
+ try {
87
+ return isTypeSupported(type);
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }) ?? null;
93
+ }
94
+ function browserVoiceDeps() {
95
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined")
96
+ return null;
97
+ return {
98
+ getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),
99
+ MediaRecorder,
100
+ now: () => performance.now(),
101
+ setInterval: (fn, ms) => setInterval(fn, ms),
102
+ clearInterval: (id) => clearInterval(id),
103
+ };
104
+ }
105
+ /** Whether this browser can record a voice note at all (hide the record button when not). */
106
+ export function canRecordVoice(deps = browserVoiceDeps()) {
107
+ return Boolean(deps && pickVoiceMimeType((type) => deps.MediaRecorder.isTypeSupported(type)));
108
+ }
109
+ /**
110
+ * A recorder for one voice note: speech at about 32 kb/s, mono, with the
111
+ * phone's echo cancellation and noise suppression — 60 s is about 240 KB.
112
+ */
113
+ export function createVoiceRecorder(options, deps = browserVoiceDeps()) {
114
+ let state = "idle";
115
+ let recorder = null;
116
+ let stream = null;
117
+ let chunks = [];
118
+ let startedAt = 0;
119
+ let ticker = null;
120
+ let finished = null;
121
+ const release = () => {
122
+ if (ticker !== null)
123
+ deps?.clearInterval(ticker);
124
+ ticker = null;
125
+ stream?.getTracks().forEach((track) => track.stop());
126
+ stream = null;
127
+ };
128
+ return {
129
+ get state() {
130
+ return state;
131
+ },
132
+ async start() {
133
+ if (!deps)
134
+ throw new Error("This browser cannot record voice notes");
135
+ if (state === "recording")
136
+ return;
137
+ const mimeType = pickVoiceMimeType((type) => deps.MediaRecorder.isTypeSupported(type));
138
+ if (!mimeType)
139
+ throw new Error("This browser cannot record voice notes");
140
+ stream = await deps.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
141
+ chunks = [];
142
+ recorder = new deps.MediaRecorder(stream, { mimeType, audioBitsPerSecond: 32_000 });
143
+ const active = recorder;
144
+ finished = new Promise((resolve) => {
145
+ active.ondataavailable = (event) => {
146
+ if (event.data.size > 0)
147
+ chunks.push(event.data);
148
+ };
149
+ active.onstop = () => {
150
+ const type = (active.mimeType || mimeType).split(";")[0];
151
+ resolve({ blob: new Blob(chunks, { type }), durationMs: Math.round(deps.now() - startedAt), mimeType: type });
152
+ };
153
+ });
154
+ startedAt = deps.now();
155
+ recorder.start(1000);
156
+ state = "recording";
157
+ ticker = deps.setInterval(() => {
158
+ const elapsed = deps.now() - startedAt;
159
+ options.onTick?.(elapsed);
160
+ if (elapsed >= options.maxSeconds * 1000 && state === "recording") {
161
+ void this.stop().then((voice) => options.onLimit?.(voice));
162
+ }
163
+ }, 250);
164
+ },
165
+ async stop() {
166
+ if (state !== "recording" || !recorder || !finished)
167
+ throw new Error("Not recording");
168
+ state = "stopped";
169
+ recorder.stop();
170
+ const voice = await finished;
171
+ release();
172
+ return voice;
173
+ },
174
+ cancel() {
175
+ if (recorder && state === "recording")
176
+ recorder.stop();
177
+ state = "stopped";
178
+ chunks = [];
179
+ release();
180
+ },
181
+ };
182
+ }
package/dist/routes.d.ts CHANGED
@@ -1,20 +1,59 @@
1
1
  /**
2
- * The canonical storefront route contract.
2
+ * The storefront's page types and their public paths.
3
3
  *
4
- * One deployment serves one store, and three parts of the platform meet on the
5
- * same public URLs: the theme renders them, the server writes them into a conversion's
6
- * landing URL (`/lp/:slug` for an attributed sale, `/thank-you` otherwise), and
7
- * the dashboard shows them as the store's public links (`/pages/:slug`). These
8
- * patterns are what all three already assume, so they are written down here: a
9
- * theme may restyle everything else, but these paths are a platform contract,
10
- * not a theme decision.
4
+ * A page type is what a URL *means* — a product, a category, a store page, a
5
+ * landing page — and the path is where it lives. The paths are a platform
6
+ * contract, not a theme decision: the platform builds them without running
7
+ * the theme (the dashboard shows a page's public link, the server names the
8
+ * landing page in a conversion's source URL), so a theme renders each page
9
+ * type it supports at its path and builds every link to one with
10
+ * `storefrontPath` — never a hand-written string. Any other route is the
11
+ * theme's own (`/about`, `/offers/summer` …): Astro's file routing owns those,
12
+ * and nothing here constrains them.
11
13
  *
12
- * Two further paths carry no parameter and no pattern: the root (`/`, the home
13
- * surface) and the catalog index (`/products`, the catalog surface). The
14
- * surface gate below handles them alongside the six frozen patterns.
14
+ * Language prefixes are applied on top: `localizeHref(storefrontPath(…),
15
+ * locale, routing)`.
15
16
  */
16
17
  import type { WidgetSurfaces } from "./types.js";
17
- /** One route in the contract: the pattern and the surface that renders it. */
18
+ /** Each page type's public path; `:handle` / `:slug` mark its one parameter. */
19
+ export declare const STOREFRONT_PAGE_PATHS: {
20
+ readonly home: "/";
21
+ readonly catalog: "/products";
22
+ readonly category: "/category/:slug";
23
+ readonly product: "/products/:handle";
24
+ readonly page: "/pages/:slug";
25
+ readonly landing: "/lp/:slug";
26
+ readonly checkout: "/checkout";
27
+ readonly thankYou: "/thank-you";
28
+ };
29
+ /** What a storefront URL means. */
30
+ export type StorefrontPageType = keyof typeof STOREFRONT_PAGE_PATHS;
31
+ type ParamOf<P extends string> = P extends `${string}:${infer Name}` ? Name : never;
32
+ /** The parameter a page type's path needs: `{ handle }` for a product, `{ slug }` for a category, page or landing page, nothing otherwise. */
33
+ export type StorefrontPathParams<T extends StorefrontPageType> = ParamOf<(typeof STOREFRONT_PAGE_PATHS)[T]> extends never ? Record<string, never> : {
34
+ [K in ParamOf<(typeof STOREFRONT_PAGE_PATHS)[T]>]: string;
35
+ };
36
+ /** A pathname recognised as one of the page types, with its parameter. */
37
+ export interface StorefrontPageMatch {
38
+ readonly type: StorefrontPageType;
39
+ readonly params: Readonly<Record<string, string>>;
40
+ }
41
+ /**
42
+ * The public path of a page type, its parameter encoded:
43
+ * `storefrontPath("product", { handle: "robe" })` → `/products/robe`.
44
+ * Throws when the parameter is missing — a link to nowhere is a bug to see,
45
+ * not a path to render.
46
+ */
47
+ export declare function storefrontPath<T extends StorefrontPageType>(type: T, params?: StorefrontPathParams<T>): string;
48
+ /**
49
+ * Which page type a pathname is, and its parameter (decoded) — or null for
50
+ * any other path. Exact matches only: `/products/` is the catalog,
51
+ * `/productsomething` and `/products/a/b` are nothing. The query, the
52
+ * fragment and a trailing slash are ignored. Pass a pathname without its
53
+ * language prefix (`resolveLocalePath` gives it).
54
+ */
55
+ export declare function matchStorefrontPath(rawPathname: string): StorefrontPageMatch | null;
56
+ /** One route with a parameter or a fixed path of its own, and the widget surface that renders it. */
18
57
  export interface StorefrontRoute {
19
58
  /** Path pattern; `:handle` and `:slug` mark the single path parameter. */
20
59
  readonly path: string;
@@ -22,24 +61,17 @@ export interface StorefrontRoute {
22
61
  readonly surface: keyof WidgetSurfaces;
23
62
  }
24
63
  /**
25
- * The frozen contract. Adding a route is a deliberate platform change: a path
26
- * that renders a surface but is missing here is a surface the merchant cannot
27
- * switch on with any confidence.
64
+ * @deprecated Use `STOREFRONT_PAGE_PATHS` (every page type, the home and the
65
+ * catalog index included) with `storefrontPath` and `matchStorefrontPath`.
66
+ * Kept equal to what it always listed.
28
67
  */
29
68
  export declare const STOREFRONT_ROUTES: readonly StorefrontRoute[];
30
69
  /**
31
- * Whether the widget belongs on a pathname.
32
- *
33
- * The merchant answers "which surfaces" (`surfaces[surface]`); this function
34
- * answers the platform half — which surface a pathname *is* — and it reads the
35
- * contract above:
36
- *
37
- * - `/` → the home surface;
38
- * - `/products` (the catalog index) and `/category/:slug` → the catalog surface;
39
- * - `/products/:handle` → the product surface;
40
- * - `/pages/:slug`, `/lp/:slug`, `/checkout`, `/thank-you` → their own surfaces;
41
- * - anything else — a 404, `/productsomething`, `/checkout-success` — → no
42
- * surface at all, never a prefix match that leaks a widget onto a page the
43
- * merchant switched off.
70
+ * Whether the widget belongs on a pathname: the merchant answers "which
71
+ * surfaces" (`surfaces[surface]`); this answers which surface a pathname *is*,
72
+ * from the page types above. Any other path — a 404, `/productsomething`,
73
+ * `/checkout-success` — is no surface at all, never a prefix match that leaks
74
+ * the widget onto a page the merchant switched off.
44
75
  */
45
76
  export declare function showsWidgetOn(surfaces: WidgetSurfaces | null | undefined, rawPathname: string): boolean;
77
+ export {};