first commit

This commit is contained in:
2026-08-05 15:26:50 +05:30
parent c000e5dc95
commit 2c8c394795
126 changed files with 10824 additions and 302 deletions

View File

@@ -0,0 +1,78 @@
'use client';
import {TabList, Tab} from '@astryxdesign/core/TabList';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Heading} from '@astryxdesign/core/Text';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import {useCopilot} from './CopilotProvider';
import type {CopilotTab} from './CopilotProvider';
import {AiTab} from './tabs/AiTab';
import {AnalyticsTab} from './tabs/AnalyticsTab';
import {ChatTab} from './tabs/ChatTab';
/**
* The Copilot body. Rendered identically inside the inline LayoutPanel and
* inside the slide-over — presentation differs, this does not. All of its
* state comes from CopilotProvider, so it is safe to unmount and remount.
*/
export function CopilotPanel({onDismiss}: {onDismiss?: () => void}) {
const {tab, setTab} = useCopilot();
return (
<VStack gap={0} height="100%">
<HStack
gap={2}
vAlign="center"
hAlign="between"
paddingInline={4}
paddingBlock={3}
>
<HStack gap={2} vAlign="center">
<Icon icon={ICONS.ai} size="sm" />
<Heading level={2}>Copilot</Heading>
</HStack>
{onDismiss ? (
<IconButton
icon={<Icon icon={ICONS.panelClose} />}
label="Hide Copilot"
variant="ghost"
size="sm"
onClick={onDismiss}
/>
) : null}
</HStack>
<TabList
value={tab}
onChange={(v) => setTab(v as CopilotTab)}
layout="fill"
size="sm"
hasDivider
>
<Tab value="ai" label="AI" icon={<Icon icon={ICONS.ai} size="sm" />} />
<Tab
value="analytics"
label="Analytics"
icon={<Icon icon={ICONS.analytics} size="sm" />}
/>
<Tab
value="chat"
label="Chat"
icon={<Icon icon={ICONS.chat} size="sm" />}
/>
</TabList>
{/* The chat tab owns its own scroll container (ChatLayout docks the
composer), so it must not be wrapped in a scrolling VStack. */}
{tab === 'chat' ? (
<ChatTab />
) : (
<VStack height="100%" isScrollable>
{tab === 'ai' ? <AiTab /> : <AnalyticsTab />}
</VStack>
)}
</VStack>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import {createContext, useCallback, useContext, useMemo, useState} from 'react';
/**
* All Copilot state lives here, and this provider is mounted in
* app/providers.tsx — ABOVE the route tree.
*
* Two things force that placement:
* 1. Route changes. The panel itself sits in (workspace)/layout.tsx, which the
* App Router preserves across sibling navigations, so it would survive
* /dashboard → /staff on its own.
* 2. The tablet breakpoint. Crossing it swaps the inline LayoutPanel for a
* slide-over, which genuinely unmounts the panel. Holding state up here
* means presentation can change without losing a half-typed message.
*
* The panel is a pure view over this context. Nothing below it owns state.
*/
export type CopilotTab = 'ai' | 'analytics' | 'chat';
export interface ChatMessage {
id: string;
sender: 'user' | 'assistant';
text: string;
at: string;
}
interface CopilotValue {
tab: CopilotTab;
setTab: (t: CopilotTab) => void;
isOpen: boolean;
setIsOpen: (v: boolean) => void;
toggle: () => void;
/** Slide-over visibility, used below the laptop breakpoint. */
isSlideOverOpen: boolean;
setSlideOverOpen: (v: boolean) => void;
messages: ChatMessage[];
/** The in-progress composer value. Kept here so navigation never drops it. */
draft: string;
setDraft: (v: string) => void;
send: (text: string) => void;
isStreaming: boolean;
}
const CopilotContext = createContext<CopilotValue | null>(null);
export function CopilotProvider({children}: {children: React.ReactNode}) {
const [tab, setTab] = useState<CopilotTab>('ai');
const [isOpen, setIsOpen] = useState(true);
const [isSlideOverOpen, setSlideOverOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [isStreaming] = useState(false);
const send = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setMessages((prev) => [
...prev,
{
id: `m${prev.length + 1}`,
sender: 'user',
text: trimmed,
// Timestamps are assigned on send (a user event), never during render —
// Date.now() in a render path is a hydration mismatch waiting to happen.
at: new Date().toISOString(),
},
]);
setDraft('');
}, []);
const toggle = useCallback(() => setIsOpen((v) => !v), []);
const value = useMemo(
() => ({
tab,
setTab,
isOpen,
setIsOpen,
toggle,
isSlideOverOpen,
setSlideOverOpen,
messages,
draft,
setDraft,
send,
isStreaming,
}),
[tab, isOpen, isSlideOverOpen, messages, draft, send, toggle, isStreaming],
);
return <CopilotContext value={value}>{children}</CopilotContext>;
}
export function useCopilot(): CopilotValue {
const ctx = useContext(CopilotContext);
if (!ctx) {
throw new Error('useCopilot must be used inside <CopilotProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,37 @@
'use client';
import {MobileNav} from '@astryxdesign/core/MobileNav';
import {useBreakpoint} from '@/lib/breakpoints';
import {useCopilot} from './CopilotProvider';
import {CopilotPanel} from './CopilotPanel';
/**
* Tablet and mobile presentation of the Copilot.
*
* MobileNav rather than Dialog: a Dialog is content-sized (height: fit-content,
* maxHeight 480px) and ignores top/bottom insets, so it renders as a floating
* card rather than a drawer. MobileNav is the edge-anchored, full-height
* primitive this actually needs.
*
* It renders the SAME <CopilotPanel /> as the inline path — crossing the
* breakpoint changes where the panel lives, never what it contains or
* remembers, because all state sits in CopilotProvider above the route tree.
*/
export function CopilotSlideOver() {
const bp = useBreakpoint();
const {isSlideOverOpen, setSlideOverOpen} = useCopilot();
return (
<MobileNav
isOpen={isSlideOverOpen}
onOpenChange={setSlideOverOpen}
side="end"
// Full-bleed on mobile, where a 380px drawer would leave nothing
// behind it worth looking at.
width={bp === 'mobile' ? undefined : 380}
label="AI Copilot"
>
<CopilotPanel onDismiss={() => setSlideOverOpen(false)} />
</MobileNav>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import {useBreakpoint, isPanelInline} from '@/lib/breakpoints';
import {useCopilot} from './CopilotProvider';
/**
* One control, two behaviours: above the laptop breakpoint it collapses the
* inline panel to give the workspace full width; below it, it opens the
* slide-over. Both read and write the same provider, so the Copilot's contents
* never notice which one is in play.
*/
export function CopilotToggle() {
const bp = useBreakpoint();
const {isOpen, toggle, isSlideOverOpen, setSlideOverOpen} = useCopilot();
const inline = isPanelInline(bp);
const isShown = inline ? isOpen : isSlideOverOpen;
return (
<IconButton
icon={<Icon icon={isShown ? ICONS.panelClose : ICONS.panelOpen} />}
label={isShown ? 'Hide Copilot' : 'Show Copilot'}
variant="ghost"
onClick={() => (inline ? toggle() : setSlideOverOpen(!isSlideOverOpen))}
/>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Card} from '@astryxdesign/core/Card';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {Badge} from '@astryxdesign/core/Badge';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
/**
* Phase 1 renders the panel's shape with representative content so the layout
* can be judged. The insight list becomes `useResource(endpoints['copilot.insights'])`
* in Phase 2 — the markup below is already the success branch of that.
*/
export function AiTab() {
return (
<VStack gap={4} padding={4}>
<Card variant="muted">
<VStack gap={2}>
<HStack gap={2} vAlign="center">
<Icon icon={ICONS.ai} size="sm" />
<Heading level={3}>Daily summary</Heading>
</HStack>
<Text size="sm" color="secondary">
Footfall is tracking 8% above last Tuesday across all five stores.
LYT redemption is concentrated in Indiranagar, which is running at
2.3× the network average.
</Text>
</VStack>
</Card>
<VStack gap={2}>
<Text size="sm" color="secondary">
Needs attention
</Text>
<Banner
status="warning"
title="Sales dropped 12% at Whitefield"
description="Down against the trailing 4-week Tuesday average."
/>
<Banner
status="error"
title="3 rewards expire in 48 hours"
description="Free Coffee, Combo 20%, Weekend Bonus."
/>
</VStack>
<VStack gap={2}>
<Text size="sm" color="secondary">
Recommendations
</Text>
<Card>
<VStack gap={1.5}>
<HStack gap={2} vAlign="center">
<Text weight="medium">Extend Weekend Bonus</Text>
<Badge variant="success" label="High confidence" />
</HStack>
<Text size="sm" color="secondary">
It converts at 34% — the best of any active reward — and expires
Friday.
</Text>
</VStack>
</Card>
<Card>
<VStack gap={1.5}>
<Text weight="medium">Add evening cover at Koramangala</Text>
<Text size="sm" color="secondary">
Peak footfall starts at 18:30 but only two staff are rostered
after 18:00.
</Text>
</VStack>
</Card>
</VStack>
</VStack>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
/**
* Placeholder until the chart primitives land in 1.7. It then hosts revenue
* forecast, customer growth and store/reward comparison at height={180} —
* the narrow-column variant of the same ChartCard used in the workspace.
*/
export function AnalyticsTab() {
return (
<VStack gap={4} padding={4}>
<Text size="sm" color="secondary">
Forecasts and comparisons, scoped to the current store and range.
</Text>
<EmptyPanel
icon="analytics"
title="Charts arrive with the analytics layer"
description="Revenue forecast, customer growth, store and reward comparison."
/>
</VStack>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import {
ChatComposer,
ChatComposerInput,
ChatDictationButton,
ChatLayout,
ChatMessage,
ChatMessageBubble,
ChatMessageList,
ChatMessageMetadata,
useChatDictation,
} from '@astryxdesign/core/Chat';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Button} from '@astryxdesign/core/Button';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {useCopilot} from '../CopilotProvider';
/**
* Suggested prompts double as the empty state — an empty AI panel with a bare
* input tells a merchant nothing about what it can answer.
*/
const SUGGESTED = [
"Generate today's report",
'Compare stores',
'Suggest rewards',
'Analyze sales',
'Show best staff',
'Predict weekend traffic',
];
function SuggestedPrompts({onPick}: {onPick: (p: string) => void}) {
return (
<VStack gap={2} padding={4}>
<Text size="sm" color="secondary">
Ask about footfall, LYT redemption, staff or store performance.
</Text>
<VStack gap={1.5}>
{SUGGESTED.map((p) => (
<Button
key={p}
variant="secondary"
size="sm"
label={p}
onClick={() => onPick(p)}
/>
))}
</VStack>
</VStack>
);
}
export function ChatTab() {
const {messages, draft, setDraft, send, isStreaming} = useCopilot();
// Appends recognised speech to whatever is already typed rather than
// replacing it, so dictation composes with the keyboard.
const dictation = useChatDictation({
onResult: (text: string) => setDraft(draft ? `${draft} ${text}` : text),
});
return (
<ChatLayout
density="compact"
emptyState={<SuggestedPrompts onPick={send} />}
composer={
<ChatComposer
value={draft}
onChange={setDraft}
onSubmit={send}
placeholder="Ask about footfall, LYTs, staff…"
density="compact"
elevation="none"
isStopShown={isStreaming}
input={<ChatComposerInput maxRows={6} />}
// Renders nothing when SpeechRecognition is unsupported
// (isHiddenWhenUnsupported defaults true) — progressive, no polyfill.
sendActions={<ChatDictationButton dictation={dictation} size="sm" />}
/>
}
>
{messages.length > 0 ? (
<ChatMessageList isStreaming={isStreaming}>
{messages.map((m) => (
<ChatMessage
key={m.id}
sender={m.sender}
density="compact"
avatar={
m.sender === 'assistant' ? (
<Avatar name="Loyaly AI" size="xsm" tooltip={false} />
) : undefined
}
>
<ChatMessageBubble
variant={m.sender === 'assistant' ? 'ghost' : 'filled'}
metadata={
<ChatMessageMetadata
timestamp={<Timestamp value={m.at} />}
/>
}
>
{m.text}
</ChatMessageBubble>
</ChatMessage>
))}
</ChatMessageList>
) : null}
</ChatLayout>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import {PanelCard} from '@/components/patterns/PanelCard';
import {ActivityFeed} from '@/components/patterns/ActivityItem';
import {SkeletonRows} from '@/components/patterns/LoadingState';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import type {ActivityEntry} from '@/components/patterns/ActivityItem';
import type {ActivityEvent, ActivityKind} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
/**
* Only one activity kind carries semantic colour. An expiry is the single
* event in this feed a merchant may need to act on; redemptions, purchases,
* check-ins and openings are the shop working normally, and colouring them
* would make the one thing that matters harder to find.
*/
const KIND: Record<ActivityKind, {tone: StatusDotVariant; toneLabel: string}> = {
reward_redeemed: {tone: 'neutral', toneLabel: 'Redemption'},
purchase: {tone: 'neutral', toneLabel: 'Purchase'},
staff_checked_in: {tone: 'neutral', toneLabel: 'Attendance'},
store_opened: {tone: 'neutral', toneLabel: 'Store'},
reward_expired: {tone: 'warning', toneLabel: 'Expiry'},
};
function toEntries(events: ActivityEvent[]): ActivityEntry[] {
return events.map((e) => ({
id: e.id,
title: e.title,
detail: e.detail,
at: e.at,
...KIND[e.kind],
}));
}
export function ActivityTimeline({
resource,
}: {
resource: Resource<ActivityEvent[]>;
}) {
return (
<PanelCard
title="Recent activity"
subtitle="Live feed across the selected store and period"
resource={resource}
loading={<SkeletonRows count={5} />}
empty={
<EmptyPanel
icon="alert"
title="No activity yet"
description="Events appear here as customers redeem rewards and staff check in."
/>
}
>
{(events) => <ActivityFeed entries={toEntries(events)} />}
</PanelCard>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import {Grid} from '@astryxdesign/core/Grid';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {MetricCard} from '@/components/patterns/MetricCard';
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
import {ICONS} from '@/lib/icons';
import {useBreakpoint, isPanelInline} from '@/lib/breakpoints';
import {useCopilot} from '@/features/copilot/CopilotProvider';
import {formatCompact, formatInrCompact} from '@/lib/format';
import type {Kpi, KpiId} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
import type {IconType} from '@astryxdesign/core/Icon';
const KPI_ICON: Record<KpiId, IconType> = {
visitors: ICONS.visitors,
purchases: ICONS.purchases,
revenue: ICONS.revenue,
activeRewards: ICONS.activeRewards,
};
/**
* Four KPIs must lay out 4-up, 2×2, or stacked — never 3+1, which reads as a
* broken grid. `repeat: 'fit'` cannot express that: at a ~800px workspace it
* yields exactly three columns whatever minWidth you choose, and the fourth
* card drops to an orphan row.
*
* So the count is computed rather than inferred. The two things that decide
* the available width are the breakpoint and whether the Copilot panel is
* taking 320–380px of it, and both are already known here.
*/
export function useMetricColumns(): number {
const bp = useBreakpoint();
const {isOpen} = useCopilot();
if (bp === 'mobile') return 1;
if (bp === 'tablet') return 2;
if (bp === 'laptop') return 2;
return isPanelInline(bp) && isOpen ? 2 : 4;
}
export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {
const columns = useMetricColumns();
return (
<AsyncBoundary
resource={resource}
loading={<SkeletonMetricGrid columns={columns} />}
>
{(kpis) => (
<Grid columns={columns} gap={4}>
{kpis.map((k) => (
<MetricCard
key={k.id}
label={k.label}
value={k.value}
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
icon={KPI_ICON[k.id]}
deltaPct={k.deltaPct}
isRiseGood={k.isRiseGood}
trend={k.trend}
/>
))}
</Grid>
)}
</AsyncBoundary>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {ChartCard} from '@/components/charts/ChartCard';
import {BarChartView} from '@/components/charts/BarChartView';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import type {Scope} from '@/lib/api/client';
import type {Granularity, PeriodPoint} from '@/lib/api/contracts';
import {formatInrCompact} from '@/lib/format';
/**
* Weekly / monthly rollup.
*
* The granularity is part of the request rather than a client-side regroup:
* the buckets have to come from the same generator the daily series does, or
* the weekly totals quietly stop matching the daily chart above them.
*/
export function PerformancePanel({
scope,
granularity,
onGranularityChange,
}: {
scope: Scope;
granularity: Granularity;
onGranularityChange: (g: Granularity) => void;
}) {
const resource = useResource<PeriodPoint[]>(
endpoints.dashboardPerformance(scope, granularity),
);
return (
<ChartCard
title="Performance"
subtitle={
granularity === 'weekly'
? 'Revenue by ISO week'
: 'Revenue by calendar month'
}
resource={resource}
actions={
<SegmentedControl
value={granularity}
onChange={(v) => onGranularityChange(v as Granularity)}
label="Performance granularity"
size="sm"
>
<SegmentedControlItem value="weekly" label="Weekly" />
<SegmentedControlItem value="monthly" label="Monthly" />
</SegmentedControl>
}
>
{(rows) => (
<BarChartView
data={rows}
xKey="label"
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
/>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import {ChartCard} from '@/components/charts/ChartCard';
import {BarChartView} from '@/components/charts/BarChartView';
import {formatCompact} from '@/lib/format';
import type {RewardUsagePoint} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
/**
* Claimed vs used, per reward.
*
* The gap between the two bars is the whole point: a reward claimed 700 times
* and redeemed 130 is an outstanding liability and a broken offer, not a
* success. Plotting the usage *rate* alone would hide the volume; plotting
* volume alone would hide the failure. So both bars, side by side.
*/
export function RewardUsageChart({
resource,
}: {
resource: Resource<RewardUsagePoint[]>;
}) {
return (
<ChartCard
title="Reward usage"
subtitle="Claimed vs actually redeemed — the gap is unspent liability"
resource={resource}
>
{(rows) => (
<BarChartView
data={rows}
xKey="name"
yFormat={formatCompact}
series={[
{key: 'claimed', label: 'Claimed'},
{key: 'used', label: 'Redeemed'},
]}
/>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Sparkline} from '@/components/charts/Sparkline';
import {ChartCard} from '@/components/charts/ChartCard';
import {StatPair, StatRow} from '@/components/patterns/StatPair';
import {formatCompact, formatInrCompact, formatPct} from '@/lib/format';
import type {StoreComparison as StoreComparisonRow} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
/**
* Small multiples, not a five-series chart.
*
* This is the palette constraint doing its job: five stores on one axis would
* need five colours, and the monochrome ramp only separates about three. One
* card per store is also simply better for the question being asked — "which
* store is the outlier?" is a comparison of shapes, not of overlapping lines.
*/
export function StoreComparisonPanel({
resource,
}: {
resource: Resource<StoreComparisonRow[]>;
}) {
return (
<ChartCard
title="Store comparison"
subtitle="One card per store — five series on one axis would need five colours"
resource={resource}
height={220}
>
{(rows) => {
const best = rows.reduce((a, b) => (b.visitors > a.visitors ? b : a));
const worst = rows.reduce((a, b) => (b.visitors < a.visitors ? b : a));
return (
<Grid columns={{minWidth: 200, repeat: 'fit'}} gap={3}>
{rows.map((r) => (
<Card key={r.storeId} variant="muted">
<VStack gap={2}>
<HStack gap={2} vAlign="center" hAlign="between">
<Text size="sm" weight="medium">
{r.name}
</Text>
{r.storeId === best.storeId ? (
<Badge variant="success" label="Top" />
) : r.storeId === worst.storeId ? (
<Badge variant="warning" label="Lowest" />
) : null}
</HStack>
{/* A visitor count is data, not a section title. */}
<Text type="display-3">{formatCompact(r.visitors)}</Text>
<Sparkline data={r.trend} dataKey="v" height={34} />
<StatRow>
<StatPair
label="Revenue"
value={formatInrCompact(r.revenueInr)}
/>
<StatPair
label="Conversion"
value={formatPct(r.conversionPct)}
align="end"
/>
</StatRow>
</VStack>
</Card>
))}
</Grid>
);
}}
</ChartCard>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Banner} from '@astryxdesign/core/Banner';
import {PanelCard} from '@/components/patterns/PanelCard';
import {SkeletonRows} from '@/components/patterns/LoadingState';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {formatCompact, formatLyt} from '@/lib/format';
import {daysUntil} from './reward-status';
import type {Reward} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
/**
* Rewards lapsing within a fortnight.
*
* This panel exists because expiry is the one thing on this page with a
* deadline. Everything else can be read whenever; an offer expiring in two
* days either gets extended today or it doesn't.
*
* Sorted soonest-first, and the description leads with outstanding liability
* rather than claim count — the actionable number is what customers are still
* holding, not how popular the offer was.
*/
const HORIZON_DAYS = 14;
export function ExpiryAlerts({
resource,
nowMs,
}: {
resource: Resource<Reward[]>;
nowMs: number;
}) {
return (
<PanelCard
title="Expiry alerts"
subtitle={`Rewards lapsing in the next ${HORIZON_DAYS} days`}
resource={resource}
loading={<SkeletonRows count={2} height={64} />}
empty={
<EmptyPanel
icon="expiry"
title="Nothing expiring"
description="No active rewards lapse in the next two weeks."
/>
}
>
{(rewards) => {
const soon = rewards
.map((r) => ({r, days: daysUntil(r.expiresAt, nowMs)}))
.filter(
(x): x is {r: Reward; days: number} =>
x.days !== null && x.days >= 0 && x.days <= HORIZON_DAYS,
)
.sort((a, b) => a.days - b.days);
if (soon.length === 0) {
return (
<EmptyPanel
icon="expiry"
title="Nothing expiring"
description="No active rewards lapse in the next two weeks."
/>
);
}
return (
<VStack gap={2}>
{soon.map(({r, days}) => {
const outstanding = r.claimed - r.used;
return (
<Banner
key={r.id}
// Under 3 days is an emergency; the rest is a heads-up.
status={days <= 3 ? 'error' : 'warning'}
title={`${r.name} expires ${days === 0 ? 'today' : days === 1 ? 'tomorrow' : `in ${days} days`}`}
description={`${formatCompact(outstanding)} unredeemed claims · ${formatLyt(outstanding * r.costLyt)} outstanding`}
/>
);
})}
</VStack>
);
}}
</PanelCard>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {SectionHeader} from '@/components/patterns/SectionHeader';
import {Badge} from '@astryxdesign/core/Badge';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {Divider} from '@astryxdesign/core/Divider';
import {Icon} from '@astryxdesign/core/Icon';
import {HoverLift} from '@/components/motion/HoverLift';
import {StatPair, StatRow} from '@/components/patterns/StatPair';
import {ICONS} from '@/lib/icons';
import {formatCompact, formatLyt, formatPct} from '@/lib/format';
import {REWARD_STATUS, daysUntil, usageRate} from './reward-status';
import type {Reward} from '@/lib/api/contracts';
/**
* One reward.
*
* The progress bar is claimed-vs-redeemed, and it is the point of the card:
* the unfilled portion is outstanding liability — LYTs a customer holds and
* can spend at any time. A reward claimed 800 times and redeemed 150 is not a
* popular offer, it is ₹78,000 of unbudgeted exposure.
*/
export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) {
const status = REWARD_STATUS[reward.status];
const rate = usageRate(reward.claimed, reward.used);
const days = daysUntil(reward.expiresAt, nowMs);
const outstanding = reward.claimed - reward.used;
return (
<HoverLift>
<Card>
<VStack gap={4} height="100%">
<SectionHeader
level={3}
title={reward.name}
subtitle={
<HStack gap={1.5} vAlign="center">
<Icon icon={ICONS.lyt} size="xsm" color="secondary" />
<Text size="sm" color="secondary">
{formatLyt(reward.costLyt)}
</Text>
</HStack>
}
actions={<Badge variant={status.variant} label={status.label} />}
/>
<VStack gap={1.5}>
<ProgressBar
value={rate}
max={100}
label={`Redeemed ${formatPct(rate, 0)} of claims`}
hasValueLabel={false}
/>
<HStack hAlign="between">
<Text size="xsm" color="secondary">
{formatCompact(reward.used)} redeemed
</Text>
<Text size="xsm" color="secondary">
{formatCompact(reward.claimed)} claimed
</Text>
</HStack>
</VStack>
<Divider />
<StatRow>
<StatPair
label="Outstanding"
value={formatLyt(outstanding * reward.costLyt)}
/>
<StatPair
label={days === null ? 'Expires' : days < 0 ? 'Expired' : 'Expires in'}
value={
days === null
? 'Never'
: days < 0
? `${Math.abs(days)}d ago`
: `${days}d`
}
align="end"
/>
</StatRow>
</VStack>
</Card>
</HoverLift>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {FilterBar} from '@/components/patterns/FilterBar';
import {CollectionRegion} from '@/components/patterns/CollectionRegion';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {SkeletonCardGrid} from '@/components/patterns/LoadingState';
import {RewardCard} from './RewardCard';
import type {Reward, RewardStatus} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
type Filter = 'all' | RewardStatus;
const REWARD_GRID_COLUMNS = {minWidth: 270, max: 3, repeat: 'fit'} as const;
export function RewardGrid({
resource,
nowMs,
}: {
resource: Resource<Reward[]>;
nowMs: number;
}) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<Filter>('all');
// Memoised on resource.data: `?? []` allocates a fresh array every render,
// which would make it an unstable dependency and recompute both memos below
// on every keystroke.
const rewards = useMemo(() => resource.data ?? [], [resource.data]);
// Counts come from the unfiltered set so the chips still read "Expiring (2)"
// while another filter is active.
const counts = useMemo(() => {
const c: Record<Filter, number> = {
all: rewards.length,
active: 0,
paused: 0,
expiring: 0,
expired: 0,
};
for (const r of rewards) c[r.status] += 1;
return c;
}, [rewards]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return rewards.filter(
(r) =>
(filter === 'all' || r.status === filter) &&
(q === '' || r.name.toLowerCase().includes(q)),
);
}, [rewards, query, filter]);
return (
<CollectionRegion label="Reward catalogue">
<FilterBar
query={query}
onQueryChange={setQuery}
placeholder="Search rewards…"
filterValue={filter}
onFilterChange={(v) => setFilter(v as Filter)}
filterLabel="Filter rewards by status"
options={[
{value: 'all', label: 'All', hint: counts.all},
{value: 'active', label: 'Active', hint: counts.active},
{value: 'expiring', label: 'Expiring', hint: counts.expiring},
{value: 'paused', label: 'Paused', hint: counts.paused},
{value: 'expired', label: 'Expired', hint: counts.expired},
]}
resultCount={visible.length}
resultNoun={visible.length === 1 ? 'reward' : 'rewards'}
/>
<AsyncBoundary
resource={resource}
loading={<SkeletonCardGrid count={6} height={230} />}
empty={
<EmptyPanel
icon="lyts"
title="No rewards yet"
description="Create a reward to let customers spend their LYTs in store."
/>
}
>
{() =>
visible.length === 0 ? (
<EmptyPanel
icon="lyts"
title="No rewards match"
description="Try a different status filter or clear the search."
/>
) : (
<Grid columns={REWARD_GRID_COLUMNS} gap={4}>
{visible.map((r) => (
<RewardCard key={r.id} reward={r} nowMs={nowMs} />
))}
</Grid>
)
}
</AsyncBoundary>
</CollectionRegion>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import {Table, proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {Badge} from '@astryxdesign/core/Badge';
import {Text} from '@astryxdesign/core/Text';
import {HStack} from '@astryxdesign/core/Layout';
import {PanelCard} from '@/components/patterns/PanelCard';
import {SkeletonRows} from '@/components/patterns/LoadingState';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {MetricDelta} from '@/components/primitives/MetricDelta';
import {formatCompact, formatLyt} from '@/lib/format';
import {REWARD_STATUS, usageRate} from './reward-status';
import type {Reward} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
interface Row extends Record<string, unknown> {
id: string;
name: string;
status: Reward['status'];
costLyt: number;
claimed: number;
used: number;
rate: number;
outstanding: number;
}
/**
* The whole catalogue as rows.
*
* A table rather than more cards: this is the view for comparing eight
* rewards on the same four numbers, and cards force the eye to travel in two
* dimensions to do a one-dimensional comparison. Sorted by redemption rate so
* the offers that aren't working surface at the bottom without hunting.
*/
export function RewardPerformanceTable({
resource,
}: {
resource: Resource<Reward[]>;
}) {
const columns: TableColumn<Row>[] = [
{
key: 'name',
header: 'Reward',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{row.name}
</Text>
<Badge
variant={REWARD_STATUS[row.status].variant}
label={REWARD_STATUS[row.status].label}
/>
</HStack>
),
},
{
key: 'costLyt',
header: 'Cost',
width: pixel(96),
align: 'end',
renderCell: (row) => <Text size="sm">{formatLyt(row.costLyt)}</Text>,
},
{
key: 'claimed',
header: 'Claimed',
width: pixel(96),
align: 'end',
renderCell: (row) => <Text size="sm">{formatCompact(row.claimed)}</Text>,
},
{
key: 'used',
header: 'Redeemed',
width: pixel(104),
align: 'end',
renderCell: (row) => <Text size="sm">{formatCompact(row.used)}</Text>,
},
{
key: 'rate',
header: 'Rate',
width: pixel(104),
align: 'end',
// Measured against a 50% house benchmark, so the arrow says "better or
// worse than a reward should do" rather than "up or down since when?".
renderCell: (row) => (
<HStack hAlign="end">
<MetricDelta value={row.rate - 50} isRiseGood size="xsm" />
</HStack>
),
},
{
key: 'outstanding',
header: 'Outstanding',
width: pixel(128),
align: 'end',
renderCell: (row) => (
<Text size="sm">{formatLyt(row.outstanding)}</Text>
),
},
];
return (
<PanelCard
title="Reward performance"
subtitle="Sorted by redemption rate — the weakest offers sit at the bottom"
resource={resource}
loading={<SkeletonRows count={6} height={32} />}
empty={
<EmptyPanel
icon="lyts"
title="No rewards to compare"
description="Performance appears once rewards have been claimed."
/>
}
>
{(rewards) => {
const rows: Row[] = rewards
.map((r) => ({
id: r.id,
name: r.name,
status: r.status,
costLyt: r.costLyt,
claimed: r.claimed,
used: r.used,
rate: usageRate(r.claimed, r.used),
outstanding: (r.claimed - r.used) * r.costLyt,
}))
.sort((a, b) => b.rate - a.rate);
return (
<Table
data={rows}
columns={columns}
idKey="id"
density="compact"
hasHover
/>
);
}}
</PanelCard>
);
}

View File

@@ -0,0 +1,29 @@
import type {RewardStatus} from '@/lib/api/contracts';
/**
* Reward status → badge treatment.
*
* Only `expiring` and `expired` carry colour. An active reward is the normal
* case and a paused one is a deliberate choice by the merchant — neither
* needs attention. Colouring all four would leave nothing standing out on a
* page whose entire job is surfacing the ones about to lapse.
*/
export const REWARD_STATUS: Record<
RewardStatus,
{label: string; variant: 'neutral' | 'warning' | 'error'}
> = {
active: {label: 'Active', variant: 'neutral'},
paused: {label: 'Paused', variant: 'neutral'},
expiring: {label: 'Expiring', variant: 'warning'},
expired: {label: 'Expired', variant: 'error'},
};
/** Whole days until expiry; negative once lapsed, null if it never expires. */
export function daysUntil(expiresAt: string | null, nowMs: number): number | null {
if (!expiresAt) return null;
return Math.ceil((new Date(expiresAt).getTime() - nowMs) / 86400000);
}
export function usageRate(claimed: number, used: number): number {
return claimed === 0 ? 0 : Number(((used / claimed) * 100).toFixed(1));
}

View File

@@ -0,0 +1,120 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Switch} from '@astryxdesign/core/Switch';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
import type {MerchantProfile} from '@/lib/api/contracts';
type NotificationKey = keyof MerchantProfile['notifications'];
/**
* Each toggle names the alert AND what triggers it. "Low conversion" alone is
* a setting a merchant has to guess at; "when a store drops below 15% for two
* days" is one they can decide about.
*/
const ALERTS: {
key: NotificationKey;
label: string;
description: string;
}[] = [
{
key: 'expiryAlerts',
label: 'Reward expiry',
description:
'When an active reward is within 3 days of lapsing, with the outstanding LYT liability.',
},
{
key: 'dailySummary',
label: 'Daily summary',
description:
'Footfall, revenue and redemption for the previous day, each morning at 09:00.',
},
{
key: 'staffAbsence',
label: 'Staff absence',
description:
'When a store opens with fewer than half its rostered team present.',
},
{
key: 'lowConversion',
label: 'Low conversion',
description:
'When a store stays below 15% conversion for two consecutive days.',
},
];
export function NotificationsForm({
initialData,
}: {
initialData: MerchantProfile['notifications'];
}) {
const toast = useToast();
const [value, setValue] = useState(initialData);
const [isSaving, setIsSaving] = useState(false);
const isDirty = JSON.stringify(value) !== JSON.stringify(initialData);
async function save() {
setIsSaving(true);
try {
const res = await fetch('/api/settings/profile', {
method: 'PATCH',
headers: {'content-type': 'application/json'},
body: JSON.stringify({notifications: value}),
});
if (!res.ok) throw new Error(`Save failed (${res.status})`);
toast({body: 'Notification preferences saved'});
} catch (err) {
toast({type: 'error', body: (err as Error).message});
} finally {
setIsSaving(false);
}
}
return (
<StaticPanel
title="Alerts"
subtitle="What Loyaly tells you about, and when."
actions={
<HStack gap={2} vAlign="center">
{isDirty ? (
<Text size="sm" color="secondary">
Unsaved changes
</Text>
) : null}
<Button
variant="primary"
size="sm"
label="Save"
isDisabled={!isDirty}
isLoading={isSaving}
onClick={save}
/>
</HStack>
}
>
<VStack gap={0}>
{ALERTS.map((a, i) => (
<VStack key={a.key} gap={0}>
<HStack paddingBlock={3}>
<Switch
label={a.label}
description={a.description}
value={value[a.key]}
onChange={(checked) =>
setValue((v) => ({...v, [a.key]: checked}))
}
/>
</HStack>
{i === ALERTS.length - 1 ? null : <Divider />}
</VStack>
))}
</VStack>
</StaticPanel>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {FormLayout} from '@astryxdesign/core/FormLayout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {NumberInput} from '@astryxdesign/core/NumberInput';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
import {endpointUrl} from '@/lib/api/client';
import type {MerchantProfile} from '@/lib/api/contracts';
/**
* Merchant profile.
*
* `initialData` arrives from a Server Component that read the record
* DIRECTLY — no HTTP round trip — so the form paints filled on first render
* rather than flashing empty inputs. This is the hybrid path the data layer
* was designed for: dashboards fetch on the client because their scope
* changes constantly, but a settings form has exactly one server-known state
* at page load and should use it.
*
* Status is only ever set on submit, never at initial paint — Astryx's
* FieldStatus breaks SSR hydration when it is server-rendered with a status
* already applied (see AGENTS.md).
*/
export function ProfileForm({initialData}: {initialData: MerchantProfile}) {
const toast = useToast();
const [form, setForm] = useState(initialData);
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<Partial<Record<keyof MerchantProfile, string>>>({});
const set = <K extends keyof MerchantProfile>(
key: K,
value: MerchantProfile[K],
) => setForm((f) => ({...f, [key]: value}));
const isDirty = JSON.stringify(form) !== JSON.stringify(initialData);
async function save() {
// Validation runs on submit so the first paint is never a wall of red.
const next: typeof errors = {};
if (!form.businessName.trim()) next.businessName = 'Business name is required';
if (!/^\S+@\S+\.\S+$/.test(form.email)) next.email = 'Enter a valid email address';
if (form.gstin && form.gstin.length !== 15) {
next.gstin = 'GSTIN is 15 characters';
}
setErrors(next);
if (Object.keys(next).length > 0) return;
setIsSaving(true);
try {
const res = await fetch(
endpointUrl({path: '/api/settings/profile', params: {}}),
{
method: 'PATCH',
headers: {'content-type': 'application/json'},
body: JSON.stringify(form),
},
);
if (!res.ok) throw new Error(`Save failed (${res.status})`);
toast({body: 'Profile saved'});
} catch (err) {
toast({
type: 'error',
body: (err as Error).message || 'Could not save profile',
});
} finally {
setIsSaving(false);
}
}
return (
<StaticPanel
title="Business profile"
subtitle="Shown on receipts and used for LYT settlement."
actions={
<HStack gap={2} vAlign="center">
{isDirty ? (
<Text size="sm" color="secondary">
Unsaved changes
</Text>
) : null}
<Button
variant="secondary"
size="sm"
label="Reset"
isDisabled={!isDirty || isSaving}
onClick={() => {
setForm(initialData);
setErrors({});
}}
/>
<Button
variant="primary"
size="sm"
label="Save changes"
isDisabled={!isDirty}
isLoading={isSaving}
onClick={save}
/>
</HStack>
}
>
<VStack gap={5}>
<FormLayout direction="horizontal">
<TextInput
label="Business name"
value={form.businessName}
onChange={(v) => set('businessName', v)}
isRequired
status={
errors.businessName
? {type: 'error', message: errors.businessName}
: undefined
}
/>
<TextInput
label="Contact name"
value={form.contactName}
onChange={(v) => set('contactName', v)}
/>
</FormLayout>
<FormLayout direction="horizontal">
<TextInput
label="Email"
type="email"
value={form.email}
onChange={(v) => set('email', v)}
isRequired
status={
errors.email ? {type: 'error', message: errors.email} : undefined
}
/>
<TextInput
label="Phone"
value={form.phone}
onChange={(v) => set('phone', v)}
/>
</FormLayout>
<Divider />
<FormLayout direction="horizontal">
<TextInput
label="GSTIN"
value={form.gstin}
onChange={(v) => set('gstin', v.toUpperCase())}
description="15-character GST registration number"
status={
errors.gstin ? {type: 'error', message: errors.gstin} : undefined
}
/>
<TextInput
label="Timezone"
value={form.timezone}
onChange={(v) => set('timezone', v)}
description="All reports are rendered in this zone"
/>
</FormLayout>
<NumberInput
label="LYTs per ₹100 spent"
value={form.lytsPerHundred}
onChange={(v) => set('lytsPerHundred', v ?? 0)}
min={0}
max={100}
description="1 LYT = ₹1. At 5, a ₹1,000 basket earns 50 LYTs — ₹50 of future liability."
width={320}
/>
</VStack>
</StaticPanel>
);
}

View File

@@ -0,0 +1,42 @@
import {ICONS} from '@/lib/icons';
import type {IconType} from '@astryxdesign/core/Icon';
export interface SettingsSection {
label: string;
href: string;
icon: IconType;
description: string;
}
/**
* Settings sub-navigation.
*
* Same shape as the primary nav-config for the same reason: adding a section
* should be a one-line push here plus a route folder, with nothing in the
* layout changing.
*/
export const SETTINGS_NAV: SettingsSection[] = [
{
label: 'Profile',
href: '/settings',
icon: ICONS.profile,
description: 'Business details, contact and tax registration.',
},
{
label: 'Notifications',
href: '/settings/notifications',
icon: ICONS.notifications,
description: 'What Loyaly alerts you about, and when.',
},
{
label: 'Billing',
href: '/settings/billing',
icon: ICONS.revenue,
description: 'Plan, invoices and LYT settlement.',
},
];
/** Exact match only — /settings must not stay active on /settings/billing. */
export function isSettingsActive(pathname: string, href: string): boolean {
return pathname === href;
}

View File

@@ -0,0 +1,65 @@
'use client';
import {ChartCard} from '@/components/charts/ChartCard';
import {BarChartView} from '@/components/charts/BarChartView';
import {CHART} from '@/components/charts/palette';
import {formatCompact, formatDayLabel} from '@/lib/format';
import type {AttendancePoint} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
/**
* Daily attendance, stacked.
*
* PRESENT IS GRAY. Colouring it green produced a wall of saturated bars that
* dominated the page and buried the only thing worth looking at — the amber
* and red slivers. Present is the baseline, not an event; the exceptions are
* what a merchant scans for, and they are what gets colour.
*
* This keeps the app's rule intact (colour marks what needs action) while
* still letting late and absent read instantly without a legend lookup.
*
* Stacked rather than grouped because the meaningful quantity is the roster
* total and how it splits — grouped bars would make you add three numbers to
* answer "how many were rostered on Friday".
*/
export function AttendanceChart({
resource,
}: {
resource: Resource<AttendancePoint[]>;
}) {
return (
<ChartCard
title="Attendance"
subtitle="Daily roster split — present, late, absent"
resource={resource}
>
{(d) => (
<BarChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
isStacked
// Encoding is forced solid: the colour already separates these three,
// so the default hatch on the third series would be noise on top of
// an already-unambiguous signal.
series={[
{key: 'present', label: 'Present', encoding: 'solid'},
{
key: 'late',
label: 'Late',
color: CHART.attention,
encoding: 'solid',
},
{
key: 'absent',
label: 'Absent',
color: CHART.negative,
encoding: 'solid',
},
]}
/>
)}
</ChartCard>
);
}

View File

@@ -0,0 +1,178 @@
'use client';
import {Table, proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Badge} from '@astryxdesign/core/Badge';
import {Text} from '@astryxdesign/core/Text';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {PanelCard} from '@/components/patterns/PanelCard';
import {SkeletonRows} from '@/components/patterns/LoadingState';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {ATTENDANCE} from './attendance';
import {storeName} from '@/lib/mock/stores';
import {formatCompact, formatPct} from '@/lib/format';
import type {Attendance, StaffMember} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
interface Row extends Record<string, unknown> {
id: string;
rank: number;
name: string;
role: string;
storeId: string;
attendance: Attendance;
salesCount: number;
rewardsIssued: number;
performanceScore: number;
punctualityPct: number;
}
/**
* The whole team, ranked.
*
* A table because this is a one-dimensional comparison across many rows —
* cards would force the eye to travel in two dimensions to answer "who is
* third". Capped at the top 10: a leaderboard that lists everyone is a
* roster, and the roster already exists above.
*/
export function Leaderboard({
resource,
}: {
resource: Resource<StaffMember[]>;
}) {
const columns: TableColumn<Row>[] = [
{
key: 'rank',
header: '#',
width: pixel(48),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.rank}
</Text>
),
},
{
key: 'name',
header: 'Team member',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Avatar name={row.name} size="sm" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="xsm" color="secondary">
{row.role} · {storeName(row.storeId)}
</Text>
</VStack>
</HStack>
),
},
{
key: 'attendance',
header: 'Today',
width: pixel(112),
renderCell: (row) => (
<Badge
variant={ATTENDANCE[row.attendance].badge}
label={ATTENDANCE[row.attendance].label}
/>
),
},
{
key: 'salesCount',
header: 'Sales',
width: pixel(88),
align: 'end',
renderCell: (row) => (
<Text size="sm">{formatCompact(row.salesCount)}</Text>
),
},
{
key: 'rewardsIssued',
header: 'Rewards',
width: pixel(96),
align: 'end',
renderCell: (row) => (
<Text size="sm">{formatCompact(row.rewardsIssued)}</Text>
),
},
{
key: 'punctualityPct',
header: 'Punctuality',
width: pixel(112),
align: 'end',
renderCell: (row) => (
<Text size="sm">{formatPct(row.punctualityPct, 0)}</Text>
),
},
{
key: 'performanceScore',
header: 'Score',
// 140px was too narrow for a bar plus a 2-digit number side by side —
// the value wrapped under the bar and read as two separate figures.
width: pixel(180),
renderCell: (row) => (
<HStack gap={2} vAlign="center" wrap="nowrap">
<ProgressBar
value={row.performanceScore}
max={100}
label={`Score ${row.performanceScore} of 100`}
isLabelHidden
hasValueLabel={false}
/>
<Text size="sm" weight="medium">
{row.performanceScore}
</Text>
</HStack>
),
},
];
return (
<PanelCard
title="Leaderboard"
subtitle="Top 10 by performance score across the selected scope"
resource={resource}
loading={<SkeletonRows count={6} height={40} />}
empty={
<EmptyPanel
icon="leaderboard"
title="No one to rank yet"
description="The leaderboard fills in once staff record sales."
/>
}
>
{(staff) => {
const rows: Row[] = [...staff]
.sort((a, b) => b.performanceScore - a.performanceScore)
.slice(0, 10)
.map((m, i) => ({
id: m.id,
rank: i + 1,
name: m.name,
role: m.role,
storeId: m.storeId,
attendance: m.attendance,
salesCount: m.salesCount,
rewardsIssued: m.rewardsIssued,
performanceScore: m.performanceScore,
punctualityPct: m.punctualityPct,
}));
return (
<Table
data={rows}
columns={columns}
idKey="id"
density="compact"
hasHover
/>
);
}}
</PanelCard>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Avatar} from '@astryxdesign/core/Avatar';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {Divider} from '@astryxdesign/core/Divider';
import {HoverLift} from '@/components/motion/HoverLift';
import {SectionHeader} from '@/components/patterns/SectionHeader';
import {StatPair, StatRow} from '@/components/patterns/StatPair';
import {ATTENDANCE} from './attendance';
import {storeName} from '@/lib/mock/stores';
import {formatCompact, formatPct} from '@/lib/format';
import type {StaffMember} from '@/lib/api/contracts';
/**
* One team member.
*
* The performance score gets a bar rather than a bare number because a score
* out of 100 is meaningless without a sense of the scale — 68 reads very
* differently as a filled bar than as a digit. Sales and rewards stay as
* plain figures; they are counts, not proportions.
*/
export function StaffCard({member}: {member: StaffMember}) {
const attendance = ATTENDANCE[member.attendance];
return (
<HoverLift>
<Card>
<VStack gap={4} height="100%">
<SectionHeader
level={3}
title={member.name}
subtitle={
<Text size="sm" color="secondary">
{member.role} · {storeName(member.storeId)}
</Text>
}
actions={
<Badge variant={attendance.badge} label={attendance.label} />
}
/>
<HStack gap={3} vAlign="center">
<Avatar name={member.name} size="md" tooltip={false} />
<VStack gap={1} width="100%">
<ProgressBar
value={member.performanceScore}
max={100}
label={`Performance score ${member.performanceScore} of 100`}
hasValueLabel={false}
/>
<HStack hAlign="between">
<Text size="xsm" color="secondary">
Performance
</Text>
<Text size="xsm" weight="medium">
{member.performanceScore}/100
</Text>
</HStack>
</VStack>
</HStack>
<Divider />
<StatRow>
<StatPair label="Sales" value={formatCompact(member.salesCount)} />
<StatPair
label="Rewards issued"
value={formatCompact(member.rewardsIssued)}
/>
<StatPair
label="Punctuality"
value={formatPct(member.punctualityPct, 0)}
align="end"
/>
</StatRow>
</VStack>
</Card>
</HoverLift>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {FilterBar} from '@/components/patterns/FilterBar';
import {CollectionRegion} from '@/components/patterns/CollectionRegion';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {SkeletonCardGrid} from '@/components/patterns/LoadingState';
import {StaffCard} from './StaffCard';
import {ATTENDANCE} from './attendance';
import type {Attendance, StaffMember} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
type Filter = 'all' | Attendance;
const STAFF_GRID_COLUMNS = {minWidth: 300, max: 3, repeat: 'fit'} as const;
export function StaffGrid({resource}: {resource: Resource<StaffMember[]>}) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<Filter>('all');
// `?? []` allocates a fresh array every render, which would make it an
// unstable dependency and recompute both memos on every keystroke.
const staff = useMemo(() => resource.data ?? [], [resource.data]);
const counts = useMemo(() => {
const c: Record<Filter, number> = {
all: staff.length,
present: 0,
late: 0,
absent: 0,
leave: 0,
};
for (const m of staff) c[m.attendance] += 1;
return c;
}, [staff]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return staff
.filter(
(m) =>
(filter === 'all' || m.attendance === filter) &&
(q === '' ||
m.name.toLowerCase().includes(q) ||
m.role.toLowerCase().includes(q)),
)
// Best performers first — the roster's default question is "who is
// carrying this store", not "who was added first".
.sort((a, b) => b.performanceScore - a.performanceScore);
}, [staff, query, filter]);
return (
<CollectionRegion label="Team roster">
<FilterBar
query={query}
onQueryChange={setQuery}
placeholder="Search name or role…"
filterValue={filter}
onFilterChange={(v) => setFilter(v as Filter)}
filterLabel="Filter staff by attendance"
options={[
{value: 'all', label: 'All', hint: counts.all},
{value: 'present', label: ATTENDANCE.present.label, hint: counts.present},
{value: 'late', label: ATTENDANCE.late.label, hint: counts.late},
{value: 'absent', label: ATTENDANCE.absent.label, hint: counts.absent},
{value: 'leave', label: ATTENDANCE.leave.label, hint: counts.leave},
]}
resultCount={visible.length}
resultNoun={visible.length === 1 ? 'person' : 'people'}
/>
<AsyncBoundary
resource={resource}
loading={<SkeletonCardGrid count={6} height={230} minWidth={300} />}
empty={
<EmptyPanel
icon="staff"
title="No staff yet"
description="Team members appear here once they are added to a store."
/>
}
>
{() =>
visible.length === 0 ? (
<EmptyPanel
icon="staff"
title="Nobody matches"
description="Try a different attendance filter or clear the search."
/>
) : (
<Grid columns={STAFF_GRID_COLUMNS} gap={4}>
{visible.map((m) => (
<StaffCard key={m.id} member={m} />
))}
</Grid>
)
}
</AsyncBoundary>
</CollectionRegion>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
import {HoverLift} from '@/components/motion/HoverLift';
import {AnimatedNumber} from '@/components/motion/AnimatedNumber';
import {useMetricColumns} from '@/features/dashboard/KpiRow';
import {ATTENDANCE} from './attendance';
import {formatPct} from '@/lib/format';
import type {StaffSummary} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
/**
* Attendance counts.
*
* Deliberately NOT MetricCard: these have no trend line and no
* period-over-period delta — "present today" has nothing meaningful to
* compare against last month. What they carry instead is a status dot and a
* share-of-total, so they get their own small card rather than a MetricCard
* with two empty slots.
*/
function CountCard({
label,
value,
total,
tone,
}: {
label: string;
value: number;
total: number;
tone: StatusDotVariant;
}) {
const share = total === 0 ? 0 : (value / total) * 100;
return (
<HoverLift>
<Card>
<VStack gap={3}>
<HStack gap={2} vAlign="center">
<StatusDot variant={tone} label={label} />
<Text size="sm" color="secondary">
{label}
</Text>
</HStack>
{/* Text, not Heading — a count is data, not a section title. */}
<Text type="display-3">
<AnimatedNumber
value={value}
format={(v) => String(Math.round(v))}
/>
</Text>
<Text size="xsm" color="secondary">
{formatPct(share, 0)} of {total} rostered
</Text>
</VStack>
</Card>
</HoverLift>
);
}
export function StaffKpis({resource}: {resource: Resource<StaffSummary>}) {
const columns = useMetricColumns();
return (
<AsyncBoundary
resource={resource}
loading={<SkeletonMetricGrid columns={columns} />}
>
{(s) => (
<Grid columns={columns} gap={4}>
<CountCard
label={ATTENDANCE.present.label}
value={s.present}
total={s.total}
tone={ATTENDANCE.present.tone}
/>
<CountCard
label={ATTENDANCE.late.label}
value={s.late}
total={s.total}
tone={ATTENDANCE.late.tone}
/>
<CountCard
label={ATTENDANCE.absent.label}
value={s.absent}
total={s.total}
tone={ATTENDANCE.absent.tone}
/>
<CountCard
label="Total staff"
value={s.total}
total={s.total}
tone="neutral"
/>
</Grid>
)}
</AsyncBoundary>
);
}

View File

@@ -0,0 +1,29 @@
import type {Attendance} from '@/lib/api/contracts';
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
/**
* Attendance is the one place in this app where semantic colour applies to
* every value rather than to an exception.
*
* Elsewhere the rule is "most things gray, colour marks what needs action".
* Here the whole vocabulary IS status: present/late/absent map cleanly onto
* success/warning/error, and a merchant scanning a roster is reading exactly
* that axis. Greying three of the four would make the set harder to scan, not
* calmer. On leave stays neutral — it is planned, so it needs no attention.
*/
export const ATTENDANCE: Record<
Attendance,
{label: string; tone: StatusDotVariant; badge: 'success' | 'warning' | 'error' | 'neutral'}
> = {
present: {label: 'Present', tone: 'success', badge: 'success'},
late: {label: 'Late', tone: 'warning', badge: 'warning'},
absent: {label: 'Absent', tone: 'error', badge: 'error'},
leave: {label: 'On leave', tone: 'neutral', badge: 'neutral'},
};
export const ATTENDANCE_ORDER: Attendance[] = [
'present',
'late',
'absent',
'leave',
];

View File

@@ -0,0 +1,82 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {SectionHeader} from '@/components/patterns/SectionHeader';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {Divider} from '@astryxdesign/core/Divider';
import {Icon} from '@astryxdesign/core/Icon';
import {HoverLift} from '@/components/motion/HoverLift';
import {StatPair, StatRow} from '@/components/patterns/StatPair';
import {ICONS} from '@/lib/icons';
import {formatCompact, formatInrCompact, formatPct} from '@/lib/format';
import type {Store, StoreStatus} from '@/lib/api/contracts';
/**
* Status is the one thing on this card allowed colour, because it is the one
* thing that can require action. A closed or under-maintenance store is why a
* merchant opened this page; its visitor count is context.
*/
const STATUS: Record<
StoreStatus,
{label: string; variant: 'success' | 'warning' | 'error' | 'neutral'}
> = {
open: {label: 'Open', variant: 'success'},
maintenance: {label: 'Maintenance', variant: 'warning'},
closed: {label: 'Closed', variant: 'error'},
};
export function StoreCard({store}: {store: Store}) {
const status = STATUS[store.status];
return (
<HoverLift>
<Card>
<VStack gap={4} height="100%">
<SectionHeader
level={3}
title={store.name}
subtitle={
<HStack gap={1.5} vAlign="center">
<Icon icon={ICONS.staff} size="xsm" color="secondary" />
<Text size="sm" color="secondary">
{store.staffCount} staff
</Text>
</HStack>
}
actions={<Badge variant={status.variant} label={status.label} />}
/>
<Divider />
<StatRow>
<StatPair label="Visitors" value={formatCompact(store.visitors)} />
<StatPair
label="Purchases"
value={formatCompact(store.purchases)}
/>
</StatRow>
<StatRow>
<StatPair
label="Revenue"
value={formatInrCompact(store.revenueInr)}
/>
<StatPair
label="Conversion"
value={formatPct(store.conversionPct)}
/>
</StatRow>
<Button
variant="secondary"
size="sm"
label="Open dashboard"
href={`/stores/${store.id}`}
/>
</VStack>
</Card>
</HoverLift>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {FilterBar} from '@/components/patterns/FilterBar';
import {CollectionRegion} from '@/components/patterns/CollectionRegion';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {SkeletonCardGrid} from '@/components/patterns/LoadingState';
import {StoreCard} from './StoreCard';
import type {Store, StoreStatus} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
type Filter = 'all' | StoreStatus;
/**
* Three-up on desktop, per spec.
*
* minWidth 270 is what actually yields three tracks once the 16px gaps are
* subtracted from a ~880px workspace — 300 silently collapsed it to two.
* Capped at 3 so a wide monitor doesn't stretch the roster into a thin strip,
* and it steps down to two when the Copilot panel is taking 380px.
*/
const STORE_GRID_COLUMNS = {minWidth: 270, max: 3, repeat: 'fit'} as const;
export function StoreGrid({resource}: {resource: Resource<Store[]>}) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<Filter>('all');
// Memoised on resource.data: `?? []` allocates a fresh array every render,
// which would make it an unstable dependency and recompute both memos below
// on every keystroke.
const stores = useMemo(() => resource.data ?? [], [resource.data]);
// Counts come from the unfiltered set so the chips still read "Closed (1)"
// while the closed filter is active — otherwise every chip but the selected
// one shows zero, which is useless.
const counts = useMemo(() => {
const c: Record<Filter, number> = {
all: stores.length,
open: 0,
maintenance: 0,
closed: 0,
};
for (const s of stores) c[s.status] += 1;
return c;
}, [stores]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return stores.filter(
(s) =>
(filter === 'all' || s.status === filter) &&
(q === '' || s.name.toLowerCase().includes(q)),
);
}, [stores, query, filter]);
return (
<CollectionRegion label="All stores">
<FilterBar
query={query}
onQueryChange={setQuery}
placeholder="Search stores…"
filterValue={filter}
onFilterChange={(v) => setFilter(v as Filter)}
filterLabel="Filter stores by status"
options={[
{value: 'all', label: 'All', hint: counts.all},
{value: 'open', label: 'Open', hint: counts.open},
{value: 'maintenance', label: 'Maintenance', hint: counts.maintenance},
{value: 'closed', label: 'Closed', hint: counts.closed},
]}
resultCount={visible.length}
resultNoun={visible.length === 1 ? 'store' : 'stores'}
/>
<AsyncBoundary
resource={resource}
loading={<SkeletonCardGrid count={6} height={240} />}
empty={
<EmptyPanel
icon="stores"
title="No stores yet"
description="Stores appear here once they are added to your Loyaly account."
/>
}
>
{() =>
visible.length === 0 ? (
// A filter returning nothing is a different situation from having
// no stores at all, and needs a different way out.
<EmptyPanel
icon="stores"
title="No stores match"
description="Try a different status filter or clear the search."
/>
) : (
<Grid columns={STORE_GRID_COLUMNS} gap={4}>
{visible.map((s) => (
<StoreCard key={s.id} store={s} />
))}
</Grid>
)
}
</AsyncBoundary>
</CollectionRegion>
);
}