@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -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';
@@ -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 md:grid-cols-2',
7
- 3: 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3',
8
- 4: 'grid-cols-1 md:grid-cols-2 xl:grid-cols-4',
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: 'md:col-span-1',
19
- 2: 'md:col-span-2',
20
- 3: 'xl:col-span-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
- columns?: keyof typeof columnsMap
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
 
@@ -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'
@@ -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 onClick={() => setOpen(false)} className="cursor-pointer">
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} onClick={() => setOpen(false)}>
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
- renderLink({
129
- href: cta.secondary.href,
130
- children: <Button variant="outline" className="w-full">{cta.secondary.label}</Button>,
131
- })}
132
- {cta?.primary &&
133
- renderLink({
134
- href: cta.primary.href,
135
- children: <Button className="w-full">{cta.primary.label}</Button>,
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 subscription mode only */}
96
- {!isOneTime && (
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
- <Button
99
- variant="ghost"
100
- size="sm"
101
- onClick={() => setAnnual(false)}
102
- className={cn(
103
- 'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
104
- !annual ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
105
- )}
106
- >
107
- Monthly
108
- </Button>
109
- <Button
110
- variant="ghost"
111
- size="sm"
112
- onClick={() => setAnnual(true)}
113
- className={cn(
114
- 'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
115
- annual ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
116
- )}
117
- >
118
- Annual
119
- {saveBadge && (
120
- <Badge variant="secondary" className="text-xs">{saveBadge}</Badge>
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="mt-10 grid gap-4 lg:grid-cols-3">
261
+ <StaggerChildren className={cn('mt-10 grid gap-4', gridClassesFor(plans.length))}>
129
262
  {plans.map((plan) => {
130
- const rawPrice = isOneTime ? plan.monthly : (annual ? plan.annual : plan.monthly);
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
- const period = plan.periodDisplay ?? (isOneTime ? 'one-time' : (rawPrice > 0 ? '/mo' : ''));
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">