@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.18.1",
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": {
@@ -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: widget_layout / widget_page_size / widget_photo_first.
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: Cart | null }) {
22
- const { open } = useCartDrawer()
23
- const totalItems = productItemCount(cart?.items)
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
@@ -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 still OWNS *setting* the cart cookie carts.md is explicit that
7
- * "the SDK never persists the cart" this module is the single source of
8
- * the NAME so every consumer emits the same wire fingerprint instead of
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
- /** The variant the address names; see the product page contract. */
27
- initialVariantId?: string | null
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={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: pass `searchParams.variant` from the
63
- * page, so the server renders that variant's price, code and stock and
64
- * nothing flashes (the product page contract, `use-product-actions.ts`).
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?: string | null
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, useSearchParams } from "next/navigation"
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
- if (searchParams.get(VARIANT_PARAM) === variantParamValue(variant.id)) return
134
- window.history.replaceState(null, "", variantHref(pathname, searchParams, variant.id))
135
- }, [syncAddress, variants.length, variant, searchParams, pathname])
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
@@ -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
- ReviewList,
55
- ReviewLightbox,
56
- type ReviewListProps,
57
- } from "./review-list"
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,
@@ -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
+ }