diff --git a/src/app/(workspace)/dashboard/page.tsx b/src/app/(workspace)/dashboard/page.tsx index ba50341..0271e45 100644 --- a/src/app/(workspace)/dashboard/page.tsx +++ b/src/app/(workspace)/dashboard/page.tsx @@ -80,7 +80,7 @@ export default function DashboardPage() { return ( {/* 1 + 2 — Sticky header: greeting, title, description, and filter controls. */} -
+
. 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. diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bb3f3e9..7dc90cc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 { + 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 { + 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 + // re-syncs this same attribute after hydration; it writes exactly + // what htmlThemeAttr() writes here, so the two never disagree. - {children} + + {children} + ); diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 3918c2b..c7eff4c 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -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 ( - + // Outside , because takes the mode as a prop — a context + // rendered inside it could never reach it. + + + {children} + + + ); +} + +function ThemedProviders({ + children, + initialSession, +}: { + children: React.ReactNode; + initialSession: AuthSession | null; +}) { + const {mode} = useThemeMode(); + + return ( + {/* One line, and every motion.* in the app honours prefers-reduced-motion. */} diff --git a/src/features/auth/mock/users.mock.ts b/src/features/auth/mock/users.mock.ts index c2f5cda..3d2eca5 100644 --- a/src/features/auth/mock/users.mock.ts +++ b/src/features/auth/mock/users.mock.ts @@ -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', diff --git a/src/features/commerce/components/CommerceRecentOrders.tsx b/src/features/commerce/components/CommerceRecentOrders.tsx index f22ae98..f5db832 100644 --- a/src/features/commerce/components/CommerceRecentOrders.tsx +++ b/src/features/commerce/components/CommerceRecentOrders.tsx @@ -18,8 +18,8 @@ export function CommerceRecentOrders() {
- - + + Recent Orders @@ -29,21 +29,21 @@ export function CommerceRecentOrders() {
-
+
{RECENT_ORDERS.map((ord) => (
-
+
{ord.code} - • {ord.customer} + • {ord.customer}
-
+
{ord.store} ({ord.method})
-
{ord.amount}
-
{ord.time}
+
{ord.amount}
+
{ord.time}
))} diff --git a/src/features/commerce/components/CommerceRevenueSummary.tsx b/src/features/commerce/components/CommerceRevenueSummary.tsx index f67799b..9033f0a 100644 --- a/src/features/commerce/components/CommerceRevenueSummary.tsx +++ b/src/features/commerce/components/CommerceRevenueSummary.tsx @@ -13,7 +13,7 @@ export function CommerceRevenueSummary() { return (
- + Revenue Summary diff --git a/src/features/commerce/components/CommerceSalesInsights.tsx b/src/features/commerce/components/CommerceSalesInsights.tsx index b1e38c7..4446894 100644 --- a/src/features/commerce/components/CommerceSalesInsights.tsx +++ b/src/features/commerce/components/CommerceSalesInsights.tsx @@ -34,7 +34,7 @@ export function CommerceSalesInsights() {
- + Sales Insights @@ -55,7 +55,7 @@ export function CommerceSalesInsights() { }`} >
- {item.title} + {item.title} {/* Sticky Header matching Dashboard visual language */} -
+
- + Real-Time Business & Operational Alerts @@ -48,7 +48,7 @@ export function BusinessAlerts() { } />
- {alert.title} + {alert.title} {alert.description} @@ -60,10 +60,10 @@ export function BusinessAlerts() { />
-
+
diff --git a/src/features/commerce/components/analytics/OperationalFlowcharts.tsx b/src/features/commerce/components/analytics/OperationalFlowcharts.tsx index d9d12b4..77c2b6a 100644 --- a/src/features/commerce/components/analytics/OperationalFlowcharts.tsx +++ b/src/features/commerce/components/analytics/OperationalFlowcharts.tsx @@ -31,7 +31,7 @@ export function OperationalFlowcharts() { return (
- + Operational Flow Diagrams @@ -48,12 +48,12 @@ export function OperationalFlowcharts() {
{ORDER_FLOW.map((node, i) => (
-
-
{node.step}
-
{node.detail}
+
+
{node.step}
+
{node.detail}
{i < ORDER_FLOW.length - 1 ? ( - → + → ) : null}
))} @@ -72,7 +72,7 @@ export function OperationalFlowcharts() {
{node.step}
-
{node.detail}
+
{node.detail}
{i < REVENUE_FLOW.length - 1 ? ( → @@ -94,7 +94,7 @@ export function OperationalFlowcharts() {
{node.step}
-
{node.detail}
+
{node.detail}
{i < INVENTORY_FLOW.length - 1 ? ( → diff --git a/src/features/commerce/components/analytics/OrdersChart.tsx b/src/features/commerce/components/analytics/OrdersChart.tsx index 70e9677..95c9605 100644 --- a/src/features/commerce/components/analytics/OrdersChart.tsx +++ b/src/features/commerce/components/analytics/OrdersChart.tsx @@ -15,14 +15,14 @@ export function OrdersChart() {
- + Orders Volume Trend Completed order count grouped by hourly buckets
- + {totalOrders.toLocaleString()} Orders
diff --git a/src/features/commerce/components/analytics/PaymentBreakdown.tsx b/src/features/commerce/components/analytics/PaymentBreakdown.tsx index d6c78d5..b16d3bd 100644 --- a/src/features/commerce/components/analytics/PaymentBreakdown.tsx +++ b/src/features/commerce/components/analytics/PaymentBreakdown.tsx @@ -19,7 +19,7 @@ export function PaymentBreakdown() {
- + Payment Methods Breakdown @@ -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() {
{/* Visual Ring / Stack Bar */} -
+
{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}} />
- + {item.method} - + {item.percentage}% share
-
+
{item.amount}
{isSelected ? ( @@ -140,21 +140,21 @@ export function PaymentBreakdown() { {/* Selected Channel Deep-Dive Banner */} {activeItem ? ( -
+
- {activeItem.method} Channel Details: - + {activeItem.method} Channel Details: + {activeItem.amount} collected across selected scope terminals.
diff --git a/src/features/commerce/components/analytics/RevenueChart.tsx b/src/features/commerce/components/analytics/RevenueChart.tsx index 457b848..781c165 100644 --- a/src/features/commerce/components/analytics/RevenueChart.tsx +++ b/src/features/commerce/components/analytics/RevenueChart.tsx @@ -13,7 +13,7 @@ export function RevenueChart() {
- + Revenue Trend diff --git a/src/features/commerce/components/analytics/StorePerformanceChart.tsx b/src/features/commerce/components/analytics/StorePerformanceChart.tsx index e37c357..70a737a 100644 --- a/src/features/commerce/components/analytics/StorePerformanceChart.tsx +++ b/src/features/commerce/components/analytics/StorePerformanceChart.tsx @@ -9,7 +9,7 @@ export function StorePerformanceChart() { return (
- + Top Performing Outlets @@ -23,12 +23,14 @@ export function StorePerformanceChart() { return (
- {store.store} + {store.store} ₹{(store.Revenue / 100000).toFixed(1)}L
-
+ {/* 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. */} +
- + Top Selling Products Leaderboard @@ -23,7 +23,7 @@ export function TopProductsTable() {
- + @@ -32,13 +32,13 @@ export function TopProductsTable() { - + {products.map((prod) => ( - - - - - + + + + + - + {t.columns.map((col) => ( - + {t.rows.map((row, rIdx) => ( - + {t.columns.map((col) => { const val = row[col.key]; const valStr = String(val ?? ''); @@ -66,7 +66,7 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) { return (
Product Name Category Units SoldStock Status
{prod.name}{prod.category}{prod.unitsSold.toLocaleString()}{prod.revenue}
{prod.name}{prod.category}{prod.unitsSold.toLocaleString()}{prod.revenue} {prod.growth} diff --git a/src/features/commerce/components/filters/CommerceFilterToolbar.tsx b/src/features/commerce/components/filters/CommerceFilterToolbar.tsx index 334bb0a..394824e 100644 --- a/src/features/commerce/components/filters/CommerceFilterToolbar.tsx +++ b/src/features/commerce/components/filters/CommerceFilterToolbar.tsx @@ -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({
(DEFAULT_PREFS); const [savedPrefs, setSavedPrefs] = useState(DEFAULT_PREFS); const [isSaving, setIsSaving] = useState(false); @@ -80,8 +89,8 @@ export function PreferencesForm() { 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'}, diff --git a/src/shared/components/charts/ChartFrame.tsx b/src/shared/components/charts/ChartFrame.tsx index 8d5000c..d9a18ac 100644 --- a/src/shared/components/charts/ChartFrame.tsx +++ b/src/shared/components/charts/ChartFrame.tsx @@ -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; diff --git a/src/shared/components/patterns/DownloadDropdown.tsx b/src/shared/components/patterns/DownloadDropdown.tsx index 844b94e..f6730d3 100644 --- a/src/shared/components/patterns/DownloadDropdown.tsx +++ b/src/shared/components/patterns/DownloadDropdown.tsx @@ -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" > , diff --git a/src/shared/layouts/workspace/AccountMenu.tsx b/src/shared/layouts/workspace/AccountMenu.tsx index 8314338..3ea83af 100644 --- a/src/shared/layouts/workspace/AccountMenu.tsx +++ b/src/shared/layouts/workspace/AccountMenu.tsx @@ -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. diff --git a/src/shared/providers/ThemeModeProvider.tsx b/src/shared/providers/ThemeModeProvider.tsx new file mode 100644 index 0000000..306f00c --- /dev/null +++ b/src/shared/providers/ThemeModeProvider.tsx @@ -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 in app/providers.tsx — it has to, because + * takes the mode as a prop and cannot read a context rendered inside itself. + * Everything downstream of the mode change is CSS: flips + * `color-scheme` on its wrapper and on , 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(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(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 {children}; +} + +export function useThemeMode(): ThemeModeValue { + const ctx = useContext(ThemeModeContext); + if (!ctx) { + throw new Error('useThemeMode must be used within '); + } + return ctx; +} diff --git a/src/shared/theme/themeMode.ts b/src/shared/theme/themeMode.ts new file mode 100644 index 0000000..0fd7262 --- /dev/null +++ b/src/shared/theme/themeMode.ts @@ -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 + * 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 ``. + * + * '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 + * 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; +}