@djangocfg/layouts 2.1.607 → 2.1.609

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 (54) hide show
  1. package/README.md +4 -0
  2. package/package.json +12 -12
  3. package/src/components/account/AccountInline.tsx +122 -0
  4. package/src/components/account/AccountMenu.test.tsx +110 -0
  5. package/src/components/account/AccountMenu.tsx +117 -0
  6. package/src/components/account/accountContext.ts +14 -0
  7. package/src/components/account/index.ts +13 -0
  8. package/src/components/account/triggers.tsx +100 -0
  9. package/src/{layouts/SettingsLayout/hooks → components/account}/useAccountMenuItems.tsx +45 -17
  10. package/src/components/account/useAccountModel.tsx +76 -0
  11. package/src/components/errors/AppErrorFallback.test.tsx +33 -0
  12. package/src/components/errors/AppErrorFallback.tsx +42 -0
  13. package/src/components/errors/ErrorBoundary.tsx +4 -41
  14. package/src/components/errors/ErrorLayout.tsx +17 -1
  15. package/src/components/errors/README.md +6 -1
  16. package/src/components/errors/chunkReload.test.ts +89 -0
  17. package/src/components/errors/index.ts +2 -0
  18. package/src/layouts/AppLayout/BaseApp.tsx +15 -55
  19. package/src/layouts/AppLayout/index.ts +1 -0
  20. package/src/layouts/AppLayout/pwaMeta.test.ts +36 -0
  21. package/src/layouts/AppLayout/pwaMeta.tsx +40 -0
  22. package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
  23. package/src/layouts/PrivateLayout/README.md +22 -6
  24. package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
  25. package/src/layouts/PrivateLayout/components/ForbiddenState.tsx +1 -1
  26. package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +176 -11
  27. package/src/layouts/PrivateLayout/components/PrivateContent.tsx +85 -25
  28. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +4 -2
  29. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +16 -123
  30. package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +3 -3
  31. package/src/layouts/PrivateLayout/components/PrivateTopBarAccount.tsx +27 -0
  32. package/src/layouts/PrivateLayout/components/SidebarBody.test.tsx +73 -0
  33. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +13 -4
  34. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +25 -14
  35. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +10 -5
  36. package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
  37. package/src/layouts/PrivateLayout/context.tsx +15 -3
  38. package/src/layouts/PrivateLayout/geometry.test.ts +19 -7
  39. package/src/layouts/PrivateLayout/geometry.ts +30 -10
  40. package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
  41. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
  42. package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +1 -1
  43. package/src/layouts/PrivateLayout/index.ts +2 -0
  44. package/src/layouts/PrivateLayout/topBar.test.tsx +213 -0
  45. package/src/layouts/PrivateLayout/types.ts +27 -2
  46. package/src/layouts/PublicLayout/components/UserMenu.tsx +24 -297
  47. package/src/layouts/PublicLayout/components/index.ts +2 -2
  48. package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +13 -7
  49. package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +19 -60
  50. package/src/layouts/SettingsLayout/hooks/index.ts +2 -2
  51. package/src/layouts/types/layout.types.ts +7 -0
  52. package/src/shared/ProjectContext.tsx +18 -0
  53. package/src/testing/MockAuthProvider.tsx +5 -2
  54. /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * What the account menu shows, shared by every place it can live (sidebar footer, top bar avatar): the user, the
