@djangocfg/layouts 2.1.264 → 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.
Files changed (38) hide show
  1. package/README.md +113 -4
  2. package/package.json +19 -18
  3. package/src/hooks/index.ts +1 -1
  4. package/src/hooks/usePathnameWithoutLocale.ts +35 -19
  5. package/src/layouts/AppLayout/AppLayout.tsx +15 -4
  6. package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +50 -6
  7. package/src/layouts/ProfileLayout/ProfileLayout.tsx +206 -235
  8. package/src/layouts/ProfileLayout/components/AvatarSection.tsx +2 -3
  9. package/src/layouts/ProfileLayout/components/DeleteAccountSection.tsx +22 -106
  10. package/src/layouts/ProfileLayout/components/EditableField.tsx +15 -10
  11. package/src/layouts/ProfileLayout/components/Section.tsx +1 -1
  12. package/src/layouts/ProfileLayout/components/TwoFactorSection.tsx +255 -215
  13. package/src/layouts/ProfileLayout/context.tsx +108 -16
  14. package/src/layouts/ProfileLayout/index.ts +1 -1
  15. package/src/layouts/PublicLayout/PublicLayout.tsx +18 -0
  16. package/src/layouts/PublicLayout/components/NavActions.tsx +50 -0
  17. package/src/layouts/PublicLayout/components/NavBrand.tsx +26 -0
  18. package/src/layouts/PublicLayout/components/NavDesktopItems.tsx +207 -0
  19. package/src/layouts/PublicLayout/components/PublicMobileDrawer.tsx +1 -1
  20. package/src/layouts/PublicLayout/components/PublicNavbar.tsx +44 -6
  21. package/src/layouts/PublicLayout/components/PublicNavigation.tsx +199 -396
  22. package/src/layouts/PublicLayout/hooks/index.ts +5 -1
  23. package/src/layouts/PublicLayout/hooks/useDropdownMenu.ts +58 -0
  24. package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +61 -0
  25. package/src/layouts/PublicLayout/hooks/useNavbarViewportVars.ts +46 -0
  26. package/src/layouts/PublicLayout/index.ts +4 -0
  27. package/src/layouts/PublicLayout/navbarTypes.ts +17 -0
  28. package/src/utils/pathMatcher.ts +6 -3
  29. package/src/layouts/ProfileLayout/.claude/.sidecar/activity.jsonl +0 -2
  30. package/src/layouts/ProfileLayout/.claude/.sidecar/history/2026-03-15.md +0 -35
  31. package/src/layouts/ProfileLayout/.claude/.sidecar/review.md +0 -35
  32. package/src/layouts/ProfileLayout/.claude/.sidecar/scan.log +0 -3
  33. package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-001.md +0 -18
  34. package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-002.md +0 -19
  35. package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-003.md +0 -18
  36. package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-004.md +0 -18
  37. package/src/layouts/ProfileLayout/.claude/.sidecar/tasks/T-005.md +0 -18
  38. package/src/layouts/ProfileLayout/.claude/.sidecar/usage.json +0 -5
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,130 @@ 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
- | **ProfileLayout** | Profile + 2FA |
133
+ | **ProfileLayout** | Profile page with avatar, editable fields, 2FA, and slot/tab system |
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
+ ---
128
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
+
201
+ ### ProfileLayout — slots & tabs
202
+
203
+ ```tsx
204
+ import { ProfileLayout } from '@djangocfg/layouts';
205
+ import type { ProfileTab, ProfileSlots } from '@djangocfg/layouts';
206
+
207
+ const tabs: ProfileTab[] = [
208
+ {
209
+ value: 'billing',
210
+ label: 'Billing',
211
+ content: <BillingSection />,
212
+ },
213
+ ];
214
+
215
+ const slots: ProfileSlots = {
216
+ headerBadge: <Badge>Pro</Badge>, // next to user name
217
+ headerMenuItems: <DropdownMenuItem>…</DropdownMenuItem>, // in ⋯ menu
218
+ headerAfter: <OnboardingBanner />, // below avatar, above tabs
219
+ footer: <LinkedAccounts />, // below all tab content
220
+ };
221
+
222
+ <ProfileLayout
223
+ enable2FA
224
+ enableDeleteAccount
225
+ tabs={tabs}
226
+ slots={slots}
227
+ />
228
+ ```
229
+
230
+ | Prop | Type | Description |
231
+ |------|------|-------------|
232
+ | `enable2FA` | `boolean` | Show Security tab with 2FA management |
233
+ | `enableDeleteAccount` | `boolean` | Show Delete account in `⋯` menu |
234
+ | `tabs` | `ProfileTab[]` | Extra tabs appended after built-in ones |
235
+ | `slots.headerBadge` | `ReactNode` | Rendered next to the user name (plan, role…) |
236
+ | `slots.headerMenuItems` | `ReactNode` | Extra `DropdownMenuItem`s in the `⋯` menu |
237
+ | `slots.headerAfter` | `ReactNode` | Below avatar row, above tabs |
238
+ | `slots.footer` | `ReactNode` | Below all tab content |
239
+
131
240
  ---
