@olwiba/ui 0.2.6 → 0.2.8
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/dist/index.d.ts +180 -3
- package/dist/index.js +297 -139
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/DataView.tsx +99 -0
- package/src/components/MediaCard.tsx +68 -0
- package/src/components/ViewToggle.tsx +47 -0
- package/src/hooks/use-view-mode.ts +36 -0
- package/src/index.ts +4 -0
- package/src/layout/AppGrid.tsx +26 -7
- package/src/layout/index.ts +1 -1
- package/src/marketing/Navbar.tsx +19 -12
- package/src/marketing/PricingSection.tsx +172 -29
package/package.json
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import type { ColumnDef } from '@tanstack/react-table';
|
|
5
|
+
import { cn } from '@olwiba/cn';
|
|
6
|
+
import { AppGrid, type AppGridColumns } from '../layout/AppGrid';
|
|
7
|
+
import { DataTable } from '../components/DataTable';
|
|
8
|
+
import type { ViewMode } from '../hooks/use-view-mode';
|
|
9
|
+
|
|
10
|
+
export interface DataViewProps<TData> {
|
|
11
|
+
items: TData[];
|
|
12
|
+
/** Stable key per item. */
|
|
13
|
+
getRowId: (item: TData) => string;
|
|
14
|
+
/** Card renderer for the grid view. */
|
|
15
|
+
renderCard: (item: TData) => React.ReactNode;
|
|
16
|
+
/** Columns for the list view. */
|
|
17
|
+
columns: ColumnDef<TData>[];
|
|
18
|
+
view: ViewMode;
|
|
19
|
+
/**
|
|
20
|
+
* False while a persisted preference is still being read. The whole view is
|
|
21
|
+
* withheld until true — see the note on the component.
|
|
22
|
+
*/
|
|
23
|
+
ready?: boolean;
|
|
24
|
+
/** Grid density at the widest breakpoint. Default 3. */
|
|
25
|
+
gridColumns?: AppGridColumns;
|
|
26
|
+
gap?: 'none' | 'sm' | 'md' | 'lg';
|
|
27
|
+
/** Rendered instead of either view when there is nothing to show. */
|
|
28
|
+
empty?: React.ReactNode;
|
|
29
|
+
/** Shown while `ready` is false. Defaults to nothing. */
|
|
30
|
+
placeholder?: React.ReactNode;
|
|
31
|
+
searchKey?: string;
|
|
32
|
+
searchPlaceholder?: string;
|
|
33
|
+
pageSize?: number;
|
|
34
|
+
emptyMessage?: string;
|
|
35
|
+
onRowClick?: (row: TData) => void;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A collection rendered either as a grid of cards or as a table, from one set
|
|
41
|
+
* of data.
|
|
42
|
+
*
|
|
43
|
+
* Knows nothing about what it is listing: callers supply a card renderer and
|
|
44
|
+
* table columns. Cards are for "what arrived recently", the table for
|
|
45
|
+
* comparing many rows at once, and switching between them should not mean two
|
|
46
|
+
* page implementations that drift apart.
|
|
47
|
+
*
|
|
48
|
+
* The `ready` gate is the subtle part, and the reason this is a component
|
|
49
|
+
* rather than a snippet to copy. A persisted preference cannot be read during
|
|
50
|
+
* SSR or the first client render, so a page that renders its default
|
|
51
|
+
* immediately will either mismatch on hydration or visibly flash the wrong
|
|
52
|
+
* view at anyone who chose the other one. Holding the section until the
|
|
53
|
+
* preference resolves avoids both, and putting that here means no consumer
|
|
54
|
+
* has to work it out again.
|
|
55
|
+
*/
|
|
56
|
+
export function DataView<TData>({
|
|
57
|
+
items,
|
|
58
|
+
getRowId,
|
|
59
|
+
renderCard,
|
|
60
|
+
columns,
|
|
61
|
+
view,
|
|
62
|
+
ready = true,
|
|
63
|
+
gridColumns = 3,
|
|
64
|
+
gap = 'sm',
|
|
65
|
+
empty,
|
|
66
|
+
placeholder = null,
|
|
67
|
+
searchKey,
|
|
68
|
+
searchPlaceholder,
|
|
69
|
+
pageSize = 25,
|
|
70
|
+
emptyMessage,
|
|
71
|
+
onRowClick,
|
|
72
|
+
className,
|
|
73
|
+
}: DataViewProps<TData>) {
|
|
74
|
+
if (!ready) return <>{placeholder}</>;
|
|
75
|
+
if (items.length === 0 && empty) return <>{empty}</>;
|
|
76
|
+
|
|
77
|
+
if (view === 'list') {
|
|
78
|
+
return (
|
|
79
|
+
<DataTable
|
|
80
|
+
columns={columns}
|
|
81
|
+
data={items}
|
|
82
|
+
searchKey={searchKey}
|
|
83
|
+
searchPlaceholder={searchPlaceholder}
|
|
84
|
+
pageSize={pageSize}
|
|
85
|
+
emptyMessage={emptyMessage}
|
|
86
|
+
onRowClick={onRowClick}
|
|
87
|
+
className={className}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<AppGrid columns={gridColumns} gap={gap} className={cn(className)}>
|
|
94
|
+
{items.map((item) => (
|
|
95
|
+
<React.Fragment key={getRowId(item)}>{renderCard(item)}</React.Fragment>
|
|
96
|
+
))}
|
|
97
|
+
</AppGrid>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Card, cn } from '@olwiba/cn';
|
|
5
|
+
|
|
6
|
+
const bannerAspectClass = {
|
|
7
|
+
wide: 'aspect-[3/1]',
|
|
8
|
+
video: 'aspect-video',
|
|
9
|
+
square: 'aspect-square',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface MediaCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
13
|
+
/**
|
|
14
|
+
* Banner rendered above the content, clipped to the card's rounded corners.
|
|
15
|
+
* A ReactNode rather than a src, so a brand fallback, an inline SVG, a map,
|
|
16
|
+
* or a plain <img> all work — this is the difference from ImageCard, which
|
|
17
|
+
* takes a URL and is the right choice when you actually have one.
|
|
18
|
+
*/
|
|
19
|
+
banner?: React.ReactNode;
|
|
20
|
+
/** Banner proportions. Default 'wide' (3:1), which suits a header strip. */
|
|
21
|
+
bannerAspect?: keyof typeof bannerAspectClass;
|
|
22
|
+
/**
|
|
23
|
+
* Pinned to the bottom of the card. In a grid this is what keeps the
|
|
24
|
+
* primary action on a shared baseline across a row, however unevenly the
|
|
25
|
+
* titles above it wrap.
|
|
26
|
+
*/
|
|
27
|
+
footer?: React.ReactNode;
|
|
28
|
+
children?: React.ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A Card with an optional banner and a bottom-pinned footer.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately knows nothing about what it is showing — the domain-shaped
|
|
35
|
+
* cards (a property, a monitor, an article) compose this and supply their own
|
|
36
|
+
* content. It exists because "banner, then content, then an action pinned to
|
|
37
|
+
* the bottom, all the same height across a row" was being hand-rolled per
|
|
38
|
+
* product, and the equal-height part in particular is easy to get subtly
|
|
39
|
+
* wrong.
|
|
40
|
+
*
|
|
41
|
+
* `h-full` is on the card itself so a bare `<MediaCard>` inside a grid cell
|
|
42
|
+
* fills that cell without every caller remembering to ask.
|
|
43
|
+
*/
|
|
44
|
+
export function MediaCard({
|
|
45
|
+
banner,
|
|
46
|
+
bannerAspect = 'wide',
|
|
47
|
+
footer,
|
|
48
|
+
children,
|
|
49
|
+
className,
|
|
50
|
+
...props
|
|
51
|
+
}: MediaCardProps) {
|
|
52
|
+
return (
|
|
53
|
+
<Card
|
|
54
|
+
className={cn('flex h-full w-full flex-col overflow-hidden', banner && 'pt-0', className)}
|
|
55
|
+
{...props}
|
|
56
|
+
>
|
|
57
|
+
{banner && (
|
|
58
|
+
<div className={cn('w-full shrink-0 overflow-hidden', bannerAspectClass[bannerAspect])}>
|
|
59
|
+
{banner}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
<div className="flex flex-1 flex-col gap-3 p-4">
|
|
63
|
+
{children}
|
|
64
|
+
{footer && <div className="mt-auto pt-1">{footer}</div>}
|
|
65
|
+
</div>
|
|
66
|
+
</Card>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Button, cn } from '@olwiba/cn';
|
|
5
|
+
import { LayoutGrid, List } from 'lucide-react';
|
|
6
|
+
import type { ViewMode } from '../hooks/use-view-mode';
|
|
7
|
+
|
|
8
|
+
export interface ViewToggleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
|
9
|
+
view: ViewMode;
|
|
10
|
+
onChange: (view: ViewMode) => void;
|
|
11
|
+
labels?: { cards?: string; list?: string };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Two-state segmented control for switching a collection between cards and a
|
|
16
|
+
* list. Controlled — pair it with useViewMode to persist the choice.
|
|
17
|
+
*/
|
|
18
|
+
export function ViewToggle({ view, onChange, labels, className, ...props }: ViewToggleProps) {
|
|
19
|
+
const options = [
|
|
20
|
+
{ mode: 'cards' as const, label: labels?.cards ?? 'Card view', Icon: LayoutGrid },
|
|
21
|
+
{ mode: 'list' as const, label: labels?.list ?? 'List view', Icon: List },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={cn('inline-flex items-center rounded-md border p-0.5', className)}
|
|
27
|
+
role="group"
|
|
28
|
+
aria-label="View mode"
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
{options.map(({ mode, label, Icon }) => (
|
|
32
|
+
<Button
|
|
33
|
+
key={mode}
|
|
34
|
+
type="button"
|
|
35
|
+
size="sm"
|
|
36
|
+
variant={view === mode ? 'secondary' : 'ghost'}
|
|
37
|
+
className="h-7 px-2"
|
|
38
|
+
aria-label={label}
|
|
39
|
+
aria-pressed={view === mode}
|
|
40
|
+
onClick={() => onChange(mode)}
|
|
41
|
+
>
|
|
42
|
+
<Icon className="size-4" />
|
|
43
|
+
</Button>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useLocalStorage } from './use-local-storage';
|
|
4
|
+
import { useMounted } from './use-mounted';
|
|
5
|
+
|
|
6
|
+
export type ViewMode = 'cards' | 'list';
|
|
7
|
+
|
|
8
|
+
export interface UseViewModeReturn {
|
|
9
|
+
view: ViewMode;
|
|
10
|
+
setView: (view: ViewMode) => void;
|
|
11
|
+
/**
|
|
12
|
+
* False until the stored preference is readable. Render a skeleton or hold
|
|
13
|
+
* the section until this is true — see the note below on why.
|
|
14
|
+
*/
|
|
15
|
+
ready: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Remembered cards/list preference.
|
|
20
|
+
*
|
|
21
|
+
* Returns the fallback on the server and on the first client render, then
|
|
22
|
+
* settles to the stored value once mounted. Returning the stored value
|
|
23
|
+
* immediately would have the server emit card markup while the client builds
|
|
24
|
+
* a table — a hydration mismatch — and painting the fallback first flashes
|
|
25
|
+
* the wrong view at someone who chose the other one. `ready` lets callers
|
|
26
|
+
* wait for the real answer instead of doing either.
|
|
27
|
+
*
|
|
28
|
+
* The key is shared by default so the choice reads as one product-wide
|
|
29
|
+
* preference: pick list on one page and every page follows. Pass a distinct
|
|
30
|
+
* key where a page genuinely wants its own.
|
|
31
|
+
*/
|
|
32
|
+
export function useViewMode(key = 'view-mode', fallback: ViewMode = 'cards'): UseViewModeReturn {
|
|
33
|
+
const [stored, setStored] = useLocalStorage<ViewMode>(key, fallback);
|
|
34
|
+
const mounted = useMounted();
|
|
35
|
+
return { view: mounted ? stored : fallback, setView: setStored, ready: mounted };
|
|
36
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -128,6 +128,7 @@ export { CommandMenu, type CommandMenuProps, type CommandMenuGroup, type Command
|
|
|
128
128
|
|
|
129
129
|
// ─── Components — data ───────────────────────────────────────────────────────
|
|
130
130
|
export { DataTable, type DataTableProps, type DataTableColumn } from './components/DataTable';
|
|
131
|
+
export { DataView, type DataViewProps } from './app/DataView';
|
|
131
132
|
export { Chart, type ChartProps, type ChartSeries } from './components/Chart';
|
|
132
133
|
export { FileUpload, type FileUploadProps, type FileUploadEntry } from './components/FileUpload';
|
|
133
134
|
|
|
@@ -147,6 +148,8 @@ export { StatCard, type StatCardProps } from './components/StatCard';
|
|
|
147
148
|
export { TestimonialCard, type TestimonialCardProps } from './components/TestimonialCard';
|
|
148
149
|
export { PricingCard, type PricingCardProps, type PricingFeature } from './components/PricingCard';
|
|
149
150
|
export { ImageCard, type ImageCardProps } from './components/ImageCard';
|
|
151
|
+
export { MediaCard, type MediaCardProps } from './components/MediaCard';
|
|
152
|
+
export { ViewToggle, type ViewToggleProps } from './components/ViewToggle';
|
|
150
153
|
|
|
151
154
|
// ─── Components — diagram ────────────────────────────────────────────────────
|
|
152
155
|
export { FlowConnector, type FlowConnectorProps } from './components/FlowConnector';
|
|
@@ -183,5 +186,6 @@ export { useCopyToClipboard } from './hooks/use-copy-to-clipboard';
|
|
|
183
186
|
export { useDebounce } from './hooks/use-debounce';
|
|
184
187
|
export { useIntersectionObserver } from './hooks/use-intersection-observer';
|
|
185
188
|
export { useLocalStorage } from './hooks/use-local-storage';
|
|
189
|
+
export { useViewMode, type ViewMode, type UseViewModeReturn } from './hooks/use-view-mode';
|
|
186
190
|
export { useMediaQuery } from './hooks/use-media-query';
|
|
187
191
|
export { usePagination, type UsePaginationReturn } from './hooks/use-pagination';
|
package/src/layout/AppGrid.tsx
CHANGED
|
@@ -1,29 +1,48 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { cn } from '../lib/utils'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Responsive ramps per column count. Every ramp starts at a single column —
|
|
6
|
+
* a card grid that stays multi-column on a phone is unreadable — and adds
|
|
7
|
+
* columns at breakpoints wide enough to keep each card legible.
|
|
8
|
+
*
|
|
9
|
+
* Written out in full rather than composed, because Tailwind scans source for
|
|
10
|
+
* complete class strings; a template literal like `xl:grid-cols-${n}` produces
|
|
11
|
+
* nothing at build time.
|
|
12
|
+
*/
|
|
4
13
|
const columnsMap = {
|
|
5
14
|
1: 'grid-cols-1',
|
|
6
|
-
2: 'grid-cols-1
|
|
7
|
-
3: 'grid-cols-1
|
|
8
|
-
4: 'grid-cols-1
|
|
15
|
+
2: 'grid-cols-1 sm:grid-cols-2',
|
|
16
|
+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
17
|
+
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
|
18
|
+
5: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
|
|
19
|
+
6: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
|
|
9
20
|
}
|
|
10
21
|
|
|
11
22
|
const gapMap = {
|
|
23
|
+
none: 'gap-0',
|
|
12
24
|
sm: 'gap-3',
|
|
13
25
|
md: 'gap-4',
|
|
14
26
|
lg: 'gap-6',
|
|
15
27
|
}
|
|
16
28
|
|
|
17
29
|
const spanMap = {
|
|
18
|
-
1: '
|
|
19
|
-
2: '
|
|
20
|
-
3: '
|
|
30
|
+
1: 'sm:col-span-1',
|
|
31
|
+
2: 'sm:col-span-2',
|
|
32
|
+
3: 'lg:col-span-3',
|
|
21
33
|
4: 'xl:col-span-4',
|
|
22
34
|
full: 'col-span-full',
|
|
23
35
|
}
|
|
24
36
|
|
|
37
|
+
export type AppGridColumns = keyof typeof columnsMap
|
|
38
|
+
|
|
25
39
|
export interface AppGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
26
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Columns at the widest breakpoint. Narrower screens step down through the
|
|
42
|
+
* ramp automatically, so this is "how dense at full width", not a fixed
|
|
43
|
+
* count. Default 3.
|
|
44
|
+
*/
|
|
45
|
+
columns?: AppGridColumns
|
|
27
46
|
gap?: keyof typeof gapMap
|
|
28
47
|
}
|
|
29
48
|
|
package/src/layout/index.ts
CHANGED
|
@@ -7,6 +7,6 @@ export type { SectionProps } from './Section'
|
|
|
7
7
|
export { AppContent } from './AppContent'
|
|
8
8
|
export type { AppContentProps } from './AppContent'
|
|
9
9
|
export { AppGrid, AppGridCell } from './AppGrid'
|
|
10
|
-
export type { AppGridProps, AppGridCellProps } from './AppGrid'
|
|
10
|
+
export type { AppGridProps, AppGridCellProps, AppGridColumns } from './AppGrid'
|
|
11
11
|
export { PublicPageFrame } from './PublicPageFrame'
|
|
12
12
|
export type { PublicPageFrameProps } from './PublicPageFrame'
|
package/src/marketing/Navbar.tsx
CHANGED
|
@@ -31,6 +31,7 @@ export function Navbar({
|
|
|
31
31
|
const [open, setOpen] = React.useState(false);
|
|
32
32
|
const brandHref = brand.href ?? '/';
|
|
33
33
|
const hasRightContent = controls?.length || cta?.primary || cta?.secondary;
|
|
34
|
+
const closeMobileMenu = React.useCallback(() => setOpen(false), []);
|
|
34
35
|
|
|
35
36
|
return (
|
|
36
37
|
<section className="overflow-hidden rounded-2xl border bg-card">
|
|
@@ -92,7 +93,7 @@ export function Navbar({
|
|
|
92
93
|
<SheetContent side="left" className="w-72">
|
|
93
94
|
{/* Brand row alone at the top — leaves the top-right corner to the built-in close */}
|
|
94
95
|
<div className="flex items-center pb-4">
|
|
95
|
-
<span
|
|
96
|
+
<span onClickCapture={closeMobileMenu} className="cursor-pointer">
|
|
96
97
|
{renderLink({
|
|
97
98
|
href: brandHref,
|
|
98
99
|
children: (
|
|
@@ -114,7 +115,7 @@ export function Navbar({
|
|
|
114
115
|
<Separator />
|
|
115
116
|
<nav className="mt-4 flex flex-col gap-1">
|
|
116
117
|
{navLinks.map((link) => (
|
|
117
|
-
<span key={link.label}
|
|
118
|
+
<span key={link.label} onClickCapture={closeMobileMenu}>
|
|
118
119
|
{renderLink({
|
|
119
120
|
href: link.href,
|
|
120
121
|
className: 'block rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
|
|
@@ -124,16 +125,22 @@ export function Navbar({
|
|
|
124
125
|
))}
|
|
125
126
|
</nav>
|
|
126
127
|
<div className="mt-6 flex flex-col gap-2">
|
|
127
|
-
{cta?.secondary &&
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
128
|
+
{cta?.secondary && (
|
|
129
|
+
<span onClickCapture={closeMobileMenu}>
|
|
130
|
+
{renderLink({
|
|
131
|
+
href: cta.secondary.href,
|
|
132
|
+
children: <Button variant="outline" className="w-full">{cta.secondary.label}</Button>,
|
|
133
|
+
})}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
{cta?.primary && (
|
|
137
|
+
<span onClickCapture={closeMobileMenu}>
|
|
138
|
+
{renderLink({
|
|
139
|
+
href: cta.primary.href,
|
|
140
|
+
children: <Button className="w-full">{cta.primary.label}</Button>,
|
|
141
|
+
})}
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
137
144
|
</div>
|
|
138
145
|
</SheetContent>
|
|
139
146
|
</Sheet>
|
|
@@ -7,10 +7,37 @@ import { StaggerChildren } from '../motion/StaggerChildren';
|
|
|
7
7
|
import { CountdownTimer } from '../motion/CountdownTimer';
|
|
8
8
|
import type { AppShellRenderLink } from '../app/AppShell';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* One billing period a plan can be bought at.
|
|
12
|
+
*
|
|
13
|
+
* Products are not all sold monthly-or-annually: weekly, quarterly, and
|
|
14
|
+
* one-off all exist. Pass `cadences` to describe whatever this product
|
|
15
|
+
* actually sells and the section renders a tab per entry, rather than the
|
|
16
|
+
* fixed Monthly/Annual pair it assumes otherwise.
|
|
17
|
+
*/
|
|
18
|
+
export interface PricingCadence {
|
|
19
|
+
/** Matches the keys of `PricingPlan.prices`. */
|
|
20
|
+
key: string;
|
|
21
|
+
label: string;
|
|
22
|
+
/**
|
|
23
|
+
* Billing periods in a year — 52 weekly, 12 monthly, 1 annual. Used only to
|
|
24
|
+
* compare cadences for the savings badge; omit it and no badge is computed
|
|
25
|
+
* for this cadence.
|
|
26
|
+
*/
|
|
27
|
+
periodsPerYear?: number;
|
|
28
|
+
/** Price suffix, e.g. "/ week". Falls back to `PricingPlan.periodDisplay`. */
|
|
29
|
+
suffix?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
export interface PricingPlan {
|
|
11
33
|
name: string;
|
|
12
34
|
monthly: number;
|
|
13
35
|
annual: number;
|
|
36
|
+
/**
|
|
37
|
+
* Price per cadence key, for products using `cadences`. The `monthly` and
|
|
38
|
+
* `annual` fields above stay as the fallback for callers that don't.
|
|
39
|
+
*/
|
|
40
|
+
prices?: Record<string, number>;
|
|
14
41
|
description: string;
|
|
15
42
|
cta: string;
|
|
16
43
|
highlighted?: boolean;
|
|
@@ -24,6 +51,61 @@ export interface PricingPlan {
|
|
|
24
51
|
periodDisplay?: string;
|
|
25
52
|
}
|
|
26
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Cheapest-per-year wins: annualise every cadence across all plans and return
|
|
56
|
+
* a `Save N%` label for each one that costs less than the default.
|
|
57
|
+
*
|
|
58
|
+
* Computed rather than configured because a hand-written "Save 34%" silently
|
|
59
|
+
* stops being true the first time a price changes.
|
|
60
|
+
*/
|
|
61
|
+
function computeSaveBadges(
|
|
62
|
+
plans: PricingPlan[],
|
|
63
|
+
cadences: PricingCadence[],
|
|
64
|
+
defaultKey: string,
|
|
65
|
+
): Record<string, string> {
|
|
66
|
+
const annualised = (key: string) => {
|
|
67
|
+
const cadence = cadences.find((c) => c.key === key);
|
|
68
|
+
if (!cadence?.periodsPerYear) return null;
|
|
69
|
+
// Plans priced by `priceDisplay` (pay-what-you-can, "contact us") carry no
|
|
70
|
+
// comparable number, so they're left out of the comparison entirely.
|
|
71
|
+
const totals = plans
|
|
72
|
+
.filter((plan) => plan.priceDisplay === undefined && plan.prices?.[key] !== undefined)
|
|
73
|
+
.map((plan) => plan.prices![key]! * cadence.periodsPerYear!);
|
|
74
|
+
return totals.length > 0 ? totals.reduce((sum, n) => sum + n, 0) : null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const baseline = annualised(defaultKey);
|
|
78
|
+
if (!baseline) return {};
|
|
79
|
+
|
|
80
|
+
const badges: Record<string, string> = {};
|
|
81
|
+
for (const cadence of cadences) {
|
|
82
|
+
if (cadence.key === defaultKey) continue;
|
|
83
|
+
const total = annualised(cadence.key);
|
|
84
|
+
if (!total || total >= baseline) continue;
|
|
85
|
+
const percent = Math.round(((baseline - total) / baseline) * 100);
|
|
86
|
+
if (percent > 0) badges[cadence.key] = `Save ${percent}%`;
|
|
87
|
+
}
|
|
88
|
+
return badges;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Column layout for the number of plans actually being shown.
|
|
93
|
+
*
|
|
94
|
+
* A fixed three-column grid leaves one or two plans hugging the left edge with
|
|
95
|
+
* dead space beside them, which reads as a rendering fault rather than a
|
|
96
|
+
* deliberate layout. Width is capped per count so cards keep a sensible size
|
|
97
|
+
* instead of stretching to fill.
|
|
98
|
+
*/
|
|
99
|
+
function gridClassesFor(count: number): string {
|
|
100
|
+
if (count <= 1) return 'mx-auto max-w-sm';
|
|
101
|
+
if (count === 2) return 'mx-auto max-w-3xl sm:grid-cols-2';
|
|
102
|
+
if (count === 3) return 'mx-auto max-w-5xl lg:grid-cols-3';
|
|
103
|
+
if (count === 4) return 'mx-auto max-w-6xl sm:grid-cols-2 lg:grid-cols-4';
|
|
104
|
+
// Beyond four, wrapping at three keeps each card readable. A carousel is the
|
|
105
|
+
// answer if a catalogue ever genuinely needs it.
|
|
106
|
+
return 'mx-auto max-w-5xl sm:grid-cols-2 lg:grid-cols-3';
|
|
107
|
+
}
|
|
108
|
+
|
|
27
109
|
export interface PricingSectionProps {
|
|
28
110
|
title?: string;
|
|
29
111
|
description?: string;
|
|
@@ -41,6 +123,14 @@ export interface PricingSectionProps {
|
|
|
41
123
|
highlightedBadgeLabel?: string;
|
|
42
124
|
/** Rendered below each plan's CTA button (e.g. a "Get notified" link). */
|
|
43
125
|
renderPlanFooter?: (plan: PricingPlan) => React.ReactNode;
|
|
126
|
+
/**
|
|
127
|
+
* Billing periods this product sells at. One entry renders no toggle at all;
|
|
128
|
+
* two or more render a tab each, with savings badges computed from
|
|
129
|
+
* `periodsPerYear`. Omit to keep the built-in Monthly/Annual pair.
|
|
130
|
+
*/
|
|
131
|
+
cadences?: PricingCadence[];
|
|
132
|
+
/** Which cadence opens selected. Defaults to the first in `cadences`. */
|
|
133
|
+
defaultCadence?: string;
|
|
44
134
|
}
|
|
45
135
|
|
|
46
136
|
const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
|
|
@@ -61,9 +151,26 @@ export function PricingSection({
|
|
|
61
151
|
currency = '$',
|
|
62
152
|
highlightedBadgeLabel = 'Founding member',
|
|
63
153
|
renderPlanFooter,
|
|
154
|
+
cadences,
|
|
155
|
+
defaultCadence,
|
|
64
156
|
}: PricingSectionProps) {
|
|
65
157
|
const [annual, setAnnual] = React.useState(false);
|
|
66
158
|
const isOneTime = mode === 'one-time';
|
|
159
|
+
|
|
160
|
+
// Explicit cadences replace the built-in Monthly/Annual pair entirely.
|
|
161
|
+
const useCadences = !!cadences?.length;
|
|
162
|
+
const initialCadence =
|
|
163
|
+
(defaultCadence && cadences?.some((c) => c.key === defaultCadence) ? defaultCadence : null) ??
|
|
164
|
+
cadences?.[0]?.key ??
|
|
165
|
+
'';
|
|
166
|
+
const [activeCadence, setActiveCadence] = React.useState(initialCadence);
|
|
167
|
+
const cadence = cadences?.find((c) => c.key === activeCadence);
|
|
168
|
+
const saveBadges = React.useMemo(
|
|
169
|
+
() => (useCadences ? computeSaveBadges(plans, cadences!, initialCadence) : {}),
|
|
170
|
+
[useCadences, plans, cadences, initialCadence],
|
|
171
|
+
);
|
|
172
|
+
// A single cadence is just a label for the price — nothing to switch between.
|
|
173
|
+
const showToggle = useCadences ? cadences!.length > 1 : !isOneTime;
|
|
67
174
|
const uiMode = useUIVariant();
|
|
68
175
|
const sectionClasses = cn(
|
|
69
176
|
'overflow-hidden bg-card',
|
|
@@ -92,44 +199,80 @@ export function PricingSection({
|
|
|
92
199
|
</p>
|
|
93
200
|
)}
|
|
94
201
|
|
|
95
|
-
{/* Billing toggle
|
|
96
|
-
{
|
|
202
|
+
{/* Billing toggle: one tab per cadence, or the legacy Monthly/Annual pair */}
|
|
203
|
+
{showToggle && (
|
|
97
204
|
<div className="mt-6 inline-flex items-center gap-3 rounded-full border bg-muted p-1">
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
205
|
+
{useCadences
|
|
206
|
+
? cadences!.map((entry) => (
|
|
207
|
+
<Button
|
|
208
|
+
key={entry.key}
|
|
209
|
+
variant="ghost"
|
|
210
|
+
size="sm"
|
|
211
|
+
onClick={() => setActiveCadence(entry.key)}
|
|
212
|
+
className={cn(
|
|
213
|
+
'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
|
214
|
+
entry.key === activeCadence
|
|
215
|
+
? 'bg-background text-foreground shadow-sm'
|
|
216
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
217
|
+
)}
|
|
218
|
+
>
|
|
219
|
+
{entry.label}
|
|
220
|
+
{saveBadges[entry.key] && (
|
|
221
|
+
<Badge variant="secondary" className="text-xs">
|
|
222
|
+
{saveBadges[entry.key]}
|
|
223
|
+
</Badge>
|
|
224
|
+
)}
|
|
225
|
+
</Button>
|
|
226
|
+
))
|
|
227
|
+
: (
|
|
228
|
+
<>
|
|
229
|
+
<Button
|
|
230
|
+
variant="ghost"
|
|
231
|
+
size="sm"
|
|
232
|
+
onClick={() => setAnnual(false)}
|
|
233
|
+
className={cn(
|
|
234
|
+
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
|
235
|
+
!annual ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
|
236
|
+
)}
|
|
237
|
+
>
|
|
238
|
+
Monthly
|
|
239
|
+
</Button>
|
|
240
|
+
<Button
|
|
241
|
+
variant="ghost"
|
|
242
|
+
size="sm"
|
|
243
|
+
onClick={() => setAnnual(true)}
|
|
244
|
+
className={cn(
|
|
245
|
+
'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
|
246
|
+
annual ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
Annual
|
|
250
|
+
{saveBadge && (
|
|
251
|
+
<Badge variant="secondary" className="text-xs">{saveBadge}</Badge>
|
|
252
|
+
)}
|
|
253
|
+
</Button>
|
|
254
|
+
</>
|
|
121
255
|
)}
|
|
122
|
-
</Button>
|
|
123
256
|
</div>
|
|
124
257
|
)}
|
|
125
258
|
</div>
|
|
126
259
|
|
|
127
260
|
{/* Plan cards */}
|
|
128
|
-
<StaggerChildren className=
|
|
261
|
+
<StaggerChildren className={cn('mt-10 grid gap-4', gridClassesFor(plans.length))}>
|
|
129
262
|
{plans.map((plan) => {
|
|
130
|
-
const rawPrice =
|
|
263
|
+
const rawPrice = useCadences
|
|
264
|
+
? (plan.prices?.[activeCadence] ?? plan.monthly)
|
|
265
|
+
: isOneTime
|
|
266
|
+
? plan.monthly
|
|
267
|
+
: annual
|
|
268
|
+
? plan.annual
|
|
269
|
+
: plan.monthly;
|
|
131
270
|
const price = plan.priceDisplay ?? `${currency}${rawPrice}`;
|
|
132
|
-
|
|
271
|
+
// With cadences the suffix follows the selected tab, so a plan's
|
|
272
|
+
// own periodDisplay would pin it to whichever it was written for.
|
|
273
|
+
const period = useCadences
|
|
274
|
+
? (cadence?.suffix ?? plan.periodDisplay ?? '')
|
|
275
|
+
: (plan.periodDisplay ?? (isOneTime ? 'one-time' : rawPrice > 0 ? '/mo' : ''));
|
|
133
276
|
const badge = plan.highlighted && foundingDeadline
|
|
134
277
|
? (
|
|
135
278
|
<span className="inline-flex items-center rounded-full border bg-secondary px-2.5 py-0.5 text-xs font-semibold text-secondary-foreground">
|