@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.
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/__tests__/components.smoke.test.tsx +34 -0
- package/src/__tests__/markdown-text.test.tsx +85 -0
- package/src/__tests__/modal.test.tsx +46 -0
- package/src/__tests__/plan-picker.test.tsx +81 -0
- package/src/__tests__/stepper.test.tsx +100 -0
- package/src/presentation/molecules/MarkdownText.tsx +171 -0
- package/src/presentation/molecules/index.ts +3 -0
- package/src/presentation/organisms/Modal.tsx +15 -3
- package/src/presentation/organisms/PlanPicker.tsx +202 -0
- package/src/presentation/organisms/Stepper.tsx +146 -0
- package/src/presentation/organisms/index.ts +6 -0
|
@@ -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
|
|
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
|
+
}
|