@wocart/storefront 1.19.0 → 1.20.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 +65 -0
- package/USAGE.md +286 -4
- package/dist/abandoned-checkout.d.ts +45 -0
- package/dist/abandoned-checkout.js +81 -3
- package/dist/answer-readers.d.ts +11 -4
- package/dist/answer-readers.js +114 -0
- package/dist/astro.d.ts +2 -0
- package/dist/astro.js +1 -0
- package/dist/basket.d.ts +11 -1
- package/dist/basket.js +17 -0
- package/dist/catalog.d.ts +18 -0
- package/dist/catalog.js +27 -0
- package/dist/client/orders.d.ts +10 -1
- package/dist/client/orders.js +8 -1
- package/dist/client/products.d.ts +13 -3
- package/dist/client/products.js +35 -12
- package/dist/coupon.d.ts +16 -1
- package/dist/coupon.js +37 -0
- package/dist/endpoints/storefront-routes.d.ts +8 -2
- package/dist/endpoints/storefront-routes.js +12 -2
- package/dist/index.d.ts +15 -7
- package/dist/index.js +9 -4
- package/dist/operations.d.ts +4 -0
- package/dist/operations.js +4 -0
- package/dist/order-number.d.ts +9 -0
- package/dist/order-number.js +11 -0
- package/dist/product-view.d.ts +8 -0
- package/dist/product-view.js +26 -4
- package/dist/quote.d.ts +27 -0
- package/dist/quote.js +43 -0
- package/dist/review-capture.d.ts +77 -0
- package/dist/review-capture.js +172 -0
- package/dist/review-media.d.ts +8 -75
- package/dist/review-media.js +7 -170
- package/dist/review-session.d.ts +97 -0
- package/dist/review-session.js +157 -0
- package/dist/reviews.d.ts +33 -0
- package/dist/reviews.js +41 -0
- package/dist/schemas.d.ts +98 -0
- package/dist/schemas.js +31 -0
- package/dist/seo.d.ts +47 -0
- package/dist/seo.js +87 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/sitemap.d.ts +22 -0
- package/dist/sitemap.js +51 -0
- package/dist/store-request.d.ts +96 -0
- package/dist/store-request.js +102 -0
- package/dist/types.d.ts +50 -2
- package/dist/utils/image.d.ts +48 -0
- package/dist/utils/image.js +51 -0
- package/package.json +1 -1
package/dist/operations.js
CHANGED
|
@@ -9,6 +9,7 @@ export const STOREFRONT_OPERATIONS = {
|
|
|
9
9
|
categoriesList: "categories.list",
|
|
10
10
|
productsGet: "products.get",
|
|
11
11
|
productsList: "products.list",
|
|
12
|
+
productsSearch: "products.search",
|
|
12
13
|
productsReviewsList: "products.reviews.list",
|
|
13
14
|
productsReviewsSubmit: "products.reviews.submit",
|
|
14
15
|
productsReviewsEligibility: "products.reviews.eligibility",
|
|
@@ -24,6 +25,7 @@ export const STOREFRONT_OPERATIONS = {
|
|
|
24
25
|
pageDataGet: "pageData.get",
|
|
25
26
|
ordersCreate: "orders.create",
|
|
26
27
|
ordersTracking: "orders.tracking",
|
|
28
|
+
ordersStatus: "orders.status",
|
|
27
29
|
ordersAbandonedUpsert: "orders.abandoned.upsert",
|
|
28
30
|
ordersAbandonedConvert: "orders.abandoned.convert",
|
|
29
31
|
ordersOtpSend: "orders.otp.send",
|
|
@@ -50,6 +52,7 @@ export const STOREFRONT_RETRY_POLICIES = {
|
|
|
50
52
|
[STOREFRONT_OPERATIONS.ordersOtpSend]: "never",
|
|
51
53
|
[STOREFRONT_OPERATIONS.ordersOtpVerify]: "never",
|
|
52
54
|
[STOREFRONT_OPERATIONS.ordersTracking]: "safe-read",
|
|
55
|
+
[STOREFRONT_OPERATIONS.ordersStatus]: "never",
|
|
53
56
|
[STOREFRONT_OPERATIONS.pageDataGet]: "safe-read",
|
|
54
57
|
[STOREFRONT_OPERATIONS.pagesGet]: "safe-read",
|
|
55
58
|
[STOREFRONT_OPERATIONS.pagesLanding]: "safe-read",
|
|
@@ -57,6 +60,7 @@ export const STOREFRONT_RETRY_POLICIES = {
|
|
|
57
60
|
[STOREFRONT_OPERATIONS.collectionsGet]: "safe-read",
|
|
58
61
|
[STOREFRONT_OPERATIONS.productsGet]: "safe-read",
|
|
59
62
|
[STOREFRONT_OPERATIONS.productsList]: "safe-read",
|
|
63
|
+
[STOREFRONT_OPERATIONS.productsSearch]: "safe-read",
|
|
60
64
|
[STOREFRONT_OPERATIONS.productsReviewsList]: "safe-read",
|
|
61
65
|
[STOREFRONT_OPERATIONS.productsReviewsSubmit]: "never",
|
|
62
66
|
[STOREFRONT_OPERATIONS.productsReviewsEligibility]: "never",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order number a shopper is given (`ORD-20261023-0042`) and types back to
|
|
3
|
+
* review what they bought or follow their order. The server refuses any other
|
|
4
|
+
* shape with `VALIDATION_FAILED`; a form can say so before asking.
|
|
5
|
+
*/
|
|
6
|
+
/** `ORD-`, the day the order was placed (`YYYYMMDD`), a dash and its digits; any case. */
|
|
7
|
+
export declare const ORDER_NUMBER_PATTERN: RegExp;
|
|
8
|
+
/** Whether `value`, without the spaces around it, has the shape of an order number. */
|
|
9
|
+
export declare function isOrderNumber(value: string): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order number a shopper is given (`ORD-20261023-0042`) and types back to
|
|
3
|
+
* review what they bought or follow their order. The server refuses any other
|
|
4
|
+
* shape with `VALIDATION_FAILED`; a form can say so before asking.
|
|
5
|
+
*/
|
|
6
|
+
/** `ORD-`, the day the order was placed (`YYYYMMDD`), a dash and its digits; any case. */
|
|
7
|
+
export const ORDER_NUMBER_PATTERN = /^ORD-\d{8}-\d+$/i;
|
|
8
|
+
/** Whether `value`, without the spaces around it, has the shape of an order number. */
|
|
9
|
+
export function isOrderNumber(value) {
|
|
10
|
+
return typeof value === "string" && ORDER_NUMBER_PATTERN.test(value.trim());
|
|
11
|
+
}
|
package/dist/product-view.d.ts
CHANGED
|
@@ -10,3 +10,11 @@ export interface ProductView {
|
|
|
10
10
|
}
|
|
11
11
|
export declare function getProductView(product: ProductDetail): ProductView;
|
|
12
12
|
export declare function getProductListView(product: ProductList): ProductView;
|
|
13
|
+
/**
|
|
14
|
+
* A product's tags as a list, whichever read it came from: a product page's
|
|
15
|
+
* `tags` is already a list, a listing's is the stored JSON text. Anything
|
|
16
|
+
* that is not a list of text reads as no tags.
|
|
17
|
+
*/
|
|
18
|
+
export declare function productTags(product: {
|
|
19
|
+
tags: string | null | readonly string[];
|
|
20
|
+
}): string[];
|
package/dist/product-view.js
CHANGED
|
@@ -8,13 +8,18 @@ function discountOf(basePrice, comparePrice) {
|
|
|
8
8
|
discountPct: Math.round((1 - basePrice / comparePrice) * 100),
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Whether what the page opens on can be sold, and is nearly gone: the default
|
|
13
|
+
* variant's stock for a product with variants, the product's own otherwise.
|
|
14
|
+
* The product decides whether stock is tracked at all.
|
|
15
|
+
*/
|
|
16
|
+
function stockOf(product, stock = product) {
|
|
12
17
|
if (!product.trackInventory) {
|
|
13
18
|
return { isOutOfStock: false, isLowStock: false };
|
|
14
19
|
}
|
|
15
20
|
return {
|
|
16
|
-
isOutOfStock:
|
|
17
|
-
isLowStock:
|
|
21
|
+
isOutOfStock: stock.inventory === 0,
|
|
22
|
+
isLowStock: stock.inventory > 0 && stock.inventory <= stock.lowStockThreshold,
|
|
18
23
|
};
|
|
19
24
|
}
|
|
20
25
|
export function getProductView(product) {
|
|
@@ -26,7 +31,7 @@ export function getProductView(product) {
|
|
|
26
31
|
basePrice,
|
|
27
32
|
comparePrice,
|
|
28
33
|
...discountOf(basePrice, comparePrice),
|
|
29
|
-
...stockOf(product),
|
|
34
|
+
...stockOf(product, defaultVariant ?? product),
|
|
30
35
|
};
|
|
31
36
|
}
|
|
32
37
|
export function getProductListView(product) {
|
|
@@ -39,3 +44,20 @@ export function getProductListView(product) {
|
|
|
39
44
|
...stockOf(product),
|
|
40
45
|
};
|
|
41
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* A product's tags as a list, whichever read it came from: a product page's
|
|
49
|
+
* `tags` is already a list, a listing's is the stored JSON text. Anything
|
|
50
|
+
* that is not a list of text reads as no tags.
|
|
51
|
+
*/
|
|
52
|
+
export function productTags(product) {
|
|
53
|
+
let tags = product.tags;
|
|
54
|
+
if (typeof tags === "string") {
|
|
55
|
+
try {
|
|
56
|
+
tags = JSON.parse(tags);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return Array.isArray(tags) ? tags.filter((tag) => typeof tag === "string") : [];
|
|
63
|
+
}
|
package/dist/quote.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CartQuoteOptions } from "./client/cart.js";
|
|
2
|
+
import type { CartQuoteLines } from "./domain.js";
|
|
3
|
+
import type { CartValidation } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* `fresh`: the answer to the latest question, to show. `stale`: a newer
|
|
6
|
+
* question was asked meanwhile; keep what is shown. `failed`: the latest
|
|
7
|
+
* question got no usable answer; the shopper can be told.
|
|
8
|
+
*/
|
|
9
|
+
export type QuoteResult = {
|
|
10
|
+
status: "fresh";
|
|
11
|
+
quote: CartValidation;
|
|
12
|
+
} | {
|
|
13
|
+
status: "stale";
|
|
14
|
+
} | {
|
|
15
|
+
status: "failed";
|
|
16
|
+
};
|
|
17
|
+
export interface QuoterOptions {
|
|
18
|
+
/** The theme's cart-validate route (`createStorefrontRoutes().cartValidate`), e.g. `/api/cart/validate`. */
|
|
19
|
+
endpoint: string;
|
|
20
|
+
/** The `fetch` to send with; the page's own by default. */
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
export interface Quoter {
|
|
24
|
+
/** Ask what `lines` would cost, with the destination, code and phone the shopper gave so far. */
|
|
25
|
+
quote(lines: CartQuoteLines, asks?: CartQuoteOptions): Promise<QuoteResult>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createQuoter(options: QuoterOptions): Quoter;
|
package/dist/quote.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking the theme's cart-validate route what an order would cost, from the
|
|
3
|
+
* browser: a product page as its order form posts it, or the basket.
|
|
4
|
+
*
|
|
5
|
+
* Every answer is the server's — lines re-priced from the catalog, offers,
|
|
6
|
+
* the coupon, the delivery fee — and a form asks again whenever the shopper
|
|
7
|
+
* changes something. Only the latest question counts: a slower answer to an
|
|
8
|
+
* earlier one is `stale`, so it never replaces what a newer answer shows.
|
|
9
|
+
*/
|
|
10
|
+
import { readCartQuote } from "./answer-readers.js";
|
|
11
|
+
export function createQuoter(options) {
|
|
12
|
+
let latest = 0;
|
|
13
|
+
return {
|
|
14
|
+
async quote(lines, asks = {}) {
|
|
15
|
+
const id = ++latest;
|
|
16
|
+
const send = options.fetch ?? globalThis.fetch;
|
|
17
|
+
const body = {
|
|
18
|
+
...lines,
|
|
19
|
+
...(asks.destination ? { destination: asks.destination } : {}),
|
|
20
|
+
...(asks.couponCode ? { couponCode: asks.couponCode } : {}),
|
|
21
|
+
...(asks.phone ? { phone: asks.phone } : {}),
|
|
22
|
+
};
|
|
23
|
+
let quote = null;
|
|
24
|
+
try {
|
|
25
|
+
const response = await send(options.endpoint, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
});
|
|
30
|
+
if (response.ok) {
|
|
31
|
+
const payload = await response.json();
|
|
32
|
+
quote = typeof payload === "object" && payload !== null ? readCartQuote(payload.data) : null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
quote = null;
|
|
37
|
+
}
|
|
38
|
+
if (id !== latest)
|
|
39
|
+
return { status: "stale" };
|
|
40
|
+
return quote === null ? { status: "failed" } : { status: "fresh", quote };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a review form does in the browser before a photo or a voice note is
|
|
3
|
+
* sent, so it arrives small on a slow mobile connection: a photo shrunk and
|
|
4
|
+
* re-encoded without its EXIF, a voice note recorded at a low bitrate that
|
|
5
|
+
* stops at the store's limit. Each takes its browser dependencies as an
|
|
6
|
+
* optional last argument, for tests and unusual hosts.
|
|
7
|
+
*/
|
|
8
|
+
export interface PhotoOptions {
|
|
9
|
+
/** Longest edge in pixels. Default 1600 (what the server keeps). */
|
|
10
|
+
maxEdge?: number;
|
|
11
|
+
/** Encoder quality, 0–1. Default 0.82. */
|
|
12
|
+
quality?: number;
|
|
13
|
+
}
|
|
14
|
+
/** The size a photo is drawn at: its own, or scaled so its longest edge is `maxEdge`. */
|
|
15
|
+
export declare function photoTargetSize(width: number, height: number, maxEdge: number): {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
};
|
|
19
|
+
export interface PhotoDeps {
|
|
20
|
+
createImageBitmap: (image: Blob, options?: ImageBitmapOptions) => Promise<ImageBitmap>;
|
|
21
|
+
/** A canvas of the given size that can encode itself. */
|
|
22
|
+
createCanvas: (width: number, height: number) => {
|
|
23
|
+
getContext(type: "2d"): Pick<CanvasRenderingContext2D, "drawImage"> | null;
|
|
24
|
+
toBlob(type: string, quality: number): Promise<Blob | null>;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A photo ready to send: drawn upright (the camera's orientation applied),
|
|
29
|
+
* no larger than `maxEdge`, re-encoded as WebP (JPEG where WebP cannot be
|
|
30
|
+
* written). When the browser cannot do it, the photo is sent as it is — the
|
|
31
|
+
* server still converts it and drops its EXIF.
|
|
32
|
+
*/
|
|
33
|
+
export declare function prepareReviewPhoto(photo: Blob, options?: PhotoOptions, deps?: PhotoDeps | null): Promise<Blob>;
|
|
34
|
+
/** Formats in the order a recorder is tried: Opus where it can be written, AAC on iPhone. */
|
|
35
|
+
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"];
|
|
36
|
+
/** The first format this browser can record, or null when it cannot record voice. */
|
|
37
|
+
export declare function pickVoiceMimeType(isTypeSupported: (type: string) => boolean): string | null;
|
|
38
|
+
export interface RecordedVoice {
|
|
39
|
+
blob: Blob;
|
|
40
|
+
/** As measured while recording; send it with the upload. */
|
|
41
|
+
durationMs: number;
|
|
42
|
+
mimeType: string;
|
|
43
|
+
}
|
|
44
|
+
export interface VoiceRecorderOptions {
|
|
45
|
+
/** Stops by itself after this many seconds (the store's limit). */
|
|
46
|
+
maxSeconds: number;
|
|
47
|
+
/** About every 250 ms while recording, with the time recorded so far. */
|
|
48
|
+
onTick?: (elapsedMs: number) => void;
|
|
49
|
+
/** When it stopped by itself at the limit. */
|
|
50
|
+
onLimit?: (voice: RecordedVoice) => void;
|
|
51
|
+
}
|
|
52
|
+
export interface VoiceRecorder {
|
|
53
|
+
/** Asks for the microphone and starts. Rejects when it is refused or unavailable. */
|
|
54
|
+
start(): Promise<void>;
|
|
55
|
+
/** Stops and gives the recording. */
|
|
56
|
+
stop(): Promise<RecordedVoice>;
|
|
57
|
+
/** Stops and throws the recording away. */
|
|
58
|
+
cancel(): void;
|
|
59
|
+
readonly state: "idle" | "recording" | "stopped";
|
|
60
|
+
}
|
|
61
|
+
export interface VoiceDeps {
|
|
62
|
+
getUserMedia: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
|
|
63
|
+
MediaRecorder: {
|
|
64
|
+
new (stream: MediaStream, options?: MediaRecorderOptions): MediaRecorder;
|
|
65
|
+
isTypeSupported(type: string): boolean;
|
|
66
|
+
};
|
|
67
|
+
now: () => number;
|
|
68
|
+
setInterval: (fn: () => void, ms: number) => unknown;
|
|
69
|
+
clearInterval: (id: unknown) => void;
|
|
70
|
+
}
|
|
71
|
+
/** Whether this browser can record a voice note at all (hide the record button when not). */
|
|
72
|
+
export declare function canRecordVoice(deps?: VoiceDeps | null): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* A recorder for one voice note: speech at about 32 kb/s, mono, with the
|
|
75
|
+
* phone's echo cancellation and noise suppression — 60 s is about 240 KB.
|
|
76
|
+
*/
|
|
77
|
+
export declare function createVoiceRecorder(options: VoiceRecorderOptions, deps?: VoiceDeps | null): VoiceRecorder;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a review form does in the browser before a photo or a voice note is
|
|
3
|
+
* sent, so it arrives small on a slow mobile connection: a photo shrunk and
|
|
4
|
+
* re-encoded without its EXIF, a voice note recorded at a low bitrate that
|
|
5
|
+
* stops at the store's limit. Each takes its browser dependencies as an
|
|
6
|
+
* optional last argument, for tests and unusual hosts.
|
|
7
|
+
*/
|
|
8
|
+
/** The size a photo is drawn at: its own, or scaled so its longest edge is `maxEdge`. */
|
|
9
|
+
export function photoTargetSize(width, height, maxEdge) {
|
|
10
|
+
const longest = Math.max(width, height);
|
|
11
|
+
if (longest <= maxEdge || longest === 0)
|
|
12
|
+
return { width, height };
|
|
13
|
+
const scale = maxEdge / longest;
|
|
14
|
+
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
|
|
15
|
+
}
|
|
16
|
+
function browserPhotoDeps() {
|
|
17
|
+
if (typeof createImageBitmap !== "function" || typeof document === "undefined")
|
|
18
|
+
return null;
|
|
19
|
+
return {
|
|
20
|
+
createImageBitmap: (image, options) => createImageBitmap(image, options),
|
|
21
|
+
createCanvas: (width, height) => {
|
|
22
|
+
const canvas = document.createElement("canvas");
|
|
23
|
+
canvas.width = width;
|
|
24
|
+
canvas.height = height;
|
|
25
|
+
return {
|
|
26
|
+
getContext: () => canvas.getContext("2d"),
|
|
27
|
+
toBlob: (type, quality) => new Promise((resolve) => canvas.toBlob(resolve, type, quality)),
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A photo ready to send: drawn upright (the camera's orientation applied),
|
|
34
|
+
* no larger than `maxEdge`, re-encoded as WebP (JPEG where WebP cannot be
|
|
35
|
+
* written). When the browser cannot do it, the photo is sent as it is — the
|
|
36
|
+
* server still converts it and drops its EXIF.
|
|
37
|
+
*/
|
|
38
|
+
export async function prepareReviewPhoto(photo, options = {}, deps = browserPhotoDeps()) {
|
|
39
|
+
if (!deps)
|
|
40
|
+
return photo;
|
|
41
|
+
const maxEdge = options.maxEdge ?? 1600;
|
|
42
|
+
const quality = options.quality ?? 0.82;
|
|
43
|
+
let bitmap;
|
|
44
|
+
try {
|
|
45
|
+
bitmap = await deps.createImageBitmap(photo, { imageOrientation: "from-image" });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return photo;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const size = photoTargetSize(bitmap.width, bitmap.height, maxEdge);
|
|
52
|
+
const canvas = deps.createCanvas(size.width, size.height);
|
|
53
|
+
const context = canvas.getContext("2d");
|
|
54
|
+
if (!context)
|
|
55
|
+
return photo;
|
|
56
|
+
context.drawImage(bitmap, 0, 0, size.width, size.height);
|
|
57
|
+
for (const type of ["image/webp", "image/jpeg"]) {
|
|
58
|
+
const blob = await canvas.toBlob(type, quality);
|
|
59
|
+
if (blob && blob.type === type && blob.size > 0)
|
|
60
|
+
return blob;
|
|
61
|
+
}
|
|
62
|
+
return photo;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return photo;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
bitmap.close?.();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Formats in the order a recorder is tried: Opus where it can be written, AAC on iPhone. */
|
|
72
|
+
export const VOICE_MIME_TYPES = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/mp4;codecs=mp4a.40.2", "audio/mp4", "audio/webm"];
|
|
73
|
+
/** The first format this browser can record, or null when it cannot record voice. */
|
|
74
|
+
export function pickVoiceMimeType(isTypeSupported) {
|
|
75
|
+
return VOICE_MIME_TYPES.find((type) => {
|
|
76
|
+
try {
|
|
77
|
+
return isTypeSupported(type);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}) ?? null;
|
|
83
|
+
}
|
|
84
|
+
function browserVoiceDeps() {
|
|
85
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined")
|
|
86
|
+
return null;
|
|
87
|
+
return {
|
|
88
|
+
getUserMedia: (constraints) => navigator.mediaDevices.getUserMedia(constraints),
|
|
89
|
+
MediaRecorder,
|
|
90
|
+
now: () => performance.now(),
|
|
91
|
+
setInterval: (fn, ms) => setInterval(fn, ms),
|
|
92
|
+
clearInterval: (id) => clearInterval(id),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Whether this browser can record a voice note at all (hide the record button when not). */
|
|
96
|
+
export function canRecordVoice(deps = browserVoiceDeps()) {
|
|
97
|
+
return Boolean(deps && pickVoiceMimeType((type) => deps.MediaRecorder.isTypeSupported(type)));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A recorder for one voice note: speech at about 32 kb/s, mono, with the
|
|
101
|
+
* phone's echo cancellation and noise suppression — 60 s is about 240 KB.
|
|
102
|
+
*/
|
|
103
|
+
export function createVoiceRecorder(options, deps = browserVoiceDeps()) {
|
|
104
|
+
let state = "idle";
|
|
105
|
+
let recorder = null;
|
|
106
|
+
let stream = null;
|
|
107
|
+
let chunks = [];
|
|
108
|
+
let startedAt = 0;
|
|
109
|
+
let ticker = null;
|
|
110
|
+
let finished = null;
|
|
111
|
+
const release = () => {
|
|
112
|
+
if (ticker !== null)
|
|
113
|
+
deps?.clearInterval(ticker);
|
|
114
|
+
ticker = null;
|
|
115
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
116
|
+
stream = null;
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
get state() {
|
|
120
|
+
return state;
|
|
121
|
+
},
|
|
122
|
+
async start() {
|
|
123
|
+
if (!deps)
|
|
124
|
+
throw new Error("This browser cannot record voice notes");
|
|
125
|
+
if (state === "recording")
|
|
126
|
+
return;
|
|
127
|
+
const mimeType = pickVoiceMimeType((type) => deps.MediaRecorder.isTypeSupported(type));
|
|
128
|
+
if (!mimeType)
|
|
129
|
+
throw new Error("This browser cannot record voice notes");
|
|
130
|
+
stream = await deps.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
|
131
|
+
chunks = [];
|
|
132
|
+
recorder = new deps.MediaRecorder(stream, { mimeType, audioBitsPerSecond: 32_000 });
|
|
133
|
+
const active = recorder;
|
|
134
|
+
finished = new Promise((resolve) => {
|
|
135
|
+
active.ondataavailable = (event) => {
|
|
136
|
+
if (event.data.size > 0)
|
|
137
|
+
chunks.push(event.data);
|
|
138
|
+
};
|
|
139
|
+
active.onstop = () => {
|
|
140
|
+
const type = (active.mimeType || mimeType).split(";")[0];
|
|
141
|
+
resolve({ blob: new Blob(chunks, { type }), durationMs: Math.round(deps.now() - startedAt), mimeType: type });
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
startedAt = deps.now();
|
|
145
|
+
recorder.start(1000);
|
|
146
|
+
state = "recording";
|
|
147
|
+
ticker = deps.setInterval(() => {
|
|
148
|
+
const elapsed = deps.now() - startedAt;
|
|
149
|
+
options.onTick?.(elapsed);
|
|
150
|
+
if (elapsed >= options.maxSeconds * 1000 && state === "recording") {
|
|
151
|
+
void this.stop().then((voice) => options.onLimit?.(voice));
|
|
152
|
+
}
|
|
153
|
+
}, 250);
|
|
154
|
+
},
|
|
155
|
+
async stop() {
|
|
156
|
+
if (state !== "recording" || !recorder || !finished)
|
|
157
|
+
throw new Error("Not recording");
|
|
158
|
+
state = "stopped";
|
|
159
|
+
recorder.stop();
|
|
160
|
+
const voice = await finished;
|
|
161
|
+
release();
|
|
162
|
+
return voice;
|
|
163
|
+
},
|
|
164
|
+
cancel() {
|
|
165
|
+
if (recorder && state === "recording")
|
|
166
|
+
recorder.stop();
|
|
167
|
+
state = "stopped";
|
|
168
|
+
chunks = [];
|
|
169
|
+
release();
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
package/dist/review-media.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@wocart/storefront/review-media` —
|
|
3
|
-
* browser before a photo or a voice note is sent, so it arrives small on a
|
|
4
|
-
* slow mobile connection:
|
|
2
|
+
* `@wocart/storefront/review-media` — a review form in the browser.
|
|
5
3
|
*
|
|
4
|
+
* createReviewSession proves the purchase, sends each photo or voice note
|
|
5
|
+
* as it is added (progress, retries), and submits the
|
|
6
|
+
* review; every refusal comes back as a code
|
|
6
7
|
* prepareReviewPhoto shrinks a photo to 1600 px at most and re-encodes it
|
|
7
8
|
* (WebP, else JPEG) — a phone photo of several MB
|
|
8
9
|
* becomes a few hundred KB, and its EXIF (the phone's
|
|
@@ -12,76 +13,8 @@
|
|
|
12
13
|
* itself at the store's limit
|
|
13
14
|
*
|
|
14
15
|
* Browser-only, and kept off the main entry point: a theme that takes no
|
|
15
|
-
*
|
|
16
|
-
* optional last argument, for tests and unusual hosts.
|
|
16
|
+
* reviews pays nothing for it.
|
|
17
17
|
*/
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
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;
|
|
18
|
+
export * from "./review-capture.js";
|
|
19
|
+
export { createReviewSession } from "./review-session.js";
|
|
20
|
+
export type { ReviewDraft, ReviewFindResult, ReviewRefusal, ReviewSession, ReviewSessionDeps, ReviewSessionOptions, ReviewSessionRoutes, ReviewSubmission, ReviewUpload, ReviewUploadOptions, ReviewUploadRequest, ReviewUploadResult, } from "./review-session.js";
|