dashboard design changes

This commit is contained in:
2026-08-05 18:34:37 +05:30
parent 2c8c394795
commit 9d7bbad32c
92 changed files with 5104 additions and 608 deletions

View File

@@ -0,0 +1,45 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/components/primitives/PageHeader';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {storeName} from '@/lib/mock/stores';
/**
* The complete event history — where the dashboard's "View all" leads.
*
* Not in the sidebar on purpose. It is a drill-down from one panel, the same
* relationship /stores/[storeId] has to the store roster, and the nav is the
* four modules the product is organised around. Adding a fifth entry for a log
* would put an operational archive at the same level as Lyts and Staff, which
* is the weighting this whole change is correcting.
*
* Reads the same resource and the same component the dashboard panel does,
* uncapped: one feed implementation, two budgets.
*/
export default function ActivityPage() {
const {storeId, range} = useWorkspace();
const activity = useResource(endpoints.dashboardActivity({range, storeId}));
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
return (
<VStack gap={5}>
<PageHeader
title="Activity"
description={`Every recorded event across ${scopeLabel}, newest first.`}
controls={<ScopeControls />}
/>
<ActivityTimeline
resource={activity}
title="All activity"
subtitle="Redemptions, purchases, attendance, store events and expiries"
/>
</VStack>
);
}

View File

@@ -3,7 +3,11 @@
import {useState} from 'react';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {PageHeader} from '@/components/primitives/PageHeader';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ICONS} from '@/lib/icons';
import {ChartCard} from '@/components/charts/ChartCard';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {LineChartView} from '@/components/charts/LineChartView';
@@ -27,18 +31,51 @@ import {
} from '@/lib/format';
/**
* The dashboard answers three questions in order, top to bottom:
* 1. How is the business performing? → KPI row
* 2. What is the shape of that? → footfall / revenue / conversion
* 3. What needs attention, and when? → peak hours, rewards, stores, feed
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
*
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider,
* so changing either control in the top nav re-queries the whole page without
* a route change — which is what keeps the Copilot mounted alongside it.
* The page reads top-down as one narrowing question: headline numbers → the two
* trends that drive them → the conversion story behind those → when and on what
* it happens → the period rollup → which store → what just happened.
*
* Operational widgets — quick actions, tasks, the AI briefing, top store/reward,
* staff status — deliberately do NOT live here. The briefing endpoint they were
* built on is still live and feeds the Copilot's AI tab, which is where that
* class of content belongs: a panel you open to be told what to do, beside a
* dashboard you read to work it out yourself.
*
* Recent activity keeps its compact form — six rows in a fixed box, full history
* on /activity. The uncapped version reached 784px, taller than any chart on the
* page, which is an operational log outweighing the analytics it sits among.
*
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider, set
* from this page's header. `series` is fetched once and shared by the four
* charts drawn from it, so they cannot disagree.
*/
/**
* Derived from the SERVER's clock, carried on the response meta.
*
* Calling Date.now() during render is impure and disagrees between the SSR and
* hydration passes. Before meta lands there is no instant to reason about, so
* the greeting is the time-independent one; both strings are a single line, so
* the swap costs no layout shift. getHours() resolves the server's instant in
* the viewer's zone, which is the hour the merchant is actually living in.
*/
function greetingFor(generatedAt?: string): string {
if (!generatedAt) return 'Welcome back';
const hour = new Date(generatedAt).getHours();
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
}
export default function DashboardPage() {
const {storeId, range} = useWorkspace();
const [granularity, setGranularity] = useState<Granularity>('weekly');
// On by default: store comparison has always been part of the all-stores
// dashboard, so Compare starts pressed and switches the panel off, rather
// than hiding a panel that used to be there until someone finds the button.
const [isComparing, setIsComparing] = useState(true);
const scope = {range, storeId};
@@ -49,17 +86,46 @@ export default function DashboardPage() {
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
const isAllStores = storeId === 'all';
const scopeLabel = isAllStores ? 'all stores' : storeName(storeId);
return (
<VStack gap={5}>
{/* 1 + 2 — greeting, title, and the store / period / compare filters. */}
<PageHeader
eyebrow={greetingFor(kpis.meta?.generatedAt)}
title="Dashboard"
description={`How the business is performing across ${scopeLabel}, what needs attention, and what to do next.`}
description={`Business performance across ${scopeLabel}.`}
controls={
<>
<ScopeControls />
<Button
size="sm"
// Pressed state is the raised gray of `primary` in this theme —
// a toggle needs a visible on-state, and it must not be colour.
variant={isComparing ? 'primary' : 'secondary'}
label="Compare"
icon={<Icon icon={ICONS.compare} size="sm" />}
aria-pressed={isComparing}
isDisabled={!isAllStores}
// With a tooltip present, Button uses aria-disabled rather than
// the native attribute, so the reason stays reachable by keyboard
// instead of the control just going dead.
tooltip={
isAllStores
? undefined
: 'Comparing stores needs the All stores scope'
}
onClick={() => setIsComparing((v) => !v)}
/>
</>
}
/>
{/* 3 — headline numbers. */}
<KpiRow resource={kpis} />
{/* 4 — primary analytics: the two series everything else explains. */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
{(d) => (
@@ -84,7 +150,10 @@ export default function DashboardPage() {
/>
)}
</ChartCard>
</Grid>
{/* 5 — secondary analytics: the conversion story, in two readings. */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Visitors vs purchases"
subtitle="The gap is the conversion opportunity"
@@ -124,6 +193,7 @@ export default function DashboardPage() {
</ChartCard>
</Grid>
{/* 6 — operational analytics: when traffic lands, what it redeems. */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Peak hours"
@@ -137,18 +207,27 @@ export default function DashboardPage() {
<RewardUsageChart resource={rewards} />
</Grid>
{/* 7 — period rollup. */}
<PerformancePanel
scope={scope}
granularity={granularity}
onGranularityChange={setGranularity}
/>
{/* Comparing stores is meaningless when scoped to one of them. */}
{storeId === 'all' ? (
{/* 8 — comparing stores is meaningless when scoped to one of them, which
is why Compare is disabled rather than merely off in that case. */}
{isAllStores && isComparing ? (
<StoreComparisonPanel resource={comparison} />
) : null}
<ActivityTimeline resource={activity} />
{/* 9 — bounded feed: six rows, fixed box, full history on /activity. */}
<ActivityTimeline
resource={activity}
subtitle="Latest events across the selected store and period"
limit={6}
height={285}
viewAllHref="/activity"
/>
</VStack>
);
}

View File

@@ -1,14 +1,27 @@
'use client';
import {AppShell} from '@astryxdesign/core/AppShell';
import {Layout, LayoutContent, LayoutPanel} from '@astryxdesign/core/Layout';
import {
Layout,
LayoutContent,
LayoutHeader,
LayoutPanel,
VStack,
} from '@astryxdesign/core/Layout';
import {AppSideNav} from '@/components/shell/AppSideNav';
import {AppTopNav} from '@/components/shell/AppTopNav';
import {MobileScopeNav} from '@/components/shell/MobileScopeNav';
import {MobileMenu} from '@/components/shell/MobileMenu';
import {SidebarProvider, useSidebar} from '@/components/shell/SidebarProvider';
import {CopilotPanel} from '@/features/copilot/CopilotPanel';
import {CopilotSlideOver} from '@/features/copilot/CopilotSlideOver';
import {useCopilot} from '@/features/copilot/CopilotProvider';
import {useBreakpoint, isPanelInline, copilotWidth} from '@/lib/breakpoints';
import {
useBreakpoint,
isPanelInline,
isSideNavInline,
copilotWidth,
contentMaxWidth,
} from '@/lib/breakpoints';
/**
* The three-column shell, and the reason the Copilot survives navigation.
@@ -18,28 +31,61 @@ import {useBreakpoint, isPanelInline, copilotWidth} from '@/lib/breakpoints';
* nav, scroll position, the Copilot — is untouched.
*
* Structure, and why AppShell alone is not enough: AppShell renders
* Layout{start, content} internally and exposes no end/panel slot. The Copilot
* column therefore comes from a NESTED Layout inside AppShell's children,
* using its `end` slot.
* Layout{header, start, content} internally and exposes no end/panel slot. The
* Copilot column therefore comes from a NESTED Layout inside its children.
*
* AppShell(topNav, sideNav)
* └─ Layout(content = page, end = LayoutPanel > CopilotPanel)
* AppShell(sideNav)
* └─ Layout(header = TopNav, content = page, end = LayoutPanel > Copilot)
*
* variant="elevated" is what produces the described visual for free: with both
* navs present the shell root and rails paint --color-background-body (#000),
* and the content area sits on an elevated --color-background-surface (#0F0F10)
* backdrop with --radius-page on the start corner.
* The top nav is deliberately NOT passed to AppShell's `topNav` slot. That slot
* renders Layout{header} at the shell root, which spans the full viewport width
* and pushes the sidebar — and therefore the branding — 48px down the screen.
* Measured in the browser, the logo sat at y=64 with the top-left 260x48 region
* empty. Moving the bar into the content column's own header starts the sidebar
* at y=0, so the brand anchors in the window's top-left corner and the bar
* begins where the content does. This is the arrangement Linear, Cursor and
* Stripe use, and no amount of padding inside the sidebar could produce it.
*
* The one thing given up: with no `topNav`, AppShell drops the --radius-page
* corner it draws where the top bar meets the sidebar. The content area still
* paints elevated --color-background-surface (#0F0F10) against the #000 rails,
* and with a full-height sidebar that corner had nothing left to round.
*/
export default function WorkspaceLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SidebarProvider>
<WorkspaceShell>{children}</WorkspaceShell>
</SidebarProvider>
);
}
/**
* Split out from the layout only so it can READ the sidebar context that the
* layout mounts — a provider's own component sits above its value.
*/
function WorkspaceShell({children}: {children: React.ReactNode}) {
const bp = useBreakpoint();
const {isOpen} = useCopilot();
const inline = isPanelInline(bp);
const showInlinePanel = inline && isOpen;
// Both flags come from SidebarProvider now. AppShell keeps a copy of the
// open state for its own context (MobileNav reads it for aria wiring), but
// ours is the one that decides — uncontrolled, there would be no way to
// dismiss the drawer when a nav item is selected.
const {isDrawerOpen, setDrawerOpen} = useSidebar();
// Below the breakpoint the rail is gone — it lives in the drawer — so there
// is no top-left corner to anchor the brand to and nothing for the bar to
// start after. Hand the top nav back to AppShell there. Keeping it in the
// content column instead pushed the SideNav into AppShell's
// `autoMobileTopBar` fallback, which crops the lockup into a 48px bar.
const sideNavInline = isSideNavInline(bp);
return (
<AppShell
variant="elevated"
@@ -47,16 +93,49 @@ export default function WorkspaceLayout({
// Layout/LayoutContent own the padding so the Copilot panel can sit
// flush against the workspace edge.
contentPadding={0}
topNav={<AppTopNav />}
topNav={sideNavInline ? undefined : <AppTopNav />}
sideNav={<AppSideNav />}
// Supplied explicitly rather than auto-generated: the default drawer
// mirrors sideNav only, which would leave the store and range controls
// unreachable below 768px (they live in TopNav's collapsing start slot).
mobileNav={{breakpoint: 'md', content: <MobileScopeNav />}}
// 'sm' (640px) matches the mobile/tablet line in lib/breakpoints —
// AppShell's own threshold and ours have to agree or the hamburger and
// the rail are both visible, or neither is.
mobileNav={{
breakpoint: 'sm',
isOpen: isDrawerOpen,
onOpenChange: setDrawerOpen,
// hasToggle: false — AppShell would otherwise inject a SECOND
// hamburger into TopNav's mobile bar, next to the one in `heading`
// that works at every width. See AppTopNav.
hasToggle: false,
content: <MobileMenu />,
}}
>
<Layout
height="fill"
content={<LayoutContent padding={5}>{children}</LayoutContent>}
header={
sideNavInline ? (
<LayoutHeader padding={0}>
<AppTopNav />
</LayoutHeader>
) : undefined
}
content={
<LayoutContent padding={5}>
{/*
Above 1920 the workspace is capped and centred. Uncapped, a
30-day line chart stretches across ~1900px, which flattens every
trend it exists to show, and body copy runs well past the ~90ch
where reading breaks down. Below the cap this is a no-op —
maxWidth only binds once there is more width than content wants.
*/}
<VStack
width="100%"
maxWidth={contentMaxWidth(bp)}
className={bp === 'ultrawide' ? 'mx-auto' : undefined}
>
{children}
</VStack>
</LayoutContent>
}
end={
showInlinePanel ? (
<LayoutPanel
@@ -65,7 +144,7 @@ export default function WorkspaceLayout({
// CopilotPanel supplies its own header and tab chrome.
padding={0}
role="complementary"
label="AI Copilot"
label="Loyaly AI"
>
<CopilotPanel />
</LayoutPanel>

View File

@@ -19,6 +19,7 @@ import {useMetricColumns} from '@/features/dashboard/KpiRow';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ICONS} from '@/lib/icons';
import {usageRate} from '@/features/lyts/reward-status';
import {
@@ -64,6 +65,7 @@ export default function LytsPage() {
<PageHeader
title="Lyts"
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
controls={<ScopeControls />}
/>
<ExpiryAlerts resource={rewards} nowMs={nowMs} />
@@ -194,7 +196,7 @@ export default function LytsPage() {
<RewardPerformanceTable resource={rewards} />
<ActivityTimeline resource={activity} />
<ActivityTimeline resource={activity} limit={6} height={285} />
</VStack>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {ApiWebhooksManager} from '@/features/settings/ApiWebhooksManager';
export default function ApiSettingsPage() {
return (
<SettingsPage
title="API & Webhooks"
description="Developer credentials, secret signing tokens, webhook subscriptions and dispatch audit logs."
>
<ApiWebhooksManager />
</SettingsPage>
);
}

View File

@@ -1,60 +1,13 @@
import {VStack} from '@astryxdesign/core/Layout';
import {Badge} from '@astryxdesign/core/Badge';
import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {PageHeader} from '@/components/primitives/PageHeader';
import {StaticPanel} from '@/components/patterns/PanelCard';
import {StatPair, StatRow} from '@/components/patterns/StatPair';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {readProfile} from '@/lib/mock/settings';
import {formatInr} from '@/lib/format';
import {SettingsPage} from '@/features/settings/SettingsPage';
import {BillingOverview} from '@/features/settings/BillingOverview';
/**
* Billing is read-only for now.
*
* The plan and settlement figures are real shapes, but there is no payment
* provider wired up — so rather than render fake "Change plan" flows that do
* nothing, the actions are absent and the invoice list says plainly that it
* is not connected yet. A disabled button that never becomes enabled is worse
* than no button.
*/
export default function SettingsBillingPage() {
const profile = readProfile();
return (
<VStack gap={5}>
<PageHeader
title="Billing"
description="Plan, LYT settlement and invoices."
/>
<StaticPanel
title="Plan"
subtitle={profile.businessName}
actions={<Badge variant="success" label="Active" />}
>
<VStack gap={4}>
<StatRow>
<StatPair label="Plan" value="Growth — 5 stores" />
<StatPair label="Billed" value="Monthly" />
<StatPair label="Next charge" value="1 Sep 2026" />
<StatPair label="Amount" value={formatInr(14999)} align="end" />
</StatRow>
<Divider />
<Text size="sm" color="secondary">
LYTs are settled monthly against redemptions, at 1 LYT = ₹1. Your
outstanding liability is shown on the Lyts page.
</Text>
</VStack>
</StaticPanel>
<StaticPanel title="Invoices" subtitle="Past statements">
<EmptyPanel
icon="revenue"
title="No billing provider connected"
description="Invoices appear here once payments are wired up."
/>
</StaticPanel>
</VStack>
<SettingsPage
title="Billing & LYT Settlement"
description="Subscription plans, quota consumption, settlement bank accounts and invoice history."
>
<BillingOverview />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {IntegrationsGrid} from '@/features/settings/IntegrationsGrid';
export default function IntegrationsSettingsPage() {
return (
<SettingsPage
title="Integrations & Connectors"
description="E-commerce POS sync, payment gateways, WhatsApp marketing and ad channels."
>
<IntegrationsGrid />
</SettingsPage>
);
}

View File

@@ -8,7 +8,7 @@ import {
VStack,
} from '@astryxdesign/core/Layout';
import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
import {TabList, Tab} from '@astryxdesign/core/TabList';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {useRouter} from 'next/navigation';
import {useBreakpoint} from '@/lib/breakpoints';
@@ -26,8 +26,8 @@ import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/settings-nav';
* and behaves exactly like a primary nav item — same selected treatment, same
* hover, same keyboard handling, no second definition to keep in sync.
*
* Below the laptop breakpoint a 220px panel would eat most of the width, so
* the sub-nav becomes a horizontal TabList above the content instead.
* Below the laptop breakpoint a 240px panel would eat most of the width, so
* the sub-nav collapses to a section picker above the content instead.
*/
export default function SettingsLayout({
children,
@@ -41,27 +41,36 @@ export default function SettingsLayout({
if (isNarrow) {
const active =
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href))?.href ??
SETTINGS_NAV[0].href;
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href)) ??
SETTINGS_NAV[0];
// A dropdown, not a TabList.
//
// This was a TabList when Settings had three sections. At eleven it stops
// working: `layout="fill"` cannot fit eleven labels in a tablet-width
// column, so the strip wrapped into a full-height vertical list and
// squeezed the page content out entirely. A picker is what every dense
// settings UI uses at this width, and it stays one tap regardless of how
// many sections the module grows to.
return (
<VStack gap={5}>
<TabList
value={active}
onChange={(href) => router.push(href)}
layout="fill"
size="sm"
hasDivider
>
{SETTINGS_NAV.map((s) => (
<Tab
key={s.href}
value={s.href}
label={s.label}
icon={<Icon icon={s.icon} size="sm" />}
/>
))}
</TabList>
<VStack width="100%">
{/* Only the picker is padded here — SettingsPage still owns every
page gutter, so the container contract stays in one place. */}
<VStack paddingInline={8} width="100%" className="pt-8">
<DropdownMenu
button={{
variant: 'secondary',
label: active.label,
icon: <Icon icon={active.icon} size="sm" />,
}}
menuWidth={260}
items={SETTINGS_NAV.map((s) => ({
label: s.label,
icon: s.icon,
onClick: () => router.push(s.href),
}))}
/>
</VStack>
{children}
</VStack>
);
@@ -71,22 +80,41 @@ export default function SettingsLayout({
<Layout
height="fill"
start={
<LayoutPanel width={220} padding={0} role="navigation" label="Settings">
<SideNav>
<SideNavSection title="Settings" isHeaderHidden>
{SETTINGS_NAV.map((s) => (
<SideNavItem
key={s.href}
label={s.label}
icon={s.icon}
href={s.href}
isSelected={isSettingsActive(pathname, s.href)}
/>
))}
</SideNavSection>
</SideNav>
// 240px rather than 220: "Roles & Permissions" and "API & Webhooks"
// were wrapping once the panel gained its own inset padding.
<LayoutPanel width={240} padding={0} role="navigation" label="Settings">
{/*
The panel's own inset. Without it the nav items sat flush against
the workspace rail on one side and the content gutter on the other,
and the first item started hard against the top of the viewport.
paddingBlock 6 (24px) is what gives the list somewhere to begin.
*/}
<VStack paddingInline={3} paddingBlock={6} width="100%">
{/*
w-full because SideNav's own width is a fixed 260px — inside a
240px panel that already spends 24px on padding it overhung by
32px and gave the sub-nav a horizontal scrollbar along its
bottom edge (measured: scrollWidth 272 in a 240px panel).
*/}
<SideNav className="w-full">
<SideNavSection title="Settings" isHeaderHidden>
{SETTINGS_NAV.map((s) => (
<SideNavItem
key={s.href}
label={s.label}
icon={s.icon}
href={s.href}
isSelected={isSettingsActive(pathname, s.href)}
/>
))}
</SideNavSection>
</SideNav>
</VStack>
</LayoutPanel>
}
// padding stays 0 here on purpose — SettingsPage owns the page gutters,
// so every screen gets the identical container whether it renders in
// this branch or the narrow picker one above.
content={<LayoutContent padding={0}>{children}</LayoutContent>}
/>
);

View File

@@ -1,19 +1,13 @@
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/components/primitives/PageHeader';
import {SettingsPage} from '@/features/settings/SettingsPage';
import {NotificationsForm} from '@/features/settings/NotificationsForm';
import {readProfile} from '@/lib/mock/settings';
/** Server Component — same direct-read hybrid as the profile page. */
export default function SettingsNotificationsPage() {
const {notifications} = readProfile();
export default function NotificationsSettingsPage() {
return (
<VStack gap={5}>
<PageHeader
title="Notifications"
description="Choose which events are worth interrupting you for."
/>
<NotificationsForm initialData={notifications} />
</VStack>
<SettingsPage
title="Notifications"
description="Delivery channels, instant alerts, weekly digest dispatches and trigger criteria."
>
<NotificationsForm />
</SettingsPage>
);
}

View File

@@ -1,30 +1,13 @@
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/components/primitives/PageHeader';
import {ProfileForm} from '@/features/settings/ProfileForm';
import {readProfile} from '@/lib/mock/settings';
/**
* A SERVER Component — the one place in the app that fetches on the server.
*
* Every other page fetches on the client because its scope (store, range)
* changes from controls that live above it in the tree, and a route
* navigation would remount the Copilot. Settings has none of that: there is
* exactly one server-known record at page load, so reading it directly here
* (no HTTP hop, no loading skeleton, no empty-input flash) is strictly better.
*
* The same readProfile() backs /api/settings/profile, so the two transports
* cannot drift.
*/
export default function SettingsProfilePage() {
const profile = readProfile();
import {SettingsPage} from '@/features/settings/SettingsPage';
import {BusinessForm} from '@/features/settings/BusinessForm';
export default function BusinessSettingsPage() {
return (
<VStack gap={5}>
<PageHeader
title="Settings"
description="Business details, contact information and LYT earn rate."
/>
<ProfileForm initialData={profile} />
</VStack>
<SettingsPage
title="Business Settings"
description="Company profile, GSTIN registration, registered address and LYT earn defaults."
>
<BusinessForm />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {PreferencesForm} from '@/features/settings/PreferencesForm';
export default function PreferencesSettingsPage() {
return (
<SettingsPage
title="Workspace Preferences"
description="Theme customization, reporting currency, localized language and default landing views."
>
<PreferencesForm />
</SettingsPage>
);
}

View File

@@ -0,0 +1,16 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {ProfileForm} from '@/features/settings/ProfileForm';
import {readProfile} from '@/lib/mock/settings';
export default function MerchantProfilePage() {
const profile = readProfile();
return (
<SettingsPage
title="Personal Profile"
description="Your user credentials, contact details, account email and timezone preference."
>
<ProfileForm initialData={profile} />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {RoleMatrix} from '@/features/settings/RoleMatrix';
export default function RolesSettingsPage() {
return (
<SettingsPage
title="Roles & Permissions"
description="Enterprise role definition, module permission matrix and access control boundaries."
>
<RoleMatrix />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {SecurityManager} from '@/features/settings/SecurityManager';
export default function SecuritySettingsPage() {
return (
<SettingsPage
title="Security & Audit Logs"
description="Two-Factor authentication, password management, active login sessions and security audit history."
>
<SecurityManager />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {StoreManagement} from '@/features/settings/StoreManagement';
export default function StoreSettingsPage() {
return (
<SettingsPage
title="Store Locations"
description="Branch operations, outlet directory, operating schedules and manager assignments."
>
<StoreManagement />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {TeamManagement} from '@/features/settings/TeamManagement';
export default function TeamSettingsPage() {
return (
<SettingsPage
title="Team & Staff"
description="Manage employee access, invite new team members, assign store locations and reset credentials."
>
<TeamManagement />
</SettingsPage>
);
}

View File

@@ -12,6 +12,7 @@ import {Leaderboard} from '@/features/staff/Leaderboard';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {storeName} from '@/lib/mock/stores';
import {formatCompact} from '@/lib/format';
@@ -40,6 +41,7 @@ export default function StaffPage() {
<PageHeader
title="Staff"
description={`Attendance, sales contribution and performance across ${scopeLabel}.`}
controls={<ScopeControls />}
/>
<StaffKpis resource={summary} />

View File

@@ -23,6 +23,7 @@ import {useMetricColumns} from '@/features/dashboard/KpiRow';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ICONS} from '@/lib/icons';
import {
formatCompact,
@@ -45,7 +46,7 @@ const STATUS: Record<
* One store's dashboard.
*
* Deliberately the SAME chart components as the main dashboard, scoped by the
* route's storeId rather than the top nav's. Building store-specific chart
* route's storeId rather than the shared scope's. Building store-specific chart
* variants would double the surface area and guarantee the two drift apart —
* the only thing that differs here is which store the query asks about.
*/
@@ -83,6 +84,8 @@ export default function StoreDetailPage({
? `${store.data.staffCount} staff · ${formatPct(store.data.conversionPct)} conversion`
: 'Loading store…'
}
// Store scope is the route here, so only the period is selectable.
controls={<ScopeControls hasStore={false} />}
actions={
<HStack gap={2} vAlign="center">
{store.data ? (
@@ -183,7 +186,7 @@ export default function StoreDetailPage({
</ChartCard>
</Grid>
<ActivityTimeline resource={activity} />
<ActivityTimeline resource={activity} limit={6} height={285} />
</VStack>
);
}

View File

@@ -9,11 +9,12 @@ import {
RANGE_LABELS,
useWorkspace,
} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
/**
* The store roster.
*
* This page deliberately ignores the top nav's STORE scope — a list of every
* This page deliberately ignores the shared STORE scope — a list of every
* store is the whole point, and narrowing it to the one already selected would
* leave a one-card page. The RANGE scope still applies, because the totals on
* each card have to be measured over some period.
@@ -27,6 +28,7 @@ export default function StoresPage() {
<PageHeader
title="Store"
description={`Performance and status for every store in the network, over ${RANGE_LABELS[range].toLowerCase()}.`}
controls={<ScopeControls hasStore={false} />}
/>
<StoreGrid resource={stores} />
</VStack>