@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.
@@ -1,227 +1,218 @@
1
- "use client"
2
-
3
- import { useCallback, useEffect, useState } from "react"
4
- import type { StorefrontClient } from "../api/http"
5
- import {
6
- getWidget,
7
- listReviews,
8
- type PublicReview,
9
- type ReviewWidgetPayload,
10
- } from "../api/reviews"
11
- import { cn } from "../lib/utils"
12
- import { useLocaleArea } from "../locales/context"
13
- import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
14
- import { sortParamsFor, type ReviewSortKey } from "./helpers"
15
- import { RatingDistribution, StarBadge } from "./star-badge"
16
- import { ReviewList } from "./review-list"
17
-
18
- /**
19
- * PDP reviews widget: aggregate header (score + distribution + sort) +
20
- * masonry card list + load-more. Ported from the Alenika PDP section
21
- * (src/components/product/reviews-list.tsx) with the Cartbase data seam:
22
- *
23
- * - Bootstrap is ONE call `api/reviews.getWidget(client, productId)`
24
- * (aggregate + first page sized/sorted per the store's Settings
25
- * Reviews display options; edge-cached 60s). Server-fetch it and pass
26
- * `initialData` (recommendedno client waterfall, mirrors the
27
- * production server-fetched first page); when omitted the widget
28
- * fetches it on mount.
29
- * - Sort changes and load-more re-fetch client-side via `listReviews`
30
- * (the API enforces ordering; the client just passes params — the
31
- * default sort is with-media-first then newest).
32
- * - Aggregate fallback preserved (production fix): if the aggregate is
33
- * missing but reviews loaded, derive avg + distribution from the
34
- * loaded page rather than showing a misleading "0.0" next to a filled
35
- * star. The whole section renders null only when there are genuinely
36
- * zero reviews.
37
- *
38
- * Settings that shape it (admin → Settings → Reviews): `widget_layout`
39
- * (masonry|list), `widget_page_size`, `widget_photo_first` all arrive
40
- * via `getWidget().options`; moderation decides what is visible at all.
41
- */
42
- export interface ReviewWidgetProps {
43
- client: StorefrontClient
44
- productId: string
45
- /** Server-fetched `getWidget` payload (recommended). */
46
- initialData?: ReviewWidgetPayload | null
47
- labels?: Partial<ReviewsUiLabels>
48
- className?: string
49
- }
50
-
51
- export function ReviewWidget({
52
- client,
53
- productId,
54
- initialData,
55
- labels,
56
- className,
57
- }: ReviewWidgetProps) {
58
- // The mounted language, then the store's overrides (2026-09-14): a store
59
- // that mounts StorefrontLocaleProvider passes nothing here.
60
- const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
61
- const [data, setData] = useState<ReviewWidgetPayload | null>(
62
- initialData ?? null
63
- )
64
- const [sort, setSort] = useState<ReviewSortKey>("default")
65
- const [reviews, setReviews] = useState<PublicReview[]>(
66
- initialData?.reviews ?? []
67
- )
68
- const [count, setCount] = useState<number>(initialData?.count ?? 0)
69
- const [hasMore, setHasMore] = useState<boolean>(initialData?.has_more ?? false)
70
- const [loading, setLoading] = useState(false)
71
-
72
- const pageSize = data?.options.page_size ?? 10
73
-
74
- // No server-provided bootstrap → fetch it on mount.
75
- useEffect(() => {
76
- if (initialData) return
77
- let cancelled = false
78
- ;(async () => {
79
- try {
80
- const payload = await getWidget(client, productId)
81
- if (cancelled) return
82
- setData(payload)
83
- setReviews(payload.reviews)
84
- setCount(payload.count)
85
- setHasMore(payload.has_more)
86
- } catch {
87
- /* widget is decorative — a failed bootstrap renders nothing */
88
- }
89
- })()
90
- return () => {
91
- cancelled = true
92
- }
93
- // eslint-disable-next-line react-hooks/exhaustive-deps
94
- }, [client, productId])
95
-
96
- // Re-fetch the first page whenever the sort changes (post-mount only —
97
- // the bootstrap page covers sort='default', so no extra round-trip).
98
- useEffect(() => {
99
- if (sort === "default") return
100
- let cancelled = false
101
- setLoading(true)
102
- ;(async () => {
103
- try {
104
- const params = sortParamsFor(sort)
105
- const res = await listReviews(client, {
106
- product_id: productId,
107
- ...params,
108
- limit: pageSize,
109
- offset: 0,
110
- })
111
- if (cancelled) return
112
- setReviews(res.reviews)
113
- setCount(res.count)
114
- setHasMore(res.has_more)
115
- } finally {
116
- if (!cancelled) setLoading(false)
117
- }
118
- })()
119
- return () => {
120
- cancelled = true
121
- }
122
- // eslint-disable-next-line react-hooks/exhaustive-deps
123
- }, [sort, productId, pageSize])
124
-
125
- const loadMore = useCallback(async () => {
126
- setLoading(true)
127
- try {
128
- const params = sortParamsFor(sort)
129
- const res = await listReviews(client, {
130
- product_id: productId,
131
- ...params,
132
- limit: pageSize,
133
- offset: reviews.length,
134
- })
135
- setReviews((prev) => [...prev, ...res.reviews])
136
- setCount(res.count)
137
- setHasMore(res.has_more)
138
- } finally {
139
- setLoading(false)
140
- }
141
- }, [client, productId, sort, pageSize, reviews.length])
142
-
143
- // Header summary uses the full aggregate when available; else derive
144
- // from the loaded reviews (see the header comment).
145
- const aggregate = data?.aggregate ?? null
146
- const total = aggregate?.count ?? count
147
- const avg =
148
- aggregate?.avg_rating ??
149
- (reviews.length
150
- ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length
151
- : 0)
152
- const distribution =
153
- aggregate?.distribution ??
154
- reviews.reduce(
155
- (d, r) => {
156
- const key = String(r.rating) as "1" | "2" | "3" | "4" | "5"
157
- if (key in d) d[key]++
158
- return d
159
- },
160
- { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 } as Record<
161
- "1" | "2" | "3" | "4" | "5",
162
- number
163
- >
164
- )
165
-
166
- if (total === 0) {
167
- return null // Hide the section entirely when there are no reviews
168
- }
169
-
170
- return (
171
- <section
172
- id="reviews"
173
- className={
174
- className ?? "max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-10 my-16 sm:my-24"
175
- }
176
- >
177
- <header className="mb-10">
178
- <h2 className="text-2xl sm:text-3xl font-semibold text-foreground mb-6">
179
- {l.sectionTitle}
180
- </h2>
181
-
182
- <div className="flex flex-col md:flex-row md:items-center gap-6 md:gap-12">
183
- <StarBadge
184
- aggregate={{ count: total, avg_rating: avg }}
185
- labels={l}
186
- />
187
-
188
- <RatingDistribution distribution={distribution} total={total} />
189
-
190
- <select
191
- value={sort}
192
- onChange={(e) => setSort(e.target.value as ReviewSortKey)}
193
- aria-label={l.sortAria}
194
- className="md:ml-auto md:self-start px-3 py-2 rounded-[2px] border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
195
- >
196
- <option value="default">{l.sortDefault}</option>
197
- <option value="date">{l.sortNewest}</option>
198
- <option value="rating-desc">{l.sortRatingDesc}</option>
199
- <option value="rating-asc">{l.sortRatingAsc}</option>
200
- </select>
201
- </div>
202
- </header>
203
-
204
- <ReviewList
205
- reviews={reviews}
206
- layout={data?.options.layout ?? "masonry"}
207
- labels={l}
208
- />
209
-
210
- {hasMore && (
211
- <div className="mt-8 text-center">
212
- <button
213
- type="button"
214
- onClick={loadMore}
215
- disabled={loading}
216
- className={cn(
217
- "inline-flex items-center justify-center px-6 py-2.5 rounded-[2px] border border-border text-sm font-medium transition-colors",
218
- loading ? "opacity-50 cursor-not-allowed" : "hover:bg-muted"
219
- )}
220
- >
221
- {loading ? l.loading : l.loadMore}
222
- </button>
223
- </div>
224
- )}
225
- </section>
226
- )
227
- }
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react"
4
+ import type { StorefrontClient } from "../api/http"
5
+ import {
6
+ getWidget,
7
+ listReviews,
8
+ type PublicReview,
9
+ type ReviewWidgetPayload,
10
+ } from "../api/reviews"
11
+ import { cn } from "../lib/utils"
12
+ import { useLocaleArea } from "../locales/context"
13
+ import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
14
+ import { sortParamsFor, type ReviewSortKey } from "./helpers"
15
+ import { ReviewSummaryHeader } from "./review-header"
16
+ import { ReviewList } from "./review-list"
17
+ import { reviewWidgetOptions } from "./widget-options"
18
+
19
+ /**
20
+ * The product reviews widget, the reviews app's grid for the bottom of a
21
+ * product page (Loox's Reviews Widget): a header, the review cards, photo
22
+ * reviews first, and "Show more reviews" under them; a card opens its
23
+ * review in the lightbox. Ported from the Alenika PDP section
24
+ * (src/components/product/reviews-list.tsx) with the Cartbase data seam:
25
+ *
26
+ * - Bootstrap is ONE call `api/reviews.getWidget(client, productId)`
27
+ * (aggregate + first page + the store's widget settings; edge-cached).
28
+ * Server-fetch it and pass `initialData` (recommended — no client
29
+ * waterfall); when omitted the widget fetches it on mount.
30
+ * - The store's settings (admin, Reviews, Widgets) arrive in
31
+ * `options` and shape it: the header (`minimal`, `compact`,
32
+ * `expanded`, and what the minimal one shows), the rating breakdown,
33
+ * the sort menu, the date on the cards, masonry or list, and the page
34
+ * size. A field an older platform does not send takes Loox's default
35
+ * (`reviewWidgetOptions`).
36
+ * - Sort changes and "Show more reviews" re-fetch client-side via
37
+ * `listReviews` (the API orders; the default sort is photo reviews
38
+ * first, then newest).
39
+ * - Aggregate fallback preserved (production fix): if the aggregate is
40
+ * missing but reviews loaded, derive avg + distribution from the
41
+ * loaded page rather than showing a misleading "0.0" next to a filled
42
+ * star. The whole section renders null only when there are genuinely
43
+ * zero reviews.
44
+ */
45
+ export interface ReviewWidgetProps {
46
+ client: StorefrontClient
47
+ productId: string
48
+ /** Server-fetched `getWidget` payload (recommended). */
49
+ initialData?: ReviewWidgetPayload | null
50
+ labels?: Partial<ReviewsUiLabels>
51
+ className?: string
52
+ }
53
+
54
+ export function ReviewWidget({
55
+ client,
56
+ productId,
57
+ initialData,
58
+ labels,
59
+ className,
60
+ }: ReviewWidgetProps) {
61
+ // The mounted language, then the store's overrides (2026-09-14): a store
62
+ // that mounts StorefrontLocaleProvider passes nothing here.
63
+ const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
64
+ const [data, setData] = useState<ReviewWidgetPayload | null>(initialData ?? null)
65
+ // The first page is photo reviews first unless the store turned that off.
66
+ const [sort, setSort] = useState<ReviewSortKey>(
67
+ initialData?.options.photo_first === false ? "date" : "default"
68
+ )
69
+ const [reviews, setReviews] = useState<PublicReview[]>(initialData?.reviews ?? [])
70
+ const [count, setCount] = useState<number>(initialData?.count ?? 0)
71
+ const [hasMore, setHasMore] = useState<boolean>(initialData?.has_more ?? false)
72
+ const [loading, setLoading] = useState(false)
73
+ const sortChanged = useRef(false)
74
+
75
+ const options = reviewWidgetOptions(data?.options)
76
+ const pageSize = options.pageSize
77
+
78
+ // No server-provided bootstrap → fetch it on mount.
79
+ useEffect(() => {
80
+ if (initialData) return
81
+ let cancelled = false
82
+ ;(async () => {
83
+ try {
84
+ const payload = await getWidget(client, productId)
85
+ if (cancelled) return
86
+ setData(payload)
87
+ setReviews(payload.reviews)
88
+ setCount(payload.count)
89
+ setHasMore(payload.has_more)
90
+ if (payload.options.photo_first === false) setSort("date")
91
+ } catch {
92
+ /* widget is decorative — a failed bootstrap renders nothing */
93
+ }
94
+ })()
95
+ return () => {
96
+ cancelled = true
97
+ }
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [client, productId])
100
+
101
+ // Re-fetch the first page when the shopper picks a sort (the bootstrap
102
+ // page already is the first sort, so the first run is skipped).
103
+ useEffect(() => {
104
+ if (!sortChanged.current) return
105
+ let cancelled = false
106
+ setLoading(true)
107
+ ;(async () => {
108
+ try {
109
+ const res = await listReviews(client, {
110
+ product_id: productId,
111
+ ...sortParamsFor(sort),
112
+ limit: pageSize,
113
+ offset: 0,
114
+ })
115
+ if (cancelled) return
116
+ setReviews(res.reviews)
117
+ setCount(res.count)
118
+ setHasMore(res.has_more)
119
+ } finally {
120
+ if (!cancelled) setLoading(false)
121
+ }
122
+ })()
123
+ return () => {
124
+ cancelled = true
125
+ }
126
+ // eslint-disable-next-line react-hooks/exhaustive-deps
127
+ }, [sort, productId, pageSize])
128
+
129
+ const chooseSort = (next: ReviewSortKey) => {
130
+ sortChanged.current = true
131
+ setSort(next)
132
+ }
133
+
134
+ const loadMore = useCallback(async () => {
135
+ setLoading(true)
136
+ try {
137
+ const res = await listReviews(client, {
138
+ product_id: productId,
139
+ ...sortParamsFor(sort),
140
+ limit: pageSize,
141
+ offset: reviews.length,
142
+ })
143
+ setReviews((prev) => [...prev, ...res.reviews])
144
+ setCount(res.count)
145
+ setHasMore(res.has_more)
146
+ } finally {
147
+ setLoading(false)
148
+ }
149
+ }, [client, productId, sort, pageSize, reviews.length])
150
+
151
+ // Header summary uses the full aggregate when available; else derive
152
+ // from the loaded reviews (see the header comment).
153
+ const aggregate = data?.aggregate ?? null
154
+ const total = aggregate?.count ?? count
155
+ const avg =
156
+ aggregate?.avg_rating ??
157
+ (reviews.length ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length : 0)
158
+ const distribution =
159
+ aggregate?.distribution ??
160
+ reviews.reduce(
161
+ (d, r) => {
162
+ const key = String(r.rating) as "1" | "2" | "3" | "4" | "5"
163
+ if (key in d) d[key]++
164
+ return d
165
+ },
166
+ { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 } as Record<"1" | "2" | "3" | "4" | "5", number>
167
+ )
168
+
169
+ if (total === 0) {
170
+ return null // Hide the section entirely when there are no reviews
171
+ }
172
+
173
+ return (
174
+ <section
175
+ id="reviews"
176
+ className={className ?? "max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-10 my-16 sm:my-24"}
177
+ >
178
+ <ReviewSummaryHeader
179
+ layout={options.header}
180
+ content={options.headerContent}
181
+ total={total}
182
+ average={avg}
183
+ distribution={distribution}
184
+ showDistribution={options.showDistribution}
185
+ showSort={options.showSort}
186
+ sort={sort}
187
+ onSortChange={chooseSort}
188
+ labels={l}
189
+ />
190
+
191
+ <ReviewList
192
+ reviews={reviews}
193
+ layout={options.layout}
194
+ showDate={options.showDate}
195
+ labels={l}
196
+ className={cn(
197
+ options.layout === "list"
198
+ ? "columns-1 [column-gap:1rem]"
199
+ : "columns-1 sm:columns-2 lg:columns-3 xl:columns-4 [column-gap:1rem]",
200
+ loading && "opacity-60 transition-opacity"
201
+ )}
202
+ />
203
+
204
+ {hasMore && (
205
+ <div className="mt-8 text-center">
206
+ <button
207
+ type="button"
208
+ onClick={loadMore}
209
+ disabled={loading}
210
+ className="inline-flex items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-body-small text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
211
+ >
212
+ {loading ? l.loading : l.loadMore}
213
+ </button>
214
+ </div>
215
+ )}
216
+ </section>
217
+ )
218
+ }
@@ -11,23 +11,35 @@ import { reviewCountLabel } from "./helpers"
11
11
  * arrives via props (`api/reviews.getAggregate` / `getWidget().aggregate`).
12
12
  */
13
13
 
14
- /** Five-star row, filled to `Math.round(rating)`. */
14
+ const STAR_SIZE = { sm: "size-3.5", md: "size-4.5", lg: "size-5" } as const
15
+
16
+ /**
17
+ * Five-star row, filled to `Math.round(rating)` in the theme's `rating`
18
+ * colour. `md` is a review card's row, `lg` the lightbox's and the header's.
19
+ */
15
20
  export function StarRow({
16
21
  rating,
22
+ size = "sm",
17
23
  className,
18
24
  }: {
19
25
  rating: number
26
+ size?: keyof typeof STAR_SIZE
20
27
  className?: string
21
28
  }) {
22
29
  return (
23
- <span className={cn("inline-flex items-center gap-0.5", className)}>
30
+ <span
31
+ role="img"
32
+ aria-label={`${Math.round(rating)} / 5`}
33
+ className={cn("inline-flex items-center", size === "sm" ? "gap-0.5" : "gap-px", className)}
34
+ >
24
35
  {Array.from({ length: 5 }).map((_, i) => (
25
36
  <Star
26
37
  key={i}
38
+ aria-hidden
27
39
  className={cn(
28
- "w-3.5 h-3.5",
40
+ STAR_SIZE[size],
29
41
  i < Math.round(rating)
30
- ? "fill-warning text-warning"
42
+ ? "fill-rating text-rating"
31
43
  : "fill-muted text-muted-foreground"
32
44
  )}
33
45
  strokeWidth={0}
@@ -53,7 +65,7 @@ export function StarBadge({ aggregate, labels, className }: StarBadgeProps) {
53
65
  if (!aggregate.count) return null
54
66
  return (
55
67
  <div className={cn("flex items-center gap-3 shrink-0", className)}>
56
- <Star className="w-10 h-10 fill-warning text-warning" strokeWidth={0} />
68
+ <Star className="w-10 h-10 fill-rating text-rating" strokeWidth={0} />
57
69
  <div>
58
70
  <div className="text-3xl font-bold text-foreground leading-none">
59
71
  {aggregate.avg_rating.toFixed(1)}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The reviews widget's display options with every default filled, from the
3
+ * payload the platform serves (`getWidget().options`) or from nothing. A
4
+ * platform older than a field leaves it out; the widget then shows what
5
+ * Loox shows by default. Pure. Unit-tested in
6
+ * tests/unit/storefront-review-lightbox.test.ts.
7
+ */
8
+ import type {
9
+ ReviewWidgetHeader,
10
+ ReviewWidgetHeaderContent,
11
+ ReviewWidgetPayload,
12
+ } from "../api/reviews"
13
+
14
+ export interface ReviewWidgetOptions {
15
+ layout: "masonry" | "list"
16
+ pageSize: number
17
+ header: ReviewWidgetHeader
18
+ headerContent: ReviewWidgetHeaderContent
19
+ showDistribution: boolean
20
+ showSort: boolean
21
+ showDate: boolean
22
+ }
23
+
24
+ export const DEFAULT_REVIEW_WIDGET_OPTIONS: ReviewWidgetOptions = {
25
+ layout: "masonry",
26
+ pageSize: 10,
27
+ header: "minimal",
28
+ headerContent: "count",
29
+ showDistribution: true,
30
+ showSort: true,
31
+ showDate: true,
32
+ }
33
+
34
+ export function reviewWidgetOptions(
35
+ options: Partial<ReviewWidgetPayload["options"]> | null | undefined
36
+ ): ReviewWidgetOptions {
37
+ const d = DEFAULT_REVIEW_WIDGET_OPTIONS
38
+ if (!options) return { ...d }
39
+ return {
40
+ layout: options.layout === "list" ? "list" : d.layout,
41
+ pageSize:
42
+ typeof options.page_size === "number" && options.page_size > 0
43
+ ? Math.trunc(options.page_size)
44
+ : d.pageSize,
45
+ header:
46
+ options.header === "compact" || options.header === "expanded" ? options.header : d.header,
47
+ headerContent:
48
+ options.header_content === "average" || options.header_content === "stars"
49
+ ? options.header_content
50
+ : d.headerContent,
51
+ showDistribution: options.show_distribution ?? d.showDistribution,
52
+ showSort: options.show_sort ?? d.showSort,
53
+ showDate: options.show_date ?? d.showDate,
54
+ }
55
+ }
@@ -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, useSearchParams } from "next/navigation"
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
- const params = new URLSearchParams(searchParams)
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, useSearchParams } from "next/navigation"
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
- const createQueryString = useCallback(
45
- (name: string, value: string) => {
46
- const params = new URLSearchParams(searchParams)
47
- params.set(name, value)
48
- return params.toString()
49
- },
50
- [searchParams]
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
@@ -40,6 +40,7 @@
40
40
  --color-success-foreground: var(--success-foreground);
41
41
  --color-warning: var(--warning);
42
42
  --color-warning-foreground: var(--warning-foreground);
43
+ --color-rating: var(--rating);
43
44
 
44
45
  --color-border: var(--border);
45
46
  --color-input: var(--input);
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);