@djangocfg/layouts 2.1.592 → 2.1.593

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 (37) hide show
  1. package/package.json +12 -12
  2. package/src/components/errors/ErrorLayout.tsx +1 -164
  3. package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +1 -100
  4. package/src/components/errors/ErrorsTracker/providers/errorDedupe.ts +101 -0
  5. package/src/components/errors/errorGlyphs.tsx +167 -0
  6. package/src/layouts/AppLayout/BaseApp.tsx +1 -1
  7. package/src/layouts/AppLayout/index.ts +2 -2
  8. package/src/layouts/AuthLayout/AuthLayout.tsx +3 -23
  9. package/src/layouts/AuthLayout/components/shared/AuthTopBar.tsx +1 -1
  10. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +1 -1
  11. package/src/layouts/AuthLayout/layoutContext.ts +25 -0
  12. package/src/layouts/AuthLayout/styles/split-shell.css +0 -1
  13. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +10 -51
  14. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +3 -89
  15. package/src/layouts/PublicLayout/footers/DefaultFooter/ThemeModeControl.tsx +90 -0
  16. package/src/layouts/PublicLayout/primitives/DesktopDropdownPortal.tsx +47 -0
  17. package/src/layouts/PublicLayout/primitives/NavControls.tsx +1 -1
  18. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +2 -83
  19. package/src/layouts/PublicLayout/primitives/navDesktopStyles.ts +42 -0
  20. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +4 -171
  21. package/src/layouts/PublicLayout/shared/index.ts +1 -1
  22. package/src/layouts/PublicLayout/shared/navbarShell.types.ts +109 -0
  23. package/src/layouts/PublicLayout/shared/navbarShellStyles.ts +69 -0
  24. package/src/layouts/SettingsLayout/SettingsDialog.tsx +13 -3
  25. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +83 -0
  26. package/src/layouts/SettingsLayout/components/SettingsNavItem.tsx +1 -1
  27. package/src/layouts/SettingsLayout/components/SettingsSearch.tsx +1 -1
  28. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSection.tsx +14 -29
  29. package/src/layouts/SettingsLayout/components/TwoFactorSection/TwoFactorSetupDialog.tsx +35 -0
  30. package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
  31. package/src/layouts/SettingsLayout/hooks/useAccountMenuItems.ts +69 -0
  32. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +6 -3
  33. package/src/layouts/SettingsLayout/index.ts +3 -0
  34. package/src/layouts/SettingsLayout/sections/PreferencesRows.tsx +1 -1
  35. package/src/layouts/SettingsLayout/store.ts +12 -0
  36. package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +1 -1
  37. package/src/{layouts/AppLayout → shared}/LayoutI18nProvider.tsx +1 -1
@@ -16,7 +16,6 @@
16
16
  'use client';
17
17
 
