@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.
- package/CHANGELOG.md +58 -0
- package/USAGE.md +150 -35
- package/dist/client/core.d.ts +6 -0
- package/dist/client/core.js +24 -4
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +3 -1
- package/dist/client/menus.d.ts +22 -0
- package/dist/client/menus.js +30 -0
- package/dist/client/products.d.ts +19 -7
- package/dist/client/products.js +22 -2
- package/dist/domain.d.ts +6 -0
- package/dist/domain.js +6 -0
- package/dist/endpoints/proxy.d.ts +8 -2
- package/dist/endpoints/proxy.js +5 -2
- package/dist/endpoints/storefront-routes.d.ts +13 -4
- package/dist/endpoints/storefront-routes.js +46 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/review-media.d.ts +87 -0
- package/dist/review-media.js +182 -0
- package/dist/routes.d.ts +60 -28
- package/dist/routes.js +80 -51
- package/dist/schemas.d.ts +91 -3
- package/dist/schemas.js +65 -1
- package/dist/types.d.ts +115 -3
- package/package.json +5 -1
package/dist/endpoints/proxy.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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,
|
|
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
|
|
2
|
+
* The storefront's page types and their public paths.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the dashboard shows
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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 {};
|