@umituz/web-design-system 5.0.0 → 5.2.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.
@@ -0,0 +1,81 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { PlanPicker } from '../presentation/organisms/PlanPicker';
4
+ import type { PlanPickerPlan } from '../presentation/organisms/PlanPicker';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const plans: PlanPickerPlan[] = [
9
+ { id: 'starter', name: 'Starter', price: '$0', priceNote: '/ forever', ctaLabel: 'Downgrade' },
10
+ { id: 'pro', name: 'Pro', price: '$29', priceNote: '/ mo', badge: 'Popular', highlight: true, ctaLabel: 'Choose Pro' },
11
+ { id: 'master', name: 'Master', price: '$59', priceNote: '/ mo', current: true, ctaLabel: 'Current tier' },
12
+ ];
13
+
14
+ describe('PlanPicker cycle switch', () => {
15
+ it('renders the cycle options and reports changes', () => {
16
+ const onCycleChange = vi.fn();
17
+ render(
18
+ <PlanPicker
19
+ plans={plans}
20
+ cycles={[
21
+ { value: 'monthly' as const, label: 'Monthly' },
22
+ { value: 'yearly' as const, label: 'Yearly', hint: '-17%' },
23
+ ]}
24
+ cycle="monthly"
25
+ onCycleChange={onCycleChange}
26
+ />,
27
+ );
28
+ expect(screen.getByRole('button', { name: /monthly/i }).className).toContain('bg-background');
29
+ expect(screen.getByRole('button', { name: /yearly/i }).className).not.toContain('bg-background');
30
+ fireEvent.click(screen.getByRole('button', { name: /yearly/i }));
31
+ expect(onCycleChange).toHaveBeenCalledWith('yearly');
32
+ });
33
+ });
34
+
35
+ describe('PlanPicker plan cards', () => {
36
+ it('fires the per-plan CTA and marks the selected card', () => {
37
+ const onPlanSelect = vi.fn();
38
+ const proSelect = vi.fn();
39
+ render(
40
+ <PlanPicker
41
+ plans={plans.map((p) => (p.id === 'pro' ? { ...p, onSelect: proSelect } : p))}
42
+ selectedId="pro"
43
+ onPlanSelect={onPlanSelect}
44
+ />,
45
+ );
46
+ const proCard = screen.getByRole('radio', { name: /choose pro/i });
47
+ expect(proCard.getAttribute('aria-checked')).toBe('true');
48
+ fireEvent.click(screen.getByRole('button', { name: 'Choose Pro' }));
49
+ expect(proSelect).toHaveBeenCalledTimes(1);
50
+ expect(onPlanSelect).not.toHaveBeenCalled();
51
+ fireEvent.click(screen.getByRole('radio', { name: /downgrade/i }));
52
+ expect(onPlanSelect).toHaveBeenCalledWith('starter');
53
+ });
54
+
55
+ it('renders the current plan as a static marker, not a button', () => {
56
+ render(<PlanPicker plans={plans} onPlanSelect={() => {}} />);
57
+ expect(screen.queryByRole('button', { name: /current tier/i })).toBeNull();
58
+ const marker = screen.getByText('Current tier');
59
+ expect(marker.className).toContain('bg-success/10');
60
+ });
61
+
62
+ it('busy locks every CTA', () => {
63
+ render(
64
+ <PlanPicker
65
+ plans={[{ id: 'a', name: 'A', price: '$1', ctaLabel: 'Buy', onSelect: () => {} }]}
66
+ busy
67
+ />,
68
+ );
69
+ const cta = screen.getByRole('button', { name: /buy/i }) as HTMLButtonElement;
70
+ expect(cta.disabled).toBe(true);
71
+ });
72
+
73
+ it('maps the columns prop onto the grid', () => {
74
+ const { container, rerender } = render(<PlanPicker plans={plans} columns={2} />);
75
+ const grid = container.querySelector('.grid') as HTMLElement;
76
+ expect(grid.className).toContain('sm:grid-cols-2');
77
+ expect(grid.className).not.toContain('lg:grid-cols-3');
78
+ rerender(<PlanPicker plans={plans} columns={4} />);
79
+ expect(grid.className).toContain('lg:grid-cols-4');
80
+ });
81
+ });
@@ -0,0 +1,100 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { Stepper } from '../presentation/organisms/Stepper';
4
+
5
+ afterEach(cleanup);
6
+
7
+ const steps = [
8
+ { id: 'a', label: 'Account' },
9
+ { id: 'b', label: 'Profile' },
10
+ { id: 'c', label: 'Review' },
11
+ ];
12
+
13
+ describe('Stepper status derivation', () => {
14
+ it('marks only the current step with aria-current', () => {
15
+ render(<Stepper steps={steps} currentStep={1} ariaLabel="Progress" />);
16
+ const list = screen.getByRole('list', { name: 'Progress' });
17
+ const current = list.querySelectorAll('[aria-current="step"]');
18
+ expect(current).toHaveLength(1);
19
+ expect(current[0]?.textContent).toContain('Profile');
20
+ });
21
+
22
+ it('shows a check icon for completed steps and numbers for the rest', () => {
23
+ render(<Stepper steps={steps} currentStep={1} />);
24
+ const items = screen.getAllByRole('listitem');
25
+ expect(items[0]?.querySelector('svg')).not.toBeNull();
26
+ expect(items[1]?.querySelector('svg')).toBeNull();
27
+ expect(items[1]?.textContent).toContain('2');
28
+ expect(items[2]?.textContent).toContain('3');
29
+ });
30
+
31
+ it('lets step.status override the derived status', () => {
32
+ render(<Stepper steps={[{ ...steps[0]!, status: 'error' }, steps[1]!]} currentStep={0} />);
33
+ const items = screen.getAllByRole('listitem');
34
+ expect(items[0]?.textContent).toContain('Account');
35
+ const dot = items[0]?.querySelector('span');
36
+ expect(dot?.className).toContain('border-destructive');
37
+ });
38
+ });
39
+
40
+ describe('Stepper selection gating', () => {
41
+ it('fires onStepSelect only for completed steps by default', () => {
42
+ const onSelect = vi.fn();
43
+ render(<Stepper steps={steps} currentStep={2} onStepSelect={onSelect} />);
44
+ const buttons = screen.getAllByRole('button');
45
+ expect(buttons).toHaveLength(2);
46
+ fireEvent.click(screen.getByRole('button', { name: /account/i }));
47
+ expect(onSelect).toHaveBeenCalledWith(0);
48
+ expect(screen.queryByRole('button', { name: /review/i })).toBeNull();
49
+ });
50
+
51
+ it('canSelect gates forward jumps', () => {
52
+ const onSelect = vi.fn();
53
+ render(
54
+ <Stepper steps={steps} currentStep={0} onStepSelect={onSelect} canSelect={(i) => i <= 1} />,
55
+ );
56
+ fireEvent.click(screen.getByRole('button', { name: /profile/i }));
57
+ expect(onSelect).toHaveBeenCalledWith(1);
58
+ expect(screen.queryByRole('button', { name: /review/i })).toBeNull();
59
+ });
60
+
61
+ it('locked steps never render as buttons', () => {
62
+ render(
63
+ <Stepper
64
+ steps={[steps[0]!, { ...steps[1]!, locked: true }, steps[2]!]}
65
+ currentStep={2}
66
+ onStepSelect={() => {}}
67
+ />,
68
+ );
69
+ expect(screen.queryByRole('button', { name: /profile/i })).toBeNull();
70
+ expect(screen.getByRole('button', { name: /account/i })).toBeTruthy();
71
+ });
72
+
73
+ it('renders no buttons without onStepSelect', () => {
74
+ render(<Stepper steps={steps} currentStep={1} />);
75
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
76
+ });
77
+ });
78
+
79
+ describe('Stepper orientation', () => {
80
+ it('stacks vertically with a rail segment between steps', () => {
81
+ const { container } = render(<Stepper steps={steps} currentStep={1} orientation="vertical" />);
82
+ const list = container.querySelector('ol');
83
+ expect(list?.className).toContain('flex-col');
84
+ const rail = container.querySelectorAll('ol > li > span.w-px');
85
+ expect(rail).toHaveLength(2);
86
+ expect(rail[0]?.className).toContain('bg-primary/60');
87
+ expect(rail[1]?.className).toContain('bg-border');
88
+ });
89
+
90
+ it('renders descriptions and the optional marker', () => {
91
+ render(
92
+ <Stepper
93
+ steps={[{ id: 'a', label: 'Account', description: 'Your details', optional: true }]}
94
+ currentStep={0}
95
+ />,
96
+ );
97
+ expect(screen.getByText('Your details')).toBeTruthy();
98
+ expect(screen.getByText('(optional)')).toBeTruthy();
99
+ });
100
+ });
@@ -0,0 +1,171 @@
1
+ import { Fragment, type ReactNode } from 'react';
2
+ import { cn } from '../../infrastructure/utils';
3
+ import type { BaseProps } from '../../domain/types';
4
+
5
+ export interface MarkdownTextProps extends BaseProps {
6
+ children: string;
7
+ }
8
+
9
+ const INLINE_PATTERN =
10
+ /(\*\*([^*]+)\*\*)|(\*([^*\n]+)\*)|(`([^`\n]+)`)|(\[([^\]]+)\]\((https?:\/\/[^)\s]+)\))/g;
11
+
12
+ const FENCE_OPEN = /^```/;
13
+ const HEADING_PATTERN = /^(#{1,3})\s+(.*)$/;
14
+ const UNORDERED_ITEM = /^[-•]\s+(.*)$/;
15
+ const ORDERED_ITEM = /^\d+[.)]\s+(.*)$/;
16
+
17
+ function renderInline(text: string, keyPrefix: string): ReactNode[] {
18
+ const nodes: ReactNode[] = [];
19
+ let cursor = 0;
20
+ let match: RegExpExecArray | null;
21
+ let seq = 0;
22
+ INLINE_PATTERN.lastIndex = 0;
23
+
24
+ while ((match = INLINE_PATTERN.exec(text)) !== null) {
25
+ if (match.index > cursor) {
26
+ nodes.push(text.slice(cursor, match.index));
27
+ }
28
+ const key = `${keyPrefix}-i${seq++}`;
29
+ if (match[2] !== undefined) {
30
+ nodes.push(<strong key={key}>{match[2]}</strong>);
31
+ } else if (match[4] !== undefined) {
32
+ nodes.push(<em key={key}>{match[4]}</em>);
33
+ } else if (match[6] !== undefined) {
34
+ nodes.push(
35
+ <code key={key} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
36
+ {match[6]}
37
+ </code>
38
+ );
39
+ } else if (match[8] !== undefined && match[9] !== undefined) {
40
+ nodes.push(
41
+ <a
42
+ key={key}
43
+ href={match[9]}
44
+ target="_blank"
45
+ rel="noopener noreferrer"
46
+ className="font-medium text-primary underline underline-offset-2"
47
+ >
48
+ {match[8]}
49
+ </a>
50
+ );
51
+ }
52
+ cursor = match.index + match[0].length;
53
+ }
54
+
55
+ if (cursor < text.length) {
56
+ nodes.push(text.slice(cursor));
57
+ }
58
+ return nodes;
59
+ }
60
+
61
+ function renderParagraphLines(lines: string[], key: string): ReactNode {
62
+ return (
63
+ <p key={key}>
64
+ {lines.map((line, index) => (
65
+ <Fragment key={`${key}-l${index}`}>
66
+ {index > 0 && <br />}
67
+ {renderInline(line, `${key}-l${index}`)}
68
+ </Fragment>
69
+ ))}
70
+ </p>
71
+ );
72
+ }
73
+
74
+ export const MarkdownText = ({ children, className }: MarkdownTextProps) => {
75
+ const lines = children.replace(/\r\n/g, '\n').split('\n');
76
+ const blocks: ReactNode[] = [];
77
+ let index = 0;
78
+ let seq = 0;
79
+
80
+ while (index < lines.length) {
81
+ const line = lines[index] ?? '';
82
+
83
+ if (line.trim().length === 0) {
84
+ index += 1;
85
+ continue;
86
+ }
87
+
88
+ if (FENCE_OPEN.test(line.trim())) {
89
+ const codeLines: string[] = [];
90
+ index += 1;
91
+ while (index < lines.length && !FENCE_OPEN.test((lines[index] ?? '').trim())) {
92
+ codeLines.push(lines[index] ?? '');
93
+ index += 1;
94
+ }
95
+ index += 1;
96
+ blocks.push(
97
+ <pre
98
+ key={`b${seq++}`}
99
+ className="overflow-x-auto rounded-lg border border-border bg-card p-3 font-mono text-xs leading-relaxed"
100
+ >
101
+ <code>{codeLines.join('\n')}</code>
102
+ </pre>
103
+ );
104
+ continue;
105
+ }
106
+
107
+ const headingMatch = HEADING_PATTERN.exec(line);
108
+ if (headingMatch) {
109
+ const level = headingMatch[1]?.length ?? 3;
110
+ const content = headingMatch[2] ?? '';
111
+ const key = `b${seq++}`;
112
+ if (level === 1) {
113
+ blocks.push(<h3 key={key} className="text-base font-semibold">{renderInline(content, key)}</h3>);
114
+ } else if (level === 2) {
115
+ blocks.push(<h4 key={key} className="text-sm font-semibold">{renderInline(content, key)}</h4>);
116
+ } else {
117
+ blocks.push(<h5 key={key} className="text-xs font-semibold uppercase tracking-wide">{renderInline(content, key)}</h5>);
118
+ }
119
+ index += 1;
120
+ continue;
121
+ }
122
+
123
+ if (UNORDERED_ITEM.test(line)) {
124
+ const items: string[] = [];
125
+ while (index < lines.length && UNORDERED_ITEM.test(lines[index] ?? '')) {
126
+ items.push((UNORDERED_ITEM.exec(lines[index] ?? '')?.[1] ?? '').trim());
127
+ index += 1;
128
+ }
129
+ blocks.push(
130
+ <ul key={`b${seq++}`} className="list-disc space-y-1 pl-5">
131
+ {items.map((item, itemIndex) => (
132
+ <li key={`li${itemIndex}`}>{renderInline(item, `b${seq}-li${itemIndex}`)}</li>
133
+ ))}
134
+ </ul>
135
+ );
136
+ continue;
137
+ }
138
+
139
+ if (ORDERED_ITEM.test(line)) {
140
+ const items: string[] = [];
141
+ while (index < lines.length && ORDERED_ITEM.test(lines[index] ?? '')) {
142
+ items.push((ORDERED_ITEM.exec(lines[index] ?? '')?.[1] ?? '').trim());
143
+ index += 1;
144
+ }
145
+ blocks.push(
146
+ <ol key={`b${seq++}`} className="list-decimal space-y-1 pl-5">
147
+ {items.map((item, itemIndex) => (
148
+ <li key={`li${itemIndex}`}>{renderInline(item, `b${seq}-li${itemIndex}`)}</li>
149
+ ))}
150
+ </ol>
151
+ );
152
+ continue;
153
+ }
154
+
155
+ const paragraphLines: string[] = [];
156
+ while (
157
+ index < lines.length &&
158
+ (lines[index] ?? '').trim().length > 0 &&
159
+ !FENCE_OPEN.test((lines[index] ?? '').trim()) &&
160
+ !HEADING_PATTERN.test(lines[index] ?? '') &&
161
+ !UNORDERED_ITEM.test(lines[index] ?? '') &&
162
+ !ORDERED_ITEM.test(lines[index] ?? '')
163
+ ) {
164
+ paragraphLines.push(lines[index] ?? '');
165
+ index += 1;
166
+ }
167
+ blocks.push(renderParagraphLines(paragraphLines, `b${seq++}`));
168
+ }
169
+
170
+ return <div className={cn('space-y-3 text-sm leading-relaxed', className)}>{blocks}</div>;
171
+ };
@@ -67,3 +67,6 @@ export type { BookmarkButtonProps, BookmarkTone } from './BookmarkButton';
67
67
 
