@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 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. For submitting a review:
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: customerVisibleOrderNumber,
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
- orderNumber is the customer-visible order number, not the internal order UUID.
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
- | `reviewSubmit` | POST | `ReviewInput` | 201 `{ success: true, data: { id } }` |
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
@@ -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. */
@@ -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 ?? `POST ${path}`;
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: "POST",
425
- headers: buildHeaders(opts?.headers),
426
- body: JSON.stringify(body),
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
- * Submit a product review
45
- * @param data - Review data
46
- * @returns Result with review ID or error
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
  /**
@@ -12,14 +12,33 @@
12
12
  * half-shaped product into a theme.
13
13
  */
14
14
  import { z } from "zod";
15
- import { IdResponseSchema, ProductListSchema, ProductSchema, ReviewListEnvelopeSchema } from "../schemas.js";
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
- async list(filters) {
36
- try {
37
- const products = await core.get(localizedPath(core.config, "/store/products", {
38
- categoryId: filters?.categoryId || undefined,
39
- featured: filters?.featured ? "true" : undefined,
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, { schema: IdResponseSchema, operation: STOREFRONT_OPERATIONS.productsReviewsSubmit });
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 'POST' */
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) */
@@ -31,7 +31,7 @@ import { collectForwardedHeaders } from "../actions.js";
31
31
  * ```
32
32
  */
33
33
  export function createProxyEndpoint(config) {
34
- const { method, client, inputSchema, queryParams = [], pathParams = {}, csrfProtection = method === "POST", forwardAttribution = false, successStatus = 200, errorStatus, successBody, noContent = false, } = config;
34
+ const { method, client, inputSchema, queryParams = [], pathParams = {}, csrfProtection = method !== "GET", readInput, forwardAttribution = false, successStatus = 200, errorStatus, successBody, noContent = false, } = config;
35
35
  return async (context) => {
36
36
  const { request, params } = context;
37
37
  // CSRF Protection: reject cross-origin requests
@@ -41,7 +41,10 @@ export function createProxyEndpoint(config) {
41
41
  // Parse input based on method
42
42
  let input;
43
43
  try {
44
- if (method === "GET") {
44
+ if (readInput) {
45
+ input = readInput(request);
46
+ }
47
+ else if (method === "GET") {
45
48
  input = parseGetInput(request, params, queryParams, pathParams);
46
49
  }
47
50
  else {
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * The storefront's same-origin API routes, ready to export.
3
3
  *
4
- * Every theme needs the same eight routes between the shopper's browser and
5
- * the platform: OTP send and verify, cart validation, abandoned-checkout
6
- * tracking and its conversion, communes, and reviews (list and submit). What
4
+ * Every theme needs the same routes between the shopper's browser and the
5
+ * platform: OTP send and verify, cart validation, abandoned-checkout tracking
6
+ * and its conversion, communes, and reviews (list, find the order, send a
7
+ * photo or voice note, submit). What
7
8
  * each one must get right is decided here once — the cross-origin check on
8
9
  * every POST, the shopper's address forwarded wherever the server counts or
9
10
  * attributes per shopper, the input bounds, and the response shape the
@@ -47,7 +48,15 @@ export interface StorefrontRoutes<C extends AstroAPIContext> {
47
48
  readonly communes: StorefrontApiRoute<C>;
48
49
  /** GET, path param `productId`, query `limit`/`offset` → `{ data: Review[], total }`. */
49
50
  readonly reviewsList: StorefrontApiRoute<C>;
50
- /** POST a `ReviewInput` → 201 `{ success: true, data: { id } }`. */
51
+ /** POST `{ orderNumber, phone, turnstileToken? }` → `{ data: ReviewEligibility }`. */
52
+ readonly reviewEligibility: StorefrontApiRoute<C>;
53
+ /**
54
+ * PUT one file as the body, the grant in `X-Review-Grant` (and a voice
55
+ * note's length in `X-Media-Duration-Ms`) → 201 `{ data: ReviewUpload }`.
56
+ * The file streams on to the server unbuffered.
57
+ */
58
+ readonly reviewUpload: StorefrontApiRoute<C>;
59
+ /** POST a `ReviewInput` → 201 `{ success: true, data: { id, visibility } }`. */
51
60
  readonly reviewSubmit: StorefrontApiRoute<C>;
52
61
  }
53
62
  export declare const REVIEWS_PAGE_DEFAULT = 20;
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * The storefront's same-origin API routes, ready to export.
3
3
  *
4
- * Every theme needs the same eight routes between the shopper's browser and
5
- * the platform: OTP send and verify, cart validation, abandoned-checkout
6
- * tracking and its conversion, communes, and reviews (list and submit). What
4
+ * Every theme needs the same routes between the shopper's browser and the
5
+ * platform: OTP send and verify, cart validation, abandoned-checkout tracking
6
+ * and its conversion, communes, and reviews (list, find the order, send a
7
+ * photo or voice note, submit). What
7
8
  * each one must get right is decided here once — the cross-origin check on
8
9
  * every POST, the shopper's address forwarded wherever the server counts or
9
10
  * attributes per shopper, the input bounds, and the response shape the
@@ -65,11 +66,40 @@ const reviewsListInput = z.object({
65
66
  */
66
67
  const reviewSubmitInput = z.object({
67
68
  orderNumber: z.string().min(1).max(64),
69
+ phone: z.string().min(1).max(32).optional(),
68
70
  productId: z.string().min(1).max(200),
69
71
  rating: z.number(),
70
72
  title: z.string().max(500).optional(),
71
73
  body: z.string().max(5000),
74
+ grant: z.string().min(1).max(2048).optional(),
75
+ uploads: z.array(z.string().min(1).max(64)).max(8).optional(),
72
76
  });
77
+ const reviewEligibilityInput = z.object({
78
+ orderNumber: z.string().min(1).max(64),
79
+ phone: z.string().min(1).max(32),
80
+ turnstileToken: z.string().min(1).max(2048).optional(),
81
+ });
82
+ /** The file itself stays a stream; only the headers around it are checked here. */
83
+ const reviewUploadInput = z.object({
84
+ body: z.custom((value) => value instanceof ReadableStream, "No file was sent"),
85
+ grant: z.string().min(1).max(2048),
86
+ contentLength: z.number().int().positive().optional(),
87
+ durationMs: z.number().int().positive().max(600_000).optional(),
88
+ });
89
+ function readUpload(request) {
90
+ const length = Number(request.headers.get("Content-Length"));
91
+ const duration = Number(request.headers.get("X-Media-Duration-Ms"));
92
+ return {
93
+ body: request.body,
94
+ grant: request.headers.get("X-Review-Grant") ?? "",
95
+ ...(Number.isInteger(length) && length > 0 ? { contentLength: length } : {}),
96
+ ...(Number.isInteger(duration) && duration > 0 ? { durationMs: duration } : {}),
97
+ };
98
+ }
99
+ /** Drops the keys a form left undefined: the client's input type is exact. */
100
+ function defined(value) {
101
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
102
+ }
73
103
  export function createStorefrontRoutes(options) {
74
104
  const route = (config) => async (context) => {
75
105
  const { call, ...endpoint } = config;
@@ -130,12 +160,24 @@ export function createStorefrontRoutes(options) {
130
160
  data: await client.products.reviews.list(productId, { limit, offset }),
131
161
  }),
132
162
  }),
163
+ reviewEligibility: route({
164
+ method: "POST",
165
+ inputSchema: reviewEligibilityInput,
166
+ call: (client, input) => client.products.reviews.eligibility(defined(input)),
167
+ }),
168
+ reviewUpload: route({
169
+ method: "PUT",
170
+ readInput: readUpload,
171
+ inputSchema: reviewUploadInput,
172
+ successStatus: 201,
173
+ call: (client, input) => client.products.reviews.upload(defined(input)),
174
+ }),
133
175
  reviewSubmit: route({
134
176
  method: "POST",
135
177
  inputSchema: reviewSubmitInput,
136
178
  successStatus: 201,
137
179
  successBody: (data) => ({ success: true, data }),
138
- call: (client, { title, ...review }) => client.products.reviews.submit(title === undefined ? review : { ...review, title }),
180
+ call: (client, review) => client.products.reviews.submit(defined(review)),
139
181
  }),
140
182
  };
141
183
  }
@@ -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;
@@ -1,13 +1,17 @@
1
1
  export function getOfferTierView(offer, basePrice, comparePrice) {
2
2
  const isFreeShipping = offer.discountType === "free_shipping";
3
- const quantityTotal = offer.triggerQuantity + offer.rewardQuantity;
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 || quantityTotal <= 0
7
- ? 0
8
- : Math.round((offer.rewardQuantity / quantityTotal) * 100),
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.ZodNullable<z.ZodString>;
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.ZodNullable<z.ZodString>;
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.ZodNullable<z.ZodString>;
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
- deletedAt: z.string().datetime().nullable(),
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 is the proof
425
- * of purchase, and the server owns every other rule (moderation, one-review
426
- * per order line).
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.4.0",
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": [