first commit
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "loyaly-mer-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 3100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
79
AGENTS.md
79
AGENTS.md
@@ -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.
|
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 -->
|
<!-- 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 -->
|
||||||
|
|||||||
31
CLAUDE.md
31
CLAUDE.md
@@ -1 +1,32 @@
|
|||||||
@AGENTS.md
|
@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 -->
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ const eslintConfig = defineConfig([
|
|||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"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",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1792
package-lock.json
generated
1792
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -3,23 +3,32 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -p 3100",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start -p 3100",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"theme:build": "astryx theme build src/theme/loyalyTheme.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.10.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@astryxdesign/cli": "^0.2.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.12",
|
"eslint-config-next": "^16.3.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/loyalyai-logo.png
Normal file
BIN
public/loyalyai-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
public/loyalyai.png
Normal file
BIN
public/loyalyai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/lyts.png
Normal file
BIN
public/lyts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
243
src/app/(dev)/charts/page.tsx
Normal file
243
src/app/(dev)/charts/page.tsx
Normal 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
16
src/app/(dev)/layout.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
339
src/app/(dev)/tokens/page.tsx
Normal file
339
src/app/(dev)/tokens/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/app/(workspace)/dashboard/page.tsx
Normal file
154
src/app/(workspace)/dashboard/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useState} from 'react';
|
||||||
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
|
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||||
|
import {ChartCard} from '@/components/charts/ChartCard';
|
||||||
|
import {AreaChartView} from '@/components/charts/AreaChartView';
|
||||||
|
import {LineChartView} from '@/components/charts/LineChartView';
|
||||||
|
import {BarChartView} from '@/components/charts/BarChartView';
|
||||||
|
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
|
||||||
|
import {KpiRow} from '@/features/dashboard/KpiRow';
|
||||||
|
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
||||||
|
import {RewardUsageChart} from '@/features/dashboard/RewardUsageChart';
|
||||||
|
import {StoreComparisonPanel} from '@/features/dashboard/StoreComparison';
|
||||||
|
import {PerformancePanel} from '@/features/dashboard/PerformancePanel';
|
||||||
|
import {useResource} from '@/lib/api/useResource';
|
||||||
|
import {endpoints} from '@/lib/api/client';
|
||||||
|
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
||||||
|
import {storeName} from '@/lib/mock/stores';
|
||||||
|
import type {Granularity} from '@/lib/api/contracts';
|
||||||
|
import {
|
||||||
|
formatCompact,
|
||||||
|
formatDayLabel,
|
||||||
|
formatInrCompact,
|
||||||
|
formatPct,
|
||||||
|
} from '@/lib/format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dashboard answers three questions in order, top to bottom:
|
||||||
|
* 1. How is the business performing? → KPI row
|
||||||
|
* 2. What is the shape of that? → footfall / revenue / conversion
|
||||||
|
* 3. What needs attention, and when? → peak hours, rewards, stores, feed
|
||||||
|
*
|
||||||
|
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider,
|
||||||
|
* so changing either control in the top nav re-queries the whole page without
|
||||||
|
* a route change — which is what keeps the Copilot mounted alongside it.
|
||||||
|
*/
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const {storeId, range} = useWorkspace();
|
||||||
|
const [granularity, setGranularity] = useState<Granularity>('weekly');
|
||||||
|
|
||||||
|
const scope = {range, storeId};
|
||||||
|
|
||||||
|
const kpis = useResource(endpoints.dashboardKpis(scope));
|
||||||
|
const series = useResource(endpoints.dashboardTimeseries(scope));
|
||||||
|
const peak = useResource(endpoints.dashboardPeakHours(scope));
|
||||||
|
const activity = useResource(endpoints.dashboardActivity(scope));
|
||||||
|
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
|
||||||
|
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
|
||||||
|
|
||||||
|
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<PageHeader
|
||||||
|
title="Dashboard"
|
||||||
|
description={`How the business is performing across ${scopeLabel}, what needs attention, and what to do next.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KpiRow resource={kpis} />
|
||||||
|
|
||||||
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||||
|
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
|
||||||
|
{(d) => (
|
||||||
|
<AreaChartView
|
||||||
|
data={d}
|
||||||
|
xKey="t"
|
||||||
|
xFormat={formatDayLabel}
|
||||||
|
yFormat={formatCompact}
|
||||||
|
series={[{key: 'visitors', label: 'Visitors'}]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
|
||||||
|
{(d) => (
|
||||||
|
<BarChartView
|
||||||
|
data={d}
|
||||||
|
xKey="t"
|
||||||
|
xFormat={formatDayLabel}
|
||||||
|
yFormat={formatInrCompact}
|
||||||
|
series={[{key: 'revenue', label: 'Revenue'}]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Visitors vs purchases"
|
||||||
|
subtitle="The gap is the conversion opportunity"
|
||||||
|
resource={series}
|
||||||
|
>
|
||||||
|
{(d) => (
|
||||||
|
<LineChartView
|
||||||
|
data={d}
|
||||||
|
xKey="t"
|
||||||
|
xFormat={formatDayLabel}
|
||||||
|
yFormat={formatCompact}
|
||||||
|
series={[
|
||||||
|
{key: 'visitors', label: 'Visitors'},
|
||||||
|
{key: 'purchases', label: 'Purchases'},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Conversion"
|
||||||
|
subtitle="Share of visitors who bought"
|
||||||
|
resource={series}
|
||||||
|
>
|
||||||
|
{(d) => (
|
||||||
|
<LineChartView
|
||||||
|
data={d}
|
||||||
|
xKey="t"
|
||||||
|
xFormat={formatDayLabel}
|
||||||
|
yFormat={(v) => formatPct(v, 0)}
|
||||||
|
series={[{key: 'conversion', label: 'Conversion'}]}
|
||||||
|
// The only semantic colour on this chart: above the benchmark is
|
||||||
|
// good, below is not, and that is what the tint communicates.
|
||||||
|
reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||||
|
<ChartCard
|
||||||
|
title="Peak hours"
|
||||||
|
subtitle="Where the week's footfall actually lands"
|
||||||
|
resource={peak}
|
||||||
|
height={200}
|
||||||
|
>
|
||||||
|
{(d) => <HeatmapGrid data={d} />}
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<RewardUsageChart resource={rewards} />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<PerformancePanel
|
||||||
|
scope={scope}
|
||||||
|
granularity={granularity}
|
||||||
|
onGranularityChange={setGranularity}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Comparing stores is meaningless when scoped to one of them. */}
|
||||||
|
{storeId === 'all' ? (
|
||||||
|
<StoreComparisonPanel resource={comparison} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ActivityTimeline resource={activity} />
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/app/(workspace)/layout.tsx
Normal file
81
src/app/(workspace)/layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {AppShell} from '@astryxdesign/core/AppShell';
|
||||||
|
import {Layout, LayoutContent, LayoutPanel} from '@astryxdesign/core/Layout';
|
||||||
|
import {AppSideNav} from '@/components/shell/AppSideNav';
|
||||||
|
import {AppTopNav} from '@/components/shell/AppTopNav';
|
||||||
|
import {MobileScopeNav} from '@/components/shell/MobileScopeNav';
|
||||||
|
import {CopilotPanel} from '@/features/copilot/CopilotPanel';
|
||||||
|
import {CopilotSlideOver} from '@/features/copilot/CopilotSlideOver';
|
||||||
|
import {useCopilot} from '@/features/copilot/CopilotProvider';
|
||||||
|
import {useBreakpoint, isPanelInline, copilotWidth} from '@/lib/breakpoints';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three-column shell, and the reason the Copilot survives navigation.
|
||||||
|
*
|
||||||
|
* Next's App Router preserves a layout's element identity across sibling route
|
||||||
|
* changes, so /dashboard → /staff swaps only {children}. Everything else here —
|
||||||
|
* nav, scroll position, the Copilot — is untouched.
|
||||||
|
*
|
||||||
|
* Structure, and why AppShell alone is not enough: AppShell renders
|
||||||
|
* Layout{start, content} internally and exposes no end/panel slot. The Copilot
|
||||||
|
* column therefore comes from a NESTED Layout inside AppShell's children,
|
||||||
|
* using its `end` slot.
|
||||||
|
*
|
||||||
|
* AppShell(topNav, sideNav)
|
||||||
|
* └─ Layout(content = page, end = LayoutPanel > CopilotPanel)
|
||||||
|
*
|
||||||
|
* variant="elevated" is what produces the described visual for free: with both
|
||||||
|
* navs present the shell root and rails paint --color-background-body (#000),
|
||||||
|
* and the content area sits on an elevated --color-background-surface (#0F0F10)
|
||||||
|
* backdrop with --radius-page on the start corner.
|
||||||
|
*/
|
||||||
|
export default function WorkspaceLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const bp = useBreakpoint();
|
||||||
|
const {isOpen} = useCopilot();
|
||||||
|
const inline = isPanelInline(bp);
|
||||||
|
const showInlinePanel = inline && isOpen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
variant="elevated"
|
||||||
|
height="fill"
|
||||||
|
// Layout/LayoutContent own the padding so the Copilot panel can sit
|
||||||
|
// flush against the workspace edge.
|
||||||
|
contentPadding={0}
|
||||||
|
topNav={<AppTopNav />}
|
||||||
|
sideNav={<AppSideNav />}
|
||||||
|
// Supplied explicitly rather than auto-generated: the default drawer
|
||||||
|
// mirrors sideNav only, which would leave the store and range controls
|
||||||
|
// unreachable below 768px (they live in TopNav's collapsing start slot).
|
||||||
|
mobileNav={{breakpoint: 'md', content: <MobileScopeNav />}}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
height="fill"
|
||||||
|
content={<LayoutContent padding={5}>{children}</LayoutContent>}
|
||||||
|
end={
|
||||||
|
showInlinePanel ? (
|
||||||
|
<LayoutPanel
|
||||||
|
hasDivider
|
||||||
|
width={copilotWidth(bp)}
|
||||||
|
// CopilotPanel supplies its own header and tab chrome.
|
||||||
|
padding={0}
|
||||||
|
role="complementary"
|
||||||
|
label="AI Copilot"
|
||||||
|
>
|
||||||
|
<CopilotPanel />
|
||||||
|
</LayoutPanel>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Mounted only below the inline breakpoint. Because every piece of
|
||||||
|
Copilot state lives in CopilotProvider (above the route tree), moving
|
||||||
|
between these two presentations is lossless. */}
|
||||||
|
{!inline ? <CopilotSlideOver /> : null}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
src/app/(workspace)/lyts/page.tsx
Normal file
213
src/app/(workspace)/lyts/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
|
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||||
|
import {MetricCard} from '@/components/patterns/MetricCard';
|
||||||
|
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
||||||
|
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||||
|
import {ChartCard} from '@/components/charts/ChartCard';
|
||||||
|
import {AreaChartView} from '@/components/charts/AreaChartView';
|
||||||
|
import {LineChartView} from '@/components/charts/LineChartView';
|
||||||
|
import {BarChartView} from '@/components/charts/BarChartView';
|
||||||
|
import {RewardGrid} from '@/features/lyts/RewardGrid';
|
||||||
|
import {ExpiryAlerts} from '@/features/lyts/ExpiryAlerts';
|
||||||
|
import {RewardPerformanceTable} from '@/features/lyts/RewardPerformanceTable';
|
||||||
|
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
||||||
|
import {useMetricColumns} from '@/features/dashboard/KpiRow';
|
||||||
|
import {useResource} from '@/lib/api/useResource';
|
||||||
|
import {endpoints} from '@/lib/api/client';
|
||||||
|
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
||||||
|
import {ICONS} from '@/lib/icons';
|
||||||
|
import {usageRate} from '@/features/lyts/reward-status';
|
||||||
|
import {
|
||||||
|
formatCompact,
|
||||||
|
formatDayLabel,
|
||||||
|
formatLyt,
|
||||||
|
formatPct,
|
||||||
|
} from '@/lib/format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LYT programme.
|
||||||
|
*
|
||||||
|
* Ordered by urgency rather than by data type: what's expiring, then the
|
||||||
|
* headline numbers, then the catalogue, then the analysis. A merchant opening
|
||||||
|
* this page is usually here because something needs extending or pausing.
|
||||||
|
*
|
||||||
|
* 1 LYT = ₹1, so "outstanding" figures are simultaneously a count and a
|
||||||
|
* rupee liability — which is why they lead.
|
||||||
|
*/
|
||||||
|
export default function LytsPage() {
|
||||||
|
const {storeId, range} = useWorkspace();
|
||||||
|
const scope = {range, storeId};
|
||||||
|
|
||||||
|
const rewards = useResource(endpoints.lytsRewards(scope));
|
||||||
|
const redemptions = useResource(endpoints.lytsRedemptions(scope));
|
||||||
|
const activity = useResource(endpoints.lytsActivity(scope));
|
||||||
|
|
||||||
|
const columns = useMetricColumns();
|
||||||
|
|
||||||
|
// "Now" comes from the SERVER's clock, carried on every response.
|
||||||
|
//
|
||||||
|
// Date.now() here would be impure during render, would disagree between the
|
||||||
|
// SSR and hydration passes, and would report the wrong countdown to anyone
|
||||||
|
// whose device clock is off — on a panel whose entire job is telling a
|
||||||
|
// merchant an offer expires in two days. Falls back to 0 only before the
|
||||||
|
// first response lands, when no expiry panel is rendered anyway.
|
||||||
|
const nowMs = rewards.meta
|
||||||
|
? new Date(rewards.meta.generatedAt).getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<PageHeader
|
||||||
|
title="Lyts"
|
||||||
|
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExpiryAlerts resource={rewards} nowMs={nowMs} />
|
||||||
|
|
||||||
|
<AsyncBoundary
|
||||||
|
resource={rewards}
|
||||||
|
loading={<SkeletonMetricGrid columns={columns} />}
|
||||||
|
>
|
||||||
|
{(rows) => {
|
||||||
|
const active = rows.filter(
|
||||||
|
(r) => r.status === 'active' || r.status === 'expiring',
|
||||||
|
);
|
||||||
|
const claimed = rows.reduce((a, r) => a + r.claimed, 0);
|
||||||
|
const used = rows.reduce((a, r) => a + r.used, 0);
|
||||||
|
const outstanding = rows.reduce(
|
||||||
|
(a, r) => a + (r.claimed - r.used) * r.costLyt,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const mostClaimed = rows.reduce((a, b) =>
|
||||||
|
b.claimed > a.claimed ? b : a,
|
||||||
|
);
|
||||||
|
const leastUsed = rows.reduce((a, b) =>
|
||||||
|
usageRate(b.claimed, b.used) < usageRate(a.claimed, a.used) ? b : a,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid columns={columns} gap={4}>
|
||||||
|
<MetricCard
|
||||||
|
label="Active rewards"
|
||||||
|
value={active.length}
|
||||||
|
format={(v) => String(Math.round(v))}
|
||||||
|
icon={ICONS.activeRewards}
|
||||||
|
footer={
|
||||||
|
<Caption text={`${rows.length} in catalogue`} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Redemption rate"
|
||||||
|
value={usageRate(claimed, used)}
|
||||||
|
format={(v) => formatPct(v, 0)}
|
||||||
|
icon={ICONS.lyts}
|
||||||
|
deltaPct={usageRate(claimed, used) - 50}
|
||||||
|
footer={
|
||||||
|
<Caption
|
||||||
|
text={`${formatCompact(used)} of ${formatCompact(claimed)} claims`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Most claimed"
|
||||||
|
value={mostClaimed.claimed}
|
||||||
|
format={formatCompact}
|
||||||
|
icon={ICONS.up}
|
||||||
|
footer={<Caption text={mostClaimed.name} />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Outstanding liability"
|
||||||
|
value={outstanding}
|
||||||
|
format={formatLyt}
|
||||||
|
icon={ICONS.alert}
|
||||||
|
footer={
|
||||||
|
<Caption
|
||||||
|
text={`Least used: ${leastUsed.name} (${formatPct(usageRate(leastUsed.claimed, leastUsed.used), 0)})`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</AsyncBoundary>
|
||||||
|
|
||||||
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||||
|
<ChartCard
|
||||||
|
title="Redemption"
|
||||||
|
subtitle="LYTs redeemed per day"
|
||||||
|
resource={redemptions}
|
||||||
|
>
|
||||||
|
{(d) => (
|
||||||
|
<AreaChartView
|
||||||
|
data={d}
|
||||||
|
xKey="t"
|
||||||
|
xFormat={formatDayLabel}
|
||||||
|
yFormat={formatCompact}
|
||||||
|
series={[{key: 'purchases', label: 'Redeemed'}]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Issued vs redeemed"
|
||||||
|
subtitle="The gap is LYTs still in customers' hands"
|
||||||
|
resource={redemptions}
|
||||||
|
>
|
||||||
|
{(d) => (
|
||||||
|
<LineChartView
|
||||||
|
data={d}
|
||||||
|
xKey="t"
|
||||||
|
xFormat={formatDayLabel}
|
||||||
|
yFormat={formatCompact}
|
||||||
|
series={[
|
||||||
|
{key: 'visitors', label: 'Issued'},
|
||||||
|
{key: 'purchases', label: 'Redeemed'},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Top rewards"
|
||||||
|
subtitle="Claimed vs redeemed, by reward"
|
||||||
|
resource={rewards}
|
||||||
|
>
|
||||||
|
{(rows) => (
|
||||||
|
<BarChartView
|
||||||
|
data={[...rows].sort((a, b) => b.claimed - a.claimed).slice(0, 6)}
|
||||||
|
xKey="name"
|
||||||
|
yFormat={formatCompact}
|
||||||
|
series={[
|
||||||
|
{key: 'claimed', label: 'Claimed'},
|
||||||
|
{key: 'used', label: 'Redeemed'},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<RewardGrid resource={rewards} nowMs={nowMs} />
|
||||||
|
|
||||||
|
<RewardPerformanceTable resource={rewards} />
|
||||||
|
|
||||||
|
<ActivityTimeline resource={activity} />
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stands in for the sparkline on metrics with no meaningful time series —
|
||||||
|
* "most claimed" is a name, not a trend. MetricCard's `footer` slot exists
|
||||||
|
* for exactly this so the card keeps its height and the row stays aligned.
|
||||||
|
*/
|
||||||
|
function Caption({text}: {text: string}) {
|
||||||
|
return (
|
||||||
|
<Text size="xsm" color="secondary">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/(workspace)/settings/billing/page.tsx
Normal file
60
src/app/(workspace)/settings/billing/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {Badge} from '@astryxdesign/core/Badge';
|
||||||
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
|
import {Divider} from '@astryxdesign/core/Divider';
|
||||||
|
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||||
|
import {StaticPanel} from '@/components/patterns/PanelCard';
|
||||||
|
import {StatPair, StatRow} from '@/components/patterns/StatPair';
|
||||||
|
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||||
|
import {readProfile} from '@/lib/mock/settings';
|
||||||
|
import {formatInr} from '@/lib/format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billing is read-only for now.
|
||||||
|
*
|
||||||
|
* The plan and settlement figures are real shapes, but there is no payment
|
||||||
|
* provider wired up — so rather than render fake "Change plan" flows that do
|
||||||
|
* nothing, the actions are absent and the invoice list says plainly that it
|
||||||
|
* is not connected yet. A disabled button that never becomes enabled is worse
|
||||||
|
* than no button.
|
||||||
|
*/
|
||||||
|
export default function SettingsBillingPage() {
|
||||||
|
const profile = readProfile();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<PageHeader
|
||||||
|
title="Billing"
|
||||||
|
description="Plan, LYT settlement and invoices."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StaticPanel
|
||||||
|
title="Plan"
|
||||||
|
subtitle={profile.businessName}
|
||||||
|
actions={<Badge variant="success" label="Active" />}
|
||||||
|
>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<StatRow>
|
||||||
|
<StatPair label="Plan" value="Growth — 5 stores" />
|
||||||
|
<StatPair label="Billed" value="Monthly" />
|
||||||
|
<StatPair label="Next charge" value="1 Sep 2026" />
|
||||||
|
<StatPair label="Amount" value={formatInr(14999)} align="end" />
|
||||||
|
</StatRow>
|
||||||
|
<Divider />
|
||||||
|
<Text size="sm" color="secondary">
|
||||||
|
LYTs are settled monthly against redemptions, at 1 LYT = ₹1. Your
|
||||||
|
outstanding liability is shown on the Lyts page.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</StaticPanel>
|
||||||
|
|
||||||
|
<StaticPanel title="Invoices" subtitle="Past statements">
|
||||||
|
<EmptyPanel
|
||||||
|
icon="revenue"
|
||||||
|
title="No billing provider connected"
|
||||||
|
description="Invoices appear here once payments are wired up."
|
||||||
|
/>
|
||||||
|
</StaticPanel>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/(workspace)/settings/layout.tsx
Normal file
93
src/app/(workspace)/settings/layout.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {usePathname} from 'next/navigation';
|
||||||
|
import {
|
||||||
|
Layout,
|
||||||
|
LayoutContent,
|
||||||
|
LayoutPanel,
|
||||||
|
VStack,
|
||||||
|
} from '@astryxdesign/core/Layout';
|
||||||
|
import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
|
||||||
|
import {TabList, Tab} from '@astryxdesign/core/TabList';
|
||||||
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
|
import {useRouter} from 'next/navigation';
|
||||||
|
import {useBreakpoint} from '@/lib/breakpoints';
|
||||||
|
import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/settings-nav';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings gets its own sub-navigation.
|
||||||
|
*
|
||||||
|
* A nested Layout with a `start` panel rather than more entries in the primary
|
||||||
|
* sidebar: settings sections sit at a different level of the hierarchy, and
|
||||||
|
* promoting them would push Dashboard/Store/Lyts/Staff down among
|
||||||
|
* configuration screens they have nothing to do with.
|
||||||
|
*
|
||||||
|
* SideNav is reused (rather than a bespoke list) so a settings section looks
|
||||||
|
* and behaves exactly like a primary nav item — same selected treatment, same
|
||||||
|
* hover, same keyboard handling, no second definition to keep in sync.
|
||||||
|
*
|
||||||
|
* Below the laptop breakpoint a 220px panel would eat most of the width, so
|
||||||
|
* the sub-nav becomes a horizontal TabList above the content instead.
|
||||||
|
*/
|
||||||
|
export default function SettingsLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const bp = useBreakpoint();
|
||||||
|
const isNarrow = bp === 'mobile' || bp === 'tablet';
|
||||||
|
|
||||||
|
if (isNarrow) {
|
||||||
|
const active =
|
||||||
|
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href))?.href ??
|
||||||
|
SETTINGS_NAV[0].href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<TabList
|
||||||
|
value={active}
|
||||||
|
onChange={(href) => router.push(href)}
|
||||||
|
layout="fill"
|
||||||
|
size="sm"
|
||||||
|
hasDivider
|
||||||
|
>
|
||||||
|
{SETTINGS_NAV.map((s) => (
|
||||||
|
<Tab
|
||||||
|
key={s.href}
|
||||||
|
value={s.href}
|
||||||
|
label={s.label}
|
||||||
|
icon={<Icon icon={s.icon} size="sm" />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TabList>
|
||||||
|
{children}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
height="fill"
|
||||||
|
start={
|
||||||
|
<LayoutPanel width={220} padding={0} role="navigation" label="Settings">
|
||||||
|
<SideNav>
|
||||||
|
<SideNavSection title="Settings" isHeaderHidden>
|
||||||
|
{SETTINGS_NAV.map((s) => (
|
||||||
|
<SideNavItem
|
||||||
|
key={s.href}
|
||||||
|
label={s.label}
|
||||||
|
icon={s.icon}
|
||||||
|
href={s.href}
|
||||||
|
isSelected={isSettingsActive(pathname, s.href)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SideNavSection>
|
||||||
|
</SideNav>
|
||||||
|
</LayoutPanel>
|
||||||
|
}
|
||||||
|
content={<LayoutContent padding={0}>{children}</LayoutContent>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/(workspace)/settings/notifications/page.tsx
Normal file
19
src/app/(workspace)/settings/notifications/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||||
|
import {NotificationsForm} from '@/features/settings/NotificationsForm';
|
||||||
|
import {readProfile} from '@/lib/mock/settings';
|
||||||
|
|
||||||
|
/** Server Component — same direct-read hybrid as the profile page. */
|
||||||
|
export default function SettingsNotificationsPage() {
|
||||||
|
const {notifications} = readProfile();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<PageHeader
|
||||||
|
title="Notifications"
|
||||||
|
description="Choose which events are worth interrupting you for."
|
||||||
|
/>
|
||||||
|
<NotificationsForm initialData={notifications} />
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/app/(workspace)/settings/page.tsx
Normal file
30
src/app/(workspace)/settings/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||||
|
import {ProfileForm} from '@/features/settings/ProfileForm';
|
||||||
|
import {readProfile} from '@/lib/mock/settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A SERVER Component — the one place in the app that fetches on the server.
|
||||||
|
*
|
||||||
|
* Every other page fetches on the client because its scope (store, range)
|
||||||
|
* changes from controls that live above it in the tree, and a route
|
||||||
|
* navigation would remount the Copilot. Settings has none of that: there is
|
||||||
|
* exactly one server-known record at page load, so reading it directly here
|
||||||
|
* (no HTTP hop, no loading skeleton, no empty-input flash) is strictly better.
|
||||||
|
*
|
||||||
|
* The same readProfile() backs /api/settings/profile, so the two transports
|
||||||
|
* cannot drift.
|
||||||
|
*/
|
||||||
|
export default function SettingsProfilePage() {
|
||||||
|
const profile = readProfile();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<PageHeader
|
||||||
|
title="Settings"
|
||||||
|
description="Business details, contact information and LYT earn rate."
|
||||||
|
/>
|
||||||
|
<ProfileForm initialData={profile} />
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/app/(workspace)/staff/page.tsx
Normal file
83
src/app/(workspace)/staff/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
|
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||||
|
import {ChartCard} from '@/components/charts/ChartCard';
|
||||||
|
import {BarChartView} from '@/components/charts/BarChartView';
|
||||||
|
import {StaffKpis} from '@/features/staff/StaffKpis';
|
||||||
|
import {StaffGrid} from '@/features/staff/StaffGrid';
|
||||||
|
import {AttendanceChart} from '@/features/staff/AttendanceChart';
|
||||||
|
import {Leaderboard} from '@/features/staff/Leaderboard';
|
||||||
|
import {useResource} from '@/lib/api/useResource';
|
||||||
|
import {endpoints} from '@/lib/api/client';
|
||||||
|
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
||||||
|
import {storeName} from '@/lib/mock/stores';
|
||||||
|
import {formatCompact} from '@/lib/format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The team.
|
||||||
|
*
|
||||||
|
* Ordered attendance-first: the question that brings a merchant here in the
|
||||||
|
* morning is "who is in today", and only then "who is performing".
|
||||||
|
*/
|
||||||
|
export default function StaffPage() {
|
||||||
|
const {storeId, range} = useWorkspace();
|
||||||
|
const scope = {range, storeId};
|
||||||
|
|
||||||
|
const summary = useResource(endpoints.staffSummary(scope), {
|
||||||
|
// A summary object is never "empty" the way a list is — zero present is
|
||||||
|
// a real answer, not an absence of data.
|
||||||
|
isEmpty: () => false,
|
||||||
|
});
|
||||||
|
const staff = useResource(endpoints.staff(scope));
|
||||||
|
const attendance = useResource(endpoints.staffAttendance(scope));
|
||||||
|
|
||||||
|
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<PageHeader
|
||||||
|
title="Staff"
|
||||||
|
description={`Attendance, sales contribution and performance across ${scopeLabel}.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StaffKpis resource={summary} />
|
||||||
|
|
||||||
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||||
|
<AttendanceChart resource={attendance} />
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Sales by team member"
|
||||||
|
subtitle="Top 8 by transactions in the selected period"
|
||||||
|
resource={staff}
|
||||||
|
>
|
||||||
|
{(rows) => (
|
||||||
|
<BarChartView
|
||||||
|
data={[...rows]
|
||||||
|
.sort((a, b) => b.salesCount - a.salesCount)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((m) => ({
|
||||||
|
// First name only — full names overflow the axis at this
|
||||||
|
// width and the leaderboard below carries the detail.
|
||||||
|
name: m.name.split(' ')[0],
|
||||||
|
sales: m.salesCount,
|
||||||
|
rewards: m.rewardsIssued,
|
||||||
|
}))}
|
||||||
|
xKey="name"
|
||||||
|
yFormat={formatCompact}
|
||||||
|
series={[
|
||||||
|
{key: 'sales', label: 'Sales'},
|
||||||
|
{key: 'rewards', label: 'Rewards issued'},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Leaderboard resource={staff} />
|
||||||
|
|
||||||
|
<StaffGrid resource={staff} />
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
src/app/(workspace)/stores/[storeId]/page.tsx
Normal file
189
src/app/(workspace)/stores/[storeId]/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {use} from 'react';
|
||||||
|
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
|
import {Badge} from '@astryxdesign/core/Badge';
|
||||||
|
import {Button} from '@astryxdesign/core/Button';
|
||||||
|
import {
|
||||||
|
BreadcrumbItem,
|
||||||
|
Breadcrumbs,
|
||||||
|
} from '@astryxdesign/core/Breadcrumbs';
|
||||||
|
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||||
|
import {MetricCard} from '@/components/patterns/MetricCard';
|
||||||
|
import {ChartCard} from '@/components/charts/ChartCard';
|
||||||
|
import {AreaChartView} from '@/components/charts/AreaChartView';
|
||||||
|
import {BarChartView} from '@/components/charts/BarChartView';
|
||||||
|
import {LineChartView} from '@/components/charts/LineChartView';
|
||||||
|
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
|
||||||
|
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||||
|
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
||||||
|
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
||||||
|
import {useMetricColumns} from '@/features/dashboard/KpiRow';
|
||||||
|
import {useResource} from '@/lib/api/useResource';
|
||||||
|
import {endpoints} from '@/lib/api/client';
|
||||||
|
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
||||||
|
import {ICONS} from '@/lib/icons';
|
||||||
|
import {
|
||||||
|
formatCompact,
|
||||||
|
formatDayLabel,
|
||||||
|
formatInrCompact,
|
||||||
|
formatPct,
|
||||||
|
} from '@/lib/format';
|
||||||
|
import type {StoreStatus} from '@/lib/api/contracts';
|
||||||
|
|
||||||
|
const STATUS: Record<
|
||||||
|
StoreStatus,
|
||||||
|
{label: string; variant: 'success' | 'warning' | 'error'}
|
||||||
|
> = {
|
||||||
|
open: {label: 'Open', variant: 'success'},
|
||||||
|
maintenance: {label: 'Maintenance', variant: 'warning'},
|
||||||
|
closed: {label: 'Closed', variant: 'error'},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One store's dashboard.
|
||||||
|
*
|
||||||
|
* Deliberately the SAME chart components as the main dashboard, scoped by the
|
||||||
|
* route's storeId rather than the top nav's. Building store-specific chart
|
||||||
|
* variants would double the surface area and guarantee the two drift apart —
|
||||||
|
* the only thing that differs here is which store the query asks about.
|
||||||
|
*/
|
||||||
|
export default function StoreDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{storeId: string}>;
|
||||||
|
}) {
|
||||||
|
const {storeId} = use(params);
|
||||||
|
const {range} = useWorkspace();
|
||||||
|
const scope = {range, storeId};
|
||||||
|
|
||||||
|
const store = useResource(endpoints.store(scope, storeId), {
|
||||||
|
isEmpty: () => false,
|
||||||
|
});
|
||||||
|
const kpis = useResource(endpoints.dashboardKpis(scope));
|
||||||
|
const series = useResource(endpoints.dashboardTimeseries(scope));
|
||||||
|
const peak = useResource(endpoints.dashboardPeakHours(scope));
|
||||||
|
const activity = useResource(endpoints.dashboardActivity(scope));
|
||||||
|
|
||||||
|
const columns = useMetricColumns();
|
||||||
|
const name = store.data?.name ?? 'Store';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<Breadcrumbs variant="supporting">
|
||||||
|
<BreadcrumbItem href="/stores">Store</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem isCurrent>{name}</BreadcrumbItem>
|
||||||
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={name}
|
||||||
|
description={
|
||||||
|
store.data
|
||||||
|
? `${store.data.staffCount} staff · ${formatPct(store.data.conversionPct)} conversion`
|
||||||
|
: 'Loading store…'
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
{store.data ? (
|
||||||
|
<Badge
|
||||||
|
variant={STATUS[store.data.status].variant}
|
||||||
|
label={STATUS[store.data.status].label}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
label="All stores"
|
||||||
|
href="/stores"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AsyncBoundary
|
||||||
|
resource={kpis}
|
||||||
|
loading={<SkeletonMetricGrid columns={columns} />}
|
||||||
|
>
|
||||||
|
{(rows) => (
|
||||||
|
<Grid columns={columns} gap={4}>
|
||||||
|
{rows.map((k) => (
|
||||||
|
<MetricCard
|
||||||
|
key={k.id}
|
||||||
|
label={k.label}
|
||||||
|
value={k.value}
|
||||||
|
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
|
||||||
|
icon={
|
||||||
|
k.id === 'visitors'
|
||||||
|
? ICONS.visitors
|
||||||
|
: k.id === 'purchases'
|
||||||
|
? ICONS.purchases
|
||||||
|
: k.id === 'revenue'
|
||||||
|
? ICONS.revenue
|
||||||
|
: ICONS.activeRewards
|
||||||
|
}
|
||||||
|
deltaPct={k.deltaPct}
|
||||||
|
isRiseGood={k.isRiseGood}
|
||||||
|
trend={k.trend}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</AsyncBoundary>
|
||||||
|
|
||||||
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||||
|
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
|
||||||
|
{(d) => (
|
||||||
|
<AreaChartView
|
||||||
|
data={d}
|
||||||
|
xKey="t"
|
||||||
|
xFormat={formatDayLabel}
|
||||||
|
yFormat={formatCompact}
|
||||||
|
series={[{key: 'visitors', label: 'Visitors'}]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
|
||||||
|
{(d) => (
|
||||||
|
<BarChartView
|
||||||
|
data={d}
|
||||||
|
xKey="t"
|
||||||
|
xFormat={formatDayLabel}
|
||||||
|
yFormat={formatInrCompact}
|
||||||
|
series={[{key: 'revenue', label: 'Revenue'}]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Conversion"
|
||||||
|
subtitle="Share of visitors who bought"
|
||||||
|
resource={series}
|
||||||
|
>
|
||||||
|
{(d) => (
|
||||||
|
<LineChartView
|
||||||
|
data={d}
|
||||||
|
xKey="t"
|
||||||
|
xFormat={formatDayLabel}
|
||||||
|
yFormat={(v) => formatPct(v, 0)}
|
||||||
|
series={[{key: 'conversion', label: 'Conversion'}]}
|
||||||
|
reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Peak hours"
|
||||||
|
subtitle="Where this store's week actually lands"
|
||||||
|
resource={peak}
|
||||||
|
height={200}
|
||||||
|
>
|
||||||
|
{(d) => <HeatmapGrid data={d} />}
|
||||||
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ActivityTimeline resource={activity} />
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/app/(workspace)/stores/page.tsx
Normal file
34
src/app/(workspace)/stores/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {PageHeader} from '@/components/primitives/PageHeader';
|
||||||
|
import {StoreGrid} from '@/features/stores/StoreGrid';
|
||||||
|
import {useResource} from '@/lib/api/useResource';
|
||||||
|
import {endpoints} from '@/lib/api/client';
|
||||||
|
import {
|
||||||
|
RANGE_LABELS,
|
||||||
|
useWorkspace,
|
||||||
|
} from '@/components/shell/WorkspaceProvider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store roster.
|
||||||
|
*
|
||||||
|
* This page deliberately ignores the top nav's STORE scope — a list of every
|
||||||
|
* store is the whole point, and narrowing it to the one already selected would
|
||||||
|
* leave a one-card page. The RANGE scope still applies, because the totals on
|
||||||
|
* each card have to be measured over some period.
|
||||||
|
*/
|
||||||
|
export default function StoresPage() {
|
||||||
|
const {range} = useWorkspace();
|
||||||
|
const stores = useResource(endpoints.stores({range, storeId: 'all'}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap={5}>
|
||||||
|
<PageHeader
|
||||||
|
title="Store"
|
||||||
|
description={`Performance and status for every store in the network, over ${RANGE_LABELS[range].toLowerCase()}.`}
|
||||||
|
/>
|
||||||
|
<StoreGrid resource={stores} />
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/api/dashboard/activity/route.ts
Normal file
12
src/app/api/dashboard/activity/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/dashboard/kpis/route.ts
Normal file
12
src/app/api/dashboard/kpis/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/dashboard/peak-hours/route.ts
Normal file
12
src/app/api/dashboard/peak-hours/route.ts
Normal 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);
|
||||||
|
}
|
||||||
20
src/app/api/dashboard/performance/route.ts
Normal file
20
src/app/api/dashboard/performance/route.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/api/dashboard/reward-usage/route.ts
Normal file
12
src/app/api/dashboard/reward-usage/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/dashboard/store-comparison/route.ts
Normal file
12
src/app/api/dashboard/store-comparison/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/dashboard/timeseries/route.ts
Normal file
12
src/app/api/dashboard/timeseries/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/lyts/activity/route.ts
Normal file
12
src/app/api/lyts/activity/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/lyts/redemptions/route.ts
Normal file
12
src/app/api/lyts/redemptions/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/lyts/rewards/route.ts
Normal file
12
src/app/api/lyts/rewards/route.ts
Normal 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);
|
||||||
|
}
|
||||||
28
src/app/api/settings/profile/route.ts
Normal file
28
src/app/api/settings/profile/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/staff/attendance/route.ts
Normal file
12
src/app/api/staff/attendance/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/staff/route.ts
Normal file
12
src/app/api/staff/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/staff/summary/route.ts
Normal file
12
src/app/api/staff/summary/route.ts
Normal 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);
|
||||||
|
}
|
||||||
21
src/app/api/stores/[storeId]/route.ts
Normal file
21
src/app/api/stores/[storeId]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
12
src/app/api/stores/route.ts
Normal file
12
src/app/api/stores/route.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 {
|
@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
@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 {
|
@layer components {
|
||||||
--color-background: var(--background);
|
/* Kill switch for our own CSS transitions. Framer Motion is handled by
|
||||||
--color-foreground: var(--foreground);
|
<MotionConfig reducedMotion="user">, recharts by useChartMotion(). */
|
||||||
--font-sans: var(--font-geist-sans);
|
@media (prefers-reduced-motion: reduce) {
|
||||||
--font-mono: var(--font-geist-mono);
|
*,
|
||||||
}
|
*::before,
|
||||||
|
*::after {
|
||||||
@media (prefers-color-scheme: dark) {
|
animation-duration: 0.01ms !important;
|
||||||
:root {
|
animation-iteration-count: 1 !important;
|
||||||
--background: #0a0a0a;
|
transition-duration: 0.01ms !important;
|
||||||
--foreground: #ededed;
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +1,48 @@
|
|||||||
import type { Metadata } from "next";
|
import type {Metadata, Viewport} from 'next';
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import {Sora, Inter} from 'next/font/google';
|
||||||
import "./globals.css";
|
import './globals.css';
|
||||||
|
import {Providers} from './providers';
|
||||||
|
|
||||||
const geistSans = Geist({
|
const sora = Sora({
|
||||||
variable: "--font-geist-sans",
|
subsets: ['latin'],
|
||||||
subsets: ["latin"],
|
variable: '--font-sora',
|
||||||
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const inter = Inter({
|
||||||
variable: "--font-geist-mono",
|
subsets: ['latin'],
|
||||||
subsets: ["latin"],
|
variable: '--font-inter',
|
||||||
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: {
|
||||||
description: "Generated by create next app",
|
default: 'Loyaly Merchant',
|
||||||
|
template: '%s · Loyaly Merchant',
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'Footfall, campaign and LYT redemption analytics for Loyaly merchants.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export const viewport: Viewport = {
|
||||||
children,
|
colorScheme: 'dark',
|
||||||
}: Readonly<{
|
themeColor: '#000000',
|
||||||
children: React.ReactNode;
|
};
|
||||||
}>) {
|
|
||||||
|
export default function RootLayout({children}: {children: React.ReactNode}) {
|
||||||
return (
|
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
|
<html
|
||||||
lang="en"
|
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>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,5 @@
|
|||||||
import Image from "next/image";
|
import {redirect} from 'next/navigation';
|
||||||
|
|
||||||
export default function Home() {
|
export default function RootPage() {
|
||||||
return (
|
redirect('/dashboard');
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/app/providers.tsx
Normal file
41
src/app/providers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/charts/AreaChartView.tsx
Normal file
91
src/components/charts/AreaChartView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/charts/BarChartView.tsx
Normal file
81
src/components/charts/BarChartView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/charts/ChartCard.tsx
Normal file
48
src/components/charts/ChartCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/charts/ChartDefs.tsx
Normal file
53
src/components/charts/ChartDefs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/charts/ChartFrame.tsx
Normal file
82
src/components/charts/ChartFrame.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/charts/ChartTooltip.tsx
Normal file
74
src/components/charts/ChartTooltip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/charts/HeatmapGrid.tsx
Normal file
83
src/components/charts/HeatmapGrid.tsx
Normal 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; // 0–23
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/charts/LineChartView.tsx
Normal file
67
src/components/charts/LineChartView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/charts/Sparkline.tsx
Normal file
49
src/components/charts/Sparkline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/charts/palette.ts
Normal file
67
src/components/charts/palette.ts
Normal 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';
|
||||||
|
}
|
||||||
37
src/components/charts/types.ts
Normal file
37
src/components/charts/types.ts
Normal 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;
|
||||||
|
}
|
||||||
22
src/components/charts/useChartMotion.ts
Normal file
22
src/components/charts/useChartMotion.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
80
src/components/data/AsyncBoundary.tsx
Normal file
80
src/components/data/AsyncBoundary.tsx
Normal 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="Couldn’t 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="There’s 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/motion/AnimatedNumber.tsx
Normal file
62
src/components/motion/AnimatedNumber.tsx
Normal 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>;
|
||||||
|
}
|
||||||
31
src/components/motion/HoverLift.tsx
Normal file
31
src/components/motion/HoverLift.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/patterns/ActivityItem.tsx
Normal file
78
src/components/patterns/ActivityItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/patterns/CollectionRegion.tsx
Normal file
36
src/components/patterns/CollectionRegion.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/patterns/EmptyPanel.tsx
Normal file
48
src/components/patterns/EmptyPanel.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/components/patterns/FilterBar.tsx
Normal file
97
src/components/patterns/FilterBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/patterns/LoadingState.tsx
Normal file
89
src/components/patterns/LoadingState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/components/patterns/MetricCard.tsx
Normal file
85
src/components/patterns/MetricCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/components/patterns/PanelCard.tsx
Normal file
88
src/components/patterns/PanelCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/patterns/SectionHeader.tsx
Normal file
54
src/components/patterns/SectionHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/patterns/StatPair.tsx
Normal file
41
src/components/patterns/StatPair.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/primitives/MetricDelta.tsx
Normal file
56
src/components/primitives/MetricDelta.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/primitives/PageHeader.tsx
Normal file
31
src/components/primitives/PageHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/shell/AppSideNav.tsx
Normal file
74
src/components/shell/AppSideNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/shell/AppTopNav.tsx
Normal file
66
src/components/shell/AppTopNav.tsx
Normal 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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/shell/GlobalSearch.tsx
Normal file
69
src/components/shell/GlobalSearch.tsx
Normal 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/shell/MobileScopeNav.tsx
Normal file
73
src/components/shell/MobileScopeNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/shell/RangePicker.tsx
Normal file
28
src/components/shell/RangePicker.tsx
Normal 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),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/shell/StoreSwitcher.tsx
Normal file
39
src/components/shell/StoreSwitcher.tsx
Normal 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),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/shell/WorkspaceProvider.tsx
Normal file
73
src/components/shell/WorkspaceProvider.tsx
Normal 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;
|
||||||
|
}
|
||||||
34
src/components/shell/nav-config.ts
Normal file
34
src/components/shell/nav-config.ts
Normal 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}/`);
|
||||||
|
}
|
||||||
78
src/features/copilot/CopilotPanel.tsx
Normal file
78
src/features/copilot/CopilotPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/features/copilot/CopilotProvider.tsx
Normal file
102
src/features/copilot/CopilotProvider.tsx
Normal 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;
|
||||||
|
}
|
||||||
37
src/features/copilot/CopilotSlideOver.tsx
Normal file
37
src/features/copilot/CopilotSlideOver.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/features/copilot/CopilotToggle.tsx
Normal file
30
src/features/copilot/CopilotToggle.tsx
Normal 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))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/features/copilot/tabs/AiTab.tsx
Normal file
77
src/features/copilot/tabs/AiTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/features/copilot/tabs/AnalyticsTab.tsx
Normal file
25
src/features/copilot/tabs/AnalyticsTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/features/copilot/tabs/ChatTab.tsx
Normal file
113
src/features/copilot/tabs/ChatTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/features/dashboard/ActivityTimeline.tsx
Normal file
58
src/features/dashboard/ActivityTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/features/dashboard/KpiRow.tsx
Normal file
68
src/features/dashboard/KpiRow.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Grid} from '@astryxdesign/core/Grid';
|
||||||
|
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||||
|
import {MetricCard} from '@/components/patterns/MetricCard';
|
||||||
|
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
||||||
|
import {ICONS} from '@/lib/icons';
|
||||||
|
import {useBreakpoint, isPanelInline} from '@/lib/breakpoints';
|
||||||
|
import {useCopilot} from '@/features/copilot/CopilotProvider';
|
||||||
|
import {formatCompact, formatInrCompact} from '@/lib/format';
|
||||||
|
import type {Kpi, KpiId} from '@/lib/api/contracts';
|
||||||
|
import type {Resource} from '@/lib/api/useResource';
|
||||||
|
import type {IconType} from '@astryxdesign/core/Icon';
|
||||||
|
|
||||||
|
const KPI_ICON: Record<KpiId, IconType> = {
|
||||||
|
visitors: ICONS.visitors,
|
||||||
|
purchases: ICONS.purchases,
|
||||||
|
revenue: ICONS.revenue,
|
||||||
|
activeRewards: ICONS.activeRewards,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Four KPIs must lay out 4-up, 2×2, or stacked — never 3+1, which reads as a
|
||||||
|
* broken grid. `repeat: 'fit'` cannot express that: at a ~800px workspace it
|
||||||
|
* yields exactly three columns whatever minWidth you choose, and the fourth
|
||||||
|
* card drops to an orphan row.
|
||||||
|
*
|
||||||
|
* So the count is computed rather than inferred. The two things that decide
|
||||||
|
* the available width are the breakpoint and whether the Copilot panel is
|
||||||
|
* taking 320–380px of it, and both are already known here.
|
||||||
|
*/
|
||||||
|
export function useMetricColumns(): number {
|
||||||
|
const bp = useBreakpoint();
|
||||||
|
const {isOpen} = useCopilot();
|
||||||
|
|
||||||
|
if (bp === 'mobile') return 1;
|
||||||
|
if (bp === 'tablet') return 2;
|
||||||
|
if (bp === 'laptop') return 2;
|
||||||
|
return isPanelInline(bp) && isOpen ? 2 : 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {
|
||||||
|
const columns = useMetricColumns();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncBoundary
|
||||||
|
resource={resource}
|
||||||
|
loading={<SkeletonMetricGrid columns={columns} />}
|
||||||
|
>
|
||||||
|
{(kpis) => (
|
||||||
|
<Grid columns={columns} gap={4}>
|
||||||
|
{kpis.map((k) => (
|
||||||
|
<MetricCard
|
||||||
|
key={k.id}
|
||||||
|
label={k.label}
|
||||||
|
value={k.value}
|
||||||
|
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
|
||||||
|
icon={KPI_ICON[k.id]}
|
||||||
|
deltaPct={k.deltaPct}
|
||||||
|
isRiseGood={k.isRiseGood}
|
||||||
|
trend={k.trend}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</AsyncBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/features/dashboard/PerformancePanel.tsx
Normal file
66
src/features/dashboard/PerformancePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/features/dashboard/RewardUsageChart.tsx
Normal file
41
src/features/dashboard/RewardUsageChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/features/dashboard/StoreComparison.tsx
Normal file
78
src/features/dashboard/StoreComparison.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/features/lyts/ExpiryAlerts.tsx
Normal file
85
src/features/lyts/ExpiryAlerts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/features/lyts/RewardCard.tsx
Normal file
90
src/features/lyts/RewardCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/features/lyts/RewardGrid.tsx
Normal file
105
src/features/lyts/RewardGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
src/features/lyts/RewardPerformanceTable.tsx
Normal file
143
src/features/lyts/RewardPerformanceTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/features/lyts/reward-status.ts
Normal file
29
src/features/lyts/reward-status.ts
Normal 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));
|
||||||
|
}
|
||||||
120
src/features/settings/NotificationsForm.tsx
Normal file
120
src/features/settings/NotificationsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/features/settings/ProfileForm.tsx
Normal file
178
src/features/settings/ProfileForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/features/settings/settings-nav.ts
Normal file
42
src/features/settings/settings-nav.ts
Normal 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;
|
||||||
|
}
|
||||||
65
src/features/staff/AttendanceChart.tsx
Normal file
65
src/features/staff/AttendanceChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/features/staff/Leaderboard.tsx
Normal file
178
src/features/staff/Leaderboard.tsx
Normal 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
Reference in New Issue
Block a user