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

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "loyaly-mer-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3100
}
]
}

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.23.1

View File

@@ -3,3 +3,82 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
<!-- ASTRYX:START -->
Astryx v0.2.0 · 154 components
CLI: run every command as `npx astryx <cmd>` (shown below as `astryx ...`).
SETUP (once, in your app entry e.g. main.tsx) — without these, components render unstyled:
import "@astryxdesign/core/reset.css";
import "@astryxdesign/core/astryx.css";
WORKFLOW — discover, don't guess. Before writing UI:
1. `astryx build "<idea>"` — START HERE: returns a kit (closest [page] + [block]s + [component]s). No args = full playbook.
2. `astryx template <name> [--skeleton]` — scaffold the [page]/[block]s it named, or study their layout. Templates are reference code.
3. `astryx component <Name>` — props + examples for every component you use.
RULES:
- No <div> — components do all layout/spacing. Full page → AppShell; sidebar nav → SideNav.
- Frame first: pick the shell (AppShell / Layout+LayoutPanel) and budget regions in px BEFORE writing content (`astryx docs layout`).
- Dense data = rows (Table, List/Item) edge-to-edge — never Card-wrapped list items. Card = dashboard widgets, galleries, settings groups only.
- Status → StatusDot/Token; Badge only for counts and enumerated states, never decoration.
- Custom styling: component props first; else Tailwind utilities backed by tokens (bg-surface, text-primary, rounded-lg) via tailwind-theme.css. No raw hex/px.
- Tokens for every value (`astryx docs tokens`). Brand/accent via `astryx theme` — never override --color-* in :root.
- SELF-CHECK before you finish: re-read the file and replace any style={{…}}, raw <div>/<span> layout, imported .css/@apply, or hardcoded/arbitrary value (e.g. bg-[#fff], p-[13px]) with the component or a token-backed utility. If unsure a component/prop exists, run `astryx component <Name>` / `astryx search "<thing>"`; don't hand-roll CSS.
MORE CLI:
search "<query>" find any component / hook / doc / template / block
component --list 154 components by category
template --list page + block recipes
docs <topic> color, elevation, icons, illustrations, internationalization, layout, migration, motion, principles, shape, spacing, styling, theme, tokens, typography
swizzle <Name> eject component source for deep customization
upgrade --apply run after any @astryxdesign/core bump
<!-- ASTRYX:END -->
<!-- LOYALY:START -->
# Loyaly Merchant Dashboard — project rules
## Toolchain
- **Node 22 is required** (`.nvmrc` → 22.23.1). The astryx CLI hard-fails on Node 20.
`nvm use` before running any `npx astryx` command or `npm run theme:build`.
- Dev server runs on **port 3100** (`lytsup-site` owns 3000).
## Theme
- All design values live in `src/theme/loyalyTheme.ts`. Edit that, then run
`npm run theme:build`, then **commit the generated** `src/theme/loyaly.{css,js,d.ts,variants.d.ts}`.
- `theme:build` is deliberately NOT wired to `prebuild` — it would break `npm run build` on Node 20.
- Import the theme from **`@/theme`**, never `@/theme/loyaly` directly. `@/theme` re-attaches
the Lucide icon registry, which `astryx theme build` cannot serialise (icons are React
components). Skipping it silently degrades every `<Icon>` to Astryx's default glyph set.
- `globals.css` imports `../theme/loyaly.css`, NOT `@astryxdesign/theme-neutral/theme.css`.
Astryx theme CSS is `@scope`-gated on the theme *name*, so neutral's rules match nothing
under `[data-astryx-theme="loyaly"]`.
## The monochrome rule
Gray is the accent. The **only** colour permitted is semantic: success, warning, error.
- Astryx's categorical hues (blue/cyan/teal/purple/pink/orange) are aliased to gray in
`aliasHueToGray()`. green/red/yellow deliberately keep hue — Banner's semantic statuses
resolve *through* those hue tokens.
- Some neutral-theme component styles hardcode colour instead of reading a token
(`badge variant:info`, `progressbar variant:accent`). Those need an explicit `components`
override; `aliasHueToGray()` cannot reach them.
- `/tokens` (dev-only route) is the audit page. After any theme change, load it and run the
chromatic sweep in the browser console — it should report **0** non-semantic colour nodes.
## Known upstream issues (Astryx 0.2.0)
- **`useEntryAnimation` breaks SSR hydration.** Its source assumes `'use client'` modules never
run on the server; in the App Router they do. Any `FieldStatus` (i.e. `TextInput`/`TextArea`
`status={...}`) present at *initial paint* renders without the slide-down class on the server
and with it on the client → an unpatchable class mismatch. Setting status on blur/submit
(the normal path) is unaffected. Don't server-render a field that already has a status.
- Component keys in `defineTheme({components})` are inconsistently cased: `text-input` but
`progressbar`. The CLI warns on unknown keys — **treat any `⚠ Unknown component` from
`theme:build` as an error**, it means the override silently did nothing.
## Conventions
- Follow the Astryx rules above: no raw `<div>` for layout, no hardcoded hex/px, component
props first then token-backed Tailwind utilities.
- `src/lib/icons.ts` is the single icon map. Only the 26 semantic names resolve via
`<Icon icon="search"/>`; everything else is `<Icon icon={ICONS.stores}/>`. Never import a
lucide icon directly into a feature file.
<!-- LOYALY:END -->

View File

@@ -1 +1,32 @@
@AGENTS.md
<!-- ASTRYX:START -->
Astryx v0.2.0 · 154 components
CLI: run every command as `npx astryx <cmd>` (shown below as `astryx ...`).
SETUP (once, in your app entry e.g. main.tsx) — without these, components render unstyled:
import "@astryxdesign/core/reset.css";
import "@astryxdesign/core/astryx.css";
WORKFLOW — discover, don't guess. Before writing UI:
1. `astryx build "<idea>"` — START HERE: returns a kit (closest [page] + [block]s + [component]s). No args = full playbook.
2. `astryx template <name> [--skeleton]` — scaffold the [page]/[block]s it named, or study their layout. Templates are reference code.
3. `astryx component <Name>` — props + examples for every component you use.
RULES:
- No <div> — components do all layout/spacing. Full page → AppShell; sidebar nav → SideNav.
- Frame first: pick the shell (AppShell / Layout+LayoutPanel) and budget regions in px BEFORE writing content (`astryx docs layout`).
- Dense data = rows (Table, List/Item) edge-to-edge — never Card-wrapped list items. Card = dashboard widgets, galleries, settings groups only.
- Status → StatusDot/Token; Badge only for counts and enumerated states, never decoration.
- Custom styling: component props first; else Tailwind utilities backed by tokens (bg-surface, text-primary, rounded-lg) via tailwind-theme.css. No raw hex/px.
- Tokens for every value (`astryx docs tokens`). Brand/accent via `astryx theme` — never override --color-* in :root.
- SELF-CHECK before you finish: re-read the file and replace any style={{…}}, raw <div>/<span> layout, imported .css/@apply, or hardcoded/arbitrary value (e.g. bg-[#fff], p-[13px]) with the component or a token-backed utility. If unsure a component/prop exists, run `astryx component <Name>` / `astryx search "<thing>"`; don't hand-roll CSS.
MORE CLI:
search "<query>" find any component / hook / doc / template / block
component --list 154 components by category
template --list page + block recipes
docs <topic> color, elevation, icons, illustrations, internationalization, layout, migration, motion, principles, shape, spacing, styling, theme, tokens, typography
swizzle <Name> eject component source for deep customization
upgrade --apply run after any @astryxdesign/core bump
<!-- ASTRYX:END -->

View File

@@ -12,6 +12,12 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// Generated by `npm run theme:build` — edit src/theme/loyalyTheme.ts
// instead. The emitted .d.ts uses a triple-slash reference we do not own.
"src/theme/loyaly.css",
"src/theme/loyaly.js",
"src/theme/loyaly.d.ts",
"src/theme/loyaly.variants.d.ts",
]),
]);

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

1792
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,23 +3,32 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3100",
"build": "next build",
"start": "next start",
"lint": "eslint"
"start": "next start -p 3100",
"lint": "eslint",
"theme:build": "astryx theme build src/theme/loyalyTheme.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.2.12",
"@astryxdesign/core": "^0.2.0",
"@astryxdesign/theme-neutral": "^0.2.0",
"@stylexjs/stylex": "^0.19.0",
"framer-motion": "^12.43.0",
"lucide-react": "^1.28.0",
"next": "^16.3.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"recharts": "^3.10.1"
},
"devDependencies": {
"@astryxdesign/cli": "^0.2.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.12",
"eslint-config-next": "^16.3.0",
"tailwindcss": "^4",
"typescript": "^5"
}

BIN
public/loyalyai-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/loyalyai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/lyts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,243 @@
'use client';
/**
* Dev-only chart gallery.
*
* Every primitive is exercised here against the REAL route handlers, not
* inline arrays — so this page simultaneously proves the charts render, the
* data layer round-trips, and the loading/empty/error skins are correct.
* Use the state buttons to force each branch.
*/
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Card} from '@astryxdesign/core/Card';
import {Heading, Text} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {Badge} from '@astryxdesign/core/Badge';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {ChartCard} from '@/components/charts/ChartCard';
import {LineChartView} from '@/components/charts/LineChartView';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {BarChartView} from '@/components/charts/BarChartView';
import {Sparkline} from '@/components/charts/Sparkline';
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
import type {HourCell, Kpi, TimePoint} from '@/lib/api/contracts';
import {
formatCompact,
formatDayLabel,
formatDelta,
formatInrCompact,
formatPct,
} from '@/lib/format';
type Forced = '' | 'empty' | 'error' | 'delay';
export default function ChartsGallery() {
const [forced, setForced] = useState<Forced>('');
// The forced state is folded into the endpoint params, so it becomes part of
// the request identity and useResource refetches when it changes.
const extra =
forced === 'delay'
? {_delay: '4000'}
: forced
? {_state: forced}
: {};
const scope = {range: '30d' as const, storeId: 'all'};
const withExtra = <T,>(e: {path: string; params: Record<string, string>}) =>
({...e, params: {...e.params, ...extra}}) as never as T;
const series = useResource<TimePoint[]>(
withExtra(endpoints.dashboardTimeseries(scope)),
);
const kpis = useResource<Kpi[]>(withExtra(endpoints.dashboardKpis(scope)));
const peak = useResource<HourCell[]>(
withExtra(endpoints.dashboardPeakHours(scope)),
);
return (
<VStack gap={6} padding={6}>
<VStack gap={2}>
<Heading level={1}>Chart primitives</Heading>
<Text color="secondary">
Monochrome by construction. Series beyond the second are separated by
dash pattern and hatch fill, never hue semantic colour is reserved
for thresholds and deltas.
</Text>
<HStack gap={2} wrap="wrap">
{(
[
['', 'Live data'],
['delay', 'Loading (4s)'],
['empty', 'Empty'],
['error', 'Error'],
] as [Forced, string][]
).map(([v, label]) => (
<Button
key={label}
size="sm"
variant={forced === v ? 'primary' : 'secondary'}
label={label}
onClick={() => setForced(v)}
/>
))}
</HStack>
</VStack>
{/* KPI sparklines — never animated, semantic tone on the delta only. */}
<Grid columns={{minWidth: 220, repeat: 'fit'}} gap={4}>
{kpis.status === 'success'
? kpis.data.map((k) => (
<Card key={k.id}>
<VStack gap={2}>
<Text size="sm" color="secondary">
{k.label}
</Text>
<HStack gap={2} vAlign="center" hAlign="between">
<Heading level={3}>
{k.unit === 'inr'
? formatInrCompact(k.value)
: formatCompact(k.value)}
</Heading>
<Badge
variant={
k.deltaPct === 0
? 'neutral'
: k.deltaPct > 0 === k.isRiseGood
? 'success'
: 'error'
}
label={formatDelta(k.deltaPct)}
/>
</HStack>
<Sparkline
data={k.trend}
dataKey="v"
tone={
k.deltaPct === 0
? 'neutral'
: k.deltaPct > 0 === k.isRiseGood
? 'positive'
: 'negative'
}
/>
</VStack>
</Card>
))
: null}
</Grid>
<Grid columns={{minWidth: 420, 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="Visitors vs purchases"
subtitle="Two series — solid and dashed, same ramp"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[
{key: 'visitors', label: 'Visitors'},
{key: 'purchases', label: 'Purchases'},
]}
/>
)}
</ChartCard>
<ChartCard
title="Revenue"
subtitle="Bars, with a target reference line"
resource={series}
>
{(d) => (
<BarChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
reference={{y: 90000, label: 'Daily target', tone: 'positive'}}
/>
)}
</ChartCard>
<ChartCard
title="Three series"
subtitle="The encoding test: luminance, then hatch — never hue"
resource={series}
>
{(d) => (
<BarChartView
// Three series only tells you anything if they share a scale, so
// this splits visitors into who bought and who didn't rather
// than mixing a percentage onto a counts axis.
data={d.slice(-10).map((p) => ({
t: p.t,
purchases: p.purchases,
browsed: p.visitors - p.purchases,
repeat: Math.round(p.purchases * 0.42),
}))}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[
{key: 'browsed', label: 'Browsed only'},
{key: 'purchases', label: 'Purchased'},
{key: 'repeat', label: 'Repeat customers'},
]}
/>
)}
</ChartCard>
<ChartCard
title="Conversion"
subtitle="Dotted third encoding at line scale"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={(v) => formatPct(v, 0)}
series={[{key: 'conversion', label: 'Conversion'}]}
reference={{y: 22, label: 'Benchmark'}}
/>
)}
</ChartCard>
<ChartCard
title="Peak hours"
subtitle="CSS grid, not recharts — monochrome by construction"
resource={peak}
height={200}
>
{(d) => <HeatmapGrid data={d} />}
</ChartCard>
</Grid>
</VStack>
);
}

