@djangocfg/layouts 2.1.266 → 2.1.267

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,7 +104,7 @@ import { AppLayout } from '@djangocfg/layouts';
104
104
  public: { component: PublicLayout, enabledPath: ['/', '/legal', '/contact'] },
105
105
  private: { component: PrivateLayout, enabledPath: ['/dashboard'] },
106
106
  admin: { component: AdminLayout, enabledPath: '/admin' },
107
- noLayoutPaths: ['/embed'],
107
+ noLayoutPaths: ['/embed', '/ui'], // fullscreen, no navbar/footer
108
108
  }}
109
109
  baseApp={{ project: 'my-app', theme: { defaultTheme: 'system' }, auth: { apiUrl: '…' } }}
110
110
  i18n={{ locale, locales, onLocaleChange }}
@@ -113,21 +113,91 @@ import { AppLayout } from '@djangocfg/layouts';
113
113
  </AppLayout>
114
114
  ```
115
115
 
116
+ > **`i18n` is required for correct path matching.**
117
+ > `AppLayout` strips the locale prefix from the pathname before matching `enabledPath` / `noLayoutPaths`.
118
+ > It uses `i18n.locale` for exact stripping — without it the fallback regex can misfire on
119
+ > 2-letter path segments (e.g. `/ui/*` being treated as locale `ui`).
120
+ > Always pass `i18n` when using next-intl or any locale-prefixed routing.
121
+
116
122
  ---
117
123
 
118
124
  ## Layouts (import from `@djangocfg/layouts`)
119
125
 
120
126
  | Component | Use |
121
127
  |-----------|-----|
122
- | **PublicLayout** | Marketing / docs — slots: **`navbar`**, **`footer`**, **`contentTopSpacing`**, **`contentBottomSpacing`** |
123
- | **PublicNavbar** / **PublicFooter** | **`PublicNavbarConfig`** / **`PublicFooterConfig`** — **`shell.rounding`**, **`navbarVariant`**, **`navbarPosition`**, etc. |
128
+ | **PublicLayout** | Marketing / docs — slots: **`navbar`**, **`footer`**, **`backgroundSlot`**, **`contentTopSpacing`**, **`contentBottomSpacing`** |
129
+ | **PublicNavbar** / **PublicFooter** | See `PublicNavbarConfig` below |
124
130
  | **PrivateLayout** | App shell — sidebar + header |
125
131
  | **AuthLayout** | Sign-in flows |
126
132
  | **AdminLayout** | Admin console |
127
133
  | **ProfileLayout** | Profile page with avatar, editable fields, 2FA, and slot/tab system |
128
134
 
135
+ **`PublicLayout` — `backgroundSlot`**
136
+
137
+ Pass any `ReactNode` as `backgroundSlot` to render a full-viewport layer *behind* the sticky navbar and all page content — without affecting layout flow.
138
+ The recommended pattern is `fixed inset-0 -z-10 pointer-events-none`:
139
+
140
+ ```tsx
141
+ <PublicLayout
142
+ contentTopSpacing="none"
143
+ contentBottomSpacing="none"
144
+ backgroundSlot={
145
+ <div
146
+ className="pointer-events-none fixed inset-0 -z-10"
147
+ style={{
148
+ background:
149
+ 'radial-gradient(ellipse 55% 50% at 10% 0%, rgba(139,92,246,0.13) 0%, transparent 55%),' +
150
+ 'radial-gradient(ellipse 40% 30% at 85% 75%, rgba(6,182,212,0.06) 0%, transparent 55%)',
151
+ }}
152
+ />
153
+ }
154
+ navbar={<PublicNavbar config={…} />}
155
+ >
156
+ <HeroSection />
157
+ </PublicLayout>
158
+ ```
159
+
160
+ Because the element is `fixed`, it covers the full viewport including the sticky navbar area, and `backdrop-blur` on the navbar lets the gradient show through.
161
+
162
+ ---
163
+
129
164
  **Brand:** `ThemeBrandMark` / **`ThemeBrandMarkImg`** for logo slots.
130
165
 
166
+ ### PublicNavbar — layout variants & scroll behaviour
167
+
168
+ ```tsx
169
+ <PublicNavbar config={{
170
+ brand: <Logo />,
171
+ navigation: navItems,
172
+ userMenu,
173
+
174
+ // Visual
175
+ navbarVariant: 'floating', // 'floating' | 'flush'
176
+ navbarPosition: 'sticky', // 'sticky' | 'fixed' | 'static'
177
+ navbarHeight: 'md', // 'sm' | 'md' | 'lg'
178
+
179
+ // Desktop nav arrangement
180
+ navLayout: 'default',
181
+ // 'default' → brand left | nav centered (absolute) | actions right
182
+ // 'brand-left' → brand left | nav after brand | actions pushed right
183
+ // 'centered' → brand + nav + actions all centered in one row
184
+ // 'split' → brand left | actions right | no desktop nav (drawer only)
185
+
186
+ // Scroll behaviour
187
+ hideNavOnScroll: true, // slide up on scroll-down, back on scroll-up
188
+ transparent: true, // transparent at page top, opaque after threshold
189
+ transparentThreshold: 40, // px, default 40
190
+ }} />
191
+ ```
192
+
193
+ **Exported hooks** (for custom navbars):
194
+
195
+ | Hook | Role |
196
+ |------|------|
197
+ | `useNavbarScroll(opts)` | Returns `{ hidden, scrolled }` driven by scroll position |
198
+ | `useDropdownMenu()` | Hover open/close timers for desktop dropdowns |
199
+ | `useNavbarViewportVars(ref, deps)` | Sets `--public-navbar-mobile-drawer-top/max-height` CSS vars |
200
+
131
201
  ### ProfileLayout — slots & tabs
132
202
 
133
203
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.266",
3
+ "version": "2.1.267",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -74,14 +74,14 @@
74
74
  "check": "tsc --noEmit"
75
75
  },
76
76
  "peerDependencies": {
77
- "@djangocfg/api": "^2.1.266",
78
- "@djangocfg/centrifugo": "^2.1.266",
79
- "@djangocfg/i18n": "^2.1.266",
80
- "@djangocfg/monitor": "^2.1.266",
81
- "@djangocfg/debuger": "^2.1.266",
82
- "@djangocfg/ui-core": "^2.1.266",
83
- "@djangocfg/ui-nextjs": "^2.1.266",
84
- "@djangocfg/ui-tools": "^2.1.266",
77
+ "@djangocfg/api": "^2.1.267",
78
+ "@djangocfg/centrifugo": "^2.1.267",
79
+ "@djangocfg/debuger": "^2.1.267",
80
+ "@djangocfg/i18n": "^2.1.267",
81
+ "@djangocfg/monitor": "^2.1.267",
82
+ "@djangocfg/ui-core": "^2.1.267",
83
+ "@djangocfg/ui-nextjs": "^2.1.267",
84
+ "@djangocfg/ui-tools": "^2.1.267",
85
85
  "@hookform/resolvers": "^5.2.2",
86
86
  "consola": "^3.4.2",
87
87
  "lucide-react": "^0.545.0",
@@ -110,15 +110,15 @@
110
110
  "uuid": "^11.1.0"
111
111
  },
112
112
  "devDependencies": {
113
- "@djangocfg/api": "^2.1.266",
114
- "@djangocfg/i18n": "^2.1.266",
115
- "@djangocfg/centrifugo": "^2.1.266",
116
- "@djangocfg/monitor": "^2.1.266",
117
- "@djangocfg/debuger": "^2.1.266",
118
- "@djangocfg/typescript-config": "^2.1.266",
119
- "@djangocfg/ui-core": "^2.1.266",
120
- "@djangocfg/ui-nextjs": "^2.1.266",
121
- "@djangocfg/ui-tools": "^2.1.266",
113
+ "@djangocfg/api": "^2.1.267",
114
+ "@djangocfg/centrifugo": "^2.1.267",
115
+ "@djangocfg/debuger": "^2.1.267",
116
+ "@djangocfg/i18n": "^2.1.267",
117
+ "@djangocfg/monitor": "^2.1.267",
118
+ "@djangocfg/typescript-config": "^2.1.267",
119
+ "@djangocfg/ui-core": "^2.1.267",
120
+ "@djangocfg/ui-nextjs": "^2.1.267",
121
+ "@djangocfg/ui-tools": "^2.1.267",
122
122
  "@types/node": "^24.7.2",
123
123
  "@types/react": "^19.1.0",
124
124
  "@types/react-dom": "^19.1.0",
@@ -1,2 +1,2 @@
1
- export { usePathnameWithoutLocale, stripLocalePrefix } from './usePathnameWithoutLocale';
1
+ export { usePathnameWithoutLocale } from './usePathnameWithoutLocale';
2
2
  export { useLogout } from './useLogout';
@@ -4,36 +4,52 @@ import { usePathname } from 'next/navigation';
4
4
  import { useMemo } from 'react';
5
5
 
6
6
  /**
7
- * Strip locale prefix from pathname
8
- * Handles: /xx/path, /xx-XX/path (e.g., /en, /ko, /pt-BR)
7
+ * Normalize pathname: remove trailing slash except for root "/".
8
+ * Works regardless of Next.js trailingSlash config.
9
9
  */
