@cartbase/storefront 0.18.1 → 0.20.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/api/reviews.ts +22 -1
- package/src/common/cart-button-client.tsx +7 -3
- package/src/lib/cookie-names.ts +37 -3
- package/src/locales/bg.ts +10 -2
- package/src/locales/es.ts +10 -2
- 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 +20 -4
- package/src/reviews-ui/labels.ts +24 -2
- package/src/reviews-ui/lightbox-state.ts +46 -0
- package/src/reviews-ui/review-header.tsx +166 -0
- package/src/reviews-ui/review-lightbox.tsx +271 -0
- package/src/reviews-ui/review-list.tsx +193 -251
- package/src/reviews-ui/review-widget.tsx +218 -227
- package/src/reviews-ui/star-badge.tsx +17 -5
- package/src/reviews-ui/widget-options.ts +55 -0
- 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.20.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": {
|
package/src/api/reviews.ts
CHANGED
|
@@ -95,6 +95,16 @@ export async function getAggregate(
|
|
|
95
95
|
return client.get("/api/store/reviews/aggregate", { query: { product_id: productId } })
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The widget's header, Loox's three: `minimal` is the stars and one fact
|
|
100
|
+
* with the breakdown under a chevron, `compact` the average, the stars and
|
|
101
|
+
* the count on one line, `expanded` the big score with the breakdown open.
|
|
102
|
+
*/
|
|
103
|
+
export type ReviewWidgetHeader = "minimal" | "compact" | "expanded"
|
|
104
|
+
|
|
105
|
+
/** What the minimal header shows beside its stars. */
|
|
106
|
+
export type ReviewWidgetHeaderContent = "count" | "average" | "stars"
|
|
107
|
+
|
|
98
108
|
/** Widget payload — aggregate + first page + display options, one call. */
|
|
99
109
|
export interface ReviewWidgetPayload {
|
|
100
110
|
product_id: string
|
|
@@ -106,6 +116,15 @@ export interface ReviewWidgetPayload {
|
|
|
106
116
|
layout: "masonry" | "list"
|
|
107
117
|
page_size: number
|
|
108
118
|
photo_first: boolean
|
|
119
|
+
/** The fields below arrive from platforms of 2026-09-17 on; the widget defaults each. */
|
|
120
|
+
header?: ReviewWidgetHeader
|
|
121
|
+
header_content?: ReviewWidgetHeaderContent
|
|
122
|
+
/** The 5 to 1 star breakdown. */
|
|
123
|
+
show_distribution?: boolean
|
|
124
|
+
/** The shopper's sort menu. */
|
|
125
|
+
show_sort?: boolean
|
|
126
|
+
/** The date on each card. */
|
|
127
|
+
show_date?: boolean
|
|
109
128
|
}
|
|
110
129
|
}
|
|
111
130
|
|
|
@@ -116,7 +135,9 @@ export interface ReviewWidgetPayload {
|
|
|
116
135
|
*
|
|
117
136
|
* Auth: anon (x-client-id).
|
|
118
137
|
* Errors: 400 validation_failed (missing product_id).
|
|
119
|
-
* Settings:
|
|
138
|
+
* Settings (admin, Reviews, Widgets): widget_header / widget_header_content /
|
|
139
|
+
* widget_layout / widget_page_size / widget_photo_first /
|
|
140
|
+
* widget_show_distribution / widget_show_sort / widget_show_date.
|
|
120
141
|
*/
|
|
121
142
|
export async function getWidget(
|
|
122
143
|
client: StorefrontClient,
|
|
@@ -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
|
@@ -374,14 +374,14 @@ export const bg: StorefrontLocale = {
|
|
|
374
374
|
sectionTitle: "Отзиви",
|
|
375
375
|
reviewSingular: "отзив",
|
|
376
376
|
reviewPlural: "отзива",
|
|
377
|
-
sortAria: "Сортирай",
|
|
377
|
+
sortAria: "Сортирай по",
|
|
378
378
|
sortDefault: "Препоръчани",
|
|
379
379
|
sortNewest: "Най-нови",
|
|
380
380
|
sortRatingDesc: "Най-високи оценки",
|
|
381
381
|
sortRatingAsc: "Най-ниски оценки",
|
|
382
382
|
verified: "Потвърден",
|
|
383
383
|
verifiedTitle: "Истински купувач, потвърден чрез линк от поръчка",
|
|
384
|
-
loadMore: "Покажи още",
|
|
384
|
+
loadMore: "Покажи още отзиви",
|
|
385
385
|
loading: "Зарежда...",
|
|
386
386
|
dateToday: "днес",
|
|
387
387
|
dateYesterday: "вчера",
|
|
@@ -389,6 +389,14 @@ export const bg: StorefrontLocale = {
|
|
|
389
389
|
showPhoto: "Покажи снимка",
|
|
390
390
|
playVideo: "Пусни видео",
|
|
391
391
|
close: "Затвори",
|
|
392
|
+
ratingBreakdown: "Разбивка по оценки",
|
|
393
|
+
openReview: "Отвори отзива",
|
|
394
|
+
verifiedInfo: "Какво значи потвърден",
|
|
395
|
+
storeReply: "Отговор от магазина",
|
|
396
|
+
previous: "Предишен",
|
|
397
|
+
next: "Следващ",
|
|
398
|
+
mediaAlt: "Снимка {n} от {total}, {name}",
|
|
399
|
+
showMediaAt: "Покажи снимка {n}",
|
|
392
400
|
expiredTitle: "Този линк за отзив е изтекъл",
|
|
393
401
|
expiredBody:
|
|
394
402
|
"Линкът беше валиден 60 дни от датата на покупката. Ако искаш да оставиш отзив, пиши ни на {email}.",
|
package/src/locales/es.ts
CHANGED
|
@@ -384,14 +384,14 @@ export const es: StorefrontLocale = {
|
|
|
384
384
|
sectionTitle: "Valoraciones",
|
|
385
385
|
reviewSingular: "valoración",
|
|
386
386
|
reviewPlural: "valoraciones",
|
|
387
|
-
sortAria: "Ordenar",
|
|
387
|
+
sortAria: "Ordenar por",
|
|
388
388
|
sortDefault: "Recomendadas",
|
|
389
389
|
sortNewest: "Más recientes",
|
|
390
390
|
sortRatingDesc: "Mejor valoradas",
|
|
391
391
|
sortRatingAsc: "Peor valoradas",
|
|
392
392
|
verified: "Compra verificada",
|
|
393
393
|
verifiedTitle: "Comprador real, verificado mediante un enlace de pedido",
|
|
394
|
-
loadMore: "Mostrar más",
|
|
394
|
+
loadMore: "Mostrar más valoraciones",
|
|
395
395
|
loading: "Cargando...",
|
|
396
396
|
dateToday: "hoy",
|
|
397
397
|
dateYesterday: "ayer",
|
|
@@ -399,6 +399,14 @@ export const es: StorefrontLocale = {
|
|
|
399
399
|
showPhoto: "Ver foto",
|
|
400
400
|
playVideo: "Reproducir vídeo",
|
|
401
401
|
close: "Cerrar",
|
|
402
|
+
ratingBreakdown: "Desglose de valoraciones",
|
|
403
|
+
openReview: "Abrir la valoración",
|
|
404
|
+
verifiedInfo: "Qué significa compra verificada",
|
|
405
|
+
storeReply: "Respuesta de la tienda",
|
|
406
|
+
previous: "Anterior",
|
|
407
|
+
next: "Siguiente",
|
|
408
|
+
mediaAlt: "Foto {n} de {total} de {name}",
|
|
409
|
+
showMediaAt: "Ver foto {n}",
|
|
402
410
|
expiredTitle: "Este enlace de valoración ha caducado",
|
|
403
411
|
expiredBody:
|
|
404
412
|
"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,14 +50,30 @@ 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
|
|
|
65
|
+
export {
|
|
66
|
+
ReviewSummaryHeader,
|
|
67
|
+
ReviewSortMenu,
|
|
68
|
+
type ReviewSummaryHeaderProps,
|
|
69
|
+
} from "./review-header"
|
|
70
|
+
|
|
71
|
+
export {
|
|
72
|
+
reviewWidgetOptions,
|
|
73
|
+
DEFAULT_REVIEW_WIDGET_OPTIONS,
|
|
74
|
+
type ReviewWidgetOptions,
|
|
75
|
+
} from "./widget-options"
|
|
76
|
+
|
|
61
77
|
export {
|
|
62
78
|
ReviewWizard,
|
|
63
79
|
ReviewWizardForm,
|
package/src/reviews-ui/labels.ts
CHANGED
|
@@ -31,6 +31,20 @@ export type ReviewsUiLabels = {
|
|
|
31
31
|
showPhoto: string
|
|
32
32
|
playVideo: string
|
|
33
33
|
close: string
|
|
34
|
+
/** The header's chevron that opens the 5 to 1 star breakdown. */
|
|
35
|
+
ratingBreakdown: string
|
|
36
|
+
// — lightbox —
|
|
37
|
+
/** A review card's own button: the whole card opens the lightbox. */
|
|
38
|
+
openReview: string
|
|
39
|
+
/** The info button beside "Verified"; its popover says `verifiedTitle`. */
|
|
40
|
+
verifiedInfo: string
|
|
41
|
+
storeReply: string
|
|
42
|
+
previous: string
|
|
43
|
+
next: string
|
|
44
|
+
/** `{n}` = position, `{total}` = the review's photo count, `{name}` = reviewer. */
|
|
45
|
+
mediaAlt: string
|
|
46
|
+
/** A dot under the photo; `{n}` = position. */
|
|
47
|
+
showMediaAt: string
|
|
34
48
|
// — wizard: entry states (ported from the alenika /review/[token] page) —
|
|
35
49
|
expiredTitle: string
|
|
36
50
|
/** `{email}` = store support email. */
|
|
@@ -118,14 +132,14 @@ export const defaultReviewsUiLabels: ReviewsUiLabels = {
|
|
|
118
132
|
sectionTitle: "Reviews",
|
|
119
133
|
reviewSingular: "review",
|
|
120
134
|
reviewPlural: "reviews",
|
|
121
|
-
sortAria: "Sort",
|
|
135
|
+
sortAria: "Sort by",
|
|
122
136
|
sortDefault: "Recommended",
|
|
123
137
|
sortNewest: "Newest",
|
|
124
138
|
sortRatingDesc: "Highest rated",
|
|
125
139
|
sortRatingAsc: "Lowest rated",
|
|
126
140
|
verified: "Verified",
|
|
127
141
|
verifiedTitle: "Real buyer, verified via an order link",
|
|
128
|
-
loadMore: "Show more",
|
|
142
|
+
loadMore: "Show more reviews",
|
|
129
143
|
loading: "Loading...",
|
|
130
144
|
dateToday: "today",
|
|
131
145
|
dateYesterday: "yesterday",
|
|
@@ -133,6 +147,14 @@ export const defaultReviewsUiLabels: ReviewsUiLabels = {
|
|
|
133
147
|
showPhoto: "View photo",
|
|
134
148
|
playVideo: "Play video",
|
|
135
149
|
close: "Close",
|
|
150
|
+
ratingBreakdown: "Rating breakdown",
|
|
151
|
+
openReview: "Open the review",
|
|
152
|
+
verifiedInfo: "What verified means",
|
|
153
|
+
storeReply: "Reply from the store",
|
|
154
|
+
previous: "Previous",
|
|
155
|
+
next: "Next",
|
|
156
|
+
mediaAlt: "Photo {n} of {total} from {name}",
|
|
157
|
+
showMediaAt: "Show photo {n}",
|
|
136
158
|
expiredTitle: "This review link has expired",
|
|
137
159
|
expiredBody:
|
|
138
160
|
"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,166 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { Check, ChevronDown, SlidersHorizontal, Star } from "lucide-react"
|
|
5
|
+
import type { ReviewAggregate, ReviewWidgetHeader, ReviewWidgetHeaderContent } from "../api/reviews"
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/ui/popover"
|
|
7
|
+
import { reviewCountLabel, type ReviewSortKey } from "./helpers"
|
|
8
|
+
import type { ReviewsUiLabels } from "./labels"
|
|
9
|
+
import { RatingDistribution, StarBadge, StarRow } from "./star-badge"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The reviews widget's header, Loox's three layouts:
|
|
13
|
+
*
|
|
14
|
+
* - `minimal`: the stars and one fact beside them (the review count, the
|
|
15
|
+
* average, or nothing), the breakdown under a chevron;
|
|
16
|
+
* - `compact`: the average, the stars and the count on one line, the
|
|
17
|
+
* breakdown under a chevron;
|
|
18
|
+
* - `expanded`: the section title, the big score and the breakdown open
|
|
19
|
+
* beside it (the Alenika header).
|
|
20
|
+
*
|
|
21
|
+
* The sort menu sits on the right in all three. `showDistribution` and
|
|
22
|
+
* `showSort` switch those parts off.
|
|
23
|
+
*/
|
|
24
|
+
export interface ReviewSummaryHeaderProps {
|
|
25
|
+
layout: ReviewWidgetHeader
|
|
26
|
+
content: ReviewWidgetHeaderContent
|
|
27
|
+
total: number
|
|
28
|
+
average: number
|
|
29
|
+
distribution: ReviewAggregate["distribution"]
|
|
30
|
+
showDistribution: boolean
|
|
31
|
+
showSort: boolean
|
|
32
|
+
sort: ReviewSortKey
|
|
33
|
+
onSortChange: (sort: ReviewSortKey) => void
|
|
34
|
+
labels: ReviewsUiLabels
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ReviewSummaryHeader({
|
|
38
|
+
layout,
|
|
39
|
+
content,
|
|
40
|
+
total,
|
|
41
|
+
average,
|
|
42
|
+
distribution,
|
|
43
|
+
showDistribution,
|
|
44
|
+
showSort,
|
|
45
|
+
sort,
|
|
46
|
+
onSortChange,
|
|
47
|
+
labels: l,
|
|
48
|
+
}: ReviewSummaryHeaderProps) {
|
|
49
|
+
const count = reviewCountLabel(total, l)
|
|
50
|
+
const sortMenu = showSort ? <ReviewSortMenu value={sort} onChange={onSortChange} labels={l} /> : null
|
|
51
|
+
|
|
52
|
+
if (layout === "expanded") {
|
|
53
|
+
return (
|
|
54
|
+
<header className="mb-10">
|
|
55
|
+
<h2 className="mb-6 text-h4 text-foreground">{l.sectionTitle}</h2>
|
|
56
|
+
<div className="flex flex-col gap-6 md:flex-row md:items-center md:gap-12">
|
|
57
|
+
<StarBadge aggregate={{ count: total, avg_rating: average }} labels={l} />
|
|
58
|
+
{showDistribution ? <RatingDistribution distribution={distribution} total={total} /> : null}
|
|
59
|
+
{sortMenu ? <div className="md:ml-auto md:self-start">{sortMenu}</div> : null}
|
|
60
|
+
</div>
|
|
61
|
+
</header>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const summary =
|
|
66
|
+
layout === "compact" ? (
|
|
67
|
+
<>
|
|
68
|
+
<span className="text-h6 text-foreground tabular-nums">{average.toFixed(1)}</span>
|
|
69
|
+
<StarRow rating={average} size="lg" />
|
|
70
|
+
<span className="text-body-small text-foreground">{count}</span>
|
|
71
|
+
</>
|
|
72
|
+
) : (
|
|
73
|
+
<>
|
|
74
|
+
<StarRow rating={average} size="lg" />
|
|
75
|
+
{content === "count" ? <span className="text-body-small text-foreground">{count}</span> : null}
|
|
76
|
+
{content === "average" ? (
|
|
77
|
+
<span className="text-body-small font-semibold text-foreground tabular-nums">
|
|
78
|
+
{average.toFixed(1)}
|
|
79
|
+
</span>
|
|
80
|
+
) : null}
|
|
81
|
+
</>
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<header className="mb-6 flex items-center justify-between gap-4">
|
|
86
|
+
{showDistribution ? (
|
|
87
|
+
<Popover>
|
|
88
|
+
<PopoverTrigger
|
|
89
|
+
aria-label={`${l.ratingBreakdown}: ${count}`}
|
|
90
|
+
className="group flex items-center gap-2 rounded-md text-left"
|
|
91
|
+
>
|
|
92
|
+
{summary}
|
|
93
|
+
<ChevronDown
|
|
94
|
+
aria-hidden
|
|
95
|
+
className="size-4 text-foreground transition-transform group-data-[state=open]:rotate-180"
|
|
96
|
+
/>
|
|
97
|
+
</PopoverTrigger>
|
|
98
|
+
<PopoverContent align="start" className="w-[min(26rem,calc(100vw-2rem))] p-6">
|
|
99
|
+
<div className="mb-4 flex items-center justify-center gap-2">
|
|
100
|
+
<Star aria-hidden className="size-7 fill-rating text-rating" strokeWidth={0} />
|
|
101
|
+
<span className="text-h3 text-foreground tabular-nums">{average.toFixed(1)}</span>
|
|
102
|
+
</div>
|
|
103
|
+
<RatingDistribution distribution={distribution} total={total} className="md:max-w-none" />
|
|
104
|
+
</PopoverContent>
|
|
105
|
+
</Popover>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="flex items-center gap-2">{summary}</div>
|
|
108
|
+
)}
|
|
109
|
+
{sortMenu}
|
|
110
|
+
</header>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const SORT_OPTIONS: Array<{ key: ReviewSortKey; label: keyof ReviewsUiLabels }> = [
|
|
115
|
+
{ key: "default", label: "sortDefault" },
|
|
116
|
+
{ key: "date", label: "sortNewest" },
|
|
117
|
+
{ key: "rating-desc", label: "sortRatingDesc" },
|
|
118
|
+
{ key: "rating-asc", label: "sortRatingAsc" },
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The sort menu: an icon button, and under it "Sort by" with the four
|
|
123
|
+
* orders, the chosen one ticked. Choosing one closes it.
|
|
124
|
+
*/
|
|
125
|
+
export function ReviewSortMenu({
|
|
126
|
+
value,
|
|
127
|
+
onChange,
|
|
128
|
+
labels: l,
|
|
129
|
+
}: {
|
|
130
|
+
value: ReviewSortKey
|
|
131
|
+
onChange: (sort: ReviewSortKey) => void
|
|
132
|
+
labels: ReviewsUiLabels
|
|
133
|
+
}) {
|
|
134
|
+
const [open, setOpen] = useState(false)
|
|
135
|
+
return (
|
|
136
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
137
|
+
<PopoverTrigger
|
|
138
|
+
aria-label={l.sortAria}
|
|
139
|
+
className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background text-foreground transition-colors hover:bg-muted data-[state=open]:bg-muted"
|
|
140
|
+
>
|
|
141
|
+
<SlidersHorizontal aria-hidden className="size-4.5" />
|
|
142
|
+
</PopoverTrigger>
|
|
143
|
+
<PopoverContent align="end" className="w-64 p-2">
|
|
144
|
+
<p className="px-3 pb-2 pt-2 text-h6 text-foreground">{l.sortAria}</p>
|
|
145
|
+
<ul>
|
|
146
|
+
{SORT_OPTIONS.map((option) => (
|
|
147
|
+
<li key={option.key}>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
aria-pressed={value === option.key}
|
|
151
|
+
onClick={() => {
|
|
152
|
+
setOpen(false)
|
|
153
|
+
if (option.key !== value) onChange(option.key)
|
|
154
|
+
}}
|
|
155
|
+
className="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-body-small text-foreground transition-colors hover:bg-muted"
|
|
156
|
+
>
|
|
157
|
+
{l[option.label]}
|
|
158
|
+
{value === option.key ? <Check aria-hidden className="size-4" /> : null}
|
|
159
|
+
</button>
|
|
160
|
+
</li>
|
|
161
|
+
))}
|
|
162
|
+
</ul>
|
|
163
|
+
</PopoverContent>
|
|
164
|
+
</Popover>
|
|
165
|
+
)
|
|
166
|
+
}
|