@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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { AlertCircle, CheckCircle2, File as FileIcon, Loader2, UploadCloud, X } from 'lucide-react';
|
|
5
|
+
import { cn } from '@olwiba/cn';
|
|
6
|
+
import { Button } from '../primitives/Button';
|
|
7
|
+
|
|
8
|
+
export interface FileUploadEntry {
|
|
9
|
+
id: string;
|
|
10
|
+
file: File;
|
|
11
|
+
/** 0–100. Omit while pending, or when not tracking progress. */
|
|
12
|
+
progress?: number;
|
|
13
|
+
status?: 'pending' | 'uploading' | 'done' | 'error';
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FileUploadProps {
|
|
18
|
+
/** Comma-separated MIME types / extensions, e.g. `"image/png,image/jpeg"`. */
|
|
19
|
+
accept?: string;
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
maxSizeMb?: number;
|
|
22
|
+
maxFiles?: number;
|
|
23
|
+
/** Controlled file list — pass this (with `onFilesChange`) to drive upload progress from your own network layer. */
|
|
24
|
+
files?: FileUploadEntry[];
|
|
25
|
+
/** Uncontrolled default list. */
|
|
26
|
+
defaultFiles?: FileUploadEntry[];
|
|
27
|
+
onFilesChange?: (files: FileUploadEntry[]) => void;
|
|
28
|
+
/** Fired with the raw, already-validated `File` objects a user just added. */
|
|
29
|
+
onFilesAdded?: (files: File[]) => void;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** Helper text under the drop zone, e.g. "PNG or JPG, up to 5MB". */
|
|
32
|
+
hint?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatBytes(bytes: number) {
|
|
37
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
38
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
39
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeId() {
|
|
43
|
+
return Math.random().toString(36).slice(2, 10);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Drag-and-drop file picker with a validated queue list. One component —
|
|
48
|
+
* toggle `multiple`/`accept`/`maxSizeMb`/`maxFiles` rather than reaching for
|
|
49
|
+
* a separate dropzone per use case. Progress/status is presentation-only;
|
|
50
|
+
* wire `files`/`onFilesChange` to your own upload layer to drive it.
|
|
51
|
+
*/
|
|
52
|
+
export function FileUpload({
|
|
53
|
+
accept,
|
|
54
|
+
multiple = false,
|
|
55
|
+
maxSizeMb,
|
|
56
|
+
maxFiles,
|
|
57
|
+
files: filesProp,
|
|
58
|
+
defaultFiles,
|
|
59
|
+
onFilesChange,
|
|
60
|
+
onFilesAdded,
|
|
61
|
+
disabled,
|
|
62
|
+
hint,
|
|
63
|
+
className,
|
|
64
|
+
}: FileUploadProps) {
|
|
65
|
+
const [internalFiles, setInternalFiles] = React.useState<FileUploadEntry[]>(defaultFiles ?? []);
|
|
66
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
67
|
+
const [validationError, setValidationError] = React.useState<string | null>(null);
|
|
68
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
69
|
+
// dragenter/dragleave fire for every child element crossed — count them so the
|
|
70
|
+
// highlight doesn't flicker while moving over the icon/text inside the zone
|
|
71
|
+
const dragDepth = React.useRef(0);
|
|
72
|
+
const files = filesProp ?? internalFiles;
|
|
73
|
+
|
|
74
|
+
const setFiles = React.useCallback(
|
|
75
|
+
(next: FileUploadEntry[]) => {
|
|
76
|
+
if (!filesProp) setInternalFiles(next);
|
|
77
|
+
onFilesChange?.(next);
|
|
78
|
+
},
|
|
79
|
+
[filesProp, onFilesChange],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const acceptList = React.useMemo(
|
|
83
|
+
() => accept?.split(',').map((a) => a.trim().toLowerCase()).filter(Boolean) ?? [],
|
|
84
|
+
[accept],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const matchesAccept = (file: File) => {
|
|
88
|
+
if (!acceptList.length) return true;
|
|
89
|
+
const name = file.name.toLowerCase();
|
|
90
|
+
return acceptList.some((pattern) =>
|
|
91
|
+
pattern.startsWith('.') ? name.endsWith(pattern) : file.type === pattern || file.type.startsWith(pattern.replace('/*', '/')),
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const handleFiles = (fileList: FileList | null) => {
|
|
96
|
+
if (!fileList || disabled) return;
|
|
97
|
+
const incoming = Array.from(fileList);
|
|
98
|
+
// Single mode replaces the current file, so the existing queue never counts against maxFiles
|
|
99
|
+
const room = maxFiles && multiple ? Math.max(0, maxFiles - files.length) : maxFiles || Infinity;
|
|
100
|
+
if (maxFiles && room <= 0) {
|
|
101
|
+
setValidationError(`You can only add up to ${maxFiles} file${maxFiles === 1 ? '' : 's'}.`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const accepted: File[] = [];
|
|
106
|
+
let rejected = false;
|
|
107
|
+
for (const file of incoming.slice(0, room)) {
|
|
108
|
+
if (!matchesAccept(file)) { rejected = true; continue; }
|
|
109
|
+
if (maxSizeMb && file.size > maxSizeMb * 1024 * 1024) { rejected = true; continue; }
|
|
110
|
+
accepted.push(file);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setValidationError(rejected ? `Some files were skipped — check the file type and size limit.` : null);
|
|
114
|
+
if (!accepted.length) return;
|
|
115
|
+
|
|
116
|
+
const entries: FileUploadEntry[] = accepted.map((file) => ({ id: makeId(), file, status: 'pending' }));
|
|
117
|
+
setFiles(multiple ? [...files, ...entries] : entries);
|
|
118
|
+
onFilesAdded?.(accepted);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const removeFile = (id: string) => {
|
|
122
|
+
setFiles(files.filter((f) => f.id !== id));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className={cn('space-y-3', className)}>
|
|
127
|
+
<div
|
|
128
|
+
role="button"
|
|
129
|
+
tabIndex={disabled ? -1 : 0}
|
|
130
|
+
onClick={() => !disabled && inputRef.current?.click()}
|
|
131
|
+
onKeyDown={(e) => {
|
|
132
|
+
if (disabled) return;
|
|
133
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
inputRef.current?.click();
|
|
136
|
+
}
|
|
137
|
+
}}
|
|
138
|
+
onDragOver={(e) => e.preventDefault()}
|
|
139
|
+
onDragEnter={(e) => {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
dragDepth.current += 1;
|
|
142
|
+
if (!disabled) setIsDragging(true);
|
|
143
|
+
}}
|
|
144
|
+
onDragLeave={() => {
|
|
145
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
146
|
+
if (dragDepth.current === 0) setIsDragging(false);
|
|
147
|
+
}}
|
|
148
|
+
onDrop={(e) => {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
dragDepth.current = 0;
|
|
151
|
+
setIsDragging(false);
|
|
152
|
+
handleFiles(e.dataTransfer.files);
|
|
153
|
+
}}
|
|
154
|
+
className={cn(
|
|
155
|
+
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition-colors',
|
|
156
|
+
isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-foreground/30',
|
|
157
|
+
disabled && 'pointer-events-none opacity-50',
|
|
158
|
+
)}
|
|
159
|
+
>
|
|
160
|
+
<UploadCloud className="size-8 text-muted-foreground" />
|
|
161
|
+
<p className="text-sm font-medium">
|
|
162
|
+
<span className="text-primary underline underline-offset-4">Click to upload</span> or drag and drop
|
|
163
|
+
</p>
|
|
164
|
+
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
165
|
+
<input
|
|
166
|
+
ref={inputRef}
|
|
167
|
+
type="file"
|
|
168
|
+
accept={accept}
|
|
169
|
+
multiple={multiple}
|
|
170
|
+
disabled={disabled}
|
|
171
|
+
className="sr-only"
|
|
172
|
+
onChange={(e) => { handleFiles(e.target.files); e.target.value = ''; }}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
{validationError && <p role="alert" className="text-sm font-medium text-destructive">{validationError}</p>}
|
|
177
|
+
|
|
178
|
+
{files.length > 0 && (
|
|
179
|
+
<ul className="space-y-2">
|
|
180
|
+
{files.map(({ id, file, progress, status = 'pending', error }) => (
|
|
181
|
+
<li key={id} className="flex items-center gap-3 rounded-lg border bg-card/60 px-3 py-2.5">
|
|
182
|
+
<FileIcon className="size-5 shrink-0 text-muted-foreground" />
|
|
183
|
+
<div className="min-w-0 flex-1">
|
|
184
|
+
<div className="flex items-center justify-between gap-2">
|
|
185
|
+
<p className="truncate text-sm font-medium">{file.name}</p>
|
|
186
|
+
<span className="shrink-0 text-xs text-muted-foreground">{formatBytes(file.size)}</span>
|
|
187
|
+
</div>
|
|
188
|
+
{status === 'uploading' && (
|
|
189
|
+
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
190
|
+
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${progress ?? 0}%` }} />
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
{status === 'error' && error && <p className="mt-1 text-xs text-destructive">{error}</p>}
|
|
194
|
+
</div>
|
|
195
|
+
{status === 'uploading' && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
|
196
|
+
{status === 'done' && <CheckCircle2 className="size-4 shrink-0 text-primary" />}
|
|
197
|
+
{status === 'error' && <AlertCircle className="size-4 shrink-0 text-destructive" />}
|
|
198
|
+
<Button
|
|
199
|
+
type="button"
|
|
200
|
+
variant="ghost"
|
|
201
|
+
size="icon"
|
|
202
|
+
className="size-7 shrink-0"
|
|
203
|
+
onClick={() => removeFile(id)}
|
|
204
|
+
>
|
|
205
|
+
<X className="size-3.5" />
|
|
206
|
+
<span className="sr-only">Remove {file.name}</span>
|
|
207
|
+
</Button>
|
|
208
|
+
</li>
|
|
209
|
+
))}
|
|
210
|
+
</ul>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Bell, Inbox } from 'lucide-react';
|
|
5
|
+
import {
|
|
6
|
+
Avatar,
|
|
7
|
+
AvatarFallback,
|
|
8
|
+
AvatarImage,
|
|
9
|
+
Popover,
|
|
10
|
+
PopoverContent,
|
|
11
|
+
PopoverTrigger,
|
|
12
|
+
cn,
|
|
13
|
+
} from '@olwiba/cn';
|
|
14
|
+
import { Button } from '../primitives/Button';
|
|
15
|
+
|
|
16
|
+
export interface NotificationItem {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
/** Pre-formatted timestamp, e.g. "2h ago" or "Yesterday". */
|
|
21
|
+
timestamp?: string;
|
|
22
|
+
read?: boolean;
|
|
23
|
+
/** Avatar image — takes precedence over `icon`. */
|
|
24
|
+
avatar?: string;
|
|
25
|
+
icon?: React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NotificationsPopoverProps {
|
|
29
|
+
notifications: NotificationItem[];
|
|
30
|
+
onNotificationClick?: (notification: NotificationItem) => void;
|
|
31
|
+
/** Shows a "Mark all read" action in the header when there are unread items. */
|
|
32
|
+
onMarkAllRead?: () => void;
|
|
33
|
+
title?: string;
|
|
34
|
+
emptyMessage?: string;
|
|
35
|
+
/** Popover alignment relative to the bell button. @default 'end' */
|
|
36
|
+
align?: 'start' | 'center' | 'end';
|
|
37
|
+
/** Controlled open state — omit to let the component manage it internally. */
|
|
38
|
+
open?: boolean;
|
|
39
|
+
onOpenChange?: (open: boolean) => void;
|
|
40
|
+
className?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Bell button + persistent notification inbox. Complements `notify()` toasts:
|
|
45
|
+
* a toast announces an event as it happens, this popover holds the history.
|
|
46
|
+
* Presentation-only — pass `notifications` from your own data layer and
|
|
47
|
+
* persist read state via `onMarkAllRead`/`onNotificationClick`.
|
|
48
|
+
*/
|
|
49
|
+
export function NotificationsPopover({
|
|
50
|
+
notifications,
|
|
51
|
+
onNotificationClick,
|
|
52
|
+
onMarkAllRead,
|
|
53
|
+
title = 'Notifications',
|
|
54
|
+
emptyMessage = 'Nothing new — you’re all caught up.',
|
|
55
|
+
align = 'end',
|
|
56
|
+
open,
|
|
57
|
+
onOpenChange,
|
|
58
|
+
className,
|
|
59
|
+
}: NotificationsPopoverProps) {
|
|
60
|
+
const unreadCount = notifications.filter((n) => !n.read).length;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Popover open={open} onOpenChange={onOpenChange}>
|
|
64
|
+
<PopoverTrigger asChild>
|
|
65
|
+
<Button variant="ghost" size="icon" className={cn('relative size-8', className)}>
|
|
66
|
+
<Bell className="size-4" />
|
|
67
|
+
{unreadCount > 0 && (
|
|
68
|
+
<span className="absolute -right-0.5 -top-0.5 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-primary-foreground">
|
|
69
|
+
{unreadCount > 9 ? '9+' : unreadCount}
|
|
70
|
+
</span>
|
|
71
|
+
)}
|
|
72
|
+
<span className="sr-only">
|
|
73
|
+
{title}{unreadCount > 0 ? ` (${unreadCount} unread)` : ''}
|
|
74
|
+
</span>
|
|
75
|
+
</Button>
|
|
76
|
+
</PopoverTrigger>
|
|
77
|
+
<PopoverContent align={align} className="w-80 p-0">
|
|
78
|
+
<div className="flex items-center justify-between border-b px-4 py-3">
|
|
79
|
+
<p className="text-sm font-semibold">{title}</p>
|
|
80
|
+
{onMarkAllRead && unreadCount > 0 && (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={onMarkAllRead}
|
|
84
|
+
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
85
|
+
>
|
|
86
|
+
Mark all read
|
|
87
|
+
</button>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{notifications.length === 0 ? (
|
|
92
|
+
<div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
|
|
93
|
+
<Inbox className="size-6 text-muted-foreground" />
|
|
94
|
+
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
|
95
|
+
</div>
|
|
96
|
+
) : (
|
|
97
|
+
<ul className="max-h-80 overflow-y-auto">
|
|
98
|
+
{notifications.map((notification) => (
|
|
99
|
+
<li key={notification.id} className="border-b last:border-b-0">
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={() => onNotificationClick?.(notification)}
|
|
103
|
+
className={cn(
|
|
104
|
+
'flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60',
|
|
105
|
+
!notification.read && 'bg-muted/40',
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
<span
|
|
109
|
+
aria-hidden
|
|
110
|
+
className={cn(
|
|
111
|
+
'mt-1.5 size-2 shrink-0 rounded-full',
|
|
112
|
+
notification.read ? 'bg-transparent' : 'bg-primary',
|
|
113
|
+
)}
|
|
114
|
+
/>
|
|
115
|
+
{notification.avatar ? (
|
|
116
|
+
<Avatar className="size-8 shrink-0">
|
|
117
|
+
<AvatarImage src={notification.avatar} alt="" />
|
|
118
|
+
<AvatarFallback>{notification.title.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
119
|
+
</Avatar>
|
|
120
|
+
) : notification.icon ? (
|
|
121
|
+
<span className="mt-0.5 shrink-0 text-muted-foreground">{notification.icon}</span>
|
|
122
|
+
) : null}
|
|
123
|
+
<span className="min-w-0 flex-1">
|
|
124
|
+
<span className={cn('block truncate text-sm', !notification.read && 'font-medium')}>
|
|
125
|
+
{notification.title}
|
|
126
|
+
</span>
|
|
127
|
+
{notification.description && (
|
|
128
|
+
<span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">
|
|
129
|
+
{notification.description}
|
|
130
|
+
</span>
|
|
131
|
+
)}
|
|
132
|
+
{notification.timestamp && (
|
|
133
|
+
<span className="mt-1 block text-xs text-muted-foreground/70">
|
|
134
|
+
{notification.timestamp}
|
|
135
|
+
</span>
|
|
136
|
+
)}
|
|
137
|
+
</span>
|
|
138
|
+
</button>
|
|
139
|
+
</li>
|
|
140
|
+
))}
|
|
141
|
+
</ul>
|
|
142
|
+
)}
|
|
143
|
+
</PopoverContent>
|
|
144
|
+
</Popover>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { toast } from 'sonner';
|
|
5
|
+
import { AlertCircle, AlertTriangle, CheckCircle2, Inbox, Info, X } from 'lucide-react';
|
|
6
|
+
import { cn } from '@olwiba/cn';
|
|
7
|
+
import { Button } from '../primitives/Button';
|
|
8
|
+
|
|
9
|
+
export interface NotifyAction {
|
|
10
|
+
label: string;
|
|
11
|
+
onClick: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface NotificationToastProps {
|
|
15
|
+
variant?: 'success' | 'info' | 'warning' | 'error' | 'message';
|
|
16
|
+
title: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
/** Avatar image — overrides the variant icon (e.g. for a message-from-a-person toast). */
|
|
19
|
+
avatar?: string;
|
|
20
|
+
/** Primary action, right-aligned next to the description (e.g. "Undo"). */
|
|
21
|
+
action?: NotifyAction;
|
|
22
|
+
/** Secondary action, rendered after the primary one (e.g. "Decline"). */
|
|
23
|
+
secondaryAction?: NotifyAction;
|
|
24
|
+
onDismiss?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const variantIcon = {
|
|
28
|
+
success: <CheckCircle2 className="size-5 text-primary" />,
|
|
29
|
+
info: <Info className="size-5 text-muted-foreground" />,
|
|
30
|
+
warning: <AlertTriangle className="size-5 text-amber-500 dark:text-amber-400" />,
|
|
31
|
+
error: <AlertCircle className="size-5 text-destructive" />,
|
|
32
|
+
message: <Inbox className="size-5 text-muted-foreground" />,
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Rich toast content — rendered via `notify()` inside sonner's `toast.custom`.
|
|
37
|
+
* One component: `variant` swaps the default icon, `avatar`/`action`/
|
|
38
|
+
* `secondaryAction` add the pieces a given toast needs, rather than a
|
|
39
|
+
* separate toast component per shape.
|
|
40
|
+
*/
|
|
41
|
+
export function NotificationToast({
|
|
42
|
+
variant = 'info',
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
avatar,
|
|
46
|
+
action,
|
|
47
|
+
secondaryAction,
|
|
48
|
+
onDismiss,
|
|
49
|
+
}: NotificationToastProps) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="w-full max-w-sm rounded-lg border bg-card p-4 text-card-foreground shadow-lg">
|
|
52
|
+
<div className="flex items-start gap-3">
|
|
53
|
+
<div className="shrink-0 pt-0.5">
|
|
54
|
+
{avatar ? (
|
|
55
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
56
|
+
<img src={avatar} alt="" className="size-9 rounded-full border object-cover" />
|
|
57
|
+
) : (
|
|
58
|
+
variantIcon[variant]
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
<div className="min-w-0 flex-1 pt-0.5">
|
|
62
|
+
<p className="text-sm font-medium">{title}</p>
|
|
63
|
+
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
|
64
|
+
{(action || secondaryAction) && (
|
|
65
|
+
<div className={cn('mt-3 flex gap-4', avatar && 'gap-3')}>
|
|
66
|
+
{action && (
|
|
67
|
+
<button type="button" onClick={action.onClick} className="text-sm font-medium text-primary hover:underline">
|
|
68
|
+
{action.label}
|
|
69
|
+
</button>
|
|
70
|
+
)}
|
|
71
|
+
{secondaryAction && (
|
|
72
|
+
<button type="button" onClick={secondaryAction.onClick} className="text-sm font-medium text-muted-foreground hover:text-foreground">
|
|
73
|
+
{secondaryAction.label}
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
<Button variant="ghost" size="icon" className="size-6 shrink-0 -mt-1 -mr-1" onClick={onDismiss}>
|
|
80
|
+
<X className="size-4" />
|
|
81
|
+
<span className="sr-only">Dismiss</span>
|
|
82
|
+
</Button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface NotifyOptions extends Omit<NotificationToastProps, 'onDismiss'> {
|
|
89
|
+
duration?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Fires a `NotificationToast` through sonner. Requires `<Toaster />` from `@olwiba/cn` mounted once in your app. */
|
|
93
|
+
export function notify(options: NotifyOptions) {
|
|
94
|
+
const { duration, ...toastProps } = options;
|
|
95
|
+
return toast.custom((id) => <NotificationToast {...toastProps} onDismiss={() => toast.dismiss(id)} />, { duration });
|
|
96
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,31 @@ export {
|
|
|
35
35
|
type AuthFormProps,
|
|
36
36
|
} from './app/AuthSection';
|
|
37
37
|
|
|
38
|
+
export {
|
|
39
|
+
SettingsSection,
|
|
40
|
+
type SettingsSectionProps,
|
|
41
|
+
} from './app/SettingsSection';
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
TeamMembersPanel,
|
|
45
|
+
type TeamMembersPanelProps,
|
|
46
|
+
type TeamMemberRecord,
|
|
47
|
+
} from './app/TeamMembersPanel';
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
BillingPanel,
|
|
51
|
+
type BillingPanelProps,
|
|
52
|
+
type BillingUsageMetric,
|
|
53
|
+
type BillingInvoice,
|
|
54
|
+
type BillingPaymentMethod,
|
|
55
|
+
} from './app/BillingPanel';
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
OnboardingWizard,
|
|
59
|
+
type OnboardingWizardProps,
|
|
60
|
+
type OnboardingStep,
|
|
61
|
+
} from './app/OnboardingWizard';
|
|
62
|
+
|
|
38
63
|
export {
|
|
39
64
|
EmptyState,
|
|
40
65
|
type EmptyStateProps,
|
|
@@ -59,10 +84,8 @@ export { FeaturesSection, type FeaturesSectionProps } from './marketing/Features
|
|
|
59
84
|
export { GroupedFeaturesSection, type GroupedFeaturesSectionProps, type GroupedFeatureGroup } from './marketing/GroupedFeaturesSection';
|
|
60
85
|
export { StepsSection, type StepsSectionProps, type StepItem } from './marketing/StepsSection';
|
|
61
86
|
export { TechStackSection, type TechStackSectionProps, type TechStackItem } from './marketing/TechStackSection';
|
|
62
|
-
export { CarouselSection, type CarouselSectionProps } from './marketing/CarouselSection';
|
|
63
87
|
export { FeatureMarqueeSection, type FeatureMarqueeSectionProps, type FeatureMarqueeRow, type FeatureMarqueeItem } from './marketing/FeatureMarqueeSection';
|
|
64
88
|
export { CtaSection, type CtaSectionProps } from './marketing/CtaSection';
|
|
65
|
-
export { CtaCardSection, type CtaCardSectionProps } from './marketing/CtaCardSection';
|
|
66
89
|
export { PricingSection, type PricingSectionProps, type PricingPlan } from './marketing/PricingSection';
|
|
67
90
|
export { TestimonialsSection, type TestimonialsSectionProps } from './marketing/TestimonialsSection';
|
|
68
91
|
export { TeamSection, type TeamMember, type TeamSectionProps } from './marketing/TeamSection';
|
|
@@ -90,11 +113,24 @@ export { CountdownTimer, type CountdownTimerProps } from './motion/CountdownTime
|
|
|
90
113
|
export { AnimatedPill, type AnimatedPillProps } from './motion/AnimatedPill';
|
|
91
114
|
export { PageTransition, type PageTransitionProps } from './motion/PageTransition';
|
|
92
115
|
|
|
116
|
+
// ─── Mechanics — behavior wrappers that play any children ────────────────────
|
|
117
|
+
export { Carousel, type CarouselProps } from './mechanics/Carousel';
|
|
118
|
+
|
|
93
119
|
// ─── Components — interactive ────────────────────────────────────────────────
|
|
94
120
|
export { Spotlight, type SpotlightProps, type SpotlightGroup, type SpotlightItem } from './components/Spotlight';
|
|
95
121
|
export { Dock, type DockProps, type DockItem } from './components/Dock';
|
|
96
122
|
export { ContextMenu, type ContextMenuProps, type ContextMenuDef } from './components/ContextMenu';
|
|
97
123
|
export { ConfirmDialog, type ConfirmDialogProps } from './components/ConfirmDialog';
|
|
124
|
+
export { CommandMenu, type CommandMenuProps, type CommandMenuGroup, type CommandMenuItem } from './components/CommandMenu';
|
|
125
|
+
|
|
126
|
+
// ─── Components — data ───────────────────────────────────────────────────────
|
|
127
|
+
export { DataTable, type DataTableProps, type DataTableColumn } from './components/DataTable';
|
|
128
|
+
export { FileUpload, type FileUploadProps, type FileUploadEntry } from './components/FileUpload';
|
|
129
|
+
|
|
130
|
+
// ─── Components — notifications ──────────────────────────────────────────────
|
|
131
|
+
export { NotificationToast, type NotificationToastProps, notify, type NotifyOptions, type NotifyAction } from './components/Notify';
|
|
132
|
+
export { NotificationsPopover, type NotificationsPopoverProps, type NotificationItem } from './components/NotificationsPopover';
|
|
133
|
+
export { ActivityFeed, type ActivityFeedProps, type ActivityFeedItem } from './components/ActivityFeed';
|
|
98
134
|
|
|
99
135
|
// ─── Components — device mockups ────────────────────────────────────────────
|
|
100
136
|
export { PhoneFrame, type PhoneFrameProps } from './components/PhoneFrame';
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { ChevronDown, Mail, MessageSquare, Send, type LucideIcon } from 'lucide-react';
|
|
5
|
-
import {
|
|
5
|
+
import { cn, Label, useUIVariant } from '@olwiba/cn';
|
|
6
|
+
import { Badge } from '../primitives/Badge';
|
|
7
|
+
import { Button } from '../primitives/Button';
|
|
8
|
+
import { Input } from '../primitives/Input';
|
|
9
|
+
import { Textarea } from '../primitives/Textarea';
|
|
6
10
|
|
|
7
11
|
export type ContactInfoItem = {
|
|
8
12
|
label: string;
|
|
@@ -53,6 +57,13 @@ export function ContactSection({
|
|
|
53
57
|
onSubmit,
|
|
54
58
|
}: ContactSectionProps = {}) {
|
|
55
59
|
const [submitted, setSubmitted] = React.useState(false);
|
|
60
|
+
const mode = useUIVariant();
|
|
61
|
+
const sectionClasses = cn(
|
|
62
|
+
'overflow-hidden bg-card',
|
|
63
|
+
mode === 'smooth' && 'rounded-3xl border',
|
|
64
|
+
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
65
|
+
!mode && 'rounded-2xl border',
|
|
66
|
+
);
|
|
56
67
|
|
|
57
68
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
58
69
|
e.preventDefault();
|
|
@@ -61,7 +72,7 @@ export function ContactSection({
|
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
return (
|
|
64
|
-
<section className=
|
|
75
|
+
<section className={sectionClasses}>
|
|
65
76
|
<div className="px-6 py-14 sm:px-10 sm:py-20">
|
|
66
77
|
|
|
67
78
|
{/* Header */}
|