68
68
  export { TagInput } from './TagInput';
69
69
  export type { TagInputProps } from './TagInput';
70
+
71
+ export { MarkdownText } from './MarkdownText';
72
+ export type { MarkdownTextProps } from './MarkdownText';
@@ -15,7 +15,7 @@ import {
15
15
  DialogTitle,
16
16
  } from './Dialog';
17
17
 
18
- export type ModalSize = 'sm' | 'md' | 'lg';
18
+ export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl';
19
19
 
20
20
  export interface ModalProps {
21
21
  open: boolean;
@@ -25,6 +25,7 @@ export interface ModalProps {
25
25
  subtitle?: ReactNode;
26
26
  size?: ModalSize;
27
27
  danger?: boolean;
28
+ scrollable?: boolean;
28
29
  footer?: ReactNode;
29
30
  children?: ReactNode;
30
31
  className?: string;
@@ -35,6 +36,8 @@ const sizeWidths: Record<ModalSize, string> = {
35
36
  sm: 'max-w-sm',
36
37
  md: 'max-w-lg',
37
38
  lg: 'max-w-2xl',
39
+ xl: 'max-w-4xl',
40
+ '2xl': 'max-w-5xl',
38
41
  };
39
42
 
40
43
  export function Modal({
@@ -45,6 +48,7 @@ export function Modal({
45
48
  subtitle,
46
49
  size = 'md',
47
50
  danger = false,
51
+ scrollable = false,
48
52
  footer,
49
53
  children,
50
54
  className,
@@ -52,7 +56,15 @@ export function Modal({
52
56
  }: ModalProps) {
53
57
  return (
54
58
  <Dialog open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
55
- <DialogContent className={cn(sizeWidths[size], danger && 'border-destructive', className)} {...contentProps}>
59
+ <DialogContent
60
+ className={cn(
61
+ sizeWidths[size],
62
+ danger && 'border-destructive',
63
+ scrollable && 'flex max-h-[92vh] flex-col overflow-hidden',
64
+ className,
65
+ )}
66
+ {...contentProps}
67
+ >
56
68
  {(title !== undefined || description !== undefined || subtitle !== undefined) && (
57
69
  <DialogHeader>
58
70
  {title !== undefined && (
@@ -64,7 +76,7 @@ export function Modal({
64
76
  {(subtitle ?? description) !== undefined && <DialogDescription>{subtitle ?? description}</DialogDescription>}
65
77
  </DialogHeader>
66
78
  )}
67
- {children}
79
+ {scrollable ? <div className="min-h-0 flex-1 overflow-y-auto">{children}</div> : children}
68
80
  {footer !== undefined && <DialogFooter>{footer}</DialogFooter>}
69
81
  </DialogContent>
70
82
  </Dialog>
@@ -0,0 +1,202 @@
1
+ /**
2
+ * PlanPicker Component (Organism)
3
+ * @description Pricing plan card group with an optional billing cycle switch;
4
+ * money stays caller-computed — price and features are plain nodes
5
+ */
6
+
7
+ import type { KeyboardEvent, ReactNode } from 'react';
8
+ import { Check } from 'lucide-react';
9
+ import { cn } from '../../infrastructure/utils';
10
+ import { Button } from '../atoms/Button';
11
+ import { Segmented } from '../molecules/Segmented';
12
+
13
+ export interface PlanPickerCycle<T extends string = string> {
14
+ value: T;
15
+ label: ReactNode;
16
+ hint?: ReactNode;
17
+ }
18
+
19
+ export interface PlanPickerPlan {
20
+ id: string;
21
+ name: ReactNode;
22
+ badge?: ReactNode;
23
+ description?: ReactNode;
24
+ price: ReactNode;
25
+ priceNote?: ReactNode;
26
+ meta?: ReactNode;
27
+ features?: ReactNode[];
28
+ current?: boolean;
29
+ highlight?: boolean;
30
+ ctaLabel?: ReactNode;
31
+ disabled?: boolean;
32
+ onSelect?: () => void;
33
+ }
34
+
35
+ export interface PlanPickerProps<T extends string = string> {
36
+ plans: readonly PlanPickerPlan[];
37
+ cycle?: T;
38
+ cycles?: readonly PlanPickerCycle<T>[];
39
+ onCycleChange?: (value: T) => void;
40
+ selectedId?: string;
41
+ onPlanSelect?: (id: string) => void;
42
+ busy?: boolean;
43
+ columns?: 2 | 3 | 4;
44
+ className?: string;
45
+ }
46
+
47
+ const gridColumns: Record<2 | 3 | 4, string> = {
48
+ 2: 'grid-cols-1 sm:grid-cols-2',
49
+ 3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
50
+ 4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
51
+ };
52
+
53
+ function PlanCard({
54
+ plan,
55
+ selected,
56
+ selectable,
57
+ busy,
58
+ onSelect,
59
+ }: {
60
+ plan: PlanPickerPlan;
61
+ selected: boolean;
62
+ selectable: boolean;
63
+ busy: boolean;
64
+ onSelect: (id: string) => void;
65
+ }) {
66
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
67
+ if (selectable && (event.key === 'Enter' || event.key === ' ')) {
68
+ event.preventDefault();
69
+ onSelect(plan.id);
70
+ }
71
+ };
72
+ return (
73
+ <article
74
+ role={selectable ? 'radio' : undefined}
75
+ aria-checked={selectable ? selected : undefined}
76
+ tabIndex={selectable ? 0 : undefined}
77
+ onClick={() => selectable && onSelect(plan.id)}
78
+ onKeyDown={handleKeyDown}
79
+ className={cn(
80
+ 'relative flex flex-col gap-4 rounded-xl border bg-card p-5 text-left transition',
81
+ plan.highlight ? 'border-2 border-primary shadow-lg shadow-primary/5' : 'border-border',
82
+ selected && !plan.highlight && 'border-primary bg-primary/5',
83
+ plan.current && 'bg-muted/30',
84
+ selectable && 'cursor-pointer hover:border-primary/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
85
+ )}
86
+ >
87
+ {plan.badge !== undefined && (
88
+ <span
89
+ className={cn(
90
+ 'absolute -top-2.5 left-1/2 -translate-x-1/2 rounded-full px-2.5 py-0.5 text-[11px] font-bold shadow-xs',
91
+ plan.highlight ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground',
92
+ )}
93
+ >
94
+ {plan.badge}
95
+ </span>
96
+ )}
97
+ <div className="flex flex-col gap-1">
98
+ <span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{plan.name}</span>
99
+ <span className="flex flex-wrap items-baseline gap-1">
100
+ <span className="text-3xl font-extrabold tracking-tight text-foreground">{plan.price}</span>
101
+ {plan.priceNote !== undefined && (
102
+ <span className="text-xs text-muted-foreground">{plan.priceNote}</span>
103
+ )}
104
+ </span>
105
+ {plan.description !== undefined && (
106
+ <p className="text-xs text-muted-foreground">{plan.description}</p>
107
+ )}
108
+ </div>
109
+ {plan.meta !== undefined && (
110
+ <div className="flex items-center gap-2 rounded-lg bg-muted/60 px-2.5 py-2 text-xs text-muted-foreground">
111
+ {plan.meta}
112
+ </div>
113
+ )}
114
+ {plan.features !== undefined && plan.features.length > 0 && (
115
+ <ul className="flex flex-col gap-2 text-xs text-foreground/80">
116
+ {plan.features.map((feature, index) => (
117
+ <li key={index} className="flex items-start gap-2">
118
+ <Check className="mt-0.5 size-3.5 shrink-0 text-primary" aria-hidden="true" />
119
+ <span>{feature}</span>
120
+ </li>
121
+ ))}
122
+ </ul>
123
+ )}
124
+ <div className="mt-auto pt-1">
125
+ {plan.current ? (
126
+ <div
127
+ aria-disabled="true"
128
+ className="w-full rounded-lg bg-success/10 px-3 py-2 text-center text-xs font-semibold text-success"
129
+ >
130
+ {plan.ctaLabel ?? 'Current plan'}
131
+ </div>
132
+ ) : (
133
+ <Button
134
+ variant={plan.highlight ? 'primary' : 'secondary'}
135
+ size="sm"
136
+ block
137
+ disabled={plan.disabled}
138
+ loading={busy}
139
+ onClick={(event) => {
140
+ event.stopPropagation();
141
+ plan.onSelect?.();
142
+ }}
143
+ >
144
+ {plan.ctaLabel ?? 'Select'}
145
+ </Button>
146
+ )}
147
+ </div>
148
+ </article>
149
+ );
150
+ }
151
+
152
+ export function PlanPicker<T extends string = string>({
153
+ plans,
154
+ cycle,
155
+ cycles,
156
+ onCycleChange,
157
+ selectedId,
158
+ onPlanSelect,
159
+ busy = false,
160
+ columns = 3,
161
+ className,
162
+ }: PlanPickerProps<T>) {
163
+ const showCycles = cycles !== undefined && cycles.length > 0 && cycle !== undefined && onCycleChange !== undefined;
164
+ return (
165
+ <div className={className}>
166
+ {showCycles && (
167
+ <div className="mb-5 flex justify-center">
168
+ <Segmented
169
+ aria-label="Billing cycle"
170
+ options={cycles.map((entry) => ({
171
+ value: entry.value,
172
+ label: (
173
+ <span className="flex items-center gap-1.5">
174
+ {entry.label}
175
+ {entry.hint !== undefined && (
176
+ <span className="rounded bg-success/15 px-1 py-0.5 text-[10px] font-bold text-success">
177
+ {entry.hint}
178
+ </span>
179
+ )}
180
+ </span>
181
+ ),
182
+ }))}
183
+ value={cycle}
184
+ onChange={onCycleChange}
185
+ />
186
+ </div>
187
+ )}
188
+ <div className={cn('grid gap-4', gridColumns[columns])}>
189
+ {plans.map((plan) => (
190
+ <PlanCard
191
+ key={plan.id}
192
+ plan={plan}
193
+ selected={selectedId === plan.id}
194
+ selectable={onPlanSelect !== undefined && !plan.current && !plan.disabled}
195
+ busy={busy}
196
+ onSelect={onPlanSelect ?? (() => {})}
197
+ />
198
+ ))}
199
+ </div>
200
+ </div>
201
+ );
202
+ }