update ui and remove mock data

This commit is contained in:
2026-09-09 19:50:52 +05:30
parent 5ce580dced
commit bfe4d3d2f2
172 changed files with 4280 additions and 8775 deletions

View File

@@ -3,44 +3,34 @@
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
import {useDashboardActivity} from '@/features/dashboard/hooks/useDashboard';
import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
import {useRecentVisits} from '@/features/dashboard/hooks/useReports';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
/**
* The complete EVENT history — where "View all" on the dashboard's recent
* activity feed leads.
* Every arrival, where the dashboard's "View all" leads.
*
* Deliberately narrow. The grouped activity ecosystem — all ten activities,
* their status, their LYT cost and their impact chains — lives on /lyts,
* because that is where a merchant manages the programme. It briefly lived
* here too; two homes for one dataset is exactly the duplication that lets
* two screens drift apart, so this page kept the half that is genuinely its
* own: the raw, chronological log of individual events.
*
* 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
* modules the product is organised around.
*
* Reads the same resource and the same component the dashboard panel does,
* uncapped: one feed implementation, two budgets.
* Same component and same resource as the dashboard panel, at a larger page
* size — one feed implementation, two budgets. The cursor the platform returns
* is the supported way to page further; the dashboard never needs it, so it is
* wired here first when infinite scroll lands.
*/
export default function ActivityPage() {
const activity = useDashboardActivity();
const visits = useRecentVisits(50);
const scopeLabel = useScopeLabel();
return (
<VStack gap={5}>
<PageHeader
title="Activity"
description={`Every recorded event across ${scopeLabel}, newest first.`}
description={`Every recognised arrival across ${scopeLabel}, newest first.`}
controls={<ScopeControls />}
/>
<ActivityTimeline
resource={activity}
title="All activity"
subtitle="Redemptions, purchases, attendance, store events and expiries"
<ArrivalsFeed
resource={visits}
title="All arrivals"
subtitle="Customers recognised at the door"
/>
</VStack>
);

View File

@@ -1,10 +1,70 @@
import {CommerceWorkspace} from '@/features/commerce/components/CommerceWorkspace';
'use client';
export const metadata = {
title: 'Commerce | Loyaly Merchant OS',
description: 'AI-first commerce workspace for sales, orders, revenue and business performance telemetry.',
};
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {CHART} from '@/shared/components/charts/palette';
import {useConversionReport} from '@/features/dashboard/hooks/useReports';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {formatInrCompact} from '@/shared/utils/format';
/**
* Sales.
*
* ── What changed and why ─────────────────────────────────────────────────
* This page previously rendered eight panels — product leaderboards, payment
* method splits, stock levels, refund rates, hourly targets — every number of
* which came from a hardcoded service imported synchronously by ten
* components. None of it had an API, a loading state, or a way to become real.
*
* The platform reports revenue and basket size through the conversion report,
* and nothing else on this page. So the page now shows the part that is real
* and names the resources the rest is waiting for, rather than presenting
* invented inventory as though a merchant could act on it.
*/
export default function CommercePage() {
return <CommerceWorkspace />;
const conversion = useConversionReport({bucket: 'day'});
const scopeLabel = useScopeLabel();
return (
<VStack gap={5}>
<PageHeader
eyebrow="Sales & revenue"
title="Sales"
description={`Revenue and conversion across ${scopeLabel}.`}
controls={<ScopeControls />}
/>
<ChartCard
title="Revenue"
subtitle="Daily takings, from the conversion report"
resource={conversion}
>
{(report) => (
<BarChartView
data={report.buckets}
xKey="label"
yFormat={formatInrCompact}
series={[
{key: 'revenue', label: 'Revenue', color: CHART.brand.warmBar},
]}
/>
)}
</ChartCard>
<FeatureUnavailable
title="Orders, products and payments"
description="The Loyaly platform records a purchase against a visit and reports revenue in aggregate, but does not yet expose an orders list, a product catalogue, stock levels, payment-method splits or refunds. Those panels are held back rather than filled with sample data."
endpoints={[
'GET /api/purchases — orders list (POST exists; there is no read)',
'GET /api/products — catalogue, categories, stock',
'GET /api/reports/payments — method split',
'GET /api/reports/refunds',
]}
/>
</VStack>
);
}

View File

@@ -1,119 +1,56 @@
'use client';
import {useState} from 'react';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Collapsible} from '@astryxdesign/core/Collapsible';
import {Text} from '@astryxdesign/core/Text';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
import {KpiRow} from '@/features/dashboard/components/KpiRow';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
import {RewardUsageChart} from '@/features/dashboard/components/RewardUsageChart';
import {StoreComparisonPanel} from '@/features/dashboard/components/StoreComparison';
import {PerformancePanel} from '@/features/dashboard/components/PerformancePanel';
import {CustomerActivity} from '@/features/dashboard/components/CustomerActivity';
import {CustomerJourney} from '@/features/dashboard/components/CustomerJourney';
import {CampaignPerformance} from '@/features/dashboard/components/CampaignPerformance';
import {StoreInsights} from '@/features/dashboard/components/StoreInsights';
import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {CHART} from '@/shared/components/charts/palette';
import {
useActivityMetrics,
useCampaigns,
useCustomerJourney,
useDashboardActivity,
useConversionReport,
useDashboardKpis,
useDashboardPeakHours,
useDashboardRewardUsage,
useDashboardStoreComparison,
useDashboardTimeseries,
useStoreInsights,
useFootfallReport,
useRecentVisits,
} from '@/features/dashboard/hooks/useDashboard';
import {usePersistentFlag} from '@/shared/hooks/usePersistentFlag';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {greetingFor} from '@/features/dashboard/services/dashboardService';
import type {Granularity} from '@/features/dashboard/types/dashboard';
import {
formatCompact,
formatDayLabel,
formatInrCompact,
formatPct,
} from '@/shared/utils/format';
import {formatCompact, formatInrCompact} from '@/shared/utils/format';
/**
* Store intelligence, in the order a merchant actually asks the questions:
* what happened → why is it happening → what should I do next.
* The dashboard, as a READ MODEL over the platform.
*
* what happened the four headline metrics, then the two trends behind them
* why what customers DID (activity), where they stopped
* (journey), which campaigns moved them (campaigns), and the
* supporting analytics for all three
* what next findings with one action each, then the raw event feed
* Every number on this page now traces to a resource the Loyaly platform
* actually serves — footfall, conversion, arrivals — and the same resources
* answer the mobile app. There is no dashboard database, no dashboard-shaped
* endpoint, and no fixture: if the platform returns nothing, this page shows
* nothing rather than something plausible.
*
* The activity band is the change that makes this a Merchant OS rather than an
* analytics page. Purchases are not the only thing that creates customer
* value: a spin, a challenge, a referral or an event all produce engagement,
* and every one of them is rendered here WITH its downstream chain — customers,
* return visits, purchases — so no activity can be read as a vanity count.
* ── What was removed and why ─────────────────────────────────────────────
* The engagement layer that used to sit here — ten activity types, impact
* chains, campaign funnels, a customer journey, generated insights — was built
* against a loyalty domain the platform does not expose. It rendered numbers
* with no source. Rather than keep them behind a demo flag where a merchant
* could mistake them for real, the panels are replaced by a statement of what
* they need. The layout, spacing and hierarchy are otherwise untouched.
*
* What did not change, on purpose: the sticky header, the store and range
* controls, the KPI row, the Footfall and Revenue charts, the conversion pair,
* peak hours, reward usage, the period rollup and store comparison. This is a
* restructure of an already-sound page, not a rebuild — the new sections were
* inserted at the points in the hierarchy where they answer the next question,
* and the existing analytics moved below them because they are the evidence
* for the activity story rather than the story itself.
*
* Operational widgets — quick actions, tasks, the AI briefing — still do NOT
* live here. "What needs your attention" is not that: it is four findings
* computed from this page's own numbers, each with a single action, which is a
* different thing from a to-do list a merchant has to maintain.
*
* 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, and the activity, journey, campaign and insight
* fixtures are all derived from that same series server-side, so no two panels
* on this page can report different numbers for the same fact.
* Bucket labels from the reports are rendered as STRINGS. They are local wall
* time with no offset; parsing one into a Date re-interprets it in the
* viewer's zone and shifts every label on the axis.
*/
export default function DashboardPage() {
const {storeId} = 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);
// Persisted, not component state: a merchant who wants the charts expanded
// wants them expanded tomorrow too, and re-collapsing them on every visit is
// the fastest way to make a disclosure control feel like an obstacle.
const [isSupportingOpen, setSupportingOpen] = usePersistentFlag(
'loyaly.dashboard.supporting-analytics',
false,
);
const kpis = useDashboardKpis();
const series = useDashboardTimeseries();
const peak = useDashboardPeakHours();
const activity = useDashboardActivity();
const rewards = useDashboardRewardUsage();
const comparison = useDashboardStoreComparison();
const activityMetrics = useActivityMetrics();
const journey = useCustomerJourney();
const campaigns = useCampaigns();
const insights = useStoreInsights();
const isAllStores = storeId === 'all';
const footfall = useFootfallReport({bucket: 'day'});
const conversion = useConversionReport({bucket: 'day'});
const visits = useRecentVisits(6);
const scopeLabel = useScopeLabel();
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-surface border-b border-border shadow-sm">
<PageHeader
eyebrow={greetingFor(kpis.meta?.generatedAt)}
@@ -123,26 +60,18 @@ export default function DashboardPage() {
/>
</div>
{/* 3 — headline numbers. */}
<KpiRow resource={kpis} />
{/*
4 — primary analytics: the two series everything else explains.
These two carry the brand hues and nothing else on the page does at
chart scale. Footfall is cool because it is the system measuring the
store; revenue is warm because it is the outcome the brand is for. One
of each, on the two charts that matter most — that is the whole colour
budget for analytics, and it is why the four charts further down stay
monochrome.
*/}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
{(d) => (
<ChartCard
title="Footfall"
subtitle="Visitors per day"
resource={footfall}
>
{(report) => (
<AreaChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
data={report.buckets}
xKey="label"
yFormat={formatCompact}
series={[
{key: 'visitors', label: 'Visitors', color: CHART.brand.cool},
@@ -151,12 +80,15 @@ export default function DashboardPage() {
)}
</ChartCard>
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
{(d) => (
<ChartCard
title="Revenue"
subtitle="Daily takings"
resource={conversion}
>
{(report) => (
<BarChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
data={report.buckets}
xKey="label"
yFormat={formatInrCompact}
series={[
{key: 'revenue', label: 'Revenue', color: CHART.brand.warmBar},
@@ -166,128 +98,36 @@ export default function DashboardPage() {
</ChartCard>
</Grid>
{/* 5 — what customers did, and what each activity was worth. Six of ten;
the full ecosystem is on /activity. */}
<CustomerActivity resource={activityMetrics} />
{/* 6 — where that engagement progresses, and where it stops. */}
<CustomerJourney resource={journey} />
{/* 7 — which deliberate campaigns produced the movement above. */}
<CampaignPerformance resource={campaigns} />
{/*
8 — supporting analytics, COLLAPSED BY DEFAULT.
These six panels are preserved exactly as they were; what changed is
their rank. Left expanded they run 1,724px on desktop and roughly
3,000px on a phone, which put "What needs your attention" — the only
section on the page a merchant can act on — at 81% of the scroll,
behind four charts that explain rather than prompt. Evidence should be
available on demand, not stand between the finding and the action.
Collapsed, not deleted, and the choice is remembered: a merchant who
opens it once gets it open every visit thereafter. Nothing is
unreachable and nothing was redesigned.
*/}
<Collapsible
isOpen={isSupportingOpen}
onOpenChange={setSupportingOpen}
trigger={
<VStack gap={0.5} hAlign="start">
{/* Text, not Heading: Collapsible renders the trigger inside a
<button>, and a heading in button content is invalid markup.
The panels within carry their own h2s when expanded. */}
<Text size="base" weight="semibold">
Supporting analytics
</Text>
<Text size="sm" color="secondary">
Conversion, peak hours, reward usage, the period rollup and store
comparison
</Text>
</VStack>
}
<ChartCard
title="New vs returning visitors"
subtitle="Who walked in, split by whether the platform had seen them before"
resource={footfall}
>
<VStack gap={5} className="pt-4">
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<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>
{(report) => (
<BarChartView
data={report.buckets}
xKey="label"
isStacked
yFormat={formatCompact}
series={[
{key: 'newVisitors', label: 'New', color: CHART.brand.cool},
{key: 'returningVisitors', label: 'Returning', color: CHART.brand.warmBar},
]}
/>
)}
</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>
<ArrivalsFeed resource={visits} viewAllHref="/activity" />
{/* 9 — operational analytics: when traffic lands, what it redeems. */}
<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>
{/* 10 — period rollup. */}
<PerformancePanel
granularity={granularity}
onGranularityChange={setGranularity}
/>
{/* 11 — 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}
</VStack>
</Collapsible>
{/* 12 — what to do next. Last because it is the conclusion: every claim
it makes is checkable against a panel above it. */}
<StoreInsights resource={insights} />
{/* 13 — 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"
<FeatureUnavailable
title="Customer activity and engagement"
description="Selfies, spins, scratch cards, challenges, referrals, events and their impact on repeat visits and revenue are not part of the Loyaly platform contract yet. These panels previously showed generated figures; they are held back until the resources exist so no number on this page is invented."
endpoints={[
'GET /api/activities — the activity catalogue and participation',
'GET /api/activities/impact — customers, repeat visits, attributed purchases',
'GET /api/campaigns — campaign funnels',
'GET /api/reports/journey — visit → engage → purchase → return → refer',
]}
/>
</VStack>
);

View File

@@ -1,230 +1,38 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Text} from '@astryxdesign/core/Text';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {MetricCard} from '@/shared/components/patterns/MetricCard';
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {RewardGrid} from '@/features/lyts/components/RewardGrid';
import {ExpiryAlerts} from '@/features/lyts/components/ExpiryAlerts';
import {RewardPerformanceTable} from '@/features/lyts/components/RewardPerformanceTable';
import {ActivityProgramme} from '@/features/lyts/components/ActivityProgramme';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
import {
useLytsActivity,
useRedemptions,
useRewards,
} from '@/features/lyts/hooks/useLyts';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ICONS} from '@/shared/utils/icons';
import {usageRate} from '@/features/lyts/services/lytsService';
import {
formatCompact,
formatDayLabel,
formatLyt,
formatPct,
} from '@/shared/utils/format';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
/**
* The LYT programme — activities and the rewards they pay out in.
* LYTs.
*
* Ordered by urgency rather than by data type: what's expiring, then the
* headline numbers, then what customers can DO to earn, then the catalogue of
* what they spend on, then the analysis. A merchant opening this page is
* usually here because something needs extending or pausing.
* The entire loyalty programme — reward catalogue, claims, redemptions,
* outstanding LYT liability, expiry windows, accrual rate — has no counterpart
* in the Loyaly platform contract. Every figure this page used to show was
* generated locally, including the "outstanding liability" a merchant would
* reasonably read as money they owe.
*
* 1 LYT = ₹1, so "outstanding" figures are simultaneously a count and a
* rupee liability — which is why they lead.
*
* ── The activity half ─────────────────────────────────────────────────────
* Activities sit here, above rewards, because they are the earning side of
* the same ledger: an activity issues LYTs, a reward spends them, and a
* merchant tuning the programme is trading one against the other. The
* dashboard shows six activities as a summary of engagement; this page is
* where all ten are managed, and it reads the SAME activity model — see
* ActivityProgramme.
* The page is kept, and states what it needs. Nothing is simulated.
*/
export default function LytsPage() {
const rewards = useRewards();
const redemptions = useRedemptions();
const activity = useLytsActivity();
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="Customer activities and rewards — what earns LYTs, what spends them, and what is still outstanding. 1 LYT = ₹1."
controls={<ScopeControls />}
description="Customer activities and rewards. 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>
{/*
The earning side, before the spending side. All ten activities, their
status and their LYT cost — the same model the dashboard summarises.
*/}
<ActivityProgramme />
<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} limit={6} height={285} />
<FeatureUnavailable
title="The LYT programme"
description="Rewards, claims, redemptions and outstanding LYT balance are not part of the platform contract. This page previously showed generated figures — including an outstanding liability in rupees — which a merchant could not tell from real ones."
endpoints={[
'GET /api/rewards — catalogue, cost in LYTs, status, expiry',
'GET /api/rewards/redemptions — claimed vs redeemed over time',
'GET /api/lyts/ledger — issued, redeemed, outstanding balance',
'GET /api/activities — what earns LYTs, and participation',
]}
/>
</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>
);
}

View File

@@ -1,5 +1,6 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {ProfileForm} from '@/features/settings/components/ProfileForm';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {settingsServerRepository} from '@/features/settings/repositories/settingsServerRepository';
/**
@@ -19,7 +20,15 @@ export default async function MerchantProfilePage() {
title="Personal Profile"
description="Your user credentials, contact details, account email and timezone preference."
>
<ProfileForm initialData={profile} />
{profile ? (
<ProfileForm initialData={profile} />
) : (
<FeatureUnavailable
title="Business profile"
description="The platform identifies the signed-in user but does not yet expose the company record this form edits — business name, GSTIN, timezone, currency and LYT accrual. Rather than showing an editable form backed by nothing, it is held until the resource exists."
endpoints={['GET /api/settings/profile', 'PATCH /api/settings/profile']}
/>
)}
</SettingsPage>
);
}

View File

@@ -1,79 +1,45 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {StaffKpis} from '@/features/staff/components/StaffKpis';
import {StaffGrid} from '@/features/staff/components/StaffGrid';
import {AttendanceChart} from '@/features/staff/components/AttendanceChart';
import {Leaderboard} from '@/features/staff/components/Leaderboard';
import {
useStaffAttendance,
useStaffList,
useStaffSummary,
} from '@/features/staff/hooks/useStaff';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {formatCompact} from '@/shared/utils/format';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {TeamTable} from '@/features/team/components/TeamTable';
import {useTeam} from '@/features/team/hooks/useTeam';
/**
* The team.
* Leaderboard.
*
* Ordered attendance-first: the question that brings a merchant here in the
* morning is "who is in today", and only then "who is performing".
* ── An important distinction ─────────────────────────────────────────────
* The platform's `/api/team` is who can SIGN IN to the console, at what
* privilege. It is not shop-floor rostering: there is no attendance, no shift,
* no sales-per-head and no performance score anywhere in the contract.
*
* The page used to show all of those from a fixture. The real team list is
* shown instead, and the ranking metrics are named as the gap they are —
* because a leaderboard built from invented performance scores is the single
* most damaging fake number in this product.
*/
export default function StaffPage() {
const summary = useStaffSummary();
const staff = useStaffList();
const attendance = useStaffAttendance();
const scopeLabel = useScopeLabel();
export default function LeaderboardPage() {
const team = useTeam();
return (
<VStack gap={5}>
<PageHeader
title="Leaderboard"
description={`Attendance, sales contribution and performance across ${scopeLabel}.`}
controls={<ScopeControls />}
description="People with access to this console."
/>
<StaffKpis resource={summary} />
<TeamTable resource={team} />
<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} />
<FeatureUnavailable
title="Attendance and performance ranking"
description="Ranking staff needs rostering data — shifts, attendance, sales attributed per person — which the platform does not record. /api/team answers who can sign in, not who was on the floor."
endpoints={[
'GET /api/staff — shop-floor roster, distinct from console accounts',
'GET /api/staff/attendance — present, late, absent, on leave',
'GET /api/reports/staff-sales — sales attributed per person',
]}
/>
</VStack>
);
}

View File

@@ -1,193 +0,0 @@
'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 '@/shared/components/primitives/PageHeader';
import {MetricCard} from '@/shared/components/patterns/MetricCard';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
import {useResource} from '@/shared/hooks/useResource';
import {storeRepository} from '@/features/stores/repositories/storeRepository';
import {dashboardRepository} from '@/features/dashboard/repositories/dashboardRepository';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ICONS} from '@/shared/utils/icons';
import {
formatCompact,
formatDayLabel,
formatInrCompact,
formatPct,
} from '@/shared/utils/format';
import type {StoreStatus} from '@/features/stores/types/store';
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 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.
*/
export default function StoreDetailPage({
params,
}: {
params: Promise<{storeId: string}>;
}) {
const {storeId} = use(params);
const {range} = useWorkspace();
const scope = {range, storeId};
const store = useResource(storeRepository.byId(scope, storeId), {
isEmpty: () => false,
});
const kpis = useResource(dashboardRepository.kpis(scope));
const series = useResource(dashboardRepository.timeseries(scope));
const peak = useResource(dashboardRepository.peakHours(scope));
const activity = useResource(dashboardRepository.activity(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…'
}
// Store scope is the route here, so only the period is selectable.
controls={<ScopeControls hasStore={false} />}
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} limit={6} height={285} />
</VStack>
);
}

View File

@@ -1,35 +1,96 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Card} from '@astryxdesign/core/Card';
import {Text, Heading} from '@astryxdesign/core/Text';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {StoreGrid} from '@/features/stores/components/StoreGrid';
import {useStoreList} from '@/features/stores/hooks/useStores';
import {
RANGE_LABELS,
useWorkspace,
} from '@/shared/providers/WorkspaceProvider';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {useSites} from '@/features/stores/hooks/useSites';
import {formatPct} from '@/shared/utils/format';
/**
* The store roster.
* The estate, from GET /api/sites.
*
* 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.
* Health fields are nullable and rendered as "—" when the platform does not
* report them. A deployment that sends no camera health is not a deployment
* with zero cameras up, and printing "0/0" for "not reported" makes a working
* estate look broken.
*/
export default function StoresPage() {
const {range} = useWorkspace();
const stores = useStoreList();
const sites = useSites();
return (
<VStack gap={5}>
<PageHeader
title="Store"
description={`Performance and status for every store in the network, over ${RANGE_LABELS[range].toLowerCase()}.`}
controls={<ScopeControls hasStore={false} />}
description="Every shop in the network, with its camera health."
/>
<StoreGrid resource={stores} />
<AsyncBoundary
resource={sites}
loading={<SkeletonCardGrid count={4} height={170} />}
empty={
<EmptyPanel
icon="stores"
title="No stores yet"
description="Shops appear here once they are registered on the platform."
/>
}
>
{(rows) => (
<Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
{rows.map((site) => (
<Card key={site.id}>
<VStack gap={3}>
<HStack gap={2} vAlign="center" hAlign="between">
<Heading level={3}>{site.name}</Heading>
{site.isOnline === null ? null : (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={site.isOnline ? 'success' : 'error'}
label={site.isOnline ? 'Online' : 'Offline'}
/>
<Text size="xsm" color="secondary">
{site.isOnline ? 'Online' : 'Offline'}
</Text>
</HStack>
)}
</HStack>
<Text size="sm" color="secondary" className="font-mono">
{site.id}
</Text>
<StatRow>
<StatPair
label="Cameras up"
value={
site.camerasUp === null || site.camerasTotal === null
? '—'
: `${site.camerasUp}/${site.camerasTotal}`
}
/>
<StatPair
label="Below gate"
value={
site.fractionBelowGate === null
? '—'
: formatPct(site.fractionBelowGate * 100, 0)
}
align="end"
/>
</StatRow>
</VStack>
</Card>
))}
</Grid>
)}
</AsyncBoundary>
</VStack>
);
}