16
src/app/(dev)/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import {notFound} from 'next/navigation';
/**
* /tokens and /charts are internal design-system tools, not product surface.
* Without this gate they appear in the production route table and are publicly
* reachable. `dynamic = 'force-dynamic'` is required so the check runs per
* request rather than being folded into a static prerender.
*/
export const dynamic = 'force-dynamic';
export default function DevLayout({children}: {children: React.ReactNode}) {
if (process.env.NODE_ENV === 'production') {
notFound();
}
return <>{children}</>;
}

View File

@@ -0,0 +1,339 @@
'use client';
/**
* Dev-only design-system audit page.
*
* Its job is to make the monochrome rule falsifiable: every Astryx surface
* that can carry a colour is rendered here, including the categorical hue
* variants we deliberately aliased to gray. Anything that still shows a hue
* other than success-green / warning-amber / error-red is a token we missed
* in aliasHueToGray(), and gets added there rather than patched locally.
*/
import {useState, useSyncExternalStore} from 'react';
import {Card} from '@astryxdesign/core/Card';
import {Button} from '@astryxdesign/core/Button';
import {Badge} from '@astryxdesign/core/Badge';
import {Banner} from '@astryxdesign/core/Banner';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {Skeleton} from '@astryxdesign/core/Skeleton';
import {EmptyState} from '@astryxdesign/core/EmptyState';
import {TextInput} from '@astryxdesign/core/TextInput';
import {TextArea} from '@astryxdesign/core/TextArea';
import {Switch} from '@astryxdesign/core/Switch';
import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
import {Divider} from '@astryxdesign/core/Divider';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Icon} from '@astryxdesign/core/Icon';
import {Heading, Text} from '@astryxdesign/core/Text';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
const HUES = [
'blue',
'cyan',
'green',
'orange',
'pink',
'purple',
'red',
'teal',
'yellow',
] as const;
const DATA_RAMP = [1, 2, 3, 4, 5] as const;
function Section({title, hint, children}: {title: string; hint?: string; children: React.ReactNode}) {
return (
<VStack gap={3}>
<VStack gap={1}>
<Heading level={3}>{title}</Heading>
{hint ? <Text size="sm" color="secondary">{hint}</Text> : null}
</VStack>
<Card>
<VStack gap={4}>{children}</VStack>
</Card>
</VStack>
);
}
function Swatch({label, color}: {label: string; color: string}) {
return (
<VStack gap={1}>
<div
style={{
height: 48,
borderRadius: 'var(--radius-element)',
background: color,
boxShadow: 'inset 0 0 0 1px var(--color-border)',
}}
/>
<Text size="xsm" color="secondary">{label}</Text>
</VStack>
);
}
export default function TokensPage() {
const [name, setName] = useState('');
const [query, setQuery] = useState('Free coffee');
const [notes, setNotes] = useState('');
const [auto, setAuto] = useState(true);
const [closed, setClosed] = useState(false);
// "Has this hydrated yet?" via useSyncExternalStore — false on the server,
// true on the client, with no setState in an effect body.
const showError = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
return (
<VStack gap={6} padding={6}>
<VStack gap={1}>
<Heading level={1}>Design tokens</Heading>
<Text color="secondary">
Monochrome audit. The only colour permitted below is semantic:
success, warning, error. Everything else must read as gray.
</Text>
</VStack>
<Section title="Surfaces" hint="body → surface → card → muted, the four elevation steps of the shell.">
<Grid columns={{minWidth: 140, repeat: 'fit'}} gap={3}>
<Swatch label="body #000000" color="var(--color-background-body)" />
<Swatch label="surface #0F0F10" color="var(--color-background-surface)" />
<Swatch label="card #171717" color="var(--color-background-card)" />
<Swatch label="muted #202124" color="var(--color-background-muted)" />
<Swatch label="border #2F2F2F" color="var(--color-border)" />
<Swatch label="border-emph" color="var(--color-border-emphasized)" />
</Grid>
</Section>
<Section title="Text & icon" hint="White / #A1A1AA / #71717A.">
<VStack gap={2}>
<Text>Primary text white on black.</Text>
<Text color="secondary">Secondary text #A1A1AA.</Text>
<Text color="disabled">Disabled text #71717A.</Text>
<HStack gap={3}>
<Icon icon="search" />
<Icon icon="check" color="secondary" />
<Icon icon="warning" color="warning" />
<Icon icon="error" color="error" />
<Icon icon="success" color="success" />
</HStack>
</VStack>
</Section>
<Section
title="Buttons"
hint="Primary = gray fill. Secondary = transparent + border. Ghost = transparent, gray hover. Tab through these to check focus rings."
>
<VStack gap={3}>
<HStack gap={2} wrap="wrap">
<Button variant="primary" label="Primary" />
<Button variant="secondary" label="Secondary" />
<Button variant="ghost" label="Ghost" />
<Button variant="destructive" label="Destructive" />
</HStack>
<HStack gap={2} wrap="wrap">
<Button variant="primary" label="Disabled" isDisabled />
<Button variant="secondary" label="Disabled" isDisabled />
<Button variant="primary" label="Loading" isLoading />
<Button variant="primary" label="With icon" icon={<Icon icon="search" />} />
</HStack>
<HStack gap={2} wrap="wrap" vAlign="center">
<Button size="sm" variant="primary" label="Small" />
<Button size="md" variant="primary" label="Medium" />
<Button size="lg" variant="primary" label="Large" />
</HStack>
</VStack>
</Section>
<Section
title="Badges — semantic"
hint="These four are allowed to carry colour. They are the semantic language."
>
<HStack gap={2} wrap="wrap">
<Badge variant="neutral" label="Neutral" />
<Badge variant="info" label="Info" />
<Badge variant="success" label="Success" />
<Badge variant="warning" label="Warning" />
<Badge variant="error" label="Error" />
</HStack>
</Section>
<Section
title="Badges — categorical hues"
hint="AUDIT: blue/cyan/orange/pink/purple/teal must render gray. green/red/yellow stay tinted by design — Banner's semantic statuses resolve through those same hue tokens."
>
<HStack gap={2} wrap="wrap">
{HUES.map((hue) => (
<Badge key={hue} variant={hue} label={hue} />
))}
</HStack>
</Section>
<Section
title="Cards — categorical variants"
hint="AUDIT: same rule. default / transparent / muted / gray plus the nine hues."
>
<Grid columns={{minWidth: 150, repeat: 'fit'}} gap={3}>
{(['default', 'transparent', 'muted', 'gray', ...HUES] as const).map((v) => (
<Card key={v} variant={v}>
<Text size="sm">{v}</Text>
</Card>
))}
</Grid>
</Section>
<Section title="Status dots" hint="accent must be gray; the rest semantic.">
<HStack gap={4} wrap="wrap" vAlign="center">
{(['neutral', 'accent', 'success', 'warning', 'error'] as const).map((v) => (
<HStack key={v} gap={2} vAlign="center">
<StatusDot variant={v} label={v} />
<Text size="sm" color="secondary">{v}</Text>
</HStack>
))}
</HStack>
</Section>
<Section title="Banners" hint="Full-width status surfaces used for alerts and expiry warnings.">
<VStack gap={2}>
<Banner status="info" title="Peak hours start in 20 minutes" description="Staff coverage is below the weekday average." />
<Banner status="success" title="Weekend campaign hit its target" description="1,284 LYTs redeemed against a 1,000 goal." />
<Banner status="warning" title="3 rewards expire this week" description="Free Coffee, Combo 20% and Weekend Bonus." />
<Banner status="error" title="Koramangala store is offline" description="No footfall events received in 4 hours." />
</VStack>
</Section>
<Section title="Inputs" hint="Black background, gray border, white text, gray placeholder.">
<Grid columns={{minWidth: 240, repeat: 'fit'}} gap={4}>
<TextInput
label="Store name"
placeholder="e.g. Indiranagar Flagship"
value={name}
onChange={setName}
/>
<TextInput
label="Disabled"
placeholder="Not editable"
value=""
isDisabled
/>
{/*
Status is applied AFTER mount on purpose. Astryx's useEntryAnimation
assumes 'use client' modules never execute on the server, which is
false in the App Router: SSR renders without the slide-down class,
then hydration (which lands after the first rAF) renders with it,
and React reports a class mismatch it will not patch up.
Real forms set status on blur/submit, so they never hit this; only
a status present at initial paint does. See AGENTS.md.
*/}
<TextInput
label="With error"
placeholder="Required"
value=""
status={
showError
? {type: 'error', message: 'This field is required'}
: undefined
}
/>
<TextInput
label="Search"
placeholder="Find a reward…"
value={query}
onChange={setQuery}
startIcon="search"
hasClear
/>
</Grid>
<TextArea
label="Notes"
placeholder="Internal notes about this store…"
rows={3}
value={notes}
onChange={setNotes}
/>
<HStack gap={5} wrap="wrap" vAlign="center">
<Switch label="Auto-issue rewards" value={auto} onChange={setAuto} />
<Switch label="Disabled" value={false} isDisabled />
<CheckboxInput
label="Include closed stores"
value={closed}
onChange={setClosed}
/>
<CheckboxInput label="Indeterminate" value="indeterminate" />
</HStack>
</Section>
<Section title="Feedback" hint="Progress, skeleton and empty state.">
<VStack gap={4}>
<ProgressBar value={64} label="Monthly target" />
<VStack gap={2}>
<Skeleton height={16} width="40%" />
<Skeleton height={16} width="70%" />
<Skeleton height={16} width="55%" />
</VStack>
<Divider />
<EmptyState
icon={<Icon icon="search" size="lg" />}
title="No rewards match this filter"
description="Try widening the date range or clearing the store filter."
actions={<Button variant="secondary" label="Clear filters" />}
/>
</VStack>
</Section>
<Section
title="Avatars"
hint="AUDIT: Astryx auto-assigns a categorical hue per name. These must all read gray."
>
<HStack gap={2} wrap="wrap">
{['Anita R', 'Vikram S', 'Priya M', 'Rahul K', 'Deepa N', 'Suresh T'].map((n) => (
<Avatar key={n} name={n} />
))}
</HStack>
</Section>
<Section
title="Chart ramp"
hint="--color-data-gray-1..5, the ordered series palette. Lightest = most important series."
>
<Grid columns={{minWidth: 110, repeat: 'fit'}} gap={3}>
{DATA_RAMP.map((i) => (
<Swatch key={i} label={`data-gray-${i}`} color={`var(--color-data-gray-${i})`} />
))}
<Swatch label="data-neutral" color="var(--color-data-neutral)" />
</Grid>
</Section>
<Section
title="Chart categorical slots"
hint="AUDIT: Astryx's 10 categorical data tokens, all remapped onto the gray ramp."
>
<Grid columns={{minWidth: 110, repeat: 'fit'}} gap={3}>
{(['blue', 'orange', 'green', 'purple', 'cyan', 'red', 'teal', 'pink', 'brown', 'indigo'] as const).map(
(hue) => (
<Swatch
key={hue}
label={hue}
color={`var(--color-data-categorical-${hue})`}
/>
),
)}
</Grid>
</Section>
<Section title="Elevation" hint="On pure black, separation comes from the 1px inset rim, not the drop shadow.">
<Grid columns={{minWidth: 160, repeat: 'fit'}} gap={4}>
{(['none', 'low', 'med', 'high'] as const).map((e) => (
<Card key={e} elevation={e}>
<Text size="sm">elevation {e}</Text>
</Card>
))}
</Grid>
</Section>
</VStack>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import {useState} from 'react';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PageHeader} from '@/components/primitives/PageHeader';
import {ChartCard} from '@/components/charts/ChartCard';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {LineChartView} from '@/components/charts/LineChartView';
import {BarChartView} from '@/components/charts/BarChartView';
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
import {KpiRow} from '@/features/dashboard/KpiRow';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {RewardUsageChart} from '@/features/dashboard/RewardUsageChart';
import {StoreComparisonPanel} from '@/features/dashboard/StoreComparison';
import {PerformancePanel} from '@/features/dashboard/PerformancePanel';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {storeName} from '@/lib/mock/stores';
import type {Granularity} from '@/lib/api/contracts';
import {
formatCompact,
formatDayLabel,
formatInrCompact,
formatPct,
} from '@/lib/format';
/**
* The dashboard answers three questions in order, top to bottom:
* 1. How is the business performing? → KPI row
* 2. What is the shape of that? → footfall / revenue / conversion
* 3. What needs attention, and when? → peak hours, rewards, stores, feed
*
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider,
* so changing either control in the top nav re-queries the whole page without
* a route change — which is what keeps the Copilot mounted alongside it.
*/
export default function DashboardPage() {
const {storeId, range} = useWorkspace();
const [granularity, setGranularity] = useState<Granularity>('weekly');
const scope = {range, storeId};
const kpis = useResource(endpoints.dashboardKpis(scope));
const series = useResource(endpoints.dashboardTimeseries(scope));
const peak = useResource(endpoints.dashboardPeakHours(scope));
const activity = useResource(endpoints.dashboardActivity(scope));
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
return (
<VStack gap={5}>
<PageHeader
title="Dashboard"
description={`How the business is performing across ${scopeLabel}, what needs attention, and what to do next.`}
/>
<KpiRow resource={kpis} />
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
{(d) => (
<AreaChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[{key: 'visitors', label: 'Visitors'}]}
/>
)}
</ChartCard>
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
{(d) => (
<BarChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
/>
)}
</ChartCard>
<ChartCard
title="Visitors vs purchases"
subtitle="The gap is the conversion opportunity"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[
{key: 'visitors', label: 'Visitors'},
{key: 'purchases', label: 'Purchases'},
]}
/>
)}
</ChartCard>
<ChartCard
title="Conversion"
subtitle="Share of visitors who bought"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={(v) => formatPct(v, 0)}
series={[{key: 'conversion', label: 'Conversion'}]}
// The only semantic colour on this chart: above the benchmark is
// good, below is not, and that is what the tint communicates.
reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
/>
)}
</ChartCard>
</Grid>
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Peak hours"
subtitle="Where the week's footfall actually lands"
resource={peak}
height={200}
>
{(d) => <HeatmapGrid data={d} />}
</ChartCard>
<RewardUsageChart resource={rewards} />
</Grid>
<PerformancePanel
scope={scope}
granularity={granularity}
onGranularityChange={setGranularity}
/>
{/* Comparing stores is meaningless when scoped to one of them. */}
{storeId === 'all' ? (
<StoreComparisonPanel resource={comparison} />
) : null}
<ActivityTimeline resource={activity} />
</VStack>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import {AppShell} from '@astryxdesign/core/AppShell';
import {Layout, LayoutContent, LayoutPanel} from '@astryxdesign/core/Layout';
import {AppSideNav} from '@/components/shell/AppSideNav';
import {AppTopNav} from '@/components/shell/AppTopNav';
import {MobileScopeNav} from '@/components/shell/MobileScopeNav';
import {CopilotPanel} from '@/features/copilot/CopilotPanel';
import {CopilotSlideOver} from '@/features/copilot/CopilotSlideOver';
import {useCopilot} from '@/features/copilot/CopilotProvider';
import {useBreakpoint, isPanelInline, copilotWidth} from '@/lib/breakpoints';
/**
* The three-column shell, and the reason the Copilot survives navigation.
*
* Next's App Router preserves a layout's element identity across sibling route
* changes, so /dashboard → /staff swaps only {children}. Everything else here —
* nav, scroll position, the Copilot — is untouched.
*
* Structure, and why AppShell alone is not enough: AppShell renders
* Layout{start, content} internally and exposes no end/panel slot. The Copilot
* column therefore comes from a NESTED Layout inside AppShell's children,
* using its `end` slot.
*
* AppShell(topNav, sideNav)
* └─ Layout(content = page, end = LayoutPanel > CopilotPanel)
*
* variant="elevated" is what produces the described visual for free: with both
* navs present the shell root and rails paint --color-background-body (#000),
* and the content area sits on an elevated --color-background-surface (#0F0F10)
* backdrop with --radius-page on the start corner.
*/
export default function WorkspaceLayout({
children,
}: {
children: React.ReactNode;
}) {
const bp = useBreakpoint();
const {isOpen} = useCopilot();
const inline = isPanelInline(bp);
const showInlinePanel = inline && isOpen;
return (
<AppShell
variant="elevated"
height="fill"
// Layout/LayoutContent own the padding so the Copilot panel can sit
// flush against the workspace edge.
contentPadding={0}
topNav={<AppTopNav />}
sideNav={<AppSideNav />}
// Supplied explicitly rather than auto-generated: the default drawer
// mirrors sideNav only, which would leave the store and range controls
// unreachable below 768px (they live in TopNav's collapsing start slot).
mobileNav={{breakpoint: 'md', content: <MobileScopeNav />}}
>
<Layout
height="fill"
content={<LayoutContent padding={5}>{children}</LayoutContent>}
end={
showInlinePanel ? (
<LayoutPanel
hasDivider
width={copilotWidth(bp)}
// CopilotPanel supplies its own header and tab chrome.
padding={0}
role="complementary"
label="AI Copilot"
>
<CopilotPanel />
</LayoutPanel>
) : undefined
}
/>
{/* Mounted only below the inline breakpoint. Because every piece of
Copilot state lives in CopilotProvider (above the route tree), moving
between these two presentations is lossless. */}
{!inline ? <CopilotSlideOver /> : null}
</AppShell>
);
}

