@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -2,7 +2,18 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { Building2, ShieldCheck, Sparkles } from 'lucide-react';
5
- import { Badge, CardContent, CardDescription, CardHeader, CardTitle, Label, cn } from '@olwiba/cn';
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
- /** Controls form title, fields, and footer text. @default 'signin' */
69
- mode?: 'signin' | 'signup';
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>{isSignUp ? 'Create an account' : 'Sign in'}</CardTitle>
164
+ <CardTitle>{copy.title}</CardTitle>
122
165
  <CardDescription>
123
- {isSignUp
124
- ? 'Enter your details to create your account.'
125
- : 'Enter your email and password to continue.'}
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
- <div className="space-y-2">
137
- <Label htmlFor="auth-email">Email address</Label>
138
- <Input
139
- id="auth-email"
140
- name="email"
141
- type="email"
142
- placeholder="name@company.com"
143
- autoComplete="email"
144
- defaultValue={defaultEmail}
145
- style={prefillStyle(!!defaultEmail)}
146
- />
147
- </div>
148
- <div className="space-y-2">
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
- <Input
158
- id="auth-password"
159
- name="password"
160
- type="password"
161
- placeholder="••••••••"
162
- autoComplete={isSignUp ? 'new-password' : 'current-password'}
163
- defaultValue={defaultPassword}
164
- style={prefillStyle(!!defaultPassword)}
165
- />
166
- </div>
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…' : isSignUp ? 'Create account' : 'Sign in'}
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
- <p className="text-center text-xs text-muted-foreground">
182
- {isSignUp ? (
183
- <>
184
- Already have an account?{' '}
185
- {renderLink({ href: signInHref, className: 'text-foreground underline underline-offset-4', children: 'Sign in' })}
186
- </>
187
- ) : (
188
- signUpHref && (
263
+
264
+ {isVerify && onResend && (
265
+ <p className="text-center text-xs text-muted-foreground">
266
+ Didn&rsquo;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
- New here?{' '}
191
- {renderLink({ href: signUpHref, className: 'text-foreground underline underline-offset-4', children: 'Create an account' })}
277
+ Already have an account?{' '}
278
+ {renderLink({ href: signInHref, className: 'text-foreground underline underline-offset-4', children: 'Sign in' })}
192
279
  </>
193
- )
194
- )}
195
- </p>
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
+ }