@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.
Files changed (107) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -256
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/products.ts +43 -1
  18. package/src/api/redirects.ts +37 -37
  19. package/src/api/regions.ts +200 -200
  20. package/src/api/reviews.ts +259 -259
  21. package/src/api/search.ts +163 -163
  22. package/src/api/store.ts +35 -35
  23. package/src/api/types.ts +91 -91
  24. package/src/cart-drawer/context.tsx +277 -164
  25. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  26. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  27. package/src/cart-drawer/gift-wrap.tsx +82 -82
  28. package/src/cart-drawer/index.ts +71 -68
  29. package/src/cart-drawer/item/index.tsx +162 -162
  30. package/src/cart-drawer/item/quantity.tsx +8 -14
  31. package/src/cart-drawer/item/upsell.tsx +110 -110
  32. package/src/cart-drawer/labels.ts +123 -123
  33. package/src/cart-drawer/mutation-queue.ts +78 -0
  34. package/src/cart-drawer/notes.tsx +131 -131
  35. package/src/cart-drawer/sticky-footer.tsx +73 -73
  36. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  37. package/src/checkout/address-error-copy.ts +117 -117
  38. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  39. package/src/checkout/compare-addresses.ts +40 -40
  40. package/src/checkout/discount-section.tsx +218 -218
  41. package/src/checkout/error-copy-codes.ts +63 -63
  42. package/src/checkout/geocode.ts +154 -154
  43. package/src/checkout/gift-card-section.tsx +224 -224
  44. package/src/checkout/index.ts +72 -72
  45. package/src/checkout/labels.ts +495 -495
  46. package/src/checkout/payment-button.tsx +372 -372
  47. package/src/checkout/payment-error-copy.ts +154 -154
  48. package/src/checkout/promotion-error-copy.ts +91 -91
  49. package/src/checkout/use-checkout-orchestration.ts +1592 -1592
  50. package/src/common/country-flag.tsx +52 -52
  51. package/src/common/country-select.tsx +11 -11
  52. package/src/common/index.ts +20 -20
  53. package/src/common/market-select.tsx +57 -57
  54. package/src/index.ts +12 -12
  55. package/src/lib/country-name.ts +59 -59
  56. package/src/lib/get-product-price.ts +133 -133
  57. package/src/lib/media-image.tsx +39 -39
  58. package/src/lib/payment-constants.ts +53 -53
  59. package/src/lib/platform.ts +13 -13
  60. package/src/lib/price.tsx +39 -39
  61. package/src/lib/store-api-error.ts +36 -36
  62. package/src/lib/variant-caption.ts +32 -32
  63. package/src/locales/bg.ts +467 -467
  64. package/src/locales/context.ts +37 -37
  65. package/src/locales/en.ts +26 -26
  66. package/src/locales/es.ts +466 -466
  67. package/src/locales/index.ts +19 -19
  68. package/src/locales/provider.tsx +59 -59
  69. package/src/locales/types.ts +77 -77
  70. package/src/order/index.ts +62 -62
  71. package/src/order/labels.ts +79 -79
  72. package/src/order/order-totals.tsx +250 -250
  73. package/src/primitives/select-field.tsx +93 -93
  74. package/src/products/image-gallery.tsx +43 -43
  75. package/src/products/index.ts +68 -59
  76. package/src/products/option-select.tsx +67 -67
  77. package/src/products/product-actions-wrapper.tsx +63 -62
  78. package/src/products/product-actions.tsx +151 -150
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/product-template.tsx +163 -162
  84. package/src/products/purchase-options.tsx +130 -130
  85. package/src/products/sets.ts +110 -0
  86. package/src/products/use-product-actions.ts +199 -164
  87. package/src/products/variant-matching.ts +71 -71
  88. package/src/products/variant-url.ts +74 -74
  89. package/src/reviews-ui/index.ts +73 -73
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. package/theme/index.css +25 -25
  106. package/theme/theme.css +150 -150
  107. package/theme/tokens.css +106 -106
@@ -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
+ }