@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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
|
+
}
|