View File

@@ -0,0 +1,213 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Text} from '@astryxdesign/core/Text';
import {PageHeader} from '@/components/primitives/PageHeader';
import {MetricCard} from '@/components/patterns/MetricCard';
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {ChartCard} from '@/components/charts/ChartCard';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {LineChartView} from '@/components/charts/LineChartView';
import {BarChartView} from '@/components/charts/BarChartView';
import {RewardGrid} from '@/features/lyts/RewardGrid';
import {ExpiryAlerts} from '@/features/lyts/ExpiryAlerts';
import {RewardPerformanceTable} from '@/features/lyts/RewardPerformanceTable';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {useMetricColumns} from '@/features/dashboard/KpiRow';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ICONS} from '@/lib/icons';
import {usageRate} from '@/features/lyts/reward-status';
import {
formatCompact,
formatDayLabel,
formatLyt,
formatPct,
} from '@/lib/format';
/**
* The LYT programme.
*
* Ordered by urgency rather than by data type: what's expiring, then the
* headline numbers, then the catalogue, then the analysis. A merchant opening
* this page is usually here because something needs extending or pausing.
*
* 1 LYT = ₹1, so "outstanding" figures are simultaneously a count and a
* rupee liability — which is why they lead.
*/
export default function LytsPage() {
const {storeId, range} = useWorkspace();
const scope = {range, storeId};
const rewards = useResource(endpoints.lytsRewards(scope));
const redemptions = useResource(endpoints.lytsRedemptions(scope));
const activity = useResource(endpoints.lytsActivity(scope));
const columns = useMetricColumns();
// "Now" comes from the SERVER's clock, carried on every response.
//
// Date.now() here would be impure during render, would disagree between the
// SSR and hydration passes, and would report the wrong countdown to anyone
// whose device clock is off — on a panel whose entire job is telling a
// merchant an offer expires in two days. Falls back to 0 only before the
// first response lands, when no expiry panel is rendered anyway.
const nowMs = rewards.meta
? new Date(rewards.meta.generatedAt).getTime()
: 0;
return (
<VStack gap={5}>
<PageHeader
title="Lyts"
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
/>
<ExpiryAlerts resource={rewards} nowMs={nowMs} />
<AsyncBoundary
resource={rewards}
loading={<SkeletonMetricGrid columns={columns} />}
>
{(rows) => {
const active = rows.filter(
(r) => r.status === 'active' || r.status === 'expiring',
);
const claimed = rows.reduce((a, r) => a + r.claimed, 0);
const used = rows.reduce((a, r) => a + r.used, 0);
const outstanding = rows.reduce(
(a, r) => a + (r.claimed - r.used) * r.costLyt,
0,
);
const mostClaimed = rows.reduce((a, b) =>
b.claimed > a.claimed ? b : a,
);
const leastUsed = rows.reduce((a, b) =>
usageRate(b.claimed, b.used) < usageRate(a.claimed, a.used) ? b : a,
);
return (
<Grid columns={columns} gap={4}>
<MetricCard
label="Active rewards"
value={active.length}
format={(v) => String(Math.round(v))}
icon={ICONS.activeRewards}
footer={
<Caption text={`${rows.length} in catalogue`} />
}
/>
<MetricCard
label="Redemption rate"
value={usageRate(claimed, used)}
format={(v) => formatPct(v, 0)}
icon={ICONS.lyts}
deltaPct={usageRate(claimed, used) - 50}
footer={
<Caption
text={`${formatCompact(used)} of ${formatCompact(claimed)} claims`}
/>
}
/>
<MetricCard
label="Most claimed"
value={mostClaimed.claimed}
format={formatCompact}
icon={ICONS.up}
footer={<Caption text={mostClaimed.name} />}
/>
<MetricCard
label="Outstanding liability"
value={outstanding}
format={formatLyt}
icon={ICONS.alert}
footer={
<Caption
text={`Least used: ${leastUsed.name} (${formatPct(usageRate(leastUsed.claimed, leastUsed.used), 0)})`}
/>
}
/>
</Grid>
);
}}
</AsyncBoundary>
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Redemption"
subtitle="LYTs redeemed per day"
resource={redemptions}
>
{(d) => (
<AreaChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[{key: 'purchases', label: 'Redeemed'}]}
/>
)}
</ChartCard>
<ChartCard
title="Issued vs redeemed"
subtitle="The gap is LYTs still in customers' hands"
resource={redemptions}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[
{key: 'visitors', label: 'Issued'},
{key: 'purchases', label: 'Redeemed'},
]}
/>
)}
</ChartCard>
</Grid>
<ChartCard
title="Top rewards"
subtitle="Claimed vs redeemed, by reward"
resource={rewards}
>
{(rows) => (
<BarChartView
data={[...rows].sort((a, b) => b.claimed - a.claimed).slice(0, 6)}
xKey="name"
yFormat={formatCompact}
series={[
{key: 'claimed', label: 'Claimed'},
{key: 'used', label: 'Redeemed'},
]}
/>
)}
</ChartCard>
<RewardGrid resource={rewards} nowMs={nowMs} />
<RewardPerformanceTable resource={rewards} />
<ActivityTimeline resource={activity} />
</VStack>
);
}
/**
* Stands in for the sparkline on metrics with no meaningful time series —
* "most claimed" is a name, not a trend. MetricCard's `footer` slot exists
* for exactly this so the card keeps its height and the row stays aligned.
*/
function Caption({text}: {text: string}) {
return (
<Text size="xsm" color="secondary">
{text}
</Text>
);
}

View File

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

View File

