@cartbase/storefront 0.20.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.
- package/LICENSE +21 -21
- package/package.json +258 -258
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -166
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -193
- package/src/reviews-ui/review-widget.tsx +219 -218
- package/src/reviews-ui/widget-options.ts +55 -55
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- 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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,166 +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
|
-
}
|
|
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
|
+
}
|