132
241
 
133
242
  ## i18n on AppLayout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.264",
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.264",
78
- "@djangocfg/centrifugo": "^2.1.264",
79
- "@djangocfg/i18n": "^2.1.264",
80
- "@djangocfg/monitor": "^2.1.264",
81
- "@djangocfg/debuger": "^2.1.264",
82
- "@djangocfg/ui-core": "^2.1.264",
83
- "@djangocfg/ui-nextjs": "^2.1.264",
84
- "@djangocfg/ui-tools": "^2.1.264",
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",
@@ -103,21 +103,22 @@
103
103
  }
104
104
  },
105
105
  "dependencies": {
106
+ "libphonenumber-js": "^1.12.24",
106
107
  "nextjs-toploader": "^3.9.17",
107
108
  "qrcode.react": "^4.2.0",
108
109
  "react-ga4": "^2.1.0",
109
110
  "uuid": "^11.1.0"
110
111
  },
111
112
  "devDependencies": {
112
- "@djangocfg/api": "^2.1.264",
113
- "@djangocfg/i18n": "^2.1.264",
114
- "@djangocfg/centrifugo": "^2.1.264",
115
- "@djangocfg/monitor": "^2.1.264",
116
- "@djangocfg/debuger": "^2.1.264",
117
- "@djangocfg/typescript-config": "^2.1.264",
118
- "@djangocfg/ui-core": "^2.1.264",
119
- "@djangocfg/ui-nextjs": "^2.1.264",
120
- "@djangocfg/ui-tools": "^2.1.264",
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",
121
122
  "@types/node": "^24.7.2",
122
123
  "@types/react": "^19.1.0",
123
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(() => {
@@ -15,12 +15,8 @@ export interface SetupStepProps {
15
15
  }
16
16
 
17
17
  /**
18
- * SetupStep - Orchestrator for 2FA setup flow
19
- *
20
- * Delegates rendering to focused sub-components:
21
- * - SetupLoading: Initial loading state
22
- * - SetupQRCode: QR code scanning
23
- * - SetupComplete: Backup codes display
18
+ * SetupStep - Orchestrator for 2FA setup flow (requires AuthFormContext).
19
+ * For use outside AuthLayout use SetupStepStandalone.
24
20
  */
25
21
  export const SetupStep: React.FC<SetupStepProps> = ({ onComplete, onSkip }) => {
26
22
  const { setStep } = useAuthFormContext();
@@ -89,3 +85,51 @@ export const SetupStep: React.FC<SetupStepProps> = ({ onComplete, onSkip }) => {
89
85
  // Fallback to loading
90
86
  return <SetupLoading />;
91
87
  };
88
+
89
+ /**
90
+ * SetupStepStandalone — same flow as SetupStep but without AuthFormContext.
91
+ * Use this inside ProfileLayout or any page outside AuthLayout.
92
+ */
93
+ export const SetupStepStandalone: React.FC<SetupStepProps> = ({ onComplete, onSkip }) => {
94
+ const {
95
+ isLoading,
96
+ error,
97
+ setupData,
98
+ backupCodes,
99
+ backupCodesWarning,
100
+ setupStep,
101
+ startSetup,
102
+ confirmSetup,
103
+ } = useTwoFactorSetup({ onComplete, onError: () => {} });
104
+
105
+ React.useEffect(() => {
106
+ if (setupStep === 'idle') startSetup();
107
+ }, [setupStep, startSetup]);
108
+
109
+ if (isLoading && !setupData) return <SetupLoading />;
110
+
111
+ if (setupStep === 'complete' && backupCodes) {
112
+ return (
113
+ <SetupComplete
114
+ backupCodes={backupCodes}
115
+ backupCodesWarning={backupCodesWarning}
116
+ onDone={() => onComplete?.()}
117
+ />
118
+ );
119
+ }
120
+
121
+ if (setupData) {
122
+ return (
123
+ <SetupQRCode
124
+ provisioningUri={setupData.provisioningUri}
125
+ secret={setupData.secret}
126
+ isLoading={isLoading}
127
+ error={error}
128
+ onConfirm={confirmSetup}
129
+ onSkip={onSkip}
130
+ />
131
+ );
132
+ }
133
+
134
+ return <SetupLoading />;
135
+ };