3
+ * display fields and the menu items. One source, so the menus can never drift apart.
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import React from 'react';
9
+
10
+ import { useAuth } from '@djangocfg/api/auth';
11
+ import type { MenuItem } from '@djangocfg/ui-core/components';
12
+
13
+ import type { UserMenuGroup, UserMenuLocaleConfig } from '../../layouts/types';
14
+ import { useAccountMenuItems } from './useAccountMenuItems';
15
+
16
+ export interface AccountView {
17
+ source: 'user' | 'dev-fallback';
18
+ displayName: string;
19
+ email: string | null;
20
+ avatarUrl: string;
21
+ plan: string | null;
22
+ }
23
+
24
+ export interface UseAccountModelOptions {
25
+ /** App link groups. */
26
+ groups?: UserMenuGroup[];
27
+ /** Plan shown after the name in the footer trigger. */
28
+ plan?: string | null;
29
+ i18n?: UserMenuLocaleConfig;
30
+ /** Extra items between the groups and Sign out (language, app links). */
31
+ extra?: MenuItem[];
32
+ /** Settings rows in the menu. @default true */
33
+ settings?: boolean;
34
+ /** Identity block above the items: the `email` (default) or a name + email `card`. */
35
+ identity?: 'email' | 'card';
36
+ }
37
+
38
+ export function useAccountModel({ groups, plan = null, i18n, settings = true, extra, identity = 'email' }: UseAccountModelOptions = {}) {
39
+ const { user } = useAuth();
40
+
41
+ /**
42
+ * When `useAuth` returns a real user we use it; otherwise (dev only) a placeholder keeps the menu reachable for
43
+ * debugging. Production stays strict: callers render nothing without a user.
44
+ */
45
+ const account = React.useMemo<AccountView>(() => {
46
+ if (user) {
47
+ return {
48
+ source: 'user',
49
+ displayName: user.display_username || user.email || 'User',
50
+ email: user.email ?? null,
51
+ avatarUrl: user.avatar ?? '',
52
+ plan,
53
+ };
54
+ }
55
+ return {
56
+ source: 'dev-fallback',
57
+ displayName: 'Guest (dev)',
58
+ email: null,
59
+ avatarUrl: '',
60
+ plan: plan ?? 'No session',
61
+ };
62
+ }, [user, plan]);
63
+
64
+ const headerLabel: React.ReactNode =
65
+ identity === 'card' ? (
66
+ <div className="flex flex-col space-y-1">
67
+ <p className="text-sm font-medium leading-none">{account.displayName}</p>
68
+ <p className="text-xs leading-none text-muted-foreground">{account.email}</p>
69
+ </div>
70
+ ) : (
71
+ account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null)
72
+ );
73
+ const menuItems = useAccountMenuItems({ groups, label: headerLabel, i18n, settings, extra });
74
+
75
+ return { user, account, menuItems };
76
+ }
@@ -0,0 +1,33 @@
1
+ // @vitest-environment jsdom
2
+ import React, { act } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+
6
+ import { AppErrorFallback } from './AppErrorFallback';
7
+
8
+ vi.mock('@djangocfg/i18n', () => ({
9
+ getT: (key: string) => key,
10
+ }));
11
+
12
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ function render(props = {}) {
15
+ const host = document.createElement('div');
16
+ act(() => createRoot(host).render(<AppErrorFallback {...props} />));
17
+ return host;
18
+ }
19
+
20
+ describe('AppErrorFallback', () => {
21
+ it('renders exactly one action button', () => {
22
+ const host = render();
23
+ const buttons = host.querySelectorAll('button');
24
+ expect(buttons).toHaveLength(1);
25
+ expect(buttons[0]!.textContent).toBe('layouts.errors.refreshPage');
26
+ });
27
+
28
+ it('keeps one button when a support email is shown', () => {
29
+ const host = render({ supportEmail: 'help@example.com' });
30
+ expect(host.querySelectorAll('button')).toHaveLength(1);
31
+ expect(host.querySelector('a[href="mailto:help@example.com"]')).not.toBeNull();
32
+ });
33
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * AppErrorFallback — the one localized crash screen for Next app layouts.
3
+ *
4
+ * Exactly ONE action: "Refresh Page" (hard reload). A soft `reset()` re-mounts
5
+ * the same broken tree and usually crashes again, so it is not offered as a
6
+ * second near-identical button. Used by `BaseApp` (via UiProviders) and
7
+ * `ErrorBoundary`.
8
+ */
9
+
10
+ 'use client';
11
+
12
+ import { getT } from '@djangocfg/i18n';
13
+
14
+ export interface AppErrorFallbackProps {
15
+ supportEmail?: string;
16
+ }
17
+
18
+ export function AppErrorFallback({ supportEmail }: AppErrorFallbackProps) {
19
+ return (
20
+ <div className="flex min-h-screen items-center justify-center bg-background p-4">
21
+ <div className="max-w-md w-full space-y-4 text-center">
22
+ <h1 className="text-2xl font-bold text-foreground">{getT('layouts.errors.somethingWentWrong')}</h1>
23
+ <p className="text-muted-foreground">{getT('layouts.errors.tryRefreshing')}</p>
24
+ {supportEmail && (
25
+ <p className="text-sm text-muted-foreground">
26
+ {getT('layouts.errors.persistsContact', { email: '' })}{' '}
27
+ <a href={`mailto:${supportEmail}`} className="text-primary hover:underline">
28
+ {supportEmail}
29
+ </a>
30
+ </p>
31
+ )}
32
+ <button
33
+ type="button"
34
+ onClick={() => window.location.reload()}
35
+ className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
36
+ >
37
+ {getT('layouts.errors.refreshPage')}
38
+ </button>
39
+ </div>
40
+ </div>
41
+ );
42
+ }
@@ -13,7 +13,7 @@
13
13
  import { useMemo, type ComponentType, type ErrorInfo, type ReactNode } from 'react';
14
14
 
15
15
  import { Boundary, type BoundaryRenderProps } from '@djangocfg/ui-core/components';
16
- import { getT } from '@djangocfg/i18n';
16
+ import { AppErrorFallback } from './AppErrorFallback';
17
17
 
18
18
  interface ErrorBoundaryProps {
19
19
  children: ReactNode;
@@ -21,50 +21,13 @@ interface ErrorBoundaryProps {
21
21
  onError?: (error: Error, errorInfo: ErrorInfo) => void;
22
22
  }
23
23
 
24
- /** Localized fullscreen fallback. `reset()` re-mounts the tree (ui-core); we
25
- * also offer a hard reload, matching the prior behavior. */
26
- function makeFallback(supportEmail?: string) {
27
- return function ErrorFallback({ reset }: BoundaryRenderProps) {
28
- const title = getT('layouts.errors.somethingWentWrong');
29
- const description = getT('layouts.errors.tryRefreshing');
30
- const refreshButton = getT('layouts.errors.refreshPage');
31
- return (
32
- <div className="flex min-h-screen items-center justify-center bg-background p-4">
33
- <div className="max-w-md w-full space-y-4 text-center">
34
- <h1 className="text-2xl font-bold text-foreground">{title}</h1>
35
- <p className="text-muted-foreground">{description}</p>
36
- {supportEmail && (
37
- <p className="text-sm text-muted-foreground">
38
- {getT('layouts.errors.persistsContact', { email: '' })}{' '}
39
- <a
40
- href={`mailto:${supportEmail}`}
41
- className="text-primary hover:underline"
42
- >
43
- {supportEmail}
44
- </a>
45
- </p>
46
- )}
47
- <button
48
- onClick={() => {
49
- reset();
50
- // Hard reload as a fallback if a pure reset can't recover.
51
- window.location.reload();
52
- }}
53
- className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
54
- >
55
- {refreshButton}
56
- </button>
57
- </div>
58
- </div>
59
- );
60
- };
61
- }
62
-
63
24
  export function ErrorBoundary({ children, supportEmail, onError }: ErrorBoundaryProps) {
64
25
  // Memoize so the boundary's FallbackComponent identity is stable across
65
26
  // renders (no remount churn).
66
27
  const Fallback = useMemo<ComponentType<BoundaryRenderProps>>(
67
- () => makeFallback(supportEmail),
28
+ () => function ErrorFallback() {
29
+ return <AppErrorFallback supportEmail={supportEmail} />;
30
+ },
68
31
  [supportEmail],
69
32
  );
70
33
  return (
@@ -17,9 +17,10 @@
17
17
 
18
18
  'use client';
19
19
 
20
- import React, { useMemo } from 'react';
20
+ import React, { useEffect, useMemo } from 'react';
21
21
 
22
22
  import { Button } from '@djangocfg/ui-core/components';
23
+ import { canAutoReloadForChunk, isChunkLoadError, reloadForChunkError } from '@djangocfg/ui-core/lib';
23
24
  import { useAppT } from '@djangocfg/i18n';
24
25
 
25
26
  import { getErrorContent } from './errorConfig';
@@ -54,6 +55,14 @@ export interface ErrorLayoutProps {
54
55
  * it finally matters.
55
56
  */
56
57
  statusUrl?: string;
58
+ /**
59
+ * The caught error (`global-error.tsx` / `error.tsx` receive it as `error`).
60
+ * When it is a stale-chunk failure (ChunkLoadError, cancelled `/_next/static`
61
+ * fetch after a deploy) the page reloads ONCE (sessionStorage loop guard) and
62
+ * renders nothing meanwhile; if the reload already happened, the normal error
63
+ * screen is shown.
64
+ */
65
+ error?: unknown;
57
66
  }
58
67
 
59
68
  /**
@@ -82,8 +91,13 @@ export function ErrorLayout({
82
91
  illustration,
83
92
  supportEmail = 'support@example.com',
84
93
  statusUrl,
94
+ error,
85
95
  }: ErrorLayoutProps) {
86
96
  const t = useAppT();
97
+ const staleChunk = error !== undefined && isChunkLoadError(error);
98
+ useEffect(() => {
99
+ if (staleChunk) reloadForChunkError();
100
+ }, [staleChunk]);
87
101
 
88
102
  const labels = useMemo(() => ({
89
103
  goBack: t('layouts.errors.goBack'),
@@ -121,6 +135,8 @@ export function ErrorLayout({
121
135
  window.location.href = '/';
122
136
  };
123
137
 
138
+ if (staleChunk && canAutoReloadForChunk()) return null;
139
+
124
140
  return (
125
141
  <div className="min-h-screen flex items-center justify-center px-4 bg-background">
126
142
  <div className="max-w-2xl w-full text-center space-y-8">
@@ -11,11 +11,16 @@ All boundaries here build on the single `Boundary` primitive in
11
11
 
12
12
  | File | What it does |
13
13
  |------|--------------|
14
- | `ErrorBoundary.tsx` | Thin wrapper over ui-core `Boundary` — i18n fallback + support email (for direct use; `BaseApp` no longer wraps it). |
14
+ | `AppErrorFallback.tsx` | The one localized crash screen: title, text, support email, ONE "Refresh Page" button (hard reload). Used by `BaseApp` and `ErrorBoundary`. |
15
+ | `ErrorBoundary.tsx` | Thin wrapper over ui-core `Boundary` — renders `AppErrorFallback` (for direct use; `BaseApp` no longer wraps it). |
15
16
  | `MonitorBoundary.tsx` | ui-core `Boundary` that also reports to `@djangocfg/devtools`. |
16
17
  | `ErrorLayout.tsx` | Full-page error layout (`getErrorContent` / `ERROR_CODES`). |
17
18
  | `ErrorsTracker/` | Global runtime tracker — listens for error CustomEvents and shows toasts. |
18
19
 
20
+ ## Stale chunks after a deploy
21
+
22
+ A chunk-load failure (ChunkLoadError, "Loading chunk N failed", dynamic import failure, cancelled fetch of `/_next/static/...`) is a stale tab, not a crash: the page reloads ONCE instead of showing the error screen. Logic lives in `@djangocfg/ui-core/lib` (`chunk-reload.ts`), wired in `Boundary` (render errors), `UiProviders` (window `error` + `unhandledrejection`) and `ErrorLayout error={error}` (`global-error.tsx`). Loop guard: `sessionStorage` key `djangocfg:chunk-reload-at`, 30 s window; with storage blocked no auto reload. Tests: `chunkReload.test.ts`, `AppErrorFallback.test.tsx`.
23
+
19
24
  ## ErrorLayout — `statusUrl`
20
25
 
21
26
  `ErrorLayout` takes an optional `statusUrl`, rendered as a secondary link beside
@@ -0,0 +1,89 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import {
5
+ CHUNK_RELOAD_STORAGE_KEY,
6
+ CHUNK_RELOAD_WINDOW_MS,
7
+ canAutoReloadForChunk,
8
+ installChunkReloadListeners,
9
+ isChunkLoadError,
10
+ isStaticAssetLoadEvent,
11
+ reloadForChunkError,
12
+ } from '@djangocfg/ui-core/lib';
13
+
14
+ describe('isChunkLoadError', () => {
15
+ it.each([
16
+ ['ChunkLoadError by name', Object.assign(new Error('x'), { name: 'ChunkLoadError' })],
17
+ ['webpack JS chunk', new Error('Loading chunk 123 failed.\n(error: /_next/static/chunks/123.js)')],
18
+ ['webpack CSS chunk', new Error('Loading CSS chunk 45 failed.')],
19
+ ['Chrome dynamic import', new TypeError('Failed to fetch dynamically imported module: /_next/static/a.js')],
20
+ ['Firefox dynamic import', new TypeError('error loading dynamically imported module')],
21
+ ['Safari module script', new TypeError('Importing a module script failed.')],
22
+ ['cancelled fetch of static asset', new TypeError('Failed to fetch /_next/static/chunks/app/page-abc.js')],
23
+ ['aborted static asset via stack', Object.assign(new Error('signal is aborted'), { stack: 'at https://x.io/_next/static/chunks/1.js:1' })],
24
+ ['string reason', 'Loading chunk 9 failed.'],
25
+ ])('detects %s', (_n, err) => expect(isChunkLoadError(err)).toBe(true));
26
+
27
+ it.each([
28
+ ['ordinary error', new Error('Cannot read properties of undefined')],
29
+ ['API fetch failure', new TypeError('Failed to fetch https://api.example.com/items')],
30
+ ['null', null],
31
+ ['undefined', undefined],
32
+ ])('ignores %s', (_n, err) => expect(isChunkLoadError(err)).toBe(false));
33
+ });
34
+
35
+ describe('isStaticAssetLoadEvent', () => {
36
+ it('matches failed script/link under /_next/static and nothing else', () => {
37
+ const script = document.createElement('script');
38
+ script.src = 'https://x.io/_next/static/chunks/1.js';
39
+ const other = document.createElement('script');
40
+ other.src = 'https://cdn.io/lib.js';
41
+ const link = document.createElement('link');
42
+ link.href = 'https://x.io/_next/static/css/a.css';
43
+ const mk = (target: Element) => Object.defineProperty(new Event('error'), 'target', { value: target });
44
+ expect(isStaticAssetLoadEvent(mk(script))).toBe(true);
45
+ expect(isStaticAssetLoadEvent(mk(link))).toBe(true);
46
+ expect(isStaticAssetLoadEvent(mk(other))).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('reload guard', () => {
51
+ const reload = vi.fn();
52
+ beforeEach(() => {
53
+ sessionStorage.clear();
54
+ reload.mockClear();
55
+ Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, reload } });
56
+ });
57
+ afterEach(() => vi.restoreAllMocks());
58
+
59
+ it('reloads once, then blocks a second reload inside the window', () => {
60
+ expect(reloadForChunkError(1_000)).toBe(true);
61
+ expect(sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY)).toBe('1000');
62
+ expect(reloadForChunkError(1_000 + 5_000)).toBe(false);
63
+ expect(reload).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('allows another reload after the window passes', () => {
67
+ reloadForChunkError(1_000);
68
+ expect(canAutoReloadForChunk(1_000 + CHUNK_RELOAD_WINDOW_MS + 1)).toBe(true);
69
+ });
70
+
71
+ it('never reloads when sessionStorage is unusable (no loop risk)', () => {
72
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
73
+ throw new Error('denied');
74
+ });
75
+ expect(canAutoReloadForChunk()).toBe(false);
76
+ expect(reloadForChunkError()).toBe(false);
77
+ expect(reload).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('window listeners reload once for an unhandled chunk rejection', () => {
81
+ const off = installChunkReloadListeners();
82
+ const ev = new Event('unhandledrejection') as Event & { reason?: unknown };
83
+ ev.reason = new Error('Loading chunk 7 failed.');
84
+ window.dispatchEvent(ev);
85
+ window.dispatchEvent(ev);
86
+ expect(reload).toHaveBeenCalledTimes(1);
87
+ off();
88
+ });
89
+ });
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  export { ErrorBoundary } from './ErrorBoundary';
6
+ export { AppErrorFallback } from './AppErrorFallback';
7
+ export type { AppErrorFallbackProps } from './AppErrorFallback';
6
8
  export { MonitorBoundary } from './MonitorBoundary';
7
9
  export type { MonitorBoundaryProps } from './MonitorBoundary';
8
10
  export { ErrorLayout } from './ErrorLayout';
@@ -54,65 +54,21 @@ import { useScrollToTop } from '@djangocfg/ui-core/hooks';
54
54
  import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters/nextjs';
55
55
  import { NextIntlLinkBridge } from './NextIntlLinkBridge';
56
56
  import { ThemeProvider } from '@djangocfg/ui-core/theme';
57
- import type { BoundaryRenderProps } from '@djangocfg/ui-core/components';
58
- import { getT } from '@djangocfg/i18n';
57
+ import { AppErrorFallback } from '../../components/errors/AppErrorFallback';
59
58
  import { ErrorTrackingProvider } from '../../components/errors/ErrorsTracker';
60
59
  import { AnalyticsProvider } from '../../snippets/Analytics';
61
60
  import { AuthDialog } from '../../snippets/AuthDialog';
62
61
 
63
62
  import type { BaseLayoutProps } from '../types/layout.types';
64
63
  import { LayoutI18nProvider } from '../../shared/LayoutI18nProvider';
64
+ import { ProjectNameProvider } from '../../shared/ProjectContext';
65
+ import { PwaMetaGuard } from './pwaMeta';
65
66
 
66
67
  import React from 'react';
67
68
 
68
69
  // For backwards compatibility, re-export as BaseAppProps
69
70
  export type BaseAppProps = BaseLayoutProps;
70
71
 
71
- /**
72
- * Localized crash fallback fed into UiProviders' built-in Boundary. Renders the
73
- * i18n'd "something went wrong" screen + optional support email. `reset()`
74
- * re-mounts (soft recover); the Reload button hard-reloads when a soft reset
75
- * can't recover (e.g. the crash is in a provider above the boundary's subtree).
76
- */
77
- function AppErrorFallback({
78
- reset,
79
- supportEmail,
80
- }: BoundaryRenderProps & { supportEmail?: string }) {
81
- const title = getT('layouts.errors.somethingWentWrong');
82
- const description = getT('layouts.errors.tryRefreshing');
83
- const retry = getT('layouts.errors.refreshPage');
84
- return (
85
- <div className="flex min-h-screen items-center justify-center bg-background p-4">
86
- <div className="max-w-md w-full space-y-4 text-center">
87
- <h1 className="text-2xl font-bold text-foreground">{title}</h1>
88
- <p className="text-muted-foreground">{description}</p>
89
- {supportEmail && (
90
- <p className="text-sm text-muted-foreground">
91
- {getT('layouts.errors.persistsContact', { email: '' })}{' '}
92
- <a href={`mailto:${supportEmail}`} className="text-primary hover:underline">
93
- {supportEmail}
94
- </a>
95
- </p>
96
- )}
97
- <div className="flex items-center justify-center gap-2">
98
- <button
99
- onClick={reset}
100
- className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
101
- >
102
- {retry}
103
- </button>
104
- <button
105
- onClick={() => window.location.reload()}
106
- className="rounded-md border border-border px-4 py-2 text-foreground hover:bg-muted"
107
- >
108
- {getT('layouts.errors.refreshPage')}
109
- </button>
110
- </div>
111
- </div>
112
- </div>
113
- );
114
- }
115
-
116
72
  /**
117
73
  * Lands every forward navigation at the top of the page.
118
74
  *
@@ -150,6 +106,7 @@ export function BaseApp({
150
106
  swr,
151
107
  devtools: devtoolsConfig,
152
108
  i18n,
109
+ pwaMeta = true,
153
110
  }: BaseAppProps) {
154
111
  // auth=false disables AuthProvider entirely (public apps with no login)
155
112
  const authEnabled = auth !== false;
@@ -167,6 +124,8 @@ export function BaseApp({
167
124
  };
168
125
 
169
126
  const content = (
127
+ <>
128
+ {pwaMeta && <PwaMetaGuard />}
170
129
  <ThemeProvider
171
130
  defaultTheme={theme?.defaultTheme || 'system'}
172
131
  storageKey={theme?.storageKey}
@@ -189,9 +148,7 @@ export function BaseApp({
189
148
  onError={errorBoundary?.onError}
190
149
  errorFallback={
191
150
  enableErrorBoundary
192
- ? (props) => (
193
- <AppErrorFallback {...props} supportEmail={errorBoundary?.supportEmail} />
194
- )
151
+ ? () => <AppErrorFallback supportEmail={errorBoundary?.supportEmail} />
195
152
  : undefined
196
153
  }
197
154
  >
@@ -214,11 +171,13 @@ export function BaseApp({
214
171
  <ScrollToTop />
215
172
  <Devtools {...devtoolsProps} />
216
173
  <LayoutI18nProvider value={i18n}>
217
- {i18n?.routing ? (
218
- <NextIntlLinkBridge routing={i18n.routing}>{children}</NextIntlLinkBridge>
219
- ) : (
220
- children
221
- )}
174
+ <ProjectNameProvider value={project}>
175
+ {i18n?.routing ? (
176
+ <NextIntlLinkBridge routing={i18n.routing}>{children}</NextIntlLinkBridge>
177
+ ) : (
178
+ children
179
+ )}
180
+ </ProjectNameProvider>
222
181
  </LayoutI18nProvider>
223
182
  <NextTopLoader
224
183
  color="var(--primary)"
@@ -237,6 +196,7 @@ export function BaseApp({
237
196
  </NextLinkProvider>
238
197
  </NextRouterAdapter>
239
198
  </ThemeProvider>
199
+ </>
240
200
  );
241
201
 
242
202
  // The error boundary is now UiProviders' built-in one (fed our i18n fallback
@@ -13,6 +13,7 @@ export type { BaseAppProps } from './BaseApp';
13
13
 
14
14
  export type { I18nLayoutConfig } from '../types';
15
15
 
16
+ export { ProjectNameProvider, useProjectName } from '../../shared/ProjectContext';
16
17
  export {
17
18
  LayoutI18nProvider,
18
19
  useLayoutI18n,
@@ -0,0 +1,36 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+
4
+ import { PWA_META_SCRIPT } from './pwaMeta';
5
+
6
+ const meta = (n: string) => document.head.querySelector(`meta[name="${n}"]`)?.getAttribute('content');
7
+ const run = () => new Function(PWA_META_SCRIPT)();
8
+
9
+ describe('PWA_META_SCRIPT', () => {
10
+ beforeEach(() => {
11
+ document.head.innerHTML = '';
12
+ });
13
+
14
+ it('adds viewport-fit=cover to an existing viewport and the iOS app tags', () => {
15
+ document.head.innerHTML = '<meta name="viewport" content="width=device-width, initial-scale=1">';
16
+ run();
17
+ expect(meta('viewport')).toBe('width=device-width, initial-scale=1, viewport-fit=cover');
18
+ expect(meta('apple-mobile-web-app-capable')).toBe('yes');
19
+ expect(meta('mobile-web-app-capable')).toBe('yes');
20
+ expect(meta('apple-mobile-web-app-status-bar-style')).toBe('black-translucent');
21
+ });
22
+
23
+ it('creates a viewport when none exists', () => {
24
+ run();
25
+ expect(meta('viewport')).toContain('viewport-fit=cover');
26
+ });
27
+
28
+ it('never overrides what the app set', () => {
29
+ document.head.innerHTML =
30
+ '<meta name="viewport" content="width=device-width, viewport-fit=contain">' +
31
+ '<meta name="apple-mobile-web-app-status-bar-style" content="default">';
32
+ run();
33
+ expect(meta('viewport')).toBe('width=device-width, viewport-fit=contain');
34
+ expect(meta('apple-mobile-web-app-status-bar-style')).toBe('default');
35
+ });
36
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Installed-app `<meta>` tags, applied by `BaseApp` so an app does not have to remember them.
3
+ *
4
+ * Next's App Router builds `<head>` from the server `viewport` / `metadata` exports; a client component such as
5
+ * `BaseApp` cannot contribute to them, and a second `<meta name="viewport">` would fight Next's own. So this is a tiny
6
+ * inline script (it runs while the HTML is parsed, before first paint) that completes what is missing in `<head>`:
7
+ *
8
+ * viewport + `viewport-fit=cover` (without it every `--safe-*` inset is 0 and the
9
+ * installed iOS app letterboxes)
10
+ * apple-mobile-web-app-capable yes (Next 16 only emits `mobile-web-app-capable`; iOS < 26 needs the apple tag)
11
+ * mobile-web-app-capable yes
12
+ * apple-mobile-web-app-status-bar-style black-translucent
13
+ *
14
+ * Existing tags are never overridden (an app's own `appleWebApp.statusBarStyle` wins); only `viewport-fit` is added to
15
+ * an existing viewport that lacks it. A MutationObserver re-applies it if a client navigation rewrites the viewport tag.
16
+ * Prefer exporting `viewport = createViewport(...)` (default `viewportFit: 'cover'`): that puts it in the server HTML;
17
+ * this is the safety net for apps that forgot.
18
+ */
19
+
20
+ import * as React from 'react';
21
+
22
+ export const PWA_META_SCRIPT = `(function(){try{
23
+ var d=document,h=d.head;if(!h)return;
24
+ function q(n){return h.querySelector('meta[name="'+n+'"]')}
25
+ function ensure(n,c){if(!q(n)){var m=d.createElement('meta');m.name=n;m.content=c;h.appendChild(m)}}
26
+ function fit(){var v=q('viewport');
27
+ if(!v){v=d.createElement('meta');v.name='viewport';v.content='width=device-width, initial-scale=1, viewport-fit=cover';h.appendChild(v);return}
28
+ var c=v.getAttribute('content')||'';
29
+ if(!/viewport-fit\\s*=/.test(c))v.setAttribute('content',(c?c+', ':'')+'viewport-fit=cover')}
30
+ fit();
31
+ ensure('apple-mobile-web-app-capable','yes');
32
+ ensure('mobile-web-app-capable','yes');
33
+ ensure('apple-mobile-web-app-status-bar-style','black-translucent');
34
+ if(window.MutationObserver){new MutationObserver(function(){var v=q('viewport');if(!v||!/viewport-fit\\s*=\\s*cover/.test(v.getAttribute('content')||''))fit()}).observe(h,{subtree:true,childList:true,attributes:true,attributeFilter:['content']})}
35
+ }catch(e){}})();`;
36
+
37
+ /** Renders the inline script. Server-rendered into the HTML; React does not re-run it on hydration. */
38
+ export function PwaMetaGuard() {
39
+ return <script dangerouslySetInnerHTML={{ __html: PWA_META_SCRIPT }} suppressHydrationWarning />;
40
+ }