@djangocfg/layouts 2.1.607 → 2.1.608

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 (50) hide show
  1. package/README.md +4 -0
  2. package/package.json +12 -12
  3. package/src/components/account/AccountInline.tsx +122 -0
  4. package/src/components/account/AccountMenu.test.tsx +110 -0
  5. package/src/components/account/AccountMenu.tsx +117 -0
  6. package/src/components/account/accountContext.ts +14 -0
  7. package/src/components/account/index.ts +13 -0
  8. package/src/components/account/triggers.tsx +100 -0
  9. package/src/{layouts/SettingsLayout/hooks → components/account}/useAccountMenuItems.tsx +45 -17
  10. package/src/components/account/useAccountModel.tsx +76 -0
  11. package/src/components/errors/AppErrorFallback.test.tsx +33 -0
  12. package/src/components/errors/AppErrorFallback.tsx +42 -0
  13. package/src/components/errors/ErrorBoundary.tsx +4 -41
  14. package/src/components/errors/ErrorLayout.tsx +17 -1
  15. package/src/components/errors/README.md +6 -1
  16. package/src/components/errors/chunkReload.test.ts +89 -0
  17. package/src/components/errors/index.ts +2 -0
  18. package/src/layouts/AppLayout/BaseApp.tsx +10 -55
  19. package/src/layouts/AppLayout/index.ts +1 -0
  20. package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
  21. package/src/layouts/PrivateLayout/README.md +22 -6
  22. package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
  23. package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +176 -11
  24. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +85 -25
  25. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +4 -2
  26. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +16 -123
  27. package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +3 -3
  28. package/src/layouts/PrivateLayout/components/PrivateTopBarAccount.tsx +27 -0
  29. package/src/layouts/PrivateLayout/components/SidebarBody.test.tsx +73 -0
  30. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +13 -4
  31. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +25 -14
  32. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +10 -5
  33. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
  34. package/src/layouts/PrivateLayout/context.tsx +15 -3
  35. package/src/layouts/PrivateLayout/geometry.test.ts +19 -7
  36. package/src/layouts/PrivateLayout/geometry.ts +30 -10
  37. package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
  38. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +2 -2
  39. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +1 -1
  40. package/src/layouts/PrivateLayout/index.ts +2 -0
  41. package/src/layouts/PrivateLayout/topBar.test.tsx +213 -0
  42. package/src/layouts/PrivateLayout/types.ts +27 -2
  43. package/src/layouts/PublicLayout/components/UserMenu.tsx +24 -297
  44. package/src/layouts/PublicLayout/components/index.ts +2 -2
  45. package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +13 -7
  46. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +19 -60
  47. package/src/layouts/SettingsLayout/hooks/index.ts +2 -2
  48. package/src/shared/ProjectContext.tsx +18 -0
  49. package/src/testing/MockAuthProvider.tsx +5 -2
  50. /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
@@ -1,53 +1,16 @@
1
1
  /**
2
- * User Menu Component for Layouts
3
- *
4
- * Flexible user menu component with group-based structure (like footer)
5
- * Uses useAuth() hook for authentication state
2
+ * User menu for the public navbar and its phone drawer. A thin wrapper over the shared account menu
3
+ * (`components/account`): `desktop` is the avatar dropdown, `mobile` the inline list. Props are unchanged.
6
4
  *
7
5
  * @example
8
- * ```tsx
9
- * import { Settings, LogOut, User } from 'lucide-react';
10
- *
11
- * <UserMenu
12
- * groups={[
13
- * {
14
- * title: 'Account',
15
- * items: [
16
- * { label: 'Profile', href: '/profile', icon: Settings },
17
- * { label: 'Dashboard', href: '/dashboard', icon: User }
18
- * ]
19
- * },
20
- * {
21
- * items: [
22
- * { label: 'Sign Out', onClick: () => logout(), icon: LogOut, variant: 'destructive' }
23
- * ]
24
- * }
25
- * ]}
26
- * authPath="/auth"
27
- * />
28
- * ```
6
+ * <UserMenu groups={[{ title: 'Account', items: [{ label: 'Profile', href: '/profile' }] }]} authPath="/auth" />
29
7
  */
