@cartbase/storefront 0.1.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 (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,560 @@
1
+ "use client"
2
+
3
+ import { useCallback, useState } from "react"
4
+ import Link from "next/link"
5
+ import { Check, Copy, Gift, Star, X } from "lucide-react"
6
+ import type { StorefrontClient } from "../api/http"
7
+ import {
8
+ attachReviewPhoto,
9
+ submitReview,
10
+ type TokenValidation,
11
+ } from "../api/reviews"
12
+ import { StoreApiError } from "../api/types"
13
+ import { cn } from "../lib/utils"
14
+ import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
15
+ import { MAX_BODY, firstNameOf, formatLabel } from "./helpers"
16
+ import {
17
+ canSubmitRating,
18
+ resolveWizardEntry,
19
+ submitErrorKeyFor,
20
+ type WizardStep,
21
+ } from "./wizard-state"
22
+ import {
23
+ ReviewPhotoUpload,
24
+ readyMedia,
25
+ type LocalMedia,
26
+ } from "./photo-upload"
27
+
28
+ /**
29
+ * The review token wizard — everything behind `<review_link_base>/<token>`.
30
+ * Ported from the Alenika production storefront: the /review/[token]
31
+ * server page's branching (page.tsx) AND the two-step client form
32
+ * (review-form.tsx), merged into one component over the barter SDK:
33
+ *
34
+ * entry: `resolveWizardEntry(validation)` — invalid/expired panels,
35
+ * the terminal "already submitted" panel, or the form at the
36
+ * right step. THE RESUME RULE: a consumed token with a review
37
+ * row and `reward_code` null resumes at the photo step;
38
+ * `reward_code` set opens at done, showing the code.
39
+ * rate: stars + body → `api/reviews.submitReview` (consumes the
40
+ * token; the rating is LOCKED IN even if the customer bails
41
+ * on step 2). Errors map via status: 429/409/410 → the
42
+ * labels' error copy (`submitErrorKeyFor`).
43
+ * photo: optional media upsell (`<ReviewPhotoUpload>`; signed R2
44
+ * PUTs) → `api/reviews.attachReviewPhoto` mints the
45
+ * single-use reward code. Skippable — skipping keeps the
46
+ * rating, just no code. `code: null` in a 200 = media saved
47
+ * but the mint failed → the "write to us" note, NEVER an
48
+ * error state (production rule).
49
+ * done: thank-you + the code with one-tap copy (select-all backup
50
+ * for blocked clipboards).
51
+ *
52
+ * The reward is for ATTACHING A PHOTO, never for a positive rating.
53
+ *
54
+ * VALIDATE SERVER-SIDE: fetch `api/reviews.validateToken(client, token)`
55
+ * in the page server component and pass `validation` — rendering the
56
+ * form before discovering the token is dead would flash a usable form
57
+ * (production UX rule). The page should also be noindex/nofollow (private
58
+ * per-customer URL).
59
+ *
60
+ * Settings: reward_enabled / reward_percentage (admin → Settings →
61
+ * Reviews) — the store surface does not expose the percentage, so the
62
+ * app passes `rewardPct` (default 10, the platform default); moderation
63
+ * `hold` changes nothing here (the thanks copy stays true — the review
64
+ * is received; visibility is the store's call).
65
+ */
66
+ export interface ReviewWizardProps {
67
+ client: StorefrontClient
68
+ token: string
69
+ /** Server-fetched `validateToken` result. */
70
+ validation: TokenValidation
71
+ /** Reward percentage shown in the copy (Settings → Reviews). */
72
+ rewardPct?: number
73
+ /** Store support email for the failure copy (labels `{email}`). */
74
+ supportEmail?: string
75
+ /** Base for the "view product" CTA; joined with the product handle. */
76
+ productHrefBase?: string
77
+ /** The "back to the store" CTA target. */
78
+ storeHref?: string
79
+ labels?: Partial<ReviewsUiLabels>
80
+ }
81
+
82
+ export function ReviewWizard({
83
+ client,
84
+ token,
85
+ validation,
86
+ rewardPct = 10,
87
+ supportEmail = "",
88
+ productHrefBase = "/products",
89
+ storeHref = "/",
90
+ labels,
91
+ }: ReviewWizardProps) {
92
+ const l = { ...defaultReviewsUiLabels, ...labels }
93
+ const entry = resolveWizardEntry(validation)
94
+
95
+ // ── Invalid / expired ────────────────────────────────────────────────
96
+ if (entry.kind === "invalid") {
97
+ const expired = entry.reason === "expired"
98
+ return (
99
+ <StatePanel
100
+ heading={expired ? l.expiredTitle : l.invalidTitle}
101
+ sub={formatLabel(expired ? l.expiredBody : l.invalidBody, {
102
+ email: supportEmail,
103
+ })}
104
+ >
105
+ <Link
106
+ href={storeHref}
107
+ className="inline-flex items-center justify-center px-5 py-2.5 rounded-[2px] bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
108
+ >
109
+ {l.toStore}
110
+ </Link>
111
+ </StatePanel>
112
+ )
113
+ }
114
+
115
+ // (valid:true branches below — narrow once for product/name access)
116
+ const v = validation as Extract<TokenValidation, { valid: true }>
117
+ const product = v.product
118
+
119
+ // ── Consumed token, no review row — terminal thanks ──────────────────
120
+ if (entry.kind === "already-submitted") {
121
+ return (
122
+ <StatePanel
123
+ heading={l.alreadyTitle}
124
+ sub={
125
+ product
126
+ ? formatLabel(l.alreadyBodyWithProduct, { name: product.title })
127
+ : l.alreadyBody
128
+ }
129
+ >
130
+ <div className="flex flex-col sm:flex-row gap-3 justify-center">
131
+ {product?.handle && (
132
+ <Link
133
+ href={`${productHrefBase}/${product.handle}`}
134
+ className="inline-flex items-center justify-center px-5 py-2.5 rounded-[2px] bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
135
+ >
136
+ {l.viewProduct}
137
+ </Link>
138
+ )}
139
+ <Link
140
+ href={storeHref}
141
+ className="inline-flex items-center justify-center px-5 py-2.5 rounded-[2px] border border-border text-sm font-medium hover:bg-muted transition-colors"
142
+ >
143
+ {l.toStore}
144
+ </Link>
145
+ </div>
146
+ </StatePanel>
147
+ )
148
+ }
149
+
150
+ return (
151
+ <section className="max-w-2xl mx-auto px-4 sm:px-6 py-10 sm:py-16">
152
+ {entry.step !== "done" && (
153
+ <header className="mb-8">
154
+ <p className="text-xs uppercase tracking-wider text-muted-foreground mb-3">
155
+ {l.greeting}
156
+ {v.customer_name ? `, ${firstNameOf(v.customer_name)}` : ""}
157
+ </p>
158
+ <h1 className="text-2xl sm:text-3xl font-semibold text-foreground leading-tight">
159
+ {entry.step === "photo" ? l.headingPhoto : l.headingRate}
160
+ </h1>
161
+ <p className="text-sm text-muted-foreground mt-2 leading-relaxed">
162
+ {entry.step === "photo"
163
+ ? formatLabel(l.introPhoto, { pct: rewardPct })
164
+ : l.introRate}
165
+ </p>
166
+ </header>
167
+ )}
168
+
169
+ {product && entry.step !== "done" && (
170
+ <div className="flex items-center gap-4 p-3 rounded-[2px] border border-border bg-muted/30 mb-8">
171
+ {product.thumbnail ? (
172
+ // eslint-disable-next-line @next/next/no-img-element
173
+ <img
174
+ src={product.thumbnail}
175
+ alt=""
176
+ width={64}
177
+ height={64}
178
+ className="w-16 h-16 object-cover rounded-[2px] bg-muted"
179
+ />
180
+ ) : (
181
+ <div className="w-16 h-16 rounded-[2px] bg-muted" />
182
+ )}
183
+ <div className="min-w-0 flex-1">
184
+ <p className="text-xs uppercase tracking-wider text-muted-foreground">
185
+ {l.productLabel}
186
+ </p>
187
+ <p className="text-sm font-medium truncate text-foreground">
188
+ {product.title}
189
+ </p>
190
+ </div>
191
+ </div>
192
+ )}
193
+
194
+ <ReviewWizardForm
195
+ client={client}
196
+ token={token}
197
+ productHandle={product?.handle ?? null}
198
+ productHrefBase={productHrefBase}
199
+ storeHref={storeHref}
200
+ rewardPct={rewardPct}
201
+ supportEmail={supportEmail}
202
+ initialStep={entry.step}
203
+ initialReviewId={entry.reviewId}
204
+ initialRewardCode={entry.rewardCode}
205
+ labels={l}
206
+ />
207
+ </section>
208
+ )
209
+ }
210
+
211
+ function StatePanel({
212
+ heading,
213
+ sub,
214
+ children,
215
+ }: {
216
+ heading: string
217
+ sub: string
218
+ children: React.ReactNode
219
+ }) {
220
+ return (
221
+ <section className="max-w-md mx-auto px-4 sm:px-6 py-16 sm:py-24 text-center">
222
+ <h1 className="text-2xl sm:text-3xl font-semibold text-foreground leading-tight">
223
+ {heading}
224
+ </h1>
225
+ <p className="text-sm text-muted-foreground mt-3 leading-relaxed">{sub}</p>
226
+ <div className="mt-8">{children}</div>
227
+ </section>
228
+ )
229
+ }
230
+
231
+ /** The two-step form (rate → photo → done). Port of review-form.tsx. */
232
+ export function ReviewWizardForm({
233
+ client,
234
+ token,
235
+ productHandle,
236
+ productHrefBase = "/products",
237
+ storeHref = "/",
238
+ rewardPct = 10,
239
+ supportEmail = "",
240
+ initialStep = "rate",
241
+ initialReviewId = null,
242
+ initialRewardCode = null,
243
+ labels,
244
+ }: {
245
+ client: StorefrontClient
246
+ token: string
247
+ productHandle: string | null
248
+ productHrefBase?: string
249
+ storeHref?: string
250
+ rewardPct?: number
251
+ supportEmail?: string
252
+ initialStep?: WizardStep
253
+ initialReviewId?: string | null
254
+ initialRewardCode?: string | null
255
+ labels?: Partial<ReviewsUiLabels>
256
+ }) {
257
+ const l = { ...defaultReviewsUiLabels, ...labels }
258
+ const [step, setStep] = useState<WizardStep>(initialStep)
259
+ const [rating, setRating] = useState<number>(0)
260
+ const [hoverRating, setHoverRating] = useState<number>(0)
261
+ const [body, setBody] = useState("")
262
+ const [media, setMedia] = useState<LocalMedia[]>([])
263
+ const [submitting, setSubmitting] = useState(false)
264
+ const [error, setError] = useState<string | null>(null)
265
+ const [reviewId, setReviewId] = useState<string | null>(initialReviewId)
266
+ const [rewardCode, setRewardCode] = useState<string | null>(initialRewardCode)
267
+ const [copied, setCopied] = useState(false)
268
+ // Photo saved but the reward code couldn't be minted (rare). Distinguishes
269
+ // "no code because they skipped" from "no code because generation failed".
270
+ const [codeUnavailable, setCodeUnavailable] = useState(false)
271
+
272
+ // One-tap copy on the thank-you step. The code is also select-all, so a
273
+ // blocked clipboard (rare, permissions) still leaves manual copy working.
274
+ const copyCode = useCallback(async () => {
275
+ if (!rewardCode) return
276
+ try {
277
+ await navigator.clipboard.writeText(rewardCode)
278
+ setCopied(true)
279
+ setTimeout(() => setCopied(false), 2000)
280
+ } catch {
281
+ /* clipboard blocked — select-all fallback covers it */
282
+ }
283
+ }, [rewardCode])
284
+
285
+ const uploading = media.some((m) => m._uploading)
286
+ const hasReadyMedia = media.some((m) => !m._uploading && !m._failed)
287
+
288
+ // ── Step 1: submit the rating (locks it in) ──────────────────────────
289
+ const submitRating = async () => {
290
+ if (!canSubmitRating(rating, body) || submitting) return
291
+ setSubmitting(true)
292
+ setError(null)
293
+ try {
294
+ const result = await submitReview(client, {
295
+ token,
296
+ rating,
297
+ body: body.trim(),
298
+ })
299
+ setReviewId(result.id)
300
+ setStep("photo")
301
+ } catch (err) {
302
+ const status = err instanceof StoreApiError ? err.status : 0
303
+ setError(l[submitErrorKeyFor(status)])
304
+ } finally {
305
+ setSubmitting(false)
306
+ }
307
+ }
308
+
309
+ // ── Step 2: attach the photo(s) + claim the reward ───────────────────
310
+ const submitPhoto = async () => {
311
+ if (!reviewId || submitting || uploading) return
312
+ const ready = readyMedia(media)
313
+ if (ready.length === 0) return
314
+
315
+ setSubmitting(true)
316
+ setError(null)
317
+ try {
318
+ const result = await attachReviewPhoto(client, reviewId, {
319
+ token,
320
+ media: ready,
321
+ })
322
+ setRewardCode(result.code)
323
+ setCodeUnavailable(result.code === null)
324
+ setStep("done")
325
+ } catch {
326
+ // 400 invalid_data / 403 forbidden / 404 not_found — one user-facing
327
+ // copy: the photo could not be saved (the media itself is retryable).
328
+ setError(l.errPhotoSave)
329
+ } finally {
330
+ setSubmitting(false)
331
+ }
332
+ }
333
+
334
+ const skipPhoto = () => setStep("done")
335
+
336
+ // ── Step: done ───────────────────────────────────────────────────────
337
+ if (step === "done") {
338
+ return (
339
+ <div className="rounded-[2px] border border-border bg-muted/30 p-6 text-center">
340
+ <p className="text-lg font-medium text-foreground">{l.thanksTitle}</p>
341
+ <p className="text-sm text-muted-foreground mt-2">{l.thanksBody}</p>
342
+
343
+ {rewardCode && (
344
+ <div className="mt-6 rounded-[2px] border border-border bg-background p-5">
345
+ <div className="inline-flex items-center gap-2 text-sm font-medium text-foreground">
346
+ <Gift className="w-4 h-4" />
347
+ {formatLabel(l.rewardTitle, { pct: rewardPct })}
348
+ </div>
349
+ <div className="mt-3 flex items-center justify-center gap-2">
350
+ <span className="select-all font-mono text-lg font-semibold tracking-wider text-foreground">
351
+ {rewardCode}
352
+ </span>
353
+ <button
354
+ type="button"
355
+ onClick={copyCode}
356
+ aria-label={l.copyAria}
357
+ className="inline-flex items-center gap-1.5 rounded-[2px] border border-border px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-muted transition-colors"
358
+ >
359
+ {copied ? (
360
+ <>
361
+ <Check className="w-3.5 h-3.5" />
362
+ {l.copied}
363
+ </>
364
+ ) : (
365
+ <>
366
+ <Copy className="w-3.5 h-3.5" />
367
+ {l.copy}
368
+ </>
369
+ )}
370
+ </button>
371
+ </div>
372
+ <p className="text-xs text-muted-foreground mt-2">{l.rewardNote}</p>
373
+ </div>
374
+ )}
375
+
376
+ {!rewardCode && codeUnavailable && (
377
+ <p className="mt-6 text-sm text-muted-foreground">
378
+ {formatLabel(l.codeUnavailable, { email: supportEmail })}
379
+ </p>
380
+ )}
381
+
382
+ <div className="mt-6 flex flex-col sm:flex-row gap-3 justify-center">
383
+ {productHandle && (
384
+ <Link
385
+ href={`${productHrefBase}/${productHandle}`}
386
+ className="inline-flex items-center justify-center px-5 py-2.5 rounded-[2px] bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
387
+ >
388
+ {l.viewProduct}
389
+ </Link>
390
+ )}
391
+ <Link
392
+ href={storeHref}
393
+ className="inline-flex items-center justify-center px-5 py-2.5 rounded-[2px] border border-border text-sm font-medium hover:bg-muted transition-colors"
394
+ >
395
+ {l.toStore}
396
+ </Link>
397
+ </div>
398
+ </div>
399
+ )
400
+ }
401
+
402
+ // ── Step: photo upsell ───────────────────────────────────────────────
403
+ if (step === "photo") {
404
+ return (
405
+ <div className="relative rounded-[2px] border border-border bg-background p-5 sm:p-6">
406
+ <button
407
+ type="button"
408
+ onClick={skipPhoto}
409
+ aria-label={l.skipAria}
410
+ className="absolute top-3 right-3 w-9 h-9 rounded-full hover:bg-muted text-muted-foreground flex items-center justify-center"
411
+ >
412
+ <X className="w-5 h-5" />
413
+ </button>
414
+
415
+ <div className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-bold uppercase tracking-wider text-primary">
416
+ <Gift className="w-3.5 h-3.5" />
417
+ {l.bonusBadge}
418
+ </div>
419
+ <h2 className="text-xl sm:text-2xl font-semibold text-foreground mt-3 leading-tight">
420
+ {formatLabel(l.photoTitle, { pct: rewardPct })}
421
+ </h2>
422
+ <p className="text-sm text-muted-foreground mt-2 leading-relaxed">
423
+ {formatLabel(l.photoSubtitle, { pct: rewardPct })}
424
+ </p>
425
+
426
+ <ReviewPhotoUpload
427
+ client={client}
428
+ token={token}
429
+ media={media}
430
+ onChange={setMedia}
431
+ onError={setError}
432
+ labels={l}
433
+ />
434
+
435
+ {error && (
436
+ <div className="mt-4 rounded-[2px] border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
437
+ {error}
438
+ </div>
439
+ )}
440
+
441
+ <div className="mt-6 flex items-center justify-between gap-4">
442
+ <button
443
+ type="button"
444
+ onClick={skipPhoto}
445
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
446
+ >
447
+ {l.skip}
448
+ </button>
449
+ <button
450
+ type="button"
451
+ onClick={submitPhoto}
452
+ disabled={!hasReadyMedia || submitting || uploading}
453
+ className={cn(
454
+ "inline-flex items-center justify-center px-6 py-3 rounded-[2px] bg-primary text-primary-foreground text-sm font-medium transition-opacity flex-shrink-0",
455
+ !hasReadyMedia || submitting || uploading
456
+ ? "opacity-50 cursor-not-allowed"
457
+ : "hover:opacity-90"
458
+ )}
459
+ >
460
+ {submitting
461
+ ? l.submitting
462
+ : uploading
463
+ ? l.waitingUpload
464
+ : formatLabel(l.submitPhoto, { pct: rewardPct })}
465
+ </button>
466
+ </div>
467
+ </div>
468
+ )
469
+ }
470
+
471
+ // ── Step: rate (default) ─────────────────────────────────────────────
472
+ return (
473
+ <div className="space-y-6">
474
+ {/* Star picker */}
475
+ <div>
476
+ <label className="block text-xs uppercase tracking-wider text-muted-foreground mb-2">
477
+ {l.ratingLabel}
478
+ </label>
479
+ <div
480
+ className="flex items-center gap-1.5"
481
+ onMouseLeave={() => setHoverRating(0)}
482
+ >
483
+ {[1, 2, 3, 4, 5].map((v) => {
484
+ const filled = v <= (hoverRating || rating)
485
+ return (
486
+ <button
487
+ key={v}
488
+ type="button"
489
+ onMouseEnter={() => setHoverRating(v)}
490
+ onClick={() => setRating(v)}
491
+ aria-label={formatLabel(l.ratingAria, { n: v })}
492
+ className="p-1 transition-transform hover:scale-110"
493
+ >
494
+ <Star
495
+ className={cn(
496
+ "w-8 h-8",
497
+ filled
498
+ ? "fill-warning text-warning"
499
+ : "fill-muted text-muted-foreground"
500
+ )}
501
+ strokeWidth={0}
502
+ />
503
+ </button>
504
+ )
505
+ })}
506
+ {rating > 0 && (
507
+ <span className="ml-3 text-sm text-muted-foreground">
508
+ {formatLabel(l.ratingOutOf, { n: rating })}
509
+ </span>
510
+ )}
511
+ </div>
512
+ </div>
513
+
514
+ {/* Body */}
515
+ <div>
516
+ <label
517
+ htmlFor="review-body"
518
+ className="block text-xs uppercase tracking-wider text-muted-foreground mb-2"
519
+ >
520
+ {l.bodyLabel}
521
+ </label>
522
+ <textarea
523
+ id="review-body"
524
+ value={body}
525
+ onChange={(e) => setBody(e.target.value.slice(0, MAX_BODY))}
526
+ maxLength={MAX_BODY}
527
+ rows={5}
528
+ placeholder={l.bodyPlaceholder}
529
+ className="w-full px-4 py-2.5 rounded-[2px] border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 resize-y"
530
+ />
531
+ <div className="text-xs text-muted-foreground mt-1 text-right">
532
+ {body.length} / {MAX_BODY}
533
+ </div>
534
+ </div>
535
+
536
+ {error && (
537
+ <div className="rounded-[2px] border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
538
+ {error}
539
+ </div>
540
+ )}
541
+
542
+ <div className="flex items-center justify-between gap-4">
543
+ <p className="text-xs text-muted-foreground">{l.consentNote}</p>
544
+ <button
545
+ type="button"
546
+ onClick={submitRating}
547
+ disabled={!canSubmitRating(rating, body) || submitting}
548
+ className={cn(
549
+ "inline-flex items-center justify-center px-6 py-3 rounded-[2px] bg-primary text-primary-foreground text-sm font-medium transition-opacity flex-shrink-0",
550
+ !canSubmitRating(rating, body) || submitting
551
+ ? "opacity-50 cursor-not-allowed"
552
+ : "hover:opacity-90"
553
+ )}
554
+ >
555
+ {submitting ? l.submitting : l.continue}
556
+ </button>
557
+ </div>
558
+ </div>
559
+ )
560
+ }
@@ -0,0 +1,104 @@
1
+ import { Star } from "lucide-react"
2
+ import { cn } from "../lib/utils"
3
+ import type { ReviewAggregate } from "../api/reviews"
4
+ import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
5
+ import { reviewCountLabel } from "./helpers"
6
+
7
+ /**
8
+ * Star primitives + the PDP aggregate badge. Ported from the Alenika
9
+ * reviews section header (src/components/product/reviews-list.tsx —
10
+ * `StarRow` + the overall-score block). Pure render, server-safe: data
11
+ * arrives via props (`api/reviews.getAggregate` / `getWidget().aggregate`).
12
+ */
13
+
14
+ /** Five-star row, filled to `Math.round(rating)`. */
15
+ export function StarRow({
16
+ rating,
17
+ className,
18
+ }: {
19
+ rating: number
20
+ className?: string
21
+ }) {
22
+ return (
23
+ <span className={cn("inline-flex items-center gap-0.5", className)}>
24
+ {Array.from({ length: 5 }).map((_, i) => (
25
+ <Star
26
+ key={i}
27
+ className={cn(
28
+ "w-3.5 h-3.5",
29
+ i < Math.round(rating)
30
+ ? "fill-warning text-warning"
31
+ : "fill-muted text-muted-foreground"
32
+ )}
33
+ strokeWidth={0}
34
+ />
35
+ ))}
36
+ </span>
37
+ )
38
+ }
39
+
40
+ export interface StarBadgeProps {
41
+ aggregate: Pick<ReviewAggregate, "count" | "avg_rating">
42
+ labels?: Pick<ReviewsUiLabels, "reviewSingular" | "reviewPlural">
43
+ className?: string
44
+ }
45
+
46
+ /**
47
+ * The big score badge: filled star + avg (1 decimal) + "{n} reviews".
48
+ * Renders nothing when there are no reviews (matching the production
49
+ * "hide the section entirely" rule).
50
+ */
51
+ export function StarBadge({ aggregate, labels, className }: StarBadgeProps) {
52
+ const l = { ...defaultReviewsUiLabels, ...labels }
53
+ if (!aggregate.count) return null
54
+ return (
55
+ <div className={cn("flex items-center gap-3 shrink-0", className)}>
56
+ <Star className="w-10 h-10 fill-warning text-warning" strokeWidth={0} />
57
+ <div>
58
+ <div className="text-3xl font-bold text-foreground leading-none">
59
+ {aggregate.avg_rating.toFixed(1)}
60
+ </div>
61
+ <div className="text-sm text-muted-foreground mt-1.5">
62
+ {reviewCountLabel(aggregate.count, l)}
63
+ </div>
64
+ </div>
65
+ </div>
66
+ )
67
+ }
68
+
69
+ export interface RatingDistributionProps {
70
+ distribution: ReviewAggregate["distribution"]
71
+ /** Total review count (bar percentages). */
72
+ total: number
73
+ className?: string
74
+ }
75
+
76
+ /** The 5→1 distribution bars block from the section header. */
77
+ export function RatingDistribution({
78
+ distribution,
79
+ total,
80
+ className,
81
+ }: RatingDistributionProps) {
82
+ return (
83
+ <div className={cn("w-full md:max-w-sm space-y-1.5", className)}>
84
+ {([5, 4, 3, 2, 1] as const).map((s) => {
85
+ const c = distribution[String(s) as "1" | "2" | "3" | "4" | "5"] ?? 0
86
+ const pct = total > 0 ? Math.round((c / total) * 100) : 0
87
+ return (
88
+ <div key={s} className="flex items-center gap-3">
89
+ <StarRow rating={s} className="shrink-0" />
90
+ <div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
91
+ <div
92
+ className="h-full bg-foreground/80"
93
+ style={{ width: `${pct}%` }}
94
+ />
95
+ </div>
96
+ <span className="w-9 text-right text-xs text-muted-foreground tabular-nums">
97
+ ({c})
98
+ </span>
99
+ </div>
100
+ )
101
+ })}
102
+ </div>
103
+ )
104
+ }