@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.592",
3
+ "version": "2.1.593",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -90,11 +90,11 @@
90
90
  "check": "tsc --noEmit"
91
91
  },
92
92
  "peerDependencies": {
93
- "@djangocfg/analytics": "^2.1.592",
94
- "@djangocfg/api": "^2.1.592",
95
- "@djangocfg/devtools": "^2.1.592",
96
- "@djangocfg/i18n": "^2.1.592",
97
- "@djangocfg/ui-core": "^2.1.592",
93
+ "@djangocfg/analytics": "^2.1.593",
94
+ "@djangocfg/api": "^2.1.593",
95
+ "@djangocfg/devtools": "^2.1.593",
96
+ "@djangocfg/i18n": "^2.1.593",
97
+ "@djangocfg/ui-core": "^2.1.593",
98
98
  "@hookform/resolvers": "^5.2.2",
99
99
  "consola": "^3.4.2",
100
100
  "lucide-react": "^0.545.0",
@@ -124,12 +124,12 @@
124
124
  "uuid": "^11.1.1"
125
125
  },
126
126
  "devDependencies": {
127
- "@djangocfg/analytics": "^2.1.592",
128
- "@djangocfg/api": "^2.1.592",
129
- "@djangocfg/devtools": "^2.1.592",
130
- "@djangocfg/i18n": "^2.1.592",
131
- "@djangocfg/typescript-config": "^2.1.592",
132
- "@djangocfg/ui-core": "^2.1.592",
127
+ "@djangocfg/analytics": "^2.1.593",
128
+ "@djangocfg/api": "^2.1.593",
129
+ "@djangocfg/devtools": "^2.1.593",
130
+ "@djangocfg/i18n": "^2.1.593",
131
+ "@djangocfg/typescript-config": "^2.1.593",
132
+ "@djangocfg/ui-core": "^2.1.593",
133
133
  "@storybook/react-vite": "^10.5.0",
134
134
  "@types/node": "^25.9.5",
135
135
  "@types/react": "19.2.15",
@@ -23,6 +23,7 @@ import { Button } from '@djangocfg/ui-core/components';
23
23
  import { useAppT } from '@djangocfg/i18n';
24
24
 
25
25
  import { getErrorContent } from './errorConfig';
26
+ import { getErrorIcon } from './errorGlyphs';
26
27
 
27
28
  export interface ErrorLayoutProps {
28
29
  /** Error code (e.g., "404", "500", "403") - if provided, auto-configures title/description/icon */
@@ -69,170 +70,6 @@ export interface ErrorLayoutProps {
69
70
  * weight comes from a soft radial glow on the wrapper, not the icon itself.
70
71
  */
71
72
 
72
- interface GlyphSpec {
73
- /** Accent tint used for the glow and the gradient stop. */
74
- accent: string;
75
- /** viewBox for the inner SVG. Default 24 24. */
76
- viewBox?: string;
77
- /** Inner SVG children. Must use `currentColor` for stroke. */
78
- paths: React.ReactNode;
79
- }
80
-
81
- const GLYPH_SPECS: Record<string, GlyphSpec> = {
82
- // 404 — magnifier over a page corner (not-found)
83
- '404': {
84
- accent: 'rgb(139 92 246)', // violet-500
85
- paths: (
86
- <>
87
- <path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7.5" />
88
- <path d="M13.5 3 19 8.5" />
89
- <path d="M13 3v4a2 2 0 0 0 2 2h4" />
90
- <circle cx="11" cy="14.5" r="2.5" />
91
- <path d="m13 16.5 2 2" />
92
- </>
93
- ),
94
- },
95
- // 500 — server stack with a warning bolt (server-side failure)
96
- '500': {
97
- accent: 'rgb(239 68 68)', // red-500
98
- paths: (
99
- <>
100
- <rect x="3" y="4" width="18" height="6" rx="1.5" />
101
- <rect x="3" y="14" width="18" height="6" rx="1.5" />
102
- <path d="M7 7h.01" />
103
- <path d="M7 17h.01" />
104
- <path d="m15 6-2 4h3l-2 4" strokeLinejoin="round" />
105
- </>
106
- ),
107
- },
108
- // 502 — two servers with a broken link between
109
- '502': {
110
- accent: 'rgb(249 115 22)', // orange-500
111
- paths: (
112
- <>
113
- <rect x="3" y="4" width="8" height="16" rx="1.5" />
114
- <rect x="13" y="4" width="8" height="16" rx="1.5" />
115
- <path d="M6 8h.01" />
116
- <path d="M16 8h.01" />
117
- <path d="M11 11.5h-1" />
118
- <path d="M14 12.5h-1" />
119
- <path d="m11 10 2 4" strokeLinejoin="round" />
120
- </>
121
- ),
122
- },
123
- // 503 — power off / plug pulled
124
- '503': {
125
- accent: 'rgb(249 115 22)', // orange-500
126
- paths: (
127
- <>
128
- <path d="M12 2v10" />
129
- <path d="M18.4 6.6a9 9 0 1 1-12.77 0" />
130
- </>
131
- ),
132
- },
133
- // 504 — clock with slash (gateway timeout)
134
- '504': {
135
- accent: 'rgb(234 179 8)', // yellow-500
136
- paths: (
137
- <>
138
- <circle cx="12" cy="12" r="9" />
139
- <path d="M12 7v5l3 2" />
140
- </>
141
- ),
142
- },
143
- // 408 — clock (request timeout)
144
- '408': {
145
- accent: 'rgb(234 179 8)', // yellow-500
146
- paths: (
147
- <>
148
- <circle cx="12" cy="12" r="9" />
149
- <path d="M12 7v5l3 2" />
150
- </>
151
- ),
152
- },
153
- // 403 — shield with a prohibition line (forbidden)
154
- '403': {
155
- accent: 'rgb(239 68 68)', // red-500
156
- paths: (
157
- <>
158
- <path d="M12 2 4 5v6c0 5 3.5 9 8 11 4.5-2 8-6 8-11V5l-8-3Z" />
159
- <path d="m9 9 6 6" strokeLinejoin="round" />
160
- </>
161
- ),
162
- },
163
- // 401 — key (auth required)
164
- '401': {
165
- accent: 'rgb(59 130 246)', // blue-500
166
- paths: (
167
- <>
168
- <circle cx="8" cy="15" r="4" />
169
- <path d="m10.8 12.2 8.2-8.2" />
170
- <path d="m17 5 3 3" />
171
- <path d="m15 7 2 2" />
172
- </>
173
- ),
174
- },
175
- // 400 — general alert triangle (bad request)
176
- '400': {
177
- accent: 'rgb(234 179 8)', // yellow-500
178
- paths: (
179
- <>
180
- <path d="m12 3 10 18H2L12 3Z" strokeLinejoin="round" />
181
- <path d="M12 9v5" />
182
- <path d="M12 17.5h.01" />
183
- </>
184
- ),
185
- },
186
- };
187
-
188
- const DEFAULT_GLYPH: GlyphSpec = {
189
- accent: 'rgb(113 113 122)', // zinc-500
190
- paths: (
191
- <>
192
- <circle cx="12" cy="12" r="9" />
193
- <path d="M12 8v4" />
194
- <path d="M12 16h.01" />
195
- </>
196
- ),
197
- };
198
-
199
- /**
200
- * Render the glyph for a given code, wrapped in a container with a soft radial
201
- * glow behind the icon. Returns `null` if nothing should be shown.
202
- */
203
- function getErrorIcon(code?: string | number): React.ReactNode {
204
- if (code === undefined || code === null || code === '') return null;
205
- const c = String(code);
206
- const spec = GLYPH_SPECS[c] ?? DEFAULT_GLYPH;
207
-
208
- return (
209
- <div
210
- className="relative mx-auto flex size-28 items-center justify-center"
211
- aria-hidden="true"
212
- >
213
- {/* soft radial glow */}
214
- <div
215
- className="pointer-events-none absolute inset-0 rounded-full blur-2xl opacity-60"
216
- style={{
217
- background: `radial-gradient(circle at 50% 50%, ${spec.accent} 0%, transparent 65%)`,
218
- }}
219
- />
220
- {/* icon */}
221
- <svg
222
- className="relative size-20"
223
- viewBox={spec.viewBox ?? '0 0 24 24'}
224
- fill="none"
225
- stroke="currentColor"
226
- strokeWidth={1.25}
227
- strokeLinecap="round"
228
- style={{ color: spec.accent }}
229
- >
230
- {spec.paths}
231
- </svg>
232
- </div>
233
- );
234
- }
235
-
236
73
  /**
237
74
  * ErrorLayout Component
238
75
  */
@@ -33,6 +33,7 @@ import React, {
33
33
  import { toast } from '@djangocfg/ui-core/hooks';
34
34
  import { useDebugMode, isProduction } from '@djangocfg/devtools';
35
35
 
36
+ import { generateErrorId, shouldShowError, shouldThrottleProdToast } from './errorDedupe';
36
37
  import { createErrorToast } from '../components/ErrorToast';
37
38
  import { generateShortErrorId, getProductionToast } from '../utils/formatters';
38
39
  import {
@@ -59,106 +60,6 @@ import type {
59
60
  } from '../types';
60
61
  const ErrorTrackingContext = createContext<ErrorTrackingContextValue | undefined>(undefined);
61
62
 
62
- /**
63
- * Generate unique ID for error
64
- */
65
- let errorIdCounter = 0;
66
- function generateErrorId(type: string): string {
67
- return `${type}-error-${Date.now()}-${++errorIdCounter}`;
68
- }
69
-
70
- /**
71
- * Generate deduplication key for error
72
- * Used to prevent duplicate toasts for the same error
73
- */
74
- function getErrorDedupeKey(detail: ErrorDetail): string {
75
- switch (detail.type) {
76
- case 'validation':
77
- return `validation:${detail.path}:${detail.method}`;
78
- case 'cors':
79
- return `cors:${detail.url}`;
80
- case 'network':
81
- return `network:${detail.url}:${detail.statusCode || 'unknown'}`;
82
- case 'runtime':
83
- return `runtime:${detail.source}:${detail.message.slice(0, 50)}`;
84
- default:
85
- return `unknown:${Date.now()}`;
86
- }
87
- }
88
-
89
- /** Deduplication window in ms - same errors within this window are ignored */
90
- const DEDUPE_WINDOW_MS = 5000;
91
-
92
- /** Max entries in deduplication map before forced cleanup */
93
- const DEDUPE_MAX_ENTRIES = 50;
94
-
95
- /** Map to track recent errors for deduplication (browser-only) */
96
- let recentErrors: Map<string, number> | null = null;
97
-
98
- function getRecentErrorsMap(): Map<string, number> {
99
- // Lazy init - only create in browser
100
- if (typeof window === 'undefined') {
101
- // Return dummy map for SSR (won't persist)
102
- return new Map();
103
- }
104
- if (!recentErrors) {
105
- recentErrors = new Map();
106
- }
107
- return recentErrors;
108
- }
109
-
110
- /**
111
- * Check if error should be shown (not a duplicate)
112
- * Returns true if error should be shown, false if it's a duplicate
113
- */
114
- function shouldShowError(detail: ErrorDetail): boolean {
115
- const map = getRecentErrorsMap();
116
- const key = getErrorDedupeKey(detail);
117
- const now = Date.now();
118
- const lastSeen = map.get(key);
119
-
120
- // Clean up old entries when map gets too large
121
- if (map.size > DEDUPE_MAX_ENTRIES) {
122
- for (const [k, timestamp] of map.entries()) {
123
- if (now - timestamp > DEDUPE_WINDOW_MS) {
124
- map.delete(k);
125
- }
126
- }
127
- // If still too large after cleanup, clear oldest half
128
- if (map.size > DEDUPE_MAX_ENTRIES) {
129
- const entries = Array.from(map.entries())
130
- .sort((a, b) => a[1] - b[1])
131
- .slice(0, Math.floor(map.size / 2));
132
- for (const [k] of entries) {
133
- map.delete(k);
134
- }
135
- }
136
- }
137
-
138
- // Check if we've seen this error recently
139
- if (lastSeen && now - lastSeen < DEDUPE_WINDOW_MS) {
140
- return false; // Duplicate, don't show
141
- }
142
-
143
- // Mark as seen
144
- map.set(key, now);
145
- return true;
146
- }
147
-
148
- /** Throttle production toasts — max 1 per type within this window */
149
- const PROD_TOAST_THROTTLE_MS = 3000;
150
- let lastProdToastByType: Map<string, number> | null = null;
151
-
152
- function shouldThrottleProdToast(type: string): boolean {
153
- if (typeof window === 'undefined') return false;
154
- if (!lastProdToastByType) lastProdToastByType = new Map();
155
- const now = Date.now();
156
- const last = lastProdToastByType.get(type);
157
- if (last && now - last < PROD_TOAST_THROTTLE_MS) return true;
158
- lastProdToastByType.set(type, now);
159
- return false;
160
- }
161
-
162
63
  export interface ErrorTrackingProviderProps {
163
64
  children: ReactNode;
164
65
  validation?: Partial<ValidationErrorConfig>;
@@ -0,0 +1,101 @@
1
+ import type { ErrorDetail } from '../types';
2
+
3
+ /**
4
+ * Generate unique ID for error
5
+ */
6
+ let errorIdCounter = 0;
7
+ export function generateErrorId(type: string): string {
8
+ return `${type}-error-${Date.now()}-${++errorIdCounter}`;
9
+ }
10
+
11
+ /**
12
+ * Generate deduplication key for error
13
+ * Used to prevent duplicate toasts for the same error
14
+ */
15
+ function getErrorDedupeKey(detail: ErrorDetail): string {
16
+ switch (detail.type) {
17
+ case 'validation':
18
+ return `validation:${detail.path}:${detail.method}`;
19
+ case 'cors':
20
+ return `cors:${detail.url}`;
21
+ case 'network':
22
+ return `network:${detail.url}:${detail.statusCode || 'unknown'}`;
23
+ case 'runtime':
24
+ return `runtime:${detail.source}:${detail.message.slice(0, 50)}`;
25
+ default:
26
+ return `unknown:${Date.now()}`;
27
+ }
28
+ }
29
+
30
+ /** Deduplication window in ms - same errors within this window are ignored */
31
+ const DEDUPE_WINDOW_MS = 5000;
32
+
33
+ /** Max entries in deduplication map before forced cleanup */
34
+ const DEDUPE_MAX_ENTRIES = 50;
35
+
36
+ /** Map to track recent errors for deduplication (browser-only) */
37
+ let recentErrors: Map<string, number> | null = null;
38
+
39
+ function getRecentErrorsMap(): Map<string, number> {
40
+ // Lazy init - only create in browser
41
+ if (typeof window === 'undefined') {
42
+ // Return dummy map for SSR (won't persist)
43
+ return new Map();
44
+ }
45
+ if (!recentErrors) {
46
+ recentErrors = new Map();
47
+ }
48
+ return recentErrors;
49
+ }
50
+
51
+ /**
52
+ * Check if error should be shown (not a duplicate)
53
+ * Returns true if error should be shown, false if it's a duplicate
54
+ */
55
+ export function shouldShowError(detail: ErrorDetail): boolean {
56
+ const map = getRecentErrorsMap();
57
+ const key = getErrorDedupeKey(detail);
58
+ const now = Date.now();
59
+ const lastSeen = map.get(key);
60
+
61
+ // Clean up old entries when map gets too large
62
+ if (map.size > DEDUPE_MAX_ENTRIES) {
63
+ for (const [k, timestamp] of map.entries()) {
64
+ if (now - timestamp > DEDUPE_WINDOW_MS) {
65
+ map.delete(k);
66
+ }
67
+ }
68
+ // If still too large after cleanup, clear oldest half
69
+ if (map.size > DEDUPE_MAX_ENTRIES) {
70
+ const entries = Array.from(map.entries())
71
+ .sort((a, b) => a[1] - b[1])
72
+ .slice(0, Math.floor(map.size / 2));
73
+ for (const [k] of entries) {
74
+ map.delete(k);
75
+ }
76
+ }
77
+ }
78
+
79
+ // Check if we've seen this error recently
80
+ if (lastSeen && now - lastSeen < DEDUPE_WINDOW_MS) {
81
+ return false; // Duplicate, don't show
82
+ }
83
+
84
+ // Mark as seen
85
+ map.set(key, now);
86
+ return true;
87
+ }
88
+
89
+ /** Throttle production toasts — max 1 per type within this window */
90
+ const PROD_TOAST_THROTTLE_MS = 3000;
91
+ let lastProdToastByType: Map<string, number> | null = null;
92
+
93
+ export function shouldThrottleProdToast(type: string): boolean {
94
+ if (typeof window === 'undefined') return false;
95
+ if (!lastProdToastByType) lastProdToastByType = new Map();
96
+ const now = Date.now();
97
+ const last = lastProdToastByType.get(type);
98
+ if (last && now - last < PROD_TOAST_THROTTLE_MS) return true;
99
+ lastProdToastByType.set(type, now);
100
+ return false;
101
+ }
@@ -0,0 +1,167 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ interface GlyphSpec {
6
+ /** Accent tint used for the glow and the gradient stop. */
7
+ accent: string;
8
+ /** viewBox for the inner SVG. Default 24 24. */
9
+ viewBox?: string;
10
+ /** Inner SVG children. Must use `currentColor` for stroke. */
11
+ paths: React.ReactNode;
12
+ }
13
+
14
+ const GLYPH_SPECS: Record<string, GlyphSpec> = {
15
+ // 404 — magnifier over a page corner (not-found)
16
+ '404': {
17
+ accent: 'rgb(139 92 246)', // violet-500
18
+ paths: (
19
+ <>
20
+ <path d="M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7.5" />
21
+ <path d="M13.5 3 19 8.5" />
22
+ <path d="M13 3v4a2 2 0 0 0 2 2h4" />
23
+ <circle cx="11" cy="14.5" r="2.5" />
24
+ <path d="m13 16.5 2 2" />
25
+ </>
26
+ ),
27
+ },
28
+ // 500 — server stack with a warning bolt (server-side failure)
29
+ '500': {
30
+ accent: 'rgb(239 68 68)', // red-500
31
+ paths: (
32
+ <>
33
+ <rect x="3" y="4" width="18" height="6" rx="1.5" />
34
+ <rect x="3" y="14" width="18" height="6" rx="1.5" />
35
+ <path d="M7 7h.01" />
36
+ <path d="M7 17h.01" />
37
+ <path d="m15 6-2 4h3l-2 4" strokeLinejoin="round" />
38
+ </>
39
+ ),
40
+ },
41
+ // 502 — two servers with a broken link between
42
+ '502': {
43
+ accent: 'rgb(249 115 22)', // orange-500
44
+ paths: (
45
+ <>
46
+ <rect x="3" y="4" width="8" height="16" rx="1.5" />
47
+ <rect x="13" y="4" width="8" height="16" rx="1.5" />
48
+ <path d="M6 8h.01" />
49
+ <path d="M16 8h.01" />
50
+ <path d="M11 11.5h-1" />
51
+ <path d="M14 12.5h-1" />
52
+ <path d="m11 10 2 4" strokeLinejoin="round" />
53
+ </>
54
+ ),
55
+ },
56
+ // 503 — power off / plug pulled
57
+ '503': {
58
+ accent: 'rgb(249 115 22)', // orange-500
59
+ paths: (
60
+ <>
61
+ <path d="M12 2v10" />
62
+ <path d="M18.4 6.6a9 9 0 1 1-12.77 0" />
63
+ </>
64
+ ),
65
+ },
66
+ // 504 — clock with slash (gateway timeout)
67
+ '504': {
68
+ accent: 'rgb(234 179 8)', // yellow-500
69
+ paths: (
70
+ <>
71
+ <circle cx="12" cy="12" r="9" />
72
+ <path d="M12 7v5l3 2" />
73
+ </>
74
+ ),
75
+ },
76
+ // 408 — clock (request timeout)
77
+ '408': {
78
+ accent: 'rgb(234 179 8)', // yellow-500
79
+ paths: (
80
+ <>
81
+ <circle cx="12" cy="12" r="9" />
82
+ <path d="M12 7v5l3 2" />
83
+ </>
84
+ ),
85
+ },
86
+ // 403 — shield with a prohibition line (forbidden)
87
+ '403': {
88
+ accent: 'rgb(239 68 68)', // red-500
89
+ paths: (
90
+ <>
91
+ <path d="M12 2 4 5v6c0 5 3.5 9 8 11 4.5-2 8-6 8-11V5l-8-3Z" />
92
+ <path d="m9 9 6 6" strokeLinejoin="round" />
93
+ </>
94
+ ),
95
+ },
96
+ // 401 — key (auth required)
97
+ '401': {
98
+ accent: 'rgb(59 130 246)', // blue-500
99
+ paths: (
100
+ <>
101
+ <circle cx="8" cy="15" r="4" />
102
+ <path d="m10.8 12.2 8.2-8.2" />
103
+ <path d="m17 5 3 3" />
104
+ <path d="m15 7 2 2" />
105
+ </>
106
+ ),
107
+ },
108
+ // 400 — general alert triangle (bad request)
109
+ '400': {
110
+ accent: 'rgb(234 179 8)', // yellow-500
111
+ paths: (
112
+ <>
113
+ <path d="m12 3 10 18H2L12 3Z" strokeLinejoin="round" />
114
+ <path d="M12 9v5" />
115
+ <path d="M12 17.5h.01" />
116
+ </>
117
+ ),
118
+ },
119
+ };
120
+
121
+ const DEFAULT_GLYPH: GlyphSpec = {
122
+ accent: 'rgb(113 113 122)', // zinc-500
123
+ paths: (
124
+ <>
125
+ <circle cx="12" cy="12" r="9" />
126
+ <path d="M12 8v4" />
127
+ <path d="M12 16h.01" />
128
+ </>
129
+ ),
130
+ };
131
+
132
+ /**
133
+ * Render the glyph for a given code, wrapped in a container with a soft radial
134
+ * glow behind the icon. Returns `null` if nothing should be shown.
135
+ */
136
+ export function getErrorIcon(code?: string | number): React.ReactNode {
137
+ if (code === undefined || code === null || code === '') return null;
138
+ const c = String(code);
139
+ const spec = GLYPH_SPECS[c] ?? DEFAULT_GLYPH;
140
+
141
+ return (
142
+ <div
143
+ className="relative mx-auto flex size-28 items-center justify-center"
144
+ aria-hidden="true"
145
+ >
146
+ {/* soft radial glow */}
147
+ <div
148
+ className="pointer-events-none absolute inset-0 rounded-full blur-2xl opacity-60"
149
+ style={{
150
+ background: `radial-gradient(circle at 50% 50%, ${spec.accent} 0%, transparent 65%)`,
151
+ }}
152
+ />
153
+ {/* icon */}
154
+ <svg
155
+ className="relative size-20"
156
+ viewBox={spec.viewBox ?? '0 0 24 24'}
157
+ fill="none"
158
+ stroke="currentColor"
159
+ strokeWidth={1.25}
160
+ strokeLinecap="round"
161
+ style={{ color: spec.accent }}
162
+ >
163
+ {spec.paths}
164
+ </svg>
165
+ </div>
166
+ );
167
+ }
@@ -61,7 +61,7 @@ import { AnalyticsProvider } from '../../snippets/Analytics';
61
61
  import { AuthDialog } from '../../snippets/AuthDialog';
62
62
 
63
63
  import type { BaseLayoutProps } from '../types/layout.types';
64
- import { LayoutI18nProvider } from './LayoutI18nProvider';
64
+ import { LayoutI18nProvider } from '../../shared/LayoutI18nProvider';
65
65
 
66
66
  import React from 'react';
67
67
 
@@ -17,8 +17,8 @@ export {
17
17
  LayoutI18nProvider,
18
18
  useLayoutI18n,
19
19
  useLayoutI18nOptional,
20
- } from './LayoutI18nProvider';
21
- export type { LayoutI18nProviderProps } from './LayoutI18nProvider';
20
+ } from '../../shared/LayoutI18nProvider';
21
+ export type { LayoutI18nProviderProps } from '../../shared/LayoutI18nProvider';
22
22
 
23
23
  // Re-export theme-override primitives so consumers can force a theme on a
24
24
  // subtree (e.g. an always-dark marketing route) without depending on ui-core
@@ -13,7 +13,7 @@
13
13
 
14
14
  'use client';
15
15
 
16
- import React, { createContext, memo, useContext, useMemo } from 'react';
16
+ import React, { memo, useMemo } from 'react';
17
17
 
18
18
  import { consumeSavedRedirect, useAuth, useCfgRouter } from '@djangocfg/api/auth';
19
19
  import { useAnalytics } from '@djangocfg/analytics';
@@ -34,31 +34,11 @@ import './styles/split-shell.css';
34
34
  import './styles/fullsplit-shell.css';
35
35
 
36
36
  import type { AuthLayoutProps } from './types';
37
+ import { AuthLayoutContext } from './layoutContext';
38
+ export { useAuthLayoutContext } from './layoutContext';
37
39
 
38
40
  // ─── Layout-level context (header suppression, etc.) ─────────────────────────
39
41
 
40
- interface AuthLayoutContextValue {
41
- /** True when the consumer passed custom children — step headers should be hidden */
42
- hideHeader: boolean;
43
- /**
44
- * True when the brand is already rendered on the media half, so the form
45
- * header must not repeat it.
46
- *
47
- * A statement about the DESKTOP layout only: `fullsplit` drops the media half
48
- * below 1024px, where the logo above the form is the only brand left. CSS
49
- * owns that half of the decision on the same breakpoint — see
50
- * `.auth-header[data-brand-elsewhere]` in `auth.css`.
51
- */
52
- hasBrandPanel: boolean;
53
- }
54
-
55
- const AuthLayoutContext = createContext<AuthLayoutContextValue>({
56
- hideHeader: false,
57
- hasBrandPanel: false,
58
- });
59
-
60
- export const useAuthLayoutContext = (): AuthLayoutContextValue => useContext(AuthLayoutContext);
61
-
62
42
  /**
63
43
  * The provider itself, for harnesses that mount the steps without `AuthLayout`
64
44
  * (Storybook does, via `AuthShell`). Without it those previews always read the
@@ -8,7 +8,7 @@ import { useAppT } from '@djangocfg/i18n';
8
8
  import { Link } from '@djangocfg/ui-core/components';
9
9
 
10
10
  import { LocaleSwitcher } from '../../../_components/LocaleSwitcher';
11
- import { useLayoutI18nOptional } from '../../../AppLayout/LayoutI18nProvider';
11
+ import { useLayoutI18nOptional } from '../../../../shared/LayoutI18nProvider';
12
12
  import { AUTH } from '../../constants';
13
13
 
14
14
  export interface AuthTopBarProps {
@@ -7,7 +7,7 @@ import { useGithubAuth } from '@djangocfg/api/auth';
7
7
  import { useAppT } from '@djangocfg/i18n';
8
8
  import { Checkbox, Input } from '@djangocfg/ui-core/components';
9
9
 
10
- import { useAuthLayoutContext } from '../../AuthLayout';
10
+ import { useAuthLayoutContext } from '../../layoutContext';
11
11
  import { useAuthFormContext } from '../../context';
12
12
  import {
13
13
  AuthButton,