@exotic-holidays/ui 0.1.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.
Files changed (38) hide show
  1. package/README.md +46 -0
  2. package/package.json +42 -0
  3. package/src/base-components/Accordion.tsx +561 -0
  4. package/src/base-components/Badge.tsx +191 -0
  5. package/src/base-components/Button.tsx +331 -0
  6. package/src/base-components/ButtonGroup.tsx +149 -0
  7. package/src/base-components/Card.tsx +250 -0
  8. package/src/base-components/Checkbox.tsx +49 -0
  9. package/src/base-components/ChipInput.tsx +208 -0
  10. package/src/base-components/CommonButton.tsx +33 -0
  11. package/src/base-components/DataTable.tsx +82 -0
  12. package/src/base-components/Divider.tsx +82 -0
  13. package/src/base-components/Dropdown.tsx +85 -0
  14. package/src/base-components/EmptyState.tsx +18 -0
  15. package/src/base-components/FilterPopover.tsx +50 -0
  16. package/src/base-components/Input.tsx +60 -0
  17. package/src/base-components/Modal.tsx +107 -0
  18. package/src/base-components/OtpVerificationModal.tsx +251 -0
  19. package/src/base-components/Pagination.tsx +51 -0
  20. package/src/base-components/PhoneInput.tsx +142 -0
  21. package/src/base-components/PopConfirm.tsx +350 -0
  22. package/src/base-components/SearchPopover.tsx +70 -0
  23. package/src/base-components/SearchableSelect.tsx +734 -0
  24. package/src/base-components/Select.tsx +49 -0
  25. package/src/base-components/Table.tsx +78 -0
  26. package/src/base-components/Textarea.tsx +45 -0
  27. package/src/base-components/ThemeProvider.tsx +92 -0
  28. package/src/base-components/Toaster.tsx +198 -0
  29. package/src/base-components/index.ts +32 -0
  30. package/src/components/DashboardLayout.tsx +326 -0
  31. package/src/components/ListPage.tsx +140 -0
  32. package/src/components/QuickAccess.tsx +118 -0
  33. package/src/components/UserMenu.tsx +138 -0
  34. package/src/helpers/bem.ts +13 -0
  35. package/src/helpers/cn.ts +9 -0
  36. package/src/index.ts +16 -0
  37. package/src/theme.css +285 -0
  38. package/tsconfig.json +11 -0