30
8
 
31
9
  'use client';
32
10
 
33
- import { ArrowRight, Languages, LogOut } from 'lucide-react';
34
- import { Link } from '@djangocfg/ui-core/components';
35
- import React, { useMemo } from 'react';
36
-
37
- import { useAuth } from '@djangocfg/api/auth';
38
- import { useAppT } from '@djangocfg/i18n';
39
-
40
- import { useLogout } from '../../../hooks';
41
- import {
42
- Button,
43
- LanguageFlag,
44
- MenuBuilder,
45
- } from '@djangocfg/ui-core/components';
46
- import type { MenuItem } from '@djangocfg/ui-core/components';
47
-
48
- import { LOCALE_LABELS } from '../../_components/LocaleSwitcher';
49
- import { UserAvatar } from './UserAvatar';
11
+ import React from 'react';
50
12
 
13
+ import { AccountAvatarTrigger, AccountInline, AccountMenu } from '../../../components/account';
51
14
  import type { UserMenuGroup, UserMenuLocaleConfig } from '../../types';
52
15
 
53
16
  export interface UserMenuProps {
@@ -61,270 +24,34 @@ export interface UserMenuProps {
61
24
  disableAuth?: boolean;
62
25
  /**
63
26
  * Language switching inside this menu (desktop: submenu; mobile: button row).
64
- * Prefer this over nesting `<LocaleSwitcher />` in the same dropdown — nested menus close each other.
27
+ * Prefer this over nesting `<LocaleSwitcher />` in the same dropdown: nested menus close each other.
65
28
  */
66
29
  i18n?: UserMenuLocaleConfig;
67
30
  }
68
31
 
69
- export function UserMenu({
70
- variant = 'desktop',
71
- groups,
72
- authPath,
73
- disableAuth = false,
74
- i18n,
75
- }: UserMenuProps) {
76
- const { user, isAuthenticated, routes } = useAuth();
77
- const resolvedAuthPath = authPath ?? routes.auth;
78
- const handleLogout = useLogout();
32
+ export function UserMenu({ variant = 'desktop', groups, authPath, disableAuth = false, i18n }: UserMenuProps) {
79
33
  const [mounted, setMounted] = React.useState(false);
80
- const t = useAppT();
81
-
82
- const labels = useMemo(() => ({
83
- signIn: t('layouts.profile.login'),
84
- signOut: t('layouts.profile.signOut'),
85
- userMenu: t('layouts.profile.userMenu'),
86
- language: t('layouts.profile.language'),
87
- }), [t]);
88
-
89
- React.useEffect(() => {
90
- setMounted(true);
91
- }, []);
92
-
93
- /** Profile links only; sign out rendered separately so we can insert locale UI between. */
94
- const profileGroups: UserMenuGroup[] = React.useMemo(() => {
95
- if (groups && groups.length > 0) return [...groups];
96
- return [];
97
- }, [groups]);
98
-
99
- const signOutItem = React.useMemo(
100
- () => ({
101
- label: labels.signOut,
102
- onClick: handleLogout,
103
- icon: LogOut,
104
- variant: 'destructive' as const,
105
- }),
106
- [handleLogout, labels.signOut],
107
- );
108
-
109
- /** Prepared locale UI data — avoid `i18n && i18n.locales.length` chains in JSX. */
110
- const localeMenu = React.useMemo(() => {
111
- const codes = i18n?.locales;
112
- if (!i18n || !codes?.length) return null;
113
- return {
114
- codes,
115
- current: i18n.locale,
116
- onChange: i18n.onLocaleChange,
117
- };
118
- }, [i18n]);
119
-
120
- const localeLabel = (code: string) => LOCALE_LABELS[code] || code.toUpperCase();
121
-
122
- if (!mounted) {
123
- return null;
124
- }
125
-
126
- if (!isAuthenticated || !user) {
127
- // Guest user - show sign in button
128
- if (variant === 'mobile') {
129
- return (
130
- <div className="pt-4 border-t border-border/50">
131
- <Link
132
- href={resolvedAuthPath}
133
- className="group flex items-center justify-between rounded-control px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-accent/40"
134
- >
135
- <span>{labels.signIn}</span>
136
- <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
137
- </Link>
138
- </div>
139
- );
140
- }
141
- if (disableAuth) {
142
- return (
143
- <Button variant="default" size="sm" className="h-9 rounded-full px-5" disabled>
144
- {labels.signIn}
145
- </Button>
146
- );
147
- }
148
- return (
149
- <Link href={resolvedAuthPath}>
150
- <Button variant="default" size="sm" className="h-9 rounded-full px-5">
151
- {labels.signIn}
152
- </Button>
153
- </Link>
154
- );
155
- }
156
-
157
- // Authenticated user
158
- const displayName = user.display_username || user.email || 'User';
159
- const userAvatar = user.avatar || '';
34
+ React.useEffect(() => setMounted(true), []);
35
+ if (!mounted) return null;
160
36
 
161
37
  if (variant === 'mobile') {
162
- return (
163
- <div className="space-y-3">
164
- <div className="flex items-center gap-3 px-4 py-3 rounded-[var(--radius-popover)] bg-accent/30">
165
- <UserAvatar src={userAvatar} name={displayName} size="md" />
166
- <div className="flex-1 min-w-0">
167
- <p className="text-sm font-semibold text-foreground truncate">
168
- {displayName}
169
- </p>
170
- <p className="text-xs text-muted-foreground truncate">
171
- {user.email}
172
- </p>
173
- </div>
174
- </div>
175
- <div className="space-y-1">
176
- {profileGroups.map((group, groupIndex) => (
177
- <div key={groupIndex}>
178
- {group.title && (
179
- <div className="px-4 pt-2 pb-1">
180
- <p className="text-[11px] font-normal text-muted-foreground/70">
181
- {group.title}
182
- </p>
183
- </div>
184
- )}
185
- {group.items.map((item, itemIndex) => {
186
- const Icon = item.icon;
187
- const isDestructive = item.variant === 'destructive';
188
- const baseClasses = `flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-sm transition-colors w-full text-left ${
189
- isDestructive
190
- ? 'text-destructive hover:bg-destructive/10'
191
- : 'text-foreground hover:bg-accent hover:text-accent-foreground'
192
- }`;
193
-
194
- if (item.onClick) {
195
- return (
196
- <button
197
- key={itemIndex}
198
- onClick={item.onClick}
199
- className={baseClasses}
200
- >
201
- {Icon && <Icon className="h-4 w-4" />}
202
- {item.label}
203
- </button>
204
- );
205
- }
206
-
207
- if (item.href) {
208
- return (
209
- <Link key={itemIndex} href={item.href} className={baseClasses}>
210
- {Icon && <Icon className="h-4 w-4" />}
211
- {item.label}
212
- </Link>
213
- );
214
- }
215
-
216
- return null;
217
- })}
218
- </div>
219
- ))}
220
- {localeMenu && (
221
- <div className="border-t border-border/50 px-4 pt-3">
222
- <p className="text-[11px] font-normal text-muted-foreground/70">
223
- {labels.language}
224
- </p>
225
- <div className="mt-2 flex flex-wrap gap-2">
226
- {localeMenu.codes.map((code) => {
227
- const active = code === localeMenu.current;
228
- return (
229
- <button
230
- key={code}
231
- type="button"
232
- onClick={() => localeMenu.onChange(code)}
233
- className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm font-medium transition-colors ${
234
- active
235
- ? 'border-primary bg-accent text-foreground'
236
- : 'border-border/60 text-muted-foreground hover:bg-accent hover:text-foreground'
237
- }`}
238
- >
239
- <LanguageFlag code={code} className="h-3 w-4" rounded />
240
- {localeLabel(code)}
241
- </button>
242
- );
243
- })}
244
- </div>
245
- </div>
246
- )}
247
- <div>
248
- <button
249
- type="button"
250
- onClick={signOutItem.onClick}
251
- className="flex w-full items-center gap-3 rounded-sm px-4 py-3 text-left text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
252
- >
253
- <LogOut className="h-4 w-4" />
254
- {signOutItem.label}
255
- </button>
256
- </div>
257
- </div>
258
- </div>
259
- );
38
+ return <AccountInline groups={groups} authPath={authPath} i18n={i18n} />;
260
39
  }
261
40
 
262
- // Desktop variant — declarative menu via the shared MenuBuilder.
263
- const menuItems: MenuItem[] = [
264
- {
265
- kind: 'label',
266
- id: 'identity',
267
- label: (
268
- <div className="flex flex-col space-y-1">
269
- <p className="text-sm font-medium leading-none">{displayName}</p>
270
- <p className="text-xs leading-none text-muted-foreground">{user.email}</p>
271
- </div>
272
- ),
273
- },
274
- // App-provided link groups (each its own section with a trailing separator).
275
- ...profileGroups.map((group, i): MenuItem => ({
276
- kind: 'section',
277
- id: `group-${i}`,
278
- label: group.title,
279
- items: group.items.map((item, j): MenuItem => ({
280
- kind: 'item',
281
- id: item.href ?? `${i}-${j}`,
282
- label: item.label,
283
- icon: item.icon,
284
- href: item.href,
285
- onSelect: item.onClick ? () => item.onClick!() : undefined,
286
- variant: item.variant === 'destructive' ? 'destructive' : 'default',
287
- })),
288
- })),
289
- // Language submenu — radio group of locales (flag + label + checkmark).
290
- ...(localeMenu
291
- ? [{
292
- kind: 'submenu' as const,
293
- id: 'language',
294
- label: labels.language,
295
- icon: Languages,
296
- items: [{
297
- kind: 'radio-group' as const,
298
- id: 'locale',
299
- value: localeMenu.current,
300
- onValueChange: localeMenu.onChange,
301
- options: localeMenu.codes.map((code) => ({
302
- id: code,
303
- value: code,
304
- label: localeLabel(code),
305
- icon: <LanguageFlag code={code} className="h-3 w-4 shrink-0" rounded />,
306
- })),
307
- }],
308
- }]
309
- : []),
310
- { kind: 'separator', id: 'sep-logout' },
311
- {
312
- kind: 'item',
313
- id: 'signout',
314
- label: signOutItem.label,
315
- icon: LogOut,
316
- variant: 'destructive',
317
- onSelect: () => signOutItem.onClick(),
318
- },
319
- ];
320
-
321
41
  return (
322
- <MenuBuilder items={menuItems} align="end" contentClassName="w-56">
323
- <Button variant="ghost" size="icon" className="rounded-full p-0">
324
- <UserAvatar src={userAvatar} name={displayName} size="sm" />
325
- <span className="sr-only">{labels.userMenu}</span>
326
- </Button>
327
- </MenuBuilder>
42
+ <AccountMenu
43
+ groups={groups}
44
+ i18n={i18n}
45
+ authPath={authPath}
46
+ settings={false}
47
+ identity="card"
48
+ signIn={disableAuth ? 'disabled' : 'button'}
49
+ side="bottom"
50
+ align="end"
51
+ sideOffset={4}
52
+ contentClassName="w-56"
53
+ >
54
+ <AccountAvatarTrigger ring />
55
+ </AccountMenu>
328
56
  );
329
57
  }
330
-
@@ -1,4 +1,4 @@
1
1
  export { UserMenu } from './UserMenu';
2
2
  export type { UserMenuProps } from './UserMenu';
3
- export { UserAvatar } from './UserAvatar';
4
- export type { UserAvatarProps } from './UserAvatar';
3
+ export { UserAvatar } from '../../../components/account/UserAvatar';
4
+ export type { UserAvatarProps } from '../../../components/account/UserAvatar';
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { useEffect, useRef, useState, type RefObject } from 'react';
4
4
 
5
5
  import { useScroll } from '@djangocfg/ui-core/hooks';
6
6
 
@@ -11,6 +11,10 @@ export interface UseNavbarScrollOptions {
11
11
  transparent?: boolean;
12
12
  /** scrollY threshold for hide + transparent triggers. @default 40 */
13
13
  transparentThreshold?: number;
14
+ /** Scroll container to follow instead of the window (shells where the document never scrolls). */
15
+ target?: RefObject<HTMLElement | null>;
16
+ /** When false the hide still happens under `prefers-reduced-motion` (the caller drops the animation). @default true */
17
+ respectReducedMotion?: boolean;
14
18
  }
15
19
 
16
20
  export interface UseNavbarScrollReturn {
@@ -51,12 +55,12 @@ const DIRECTION_COMMIT_PX = 8;
51
55
  * renders.
52
56
  */
53
57
  export function useNavbarScroll(options: UseNavbarScrollOptions = {}): UseNavbarScrollReturn {
54
- const { hideNavOnScroll = false, transparent = false, transparentThreshold = 40 } = options;
58
+ const { hideNavOnScroll = false, transparent = false, transparentThreshold = 40, target, respectReducedMotion = true } = options;
55
59
 
56
60
  const active = hideNavOnScroll || transparent;
57
61
  // Subscribe to the shared store only when the feature is on. The `y` this
58
62
  // returns is already rAF-coalesced; we run cheap arithmetic on each change.
59
- const { y } = useScroll(active ? undefined : NEVER_TARGET);
63
+ const { y } = useScroll(active ? target : NEVER_TARGET);
60
64
 
61
65
  const [hidden, setHidden] = useState(false);
62
66
  const [scrolled, setScrolled] = useState(false);
@@ -84,13 +88,15 @@ export function useNavbarScroll(options: UseNavbarScrollOptions = {}): UseNavbar
84
88
  const prefersReducedMotion =
85
89
  typeof window.matchMedia === 'function' &&
86
90
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;
87
- const hideEnabled = hideNavOnScroll && !prefersReducedMotion;
91
+ const hideEnabled = hideNavOnScroll && !(respectReducedMotion && prefersReducedMotion);
88
92
 
89
93
  // iOS Safari rubber-banding reports negative scrollY at the top and values
90
94
  // past maxScroll at the bottom while the page bounces back; feeding those
91
95
  // into the integrator makes the bar twitch on every overscroll pull.
92
- const doc = document.documentElement;
93
- const maxScroll = Math.max(0, doc.scrollHeight - window.innerHeight);
96
+ const el = target?.current;
97
+ const maxScroll = el
98
+ ? Math.max(0, el.scrollHeight - el.clientHeight)
99
+ : Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
94
100
  const clampedY = Math.min(Math.max(0, y), maxScroll);
95
101
 
96
102
  if (transparent) {
@@ -134,7 +140,7 @@ export function useNavbarScroll(options: UseNavbarScrollOptions = {}): UseNavbar
134
140
  }
135
141
 
136
142
  lastY.current = clampedY;
137
- }, [y, active, hideNavOnScroll, transparent, transparentThreshold]);
143
+ }, [y, active, hideNavOnScroll, transparent, transparentThreshold, target, respectReducedMotion]);
138
144
 
139
145
  return { hidden, scrolled };
140
146
  }
@@ -1,14 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { ChevronDown } from 'lucide-react';
4
3
  import React from 'react';
5
4
 
6
- import { useAuth } from '@djangocfg/api/auth';
7
- import { Avatar, AvatarFallback, AvatarImage, Button, MenuBuilder } from '@djangocfg/ui-core/components';
8
5
  import type { MenuItem } from '@djangocfg/ui-core/components';
9
6
  import { cn } from '@djangocfg/ui-core/lib';
10
7
 
11
- import { useAccountMenuItems } from '../hooks/useAccountMenuItems';
8
+ import { AccountAvatarTrigger, AccountFooterTrigger, AccountMenu } from '../../../components/account';
12
9
 
13
10
  export interface AccountMenuButtonProps {
14
11
  /** `row`: avatar, name and chevron (a sidebar footer; the menu opens up). `avatar`: the avatar alone (a header; the menu opens down). */
@@ -26,67 +23,29 @@ export interface AccountMenuButtonProps {
26
23
  }
27
24
 
28
25
  /**
29
- * The ready user menu: trigger button plus the shared menu (email, Settings, a shortcut per
30
- * Settings section, Sign out). Drop it into any chrome; renders nothing for a signed-out visitor.
31
- * `PrivateLayout`'s sidebar footer builds on the same `useAccountMenuItems`.
26
+ * The ready user menu: the shared `AccountMenu` with a row or avatar trigger. Drop it into any chrome; renders
27
+ * nothing for a signed-out visitor.
32
28
  */
33
- export const AccountMenuButton: React.FC<AccountMenuButtonProps> = ({
34
- variant = 'row',
35
- extra,
36
- plan,
37
- className,
38
- classNames,
39
- }) => {
40
- const { user } = useAuth();
41
- const items = useAccountMenuItems({ extra });
42
- const [open, setOpen] = React.useState(false);
43
-
44
- if (!user) return null;
45
-
46
- const name = user.display_username || user.email || 'User';
47
- const initial = (name.charAt(0) || '?').toUpperCase();
29
+ export const AccountMenuButton: React.FC<AccountMenuButtonProps> = ({ variant = 'row', extra, plan, className, classNames }) => {
48
30
  const isRow = variant === 'row';
49
-
50
31
  return (
51
- <MenuBuilder
52
- items={items}
53
- open={open}
54
- onOpenChange={setOpen}
32
+ <AccountMenu
33
+ plan={plan}
34
+ extra={extra}
35
+ signIn="none"
55
36
  side={isRow ? 'top' : 'bottom'}
56
37
  align={isRow ? 'start' : 'end'}
57
- sideOffset={8}
58
- contentClassName="w-60 p-1.5"
59
38
  >
60
- <Button
61
- type="button"
62
- variant="ghost"
63
- aria-label={name}
64
- className={cn(
65
- 'group min-w-0 select-none gap-2 rounded-lg font-normal focus-visible:ring-0',
66
- isRow ? 'h-9 w-full justify-start px-2 text-left' : 'size-9 p-0',
67
- className,
68
- )}
69
- >
70
- <Avatar className={cn('size-6 shrink-0', classNames?.avatar)}>
71
- <AvatarImage src={user.avatar ?? ''} alt={name} />
72
- <AvatarFallback className="text-xs font-medium">{initial}</AvatarFallback>
73
- </Avatar>
74
- {isRow && (
75
- <>
76
- <span className={cn('flex min-w-0 items-baseline gap-1', classNames?.name)}>
77
- <span className="truncate text-sm">{name}</span>
78
- {plan ? <span className="shrink-0 text-xs text-muted-foreground">· {plan}</span> : null}
79
- </span>
80
- <ChevronDown
81
- className={cn(
82
- 'ml-auto size-4 shrink-0 opacity-50 transition-transform duration-150 group-data-[state=open]:rotate-180',
83
- classNames?.chevron,
84
- )}
85
- aria-hidden
86
- />
87
- </>
88
- )}
89
- </Button>
90
- </MenuBuilder>
39
+ {isRow ? (
40
+ <AccountFooterTrigger
41
+ className={cn('h-9', className)}
42
+ avatarClassName={classNames?.avatar}
43
+ nameClassName={classNames?.name}
44
+ chevronClassName={classNames?.chevron}
45
+ />
46
+ ) : (
47
+ <AccountAvatarTrigger className={className} />
48
+ )}
49
+ </AccountMenu>
91
50
  );
92
51
  };
@@ -11,8 +11,8 @@ export type {
11
11
  UseSettingsSectionsResult,
12
12
  } from './useSettingsSections';
13
13
 
14
- export { useAccountMenuItems } from './useAccountMenuItems';
15
- export type { UseAccountMenuItemsOptions } from './useAccountMenuItems';
14
+ export { useAccountMenuItems } from '../../../components/account/useAccountMenuItems';
15
+ export type { UseAccountMenuItemsOptions } from '../../../components/account/useAccountMenuItems';
16
16
 
17
17
  export { useSettingsSectionState } from './useSettingsSectionState';
18
18
  export type { SettingsSectionState } from './useSettingsSectionState';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The project name `BaseApp` already receives (`project` prop), exposed to descendants so shells can use it as the
3
+ * default product name. Outside `BaseApp` (stories, tests) the hook returns `undefined` and never throws.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+
10
+ const ProjectNameContext = React.createContext<string | undefined>(undefined);
11
+
12
+ export function ProjectNameProvider({ value, children }: { value?: string; children: React.ReactNode }) {
13
+ return <ProjectNameContext.Provider value={value?.trim() || undefined}>{children}</ProjectNameContext.Provider>;
14
+ }
15
+
16
+ export function useProjectName(): string | undefined {
17
+ return React.useContext(ProjectNameContext);
18
+ }
@@ -20,6 +20,8 @@ export interface MockAuthProviderProps {
20
20
  isAuthenticated?: boolean;
21
21
  /** Override `isAdminUser`. Defaults to `false`. */
22
22
  isAdminUser?: boolean;
23
+ /** Override resolved auth routes (e.g. `defaultCallback`, the private landing page). */
24
+ routes?: Partial<{ auth: string; defaultCallback: string; defaultAuthCallback: string }>;
23
25
  }
24
26
 
25
27
  const noopAsync = async () => {};
@@ -54,6 +56,7 @@ export const MockAuthProvider: React.FC<MockAuthProviderProps> = ({
54
56
  isLoading = false,
55
57
  isAuthenticated,
56
58
  isAdminUser = false,
59
+ routes,
57
60
  }) => {
58
61
  const [user, setUser] = useState<UserProfile | null>(initialUser);
59
62
 
@@ -62,7 +65,7 @@ export const MockAuthProvider: React.FC<MockAuthProviderProps> = ({
62
65
  isLoading,
63
66
  isAuthenticated: isAuthenticated ?? Boolean(user),
64
67
  isAdminUser,
65
- routes: { auth: '/auth', defaultCallback: '/', defaultAuthCallback: '/auth' },
68
+ routes: { auth: '/auth', defaultCallback: '/', defaultAuthCallback: '/auth', ...routes },
66
69
 
67
70
  loadCurrentProfile: noopAsync,
68
71
  checkAuthAndRedirect: noopAsync,
@@ -93,7 +96,7 @@ export const MockAuthProvider: React.FC<MockAuthProviderProps> = ({
93
96
  return next;
94
97
  },
95
98
  uploadAvatar: async () => user ?? buildMockUser(),
96
- }), [user, isLoading, isAuthenticated, isAdminUser]);
99
+ }), [user, isLoading, isAuthenticated, isAdminUser, routes]);
97
100
 
98
101
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
99
102
  };