18
18
  import React, {
19
- type ReactNode,
20
19
  memo,
21
20
  useEffect,
22
21
  useLayoutEffect,
@@ -31,190 +30,24 @@ import { cn } from '@djangocfg/ui-core/lib';
31
30
  import { usePathnameWithoutLocale } from '../../../hooks';
32
31
  import { navItemsShallowEqual } from '../../../utils/navItemsShallowEqual';
33
32
  import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
34
- import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
33
+ import { useLayoutI18nOptional } from '../../../shared/LayoutI18nProvider';
35
34
  import { usePublicLayoutOptional } from '../context';
36
35
  import {
37
36
  useDropdownMenu,
38
37
  useNavbarScroll,
39
38
  useNavbarViewportVars,
40
39
  } from '../hooks';
41
- import type {
42
- PublicDesktopDropdownRenderer,
43
- PublicNavbarHeight,
44
- PublicNavbarInsetX,
45
- PublicNavbarPosition,
46
- PublicNavbarVariant,
47
- PublicNavLayout,
48
- } from '../navbarTypes';
49
- import type { NavigationItem, UserMenuConfig } from '../../types';
40
+ import type { NavigationItem } from '../../types';
50
41
 
51
42
  import { NavActions } from '../primitives/NavActions';
52
- import type { NavAction } from '../primitives/NavActionItem';
53
43
  import { NavBrand } from '../primitives/NavBrand';
54
44
  import { NavControls } from '../primitives/NavControls';
55
45
  import { NavDesktopItems } from '../primitives/NavDesktopItems';
56
46
 
57
47
  import { NavbarRowDesktop, NavbarRowMobile } from './navbarRows';
58
48
 
59
- /**
60
- * Vertical padding, per breakpoint. Two independent scales rather than one with
61
- * `sm:` steps inside it: the mobile row's tallest element is the 44px burger,
62
- * so padding there is pure added height, while the desktop row needs the extra
63
- * air to keep the link list off the panel edge.
64
- */
65
- const heightClsDesktop: Record<PublicNavbarHeight, string> = {
66
- sm: 'py-2',
67
- md: 'py-3.5',
68
- lg: 'py-5',
69
- };
70
-
71
- const heightClsMobile: Record<PublicNavbarHeight, string> = {
72
- sm: 'py-1.5',
73
- md: 'py-2',
74
- lg: 'py-3',
75
- };
76
-
77
- /**
78
- * Token → Tailwind class for the inner-row horizontal padding.
79
- *
80
- * Deliberately ASYMMETRIC at every breakpoint (`pl` > `pr`) to correct an
81
- * optical imbalance, not a measured one. On the left the gutter meets the
82
- * brand's glyphs directly; on the right it meets a button — an icon square on
83
- * phones, the Login pill on desktop — whose box carries its own internal
84
- * padding around the glyph. Equal `px` therefore *looks* lopsided, with a
85
- * conspicuous moat to the right of the last control.
86
- *
87
- * It also stacks with the navbar shell's own outer gutter (`px-3 sm:px-4
88
- * lg:px-6`), which is why the right side stays small in absolute terms: at
89
- * `lg` the symmetric version put ~48px between the Login button and the
90
- * viewport edge.
91
- */
92
- /** Desktop (`lg`+) horizontal inset. Right stays trimmed — see the note above. */
93
- const insetXClsDesktop: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
94
- none: '',
95
- xs: 'lg:pl-4 lg:pr-1.5',
96
- sm: 'lg:pl-6 lg:pr-2',
97
- md: 'lg:pl-8 lg:pr-3',
98
- lg: 'lg:pl-12 lg:pr-5',
99
- };
100
-
101
- /** Mobile horizontal inset — tighter, and asymmetric for the same reason. */
102
- const insetXClsMobile: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
103
- none: '',
104
- xs: 'pl-3 pr-1',
105
- sm: 'pl-4 pr-1.5',
106
- md: 'pl-5 pr-2',
107
- lg: 'pl-6 pr-3',
108
- };
109
-
110
- /**
111
- * Resolves the inset token into one class string covering both breakpoints.
112
- *
113
- * A caller may also pass raw Tailwind instead of a token; that escape hatch is
114
- * applied verbatim to both rows, since we cannot know how to split it.
115
- */
116
- function resolveInsetX(value: PublicNavbarInsetX | undefined): string {
117
- const token = (value ?? 'sm') as keyof typeof insetXClsMobile;
118
- if (!(token in insetXClsMobile)) return value as string;
119
- return cn(insetXClsMobile[token], insetXClsDesktop[token]);
120
- }
121
-
122
- interface ShapeInputs {
123
- /** true at scrollY >= threshold */
124
- scrolled: boolean;
125
- /** Matches `transparent` flag at the source. */
126
- transparent: boolean;
127
- /**
128
- * true while the mobile drawer is open. A `transparent` bar MUST go opaque
129
- * here regardless of scroll position: an open drawer means the brand + close
130
- * button sit over page content, and a see-through bar leaves them floating on
131
- * whatever is scrolled behind — the "navbar lost its background" bug.
132
- */
133
- menuOpen: boolean;
134
- }
135
-
136
- export interface NavbarShellProps {
137
- // ── Surface identity (reported to context) ────────────────────────────────
138
- variant: PublicNavbarVariant;
139
- position: PublicNavbarPosition;
140
-
141
- // ── Content ───────────────────────────────────────────────────────────────
142
- brand?: ReactNode;
143
- brandHref?: string;
144
- navigation?: NavigationItem[];
145
- userMenu?: UserMenuConfig;
146
- renderDesktopDropdown?: PublicDesktopDropdownRenderer;
147
- /**
148
- * Hard cap for how many items to try to fit on desktop. Useful when a
149
- * consumer wants to force a compact primary row even if more would fit.
150
- * Defaults to the full list length (let the responsive measurer decide).
151
- */
152
- desktopMaxPrimaryItems?: number;
153
-
154
- // ── Layout ────────────────────────────────────────────────────────────────
155
- navLayout?: PublicNavLayout;
156
- navbarHeight?: PublicNavbarHeight;
157
- /** Horizontal inset between outer edge and the inner row.
158
- * @default 'sm' */
159
- insetX?: PublicNavbarInsetX;
160
- /** Full override for the row container (escape hatch).
161
- * When set, wins over `insetX`. */
162
- innerPadding?: string;
163
-
164
- // ── Scroll behavior ───────────────────────────────────────────────────────
165
- hideNavOnScroll?: boolean;
166
- transparent?: boolean;
167
- transparentThreshold?: number;
168
-
169
- // ── Chrome (per-variant) ──────────────────────────────────────────────────
170
- /** Classes on the outer wrapper (fixed/sticky, top offset, outer x-padding). */
171
- outerClassName?: string;
172
- /** Classes for the `<nav>` element defining the shape (rounding, border, shadow). */
173
- shapeClassName?: string;
174
- /** Surface classes (bg, backdrop-blur). Driven by `shapeForState`. */
175
- shapeForState?: (inputs: ShapeInputs) => string;
176
-
177
- /**
178
- * Replaces the default actions column (UserMenu + mobile-toggle).
179
- * Variant owns the mobile toggle too. Receives helpers so it can build the
180
- * right-hand side however it wants.
181
- */
182
- renderActions?: (ctx: NavbarActionsContext) => ReactNode;
183
-
184
- // ── Default-actions slots (only used when `renderActions` is NOT provided) ─
185
- /** Typed CTA pills (Book a demo / Get started / …) before UserMenu. */
186
- actions?: NavAction[];
187
- /** Arbitrary ReactNode between actions and UserMenu. */
188
- actionsLeadingSlot?: ReactNode;
189
- /** Arbitrary ReactNode after the mobile toggle. */
190
- actionsTrailingSlot?: ReactNode;
191
-
192
- // ── Theme + locale controls (rendered next to UserMenu on desktop) ────────
193
- /** Toggle individual controls. Locale switcher also requires `i18n`. */
194
- controls?: {
195
- /** Light / system / dark pill. @default false */
196
- showThemeSwitcher?: boolean;
197
- /** Locale dropdown. Requires `i18n`. @default false */
198
- showLocaleSwitcher?: boolean;
199
- };
200
-
201
- // ── Props override (used by variants that proxy ctx differently) ──────────
202
- mobileMenuOpen?: boolean;
203
- onMobileMenuToggle?: () => void;
204
- }
205
-
206
- export interface NavbarActionsContext {
207
- userMenu?: UserMenuConfig;
208
- mobileMenuOpen: boolean;
209
- toggleMobileMenu: () => void;
210
- toggleMobileLabel: string;
211
- navLayout: PublicNavLayout;
212
- /**
213
- * Pre-built theme/locale controls node. `null` when both toggles are off.
214
- * Variants with a custom `renderActions` can choose where to place it.
215
- */
216
- controls: ReactNode | null;
217
- }
49
+ import { heightClsDesktop, heightClsMobile, resolveInsetX } from './navbarShellStyles';
50
+ import type { NavbarShellProps } from './navbarShell.types';
218
51
 
