@cartbase/storefront 0.17.0 → 0.18.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/LICENSE +21 -21
- package/package.json +258 -256
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/products.ts +43 -1
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +259 -259
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +277 -164
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/index.ts +71 -68
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/quantity.tsx +8 -14
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -0
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/checkout/use-checkout-orchestration.ts +1592 -1592
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +467 -467
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +466 -466
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/index.ts +68 -59
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-actions-wrapper.tsx +63 -62
- package/src/products/product-actions.tsx +151 -150
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/product-template.tsx +163 -162
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -0
- package/src/products/use-product-actions.ts +199 -164
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +73 -73
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
- package/theme/theme.css +150 -150
- package/theme/tokens.css +106 -106
package/src/api/reviews.ts
CHANGED
|
@@ -1,259 +1,259 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @cartbase/storefront/api/reviews — verified-purchase reviews (reviews card;
|
|
3
|
-
* shapes mirror alenika/src/lib/reviews-api.ts verbatim).
|
|
4
|
-
*
|
|
5
|
-
* Ground truth: src/app/api/store/reviews/** + src/lib/reviews/*.
|
|
6
|
-
*
|
|
7
|
-
* Model: reviews exist ONLY via a single-use, order-scoped, expiring TOKEN
|
|
8
|
-
* minted by the request scanner and mailed as `<review_link_base>/<token>`.
|
|
9
|
-
* The token IS the auth for every write — no login. All public reads serve
|
|
10
|
-
* `status='visible'` rows only; hidden/pending/deleted never leak.
|
|
11
|
-
*
|
|
12
|
-
* Two-step wizard: submit (consumes the token) → attach photo (reward).
|
|
13
|
-
* Resume rule: token consumed + review.reward_code null → resume at the
|
|
14
|
-
* photo step; reward_code set → fully done, show the code.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { StorefrontClient } from "./http"
|
|
18
|
-
import type { IsoDateString } from "./types"
|
|
19
|
-
|
|
20
|
-
/** One media entry on a review. */
|
|
21
|
-
export interface ReviewMedia {
|
|
22
|
-
type: "image" | "video"
|
|
23
|
-
/** Must live on the store's public file host (R2) — see createUploadUrl. */
|
|
24
|
-
url: string
|
|
25
|
-
thumb: string
|
|
26
|
-
w: number
|
|
27
|
-
h: number
|
|
28
|
-
bytes: number
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Public review — EXACTLY these keys (leak guard: no email/order/IP/status). */
|
|
32
|
-
export interface PublicReview {
|
|
33
|
-
id: string
|
|
34
|
-
customer_name: string
|
|
35
|
-
rating: number
|
|
36
|
-
/** Always null today — the form has no title field. */
|
|
37
|
-
title: string | null
|
|
38
|
-
body: string | null
|
|
39
|
-
/** Moderation-hidden entries are filtered out server-side. */
|
|
40
|
-
media: ReviewMedia[]
|
|
41
|
-
admin_response: string | null
|
|
42
|
-
admin_response_at: IsoDateString | null
|
|
43
|
-
created_at: IsoDateString
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface ListReviewsQuery {
|
|
47
|
-
product_id: string
|
|
48
|
-
/** default = with-media first then newest; rating uses `order`; date = newest. */
|
|
49
|
-
sort?: "default" | "rating" | "date"
|
|
50
|
-
order?: "asc" | "desc"
|
|
51
|
-
/** ≤ 50, default 10. */
|
|
52
|
-
limit?: number
|
|
53
|
-
offset?: number
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface ReviewListResponse {
|
|
57
|
-
reviews: PublicReview[]
|
|
58
|
-
count: number
|
|
59
|
-
has_more: boolean
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* GET /api/store/reviews — visible reviews for a product.
|
|
64
|
-
*
|
|
65
|
-
* Auth: anon (x-client-id).
|
|
66
|
-
* Errors: 400 validation_failed (missing product_id, bad sort/limit).
|
|
67
|
-
*/
|
|
68
|
-
export async function listReviews(
|
|
69
|
-
client: StorefrontClient,
|
|
70
|
-
query: ListReviewsQuery
|
|
71
|
-
): Promise<ReviewListResponse> {
|
|
72
|
-
return client.get("/api/store/reviews", { query: { ...query } })
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Star-rating stats. distribution keys are "1".."5". */
|
|
76
|
-
export interface ReviewAggregate {
|
|
77
|
-
product_id: string
|
|
78
|
-
count: number
|
|
79
|
-
/** Rounded to 1 decimal; 0 when no reviews. */
|
|
80
|
-
avg_rating: number
|
|
81
|
-
distribution: Record<"1" | "2" | "3" | "4" | "5", number>
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* GET /api/store/reviews/aggregate — the PDP star-badge stats. Visible
|
|
86
|
-
* reviews only. Edge-cached 60s (`Cache-Control: public, max-age=60`).
|
|
87
|
-
*
|
|
88
|
-
* Auth: anon (x-client-id).
|
|
89
|
-
* Errors: 400 validation_failed (missing product_id).
|
|
90
|
-
*/
|
|
91
|
-
export async function getAggregate(
|
|
92
|
-
client: StorefrontClient,
|
|
93
|
-
productId: string
|
|
94
|
-
): Promise<ReviewAggregate> {
|
|
95
|
-
return client.get("/api/store/reviews/aggregate", { query: { product_id: productId } })
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Widget payload — aggregate + first page + display options, one call. */
|
|
99
|
-
export interface ReviewWidgetPayload {
|
|
100
|
-
product_id: string
|
|
101
|
-
aggregate: ReviewAggregate
|
|
102
|
-
reviews: PublicReview[]
|
|
103
|
-
count: number
|
|
104
|
-
has_more: boolean
|
|
105
|
-
options: {
|
|
106
|
-
layout: "masonry" | "list"
|
|
107
|
-
page_size: number
|
|
108
|
-
photo_first: boolean
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* GET /api/store/reviews/widget — everything the product review widget +
|
|
114
|
-
* star badge need in one call (first page sized/sorted per the store's
|
|
115
|
-
* Settings → Reviews display options). Edge-cached 60s.
|
|
116
|
-
*
|
|
117
|
-
* Auth: anon (x-client-id).
|
|
118
|
-
* Errors: 400 validation_failed (missing product_id).
|
|
119
|
-
* Settings: widget_layout / widget_page_size / widget_photo_first.
|
|
120
|
-
*/
|
|
121
|
-
export async function getWidget(
|
|
122
|
-
client: StorefrontClient,
|
|
123
|
-
productId: string
|
|
124
|
-
): Promise<ReviewWidgetPayload> {
|
|
125
|
-
return client.get("/api/store/reviews/widget", { query: { product_id: productId } })
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Token validation — the review-form bootstrap state. */
|
|
129
|
-
export type TokenValidation =
|
|
130
|
-
| { valid: false; reason: "not_found" | "expired" | "invalid" }
|
|
131
|
-
| {
|
|
132
|
-
valid: true
|
|
133
|
-
already_submitted: boolean
|
|
134
|
-
/** Present when already_submitted and the review row exists. */
|
|
135
|
-
review: { id: string; reward_code: string | null } | null
|
|
136
|
-
product: {
|
|
137
|
-
id: string
|
|
138
|
-
handle: string
|
|
139
|
-
title: string
|
|
140
|
-
thumbnail: string | null
|
|
141
|
-
} | null
|
|
142
|
-
customer_name: string
|
|
143
|
-
expires_at: IsoDateString
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* GET /api/store/reviews/token/:token — validate a review token and load
|
|
148
|
-
* the form context (product card, greeting, resume state). ALWAYS 200 —
|
|
149
|
-
* invalid tokens come back as `{valid: false, reason}`. Never cached.
|
|
150
|
-
*
|
|
151
|
-
* Auth: anon (x-client-id); the token is the bearer secret.
|
|
152
|
-
*/
|
|
153
|
-
export async function validateToken(
|
|
154
|
-
client: StorefrontClient,
|
|
155
|
-
token: string
|
|
156
|
-
): Promise<TokenValidation> {
|
|
157
|
-
return client.get(`/api/store/reviews/token/${encodeURIComponent(token)}`)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export interface SubmitReviewInput {
|
|
161
|
-
token: string
|
|
162
|
-
/** Integer 1–5. */
|
|
163
|
-
rating: number
|
|
164
|
-
/** REQUIRED — a rating alone is not a review. HTML stripped; ≤ 2000 chars. */
|
|
165
|
-
body: string
|
|
166
|
-
/** ≤ 7 items: ≤ 6 images + ≤ 1 video; URLs must be on the file host. */
|
|
167
|
-
media?: ReviewMedia[]
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export interface SubmitReviewResponse {
|
|
171
|
-
id: string
|
|
172
|
-
success: true
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* POST /api/store/reviews — submit the review (wizard step 1). Consumes the
|
|
177
|
-
* token; idempotent on the (order, product) unique — a race returns the
|
|
178
|
-
* existing review. No title field.
|
|
179
|
-
*
|
|
180
|
-
* Auth: anon (x-client-id); the token is the auth.
|
|
181
|
-
* Errors: 400 invalid_data (rating/body/media rules; body empty after
|
|
182
|
-
* sanitize) · 404 not_found (unknown token) · 409 conflict (token already
|
|
183
|
-
* consumed — replay) · 410 gone (expired) · 429 rate_limited (3/h per IP).
|
|
184
|
-
* Settings: moderation_mode `hold` lands the review as pending (not
|
|
185
|
-
* publicly visible until approved).
|
|
186
|
-
*/
|
|
187
|
-
export async function submitReview(
|
|
188
|
-
client: StorefrontClient,
|
|
189
|
-
input: SubmitReviewInput
|
|
190
|
-
): Promise<SubmitReviewResponse> {
|
|
191
|
-
return client.post("/api/store/reviews", input)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
export interface AttachReviewPhotoInput {
|
|
195
|
-
/** The SAME review token — consumed is OK (two-step wizard). */
|
|
196
|
-
token: string
|
|
197
|
-
/** 1–7 items, ≤ 6 images + ≤ 1 video, file-host URLs only. */
|
|
198
|
-
media: ReviewMedia[]
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** `{code}` fresh mint · `{code, already_issued}` retry · `{code: null, message}` mint failed (media still saved). */
|
|
202
|
-
export type AttachReviewPhotoResponse =
|
|
203
|
-
| { code: string }
|
|
204
|
-
| { code: string; already_issued: true }
|
|
205
|
-
| { code: null; message: string }
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* POST /api/store/reviews/:id/photo — wizard step 2: attach media and mint
|
|
209
|
-
* the single-use reward code (a real promotion, percentage-off-order; 10%
|
|
210
|
-
* default). The reward is for the PHOTO, never the rating. Idempotent —
|
|
211
|
-
* retries return the same code; a promo failure never loses the media.
|
|
212
|
-
*
|
|
213
|
-
* Auth: anon (x-client-id); token must match the review's (order, product).
|
|
214
|
-
* Errors: 400 invalid_data (missing/invalid media) · 403 forbidden (token
|
|
215
|
-
* does not match this review) · 404 not_found (review or token unknown).
|
|
216
|
-
* Settings: reward_enabled / reward_percentage (Settings → Reviews).
|
|
217
|
-
*/
|
|
218
|
-
export async function attachReviewPhoto(
|
|
219
|
-
client: StorefrontClient,
|
|
220
|
-
reviewId: string,
|
|
221
|
-
input: AttachReviewPhotoInput
|
|
222
|
-
): Promise<AttachReviewPhotoResponse> {
|
|
223
|
-
return client.post(`/api/store/reviews/${encodeURIComponent(reviewId)}/photo`, input)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export interface CreateUploadUrlInput {
|
|
227
|
-
token: string
|
|
228
|
-
/** Original filename (informational). */
|
|
229
|
-
name: string
|
|
230
|
-
/** MIME: image/jpeg|jpg|png|webp or video/mp4|quicktime. */
|
|
231
|
-
type: string
|
|
232
|
-
/** Bytes — pre-checked against the per-type cap when provided. */
|
|
233
|
-
size?: number
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
export interface CreateUploadUrlResponse {
|
|
237
|
-
/** Signed R2 PUT URL — upload the raw file body here. */
|
|
238
|
-
uploadUrl: string
|
|
239
|
-
/** The public URL to reference in ReviewMedia.url after upload. */
|
|
240
|
-
publicUrl: string
|
|
241
|
-
key: string
|
|
242
|
-
filename: string
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* POST /api/store/reviews/upload-url — signed R2 upload for the photo step.
|
|
247
|
-
* A consumed token is accepted (step 1 consumed it); expiry still applies.
|
|
248
|
-
* Caps: image ≤ 8MB, video ≤ 50MB.
|
|
249
|
-
*
|
|
250
|
-
* Auth: anon (x-client-id); the token is the auth.
|
|
251
|
-
* Errors: 400 invalid_data (unsupported MIME, file too large) ·
|
|
252
|
-
* 404 not_found (unknown token) · 410 gone (expired token).
|
|
253
|
-
*/
|
|
254
|
-
export async function createUploadUrl(
|
|
255
|
-
client: StorefrontClient,
|
|
256
|
-
input: CreateUploadUrlInput
|
|
257
|
-
): Promise<CreateUploadUrlResponse> {
|
|
258
|
-
return client.post("/api/store/reviews/upload-url", input)
|
|
259
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/api/reviews — verified-purchase reviews (reviews card;
|
|
3
|
+
* shapes mirror alenika/src/lib/reviews-api.ts verbatim).
|
|
4
|
+
*
|
|
5
|
+
* Ground truth: src/app/api/store/reviews/** + src/lib/reviews/*.
|
|
6
|
+
*
|
|
7
|
+
* Model: reviews exist ONLY via a single-use, order-scoped, expiring TOKEN
|
|
8
|
+
* minted by the request scanner and mailed as `<review_link_base>/<token>`.
|
|
9
|
+
* The token IS the auth for every write — no login. All public reads serve
|
|
10
|
+
* `status='visible'` rows only; hidden/pending/deleted never leak.
|
|
11
|
+
*
|
|
12
|
+
* Two-step wizard: submit (consumes the token) → attach photo (reward).
|
|
13
|
+
* Resume rule: token consumed + review.reward_code null → resume at the
|
|
14
|
+
* photo step; reward_code set → fully done, show the code.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { StorefrontClient } from "./http"
|
|
18
|
+
import type { IsoDateString } from "./types"
|
|
19
|
+
|
|
20
|
+
/** One media entry on a review. */
|
|
21
|
+
export interface ReviewMedia {
|
|
22
|
+
type: "image" | "video"
|
|
23
|
+
/** Must live on the store's public file host (R2) — see createUploadUrl. */
|
|
24
|
+
url: string
|
|
25
|
+
thumb: string
|
|
26
|
+
w: number
|
|
27
|
+
h: number
|
|
28
|
+
bytes: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Public review — EXACTLY these keys (leak guard: no email/order/IP/status). */
|
|
32
|
+
export interface PublicReview {
|
|
33
|
+
id: string
|
|
34
|
+
customer_name: string
|
|
35
|
+
rating: number
|
|
36
|
+
/** Always null today — the form has no title field. */
|
|
37
|
+
title: string | null
|
|
38
|
+
body: string | null
|
|
39
|
+
/** Moderation-hidden entries are filtered out server-side. */
|
|
40
|
+
media: ReviewMedia[]
|
|
41
|
+
admin_response: string | null
|
|
42
|
+
admin_response_at: IsoDateString | null
|
|
43
|
+
created_at: IsoDateString
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ListReviewsQuery {
|
|
47
|
+
product_id: string
|
|
48
|
+
/** default = with-media first then newest; rating uses `order`; date = newest. */
|
|
49
|
+
sort?: "default" | "rating" | "date"
|
|
50
|
+
order?: "asc" | "desc"
|
|
51
|
+
/** ≤ 50, default 10. */
|
|
52
|
+
limit?: number
|
|
53
|
+
offset?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ReviewListResponse {
|
|
57
|
+
reviews: PublicReview[]
|
|
58
|
+
count: number
|
|
59
|
+
has_more: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* GET /api/store/reviews — visible reviews for a product.
|
|
64
|
+
*
|
|
65
|
+
* Auth: anon (x-client-id).
|
|
66
|
+
* Errors: 400 validation_failed (missing product_id, bad sort/limit).
|
|
67
|
+
*/
|
|
68
|
+
export async function listReviews(
|
|
69
|
+
client: StorefrontClient,
|
|
70
|
+
query: ListReviewsQuery
|
|
71
|
+
): Promise<ReviewListResponse> {
|
|
72
|
+
return client.get("/api/store/reviews", { query: { ...query } })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Star-rating stats. distribution keys are "1".."5". */
|
|
76
|
+
export interface ReviewAggregate {
|
|
77
|
+
product_id: string
|
|
78
|
+
count: number
|
|
79
|
+
/** Rounded to 1 decimal; 0 when no reviews. */
|
|
80
|
+
avg_rating: number
|
|
81
|
+
distribution: Record<"1" | "2" | "3" | "4" | "5", number>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* GET /api/store/reviews/aggregate — the PDP star-badge stats. Visible
|
|
86
|
+
* reviews only. Edge-cached 60s (`Cache-Control: public, max-age=60`).
|
|
87
|
+
*
|
|
88
|
+
* Auth: anon (x-client-id).
|
|
89
|
+
* Errors: 400 validation_failed (missing product_id).
|
|
90
|
+
*/
|
|
91
|
+
export async function getAggregate(
|
|
92
|
+
client: StorefrontClient,
|
|
93
|
+
productId: string
|
|
94
|
+
): Promise<ReviewAggregate> {
|
|
95
|
+
return client.get("/api/store/reviews/aggregate", { query: { product_id: productId } })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Widget payload — aggregate + first page + display options, one call. */
|
|
99
|
+
export interface ReviewWidgetPayload {
|
|
100
|
+
product_id: string
|
|
101
|
+
aggregate: ReviewAggregate
|
|
102
|
+
reviews: PublicReview[]
|
|
103
|
+
count: number
|
|
104
|
+
has_more: boolean
|
|
105
|
+
options: {
|
|
106
|
+
layout: "masonry" | "list"
|
|
107
|
+
page_size: number
|
|
108
|
+
photo_first: boolean
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* GET /api/store/reviews/widget — everything the product review widget +
|
|
114
|
+
* star badge need in one call (first page sized/sorted per the store's
|
|
115
|
+
* Settings → Reviews display options). Edge-cached 60s.
|
|
116
|
+
*
|
|
117
|
+
* Auth: anon (x-client-id).
|
|
118
|
+
* Errors: 400 validation_failed (missing product_id).
|
|
119
|
+
* Settings: widget_layout / widget_page_size / widget_photo_first.
|
|
120
|
+
*/
|
|
121
|
+
export async function getWidget(
|
|
122
|
+
client: StorefrontClient,
|
|
123
|
+
productId: string
|
|
124
|
+
): Promise<ReviewWidgetPayload> {
|
|
125
|
+
return client.get("/api/store/reviews/widget", { query: { product_id: productId } })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Token validation — the review-form bootstrap state. */
|
|
129
|
+
export type TokenValidation =
|
|
130
|
+
| { valid: false; reason: "not_found" | "expired" | "invalid" }
|
|
131
|
+
| {
|
|
132
|
+
valid: true
|
|
133
|
+
already_submitted: boolean
|
|
134
|
+
/** Present when already_submitted and the review row exists. */
|
|
135
|
+
review: { id: string; reward_code: string | null } | null
|
|
136
|
+
product: {
|
|
137
|
+
id: string
|
|
138
|
+
handle: string
|
|
139
|
+
title: string
|
|
140
|
+
thumbnail: string | null
|
|
141
|
+
} | null
|
|
142
|
+
customer_name: string
|
|
143
|
+
expires_at: IsoDateString
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* GET /api/store/reviews/token/:token — validate a review token and load
|
|
148
|
+
* the form context (product card, greeting, resume state). ALWAYS 200 —
|
|
149
|
+
* invalid tokens come back as `{valid: false, reason}`. Never cached.
|
|
150
|
+
*
|
|
151
|
+
* Auth: anon (x-client-id); the token is the bearer secret.
|
|
152
|
+
*/
|
|
153
|
+
export async function validateToken(
|
|
154
|
+
client: StorefrontClient,
|
|
155
|
+
token: string
|
|
156
|
+
): Promise<TokenValidation> {
|
|
157
|
+
return client.get(`/api/store/reviews/token/${encodeURIComponent(token)}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface SubmitReviewInput {
|
|
161
|
+
token: string
|
|
162
|
+
/** Integer 1–5. */
|
|
163
|
+
rating: number
|
|
164
|
+
/** REQUIRED — a rating alone is not a review. HTML stripped; ≤ 2000 chars. */
|
|
165
|
+
body: string
|
|
166
|
+
/** ≤ 7 items: ≤ 6 images + ≤ 1 video; URLs must be on the file host. */
|
|
167
|
+
media?: ReviewMedia[]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface SubmitReviewResponse {
|
|
171
|
+
id: string
|
|
172
|
+
success: true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* POST /api/store/reviews — submit the review (wizard step 1). Consumes the
|
|
177
|
+
* token; idempotent on the (order, product) unique — a race returns the
|
|
178
|
+
* existing review. No title field.
|
|
179
|
+
*
|
|
180
|
+
* Auth: anon (x-client-id); the token is the auth.
|
|
181
|
+
* Errors: 400 invalid_data (rating/body/media rules; body empty after
|
|
182
|
+
* sanitize) · 404 not_found (unknown token) · 409 conflict (token already
|
|
183
|
+
* consumed — replay) · 410 gone (expired) · 429 rate_limited (3/h per IP).
|
|
184
|
+
* Settings: moderation_mode `hold` lands the review as pending (not
|
|
185
|
+
* publicly visible until approved).
|
|
186
|
+
*/
|
|
187
|
+
export async function submitReview(
|
|
188
|
+
client: StorefrontClient,
|
|
189
|
+
input: SubmitReviewInput
|
|
190
|
+
): Promise<SubmitReviewResponse> {
|
|
191
|
+
return client.post("/api/store/reviews", input)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface AttachReviewPhotoInput {
|
|
195
|
+
/** The SAME review token — consumed is OK (two-step wizard). */
|
|
196
|
+
token: string
|
|
197
|
+
/** 1–7 items, ≤ 6 images + ≤ 1 video, file-host URLs only. */
|
|
198
|
+
media: ReviewMedia[]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `{code}` fresh mint · `{code, already_issued}` retry · `{code: null, message}` mint failed (media still saved). */
|
|
202
|
+
export type AttachReviewPhotoResponse =
|
|
203
|
+
| { code: string }
|
|
204
|
+
| { code: string; already_issued: true }
|
|
205
|
+
| { code: null; message: string }
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* POST /api/store/reviews/:id/photo — wizard step 2: attach media and mint
|
|
209
|
+
* the single-use reward code (a real promotion, percentage-off-order; 10%
|
|
210
|
+
* default). The reward is for the PHOTO, never the rating. Idempotent —
|
|
211
|
+
* retries return the same code; a promo failure never loses the media.
|
|
212
|
+
*
|
|
213
|
+
* Auth: anon (x-client-id); token must match the review's (order, product).
|
|
214
|
+
* Errors: 400 invalid_data (missing/invalid media) · 403 forbidden (token
|
|
215
|
+
* does not match this review) · 404 not_found (review or token unknown).
|
|
216
|
+
* Settings: reward_enabled / reward_percentage (Settings → Reviews).
|
|
217
|
+
*/
|
|
218
|
+
export async function attachReviewPhoto(
|
|
219
|
+
client: StorefrontClient,
|
|
220
|
+
reviewId: string,
|
|
221
|
+
input: AttachReviewPhotoInput
|
|
222
|
+
): Promise<AttachReviewPhotoResponse> {
|
|
223
|
+
return client.post(`/api/store/reviews/${encodeURIComponent(reviewId)}/photo`, input)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface CreateUploadUrlInput {
|
|
227
|
+
token: string
|
|
228
|
+
/** Original filename (informational). */
|
|
229
|
+
name: string
|
|
230
|
+
/** MIME: image/jpeg|jpg|png|webp or video/mp4|quicktime. */
|
|
231
|
+
type: string
|
|
232
|
+
/** Bytes — pre-checked against the per-type cap when provided. */
|
|
233
|
+
size?: number
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface CreateUploadUrlResponse {
|
|
237
|
+
/** Signed R2 PUT URL — upload the raw file body here. */
|
|
238
|
+
uploadUrl: string
|
|
239
|
+
/** The public URL to reference in ReviewMedia.url after upload. */
|
|
240
|
+
publicUrl: string
|
|
241
|
+
key: string
|
|
242
|
+
filename: string
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* POST /api/store/reviews/upload-url — signed R2 upload for the photo step.
|
|
247
|
+
* A consumed token is accepted (step 1 consumed it); expiry still applies.
|
|
248
|
+
* Caps: image ≤ 8MB, video ≤ 50MB.
|
|
249
|
+
*
|
|
250
|
+
* Auth: anon (x-client-id); the token is the auth.
|
|
251
|
+
* Errors: 400 invalid_data (unsupported MIME, file too large) ·
|
|
252
|
+
* 404 not_found (unknown token) · 410 gone (expired token).
|
|
253
|
+
*/
|
|
254
|
+
export async function createUploadUrl(
|
|
255
|
+
client: StorefrontClient,
|
|
256
|
+
input: CreateUploadUrlInput
|
|
257
|
+
): Promise<CreateUploadUrlResponse> {
|
|
258
|
+
return client.post("/api/store/reviews/upload-url", input)
|
|
259
|
+
}
|