Files
loyaly-merchant/src/app/layout.tsx

148 lines
5.2 KiB
TypeScript

import type {Metadata, Viewport} from 'next';
import {cookies} from 'next/headers';
import {Sora, Inter} from 'next/font/google';
import './globals.css';
import {getServerSession} from '@/features/auth/services/serverSession';
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
import {tabSessionScript} from '@/features/auth/services/tabSession';
import {
htmlThemeAttr,
parseThemeMode,
THEME_MODE_COOKIE,
type ThemeMode,
} from '@/shared/theme/themeMode';
import {Providers} from './providers';
const sora = Sora({
subsets: ['latin'],
variable: '--font-sora',
display: 'swap',
});
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
/**
* Icons and the web manifest are NOT declared here. They come from the app-dir
* file conventions — favicon.ico, icon.png, apple-icon.png and manifest.ts,
* all siblings of this file — which Next hashes and emits the <link> tags for.
* Adding an `icons` key would shadow those with unversioned paths.
*/
export const metadata: Metadata = {
applicationName: 'Loyaly.ai Merchant OS',
title: {
default: 'Loyaly.ai Merchant OS',
template: '%s · Loyaly.ai',
},
description:
'Footfall, campaign and LYT redemption analytics for Loyaly merchants.',
appleWebApp: {
capable: true,
title: 'Loyaly',
// The mark sits on black in apple-icon.png; match it so the notch area
// does not seam against the canvas when installed.
statusBarStyle: 'black-translucent',
},
};
/**
* Resolved per request rather than exported as a constant, so the UA chrome
* (address bar, native scrollbars, the paint before CSS arrives) matches the
* mode this visitor actually chose. A hardcoded `colorScheme: 'dark'` here
* was one of the reasons picking Light did nothing visible.
*/
export async function generateViewport(): Promise<Viewport> {
const mode = await readThemeMode();
if (mode === 'light') {
return {colorScheme: 'light', themeColor: '#F7F7F8'};
}
if (mode === 'dark') {
return {colorScheme: 'dark', themeColor: '#000000'};
}
return {
colorScheme: 'light dark',
themeColor: [
{media: '(prefers-color-scheme: light)', color: '#F7F7F8'},
{media: '(prefers-color-scheme: dark)', color: '#000000'},
],
};
}
async function readThemeMode(): Promise<ThemeMode> {
const store = await cookies();
return parseThemeMode(store.get(THEME_MODE_COOKIE)?.value);
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
/**
* Resolved on the server so the client provider starts already knowing who
* it is. Without the seed, every full page load would paint a guard spinner
* while the browser asked `/api/auth/session` a question the server had
* already answered in this very request.
*/
const session = await getServerSession();
/**
* WHICH tab that session was resolved from, handed to the client alongside it.
*
* A document navigation carries no `X-Tab-Id`, so `getServerSession` resolves
* through the `loyaly_tab` pointer — the tab that was last FOCUSED, which for
* a newly opened tab is somebody else. The seed is still worth sending (it is
* what saves a guard spinner on every load), but the client has to be able to
* tell whether the seed is its own. Without this it could not: it received an
* identity with nothing attached saying who it belonged to, so a second tab
* rendered the first tab's user in the shell and only found out when a data
* call answered 401.
*
* Not a credential and not a decision — just the label that lets the client
* recognise a seed that is not about it. See SessionProvider.
*/
const tabId = await resolveTabId();
const themeMode = await readThemeMode();
return (
// The theme attributes are server-rendered so the @scope rules in
// loyaly.css match on the very first byte — no flash from stock defaults.
//
// data-theme now comes from the cookie, and is ABSENT for 'system' so
// color-scheme stays `light dark` and light-dark() follows the OS. Astryx's
// <Theme> re-syncs this same attribute after hydration; it writes exactly
// what htmlThemeAttr() writes here, so the two never disagree.
<html
lang="en"
data-theme={htmlThemeAttr(themeMode)}
data-astryx-theme="loyaly"
className={`${sora.variable} ${inter.variable}`}
suppressHydrationWarning
>
<body>
{/*
FIRST child of <body>, and that position is the whole point: it gives
this tab its id and points the cookie at it before the markup below is
parsed, so the very first navigation is rendered as the right user. An
effect inside Providers would run after the first paint instead.
It no longer takes the session: it does not decide anything about
being signed in, and nothing in it signs anybody out. See
services/tabSession.ts for what it replaced and why.
*/}
<script dangerouslySetInnerHTML={{__html: tabSessionScript()}} />
<Providers
initialSession={session}
initialTabId={tabId}
initialThemeMode={themeMode}
>
{children}
</Providers>
</body>
</html>
);
}