@cartbase/storefront 0.18.1 → 0.19.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 +1 -1
- package/src/common/cart-button-client.tsx +7 -3
- package/src/lib/cookie-names.ts +37 -3
- package/src/locales/bg.ts +7 -0
- package/src/locales/es.ts +7 -0
- package/src/products/product-actions-wrapper.tsx +10 -3
- package/src/products/product-template.tsx +9 -5
- package/src/products/use-product-actions.ts +9 -6
- package/src/reviews-ui/index.ts +8 -4
- package/src/reviews-ui/labels.ts +19 -0
- package/src/reviews-ui/lightbox-state.ts +46 -0
- package/src/reviews-ui/review-lightbox.tsx +271 -0
- package/src/reviews-ui/review-list.tsx +191 -251
- package/src/reviews-ui/star-badge.tsx +15 -5
- package/src/store/pagination.tsx +4 -3
- package/src/store/sort-select.tsx +8 -10
- package/theme/theme.css +1 -0
- package/theme/tokens.css +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartbase/storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -18,9 +18,13 @@ import { useCartDrawer } from "../cart-drawer/context"
|
|
|
18
18
|
* NOTE: depends on the sibling `cart-drawer` family's context (ported in
|
|
19
19
|
* the same batch); mount inside `<CartDrawerProvider>`.
|
|
20
20
|
*/
|
|
21
|
-
export function CartButtonClient({ cart }: { cart
|
|
22
|
-
const
|
|
23
|
-
|
|
21
|
+
export function CartButtonClient({ cart }: { cart?: Cart | null }) {
|
|
22
|
+
const drawer = useCartDrawer()
|
|
23
|
+
// Leave `cart` out and the badge counts the drawer's own cart, the one
|
|
24
|
+
// the provider read in the browser, so the header needs no server read
|
|
25
|
+
// and the page around it can be prerendered.
|
|
26
|
+
const totalItems = productItemCount((cart === undefined ? drawer.cart : cart)?.items)
|
|
27
|
+
const { open } = drawer
|
|
24
28
|
|
|
25
29
|
return (
|
|
26
30
|
<button
|
package/src/lib/cookie-names.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* (platform-fingerprints card, fingerprint #4 — cookie names are a
|
|
4
4
|
* primary Wappalyzer/BuiltWith detection signal).
|
|
5
5
|
*
|
|
6
|
-
* The app
|
|
7
|
-
*
|
|
8
|
-
* the NAME so every consumer emits the
|
|
6
|
+
* The app decides WHEN the cart cookie is written (it hands
|
|
7
|
+
* `writeCartCookie` to the drawer's `onCartChange`); this module is the
|
|
8
|
+
* single source of the NAME and the shape, so every consumer emits the
|
|
9
|
+
* same wire fingerprint instead of
|
|
9
10
|
* inventing its own prefix (the reference app previously hardcoded
|
|
10
11
|
* `_barter_cart_id` locally in `examples/storefront/src/lib/config.ts`).
|
|
11
12
|
*
|
|
@@ -40,6 +41,39 @@ export function readCartCookie(
|
|
|
40
41
|
return get(CART_COOKIE) ?? get(LEGACY_CART_COOKIE)
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** How long a stored cart id lives: thirty days. */
|
|
45
|
+
export const CART_COOKIE_MAX_AGE = 60 * 60 * 24 * 30
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The stored cart id, read in the browser; null on the server or when none
|
|
49
|
+
* is stored. A storefront hands it to `CartDrawerProvider` as `cartId`, so
|
|
50
|
+
* no page reads the cart cookie on the server and every page can be
|
|
51
|
+
* prerendered (Cache Components).
|
|
52
|
+
*/
|
|
53
|
+
export function readBrowserCartId(): string | null {
|
|
54
|
+
if (typeof document === "undefined") return null
|
|
55
|
+
const cookies = new Map(
|
|
56
|
+
document.cookie
|
|
57
|
+
.split("; ")
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((row) => {
|
|
60
|
+
const at = row.indexOf("=")
|
|
61
|
+
return [row.slice(0, at), decodeURIComponent(row.slice(at + 1))] as const
|
|
62
|
+
})
|
|
63
|
+
)
|
|
64
|
+
return readCartCookie((name) => cookies.get(name) || undefined) ?? null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Store the cart id in the browser, under the current name: what a
|
|
69
|
+
* storefront hands to `CartDrawerProvider`'s `onCartChange`. A no-op on the
|
|
70
|
+
* server.
|
|
71
|
+
*/
|
|
72
|
+
export function writeCartCookie(cartId: string): void {
|
|
73
|
+
if (typeof document === "undefined") return
|
|
74
|
+
document.cookie = `${CART_COOKIE}=${encodeURIComponent(cartId)};path=/;max-age=${CART_COOKIE_MAX_AGE};samesite=lax`
|
|
75
|
+
}
|
|
76
|
+
|
|
43
77
|
/**
|
|
44
78
|
* Forget the stored cart in the browser, under both names: Medusa's
|
|
45
79
|
* `removeCartId()`. The package's checkout calls it when the order is
|
package/src/locales/bg.ts
CHANGED
|
@@ -389,6 +389,13 @@ export const bg: StorefrontLocale = {
|
|
|
389
389
|
showPhoto: "Покажи снимка",
|
|
390
390
|
playVideo: "Пусни видео",
|
|
391
391
|
close: "Затвори",
|
|
392
|
+
openReview: "Отвори отзива",
|
|
393
|
+
verifiedInfo: "Какво значи потвърден",
|
|
394
|
+
storeReply: "Отговор от магазина",
|
|
395
|
+
previous: "Предишен",
|
|
396
|
+
next: "Следващ",
|
|
397
|
+
mediaAlt: "Снимка {n} от {total}, {name}",
|
|
398
|
+
showMediaAt: "Покажи снимка {n}",
|
|
392
399
|
expiredTitle: "Този линк за отзив е изтекъл",
|
|
393
400
|
expiredBody:
|
|
394
401
|
"Линкът беше валиден 60 дни от датата на покупката. Ако искаш да оставиш отзив, пиши ни на {email}.",
|
package/src/locales/es.ts
CHANGED
|
@@ -399,6 +399,13 @@ export const es: StorefrontLocale = {
|
|
|
399
399
|
showPhoto: "Ver foto",
|
|
400
400
|
playVideo: "Reproducir vídeo",
|
|
401
401
|
close: "Cerrar",
|
|
402
|
+
openReview: "Abrir la valoración",
|
|
403
|
+
verifiedInfo: "Qué significa compra verificada",
|
|
404
|
+
storeReply: "Respuesta de la tienda",
|
|
405
|
+
previous: "Anterior",
|
|
406
|
+
next: "Siguiente",
|
|
407
|
+
mediaAlt: "Foto {n} de {total} de {name}",
|
|
408
|
+
showMediaAt: "Ver foto {n}",
|
|
402
409
|
expiredTitle: "Este enlace de valoración ha caducado",
|
|
403
410
|
expiredBody:
|
|
404
411
|
"El enlace era válido durante 60 días desde tu compra. Si quieres dejar una valoración, escríbenos a {email}.",
|
|
@@ -23,8 +23,12 @@ type ProductActionsWrapperProps = {
|
|
|
23
23
|
id: string
|
|
24
24
|
/** Pricing context so `calculated_price` is present for the price panel. */
|
|
25
25
|
pricingContext?: PricingContextQuery
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The variant the address names; see the product page contract. A promise
|
|
28
|
+
* is read here, behind the template's boundary, so a page on Cache
|
|
29
|
+
* Components never reads its query outside one.
|
|
30
|
+
*/
|
|
31
|
+
initialVariantId?: string | null | Promise<string | null | undefined>
|
|
28
32
|
/** A server action; leave it out for the instant add through the mounted cart drawer. */
|
|
29
33
|
addToCart?: (input: AddToCartInput) => Promise<void>
|
|
30
34
|
onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
|
|
@@ -40,6 +44,9 @@ export async function ProductActionsWrapper({
|
|
|
40
44
|
onAddToCart,
|
|
41
45
|
openCart,
|
|
42
46
|
}: ProductActionsWrapperProps) {
|
|
47
|
+
// The address first: it is request data, so the live read never runs
|
|
48
|
+
// while a build prerenders the page.
|
|
49
|
+
const variantId = await initialVariantId
|
|
43
50
|
let product: StoreProduct
|
|
44
51
|
try {
|
|
45
52
|
const res = await retrieveProduct(client, id, pricingContext)
|
|
@@ -52,7 +59,7 @@ export async function ProductActionsWrapper({
|
|
|
52
59
|
return (
|
|
53
60
|
<ProductActions
|
|
54
61
|
product={product}
|
|
55
|
-
initialVariantId={
|
|
62
|
+
initialVariantId={variantId}
|
|
56
63
|
addToCart={addToCart}
|
|
57
64
|
onAddToCart={onAddToCart}
|
|
58
65
|
openCart={openCart}
|
|
@@ -26,6 +26,8 @@ import { RelatedProducts } from "./related-products"
|
|
|
26
26
|
import { ProductInfo } from "./product-info"
|
|
27
27
|
import { ProductActionsWrapper } from "./product-actions-wrapper"
|
|
28
28
|
|
|
29
|
+
type VariantParam = string | null | Promise<string | null | undefined>
|
|
30
|
+
|
|
29
31
|
type ProductTemplateProps = {
|
|
30
32
|
client: StorefrontClient
|
|
31
33
|
product: StoreProduct
|
|
@@ -59,11 +61,13 @@ type ProductTemplateProps = {
|
|
|
59
61
|
/** Anything else the store wants in the accordion, appended in order. */
|
|
60
62
|
sections?: ProductSection[]
|
|
61
63
|
/**
|
|
62
|
-
* The variant the address names
|
|
63
|
-
*
|
|
64
|
-
*
|
|
64
|
+
* The variant the address names, so the server renders that variant's
|
|
65
|
+
* price, code and stock and nothing flashes (the product page contract,
|
|
66
|
+
* `use-product-actions.ts`). On Cache Components pass it as a promise
|
|
67
|
+
* (`searchParams.then((q) => q.variant)`): the live buy box reads it
|
|
68
|
+
* behind its boundary and the rest of the page stays prerendered.
|
|
65
69
|
*/
|
|
66
|
-
initialVariantId?:
|
|
70
|
+
initialVariantId?: VariantParam
|
|
67
71
|
/** The store's own card for the related strip; the library's preview without it. */
|
|
68
72
|
renderProduct?: ComponentType<{ product: StoreProduct }>
|
|
69
73
|
}
|
|
@@ -112,7 +116,7 @@ export function ProductTemplate({
|
|
|
112
116
|
<ProductActions
|
|
113
117
|
disabled={true}
|
|
114
118
|
product={product}
|
|
115
|
-
initialVariantId={initialVariantId}
|
|
119
|
+
initialVariantId={typeof initialVariantId === "string" ? initialVariantId : null}
|
|
116
120
|
addToCart={addToCart}
|
|
117
121
|
onAddToCart={onAddToCart}
|
|
118
122
|
openCart={openCart}
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* to out of stock until the choice changes.
|
|
32
32
|
*/
|
|
33
33
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
34
|
-
import { usePathname, useRouter
|
|
34
|
+
import { usePathname, useRouter } from "next/navigation"
|
|
35
35
|
|
|
36
36
|
import type { StoreProduct, StoreProductVariant } from "../api/products"
|
|
37
37
|
import { useCartDrawer } from "../cart-drawer/context"
|
|
@@ -102,7 +102,6 @@ export function useProductActions({
|
|
|
102
102
|
const router = useRouter()
|
|
103
103
|
const drawer = useCartDrawer()
|
|
104
104
|
const pathname = usePathname()
|
|
105
|
-
const searchParams = useSearchParams()
|
|
106
105
|
const variants = product.variants ?? []
|
|
107
106
|
|
|
108
107
|
const [chosen, setChosen] = useState<OptionChoices>(() => {
|
|
@@ -127,12 +126,16 @@ export function useProductActions({
|
|
|
127
126
|
setQuantityState(Math.max(1, Math.floor(next) || 1))
|
|
128
127
|
}, [])
|
|
129
128
|
|
|
130
|
-
// Rule 3: the address follows the choice, in the browser alone.
|
|
129
|
+
// Rule 3: the address follows the choice, in the browser alone. The query
|
|
130
|
+
// is read here, when the address is written, and never while rendering: a
|
|
131
|
+
// render that reads the query is request data, and would stop a store on
|
|
132
|
+
// Cache Components from prerendering its product pages.
|
|
131
133
|
useEffect(() => {
|
|
132
134
|
if (!syncAddress || variants.length < 2 || !variant) return
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
const search = new URLSearchParams(window.location.search)
|
|
136
|
+
if (search.get(VARIANT_PARAM) === variantParamValue(variant.id)) return
|
|
137
|
+
window.history.replaceState(null, "", variantHref(pathname, search, variant.id))
|
|
138
|
+
}, [syncAddress, variants.length, variant, pathname])
|
|
136
139
|
|
|
137
140
|
const inStock = useMemo(() => {
|
|
138
141
|
if (!variant) return false
|
package/src/reviews-ui/index.ts
CHANGED
|
@@ -50,11 +50,15 @@ export {
|
|
|
50
50
|
type RatingDistributionProps,
|
|
51
51
|
} from "./star-badge"
|
|
52
52
|
|
|
53
|
+
export { ReviewList, type ReviewListProps } from "./review-list"
|
|
54
|
+
|
|
55
|
+
export { ReviewLightbox, type ReviewLightboxProps } from "./review-lightbox"
|
|
56
|
+
|
|
53
57
|
export {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
type
|
|
57
|
-
} from "./
|
|
58
|
+
lightboxMedia,
|
|
59
|
+
stepLightbox,
|
|
60
|
+
type LightboxPosition,
|
|
61
|
+
} from "./lightbox-state"
|
|
58
62
|
|
|
59
63
|
export { ReviewWidget, type ReviewWidgetProps } from "./review-widget"
|
|
60
64
|
|
package/src/reviews-ui/labels.ts
CHANGED
|
@@ -31,6 +31,18 @@ export type ReviewsUiLabels = {
|
|
|
31
31
|
showPhoto: string
|
|
32
32
|
playVideo: string
|
|
33
33
|
close: string
|
|
34
|
+
// — lightbox —
|
|
35
|
+
/** A review card's own button: the whole card opens the lightbox. */
|
|
36
|
+
openReview: string
|
|
37
|
+
/** The info button beside "Verified"; its popover says `verifiedTitle`. */
|
|
38
|
+
verifiedInfo: string
|
|
39
|
+
storeReply: string
|
|
40
|
+
previous: string
|
|
41
|
+
next: string
|
|
42
|
+
/** `{n}` = position, `{total}` = the review's photo count, `{name}` = reviewer. */
|
|
43
|
+
mediaAlt: string
|
|
44
|
+
/** A dot under the photo; `{n}` = position. */
|
|
45
|
+
showMediaAt: string
|
|
34
46
|
// — wizard: entry states (ported from the alenika /review/[token] page) —
|
|
35
47
|
expiredTitle: string
|
|
36
48
|
/** `{email}` = store support email. */
|
|
@@ -133,6 +145,13 @@ export const defaultReviewsUiLabels: ReviewsUiLabels = {
|
|
|
133
145
|
showPhoto: "View photo",
|
|
134
146
|
playVideo: "Play video",
|
|
135
147
|
close: "Close",
|
|
148
|
+
openReview: "Open the review",
|
|
149
|
+
verifiedInfo: "What verified means",
|
|
150
|
+
storeReply: "Reply from the store",
|
|
151
|
+
previous: "Previous",
|
|
152
|
+
next: "Next",
|
|
153
|
+
mediaAlt: "Photo {n} of {total} from {name}",
|
|
154
|
+
showMediaAt: "Show photo {n}",
|
|
136
155
|
expiredTitle: "This review link has expired",
|
|
137
156
|
expiredBody:
|
|
138
157
|
"The link was valid for 60 days after your purchase. If you'd like to leave a review, write to us at {email}.",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the review lightbox stands, and how it steps. Pure, no React, so
|
|
3
|
+
* every widget that opens the lightbox (the review list, a carousel, a
|
|
4
|
+
* store's own row of reviews) walks the same sequence. Unit-tested in
|
|
5
|
+
* tests/unit/storefront-review-lightbox.test.ts.
|
|
6
|
+
*/
|
|
7
|
+
import type { PublicReview, ReviewMedia } from "../api/reviews"
|
|
8
|
+
|
|
9
|
+
/** The open review (its index in the widget's list) and which of its photos or videos. */
|
|
10
|
+
export interface LightboxPosition {
|
|
11
|
+
review: number
|
|
12
|
+
media: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A review's photos and videos as the lightbox shows them: the SAME
|
|
17
|
+
* filtered list the cards count their indexes from, so a falsy entry can
|
|
18
|
+
* never shift which item opens (the Alenika production fix).
|
|
19
|
+
*/
|
|
20
|
+
export function lightboxMedia(review: Pick<PublicReview, "media"> | undefined): ReviewMedia[] {
|
|
21
|
+
return (review?.media ?? []).filter(Boolean)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One step through the reviews: through the open review's photos first,
|
|
26
|
+
* then on to the next review's first one, or back to the previous review's
|
|
27
|
+
* last. Null at either end, where the arrow is not drawn.
|
|
28
|
+
*/
|
|
29
|
+
export function stepLightbox(
|
|
30
|
+
reviews: ReadonlyArray<Pick<PublicReview, "media">>,
|
|
31
|
+
at: LightboxPosition,
|
|
32
|
+
direction: 1 | -1
|
|
33
|
+
): LightboxPosition | null {
|
|
34
|
+
const count = lightboxMedia(reviews[at.review]).length
|
|
35
|
+
if (direction === 1) {
|
|
36
|
+
if (at.media + 1 < count) return { review: at.review, media: at.media + 1 }
|
|
37
|
+
if (at.review + 1 < reviews.length) return { review: at.review + 1, media: 0 }
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
if (at.media > 0) return { review: at.review, media: at.media - 1 }
|
|
41
|
+
if (at.review > 0) {
|
|
42
|
+
const previous = lightboxMedia(reviews[at.review - 1]).length
|
|
43
|
+
return { review: at.review - 1, media: Math.max(previous - 1, 0) }
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, type ReactNode } from "react"
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|
5
|
+
import { ChevronLeft, ChevronRight, CircleCheck, Info, X } from "lucide-react"
|
|
6
|
+
import type { PublicReview } from "../api/reviews"
|
|
7
|
+
import { cn } from "../lib/utils"
|
|
8
|
+
import { useLocaleArea } from "../locales/context"
|
|
9
|
+
import { DialogOverlay, DialogPortal } from "../primitives/ui/dialog"
|
|
10
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/ui/popover"
|
|
11
|
+
import { formatLabel, formatReviewDate, reviewDisplayName } from "./helpers"
|
|
12
|
+
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
13
|
+
import { lightboxMedia, stepLightbox, type LightboxPosition } from "./lightbox-state"
|
|
14
|
+
import { StarRow } from "./star-badge"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The review lightbox: one review, whole. The photo or video on the left
|
|
18
|
+
* over a blurred copy of itself, the review on the right: the reviewer's
|
|
19
|
+
* name, "Verified" with a button that says what it means, the stars, the
|
|
20
|
+
* date, the text and the store's reply. On a phone the photo sits on top
|
|
21
|
+
* and the review scrolls under it.
|
|
22
|
+
*
|
|
23
|
+
* Every widget that shows reviews opens this one: it takes the widget's
|
|
24
|
+
* own list and a position, so the arrows (and the arrow keys) walk through
|
|
25
|
+
* the open review's photos and then on to the next review. The dots jump
|
|
26
|
+
* between one review's photos. Built on the package's Dialog (focus trap,
|
|
27
|
+
* scroll lock, Escape and a click outside close it).
|
|
28
|
+
*
|
|
29
|
+
* const [open, setOpen] = useState<LightboxPosition | null>(null)
|
|
30
|
+
* <ReviewLightbox reviews={reviews} position={open} onPositionChange={setOpen} />
|
|
31
|
+
*
|
|
32
|
+
* Alenika's production fixes are kept: the media index counts from the
|
|
33
|
+
* same filtered list the cards use, and a video plays inline on iPhone
|
|
34
|
+
* with its controls as the answer to a blocked autoplay.
|
|
35
|
+
*/
|
|
36
|
+
export interface ReviewLightboxProps {
|
|
37
|
+
/** The reviews the opening widget shows, in its order. */
|
|
38
|
+
reviews: PublicReview[]
|
|
39
|
+
/** The open review and which of its photos; null when closed. */
|
|
40
|
+
position: LightboxPosition | null
|
|
41
|
+
/** The new position when the shopper steps, null when they close. */
|
|
42
|
+
onPositionChange: (position: LightboxPosition | null) => void
|
|
43
|
+
labels?: Partial<ReviewsUiLabels>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ReviewLightbox({ reviews, position, onPositionChange, labels }: ReviewLightboxProps) {
|
|
47
|
+
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
48
|
+
const review = position ? reviews[position.review] : undefined
|
|
49
|
+
const media = lightboxMedia(review)
|
|
50
|
+
const item = position ? media[position.media] : undefined
|
|
51
|
+
const previous = position ? stepLightbox(reviews, position, -1) : null
|
|
52
|
+
const next = position ? stepLightbox(reviews, position, 1) : null
|
|
53
|
+
|
|
54
|
+
// The arrow keys step as the arrows do; a video keeps its own keys.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!position) return
|
|
57
|
+
const onKey = (event: KeyboardEvent) => {
|
|
58
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
|
|
59
|
+
if ((event.target as HTMLElement | null)?.closest?.("video, input, textarea, select")) return
|
|
60
|
+
const to = stepLightbox(reviews, position, event.key === "ArrowLeft" ? -1 : 1)
|
|
61
|
+
if (to) onPositionChange(to)
|
|
62
|
+
}
|
|
63
|
+
window.addEventListener("keydown", onKey)
|
|
64
|
+
return () => window.removeEventListener("keydown", onKey)
|
|
65
|
+
}, [position, reviews, onPositionChange])
|
|
66
|
+
|
|
67
|
+
const name = review ? reviewDisplayName(review.customer_name) : ""
|
|
68
|
+
const stepping = Boolean(previous || next)
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<DialogPrimitive.Root
|
|
72
|
+
open={Boolean(review)}
|
|
73
|
+
onOpenChange={(open) => {
|
|
74
|
+
if (!open) onPositionChange(null)
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<DialogPortal>
|
|
78
|
+
<DialogOverlay />
|
|
79
|
+
<DialogPrimitive.Content
|
|
80
|
+
aria-describedby={undefined}
|
|
81
|
+
className={cn(
|
|
82
|
+
"fixed inset-0 z-50 outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
|
83
|
+
"md:inset-auto md:left-1/2 md:top-1/2 md:w-[calc(100%-10rem)] md:-translate-x-1/2 md:-translate-y-1/2 md:data-[state=open]:zoom-in-95 md:data-[state=closed]:zoom-out-95",
|
|
84
|
+
item ? "md:max-w-[55rem]" : "md:max-w-lg"
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{review ? (
|
|
88
|
+
<>
|
|
89
|
+
<DialogPrimitive.Close
|
|
90
|
+
aria-label={l.close}
|
|
91
|
+
className={cn(
|
|
92
|
+
"absolute left-4 top-4 z-10 flex size-10 items-center justify-center rounded-md transition-colors",
|
|
93
|
+
item
|
|
94
|
+
? "bg-black/40 text-white hover:bg-black/55"
|
|
95
|
+
: "bg-muted text-foreground hover:bg-border"
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<X className="size-5" />
|
|
99
|
+
</DialogPrimitive.Close>
|
|
100
|
+
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
"flex h-full flex-col overflow-y-auto bg-background md:overflow-hidden md:rounded-lg md:shadow-popover",
|
|
104
|
+
item ? "md:h-[min(40rem,calc(100dvh-4rem))] md:flex-row" : "md:h-auto md:max-h-[calc(100dvh-4rem)]"
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{item && position ? (
|
|
108
|
+
<div className="relative flex h-[min(60vh,32rem)] shrink-0 items-center justify-center overflow-hidden bg-muted md:h-full md:w-[54%]">
|
|
109
|
+
{item.type === "image" ? (
|
|
110
|
+
<>
|
|
111
|
+
<img
|
|
112
|
+
src={item.url}
|
|
113
|
+
alt=""
|
|
114
|
+
aria-hidden
|
|
115
|
+
className="pointer-events-none absolute inset-0 size-full scale-110 object-cover blur-2xl"
|
|
116
|
+
/>
|
|
117
|
+
<img
|
|
118
|
+
key={item.url}
|
|
119
|
+
src={item.url}
|
|
120
|
+
alt={formatLabel(l.mediaAlt, {
|
|
121
|
+
n: position.media + 1,
|
|
122
|
+
total: media.length,
|
|
123
|
+
name,
|
|
124
|
+
})}
|
|
125
|
+
className="relative size-full object-contain"
|
|
126
|
+
/>
|
|
127
|
+
</>
|
|
128
|
+
) : (
|
|
129
|
+
<video
|
|
130
|
+
key={item.url}
|
|
131
|
+
src={`${item.url}#t=0.001`}
|
|
132
|
+
controls
|
|
133
|
+
autoPlay
|
|
134
|
+
playsInline
|
|
135
|
+
preload="metadata"
|
|
136
|
+
className="relative size-full bg-black object-contain"
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{media.length > 1 ? (
|
|
141
|
+
<div
|
|
142
|
+
className={cn(
|
|
143
|
+
"absolute inset-x-0 flex justify-center gap-1.5",
|
|
144
|
+
item.type === "video" ? "top-4" : "bottom-4"
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
{media.map((entry, index) => (
|
|
148
|
+
<button
|
|
149
|
+
key={`${entry.url}-${index}`}
|
|
150
|
+
type="button"
|
|
151
|
+
aria-label={formatLabel(l.showMediaAt, { n: index + 1 })}
|
|
152
|
+
aria-current={index === position.media}
|
|
153
|
+
onClick={() => onPositionChange({ review: position.review, media: index })}
|
|
154
|
+
className={cn(
|
|
155
|
+
"size-2 rounded-full ring-1 ring-black/20 transition-colors",
|
|
156
|
+
index === position.media ? "bg-white" : "bg-white/50 hover:bg-white/80"
|
|
157
|
+
)}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
) : null}
|
|
162
|
+
</div>
|
|
163
|
+
) : null}
|
|
164
|
+
|
|
165
|
+
<div
|
|
166
|
+
className={cn(
|
|
167
|
+
"flex min-w-0 flex-1 flex-col gap-4 p-6 md:overflow-y-auto md:p-7",
|
|
168
|
+
!item && "pt-16 md:pt-16",
|
|
169
|
+
stepping && "pb-20 md:pb-7"
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
<div className="flex flex-col gap-1.5">
|
|
173
|
+
<div className="flex items-center justify-between gap-3">
|
|
174
|
+
<DialogPrimitive.Title className="min-w-0 truncate text-body text-foreground">
|
|
175
|
+
{name}
|
|
176
|
+
</DialogPrimitive.Title>
|
|
177
|
+
<div className="flex shrink-0 items-center gap-2 text-body-small text-foreground">
|
|
178
|
+
<span className="flex items-center gap-1.5">
|
|
179
|
+
<CircleCheck aria-hidden className="size-4.5 fill-foreground text-background" />
|
|
180
|
+
{l.verified}
|
|
181
|
+
</span>
|
|
182
|
+
<span aria-hidden className="h-5 w-px bg-border" />
|
|
183
|
+
<Popover>
|
|
184
|
+
<PopoverTrigger
|
|
185
|
+
aria-label={l.verifiedInfo}
|
|
186
|
+
className="flex size-7 items-center justify-center rounded-full transition-colors hover:bg-muted"
|
|
187
|
+
>
|
|
188
|
+
<Info className="size-4.5" />
|
|
189
|
+
</PopoverTrigger>
|
|
190
|
+
<PopoverContent align="end" className="w-64 text-body-small">
|
|
191
|
+
{l.verifiedTitle}
|
|
192
|
+
</PopoverContent>
|
|
193
|
+
</Popover>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
<div className="flex items-center justify-between gap-3">
|
|
197
|
+
<StarRow rating={review.rating} size="lg" />
|
|
198
|
+
<span className="shrink-0 text-caption text-muted-foreground">
|
|
199
|
+
{formatReviewDate(review.created_at, l)}
|
|
200
|
+
</span>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{review.title ? <p className="text-h6 text-foreground">{review.title}</p> : null}
|
|
205
|
+
{review.body ? (
|
|
206
|
+
<p className="whitespace-pre-wrap text-body text-foreground">{review.body}</p>
|
|
207
|
+
) : null}
|
|
208
|
+
|
|
209
|
+
{review.admin_response ? (
|
|
210
|
+
<div className="rounded-md bg-muted p-4">
|
|
211
|
+
<p className="text-caption text-muted-foreground">{l.storeReply}</p>
|
|
212
|
+
<p className="mt-1 whitespace-pre-wrap text-body-small text-foreground">
|
|
213
|
+
{review.admin_response}
|
|
214
|
+
</p>
|
|
215
|
+
</div>
|
|
216
|
+
) : null}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{previous ? (
|
|
221
|
+
<StepButton
|
|
222
|
+
label={l.previous}
|
|
223
|
+
onClick={() => onPositionChange(previous)}
|
|
224
|
+
className="bottom-4 left-4 md:-left-14"
|
|
225
|
+
>
|
|
226
|
+
<ChevronLeft className="size-5" />
|
|
227
|
+
</StepButton>
|
|
228
|
+
) : null}
|
|
229
|
+
{next ? (
|
|
230
|
+
<StepButton
|
|
231
|
+
label={l.next}
|
|
232
|
+
onClick={() => onPositionChange(next)}
|
|
233
|
+
className="bottom-4 right-4 md:-right-14"
|
|
234
|
+
>
|
|
235
|
+
<ChevronRight className="size-5" />
|
|
236
|
+
</StepButton>
|
|
237
|
+
) : null}
|
|
238
|
+
</>
|
|
239
|
+
) : null}
|
|
240
|
+
</DialogPrimitive.Content>
|
|
241
|
+
</DialogPortal>
|
|
242
|
+
</DialogPrimitive.Root>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A round arrow: in the bottom corners on a phone, beside the card on a wider screen. */
|
|
247
|
+
function StepButton({
|
|
248
|
+
label,
|
|
249
|
+
onClick,
|
|
250
|
+
className,
|
|
251
|
+
children,
|
|
252
|
+
}: {
|
|
253
|
+
label: string
|
|
254
|
+
onClick: () => void
|
|
255
|
+
className: string
|
|
256
|
+
children: ReactNode
|
|
257
|
+
}) {
|
|
258
|
+
return (
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
aria-label={label}
|
|
262
|
+
onClick={onClick}
|
|
263
|
+
className={cn(
|
|
264
|
+
"absolute z-10 flex size-10 items-center justify-center rounded-full bg-background text-foreground shadow-popover transition-colors hover:bg-muted md:bottom-auto md:top-1/2 md:-translate-y-1/2",
|
|
265
|
+
className
|
|
266
|
+
)}
|
|
267
|
+
>
|
|
268
|
+
{children}
|
|
269
|
+
</button>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
@@ -1,251 +1,191 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import { ShieldCheck
|
|
5
|
-
import type { PublicReview } from "../api/reviews"
|
|
6
|
-
import { useLocaleArea } from "../locales/context"
|
|
7
|
-
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
8
|
-
import { formatReviewDate, reviewDisplayName } from "./helpers"
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<span className="text-
|
|
121
|
-
{
|
|
122
|
-
</span>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}: {
|
|
193
|
-
review: PublicReview | undefined
|
|
194
|
-
mediaIdx: number
|
|
195
|
-
closeLabel?: string
|
|
196
|
-
onClose: () => void
|
|
197
|
-
}) {
|
|
198
|
-
const fallback = useLocaleArea("reviews", defaultReviewsUiLabels)
|
|
199
|
-
// Close on Escape
|
|
200
|
-
useEffect(() => {
|
|
201
|
-
const onKey = (e: KeyboardEvent) => {
|
|
202
|
-
if (e.key === "Escape") onClose()
|
|
203
|
-
}
|
|
204
|
-
window.addEventListener("keydown", onKey)
|
|
205
|
-
return () => window.removeEventListener("keydown", onKey)
|
|
206
|
-
}, [onClose])
|
|
207
|
-
|
|
208
|
-
if (!review) return null
|
|
209
|
-
// Index into the SAME filtered array the grid used to compute mediaIdx,
|
|
210
|
-
// so a falsy/hidden entry can't shift which item opens (production fix).
|
|
211
|
-
const items = (review.media ?? []).filter(Boolean)
|
|
212
|
-
const item = items[mediaIdx]
|
|
213
|
-
if (!item) return null
|
|
214
|
-
|
|
215
|
-
return (
|
|
216
|
-
<div
|
|
217
|
-
role="dialog"
|
|
218
|
-
aria-modal="true"
|
|
219
|
-
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
|
|
220
|
-
onClick={onClose}
|
|
221
|
-
>
|
|
222
|
-
<button
|
|
223
|
-
type="button"
|
|
224
|
-
aria-label={closeLabel ?? fallback.close}
|
|
225
|
-
onClick={onClose}
|
|
226
|
-
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-background/10 hover:bg-background/20 text-background flex items-center justify-center"
|
|
227
|
-
>
|
|
228
|
-
<X className="w-5 h-5" />
|
|
229
|
-
</button>
|
|
230
|
-
<div
|
|
231
|
-
className="max-w-5xl max-h-[90vh] w-full flex items-center justify-center"
|
|
232
|
-
onClick={(e) => e.stopPropagation()}
|
|
233
|
-
>
|
|
234
|
-
{item.type === "image" ? (
|
|
235
|
-
<img
|
|
236
|
-
src={item.url}
|
|
237
|
-
alt=""
|
|
238
|
-
className="max-w-full max-h-[90vh] object-contain"
|
|
239
|
-
/>
|
|
240
|
-
) : (
|
|
241
|
-
<video
|
|
242
|
-
src={item.url}
|
|
243
|
-
controls
|
|
244
|
-
autoPlay
|
|
245
|
-
className="max-w-full max-h-[90vh]"
|
|
246
|
-
/>
|
|
247
|
-
)}
|
|
248
|
-
</div>
|
|
249
|
-
</div>
|
|
250
|
-
)
|
|
251
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { ShieldCheck } from "lucide-react"
|
|
5
|
+
import type { PublicReview } from "../api/reviews"
|
|
6
|
+
import { useLocaleArea } from "../locales/context"
|
|
7
|
+
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
8
|
+
import { formatReviewDate, reviewDisplayName } from "./helpers"
|
|
9
|
+
import { lightboxMedia, type LightboxPosition } from "./lightbox-state"
|
|
10
|
+
import { ReviewLightbox } from "./review-lightbox"
|
|
11
|
+
import { StarRow } from "./star-badge"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Review cards (masonry) + the review lightbox. Ported from the Alenika PDP
|
|
15
|
+
* reviews section (src/components/product/reviews-list.tsx, the card
|
|
16
|
+
* grid), presentational half: the fetch/sort/paging orchestration lives in
|
|
17
|
+
* `ReviewWidget`. Data: `api/reviews` `PublicReview[]` (hidden media is
|
|
18
|
+
* filtered out server-side, the client just renders what it gets).
|
|
19
|
+
*
|
|
20
|
+
* A click anywhere on a card opens that review in `ReviewLightbox`; a click
|
|
21
|
+
* on one of its photos opens it on that photo.
|
|
22
|
+
*
|
|
23
|
+
* Verified-purchaser badge is unconditional — every Cartbase review comes
|
|
24
|
+
* from a tokenized post-purchase email, so the badge is a tautology of
|
|
25
|
+
* the system, not an opt-in flag.
|
|
26
|
+
*
|
|
27
|
+
* Masonry: CSS multi-column so photo reviews (tall) and text-only reviews
|
|
28
|
+
* (short) pack together without empty gaps; `break-inside-avoid` keeps
|
|
29
|
+
* each card whole. Images-first ordering comes from the API's default
|
|
30
|
+
* sort, so photo cards lead the flow. `layout="list"` (the store's
|
|
31
|
+
* widget_layout setting) renders a single column instead.
|
|
32
|
+
*/
|
|
33
|
+
export interface ReviewListProps {
|
|
34
|
+
reviews: PublicReview[]
|
|
35
|
+
/** Store display option (`getWidget().options.layout`). Default masonry. */
|
|
36
|
+
layout?: "masonry" | "list"
|
|
37
|
+
labels?: Partial<ReviewsUiLabels>
|
|
38
|
+
className?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ReviewList({
|
|
42
|
+
reviews,
|
|
43
|
+
layout = "masonry",
|
|
44
|
+
labels,
|
|
45
|
+
className,
|
|
46
|
+
}: ReviewListProps) {
|
|
47
|
+
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
48
|
+
const [open, setOpen] = useState<LightboxPosition | null>(null)
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<>
|
|
52
|
+
<ul
|
|
53
|
+
className={
|
|
54
|
+
className ??
|
|
55
|
+
(layout === "list"
|
|
56
|
+
? "columns-1 [column-gap:1rem]"
|
|
57
|
+
: "columns-1 sm:columns-2 lg:columns-3 xl:columns-4 [column-gap:1rem]")
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
{reviews.map((r, index) => {
|
|
61
|
+
const media = lightboxMedia(r)
|
|
62
|
+
const hero = media[0]
|
|
63
|
+
const rest = media.slice(1)
|
|
64
|
+
return (
|
|
65
|
+
<li
|
|
66
|
+
key={r.id}
|
|
67
|
+
className="relative break-inside-avoid mb-4 rounded-[2px] border border-border bg-background overflow-hidden"
|
|
68
|
+
>
|
|
69
|
+
{/* The whole card is the button; the photos sit above it. */}
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => setOpen({ review: index, media: 0 })}
|
|
73
|
+
aria-label={`${l.openReview}: ${reviewDisplayName(r.customer_name)}`}
|
|
74
|
+
className="absolute inset-0 z-0 cursor-pointer"
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
{/* Hero media on top */}
|
|
78
|
+
{hero && (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={() => setOpen({ review: index, media: 0 })}
|
|
82
|
+
aria-label={hero.type === "image" ? l.showPhoto : l.playVideo}
|
|
83
|
+
className="relative z-10 block w-full group"
|
|
84
|
+
>
|
|
85
|
+
{hero.type === "image" ? (
|
|
86
|
+
<img
|
|
87
|
+
src={hero.thumb || hero.url}
|
|
88
|
+
alt=""
|
|
89
|
+
loading="lazy"
|
|
90
|
+
className="block w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
|
|
91
|
+
/>
|
|
92
|
+
) : (
|
|
93
|
+
<>
|
|
94
|
+
{/* `#t=0.001`: iOS Safari paints no first frame from
|
|
95
|
+
`preload="metadata"` alone; the seek makes it. */}
|
|
96
|
+
<video
|
|
97
|
+
src={`${hero.url}#t=0.001`}
|
|
98
|
+
muted
|
|
99
|
+
playsInline
|
|
100
|
+
preload="metadata"
|
|
101
|
+
className="block w-full h-auto object-cover bg-black"
|
|
102
|
+
/>
|
|
103
|
+
<span className="absolute inset-0 flex items-center justify-center">
|
|
104
|
+
<span className="w-11 h-11 rounded-full bg-background/80 flex items-center justify-center">
|
|
105
|
+
<span
|
|
106
|
+
aria-hidden
|
|
107
|
+
className="w-0 h-0 border-y-[7px] border-y-transparent border-l-[12px] border-l-foreground ml-0.5"
|
|
108
|
+
/>
|
|
109
|
+
</span>
|
|
110
|
+
</span>
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<div className="p-4 sm:p-5">
|
|
117
|
+
<StarRow rating={r.rating} />
|
|
118
|
+
|
|
119
|
+
<div className="flex items-center flex-wrap gap-x-2 gap-y-0.5 mt-2">
|
|
120
|
+
<span className="text-sm font-medium text-foreground">
|
|
121
|
+
{reviewDisplayName(r.customer_name)}
|
|
122
|
+
</span>
|
|
123
|
+
<span
|
|
124
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
|
125
|
+
title={l.verifiedTitle}
|
|
126
|
+
>
|
|
127
|
+
<ShieldCheck className="w-3.5 h-3.5" />
|
|
128
|
+
{l.verified}
|
|
129
|
+
</span>
|
|
130
|
+
<span className="text-xs text-muted-foreground ml-auto">
|
|
131
|
+
{formatReviewDate(r.created_at, l)}
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{r.title && (
|
|
136
|
+
<h3 className="text-base font-semibold text-foreground leading-snug mt-3">
|
|
137
|
+
{r.title}
|
|
138
|
+
</h3>
|
|
139
|
+
)}
|
|
140
|
+
{r.body && (
|
|
141
|
+
<p className="text-sm text-foreground/90 whitespace-pre-wrap leading-relaxed mt-1.5">
|
|
142
|
+
{r.body}
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Extra media beyond the hero */}
|
|
147
|
+
{rest.length > 0 && (
|
|
148
|
+
<div className="relative z-10 grid grid-cols-4 gap-2 mt-3">
|
|
149
|
+
{rest.map((m, i) => (
|
|
150
|
+
<button
|
|
151
|
+
key={i}
|
|
152
|
+
type="button"
|
|
153
|
+
onClick={() => setOpen({ review: index, media: i + 1 })}
|
|
154
|
+
aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
|
|
155
|
+
className="relative aspect-square rounded-[2px] overflow-hidden bg-muted"
|
|
156
|
+
>
|
|
157
|
+
{m.type === "image" ? (
|
|
158
|
+
<img
|
|
159
|
+
src={m.thumb || m.url}
|
|
160
|
+
alt=""
|
|
161
|
+
loading="lazy"
|
|
162
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
163
|
+
/>
|
|
164
|
+
) : (
|
|
165
|
+
<video
|
|
166
|
+
src={`${m.url}#t=0.001`}
|
|
167
|
+
muted
|
|
168
|
+
playsInline
|
|
169
|
+
preload="metadata"
|
|
170
|
+
className="absolute inset-0 w-full h-full object-cover bg-black"
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
</button>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
</li>
|
|
179
|
+
)
|
|
180
|
+
})}
|
|
181
|
+
</ul>
|
|
182
|
+
|
|
183
|
+
<ReviewLightbox
|
|
184
|
+
reviews={reviews}
|
|
185
|
+
position={open}
|
|
186
|
+
onPositionChange={setOpen}
|
|
187
|
+
labels={labels}
|
|
188
|
+
/>
|
|
189
|
+
</>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -11,23 +11,33 @@ import { reviewCountLabel } from "./helpers"
|
|
|
11
11
|
* arrives via props (`api/reviews.getAggregate` / `getWidget().aggregate`).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Five-star row, filled to `Math.round(rating)` in the theme's `rating`
|
|
16
|
+
* colour. `lg` is the lightbox's row.
|
|
17
|
+
*/
|
|
15
18
|
export function StarRow({
|
|
16
19
|
rating,
|
|
20
|
+
size = "sm",
|
|
17
21
|
className,
|
|
18
22
|
}: {
|
|
19
23
|
rating: number
|
|
24
|
+
size?: "sm" | "lg"
|
|
20
25
|
className?: string
|
|
21
26
|
}) {
|
|
22
27
|
return (
|
|
23
|
-
<span
|
|
28
|
+
<span
|
|
29
|
+
role="img"
|
|
30
|
+
aria-label={`${Math.round(rating)} / 5`}
|
|
31
|
+
className={cn("inline-flex items-center", size === "lg" ? "gap-px" : "gap-0.5", className)}
|
|
32
|
+
>
|
|
24
33
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
25
34
|
<Star
|
|
26
35
|
key={i}
|
|
36
|
+
aria-hidden
|
|
27
37
|
className={cn(
|
|
28
|
-
"
|
|
38
|
+
size === "lg" ? "size-5" : "size-3.5",
|
|
29
39
|
i < Math.round(rating)
|
|
30
|
-
? "fill-
|
|
40
|
+
? "fill-rating text-rating"
|
|
31
41
|
: "fill-muted text-muted-foreground"
|
|
32
42
|
)}
|
|
33
43
|
strokeWidth={0}
|
|
@@ -53,7 +63,7 @@ export function StarBadge({ aggregate, labels, className }: StarBadgeProps) {
|
|
|
53
63
|
if (!aggregate.count) return null
|
|
54
64
|
return (
|
|
55
65
|
<div className={cn("flex items-center gap-3 shrink-0", className)}>
|
|
56
|
-
<Star className="w-10 h-10 fill-
|
|
66
|
+
<Star className="w-10 h-10 fill-rating text-rating" strokeWidth={0} />
|
|
57
67
|
<div>
|
|
58
68
|
<div className="text-3xl font-bold text-foreground leading-none">
|
|
59
69
|
{aggregate.avg_rating.toFixed(1)}
|
package/src/store/pagination.tsx
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* the server templates read it back and convert to `offset`. Windowed
|
|
7
7
|
* layout: ≤7 pages inline, otherwise ellipsis around the current window.
|
|
8
8
|
*/
|
|
9
|
-
import { usePathname, useRouter
|
|
9
|
+
import { usePathname, useRouter } from "next/navigation"
|
|
10
10
|
import { cn } from "../lib/utils"
|
|
11
11
|
|
|
12
12
|
export function Pagination({
|
|
@@ -20,13 +20,14 @@ export function Pagination({
|
|
|
20
20
|
}) {
|
|
21
21
|
const router = useRouter()
|
|
22
22
|
const pathname = usePathname()
|
|
23
|
-
const searchParams = useSearchParams()
|
|
24
23
|
|
|
25
24
|
const arrayRange = (start: number, stop: number) =>
|
|
26
25
|
Array.from({ length: stop - start + 1 }, (_, index) => start + index)
|
|
27
26
|
|
|
28
27
|
const handlePageChange = (newPage: number) => {
|
|
29
|
-
|
|
28
|
+
// The query as it is at the click: read while rendering, it would make
|
|
29
|
+
// every listing page request data and stop it from being prerendered.
|
|
30
|
+
const params = new URLSearchParams(window.location.search)
|
|
30
31
|
params.set("page", newPage.toString())
|
|
31
32
|
router.push(`${pathname}?${params.toString()}`)
|
|
32
33
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* from `sortOptionLabelKeys` (labels.ts, pure) so map completeness against
|
|
13
13
|
* `SortOptions` is unit-tested.
|
|
14
14
|
*/
|
|
15
|
-
import { usePathname, useRouter
|
|
15
|
+
import { usePathname, useRouter } from "next/navigation"
|
|
16
16
|
import { useCallback } from "react"
|
|
17
17
|
import { cn } from "../lib/utils"
|
|
18
18
|
import type { SortOptions } from "../lib/sort-products"
|
|
@@ -35,20 +35,18 @@ export function SortSelect({
|
|
|
35
35
|
const l = { ...defaultStoreLabels, ...labels }
|
|
36
36
|
const router = useRouter()
|
|
37
37
|
const pathname = usePathname()
|
|
38
|
-
const searchParams = useSearchParams()
|
|
39
38
|
|
|
40
39
|
const sortOptions = (
|
|
41
40
|
Object.keys(sortOptionLabelKeys) as SortOptions[]
|
|
42
41
|
).map((value) => ({ value, label: l[sortOptionLabelKeys[value]] }))
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
43
|
+
// The query as it is at the click: read while rendering, it would make
|
|
44
|
+
// every listing page request data and stop it from being prerendered.
|
|
45
|
+
const createQueryString = useCallback((name: string, value: string) => {
|
|
46
|
+
const params = new URLSearchParams(window.location.search)
|
|
47
|
+
params.set(name, value)
|
|
48
|
+
return params.toString()
|
|
49
|
+
}, [])
|
|
52
50
|
|
|
53
51
|
const handleChange = (value: SortOptions) => {
|
|
54
52
|
const query = createQueryString("sortBy", value)
|
package/theme/theme.css
CHANGED
package/theme/tokens.css
CHANGED
|
@@ -44,6 +44,9 @@
|
|
|
44
44
|
--warning: oklch(0.828 0.189 84.429);
|
|
45
45
|
--warning-foreground: oklch(0.279 0.077 45.635);
|
|
46
46
|
|
|
47
|
+
/* ── Ratings: the filled review star ──────────────────────────────────── */
|
|
48
|
+
--rating: oklch(0.828 0.189 84.429);
|
|
49
|
+
|
|
47
50
|
/* ── Lines and focus ──────────────────────────────────────────────────── */
|
|
48
51
|
--border: oklch(0.922 0 0);
|
|
49
52
|
--input: oklch(0.922 0 0);
|
|
@@ -94,6 +97,8 @@
|
|
|
94
97
|
--warning: oklch(0.828 0.189 84.429);
|
|
95
98
|
--warning-foreground: oklch(0.279 0.077 45.635);
|
|
96
99
|
|
|
100
|
+
--rating: oklch(0.828 0.189 84.429);
|
|
101
|
+
|
|
97
102
|
--border: oklch(1 0 0 / 12%);
|
|
98
103
|
--input: oklch(1 0 0 / 18%);
|
|
99
104
|
--ring: oklch(0.556 0 0);
|