148 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|