@@ -0,0 +1,93 @@
'use client';
import {usePathname} from 'next/navigation';
import {
Layout,
LayoutContent,
LayoutPanel,
VStack,
} from '@astryxdesign/core/Layout';
import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
import {TabList, Tab} from '@astryxdesign/core/TabList';
import {Icon} from '@astryxdesign/core/Icon';
import {useRouter} from 'next/navigation';
import {useBreakpoint} from '@/lib/breakpoints';
import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/settings-nav';
/**
* Settings gets its own sub-navigation.
*
* A nested Layout with a `start` panel rather than more entries in the primary
* sidebar: settings sections sit at a different level of the hierarchy, and
* promoting them would push Dashboard/Store/Lyts/Staff down among
* configuration screens they have nothing to do with.
*
* SideNav is reused (rather than a bespoke list) so a settings section looks
* and behaves exactly like a primary nav item — same selected treatment, same
* hover, same keyboard handling, no second definition to keep in sync.
*
* Below the laptop breakpoint a 220px panel would eat most of the width, so
* the sub-nav becomes a horizontal TabList above the content instead.
*/
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const bp = useBreakpoint();
const isNarrow = bp === 'mobile' || bp === 'tablet';
if (isNarrow) {
const active =
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href))?.href ??
SETTINGS_NAV[0].href;
return (
<VStack gap={5}>
<TabList
value={active}
onChange={(href) => router.push(href)}
layout="fill"
size="sm"
hasDivider
>
{SETTINGS_NAV.map((s) => (
<Tab
key={s.href}
value={s.href}
label={s.label}
icon={<Icon icon={s.icon} size="sm" />}
/>
))}
</TabList>
{children}
</VStack>
);
}
return (
<Layout
height="fill"
start={
<LayoutPanel width={220} padding={0} role="navigation" label="Settings">
<SideNav>
<SideNavSection title="Settings" isHeaderHidden>
{SETTINGS_NAV.map((s) => (
<SideNavItem
key={s.href}
label={s.label}
icon={s.icon}
href={s.href}
isSelected={isSettingsActive(pathname, s.href)}
/>
))}
</SideNavSection>
</SideNav>
</LayoutPanel>
}
content={<LayoutContent padding={0}>{children}</LayoutContent>}
/>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PageHeader} from '@/components/primitives/PageHeader';
import {ChartCard} from '@/components/charts/ChartCard';
import {BarChartView} from '@/components/charts/BarChartView';
import {StaffKpis} from '@/features/staff/StaffKpis';
import {StaffGrid} from '@/features/staff/StaffGrid';
import {AttendanceChart} from '@/features/staff/AttendanceChart';
import {Leaderboard} from '@/features/staff/Leaderboard';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {storeName} from '@/lib/mock/stores';
import {formatCompact} from '@/lib/format';
/**
* The team.
*
* Ordered attendance-first: the question that brings a merchant here in the
* morning is "who is in today", and only then "who is performing".
*/
export default function StaffPage() {
const {storeId, range} = useWorkspace();
const scope = {range, storeId};
const summary = useResource(endpoints.staffSummary(scope), {
// A summary object is never "empty" the way a list is — zero present is
// a real answer, not an absence of data.
isEmpty: () => false,
});
const staff = useResource(endpoints.staff(scope));
const attendance = useResource(endpoints.staffAttendance(scope));
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
return (
<VStack gap={5}>
<PageHeader
title="Staff"
description={`Attendance, sales contribution and performance across ${scopeLabel}.`}
/>
<StaffKpis resource={summary} />
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<AttendanceChart resource={attendance} />
<ChartCard
title="Sales by team member"
subtitle="Top 8 by transactions in the selected period"
resource={staff}
>
{(rows) => (
<BarChartView
data={[...rows]
.sort((a, b) => b.salesCount - a.salesCount)
.slice(0, 8)
.map((m) => ({
// First name only — full names overflow the axis at this
// width and the leaderboard below carries the detail.
name: m.name.split(' ')[0],
sales: m.salesCount,
rewards: m.rewardsIssued,
}))}
xKey="name"
yFormat={formatCompact}
series={[
{key: 'sales', label: 'Sales'},
{key: 'rewards', label: 'Rewards issued'},
]}
/>
)}
</ChartCard>
</Grid>
<Leaderboard resource={staff} />
<StaffGrid resource={staff} />
</VStack>
);
}

View File

@@ -0,0 +1,189 @@
'use client';
import {use} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {
BreadcrumbItem,
Breadcrumbs,
} from '@astryxdesign/core/Breadcrumbs';
import {PageHeader} from '@/components/primitives/PageHeader';
import {MetricCard} from '@/components/patterns/MetricCard';
import {ChartCard} from '@/components/charts/ChartCard';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {BarChartView} from '@/components/charts/BarChartView';
import {LineChartView} from '@/components/charts/LineChartView';
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {useMetricColumns} from '@/features/dashboard/KpiRow';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ICONS} from '@/lib/icons';
import {
formatCompact,
formatDayLabel,
formatInrCompact,
formatPct,
} from '@/lib/format';
import type {StoreStatus} from '@/lib/api/contracts';
const STATUS: Record<
StoreStatus,
{label: string; variant: 'success' | 'warning' | 'error'}
> = {
open: {label: 'Open', variant: 'success'},
maintenance: {label: 'Maintenance', variant: 'warning'},
closed: {label: 'Closed', variant: 'error'},
};
/**
* One store's dashboard.
*
* Deliberately the SAME chart components as the main dashboard, scoped by the
* route's storeId rather than the top nav's. Building store-specific chart
* variants would double the surface area and guarantee the two drift apart —
* the only thing that differs here is which store the query asks about.
*/
export default function StoreDetailPage({
params,
}: {
params: Promise<{storeId: string}>;
}) {
const {storeId} = use(params);
const {range} = useWorkspace();
const scope = {range, storeId};
const store = useResource(endpoints.store(scope, storeId), {
isEmpty: () => false,
});
const kpis = useResource(endpoints.dashboardKpis(scope));
const series = useResource(endpoints.dashboardTimeseries(scope));
const peak = useResource(endpoints.dashboardPeakHours(scope));
const activity = useResource(endpoints.dashboardActivity(scope));
const columns = useMetricColumns();
const name = store.data?.name ?? 'Store';
return (
<VStack gap={5}>
<Breadcrumbs variant="supporting">
<BreadcrumbItem href="/stores">Store</BreadcrumbItem>
<BreadcrumbItem isCurrent>{name}</BreadcrumbItem>
</Breadcrumbs>
<PageHeader
title={name}
description={
store.data
? `${store.data.staffCount} staff · ${formatPct(store.data.conversionPct)} conversion`
: 'Loading store…'
}
actions={
<HStack gap={2} vAlign="center">
{store.data ? (
<Badge
variant={STATUS[store.data.status].variant}
label={STATUS[store.data.status].label}
/>
) : null}
<Button
variant="secondary"
size="sm"
label="All stores"
href="/stores"
/>
</HStack>
}
/>
<AsyncBoundary
resource={kpis}
loading={<SkeletonMetricGrid columns={columns} />}
>
{(rows) => (
<Grid columns={columns} gap={4}>
{rows.map((k) => (
<MetricCard
key={k.id}
label={k.label}
value={k.value}
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
icon={
k.id === 'visitors'
? ICONS.visitors
: k.id === 'purchases'
? ICONS.purchases
: k.id === 'revenue'
? ICONS.revenue
: ICONS.activeRewards
}
deltaPct={k.deltaPct}
isRiseGood={k.isRiseGood}
trend={k.trend}
/>
))}
</Grid>
)}
</AsyncBoundary>
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
{(d) => (
<AreaChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[{key: 'visitors', label: 'Visitors'}]}
/>
)}
</ChartCard>
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
{(d) => (
<BarChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
/>
)}
</ChartCard>
<ChartCard
title="Conversion"
subtitle="Share of visitors who bought"
resource={series}
>
{(d) => (
<LineChartView
data={d}
xKey="t"
xFormat={formatDayLabel}
yFormat={(v) => formatPct(v, 0)}
series={[{key: 'conversion', label: 'Conversion'}]}
reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
/>
)}
</ChartCard>
<ChartCard
title="Peak hours"
subtitle="Where this store's week actually lands"
resource={peak}
height={200}
>
{(d) => <HeatmapGrid data={d} />}
</ChartCard>
</Grid>
<ActivityTimeline resource={activity} />
</VStack>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/components/primitives/PageHeader';
import {StoreGrid} from '@/features/stores/StoreGrid';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {
RANGE_LABELS,
useWorkspace,
} from '@/components/shell/WorkspaceProvider';
/**
* The store roster.
*
* This page deliberately ignores the top nav's STORE scope — a list of every
* store is the whole point, and narrowing it to the one already selected would
* leave a one-card page. The RANGE scope still applies, because the totals on
* each card have to be measured over some period.
*/
export default function StoresPage() {
const {range} = useWorkspace();
const stores = useResource(endpoints.stores({range, storeId: 'all'}));
return (
<VStack gap={5}>
<PageHeader
title="Store"
description={`Performance and status for every store in the network, over ${RANGE_LABELS[range].toLowerCase()}.`}
/>
<StoreGrid resource={stores} />
</VStack>
);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildActivity} from '@/lib/mock/dashboard';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildActivity(q.storeId, q.nowMs), q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildKpis} from '@/lib/mock/dashboard';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildKpis(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildPeakHours} from '@/lib/mock/dashboard';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildPeakHours(q.storeId), q);
}

View File

@@ -0,0 +1,20 @@
import type {NextRequest} from 'next/server';
import type {Granularity} from '@/lib/api/contracts';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildPeriodPerformance} from '@/lib/mock/analytics';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
const raw = req.nextUrl.searchParams.get('granularity');
const granularity: Granularity = raw === 'monthly' ? 'monthly' : 'weekly';
return ok(
wantsEmpty(q) ? [] : buildPeriodPerformance(granularity, q.storeId, q.nowMs),
q,
);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildRewardUsage} from '@/lib/mock/analytics';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildRewardUsage(q.storeId, q.range), q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildStoreComparison} from '@/lib/mock/analytics';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildStoreComparison(q.range, q.nowMs), q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildTimeseries} from '@/lib/mock/dashboard';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildTimeseries(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildLytActivity} from '@/lib/mock/lyts';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildLytActivity(q.storeId, q.nowMs), q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildRedemptions} from '@/lib/mock/lyts';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildRedemptions(q.storeId, q.range, q.nowMs), q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildRewards} from '@/lib/mock/lyts';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildRewards(q.storeId, q.range, q.nowMs), q);
}

View File

@@ -0,0 +1,28 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate} from '@/lib/api/server';
import {readProfile} from '@/lib/mock/settings';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(readProfile(), q);
}
/**
* Accepts a profile patch and echoes the merged result.
*
* There is no persistence layer yet, so this deliberately does NOT pretend to
* save — it validates the shape and returns what the merged record would be,
* which is enough for the form to exercise its success path honestly.
*/
export async function PATCH(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
const patch = (await req.json()) as Record<string, unknown>;
return ok({...readProfile(), ...patch}, q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildAttendance} from '@/lib/mock/staff';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildAttendance(q.storeId, q.range, q.nowMs), q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildStaff} from '@/lib/mock/staff';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildStaff(q.storeId, q.range), q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate} from '@/lib/api/server';
import {buildStaffSummary} from '@/lib/mock/staff';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(buildStaffSummary(q.storeId, q.range), q);
}

View File

@@ -0,0 +1,21 @@
import type {NextRequest} from 'next/server';
import {fail, ok, parseQuery, simulate} from '@/lib/api/server';
import {buildStore} from '@/lib/mock/stores';
export const dynamic = 'force-dynamic';
export async function GET(
req: NextRequest,
{params}: {params: Promise<{storeId: string}>},
) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
const {storeId} = await params;
const store = buildStore(storeId, q.range, q.nowMs);
if (!store) {
return fail('not_found', `No store with id "${storeId}"`, 404);
}
return ok(store, q);
}

View File

@@ -0,0 +1,12 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildStores} from '@/lib/mock/stores';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildStores(q.range, q.nowMs), q);
}

View File

