update ui update and fix layout issue

This commit is contained in:
2026-08-06 13:21:10 +05:30
parent 92322bff16
commit e5f8144fb3
251 changed files with 9041 additions and 2119 deletions

View File

@@ -0,0 +1,62 @@
import Image from 'next/image';
/**
* The two official Loyaly.ai assets — and the only place in the product where
* brand colour is allowed to appear. Everything around them stays monochrome.
*
* Both render at their own intrinsic ratio: a caller sizes ONE axis and the
* other is derived, so neither mark can be letterboxed or stretched. There is
* deliberately no `fill` / `objectFit` escape hatch — that is how logos end up
* distorted. Recolouring is likewise not a prop: the PNGs carry the mark.
*/
// Intrinsic pixel dimensions of the shipped files, alpha-trimmed so the
// wordmark optically centres (the supplied original carried 44px of dead
// transparent space on its trailing edge).
const LOGO = {src: '/white-logo.png', width: 1489, height: 248};
const MARK = {src: '/brand/loyaly-mark.png', width: 285, height: 256};
interface BrandProps {
/** Alternative text. Pass '' where a sibling element already names the brand. */
alt?: string;
/** Set on marks that paint above the fold, so Next preloads them. */
priority?: boolean;
}
/** Full horizontal Loyaly.ai lockup. Sized by height; width follows. */
export function BrandLogo({
height = 28,
alt = 'Loyaly.ai',
priority = false,
}: BrandProps & {height?: number}) {
return (
<Image
src={LOGO.src}
alt={alt}
height={height}
width={Math.round((height * LOGO.width) / LOGO.height)}
priority={priority}
// Block display keeps the anchor that usually wraps this from painting a
// hover underline in the leftover line box.
className="block"
/>
);
}
/** Heart symbol alone, for spaces too narrow for the lockup. Sized by width. */
export function BrandMark({
size = 28,
alt = 'Loyaly.ai',
priority = false,
}: BrandProps & {size?: number}) {
return (
<Image
src={MARK.src}
alt={alt}
width={size}
height={Math.round((size * MARK.height) / MARK.width)}
priority={priority}
className="block"
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Tooltip} from '@astryxdesign/core/Tooltip';
export interface HeatCell {
day: number; // 0 = Monday
hour: number; // 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Banner} from '@astryxdesign/core/Banner';
import {Button} from '@astryxdesign/core/Button';
import {EmptyState} from '@astryxdesign/core/EmptyState';
import type {Resource} from '@/shared/hooks/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>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {Icon} from '@astryxdesign/core/Icon';
import {Item} from '@astryxdesign/core/Item';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {BrandLogo} from '@/shared/components/brand/BrandLogo';
import {APP_ENVIRONMENT, APP_NAME, APP_VERSION} from '@/shared/utils/appInfo';
/**
* About, opened from the account menu's Help panel.
*
* Everything on it is read from somewhere real — the version from
* package.json, the environment from the build, the identity from the session.
* Nothing is a placeholder, because the one job of an About dialog is to be
* quotable in a support ticket.
*/
export function AboutDialog({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const {user} = useSession();
return (
<Dialog
isOpen={isOpen}
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="info"
width={420}
aria-label={`About ${APP_NAME}`}
>
<VStack gap={4} width="100%">
<DialogHeader
title={`About ${APP_NAME}`}
onOpenChange={(open) => (open ? undefined : onClose())}
/>
<HStack paddingInline={2} paddingBlock={2}>
<BrandLogo height={28} alt="" />
</HStack>
<VStack gap={0.5} width="100%">
<Item
label="Version"
density="balanced"
endContent={<Text type="supporting">{APP_VERSION}</Text>}
/>
<Item
label="Environment"
density="balanced"
endContent={<Text type="supporting">{APP_ENVIRONMENT}</Text>}
/>
<Item
label="Signed in as"
density="balanced"
endContent={
<Text type="supporting">{user?.email ?? 'Not signed in'}</Text>
}
/>
{user ? (
<Item
label="Organisation"
density="balanced"
endContent={<Text type="supporting">{user.organisation}</Text>}
/>
) : null}
</VStack>
<Item
label="loyaly.ai"
density="balanced"
href="https://loyaly.ai"
target="_blank"
endContent={
<Icon icon="externalLink" size="sm" color="secondary" />
}
/>
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {Item} from '@astryxdesign/core/Item';
import {Kbd} from '@astryxdesign/core/Kbd';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {
SHORTCUT_GROUPS,
WORKSPACE_SHORTCUTS,
} from '@/shared/layouts/workspace/shortcuts';
/**
* The shortcuts sheet, opened from the account menu's Help panel (or by the
* shortcut it lists for itself).
*
* It renders WORKSPACE_SHORTCUTS directly. Nothing is typed in twice, so the
* list cannot describe a binding the app does not have — which is the failure
* mode of every hand-written shortcuts dialog.
*/
export function KeyboardShortcutsDialog({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
return (
<Dialog
isOpen={isOpen}
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="info"
width={480}
aria-label="Keyboard shortcuts"
>
<VStack gap={4} width="100%">
<DialogHeader
title="Keyboard shortcuts"
subtitle="Every binding the workspace answers to."
onOpenChange={(open) => (open ? undefined : onClose())}
/>
{SHORTCUT_GROUPS.map((group) => (
<VStack gap={1} key={group} width="100%">
<HStack paddingInline={2}>
<Text type="supporting" size="sm" weight="medium">
{group}
</Text>
</HStack>
{WORKSPACE_SHORTCUTS.filter(
(shortcut) => shortcut.group === group,
).map((shortcut) => (
<Item
key={shortcut.keys}
label={shortcut.label}
density="balanced"
endContent={<Kbd keys={shortcut.keys} />}
/>
))}
</VStack>
))}
</VStack>
</Dialog>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,105 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import {ICONS} from '@/shared/utils/icons';
import type {IconKey} from '@/shared/utils/icons';
/** Icon tones. Only `warning` carries colour — see the note on ActivityFeed. */
export type ActivityTone = 'neutral' | 'warning';
export interface ActivityEntry {
id: string;
title: string;
detail?: string;
at: string;
/** Which kind of event this is — the glyph IS the category label. */
icon: IconKey;
tone: ActivityTone;
/** Announced with the icon; a glyph 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.
*
* The leading glyph is the event's TYPE — purchase, reward, staff, store,
* alert — not its status. A status dot could only encode severity, which meant
* five different kinds of event all rendered as the same grey circle and the
* feed could not be skimmed by category. The icon does that job, and colour is
* left to do the one job it is good at: marking the row that needs action.
*
* Hover is `--color-overlay-hover`, the same white-at-6% the sidebar nav uses.
* It tracks the eye across a dense row; it does not imply the row is clickable,
* because there is no per-event destination to send anyone to.
*/
export function ActivityItem({entry}: {entry: ActivityEntry}) {
return (
<HStack
gap={3}
vAlign="start"
paddingBlock={1}
paddingInline={2}
// Token-backed utilities: --color-overlay-hover is bridged into Tailwind
// by tailwind-theme.css, so this is the theme's hover, not a new value.
className="rounded-md transition-colors hover:bg-overlay-hover"
>
<HStack paddingBlock={0.5}>
<Icon
icon={ICONS[entry.icon]}
size="sm"
color={entry.tone === 'warning' ? 'warning' : 'secondary'}
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>
);
}
/**
* The list wrapper.
*
* Dividers are gone: with a hover background doing the row separation, rules
* between every entry were two separators doing one job, and each one cost
* vertical space on a panel whose whole problem was height.
*
* `limit` is deliberately applied HERE rather than by each caller slicing its
* own array — a feed that quietly grows without bound is what put this panel
* at 784px on the dashboard, and one place to cap it is one place to get right.
*/
export function ActivityFeed({
entries,
limit,
}: {
entries: ActivityEntry[];
limit?: number;
}) {
const shown = limit ? entries.slice(0, limit) : entries;
return (
<VStack gap={0}>
{shown.map((e) => (
<ActivityItem key={e.id} entry={e} />
))}
</VStack>
);
}

View File

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

View File

@@ -0,0 +1,198 @@
'use client';
import {useState, useRef, useEffect} from 'react';
import {createPortal} from 'react-dom';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {useToast} from '@astryxdesign/core/Toast';
import {ICONS} from '@/shared/utils/icons';
import {exportData, ExportFormat, ExportColumn} from '@/shared/utils/export/exportManager';
export interface DownloadDropdownProps {
filename: string;
title: string;
subtitle?: string;
columns: ExportColumn[];
data: Record<string, any>[];
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md';
}
export function DownloadDropdown({
filename,
title,
subtitle,
columns,
data,
variant = 'primary',
size = 'sm',
}: DownloadDropdownProps) {
const toast = useToast();
const [isOpen, setIsOpen] = useState(false);
const [loadingFormat, setLoadingFormat] = useState<ExportFormat | null>(null);
const [coords, setCoords] = useState<{top: number; left: number} | null>(null);
const [mounted, setMounted] = useState(false);
const buttonWrapperRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
// Close dropdown on click outside or window/container scroll
useEffect(() => {
if (!isOpen) return;
const handleScroll = () => {
setIsOpen(false);
};
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (
buttonWrapperRef.current &&
!buttonWrapperRef.current.contains(target) &&
menuRef.current &&
!menuRef.current.contains(target)
) {
setIsOpen(false);
}
};
window.addEventListener('scroll', handleScroll, {capture: true, passive: true});
document.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('scroll', handleScroll, {capture: true});
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleToggle = () => {
if (!isOpen && buttonWrapperRef.current) {
const rect = buttonWrapperRef.current.getBoundingClientRect();
const leftPos = Math.max(12, rect.right - 224);
// Smart vertical flip: if space below < 180px, open UPWARDS above button
const spaceBelow = window.innerHeight - rect.bottom;
const menuHeight = 168; // 3 items padding height
let topPos: number;
if (spaceBelow < menuHeight && rect.top > menuHeight) {
topPos = rect.top - menuHeight - 6;
} else {
topPos = rect.bottom + 6;
}
setCoords({top: topPos, left: leftPos});
}
setIsOpen((prev) => !prev);
};
const handleSelectFormat = async (fmt: ExportFormat) => {
console.log('[DownloadDropdown] Selected format:', fmt);
setIsOpen(false);
setLoadingFormat(fmt);
try {
await exportData({
filename,
title,
subtitle,
columns,
data,
format: fmt,
});
const label = fmt === 'pdf' ? 'PDF' : fmt === 'excel' ? 'Excel' : 'CSV';
console.log(`[DownloadDropdown] Success toast for ${label}`);
toast({
body: `✓ ${label} downloaded successfully`,
});
} catch (err) {
console.error('[DownloadDropdown] Export failed:', err);
toast({
body: '❌ Download failed. Unable to generate file.',
});
} finally {
setLoadingFormat(null);
}
};
const getButtonLabel = () => {
if (loadingFormat) {
const fmtName = loadingFormat === 'pdf' ? 'PDF' : loadingFormat === 'excel' ? 'Excel' : 'CSV';
return `Generating ${fmtName}...`;
}
return 'Download';
};
return (
<div className="inline-block text-left">
<div ref={buttonWrapperRef}>
<Button
size={size}
variant={variant}
label={getButtonLabel()}
icon={<Icon icon={ICONS.download} size="sm" />}
isLoading={loadingFormat !== null}
isDisabled={loadingFormat !== null}
onClick={handleToggle}
/>
</div>
{isOpen && !loadingFormat && mounted && coords
? createPortal(
<div
ref={menuRef}
onMouseDown={(e) => e.stopPropagation()}
style={{
position: 'fixed',
top: `${coords.top}px`,
left: `${coords.left}px`,
zIndex: 99999,
}}
className="w-56 rounded-xl border border-zinc-700 bg-[#18181b] backdrop-blur-xl shadow-2xl z-[99999] py-1.5 divide-y divide-zinc-800 select-none"
>
<button
type="button"
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
onClick={() => handleSelectFormat('pdf')}
>
<span className="text-xl">📄</span>
<div>
<div className="font-semibold text-sm text-white">PDF Document</div>
<div className="text-xs text-zinc-300">.pdf file format</div>
</div>
</button>
<button
type="button"
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
onClick={() => handleSelectFormat('excel')}
>
<span className="text-xl">📊</span>
<div>
<div className="font-semibold text-sm text-white">Excel Spreadsheet</div>
<div className="text-xs text-zinc-300">.xlsx file format</div>
</div>
</button>
<button
type="button"
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
onClick={() => handleSelectFormat('csv')}
>
<span className="text-xl">📑</span>
<div>
<div className="font-semibold text-sm text-white">CSV Data File</div>
<div className="text-xs text-zinc-300">.csv file format</div>
</div>
</button>
</div>,
document.body
)
: null}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
'use client';
import {Table} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
/**
* A table on wide screens; a list of cards on a phone.
*
* A six-column table cannot be made to work at 375px. Horizontal scroll is the
* usual fallback, but it hides the columns that matter most — status and
* actions sit on the right, which is exactly what is off-screen — and it puts
* a scroll gesture inside a vertically scrolling page, which fights the thumb.
*
* So below `tablet` the same column definitions are re-rendered as stacked
* label/value rows. Crucially it is the SAME `columns` array: the cell
* renderers, formatting and badges are reused verbatim, so the two
* presentations cannot drift and adding a column updates both.
*
* `primaryKey` names the column that becomes the card's heading — usually the
* entity's name. `summaryKeys`, when given, limits which of the remaining
* columns appear on the card, because a phone card listing nine fields is just
* a table rotated 90°.
*/
export function ResponsiveTable<T extends Record<string, unknown>>({
data,
columns,
idKey,
primaryKey,
summaryKeys,
density = 'spacious',
}: {
data: T[];
columns: TableColumn<T>[];
/** Row identity. Falls back to the array index when a row has no id field. */
idKey?: keyof T & string;
primaryKey: string;
summaryKeys?: string[];
density?: 'compact' | 'balanced' | 'spacious';
}) {
const bp = useBreakpoint();
if (bp !== 'mobile') {
return (
<Table
data={data}
columns={columns}
idKey={idKey}
density={density}
hasHover
/>
);
}
const primary = columns.find((c) => c.key === primaryKey);
const rest = columns.filter(
(c) =>
c.key !== primaryKey &&
(summaryKeys ? summaryKeys.includes(c.key) : true),
);
return (
<VStack gap={3}>
{data.map((row, i) => (
<Card key={idKey ? String(row[idKey]) : i} variant="muted">
<VStack gap={3}>
{primary ? (
<VStack gap={0}>
{primary.renderCell
? primary.renderCell(row)
: String(row[primary.key] ?? '')}
</VStack>
) : null}
{rest.length ? <Divider /> : null}
<VStack gap={2}>
{rest.map((col) => (
<HStack key={col.key} hAlign="between" vAlign="center" gap={3}>
<Text size="sm" color="secondary">
{typeof col.header === 'string' ? col.header : col.key}
</Text>
{/* The real cell renderer — badges, deltas and progress bars
all survive the transposition unchanged. */}
{col.renderCell
? col.renderCell(row)
: <Text size="sm">{String(row[col.key] ?? '')}</Text>}
</HStack>
))}
</VStack>
</VStack>
</Card>
))}
</VStack>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
'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,
* then the filters and actions that belong to THIS page. Centralising it is
* what keeps five modules from drifting into five different header treatments.
*
* Two trailing slots, deliberately distinct:
* `controls` — filters that scope the page's data (store, period, compare).
* Own row beneath the title, because a page can have several
* and they would otherwise crowd the heading and wrap badly.
* `actions` — page-level verbs (export, add store). Trailing edge of the
* title row, where a primary action is conventionally found.
*
* `eyebrow` is the small line ABOVE the title — a greeting, a breadcrumb-ish
* context line. It sits inside the heading block rather than above the whole
* header so it cannot drift away from the title it belongs to.
*/
export function PageHeader({
eyebrow,
title,
description,
controls,
actions,
}: {
eyebrow?: string;
title: string;
description?: string;
controls?: React.ReactNode;
actions?: React.ReactNode;
}) {
return (
<HStack hAlign="between" vAlign="center" gap={4} wrap="wrap">
<VStack gap={1}>
{eyebrow ? (
<Text size="sm" color="secondary">
{eyebrow}
</Text>
) : null}
<Heading level={1} className="font-bold">{title}</Heading>
{description ? <Text color="secondary">{description}</Text> : null}
</VStack>
{controls || actions ? (
<HStack gap={2} vAlign="center" wrap="wrap">
{controls}
{actions}
</HStack>
) : null}
</HStack>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import {useState} from 'react';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {Dialog} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Button} from '@astryxdesign/core/Button';
import {RANGE_LABELS, useWorkspace} from '@/shared/providers/WorkspaceProvider';
import type {RangeKey} from '@/shared/providers/WorkspaceProvider';
const ORDER: RangeKey[] = ['7d', '30d', '90d', 'mtd', 'ytd'];
/** Period scope for the charts and KPIs on the page that renders it. */
export function RangePicker() {
const {range, setRange, customRange, setCustomRange} = useWorkspace();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [startDate, setStartDate] = useState(customRange?.start || '2026-08-01');
const [endDate, setEndDate] = useState(customRange?.end || '2026-08-05');
const getLabel = () => {
if (range === 'custom' && customRange?.start && customRange?.end) {
return `${customRange.start} – ${customRange.end}`;
}
return RANGE_LABELS[range] || 'Select period';
};
const handleApplyCustom = () => {
setCustomRange({start: startDate, end: endDate});
setRange('custom');
setIsDialogOpen(false);
};
return (
<>
<DropdownMenu
button={{
variant: 'secondary',
size: 'sm',
label: getLabel(),
icon: <Icon icon="calendar" size="sm" />,
}}
items={[
...ORDER.map((key) => ({
label: RANGE_LABELS[key],
onClick: () => setRange(key),
})),
{type: 'divider' as const},
{
label: 'Custom range...',
onClick: () => setIsDialogOpen(true),
},
]}
/>
<Dialog
isOpen={isDialogOpen}
onOpenChange={setIsDialogOpen}
width={400}
purpose="info"
>
<VStack gap={4} padding={4}>
<VStack gap={1}>
<Heading level={3}>Custom Date Range</Heading>
<Text size="sm" color="secondary">
Select start and end dates to query performance data.
</Text>
</VStack>
<VStack gap={3}>
<TextInput
type="text"
label="Start Date"
value={startDate}
onChange={setStartDate}
/>
<TextInput
type="text"
label="End Date"
value={endDate}
onChange={setEndDate}
/>
</VStack>
<HStack hAlign="end" gap={2}>
<Button
variant="secondary"
size="sm"
label="Cancel"
onClick={() => setIsDialogOpen(false)}
/>
<Button
variant="primary"
size="sm"
label="Apply Range"
onClick={handleApplyCustom}
/>
</HStack>
</VStack>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import {StoreSwitcher} from './StoreSwitcher';
import {RangePicker} from './RangePicker';
/**
* The store + period controls a data page puts in its own header.
*
* These read and write WorkspaceProvider, so the selection still persists
* across navigation — what changed is where they are *stated*. A page that
* queries by store and period declares those controls itself; a page that
* does not (Settings) shows none, instead of the shell implying a scope that
* nothing on screen honours.
*
* Returns a fragment: PageHeader's `controls` slot supplies the row.
*/
export function ScopeControls({
/** Off for pages that read the period but deliberately ignore store scope. */
hasStore = true,
}: {
hasStore?: boolean;
}) {
return (
<>
{hasStore ? <StoreSwitcher /> : null}
<RangePicker />
</>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/shared/utils/icons';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
/**
* Store scope for the page that renders it. The selection lives in
* WorkspaceProvider, so switching re-queries every panel on the page without a
* route change — which is what keeps Loyaly AI mounted — and the choice
* carries to the next page that also scopes by store.
*
* `secondary` rather than `ghost`: in a page header these are the row, not
* decoration on a bar, and they need a visible edge to read as controls.
*/
export function StoreSwitcher() {
const {storeId, setStoreId, stores} = useWorkspace();
const current = stores.find((s) => s.id === storeId);
return (
<DropdownMenu
button={{
variant: 'secondary',
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),
})),
},
]}
/>
);
}