@cartbase/storefront 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -258
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/reviews.ts +22 -1
  20. package/src/api/search.ts +163 -163
  21. package/src/api/store.ts +35 -35
  22. package/src/api/types.ts +91 -91
  23. package/src/cart-drawer/context.tsx +778 -778
  24. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  25. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  26. package/src/cart-drawer/gift-wrap.tsx +82 -82
  27. package/src/cart-drawer/item/index.tsx +162 -162
  28. package/src/cart-drawer/item/upsell.tsx +110 -110
  29. package/src/cart-drawer/labels.ts +123 -123
  30. package/src/cart-drawer/mutation-queue.ts +78 -78
  31. package/src/cart-drawer/notes.tsx +131 -131
  32. package/src/cart-drawer/sticky-footer.tsx +73 -73
  33. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  34. package/src/checkout/address-error-copy.ts +117 -117
  35. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  36. package/src/checkout/compare-addresses.ts +40 -40
  37. package/src/checkout/discount-section.tsx +218 -218
  38. package/src/checkout/error-copy-codes.ts +63 -63
  39. package/src/checkout/geocode.ts +154 -154
  40. package/src/checkout/gift-card-section.tsx +224 -224
  41. package/src/checkout/index.ts +72 -72
  42. package/src/checkout/labels.ts +495 -495
  43. package/src/checkout/payment-button.tsx +372 -372
  44. package/src/checkout/payment-error-copy.ts +154 -154
  45. package/src/checkout/promotion-error-copy.ts +91 -91
  46. package/src/common/country-flag.tsx +52 -52
  47. package/src/common/country-select.tsx +11 -11
  48. package/src/common/index.ts +20 -20
  49. package/src/common/market-select.tsx +57 -57
  50. package/src/index.ts +12 -12
  51. package/src/lib/country-name.ts +59 -59
  52. package/src/lib/get-product-price.ts +133 -133
  53. package/src/lib/media-image.tsx +39 -39
  54. package/src/lib/payment-constants.ts +53 -53
  55. package/src/lib/platform.ts +13 -13
  56. package/src/lib/price.tsx +39 -39
  57. package/src/lib/store-api-error.ts +36 -36
  58. package/src/lib/variant-caption.ts +32 -32
  59. package/src/locales/bg.ts +3 -2
  60. package/src/locales/context.ts +37 -37
  61. package/src/locales/en.ts +26 -26
  62. package/src/locales/es.ts +3 -2
  63. package/src/locales/index.ts +19 -19
  64. package/src/locales/provider.tsx +59 -59
  65. package/src/locales/types.ts +77 -77
  66. package/src/order/index.ts +62 -62
  67. package/src/order/labels.ts +79 -79
  68. package/src/order/order-totals.tsx +250 -250
  69. package/src/primitives/select-field.tsx +93 -93
  70. package/src/products/image-gallery.tsx +43 -43
  71. package/src/products/option-select.tsx +67 -67
  72. package/src/products/product-info.tsx +5 -5
  73. package/src/products/product-price.tsx +69 -69
  74. package/src/products/product-promises.tsx +61 -61
  75. package/src/products/product-specs.tsx +65 -65
  76. package/src/products/product-tabs.tsx +123 -123
  77. package/src/products/purchase-options.tsx +130 -130
  78. package/src/products/sets.ts +110 -110
  79. package/src/products/variant-matching.ts +71 -71
  80. package/src/products/variant-url.ts +74 -74
  81. package/src/reviews-ui/index.ts +12 -0
  82. package/src/reviews-ui/labels.ts +5 -2
  83. package/src/reviews-ui/lightbox-state.ts +46 -46
  84. package/src/reviews-ui/review-header.tsx +166 -0
  85. package/src/reviews-ui/review-lightbox.tsx +271 -271
  86. package/src/reviews-ui/review-list.tsx +193 -191
  87. package/src/reviews-ui/review-widget.tsx +63 -71
  88. package/src/reviews-ui/star-badge.tsx +6 -4
  89. package/src/reviews-ui/widget-options.ts +55 -0
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. package/theme/index.css +25 -25
@@ -1,74 +1,74 @@
1
- /**
2
- * The product page contract, part one: THE ADDRESS IS THE STATE.
3
- *
4
- * A product page names its chosen variant in the address, the way Shopify
5
- * does: `/products/<handle>?variant=<id>`. One parameter name for every
6
- * store, so a link in an email, an ad or a chat opens on the variant it was
7
- * copied from, and a refresh never forgets a choice.
8
- *
9
- * The value is the id's DIGITS, never the prefixed key: the platform's law
10
- * for every id in a URL (entity-numbers-everywhere card). `pvar_1000683784068`
11
- * travels as `1000683784068`; reading accepts both forms, so an old link
12
- * with the whole key still resolves.
13
- *
14
- * The default, when the address names nothing, is THE MERCHANT'S: the
15
- * variant made of each option's first value, in the option order the admin
16
- * shows. Not the first variant of the wire, which is whatever the database
17
- * scanned first (2026-09-15, evoo: the carton before the bottle on a product
18
- * whose option says bottle first). Pure module, no React.
19
- */
20
- import type { StoreProduct, StoreProductVariant } from "../api/products"
21
- import { findMatchingVariant } from "./variant-matching"
22
-
23
- /** Shopify's parameter name, and ours. */
24
- export const VARIANT_PARAM = "variant"
25
-
26
- /** The digits of a platform id; an id without a prefix travels whole. */
27
- export function variantParamValue(variantId: string): string {
28
- const underscore = variantId.indexOf("_")
29
- return underscore >= 0 ? variantId.slice(underscore + 1) : variantId
30
- }
31
-
32
- /** The variant an address names, by its digits or by its whole id. */
33
- export function findVariantByParam<V extends { id: string }>(
34
- variants: V[] | null | undefined,
35
- value: string | null | undefined
36
- ): V | undefined {
37
- if (!value) return undefined
38
- return variants?.find((v) => v.id === value || variantParamValue(v.id) === value)
39
- }
40
-
41
- /**
42
- * The merchant's default variant: each option's first value, together;
43
- * failing that, the lowest rank; failing that, the first of the wire.
44
- */
45
- export function defaultVariant(
46
- product: Pick<StoreProduct, "options" | "variants">
47
- ): StoreProductVariant | undefined {
48
- const variants = product.variants ?? []
49
- if (!variants.length) return undefined
50
- if (variants.length === 1) return variants[0]
51
-
52
- const firstValues: Record<string, string> = {}
53
- for (const option of product.options ?? []) {
54
- const first = option.values?.[0]?.value
55
- if (first) firstValues[option.id] = first
56
- }
57
- const byOptions = Object.keys(firstValues).length
58
- ? findMatchingVariant(variants, firstValues)
59
- : undefined
60
- if (byOptions) return byOptions
61
-
62
- return [...variants].sort((a, b) => (a.variant_rank ?? 0) - (b.variant_rank ?? 0))[0]
63
- }
64
-
65
- /** The page's address with the variant written in, every other parameter kept. */
66
- export function variantHref(
67
- pathname: string,
68
- search: string | URLSearchParams | null | undefined,
69
- variantId: string
70
- ): string {
71
- const params = new URLSearchParams(search ?? "")
72
- params.set(VARIANT_PARAM, variantParamValue(variantId))
73
- return `${pathname}?${params.toString()}`
74
- }
1
+ /**
2
+ * The product page contract, part one: THE ADDRESS IS THE STATE.
3
+ *
4
+ * A product page names its chosen variant in the address, the way Shopify
5
+ * does: `/products/<handle>?variant=<id>`. One parameter name for every
6
+ * store, so a link in an email, an ad or a chat opens on the variant it was
7
+ * copied from, and a refresh never forgets a choice.
8
+ *
9
+ * The value is the id's DIGITS, never the prefixed key: the platform's law
10
+ * for every id in a URL (entity-numbers-everywhere card). `pvar_1000683784068`
11
+ * travels as `1000683784068`; reading accepts both forms, so an old link
12
+ * with the whole key still resolves.
13
+ *
14
+ * The default, when the address names nothing, is THE MERCHANT'S: the
15
+ * variant made of each option's first value, in the option order the admin
16
+ * shows. Not the first variant of the wire, which is whatever the database
17
+ * scanned first (2026-09-15, evoo: the carton before the bottle on a product
18
+ * whose option says bottle first). Pure module, no React.
19
+ */
20
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
21
+ import { findMatchingVariant } from "./variant-matching"
22
+
23
+ /** Shopify's parameter name, and ours. */
24
+ export const VARIANT_PARAM = "variant"
25
+
26
+ /** The digits of a platform id; an id without a prefix travels whole. */
27
+ export function variantParamValue(variantId: string): string {
28
+ const underscore = variantId.indexOf("_")
29
+ return underscore >= 0 ? variantId.slice(underscore + 1) : variantId
30
+ }
31
+
32
+ /** The variant an address names, by its digits or by its whole id. */
33
+ export function findVariantByParam<V extends { id: string }>(
34
+ variants: V[] | null | undefined,
35
+ value: string | null | undefined
36
+ ): V | undefined {
37
+ if (!value) return undefined
38
+ return variants?.find((v) => v.id === value || variantParamValue(v.id) === value)
39
+ }
40
+
41
+ /**
42
+ * The merchant's default variant: each option's first value, together;
43
+ * failing that, the lowest rank; failing that, the first of the wire.
44
+ */
45
+ export function defaultVariant(
46
+ product: Pick<StoreProduct, "options" | "variants">
47
+ ): StoreProductVariant | undefined {
48
+ const variants = product.variants ?? []
49
+ if (!variants.length) return undefined
50
+ if (variants.length === 1) return variants[0]
51
+
52
+ const firstValues: Record<string, string> = {}
53
+ for (const option of product.options ?? []) {
54
+ const first = option.values?.[0]?.value
55
+ if (first) firstValues[option.id] = first
56
+ }
57
+ const byOptions = Object.keys(firstValues).length
58
+ ? findMatchingVariant(variants, firstValues)
59
+ : undefined
60
+ if (byOptions) return byOptions
61
+
62
+ return [...variants].sort((a, b) => (a.variant_rank ?? 0) - (b.variant_rank ?? 0))[0]
63
+ }
64
+
65
+ /** The page's address with the variant written in, every other parameter kept. */
66
+ export function variantHref(
67
+ pathname: string,
68
+ search: string | URLSearchParams | null | undefined,
69
+ variantId: string
70
+ ): string {
71
+ const params = new URLSearchParams(search ?? "")
72
+ params.set(VARIANT_PARAM, variantParamValue(variantId))
73
+ return `${pathname}?${params.toString()}`
74
+ }
@@ -62,6 +62,18 @@ export {
62
62
 
63
63
  export { ReviewWidget, type ReviewWidgetProps } from "./review-widget"
64
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
+
65
77
  export {
66
78
  ReviewWizard,
67
79
  ReviewWizardForm,
@@ -31,6 +31,8 @@ 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
34
36
  // — lightbox —
35
37
  /** A review card's own button: the whole card opens the lightbox. */
36
38
  openReview: string
@@ -130,14 +132,14 @@ export const defaultReviewsUiLabels: ReviewsUiLabels = {
130
132
  sectionTitle: "Reviews",
131
133
  reviewSingular: "review",
132
134
  reviewPlural: "reviews",
133
- sortAria: "Sort",
135
+ sortAria: "Sort by",
134
136
  sortDefault: "Recommended",
135
137
  sortNewest: "Newest",
136
138
  sortRatingDesc: "Highest rated",
137
139
  sortRatingAsc: "Lowest rated",
138
140
  verified: "Verified",
139
141
  verifiedTitle: "Real buyer, verified via an order link",
140
- loadMore: "Show more",
142
+ loadMore: "Show more reviews",
141
143
  loading: "Loading...",
142
144
  dateToday: "today",
143
145
  dateYesterday: "yesterday",
@@ -145,6 +147,7 @@ export const defaultReviewsUiLabels: ReviewsUiLabels = {
145
147
  showPhoto: "View photo",
146
148
  playVideo: "Play video",
147
149
  close: "Close",
150
+ ratingBreakdown: "Rating breakdown",
148
151
  openReview: "Open the review",
149
152
  verifiedInfo: "What verified means",
150
153
  storeReply: "Reply from the store",
@@ -1,46 +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
- }
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
+ }