update dark and ligth theme
This commit is contained in:
@@ -80,7 +80,7 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
{/* 1 + 2 — Sticky header: greeting, title, description, and filter controls. */}
|
||||
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-[#0F0F10] border-b border-[#2F2F2F] shadow-sm">
|
||||
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-surface border-b border-border shadow-sm">
|
||||
<PageHeader
|
||||
eyebrow={greetingFor(kpis.meta?.generatedAt)}
|
||||
title="Dashboard"
|
||||
|
||||
@@ -22,13 +22,31 @@
|
||||
/*
|
||||
* Paint the canvas before React mounts. AppShell sets these itself once it
|
||||
* renders, but this guarantees the very first frame — and any non-shell route
|
||||
* such as an error boundary — is already black rather than white.
|
||||
* such as an error boundary — already has the right ground colour.
|
||||
*
|
||||
* These rules are UNLAYERED, so they beat everything in loyaly.css. That is
|
||||
* why `color-scheme` has to be resolved from `data-theme` here rather than
|
||||
* pinned: a flat `color-scheme: dark` silently won over the theme's own
|
||||
* html[data-theme="light"] rule, and every `light-dark()` token in the
|
||||
* stylesheet kept resolving to its dark branch no matter what mode said.
|
||||
*
|
||||
* data-theme is written by the root layout (from the cookie) and re-synced by
|
||||
* Astryx's <Theme>. It is absent in system mode — that is what leaves
|
||||
* `light dark` in force so the OS preference decides.
|
||||
*/
|
||||
html {
|
||||
background-color: var(--color-background-body);
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/*
|
||||
* Global Data Table Inset Contract: 24px left lead-in, 32px right inset.
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
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 {
|
||||
htmlThemeAttr,
|
||||
parseThemeMode,
|
||||
THEME_MODE_COOKIE,
|
||||
type ThemeMode,
|
||||
} from '@/shared/theme/themeMode';
|
||||
import {Providers} from './providers';
|
||||
|
||||
const sora = Sora({
|
||||
@@ -39,10 +46,34 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
colorScheme: 'dark',
|
||||
themeColor: '#000000',
|
||||
};
|
||||
/**
|
||||
* 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,
|
||||
@@ -56,19 +87,27 @@ export default async function RootLayout({
|
||||
* already answered in this very request.
|
||||
*/
|
||||
const session = await getServerSession();
|
||||
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="dark"
|
||||
data-theme={htmlThemeAttr(themeMode)}
|
||||
data-astryx-theme="loyaly"
|
||||
className={`${sora.variable} ${inter.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body>
|
||||
<Providers initialSession={session}>{children}</Providers>
|
||||
<Providers initialSession={session} initialThemeMode={themeMode}>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,11 @@ import {LinkProvider} from '@astryxdesign/core/Link';
|
||||
import {ToastViewport} from '@astryxdesign/core/Toast';
|
||||
import {loyalyTheme} from '@/theme';
|
||||
import {WorkspaceProvider} from '@/shared/providers/WorkspaceProvider';
|
||||
import {
|
||||
ThemeModeProvider,
|
||||
useThemeMode,
|
||||
} from '@/shared/providers/ThemeModeProvider';
|
||||
import type {ThemeMode} from '@/shared/theme/themeMode';
|
||||
import {SessionProvider} from '@/features/auth/providers/SessionProvider';
|
||||
import type {AuthSession} from '@/features/auth/types/auth';
|
||||
import {LoyalyAiProvider} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
|
||||
@@ -27,13 +32,36 @@ import {LoyalyAiProvider} from '@/features/loyaly-ai/providers/LoyalyAiProvider'
|
||||
export function Providers({
|
||||
children,
|
||||
initialSession,
|
||||
initialThemeMode,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Resolved from the signed cookie in the root layout — see SessionProvider. */
|
||||
initialSession: AuthSession | null;
|
||||
/** Resolved from the theme-mode cookie in the root layout. */
|
||||
initialThemeMode: ThemeMode;
|
||||
}) {
|
||||
return (
|
||||
<Theme theme={loyalyTheme} mode="dark">
|
||||
// Outside <Theme>, because <Theme> takes the mode as a prop — a context
|
||||
// rendered inside it could never reach it.
|
||||
<ThemeModeProvider initialMode={initialThemeMode}>
|
||||
<ThemedProviders initialSession={initialSession}>
|
||||
{children}
|
||||
</ThemedProviders>
|
||||
</ThemeModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemedProviders({
|
||||
children,
|
||||
initialSession,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialSession: AuthSession | null;
|
||||
}) {
|
||||
const {mode} = useThemeMode();
|
||||
|
||||
return (
|
||||
<Theme theme={loyalyTheme} mode={mode}>
|
||||
<LinkProvider component={NextLink}>
|
||||
{/* One line, and every motion.* in the app honours prefers-reduced-motion. */}
|
||||
<MotionConfig reducedMotion="user">
|
||||
|
||||
@@ -24,7 +24,7 @@ interface MockUserRecord extends AuthUser {
|
||||
const USERS: MockUserRecord[] = [
|
||||
{
|
||||
id: 'usr_owner_01',
|
||||
email: 'aravind@nealre.in',
|
||||
email: 'aravind@loyaly.in',
|
||||
name: 'Aravind',
|
||||
role: 'owner',
|
||||
organisation: 'Loyaly Retail Pvt Ltd',
|
||||
@@ -39,12 +39,20 @@ const USERS: MockUserRecord[] = [
|
||||
password: 'admin123',
|
||||
},
|
||||
{
|
||||
id: 'usr_mgr_01',
|
||||
email: 'priya.sharma@loyaly.ai',
|
||||
name: 'Priya Sharma',
|
||||
role: 'manager',
|
||||
id: 'usr_owner_03',
|
||||
email: 'fazul@loyaly.in',
|
||||
name: 'Fazul Ilahi',
|
||||
role: 'owner',
|
||||
organisation: 'Loyaly Retail Pvt Ltd',
|
||||
password: 'indiranagar@01',
|
||||
password: 'admin123',
|
||||
},
|
||||
{
|
||||
id: 'usr_mgr_01',
|
||||
email: 'suriya@loyaly.in',
|
||||
name: 'Suriya',
|
||||
role: 'owner',
|
||||
organisation: 'Loyaly Retail Pvt Ltd',
|
||||
password: 'admin123',
|
||||
},
|
||||
{
|
||||
id: 'usr_analyst_01',
|
||||
|
||||
@@ -18,8 +18,8 @@ export function CommerceRecentOrders() {
|
||||
<div className="rounded-xl border border-border bg-card p-4 space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div>
|
||||
<Heading level={4} className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Icon icon={ICONS.purchases} size="sm" className="text-zinc-400" />
|
||||
<Heading level={4} className="text-base font-bold text-primary flex items-center gap-2">
|
||||
<Icon icon={ICONS.purchases} size="sm" className="text-secondary" />
|
||||
Recent Orders
|
||||
</Heading>
|
||||
<Text size="sm" color="secondary">
|
||||
@@ -29,21 +29,21 @@ export function CommerceRecentOrders() {
|
||||
<Badge variant="success" label="Live Feed" />
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-zinc-800/80">
|
||||
<div className="divide-y divide-border">
|
||||
{RECENT_ORDERS.map((ord) => (
|
||||
<div key={ord.id} className="py-2.5 first:pt-0 last:pb-0 flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<div className="font-bold text-white flex items-center gap-2">
|
||||
<div className="font-bold text-primary flex items-center gap-2">
|
||||
{ord.code}
|
||||
<span className="text-xs text-zinc-400 font-normal">• {ord.customer}</span>
|
||||
<span className="text-xs text-secondary font-normal">• {ord.customer}</span>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400 font-medium">
|
||||
<div className="text-xs text-secondary font-medium">
|
||||
{ord.store} ({ord.method})
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-white tabular-nums">{ord.amount}</div>
|
||||
<div className="text-xs text-zinc-400">{ord.time}</div>
|
||||
<div className="font-bold text-primary tabular-nums">{ord.amount}</div>
|
||||
<div className="text-xs text-secondary">{ord.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function CommerceRevenueSummary() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 space-y-4 shadow-sm">
|
||||
<div className="border-b border-border pb-3">
|
||||
<Heading level={4} className="text-base font-bold text-white">
|
||||
<Heading level={4} className="text-base font-bold text-primary">
|
||||
Revenue Summary
|
||||
</Heading>
|
||||
<Text size="sm" color="secondary">
|
||||
|
||||
@@ -34,7 +34,7 @@ export function CommerceSalesInsights() {
|
||||
<div className="rounded-xl border border-border bg-card p-4 space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<div>
|
||||
<Heading level={4} className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Heading level={4} className="text-base font-bold text-primary flex items-center gap-2">
|
||||
<Icon icon={ICONS.ai} size="sm" className="text-amber-400" />
|
||||
Sales Insights
|
||||
</Heading>
|
||||
@@ -55,7 +55,7 @@ export function CommerceSalesInsights() {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-white">{item.title}</span>
|
||||
<span className="text-xs font-bold text-primary">{item.title}</span>
|
||||
<Badge
|
||||
variant={item.intent === 'warning' ? 'warning' : 'success'}
|
||||
label={item.badge}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function CommerceWorkspace() {
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
{/* Sticky Header matching Dashboard visual language */}
|
||||
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-[#0F0F10] border-b border-[#2F2F2F] shadow-sm">
|
||||
<div className="sticky -top-5 z-40 -mx-5 px-5 pt-5 pb-4 bg-surface border-b border-border shadow-sm">
|
||||
<PageHeader
|
||||
eyebrow="Sales & Revenue Analytics"
|
||||
title="Commerce"
|
||||
|
||||
@@ -9,7 +9,7 @@ import {BUSINESS_ALERTS} from '../../services/commerceService';
|
||||
export function BusinessAlerts() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Heading level={4} className="text-base font-bold text-white">
|
||||
<Heading level={4} className="text-base font-bold text-primary">
|
||||
Real-Time Business & Operational Alerts
|
||||
</Heading>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function BusinessAlerts() {
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-bold text-white block">{alert.title}</span>
|
||||
<span className="text-sm font-bold text-primary block">{alert.title}</span>
|
||||
<Text size="sm" color="secondary" className="mt-0.5">
|
||||
{alert.description}
|
||||
</Text>
|
||||
@@ -60,10 +60,10 @@ export function BusinessAlerts() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-white/5 flex justify-end">
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-bold text-white hover:text-amber-300 transition-colors flex items-center gap-1"
|
||||
className="text-xs font-bold text-primary hover:text-amber-300 transition-colors flex items-center gap-1"
|
||||
>
|
||||
{alert.actionText} →
|
||||
</button>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function OperationalFlowcharts() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-5 space-y-6 shadow-sm overflow-hidden">
|
||||
<div>
|
||||
<Heading level={4} className="text-base font-bold text-white">
|
||||
<Heading level={4} className="text-base font-bold text-primary">
|
||||
Operational Flow Diagrams
|
||||
</Heading>
|
||||
<Text size="sm" color="secondary">
|
||||
@@ -48,12 +48,12 @@ export function OperationalFlowcharts() {
|
||||
<div className="flex items-center gap-2 min-w-[640px]">
|
||||
{ORDER_FLOW.map((node, i) => (
|
||||
<div key={node.step} className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1 rounded-lg border border-zinc-700 bg-zinc-900/90 p-2.5 text-center shadow-sm">
|
||||
<div className="text-xs font-bold text-white">{node.step}</div>
|
||||
<div className="text-[10px] text-zinc-400 font-medium mt-0.5">{node.detail}</div>
|
||||
<div className="flex-1 rounded-lg border border-border-strong bg-card p-2.5 text-center shadow-sm">
|
||||
<div className="text-xs font-bold text-primary">{node.step}</div>
|
||||
<div className="text-[10px] text-secondary font-medium mt-0.5">{node.detail}</div>
|
||||
</div>
|
||||
{i < ORDER_FLOW.length - 1 ? (
|
||||
<span className="text-zinc-500 font-bold text-sm shrink-0">→</span>
|
||||
<span className="text-disabled font-bold text-sm shrink-0">→</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
@@ -72,7 +72,7 @@ export function OperationalFlowcharts() {
|
||||
<div key={node.step} className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1 rounded-lg border border-emerald-950/60 bg-emerald-950/20 p-2.5 text-center shadow-sm">
|
||||
<div className="text-xs font-bold text-emerald-300">{node.step}</div>
|
||||
<div className="text-[10px] text-zinc-400 font-medium mt-0.5">{node.detail}</div>
|
||||
<div className="text-[10px] text-secondary font-medium mt-0.5">{node.detail}</div>
|
||||
</div>
|
||||
{i < REVENUE_FLOW.length - 1 ? (
|
||||
<span className="text-emerald-600 font-bold text-sm shrink-0">→</span>
|
||||
@@ -94,7 +94,7 @@ export function OperationalFlowcharts() {
|
||||
<div key={node.step} className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1 rounded-lg border border-indigo-950/60 bg-indigo-950/20 p-2.5 text-center shadow-sm">
|
||||
<div className="text-xs font-bold text-indigo-300">{node.step}</div>
|
||||
<div className="text-[10px] text-zinc-400 font-medium mt-0.5">{node.detail}</div>
|
||||
<div className="text-[10px] text-secondary font-medium mt-0.5">{node.detail}</div>
|
||||
</div>
|
||||
{i < INVENTORY_FLOW.length - 1 ? (
|
||||
<span className="text-indigo-600 font-bold text-sm shrink-0">→</span>
|
||||
|
||||
@@ -15,14 +15,14 @@ export function OrdersChart() {
|
||||
<div className="rounded-xl border border-border bg-card p-4 space-y-3 overflow-hidden shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Heading level={4} className="text-base font-bold text-white">
|
||||
<Heading level={4} className="text-base font-bold text-primary">
|
||||
Orders Volume Trend
|
||||
</Heading>
|
||||
<Text size="sm" color="secondary">
|
||||
Completed order count grouped by hourly buckets
|
||||
</Text>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-white bg-zinc-800 px-2.5 py-1 rounded-full border border-zinc-700">
|
||||
<span className="text-xs font-bold text-primary bg-muted px-2.5 py-1 rounded-full border border-border-strong">
|
||||
{totalOrders.toLocaleString()} Orders
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function PaymentBreakdown() {
|
||||
<div className="rounded-xl border border-border bg-card p-5 space-y-4 shadow-sm overflow-hidden">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<Heading level={4} className="text-base font-bold text-white">
|
||||
<Heading level={4} className="text-base font-bold text-primary">
|
||||
Payment Methods Breakdown
|
||||
</Heading>
|
||||
<Text size="sm" color="secondary">
|
||||
@@ -34,8 +34,8 @@ export function PaymentBreakdown() {
|
||||
onClick={() => setSelectedMethod(null)}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-bold transition-all ${
|
||||
selectedMethod === null
|
||||
? 'bg-zinc-700 text-white shadow-sm'
|
||||
: 'bg-zinc-900/80 text-zinc-400 hover:text-white border border-zinc-800'
|
||||
? 'bg-muted text-primary shadow-sm'
|
||||
: 'bg-card text-secondary hover:text-primary border border-border'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
@@ -49,8 +49,8 @@ export function PaymentBreakdown() {
|
||||
}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||
selectedMethod === item.method
|
||||
? 'text-white border shadow-sm'
|
||||
: 'bg-zinc-900/80 text-zinc-400 hover:text-white border border-zinc-800'
|
||||
? 'text-primary border shadow-sm'
|
||||
: 'bg-card text-secondary hover:text-primary border border-border'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: selectedMethod === item.method ? item.color : undefined,
|
||||
@@ -68,7 +68,7 @@ export function PaymentBreakdown() {
|
||||
</div>
|
||||
|
||||
{/* Visual Ring / Stack Bar */}
|
||||
<div className="h-4 w-full rounded-full bg-zinc-800 overflow-hidden flex shadow-inner p-0.5">
|
||||
<div className="h-4 w-full rounded-full bg-muted overflow-hidden flex shadow-inner p-0.5">
|
||||
{paymentData.map((item) => {
|
||||
const isSelected = selectedMethod === null || selectedMethod === item.method;
|
||||
return (
|
||||
@@ -101,10 +101,10 @@ export function PaymentBreakdown() {
|
||||
}
|
||||
className={`flex items-center justify-between p-3 rounded-xl cursor-pointer transition-all border ${
|
||||
isSelected
|
||||
? 'bg-zinc-800/90 shadow-md ring-1'
|
||||
? 'bg-muted shadow-md ring-1'
|
||||
: selectedMethod !== null
|
||||
? 'bg-zinc-900/40 border-zinc-800/60 opacity-60 hover:opacity-100'
|
||||
: 'bg-zinc-900/60 border-zinc-800 hover:border-zinc-700'
|
||||
? 'bg-card border-border opacity-60 hover:opacity-100'
|
||||
: 'bg-card border-border hover:border-border-strong'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: isSelected ? item.color : undefined,
|
||||
@@ -116,17 +116,17 @@ export function PaymentBreakdown() {
|
||||
style={{backgroundColor: item.color}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-xs font-bold text-white block truncate">
|
||||
<span className="text-xs font-bold text-primary block truncate">
|
||||
{item.method}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold text-zinc-400">
|
||||
<span className="text-[10px] font-semibold text-secondary">
|
||||
{item.percentage}% share
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-xs font-bold text-white tabular-nums">
|
||||
<div className="text-xs font-bold text-primary tabular-nums">
|
||||
{item.amount}
|
||||
</div>
|
||||
{isSelected ? (
|
||||
@@ -140,21 +140,21 @@ export function PaymentBreakdown() {
|
||||
|
||||
{/* Selected Channel Deep-Dive Banner */}
|
||||
{activeItem ? (
|
||||
<div className="p-3 rounded-lg bg-zinc-900/90 border border-zinc-700 flex items-center justify-between text-xs animate-fadeIn">
|
||||
<div className="p-3 rounded-lg bg-card border border-border-strong flex items-center justify-between text-xs animate-fadeIn">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="size-2.5 rounded-full"
|
||||
style={{backgroundColor: activeItem.color}}
|
||||
/>
|
||||
<span className="font-bold text-white">{activeItem.method} Channel Details:</span>
|
||||
<span className="text-zinc-300">
|
||||
<span className="font-bold text-primary">{activeItem.method} Channel Details:</span>
|
||||
<span className="text-secondary">
|
||||
{activeItem.amount} collected across selected scope terminals.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedMethod(null)}
|
||||
className="text-zinc-400 hover:text-white font-bold text-xs"
|
||||
className="text-secondary hover:text-primary font-bold text-xs"
|
||||
>
|
||||
Clear Filter ✕
|
||||
</button>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function RevenueChart() {
|
||||
<div className="rounded-xl border border-border bg-card p-4 space-y-3 overflow-hidden shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Heading level={4} className="text-base font-bold text-white">
|
||||
<Heading level={4} className="text-base font-bold text-primary">
|
||||
Revenue Trend
|
||||
</Heading>
|
||||
<Text size="sm" color="secondary">
|
||||
|
||||
@@ -9,7 +9,7 @@ export function StorePerformanceChart() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-5 space-y-4 shadow-sm overflow-hidden">
|
||||
<div>
|
||||
<Heading level={4} className="text-base font-bold text-white">
|
||||
<Heading level={4} className="text-base font-bold text-primary">
|
||||
Top Performing Outlets
|
||||
</Heading>
|
||||
<Text size="sm" color="secondary">
|
||||
@@ -23,12 +23,14 @@ export function StorePerformanceChart() {
|
||||
return (
|
||||
<div key={store.store} className="space-y-1">
|
||||
<div className="flex justify-between items-center text-xs font-semibold">
|
||||
<span className="text-white font-bold">{store.store}</span>
|
||||
<span className="text-primary font-bold">{store.store}</span>
|
||||
<span className="text-emerald-400 font-bold tabular-nums">
|
||||
₹{(store.Revenue / 100000).toFixed(1)}L
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-6 w-full rounded-md bg-zinc-900/80 p-0.5 border border-zinc-800">
|
||||
{/* bg-muted, not bg-card: the track sits INSIDE a card, so it
|
||||
needs the next surface step up to read as a track at all. */}
|
||||
<div className="h-6 w-full rounded-md bg-muted p-0.5 border border-border">
|
||||
<div
|
||||
className="h-full rounded bg-gradient-to-r from-emerald-600 to-emerald-400 transition-all duration-500"
|
||||
style={{width: `${widthPercent}%`}}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function TopProductsTable() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm space-y-3">
|
||||
<div className="p-4 border-b border-border bg-card">
|
||||
<Heading level={4} className="text-base font-bold text-white">
|
||||
<Heading level={4} className="text-base font-bold text-primary">
|
||||
Top Selling Products Leaderboard
|
||||
</Heading>
|
||||
<Text size="sm" color="secondary">
|
||||
@@ -23,7 +23,7 @@ export function TopProductsTable() {
|
||||
<div className="overflow-x-auto w-full">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-white font-bold">
|
||||
<tr className="border-b border-border bg-card text-primary font-bold">
|
||||
<th className="px-4 py-3 text-left">Product Name</th>
|
||||
<th className="px-4 py-3 text-left">Category</th>
|
||||
<th className="px-4 py-3 text-right">Units Sold</th>
|
||||
@@ -32,13 +32,13 @@ export function TopProductsTable() {
|
||||
<th className="px-4 py-3 text-center">Stock Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
<tbody className="divide-y divide-border">
|
||||
{products.map((prod) => (
|
||||
<tr key={prod.id} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-4 py-3.5 text-white font-semibold">{prod.name}</td>
|
||||
<td className="px-4 py-3.5 text-zinc-300 font-medium">{prod.category}</td>
|
||||
<td className="px-4 py-3.5 text-right text-white font-bold tabular-nums">{prod.unitsSold.toLocaleString()}</td>
|
||||
<td className="px-4 py-3.5 text-right text-white font-bold tabular-nums">{prod.revenue}</td>
|
||||
<tr key={prod.id} className="hover:bg-overlay-hover transition-colors">
|
||||
<td className="px-4 py-3.5 text-primary font-semibold">{prod.name}</td>
|
||||
<td className="px-4 py-3.5 text-secondary font-medium">{prod.category}</td>
|
||||
<td className="px-4 py-3.5 text-right text-primary font-bold tabular-nums">{prod.unitsSold.toLocaleString()}</td>
|
||||
<td className="px-4 py-3.5 text-right text-primary font-bold tabular-nums">{prod.revenue}</td>
|
||||
<td className="px-4 py-3.5 text-right font-bold tabular-nums">
|
||||
<span className={prod.growthDirection === 'up' ? 'text-emerald-400' : 'text-rose-400'}>
|
||||
{prod.growth}
|
||||
|
||||
@@ -29,8 +29,8 @@ export function CommerceFilterToolbar({
|
||||
onClick={() => onFilterChange({...filters, dateRange: r})}
|
||||
className={`px-2.5 py-1 rounded-md transition-colors capitalize ${
|
||||
filters.dateRange === r
|
||||
? 'bg-zinc-800 text-white font-bold'
|
||||
: 'text-zinc-400 hover:text-white'
|
||||
? 'bg-muted text-primary font-bold'
|
||||
: 'text-secondary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{r === 'today' ? 'Today' : r === '7d' ? '7 Days' : r === '30d' ? '30 Days' : '90 Days'}
|
||||
@@ -42,7 +42,7 @@ export function CommerceFilterToolbar({
|
||||
<select
|
||||
value={filters.storeId}
|
||||
onChange={(e) => onFilterChange({...filters, storeId: e.target.value})}
|
||||
className="h-8 rounded-lg border border-border bg-card px-2.5 text-xs font-semibold text-white outline-none focus:border-zinc-500"
|
||||
className="h-8 rounded-lg border border-border bg-card px-2.5 text-xs font-semibold text-primary outline-none focus:border-border-strong"
|
||||
>
|
||||
<option value="all">All Active Stores</option>
|
||||
<option value="indiranagar">Indiranagar Flagship</option>
|
||||
|
||||
@@ -33,15 +33,15 @@ import {ICONS} from '@/shared/utils/icons';
|
||||
const MAX_HEIGHT_PX = 132;
|
||||
|
||||
const PILL = [
|
||||
'rounded-[28px] bg-zinc-900/90 border border-zinc-700/80 shadow-md',
|
||||
'rounded-[28px] bg-card border border-border-strong shadow-md',
|
||||
'transition-colors duration-150',
|
||||
'focus-within:border-zinc-500 focus-within:ring-1 focus-within:ring-zinc-500',
|
||||
'focus-within:border-border-strong focus-within:ring-1 focus-within:ring-border-strong',
|
||||
'pl-4 pr-2 py-1.5',
|
||||
].join(' ');
|
||||
|
||||
const FIELD = [
|
||||
'flex-1 min-w-0 resize-none bg-transparent border-0 outline-none',
|
||||
'text-sm font-medium text-white placeholder:text-zinc-400 placeholder:font-normal',
|
||||
'text-sm font-medium text-primary placeholder:text-secondary placeholder:font-normal',
|
||||
'py-2 leading-6 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden',
|
||||
].join(' ');
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ export function ChartBlock({charts}: {charts: ChartPartSpec[]}) {
|
||||
className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-3 overflow-hidden"
|
||||
>
|
||||
<div>
|
||||
<Heading level={4} className="text-base font-bold text-white">
|
||||
<Heading level={4} className="text-base font-bold text-primary">
|
||||
{c.title}
|
||||
</Heading>
|
||||
{c.subtitle ? (
|
||||
<p className="text-sm font-medium text-zinc-300 mt-0.5">{c.subtitle}</p>
|
||||
<p className="text-sm font-medium text-secondary mt-0.5">{c.subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,13 +57,13 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1.5">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="text-lg shrink-0">{meta.icon}</span>
|
||||
<span className="text-base font-bold text-white whitespace-normal break-words min-w-0">
|
||||
<span className="text-base font-bold text-primary whitespace-normal break-words min-w-0">
|
||||
{insight.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{insight.confidence ? (
|
||||
<span className="text-sm font-semibold text-zinc-300 font-mono tabular-nums whitespace-nowrap">
|
||||
<span className="text-sm font-semibold text-secondary font-mono tabular-nums whitespace-nowrap">
|
||||
{insight.confidence}% confidence
|
||||
</span>
|
||||
) : null}
|
||||
@@ -71,14 +71,14 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-zinc-200 leading-relaxed whitespace-normal break-words">
|
||||
<p className="text-sm font-medium text-primary leading-relaxed whitespace-normal break-words">
|
||||
{insight.explanation}
|
||||
</p>
|
||||
|
||||
{insight.recommendedAction ? (
|
||||
<div className="pt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm font-semibold">
|
||||
<span className="text-amber-300 font-bold shrink-0">Action:</span>
|
||||
<span className="text-white whitespace-normal break-words min-w-0">
|
||||
<span className="text-primary whitespace-normal break-words min-w-0">
|
||||
{insight.recommendedAction}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,7 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
|
||||
so a long token can still break as a last resort but ordinary
|
||||
prose never breaks mid-word.
|
||||
*/}
|
||||
<span className="text-xs font-semibold text-zinc-300 whitespace-normal break-words min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold text-secondary whitespace-normal break-words min-w-0 flex-1">
|
||||
{kpi.title}
|
||||
</span>
|
||||
{kpi.iconName ? (
|
||||
@@ -98,14 +98,14 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
|
||||
width decides whether there is room for the larger setting,
|
||||
which is the question `sm:` was answering with the window's.
|
||||
*/}
|
||||
<span className="text-xl @xs/report:text-2xl font-bold tracking-tight text-white tabular-nums whitespace-nowrap shrink-0">
|
||||
<span className="text-xl @xs/report:text-2xl font-bold tracking-tight text-primary tabular-nums whitespace-nowrap shrink-0">
|
||||
{kpi.value}
|
||||
</span>
|
||||
{kpi.trend ? (
|
||||
<span
|
||||
title={kpi.trend}
|
||||
className={`text-xs font-bold truncate max-w-full min-w-0 ${
|
||||
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-zinc-300'
|
||||
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-secondary'
|
||||
}`}
|
||||
>
|
||||
{kpi.trend}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
|
||||
>
|
||||
{t.title ? (
|
||||
<div className="p-4 border-b border-border bg-card">
|
||||
<Heading level={4} className="text-base font-bold text-white whitespace-normal break-words">
|
||||
<Heading level={4} className="text-base font-bold text-primary whitespace-normal break-words">
|
||||
{t.title}
|
||||
</Heading>
|
||||
</div>
|
||||
@@ -38,11 +38,11 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
|
||||
className="w-full min-w-full table-auto text-left text-sm border-collapse"
|
||||
>
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-700 bg-zinc-800/80 text-white font-bold">
|
||||
<tr className="border-b border-border-strong bg-muted text-primary font-bold">
|
||||
{t.columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3.5 py-3.5 whitespace-nowrap text-sm font-bold text-white ${
|
||||
className={`px-3.5 py-3.5 whitespace-nowrap text-sm font-bold text-primary ${
|
||||
col.align === 'end'
|
||||
? 'text-right'
|
||||
: col.align === 'center'
|
||||
@@ -55,9 +55,9 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/80">
|
||||
<tbody className="divide-y divide-border">
|
||||
{t.rows.map((row, rIdx) => (
|
||||
<tr key={rIdx} className="hover:bg-white/[0.04] transition-colors">
|
||||
<tr key={rIdx} className="hover:bg-overlay-hover transition-colors">
|
||||
{t.columns.map((col) => {
|
||||
const val = row[col.key];
|
||||
const valStr = String(val ?? '');
|
||||
@@ -66,7 +66,7 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3.5 py-3.5 text-white font-semibold align-middle text-sm ${
|
||||
className={`px-3.5 py-3.5 text-primary font-semibold align-middle text-sm ${
|
||||
col.align === 'end'
|
||||
? 'text-right tabular-nums whitespace-nowrap'
|
||||
: col.align === 'center'
|
||||
|
||||
@@ -8,9 +8,11 @@ import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {useThemeMode} from '@/shared/providers/ThemeModeProvider';
|
||||
import type {ThemeMode} from '@/shared/theme/themeMode';
|
||||
|
||||
/** Theme mode is deliberately absent — it lives in ThemeModeProvider. */
|
||||
export interface SystemPreferences {
|
||||
theme: 'dark' | 'light' | 'system';
|
||||
currency: 'INR' | 'USD' | 'EUR' | 'GBP';
|
||||
language: 'en' | 'hi' | 'kn' | 'ta';
|
||||
dateFormat: 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD';
|
||||
@@ -18,7 +20,6 @@ export interface SystemPreferences {
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: SystemPreferences = {
|
||||
theme: 'dark',
|
||||
currency: 'INR',
|
||||
language: 'en',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
@@ -27,6 +28,14 @@ const DEFAULT_PREFS: SystemPreferences = {
|
||||
|
||||
export function PreferencesForm() {
|
||||
const toast = useToast();
|
||||
/**
|
||||
* Theme is the one preference that does NOT go through the save cycle.
|
||||
* It repaints the workspace the instant it changes and persists itself to
|
||||
* the theme-mode cookie, so there is nothing left for Save to commit — and
|
||||
* a mode you can see but have not "saved" would be the confusing state.
|
||||
* The rest of the form is still mock local state.
|
||||
*/
|
||||
const {mode: themeMode, setMode: setThemeMode} = useThemeMode();
|
||||
const [prefs, setPrefs] = useState<SystemPreferences>(DEFAULT_PREFS);
|
||||
const [savedPrefs, setSavedPrefs] = useState<SystemPreferences>(DEFAULT_PREFS);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -80,8 +89,8 @@ export function PreferencesForm() {
|
||||
<FormLayout direction="horizontal">
|
||||
<Selector
|
||||
label="Theme Interface Mode"
|
||||
value={prefs.theme}
|
||||
onChange={(v) => set('theme', v as SystemPreferences['theme'])}
|
||||
value={themeMode}
|
||||
onChange={(v) => setThemeMode(v as ThemeMode)}
|
||||
options={[
|
||||
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
|
||||
{label: 'Light Mode', value: 'light'},
|
||||
|
||||
@@ -19,7 +19,10 @@ import {ChartTooltip} from './ChartTooltip';
|
||||
*/
|
||||
|
||||
export const AXIS_TICK = {
|
||||
fill: '#e4e4e7',
|
||||
// recharts writes this straight onto the SVG fill attribute, where var()
|
||||
// resolves natively — so ticks follow the mode instead of staying at the
|
||||
// one light gray that only ever read on a dark canvas.
|
||||
fill: CHART.axis,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
} as const;
|
||||
|
||||
@@ -152,41 +152,41 @@ export function DownloadDropdown({
|
||||
left: `${coords.left}px`,
|
||||
zIndex: 99999,
|
||||
}}
|
||||
className="w-56 rounded-xl border border-zinc-700 bg-[#18181b] backdrop-blur-xl shadow-2xl z-[99999] py-1.5 divide-y divide-zinc-800 select-none"
|
||||
className="w-56 rounded-xl border border-border-strong bg-popover backdrop-blur-xl shadow-2xl z-[99999] py-1.5 divide-y divide-border select-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
|
||||
className="w-full text-left px-4 py-3 hover:bg-overlay-pressed flex items-center gap-3 transition-colors cursor-pointer"
|
||||
onClick={() => handleSelectFormat('pdf')}
|
||||
>
|
||||
<span className="text-xl">📄</span>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-white">PDF Document</div>
|
||||
<div className="text-xs text-zinc-300">.pdf file format</div>
|
||||
<div className="font-semibold text-sm text-primary">PDF Document</div>
|
||||
<div className="text-xs text-secondary">.pdf file format</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
|
||||
className="w-full text-left px-4 py-3 hover:bg-overlay-pressed flex items-center gap-3 transition-colors cursor-pointer"
|
||||
onClick={() => handleSelectFormat('excel')}
|
||||
>
|
||||
<span className="text-xl">📊</span>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-white">Excel Spreadsheet</div>
|
||||
<div className="text-xs text-zinc-300">.xlsx file format</div>
|
||||
<div className="font-semibold text-sm text-primary">Excel Spreadsheet</div>
|
||||
<div className="text-xs text-secondary">.xlsx file format</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
|
||||
className="w-full text-left px-4 py-3 hover:bg-overlay-pressed flex items-center gap-3 transition-colors cursor-pointer"
|
||||
onClick={() => handleSelectFormat('csv')}
|
||||
>
|
||||
<span className="text-xl">📑</span>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-white">CSV Data File</div>
|
||||
<div className="text-xs text-zinc-300">.csv file format</div>
|
||||
<div className="font-semibold text-sm text-primary">CSV Data File</div>
|
||||
<div className="text-xs text-secondary">.csv file format</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>,
|
||||
|
||||
@@ -65,7 +65,7 @@ import {useAccountActions} from './useAccountActions';
|
||||
const LAYER_OFFSET = 'mb-2';
|
||||
|
||||
/** The chip. Rounded like a nav item, and lit while its menu is open. */
|
||||
const CHIP = 'rounded-lg transition-colors duration-150 cursor-pointer hover:bg-white/[0.06] p-2';
|
||||
const CHIP = 'rounded-lg transition-colors duration-150 cursor-pointer hover:bg-overlay-hover p-2';
|
||||
|
||||
/**
|
||||
* The collapsed chip, sized to the rail rather than to IconButton's default.
|
||||
|
||||
62
src/shared/providers/ThemeModeProvider.tsx
Normal file
62
src/shared/providers/ThemeModeProvider.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import {createContext, useCallback, useContext, useMemo, useState} from 'react';
|
||||
import {
|
||||
THEME_MODE_COOKIE,
|
||||
THEME_MODE_MAX_AGE,
|
||||
type ThemeMode,
|
||||
} from '@/shared/theme/themeMode';
|
||||
|
||||
/**
|
||||
* Holds the workspace colour mode and persists it to a cookie.
|
||||
*
|
||||
* This sits ABOVE <Theme> in app/providers.tsx — it has to, because <Theme>
|
||||
* takes the mode as a prop and cannot read a context rendered inside itself.
|
||||
* Everything downstream of the mode change is CSS: <Theme> flips
|
||||
* `color-scheme` on its wrapper and on <html>, and every colour token in
|
||||
* loyaly.css is a `light-dark()` pair, so the whole surface repaints without
|
||||
* a single component re-reading a colour.
|
||||
*
|
||||
* The cookie (not localStorage) is what lets the server render the chosen
|
||||
* mode on the first byte — see @/shared/theme/themeMode.
|
||||
*/
|
||||
|
||||
interface ThemeModeValue {
|
||||
/** What the user picked, including the literal 'system'. */
|
||||
mode: ThemeMode;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
const ThemeModeContext = createContext<ThemeModeValue | null>(null);
|
||||
ThemeModeContext.displayName = 'ThemeModeContext';
|
||||
|
||||
export function ThemeModeProvider({
|
||||
initialMode,
|
||||
children,
|
||||
}: {
|
||||
/** Read from the cookie in the root layout, so SSR paints it correctly. */
|
||||
initialMode: ThemeMode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [mode, setModeState] = useState<ThemeMode>(initialMode);
|
||||
|
||||
const setMode = useCallback((next: ThemeMode) => {
|
||||
setModeState(next);
|
||||
// Written directly rather than through a server action: this is a display
|
||||
// preference with no server-side consequence, and a round trip would put
|
||||
// a network hop between the click and the repaint.
|
||||
document.cookie = `${THEME_MODE_COOKIE}=${next}; path=/; max-age=${THEME_MODE_MAX_AGE}; samesite=lax`;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({mode, setMode}), [mode, setMode]);
|
||||
|
||||
return <ThemeModeContext value={value}>{children}</ThemeModeContext>;
|
||||
}
|
||||
|
||||
export function useThemeMode(): ThemeModeValue {
|
||||
const ctx = useContext(ThemeModeContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useThemeMode must be used within <ThemeModeProvider>');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
45
src/shared/theme/themeMode.ts
Normal file
45
src/shared/theme/themeMode.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The colour-mode contract, shared by the server layout and the client
|
||||
* provider. Kept free of 'use client' and of any React import so the root
|
||||
* layout (a server component) can read the cookie without pulling the
|
||||
* provider module into the server graph.
|
||||
*
|
||||
* WHY A COOKIE AND NOT localStorage
|
||||
* <Theme> renders a wrapper element whose color-scheme comes from the mode
|
||||
* prop, so the mode has to be known during SSR — a localStorage read only
|
||||
* happens after hydration, which would mean either a flash of the wrong
|
||||
* scheme or a hydration mismatch on that wrapper. The cookie travels with
|
||||
* the request, so the server renders the mode the user actually chose.
|
||||
*/
|
||||
|
||||
export type ThemeMode = 'dark' | 'light' | 'system';
|
||||
|
||||
export const THEME_MODE_COOKIE = 'loyaly.theme-mode';
|
||||
|
||||
/** One year — a display preference should outlive the session cookie. */
|
||||
export const THEME_MODE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
/**
|
||||
* Dark is the product's identity, so it is what an unknown visitor gets —
|
||||
* not 'system'. Only an explicit choice moves off it.
|
||||
*/
|
||||
export const DEFAULT_THEME_MODE: ThemeMode = 'dark';
|
||||
|
||||
export function parseThemeMode(value: string | undefined | null): ThemeMode {
|
||||
return value === 'light' || value === 'dark' || value === 'system'
|
||||
? value
|
||||
: DEFAULT_THEME_MODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value for `<html data-theme>`.
|
||||
*
|
||||
* 'system' deliberately maps to undefined: the attribute's whole job is to
|
||||
* pin `color-scheme`, and system mode wants `light dark` — i.e. no pin — so
|
||||
* that `light-dark()` follows the OS. This mirrors what Astryx's <Theme>
|
||||
* does to documentElement after hydration; matching it here is what keeps
|
||||
* the server and client attribute identical.
|
||||
*/
|
||||
export function htmlThemeAttr(mode: ThemeMode): 'light' | 'dark' | undefined {
|
||||
return mode === 'system' ? undefined : mode;
|
||||
}
|
||||
Reference in New Issue
Block a user