10
- function stripLocalePrefix(pathname: string): string {
11
- // Match /xx or /xx-XX at the start
12
- const match = pathname.match(/^\/[a-z]{2}(-[A-Z]{2})?(\/|$)/);
13
-
14
- if (match) {
15
- const rest = pathname.slice(match[0].length - 1) || '/';
16
- return rest.startsWith('/') ? rest : '/' + rest;
17
- }
10
+ function normalize(p: string): string {
11
+ return p.length > 1 ? p.replace(/\/+$/, '') : p;
12
+ }
18
13
 
14
+ /**
15
+ * Strip locale prefix from pathname using known locale string.
16
+ */
17
+ function stripLocale(pathname: string, locale: string): string {
18
+ const prefix = '/' + locale;
19
+ if (pathname === prefix || pathname === prefix + '/') return '/';
20
+ if (pathname.startsWith(prefix + '/')) return pathname.slice(prefix.length);
21
+ // Path already has no locale prefix (next-intl localePrefix:'as-needed' for default locale)
19
22
  return pathname;
20
23
  }
21
24
 
22
25
  /**
23
26
  * usePathnameWithoutLocale
24
27
  *
25
- * Returns pathname without locale prefix.
26
- * Useful for route matching when using next-intl or similar i18n routing.
28
+ * Returns the current pathname without locale prefix, normalized (no trailing slash).
29
+ *
30
+ * Pass `locale` (from i18n config / useLocaleSwitcher) for exact stripping.
31
+ * Without locale, falls back to regex — known to misfire on 2-letter path segments like /ui.
27
32
  *
28
33
  * @example
29
- * // URL: /ko/private/dashboard
30
- * const pathname = usePathnameWithoutLocale();
31
- * // pathname = '/private/dashboard'
34
+ * // URL: /en/ui/playground, locale="en" → "/ui/playground"
35
+ * // URL: /ui/playground/ (no prefix) → "/ui/playground"
32
36
  */
33
- export function usePathnameWithoutLocale(): string {
37
+ export function usePathnameWithoutLocale(locale?: string): string {
34
38
  const pathname = usePathname();
35
39
 
36
- return useMemo(() => stripLocalePrefix(pathname), [pathname]);
37
- }
40
+ return useMemo(() => {
41
+ if (locale) {
42
+ return normalize(stripLocale(pathname, locale));
43
+ }
38
44
 
39
- export { stripLocalePrefix };
45
+ // Fallback regex — only when locale is unknown
46
+ const match = pathname.match(/^\/[a-z]{2}(-[A-Z]{2})?(\/|$)/);
47
+ if (match) {
48
+ const rest = pathname.slice(match[0].length - 1) || '/';
49
+ const withSlash = rest.startsWith('/') ? rest : '/' + rest;
50
+ return normalize(withSlash);
51
+ }
52
+
53
+ return normalize(pathname);
54
+ }, [pathname, locale]);
55
+ }
@@ -75,6 +75,14 @@ export interface AppLayoutPublicChrome {
75
75
  topSpacing?: PublicMainTopSpacing;
76
76
  bottomSpacing?: PublicMainBottomSpacing;
77
77
  };
78
+ /**
79
+ * Full-viewport background layer rendered behind the navbar and all page content.
80
+ * Pass a `fixed inset-0 -z-10 pointer-events-none` element — it covers the whole
81
+ * viewport (including the sticky navbar area) without affecting layout flow.
82
+ *
83
+ * Set per-page via `AppLayout publicChrome` or directly on `PublicSiteLayout`.
84
+ */
85
+ backgroundSlot?: ReactNode;
78
86
  }
