@olwiba/ui 0.1.14 → 0.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/index.d.ts +328 -36
- package/dist/index.js +1160 -326
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/AuthSection.tsx +158 -56
- package/src/app/BillingPanel.tsx +155 -0
- package/src/app/OnboardingWizard.tsx +128 -0
- package/src/app/SettingsSection.tsx +29 -0
- package/src/app/TeamMembersPanel.tsx +184 -0
- package/src/components/ActivityFeed.tsx +82 -0
- package/src/components/CommandMenu.tsx +106 -0
- package/src/components/DataTable.tsx +212 -0
- package/src/components/FileUpload.tsx +214 -0
- package/src/components/NotificationsPopover.tsx +146 -0
- package/src/components/Notify.tsx +96 -0
- package/src/index.ts +38 -2
- package/src/marketing/ContactSection.tsx +13 -2
- package/src/marketing/CtaSection.tsx +93 -9
- package/src/marketing/FeaturesSection.tsx +27 -12
- package/src/marketing/HeroSection.tsx +5 -2
- package/src/marketing/NewsletterSection.tsx +12 -2
- package/src/marketing/TeamSection.tsx +10 -2
- package/src/mechanics/Carousel.tsx +112 -0
- package/src/marketing/CarouselSection.tsx +0 -126
- package/src/marketing/CtaCardSection.tsx +0 -95
package/package.json
CHANGED
package/src/app/AuthSection.tsx
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
|
-
import { Building2, ShieldCheck, Sparkles } from 'lucide-react';
|
|
5
|
-
import {
|
|
4
|
+
import { Building2, Loader2, ShieldCheck, Sparkles } from 'lucide-react';
|
|
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,125 @@ 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
|
-
<p className="text-sm font-medium text-destructive">{error}</p>
|
|
246
|
+
<p role="alert" className="text-sm font-medium text-destructive">{error}</p>
|
|
169
247
|
)}
|
|
248
|
+
{success && (
|
|
249
|
+
<p role="status" 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
|
|
253
|
+
<Button type="submit" className="w-full" disabled={loading || (isVerify && code.length < codeLength)}>
|
|
254
|
+
{loading && <Loader2 className="size-4 animate-spin" />}
|
|
255
|
+
{loading ? 'Please wait…' : copy.submit}
|
|
173
256
|
</Button>
|
|
174
|
-
{onSso && (
|
|
257
|
+
{onSso && (mode === 'signin' || mode === 'signup') && (
|
|
175
258
|
<Button type="button" variant="outline" className="w-full" onClick={onSso} disabled={loading}>
|
|
176
259
|
Use SSO
|
|
177
260
|
</Button>
|
|
178
261
|
)}
|
|
179
262
|
</div>
|
|
180
263
|
</form>
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
264
|
+
|
|
265
|
+
{isVerify && onResend && (
|
|
266
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
267
|
+
Didn’t get a code?{' '}
|
|
268
|
+
<button type="button" onClick={onResend} className="text-foreground underline underline-offset-4">
|
|
269
|
+
Resend
|
|
270
|
+
</button>
|
|
271
|
+
</p>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
{(mode === 'signin' || mode === 'signup') && (
|
|
275
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
276
|
+
{isSignUp ? (
|
|
189
277
|
<>
|
|
190
|
-
|
|
191
|
-
{renderLink({ href:
|
|
278
|
+
Already have an account?{' '}
|
|
279
|
+
{renderLink({ href: signInHref, className: 'text-foreground underline underline-offset-4', children: 'Sign in' })}
|
|
192
280
|
</>
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
|
|
281
|
+
) : (
|
|
282
|
+
signUpHref && (
|
|
283
|
+
<>
|
|
284
|
+
New here?{' '}
|
|
285
|
+
{renderLink({ href: signUpHref, className: 'text-foreground underline underline-offset-4', children: 'Create an account' })}
|
|
286
|
+
</>
|
|
287
|
+
)
|
|
288
|
+
)}
|
|
289
|
+
</p>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{(isForgotPassword || isResetPassword || isVerify) && (
|
|
293
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
294
|
+
{renderLink({ href: signInHref, className: 'text-foreground underline underline-offset-4', children: 'Back to sign in' })}
|
|
295
|
+
</p>
|
|
296
|
+
)}
|
|
297
|
+
|
|
196
298
|
{footer && <div>{footer}</div>}
|
|
197
299
|
</CardContent>
|
|
198
300
|
</Card>
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
// Dates and amounts are display strings — sorting them would be lexicographic, so keep it off
|
|
68
|
+
const invoiceColumns = React.useMemo<ColumnDef<BillingInvoice>[]>(() => [
|
|
69
|
+
{ accessorKey: 'date', header: 'Date', enableSorting: false },
|
|
70
|
+
{ accessorKey: 'amount', header: 'Amount', enableSorting: false },
|
|
71
|
+
{
|
|
72
|
+
accessorKey: 'status',
|
|
73
|
+
header: 'Status',
|
|
74
|
+
enableSorting: false,
|
|
75
|
+
cell: ({ row }) => (
|
|
76
|
+
<Badge variant={invoiceStatusVariant[row.original.status]} className="capitalize">
|
|
77
|
+
{row.original.status}
|
|
78
|
+
</Badge>
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'download',
|
|
83
|
+
header: '',
|
|
84
|
+
cell: ({ row }) =>
|
|
85
|
+
row.original.downloadUrl ? (
|
|
86
|
+
<Button variant="ghost" size="icon" className="size-8" asChild>
|
|
87
|
+
<a href={row.original.downloadUrl} download>
|
|
88
|
+
<Download className="size-4" />
|
|
89
|
+
<span className="sr-only">Download invoice</span>
|
|
90
|
+
</a>
|
|
91
|
+
</Button>
|
|
92
|
+
) : null,
|
|
93
|
+
enableSorting: false,
|
|
94
|
+
},
|
|
95
|
+
], []);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className={className}>
|
|
99
|
+
<div className="divide-y divide-border">
|
|
100
|
+
<SettingsSection title="Current plan" description="Your subscription and usage against plan limits.">
|
|
101
|
+
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border bg-card/60 p-4">
|
|
102
|
+
<div>
|
|
103
|
+
<div className="flex items-center gap-2">
|
|
104
|
+
<p className="text-lg font-semibold">{planName}</p>
|
|
105
|
+
{planPrice && <Badge variant="secondary">{planPrice}</Badge>}
|
|
106
|
+
</div>
|
|
107
|
+
{renewalDate && <p className="mt-1 text-sm text-muted-foreground">Renews {renewalDate}</p>}
|
|
108
|
+
</div>
|
|
109
|
+
{onManagePlan && <Button onClick={onManagePlan}>Manage plan</Button>}
|
|
110
|
+
</div>
|
|
111
|
+
{usage && usage.length > 0 && (
|
|
112
|
+
<div className="mt-4 space-y-4">
|
|
113
|
+
{usage.map((metric) => {
|
|
114
|
+
const overLimit = metric.limit > 0 && metric.used > metric.limit;
|
|
115
|
+
const percent = metric.limit > 0 ? Math.min(100, (metric.used / metric.limit) * 100) : 0;
|
|
116
|
+
return (
|
|
117
|
+
<div key={metric.label} className="space-y-1.5">
|
|
118
|
+
<div className="flex items-center justify-between text-sm">
|
|
119
|
+
<span>{metric.label}</span>
|
|
120
|
+
<span className={overLimit ? 'font-medium text-destructive' : 'text-muted-foreground'}>
|
|
121
|
+
{metric.used}{metric.unit} / {metric.limit}{metric.unit}
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
<Progress value={percent} className={overLimit ? '[&>div]:bg-destructive' : undefined} />
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</SettingsSection>
|
|
131
|
+
|
|
132
|
+
{paymentMethod && (
|
|
133
|
+
<SettingsSection title="Payment method" description="Used for your subscription renewal.">
|
|
134
|
+
<div className="flex items-center justify-between gap-4 rounded-lg border bg-card/60 p-4">
|
|
135
|
+
<div className="flex items-center gap-3">
|
|
136
|
+
<CreditCard className="size-5 text-muted-foreground" />
|
|
137
|
+
<p className="text-sm">
|
|
138
|
+
<span className="font-medium capitalize">{paymentMethod.brand}</span> ending in {paymentMethod.last4}
|
|
139
|
+
<span className="text-muted-foreground"> · expires {paymentMethod.expiry}</span>
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
{onUpdatePaymentMethod && <Button variant="outline" onClick={onUpdatePaymentMethod}>Update</Button>}
|
|
143
|
+
</div>
|
|
144
|
+
</SettingsSection>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{invoices && invoices.length > 0 && (
|
|
148
|
+
<SettingsSection title="Billing history" description="Download past invoices for your records.">
|
|
149
|
+
<DataTable columns={invoiceColumns} data={invoices} pageSize={5} />
|
|
150
|
+
</SettingsSection>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Check, Loader2 } 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
|
+
let result: boolean | string;
|
|
61
|
+
try {
|
|
62
|
+
result = await current.onNext();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
|
65
|
+
return;
|
|
66
|
+
} finally {
|
|
67
|
+
setPending(false);
|
|
68
|
+
}
|
|
69
|
+
if (result === false) { setError('Please complete this step before continuing.'); return; }
|
|
70
|
+
if (typeof result === 'string') { setError(result); return; }
|
|
71
|
+
}
|
|
72
|
+
if (isLast) {
|
|
73
|
+
onComplete?.();
|
|
74
|
+
} else {
|
|
75
|
+
goTo(index + 1);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const handleBack = () => {
|
|
80
|
+
if (index > 0) goTo(index - 1);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className={cn('w-full max-w-lg space-y-8', className)}>
|
|
85
|
+
<ol className="flex items-center" aria-label="Progress">
|
|
86
|
+
{steps.map((step, i) => (
|
|
87
|
+
<li
|
|
88
|
+
key={step.id}
|
|
89
|
+
aria-current={i === index ? 'step' : undefined}
|
|
90
|
+
className={cn('flex items-center', i !== steps.length - 1 && 'flex-1')}
|
|
91
|
+
>
|
|
92
|
+
<div
|
|
93
|
+
className={cn(
|
|
94
|
+
'flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium',
|
|
95
|
+
i < index ? 'border-primary bg-primary text-primary-foreground' : i === index ? 'border-primary text-primary' : 'border-border text-muted-foreground',
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
{i < index ? <Check className="size-4" /> : i + 1}
|
|
99
|
+
<span className="sr-only">{step.title}{i < index ? ' (completed)' : ''}</span>
|
|
100
|
+
</div>
|
|
101
|
+
{i !== steps.length - 1 && (
|
|
102
|
+
<div className={cn('mx-2 h-px flex-1', i < index ? 'bg-primary' : 'bg-border')} />
|
|
103
|
+
)}
|
|
104
|
+
</li>
|
|
105
|
+
))}
|
|
106
|
+
</ol>
|
|
107
|
+
|
|
108
|
+
<div className="space-y-4">
|
|
109
|
+
<div>
|
|
110
|
+
<h2 className="text-xl font-semibold tracking-tight">{current.title}</h2>
|
|
111
|
+
{current.description && <p className="mt-1 text-sm text-muted-foreground">{current.description}</p>}
|
|
112
|
+
</div>
|
|
113
|
+
<div>{current.content}</div>
|
|
114
|
+
{error && <p role="alert" className="text-sm font-medium text-destructive">{error}</p>}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="flex items-center justify-between">
|
|
118
|
+
<Button type="button" variant="ghost" onClick={handleBack} disabled={index === 0 || pending}>
|
|
119
|
+
Back
|
|
120
|
+
</Button>
|
|
121
|
+
<Button type="button" onClick={handleNext} disabled={pending}>
|
|
122
|
+
{pending && <Loader2 className="size-4 animate-spin" />}
|
|
123
|
+
{pending ? 'Please wait…' : isLast ? completeLabel : 'Continue'}
|
|
124
|
+
</Button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -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
|
+
/** Marks the section as a destructive action (e.g. "Delete account") — renders the title in the destructive color. @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
|
+
}
|