@wocart/storefront 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/USAGE.md +317 -4
  3. package/dist/abandoned-checkout.d.ts +45 -0
  4. package/dist/abandoned-checkout.js +81 -3
  5. package/dist/answer-readers.d.ts +11 -4
  6. package/dist/answer-readers.js +114 -0
  7. package/dist/astro.d.ts +4 -0
  8. package/dist/astro.js +2 -0
  9. package/dist/basket.d.ts +11 -1
  10. package/dist/basket.js +17 -0
  11. package/dist/catalog.d.ts +24 -0
  12. package/dist/catalog.js +33 -0
  13. package/dist/client/orders.d.ts +10 -1
  14. package/dist/client/orders.js +8 -1
  15. package/dist/client/products.d.ts +13 -3
  16. package/dist/client/products.js +35 -12
  17. package/dist/coupon.d.ts +16 -1
  18. package/dist/coupon.js +37 -0
  19. package/dist/endpoints/storefront-routes.d.ts +8 -2
  20. package/dist/endpoints/storefront-routes.js +9 -2
  21. package/dist/index.d.ts +15 -7
  22. package/dist/index.js +9 -4
  23. package/dist/operations.d.ts +4 -0
  24. package/dist/operations.js +4 -0
  25. package/dist/order-lookup.d.ts +51 -0
  26. package/dist/order-lookup.js +44 -0
  27. package/dist/order-number.d.ts +9 -0
  28. package/dist/order-number.js +11 -0
  29. package/dist/product-view.d.ts +8 -0
  30. package/dist/product-view.js +26 -4
  31. package/dist/quote.d.ts +27 -0
  32. package/dist/quote.js +43 -0
  33. package/dist/review-capture.d.ts +77 -0
  34. package/dist/review-capture.js +172 -0
  35. package/dist/review-media.d.ts +8 -75
  36. package/dist/review-media.js +7 -170
  37. package/dist/review-session.d.ts +97 -0
  38. package/dist/review-session.js +157 -0
  39. package/dist/reviews.d.ts +33 -0
  40. package/dist/reviews.js +41 -0
  41. package/dist/routes.d.ts +2 -0
  42. package/dist/routes.js +3 -0
  43. package/dist/schemas.d.ts +98 -0
  44. package/dist/schemas.js +31 -0
  45. package/dist/seo.d.ts +47 -0
  46. package/dist/seo.js +87 -0
  47. package/dist/server.d.ts +1 -0
  48. package/dist/server.js +1 -0
  49. package/dist/sitemap.d.ts +22 -0
  50. package/dist/sitemap.js +51 -0
  51. package/dist/store-request.d.ts +96 -0
  52. package/dist/store-request.js +102 -0
  53. package/dist/types.d.ts +50 -2
  54. package/dist/utils/image.d.ts +48 -0
  55. package/dist/utils/image.js +51 -0
  56. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
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:
2
+ * `@wocart/storefront/review-media` — a review form in the browser.
5
3
  *
4
+ * createReviewSession proves the purchase, sends each photo or voice note
5
+ * as it is added (progress, retries), and submits the
6
+ * review; every refusal comes back as a code
6
7
  * prepareReviewPhoto shrinks a photo to 1600 px at most and re-encodes it
7
8
  * (WebP, else JPEG) — a phone photo of several MB