@@ -1,26 +1,45 @@
@import "tailwindcss";
/*
* Layer order is load-bearing — it maps to the Astryx cascade:
* reset → tailwind base → astryx component styles → our theme tokens →
* app components → tailwind utilities (must win last).
*
* NOTE: we import OUR built theme, not @astryxdesign/theme-neutral/theme.css.
* Astryx theme CSS is @scope-gated on the theme *name*, so neutral's rules
* would match nothing under [data-astryx-theme="loyaly"]. loyaly.css already
* contains everything it inherits from neutral.
*/
:root {
--background: #ffffff;
--foreground: #171717;
@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/preflight.css' layer(base);
@import '@astryxdesign/core/reset.css';
@import '@astryxdesign/core/astryx.css';
@import '../theme/loyaly.css';
@import '@astryxdesign/core/tailwind-theme.css';
@import 'tailwindcss/utilities.css' layer(utilities);
/*
* Paint the canvas before React mounts. AppShell sets these itself once it
* renders, but this guarantees the very first frame — and any non-shell route
* such as an error boundary — is already black rather than white.
*/
html {
background-color: var(--color-background-body);
color-scheme: dark;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
@layer components {
/* Kill switch for our own CSS transitions. Framer Motion is handled by
<MotionConfig reducedMotion="user">, recharts by useChartMotion(). */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,33 +1,48 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type {Metadata, Viewport} from 'next';
import {Sora, Inter} from 'next/font/google';
import './globals.css';
import {Providers} from './providers';
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
const sora = Sora({
subsets: ['latin'],
variable: '--font-sora',
display: 'swap',
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: 'Loyaly Merchant',
template: '%s · Loyaly Merchant',
},
description:
'Footfall, campaign and LYT redemption analytics for Loyaly merchants.',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export const viewport: Viewport = {
colorScheme: 'dark',
themeColor: '#000000',
};
export default function RootLayout({children}: {children: React.ReactNode}) {
return (
// The theme attributes are server-rendered so the @scope rules in
// loyaly.css match on the very first byte — no flash from stock defaults.
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
data-theme="dark"
data-astryx-theme="loyaly"
className={`${sora.variable} ${inter.variable}`}
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">{children}</body>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -1,65 +1,5 @@
import Image from "next/image";
import {redirect} from 'next/navigation';
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
export default function RootPage() {
redirect('/dashboard');
}

41
src/app/providers.tsx Normal file
View File

@@ -0,0 +1,41 @@
'use client';
import NextLink from 'next/link';
import {MotionConfig} from 'framer-motion';
import {Theme} from '@astryxdesign/core/theme';
import {LinkProvider} from '@astryxdesign/core/Link';
import {ToastViewport} from '@astryxdesign/core/Toast';
import {loyalyTheme} from '@/theme';
import {WorkspaceProvider} from '@/components/shell/WorkspaceProvider';
import {CopilotProvider} from '@/features/copilot/CopilotProvider';
/**
* @/theme wraps the GENERATED module (__built: true), so <Theme> skips runtime
* token injection entirely — tokens already ship in loyaly.css. Importing
* src/theme/loyalyTheme.ts here instead would move theming to a client-only
* useInsertionEffect and reintroduce the first-paint flash.
*
* @/theme also re-attaches the Lucide icon registry that `theme build` drops;
* <Theme> registers it during render, on both the SSR and hydration passes.
*
* WorkspaceProvider and CopilotProvider sit here, ABOVE the route tree, so
* query scope and Copilot state outlive both navigation and the responsive
* swap between the inline panel and the slide-over.
*/
export function Providers({children}: {children: React.ReactNode}) {
return (
<Theme theme={loyalyTheme} mode="dark">
<LinkProvider component={NextLink}>
{/* One line, and every motion.* in the app honours prefers-reduced-motion. */}
<MotionConfig reducedMotion="user">
<WorkspaceProvider>
<CopilotProvider>{children}</CopilotProvider>
</WorkspaceProvider>
{/* Mounted once at the root: useToast() positions, stacks and
expires notifications into this viewport from anywhere. */}
<ToastViewport />
</MotionConfig>
</LinkProvider>
</Theme>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import {useId} from 'react';
import {AreaChart, Area, ReferenceLine} from 'recharts';
import {ChartFrame, chartFurniture} from './ChartFrame';
import {useChartMotion} from './useChartMotion';
import {CHART, dashFor, defaultEncoding, strokeWidthFor} from './palette';
import type {ChartViewProps} from './types';
const TONE = {
neutral: CHART.reference,
positive: CHART.positive,
negative: CHART.negative,
} as const;
/**
* Areas fade to transparent rather than sitting as flat gray blocks — on a
* near-black surface a solid fill swallows the gridlines and the series below.
*/
export function AreaChartView<T extends object>({
data,
xKey,
series,
height = 260,
xFormat,
yFormat,
reference,
isAnimated = true,
isBare,
}: ChartViewProps<T>) {
const motion = useChartMotion(isAnimated);
const uid = useId().replace(/:/g, '');
return (
<ChartFrame height={height}>
<AreaChart data={data} margin={{top: 8, right: 8, bottom: 0, left: 0}}>
<defs>
{series.map((s, i) => {
const c = s.color ?? CHART.seriesAt(i);
return (
<linearGradient
key={s.key}
id={`fill-${uid}-${s.key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={c} stopOpacity={0.38} />
<stop offset="100%" stopColor={c} stopOpacity={0.03} />
</linearGradient>
);
})}
</defs>
{chartFurniture({xKey, xFormat, yFormat, isBare})}
{reference ? (
<ReferenceLine
y={reference.y}
stroke={TONE[reference.tone ?? 'neutral']}
strokeDasharray="4 4"
label={{
value: reference.label,
position: 'insideTopRight',
fill: CHART.axis,
fontSize: 11,
}}
/>
) : null}
{series.map((s, i) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.color ?? CHART.seriesAt(i)}
strokeWidth={strokeWidthFor(i)}
strokeDasharray={dashFor(s.encoding ?? defaultEncoding(i))}
fill={`url(#fill-${uid}-${s.key})`}
// recharts defaults Area to fillOpacity 0.6, which multiplies the
// gradient stops and leaves the fill nearly invisible on a
// near-black card. The gradient already carries the opacity ramp.
fillOpacity={1}
dot={false}
activeDot={{r: 3}}
{...motion}
/>
))}
</AreaChart>
</ChartFrame>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import {BarChart, Bar, ReferenceLine} from 'recharts';
import {ChartFrame, chartFurniture} from './ChartFrame';
import {ChartDefs, useHatchIds} from './ChartDefs';
import {useChartMotion} from './useChartMotion';
import {CHART, defaultEncoding} from './palette';
import type {ChartViewProps} from './types';
const TONE = {
neutral: CHART.reference,
positive: CHART.positive,
negative: CHART.negative,
} as const;
/**
* Bars cannot use a dash pattern, so the third series onward switches to a
* hatch fill instead — same idea, different channel.
*/
export function BarChartView<T extends object>({
data,
xKey,
series,
height = 260,
xFormat,
yFormat,
reference,
isAnimated = true,
isBare,
isStacked = false,
}: ChartViewProps<T> & {isStacked?: boolean}) {
const motion = useChartMotion(isAnimated);
const hatch = useHatchIds();
return (
<ChartFrame height={height}>
<BarChart data={data} margin={{top: 8, right: 8, bottom: 0, left: 0}}>
<ChartDefs
ids={hatch}
colors={{diagonal: CHART.seriesAt(2), cross: CHART.seriesAt(3)}}
/>
{chartFurniture({xKey, xFormat, yFormat, isBare})}
{reference ? (
<ReferenceLine
y={reference.y}
stroke={TONE[reference.tone ?? 'neutral']}
strokeDasharray="4 4"
label={{
value: reference.label,
position: 'insideTopRight',
fill: CHART.axis,
fontSize: 11,
}}
/>
) : null}
{series.map((s, i) => {
const encoding = s.encoding ?? (i >= 2 ? 'hatch' : defaultEncoding(i));
const solid = s.color ?? CHART.seriesAt(i);
const fill =
encoding === 'hatch'
? `url(#${i === 2 ? hatch.diagonal : hatch.cross})`
: solid;
return (
<Bar
key={s.key}
dataKey={s.key}
name={s.label}
fill={fill}
stroke={encoding === 'hatch' ? solid : undefined}
strokeWidth={encoding === 'hatch' ? 1 : 0}
stackId={isStacked ? 'stack' : undefined}
radius={isStacked ? 0 : [3, 3, 0, 0]}
maxBarSize={48}
{...motion}
/>
);
})}
</BarChart>
</ChartFrame>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import {PanelCard} from '@/components/patterns/PanelCard';
import {SkeletonChart} from '@/components/patterns/LoadingState';
import type {Resource} from '@/lib/api/useResource';
/**
* A PanelCard specialised for charts.
*
* The only thing a chart panel adds over the generic one is that its skeleton
* must reserve the chart's exact plotting height — otherwise the card resizes
* when data lands and the whole page shifts under the cursor.
*
* This is deliberately a thin wrapper rather than a parallel implementation.
* A separate ChartCard / AnalyticsCard / TableCard, each with its own copy of
* the header and async wiring, is the duplication the pattern kit exists to
* remove.
*/
export function ChartCard<T>({
title,
subtitle,
actions,
resource,
height = 260,
empty,
children,
}: {
title: string;
subtitle?: string;
actions?: React.ReactNode;
resource: Resource<T>;
height?: number;
empty?: React.ReactNode;
children: (data: T) => React.ReactNode;
}) {
return (
<PanelCard
title={title}
subtitle={subtitle}
actions={actions}
resource={resource}
loading={<SkeletonChart height={height} />}
empty={empty}
>
{children}
</PanelCard>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import {useId} from 'react';
/**
* SVG hatch patterns — the fill equivalent of a dash pattern.
*
* Once a bar or area chart needs a third series, luminance alone stops being
* reliable (a mid-gray bar on a dark card is easy to confuse with its
* neighbour). A hatch keeps the series separable without introducing hue.
*
* Pattern ids must be unique per chart instance, hence useId.
*/
export function useHatchIds() {
const base = useId().replace(/:/g, '');
return {
diagonal: `hatch-d-${base}`,
cross: `hatch-x-${base}`,
};
}
export function ChartDefs({
ids,
colors,
}: {
ids: {diagonal: string; cross: string};
colors: {diagonal: string; cross: string};
}) {
return (
<defs>
<pattern
id={ids.diagonal}
patternUnits="userSpaceOnUse"
width={6}
height={6}
patternTransform="rotate(45)"
>
<rect width={6} height={6} fill="transparent" />
<line x1={0} y1={0} x2={0} y2={6} stroke={colors.diagonal} strokeWidth={3} />
</pattern>
<pattern
id={ids.cross}
patternUnits="userSpaceOnUse"
width={6}
height={6}
>
<rect width={6} height={6} fill="transparent" />
<line x1={0} y1={0} x2={0} y2={6} stroke={colors.cross} strokeWidth={2} />
<line x1={0} y1={0} x2={6} y2={0} stroke={colors.cross} strokeWidth={2} />
</pattern>
</defs>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import {
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import {CHART} from './palette';
import {ChartTooltip} from './ChartTooltip';
/**
* The axis/grid/tooltip furniture every cartesian chart shares.
*
* Centralising it is what stops nine charts across four modules from drifting
* into nine slightly different tick sizes and grid weights. Every value here
* is a token — nothing is a literal colour.
*/
export const AXIS_TICK = {
fill: CHART.axis,
fontSize: 11,
} as const;
export function chartFurniture({
xKey,
xFormat,
yFormat,
isBare,
}: {
xKey: string;
xFormat?: (v: string | number) => string;
yFormat?: (v: number) => string;
isBare?: boolean;
}) {
if (isBare) return null;
return (
<>
<CartesianGrid
stroke={CHART.grid}
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
dataKey={xKey}
tick={AXIS_TICK}
tickLine={false}
axisLine={{stroke: CHART.axisLine}}
tickFormatter={xFormat}
minTickGap={24}
/>
<YAxis
tick={AXIS_TICK}
tickLine={false}
axisLine={false}
width={44}
tickFormatter={yFormat ? (v: number) => yFormat(v) : undefined}
/>
<Tooltip
cursor={{fill: CHART.cursor, stroke: CHART.axisLine}}
content={
<ChartTooltip labelFormat={xFormat} valueFormat={yFormat} />
}
/>
</>
);
}
export function ChartFrame({
height = 260,
children,
}: {
height?: number;
children: React.ReactElement;
}) {
return (
<ResponsiveContainer width="100%" height={height}>
{children}
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
interface TooltipEntry {
name?: string;
value?: number | string;
color?: string;
dataKey?: string | number;
}
/**
* recharts' default tooltip is a bare div with inline styles that ignore the
* theme. This renders the same data through Astryx surfaces so the tooltip
* matches every other popover in the app.
*/
export function ChartTooltip({
active,
payload,
label,
labelFormat,
valueFormat,
}: {
active?: boolean;
payload?: TooltipEntry[];
label?: string | number;
labelFormat?: (v: string | number) => string;
valueFormat?: (v: number) => string;
}) {
if (!active || !payload?.length) return null;
return (
<Card elevation="high" padding={3}>
<VStack gap={1.5}>
<Text size="sm" color="secondary">
{labelFormat && label !== undefined ? labelFormat(label) : label}
</Text>
<VStack gap={1}>
{payload.map((entry) => (
<HStack
key={String(entry.dataKey)}
gap={3}
vAlign="center"
hAlign="between"
>
<HStack gap={1.5} vAlign="center">
{/* Swatch mirrors the series' assigned gray so the tooltip
row is traceable back to the mark it describes. */}
<span
aria-hidden
style={{
width: 8,
height: 8,
borderRadius: 2,
background: entry.color,
flex: 'none',
}}
/>
<Text size="sm">{entry.name}</Text>
</HStack>
<Text size="sm" weight="medium">
{typeof entry.value === 'number' && valueFormat
? valueFormat(entry.value)
: entry.value}
</Text>
</HStack>
))}
</VStack>
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Tooltip} from '@astryxdesign/core/Tooltip';
export interface HeatCell {
day: number; // 0 = Monday
hour: number; // 023
value: number;
}
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
/**
* Peak Hours, deliberately NOT recharts.
*
* A 7×24 grid of divs tinted with color-mix() is smaller, faster and
* monochrome by construction — recharts has no heatmap primitive, and building
* one out of a scatter with custom shapes would be more code for a worse
* result. Intensity is opacity against the top of the gray ramp.
*/
export function HeatmapGrid({
data,
hourLabels = [0, 6, 12, 18, 23],
}: {
data: HeatCell[];
hourLabels?: number[];
}) {
const max = data.reduce((m, c) => Math.max(m, c.value), 0) || 1;
const byKey = new Map(data.map((c) => [`${c.day}-${c.hour}`, c.value]));
return (
<VStack gap={1}>
{DAYS.map((label, day) => (
<HStack key={label} gap={1.5} vAlign="center">
<HStack width={34}>
<Text size="xsm" color="secondary">
{label}
</Text>
</HStack>
<HStack gap={0.5} width="100%">
{Array.from({length: 24}, (_, hour) => {
const value = byKey.get(`${day}-${hour}`) ?? 0;
const pct = Math.round((value / max) * 100);
return (
<Tooltip
key={hour}
content={`${label} ${String(hour).padStart(2, '0')}:00 — ${value} visitors`}
>
<span
style={{
flex: 1,
height: 18,
borderRadius: 3,
background: `color-mix(in oklab, var(--color-data-gray-5) ${pct}%, transparent)`,
boxShadow:
pct === 0
? 'inset 0 0 0 1px var(--color-border)'
: undefined,
}}
/>
</Tooltip>
);
})}
</HStack>
</HStack>
))}
<HStack gap={1.5} width="100%">
{/* Spacer matching the day-label gutter, so the hour ticks line up
with the columns rather than the row start. */}
<HStack width={34} />
<HStack gap={0.5} width="100%" hAlign="between">
{hourLabels.map((h) => (
<Text key={h} size="xsm" color="secondary">
{String(h).padStart(2, '0')}:00
</Text>
))}
</HStack>
</HStack>
</VStack>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import {LineChart, Line, ReferenceLine} from 'recharts';
import {ChartFrame, chartFurniture} from './ChartFrame';
import {useChartMotion} from './useChartMotion';
import {
CHART,
dashFor,
defaultEncoding,
strokeWidthFor,
} from './palette';
import type {ChartViewProps} from './types';
const TONE = {
neutral: CHART.reference,
positive: CHART.positive,
negative: CHART.negative,
} as const;
export function LineChartView<T extends object>({
data,
xKey,
series,
height = 260,
xFormat,
yFormat,
reference,
isAnimated = true,
isBare,
}: ChartViewProps<T>) {
const motion = useChartMotion(isAnimated);
return (
<ChartFrame height={height}>
<LineChart data={data} margin={{top: 8, right: 8, bottom: 0, left: 0}}>
{chartFurniture({xKey, xFormat, yFormat, isBare})}
{reference ? (
<ReferenceLine
y={reference.y}
stroke={TONE[reference.tone ?? 'neutral']}
strokeDasharray="4 4"
label={{
value: reference.label,
position: 'insideTopRight',
fill: CHART.axis,
fontSize: 11,
}}
/>
) : null}
{series.map((s, i) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.color ?? CHART.seriesAt(i)}
strokeWidth={strokeWidthFor(i)}
strokeDasharray={dashFor(s.encoding ?? defaultEncoding(i))}
dot={false}
activeDot={{r: 3}}
{...motion}
/>
))}
</LineChart>
</ChartFrame>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import {LineChart, Line, ResponsiveContainer, YAxis} from 'recharts';
import {CHART} from './palette';
/**
* The 40px trend line inside a KPI card.
*
* Deliberately never animated: four of these animating on every dashboard load
* is noise, and they are glanceable context rather than the subject. Astryx's
* own dashboard template makes the same call.
*/
export function Sparkline<T extends object>({
data,
dataKey,
height = 40,
tone = 'neutral',
}: {
data: T[];
dataKey: Extract<keyof T, string>;
height?: number;
/** Semantic tint, for a KPI that is meaningfully up or down. */
tone?: 'neutral' | 'positive' | 'negative';
}) {
const stroke =
tone === 'positive'
? CHART.positive
: tone === 'negative'
? CHART.negative
: CHART.seriesAt(0);
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data} margin={{top: 2, right: 0, bottom: 2, left: 0}}>
{/* Domain padding keeps a flat-ish series from rendering as a line
pinned to the top or bottom edge of the box. */}
<YAxis hide domain={['dataMin - 1', 'dataMax + 1']} />
<Line
type="monotone"
dataKey={dataKey}
stroke={stroke}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,67 @@
/**
* The monochrome chart palette.
*
* recharts writes these straight onto SVG presentation attributes, where
* `var()` resolves natively — so colours stay CSS custom properties end to end.
* No getComputedStyle, no MutationObserver, no JS colour plumbing.
*
* THE ENCODING PROBLEM
* Five gray steps carry about three distinguishable series by luminance alone.
* Rather than reach for hue the moment a fourth series appears, the primitives
* encode extra dimensions non-chromatically — dash pattern, stroke width, and
* hatch fill. Colour stays reserved for meaning.
*/
/** Ordered series ramp, lightest first — series 0 is the most important. */
export const SERIES = [
'var(--color-data-gray-5)', // #E4E4E7
'var(--color-data-gray-4)', // #A1A1AA
'var(--color-data-gray-3)', // #71717A
'var(--color-data-gray-2)', // #52525B
'var(--color-data-gray-1)', // #3F3F46
] as const;
export const CHART = {
series: SERIES,
seriesAt: (i: number) => SERIES[i % SERIES.length],
grid: 'var(--color-border)',
axis: 'var(--color-text-secondary)',
axisLine: 'var(--color-border-emphasized)',
cursor: 'var(--color-overlay-hover)',
reference: 'var(--color-text-disabled)',
/**
* Semantic colour. Used ONLY where the colour itself carries the meaning:
* a delta against target, a threshold breach, an expiry warning. Never to
* tell two ordinary series apart.
*/
positive: 'var(--color-success)',
negative: 'var(--color-error)',
attention: 'var(--color-warning)',
} as const;
/** The non-chromatic channel. */
export type SeriesEncoding = 'solid' | 'dashed' | 'dotted' | 'hatch';
const DASH: Record<SeriesEncoding, string | undefined> = {
solid: undefined,
dashed: '6 3',
dotted: '2 3',
hatch: undefined,
};
export function dashFor(encoding: SeriesEncoding = 'solid') {
return DASH[encoding];
}
/**
* Series beyond the second get progressively thinner as well as darker, so
* they read as background context rather than competing for attention.
*/
export function strokeWidthFor(index: number) {
return index === 0 ? 2 : index === 1 ? 1.75 : 1.5;
}
/** Default encoding per series position, when the caller does not specify one. */
export function defaultEncoding(index: number): SeriesEncoding {
return index === 0 ? 'solid' : index === 1 ? 'dashed' : 'dotted';
}

View File

@@ -0,0 +1,37 @@
import type {SeriesEncoding} from './palette';
/**
* One prop shape shared by every chart view, so a module can swap a line for
* bars without rewriting its data plumbing.
*/
export interface Series<T> {
key: Extract<keyof T, string>;
label: string;
/**
* Override the auto-assigned gray. Use ONLY when the colour carries meaning
* (over/under target, positive/negative delta) — never to separate series.
*/
color?: string;
/** The non-chromatic channel. Defaults by series position. */
encoding?: SeriesEncoding;
format?: (v: number) => string;
}
export interface ChartViewProps<T> {
data: T[];
xKey: Extract<keyof T, string>;
series: Series<T>[];
/** Workspace default 260; Copilot column 180; sparkline 40. */
height?: number;
yFormat?: (v: number) => string;
xFormat?: (v: string | number) => string;
reference?: {
y: number;
label: string;
tone?: 'neutral' | 'positive' | 'negative';
};
/** Overridden to false by prefers-reduced-motion regardless of this value. */
isAnimated?: boolean;
/** Hide axes and grid — for dense small-multiples. */
isBare?: boolean;
}

View File

@@ -0,0 +1,22 @@
'use client';
import {useReducedMotion} from 'framer-motion';
/**
* recharts drives its own animation, so <MotionConfig reducedMotion="user">
* cannot reach it. This bridges the two: one hook, spread onto every animated
* recharts primitive.
*/
export function useChartMotion(enabled = true) {
const reduced = useReducedMotion();
if (reduced || !enabled) {
return {isAnimationActive: false as const};
}
return {
isAnimationActive: true as const,
animationDuration: 500,
animationBegin: 0,
animationEasing: 'ease-out' as const,
};
}

View File

@@ -0,0 +1,80 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Banner} from '@astryxdesign/core/Banner';
import {Button} from '@astryxdesign/core/Button';
import {EmptyState} from '@astryxdesign/core/EmptyState';
import type {Resource} from '@/lib/api/useResource';
/**
* The one place loading / empty / error are rendered.
*
* Feature components receive resolved data only — they never branch on status
* themselves. That is what stops four modules from inventing four different
* error treatments, and it is why the states stay consistent as the app grows.
*/
export function AsyncBoundary<T>({
resource,
loading,
empty,
children,
}: {
resource: Resource<T>;
loading: React.ReactNode;
empty?: React.ReactNode;
children: (data: T) => React.ReactNode;
}) {
if (resource.status === 'loading') return <>{loading}</>;
if (resource.status === 'error') {
return (
<VStack gap={3}>
<Banner
status="error"
title="Couldnt load this"
description={resource.error.message}
/>
<Button
variant="secondary"
size="sm"
label="Retry"
onClick={resource.refetch}
/>
</VStack>
);
}
if (resource.status === 'empty') {
return (
<>
{empty ?? (
<EmptyState
title="Nothing to show yet"
description="Theres no data for this store and period."
isCompact
/>
)}
</>
);
}
// Stale data stays visible during a refetch, dimmed and non-interactive so
// it reads as "the previous answer" rather than the current one.
//
// The wrapper is ALWAYS rendered, never conditionally swapped for a
// fragment. Changing the element shape between renders makes React unmount
// and remount the whole subtree, which restarts every AnimatedNumber from
// zero — the exact flicker this stale-while-revalidate path exists to avoid.
return (
<div
aria-busy={resource.isRefreshing || undefined}
style={{
opacity: resource.isRefreshing ? 0.45 : 1,
transition: 'opacity var(--duration-fast) var(--ease-standard)',
pointerEvents: resource.isRefreshing ? 'none' : undefined,
}}
>
{children(resource.data)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import {useEffect, useRef} from 'react';
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from 'framer-motion';
/**
* Count animation for KPI values.
*
* Two deliberate constraints:
*
* 1. NO count-up on first paint. Springs leave their origin slowly, so
* animating 0 → 32,700 renders a literal "0" for the first couple of
* hundred milliseconds — four wrong numbers on a dashboard merchants load
* dozens of times a day. The initial value is therefore painted directly.
* The animation exists to show that a number *moved*, which is only
* meaningful when it had a previous value to move from.
*
* 2. Reduced motion skips the spring entirely rather than shortening it.
* <MotionConfig reducedMotion="user"> governs motion.* props, but a spring
* driving text content is outside its reach, so it is handled here.
*/
export function AnimatedNumber({
value,
format,
}: {
value: number;
format: (v: number) => string;
}) {
const reduced = useReducedMotion();
// Seeded with the real value, so the first frame is already correct.
const raw = useMotionValue(value);
const spring = useSpring(raw, {stiffness: 120, damping: 24, mass: 0.5});
const text = useTransform(spring, (v) => format(v));
const isFirst = useRef(true);
useEffect(() => {
if (isFirst.current) {
isFirst.current = false;
return;
}
if (reduced) {
// jump() moves the spring's internal state too, so no frame is scheduled.
raw.jump(value);
spring.jump(value);
return;
}
raw.set(value);
}, [value, reduced, raw, spring]);
if (reduced) {
return <>{format(value)}</>;
}
return <motion.span>{text}</motion.span>;
}

View File

@@ -0,0 +1,31 @@
'use client';
import {motion} from 'framer-motion';
/**
* Card hover: a 2px rise and a shadow step, 120ms.
*
* Deliberately restrained — on a dashboard where a dozen cards share a screen,
* anything larger reads as the page twitching. <MotionConfig reducedMotion="user">
* in providers.tsx already neutralises the transform for users who ask for it,
* so there is no per-component guard here.
*/
export function HoverLift({
children,
isEnabled = true,
}: {
children: React.ReactNode;
isEnabled?: boolean;
}) {
if (!isEnabled) return <>{children}</>;
return (
<motion.div
whileHover={{y: -2}}
transition={{duration: 0.12, ease: 'easeOut'}}
style={{height: '100%'}}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Divider} from '@astryxdesign/core/Divider';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
export interface ActivityEntry {
id: string;
title: string;
detail?: string;
at: string;
tone: StatusDotVariant;
/** Announced with the dot; the dot's colour alone is not an accessible label. */
toneLabel: string;
}
/**
* One row in a chronological feed.
*
* Rows, not cards: Astryx's own guidance is that dense sequential data reads
* edge-to-edge, and wrapping each event in a Card would triple the vertical
* space while making the feed harder to scan.
*
* Tone is passed in rather than derived, because "which events deserve colour"
* is a per-module editorial decision. On the dashboard only expiries are
* amber; in Lyts the same rule applies to a different event set. What must
* stay constant is that MOST rows are neutral — if everything is coloured,
* the one row that matters stops standing out.
*/
export function ActivityItem({
entry,
isLast,
}: {
entry: ActivityEntry;
isLast: boolean;
}) {
return (
<VStack gap={0}>
<HStack gap={3} vAlign="start" paddingBlock={2}>
<HStack paddingBlock={1}>
<StatusDot variant={entry.tone} label={entry.toneLabel} />
</HStack>
<VStack gap={0.5} width="100%">
<HStack gap={3} hAlign="between" vAlign="center">
<Text size="sm" weight="medium">
{entry.title}
</Text>
<Timestamp value={entry.at} format="relative" isLive />
</HStack>
{entry.detail ? (
<Text size="sm" color="secondary">
{entry.detail}
</Text>
) : null}
</VStack>
</HStack>
{isLast ? null : <Divider />}
</VStack>
);
}
/** The list wrapper — owns the divider-between-but-not-after rule. */
export function ActivityFeed({entries}: {entries: ActivityEntry[]}) {
return (
<VStack gap={0}>
{entries.map((e, i) => (
<ActivityItem
key={e.id}
entry={e}
isLast={i === entries.length - 1}
/>
))}
</VStack>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Heading} from '@astryxdesign/core/Text';
import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
/**
* A labelled region wrapping a grid of entity cards.
*
* The card grids (stores, rewards, staff) are visually self-explanatory — the
* filter bar above already says "5 stores" — so they carry no visible heading.
* But each card titles itself with an h3, and an h3 with no h2 above it is a
* hole in the document outline: screen-reader users navigating by heading jump
* from the page title straight into individual cards with no sense of what
* collection they are in.
*
* So the heading exists, and is hidden. This is one of the few legitimate uses
* of visually-hidden text: the information is genuinely redundant for sighted
* users and genuinely missing for everyone else.
*/
export function CollectionRegion({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<VStack gap={4} as="section">
<VisuallyHidden>
<Heading level={2}>{label}</Heading>
</VisuallyHidden>
{children}
</VStack>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import {EmptyState} from '@astryxdesign/core/EmptyState';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import type {IconKey} from '@/lib/icons';
/**
* Astryx's EmptyState with our icon convention applied.
*
* The icon is named, NOT passed as a component. Two reasons:
*
* 1. RSC safety. A lucide icon is a function/forwardRef, and React refuses to
* serialise functions across the server/client boundary — passing
* `icon={ICONS.revenue}` from a Server Component throws
* "Functions cannot be passed directly to Client Components". A string key
* crosses fine, so this component works from either side.
* 2. It enforces the central map. There is no way to pass an ad-hoc icon,
* which is how an icon set drifts.
*
* `description` is deliberately required. An empty state that only says
* "No data" tells a merchant nothing about whether something is broken,
* filtered out, or genuinely hasn't happened yet — which is the entire reason
* to render one instead of a blank box.
*/
export function EmptyPanel({
icon,
title,
description,
actions,
isCompact = true,
}: {
icon: IconKey;
title: string;
description: string;
actions?: React.ReactNode;
isCompact?: boolean;
}) {
return (
<EmptyState
icon={<Icon icon={ICONS[icon]} size="lg" color="secondary" />}
title={title}
description={description}
actions={actions}
isCompact={isCompact}
/>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {Text} from '@astryxdesign/core/Text';
export interface FilterOption {
value: string;
label: string;
/** Shown after the label, e.g. a count. */
hint?: string | number;
}
/**
* Search plus a single-select filter, above a collection.
*
* Store, Lyts and Staff all present "a grid of things you can narrow down",
* so the control strip is defined once. A SegmentedControl rather than a
* dropdown because these sets are small and always visible — a merchant
* scanning for the one closed store should not have to open a menu to
* discover that "closed" is an option, or to see there are none.
*
* The result count is part of the bar rather than floating above the grid:
* when a filter returns nothing, the count is the fastest explanation of why
* the space below is empty.
*/
export function FilterBar({
query,
onQueryChange,
placeholder = 'Search…',
filterValue,
onFilterChange,
options,
filterLabel,
resultCount,
resultNoun,
actions,
}: {
query: string;
onQueryChange: (v: string) => void;
placeholder?: string;
filterValue: string;
onFilterChange: (v: string) => void;
options: FilterOption[];
/** Accessible name for the filter group — never rendered visually. */
filterLabel: string;
resultCount?: number;
resultNoun?: string;
actions?: React.ReactNode;
}) {
return (
<HStack gap={3} vAlign="center" wrap="wrap" hAlign="between">
<HStack gap={3} vAlign="center" wrap="wrap">
<TextInput
label={placeholder}
isLabelHidden
placeholder={placeholder}
value={query}
onChange={onQueryChange}
startIcon="search"
hasClear
size="sm"
width={260}
/>
<SegmentedControl
value={filterValue}
onChange={onFilterChange}
label={filterLabel}
size="sm"
>
{options.map((o) => (
<SegmentedControlItem
key={o.value}
value={o.value}
label={
o.hint !== undefined ? `${o.label} (${o.hint})` : o.label
}
/>
))}
</SegmentedControl>
</HStack>
<HStack gap={3} vAlign="center">
{resultCount !== undefined ? (
<Text size="sm" color="secondary">
{resultCount} {resultNoun ?? 'results'}
</Text>
) : null}
{actions}
</HStack>
</HStack>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack} from '@astryxdesign/core/Layout';
import {Skeleton} from '@astryxdesign/core/Skeleton';
/**
* The named loading shapes.
*
* Skeletons were previously written inline with hand-picked pixel heights —
* 38 here, 16/30/40 there — which meant every panel resolved with a small
* layout jump because the placeholder never quite matched the real content.
*
* A skeleton's only job is to reserve the right space. Naming the shapes makes
* that checkable: if a panel jumps on resolve, its skeleton is wrong, and
* there is one place to fix it.
*/
/** Rows of equal height — activity feeds, table bodies, list panels. */
export function SkeletonRows({
count = 5,
height = 38,
}: {
count?: number;
height?: number;
}) {
return (
<VStack gap={3}>
{Array.from({length: count}, (_, i) => (
<Skeleton key={i} height={height} width="100%" />
))}
</VStack>
);
}
/** Matches MetricCard: label, value, sparkline. */
export function SkeletonMetric() {
return (
<Card>
<VStack gap={3}>
<Skeleton height={16} width="45%" />
<Skeleton height={30} width="65%" />
<Skeleton height={40} width="100%" />
</VStack>
</Card>
);
}
/** A grid of metric placeholders, matching the real grid's column count. */
export function SkeletonMetricGrid({
count = 4,
columns,
}: {
count?: number;
columns: number;
}) {
return (
<Grid columns={columns} gap={4}>
{Array.from({length: count}, (_, i) => (
<SkeletonMetric key={i} />
))}
</Grid>
);
}
/** Reserves a chart's exact plotting height so the card cannot jump. */
export function SkeletonChart({height = 260}: {height?: number}) {
return <Skeleton height={height} width="100%" />;
}
/** A grid of card placeholders — store grid, reward grid, staff grid. */
export function SkeletonCardGrid({
count = 6,
height = 190,
minWidth = 320,
}: {
count?: number;
height?: number;
minWidth?: number;
}) {
return (
<Grid columns={{minWidth, repeat: 'fit'}} gap={4}>
{Array.from({length: count}, (_, i) => (
<Skeleton key={i} height={height} width="100%" />
))}
</Grid>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import type {IconType} from '@astryxdesign/core/Icon';
import {AnimatedNumber} from '@/components/motion/AnimatedNumber';
import {HoverLift} from '@/components/motion/HoverLift';
import {MetricDelta} from '@/components/primitives/MetricDelta';
import {Sparkline} from '@/components/charts/Sparkline';
/**
* A single headline number.
*
* Extracted from the dashboard's private KpiCard because the same object is
* needed by Staff (present/absent/late/total) and by the Store detail header —
* and a second hand-written copy is exactly how two screens start disagreeing
* about how a metric looks.
*
* Two deliberate constraints carried over from the dashboard:
*
* - The trend line is GRAY. The delta chip already states whether the
* direction is good; tinting the whole line repeats that in the loudest
* possible way and would make metric rows the most colourful thing on a
* monochrome page.
* - Sparklines never animate. A row of them firing on every load is noise.
*/
export function MetricCard({
label,
value,
format,
icon,
deltaPct,
isRiseGood = true,
trend,
footer,
}: {
label: string;
value: number;
format: (v: number) => string;
icon?: IconType;
deltaPct?: number;
isRiseGood?: boolean;
/** 14-ish points. Omit for metrics with no meaningful history. */
trend?: {v: number}[];
/** Replaces the sparkline — e.g. a share-of-total caption. */
footer?: React.ReactNode;
}) {
return (
<HoverLift>
<Card>
<VStack gap={3}>
<HStack gap={2} vAlign="center">
{icon ? <Icon icon={icon} size="sm" color="secondary" /> : null}
<Text size="sm" color="secondary">
{label}
</Text>
</HStack>
<HStack gap={2} vAlign="center" hAlign="between">
{/*
Text, NOT Heading. A KPI value is data, not a section title —
marking it up as an h2 puts "32.7k" into the document outline
and makes screen-reader heading navigation read a list of
numbers. `display-3` gives the visual weight without the
semantics.
*/}
<Text type="display-3">
<AnimatedNumber value={value} format={format} />
</Text>
{deltaPct !== undefined ? (
<MetricDelta value={deltaPct} isRiseGood={isRiseGood} />
) : null}
</HStack>
{footer ??
(trend && trend.length > 1 ? (
<Sparkline data={trend} dataKey="v" />
) : null)}
</VStack>
</Card>
</HoverLift>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack} from '@astryxdesign/core/Layout';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {SectionHeader} from './SectionHeader';
import type {Resource} from '@/lib/api/useResource';
/**
* The standard workspace panel: a card with a header and asynchronous content.
*
* This is the shape every analytics surface in the app shares — charts,
* tables, feeds, grids. Rather than a separate ChartCard, AnalyticsCard,
* TableCard and FeedCard (four near-identical components, which is the
* duplication we are trying to remove), there is one panel and callers vary
* the loading shape.
*
* The async wiring lives HERE rather than in each panel's body so that feature
* components only ever receive resolved data. That is what stops each module
* inventing its own error treatment.
*/
export function PanelCard<T>({
title,
subtitle,
actions,
resource,
loading,
empty,
headingLevel = 2,
children,
}: {
title: string;
subtitle?: string;
actions?: React.ReactNode;
resource: Resource<T>;
loading: React.ReactNode;
empty?: React.ReactNode;
headingLevel?: 2 | 3 | 4 | 5;
children: (data: T) => React.ReactNode;
}) {
return (
<Card>
<VStack gap={4}>
<SectionHeader
title={title}
subtitle={subtitle}
actions={actions}
level={headingLevel}
/>
<AsyncBoundary resource={resource} loading={loading} empty={empty}>
{children}
</AsyncBoundary>
</VStack>
</Card>
);
}
/**
* A panel with no asynchronous content — static or already-resolved data.
* Same chrome, so a static panel and a loaded one are visually identical.
*/
export function StaticPanel({
title,
subtitle,
actions,
headingLevel = 2,
children,
}: {
title: string;
subtitle?: string;
actions?: React.ReactNode;
headingLevel?: 2 | 3 | 4 | 5;
children: React.ReactNode;
}) {
return (
<Card>
<VStack gap={4}>
<SectionHeader
title={title}
subtitle={subtitle}
actions={actions}
level={headingLevel}
/>
{children}
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Heading, Text} from '@astryxdesign/core/Text';
/**
* Title + optional subtitle + optional trailing actions.
*
* This block was being hand-built in ChartCard, ActivityTimeline, the Copilot
* panel and AiTab, each with slightly different heading levels and gaps. It is
* the single most repeated shape in the app, so it gets one definition.
*
* `level` exists because the same visual treatment has to sit at different
* depths in the document outline — a panel inside a page is an h4 even though
* it looks identical to an h3 elsewhere. Screen-reader users navigate by that
* outline, so it must stay accurate.
*/
export function SectionHeader({
title,
subtitle,
actions,
level = 2,
}: {
title: string;
/**
* A plain string gets the standard secondary treatment. A node is rendered
* as-is, which is what lets entity cards — whose "subtitle" is an icon plus
* a value — share this component instead of rebuilding the same
* title / sub / trailing-action arrangement.
*/
subtitle?: React.ReactNode;
actions?: React.ReactNode;
level?: 2 | 3 | 4 | 5;
}) {
return (
<HStack hAlign="between" vAlign="start" gap={3}>
<VStack gap={0.5}>
<Heading level={level}>{title}</Heading>
{typeof subtitle === 'string' ? (
<Text size="sm" color="secondary">
{subtitle}
</Text>
) : (
subtitle
)}
</VStack>
{actions ? (
<HStack gap={1} vAlign="center">
{actions}
</HStack>
) : null}
</HStack>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
/**
* A caption above a value.
*
* The smallest repeated unit in the app — it appears four to six times inside
* every store card, reward card and staff card. Left as inline markup it is
* where label sizing quietly drifts between modules.
*/
export function StatPair({
label,
value,
align = 'start',
}: {
label: string;
value: React.ReactNode;
align?: 'start' | 'end';
}) {
return (
<VStack gap={0} hAlign={align}>
<Text size="xsm" color="secondary">
{label}
</Text>
<Text size="sm" weight="medium">
{value}
</Text>
</VStack>
);
}
/** A row of StatPairs, evenly distributed — the footer of most entity cards. */
export function StatRow({children}: {children: React.ReactNode}) {
return (
<HStack gap={4} hAlign="between" wrap="wrap">
{children}
</HStack>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import {formatDelta} from '@/lib/format';
/**
* Period-over-period change.
*
* Direction and goodness are separate ideas: a rise in refunds is bad, a rise
* in revenue is good. The arrow shows direction, the colour shows whether that
* direction is good — which is why `isRiseGood` is a required part of the KPI
* contract rather than an assumption baked in here.
*
* This is one of the few places semantic colour is allowed, because the colour
* IS the information.
*/
export function MetricDelta({
value,
isRiseGood = true,
size = 'sm',
}: {
value: number;
isRiseGood?: boolean;
size?: 'xsm' | 'sm';
}) {
const isFlat = Math.abs(value) < 0.05;
const isGood = value > 0 === isRiseGood;
// Icon exposes semantic colours as props; Text does not (its union stops at
// primary/secondary/disabled/accent). For the label we go through the
// Tailwind bridge instead, which maps --color-success/--color-error into
// utilities — still a token, never a literal.
const textClass = isFlat
? 'text-secondary'
: isGood
? 'text-success'
: 'text-error';
return (
<HStack gap={0.5} vAlign="center">
{isFlat ? null : (
<Icon
icon={value > 0 ? ICONS.up : ICONS.down}
size="xsm"
color={isGood ? 'success' : 'error'}
/>
)}
<Text size={size} weight="medium" color="inherit" className={textClass}>
{isFlat ? '—' : formatDelta(value)}
</Text>
</HStack>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Heading, Text} from '@astryxdesign/core/Text';
/**
* Every workspace page opens the same way: title, one line of orientation,
* and page-level actions on the trailing edge. Centralising it is what keeps
* five modules from drifting into five different header treatments.
*/
export function PageHeader({
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: React.ReactNode;
}) {
return (
<HStack hAlign="between" vAlign="start" gap={4}>
<VStack gap={1}>
<Heading level={1}>{title}</Heading>
{description ? (
<Text color="secondary">{description}</Text>
) : null}
</VStack>
{actions ? <HStack gap={2}>{actions}</HStack> : null}
</HStack>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import {usePathname} from 'next/navigation';
import {
SideNav,
SideNavHeading,
SideNavItem,
SideNavSection,
} from '@astryxdesign/core/SideNav';
import {NavIcon} from '@astryxdesign/core/NavIcon';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import {FOOTER_NAV, PRIMARY_NAV, isNavActive} from './nav-config';
/**
* 260px, collapsible, Settings pinned to the bottom.
*
* The active indicator and hover animation are SideNavItem's own `selected` /
* `--color-overlay-hover` styles, which the theme already recoloured to
* #202124 / white-at-6%. There is deliberately no custom CSS here — a
* hand-rolled active state would drift from the rest of the system.
*/
export function AppSideNav() {
const pathname = usePathname();
return (
<SideNav
collapsible={{
defaultIsCollapsed: false,
hasButton: true,
buttonLabel: 'Collapse navigation',
}}
resizable={{
defaultWidth: 260,
minWidth: 220,
maxWidth: 360,
autoSaveId: 'loyaly.sidenav.width',
}}
header={
<SideNavHeading
heading="Loyaly"
subheading="Merchant"
icon={<NavIcon icon={<Icon icon={ICONS.lyt} size="sm" />} />}
headingHref="/dashboard"
/>
}
footer={
<SideNavSection title="Account" isHeaderHidden>
{FOOTER_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
isSelected={isNavActive(pathname, item.href)}
/>
))}
</SideNavSection>
}
>
<SideNavSection title="Workspace" isHeaderHidden>
{PRIMARY_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
isSelected={isNavActive(pathname, item.href)}
/>
))}
</SideNavSection>
</SideNav>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import {TopNav} from '@astryxdesign/core/TopNav';
import {HStack} from '@astryxdesign/core/Layout';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Divider} from '@astryxdesign/core/Divider';
import {ICONS} from '@/lib/icons';
import {GlobalSearch} from './GlobalSearch';
import {StoreSwitcher} from './StoreSwitcher';
import {RangePicker} from './RangePicker';
import {CopilotToggle} from '@/features/copilot/CopilotToggle';
/**
* Compact application bar. Scope controls (store, range) live at the start
* because they govern the whole workspace; identity and alerts live at the end.
*/
export function AppTopNav() {
return (
<TopNav
label="Workspace"
startContent={
<HStack gap={1} vAlign="center">
<StoreSwitcher />
<Divider orientation="vertical" />
<RangePicker />
</HStack>
}
centerContent={<GlobalSearch />}
endContent={
<HStack gap={1} vAlign="center">
<CopilotToggle />
<IconButton
icon={<Icon icon={ICONS.notifications} />}
label="Notifications"
variant="ghost"
/>
<DropdownMenu
hasChevron={false}
button={{
variant: 'ghost',
isIconOnly: true,
label: 'Account',
icon: <Avatar name="Suriya N" size="sm" tooltip={false} />,
}}
menuWidth={200}
items={[
{
type: 'section',
title: 'suriya@nearle.in',
items: [
{label: 'Profile', icon: ICONS.profile},
{label: 'Settings', icon: ICONS.settings},
],
},
{type: 'divider'},
{label: 'Sign out', icon: ICONS.signOut},
]}
/>
</HStack>
}
/>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {useRouter} from 'next/navigation';
import {CommandPalette} from '@astryxdesign/core/CommandPalette';
import {createStaticSource} from '@astryxdesign/core/Typeahead';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {FOOTER_NAV, PRIMARY_NAV} from './nav-config';
/**
* Search is a command palette, not a text field. In a workspace this dense the
* fastest path to anything is ⌘K → type → Enter, so the thing in the nav bar
* is an affordance that opens the palette rather than an input of its own.
*
* Right now it indexes navigation only. Stores, rewards and staff join the
* same source once their modules land — the palette does not change, only the
* items array does.
*/
export function GlobalSearch() {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setIsOpen((v) => !v);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const source = useMemo(
() =>
createStaticSource(
[...PRIMARY_NAV, ...FOOTER_NAV].map((n) => ({
id: n.href,
label: n.label,
auxiliaryData: {group: 'Navigate', href: n.href},
})),
),
[],
);
return (
<>
<Button
variant="secondary"
size="sm"
label="Search ⌘K"
icon={<Icon icon="search" size="sm" />}
onClick={() => setIsOpen(true)}
/>
<CommandPalette
isOpen={isOpen}
onOpenChange={setIsOpen}
searchSource={source}
label="Search Loyaly"
emptyBootstrapText="Search stores, rewards and staff…"
onValueChange={(value) => {
router.push(value);
setIsOpen(false);
}}
/>
</>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import {MobileNav} from '@astryxdesign/core/MobileNav';
import {SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {usePathname} from 'next/navigation';
import {FOOTER_NAV, PRIMARY_NAV, isNavActive} from './nav-config';
import {StoreSwitcher} from './StoreSwitcher';
import {RangePicker} from './RangePicker';
/**
* The mobile drawer.
*
* AppShell will auto-generate a drawer that mirrors the sidebar, but that
* drawer contains navigation ONLY — and the store switcher and range picker
* live in TopNav's `startContent`, which collapses below 768px. The result was
* that scope could not be changed at all on a phone, on a product where every
* page's numbers depend on it.
*
* So the drawer is supplied explicitly: scope first (it governs everything
* below it), then the same nav the sidebar shows.
*/
export function MobileScopeNav() {
const pathname = usePathname();
return (
<MobileNav label="Menu" side="start" width={300}>
<VStack gap={3} padding={4}>
<Text size="xsm" color="secondary">
Viewing
</Text>
<VStack gap={2} width="100%">
<HStack width="100%">
<StoreSwitcher />
</HStack>
<HStack width="100%">
<RangePicker />
</HStack>
</VStack>
</VStack>
<Divider />
<SideNavSection title="Workspace" isHeaderHidden>
{PRIMARY_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
isSelected={isNavActive(pathname, item.href)}
/>
))}
</SideNavSection>
<Divider />
<SideNavSection title="Account" isHeaderHidden>
{FOOTER_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
isSelected={isNavActive(pathname, item.href)}
/>
))}
</SideNavSection>
</MobileNav>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {RANGE_LABELS, useWorkspace} from './WorkspaceProvider';
import type {RangeKey} from './WorkspaceProvider';
const ORDER: RangeKey[] = ['7d', '30d', '90d', 'mtd', 'ytd'];
/** Period scope for every chart and KPI in the workspace. */
export function RangePicker() {
const {range, setRange} = useWorkspace();
return (
<DropdownMenu
button={{
variant: 'ghost',
size: 'sm',
label: RANGE_LABELS[range],
icon: <Icon icon="calendar" size="sm" />,
}}
items={ORDER.map((key) => ({
label: RANGE_LABELS[key],
onClick: () => setRange(key),
}))}
/>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import {useWorkspace} from './WorkspaceProvider';
/**
* Scope selector for the whole workspace. Everything downstream reads storeId
* from WorkspaceProvider, so switching re-queries every panel without a route
* change — which is what keeps the Copilot mounted.
*/
export function StoreSwitcher() {
const {storeId, setStoreId, stores} = useWorkspace();
const current = stores.find((s) => s.id === storeId);
return (
<DropdownMenu
button={{
variant: 'ghost',
size: 'sm',
label: current ? current.name : 'All stores',
icon: <Icon icon={ICONS.stores} size="sm" />,
}}
items={[
{label: 'All stores', onClick: () => setStoreId('all')},
{type: 'divider'},
{
type: 'section',
title: 'Stores',
items: stores.map((s) => ({
label: s.name,
onClick: () => setStoreId(s.id),
})),
},
]}
/>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import {createContext, useContext, useMemo, useState} from 'react';
/**
* Workspace-wide query scope: which store, and over what period.
*
* This lives ABOVE the route tree (mounted in app/providers.tsx) for the same
* reason the Copilot does — the controls that drive it sit in the top nav,
* which outlives any individual page. A server-component page could not
* re-render from these controls without a router navigation, and a navigation
* would remount the Copilot. So scope is client state, and data panels read it.
*/
export type RangeKey = '7d' | '30d' | '90d' | 'mtd' | 'ytd';
export const RANGE_LABELS: Record<RangeKey, string> = {
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
mtd: 'Month to date',
ytd: 'Year to date',
};
export interface StoreOption {
id: string;
name: string;
}
/**
* Store list for the switcher. Deliberately static: the switcher must render
* before any data loads, and a spinner in the top nav on every page load reads
* as jank. The authoritative list comes from /api/stores once Store lands.
*/
export const STORE_OPTIONS: StoreOption[] = [
{id: 'blr-indiranagar', name: 'Indiranagar Flagship'},
{id: 'blr-koramangala', name: 'Koramangala'},
{id: 'blr-whitefield', name: 'Whitefield'},
{id: 'che-anna-nagar', name: 'Anna Nagar'},
{id: 'hyd-jubilee', name: 'Jubilee Hills'},
];
export type StoreScope = 'all' | (string & {});
interface WorkspaceValue {
storeId: StoreScope;
setStoreId: (id: StoreScope) => void;
range: RangeKey;
setRange: (r: RangeKey) => void;
stores: StoreOption[];
}
const WorkspaceContext = createContext<WorkspaceValue | null>(null);
export function WorkspaceProvider({children}: {children: React.ReactNode}) {
const [storeId, setStoreId] = useState<StoreScope>('all');
const [range, setRange] = useState<RangeKey>('30d');
const value = useMemo(
() => ({storeId, setStoreId, range, setRange, stores: STORE_OPTIONS}),
[storeId, range],
);
return <WorkspaceContext value={value}>{children}</WorkspaceContext>;
}
export function useWorkspace(): WorkspaceValue {
const ctx = useContext(WorkspaceContext);
if (!ctx) {
throw new Error('useWorkspace must be used inside <WorkspaceProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,34 @@
import {ICONS} from '@/lib/icons';
import type {IconType} from '@astryxdesign/core/Icon';
export interface NavEntry {
label: string;
href: string;
icon: IconType;
}
/**
* The sidebar. Adding a future module (Customers, Campaigns, Inventory,
* Reports, Billing) is a one-line push here plus a route folder — nothing in
* the shell changes. That is the whole point of keeping this a plain array.
*/
export const PRIMARY_NAV: NavEntry[] = [
{label: 'Dashboard', href: '/dashboard', icon: ICONS.dashboard},
{label: 'Store', href: '/stores', icon: ICONS.stores},
{label: 'Lyts', href: '/lyts', icon: ICONS.lyts},
{label: 'Staff', href: '/staff', icon: ICONS.staff},
];
/** Pinned to the bottom of the sidebar via SideNav's `footer` slot. */
export const FOOTER_NAV: NavEntry[] = [
{label: 'Settings', href: '/settings', icon: ICONS.settings},
];
/**
* `/stores` must not light up for `/staff`, and `/dashboard` must not light up
* for every route, so this is prefix matching with an exact-match guard rather
* than a bare startsWith.
*/
export function isNavActive(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}

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 320380px 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>
);
}

Some files were not shown because too many files have changed in this diff Show More