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.
|
||||
<!-- 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
|
||||
|
||||
<!-- 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/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Generated by `npm run theme:build` — edit src/theme/loyalyTheme.ts
|
||||
// instead. The emitted .d.ts uses a triple-slash reference we do not own.
|
||||
"src/theme/loyaly.css",
|
||||
"src/theme/loyaly.js",
|
||||
"src/theme/loyaly.d.ts",
|
||||
"src/theme/loyaly.variants.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1792
package-lock.json
generated
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",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -p 3100",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"start": "next start -p 3100",
|
||||
"lint": "eslint",
|
||||
"theme:build": "astryx theme build src/theme/loyalyTheme.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.12",
|
||||
"@astryxdesign/core": "^0.2.0",
|
||||
"@astryxdesign/theme-neutral": "^0.2.0",
|
||||
"@stylexjs/stylex": "^0.19.0",
|
||||
"framer-motion": "^12.43.0",
|
||||
"lucide-react": "^1.28.0",
|
||||
"next": "^16.3.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astryxdesign/cli": "^0.2.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.12",
|
||||
"eslint-config-next": "^16.3.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
BIN
public/loyalyai-logo.png
Normal file
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 {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;
|
||||
|
||||
@import 'tailwindcss/theme.css' layer(theme);
|
||||
@import 'tailwindcss/preflight.css' layer(base);
|
||||
@import '@astryxdesign/core/reset.css';
|
||||
@import '@astryxdesign/core/astryx.css';
|
||||
@import '../theme/loyaly.css';
|
||||
@import '@astryxdesign/core/tailwind-theme.css';
|
||||
@import 'tailwindcss/utilities.css' layer(utilities);
|
||||
|
||||
/*
|
||||
* Paint the canvas before React mounts. AppShell sets these itself once it
|
||||
* renders, but this guarantees the very first frame — and any non-shell route
|
||||
* such as an error boundary — is already black rather than white.
|
||||
*/
|
||||
html {
|
||||
background-color: var(--color-background-body);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
@layer components {
|
||||
/* Kill switch for our own CSS transitions. Framer Motion is handled by
|
||||
<MotionConfig reducedMotion="user">, recharts by useChartMotion(). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import type {Metadata, Viewport} from 'next';
|
||||
import {Sora, Inter} from 'next/font/google';
|
||||
import './globals.css';
|
||||
import {Providers} from './providers';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
const sora = Sora({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sora',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: 'Loyaly Merchant',
|
||||
template: '%s · Loyaly Merchant',
|
||||
},
|
||||
description:
|
||||
'Footfall, campaign and LYT redemption analytics for Loyaly merchants.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
export const viewport: Viewport = {
|
||||
colorScheme: 'dark',
|
||||
themeColor: '#000000',
|
||||
};
|
||||
|
||||
export default function RootLayout({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
// The theme attributes are server-rendered so the @scope rules in
|
||||
// loyaly.css match on the very first byte — no flash from stock defaults.
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
data-theme="dark"
|
||||
data-astryx-theme="loyaly"
|
||||
className={`${sora.variable} ${inter.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import {redirect} from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default function RootPage() {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
41
src/app/providers.tsx
Normal file
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