@olwiba/ui 0.1.13 → 0.1.15
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 +239 -5
- package/dist/index.js +786 -119
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/AuthSection.tsx +155 -54
- package/src/app/BillingPanel.tsx +149 -0
- package/src/app/OnboardingWizard.tsx +115 -0
- package/src/app/SettingsSection.tsx +29 -0
- package/src/app/TeamMembersPanel.tsx +174 -0
- package/src/components/CommandMenu.tsx +100 -0
- package/src/components/DataTable.tsx +202 -0
- package/src/components/FileUpload.tsx +195 -0
- package/src/components/Notify.tsx +94 -0
- package/src/index.ts +34 -0
package/package.json
CHANGED
package/src/app/AuthSection.tsx
CHANGED
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { Building2, ShieldCheck, Sparkles } from 'lucide-react';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Badge,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle,
|
|
11
|
+
InputOTP,
|
|
12
|
+
InputOTPGroup,
|
|
13
|
+
InputOTPSlot,
|
|
14
|
+
Label,
|
|
15
|
+
cn,
|
|
16
|
+
} from '@olwiba/cn';
|
|
6
17
|
import { Button } from '../primitives/Button';
|
|
7
18
|
import { Card } from '../primitives/Card';
|
|
8
19
|
import { Input } from '../primitives/Input';
|
|
@@ -65,19 +76,34 @@ function SplitAuth({ children, panel }: { children: React.ReactNode; panel?: Rea
|
|
|
65
76
|
// ─── Shared form slot ─────────────────────────────────────────────────────────
|
|
66
77
|
|
|
67
78
|
export interface AuthFormProps {
|
|
68
|
-
/**
|
|
69
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Controls form title, fields, and footer text.
|
|
81
|
+
* - `'signin'` / `'signup'` — email + password
|
|
82
|
+
* - `'forgot-password'` — email only, sends a reset link
|
|
83
|
+
* - `'reset-password'` — new password + confirmation
|
|
84
|
+
* - `'verify'` — one-time code entry (email verification or 2FA)
|
|
85
|
+
* @default 'signin'
|
|
86
|
+
*/
|
|
87
|
+
mode?: 'signin' | 'signup' | 'forgot-password' | 'reset-password' | 'verify';
|
|
70
88
|
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void;
|
|
71
89
|
onSso?: () => void;
|
|
72
90
|
/** (signin) Link to the sign-up page */
|
|
73
91
|
signUpHref?: string;
|
|
74
|
-
/** (signup) Link back to the sign-in page */
|
|
92
|
+
/** (signup / forgot-password / reset-password / verify) Link back to the sign-in page */
|
|
75
93
|
signInHref?: string;
|
|
76
94
|
forgotPasswordHref?: string;
|
|
95
|
+
/** (verify) Re-sends the one-time code */
|
|
96
|
+
onResend?: () => void;
|
|
97
|
+
/** (verify) Address the code was sent to, shown in the description */
|
|
98
|
+
destination?: string;
|
|
99
|
+
/** (verify) Number of digits in the code. @default 6 */
|
|
100
|
+
codeLength?: number;
|
|
77
101
|
/** Brand node — in centered layout renders above the card; in split layout renders inside the card */
|
|
78
102
|
brand?: React.ReactNode;
|
|
79
103
|
/** Error message displayed below the form fields */
|
|
80
104
|
error?: string;
|
|
105
|
+
/** Positive confirmation message displayed below the form fields (e.g. "Reset link sent") */
|
|
106
|
+
success?: string;
|
|
81
107
|
/** Disables the submit button and shows a loading label */
|
|
82
108
|
loading?: boolean;
|
|
83
109
|
/** Render prop for links — use to inject framework-native link components (e.g. TanStack Router Link) */
|
|
@@ -90,6 +116,14 @@ export interface AuthFormProps {
|
|
|
90
116
|
defaultPassword?: string;
|
|
91
117
|
}
|
|
92
118
|
|
|
119
|
+
const authCopy = {
|
|
120
|
+
signin: { title: 'Sign in', description: 'Enter your email and password to continue.', submit: 'Sign in' },
|
|
121
|
+
signup: { title: 'Create an account', description: 'Enter your details to create your account.', submit: 'Create account' },
|
|
122
|
+
'forgot-password': { title: 'Reset your password', description: 'Enter the email on your account and we’ll send you a link to reset your password.', submit: 'Send reset link' },
|
|
123
|
+
'reset-password': { title: 'Choose a new password', description: 'Your new password must be different from previous passwords.', submit: 'Reset password' },
|
|
124
|
+
verify: { title: 'Enter your code', description: 'We sent a verification code to your email.', submit: 'Verify' },
|
|
125
|
+
} as const;
|
|
126
|
+
|
|
93
127
|
function DefaultForm({
|
|
94
128
|
mode = 'signin',
|
|
95
129
|
onSubmit,
|
|
@@ -97,8 +131,12 @@ function DefaultForm({
|
|
|
97
131
|
signUpHref = '#',
|
|
98
132
|
signInHref = '#',
|
|
99
133
|
forgotPasswordHref = '#',
|
|
134
|
+
onResend,
|
|
135
|
+
destination,
|
|
136
|
+
codeLength = 6,
|
|
100
137
|
brand,
|
|
101
138
|
error,
|
|
139
|
+
success,
|
|
102
140
|
loading,
|
|
103
141
|
renderLink = defaultRenderLink,
|
|
104
142
|
footer,
|
|
@@ -106,9 +144,14 @@ function DefaultForm({
|
|
|
106
144
|
defaultPassword,
|
|
107
145
|
}: AuthFormProps) {
|
|
108
146
|
const isSignUp = mode === 'signup';
|
|
147
|
+
const isForgotPassword = mode === 'forgot-password';
|
|
148
|
+
const isResetPassword = mode === 'reset-password';
|
|
149
|
+
const isVerify = mode === 'verify';
|
|
150
|
+
const [code, setCode] = React.useState('');
|
|
109
151
|
const hasPrefill = !!(defaultEmail || defaultPassword);
|
|
110
152
|
const prefillStyle = (active: boolean): React.CSSProperties | undefined =>
|
|
111
153
|
active ? { animation: 'auth-prefill 1.6s ease-out 0.35s 1 both' } : undefined;
|
|
154
|
+
const copy = authCopy[mode];
|
|
112
155
|
|
|
113
156
|
return (
|
|
114
157
|
<Card className="w-full">
|
|
@@ -118,11 +161,11 @@ function DefaultForm({
|
|
|
118
161
|
)}
|
|
119
162
|
<CardHeader>
|
|
120
163
|
{brand && <div className="mb-2">{brand}</div>}
|
|
121
|
-
<CardTitle>{
|
|
164
|
+
<CardTitle>{copy.title}</CardTitle>
|
|
122
165
|
<CardDescription>
|
|
123
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
166
|
+
{isVerify && destination ? (
|
|
167
|
+
<>We sent a verification code to <span className="font-medium text-foreground">{destination}</span>.</>
|
|
168
|
+
) : copy.description}
|
|
126
169
|
</CardDescription>
|
|
127
170
|
</CardHeader>
|
|
128
171
|
<CardContent className="space-y-4">
|
|
@@ -133,66 +176,124 @@ function DefaultForm({
|
|
|
133
176
|
<Input id="auth-name" name="name" type="text" placeholder="Your name" autoComplete="name" />
|
|
134
177
|
</div>
|
|
135
178
|
)}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
<div className="flex items-center justify-between">
|
|
150
|
-
<Label htmlFor="auth-password">Password</Label>
|
|
151
|
-
{!isSignUp && forgotPasswordHref && renderLink({
|
|
152
|
-
href: forgotPasswordHref,
|
|
153
|
-
className: 'text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground',
|
|
154
|
-
children: 'Forgot password?',
|
|
155
|
-
})}
|
|
179
|
+
|
|
180
|
+
{(mode === 'signin' || mode === 'signup' || isForgotPassword) && (
|
|
181
|
+
<div className="space-y-2">
|
|
182
|
+
<Label htmlFor="auth-email">Email address</Label>
|
|
183
|
+
<Input
|
|
184
|
+
id="auth-email"
|
|
185
|
+
name="email"
|
|
186
|
+
type="email"
|
|
187
|
+
placeholder="name@company.com"
|
|
188
|
+
autoComplete="email"
|
|
189
|
+
defaultValue={defaultEmail}
|
|
190
|
+
style={prefillStyle(!!defaultEmail)}
|
|
191
|
+
/>
|
|
156
192
|
</div>
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{(mode === 'signin' || mode === 'signup') && (
|
|
196
|
+
<div className="space-y-2">
|
|
197
|
+
<div className="flex items-center justify-between">
|
|
198
|
+
<Label htmlFor="auth-password">Password</Label>
|
|
199
|
+
{!isSignUp && forgotPasswordHref && renderLink({
|
|
200
|
+
href: forgotPasswordHref,
|
|
201
|
+
className: 'text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground',
|
|
202
|
+
children: 'Forgot password?',
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
<Input
|
|
206
|
+
id="auth-password"
|
|
207
|
+
name="password"
|
|
208
|
+
type="password"
|
|
209
|
+
placeholder="••••••••"
|
|
210
|
+
autoComplete={isSignUp ? 'new-password' : 'current-password'}
|
|
211
|
+
defaultValue={defaultPassword}
|
|
212
|
+
style={prefillStyle(!!defaultPassword)}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
{isResetPassword && (
|
|
218
|
+
<>
|
|
219
|
+
<div className="space-y-2">
|
|
220
|
+
<Label htmlFor="auth-new-password">New password</Label>
|
|
221
|
+
<Input id="auth-new-password" name="password" type="password" placeholder="••••••••" autoComplete="new-password" />
|
|
222
|
+
</div>
|
|
223
|
+
<div className="space-y-2">
|
|
224
|
+
<Label htmlFor="auth-confirm-password">Confirm password</Label>
|
|
225
|
+
<Input id="auth-confirm-password" name="confirmPassword" type="password" placeholder="••••••••" autoComplete="new-password" />
|
|
226
|
+
</div>
|
|
227
|
+
</>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{isVerify && (
|
|
231
|
+
<div className="space-y-2">
|
|
232
|
+
<input type="hidden" name="code" value={code} />
|
|
233
|
+
<div className="flex justify-center py-2">
|
|
234
|
+
<InputOTP maxLength={codeLength} value={code} onChange={setCode} containerClassName="justify-center">
|
|
235
|
+
<InputOTPGroup>
|
|
236
|
+
{Array.from({ length: codeLength }).map((_, i) => (
|
|
237
|
+
<InputOTPSlot key={i} index={i} />
|
|
238
|
+
))}
|
|
239
|
+
</InputOTPGroup>
|
|
240
|
+
</InputOTP>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
|
|
167
245
|
{error && (
|
|
168
246
|
<p className="text-sm font-medium text-destructive">{error}</p>
|
|
169
247
|
)}
|
|
248
|
+
{success && (
|
|
249
|
+
<p className="text-sm font-medium text-primary">{success}</p>
|
|
250
|
+
)}
|
|
251
|
+
|
|
170
252
|
<div className="flex flex-col gap-2">
|
|
171
|
-
<Button type="submit" className="w-full" disabled={loading}>
|
|
172
|
-
{loading ? 'Please wait…' :
|
|
253
|
+
<Button type="submit" className="w-full" disabled={loading || (isVerify && code.length < codeLength)}>
|
|
254
|
+
{loading ? 'Please wait…' : copy.submit}
|
|
173
255
|
</Button>
|
|
174
|
-
{onSso && (
|
|
256
|
+
{onSso && mode === 'signin' && (
|
|
175
257
|
<Button type="button" variant="outline" className="w-full" onClick={onSso} disabled={loading}>
|
|
176
258
|
Use SSO
|
|
177
259
|
</Button>
|
|
178
260
|
)}
|
|
179
261
|
</div>
|
|
180
262
|
</form>
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
263
|
+
|
|
264
|
+
{isVerify && onResend && (
|
|
265
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
266
|
+
Didn’t get a code?{' '}
|
|
267
|
+
<button type="button" onClick={onResend} className="text-foreground underline underline-offset-4">
|
|
268
|
+
Resend
|
|
269
|
+
</button>
|
|
270
|
+
</p>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{(mode === 'signin' || mode === 'signup') && (
|
|
274
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
275
|
+
{isSignUp ? (
|
|
189
276
|
<>
|
|
190
|
-
|
|
191
|
-
{renderLink({ href:
|
|
277
|
+
Already have an account?{' '}
|
|
278
|
+
{renderLink({ href: signInHref, className: 'text-foreground underline underline-offset-4', children: 'Sign in' })}
|
|
192
279
|
</>
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
|
|
280
|
+
) : (
|
|
281
|
+
signUpHref && (
|
|
282
|
+
<>
|
|
283
|
+
New here?{' '}
|
|
284
|
+
{renderLink({ href: signUpHref, className: 'text-foreground underline underline-offset-4', children: 'Create an account' })}
|
|
285
|
+
</>
|
|
286
|
+
)
|
|
287
|
+
)}
|
|
288
|
+
</p>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{(isForgotPassword || isResetPassword || isVerify) && (
|
|
292
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
293
|
+
{renderLink({ href: signInHref, className: 'text-foreground underline underline-offset-4', children: 'Back to sign in' })}
|
|
294
|
+
</p>
|
|
295
|
+
)}
|
|
296
|
+
|
|
196
297
|
{footer && <div>{footer}</div>}
|
|
197
298
|
</CardContent>
|
|
198
299
|
</Card>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import type { ColumnDef } from '@tanstack/react-table';
|
|
5
|
+
import { CreditCard, Download } from 'lucide-react';
|
|
6
|
+
import { Badge, Progress } from '@olwiba/cn';
|
|
7
|
+
import { Button } from '../primitives/Button';
|
|
8
|
+
import { DataTable } from '../components/DataTable';
|
|
9
|
+
import { SettingsSection } from './SettingsSection';
|
|
10
|
+
|
|
11
|
+
export interface BillingUsageMetric {
|
|
12
|
+
label: string;
|
|
13
|
+
used: number;
|
|
14
|
+
limit: number;
|
|
15
|
+
unit?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BillingInvoice {
|
|
19
|
+
id: string;
|
|
20
|
+
date: string;
|
|
21
|
+
amount: string;
|
|
22
|
+
status: 'paid' | 'open' | 'void';
|
|
23
|
+
downloadUrl?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BillingPaymentMethod {
|
|
27
|
+
brand: string;
|
|
28
|
+
last4: string;
|
|
29
|
+
expiry: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BillingPanelProps {
|
|
33
|
+
planName: string;
|
|
34
|
+
planPrice?: string;
|
|
35
|
+
renewalDate?: string;
|
|
36
|
+
usage?: BillingUsageMetric[];
|
|
37
|
+
paymentMethod?: BillingPaymentMethod;
|
|
38
|
+
invoices?: BillingInvoice[];
|
|
39
|
+
onManagePlan?: () => void;
|
|
40
|
+
onUpdatePaymentMethod?: () => void;
|
|
41
|
+
className?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const invoiceStatusVariant = {
|
|
45
|
+
paid: 'secondary',
|
|
46
|
+
open: 'outline',
|
|
47
|
+
void: 'destructive',
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* In-app billing summary — current plan + usage, payment method, invoice
|
|
52
|
+
* history. Built from `SettingsSection` rows and `DataTable`, rather than a
|
|
53
|
+
* one-off billing page each time. Marketing plan comparisons stay in
|
|
54
|
+
* `PricingSection`/`UpgradePrompt` — this is the account-side view.
|
|
55
|
+
*/
|
|
56
|
+
export function BillingPanel({
|
|
57
|
+
planName,
|
|
58
|
+
planPrice,
|
|
59
|
+
renewalDate,
|
|
60
|
+
usage,
|
|
61
|
+
paymentMethod,
|
|
62
|
+
invoices,
|
|
63
|
+
onManagePlan,
|
|
64
|
+
onUpdatePaymentMethod,
|
|
65
|
+
className,
|
|
66
|
+
}: BillingPanelProps) {
|
|
67
|
+
const invoiceColumns = React.useMemo<ColumnDef<BillingInvoice>[]>(() => [
|
|
68
|
+
{ accessorKey: 'date', header: 'Date' },
|
|
69
|
+
{ accessorKey: 'amount', header: 'Amount' },
|
|
70
|
+
{
|
|
71
|
+
accessorKey: 'status',
|
|
72
|
+
header: 'Status',
|
|
73
|
+
cell: ({ row }) => (
|
|
74
|
+
<Badge variant={invoiceStatusVariant[row.original.status]} className="capitalize">
|
|
75
|
+
{row.original.status}
|
|
76
|
+
</Badge>
|
|
77
|
+
),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'download',
|
|
81
|
+
header: '',
|
|
82
|
+
cell: ({ row }) =>
|
|
83
|
+
row.original.downloadUrl ? (
|
|
84
|
+
<Button variant="ghost" size="icon" className="size-8" asChild>
|
|
85
|
+
<a href={row.original.downloadUrl} download>
|
|
86
|
+
<Download className="size-4" />
|
|
87
|
+
<span className="sr-only">Download invoice</span>
|
|
88
|
+
</a>
|
|
89
|
+
</Button>
|
|
90
|
+
) : null,
|
|
91
|
+
enableSorting: false,
|
|
92
|
+
},
|
|
93
|
+
], []);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className={className}>
|
|
97
|
+
<div className="divide-y divide-border">
|
|
98
|
+
<SettingsSection title="Current plan" description="Your subscription and usage against plan limits.">
|
|
99
|
+
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border bg-card/60 p-4">
|
|
100
|
+
<div>
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<p className="text-lg font-semibold">{planName}</p>
|
|
103
|
+
{planPrice && <Badge variant="secondary">{planPrice}</Badge>}
|
|
104
|
+
</div>
|
|
105
|
+
{renewalDate && <p className="mt-1 text-sm text-muted-foreground">Renews {renewalDate}</p>}
|
|
106
|
+
</div>
|
|
107
|
+
{onManagePlan && <Button onClick={onManagePlan}>Manage plan</Button>}
|
|
108
|
+
</div>
|
|
109
|
+
{usage && usage.length > 0 && (
|
|
110
|
+
<div className="mt-4 space-y-4">
|
|
111
|
+
{usage.map((metric) => (
|
|
112
|
+
<div key={metric.label} className="space-y-1.5">
|
|
113
|
+
<div className="flex items-center justify-between text-sm">
|
|
114
|
+
<span>{metric.label}</span>
|
|
115
|
+
<span className="text-muted-foreground">
|
|
116
|
+
{metric.used}{metric.unit} / {metric.limit}{metric.unit}
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
<Progress value={(metric.used / metric.limit) * 100} />
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</SettingsSection>
|
|
125
|
+
|
|
126
|
+
{paymentMethod && (
|
|
127
|
+
<SettingsSection title="Payment method" description="Used for your subscription renewal.">
|
|
128
|
+
<div className="flex items-center justify-between gap-4 rounded-lg border bg-card/60 p-4">
|
|
129
|
+
<div className="flex items-center gap-3">
|
|
130
|
+
<CreditCard className="size-5 text-muted-foreground" />
|
|
131
|
+
<p className="text-sm">
|
|
132
|
+
<span className="font-medium capitalize">{paymentMethod.brand}</span> ending in {paymentMethod.last4}
|
|
133
|
+
<span className="text-muted-foreground"> · expires {paymentMethod.expiry}</span>
|
|
134
|
+
</p>
|
|
135
|
+
</div>
|
|
136
|
+
{onUpdatePaymentMethod && <Button variant="outline" onClick={onUpdatePaymentMethod}>Update</Button>}
|
|
137
|
+
</div>
|
|
138
|
+
</SettingsSection>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{invoices && invoices.length > 0 && (
|
|
142
|
+
<SettingsSection title="Billing history" description="Download past invoices for your records.">
|
|
143
|
+
<DataTable columns={invoiceColumns} data={invoices} pageSize={5} />
|
|
144
|
+
</SettingsSection>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Check } from 'lucide-react';
|
|
5
|
+
import { cn } from '@olwiba/cn';
|
|
6
|
+
import { Button } from '../primitives/Button';
|
|
7
|
+
|
|
8
|
+
export interface OnboardingStep {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
content: React.ReactNode;
|
|
13
|
+
/** Runs before advancing past this step. Return `false`/an error string to block. */
|
|
14
|
+
onNext?: () => boolean | string | Promise<boolean | string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OnboardingWizardProps {
|
|
18
|
+
steps: OnboardingStep[];
|
|
19
|
+
/** Called after the last step's `onNext` succeeds. */
|
|
20
|
+
onComplete?: () => void;
|
|
21
|
+
onStepChange?: (index: number) => void;
|
|
22
|
+
/** Controlled current step index — omit to let the component manage it internally. */
|
|
23
|
+
step?: number;
|
|
24
|
+
onStepIndexChange?: (index: number) => void;
|
|
25
|
+
completeLabel?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stateful multi-step onboarding flow. One component driven by a `steps`
|
|
31
|
+
* prop — swap the array to reshape the flow rather than hand-building a
|
|
32
|
+
* new wizard per feature.
|
|
33
|
+
*/
|
|
34
|
+
export function OnboardingWizard({
|
|
35
|
+
steps,
|
|
36
|
+
onComplete,
|
|
37
|
+
onStepChange,
|
|
38
|
+
step: stepProp,
|
|
39
|
+
onStepIndexChange,
|
|
40
|
+
completeLabel = 'Finish',
|
|
41
|
+
className,
|
|
42
|
+
}: OnboardingWizardProps) {
|
|
43
|
+
const [internalStep, setInternalStep] = React.useState(0);
|
|
44
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
45
|
+
const [pending, setPending] = React.useState(false);
|
|
46
|
+
const index = stepProp ?? internalStep;
|
|
47
|
+
const current = steps[index];
|
|
48
|
+
const isLast = index === steps.length - 1;
|
|
49
|
+
|
|
50
|
+
const goTo = (next: number) => {
|
|
51
|
+
setError(null);
|
|
52
|
+
if (stepProp === undefined) setInternalStep(next);
|
|
53
|
+
onStepIndexChange?.(next);
|
|
54
|
+
onStepChange?.(next);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleNext = async () => {
|
|
58
|
+
if (current.onNext) {
|
|
59
|
+
setPending(true);
|
|
60
|
+
const result = await current.onNext();
|
|
61
|
+
setPending(false);
|
|
62
|
+
if (result === false) { setError('Please complete this step before continuing.'); return; }
|
|
63
|
+
if (typeof result === 'string') { setError(result); return; }
|
|
64
|
+
}
|
|
65
|
+
if (isLast) {
|
|
66
|
+
onComplete?.();
|
|
67
|
+
} else {
|
|
68
|
+
goTo(index + 1);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleBack = () => {
|
|
73
|
+
if (index > 0) goTo(index - 1);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className={cn('w-full max-w-lg space-y-8', className)}>
|
|
78
|
+
<ol className="flex items-center">
|
|
79
|
+
{steps.map((step, i) => (
|
|
80
|
+
<li key={step.id} className={cn('flex items-center', i !== steps.length - 1 && 'flex-1')}>
|
|
81
|
+
<div
|
|
82
|
+
className={cn(
|
|
83
|
+
'flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium',
|
|
84
|
+
i < index ? 'border-primary bg-primary text-primary-foreground' : i === index ? 'border-primary text-primary' : 'border-border text-muted-foreground',
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{i < index ? <Check className="size-4" /> : i + 1}
|
|
88
|
+
</div>
|
|
89
|
+
{i !== steps.length - 1 && (
|
|
90
|
+
<div className={cn('mx-2 h-px flex-1', i < index ? 'bg-primary' : 'bg-border')} />
|
|
91
|
+
)}
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</ol>
|
|
95
|
+
|
|
96
|
+
<div className="space-y-4">
|
|
97
|
+
<div>
|
|
98
|
+
<h2 className="text-xl font-semibold tracking-tight">{current.title}</h2>
|
|
99
|
+
{current.description && <p className="mt-1 text-sm text-muted-foreground">{current.description}</p>}
|
|
100
|
+
</div>
|
|
101
|
+
<div>{current.content}</div>
|
|
102
|
+
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className="flex items-center justify-between">
|
|
106
|
+
<Button type="button" variant="ghost" onClick={handleBack} disabled={index === 0 || pending}>
|
|
107
|
+
Back
|
|
108
|
+
</Button>
|
|
109
|
+
<Button type="button" onClick={handleNext} disabled={pending}>
|
|
110
|
+
{pending ? 'Please wait…' : isLast ? completeLabel : 'Continue'}
|
|
111
|
+
</Button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cn } from '@olwiba/cn';
|
|
3
|
+
|
|
4
|
+
export interface SettingsSectionProps {
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
/** Form fields, buttons, or any content for the right-hand column — e.g. a `<form>`. */
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
/** Styles the content column for a destructive action (e.g. "Delete account"). @default false */
|
|
10
|
+
danger?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One title+description+content row for a settings page. Stack a few inside
|
|
16
|
+
* a `<div className="divide-y divide-border">` to build a full settings
|
|
17
|
+
* page — one block reused per row, rather than a bespoke layout each time.
|
|
18
|
+
*/
|
|
19
|
+
export function SettingsSection({ title, description, children, danger = false, className }: SettingsSectionProps) {
|
|
20
|
+
return (
|
|
21
|
+
<div className={cn('grid grid-cols-1 gap-x-8 gap-y-6 py-10 sm:grid-cols-3', className)}>
|
|
22
|
+
<div>
|
|
23
|
+
<h2 className={cn('text-base font-semibold', danger && 'text-destructive')}>{title}</h2>
|
|
24
|
+
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
|
25
|
+
</div>
|
|
26
|
+
<div className="sm:col-span-2">{children}</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|