219
52
  function NavbarShellRaw(props: NavbarShellProps) {
220
53
  const context = usePublicLayoutOptional();
@@ -1,4 +1,4 @@
1
1
  export { NavbarShell } from './NavbarShell';
2
- export type { NavbarShellProps, NavbarActionsContext } from './NavbarShell';
2
+ export type { NavbarShellProps, NavbarActionsContext } from './navbarShell.types';
3
3
  export { MobileDrawerShell } from './MobileDrawerShell';
4
4
  export type { MobileDrawerShellProps } from './MobileDrawerShell';
@@ -0,0 +1,109 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import type {
4
+ PublicDesktopDropdownRenderer,
5
+ PublicNavbarHeight,
6
+ PublicNavbarInsetX,
7
+ PublicNavbarPosition,
8
+ PublicNavbarVariant,
9
+ PublicNavLayout,
10
+ } from '../navbarTypes';
11
+ import type { NavigationItem, UserMenuConfig } from '../../types';
12
+ import type { NavAction } from '../primitives/NavActionItem';
13
+
14
+ export interface ShapeInputs {
15
+ /** true at scrollY >= threshold */
16
+ scrolled: boolean;
17
+ /** Matches `transparent` flag at the source. */
18
+ transparent: boolean;
19
+ /**
20
+ * true while the mobile drawer is open. A `transparent` bar MUST go opaque
21
+ * here regardless of scroll position: an open drawer means the brand + close
22
+ * button sit over page content, and a see-through bar leaves them floating on
23
+ * whatever is scrolled behind — the "navbar lost its background" bug.
24
+ */
25
+ menuOpen: boolean;
26
+ }
27
+
28
+ export interface NavbarShellProps {
29
+ // ── Surface identity (reported to context) ────────────────────────────────
30
+ variant: PublicNavbarVariant;
31
+ position: PublicNavbarPosition;
32
+
33
+ // ── Content ───────────────────────────────────────────────────────────────
34
+ brand?: ReactNode;
35
+ brandHref?: string;
36
+ navigation?: NavigationItem[];
37
+ userMenu?: UserMenuConfig;
38
+ renderDesktopDropdown?: PublicDesktopDropdownRenderer;
39
+ /**
40
+ * Hard cap for how many items to try to fit on desktop. Useful when a
41
+ * consumer wants to force a compact primary row even if more would fit.
42
+ * Defaults to the full list length (let the responsive measurer decide).
43
+ */
44
+ desktopMaxPrimaryItems?: number;
45
+
46
+ // ── Layout ────────────────────────────────────────────────────────────────
47
+ navLayout?: PublicNavLayout;
48
+ navbarHeight?: PublicNavbarHeight;
49
+ /** Horizontal inset between outer edge and the inner row.
50
+ * @default 'sm' */
51
+ insetX?: PublicNavbarInsetX;
52
+ /** Full override for the row container (escape hatch).
53
+ * When set, wins over `insetX`. */
54
+ innerPadding?: string;
55
+
56
+ // ── Scroll behavior ───────────────────────────────────────────────────────
57
+ hideNavOnScroll?: boolean;
58
+ transparent?: boolean;
59
+ transparentThreshold?: number;
60
+
61
+ // ── Chrome (per-variant) ──────────────────────────────────────────────────
62
+ /** Classes on the outer wrapper (fixed/sticky, top offset, outer x-padding). */
63
+ outerClassName?: string;
64
+ /** Classes for the `<nav>` element defining the shape (rounding, border, shadow). */
65
+ shapeClassName?: string;
66
+ /** Surface classes (bg, backdrop-blur). Driven by `shapeForState`. */
67
+ shapeForState?: (inputs: ShapeInputs) => string;
68
+
69
+ /**
70
+ * Replaces the default actions column (UserMenu + mobile-toggle).
71
+ * Variant owns the mobile toggle too. Receives helpers so it can build the
72
+ * right-hand side however it wants.
73
+ */
74
+ renderActions?: (ctx: NavbarActionsContext) => ReactNode;
75
+
76
+ // ── Default-actions slots (only used when `renderActions` is NOT provided) ─
77
+ /** Typed CTA pills (Book a demo / Get started / …) before UserMenu. */
78
+ actions?: NavAction[];
79
+ /** Arbitrary ReactNode between actions and UserMenu. */
80
+ actionsLeadingSlot?: ReactNode;
81
+ /** Arbitrary ReactNode after the mobile toggle. */
82
+ actionsTrailingSlot?: ReactNode;
83
+
84
+ // ── Theme + locale controls (rendered next to UserMenu on desktop) ────────
85
+ /** Toggle individual controls. Locale switcher also requires `i18n`. */
86
+ controls?: {
87
+ /** Light / system / dark pill. @default false */
88
+ showThemeSwitcher?: boolean;
89
+ /** Locale dropdown. Requires `i18n`. @default false */
90
+ showLocaleSwitcher?: boolean;
91
+ };
92
+
93
+ // ── Props override (used by variants that proxy ctx differently) ──────────
94
+ mobileMenuOpen?: boolean;
95
+ onMobileMenuToggle?: () => void;
96
+ }
97
+
98
+ export interface NavbarActionsContext {
99
+ userMenu?: UserMenuConfig;
100
+ mobileMenuOpen: boolean;
101
+ toggleMobileMenu: () => void;
102
+ toggleMobileLabel: string;
103
+ navLayout: PublicNavLayout;
104
+ /**
105
+ * Pre-built theme/locale controls node. `null` when both toggles are off.
106
+ * Variants with a custom `renderActions` can choose where to place it.
107
+ */
108
+ controls: ReactNode | null;
109
+ }
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ import type { PublicNavbarHeight, PublicNavbarInsetX } from '../navbarTypes';
6
+
7
+ /**
8
+ * Vertical padding, per breakpoint. Two independent scales rather than one with
9
+ * `sm:` steps inside it: the mobile row's tallest element is the 44px burger,
10
+ * so padding there is pure added height, while the desktop row needs the extra
11
+ * air to keep the link list off the panel edge.
12
+ */
13
+ export const heightClsDesktop: Record<PublicNavbarHeight, string> = {
14
+ sm: 'py-2',
15
+ md: 'py-3.5',
16
+ lg: 'py-5',
17
+ };
18
+
19
+ export const heightClsMobile: Record<PublicNavbarHeight, string> = {
20
+ sm: 'py-1.5',
21
+ md: 'py-2',
22
+ lg: 'py-3',
23
+ };
24
+
25
+ /**
26
+ * Token → Tailwind class for the inner-row horizontal padding.
27
+ *
28
+ * Deliberately ASYMMETRIC at every breakpoint (`pl` > `pr`) to correct an
29
+ * optical imbalance, not a measured one. On the left the gutter meets the
30
+ * brand's glyphs directly; on the right it meets a button — an icon square on
31
+ * phones, the Login pill on desktop — whose box carries its own internal
32
+ * padding around the glyph. Equal `px` therefore *looks* lopsided, with a
33
+ * conspicuous moat to the right of the last control.
34
+ *
35
+ * It also stacks with the navbar shell's own outer gutter (`px-3 sm:px-4
36
+ * lg:px-6`), which is why the right side stays small in absolute terms: at
37
+ * `lg` the symmetric version put ~48px between the Login button and the
38
+ * viewport edge.
39
+ */
40
+ /** Desktop (`lg`+) horizontal inset. Right stays trimmed — see the note above. */
41
+ export const insetXClsDesktop: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
42
+ none: '',
43
+ xs: 'lg:pl-4 lg:pr-1.5',
44
+ sm: 'lg:pl-6 lg:pr-2',
45
+ md: 'lg:pl-8 lg:pr-3',
46
+ lg: 'lg:pl-12 lg:pr-5',
47
+ };
48
+
49
+ /** Mobile horizontal inset — tighter, and asymmetric for the same reason. */
50
+ export const insetXClsMobile: Record<'none' | 'xs' | 'sm' | 'md' | 'lg', string> = {
51
+ none: '',
52
+ xs: 'pl-3 pr-1',
53
+ sm: 'pl-4 pr-1.5',
54
+ md: 'pl-5 pr-2',
55
+ lg: 'pl-6 pr-3',
56
+ };
57
+
58
+ /**
59
+ * Resolves the inset token into one class string covering both breakpoints.
60
+ *
61
+ * A caller may also pass raw Tailwind instead of a token; that escape hatch is
62
+ * applied verbatim to both rows, since we cannot know how to split it.
63
+ */
64
+ export function resolveInsetX(value: PublicNavbarInsetX | undefined): string {
65
+ const token = (value ?? 'sm') as keyof typeof insetXClsMobile;
66
+ if (!(token in insetXClsMobile)) return value as string;
67
+ return cn(insetXClsMobile[token], insetXClsDesktop[token]);
68
+ }
69
+
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
- import React, { useCallback, useMemo } from 'react';
3
+ import React, { useCallback, useEffect, useMemo } from 'react';
4
4
 