8
9
  * becomes a few hundred KB, and its EXIF (the phone's
@@ -12,76 +13,8 @@
12
13
  * itself at the store's limit
13
14
  *
14
15
  * Browser-only, and kept off the main entry point: a theme that takes no
15
- * media pays nothing for it. Each takes its browser dependencies as an
16
- * optional last argument, for tests and unusual hosts.
16
+ * reviews pays nothing for it.
17
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;
18
+ export * from "./review-capture.js";
19
+ export { createReviewSession } from "./review-session.js";
20
+ export type { ReviewDraft, ReviewFindResult, ReviewRefusal, ReviewSession, ReviewSessionDeps, ReviewSessionOptions, ReviewSessionRoutes, ReviewSubmission, ReviewUpload, ReviewUploadOptions, ReviewUploadRequest, ReviewUploadResult, } from "./review-session.js";
@@ -1,8 +1,9 @@
1
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:
2
+ * `@wocart/storefront/review-media` — a review form in the browser.
5
3
  *
4
+ * createReviewSession proves the purchase, sends each photo or voice note
5
+ * as it is added (progress, retries), and submits the
6
+ * review; every refusal comes back as a code
6
7
  * prepareReviewPhoto shrinks a photo to 1600 px at most and re-encodes it
7
8
  * (WebP, else JPEG) — a phone photo of several MB
8
9
  * becomes a few hundred KB, and its EXIF (the phone's
@@ -12,171 +13,7 @@
12
13
  * itself at the store's limit
13
14
  *
14
15
  * Browser-only, and kept off the main entry point: a theme that takes no
15
- * media pays nothing for it. Each takes its browser dependencies as an
16
- * optional last argument, for tests and unusual hosts.
16
+ * reviews pays nothing for it.
17
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
- }
18
+ export * from "./review-capture.js";
19
+ export { createReviewSession } from "./review-session.js";
@@ -0,0 +1,97 @@
1
+ import type { ReviewRefusalCode } from "./reviews.js";
2
+ import type { ReviewEligibility, ReviewSubmitResult } from "./types.js";
3
+ /** The theme's review routes (`createStorefrontRoutes()`): `reviewEligibility`, `reviewUpload`, `reviewSubmit`. */
4
+ export interface ReviewSessionRoutes {
5
+ eligibility: string;
6
+ upload: string;
7
+ submit: string;
8
+ }
9
+ export interface ReviewSessionOptions {
10
+ routes: ReviewSessionRoutes;
11
+ /** Tries per upload, the first included. Default 3: two retries, after 2 s and then 4 s. */
12
+ attempts?: number;
13
+ }
14
+ /** A refusal: one of `REVIEW_REFUSAL_CODES` (or a code from a newer server), or null when no answer came. */
15
+ export interface ReviewRefusal {
16
+ status: "refused";
17
+ code: ReviewRefusalCode | (string & {}) | null;
18
+ }
19
+ export type ReviewFindResult = {
20
+ status: "found";
21
+ eligibility: ReviewEligibility;
22
+ } | ReviewRefusal;
23
+ export type ReviewSubmission = {
24
+ status: "submitted";
25
+ result: ReviewSubmitResult;
26
+ } | ReviewRefusal;
27
+ /** `cancelled`: `cancel()` was called, or the session was reset; nothing more happens. */
28
+ export type ReviewUploadResult = {
29
+ status: "sent";
30
+ uploadId: string;
31
+ } | ReviewRefusal | {
32
+ status: "cancelled";
33
+ };
34
+ export interface ReviewUploadOptions {
35
+ /** A photo is shrunk before it is sent; a voice note is sent as recorded. */
36
+ kind: "image" | "audio";
37
+ /** A voice note's length, as the recorder reported it. */
38
+ durationMs?: number;
39
+ /** The share sent so far, 0–1, for a progress bar. */
40
+ onProgress?: (share: number) => void;
41
+ }
42
+ export interface ReviewUpload {
43
+ readonly result: Promise<ReviewUploadResult>;
44
+ cancel(): void;
45
+ }
46
+ export interface ReviewDraft {
47
+ productId: string;
48
+ /** 1–5. */
49
+ rating: number;
50
+ body: string;
51
+ title?: string;
52
+ /** The `uploadId`s of what was sent; the grant goes with them. */
53
+ uploads?: readonly string[];
54
+ }
55
+ export interface ReviewSession {
56
+ /** The order `find` proved, until `reset`. */
57
+ readonly eligibility: ReviewEligibility | null;
58
+ /** Prove the purchase with the order's number and the phone it was placed with. */
59
+ find(proof: {
60
+ orderNumber: string;
61
+ phone: string;
62
+ }): Promise<ReviewFindResult>;
63
+ /** Send one photo or voice note under the grant `find` got. */
64
+ upload(file: Blob, options: ReviewUploadOptions): ReviewUpload;
65
+ /** Submit the review for one of the order's products. */
66
+ submit(draft: ReviewDraft): Promise<ReviewSubmission>;
67
+ /** Forget the order and cancel what is still sending: the shopper changes order. */
68
+ reset(): void;
69
+ }
70
+ /** One request with upload progress, as `XMLHttpRequest` gives it. */
71
+ export interface ReviewUploadRequest {
72
+ open(method: string, url: string): void;
73
+ setRequestHeader(name: string, value: string): void;
74
+ send(body: Blob): void;
75
+ abort(): void;
76
+ readonly status: number;
77
+ readonly responseText: string;
78
+ readonly upload: {
79
+ onprogress: ((event: {
80
+ lengthComputable: boolean;
81
+ loaded: number;
82
+ total: number;
83
+ }) => void) | null;
84
+ };
85
+ onload: (() => void) | null;
86
+ onerror: (() => void) | null;
87
+ ontimeout: (() => void) | null;
88
+ onabort: (() => void) | null;
89
+ }
90
+ /** What the session uses of the page; the page's own by default. */
91
+ export interface ReviewSessionDeps {
92
+ fetch: typeof fetch;
93
+ createRequest: () => ReviewUploadRequest;
94
+ wait: (ms: number) => Promise<void>;
95
+ preparePhoto: (photo: Blob) => Promise<Blob>;
96
+ }
97
+ export declare function createReviewSession(options: ReviewSessionOptions, deps?: ReviewSessionDeps): ReviewSession;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Writing a review from the browser, through the theme's review routes: prove
3
+ * the purchase, send each photo or voice note as soon as it is added, then
4
+ * submit a review that only names what already arrived.
5
+ *
6
+ * Every rule is the server's. The session keeps the proof and the upload
7
+ * grant between the steps, sends a photo shrunk (`prepareReviewPhoto`), shows
8
+ * an upload's progress, tries again when the connection drops or the server
9
+ * fails, and answers every refusal with its code (`REVIEW_REFUSAL_CODES`) for
10
+ * the form to word. It never throws.
11
+ */
12
+ import { isOrderNumber } from "./order-number.js";
13
+ import { prepareReviewPhoto } from "./review-capture.js";
14
+ function browserDeps() {
15
+ return {
16
+ fetch: (input, init) => globalThis.fetch(input, init),
17
+ createRequest: () => new XMLHttpRequest(),
18
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
19
+ preparePhoto: (photo) => prepareReviewPhoto(photo),
20
+ };
21
+ }
22
+ const refused = (code) => ({ status: "refused", code: typeof code === "string" && code !== "" ? code : null });
23
+ const isObject = (value) => typeof value === "object" && value !== null;
24
+ async function postJson(deps, url, body) {
25
+ try {
26
+ const response = await deps.fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
27
+ const json = await response.json().catch(() => ({}));
28
+ return { ok: response.ok, json: isObject(json) ? json : {} };
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ export function createReviewSession(options, deps = browserDeps()) {
35
+ const attempts = Math.max(1, options.attempts ?? 3);
36
+ let proof = null;
37
+ let eligibility = null;
38
+ let generation = 0;
39
+ const pending = new Set();
40
+ function send(file, headers, onProgress, register) {
41
+ return new Promise((resolve) => {
42
+ const request = deps.createRequest();
43
+ request.open("PUT", options.routes.upload);
44
+ for (const [name, value] of Object.entries(headers))
45
+ request.setRequestHeader(name, value);
46
+ request.upload.onprogress = (event) => {
47
+ if (event.lengthComputable && event.total > 0)
48
+ onProgress?.(Math.min(1, event.loaded / event.total));
49
+ };
50
+ request.onload = () => {
51
+ let json = {};
52
+ try {
53
+ json = JSON.parse(request.responseText);
54
+ }
55
+ catch {
56
+ json = {};
57
+ }
58
+ resolve({ status: request.status, json: isObject(json) ? json : {} });
59
+ };
60
+ request.onerror = request.ontimeout = request.onabort = () => resolve({ status: 0, json: {} });
61
+ register(() => request.abort());
62
+ request.send(file);
63
+ });
64
+ }
65
+ return {
66
+ get eligibility() {
67
+ return eligibility;
68
+ },
69
+ async find(given) {
70
+ const orderNumber = given.orderNumber.trim();
71
+ const phone = given.phone.trim();
72
+ if (!isOrderNumber(orderNumber) || phone === "")
73
+ return refused("VALIDATION_FAILED");
74
+ const asked = generation;
75
+ const answer = await postJson(deps, options.routes.eligibility, { orderNumber, phone });
76
+ if (answer === null)
77
+ return refused(null);
78
+ if (!answer.ok)
79
+ return refused(answer.json.code);
80
+ const data = answer.json.data;
81
+ if (!isObject(data) || !Array.isArray(data.products))
82
+ return refused(null);
83
+ if (asked === generation) {
84
+ proof = { orderNumber, phone };
85
+ eligibility = data;
86
+ }
87
+ return { status: "found", eligibility: data };
88
+ },
89
+ upload(file, uploadOptions) {
90
+ let cancelled = false;
91
+ let abort = null;
92
+ const cancel = () => {
93
+ cancelled = true;
94
+ abort?.();
95
+ pending.delete(cancel);
96
+ };
97
+ pending.add(cancel);
98
+ const grant = eligibility?.media?.grant;
99
+ const result = (async () => {
100
+ if (!grant)
101
+ return refused(eligibility ? "REVIEW_MEDIA_LIMIT" : "REVIEW_UPLOAD_GRANT_INVALID");
102
+ const body = uploadOptions.kind === "image" ? await deps.preparePhoto(file).catch(() => file) : file;
103
+ const headers = { "Content-Type": "application/octet-stream", "X-Review-Grant": grant };
104
+ if (uploadOptions.durationMs !== undefined && uploadOptions.durationMs > 0)
105
+ headers["X-Media-Duration-Ms"] = String(Math.round(uploadOptions.durationMs));
106
+ for (let attempt = 1;; attempt++) {
107
+ if (cancelled)
108
+ return { status: "cancelled" };
109
+ const answer = await send(body, headers, uploadOptions.onProgress, (stop) => (abort = stop));
110
+ abort = null;
111
+ if (cancelled)
112
+ return { status: "cancelled" };
113
+ if (answer.status >= 200 && answer.status < 300) {
114
+ const data = answer.json.data;
115
+ const uploadId = isObject(data) && typeof data.uploadId === "string" && data.uploadId !== "" ? data.uploadId : null;
116
+ return uploadId === null ? refused(null) : { status: "sent", uploadId };
117
+ }
118
+ const transient = answer.status === 0 || answer.status >= 500;
119
+ if (!transient || attempt >= attempts)
120
+ return refused(answer.json.code);
121
+ await deps.wait(1000 * 2 ** attempt);
122
+ }
123
+ })().finally(() => pending.delete(cancel));
124
+ return { result, cancel };
125
+ },
126
+ async submit(draft) {
127
+ if (proof === null)
128
+ return refused("ORDER_NOT_FOUND");
129
+ const uploads = draft.uploads ?? [];
130
+ const title = draft.title?.trim();
131
+ const answer = await postJson(deps, options.routes.submit, {
132
+ orderNumber: proof.orderNumber,
133
+ phone: proof.phone,
134
+ productId: draft.productId,
135
+ rating: draft.rating,
136
+ body: draft.body.trim(),
137
+ ...(title ? { title } : {}),
138
+ ...(uploads.length > 0 && eligibility?.media ? { grant: eligibility.media.grant, uploads: [...uploads] } : {}),
139
+ });
140
+ if (answer === null)
141
+ return refused(null);
142
+ if (!answer.ok)
143
+ return refused(answer.json.code);
144
+ const data = answer.json.data;
145
+ if (!isObject(data) || typeof data.id !== "string")
146
+ return refused(null);
147
+ return { status: "submitted", result: data };
148
+ },
149
+ reset() {
150
+ generation++;
151
+ proof = null;
152
+ eligibility = null;
153
+ for (const cancel of [...pending])
154
+ cancel();
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * What a review form needs to know of the platform's rules without asking:
3
+ * how long its text may be, and every code a refusal can carry. The rules
4
+ * themselves are the server's — these let a form say them in its own words.
5
+ */
6
+ /** The lengths the server accepts: a title up to `titleMax`; a body of `bodyMin`–`bodyMax` characters, or shorter with a voice note. */
7
+ export declare const REVIEW_TEXT_LIMITS: {
8
+ readonly titleMax: 150;
9
+ readonly bodyMin: 10;
10
+ readonly bodyMax: 2000;
11
+ };
12
+ /**
13
+ * Every code the server refuses a review step with. A newer server may send
14
+ * another; a form then shows its general sentence.
15
+ *
16
+ * - `VALIDATION_FAILED`: the order number is not one (`isOrderNumber`), or the
17
+ * text is too short without a voice note.
18
+ * - `ORDER_NOT_FOUND`: no order has this number and phone — the same answer
19
+ * for either being wrong.
20
+ * - `REVIEWS_DISABLED`: the store takes no reviews.
21
+ * - `REVIEW_ORDER_NOT_DELIVERED`: the order has not reached the shopper yet.
22
+ * - `REVIEW_RATE_LIMITED`: too many attempts for this order; try in a minute.
23
+ * - `ORDER_ALREADY_REVIEWED`: this product was reviewed from this order.
24
+ * - `PRODUCT_NOT_IN_ORDER`: the product is not one the order kept.
25
+ * - `REVIEW_UPLOAD_GRANT_INVALID`: the permission to send files expired; find
26
+ * the order again.
27
+ * - `REVIEW_UPLOAD_NOT_FOUND`: a file the review names never arrived.
28
+ * - `REVIEW_MEDIA_LIMIT`: more photos or voice notes than the store takes.
29
+ * - `INVALID_FILE_TYPE`: not a photo or a voice note the store reads.
30
+ * - `FILE_TOO_LARGE`: the file is over the store's size.
31
+ */
32
+ export declare const REVIEW_REFUSAL_CODES: readonly ["VALIDATION_FAILED", "ORDER_NOT_FOUND", "REVIEWS_DISABLED", "REVIEW_ORDER_NOT_DELIVERED", "REVIEW_RATE_LIMITED", "ORDER_ALREADY_REVIEWED", "PRODUCT_NOT_IN_ORDER", "REVIEW_UPLOAD_GRANT_INVALID", "REVIEW_UPLOAD_NOT_FOUND", "REVIEW_MEDIA_LIMIT", "INVALID_FILE_TYPE", "FILE_TOO_LARGE"];
33
+ export type ReviewRefusalCode = (typeof REVIEW_REFUSAL_CODES)[number];
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What a review form needs to know of the platform's rules without asking:
3
+ * how long its text may be, and every code a refusal can carry. The rules
4
+ * themselves are the server's — these let a form say them in its own words.
5
+ */
6
+ /** The lengths the server accepts: a title up to `titleMax`; a body of `bodyMin`–`bodyMax` characters, or shorter with a voice note. */
7
+ export const REVIEW_TEXT_LIMITS = { titleMax: 150, bodyMin: 10, bodyMax: 2000 };
8
+ /**
9
+ * Every code the server refuses a review step with. A newer server may send
10
+ * another; a form then shows its general sentence.
11
+ *
12
+ * - `VALIDATION_FAILED`: the order number is not one (`isOrderNumber`), or the
13
+ * text is too short without a voice note.
14
+ * - `ORDER_NOT_FOUND`: no order has this number and phone — the same answer
15
+ * for either being wrong.
16
+ * - `REVIEWS_DISABLED`: the store takes no reviews.
17
+ * - `REVIEW_ORDER_NOT_DELIVERED`: the order has not reached the shopper yet.
18
+ * - `REVIEW_RATE_LIMITED`: too many attempts for this order; try in a minute.
19
+ * - `ORDER_ALREADY_REVIEWED`: this product was reviewed from this order.
20
+ * - `PRODUCT_NOT_IN_ORDER`: the product is not one the order kept.
21
+ * - `REVIEW_UPLOAD_GRANT_INVALID`: the permission to send files expired; find
22
+ * the order again.
23
+ * - `REVIEW_UPLOAD_NOT_FOUND`: a file the review names never arrived.
24
+ * - `REVIEW_MEDIA_LIMIT`: more photos or voice notes than the store takes.
25
+ * - `INVALID_FILE_TYPE`: not a photo or a voice note the store reads.
26
+ * - `FILE_TOO_LARGE`: the file is over the store's size.
27
+ */
28
+ export const REVIEW_REFUSAL_CODES = [
29
+ "VALIDATION_FAILED",
30
+ "ORDER_NOT_FOUND",
31
+ "REVIEWS_DISABLED",
32
+ "REVIEW_ORDER_NOT_DELIVERED",
33
+ "REVIEW_RATE_LIMITED",
34
+ "ORDER_ALREADY_REVIEWED",
35
+ "PRODUCT_NOT_IN_ORDER",
36
+ "REVIEW_UPLOAD_GRANT_INVALID",
37
+ "REVIEW_UPLOAD_NOT_FOUND",
38
+ "REVIEW_MEDIA_LIMIT",
39
+ "INVALID_FILE_TYPE",
40
+ "FILE_TOO_LARGE",
41
+ ];
package/dist/routes.d.ts CHANGED
@@ -26,6 +26,8 @@ export declare const STOREFRONT_PAGE_PATHS: {
26
26
  readonly landing: "/lp/:slug";
27
27
  readonly checkout: "/checkout";
28
28
  readonly thankYou: "/thank-you";
29
+ /** Where a shopper follows an order by its number and phone; `?order=<number>` fills the number in. */
30
+ readonly trackOrder: "/track-order";
29
31
  };
30
32
  /** What a storefront URL means. */
31
33
  export type StorefrontPageType = keyof typeof STOREFRONT_PAGE_PATHS;
package/dist/routes.js CHANGED
@@ -9,6 +9,8 @@ export const STOREFRONT_PAGE_PATHS = {
9
9
  landing: "/lp/:slug",
10
10
  checkout: "/checkout",
11
11
  thankYou: "/thank-you",
12
+ /** Where a shopper follows an order by its number and phone; `?order=<number>` fills the number in. */
13
+ trackOrder: "/track-order",
12
14
  };
13
15
  /**
14
16
  * The public path of a page type, its parameter encoded:
@@ -65,6 +67,7 @@ const WIDGET_SURFACE = {
65
67
  landing: "landing",
66
68
  checkout: "checkout",
67
69
  thankYou: "thankYou",
70
+ trackOrder: "pages",
68
71
  };
69
72
  /**
70
73
  * @deprecated Use `STOREFRONT_PAGE_PATHS` (every page type, the home and the