@@ -0,0 +1,326 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useMemo, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { usePathname, useRouter } from 'next/navigation';
6
+ import { Menu, X } from 'lucide-react';
7
+ import { UserMenu } from './UserMenu';
8
+ import { cn } from '../helpers/cn';
9
+
10
+ export type NavIcon = React.ComponentType<{ size?: number; className?: string; strokeWidth?: number }>;
11
+
12
+ export interface NavItem {
13
+ label: string;
14
+ href: string;
15
+ icon?: NavIcon;
16
+ permission?: string;
17
+ }
18
+
19
+ export interface NavGroup {
20
+ id: string;
21
+ label?: string;
22
+ items: NavItem[];
23
+ }
24
+
25
+ export interface NavSection {
26
+ id: string;
27
+ label: string;
28
+ icon: NavIcon;
29
+ groups: NavGroup[];
30
+ }
31
+
32
+ export interface DashboardBrand {
33
+ logo: React.ReactNode;
34
+ title: string;
35
+ subtitle?: string;
36
+ }
37
+
38
+ export interface DashboardUser {
39
+ name: string;
40
+ role: string;
41
+ initials: string;
42
+ }
43
+
44
+ export interface DashboardLayoutProps {
45
+ brand: DashboardBrand;
46
+ sections: NavSection[];
47
+ user: DashboardUser;
48
+ onLogout: () => void;
49
+ hasPermission?: (permission: string) => boolean;
50
+ card?: boolean;
51
+ topbarRight?: React.ReactNode;
52
+ /** Optional override for the pathname used to determine the active navigation item. */
53
+ activePathname?: string;
54
+ /** Optional override to strictly highlight a specific item by its exact href. */
55
+ activeItemHref?: string;
56
+ children: React.ReactNode;
57
+ }
58
+
59
+ const isActiveHref = (pathname: string, href: string) =>
60
+ pathname === href || pathname.startsWith(`${href}/`);
61
+
62
+ export function DashboardLayout({
63
+ brand,
64
+ sections,
65
+ user,
66
+ onLogout,
67
+ hasPermission,
68
+ card = true,
69
+ topbarRight,
70
+ activePathname,
71
+ activeItemHref: activeItemHrefOverride,
72
+ children,
73
+ }: DashboardLayoutProps) {
74
+ const nextPathname = usePathname() || '';
75
+ const pathname = activePathname ?? nextPathname;
76
+ const router = useRouter();
77
+ const [mobileOpen, setMobileOpen] = useState(false);
78
+
79
+ // Permission-filter items, then drop empty groups and empty sections.
80
+ const visibleSections = useMemo(() => {
81
+ const allow = (perm?: string) => !perm || !hasPermission || hasPermission(perm);
82
+ return sections
83
+ .map((section) => ({
84
+ ...section,
85
+ groups: section.groups
86
+ .map((group) => ({ ...group, items: group.items.filter((i) => allow(i.permission)) }))
87
+ .filter((group) => group.items.length > 0),
88
+ }))
89
+ .filter((section) => section.groups.length > 0);
90
+ }, [sections, hasPermission]);
91
+
92
+ // Active section/item = the one owning the longest href match for the current
93
+ // path. Picking a single longest match (rather than letting every item whose
94
+ // href is a prefix of the pathname claim "active" independently) is what
95
+ // keeps a parent route like `/hotels` from also lighting up alongside a more
96
+ // specific child route like `/hotels/new`.
97
+ const { activeSectionId, activeItemHref } = useMemo(() => {
98
+ if (activeItemHrefOverride) {
99
+ for (const section of visibleSections) {
100
+ for (const group of section.groups) {
101
+ for (const item of group.items) {
102
+ if (item.href === activeItemHrefOverride) {
103
+ return { activeSectionId: section.id, activeItemHref: activeItemHrefOverride };
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ let bestSectionId = visibleSections[0]?.id ?? '';
111
+ let bestHref = '';
112
+ let bestLen = -1;
113
+ for (const section of visibleSections) {
114
+ for (const group of section.groups) {
115
+ for (const item of group.items) {
116
+ if (isActiveHref(pathname, item.href) && item.href.length > bestLen) {
117
+ bestLen = item.href.length;
118
+ bestSectionId = section.id;
119
+ bestHref = item.href;
120
+ }
121
+ }
122
+ }
123
+ }
124
+ return { activeSectionId: bestSectionId, activeItemHref: bestHref };
125
+ }, [visibleSections, pathname, activeItemHrefOverride]);
126
+
127
+ // Rail clicks can preview a section without navigating; default to the active one.
128
+ const [openSectionId, setOpenSectionId] = useState(activeSectionId);
129
+ useEffect(() => setOpenSectionId(activeSectionId), [activeSectionId]);
130
+
131
+ const activeSection =
132
+ visibleSections.find((s) => s.id === openSectionId) ?? visibleSections[0];
133
+
134
+ const itemLink = (item: NavItem, onNavigate?: () => void) => {
135
+ const active = item.href === activeItemHref;
136
+ const Icon = item.icon;
137
+ return (
138
+ <Link
139
+ key={item.href}
140
+ href={item.href}
141
+ onClick={onNavigate}
142
+ aria-current={active ? 'page' : undefined}
143
+ className={cn(
144
+ 'flex items-center gap-2.5 px-2 py-2 rounded-[8px] text-[13px] transition-colors',
145
+ active
146
+ ? 'bg-surface-0 text-foreground-0 font-semibold'
147
+ : 'text-foreground-muted font-medium hover:bg-surface-hover hover:text-foreground-0',
148
+ )}
149
+ >
150
+ {Icon && (
151
+ <Icon
152
+ size={16}
153
+ className={cn('shrink-0', active ? 'text-primary' : 'text-current')}
154
+ strokeWidth={active ? 2.3 : 2}
155
+ />
156
+ )}
157
+ <span className="truncate">{item.label}</span>
158
+ </Link>
159
+ );
160
+ };
161
+
162
+ return (
163
+ <div className="flex flex-col w-full h-screen bg-surface-0 overflow-hidden">
164
+ {/* ── Top Header (Full width) ──────────────────────────────────── */}
165
+ <header className="hidden md:flex items-center justify-between px-6 py-3 bg-surface-1 border-b border-border-subtle shrink-0 z-40">
166
+ <div className="flex items-center gap-3">
167
+ <div className="w-10 h-8 flex items-center justify-center shrink-0">{brand.logo}</div>
168
+ <span className="text-[1.1rem] font-extrabold tracking-tight text-foreground-0">{brand.title}</span>
169
+ {brand.subtitle && (
170
+ <span className="text-[0.65rem] text-foreground-muted font-bold uppercase tracking-[1.2px] ml-2 mt-1">
171
+ {brand.subtitle}
172
+ </span>
173
+ )}
174
+ </div>
175
+ <div className="flex items-center gap-4">
176
+ {topbarRight}
177
+ <div className="border-l border-border-subtle pl-4">
178
+ <UserMenu name={user.name} role={user.role} initials={user.initials} onLogout={onLogout} headerMode={true} />
179
+ </div>
180
+ </div>
181
+ </header>
182
+
183
+ {/* Mobile header */}
184
+ <header className="md:hidden flex items-center justify-between px-4 py-3 bg-surface-1 border-b border-border-subtle shrink-0 z-40">
185
+ <div className="flex items-center gap-2.5">
186
+ <div className="w-8 h-8 flex items-center justify-center">{brand.logo}</div>
187
+ <span className="text-sm font-extrabold tracking-tight text-foreground-0">{brand.title}</span>
188
+ </div>
189
+ <button
190
+ type="button"
191
+ onClick={() => setMobileOpen(true)}
192
+ aria-label="Open menu"
193
+ className="p-1.5 rounded-lg text-foreground-muted hover:bg-surface-hover hover:text-foreground-0 transition-colors cursor-pointer"
194
+ >
195
+ <Menu size={22} />
196
+ </button>
197
+ </header>
198
+
199
+ <div className="flex flex-1 min-h-0 overflow-hidden">
200
+ {/* ── Icon rail (top-level sections) ───────────────────────────── */}
201
+ <aside className="hidden md:flex w-[80px] bg-surface-1 border-r border-border-subtle flex-col items-center py-4 shrink-0 z-20">
202
+
203
+ <nav className="flex flex-col gap-1.5 flex-1 w-full px-1.5">
204
+ {visibleSections.map((section) => {
205
+ const active = section.id === activeSection?.id;
206
+ const Icon = section.icon;
207
+ const firstHref = section.groups[0]?.items[0]?.href;
208
+ return (
209
+ <Link
210
+ key={section.id}
211
+ href={firstHref ?? '#'}
212
+ onClick={() => setOpenSectionId(section.id)}
213
+ title={section.label}
214
+ aria-current={active ? 'true' : undefined}
215
+ className={cn(
216
+ 'group flex flex-col items-center justify-center gap-1.5 py-2.5 rounded-lg transition-colors cursor-pointer',
217
+ active
218
+ ? 'bg-surface-hover text-primary'
219
+ : 'text-foreground-muted hover:bg-surface-hover hover:text-foreground-0 ',
220
+ )}
221
+ >
222
+ <Icon size={22} strokeWidth={active ? 1.7 : 1.5} />
223
+ <span className={cn('text-[9px] leading-tight font-semibold text-center px-0.5', active ? 'text-foreground-0' : '')}>
224
+ {section.label}
225
+ </span>
226
+ </Link>
227
+ );
228
+ })}
229
+ </nav>
230
+
231
+ </aside>
232
+
233
+ {/* ── Contextual sidebar (active section's groups) ─────────────── */}
234
+ {activeSection && (
235
+ <aside className="hidden md:flex w-[240px] bg-surface-1 border-r border-border-subtle flex-col py-6 shrink-0 z-10">
236
+ {/* <div className="px-6 mb-6">
237
+ <h1 className="text-[1.2rem] font-extrabold tracking-tight text-foreground-0 truncate">
238
+ {activeSection.label}
239
+ </h1>
240
+ </div> */}
241
+
242
+ <nav className="flex-1 overflow-y-auto px-4 flex flex-col gap-6">
243
+ {activeSection.groups.map((group) => (
244
+ <div key={group.id} className="flex flex-col gap-1">
245
+ {group.label && (
246
+ <div className="px-2 pb-2 text-[11px] font-bold text-foreground-disabled uppercase tracking-wider">
247
+ {group.label}
248
+ </div>
249
+ )}
250
+ {group.items.map((item) => itemLink(item))}
251
+ </div>
252
+ ))}
253
+ </nav>
254
+ </aside>
255
+ )}
256
+
257
+ {/* ── Main column ──────────────────────────────────────────────── */}
258
+ <div className="flex-1 flex flex-col min-w-0 overflow-hidden bg-surface-0">
259
+ <main className="flex-1 overflow-y-auto min-h-0 p-4 md:p-6 lg:p-8">
260
+ {card ? (
261
+ <div className="bg-surface-1 border border-border-subtle rounded-2xl min-h-full p-4 md:p-6 lg:p-8 flex flex-col shadow-sm">
262
+ {children}
263
+ </div>
264
+ ) : (
265
+ children
266
+ )}
267
+ </main>
268
+ </div>
269
+ </div>
270
+
271
+ {/* ── Mobile drawer ────────────────────────────────────────────── */}
272
+ {mobileOpen && (
273
+ <div className="fixed inset-0 z-50 flex md:hidden">
274
+ <div
275
+ className="fixed inset-0 bg-foreground-0/40 backdrop-blur-sm"
276
+ onClick={() => setMobileOpen(false)}
277
+ />
278
+ <div className="relative flex w-[82%] max-w-[300px] flex-col bg-surface-1 h-full shadow-lg">
279
+ <div className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
280
+ <div className="flex items-center gap-2.5">
281
+ <div className="w-8 h-8 flex items-center justify-center">{brand.logo}</div>
282
+ <span className="text-sm font-extrabold tracking-tight text-foreground-0">{brand.title}</span>
283
+ </div>
284
+ <button
285
+ type="button"
286
+ onClick={() => setMobileOpen(false)}
287
+ aria-label="Close menu"
288
+ className="p-1.5 rounded-lg text-foreground-muted hover:bg-surface-hover hover:text-foreground-0 transition-colors cursor-pointer"
289
+ >
290
+ <X size={20} />
291
+ </button>
292
+ </div>
293
+
294
+ <div className="flex-1 overflow-y-auto py-4 px-3 flex flex-col gap-6">
295
+ {visibleSections.map((section) => {
296
+ const Icon = section.icon;
297
+ return (
298
+ <div key={section.id} className="flex flex-col gap-1">
299
+ <div className="flex items-center gap-2.5 px-3 mb-1 text-foreground-0 font-bold">
300
+ <Icon size={18} strokeWidth={2.4} />
301
+ <span className="text-[13px]">{section.label}</span>
302
+ </div>
303
+ {section.groups.map((group) => (
304
+ <div key={group.id} className="flex flex-col gap-0.5 pl-2">
305
+ {group.label && (
306
+ <div className="px-3 pt-1.5 pb-1 text-[11px] font-semibold text-foreground-disabled uppercase tracking-wider">
307
+ {group.label}
308
+ </div>
309
+ )}
310
+ {group.items.map((item) => itemLink(item, () => setMobileOpen(false)))}
311
+ </div>
312
+ ))}
313
+ </div>
314
+ );
315
+ })}
316
+ </div>
317
+
318
+ <div className="border-t border-border-subtle p-2.5">
319
+ <UserMenu name={user.name} role={user.role} initials={user.initials} onLogout={onLogout} />
320
+ </div>
321
+ </div>
322
+ </div>
323
+ )}
324
+ </div>
325
+ );
326
+ }
@@ -0,0 +1,140 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { Filter } from 'lucide-react';
5
+ import { Card } from '../base-components/Card';
6
+ import { Pagination } from '../base-components/Pagination';
7
+ import { Button } from '../base-components/Button';
8
+ import { SearchPopover } from '../base-components/SearchPopover';
9
+ import { FilterPopover } from '../base-components/FilterPopover';
10
+
11
+ /**
12
+ * Standardized index / list page scaffold.
13
+ *
14
+ * The header row holds the title + a compact action cluster — a search
15
+ * popover, a filter popover, and the primary action — all inline (the
16
+ * jewelry-ui arrangement). Below sits a card-wrapped table slot (`children`)
17
+ * with optional pagination. `toolbarLeft`/`toolbarRight` render a secondary
18
+ * in-card row for things like tab bars.
19
+ */
20
+ export interface ListPageProps {
21
+ title: string;
22
+ subtitle?: string;
23
+ /** Primary action element (usually a <Button/>), shown in the header cluster. */
24
+ action?: React.ReactNode;
25
+
26
+ /** Controlled search value. Omit `onSearchChange` to hide the search popover. */
27
+ search?: string;
28
+ onSearchChange?: (value: string) => void;
29
+ searchPlaceholder?: string;
30
+
31
+ /** Filter popover content (Select controls, etc.). Renders a filter popover when set. */
32
+ filter?: React.ReactNode;
33
+ /** Show the active-filter indicator dot on the filter trigger. */
34
+ filterActive?: boolean;
35
+
36
+ /** Legacy: a bare Filter button (no popover). Prefer `filter`. */
37
+ showFilter?: boolean;
38
+ onFilterClick?: () => void;
39
+
40
+ toolbarLeft?: React.ReactNode;
41
+ toolbarRight?: React.ReactNode;
42
+
43
+ /** Wrap content in the standard table Card (default true). Set false for
44
+ * card-grid pages so they aren't nested inside another card. */
45
+ card?: boolean;
46
+
47
+ children: React.ReactNode;
48
+
49
+ pagination?: {
50
+ currentPage: number;
51
+ totalItems: number;
52
+ itemsPerPage: number;
53
+ onPageChange: (page: number) => void;
54
+ };
55
+ }
56
+
57
+ export function ListPage({
58
+ title,
59
+ subtitle,
60
+ action,
61
+ search = '',
62
+ onSearchChange,
63
+ searchPlaceholder = 'Search…',
64
+ filter,
65
+ filterActive = false,
66
+ showFilter = false,
67
+ onFilterClick,
68
+ toolbarLeft,
69
+ toolbarRight,
70
+ card = true,
71
+ children,
72
+ pagination,
73
+ }: ListPageProps) {
74
+ const showSearch = onSearchChange !== undefined;
75
+ const showToolbar = !!toolbarLeft || !!toolbarRight;
76
+
77
+ return (
78
+ <div className="w-full flex flex-col animate-in fade-in duration-200">
79
+ <div className="mb-6">
80
+ <div className="flex items-center justify-between gap-4">
81
+ <h1 className="text-[22px] sm:text-[26px] font-bold text-foreground-0 tracking-tight min-w-0 truncate">
82
+ {title}
83
+ </h1>
84
+
85
+ {/* Compact action cluster: search · filter · primary action */}
86
+ <div className="flex items-center gap-2 shrink-0">
87
+ {showSearch && (
88
+ <SearchPopover value={search} onChange={onSearchChange!} placeholder={searchPlaceholder} />
89
+ )}
90
+ {filter && <FilterPopover active={filterActive}>{filter}</FilterPopover>}
91
+ {!filter && showFilter && (
92
+ <Button
93
+ variant="bordered"
94
+ color="default"
95
+ size="small"
96
+ icon={<Filter size={15} />}
97
+ onClick={onFilterClick}
98
+ >
99
+ Filter
100
+ </Button>
101
+ )}
102
+ {action}
103
+ </div>
104
+ </div>
105
+
106
+ {subtitle && (
107
+ <p className="text-[13px] sm:text-[14px] text-foreground-subtle mt-1">{subtitle}</p>
108
+ )}
109
+ </div>
110
+
111
+ {card ? (
112
+ <Card fullWidth variant="flat" className="border border-border-subtle overflow-hidden">
113
+ {showToolbar && (
114
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4">
115
+ <div className="flex items-center gap-2 min-w-0">{toolbarLeft}</div>
116
+ <div className="flex items-center gap-2">{toolbarRight}</div>
117
+ </div>
118
+ )}
119
+
120
+ <div className="px-3 sm:px-4 pt-3 pb-2">{children}</div>
121
+
122
+ {pagination && <Pagination {...pagination} />}
123
+ </Card>
124
+ ) : (
125
+ <>
126
+ {showToolbar && (
127
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
128
+ <div className="flex items-center gap-2 min-w-0">{toolbarLeft}</div>
129
+ <div className="flex items-center gap-2">{toolbarRight}</div>
130
+ </div>
131
+ )}
132
+
133
+ {children}
134
+
135
+ {pagination && <div className="mt-4"><Pagination {...pagination} /></div>}
136
+ </>
137
+ )}
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { Grip, X } from 'lucide-react';
6
+ import { cn } from '../helpers/cn';
7
+
8
+ export interface QuickAccessItem {
9
+ label: string;
10
+ href: string;
11
+ icon: React.ElementType;
12
+ intent?: 'primary' | 'accent' | 'success' | 'warning' | 'danger' | 'default';
13
+ }
14
+
15
+ export interface QuickAccessGroup {
16
+ label: string;
17
+ items: QuickAccessItem[];
18
+ }
19
+
20
+ export interface QuickAccessProps {
21
+ groups: QuickAccessGroup[];
22
+ }
23
+
24
+ const INTENT_CLASSES = {
25
+ primary: 'bg-primary-50 text-primary-700',
26
+ accent: 'bg-accent-50 text-accent-700',
27
+ success: 'bg-success-50 text-success-700',
28
+ warning: 'bg-warning-50 text-warning-700',
29
+ danger: 'bg-danger-50 text-danger-700',
30
+ default: 'bg-surface-hover text-foreground-0',
31
+ };
32
+
33
+ export function QuickAccess({ groups }: QuickAccessProps) {
34
+ const [isOpen, setIsOpen] = useState(false);
35
+ const containerRef = useRef<HTMLDivElement>(null);
36
+
37
+ useEffect(() => {
38
+ if (!isOpen) return;
39
+ const onClick = (e: MouseEvent) => {
40
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
41
+ setIsOpen(false);
42
+ }
43
+ };
44
+ const onKey = (e: KeyboardEvent) => {
45
+ if (e.key === 'Escape') setIsOpen(false);
46
+ };
47
+ document.addEventListener('mousedown', onClick);
48
+ document.addEventListener('keydown', onKey);
49
+ return () => {
50
+ document.removeEventListener('mousedown', onClick);
51
+ document.removeEventListener('keydown', onKey);
52
+ };
53
+ }, [isOpen]);
54
+
55
+ return (
56
+ <div ref={containerRef} className="relative">
57
+ <button
58
+ type="button"
59
+ onClick={() => setIsOpen((o) => !o)}
60
+ className={cn(
61
+ 'p-1.5 rounded-lg transition-colors cursor-pointer',
62
+ isOpen ? 'bg-primary-50 text-primary-500' : 'text-foreground-muted'
63
+ )}
64
+ aria-label="Quick Access"
65
+ >
66
+ <Grip size={22} />
67
+ </button>
68
+
69
+ {isOpen && (
70
+ <div className="absolute right-0 top-full mt-2 w-[340px] bg-surface-1 border border-border-subtle rounded-2xl shadow-xl z-50 overflow-hidden flex flex-col">
71
+ <div className="flex items-center justify-between px-5 py-4">
72
+ <h3 className="text-[13px] font-extrabold text-foreground-0">Quick Access</h3>
73
+ <button
74
+ type="button"
75
+ onClick={() => setIsOpen(false)}
76
+ className="p-1 rounded-md text-foreground-muted hover:bg-surface-hover hover:text-foreground-0 transition-colors cursor-pointer"
77
+ >
78
+ <X size={16} />
79
+ </button>
80
+ </div>
81
+
82
+ <div className="p-5 pt-1 overflow-y-auto max-h-[60vh] flex flex-col gap-6">
83
+ {groups.map((group, gIdx) => (
84
+ <div key={gIdx} className="flex flex-col gap-3">
85
+ {group.label && (
86
+ <div className="text-[10px] font-extrabold text-foreground-disabled uppercase tracking-wider">
87
+ {group.label}
88
+ </div>
89
+ )}
90
+ <div className="grid grid-cols-4 gap-2">
91
+ {group.items.map((item, iIdx) => {
92
+ const Icon = item.icon;
93
+ const intentClass = INTENT_CLASSES[item.intent || 'default'];
94
+ return (
95
+ <Link
96
+ key={iIdx}
97
+ href={item.href}
98
+ onClick={() => setIsOpen(false)}
99
+ className="group flex flex-col items-center gap-2 p-2 rounded-xl hover:bg-surface-hover transition-colors text-center cursor-pointer"
100
+ >
101
+ <div className={cn('w-12 h-12 flex flex-col items-center justify-center rounded-[12px] transition-colors', intentClass)}>
102
+ <Icon size={20} strokeWidth={2} />
103
+ </div>
104
+ <span className="text-[11px] font-semibold text-foreground-muted group-hover:text-foreground-0 leading-tight">
105
+ {item.label}
106
+ </span>
107
+ </Link>
108
+ );
109
+ })}
110
+ </div>
111
+ </div>
112
+ ))}
113
+ </div>
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ }