first commit
This commit is contained in:
154
src/app/(workspace)/dashboard/page.tsx
Normal file
154
src/app/(workspace)/dashboard/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {AreaChartView} from '@/components/charts/AreaChartView';
|
||||
import {LineChartView} from '@/components/charts/LineChartView';
|
||||
import {BarChartView} from '@/components/charts/BarChartView';
|
||||
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
|
||||
import {KpiRow} from '@/features/dashboard/KpiRow';
|
||||
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
||||
import {RewardUsageChart} from '@/features/dashboard/RewardUsageChart';
|
||||
import {StoreComparisonPanel} from '@/features/dashboard/StoreComparison';
|
||||
import {PerformancePanel} from '@/features/dashboard/PerformancePanel';
|
||||
import {useResource} from '@/lib/api/useResource';
|
||||
import {endpoints} from '@/lib/api/client';
|
||||
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
||||
import {storeName} from '@/lib/mock/stores';
|
||||
import type {Granularity} from '@/lib/api/contracts';
|
||||
import {
|
||||
formatCompact,
|
||||
formatDayLabel,
|
||||
formatInrCompact,
|
||||
formatPct,
|
||||
} 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
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
const {storeId, range} = useWorkspace();
|
||||
const [granularity, setGranularity] = useState<Granularity>('weekly');
|
||||
|
||||
const scope = {range, storeId};
|
||||
|
||||
const kpis = useResource(endpoints.dashboardKpis(scope));
|
||||
const series = useResource(endpoints.dashboardTimeseries(scope));
|
||||
const peak = useResource(endpoints.dashboardPeakHours(scope));
|
||||
const activity = useResource(endpoints.dashboardActivity(scope));
|
||||
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
|
||||
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
|
||||
|
||||
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description={`How the business is performing across ${scopeLabel}, what needs attention, and what to do next.`}
|
||||
/>
|
||||
|
||||
<KpiRow resource={kpis} />
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
|
||||
{(d) => (
|
||||
<AreaChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
series={[{key: 'visitors', label: 'Visitors'}]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
|
||||
{(d) => (
|
||||
<BarChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatInrCompact}
|
||||
series={[{key: 'revenue', label: 'Revenue'}]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Visitors vs purchases"
|
||||
subtitle="The gap is the conversion opportunity"
|
||||
resource={series}
|
||||
>
|
||||
{(d) => (
|
||||
<LineChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
series={[
|
||||
{key: 'visitors', label: 'Visitors'},
|
||||
{key: 'purchases', label: 'Purchases'},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Conversion"
|
||||
subtitle="Share of visitors who bought"
|
||||
resource={series}
|
||||
>
|
||||
{(d) => (
|
||||
<LineChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={(v) => formatPct(v, 0)}
|
||||
series={[{key: 'conversion', label: 'Conversion'}]}
|
||||
// The only semantic colour on this chart: above the benchmark is
|
||||
// good, below is not, and that is what the tint communicates.
|
||||
reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard
|
||||
title="Peak hours"
|
||||
subtitle="Where the week's footfall actually lands"
|
||||
resource={peak}
|
||||
height={200}
|
||||
>
|
||||
{(d) => <HeatmapGrid data={d} />}
|
||||
</ChartCard>
|
||||
|
||||
<RewardUsageChart resource={rewards} />
|
||||
</Grid>
|
||||
|
||||
<PerformancePanel
|
||||
scope={scope}
|
||||
granularity={granularity}
|
||||
onGranularityChange={setGranularity}
|
||||
/>
|
||||
|
||||
{/* Comparing stores is meaningless when scoped to one of them. */}
|
||||
{storeId === 'all' ? (
|
||||
<StoreComparisonPanel resource={comparison} />
|
||||
) : null}
|
||||
|
||||
<ActivityTimeline resource={activity} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
81
src/app/(workspace)/layout.tsx
Normal file
81
src/app/(workspace)/layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import {AppShell} from '@astryxdesign/core/AppShell';
|
||||
import {Layout, LayoutContent, LayoutPanel} from '@astryxdesign/core/Layout';
|
||||
import {AppSideNav} from '@/components/shell/AppSideNav';
|
||||
import {AppTopNav} from '@/components/shell/AppTopNav';
|
||||
import {MobileScopeNav} from '@/components/shell/MobileScopeNav';
|
||||
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';
|
||||
|
||||
/**
|
||||
* The three-column shell, and the reason the Copilot survives navigation.
|
||||
*
|
||||
* Next's App Router preserves a layout's element identity across sibling route
|
||||
* changes, so /dashboard → /staff swaps only {children}. Everything else here —
|
||||
* 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.
|
||||
*
|
||||
* AppShell(topNav, sideNav)
|
||||
* └─ Layout(content = page, end = LayoutPanel > CopilotPanel)
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export default function WorkspaceLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
const {isOpen} = useCopilot();
|
||||
const inline = isPanelInline(bp);
|
||||
const showInlinePanel = inline && isOpen;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
variant="elevated"
|
||||
height="fill"
|
||||
// Layout/LayoutContent own the padding so the Copilot panel can sit
|
||||
// flush against the workspace edge.
|
||||
contentPadding={0}
|
||||
topNav={<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 />}}
|
||||
>
|
||||
<Layout
|
||||
height="fill"
|
||||
content={<LayoutContent padding={5}>{children}</LayoutContent>}
|
||||
end={
|
||||
showInlinePanel ? (
|
||||
<LayoutPanel
|
||||
hasDivider
|
||||
width={copilotWidth(bp)}
|
||||
// CopilotPanel supplies its own header and tab chrome.
|
||||
padding={0}
|
||||
role="complementary"
|
||||
label="AI Copilot"
|
||||
>
|
||||
<CopilotPanel />
|
||||
</LayoutPanel>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{/* Mounted only below the inline breakpoint. Because every piece of
|
||||
Copilot state lives in CopilotProvider (above the route tree), moving
|
||||
between these two presentations is lossless. */}
|
||||
{!inline ? <CopilotSlideOver /> : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
213
src/app/(workspace)/lyts/page.tsx
Normal file
213
src/app/(workspace)/lyts/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||
import {MetricCard} from '@/components/patterns/MetricCard';
|
||||
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {AreaChartView} from '@/components/charts/AreaChartView';
|
||||
import {LineChartView} from '@/components/charts/LineChartView';
|
||||
import {BarChartView} from '@/components/charts/BarChartView';
|
||||
import {RewardGrid} from '@/features/lyts/RewardGrid';
|
||||
import {ExpiryAlerts} from '@/features/lyts/ExpiryAlerts';
|
||||
import {RewardPerformanceTable} from '@/features/lyts/RewardPerformanceTable';
|
||||
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
||||
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 {ICONS} from '@/lib/icons';
|
||||
import {usageRate} from '@/features/lyts/reward-status';
|
||||
import {
|
||||
formatCompact,
|
||||
formatDayLabel,
|
||||
formatLyt,
|
||||
formatPct,
|
||||
} from '@/lib/format';
|
||||
|
||||
/**
|
||||
* The LYT programme.
|
||||
*
|
||||
* Ordered by urgency rather than by data type: what's expiring, then the
|
||||
* headline numbers, then the catalogue, then the analysis. A merchant opening
|
||||
* this page is usually here because something needs extending or pausing.
|
||||
*
|
||||
* 1 LYT = ₹1, so "outstanding" figures are simultaneously a count and a
|
||||
* rupee liability — which is why they lead.
|
||||
*/
|
||||
export default function LytsPage() {
|
||||
const {storeId, range} = useWorkspace();
|
||||
const scope = {range, storeId};
|
||||
|
||||
const rewards = useResource(endpoints.lytsRewards(scope));
|
||||
const redemptions = useResource(endpoints.lytsRedemptions(scope));
|
||||
const activity = useResource(endpoints.lytsActivity(scope));
|
||||
|
||||
const columns = useMetricColumns();
|
||||
|
||||
// "Now" comes from the SERVER's clock, carried on every response.
|
||||
//
|
||||
// Date.now() here would be impure during render, would disagree between the
|
||||
// SSR and hydration passes, and would report the wrong countdown to anyone
|
||||
// whose device clock is off — on a panel whose entire job is telling a
|
||||
// merchant an offer expires in two days. Falls back to 0 only before the
|
||||
// first response lands, when no expiry panel is rendered anyway.
|
||||
const nowMs = rewards.meta
|
||||
? new Date(rewards.meta.generatedAt).getTime()
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Lyts"
|
||||
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
|
||||
/>
|
||||
|
||||
<ExpiryAlerts resource={rewards} nowMs={nowMs} />
|
||||
|
||||
<AsyncBoundary
|
||||
resource={rewards}
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
>
|
||||
{(rows) => {
|
||||
const active = rows.filter(
|
||||
(r) => r.status === 'active' || r.status === 'expiring',
|
||||
);
|
||||
const claimed = rows.reduce((a, r) => a + r.claimed, 0);
|
||||
const used = rows.reduce((a, r) => a + r.used, 0);
|
||||
const outstanding = rows.reduce(
|
||||
(a, r) => a + (r.claimed - r.used) * r.costLyt,
|
||||
0,
|
||||
);
|
||||
const mostClaimed = rows.reduce((a, b) =>
|
||||
b.claimed > a.claimed ? b : a,
|
||||
);
|
||||
const leastUsed = rows.reduce((a, b) =>
|
||||
usageRate(b.claimed, b.used) < usageRate(a.claimed, a.used) ? b : a,
|
||||
);
|
||||
|
||||
return (
|
||||
<Grid columns={columns} gap={4}>
|
||||
<MetricCard
|
||||
label="Active rewards"
|
||||
value={active.length}
|
||||
format={(v) => String(Math.round(v))}
|
||||
icon={ICONS.activeRewards}
|
||||
footer={
|
||||
<Caption text={`${rows.length} in catalogue`} />
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Redemption rate"
|
||||
value={usageRate(claimed, used)}
|
||||
format={(v) => formatPct(v, 0)}
|
||||
icon={ICONS.lyts}
|
||||
deltaPct={usageRate(claimed, used) - 50}
|
||||
footer={
|
||||
<Caption
|
||||
text={`${formatCompact(used)} of ${formatCompact(claimed)} claims`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Most claimed"
|
||||
value={mostClaimed.claimed}
|
||||
format={formatCompact}
|
||||
icon={ICONS.up}
|
||||
footer={<Caption text={mostClaimed.name} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Outstanding liability"
|
||||
value={outstanding}
|
||||
format={formatLyt}
|
||||
icon={ICONS.alert}
|
||||
footer={
|
||||
<Caption
|
||||
text={`Least used: ${leastUsed.name} (${formatPct(usageRate(leastUsed.claimed, leastUsed.used), 0)})`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard
|
||||
title="Redemption"
|
||||
subtitle="LYTs redeemed per day"
|
||||
resource={redemptions}
|
||||
>
|
||||
{(d) => (
|
||||
<AreaChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
series={[{key: 'purchases', label: 'Redeemed'}]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Issued vs redeemed"
|
||||
subtitle="The gap is LYTs still in customers' hands"
|
||||
resource={redemptions}
|
||||
>
|
||||
{(d) => (
|
||||
<LineChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
series={[
|
||||
{key: 'visitors', label: 'Issued'},
|
||||
{key: 'purchases', label: 'Redeemed'},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<ChartCard
|
||||
title="Top rewards"
|
||||
subtitle="Claimed vs redeemed, by reward"
|
||||
resource={rewards}
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={[...rows].sort((a, b) => b.claimed - a.claimed).slice(0, 6)}
|
||||
xKey="name"
|
||||
yFormat={formatCompact}
|
||||
series={[
|
||||
{key: 'claimed', label: 'Claimed'},
|
||||
{key: 'used', label: 'Redeemed'},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<RewardGrid resource={rewards} nowMs={nowMs} />
|
||||
|
||||
<RewardPerformanceTable resource={rewards} />
|
||||
|
||||
<ActivityTimeline resource={activity} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the sparkline on metrics with no meaningful time series —
|
||||
* "most claimed" is a name, not a trend. MetricCard's `footer` slot exists
|
||||
* for exactly this so the card keeps its height and the row stays aligned.
|
||||
*/
|
||||
function Caption({text}: {text: string}) {
|
||||
return (
|
||||
<Text size="xsm" color="secondary">
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
60
src/app/(workspace)/settings/billing/page.tsx
Normal file
60
src/app/(workspace)/settings/billing/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
93
src/app/(workspace)/settings/layout.tsx
Normal file
93
src/app/(workspace)/settings/layout.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import {usePathname} from 'next/navigation';
|
||||
import {
|
||||
Layout,
|
||||
LayoutContent,
|
||||
LayoutPanel,
|
||||
VStack,
|
||||
} from '@astryxdesign/core/Layout';
|
||||
import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
|
||||
import {TabList, Tab} from '@astryxdesign/core/TabList';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useBreakpoint} from '@/lib/breakpoints';
|
||||
import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/settings-nav';
|
||||
|
||||
/**
|
||||
* Settings gets its own sub-navigation.
|
||||
*
|
||||
* A nested Layout with a `start` panel rather than more entries in the primary
|
||||
* sidebar: settings sections sit at a different level of the hierarchy, and
|
||||
* promoting them would push Dashboard/Store/Lyts/Staff down among
|
||||
* configuration screens they have nothing to do with.
|
||||
*
|
||||
* SideNav is reused (rather than a bespoke list) so a settings section looks
|
||||
* 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.
|
||||
*/
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const bp = useBreakpoint();
|
||||
const isNarrow = bp === 'mobile' || bp === 'tablet';
|
||||
|
||||
if (isNarrow) {
|
||||
const active =
|
||||
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href))?.href ??
|
||||
SETTINGS_NAV[0].href;
|
||||
|
||||
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>
|
||||
{children}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
</LayoutPanel>
|
||||
}
|
||||
content={<LayoutContent padding={0}>{children}</LayoutContent>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
src/app/(workspace)/settings/notifications/page.tsx
Normal file
19
src/app/(workspace)/settings/notifications/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||
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();
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Notifications"
|
||||
description="Choose which events are worth interrupting you for."
|
||||
/>
|
||||
<NotificationsForm initialData={notifications} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
30
src/app/(workspace)/settings/page.tsx
Normal file
30
src/app/(workspace)/settings/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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();
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Business details, contact information and LYT earn rate."
|
||||
/>
|
||||
<ProfileForm initialData={profile} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
83
src/app/(workspace)/staff/page.tsx
Normal file
83
src/app/(workspace)/staff/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/components/charts/BarChartView';
|
||||
import {StaffKpis} from '@/features/staff/StaffKpis';
|
||||
import {StaffGrid} from '@/features/staff/StaffGrid';
|
||||
import {AttendanceChart} from '@/features/staff/AttendanceChart';
|
||||
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 {storeName} from '@/lib/mock/stores';
|
||||
import {formatCompact} from '@/lib/format';
|
||||
|
||||
/**
|
||||
* The team.
|
||||
*
|
||||
* Ordered attendance-first: the question that brings a merchant here in the
|
||||
* morning is "who is in today", and only then "who is performing".
|
||||
*/
|
||||
export default function StaffPage() {
|
||||
const {storeId, range} = useWorkspace();
|
||||
const scope = {range, storeId};
|
||||
|
||||
const summary = useResource(endpoints.staffSummary(scope), {
|
||||
// A summary object is never "empty" the way a list is — zero present is
|
||||
// a real answer, not an absence of data.
|
||||
isEmpty: () => false,
|
||||
});
|
||||
const staff = useResource(endpoints.staff(scope));
|
||||
const attendance = useResource(endpoints.staffAttendance(scope));
|
||||
|
||||
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Staff"
|
||||
description={`Attendance, sales contribution and performance across ${scopeLabel}.`}
|
||||
/>
|
||||
|
||||
<StaffKpis resource={summary} />
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<AttendanceChart resource={attendance} />
|
||||
|
||||
<ChartCard
|
||||
title="Sales by team member"
|
||||
subtitle="Top 8 by transactions in the selected period"
|
||||
resource={staff}
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={[...rows]
|
||||
.sort((a, b) => b.salesCount - a.salesCount)
|
||||
.slice(0, 8)
|
||||
.map((m) => ({
|
||||
// First name only — full names overflow the axis at this
|
||||
// width and the leaderboard below carries the detail.
|
||||
name: m.name.split(' ')[0],
|
||||
sales: m.salesCount,
|
||||
rewards: m.rewardsIssued,
|
||||
}))}
|
||||
xKey="name"
|
||||
yFormat={formatCompact}
|
||||
series={[
|
||||
{key: 'sales', label: 'Sales'},
|
||||
{key: 'rewards', label: 'Rewards issued'},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<Leaderboard resource={staff} />
|
||||
|
||||
<StaffGrid resource={staff} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
189
src/app/(workspace)/stores/[storeId]/page.tsx
Normal file
189
src/app/(workspace)/stores/[storeId]/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import {use} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {
|
||||
BreadcrumbItem,
|
||||
Breadcrumbs,
|
||||
} from '@astryxdesign/core/Breadcrumbs';
|
||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||
import {MetricCard} from '@/components/patterns/MetricCard';
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {AreaChartView} from '@/components/charts/AreaChartView';
|
||||
import {BarChartView} from '@/components/charts/BarChartView';
|
||||
import {LineChartView} from '@/components/charts/LineChartView';
|
||||
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
|
||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
||||
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
||||
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 {ICONS} from '@/lib/icons';
|
||||
import {
|
||||
formatCompact,
|
||||
formatDayLabel,
|
||||
formatInrCompact,
|
||||
formatPct,
|
||||
} from '@/lib/format';
|
||||
import type {StoreStatus} from '@/lib/api/contracts';
|
||||
|
||||
const STATUS: Record<
|
||||
StoreStatus,
|
||||
{label: string; variant: 'success' | 'warning' | 'error'}
|
||||
> = {
|
||||
open: {label: 'Open', variant: 'success'},
|
||||
maintenance: {label: 'Maintenance', variant: 'warning'},
|
||||
closed: {label: 'Closed', variant: 'error'},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
export default function StoreDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{storeId: string}>;
|
||||
}) {
|
||||
const {storeId} = use(params);
|
||||
const {range} = useWorkspace();
|
||||
const scope = {range, storeId};
|
||||
|
||||
const store = useResource(endpoints.store(scope, storeId), {
|
||||
isEmpty: () => false,
|
||||
});
|
||||
const kpis = useResource(endpoints.dashboardKpis(scope));
|
||||
const series = useResource(endpoints.dashboardTimeseries(scope));
|
||||
const peak = useResource(endpoints.dashboardPeakHours(scope));
|
||||
const activity = useResource(endpoints.dashboardActivity(scope));
|
||||
|
||||
const columns = useMetricColumns();
|
||||
const name = store.data?.name ?? 'Store';
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<Breadcrumbs variant="supporting">
|
||||
<BreadcrumbItem href="/stores">Store</BreadcrumbItem>
|
||||
<BreadcrumbItem isCurrent>{name}</BreadcrumbItem>
|
||||
</Breadcrumbs>
|
||||
|
||||
<PageHeader
|
||||
title={name}
|
||||
description={
|
||||
store.data
|
||||
? `${store.data.staffCount} staff · ${formatPct(store.data.conversionPct)} conversion`
|
||||
: 'Loading store…'
|
||||
}
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
{store.data ? (
|
||||
<Badge
|
||||
variant={STATUS[store.data.status].variant}
|
||||
label={STATUS[store.data.status].label}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="All stores"
|
||||
href="/stores"
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={kpis}
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
>
|
||||
{(rows) => (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{rows.map((k) => (
|
||||
<MetricCard
|
||||
key={k.id}
|
||||
label={k.label}
|
||||
value={k.value}
|
||||
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
|
||||
icon={
|
||||
k.id === 'visitors'
|
||||
? ICONS.visitors
|
||||
: k.id === 'purchases'
|
||||
? ICONS.purchases
|
||||
: k.id === 'revenue'
|
||||
? ICONS.revenue
|
||||
: ICONS.activeRewards
|
||||
}
|
||||
deltaPct={k.deltaPct}
|
||||
isRiseGood={k.isRiseGood}
|
||||
trend={k.trend}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
|
||||
{(d) => (
|
||||
<AreaChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
series={[{key: 'visitors', label: 'Visitors'}]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
|
||||
{(d) => (
|
||||
<BarChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatInrCompact}
|
||||
series={[{key: 'revenue', label: 'Revenue'}]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Conversion"
|
||||
subtitle="Share of visitors who bought"
|
||||
resource={series}
|
||||
>
|
||||
{(d) => (
|
||||
<LineChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={(v) => formatPct(v, 0)}
|
||||
series={[{key: 'conversion', label: 'Conversion'}]}
|
||||
reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Peak hours"
|
||||
subtitle="Where this store's week actually lands"
|
||||
resource={peak}
|
||||
height={200}
|
||||
>
|
||||
{(d) => <HeatmapGrid data={d} />}
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
<ActivityTimeline resource={activity} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
34
src/app/(workspace)/stores/page.tsx
Normal file
34
src/app/(workspace)/stores/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||
import {StoreGrid} from '@/features/stores/StoreGrid';
|
||||
import {useResource} from '@/lib/api/useResource';
|
||||
import {endpoints} from '@/lib/api/client';
|
||||
import {
|
||||
RANGE_LABELS,
|
||||
useWorkspace,
|
||||
} from '@/components/shell/WorkspaceProvider';
|
||||
|
||||
/**
|
||||
* The store roster.
|
||||
*
|
||||
* This page deliberately ignores the top nav's 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.
|
||||
*/
|
||||
export default function StoresPage() {
|
||||
const {range} = useWorkspace();
|
||||
const stores = useResource(endpoints.stores({range, storeId: 'all'}));
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Store"
|
||||
description={`Performance and status for every store in the network, over ${RANGE_LABELS[range].toLowerCase()}.`}
|
||||
/>
|
||||
<StoreGrid resource={stores} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user