update ui update and fix layout issue
This commit is contained in:
62
src/shared/components/brand/BrandLogo.tsx
Normal file
62
src/shared/components/brand/BrandLogo.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
91
src/shared/components/charts/AreaChartView.tsx
Normal file
91
src/shared/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/shared/components/charts/BarChartView.tsx
Normal file
81
src/shared/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/shared/components/charts/ChartCard.tsx
Normal file
48
src/shared/components/charts/ChartCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/shared/components/charts/ChartDefs.tsx
Normal file
53
src/shared/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/shared/components/charts/ChartFrame.tsx
Normal file
82
src/shared/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/shared/components/charts/ChartTooltip.tsx
Normal file
74
src/shared/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/shared/components/charts/HeatmapGrid.tsx
Normal file
83
src/shared/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/shared/components/charts/LineChartView.tsx
Normal file
67
src/shared/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/shared/components/charts/Sparkline.tsx
Normal file
49
src/shared/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/shared/components/charts/palette.ts
Normal file
67
src/shared/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/shared/components/charts/types.ts
Normal file
37
src/shared/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; 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;
|
||||
}
|
||||
22
src/shared/components/charts/useChartMotion.ts
Normal file
22
src/shared/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/shared/components/data/AsyncBoundary.tsx
Normal file
80
src/shared/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 '@/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>
|
||||
);
|
||||
}
|
||||
86
src/shared/components/help/AboutDialog.tsx
Normal file
86
src/shared/components/help/AboutDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/shared/components/help/KeyboardShortcutsDialog.tsx
Normal file
65
src/shared/components/help/KeyboardShortcutsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/shared/components/motion/AnimatedNumber.tsx
Normal file
62
src/shared/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/shared/components/motion/HoverLift.tsx
Normal file
31
src/shared/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>
|
||||
);
|
||||
}
|
||||
105
src/shared/components/patterns/ActivityItem.tsx
Normal file
105
src/shared/components/patterns/ActivityItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/shared/components/patterns/CollectionRegion.tsx
Normal file
36
src/shared/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>
|
||||
);
|
||||
}
|
||||
198
src/shared/components/patterns/DownloadDropdown.tsx
Normal file
198
src/shared/components/patterns/DownloadDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/shared/components/patterns/EmptyPanel.tsx
Normal file
48
src/shared/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 '@/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
97
src/shared/components/patterns/FilterBar.tsx
Normal file
97
src/shared/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/shared/components/patterns/LoadingState.tsx
Normal file
89
src/shared/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/shared/components/patterns/MetricCard.tsx
Normal file
85
src/shared/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 '@/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>
|
||||
);
|
||||
}
|
||||
88
src/shared/components/patterns/PanelCard.tsx
Normal file
88
src/shared/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 '@/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>
|
||||
);
|
||||
}
|
||||
100
src/shared/components/patterns/ResponsiveTable.tsx
Normal file
100
src/shared/components/patterns/ResponsiveTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/shared/components/patterns/SectionHeader.tsx
Normal file
54
src/shared/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, 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>
|
||||
);
|
||||
}
|
||||
41
src/shared/components/patterns/StatPair.tsx
Normal file
41
src/shared/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.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>
|
||||
);
|
||||
}
|
||||
56
src/shared/components/primitives/MetricDelta.tsx
Normal file
56
src/shared/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 '@/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>
|
||||
);
|
||||
}
|
||||
54
src/shared/components/primitives/PageHeader.tsx
Normal file
54
src/shared/components/primitives/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/shared/components/scope/RangePicker.tsx
Normal file
106
src/shared/components/scope/RangePicker.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/shared/components/scope/ScopeControls.tsx
Normal file
29
src/shared/components/scope/ScopeControls.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
src/shared/components/scope/StoreSwitcher.tsx
Normal file
43
src/shared/components/scope/StoreSwitcher.tsx
Normal 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),
|
||||
})),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user