@a4ui/core 0.21.0 → 0.23.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/README.md +17 -9
- package/dist/NumberInput-enx_0iVh.js +488 -0
- package/dist/commerce/ConditionScale.d.ts +45 -0
- package/dist/commerce/FacetSidebar.d.ts +55 -0
- package/dist/commerce/PriceBlock.d.ts +50 -0
- package/dist/commerce/index.d.ts +3 -0
- package/dist/commerce.js +465 -150
- package/dist/elements.css +138 -0
- package/dist/full.css +138 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3042 -2838
- package/dist/layout/Aurora.d.ts +5 -0
- package/dist/ui/AnnouncementBar.d.ts +36 -0
- package/dist/ui/LogoWall.d.ts +31 -0
- package/dist/ui/RatingsSummary.d.ts +44 -0
- package/dist/ui/SpecSheet.d.ts +47 -0
- package/package.json +1 -1
- package/src/commerce/ConditionScale.tsx +114 -0
- package/src/commerce/FacetSidebar.tsx +222 -0
- package/src/commerce/PriceBlock.tsx +133 -0
- package/src/commerce/index.ts +9 -0
- package/src/index.ts +5 -1
- package/src/layout/Aurora.tsx +29 -1
- package/src/ui/AnnouncementBar.tsx +124 -0
- package/src/ui/LogoWall.tsx +94 -0
- package/src/ui/RatingsSummary.tsx +128 -0
- package/src/ui/SpecSheet.tsx +89 -0
- package/dist/Checkbox-DIzLQltI.js +0 -431
package/dist/layout/Aurora.d.ts
CHANGED
|
@@ -4,6 +4,11 @@ export interface AuroraProps {
|
|
|
4
4
|
intensity?: number;
|
|
5
5
|
/** Slowly drift/scale the blobs. Reduced-motion aware (holds still). @default false */
|
|
6
6
|
animated?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* A soft glow that follows the cursor across the backdrop (plus cursor-tracking
|
|
9
|
+
* on any `.glow-edge` cards). Reduced-motion aware (off when reduced). @default true
|
|
10
|
+
*/
|
|
11
|
+
pointerGlow?: boolean;
|
|
7
12
|
class?: string;
|
|
8
13
|
}
|
|
9
14
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** Background tone of an {@link AnnouncementBar}. */
|
|
3
|
+
export type AnnouncementTone = 'primary' | 'accent' | 'neutral';
|
|
4
|
+
export interface AnnouncementBarProps {
|
|
5
|
+
/** Message content. */
|
|
6
|
+
children: JSX.Element;
|
|
7
|
+
/** Background tone. Defaults to `'primary'`. */
|
|
8
|
+
tone?: AnnouncementTone;
|
|
9
|
+
/** Show a dismiss (X) button. Defaults to `false`. */
|
|
10
|
+
dismissible?: boolean;
|
|
11
|
+
/** Optional coupon code rendered as a copyable pill. */
|
|
12
|
+
couponCode?: string;
|
|
13
|
+
/** Make the whole message a link. */
|
|
14
|
+
href?: string;
|
|
15
|
+
/** Called after the bar is dismissed. */
|
|
16
|
+
onDismiss?: () => void;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A full-width promo/notice bar, meant to sit above or below the page header.
|
|
21
|
+
* Supports an optional dismiss button, an optional link wrapping the message,
|
|
22
|
+
* and an optional copyable coupon-code pill.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <AnnouncementBar
|
|
27
|
+
* tone="accent"
|
|
28
|
+
* dismissible
|
|
29
|
+
* couponCode="SAVE20"
|
|
30
|
+
* href="/sale"
|
|
31
|
+
* >
|
|
32
|
+
* Summer sale is live — 20% off everything
|
|
33
|
+
* </AnnouncementBar>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function AnnouncementBar(props: AnnouncementBarProps): JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface LogoItem {
|
|
3
|
+
src: string;
|
|
4
|
+
alt: string;
|
|
5
|
+
href?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface LogoWallProps {
|
|
8
|
+
logos: LogoItem[];
|
|
9
|
+
/** Grayscale until hover. Defaults to true. */
|
|
10
|
+
grayscale?: boolean;
|
|
11
|
+
/** Columns on desktop. Defaults to a responsive auto-fit. */
|
|
12
|
+
columns?: number;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* "As seen in" grid of press/partner logos, grayscale by default with a
|
|
17
|
+
* color-on-hover reveal. Each logo can optionally link out; sits neutral
|
|
18
|
+
* on any background.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <LogoWall
|
|
23
|
+
* logos={[
|
|
24
|
+
* { src: 'https://picsum.photos/seed/techdaily/160/48', alt: 'TechDaily' },
|
|
25
|
+
* { src: 'https://picsum.photos/seed/stylemag/160/48', alt: 'StyleMag', href: 'https://stylemag.example' },
|
|
26
|
+
* { src: 'https://picsum.photos/seed/marketwire/160/48', alt: 'MarketWire' },
|
|
27
|
+
* ]}
|
|
28
|
+
* />
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function LogoWall(props: LogoWallProps): JSX.Element;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** One aggregate rating rendered as a tile by {@link RatingsSummary}. */
|
|
3
|
+
export interface RatingSource {
|
|
4
|
+
/** Platform / source name, e.g. `"Google"`. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Headline score, e.g. `4.9` or `"A+"`. */
|
|
7
|
+
score: string | number;
|
|
8
|
+
/** Denominator, e.g. `5`. Shown as `score/outOf` when provided and `score` is numeric. */
|
|
9
|
+
outOf?: string | number;
|
|
10
|
+
/** Number of reviews/feedback backing the score. */
|
|
11
|
+
count?: number;
|
|
12
|
+
/** Optional logo/icon node; falls back to `name` when omitted. */
|
|
13
|
+
logo?: JSX.Element;
|
|
14
|
+
/** When set, the tile renders as a link to the source. */
|
|
15
|
+
href?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface RatingsSummaryProps {
|
|
18
|
+
/** Rating sources to render, in order, as a responsive grid. */
|
|
19
|
+
sources: RatingSource[];
|
|
20
|
+
/** Columns on desktop. Defaults to a responsive 4-column grid. */
|
|
21
|
+
columns?: number;
|
|
22
|
+
class?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Compact social-proof grid of aggregate ratings across multiple sources —
|
|
26
|
+
* a trust band for landing pages and checkout flows. Each source renders as
|
|
27
|
+
* a small glass-neutral tile with its logo/name, headline score (optionally
|
|
28
|
+
* `score/outOf`), a review count, and — when the score is numeric out of 5 —
|
|
29
|
+
* a row of filled stars. Tiles with an `href` render as links that open in
|
|
30
|
+
* a new tab.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <RatingsSummary
|
|
35
|
+
* sources={[
|
|
36
|
+
* { name: 'Google', score: 4.8, outOf: 5, count: 2140, href: 'https://example.com/reviews' },
|
|
37
|
+
* { name: 'Trustpilot', score: 4.6, outOf: 5, count: 980 },
|
|
38
|
+
* { name: 'Verified Buyers', score: 4.9, outOf: 5, count: 512 },
|
|
39
|
+
* { name: 'App Store', score: 'A+', count: 310 },
|
|
40
|
+
* ]}
|
|
41
|
+
* />
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function RatingsSummary(props: RatingsSummaryProps): JSX.Element;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface SpecRow {
|
|
3
|
+
label: string;
|
|
4
|
+
value: JSX.Element | string;
|
|
5
|
+
}
|
|
6
|
+
export interface SpecGroup {
|
|
7
|
+
title?: string;
|
|
8
|
+
rows: SpecRow[];
|
|
9
|
+
}
|
|
10
|
+
export interface SpecSheetProps {
|
|
11
|
+
groups: SpecGroup[];
|
|
12
|
+
/** 1 or 2 columns of rows per group on wider screens. Defaults to 1. */
|
|
13
|
+
columns?: 1 | 2;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A grouped key/value specification table, for product specs, tech sheets,
|
|
18
|
+
* or any other structured attribute list. Rows are label/value pairs with a
|
|
19
|
+
* subtle divider; groups can carry an optional uppercase title and rows can
|
|
20
|
+
* flow in one or two columns on wider screens. Designed to sit inside a
|
|
21
|
+
* `Card` with a clean, glassy-neutral look.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <SpecSheet
|
|
26
|
+
* columns={2}
|
|
27
|
+
* groups={[
|
|
28
|
+
* {
|
|
29
|
+
* title: 'Display',
|
|
30
|
+
* rows: [
|
|
31
|
+
* { label: 'Panel', value: 'AMOLED, 6.4"' },
|
|
32
|
+
* { label: 'Resolution', value: '2340 x 1080' },
|
|
33
|
+
* { label: 'Refresh rate', value: '120 Hz' },
|
|
34
|
+
* ],
|
|
35
|
+
* },
|
|
36
|
+
* {
|
|
37
|
+
* title: 'Battery',
|
|
38
|
+
* rows: [
|
|
39
|
+
* { label: 'Capacity', value: '4500 mAh' },
|
|
40
|
+
* { label: 'Charging', value: '65 W wired' },
|
|
41
|
+
* ],
|
|
42
|
+
* },
|
|
43
|
+
* ]}
|
|
44
|
+
* />
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export declare function SpecSheet(props: SpecSheetProps): JSX.Element;
|
package/package.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Horizontal grading meter for a graded score (e.g. a pre-owned item's
|
|
2
|
+
// condition), with a positioned marker, the resolved tier label, and
|
|
3
|
+
// endpoint labels under the track.
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { For, Show } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
export interface ConditionTier {
|
|
10
|
+
/** Minimum score (inclusive) at which this tier applies. */
|
|
11
|
+
min: number
|
|
12
|
+
label: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ConditionScaleProps {
|
|
16
|
+
/** Current score. */
|
|
17
|
+
value: number
|
|
18
|
+
/** Top of the scale. Defaults to 10. */
|
|
19
|
+
max?: number
|
|
20
|
+
/** Bottom of the scale. Defaults to 1. */
|
|
21
|
+
min?: number
|
|
22
|
+
/** Tier labels, highest `min` first or any order — component sorts. Defaults to a sensible New→Poor scale. */
|
|
23
|
+
tiers?: ConditionTier[]
|
|
24
|
+
/** Show numeric value (e.g. "8.5 / 10"). Defaults to true. */
|
|
25
|
+
showValue?: boolean
|
|
26
|
+
class?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TIERS: ConditionTier[] = [
|
|
30
|
+
{ min: 10, label: 'Brand New' },
|
|
31
|
+
{ min: 9, label: 'Excellent' },
|
|
32
|
+
{ min: 7, label: 'Very Good' },
|
|
33
|
+
{ min: 5, label: 'Good' },
|
|
34
|
+
{ min: 1, label: 'Fair' },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Horizontal grading meter (e.g. a pre-owned item's 1–10 condition score)
|
|
39
|
+
* with a marker at the current value, the resolved tier label, and endpoint
|
|
40
|
+
* labels under the track.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* <ConditionScale value={8} />
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* <ConditionScale
|
|
50
|
+
* value={62}
|
|
51
|
+
* min={0}
|
|
52
|
+
* max={100}
|
|
53
|
+
* tiers={[
|
|
54
|
+
* { min: 90, label: 'Like New' },
|
|
55
|
+
* { min: 70, label: 'Lightly Used' },
|
|
56
|
+
* { min: 40, label: 'Worn' },
|
|
57
|
+
* { min: 0, label: 'Heavily Worn' },
|
|
58
|
+
* ]}
|
|
59
|
+
* />
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function ConditionScale(props: ConditionScaleProps): JSX.Element {
|
|
63
|
+
const min = () => props.min ?? 1
|
|
64
|
+
const max = () => props.max ?? 10
|
|
65
|
+
const tiers = () => (props.tiers ?? DEFAULT_TIERS).slice().sort((a, b) => a.min - b.min)
|
|
66
|
+
|
|
67
|
+
const percent = () => {
|
|
68
|
+
const range = max() - min()
|
|
69
|
+
if (range <= 0) return 0
|
|
70
|
+
return Math.max(0, Math.min(100, ((props.value - min()) / range) * 100))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const resolvedTier = () => {
|
|
74
|
+
const sorted = tiers()
|
|
75
|
+
let current = sorted[0]
|
|
76
|
+
for (const tier of sorted) {
|
|
77
|
+
if (tier.min <= props.value) current = tier
|
|
78
|
+
}
|
|
79
|
+
return current
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div class={cn('flex flex-col gap-2', props.class)}>
|
|
84
|
+
<div class="flex items-end justify-between gap-2">
|
|
85
|
+
<span class="font-semibold text-foreground">{resolvedTier()?.label}</span>
|
|
86
|
+
<Show when={props.showValue ?? true}>
|
|
87
|
+
<span class="text-sm text-muted-foreground">
|
|
88
|
+
{props.value} / {max()}
|
|
89
|
+
</span>
|
|
90
|
+
</Show>
|
|
91
|
+
</div>
|
|
92
|
+
<div
|
|
93
|
+
class="relative mt-2 h-2 w-full rounded-full bg-muted"
|
|
94
|
+
role="meter"
|
|
95
|
+
aria-valuemin={min()}
|
|
96
|
+
aria-valuemax={max()}
|
|
97
|
+
aria-valuenow={props.value}
|
|
98
|
+
aria-label={resolvedTier()?.label ?? 'Condition'}
|
|
99
|
+
>
|
|
100
|
+
<div
|
|
101
|
+
class="absolute h-2 rounded-full bg-gradient-to-r from-primary/40 to-primary"
|
|
102
|
+
style={{ width: `${percent()}%` }}
|
|
103
|
+
/>
|
|
104
|
+
<div
|
|
105
|
+
class="absolute -top-1 h-4 w-4 -translate-x-1/2 rounded-full border-2 border-background bg-primary shadow"
|
|
106
|
+
style={{ left: `${percent()}%` }}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
|
110
|
+
<For each={[tiers()[0], tiers()[tiers().length - 1]]}>{(tier) => <span>{tier?.label}</span>}</For>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Full faceted-filter panel: collapsible checkbox sections (via FilterGroup),
|
|
2
|
+
// a min/max price range, active-filter chips, a result count, and clear-all.
|
|
3
|
+
// Fully controlled — the caller owns `selected` and `price`; the only local
|
|
4
|
+
// state is each section's collapsed/expanded flag.
|
|
5
|
+
import { X } from 'lucide-solid'
|
|
6
|
+
import type { JSX } from 'solid-js'
|
|
7
|
+
import { createSignal, For, Show } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { Button } from '../ui/Button'
|
|
11
|
+
import { Collapse } from '../ui/Collapse'
|
|
12
|
+
import { NumberInput } from '../ui/NumberInput'
|
|
13
|
+
import type { FacetOption } from './FilterGroup'
|
|
14
|
+
import { FilterGroup } from './FilterGroup'
|
|
15
|
+
|
|
16
|
+
export interface FacetSection {
|
|
17
|
+
key: string
|
|
18
|
+
title: string
|
|
19
|
+
options: FacetOption[]
|
|
20
|
+
/** Collapsible section. Defaults to true. */
|
|
21
|
+
collapsible?: boolean
|
|
22
|
+
/** Open by default. Defaults to true. */
|
|
23
|
+
defaultOpen?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FacetPrice {
|
|
27
|
+
min: number
|
|
28
|
+
max: number
|
|
29
|
+
value: [number, number]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FacetSidebarProps {
|
|
33
|
+
sections: FacetSection[]
|
|
34
|
+
/** Selected values per section key. */
|
|
35
|
+
selected: Record<string, string[]>
|
|
36
|
+
onChange: (key: string, values: string[]) => void
|
|
37
|
+
/** Optional price range facet. */
|
|
38
|
+
price?: FacetPrice
|
|
39
|
+
onPriceChange?: (range: [number, number]) => void
|
|
40
|
+
/** Result count shown in the header. */
|
|
41
|
+
resultCount?: number
|
|
42
|
+
onClearAll?: () => void
|
|
43
|
+
class?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ActiveChip {
|
|
47
|
+
sectionKey: string
|
|
48
|
+
value: string
|
|
49
|
+
label: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One section's title row, collapsible or plain depending on `section.collapsible`. */
|
|
53
|
+
function FacetSectionPanel(props: {
|
|
54
|
+
section: FacetSection
|
|
55
|
+
selected: string[]
|
|
56
|
+
onChange: (values: string[]) => void
|
|
57
|
+
}): JSX.Element {
|
|
58
|
+
// eslint-disable-next-line solid/reactivity -- seed the open state once from defaultOpen; the user toggles from here
|
|
59
|
+
const [open, setOpen] = createSignal(props.section.defaultOpen ?? true)
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Show
|
|
63
|
+
when={props.section.collapsible ?? true}
|
|
64
|
+
fallback={
|
|
65
|
+
<div class="space-y-2">
|
|
66
|
+
<p class="text-sm font-semibold text-foreground">{props.section.title}</p>
|
|
67
|
+
<FilterGroup
|
|
68
|
+
title=""
|
|
69
|
+
options={props.section.options}
|
|
70
|
+
selected={props.selected}
|
|
71
|
+
onChange={props.onChange}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
<Collapse open={open()} onOpenChange={setOpen} title={props.section.title}>
|
|
77
|
+
<FilterGroup
|
|
78
|
+
title=""
|
|
79
|
+
options={props.section.options}
|
|
80
|
+
selected={props.selected}
|
|
81
|
+
onChange={props.onChange}
|
|
82
|
+
/>
|
|
83
|
+
</Collapse>
|
|
84
|
+
</Show>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Full faceted-filter sidebar panel: composes {@link FilterGroup} checkbox
|
|
90
|
+
* sections (each optionally collapsible) with a min/max price range, a row of
|
|
91
|
+
* removable active-filter chips, a result count, and a clear-all action.
|
|
92
|
+
* Fully controlled — the caller owns `selected` and `price` state.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```tsx
|
|
96
|
+
* const [selected, setSelected] = createSignal<Record<string, string[]>>({ type: ['mug'] })
|
|
97
|
+
* const [price, setPrice] = createSignal<[number, number]>([0, 100])
|
|
98
|
+
* <FacetSidebar
|
|
99
|
+
* class="w-64"
|
|
100
|
+
* sections={[
|
|
101
|
+
* { key: 'type', title: 'Type', options: [{ value: 'mug', label: 'Mug', count: 4 }, { value: 'plate', label: 'Plate', count: 2 }] },
|
|
102
|
+
* { key: 'color', title: 'Color', options: [{ value: 'blue', label: 'Blue', count: 3 }, { value: 'green', label: 'Green', count: 1 }] },
|
|
103
|
+
* ]}
|
|
104
|
+
* selected={selected()}
|
|
105
|
+
* onChange={(key, values) => setSelected({ ...selected(), [key]: values })}
|
|
106
|
+
* price={{ min: 0, max: 100, value: price() }}
|
|
107
|
+
* onPriceChange={setPrice}
|
|
108
|
+
* resultCount={6}
|
|
109
|
+
* onClearAll={() => { setSelected({}); setPrice([0, 100]) }}
|
|
110
|
+
* />
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function FacetSidebar(props: FacetSidebarProps): JSX.Element {
|
|
114
|
+
const activeChips = (): ActiveChip[] => {
|
|
115
|
+
const chips: ActiveChip[] = []
|
|
116
|
+
for (const section of props.sections) {
|
|
117
|
+
const values = props.selected[section.key] ?? []
|
|
118
|
+
for (const value of values) {
|
|
119
|
+
const option = section.options.find((candidate) => candidate.value === value)
|
|
120
|
+
chips.push({ sectionKey: section.key, value, label: option?.label ?? value })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return chips
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const removeChip = (chip: ActiveChip): void => {
|
|
127
|
+
const values = props.selected[chip.sectionKey] ?? []
|
|
128
|
+
props.onChange(
|
|
129
|
+
chip.sectionKey,
|
|
130
|
+
values.filter((value) => value !== chip.value),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const setMin = (raw: number): void => {
|
|
135
|
+
if (!props.price || !props.onPriceChange) return
|
|
136
|
+
const min = Math.min(raw, props.price.value[1])
|
|
137
|
+
props.onPriceChange([min, props.price.value[1]])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const setMax = (raw: number): void => {
|
|
141
|
+
if (!props.price || !props.onPriceChange) return
|
|
142
|
+
const max = Math.max(raw, props.price.value[0])
|
|
143
|
+
props.onPriceChange([props.price.value[0], max])
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div class={cn('space-y-5', props.class)}>
|
|
148
|
+
<div class="flex items-center justify-between gap-2">
|
|
149
|
+
<Show when={props.resultCount != null}>
|
|
150
|
+
<p class="text-sm text-muted-foreground">{props.resultCount} results</p>
|
|
151
|
+
</Show>
|
|
152
|
+
<Show when={props.onClearAll}>
|
|
153
|
+
<Button
|
|
154
|
+
variant="ghost"
|
|
155
|
+
class="ml-auto h-auto px-2 py-1 text-xs"
|
|
156
|
+
onClick={() => props.onClearAll?.()}
|
|
157
|
+
>
|
|
158
|
+
Clear all
|
|
159
|
+
</Button>
|
|
160
|
+
</Show>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<Show when={activeChips().length > 0}>
|
|
164
|
+
<div class="flex flex-wrap gap-2">
|
|
165
|
+
<For each={activeChips()}>
|
|
166
|
+
{(chip) => (
|
|
167
|
+
<span class="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs">
|
|
168
|
+
{chip.label}
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={() => removeChip(chip)}
|
|
172
|
+
aria-label={`Remove ${chip.label}`}
|
|
173
|
+
class="text-muted-foreground hover:text-foreground"
|
|
174
|
+
>
|
|
175
|
+
<X class="h-3 w-3" />
|
|
176
|
+
</button>
|
|
177
|
+
</span>
|
|
178
|
+
)}
|
|
179
|
+
</For>
|
|
180
|
+
</div>
|
|
181
|
+
</Show>
|
|
182
|
+
|
|
183
|
+
<Show when={props.price}>
|
|
184
|
+
{(price) => (
|
|
185
|
+
<div class="space-y-2">
|
|
186
|
+
<div class="flex items-center justify-between">
|
|
187
|
+
<p class="text-sm font-semibold text-foreground">Price</p>
|
|
188
|
+
<p class="text-xs text-muted-foreground">
|
|
189
|
+
${price().value[0]} – ${price().value[1]}
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="flex items-center gap-2">
|
|
193
|
+
<NumberInput
|
|
194
|
+
value={price().value[0]}
|
|
195
|
+
onChange={setMin}
|
|
196
|
+
min={price().min}
|
|
197
|
+
max={price().value[1]}
|
|
198
|
+
/>
|
|
199
|
+
<span class="text-sm text-muted-foreground">–</span>
|
|
200
|
+
<NumberInput
|
|
201
|
+
value={price().value[1]}
|
|
202
|
+
onChange={setMax}
|
|
203
|
+
min={price().value[0]}
|
|
204
|
+
max={price().max}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</Show>
|
|
210
|
+
|
|
211
|
+
<For each={props.sections}>
|
|
212
|
+
{(section) => (
|
|
213
|
+
<FacetSectionPanel
|
|
214
|
+
section={section}
|
|
215
|
+
selected={props.selected[section.key] ?? []}
|
|
216
|
+
onChange={(values) => props.onChange(section.key, values)}
|
|
217
|
+
/>
|
|
218
|
+
)}
|
|
219
|
+
</For>
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Richer price display than PriceTag: compare-at savings badge, a coupon
|
|
2
|
+
// chip with the after-coupon price, and an optional financing estimate line.
|
|
3
|
+
import { CreditCard } from 'lucide-solid'
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { Show } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { Badge } from '../ui/Badge'
|
|
9
|
+
|
|
10
|
+
/** Text size of a {@link PriceBlock}'s main amount. Defaults to `'lg'`. */
|
|
11
|
+
export type PriceBlockSize = 'md' | 'lg'
|
|
12
|
+
|
|
13
|
+
const SIZE_CLASSES: Record<PriceBlockSize, string> = {
|
|
14
|
+
md: 'text-xl',
|
|
15
|
+
lg: 'text-3xl',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PriceBlockCoupon {
|
|
19
|
+
code: string
|
|
20
|
+
/** Flat amount off (currency units). If omitted, no reduction is computed. */
|
|
21
|
+
amount?: number
|
|
22
|
+
/** Optional label override. */
|
|
23
|
+
label?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PriceBlockFinancing {
|
|
27
|
+
/** Number of monthly installments. */
|
|
28
|
+
months: number
|
|
29
|
+
/** Annual APR as a decimal, e.g. 0.1 for 10%. Omit/0 = interest-free split. */
|
|
30
|
+
apr?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PriceBlockProps {
|
|
34
|
+
amount: number
|
|
35
|
+
/** Original price; struck through and used for the savings badge when > amount. */
|
|
36
|
+
compareAt?: number
|
|
37
|
+
/** ISO 4217 currency code. Defaults to `'USD'`. */
|
|
38
|
+
currency?: string
|
|
39
|
+
/** BCP 47 locale used for formatting. Defaults to `'en-US'`. */
|
|
40
|
+
locale?: string
|
|
41
|
+
/** Coupon; renders a chip and the after-coupon price. */
|
|
42
|
+
coupon?: PriceBlockCoupon
|
|
43
|
+
/** Financing estimate; renders "As low as $X/mo". */
|
|
44
|
+
financing?: PriceBlockFinancing
|
|
45
|
+
/** Extra note under the price (e.g. a wire-transfer discount line). */
|
|
46
|
+
note?: JSX.Element
|
|
47
|
+
/** Text size of the main price. Defaults to `'lg'`. */
|
|
48
|
+
size?: PriceBlockSize
|
|
49
|
+
class?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Full price display: main amount, optional struck-through compare-at price
|
|
54
|
+
* with a savings badge, a coupon chip with the after-coupon price, and an
|
|
55
|
+
* optional financing estimate ("As low as $X/mo").
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```tsx
|
|
59
|
+
* <PriceBlock
|
|
60
|
+
* amount={249}
|
|
61
|
+
* compareAt={329}
|
|
62
|
+
* coupon={{ code: 'SAVE50', amount: 50 }}
|
|
63
|
+
* financing={{ months: 12, apr: 0.1 }}
|
|
64
|
+
* />
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function PriceBlock(props: PriceBlockProps): JSX.Element {
|
|
68
|
+
const format = (value: number) =>
|
|
69
|
+
new Intl.NumberFormat(props.locale ?? 'en-US', {
|
|
70
|
+
style: 'currency',
|
|
71
|
+
currency: props.currency ?? 'USD',
|
|
72
|
+
}).format(value)
|
|
73
|
+
|
|
74
|
+
const onSale = () => (props.compareAt ?? 0) > props.amount
|
|
75
|
+
|
|
76
|
+
const savings = () => (props.compareAt as number) - props.amount
|
|
77
|
+
|
|
78
|
+
const discountPercent = () => Math.round((1 - props.amount / (props.compareAt as number)) * 100)
|
|
79
|
+
|
|
80
|
+
const afterCouponAmount = () => {
|
|
81
|
+
const couponAmount = props.coupon?.amount ?? 0
|
|
82
|
+
return Math.max(0, props.amount - couponAmount)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const monthlyPayment = () => {
|
|
86
|
+
const financing = props.financing as PriceBlockFinancing
|
|
87
|
+
const apr = financing.apr ?? 0
|
|
88
|
+
if (apr > 0) {
|
|
89
|
+
const r = apr / 12
|
|
90
|
+
const n = financing.months
|
|
91
|
+
const m = (props.amount * r) / (1 - (1 + r) ** -n)
|
|
92
|
+
return Math.round(m * 100) / 100
|
|
93
|
+
}
|
|
94
|
+
return Math.round((props.amount / financing.months) * 100) / 100
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div class={cn('flex flex-col gap-1', props.class)}>
|
|
99
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
100
|
+
<span class={cn('font-bold text-foreground', SIZE_CLASSES[props.size ?? 'lg'])}>
|
|
101
|
+
{format(props.amount)}
|
|
102
|
+
</span>
|
|
103
|
+
<Show when={onSale()}>
|
|
104
|
+
<span class="text-muted-foreground line-through">{format(props.compareAt as number)}</span>
|
|
105
|
+
<Badge tone="danger">
|
|
106
|
+
Save {format(savings())} (-{discountPercent()}%)
|
|
107
|
+
</Badge>
|
|
108
|
+
</Show>
|
|
109
|
+
</div>
|
|
110
|
+
<Show when={props.coupon}>
|
|
111
|
+
{(coupon) => (
|
|
112
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
113
|
+
<span class="inline-flex items-center gap-1 rounded-md border border-dashed border-primary/50 px-2 py-0.5 text-xs font-mono text-primary">
|
|
114
|
+
{coupon().label ?? 'Coupon'} {coupon().code}
|
|
115
|
+
</span>
|
|
116
|
+
<Show when={coupon().amount !== undefined}>
|
|
117
|
+
<span class="text-sm text-muted-foreground">
|
|
118
|
+
{format(afterCouponAmount())} after code {coupon().code}
|
|
119
|
+
</span>
|
|
120
|
+
</Show>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</Show>
|
|
124
|
+
<Show when={props.financing}>
|
|
125
|
+
<span class="inline-flex items-center gap-1 text-sm text-muted-foreground">
|
|
126
|
+
<CreditCard class="h-4 w-4" />
|
|
127
|
+
As low as {format(monthlyPayment())}/mo
|
|
128
|
+
</span>
|
|
129
|
+
</Show>
|
|
130
|
+
<Show when={props.note}>{props.note}</Show>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
package/src/commerce/index.ts
CHANGED
|
@@ -8,4 +8,13 @@ export { ProductGrid, type ProductGridProps } from './ProductGrid'
|
|
|
8
8
|
export { CartLine, type CartLineProps } from './CartLine'
|
|
9
9
|
export { CartSummary, type CartSummaryProps, type CartSummaryItem } from './CartSummary'
|
|
10
10
|
export { FilterGroup, type FilterGroupProps, type FacetOption } from './FilterGroup'
|
|
11
|
+
export { FacetSidebar, type FacetSidebarProps, type FacetSection, type FacetPrice } from './FacetSidebar'
|
|
12
|
+
export {
|
|
13
|
+
PriceBlock,
|
|
14
|
+
type PriceBlockProps,
|
|
15
|
+
type PriceBlockCoupon,
|
|
16
|
+
type PriceBlockFinancing,
|
|
17
|
+
type PriceBlockSize,
|
|
18
|
+
} from './PriceBlock'
|
|
19
|
+
export { ConditionScale, type ConditionScaleProps, type ConditionTier } from './ConditionScale'
|
|
11
20
|
export { createCart, type Cart, type CartProduct, type CartEntry } from './createCart'
|