update ui and remove mock data
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user