79
87
 
80
88
  function mergePartialNavbar(
@@ -120,8 +128,10 @@ export function mergeAppLayoutPublicChrome(
120
128
  root?.main || fromLayouts?.main
121
129
  ? { ...root?.main, ...fromLayouts?.main }
122
130
  : undefined;
123
- if (!navbar && !footer && !main) return undefined;
124
- return { navbar, footer, ...(main ? { main } : {}) };
131
+ // backgroundSlot: layouts-level overrides root (more specific wins)
132
+ const backgroundSlot = fromLayouts?.backgroundSlot ?? root?.backgroundSlot;
133
+ if (!navbar && !footer && !main && !backgroundSlot) return undefined;
134
+ return { navbar, footer, ...(main ? { main } : {}), ...(backgroundSlot ? { backgroundSlot } : {}) };
125
135
  }
126
136
 
127
137
  /**
@@ -291,8 +301,9 @@ function AppLayoutContent({
291
301
  i18n,
292
302
  publicChrome,
293
303
  }: AppLayoutContentProps) {
294
- // Use pathname without locale prefix for route matching
295
- const pathname = usePathnameWithoutLocale();
304
+ // Use pathname without locale prefix for route matching.
305
+ // Pass locale from i18n so strip is exact — avoids regex misfiring on /ui, /ko, etc.
306
+ const pathname = usePathnameWithoutLocale(i18n?.locale);
296
307
 
297
308
  // Merge authPath into noLayoutPaths — auth pages are always fullscreen
298
309
  const effectiveNoLayoutPaths = useMemo(() => {
@@ -2,9 +2,10 @@
2
2
 
3
3
  import { LogOut, MoreHorizontal, Trash2 } from 'lucide-react';
4
4
  import moment from 'moment';
5
- import React, { useEffect } from 'react';
5
+ import React, { useCallback, useEffect } from 'react';
6
6
 
7
- import { useAuth } from '@djangocfg/api/auth';
7
+ import { useAuth, useDeleteAccount } from '@djangocfg/api/auth';
8
+ import { useAppT } from '@djangocfg/i18n';
8
9
  import {
9
10
  Button,
10
11
  DropdownMenu,
@@ -21,7 +22,6 @@ import {
21
22
 
22
23
  import {
23
24
  AvatarSection,
24
- DeleteAccountScreen,
25
25
  EditableField,
26
26
  Section,
27
27
  TwoFactorSection,
@@ -71,8 +71,25 @@ function ProfileHeader({ slots, enableDeleteAccount }: {
71
71
  slots?: ProfileSlots;
72
72
  enableDeleteAccount?: boolean;
73
73
  }) {
74
- const { labels, onLogout, setStep } = useProfileContext();
75
- const { user } = useAuth();
74
+ const { labels, onLogout } = useProfileContext();
75
+ const { user, logout } = useAuth();
76
+ const { deleteAccount } = useDeleteAccount();
77
+ const t = useAppT();
78
+
79
+ const handleDeleteAccount = useCallback(async () => {
80
+ const confirmationWord = t('layouts.profilePage.confirmationWord');
81
+ const value = await window.dialog.prompt({
82
+ title: t('layouts.profilePage.deleteAccountTitle'),
83
+ message: t('layouts.profilePage.deleteAccountDesc'),
84
+ placeholder: confirmationWord,
85
+ confirmText: t('layouts.profilePage.deleteAccount'),
86
+ cancelText: t('layouts.profilePage.cancel'),
87
+ variant: 'destructive',
88
+ });
89
+ if (value?.toUpperCase() !== confirmationWord.toUpperCase()) return;
90
+ const result = await deleteAccount();
91
+ if (result.success) logout();
92
+ }, [t, deleteAccount, logout]);
76
93
 
77
94
  if (!user) return null;
78
95
 
@@ -121,7 +138,7 @@ function ProfileHeader({ slots, enableDeleteAccount }: {
121
138
  <>
122
139
  <DropdownMenuSeparator />
123
140
  <DropdownMenuItem
124
- onClick={() => setStep('delete-account')}
141
+ onClick={handleDeleteAccount}
125
142
  className="gap-2 text-destructive focus:text-destructive"
126
143
  >
127
144
  <Trash2 className="w-4 h-4" />
@@ -260,14 +277,8 @@ function ProfileContent({
260
277
  // Router + Export
261
278
  // ─────────────────────────────────────────────────────────────────────────────
262
279
 
263
- function ProfileRouter(props: ProfileLayoutProps) {
264
- const { step } = useProfileContext();
265
- if (step === 'delete-account') return <DeleteAccountScreen />;
266
- return <ProfileContent {...props} />;
267
- }
268
-
269
280
  export const ProfileLayout: React.FC<ProfileLayoutProps> = ({ title, ...props }) => (
270
281
  <ProfileProvider title={title}>
271
- <ProfileRouter title={title} {...props} />
282
+ <ProfileContent title={title} {...props} />
272
283
  </ProfileProvider>
273
284
  );
@@ -1,128 +1,44 @@
1
1
  'use client';
2
2
 
3
- import { AlertTriangle, ChevronLeft, Loader2, Trash2 } from 'lucide-react';
4
- import React, { useMemo, useState } from 'react';
3
+ import { Trash2 } from 'lucide-react';
4
+ import React from 'react';
5
5
 
6
6
  import { useAuth, useDeleteAccount } from '@djangocfg/api/auth';
7
7
  import { useAppT } from '@djangocfg/i18n';
8
- import { Alert, AlertDescription, Button, Input } from '@djangocfg/ui-core/components';
9
8
 
10
- import { useProfileContext } from '../context';
11
9
  import { ActionButton } from './ActionButton';
12
10
 
13
- // ─── Entry point: single action row on main screen ───────────────────────────
14
-
15
11
  export const DeleteAccountSection: React.FC = () => {
16
- const { setStep } = useProfileContext();
17
- const t = useAppT();
18
-
19
- return (
20
- <ActionButton
21
- icon={<Trash2 className="w-4 h-4 text-destructive" />}
22
- label={<span className="text-destructive">{t('layouts.profilePage.deleteAccount')}</span>}
23
- onClick={() => setStep('delete-account')}
24
- />
25
- );
26
- };
27
-
28
- // ─── Full delete screen ───────────────────────────────────────────────────────
29
-
30
- export const DeleteAccountScreen: React.FC = () => {
31
- const { back } = useProfileContext();
32
12
  const { logout } = useAuth();
33
- const [confirmationInput, setConfirmationInput] = useState('');
13
+ const { deleteAccount } = useDeleteAccount();
34
14
  const t = useAppT();
35
15
 
36
- const { isLoading, error, deleteAccount, clearError } = useDeleteAccount();
37
-
38
16
  const confirmationWord = t('layouts.profilePage.confirmationWord');
39
- const isValid = confirmationInput.toUpperCase() === confirmationWord.toUpperCase();
40
17
 
41
- const labels = useMemo(() => ({
42
- back: t('layouts.navigation.back'),
43
- title: t('layouts.profilePage.deleteAccountTitle'),
44
- desc: t('layouts.profilePage.deleteAccountDesc'),
45
- typeToConfirm: t('layouts.profilePage.typeToConfirm').replace('{word}', confirmationWord),
46
- deleting: t('layouts.profilePage.deleting'),
47
- deleteAccount: t('layouts.profilePage.deleteAccount'),
48
- cancel: t('layouts.profilePage.cancel'),
49
- }), [t, confirmationWord]);
18
+ const handleClick = async () => {
19
+ const value = await window.dialog.prompt({
20
+ title: t('layouts.profilePage.deleteAccountTitle'),
21
+ message: t('layouts.profilePage.deleteAccountDesc'),
22
+ placeholder: confirmationWord,
23
+ confirmText: t('layouts.profilePage.deleteAccount'),
24
+ cancelText: t('layouts.profilePage.cancel'),
25
+ variant: 'destructive',
26
+ });
27
+
28
+ if (value?.toUpperCase() !== confirmationWord.toUpperCase()) return;
50
29
 
51
- const handleDelete = async () => {
52
- clearError();
53
30
  const result = await deleteAccount();
54
31
  if (result.success) logout();
55
32
  };
56
33
 
57
- const handleBack = () => {
58
- clearError();
59
- setConfirmationInput('');
60
- back();
61
- };
62
-
63
34
  return (
64
- <div className="container mx-auto px-4 py-12 max-w-md">
65
- {/* Back */}
66
- <button
67
- type="button"
68
- onClick={handleBack}
69
- className="flex items-center gap-1 text-sm text-muted-foreground mb-8 hover:text-foreground transition-colors"
70
- >
71
- <ChevronLeft className="w-4 h-4" />
72
- {labels.back}
73
- </button>
74
-
75
- {/* Header */}
76
- <div className="flex items-center gap-3 mb-2">
77
- <div className="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
78
- <AlertTriangle className="w-5 h-5 text-destructive" />
79
- </div>
80
- <h1 className="text-xl font-semibold">{labels.title}</h1>
81
- </div>
82
- <p className="text-sm text-muted-foreground mb-8">{labels.desc}</p>
83
-
84
- {/* Confirmation */}
85
- <div className="space-y-3">
86
- {error && (
87
- <Alert variant="destructive">
88
- <AlertDescription>{error}</AlertDescription>
89
- </Alert>
90
- )}
91
-
92
- <p className="text-sm text-muted-foreground">{labels.typeToConfirm}</p>
93
-
94
- <Input
95
- value={confirmationInput}
96
- onChange={(e) => setConfirmationInput(e.target.value)}
97
- placeholder={confirmationWord}
98
- disabled={isLoading}
99
- autoFocus
100
- autoComplete="off"
101
- className="font-mono"
102
- />
103
-
104
- <Button
105
- variant="destructive"
106
- className="w-full"
107
- onClick={handleDelete}
108
- disabled={isLoading || !isValid}
109
- >
110
- {isLoading ? (
111
- <><Loader2 className="mr-2 h-4 w-4 animate-spin" />{labels.deleting}</>
112
- ) : (
113
- labels.deleteAccount
114
- )}
115
- </Button>
116
-
117
- <Button
118
- variant="ghost"
119
- className="w-full"
120
- onClick={handleBack}
121
- disabled={isLoading}
122
- >
123
- {labels.cancel}
124
- </Button>
125
- </div>
126
- </div>
35
+ <ActionButton
36
+ icon={<Trash2 className="w-4 h-4 text-destructive" />}
37
+ label={<span className="text-destructive">{t('layouts.profilePage.deleteAccount')}</span>}
38
+ onClick={handleClick}
39
+ />
127
40
  );