5
+ import { useAuth } from '@djangocfg/api/auth';
5
6
  import {
6
7
  Dialog,
7
8
  DialogContent,
@@ -46,6 +47,8 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
46
47
  const setSection = useSettingsDialogStore((s) => s.setSection);
47
48
  const isMobile = useIsMobile();
48
49
  const copy = useSettingsCopy();
50
+ // Settings are about the signed-in user: for a guest the panels are empty, so `#settings` opens nothing until sign-in.
51
+ const { isAuthenticated } = useAuth();
49
52
 
50
53
  // Set of valid ids, so a bad URL hash falls back gracefully instead of
51
54
  // selecting a non-existent section. Cheap to recompute; built-ins are pure.
@@ -56,17 +59,24 @@ export const SettingsDialog: React.FC<SettingsDialogProps> = ({
56
59
  return ids;
57
60
  }, [builtins, enableDeleteAccount, appSections, copy]);
58
61
 
62
+ // Menus (the account menu) link to these; only this component knows the host's sections.
63
+ const setMenuSections = useSettingsDialogStore((s) => s.setSections);
64
+ useEffect(() => {
65
+ const all = [...buildBuiltinSections({ builtins, enableDeleteAccount, copy }), ...appSections];
66
+ setMenuSections(all.filter((s) => !s.hidden).map(({ id, label, icon }) => ({ id, label, icon })));
67
+ }, [builtins, enableDeleteAccount, appSections, copy, setMenuSections]);
68
+
59
69
  const resolveSection = useCallback(
60
70
  (id: string | null) => (id && knownIds.has(id) ? id : null),
61
71
  [knownIds],
62
72
  );
63
73
 
64
- useSettingsUrl({ urlKey, enabled: syncUrl, resolveSection });
74
+ useSettingsUrl({ urlKey, enabled: syncUrl && isAuthenticated, resolveSection });
65
75
 
66
76
  const resolvedTitle = title ?? copy.nav.title;
67
77
 
68
78
  return (
69
- <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
79
+ <Dialog open={isOpen && isAuthenticated} onOpenChange={(open) => !open && close()}>
70
80
  <DialogContent
71
81
  // Desktop: large centred card, proportional to the viewport.
72
82
  // Mobile: near-fullscreen sheet (small inset) so the phone settings get
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+
3
+ import { ChevronDown } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ import { useAuth } from '@djangocfg/api/auth';
7
+ import { Avatar, AvatarFallback, AvatarImage, Button, MenuBuilder } from '@djangocfg/ui-core/components';
8
+ import type { MenuItem } from '@djangocfg/ui-core/components';
9
+ import { cn } from '@djangocfg/ui-core/lib';
10
+
11
+ import { useAccountMenuItems } from '../hooks/useAccountMenuItems';
12
+
13
+ export interface AccountMenuButtonProps {
14
+ /** `row`: avatar, name and chevron (a sidebar footer; the menu opens up). `avatar`: the avatar alone (a header; the menu opens down). */
15
+ variant?: 'row' | 'avatar';
16
+ /** Items between the Settings shortcuts and Sign out (language, app links). */
17
+ extra?: MenuItem[];
18
+ /** Small muted text after the name (a plan). */
19
+ plan?: string | null;
20
+ className?: string;
21
+ }
22
+
23
+ /**
24
+ * The ready user menu: trigger button plus the shared menu (email, Settings, a shortcut per
25
+ * Settings section, Sign out). Drop it into any chrome; renders nothing for a signed-out visitor.
26
+ * `PrivateLayout`'s sidebar footer builds on the same `useAccountMenuItems`.
27
+ */
28
+ export const AccountMenuButton: React.FC<AccountMenuButtonProps> = ({
29
+ variant = 'row',
30
+ extra,
31
+ plan,
32
+ className,
33
+ }) => {
34
+ const { user } = useAuth();
35
+ const items = useAccountMenuItems({ extra });
36
+ const [open, setOpen] = React.useState(false);
37
+
38
+ if (!user) return null;
39
+
40
+ const name = user.display_username || user.email || 'User';
41
+ const initial = (name.charAt(0) || '?').toUpperCase();
42
+ const isRow = variant === 'row';
43
+
44
+ return (
45
+ <MenuBuilder
46
+ items={items}
47
+ open={open}
48
+ onOpenChange={setOpen}
49
+ side={isRow ? 'top' : 'bottom'}
50
+ align={isRow ? 'start' : 'end'}
51
+ sideOffset={8}
52
+ contentClassName="w-60 p-1.5"
53
+ >
54
+ <Button
55
+ type="button"
56
+ variant="ghost"
57
+ aria-label={name}
58
+ className={cn(
59
+ 'group min-w-0 select-none gap-2 rounded-lg font-normal focus-visible:ring-0',
60
+ isRow ? 'h-9 w-full justify-start px-2 text-left' : 'size-9 p-0',
61
+ className,
62
+ )}
63
+ >
64
+ <Avatar className="size-6 shrink-0">
65
+ <AvatarImage src={user.avatar ?? ''} alt={name} />
66
+ <AvatarFallback className="text-xs font-medium">{initial}</AvatarFallback>
67
+ </Avatar>
68
+ {isRow && (
69
+ <>
70
+ <span className="flex min-w-0 items-baseline gap-1">
71
+ <span className="truncate text-sm">{name}</span>
72
+ {plan ? <span className="shrink-0 text-xs text-muted-foreground">· {plan}</span> : null}
73
+ </span>
74
+ <ChevronDown
75
+ className="ml-auto size-4 shrink-0 opacity-50 transition-transform duration-150 group-data-[state=open]:rotate-180"
76
+ aria-hidden
77
+ />
78
+ </>
79
+ )}
80
+ </Button>
81
+ </MenuBuilder>
82
+ );
83
+ };
@@ -31,7 +31,7 @@ export const SettingsNavItem: React.FC<SettingsNavItemProps> = ({
31
31
  aria-selected={active}
32
32
  onClick={() => onSelect(section.id)}
33
33
  className={cn(
34
- 'group flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-base',
34
+ 'group flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-sm',
35
35
  'outline-none transition-colors',
36
36
  'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
37
37
  active
@@ -30,7 +30,7 @@ export const SettingsSearch: React.FC = () => {
30
30
  placeholder={placeholder}
31
31
  aria-label={placeholder}
32
32
  className={cn(
33
- 'h-8 w-full rounded-lg border border-transparent bg-foreground/[0.05] pl-8 pr-8 text-base',
33
+ 'h-8 w-full rounded-lg border border-transparent bg-foreground/[0.05] pl-8 pr-8 text-base md:text-sm',
34
34
  'text-foreground placeholder:text-muted-foreground',
35
35
  'outline-none transition-[border-color,background-color,box-shadow]',
36
36
  // Crisp thin accent outline (matches ui-core Input), no blurry halo.
@@ -14,13 +14,12 @@ import {
14
14
  } from '@djangocfg/ui-core/components';
15
15
 
16
16
  import { fill, useSettingsCopy, type SettingsCopy } from '../../i18n';
17
- import { TwoFactorSetup } from '../../../../snippets/TwoFactor';
17
+ import { TwoFactorSetupDialog } from './TwoFactorSetupDialog';
18
18
 
19
19
  // ─────────────────────────────────────────────────────────────────────────────
20
20
  // Types
21
21
  // ─────────────────────────────────────────────────────────────────────────────
22
22
 
23
- type View = 'status' | 'setup';
24
23
 
25
24
  // ─────────────────────────────────────────────────────────────────────────────
26
25
  // Disable confirmation — inline, not a dialog. Settings already lives in a
@@ -109,7 +108,7 @@ function deviceDescription(copy: SettingsCopy['security'], createdAt: string, is
109
108
 
110
109
  export const TwoFactorSection: React.FC = () => {
111
110
  const copy = useSettingsCopy().security;
112
- const [view, setView] = useState<View>('status');
111
+ const [setupOpen, setSetupOpen] = useState(false);
113
112
  const [showDisable, setShowDisable] = useState(false);
114
113
 
115
114
  const {
@@ -128,11 +127,11 @@ export const TwoFactorSection: React.FC = () => {
128
127
 
129
128
  const handleEnableClick = () => {
130
129
  resetSetup();
131
- setView('setup');
130
+ setSetupOpen(true);
132
131
  };
133
132
 
134
133
  const handleSetupDone = () => {
135
- setView('status');
134
+ setSetupOpen(false);
136
135
  fetchStatus();
137
136
  };
138
137
 
@@ -146,30 +145,6 @@ export const TwoFactorSection: React.FC = () => {
146
145
  clearError();
147
146
  };
148
147
 
149
- // ── Setup flow ──────────────────────────────────────────────────────────────
150
- // Lighter, card-free container. The standalone setup flow brings its own
151
- // visible (Preloader-based) loading state.
152
- if (view === 'setup') {
153
- return (
154
- <SettingsBlock title={copy.setupTitle}>
155
- <div className="flex items-center justify-between pb-2">
156
- <p className="text-[13px] leading-relaxed text-muted-foreground">
157
- {copy.setupHint}
158
- </p>
159
- <Button variant="ghost" size="sm" onClick={() => setView('status')}>
160
- {copy.cancel}
161
- </Button>
162
- </div>
163
- <div className="flex justify-center py-2">
164
- <TwoFactorSetup
165
- onComplete={handleSetupDone}
166
- onSkip={() => setView('status')}
167
- />
168
- </div>
169
- </SettingsBlock>
170
- );
171
- }
172
-
173
148
  // ── Status view ─────────────────────────────────────────────────────────────
174
149
  // One compact block: a single status row (state + action), the inline confirm
175
150
  // when turning off, then the connected devices as plain rows.
@@ -223,6 +198,16 @@ export const TwoFactorSection: React.FC = () => {
223
198
  description={deviceDescription(copy, device.createdAt, device.isPrimary)}
224
199
  />
225
200
  ))}
201
+
202
+ <TwoFactorSetupDialog
203
+ open={setupOpen}
204
+ onOpenChange={(open) => {
205
+ setSetupOpen(open);
206
+ // Closing mid-way or after finishing: the status row must reflect the server.
207
+ if (!open) fetchStatus();
208
+ }}
209
+ onComplete={handleSetupDone}
210
+ />
226
211
  </SettingsBlock>
227
212
  );
228
213
  };
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@djangocfg/ui-core/components';
6
+
7
+ import { TwoFactorSetup } from '../../../../snippets/TwoFactor';
8
+ import { useSettingsCopy } from '../../i18n';
9
+
10
+ export interface TwoFactorSetupDialogProps {
11
+ open: boolean;
12
+ onOpenChange: (open: boolean) => void;
13
+ /** Runs after the user dismisses the backup codes. */
14
+ onComplete: () => void;
15
+ }
16
+
17
+ /**
18
+ * Enabling 2FA is one focused task, so it gets its own dialog over Settings instead of
19
+ * replacing the Security block inline. One exit (close); no "skip" — that belongs to the
20
+ * sign-in flow, not to a deliberate opt-in. Content mounts only while open, so each
21
+ * opening starts a fresh setup and an unfinished one is dropped on close.
22
+ */
23
+ export const TwoFactorSetupDialog: React.FC<TwoFactorSetupDialogProps> = ({ open, onOpenChange, onComplete }) => {
24
+ const copy = useSettingsCopy().security;
25
+
26
+ return (
27
+ <Dialog open={open} onOpenChange={onOpenChange}>
28
+ <DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-1.5rem)] max-w-md flex-col items-center overflow-y-auto bg-card p-6">
29
+ <DialogTitle className="sr-only">{copy.setupTitle}</DialogTitle>
30
+ <DialogDescription className="sr-only">{copy.setupHint}</DialogDescription>
31
+ {open && <TwoFactorSetup onComplete={onComplete} />}
32
+ </DialogContent>
33
+ </Dialog>
34
+ );
35
+ };
@@ -10,3 +10,6 @@ export type {
10
10
  UseSettingsSectionsArgs,
11
11
  UseSettingsSectionsResult,
12
12
  } from './useSettingsSections';
13
+
14
+ export { useAccountMenuItems } from './useAccountMenuItems';
15
+ export type { UseAccountMenuItemsOptions } from './useAccountMenuItems';