@djangocfg/layouts 2.1.319 → 2.1.321

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.
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Account block: collapsible trigger + expanded card (email, links, footer row:
3
- * sign out on the left, locale + theme toggles on the right).
2
+ * Sidebar account: rich footer trigger (avatar + name + plan + optional secondary
3
+ * action) opens a popover (DropdownMenu) upward with email, account links, locale +
4
+ * theme controls, and sign-out. Replaces the legacy inline collapsible.
4
5
  */
5
6
 
6
7
  'use client';
7
8
 
8
- import { ChevronDown, LogOut } from 'lucide-react';
9
+ import { ChevronRight, ChevronsUpDown, Globe, LogOut, Monitor, Moon, Sun } from 'lucide-react';
9
10
  import { Link } from '@djangocfg/ui-core/components';
10
11
  import React from 'react';
11
12
 
@@ -16,65 +17,47 @@ import {
16
17
  AvatarFallback,
17
18
  AvatarImage,
18
19
  Button,
19
- Collapsible,
20
- CollapsibleContent,
21
- CollapsibleTrigger,
20
+ DropdownMenu,
21
+ DropdownMenuContent,
22
+ DropdownMenuItem,
23
+ DropdownMenuLabel,
24
+ DropdownMenuSeparator,
25
+ DropdownMenuTrigger,
22
26
  } from '@djangocfg/ui-core/components';
23
- import { cn } from '@djangocfg/ui-core/lib';
24
- import { useSidebar } from '@djangocfg/ui-nextjs/components';
25
- import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
27
+ import { cn, isDev } from '@djangocfg/ui-core/lib';
28
+ import { useSidebar } from '@djangocfg/ui-core/components';
29
+ import { useThemeContext } from '@djangocfg/ui-nextjs/theme';
26
30
 
27
31
  import { useLogout } from '../../hooks';
28
- import { LocaleSwitcher } from './LocaleSwitcher';
32
+ import { LocaleSwitcherDialog } from './locale-switcher';
33
+ import { getLocaleMeta } from './locale-switcher/localeMeta';
29
34
  import { useLayoutI18nOptional } from '../AppLayout/LayoutI18nProvider';
35
+ import { LucideIcon as LucideIconRender } from '../../components';
30
36
 
31
37
  import type { HeaderConfig } from '../PrivateLayout/PrivateLayout';
32
38
 
33
- /** Radix portals (dropdown, select, popover) render outside the account node — ignore those clicks for “outside”. */
34
- function isPointerFromRadixOverlay(target: EventTarget | null): boolean {
35
- if (!target || !(target instanceof Element)) return false;
36
- return Boolean(
37
- target.closest('[data-radix-popper-content-wrapper]') ||
38
- target.closest('[data-radix-dropdown-menu-content]') ||
39
- target.closest('[data-radix-select-content]') ||
40
- target.closest('[data-radix-popover-content]'),
41
- );
42
- }
43
-
44
39
  interface PrivateSidebarAccountProps {
45
40
  header?: HeaderConfig;
46
41
  }
47
42
 
43
+ interface AccountView {
44
+ source: 'user' | 'dev-fallback';
45
+ displayName: string;
46
+ email: string | null;
47
+ avatarUrl: string;
48
+ plan: string | null;
49
+ }
50
+
48
51
  export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
49
52
  const { user } = useAuth();
50
53
  const handleLogout = useLogout();
51
54
  const t = useAppT();
52
55
  const layoutI18n = useLayoutI18nOptional();
53
56
  const { state, setOpen: setSidebarOpen } = useSidebar();
54
- const [accountOpen, setAccountOpen] = React.useState(false);
55
- const accountRootRef = React.useRef<HTMLDivElement>(null);
57
+ const { theme, setTheme } = useThemeContext();
58
+ const [langDialogOpen, setLangDialogOpen] = React.useState(false);
56
59
  const narrow = state === 'collapsed';
57
60
 
