@wocart/storefront 1.4.0 → 1.6.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 +49 -0
- package/USAGE.md +53 -6
- package/dist/client/core.d.ts +6 -0
- package/dist/client/core.js +24 -4
- package/dist/client/products.d.ts +29 -7
- package/dist/client/products.js +46 -16
- package/dist/domain.d.ts +4 -0
- package/dist/domain.js +4 -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/offer-view.d.ts +1 -1
- package/dist/offer-view.js +10 -6
- package/dist/review-media.d.ts +87 -0
- package/dist/review-media.js +182 -0
- package/dist/schemas.d.ts +75 -3
- package/dist/schemas.js +47 -1
- package/dist/types.d.ts +93 -3
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,55 @@ follows [semantic versioning](https://semver.org/): breaking changes ship only
|
|
|
5
5
|
in a new major version, and an export is marked `@deprecated` for at least one
|
|
6
6
|
minor release before a major removes it.
|
|
7
7
|
|
|
8
|
+
## 1.6.0 — 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Reviews with photos and a voice note.** `products.reviews.eligibility()`
|
|
13
|
+
proves a purchase with the order number and the phone it was placed with,
|
|
14
|
+
and answers the products the order may review and what may be attached,
|
|
15
|
+
with an upload grant. `products.reviews.upload()` sends one file as it is
|
|
16
|
+
(a stream passes through unbuffered). `submit()` takes `phone`, `grant` and
|
|
17
|
+
`uploads`, and answers `visibility` (`published` or `after_review`).
|
|
18
|
+
- `Review` carries `verified` and `media` (photos with a thumbnail, the voice
|
|
19
|
+
note with its length); reviews from older servers read as unverified with
|
|
20
|
+
no media.
|
|
21
|
+
- Storefront routes `reviewEligibility` and `reviewUpload` (PUT, the file as
|
|
22
|
+
the body); `reviewSubmit` forwards the new fields. Every PUT refuses a
|
|
23
|
+
cross-origin request too.
|
|
24
|
+
- **`@wocart/storefront/review-media`**, a browser-only entry point:
|
|
25
|
+
`prepareReviewPhoto` (1600 px, re-encoded, EXIF removed before it leaves
|
|
26
|
+
the device), `createVoiceRecorder` (speech at ~32 kb/s, stops at the
|
|
27
|
+
store's limit), `canRecordVoice`, `pickVoiceMimeType`, `photoTargetSize`.
|
|
28
|
+
- Types `ReviewMedia`, `ReviewEligibility`, `ReviewEligibilityInput`,
|
|
29
|
+
`ReviewableProduct`, `ReviewUpload`, `ReviewUploadInput`,
|
|
30
|
+
`ReviewSubmitResult`, and their schemas.
|
|
31
|
+
|
|
32
|
+
### Deprecated
|
|
33
|
+
|
|
34
|
+
- **`submit()` without `phone`**: still accepted for a text review of a
|
|
35
|
+
delivered order; a later major requires the phone.
|
|
36
|
+
|
|
37
|
+
- **`deletedAt`** on a product is optional and always `null` when present: a
|
|
38
|
+
removed product is deleted on the server and never served, so the field says
|
|
39
|
+
nothing. The server keeps sending `null` for releases that require it; a
|
|
40
|
+
later major release removes it. Nothing in a theme should read it.
|
|
41
|
+
|
|
42
|
+
## 1.5.0 — 2026-10-15
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **`products.page(filters)`** returns one page of products and whether another
|
|
47
|
+
follows (`{ items, hasNext }`). It asks the server for one row more than the
|
|
48
|
+
page, so the answer is exact without a count query. A page is 24 by default
|
|
49
|
+
and at most 99 (one below the server's 100-row cap). A failed read is an empty
|
|
50
|
+
final page, as `list` degrades. Themes stop re-deriving paging from `list`.
|
|
51
|
+
- `ProductPage` type.
|
|
52
|
+
- **`rewardUnitPrice`** on an offer: what one gift unit is worth at list price
|
|
53
|
+
(the gift variant's price, or the gift product's). `getOfferTierView` states
|
|
54
|
+
the saving at that value and the share of the list value it is. Without it,
|
|
55
|
+
the product's own price stands in, as before.
|
|
56
|
+
|
|
8
57
|
## 1.4.0 — 2026-10-15
|
|
9
58
|
|
|
10
59
|
### Added
|
package/USAGE.md
CHANGED
|
@@ -624,19 +624,64 @@ const reviews = await store.products.reviews.list(productId, {
|
|
|
624
624
|
~~~
|
|
625
625
|
|
|
626
626
|
The result is { rows: Review[], total: number }. The default page size is 20
|
|
627
|
-
and the default offset is 0.
|
|
627
|
+
and the default offset is 0. Each review carries `verified` (it rests on an
|
|
628
|
+
order that reached its customer — show a "verified purchase" badge) and
|
|
629
|
+
`media`: its photos (`url` at 1600 px, `thumbUrl` at 480 px for lists, with
|
|
630
|
+
`width`/`height` to reserve the space) and its voice note (`kind: "audio"`,
|
|
631
|
+
with `durationMs`). Show photos as their `thumbUrl` with `loading="lazy"`, and
|
|
632
|
+
a voice note as `<audio preload="none">`, so neither costs anything until the
|
|
633
|
+
shopper opens or plays it.
|
|
634
|
+
|
|
635
|
+
Writing a review takes three steps, so the shopper never waits on a slow
|
|
636
|
+
connection:
|
|
628
637
|
|
|
629
638
|
~~~ts
|
|
639
|
+
// 1. Prove the purchase: the order number and the phone it was placed with.
|
|
640
|
+
const found = await store.products.reviews.eligibility({ orderNumber, phone });
|
|
641
|
+
if (!found.success) {
|
|
642
|
+
// ORDER_NOT_FOUND (wrong number or phone), REVIEW_ORDER_NOT_DELIVERED,
|
|
643
|
+
// REVIEW_RATE_LIMITED, REVIEWS_DISABLED
|
|
644
|
+
}
|
|
645
|
+
// found.data.products: what the order may review ({ reviewed } ones only shown).
|
|
646
|
+
// found.data.media: null (text only) or { maxPhotos, voiceMaxSeconds, grant }.
|
|
647
|
+
|
|
648
|
+
// 2. Send each file the moment it is picked — not on submit.
|
|
649
|
+
const sent = await store.products.reviews.upload({ grant, body: file, contentLength: file.size });
|
|
650
|
+
// sent.data.uploadId
|
|
651
|
+
|
|
652
|
+
// 3. Submit: answered at once; the files are prepared afterwards.
|
|
630
653
|
const submitted = await store.products.reviews.submit({
|
|
631
|
-
orderNumber
|
|
654
|
+
orderNumber,
|
|
655
|
+
phone,
|
|
632
656
|
productId,
|
|
633
657
|
rating: 5,
|
|
634
|
-
title: "Beautiful",
|
|
635
658
|
body: "Exactly as described.",
|
|
659
|
+
grant,
|
|
660
|
+
uploads: [uploadId],
|
|
636
661
|
});
|
|
662
|
+
// submitted.data.visibility: "published" now, or "after_review"
|
|
663
|
+
~~~
|
|
664
|
+
|
|
665
|
+
orderNumber is the customer-visible order number, not the internal order
|
|
666
|
+
UUID. The body needs 10 characters unless a voice note is attached. One review
|
|
667
|
+
per product of an order (`ORDER_ALREADY_REVIEWED`). A submit without `phone`
|
|
668
|
+
— the 1.5 shape — still works for text only, on a delivered order, and is
|
|
669
|
+
deprecated.
|
|
670
|
+
|
|
671
|
+
In the browser, `@wocart/storefront/review-media` prepares files before they
|
|
672
|
+
are sent:
|
|
673
|
+
|
|
674
|
+
~~~ts
|
|
675
|
+
import { canRecordVoice, createVoiceRecorder, prepareReviewPhoto } from "@wocart/storefront/review-media";
|
|
676
|
+
|
|
677
|
+
const photo = await prepareReviewPhoto(file); // 1600 px, WebP/JPEG, EXIF gone
|
|
678
|
+
const recorder = createVoiceRecorder({ maxSeconds: media.voiceMaxSeconds, onTick, onLimit });
|
|
679
|
+
await recorder.start();
|
|
680
|
+
const { blob, durationMs } = await recorder.stop();
|
|
637
681
|
~~~
|
|
638
682
|
|
|
639
|
-
|
|
683
|
+
`canRecordVoice()` says whether to show a record button at all; a shopper can
|
|
684
|
+
also attach an audio file. Send a recording with its `durationMs`.
|
|
640
685
|
|
|
641
686
|
## Categories, legal pages, and landing pages
|
|
642
687
|
|
|
@@ -1179,11 +1224,13 @@ export const POST = routes.otpSend;
|
|
|
1179
1224
|
| `abandonedConvert` | POST | `{ sessionId, orderId, orderNumber }` | 204 |
|
|
1180
1225
|
| `communes` | GET | path param `wilayaId` | `{ data: Commune[] }` |
|
|
1181
1226
|
| `reviewsList` | GET | path param `productId`, query `limit` (1–50, default 20) and `offset` | `{ data: Review[], total }` |
|
|
1182
|
-
| `
|
|
1227
|
+
| `reviewEligibility` | POST | `{ orderNumber, phone, turnstileToken? }` | `{ data: ReviewEligibility }` |
|
|
1228
|
+
| `reviewUpload` | PUT | the file as the body; headers `X-Review-Grant`, `X-Media-Duration-Ms` | 201 `{ data: ReviewUpload }` (streamed on, never buffered) |
|
|
1229
|
+
| `reviewSubmit` | POST | `ReviewInput` | 201 `{ success: true, data: { id, visibility } }` |
|
|
1183
1230
|
|
|
1184
1231
|
What every route gets right:
|
|
1185
1232
|
|
|
1186
|
-
- every POST refuses a cross-origin request with 403;
|
|
1233
|
+
- every POST and PUT refuses a cross-origin request with 403;
|
|
1187
1234
|
- the OTP routes and abandoned tracking forward the shopper's address and
|
|
1188
1235
|
user agent, so the server limits and attributes per shopper;
|
|
1189
1236
|
- input is bounded before it is sent, and a malformed body is a 400 that never
|
package/dist/client/core.d.ts
CHANGED
|
@@ -55,10 +55,16 @@ export interface PatchOptions<TOutput> extends RequestOptions {
|
|
|
55
55
|
* Client core infrastructure
|
|
56
56
|
* Exposed only to namespace implementations, not to end users
|
|
57
57
|
*/
|
|
58
|
+
/** What a file upload's body may be. */
|
|
59
|
+
export type UploadBody = Blob | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>;
|
|
58
60
|
export interface ClientCore {
|
|
59
61
|
config: ClientConfig;
|
|
60
62
|
get: <T>(path: string, schema?: z.Schema<T>, opts?: GetOptions) => Promise<T>;
|
|
61
63
|
post: <TInput, TOutput>(path: string, body: TInput, opts?: PostOptions<TOutput>) => Promise<ClientResult<TOutput>>;
|
|
64
|
+
/** PUT one file as the body (streams pass through unbuffered). */
|
|
65
|
+
upload: <TOutput>(path: string, body: UploadBody, opts?: PostOptions<TOutput> & {
|
|
66
|
+
contentLength?: number;
|
|
67
|
+
}) => Promise<ClientResult<TOutput>>;
|
|
62
68
|
patch: <TInput, TOutput>(path: string, body: TInput, optsOrSchema?: z.Schema<TOutput> | PatchOptions<TOutput>) => Promise<ClientResult<TOutput>>;
|
|
63
69
|
}
|
|
64
70
|
/** How long a read may take, retries included, unless the client sets another limit. */
|
package/dist/client/core.js
CHANGED
|
@@ -411,8 +411,22 @@ export function createClientCore(config) {
|
|
|
411
411
|
* outcome `unknown`, never a retry of a possible duplicate.
|
|
412
412
|
*/
|
|
413
413
|
async function post(path, body, opts) {
|
|
414
|
+
return write("POST", path, { body: JSON.stringify(body) }, opts);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* PUT one file as the request body, as it is — a stream passes through
|
|
418
|
+
* without being buffered. Same result contract as `post`.
|
|
419
|
+
*/
|
|
420
|
+
async function upload(path, body, opts) {
|
|
421
|
+
return write("PUT", path, {
|
|
422
|
+
body,
|
|
423
|
+
contentType: "application/octet-stream",
|
|
424
|
+
...(opts?.contentLength !== undefined ? { contentLength: opts.contentLength } : {}),
|
|
425
|
+
}, opts);
|
|
426
|
+
}
|
|
427
|
+
async function write(method, path, outgoing, opts) {
|
|
414
428
|
const url = buildUrl(path);
|
|
415
|
-
const operation = opts?.operation ??
|
|
429
|
+
const operation = opts?.operation ?? `${method} ${path}`;
|
|
416
430
|
const request = composeSignal(opts?.signal, opts?.timeoutMs);
|
|
417
431
|
if (request.aborted()) {
|
|
418
432
|
request.cleanup();
|
|
@@ -421,9 +435,14 @@ export function createClientCore(config) {
|
|
|
421
435
|
}
|
|
422
436
|
try {
|
|
423
437
|
const response = await fetchWithRetry(url, {
|
|
424
|
-
method
|
|
425
|
-
headers: buildHeaders(
|
|
426
|
-
|
|
438
|
+
method,
|
|
439
|
+
headers: buildHeaders({
|
|
440
|
+
...(outgoing.contentType ? { "Content-Type": outgoing.contentType } : {}),
|
|
441
|
+
...(outgoing.contentLength !== undefined ? { "Content-Length": String(outgoing.contentLength) } : {}),
|
|
442
|
+
...opts?.headers,
|
|
443
|
+
}),
|
|
444
|
+
body: outgoing.body,
|
|
445
|
+
...(outgoing.body instanceof ReadableStream ? { duplex: "half" } : {}),
|
|
427
446
|
...(request.signal ? { signal: request.signal } : {}),
|
|
428
447
|
}, 0, request.signal);
|
|
429
448
|
const read = await readBody(response);
|
|
@@ -523,6 +542,7 @@ export function createClientCore(config) {
|
|
|
523
542
|
config,
|
|
524
543
|
get,
|
|
525
544
|
post,
|
|
545
|
+
upload,
|
|
526
546
|
patch,
|
|
527
547
|
};
|
|
528
548
|
}
|
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
* half-shaped product into a theme.
|
|
13
13
|
*/
|
|
14
14
|
import { type ClientCore, type ClientResult } from "./core.js";
|
|
15
|
-
import type { Product, ProductList, ProductListFilters, Review, ReviewInput, ReviewListPagination } from "../types.js";
|
|
15
|
+
import type { Product, ProductList, ProductPage, ProductListFilters, Review, ReviewEligibility, ReviewEligibilityInput, ReviewInput, ReviewListPagination, ReviewSubmitResult, ReviewUpload, ReviewUploadInput } from "../types.js";
|
|
16
|
+
/** Page size when a caller names none — the storefront's default grid. */
|
|
17
|
+
export declare const PRODUCT_PAGE_DEFAULT = 24;
|
|
18
|
+
/** One below the server's 100-row cap, so the extra row that proves a next page fits. */
|
|
19
|
+
export declare const PRODUCT_PAGE_MAX = 99;
|
|
16
20
|
export interface ProductsNamespace {
|
|
17
21
|
/**
|
|
18
22
|
* Get a single product by its public URL handle
|
|
@@ -26,6 +30,12 @@ export interface ProductsNamespace {
|
|
|
26
30
|
* @returns Array of products
|
|
27
31
|
*/
|
|
28
32
|
list(filters?: ProductListFilters): Promise<ProductList[]>;
|
|
33
|
+
/**
|
|
34
|
+
* One page of products, and whether another page follows. The server is
|
|
35
|
+
* asked for one row more than the page, so the answer is exact without a
|
|
36
|
+
* count query. A failed read is an empty final page, as `list` degrades.
|
|
37
|
+
*/
|
|
38
|
+
page(filters?: ProductListFilters): Promise<ProductPage>;
|
|
29
39
|
/**
|
|
30
40
|
* Reviews sub-namespace
|
|
31
41
|
*/
|
|
@@ -41,13 +51,25 @@ export interface ProductsNamespace {
|
|
|
41
51
|
total: number;
|
|
42
52
|
}>;
|
|
43
53
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
54
|
+
* Prove a purchase with the order number and the phone it was placed
|
|
55
|
+
* with: answers the order's products the shopper may review (a delivered
|
|
56
|
+
* order, each product once) and, when the store takes them, what may be
|
|
57
|
+
* attached and the upload grant. A wrong number or phone fails with
|
|
58
|
+
* `ORDER_NOT_FOUND`; an order on its way with `REVIEW_ORDER_NOT_DELIVERED`.
|
|
59
|
+
*/
|
|
60
|
+
eligibility(input: ReviewEligibilityInput): Promise<ClientResult<ReviewEligibility>>;
|
|
61
|
+
/**
|
|
62
|
+
* Send one photo or voice note, as it is, under the grant — as soon as the
|
|
63
|
+
* shopper picks it, so submitting waits for nothing. A stream passes
|
|
64
|
+
* through unbuffered. Its kind is read from its bytes by the server.
|
|
65
|
+
*/
|
|
66
|
+
upload(input: ReviewUploadInput): Promise<ClientResult<ReviewUpload>>;
|
|
67
|
+
/**
|
|
68
|
+
* Submit a review. Answers at once; its files are prepared afterwards,
|
|
69
|
+
* and `visibility` says whether it is public now or after the store
|
|
70
|
+
* checks it.
|
|
47
71
|
*/
|
|
48
|
-
submit(data: ReviewInput): Promise<ClientResult<
|
|
49
|
-
id: string;
|
|
50
|
-
}>>;
|
|
72
|
+
submit(data: ReviewInput): Promise<ClientResult<ReviewSubmitResult>>;
|
|
51
73
|
};
|
|
52
74
|
}
|
|
53
75
|
/**
|
package/dist/client/products.js
CHANGED
|
@@ -12,14 +12,33 @@
|
|
|
12
12
|
* half-shaped product into a theme.
|
|
13
13
|
*/
|
|
14
14
|
import { z } from "zod";
|
|
15
|
-
import {
|
|
15
|
+
import { ProductListSchema, ProductSchema, ReviewEligibilitySchema, ReviewListEnvelopeSchema, ReviewSubmitResultSchema, ReviewUploadSchema } from "../schemas.js";
|
|
16
16
|
import { STOREFRONT_OPERATIONS } from "../domain.js";
|
|
17
17
|
import { localizedPath } from "./paths.js";
|
|
18
18
|
import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
|
|
19
|
+
/** Page size when a caller names none — the storefront's default grid. */
|
|
20
|
+
export const PRODUCT_PAGE_DEFAULT = 24;
|
|
21
|
+
/** One below the server's 100-row cap, so the extra row that proves a next page fits. */
|
|
22
|
+
export const PRODUCT_PAGE_MAX = 99;
|
|
19
23
|
/**
|
|
20
24
|
* Create products namespace
|
|
21
25
|
*/
|
|
22
26
|
export function createProductsNamespace(core) {
|
|
27
|
+
const list = async (filters) => {
|
|
28
|
+
try {
|
|
29
|
+
const products = await core.get(localizedPath(core.config, "/store/products", {
|
|
30
|
+
categoryId: filters?.categoryId || undefined,
|
|
31
|
+
featured: filters?.featured ? "true" : undefined,
|
|
32
|
+
limit: filters?.limit === undefined ? undefined : String(filters.limit),
|
|
33
|
+
offset: filters?.offset ? String(filters.offset) : undefined,
|
|
34
|
+
}), z.array(ProductListSchema), { operation: STOREFRONT_OPERATIONS.productsList });
|
|
35
|
+
return products ?? [];
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
reportDegradedRead(core.config, error, "products.list");
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
};
|
|
23
42
|
return {
|
|
24
43
|
async get(handle) {
|
|
25
44
|
try {
|
|
@@ -32,20 +51,11 @@ export function createProductsNamespace(core) {
|
|
|
32
51
|
return null;
|
|
33
52
|
}
|
|
34
53
|
},
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
limit: filters?.limit === undefined ? undefined : String(filters.limit),
|
|
41
|
-
offset: filters?.offset ? String(filters.offset) : undefined,
|
|
42
|
-
}), z.array(ProductListSchema), { operation: STOREFRONT_OPERATIONS.productsList });
|
|
43
|
-
return products ?? [];
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
reportDegradedRead(core.config, error, "products.list");
|
|
47
|
-
return [];
|
|
48
|
-
}
|
|
54
|
+
list,
|
|
55
|
+
async page(filters) {
|
|
56
|
+
const size = Math.min(Math.max(1, filters?.limit ?? PRODUCT_PAGE_DEFAULT), PRODUCT_PAGE_MAX);
|
|
57
|
+
const rows = await list({ ...filters, limit: size + 1 });
|
|
58
|
+
return { items: rows.slice(0, size), hasNext: rows.length > size };
|
|
49
59
|
},
|
|
50
60
|
reviews: {
|
|
51
61
|
async list(productId, pagination) {
|
|
@@ -71,8 +81,28 @@ export function createProductsNamespace(core) {
|
|
|
71
81
|
return { rows: [], total: 0 };
|
|
72
82
|
}
|
|
73
83
|
},
|
|
84
|
+
async eligibility(input) {
|
|
85
|
+
return core.post("/store/reviews/eligibility", input, {
|
|
86
|
+
schema: ReviewEligibilitySchema,
|
|
87
|
+
operation: STOREFRONT_OPERATIONS.productsReviewsEligibility,
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
async upload(input) {
|
|
91
|
+
return core.upload("/store/reviews/uploads", input.body, {
|
|
92
|
+
schema: ReviewUploadSchema,
|
|
93
|
+
operation: STOREFRONT_OPERATIONS.productsReviewsUpload,
|
|
94
|
+
headers: {
|
|
95
|
+
"X-Review-Grant": input.grant,
|
|
96
|
+
...(input.durationMs ? { "X-Media-Duration-Ms": String(Math.round(input.durationMs)) } : {}),
|
|
97
|
+
},
|
|
98
|
+
...(input.contentLength !== undefined ? { contentLength: input.contentLength } : {}),
|
|
99
|
+
});
|
|
100
|
+
},
|
|
74
101
|
async submit(data) {
|
|
75
|
-
return core.post("/store/reviews", data, {
|
|
102
|
+
return core.post("/store/reviews", data, {
|
|
103
|
+
schema: ReviewSubmitResultSchema,
|
|
104
|
+
operation: STOREFRONT_OPERATIONS.productsReviewsSubmit,
|
|
105
|
+
});
|
|
76
106
|
},
|
|
77
107
|
},
|
|
78
108
|
};
|
package/dist/domain.d.ts
CHANGED
|
@@ -349,6 +349,8 @@ export declare const STOREFRONT_OPERATIONS: {
|
|
|
349
349
|
readonly productsList: "products.list";
|
|
350
350
|
readonly productsReviewsList: "products.reviews.list";
|
|
351
351
|
readonly productsReviewsSubmit: "products.reviews.submit";
|
|
352
|
+
readonly productsReviewsEligibility: "products.reviews.eligibility";
|
|
353
|
+
readonly productsReviewsUpload: "products.reviews.upload";
|
|
352
354
|
readonly pagesGet: "pages.get";
|
|
353
355
|
readonly pagesLanding: "pages.landing";
|
|
354
356
|
readonly shippingRates: "shipping.rates";
|
|
@@ -392,6 +394,8 @@ export declare const STOREFRONT_RETRY_POLICIES: {
|
|
|
392
394
|
readonly "products.list": "safe-read";
|
|
393
395
|
readonly "products.reviews.list": "safe-read";
|
|
394
396
|
readonly "products.reviews.submit": "never";
|
|
397
|
+
readonly "products.reviews.eligibility": "never";
|
|
398
|
+
readonly "products.reviews.upload": "never";
|
|
395
399
|
readonly "shipping.rate": "safe-read";
|
|
396
400
|
readonly "shipping.rates": "safe-read";
|
|
397
401
|
};
|
package/dist/domain.js
CHANGED
|
@@ -202,6 +202,8 @@ export const STOREFRONT_OPERATIONS = {
|
|
|
202
202
|
productsList: "products.list",
|
|
203
203
|
productsReviewsList: "products.reviews.list",
|
|
204
204
|
productsReviewsSubmit: "products.reviews.submit",
|
|
205
|
+
productsReviewsEligibility: "products.reviews.eligibility",
|
|
206
|
+
productsReviewsUpload: "products.reviews.upload",
|
|
205
207
|
pagesGet: "pages.get",
|
|
206
208
|
pagesLanding: "pages.landing",
|
|
207
209
|
shippingRates: "shipping.rates",
|
|
@@ -244,6 +246,8 @@ export const STOREFRONT_RETRY_POLICIES = {
|
|
|
244
246
|
[STOREFRONT_OPERATIONS.productsList]: "safe-read",
|
|
245
247
|
[STOREFRONT_OPERATIONS.productsReviewsList]: "safe-read",
|
|
246
248
|
[STOREFRONT_OPERATIONS.productsReviewsSubmit]: "never",
|
|
249
|
+
[STOREFRONT_OPERATIONS.productsReviewsEligibility]: "never",
|
|
250
|
+
[STOREFRONT_OPERATIONS.productsReviewsUpload]: "never",
|
|
247
251
|
[STOREFRONT_OPERATIONS.shippingRate]: "safe-read",
|
|
248
252
|
[STOREFRONT_OPERATIONS.shippingRates]: "safe-read",
|
|
249
253
|
};
|
|
@@ -38,8 +38,14 @@ export type ProxyClientFn<TInput, TOutput> = (data: TInput, headers?: Record<str
|
|
|
38
38
|
* Configuration for creating a proxy endpoint
|
|
39
39
|
*/
|
|
40
40
|
export interface ProxyEndpointConfig<TInput, TOutput> {
|
|
41
|
-
/** HTTP method: 'GET' or '
|
|
42
|
-
method: "GET" | "POST";
|
|
41
|
+
/** HTTP method: 'GET', 'POST', or 'PUT' (a raw body, read by `readInput`). */
|
|
42
|
+
method: "GET" | "POST" | "PUT";
|
|
43
|
+
/**
|
|
44
|
+
* Read the input from the request instead of parsing JSON — for a body that
|
|
45
|
+
* is a file, passed on as a stream. Its result still goes through
|
|
46
|
+
* `inputSchema`.
|
|
47
|
+
*/
|
|
48
|
+
readInput?: (request: Request) => unknown;
|
|
43
49
|
/** Client function that calls the server — usually a `StoreClient` method. */
|
|
44
50
|
client: ProxyClientFn<TInput, TOutput>;
|
|
45
51
|
/** Input validation schema (optional for GET without query validation) */
|
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/offer-view.d.ts
CHANGED
|
@@ -6,5 +6,5 @@ export interface OfferTierView {
|
|
|
6
6
|
price: number;
|
|
7
7
|
comparePrice: number | null;
|
|
8
8
|
}
|
|
9
|
-
export type OfferTierInput = Pick<Offer, "discountType" | "triggerQuantity" | "rewardQuantity">;
|
|
9
|
+
export type OfferTierInput = Pick<Offer, "discountType" | "triggerQuantity" | "rewardQuantity" | "rewardUnitPrice">;
|
|
10
10
|
export declare function getOfferTierView(offer: OfferTierInput, basePrice: number, comparePrice: number | null): OfferTierView;
|
package/dist/offer-view.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
export function getOfferTierView(offer, basePrice, comparePrice) {
|
|
2
2
|
const isFreeShipping = offer.discountType === "free_shipping";
|
|
3
|
-
|
|
3
|
+
// A gift that is another product (or a variant priced apart) is worth its own
|
|
4
|
+
// price; without one, the product's price stands in for it.
|
|
5
|
+
const giftPrice = offer.rewardUnitPrice ?? basePrice;
|
|
6
|
+
const savingsAmount = isFreeShipping ? 0 : offer.rewardQuantity * giftPrice;
|
|
7
|
+
const price = basePrice * offer.triggerQuantity;
|
|
8
|
+
// The share of what the shopper would have paid for everything, gifts included.
|
|
9
|
+
const listValue = price + savingsAmount;
|
|
4
10
|
return {
|
|
5
11
|
isFreeShipping,
|
|
6
|
-
savePct: isFreeShipping ||
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
savingsAmount: isFreeShipping ? 0 : offer.rewardQuantity * basePrice,
|
|
10
|
-
price: basePrice * offer.triggerQuantity,
|
|
12
|
+
savePct: isFreeShipping || listValue <= 0 ? 0 : Math.round((savingsAmount / listValue) * 100),
|
|
13
|
+
savingsAmount,
|
|
14
|
+
price,
|
|
11
15
|
comparePrice: comparePrice === null ? null : comparePrice * offer.triggerQuantity,
|
|
12
16
|
};
|
|
13
17
|
}
|
|
@@ -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/schemas.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export declare const OfferSchema: z.ZodObject<{
|
|
|
41
41
|
rewardProductName: z.ZodString;
|
|
42
42
|
rewardVariantId: z.ZodNullable<z.ZodString>;
|
|
43
43
|
rewardVariantLabel: z.ZodNullable<z.ZodString>;
|
|
44
|
+
rewardUnitPrice: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
44
45
|
}, z.core.$strip>;
|
|
45
46
|
export declare const CategorySchema: z.ZodObject<{
|
|
46
47
|
id: z.ZodString;
|
|
@@ -88,7 +89,7 @@ export declare const ProductListSchema: z.ZodObject<{
|
|
|
88
89
|
}>;
|
|
89
90
|
showInStore: z.ZodBoolean;
|
|
90
91
|
storeFeatured: z.ZodBoolean;
|
|
91
|
-
deletedAt: z.
|
|
92
|
+
deletedAt: z.ZodOptional<z.ZodNull>;
|
|
92
93
|
publishedAt: z.ZodNullable<z.ZodString>;
|
|
93
94
|
createdAt: z.ZodString;
|
|
94
95
|
updatedAt: z.ZodString;
|
|
@@ -141,7 +142,7 @@ export declare const ProductSchema: z.ZodObject<{
|
|
|
141
142
|
}>;
|
|
142
143
|
showInStore: z.ZodBoolean;
|
|
143
144
|
storeFeatured: z.ZodBoolean;
|
|
144
|
-
deletedAt: z.
|
|
145
|
+
deletedAt: z.ZodOptional<z.ZodNull>;
|
|
145
146
|
publishedAt: z.ZodNullable<z.ZodString>;
|
|
146
147
|
createdAt: z.ZodString;
|
|
147
148
|
updatedAt: z.ZodString;
|
|
@@ -214,6 +215,7 @@ export declare const ProductSchema: z.ZodObject<{
|
|
|
214
215
|
rewardProductName: z.ZodString;
|
|
215
216
|
rewardVariantId: z.ZodNullable<z.ZodString>;
|
|
216
217
|
rewardVariantLabel: z.ZodNullable<z.ZodString>;
|
|
218
|
+
rewardUnitPrice: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
217
219
|
}, z.core.$strip>>;
|
|
218
220
|
}, z.core.$strip>;
|
|
219
221
|
export declare const StorePagePublicSchema: z.ZodObject<{
|
|
@@ -544,7 +546,7 @@ export declare const LandingPageSchema: z.ZodObject<{
|
|
|
544
546
|
}>;
|
|
545
547
|
showInStore: z.ZodBoolean;
|
|
546
548
|
storeFeatured: z.ZodBoolean;
|
|
547
|
-
deletedAt: z.
|
|
549
|
+
deletedAt: z.ZodOptional<z.ZodNull>;
|
|
548
550
|
publishedAt: z.ZodNullable<z.ZodString>;
|
|
549
551
|
createdAt: z.ZodString;
|
|
550
552
|
updatedAt: z.ZodString;
|
|
@@ -617,6 +619,7 @@ export declare const LandingPageSchema: z.ZodObject<{
|
|
|
617
619
|
rewardProductName: z.ZodString;
|
|
618
620
|
rewardVariantId: z.ZodNullable<z.ZodString>;
|
|
619
621
|
rewardVariantLabel: z.ZodNullable<z.ZodString>;
|
|
622
|
+
rewardUnitPrice: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
620
623
|
}, z.core.$strip>>;
|
|
621
624
|
}, z.core.$strip>>;
|
|
622
625
|
tracking: z.ZodObject<{
|
|
@@ -737,6 +740,18 @@ export declare const OtpVerifyResultSchema: z.ZodObject<{
|
|
|
737
740
|
status: z.ZodLiteral<"verified">;
|
|
738
741
|
otpToken: z.ZodString;
|
|
739
742
|
}, z.core.$strip>;
|
|
743
|
+
export declare const ReviewMediaSchema: z.ZodObject<{
|
|
744
|
+
id: z.ZodString;
|
|
745
|
+
kind: z.ZodEnum<{
|
|
746
|
+
audio: "audio";
|
|
747
|
+
image: "image";
|
|
748
|
+
}>;
|
|
749
|
+
url: z.ZodString;
|
|
750
|
+
thumbUrl: z.ZodNullable<z.ZodString>;
|
|
751
|
+
width: z.ZodNullable<z.ZodNumber>;
|
|
752
|
+
height: z.ZodNullable<z.ZodNumber>;
|
|
753
|
+
durationMs: z.ZodNullable<z.ZodNumber>;
|
|
754
|
+
}, z.core.$strip>;
|
|
740
755
|
export declare const ReviewSchema: z.ZodPreprocess<z.ZodObject<{
|
|
741
756
|
id: z.ZodString;
|
|
742
757
|
customerName: z.ZodString;
|
|
@@ -744,7 +759,51 @@ export declare const ReviewSchema: z.ZodPreprocess<z.ZodObject<{
|
|
|
744
759
|
title: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
745
760
|
body: z.ZodString;
|
|
746
761
|
createdAt: z.ZodString;
|
|
762
|
+
verified: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
763
|
+
media: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
764
|
+
id: z.ZodString;
|
|
765
|
+
kind: z.ZodEnum<{
|
|
766
|
+
audio: "audio";
|
|
767
|
+
image: "image";
|
|
768
|
+
}>;
|
|
769
|
+
url: z.ZodString;
|
|
770
|
+
thumbUrl: z.ZodNullable<z.ZodString>;
|
|
771
|
+
width: z.ZodNullable<z.ZodNumber>;
|
|
772
|
+
height: z.ZodNullable<z.ZodNumber>;
|
|
773
|
+
durationMs: z.ZodNullable<z.ZodNumber>;
|
|
774
|
+
}, z.core.$strip>>>>;
|
|
747
775
|
}, z.core.$strip>, unknown>;
|
|
776
|
+
export declare const ReviewEligibilitySchema: z.ZodObject<{
|
|
777
|
+
orderNumber: z.ZodString;
|
|
778
|
+
customerName: z.ZodString;
|
|
779
|
+
products: z.ZodArray<z.ZodObject<{
|
|
780
|
+
productId: z.ZodString;
|
|
781
|
+
name: z.ZodString;
|
|
782
|
+
imageSrc: z.ZodNullable<z.ZodString>;
|
|
783
|
+
reviewed: z.ZodBoolean;
|
|
784
|
+
}, z.core.$strip>>;
|
|
785
|
+
media: z.ZodNullable<z.ZodObject<{
|
|
786
|
+
maxPhotos: z.ZodNumber;
|
|
787
|
+
voiceMaxSeconds: z.ZodNullable<z.ZodNumber>;
|
|
788
|
+
grant: z.ZodString;
|
|
789
|
+
expiresAt: z.ZodNumber;
|
|
790
|
+
}, z.core.$strip>>;
|
|
791
|
+
}, z.core.$strip>;
|
|
792
|
+
export declare const ReviewUploadSchema: z.ZodObject<{
|
|
793
|
+
uploadId: z.ZodString;
|
|
794
|
+
kind: z.ZodEnum<{
|
|
795
|
+
audio: "audio";
|
|
796
|
+
image: "image";
|
|
797
|
+
}>;
|
|
798
|
+
bytes: z.ZodNumber;
|
|
799
|
+
}, z.core.$strip>;
|
|
800
|
+
export declare const ReviewSubmitResultSchema: z.ZodObject<{
|
|
801
|
+
id: z.ZodString;
|
|
802
|
+
visibility: z.ZodOptional<z.ZodEnum<{
|
|
803
|
+
after_review: "after_review";
|
|
804
|
+
published: "published";
|
|
805
|
+
}>>;
|
|
806
|
+
}, z.core.$strip>;
|
|
748
807
|
/**
|
|
749
808
|
* GET /store/reviews answers `{ success, data: rows, count, total }` — the
|
|
750
809
|
* total lives OUTSIDE `data`, so a caller that reads only `data` loses it.
|
|
@@ -757,6 +816,19 @@ export declare const ReviewListEnvelopeSchema: z.ZodObject<{
|
|
|
757
816
|
title: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
758
817
|
body: z.ZodString;
|
|
759
818
|
createdAt: z.ZodString;
|
|
819
|
+
verified: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
820
|
+
media: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
821
|
+
id: z.ZodString;
|
|
822
|
+
kind: z.ZodEnum<{
|
|
823
|
+
audio: "audio";
|
|
824
|
+
image: "image";
|
|
825
|
+
}>;
|
|
826
|
+
url: z.ZodString;
|
|
827
|
+
thumbUrl: z.ZodNullable<z.ZodString>;
|
|
828
|
+
width: z.ZodNullable<z.ZodNumber>;
|
|
829
|
+
height: z.ZodNullable<z.ZodNumber>;
|
|
830
|
+
durationMs: z.ZodNullable<z.ZodNumber>;
|
|
831
|
+
}, z.core.$strip>>>>;
|
|
760
832
|
}, z.core.$strip>, unknown>>;
|
|
761
833
|
total: z.ZodNumber;
|
|
762
834
|
}, z.core.$strip>;
|
package/dist/schemas.js
CHANGED
|
@@ -38,6 +38,9 @@ export const OfferSchema = z.object({
|
|
|
38
38
|
rewardProductName: z.string(),
|
|
39
39
|
rewardVariantId: z.string().nullable(),
|
|
40
40
|
rewardVariantLabel: z.string().nullable(),
|
|
41
|
+
// What one gift unit is worth at list price. Optional: a server that predates
|
|
42
|
+
// it omits the field, and the product's own price stands in for the gift.
|
|
43
|
+
rewardUnitPrice: z.number().nullish(),
|
|
41
44
|
});
|
|
42
45
|
const productCategorySchema = z.object({
|
|
43
46
|
id: z.string(),
|
|
@@ -80,7 +83,12 @@ const productBaseFields = {
|
|
|
80
83
|
status: z.enum(["DRAFT", "ACTIVE", "ARCHIVED"]),
|
|
81
84
|
showInStore: z.boolean(),
|
|
82
85
|
storeFeatured: z.boolean(),
|
|
83
|
-
|
|
86
|
+
/**
|
|
87
|
+
* @deprecated Always null: the server never serves a removed product. The
|
|
88
|
+
* server still sends it for older releases; it will stop, and a later major
|
|
89
|
+
* drops it. Do not read it.
|
|
90
|
+
*/
|
|
91
|
+
deletedAt: z.null().optional(),
|
|
84
92
|
publishedAt: z.string().datetime().nullable(),
|
|
85
93
|
createdAt: z.string().datetime(),
|
|
86
94
|
updatedAt: z.string().datetime(),
|
|
@@ -356,6 +364,15 @@ export const OtpVerifyResultSchema = z.object({
|
|
|
356
364
|
status: z.literal("verified"),
|
|
357
365
|
otpToken: z.string(),
|
|
358
366
|
});
|
|
367
|
+
export const ReviewMediaSchema = z.object({
|
|
368
|
+
id: z.string(),
|
|
369
|
+
kind: z.enum(["image", "audio"]),
|
|
370
|
+
url: z.string(),
|
|
371
|
+
thumbUrl: z.string().nullable(),
|
|
372
|
+
width: z.number().int().nullable(),
|
|
373
|
+
height: z.number().int().nullable(),
|
|
374
|
+
durationMs: z.number().int().nullable(),
|
|
375
|
+
});
|
|
359
376
|
export const ReviewSchema = z.preprocess((raw) => {
|
|
360
377
|
if (typeof raw === "object" && raw !== null) {
|
|
361
378
|
const obj = raw;
|
|
@@ -372,7 +389,36 @@ export const ReviewSchema = z.preprocess((raw) => {
|
|
|
372
389
|
title: z.string().nullable().optional().default(null),
|
|
373
390
|
body: z.string(),
|
|
374
391
|
createdAt: z.string(),
|
|
392
|
+
verified: z.boolean().optional().default(false),
|
|
393
|
+
media: z.array(ReviewMediaSchema).optional().default([]),
|
|
375
394
|
}));
|
|
395
|
+
export const ReviewEligibilitySchema = z.object({
|
|
396
|
+
orderNumber: z.string(),
|
|
397
|
+
customerName: z.string(),
|
|
398
|
+
products: z.array(z.object({
|
|
399
|
+
productId: z.string(),
|
|
400
|
+
name: z.string(),
|
|
401
|
+
imageSrc: z.string().nullable(),
|
|
402
|
+
reviewed: z.boolean(),
|
|
403
|
+
})),
|
|
404
|
+
media: z
|
|
405
|
+
.object({
|
|
406
|
+
maxPhotos: z.number().int().min(0),
|
|
407
|
+
voiceMaxSeconds: z.number().int().positive().nullable(),
|
|
408
|
+
grant: z.string().min(1),
|
|
409
|
+
expiresAt: z.number().int(),
|
|
410
|
+
})
|
|
411
|
+
.nullable(),
|
|
412
|
+
});
|
|
413
|
+
export const ReviewUploadSchema = z.object({
|
|
414
|
+
uploadId: z.string(),
|
|
415
|
+
kind: z.enum(["image", "audio"]),
|
|
416
|
+
bytes: z.number().int(),
|
|
417
|
+
});
|
|
418
|
+
export const ReviewSubmitResultSchema = z.object({
|
|
419
|
+
id: z.string(),
|
|
420
|
+
visibility: z.enum(["published", "after_review"]).optional(),
|
|
421
|
+
});
|
|
376
422
|
/**
|
|
377
423
|
* GET /store/reviews answers `{ success, data: rows, count, total }` — the
|
|
378
424
|
* total lives OUTSIDE `data`, so a caller that reads only `data` loses it.
|
package/dist/types.d.ts
CHANGED
|
@@ -395,6 +395,19 @@ export type OtpVerifyResult = {
|
|
|
395
395
|
/** Signed, 15-min TTL, bound to the phone. Travels with the order. */
|
|
396
396
|
otpToken: string;
|
|
397
397
|
};
|
|
398
|
+
/** A photo or the voice note of a review. */
|
|
399
|
+
export interface ReviewMedia {
|
|
400
|
+
id: string;
|
|
401
|
+
kind: "image" | "audio";
|
|
402
|
+
/** A photo at 1600 px at most, or the voice note as recorded. */
|
|
403
|
+
url: string;
|
|
404
|
+
/** A photo's 480 px version for lists; null for a voice note. */
|
|
405
|
+
thumbUrl: string | null;
|
|
406
|
+
width: number | null;
|
|
407
|
+
height: number | null;
|
|
408
|
+
/** A voice note's length, when known. */
|
|
409
|
+
durationMs: number | null;
|
|
410
|
+
}
|
|
398
411
|
export interface Review {
|
|
399
412
|
id: string;
|
|
400
413
|
customerName: string;
|
|
@@ -402,6 +415,50 @@ export interface Review {
|
|
|
402
415
|
title: string | null;
|
|
403
416
|
body: string;
|
|
404
417
|
createdAt: string;
|
|
418
|
+
/** Rests on an order that reached its customer: show it as a verified purchase. */
|
|
419
|
+
verified: boolean;
|
|
420
|
+
/** Photos first, then the voice note. Empty from servers before 1.6. */
|
|
421
|
+
media: ReviewMedia[];
|
|
422
|
+
}
|
|
423
|
+
/** One product of the shopper's order, as `products.reviews.eligibility()` answers it. */
|
|
424
|
+
export interface ReviewableProduct {
|
|
425
|
+
productId: string;
|
|
426
|
+
name: string;
|
|
427
|
+
imageSrc: string | null;
|
|
428
|
+
/** Already reviewed from this order: show it, but it cannot be reviewed again. */
|
|
429
|
+
reviewed: boolean;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* What a shopper's order lets them review, once its number and phone proved
|
|
433
|
+
* the purchase — and, when the store takes them, what they may attach.
|
|
434
|
+
*/
|
|
435
|
+
export interface ReviewEligibility {
|
|
436
|
+
orderNumber: string;
|
|
437
|
+
customerName: string;
|
|
438
|
+
products: ReviewableProduct[];
|
|
439
|
+
/** null: text only. */
|
|
440
|
+
media: {
|
|
441
|
+
/** 0: no photos. */
|
|
442
|
+
maxPhotos: number;
|
|
443
|
+
/** null: no voice note. */
|
|
444
|
+
voiceMaxSeconds: number | null;
|
|
445
|
+
/** Send with each upload and with the review. Lasts until `expiresAt`. */
|
|
446
|
+
grant: string;
|
|
447
|
+
/** Unix seconds. */
|
|
448
|
+
expiresAt: number;
|
|
449
|
+
} | null;
|
|
450
|
+
}
|
|
451
|
+
/** A photo or voice note the server received for a review not yet submitted. */
|
|
452
|
+
export interface ReviewUpload {
|
|
453
|
+
uploadId: string;
|
|
454
|
+
kind: "image" | "audio";
|
|
455
|
+
bytes: number;
|
|
456
|
+
}
|
|
457
|
+
/** What a submitted review answers: published now, or after the store checks it. */
|
|
458
|
+
export interface ReviewSubmitResult {
|
|
459
|
+
id: string;
|
|
460
|
+
/** Absent from servers before 1.6. */
|
|
461
|
+
visibility?: "published" | "after_review" | undefined;
|
|
405
462
|
}
|
|
406
463
|
/**
|
|
407
464
|
* Filters `products.list()` accepts — named so a theme can type its own
|
|
@@ -415,21 +472,54 @@ export interface ProductListFilters {
|
|
|
415
472
|
/** Products to skip — with `limit`, pages through the whole catalog. */
|
|
416
473
|
offset?: number;
|
|
417
474
|
}
|
|
475
|
+
/** One page of products from `products.page()`. */
|
|
476
|
+
export interface ProductPage {
|
|
477
|
+
items: ProductList[];
|
|
478
|
+
/** True when another page follows this one. */
|
|
479
|
+
hasNext: boolean;
|
|
480
|
+
}
|
|
418
481
|
/** `products.reviews.list()` paging — named for the same reason. */
|
|
419
482
|
export interface ReviewListPagination {
|
|
420
483
|
limit?: number;
|
|
421
484
|
offset?: number;
|
|
422
485
|
}
|
|
423
486
|
/**
|
|
424
|
-
* The body `products.reviews.submit()` accepts: the order number
|
|
425
|
-
*
|
|
426
|
-
*
|
|
487
|
+
* The body `products.reviews.submit()` accepts: the order number and the phone
|
|
488
|
+
* it was placed with prove the purchase, and the server owns every other rule
|
|
489
|
+
* (a delivered order, one review per product of it, moderation).
|
|
427
490
|
*/
|
|
428
491
|
export interface ReviewInput {
|
|
429
492
|
orderNumber: string;
|
|
493
|
+
/**
|
|
494
|
+
* The phone the order was placed with. Without it (the 1.5 shape) a review
|
|
495
|
+
* is text only and still needs a delivered order.
|
|
496
|
+
* @deprecated Omitting it is deprecated; a later major requires it.
|
|
497
|
+
*/
|
|
498
|
+
phone?: string;
|
|
430
499
|
productId: string;
|
|
431
500
|
/** 1–5, the server validates the range. */
|
|
432
501
|
rating: number;
|
|
433
502
|
title?: string;
|
|
503
|
+
/** At least 10 characters, unless a voice note is attached. */
|
|
434
504
|
body: string;
|
|
505
|
+
/** The grant from `eligibility()`, when files are attached. */
|
|
506
|
+
grant?: string;
|
|
507
|
+
/** `uploadId`s from `upload()`. */
|
|
508
|
+
uploads?: string[];
|
|
509
|
+
}
|
|
510
|
+
/** What `products.reviews.eligibility()` sends. */
|
|
511
|
+
export interface ReviewEligibilityInput {
|
|
512
|
+
orderNumber: string;
|
|
513
|
+
phone: string;
|
|
514
|
+
/** Required when the store has Turnstile on. */
|
|
515
|
+
turnstileToken?: string;
|
|
516
|
+
}
|
|
517
|
+
/** What `products.reviews.upload()` sends: one file, as it is. */
|
|
518
|
+
export interface ReviewUploadInput {
|
|
519
|
+
grant: string;
|
|
520
|
+
body: Blob | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>;
|
|
521
|
+
/** Its size, when known: lets the server refuse a file too large before it arrives. */
|
|
522
|
+
contentLength?: number;
|
|
523
|
+
/** A voice note's length as the recorder reported it. */
|
|
524
|
+
durationMs?: number;
|
|
435
525
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wocart/storefront",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Typed, validated SDK for Wocart cash-on-delivery storefronts: catalog reads, basket, checkout, OTP and order tracking, with Astro adapters.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wocart",
|
|
@@ -41,6 +41,10 @@
|
|
|
41
41
|
"types": "./dist/astro.d.ts",
|
|
42
42
|
"default": "./dist/astro.js"
|
|
43
43
|
},
|
|
44
|
+
"./review-media": {
|
|
45
|
+
"types": "./dist/review-media.d.ts",
|
|
46
|
+
"default": "./dist/review-media.js"
|
|
47
|
+
},
|
|
44
48
|
"./package.json": "./package.json"
|
|
45
49
|
},
|
|
46
50
|
"files": [
|