@wocart/storefront 1.19.0 → 1.21.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 +91 -0
- package/USAGE.md +317 -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 +4 -0
- package/dist/astro.js +2 -0
- package/dist/basket.d.ts +11 -1
- package/dist/basket.js +17 -0
- package/dist/catalog.d.ts +24 -0
- package/dist/catalog.js +33 -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 +9 -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-lookup.d.ts +51 -0
- package/dist/order-lookup.js +44 -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/routes.d.ts +2 -0
- package/dist/routes.js +3 -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/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Internal modules stay reachable only through their intentional entry points.
|
|
11
11
|
*/
|
|
12
12
|
export { formatMoney } from "./utils/money.js";
|
|
13
|
-
export { optimizeImage, generateSrcSet, IMAGE_SIZES, getCardImage, getGalleryImage, getThumbnailImage, getHeroImage, getCategoryImage, getLogoImage, createImageHelpers, mediaImageUrl, mediaUrl, } from "./utils/image.js";
|
|
13
|
+
export { optimizeImage, generateSrcSet, IMAGE_SIZES, getCardImage, getGalleryImage, getThumbnailImage, getHeroImage, getCategoryImage, getLogoImage, createImageHelpers, mediaImageUrl, mediaImageSrcset, mediaUrl, pictureOf, } 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
16
|
export { STOREFRONT_PAGE_PATHS, STOREFRONT_ROUTES, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
|
|
@@ -22,7 +22,12 @@ export { createOtpMachine, createOtpTransport, mapOtpResponse, otpTransportFailu
|
|
|
22
22
|
export { WILAYAS } from "./data/wilayas.js";
|
|
23
23
|
export { ALGERIA_COMMUNES, communesForWilaya } from "./data/communes.js";
|
|
24
24
|
export { isWilayaId } from "./wilaya-id.js";
|
|
25
|
-
export { readCouponOutcome, readShippingRates } from "./answer-readers.js";
|
|
25
|
+
export { readCartQuote, readCouponOutcome, readShippingRates } from "./answer-readers.js";
|
|
26
|
+
export { createQuoter } from "./quote.js";
|
|
27
|
+
export { isOrderNumber, ORDER_NUMBER_PATTERN } from "./order-number.js";
|
|
28
|
+
export { DEFAULT_COLLECTION_HANDLES, PRODUCT_SORTS, SHOPPER_ORDER_JOURNEY, SHOPPER_ORDER_STAGES } from "./catalog.js";
|
|
29
|
+
export { SEO_TEXT_LIMITS, jsonLd, productMeta, productStructuredData } from "./seo.js";
|
|
30
|
+
export { REVIEW_REFUSAL_CODES, REVIEW_TEXT_LIMITS } from "./reviews.js";
|
|
26
31
|
export { normalizeAlgerianPhone, toLocalAlgerianMobile } from "./phone.js";
|
|
27
32
|
export * from "./types.js";
|
|
28
33
|
export * from "./schemas.js";
|
|
@@ -30,7 +35,7 @@ export * from "./domain.js";
|
|
|
30
35
|
export * from "./operations.js";
|
|
31
36
|
export * from "./product-view.js";
|
|
32
37
|
export { getOfferTierView, getQuantityTierViews } from "./offer-view.js";
|
|
33
|
-
export { COUPON_NOT_APPLICABLE, COUPON_URL_PARAM, couponFromUrl, couponRefusalOf } from "./coupon.js";
|
|
38
|
+
export { COUPON_HINT_REASONS, COUPON_NOT_APPLICABLE, COUPON_REFUSAL_REASONS, COUPON_URL_PARAM, couponFromUrl, couponRefusalOf, couponRefusalTone, } from "./coupon.js";
|
|
34
39
|
export { initialVariant, optionStates, pickOption, variantFor } from "./variant-picker.js";
|
|
35
|
-
export { createAbandonedCheckoutTracker } from "./abandoned-checkout.js";
|
|
40
|
+
export { ABANDONED_CHECKOUT_SESSION_KEY, convertAbandonedCheckout, createAbandonedCheckoutTracker, createBrowserAbandonedCheckoutTracker, } from "./abandoned-checkout.js";
|
|
36
41
|
export { META_CURRENCY, buildMetaAddToCartPayload, buildMetaInitiateCheckoutPayload, buildMetaProductPayload, buildMetaViewContentPayload, createMetaPixelTracker, } from "./meta-tracking.js";
|
package/dist/operations.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare const STOREFRONT_OPERATIONS: {
|
|
|
10
10
|
readonly categoriesList: "categories.list";
|
|
11
11
|
readonly productsGet: "products.get";
|
|
12
12
|
readonly productsList: "products.list";
|
|
13
|
+
readonly productsSearch: "products.search";
|
|
13
14
|
readonly productsReviewsList: "products.reviews.list";
|
|
14
15
|
readonly productsReviewsSubmit: "products.reviews.submit";
|
|
15
16
|
readonly productsReviewsEligibility: "products.reviews.eligibility";
|
|
@@ -25,6 +26,7 @@ export declare const STOREFRONT_OPERATIONS: {
|
|
|
25
26
|
readonly pageDataGet: "pageData.get";
|
|
26
27
|
readonly ordersCreate: "orders.create";
|
|
27
28
|
readonly ordersTracking: "orders.tracking";
|
|
29
|
+
readonly ordersStatus: "orders.status";
|
|
28
30
|
readonly ordersAbandonedUpsert: "orders.abandoned.upsert";
|
|
29
31
|
readonly ordersAbandonedConvert: "orders.abandoned.convert";
|
|
30
32
|
readonly ordersOtpSend: "orders.otp.send";
|
|
@@ -52,6 +54,7 @@ export declare const STOREFRONT_RETRY_POLICIES: {
|
|
|
52
54
|
readonly "orders.otp.send": "never";
|
|
53
55
|
readonly "orders.otp.verify": "never";
|
|
54
56
|
readonly "orders.tracking": "safe-read";
|
|
57
|
+
readonly "orders.status": "never";
|
|
55
58
|
readonly "pageData.get": "safe-read";
|
|
56
59
|
readonly "pages.get": "safe-read";
|
|
57
60
|
readonly "pages.landing": "safe-read";
|
|
@@ -59,6 +62,7 @@ export declare const STOREFRONT_RETRY_POLICIES: {
|
|
|
59
62
|
readonly "collections.get": "safe-read";
|
|
60
63
|
readonly "products.get": "safe-read";
|
|
61
64
|
readonly "products.list": "safe-read";
|
|
65
|
+
readonly "products.search": "safe-read";
|
|
62
66
|
readonly "products.reviews.list": "safe-read";
|
|
63
67
|
readonly "products.reviews.submit": "never";
|
|
64
68
|
readonly "products.reviews.eligibility": "never";
|
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,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shopper following their order from a theme's own page: the form's input,
|
|
3
|
+
* and what the lookup comes to — in the few outcomes a page words, never a
|
|
4
|
+
* server code to interpret.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // src/actions/index.ts
|
|
8
|
+
* orderStatus: defineAction({
|
|
9
|
+
* accept: "form",
|
|
10
|
+
* input: orderLookupInput,
|
|
11
|
+
* handler: (input, context) => lookupOrder(input, context.request, context.locals.store.client),
|
|
12
|
+
* }),
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import type { StoreClient } from "./client/index.js";
|
|
17
|
+
import type { ShopperOrderStatus } from "./types.js";
|
|
18
|
+
/** The order number and the phone it was placed with, as a form posts them. */
|
|
19
|
+
export declare const orderLookupInput: z.ZodObject<{
|
|
20
|
+
orderNumber: z.ZodString;
|
|
21
|
+
phone: z.ZodString;
|
|
22
|
+
}, z.core.$strip>;
|
|
23
|
+
export type OrderLookupInput = z.infer<typeof orderLookupInput>;
|
|
24
|
+
/**
|
|
25
|
+
* - `found` — the order, as a shopper may see it.
|
|
26
|
+
* - `invalid-number` — what was typed is not an order number; the server is not asked.
|
|
27
|
+
* - `invalid-phone` — the server cannot read the phone as a phone number.
|
|
28
|
+
* - `not-found` — no order has that number and phone; which of the two is wrong is never said.
|
|
29
|
+
* - `rate-limited` — too many tries from this shopper or for this order; try again after the wait.
|
|
30
|
+
* - `failed` — nothing the shopper can fix: try again later.
|
|
31
|
+
*/
|
|
32
|
+
export type OrderLookup = {
|
|
33
|
+
status: "found";
|
|
34
|
+
order: ShopperOrderStatus;
|
|
35
|
+
} | {
|
|
36
|
+
status: "invalid-number";
|
|
37
|
+
} | {
|
|
38
|
+
status: "invalid-phone";
|
|
39
|
+
} | {
|
|
40
|
+
status: "not-found";
|
|
41
|
+
} | {
|
|
42
|
+
status: "rate-limited";
|
|
43
|
+
retryAfterSeconds: number | null;
|
|
44
|
+
} | {
|
|
45
|
+
status: "failed";
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Look an order up for its shopper, forwarding their address so the server's
|
|
49
|
+
* limit counts per shopper rather than per theme server.
|
|
50
|
+
*/
|
|
51
|
+
export declare function lookupOrder(input: OrderLookupInput, request: Request, client: StoreClient): Promise<OrderLookup>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shopper following their order from a theme's own page: the form's input,
|
|
3
|
+
* and what the lookup comes to — in the few outcomes a page words, never a
|
|
4
|
+
* server code to interpret.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // src/actions/index.ts
|
|
8
|
+
* orderStatus: defineAction({
|
|
9
|
+
* accept: "form",
|
|
10
|
+
* input: orderLookupInput,
|
|
11
|
+
* handler: (input, context) => lookupOrder(input, context.request, context.locals.store.client),
|
|
12
|
+
* }),
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { collectForwardedHeaders } from "./actions.js";
|
|
17
|
+
import { isOrderNumber } from "./order-number.js";
|
|
18
|
+
/** The order number and the phone it was placed with, as a form posts them. */
|
|
19
|
+
export const orderLookupInput = z.object({
|
|
20
|
+
orderNumber: z.string().trim().min(1).max(64),
|
|
21
|
+
phone: z.string().trim().min(1).max(32),
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* Look an order up for its shopper, forwarding their address so the server's
|
|
25
|
+
* limit counts per shopper rather than per theme server.
|
|
26
|
+
*/
|
|
27
|
+
export async function lookupOrder(input, request, client) {
|
|
28
|
+
if (!isOrderNumber(input.orderNumber))
|
|
29
|
+
return { status: "invalid-number" };
|
|
30
|
+
const result = await client.orders.status({ orderNumber: input.orderNumber.trim(), phone: input.phone.trim() }, collectForwardedHeaders(request));
|
|
31
|
+
if (result.success)
|
|
32
|
+
return { status: "found", order: result.data };
|
|
33
|
+
switch (result.domainError.code) {
|
|
34
|
+
case "ORDER_NOT_FOUND":
|
|
35
|
+
return { status: "not-found" };
|
|
36
|
+
case "VALIDATION_FAILED":
|
|
37
|
+
// The number was checked against the server's own pattern: what it refuses now is the phone.
|
|
38
|
+
return { status: "invalid-phone" };
|
|
39
|
+
case "ORDER_LOOKUP_RATE_LIMITED":
|
|
40
|
+
return { status: "rate-limited", retryAfterSeconds: result.domainError.retryAfterSeconds ?? null };
|
|
41
|
+
default:
|
|
42
|
+
return { status: "failed" };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -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
|
+
}
|