58
- React.useEffect(() => {
59
- if (state === 'collapsed') setAccountOpen(false);
60
- }, [state]);
61
-
62
- React.useEffect(() => {
63
- if (!accountOpen) return;
64
-
65
- const handlePointerDown = (event: PointerEvent) => {
66
- const root = accountRootRef.current;
67
- const target = event.target;
68
- if (!(target instanceof Node)) return;
69
- if (root?.contains(target)) return;
70
- if (isPointerFromRadixOverlay(target)) return;
71
- setAccountOpen(false);
72
- };
73
-
74
- document.addEventListener('pointerdown', handlePointerDown);
75
- return () => document.removeEventListener('pointerdown', handlePointerDown);
76
- }, [accountOpen]);
77
-
78
61
  const signOutLabel = t('layouts.profile.signOut');
79
62
 
80
63
  const accountLinks = React.useMemo(() => {
@@ -82,134 +65,289 @@ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
82
65
  return header.groups.flatMap((g) => g.items.filter((i) => i.href));
83
66
  }, [header?.groups]);
84
67
 
85
- if (!user) {
86
- return null;
87
- }
68
+ /**
69
+ * Single source of truth for what the footer renders. When `useAuth` returns
70
+ * a real user we use it; otherwise (in dev only) we fall back to a placeholder
71
+ * so the menu stays reachable for debugging. Production stays strict and the
72
+ * component returns null below.
73
+ */
74
+ const account = React.useMemo<AccountView>(() => {
75
+ if (user) {
76
+ return {
77
+ source: 'user',
78
+ displayName: user.display_username || user.email || 'User',
79
+ email: user.email ?? null,
80
+ avatarUrl: user.avatar ?? '',
81
+ plan: header?.userPlan ?? null,
82
+ };
83
+ }
84
+ return {
85
+ source: 'dev-fallback',
86
+ displayName: 'Guest (dev)',
87
+ email: null,
88
+ avatarUrl: '',
89
+ plan: header?.userPlan ?? 'No session',
90
+ };
91
+ }, [user, header?.userPlan]);
88
92
 
89
- const displayName = user.display_username || user.email || 'User';
90
- const userInitial = displayName.charAt(0).toUpperCase();
91
- const userAvatar = user.avatar || '';
92
- const hasEmailOrLinks = Boolean(user.email) || accountLinks.length > 0;
93
+ const onTriggerInteract = React.useCallback(() => {
94
+ if (narrow) setSidebarOpen(true);
95
+ }, [narrow, setSidebarOpen]);
96
+
97
+ const onSecondaryExpand = React.useCallback(() => {
98
+ setSidebarOpen(true);
99
+ }, [setSidebarOpen]);
100
+
101
+ const onLogoutSelect = React.useCallback((e: Event) => {
102
+ e.preventDefault();
103
+ handleLogout();
104
+ }, [handleLogout]);
105
+
106
+ const onLanguageSelect = React.useCallback((e: Event) => {
107
+ // Keep the dropdown closed (default behaviour) but defer dialog mount to
108
+ // the next tick so Radix has time to unmount the dropdown overlay first
109
+ // (avoids the "two open overlays steal focus" bug).
110
+ e.preventDefault();
111
+ setTimeout(() => setLangDialogOpen(true), 0);
112
+ }, []);
113
+
114
+ const onThemeSelect = React.useCallback((e: Event) => {
115
+ e.preventDefault();
116
+ // Cycle: light → dark → system → light
117
+ const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
118
+ setTheme(next);
119
+ }, [theme, setTheme]);
120
+
121
+ // Hide entirely in production when there's no user (auth still loading or
122
+ // /me failed and the parent guard hasn't redirected yet). In dev keep a
123
+ // placeholder so the footer + Log out are reachable for debugging.
124
+ // NOTE: this early-return must stay AFTER all hooks above to keep hook order stable.
125
+ if (!user && !isDev) return null;
126
+
127
+ const userInitial = (account.displayName.charAt(0) || '?').toUpperCase();
128
+ const secondary = header?.footerSecondaryAction;
93
129
 
