@cartbase/storefront 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +258 -258
- package/src/cart-drawer/context.tsx +111 -25
- package/src/cart-drawer/index.ts +2 -0
- package/src/checkout/use-checkout-orchestration.ts +12 -0
- package/src/common/cart-button-client.tsx +7 -3
- package/src/lib/cookie-names.ts +51 -3
- package/src/locales/bg.ts +7 -0
- package/src/locales/es.ts +7 -0
- package/src/products/product-actions-wrapper.tsx +10 -3
- package/src/products/product-template.tsx +9 -5
- package/src/products/use-product-actions.ts +9 -6
- package/src/reviews-ui/index.ts +8 -4
- package/src/reviews-ui/labels.ts +19 -0
- package/src/reviews-ui/lightbox-state.ts +46 -0
- package/src/reviews-ui/review-lightbox.tsx +271 -0
- package/src/reviews-ui/review-list.tsx +191 -251
- package/src/reviews-ui/star-badge.tsx +15 -5
- package/src/store/pagination.tsx +4 -3
- package/src/store/sort-select.tsx +8 -10
- package/theme/theme.css +1 -0
- package/theme/tokens.css +5 -0
|
@@ -1,251 +1,191 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import { ShieldCheck
|
|
5
|
-
import type { PublicReview } from "../api/reviews"
|
|
6
|
-
import { useLocaleArea } from "../locales/context"
|
|
7
|
-
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
8
|
-
import { formatReviewDate, reviewDisplayName } from "./helpers"
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<span className="text-
|
|
121
|
-
{
|
|
122
|
-
</span>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}: {
|
|
193
|
-
review: PublicReview | undefined
|
|
194
|
-
mediaIdx: number
|
|
195
|
-
closeLabel?: string
|
|
196
|
-
onClose: () => void
|
|
197
|
-
}) {
|
|
198
|
-
const fallback = useLocaleArea("reviews", defaultReviewsUiLabels)
|
|
199
|
-
// Close on Escape
|
|
200
|
-
useEffect(() => {
|
|
201
|
-
const onKey = (e: KeyboardEvent) => {
|
|
202
|
-
if (e.key === "Escape") onClose()
|
|
203
|
-
}
|
|
204
|
-
window.addEventListener("keydown", onKey)
|
|
205
|
-
return () => window.removeEventListener("keydown", onKey)
|
|
206
|
-
}, [onClose])
|
|
207
|
-
|
|
208
|
-
if (!review) return null
|
|
209
|
-
// Index into the SAME filtered array the grid used to compute mediaIdx,
|
|
210
|
-
// so a falsy/hidden entry can't shift which item opens (production fix).
|
|
211
|
-
const items = (review.media ?? []).filter(Boolean)
|
|
212
|
-
const item = items[mediaIdx]
|
|
213
|
-
if (!item) return null
|
|
214
|
-
|
|
215
|
-
return (
|
|
216
|
-
<div
|
|
217
|
-
role="dialog"
|
|
218
|
-
aria-modal="true"
|
|
219
|
-
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
|
|
220
|
-
onClick={onClose}
|
|
221
|
-
>
|
|
222
|
-
<button
|
|
223
|
-
type="button"
|
|
224
|
-
aria-label={closeLabel ?? fallback.close}
|
|
225
|
-
onClick={onClose}
|
|
226
|
-
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-background/10 hover:bg-background/20 text-background flex items-center justify-center"
|
|
227
|
-
>
|
|
228
|
-
<X className="w-5 h-5" />
|
|
229
|
-
</button>
|
|
230
|
-
<div
|
|
231
|
-
className="max-w-5xl max-h-[90vh] w-full flex items-center justify-center"
|
|
232
|
-
onClick={(e) => e.stopPropagation()}
|
|
233
|
-
>
|
|
234
|
-
{item.type === "image" ? (
|
|
235
|
-
<img
|
|
236
|
-
src={item.url}
|
|
237
|
-
alt=""
|
|
238
|
-
className="max-w-full max-h-[90vh] object-contain"
|
|
239
|
-
/>
|
|
240
|
-
) : (
|
|
241
|
-
<video
|
|
242
|
-
src={item.url}
|
|
243
|
-
controls
|
|
244
|
-
autoPlay
|
|
245
|
-
className="max-w-full max-h-[90vh]"
|
|
246
|
-
/>
|
|
247
|
-
)}
|
|
248
|
-
</div>
|
|
249
|
-
</div>
|
|
250
|
-
)
|
|
251
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { ShieldCheck } from "lucide-react"
|
|
5
|
+
import type { PublicReview } from "../api/reviews"
|
|
6
|
+
import { useLocaleArea } from "../locales/context"
|
|
7
|
+
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
8
|
+
import { formatReviewDate, reviewDisplayName } from "./helpers"
|
|
9
|
+
import { lightboxMedia, type LightboxPosition } from "./lightbox-state"
|
|
10
|
+
import { ReviewLightbox } from "./review-lightbox"
|
|
11
|
+
import { StarRow } from "./star-badge"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Review cards (masonry) + the review lightbox. Ported from the Alenika PDP
|
|
15
|
+
* reviews section (src/components/product/reviews-list.tsx, the card
|
|
16
|
+
* grid), presentational half: the fetch/sort/paging orchestration lives in
|
|
17
|
+
* `ReviewWidget`. Data: `api/reviews` `PublicReview[]` (hidden media is
|
|
18
|
+
* filtered out server-side, the client just renders what it gets).
|
|
19
|
+
*
|
|
20
|
+
* A click anywhere on a card opens that review in `ReviewLightbox`; a click
|
|
21
|
+
* on one of its photos opens it on that photo.
|
|
22
|
+
*
|
|
23
|
+
* Verified-purchaser badge is unconditional — every Cartbase review comes
|
|
24
|
+
* from a tokenized post-purchase email, so the badge is a tautology of
|
|
25
|
+
* the system, not an opt-in flag.
|
|
26
|
+
*
|
|
27
|
+
* Masonry: CSS multi-column so photo reviews (tall) and text-only reviews
|
|
28
|
+
* (short) pack together without empty gaps; `break-inside-avoid` keeps
|
|
29
|
+
* each card whole. Images-first ordering comes from the API's default
|
|
30
|
+
* sort, so photo cards lead the flow. `layout="list"` (the store's
|
|
31
|
+
* widget_layout setting) renders a single column instead.
|
|
32
|
+
*/
|
|
33
|
+
export interface ReviewListProps {
|
|
34
|
+
reviews: PublicReview[]
|
|
35
|
+
/** Store display option (`getWidget().options.layout`). Default masonry. */
|
|
36
|
+
layout?: "masonry" | "list"
|
|
37
|
+
labels?: Partial<ReviewsUiLabels>
|
|
38
|
+
className?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ReviewList({
|
|
42
|
+
reviews,
|
|
43
|
+
layout = "masonry",
|
|
44
|
+
labels,
|
|
45
|
+
className,
|
|
46
|
+
}: ReviewListProps) {
|
|
47
|
+
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
48
|
+
const [open, setOpen] = useState<LightboxPosition | null>(null)
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<>
|
|
52
|
+
<ul
|
|
53
|
+
className={
|
|
54
|
+
className ??
|
|
55
|
+
(layout === "list"
|
|
56
|
+
? "columns-1 [column-gap:1rem]"
|
|
57
|
+
: "columns-1 sm:columns-2 lg:columns-3 xl:columns-4 [column-gap:1rem]")
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
{reviews.map((r, index) => {
|
|
61
|
+
const media = lightboxMedia(r)
|
|
62
|
+
const hero = media[0]
|
|
63
|
+
const rest = media.slice(1)
|
|
64
|
+
return (
|
|
65
|
+
<li
|
|
66
|
+
key={r.id}
|
|
67
|
+
className="relative break-inside-avoid mb-4 rounded-[2px] border border-border bg-background overflow-hidden"
|
|
68
|
+
>
|
|
69
|
+
{/* The whole card is the button; the photos sit above it. */}
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => setOpen({ review: index, media: 0 })}
|
|
73
|
+
aria-label={`${l.openReview}: ${reviewDisplayName(r.customer_name)}`}
|
|
74
|
+
className="absolute inset-0 z-0 cursor-pointer"
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
{/* Hero media on top */}
|
|
78
|
+
{hero && (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={() => setOpen({ review: index, media: 0 })}
|
|
82
|
+
aria-label={hero.type === "image" ? l.showPhoto : l.playVideo}
|
|
83
|
+
className="relative z-10 block w-full group"
|
|
84
|
+
>
|
|
85
|
+
{hero.type === "image" ? (
|
|
86
|
+
<img
|
|
87
|
+
src={hero.thumb || hero.url}
|
|
88
|
+
alt=""
|
|
89
|
+
loading="lazy"
|
|
90
|
+
className="block w-full h-auto object-cover transition-transform group-hover:scale-[1.02]"
|
|
91
|
+
/>
|
|
92
|
+
) : (
|
|
93
|
+
<>
|
|
94
|
+
{/* `#t=0.001`: iOS Safari paints no first frame from
|
|
95
|
+
`preload="metadata"` alone; the seek makes it. */}
|
|
96
|
+
<video
|
|
97
|
+
src={`${hero.url}#t=0.001`}
|
|
98
|
+
muted
|
|
99
|
+
playsInline
|
|
100
|
+
preload="metadata"
|
|
101
|
+
className="block w-full h-auto object-cover bg-black"
|
|
102
|
+
/>
|
|
103
|
+
<span className="absolute inset-0 flex items-center justify-center">
|
|
104
|
+
<span className="w-11 h-11 rounded-full bg-background/80 flex items-center justify-center">
|
|
105
|
+
<span
|
|
106
|
+
aria-hidden
|
|
107
|
+
className="w-0 h-0 border-y-[7px] border-y-transparent border-l-[12px] border-l-foreground ml-0.5"
|
|
108
|
+
/>
|
|
109
|
+
</span>
|
|
110
|
+
</span>
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<div className="p-4 sm:p-5">
|
|
117
|
+
<StarRow rating={r.rating} />
|
|
118
|
+
|
|
119
|
+
<div className="flex items-center flex-wrap gap-x-2 gap-y-0.5 mt-2">
|
|
120
|
+
<span className="text-sm font-medium text-foreground">
|
|
121
|
+
{reviewDisplayName(r.customer_name)}
|
|
122
|
+
</span>
|
|
123
|
+
<span
|
|
124
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
|
125
|
+
title={l.verifiedTitle}
|
|
126
|
+
>
|
|
127
|
+
<ShieldCheck className="w-3.5 h-3.5" />
|
|
128
|
+
{l.verified}
|
|
129
|
+
</span>
|
|
130
|
+
<span className="text-xs text-muted-foreground ml-auto">
|
|
131
|
+
{formatReviewDate(r.created_at, l)}
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{r.title && (
|
|
136
|
+
<h3 className="text-base font-semibold text-foreground leading-snug mt-3">
|
|
137
|
+
{r.title}
|
|
138
|
+
</h3>
|
|
139
|
+
)}
|
|
140
|
+
{r.body && (
|
|
141
|
+
<p className="text-sm text-foreground/90 whitespace-pre-wrap leading-relaxed mt-1.5">
|
|
142
|
+
{r.body}
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Extra media beyond the hero */}
|
|
147
|
+
{rest.length > 0 && (
|
|
148
|
+
<div className="relative z-10 grid grid-cols-4 gap-2 mt-3">
|
|
149
|
+
{rest.map((m, i) => (
|
|
150
|
+
<button
|
|
151
|
+
key={i}
|
|
152
|
+
type="button"
|
|
153
|
+
onClick={() => setOpen({ review: index, media: i + 1 })}
|
|
154
|
+
aria-label={m.type === "image" ? l.showPhoto : l.playVideo}
|
|
155
|
+
className="relative aspect-square rounded-[2px] overflow-hidden bg-muted"
|
|
156
|
+
>
|
|
157
|
+
{m.type === "image" ? (
|
|
158
|
+
<img
|
|
159
|
+
src={m.thumb || m.url}
|
|
160
|
+
alt=""
|
|
161
|
+
loading="lazy"
|
|
162
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
163
|
+
/>
|
|
164
|
+
) : (
|
|
165
|
+
<video
|
|
166
|
+
src={`${m.url}#t=0.001`}
|
|
167
|
+
muted
|
|
168
|
+
playsInline
|
|
169
|
+
preload="metadata"
|
|
170
|
+
className="absolute inset-0 w-full h-full object-cover bg-black"
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
</button>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
</li>
|
|
179
|
+
)
|
|
180
|
+
})}
|
|
181
|
+
</ul>
|
|
182
|
+
|
|
183
|
+
<ReviewLightbox
|
|
184
|
+
reviews={reviews}
|
|
185
|
+
position={open}
|
|
186
|
+
onPositionChange={setOpen}
|
|
187
|
+
labels={labels}
|
|
188
|
+
/>
|
|
189
|
+
</>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -11,23 +11,33 @@ import { reviewCountLabel } from "./helpers"
|
|
|
11
11
|
* arrives via props (`api/reviews.getAggregate` / `getWidget().aggregate`).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Five-star row, filled to `Math.round(rating)` in the theme's `rating`
|
|
16
|
+
* colour. `lg` is the lightbox's row.
|
|
17
|
+
*/
|
|
15
18
|
export function StarRow({
|
|
16
19
|
rating,
|
|
20
|
+
size = "sm",
|
|
17
21
|
className,
|
|
18
22
|
}: {
|
|
19
23
|
rating: number
|
|
24
|
+
size?: "sm" | "lg"
|
|
20
25
|
className?: string
|
|
21
26
|
}) {
|
|
22
27
|
return (
|
|
23
|
-
<span
|
|
28
|
+
<span
|
|
29
|
+
role="img"
|
|
30
|
+
aria-label={`${Math.round(rating)} / 5`}
|
|
31
|
+
className={cn("inline-flex items-center", size === "lg" ? "gap-px" : "gap-0.5", className)}
|
|
32
|
+
>
|
|
24
33
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
25
34
|
<Star
|
|
26
35
|
key={i}
|
|
36
|
+
aria-hidden
|
|
27
37
|
className={cn(
|
|
28
|
-
"
|
|
38
|
+
size === "lg" ? "size-5" : "size-3.5",
|
|
29
39
|
i < Math.round(rating)
|
|
30
|
-
? "fill-
|
|
40
|
+
? "fill-rating text-rating"
|
|
31
41
|
: "fill-muted text-muted-foreground"
|
|
32
42
|
)}
|
|
33
43
|
strokeWidth={0}
|
|
@@ -53,7 +63,7 @@ export function StarBadge({ aggregate, labels, className }: StarBadgeProps) {
|
|
|
53
63
|
if (!aggregate.count) return null
|
|
54
64
|
return (
|
|
55
65
|
<div className={cn("flex items-center gap-3 shrink-0", className)}>
|
|
56
|
-
<Star className="w-10 h-10 fill-
|
|
66
|
+
<Star className="w-10 h-10 fill-rating text-rating" strokeWidth={0} />
|
|
57
67
|
<div>
|
|
58
68
|
<div className="text-3xl font-bold text-foreground leading-none">
|
|
59
69
|
{aggregate.avg_rating.toFixed(1)}
|
package/src/store/pagination.tsx
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* the server templates read it back and convert to `offset`. Windowed
|
|
7
7
|
* layout: ≤7 pages inline, otherwise ellipsis around the current window.
|
|
8
8
|
*/
|
|
9
|
-
import { usePathname, useRouter
|
|
9
|
+
import { usePathname, useRouter } from "next/navigation"
|
|
10
10
|
import { cn } from "../lib/utils"
|
|
11
11
|
|
|
12
12
|
export function Pagination({
|
|
@@ -20,13 +20,14 @@ export function Pagination({
|
|
|
20
20
|
}) {
|
|
21
21
|
const router = useRouter()
|
|
22
22
|
const pathname = usePathname()
|
|
23
|
-
const searchParams = useSearchParams()
|
|
24
23
|
|
|
25
24
|
const arrayRange = (start: number, stop: number) =>
|
|
26
25
|
Array.from({ length: stop - start + 1 }, (_, index) => start + index)
|
|
27
26
|
|
|
28
27
|
const handlePageChange = (newPage: number) => {
|
|
29
|
-
|
|
28
|
+
// The query as it is at the click: read while rendering, it would make
|
|
29
|
+
// every listing page request data and stop it from being prerendered.
|
|
30
|
+
const params = new URLSearchParams(window.location.search)
|
|
30
31
|
params.set("page", newPage.toString())
|
|
31
32
|
router.push(`${pathname}?${params.toString()}`)
|
|
32
33
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* from `sortOptionLabelKeys` (labels.ts, pure) so map completeness against
|
|
13
13
|
* `SortOptions` is unit-tested.
|
|
14
14
|
*/
|
|
15
|
-
import { usePathname, useRouter
|
|
15
|
+
import { usePathname, useRouter } from "next/navigation"
|
|
16
16
|
import { useCallback } from "react"
|
|
17
17
|
import { cn } from "../lib/utils"
|
|
18
18
|
import type { SortOptions } from "../lib/sort-products"
|
|
@@ -35,20 +35,18 @@ export function SortSelect({
|
|
|
35
35
|
const l = { ...defaultStoreLabels, ...labels }
|
|
36
36
|
const router = useRouter()
|
|
37
37
|
const pathname = usePathname()
|
|
38
|
-
const searchParams = useSearchParams()
|
|
39
38
|
|
|
40
39
|
const sortOptions = (
|
|
41
40
|
Object.keys(sortOptionLabelKeys) as SortOptions[]
|
|
42
41
|
).map((value) => ({ value, label: l[sortOptionLabelKeys[value]] }))
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
43
|
+
// The query as it is at the click: read while rendering, it would make
|
|
44
|
+
// every listing page request data and stop it from being prerendered.
|
|
45
|
+
const createQueryString = useCallback((name: string, value: string) => {
|
|
46
|
+
const params = new URLSearchParams(window.location.search)
|
|
47
|
+
params.set(name, value)
|
|
48
|
+
return params.toString()
|
|
49
|
+
}, [])
|
|
52
50
|
|
|
53
51
|
const handleChange = (value: SortOptions) => {
|
|
54
52
|
const query = createQueryString("sortBy", value)
|
package/theme/theme.css
CHANGED
package/theme/tokens.css
CHANGED
|
@@ -44,6 +44,9 @@
|
|
|
44
44
|
--warning: oklch(0.828 0.189 84.429);
|
|
45
45
|
--warning-foreground: oklch(0.279 0.077 45.635);
|
|
46
46
|
|
|
47
|
+
/* ── Ratings: the filled review star ──────────────────────────────────── */
|
|
48
|
+
--rating: oklch(0.828 0.189 84.429);
|
|
49
|
+
|
|
47
50
|
/* ── Lines and focus ──────────────────────────────────────────────────── */
|
|
48
51
|
--border: oklch(0.922 0 0);
|
|
49
52
|
--input: oklch(0.922 0 0);
|
|
@@ -94,6 +97,8 @@
|
|
|
94
97
|
--warning: oklch(0.828 0.189 84.429);
|
|
95
98
|
--warning-foreground: oklch(0.279 0.077 45.635);
|
|
96
99
|
|
|
100
|
+
--rating: oklch(0.828 0.189 84.429);
|
|
101
|
+
|
|
97
102
|
--border: oklch(1 0 0 / 12%);
|
|
98
103
|
--input: oklch(1 0 0 / 18%);
|
|
99
104
|
--ring: oklch(0.556 0 0);
|