@olwiba/ui 0.1.15 → 0.2.1
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 +149 -35
- package/dist/index.js +790 -408
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/AuthSection.tsx +5 -4
- package/src/app/BillingPanel.tsx +18 -12
- package/src/app/OnboardingWizard.tsx +19 -6
- package/src/app/SettingsSection.tsx +1 -1
- package/src/app/TeamMembersPanel.tsx +47 -37
- package/src/components/ActivityFeed.tsx +82 -0
- package/src/components/Chart.tsx +245 -0
- package/src/components/CommandMenu.tsx +7 -1
- package/src/components/DataTable.tsx +23 -13
- package/src/components/FileUpload.tsx +24 -5
- package/src/components/NotificationsPopover.tsx +146 -0
- package/src/components/Notify.tsx +4 -2
- package/src/index.ts +7 -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/mechanics/Sortable.tsx +128 -0
- package/src/marketing/CarouselSection.tsx +0 -126
- package/src/marketing/CtaCardSection.tsx +0 -95
|
@@ -135,7 +135,10 @@ export function DataTable<TData>({
|
|
|
135
135
|
const canSort = header.column.getCanSort();
|
|
136
136
|
const sortDir = header.column.getIsSorted();
|
|
137
137
|
return (
|
|
138
|
-
<TableHead
|
|
138
|
+
<TableHead
|
|
139
|
+
key={header.id}
|
|
140
|
+
aria-sort={sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
|
141
|
+
>
|
|
139
142
|
{header.isPlaceholder ? null : canSort ? (
|
|
140
143
|
<button
|
|
141
144
|
type="button"
|
|
@@ -161,7 +164,11 @@ export function DataTable<TData>({
|
|
|
161
164
|
key={row.id}
|
|
162
165
|
data-state={row.getIsSelected() ? 'selected' : undefined}
|
|
163
166
|
onClick={() => onRowClick?.(row.original)}
|
|
164
|
-
|
|
167
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
168
|
+
onKeyDown={onRowClick ? (e) => {
|
|
169
|
+
if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row.original);
|
|
170
|
+
} : undefined}
|
|
171
|
+
className={cn(onRowClick && 'cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring')}
|
|
165
172
|
>
|
|
166
173
|
{row.getVisibleCells().map((cell) => (
|
|
167
174
|
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
|
@@ -179,20 +186,23 @@ export function DataTable<TData>({
|
|
|
179
186
|
</Table>
|
|
180
187
|
</div>
|
|
181
188
|
|
|
182
|
-
{pageSize > 0 && table.getPageCount() > 1 && (
|
|
189
|
+
{(selectable || (pageSize > 0 && table.getPageCount() > 1)) && (
|
|
183
190
|
<div className="flex items-center justify-between">
|
|
184
191
|
<p className="text-sm text-muted-foreground">
|
|
185
|
-
{selectable && `${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} selected
|
|
186
|
-
|
|
192
|
+
{selectable && `${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} selected`}
|
|
193
|
+
{selectable && pageSize > 0 && table.getPageCount() > 1 && ' · '}
|
|
194
|
+
{pageSize > 0 && table.getPageCount() > 1 && `Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
|
|
187
195
|
</p>
|
|
188
|
-
|
|
189
|
-
<
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
{pageSize > 0 && table.getPageCount() > 1 && (
|
|
197
|
+
<div className="flex gap-2">
|
|
198
|
+
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
|
199
|
+
<ChevronLeft className="size-4" /> Previous
|
|
200
|
+
</Button>
|
|
201
|
+
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
|
202
|
+
Next <ChevronRight className="size-4" />
|
|
203
|
+
</Button>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
196
206
|
</div>
|
|
197
207
|
)}
|
|
198
208
|
</div>
|
|
@@ -66,6 +66,9 @@ export function FileUpload({
|
|
|
66
66
|
const [isDragging, setIsDragging] = React.useState(false);
|
|
67
67
|
const [validationError, setValidationError] = React.useState<string | null>(null);
|
|
68
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);
|
|
69
72
|
const files = filesProp ?? internalFiles;
|
|
70
73
|
|
|
71
74
|
const setFiles = React.useCallback(
|
|
@@ -92,7 +95,8 @@ export function FileUpload({
|
|
|
92
95
|
const handleFiles = (fileList: FileList | null) => {
|
|
93
96
|
if (!fileList || disabled) return;
|
|
94
97
|
const incoming = Array.from(fileList);
|
|
95
|
-
|
|
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;
|
|
96
100
|
if (maxFiles && room <= 0) {
|
|
97
101
|
setValidationError(`You can only add up to ${maxFiles} file${maxFiles === 1 ? '' : 's'}.`);
|
|
98
102
|
return;
|
|
@@ -124,11 +128,26 @@ export function FileUpload({
|
|
|
124
128
|
role="button"
|
|
125
129
|
tabIndex={disabled ? -1 : 0}
|
|
126
130
|
onClick={() => !disabled && inputRef.current?.click()}
|
|
127
|
-
onKeyDown={(e) => {
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
}}
|
|
130
148
|
onDrop={(e) => {
|
|
131
149
|
e.preventDefault();
|
|
150
|
+
dragDepth.current = 0;
|
|
132
151
|
setIsDragging(false);
|
|
133
152
|
handleFiles(e.dataTransfer.files);
|
|
134
153
|
}}
|
|
@@ -154,7 +173,7 @@ export function FileUpload({
|
|
|
154
173
|
/>
|
|
155
174
|
</div>
|
|
156
175
|
|
|
157
|
-
{validationError && <p className="text-sm font-medium text-destructive">{validationError}</p>}
|
|
176
|
+
{validationError && <p role="alert" className="text-sm font-medium text-destructive">{validationError}</p>}
|
|
158
177
|
|
|
159
178
|
{files.length > 0 && (
|
|
160
179
|
<ul className="space-y-2">
|
|
@@ -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
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { toast } from 'sonner';
|
|
5
|
-
import { CheckCircle2, Inbox, Info, X } from 'lucide-react';
|
|
5
|
+
import { AlertCircle, AlertTriangle, CheckCircle2, Inbox, Info, X } from 'lucide-react';
|
|
6
6
|
import { cn } from '@olwiba/cn';
|
|
7
7
|
import { Button } from '../primitives/Button';
|
|
8
8
|
|
|
@@ -12,7 +12,7 @@ export interface NotifyAction {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface NotificationToastProps {
|
|
15
|
-
variant?: 'success' | 'info' | 'message';
|
|
15
|
+
variant?: 'success' | 'info' | 'warning' | 'error' | 'message';
|
|
16
16
|
title: string;
|
|
17
17
|
description?: string;
|
|
18
18
|
/** Avatar image — overrides the variant icon (e.g. for a message-from-a-person toast). */
|
|
@@ -27,6 +27,8 @@ export interface NotificationToastProps {
|
|
|
27
27
|
const variantIcon = {
|
|
28
28
|
success: <CheckCircle2 className="size-5 text-primary" />,
|
|
29
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" />,
|
|
30
32
|
message: <Inbox className="size-5 text-muted-foreground" />,
|
|
31
33
|
} as const;
|
|
32
34
|
|
package/src/index.ts
CHANGED
|
@@ -84,10 +84,8 @@ export { FeaturesSection, type FeaturesSectionProps } from './marketing/Features
|
|
|
84
84
|
export { GroupedFeaturesSection, type GroupedFeaturesSectionProps, type GroupedFeatureGroup } from './marketing/GroupedFeaturesSection';
|
|
85
85
|
export { StepsSection, type StepsSectionProps, type StepItem } from './marketing/StepsSection';
|
|
86
86
|
export { TechStackSection, type TechStackSectionProps, type TechStackItem } from './marketing/TechStackSection';
|
|
87
|
-
export { CarouselSection, type CarouselSectionProps } from './marketing/CarouselSection';
|
|
88
87
|
export { FeatureMarqueeSection, type FeatureMarqueeSectionProps, type FeatureMarqueeRow, type FeatureMarqueeItem } from './marketing/FeatureMarqueeSection';
|
|
89
88
|
export { CtaSection, type CtaSectionProps } from './marketing/CtaSection';
|
|
90
|
-
export { CtaCardSection, type CtaCardSectionProps } from './marketing/CtaCardSection';
|
|
91
89
|
export { PricingSection, type PricingSectionProps, type PricingPlan } from './marketing/PricingSection';
|
|
92
90
|
export { TestimonialsSection, type TestimonialsSectionProps } from './marketing/TestimonialsSection';
|
|
93
91
|
export { TeamSection, type TeamMember, type TeamSectionProps } from './marketing/TeamSection';
|
|
@@ -115,6 +113,10 @@ export { CountdownTimer, type CountdownTimerProps } from './motion/CountdownTime
|
|
|
115
113
|
export { AnimatedPill, type AnimatedPillProps } from './motion/AnimatedPill';
|
|
116
114
|
export { PageTransition, type PageTransitionProps } from './motion/PageTransition';
|
|
117
115
|
|
|
116
|
+
// ─── Mechanics — behavior wrappers that play any children ────────────────────
|
|
117
|
+
export { Carousel, type CarouselProps } from './mechanics/Carousel';
|
|
118
|
+
export { Sortable, type SortableProps } from './mechanics/Sortable';
|
|
119
|
+
|
|
118
120
|
// ─── Components — interactive ────────────────────────────────────────────────
|
|
119
121
|
export { Spotlight, type SpotlightProps, type SpotlightGroup, type SpotlightItem } from './components/Spotlight';
|
|
120
122
|
export { Dock, type DockProps, type DockItem } from './components/Dock';
|
|
@@ -124,10 +126,13 @@ export { CommandMenu, type CommandMenuProps, type CommandMenuGroup, type Command
|
|
|
124
126
|
|
|
125
127
|
// ─── Components — data ───────────────────────────────────────────────────────
|
|
126
128
|
export { DataTable, type DataTableProps, type DataTableColumn } from './components/DataTable';
|
|
129
|
+
export { Chart, type ChartProps, type ChartSeries } from './components/Chart';
|
|
127
130
|
export { FileUpload, type FileUploadProps, type FileUploadEntry } from './components/FileUpload';
|
|
128
131
|
|
|
129
132
|
// ─── Components — notifications ──────────────────────────────────────────────
|
|
130
133
|
export { NotificationToast, type NotificationToastProps, notify, type NotifyOptions, type NotifyAction } from './components/Notify';
|
|
134
|
+
export { NotificationsPopover, type NotificationsPopoverProps, type NotificationItem } from './components/NotificationsPopover';
|
|
135
|
+
export { ActivityFeed, type ActivityFeedProps, type ActivityFeedItem } from './components/ActivityFeed';
|
|
131
136
|
|
|
132
137
|
// ─── Components — device mockups ────────────────────────────────────────────
|
|
133
138
|
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 */}
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { ArrowRight, Rocket, Sparkles } from 'lucide-react';
|
|
5
|
+
import { cn, useUIVariant } from '@olwiba/cn';
|
|
6
|
+
import { Button } from '../primitives/Button';
|
|
5
7
|
import { FadeIn } from '../motion/FadeIn';
|
|
8
|
+
import { useIntersectionObserver } from '../hooks/use-intersection-observer';
|
|
6
9
|
import type { AppShellRenderLink } from '../app/AppShell';
|
|
7
10
|
|
|
8
11
|
export interface CtaSectionProps {
|
|
9
12
|
heading: string;
|
|
10
|
-
description
|
|
13
|
+
description?: string;
|
|
11
14
|
primaryCta: { label: string; href: string };
|
|
12
15
|
secondaryCta?: { label: string; href: string };
|
|
13
16
|
footnote?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Visual treatment of the section.
|
|
19
|
+
* - `'default'` — badge icon, radial glow, primary + secondary CTAs
|
|
20
|
+
* - `'showcase'` — large watermark icon with a scroll-reveal, single pill CTA
|
|
21
|
+
* @default 'default'
|
|
22
|
+
*/
|
|
23
|
+
variant?: 'default' | 'showcase';
|
|
24
|
+
/** (showcase) Watermark icon rendered behind the content. @default <Rocket /> */
|
|
25
|
+
icon?: React.ReactNode;
|
|
14
26
|
renderLink?: AppShellRenderLink;
|
|
15
27
|
}
|
|
16
28
|
|
|
@@ -18,14 +30,79 @@ const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) =>
|
|
|
18
30
|
<a href={href} className={className}>{children}</a>
|
|
19
31
|
);
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
function ShowcaseCta({
|
|
22
34
|
heading,
|
|
23
35
|
description,
|
|
24
36
|
primaryCta,
|
|
25
|
-
secondaryCta,
|
|
26
37
|
footnote,
|
|
38
|
+
icon,
|
|
27
39
|
renderLink = defaultRenderLink,
|
|
28
|
-
|
|
40
|
+
sectionClasses,
|
|
41
|
+
}: CtaSectionProps & { sectionClasses: string }) {
|
|
42
|
+
const [ref, intersecting] = useIntersectionObserver({ threshold: 0.15 });
|
|
43
|
+
const [visible, setVisible] = React.useState(false);
|
|
44
|
+
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
if (intersecting) setVisible(true);
|
|
47
|
+
}, [intersecting]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<section
|
|
51
|
+
ref={ref as React.RefObject<HTMLElement>}
|
|
52
|
+
className={cn(
|
|
53
|
+
sectionClasses,
|
|
54
|
+
'relative transition-[opacity,transform] duration-700 ease-out',
|
|
55
|
+
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6',
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{/* Decorative watermark icon */}
|
|
59
|
+
<div
|
|
60
|
+
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
|
61
|
+
style={{
|
|
62
|
+
opacity: visible ? 0.1 : 0,
|
|
63
|
+
transform: visible ? 'scale(1) rotate(0deg)' : 'scale(0.9) rotate(-5deg)',
|
|
64
|
+
transition: 'opacity 700ms ease 200ms, transform 700ms ease 200ms',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{icon ?? <Rocket className="size-72 text-foreground" strokeWidth={0.75} />}
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="relative z-10 px-6 py-20 text-center sm:px-10 sm:py-28">
|
|
71
|
+
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
|
|
72
|
+
{heading}
|
|
73
|
+
</h2>
|
|
74
|
+
{description && (
|
|
75
|
+
<p className="mx-auto mt-4 max-w-lg text-pretty text-muted-foreground">{description}</p>
|
|
76
|
+
)}
|
|
77
|
+
<div className="mt-8">
|
|
78
|
+
{renderLink({
|
|
79
|
+
href: primaryCta.href,
|
|
80
|
+
children: (
|
|
81
|
+
<Button size="lg" className="rounded-full px-6">
|
|
82
|
+
{primaryCta.label}
|
|
83
|
+
<ArrowRight className="ml-2 size-4" />
|
|
84
|
+
</Button>
|
|
85
|
+
),
|
|
86
|
+
})}
|
|
87
|
+
</div>
|
|
88
|
+
{footnote && (
|
|
89
|
+
<p className="mt-5 text-sm text-muted-foreground">{footnote}</p>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function CtaSection(props: CtaSectionProps) {
|
|
97
|
+
const {
|
|
98
|
+
heading,
|
|
99
|
+
description,
|
|
100
|
+
primaryCta,
|
|
101
|
+
secondaryCta,
|
|
102
|
+
footnote,
|
|
103
|
+
variant = 'default',
|
|
104
|
+
renderLink = defaultRenderLink,
|
|
105
|
+
} = props;
|
|
29
106
|
const mode = useUIVariant()
|
|
30
107
|
const sectionClasses = cn(
|
|
31
108
|
'overflow-hidden bg-card',
|
|
@@ -33,6 +110,11 @@ export function CtaSection({
|
|
|
33
110
|
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
34
111
|
!mode && 'rounded-2xl border',
|
|
35
112
|
)
|
|
113
|
+
|
|
114
|
+
if (variant === 'showcase') {
|
|
115
|
+
return <ShowcaseCta {...props} sectionClasses={sectionClasses} />;
|
|
116
|
+
}
|
|
117
|
+
|
|
36
118
|
return (
|
|
37
119
|
<section className={sectionClasses}>
|
|
38
120
|
<div className="relative px-6 py-16 sm:px-10 sm:py-24">
|
|
@@ -45,9 +127,11 @@ export function CtaSection({
|
|
|
45
127
|
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
|
|
46
128
|
{heading}
|
|
47
129
|
</h2>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
130
|
+
{description && (
|
|
131
|
+
<p className="mx-auto mt-4 max-w-lg text-pretty text-muted-foreground">
|
|
132
|
+
{description}
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
51
135
|
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
52
136
|
{renderLink({
|
|
53
137
|
href: primaryCta.href,
|
|
@@ -3,14 +3,18 @@
|
|
|
3
3
|
import type { LucideIcon } from 'lucide-react';
|
|
4
4
|
import { cn, useUIVariant } from '@olwiba/cn';
|
|
5
5
|
import { FeatureCard } from '../components/FeatureCard';
|
|
6
|
+
import { Carousel } from '../mechanics/Carousel';
|
|
6
7
|
import { SectionTitle } from './SectionTitle';
|
|
7
8
|
import { StaggerChildren } from '../motion/StaggerChildren';
|
|
9
|
+
import { FadeIn } from '../motion/FadeIn';
|
|
8
10
|
|
|
9
11
|
export interface FeaturesSectionProps {
|
|
10
12
|
title?: string;
|
|
11
13
|
description?: string;
|
|
12
14
|
badge?: string;
|
|
13
15
|
features: Array<{ icon: LucideIcon; title: string; description: string; href?: string }>;
|
|
16
|
+
/** How the feature cards are arranged. @default 'grid' */
|
|
17
|
+
layout?: 'grid' | 'carousel';
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export function FeaturesSection({
|
|
@@ -18,6 +22,7 @@ export function FeaturesSection({
|
|
|
18
22
|
description = 'A complete system of components, blocks, and hooks designed to work together - and get out of your way.',
|
|
19
23
|
badge = 'Features',
|
|
20
24
|
features,
|
|
25
|
+
layout = 'grid',
|
|
21
26
|
}: FeaturesSectionProps) {
|
|
22
27
|
const mode = useUIVariant()
|
|
23
28
|
const sectionClasses = cn(
|
|
@@ -26,23 +31,33 @@ export function FeaturesSection({
|
|
|
26
31
|
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
27
32
|
!mode && 'rounded-2xl border',
|
|
28
33
|
)
|
|
34
|
+
const cards = features.map((feature) => (
|
|
35
|
+
<FeatureCard
|
|
36
|
+
key={feature.title}
|
|
37
|
+
icon={feature.icon}
|
|
38
|
+
title={feature.title}
|
|
39
|
+
description={feature.description}
|
|
40
|
+
href={feature.href}
|
|
41
|
+
/>
|
|
42
|
+
));
|
|
43
|
+
|
|
29
44
|
return (
|
|
30
45
|
<section className={sectionClasses}>
|
|
31
46
|
<div className="px-6 py-14 sm:px-10 sm:py-20">
|
|
32
|
-
<div className=
|
|
47
|
+
<div className={cn('mx-auto', layout === 'carousel' ? 'max-w-5xl' : 'max-w-4xl')}>
|
|
33
48
|
<SectionTitle title={title} description={description} badge={badge} />
|
|
34
49
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
{layout === 'carousel' ? (
|
|
51
|
+
<FadeIn direction="up">
|
|
52
|
+
<Carousel className="mt-10" ariaLabel={title}>
|
|
53
|
+
{cards}
|
|
54
|
+
</Carousel>
|
|
55
|
+
</FadeIn>
|
|
56
|
+
) : (
|
|
57
|
+
<StaggerChildren className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
|
58
|
+
{cards}
|
|
59
|
+
</StaggerChildren>
|
|
60
|
+
)}
|
|
46
61
|
</div>
|
|
47
62
|
</div>
|
|
48
63
|
</section>
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { ArrowRight } from 'lucide-react';
|
|
5
|
-
import { Avatar, AvatarFallback, AvatarImage,
|
|
5
|
+
import { Avatar, AvatarFallback, AvatarImage, cn, useUIVariant } from '@olwiba/cn';
|
|
6
|
+
import { Badge } from '../primitives/Badge';
|
|
7
|
+
import { Button } from '../primitives/Button';
|
|
6
8
|
import { FadeIn } from '../motion/FadeIn';
|
|
7
9
|
import { PhoneFrame } from '../components/PhoneFrame';
|
|
8
10
|
import type { AppShellRenderLink } from '../app/AppShell';
|
|
@@ -39,6 +41,7 @@ export function HeroSection({
|
|
|
39
41
|
socialProofText,
|
|
40
42
|
renderLink = defaultRenderLink,
|
|
41
43
|
}: HeroSectionProps) {
|
|
44
|
+
const mode = useUIVariant();
|
|
42
45
|
return (
|
|
43
46
|
<section className="overflow-hidden">
|
|
44
47
|
<div className={marketingSectionSpacing.hero}>
|
|
@@ -90,7 +93,7 @@ export function HeroSection({
|
|
|
90
93
|
</PhoneFrame>
|
|
91
94
|
</div>
|
|
92
95
|
) : (
|
|
93
|
-
<div className=
|
|
96
|
+
<div className={cn('overflow-hidden', mode === 'smooth' ? 'rounded-3xl' : 'rounded-2xl')}>
|
|
94
97
|
{heroImage}
|
|
95
98
|
</div>
|
|
96
99
|
)}
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { ArrowRight, Mail } from 'lucide-react';
|
|
5
|
-
import {
|
|
5
|
+
import { cn, useUIVariant } from '@olwiba/cn';
|
|
6
|
+
import { Badge } from '../primitives/Badge';
|
|
7
|
+
import { Button } from '../primitives/Button';
|
|
8
|
+
import { Input } from '../primitives/Input';
|
|
6
9
|
|
|
7
10
|
export interface NewsletterSectionProps {
|
|
8
11
|
badge?: string;
|
|
@@ -44,6 +47,13 @@ export function NewsletterSection({
|
|
|
44
47
|
}: NewsletterSectionProps = {}) {
|
|
45
48
|
const [email, setEmail] = React.useState('');
|
|
46
49
|
const [submitted, setSubmitted] = React.useState(false);
|
|
50
|
+
const mode = useUIVariant();
|
|
51
|
+
const sectionClasses = cn(
|
|
52
|
+
'overflow-hidden bg-card',
|
|
53
|
+
mode === 'smooth' && 'rounded-3xl border',
|
|
54
|
+
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
55
|
+
!mode && 'rounded-2xl border',
|
|
56
|
+
);
|
|
47
57
|
|
|
48
58
|
async function handleSubmit(e: React.FormEvent) {
|
|
49
59
|
e.preventDefault();
|
|
@@ -53,7 +63,7 @@ export function NewsletterSection({
|
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
return (
|
|
56
|
-
<section className=
|
|
66
|
+
<section className={sectionClasses}>
|
|
57
67
|
<div className="relative px-6 py-14 sm:px-10 sm:py-20">
|
|
58
68
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,hsl(var(--primary)/0.1),transparent_60%)]" />
|
|
59
69
|
<div className="relative mx-auto max-w-xl text-center">
|