94
130
  const triggerClassName = cn(
95
- 'h-auto min-h-10 w-full gap-2 rounded-md px-2 py-2 text-left hover:bg-sidebar-accent',
96
- narrow && 'justify-center px-0',
97
- );
98
- const chevronClassName = cn(
99
- 'h-4 w-4 shrink-0 text-sidebar-foreground/65 transition-transform duration-200',
100
- accountOpen && 'rotate-180',
131
+ 'group h-auto w-full gap-3 rounded-lg px-2 py-2 text-left',
132
+ 'hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground',
133
+ narrow ? 'justify-center px-0 py-1.5' : 'min-h-14',
101
134
  );
102
- const accountPanelClassName = cn(
103
- 'mt-2 flex flex-col gap-3 rounded-lg border border-sidebar-border/60 bg-sidebar-accent/45 p-3 shadow-sm',
104
- 'dark:border-sidebar-border dark:bg-sidebar-accent/20',
135
+
136
+ const secondaryButton = secondary && !narrow ? (
137
+ <SecondaryAction action={secondary} onParentExpand={onSecondaryExpand} />
138
+ ) : null;
139
+
140
+ const dropdownContentClass = cn(
141
+ 'p-1.5',
142
+ narrow ? 'min-w-60' : 'w-[var(--radix-dropdown-menu-trigger-width)] min-w-60',
105
143
  );
106
- const accountActionsClassName = cn(
107
- 'flex min-h-10 items-center gap-2',
108
- hasEmailOrLinks && 'border-t border-sidebar-border/50 pt-3',
144
+ const dropdownSide: 'top' | 'right' = narrow ? 'right' : 'top';
145
+ const avatarClass = cn(
146
+ 'h-9 w-9 shrink-0 border border-transparent transition-colors',
147
+ 'group-hover:border-sidebar-border/70',
109
148
  );
110
149
 
111
- const onAccountTriggerClick = React.useCallback(() => {
112
- if (narrow) setSidebarOpen(true);
113
- }, [narrow, setSidebarOpen]);
150
+ const headerLabelText = account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null);
151
+ const headerLabel = headerLabelText ? (
152
+ <DropdownMenuLabel className="truncate px-2 py-1.5 text-xs font-normal text-muted-foreground">
153
+ {headerLabelText}
154
+ </DropdownMenuLabel>
155
+ ) : null;
114
156
 
115
- const accountLinkRows = React.useMemo(
116
- () =>
117
- accountLinks.map((item) => {
118
- const Icon = item.icon;
119
- return (
120
- <Link
121
- key={item.href}
122
- href={item.href!}
123
- className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm leading-snug text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
124
- >
125
- {Icon ? <Icon className="h-4 w-4 shrink-0 text-sidebar-foreground/65" /> : null}
126
- <span className="truncate">{item.label}</span>
127
- </Link>
128
- );
129
- }),
130
- [accountLinks],
131
- );
157
+ const accountLinksItems = accountLinks.map((item) => {
158
+ const Icon = item.icon;
159
+ return (
160
+ <DropdownMenuItem key={item.href} asChild>
161
+ <Link
162
+ href={item.href!}
163
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
164
+ >
165
+ {Icon ? <Icon className="h-4 w-4 shrink-0 text-muted-foreground" /> : null}
166
+ <span className="truncate">{item.label}</span>
167
+ </Link>
168
+ </DropdownMenuItem>
169
+ );
170
+ });
171
+ const accountLinksBlock = accountLinks.length > 0 ? (
172
+ <>
173
+ {headerLabel ? <DropdownMenuSeparator /> : null}
174
+ {accountLinksItems}
175
+ </>
176
+ ) : null;
132
177
 
133
- const emailBlock = user.email ? (
134
- <p className="truncate text-xs leading-snug text-sidebar-foreground/65">{user.email}</p>
178
+ const currentLocaleLabel = layoutI18n
179
+ ? getLocaleMeta(layoutI18n.locale).native
180
+ : null;
181
+ const languageItem = layoutI18n ? (
182
+ <DropdownMenuItem
183
+ onSelect={onLanguageSelect}
184
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
185
+ >
186
+ <Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
187
+ <span className="flex-1 truncate">Language</span>
188
+ <span className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
189
+ {currentLocaleLabel}
190
+ <ChevronRight className="h-3.5 w-3.5" aria-hidden />
191
+ </span>
192
+ </DropdownMenuItem>
135
193
  ) : null;
136
194
 
137
- const accountLinksNav =
138
- accountLinks.length > 0 ? (
139
- <nav className="flex max-h-40 flex-col gap-0.5 overflow-y-auto" aria-label="Account">
140
- {accountLinkRows}
141
- </nav>
142
- ) : null;
195
+ const themeIcon = theme === 'dark'
196
+ ? <Moon className="h-4 w-4 shrink-0 text-muted-foreground" />
197
+ : theme === 'light'
198
+ ? <Sun className="h-4 w-4 shrink-0 text-muted-foreground" />
199
+ : <Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />;
200
+ const themeValueLabel = theme === 'dark' ? 'Dark' : theme === 'light' ? 'Light' : 'System';
201
+ const themeItem = (
202
+ <DropdownMenuItem
203
+ onSelect={onThemeSelect}
204
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
205
+ >
206
+ {themeIcon}
207
+ <span className="flex-1 truncate">Theme</span>
208
+ <span className="ml-auto shrink-0 text-xs text-muted-foreground">{themeValueLabel}</span>
209
+ </DropdownMenuItem>
210
+ );
143
211
 
144
- const expandedTriggerMeta = narrow ? null : (
212
+ const expandedMeta = narrow ? null : (
145
213
  <>
146
- <span className="min-w-0 flex-1 truncate text-left text-sm font-medium leading-tight text-sidebar-foreground">
147
- {displayName}
214
+ <span className="flex min-w-0 flex-1 flex-col text-left">
215
+ <span className="truncate text-sm font-medium leading-tight text-sidebar-foreground">
216
+ {account.displayName}
217
+ </span>
218
+ {account.plan ? (
219
+ <span className="truncate text-xs leading-snug text-sidebar-foreground/60">
220
+ {account.plan}
221
+ </span>
222
+ ) : null}
223
+ </span>
224
+ <span className="flex shrink-0 items-center gap-1.5">
225
+ {secondaryButton}
226
+ <ChevronsUpDown className="h-3.5 w-3.5 text-sidebar-foreground/55" aria-hidden />
148
227
  </span>
149
- <ChevronDown className={chevronClassName} aria-hidden />
150
228
  </>
151
229
  );
152
230
 
153
- const localeThemeGroup = layoutI18n ? (
154
- <LocaleSwitcher
155
- variant="dropdown"
156
- buttonVariant="ghost"
157
- size="icon"
158
- showTriggerLabel={false}
159
- showIcon={false}
160
- className="h-8 w-8 shrink-0 text-base leading-none"
161
- />
162
- ) : null;
163
-
164
231
  return (
165
- <div ref={accountRootRef} className="w-full min-w-0 border-t border-sidebar-border/45 pt-2">
166
- <Collapsible open={accountOpen} onOpenChange={setAccountOpen} className="w-full min-w-0">
167
- <CollapsibleTrigger asChild>
232
+ <div className="w-full min-w-0 border-t border-sidebar-border/45 pt-2">
233
+ <DropdownMenu>
234
+ <DropdownMenuTrigger asChild>
168
235
  <Button
169
236
  type="button"
170
237
  variant="ghost"
171
- aria-expanded={accountOpen}
172
- aria-label={narrow ? 'Account' : undefined}
238
+ aria-label={narrow ? account.displayName : undefined}
173
239
  className={triggerClassName}
174
- onClick={onAccountTriggerClick}
240
+ onClick={onTriggerInteract}
175
241
  >
176
- <Avatar className="h-8 w-8 shrink-0">
177
- <AvatarImage src={userAvatar} alt={displayName} />
178
- <AvatarFallback className="text-xs">{userInitial}</AvatarFallback>
242
+ <Avatar className={avatarClass}>
243
+ <AvatarImage src={account.avatarUrl} alt={account.displayName} />
244
+ <AvatarFallback className="text-sm font-semibold">{userInitial}</AvatarFallback>
179
245
  </Avatar>
180
- {expandedTriggerMeta}
246
+ {expandedMeta}
181
247
  </Button>
182
- </CollapsibleTrigger>
183
-
184
- <CollapsibleContent className="overflow-hidden data-[state=closed]:hidden">
185
- <div className={accountPanelClassName}>
186
- {emailBlock}
187
- {accountLinksNav}
188
-
189
- <div className={accountActionsClassName}>
190
- <button
191
- type="button"
192
- onClick={handleLogout}
193
- className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-destructive hover:bg-destructive/10"
194
- >
195
- <LogOut className="h-4 w-4 shrink-0" />
196
- <span className="truncate">{signOutLabel}</span>
197
- </button>
198
- <div
199
- className="flex shrink-0 items-center gap-0.5 border-l border-sidebar-border/50 pl-2"
200
- role="group"
201
- aria-label="Language and theme"
202
- >
203
- {localeThemeGroup}
204
- <ThemeToggle
205
- size="compact"
206
- className="h-8 w-8 shrink-0 focus-visible:ring-1 focus-visible:ring-sidebar-ring focus-visible:ring-offset-0"
207
- />
208
- </div>
209
- </div>
210
- </div>
211
- </CollapsibleContent>
212
- </Collapsible>
248
+ </DropdownMenuTrigger>
249
+
250
+ <DropdownMenuContent
251
+ side={dropdownSide}
252
+ align="start"
253
+ sideOffset={8}
254
+ className={dropdownContentClass}
255
+ >
256
+ {headerLabel}
257
+ {accountLinksBlock}
258
+
259
+ <DropdownMenuSeparator />
260
+ {languageItem}
261
+ {themeItem}
262
+
263
+ <DropdownMenuSeparator />
264
+ <DropdownMenuItem
265
+ onSelect={onLogoutSelect}
266
+ className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive focus:bg-destructive/10 focus:text-destructive"
267
+ >
268
+ <LogOut className="h-4 w-4 shrink-0" />
269
+ <span className="truncate">{signOutLabel}</span>
270
+ </DropdownMenuItem>
271
+ </DropdownMenuContent>
272
+ </DropdownMenu>
273
+
274
+ {layoutI18n ? (
275
+ <LocaleSwitcherDialog
276
+ open={langDialogOpen}
277
+ onOpenChange={setLangDialogOpen}
278
+ locale={layoutI18n.locale}
279
+ locales={layoutI18n.locales}
280
+ onChange={layoutI18n.onLocaleChange}
281
+ brand={layoutI18n.brand}
282
+ i18nLabels={layoutI18n.dialogLabels}
283
+ />
284
+ ) : null}
213
285
  </div>
214
286
  );
215
287
  }
288
+
289
+ interface SecondaryActionProps {
290
+ action: NonNullable<HeaderConfig['footerSecondaryAction']>;
291
+ onParentExpand: () => void;
292
+ }
293
+
294
+ function SecondaryAction({ action, onParentExpand }: SecondaryActionProps) {
295
+ // Radix's DropdownMenuTrigger opens on pointerdown, before React onClick fires.
296
+ // Stop the event there too — onClick alone is too late.
297
+ const stop = (e: React.SyntheticEvent) => {
298
+ e.stopPropagation();
299
+ e.preventDefault();
300
+ };
301
+
302
+ const handleClick = (e: React.MouseEvent) => {
303
+ stop(e);
304
+ onParentExpand();
305
+ action.onClick?.();
306
+ };
307
+
308
+ const inner = (
309
+ <span
310
+ className={cn(
311
+ 'relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
312
+ 'border border-sidebar-border/40 bg-transparent text-sidebar-foreground/70',
313
+ 'transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
314
+ )}
315
+ >
316
+ <LucideIconRender icon={action.icon} className="h-4 w-4" />
317
+ {action.pulse ? (
318
+ <span className="pointer-events-none absolute -right-0.5 -top-0.5 flex h-2 w-2">
319
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
320
+ <span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
321
+ </span>
322
+ ) : null}
323
+ </span>
324
+ );
325
+
326
+ if (action.href) {
327
+ return (
328
+ <Link
329
+ href={action.href}
330
+ aria-label={action.ariaLabel}
331
+ onClick={handleClick}
332
+ onPointerDown={stop}
333
+ onPointerUp={stop}
334
+ data-no-expand
335
+ >
336
+ {inner}
337
+ </Link>
338
+ );
339
+ }
340
+
341
+ return (
342
+ <button
343
+ type="button"
344
+ aria-label={action.ariaLabel}
345
+ onClick={handleClick}
346
+ onPointerDown={stop}
347
+ onPointerUp={stop}
348
+ data-no-expand
349
+ >
350
+ {inner}
351
+ </button>
352
+ );
353
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Featured CTA tile for the sidebar — accent-tinted plate with icon, label,
3
+ * optional inline badge, and a trailing arrow. Mailersend-style.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import { ArrowRight } from 'lucide-react';
9
+ import { Link } from '@djangocfg/ui-core/components';
10
+ import React from 'react';
11
+
12
+ import { cn } from '@djangocfg/ui-core/lib';
13
+
14
+ import { LucideIcon } from '../../components';
15
+
16
+ import type { SidebarFeaturedConfig } from '../PrivateLayout/PrivateLayout';
17
+
18
+ const ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
19
+ green: 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 dark:bg-emerald-400/10 dark:hover:bg-emerald-400/15',
20
+ blue: 'bg-sky-500/10 text-sky-700 hover:bg-sky-500/15 dark:text-sky-300 dark:bg-sky-400/10 dark:hover:bg-sky-400/15',
21
+ amber: 'bg-amber-500/10 text-amber-800 hover:bg-amber-500/15 dark:text-amber-300 dark:bg-amber-400/10 dark:hover:bg-amber-400/15',
22
+ primary: 'bg-primary/10 text-primary hover:bg-primary/15',
23
+ };
24
+
25
+ const BADGE_ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
26
+ green: 'bg-emerald-500 text-white dark:bg-emerald-400 dark:text-emerald-950',
27
+ blue: 'bg-sky-500 text-white dark:bg-sky-400 dark:text-sky-950',
28
+ amber: 'bg-amber-500 text-white dark:bg-amber-400 dark:text-amber-950',
29
+ primary: 'bg-primary text-primary-foreground',
30
+ };
31
+
32
+ interface SidebarFeaturedProps {
33
+ config: SidebarFeaturedConfig;
34
+ }
35
+
36
+ export function SidebarFeatured({ config }: SidebarFeaturedProps) {
37
+ const accent = config.accent ?? 'green';
38
+ const tileClass = cn(
39
+ 'group/featured flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2',
40
+ 'transition-colors',
41
+ ACCENT_CLASS[accent],
42
+ );
43
+
44
+ const badgeNode = config.badge ? (
45
+ <span
46
+ className={cn(
47
+ 'inline-flex shrink-0 items-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
48
+ BADGE_ACCENT_CLASS[accent],
49
+ )}
50
+ >
51
+ {config.badge}
52
+ </span>
53
+ ) : null;
54
+
55
+ const iconNode = config.icon ? (
56
+ <LucideIcon icon={config.icon} className="h-4 w-4 shrink-0" />
57
+ ) : null;
58
+
59
+ return (
60
+ <Link href={config.href} className={tileClass}>
61
+ {iconNode}
62
+ {badgeNode}
63
+ <span className="flex-1 truncate text-sm font-medium">{config.label}</span>
64
+ <ArrowRight
65
+ className="h-4 w-4 shrink-0 transition-transform duration-200 group-hover/featured:translate-x-0.5"
66
+ aria-hidden
67
+ />
68
+ </Link>
69
+ );
70
+ }