128
41
  };
42
+
43
+ // Keep export so nothing breaks — no longer used but exported for backwards compat
44
+ export const DeleteAccountScreen: React.FC = () => null;
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
3
+ import React, { createContext, useCallback, useContext, useMemo } from 'react';
4
4
 
5
5
  import { useAppT } from '@djangocfg/i18n';
6
6
  import { toast } from '@djangocfg/ui-core/hooks';
@@ -13,8 +13,6 @@ import { useLogout } from '../../hooks';
13
13
  // Types
14
14
  // ─────────────────────────────────────────────────────────────────────────────
15
15
 
16
- export type ProfileStep = 'main' | 'delete-account';
17
-
18
16
  export interface ProfileLabels {
19
17
  title: string;
20
18
  personalInfo: string;
@@ -43,9 +41,6 @@ export interface ProfileLabels {
43
41
  }
44
42
 
45
43
  interface ProfileContextValue {
46
- step: ProfileStep;
47
- setStep: (step: ProfileStep) => void;
48
- back: () => void;
49
44
  labels: ProfileLabels;
50
45
  onLogout: () => void;
51
46
  onFieldSave: (field: string, value: string) => Promise<void>;
@@ -77,13 +72,10 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({
77
72
  children,
78
73
  title,
79
74
  }) => {
80
- const [step, setStep] = useState<ProfileStep>('main');
81
75
  const { updateProfile } = useAuth();
82
76
  const t = useAppT();
83
77
  const onLogout = useLogout();
84
78
 
85
- const back = useCallback(() => setStep('main'), []);
86
-
87
79
  const labels = useMemo<ProfileLabels>(() => ({
88
80
  title: title || t('layouts.profilePage.title'),
89
81
  personalInfo: t('layouts.profilePage.personalInfo'),
@@ -124,7 +116,7 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({
124
116
  }, [updateProfile, labels]);
125
117
 
126
118
  return (
127
- <ProfileContext.Provider value={{ step, setStep, back, labels, onLogout, onFieldSave }}>
119
+ <ProfileContext.Provider value={{ labels, onLogout, onFieldSave }}>
128
120
  {children}
129
121
  </ProfileContext.Provider>
130
122
  );
@@ -53,6 +53,20 @@ export interface PublicLayoutProps {
53
53
  */
54
54
  navbar?: ReactNode;
55
55
  footer?: ReactNode;
56
+ /**
57
+ * Optional background layer rendered behind navbar and content.
58
+ * Use `position: fixed; inset: 0; pointer-events: none` (or similar) on the element
59
+ * so it fills the viewport without affecting layout flow.
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * backgroundSlot={
64
+ * <div className="fixed inset-0 -z-10 pointer-events-none"
65
+ * style={{ background: 'radial-gradient(ellipse 60% 50% at 10% 0%, violet, transparent)' }} />
66
+ * }
67
+ * ```
68
+ */
69
+ backgroundSlot?: ReactNode;
56
70
  /**
57
71
  * When `auto` (default), `<main>` gets a small top offset from `PublicNavigation` surface
58
72
  * (`floating` vs `flush`). Set `none` if the page controls spacing itself.
@@ -105,6 +119,7 @@ export function PublicLayout({
105
119
  children,
106
120
  navbar,
107
121
  footer,
122
+ backgroundSlot,
108
123
  contentTopSpacing = 'auto',
109
124
  contentBottomSpacing = 'auto',
110
125
  }: PublicLayoutProps) {
@@ -134,6 +149,9 @@ export function PublicLayout({
134
149
 
135
150
  return (
136
151
  <PublicLayoutProvider value={contextValue}>
152
+ {/* Background slot — renders behind everything, including the sticky navbar */}
153
+ {backgroundSlot ?? null}
154
+
137
155
  <div className="min-h-screen flex flex-col">
138
156
  {navbar ?? null}
139
157
 
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { Menu, X } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ import { Button } from '@djangocfg/ui-core/components';
7
+
8
+ import { UserMenu } from '../../_components/UserMenu';
9
+ import type { UserMenuConfig } from '../../types';
10
+
11
+ interface NavActionsProps {
12
+ userMenu?: UserMenuConfig;
13
+ mobileMenuOpen: boolean;
14
+ onMobileMenuToggle: () => void;
15
+ toggleMobileLabel: string;
16
+ /** When true, mobile trigger is always visible (not hidden on lg+). Used for `split` layout. */
17
+ forceShowMobileTrigger?: boolean;
18
+ }
19
+
20
+ export function NavActions({
21
+ userMenu,
22
+ mobileMenuOpen,
23
+ onMobileMenuToggle,
24
+ toggleMobileLabel,
25
+ forceShowMobileTrigger = false,
26
+ }: NavActionsProps) {
27
+ return (
28
+ <div className="flex items-center gap-4">
29
+ <div className="hidden lg:flex">
30
+ <UserMenu
31
+ variant="desktop"
32
+ groups={userMenu?.groups}
33
+ authPath={userMenu?.authPath}
34
+ i18n={userMenu?.i18n}
35
+ />
36
+ </div>
37
+
38
+ <Button
39
+ variant="ghost"
40
+ size="icon"
41
+ aria-label={toggleMobileLabel}
42
+ data-mobile-menu-trigger="true"
43
+ className={forceShowMobileTrigger ? 'rounded-full' : 'lg:hidden rounded-full'}
44
+ onClick={onMobileMenuToggle}
45
+ >
46
+ {mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
47
+ </Button>
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import React, { type ReactNode } from 'react';
5
+
6
+ interface NavBrandProps {
7
+ brand?: ReactNode;
8
+ brandHref?: string;
9
+ }
10
+
11
+ export function NavBrand({ brand, brandHref = '/' }: NavBrandProps) {
12
+ if (brand == null || brand === '' || brand === false) return null;
13
+
14
+ if (typeof brand === 'string') {
15
+ return (
16
+ <Link
17
+ href={brandHref}
18
+ className="font-bold text-[15px] text-foreground hover:opacity-90 transition-opacity"
19
+ >
20
+ {brand}
21
+ </Link>
22
+ );
23
+ }
24
+
25
+ return <>{brand}</>;
26
+ }