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),
})),
},
]}
/>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import {useSyncExternalStore} from 'react';
/**
* Shell breakpoints.
*
* These are LAYOUT decisions — they choose whether Loyaly AI is an inline
* panel or a sheet, whether a table renders as rows or cards. Those are React
* tree differences that CSS cannot express.
*
* Anything that CAN be done in CSS should be: use Grid's responsive `columns`
* or a Tailwind breakpoint instead of reaching for this hook. Every consumer
* here costs a client-side re-render on resize.
*/
export type Breakpoint =
| 'mobile'
| 'tablet'
| 'laptop'
| 'desktop'
| 'ultrawide';
/**
* Ordered widest-first — `read()` returns the first match.
*
* mobile <640 phones
* tablet 640–1024
* laptop 1024–1440
* desktop 1440–1920
* ultrawide >1920 content gets capped rather than stretched
*/
export const BREAKPOINT_MIN = {
ultrawide: 1920,
desktop: 1440,
laptop: 1024,
tablet: 640,
mobile: 0,
} as const;
const QUERIES: [Breakpoint, string][] = [
['ultrawide', `(min-width: ${BREAKPOINT_MIN.ultrawide}px)`],
['desktop', `(min-width: ${BREAKPOINT_MIN.desktop}px)`],
['laptop', `(min-width: ${BREAKPOINT_MIN.laptop}px)`],
['tablet', `(min-width: ${BREAKPOINT_MIN.tablet}px)`],
];
function read(): Breakpoint {
for (const [name, q] of QUERIES) {
if (window.matchMedia(q).matches) return name;
}
return 'mobile';
}
function subscribe(onChange: () => void) {
const mqls = QUERIES.map(([, q]) => window.matchMedia(q));
mqls.forEach((m) => m.addEventListener('change', onChange));
return () => mqls.forEach((m) => m.removeEventListener('change', onChange));
}
/**
* SSR resolves to 'desktop'. The shell is flex-based, so when a narrow client
* corrects on the first frame the only visible change is the Loyaly AI panel
* dropping out — no reflow of the content column.
*/
export function useBreakpoint(): Breakpoint {
return useSyncExternalStore(subscribe, read, () => 'desktop');
}
/**
* True when the side nav renders as an inline rail. Below this AppShell moves
* it into the drawer, which is also the point where the workspace shell has to
* put the top bar back at the shell root — see (workspace)/layout.tsx.
*
* Must stay in sync with AppShell's own `mobileNav.breakpoint`, which is
* 'sm' (640px) — the same line as the mobile/tablet boundary.
*/
export function isSideNavInline(bp: Breakpoint): boolean {
return bp !== 'mobile';
}
/**
* True when Loyaly AI renders as a third column.
*
* Tablet is excluded deliberately: at 640–1024 a 320px panel leaves under
* 400px of workspace once the rail is out, which is narrower than a single
* chart needs. There it becomes a sheet, same as on mobile.
*/
export function isPanelInline(bp: Breakpoint): boolean {
return bp === 'laptop' || bp === 'desktop' || bp === 'ultrawide';
}
/** Loyaly AI panel width. Laptop trades panel width for workspace. */
export function assistantWidth(bp: Breakpoint): number {
if (bp === 'ultrawide') return 420;
if (bp === 'desktop') return 380;
return 320;
}
/**
* Columns for a four-up metric row.
*
* Four KPIs must lay out 4-up, 2×2, or stacked — never 3+1, which reads as a
* broken grid. Grid's `repeat: 'fit'` cannot express that: at a ~800px
* workspace it yields exactly three tracks whatever minWidth you choose, and
* the fourth card drops to an orphan row. So the count is computed.
*/
export function metricColumns(bp: Breakpoint, isPanelOpen: boolean): number {
if (bp === 'mobile') return 1;
if (bp === 'tablet') return 2;
if (bp === 'laptop') return 2;
// Desktop and ultrawide fit four unless Loyaly AI is taking 380–420px.
return isPanelInline(bp) && isPanelOpen ? 2 : 4;
}
/**
* Max content width above `desktop`.
*
* On a 2560px monitor an uncapped workspace stretches a 30-day line chart
* across ~1900px, which flattens every trend it is supposed to show, and runs
* body text past the ~90ch where reading breaks down. Capping and centring
* costs nothing below the cap.
*/
export function contentMaxWidth(bp: Breakpoint): number | undefined {
return bp === 'ultrawide' ? 1600 : undefined;
}

View File

@@ -0,0 +1,94 @@
'use client';
import {useCallback, useSyncExternalStore} from 'react';
/**
* A boolean persisted in localStorage, read without a hydration mismatch.
*
* useSyncExternalStore already solves the hydration problem on its own:
* React uses `getServerSnapshot` for the SSR render AND for the first client
* render, so the two markups match, then immediately re-reads `getSnapshot`
* and re-renders with the stored value. No `isHydrated` flag is needed — and
* adding one means a setState inside an effect, which React's lint rule
* rejects and which costs an extra render pass to do what the hook already
* does natively.
*
* Writes notify every subscriber, so two components reading the same key never
* disagree, and the `storage` event keeps other tabs in step.
*/
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((l) => l());
}
function subscribe(onChange: () => void) {
listeners.add(onChange);
window.addEventListener('storage', onChange);
return () => {
listeners.delete(onChange);
window.removeEventListener('storage', onChange);
};
}
/**
* The same store, but "never set" stays distinguishable from "set to false".
*
* The sidebar needs that distinction: its default is per-device (a tablet
* starts collapsed, a desktop starts expanded), so a stored `false` has to
* mean "this merchant expanded it" and not "no preference yet". Collapsing
* the two would either strand tablets expanded or force desktops to re-expand
* on every first visit.
*/
export function usePersistentTriState(
key: string,
): [boolean | null, (next: boolean) => void] {
const getSnapshot = useCallback(() => {
try {
const raw = window.localStorage.getItem(key);
return raw === null ? null : raw === 'true';
} catch {
// Private mode / storage disabled — degrade to "no preference" rather
// than taking the whole shell down over a preference.
return null;
}
}, [key]);
const getServerSnapshot = useCallback(() => null, []);
const value = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
const set = useCallback(
(next: boolean) => {
try {
window.localStorage.setItem(key, String(next));
} catch {
/* ignore — the notify below still updates this session */
}
emit();
},
[key],
);
return [value, set];
}
/**
* The plain boolean form, for a preference with one default everywhere.
*
* Kept rather than deleted as unused: it is the shape most callers want, the
* tri-state variant exists only because the sidebar's default is per-device,
* and collapsing the file to the harder API would push that `?? fallback` into
* every future call site.
*/
export function usePersistentFlag(
key: string,
fallback = false,
): [boolean, (next: boolean) => void] {
const [stored, set] = usePersistentTriState(key);
return [stored ?? fallback, set];
}

View File

@@ -0,0 +1,144 @@
'use client';
import {useCallback, useEffect, useState} from 'react';
import {fetchEndpoint, endpointUrl} from '@/shared/services/httpClient';
import type {Endpoint} from '@/shared/services/httpClient';
import {isFailure} from '@/shared/types/api';
import type {ApiFailure, ApiMeta} from '@/shared/types/api';
/**
* Deliberately shaped like SWR so it can be replaced by SWR or TanStack Query
* in exactly one file when a real backend arrives.
*
* 'empty' is a first-class status rather than something each caller derives
* from `data.length === 0`. Getting that wrong is how "no results" ends up
* rendering as an axis with no bars.
*/
export type AsyncState<T> =
| {status: 'loading'; data: undefined; error: undefined}
| {status: 'success'; data: T; error: undefined}
| {status: 'empty'; data: T; error: undefined}
| {status: 'error'; data: undefined; error: ApiFailure['error']};
export type Resource<T> = AsyncState<T> & {
refetch: () => void;
/**
* True while a new request is in flight but previous data is still on
* screen. Panels use this to dim rather than to blank.
*/
isRefreshing: boolean;
/**
* Response metadata, including the SERVER's clock. Anything computing a
* relative deadline — days-to-expiry, "2 hours ago" — must measure against
* this rather than Date.now(): calling Date.now() during render is impure,
* disagrees between the SSR and hydration passes, and quietly reports the
* wrong countdown to any user whose device clock is off.
*/
meta?: ApiMeta;
};
/** What the fetch resolved to, tagged with the request it belongs to. */
type Settled<T> =
| {key: string; ok: true; data: T; meta?: ApiMeta}
| {key: string; ok: false; error: ApiFailure['error']};
export function useResource<T>(
endpoint: Endpoint<T> | null,
opts?: {isEmpty?: (d: T) => boolean; initialData?: T},
): Resource<T> {
const {isEmpty, initialData} = opts ?? {};
// The URL IS the request identity. Keying on it means a store or range change
// refetches while an unrelated re-render does not.
const key = endpoint ? endpointUrl(endpoint) : null;
// Seeded from initialData with no meta — a caller-supplied payload has no
// server timestamp to report.
const [settled, setSettled] = useState<Settled<T> | null>(() =>
initialData !== undefined && key
? {key, ok: true, data: initialData}
: null,
);
const [nonce, setNonce] = useState(0);
useEffect(() => {
if (!endpoint || !key) return;
const controller = new AbortController();
let cancelled = false;
// No setState for the loading state: it is DERIVED below from whether the
// settled result matches the current key. Setting it here would be a
// synchronous setState in an effect body — a cascading render, and the
// thing react-hooks/set-state-in-effect exists to catch.
fetchEndpoint(endpoint, controller.signal)
.then((res) => {
if (cancelled) return;
setSettled(
isFailure(res)
? {key, ok: false, error: res.error}
: {key, ok: true, data: res.data, meta: res.meta},
);
})
.catch((err: unknown) => {
// An abort is a superseded request, not a failure to show the user.
if (cancelled || (err as Error)?.name === 'AbortError') return;
setSettled({
key,
ok: false,
error: {
code: 'internal',
message: (err as Error)?.message ?? 'Request failed',
},
});
});
return () => {
cancelled = true;
controller.abort();
};
// `endpoint` is intentionally not a dep: `key` is its serialized identity,
// and callers construct the object inline on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
// Stale-while-revalidate.
//
// When the scope changes, the honest-but-wrong thing is to drop straight to
// 'loading': the whole dashboard blanks to skeletons and every animated
// counter restarts from zero. Merchants switch store and period constantly,
// so that reads as the page breaking rather than as data arriving.
//
// Instead the previous result stays on screen until the new one lands, with
// `isRefreshing` set. 'loading' is now reserved for the genuine first paint,
// when there is nothing to show. A stale ERROR is not kept — showing a dead
// error under a fresh request would be actively misleading.
const isStale = !!settled && settled.key !== key;
const usable = settled && (settled.ok || !isStale) ? settled : null;
const state: AsyncState<T> =
!key || !usable
? {status: 'loading', data: undefined, error: undefined}
: usable.ok
? {
// Emptiness is derived here rather than inside the effect, so
// `isEmpty` never has to be a dependency — no ref, no stale
// closure, no ref mutation during render.
status: (
isEmpty
? isEmpty(usable.data)
: Array.isArray(usable.data) && usable.data.length === 0
)
? 'empty'
: 'success',
data: usable.data,
error: undefined,
}
: {status: 'error', data: undefined, error: usable.error};
const meta = usable && usable.ok ? usable.meta : undefined;
return {...state, refetch, isRefreshing: isStale, meta} as Resource<T>;
}

View File

@@ -0,0 +1,22 @@
'use client';
import {useMemo} from 'react';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import type {Scope} from '@/shared/services/httpClient';
/**
* The {storeId, range} every scoped request is filtered by, in one hook.
*
* Feature hooks call this instead of taking scope as an argument, so a page
* never threads query parameters through its component tree — which is how the
* dashboard's six panels are guaranteed to agree about what period they are
* showing.
*
* Memoised on its two fields so the object identity only changes when the
* scope actually does. useResource keys off the serialized URL rather than
* this reference, but a stable object keeps it out of other dependency arrays.
*/
export function useScope(): Scope {
const {storeId, range} = useWorkspace();
return useMemo(() => ({storeId, range}), [storeId, range]);
}

View File

@@ -0,0 +1,26 @@
'use client';
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
import {WorkspaceShell} from '@/shared/layouts/workspace/WorkspaceShell';
/**
* Everything behind the sign-in wall renders through here.
*
* Two responsibilities, in order: prove there is a session, then draw the
* workspace frame around whatever the route rendered. Keeping them composed in
* one named layout — rather than repeated in each route group — is what makes
* "add a protected section" a matter of putting a folder under (workspace)
* with nothing to remember.
*
* The guard sits OUTSIDE the shell deliberately. Inside, an expired session
* would paint a full sidebar, top bar and Loyaly AI rail around a spinner before
* redirecting; outside, an unauthenticated visitor never sees the chrome of an
* account they are not in.
*/
export function ProtectedLayout({children}: {children: React.ReactNode}) {
return (
<AuthGuard>
<WorkspaceShell>{children}</WorkspaceShell>
</AuthGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import {GuestGuard} from '@/features/auth/guards/GuestGuard';
/**
* The unauthenticated surface: sign-in today, password reset and invitation
* acceptance when they land.
*
* No shell — no sidebar, no top bar, no workspace scope — because none of it
* means anything without an account, and mounting the providers behind it
* would start fetching workspace data for a visitor who has not signed in.
* The page owns its own full-bleed layout.
*/
export function PublicLayout({children}: {children: React.ReactNode}) {
return <GuestGuard>{children}</GuestGuard>;
}

View File

@@ -0,0 +1,26 @@
'use client';
import {AboutDialog} from '@/shared/components/help/AboutDialog';
import {KeyboardShortcutsDialog} from '@/shared/components/help/KeyboardShortcutsDialog';
import {useAccountMenu} from './AccountMenuProvider';
/**
* The two surfaces the Help panel opens, mounted at the shell.
*
* They are here rather than inside the menu because the row that opens one
* also closes the menu — a dialog mounted in the popover would unmount in the
* same tick it was asked for. See AccountMenuProvider.
*/
export function AccountDialogs() {
const {dialog, closeDialog} = useAccountMenu();
return (
<>
<KeyboardShortcutsDialog
isOpen={dialog === 'shortcuts'}
onClose={closeDialog}
/>
<AboutDialog isOpen={dialog === 'about'} onClose={closeDialog} />
</>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import {useCallback, useEffect, useRef, useState} from 'react';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Icon} from '@astryxdesign/core/Icon';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Item} from '@astryxdesign/core/Item';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {layerAnimations} from '@astryxdesign/core/Layer';
import {usePopover} from '@astryxdesign/core/Popover';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {ICONS} from '@/shared/utils/icons';
import type {AccountPanelId} from './account-menu';
import {useAccountMenu} from './AccountMenuProvider';
import {
AccountRootPanel,
AccountSubPanel,
PANEL_ONE_WIDTH,
PANEL_SURFACE,
PANEL_TWO_MOTION,
PANEL_TWO_WIDTH,
} from './AccountPanels';
import {useSidebar} from './SidebarProvider';
import {useAccountActions} from './useAccountActions';
/**
* A cascading menu, not a dropdown.
*
* The distinction is behavioural, and it is the whole point: clicking a row in
* panel one opens a SECOND panel beside the first and navigates nowhere. Both
* panels stay up until the merchant either clicks a real destination in panel
* two, clicks outside, or presses Escape. This is how a desktop application's
* account menu behaves; the dropdown it replaces navigated on the first click
* and dismissed itself before you had chosen anything.
*
* ── Why usePopover and not <Popover> ──────────────────────────────────────
* <Popover> paints the surface — background, radius, shadow — on a wrapper
* OUTSIDE the content it is given. One surface around two panels is exactly
* the thing a cascade must not be: the fill would bridge the gap between them
* and the pair would read as one wide card with a seam down it. `usePopover`
* takes `hasSurface: false`, which <Popover> does not forward, so the layer
* stays transparent and each panel paints its own card (see PANEL_SURFACE).
*
* Everything else the component gave us is still here, because it all lives in
* the hook: CSS anchor positioning, the focus trap, native light dismiss and
* Escape. The only thing hand-wired is the trigger, thirty lines below.
*
* ── Dismissal ────────────────────────────────────────────────────────────
* Outside click and Escape both close EVERYTHING, panel two included — they
* are the native popover's own light dismiss acting on the single layer that
* holds both panels, so there is no state in which one panel outlives the
* other. Panel two is reset on close rather than on open, so re-opening the
* menu always starts at panel one.
*/
/**
* 8px between the chip and the panel. --spacing-2 via the Tailwind bridge, and
* it has to be a utility rather than a Stack prop because the element it
* applies to is the layer itself, which useLayer renders.
*
* It wins over useLayer's own `margin: 0` on layer sorting alone: astryx.css
* declares @layer astryx-base and Tailwind's utilities layer is declared after
* it, so no !important is involved.
*/
const LAYER_OFFSET = 'mb-2';
/** The chip. Rounded like a nav item, and lit while its menu is open. */
const CHIP = 'rounded-lg transition-colors duration-150 cursor-pointer hover:bg-white/[0.06] p-2';
/**
* The collapsed chip, sized to the rail rather than to IconButton's default.
*
* AppSideNav widens the collapsed rail to 84px and restyles its items to 44px
* around 24px icons, but that rule is scoped to `.astryx-side-nav-item` and
* this is an IconButton — it would otherwise sit at 32px under a column of
* 44px targets. Both values are spacing tokens: size-11 is 44px, size-6 is 24.
*/
const CHIP_COLLAPSED = 'size-11 mx-auto [&_.astryx-avatar]:size-6';
/**
* The account chip and its cascading menu, for the inline rail.
*
* Rendered in SideNav's `footer`, so it sits in the bottom-left corner at
* every rail width. Below the rail breakpoint this is not mounted at all —
* the drawer gets AccountSheetTrigger and the sheet instead.
*/
export function AccountMenu() {
const {isCollapsed} = useSidebar();
const {user} = useSession();
const {locale, setLocale} = useAccountMenu();
const [activePanel, setActivePanel] = useState<AccountPanelId | null>(null);
// Set only when a branch is opened from the keyboard, and consumed by the
// effect below. A ref, not state: it must not cause a render of its own, and
// the effect that reads it already runs on the render `activePanel` causes.
const wantsSubFocus = useRef(false);
const subPanelRef = useRef<HTMLElement>(null);
const {hide, isOpen, render, toggle, triggerProps, triggerRef} =
usePopover({
dialogLabel: 'Account menu',
// See the header note. This is the reason the hook is used directly.
hasSurface: false,
// Reset on CLOSE rather than on open, and from the hide callback rather
// than an effect watching isOpen: this runs for every way the menu can
// go away — light dismiss, Escape, a chosen row — and never schedules a
// second render pass the way `useEffect(() => setState())` would.
onHide: () => setActivePanel(null),
});
const close = useCallback(() => hide(), [hide]);
const handleLeaf = useAccountActions(close);
// Mouse users are already looking at panel two; keyboard users are not, and
// leaving focus on the branch row would make them tab through the rest of
// panel one to reach the panel they just asked for.
useEffect(() => {
if (!activePanel || !wantsSubFocus.current) return;
wantsSubFocus.current = false;
subPanelRef.current?.querySelector('button')?.focus();
}, [activePanel]);
const handleBranch = useCallback(
(panel: AccountPanelId, viaKeyboard: boolean) => {
wantsSubFocus.current = viaKeyboard;
// Re-selecting the open branch closes panel two, so the same key that
// opened it also puts it away.
setActivePanel((current) => (current === panel ? null : panel));
},
[],
);
const label = user?.name ?? 'Account';
return (
<>
{isCollapsed ? (
<IconButton
ref={triggerRef}
variant="ghost"
label={label}
// The avatar draws its own initials from the name; a signed-out visit
// falls back to Avatar's person glyph rather than someone else's
// letter.
icon={
<Avatar name={user?.name || undefined} size="md" tooltip={false} />
}
className={CHIP_COLLAPSED}
onClick={toggle}
{...triggerProps}
data-testid="account-menu-trigger"
/>
) : (
<Item
ref={triggerRef}
// role="button" rather than Item's default: given a role, Item skips
// the inner <button> it would otherwise wrap the label in and puts
// the handler on the root — which is the element the ARIA below has
// to describe, and the element the popover is anchored to. With the
// inner button in play those would be two different nodes.
role="button"
tabIndex={0}
onClick={toggle}
// A native <button> synthesises click from Enter and Space;
// role="button" does not, so it is spelled out.
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggle();
}
}}
{...triggerProps}
label={label}
description={user?.email ?? 'Not signed in'}
labelLines={1}
descriptionLines={1}
density="balanced"
className={CHIP}
// isHighlighted, not isSelected: Item turns isSelected into
// aria-current on a root that has no role permitting aria-selected,
// and "current item" is not what an open menu means. aria-expanded
// above already says it; this only has to look lit.
isHighlighted={isOpen}
startContent={
<Avatar name={user?.name || undefined} size="sm" tooltip={false} />
}
// The switcher glyph, not a chevron: this opens a panel of its own,
// it does not expand in place.
endContent={
<Icon icon={ICONS.switcher} size="sm" color="secondary" />
}
data-testid="account-menu-trigger"
/>
)}
{render(
// vAlign="end" keeps both cards' bottom edges flush with each other.
// The layer's own bottom edge is pinned to the chip, so a panel two
// that is taller than panel one grows upward instead of hanging below
// the trigger.
<HStack gap={2} vAlign="end">
<VStack
width={PANEL_ONE_WIDTH}
padding={2}
className={PANEL_SURFACE}
data-testid="account-panel-root"
>
<AccountRootPanel
activePanel={activePanel}
onBranch={handleBranch}
onLeaf={handleLeaf}
/>
</VStack>
{activePanel ? (
<VStack
// Keyed on the panel so moving between branches re-runs the
// entrance instead of swapping the rows in place.
key={activePanel}
ref={subPanelRef}
width={PANEL_TWO_WIDTH}
padding={2}
className={`${PANEL_SURFACE} ${PANEL_TWO_MOTION}`}
data-testid="account-panel-sub"
>
<AccountSubPanel
panel={activePanel}
locale={locale}
onLocale={setLocale}
onLeaf={handleLeaf}
/>
</VStack>
) : null}
</HStack>,
{
placement: 'above',
alignment: 'start',
className: LAYER_OFFSET,
xstyle: layerAnimations.above,
},
)}
</>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import {createContext, useCallback, useContext, useMemo, useState} from 'react';
import {DEFAULT_LOCALE, type LocaleOption} from './account-menu';
/**
* The account menu's shell-level state.
*
* Two things in this menu cannot live inside the menu:
*
* the help dialogs they are opened BY a menu row and must outlive it. A
* dialog mounted inside the popover would be torn down
* the moment the row that opened it closed the popover,
* and a `<dialog>` nested inside MobileNav's own dialog
* cannot show at all.
* the mobile sheet its trigger is the chip inside the nav drawer, and the
* drawer has to close before the sheet opens. Same
* nesting problem, same fix: mount both at the shell and
* let the chip ask.
*
* The locale is here for a plainer reason — the popover and the sheet are two
* renderings of one menu, and a check mark that disagreed between them would
* be a bug you could only see by resizing the window.
*/
export type AccountDialogId = 'shortcuts' | 'about';
interface AccountMenuValue {
isSheetOpen: boolean;
openSheet: () => void;
closeSheet: () => void;
dialog: AccountDialogId | null;
openDialog: (id: AccountDialogId) => void;
closeDialog: () => void;
/** Display-only until there is a locale provider. See account-menu.ts. */
locale: LocaleOption['code'];
setLocale: (code: LocaleOption['code']) => void;
}
const AccountMenuContext = createContext<AccountMenuValue | null>(null);
export function AccountMenuProvider({children}: {children: React.ReactNode}) {
const [isSheetOpen, setSheetOpen] = useState(false);
const [dialog, setDialog] = useState<AccountDialogId | null>(null);
const [locale, setLocale] = useState<LocaleOption['code']>(DEFAULT_LOCALE);
const openSheet = useCallback(() => setSheetOpen(true), []);
const closeSheet = useCallback(() => setSheetOpen(false), []);
const closeDialog = useCallback(() => setDialog(null), []);
const openDialog = useCallback((id: AccountDialogId) => {
// The menu that opened it is already closing; opening the dialog in the
// same tick would otherwise race the sheet's own close transition and
// leave two top-layer surfaces stacked for ~200ms.
setSheetOpen(false);
setDialog(id);
}, []);
const value = useMemo(
() => ({
isSheetOpen,
openSheet,
closeSheet,
dialog,
openDialog,
closeDialog,
locale,
setLocale,
}),
[isSheetOpen, openSheet, closeSheet, dialog, openDialog, closeDialog, locale],
);
return <AccountMenuContext value={value}>{children}</AccountMenuContext>;
}
export function useAccountMenu(): AccountMenuValue {
const ctx = useContext(AccountMenuContext);
if (!ctx) {
throw new Error('useAccountMenu must be used inside <AccountMenuProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,298 @@
'use client';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Divider} from '@astryxdesign/core/Divider';
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 {
ACCOUNT_PANELS,
ACCOUNT_ROOT_GROUPS,
LOCALE_OPTIONS,
isBranch,
type AccountBranch,
type AccountLeaf,
type AccountPanelId,
type LocaleOption,
} from './account-menu';
/**
* The two panels, with no opinion about how they float.
*
* Everything here is content: the cascade — anchoring, dismissal, which panel
* is open — belongs to AccountMenu, and the mobile sheet reuses these same two
* components without a second code path.
*/
/**
* The panel skin, shared by both panels so they read as one system.
*
* The popover layer underneath is deliberately surface-less (see AccountMenu),
* so this is the ONLY thing painting a background: two separate cards with a
* gap of nothing between them, which is what makes the second panel read as a
* second panel rather than a wider first one.
*
* Every value is a token: bg-popover IS --color-background-popover,
* border-border IS --color-border, rounded-xl IS --radius-page (16px, the
* largest radius the theme defines), shadow-lg IS --shadow-high.
*/
export const PANEL_SURFACE =
'bg-popover border border-border rounded-xl shadow-lg';
/** Panel one. Wide enough for an email address at 14px without truncating. */
export const PANEL_ONE_WIDTH = 288;
/** Panel two. Narrower — its longest row is "Roles & Permissions". */
export const PANEL_TWO_WIDTH = 264;
const ROW = 'rounded-lg transition-colors duration-150 cursor-pointer';
/**
* The one row allowed semantic colour, and only on hover — the monochrome rule
* keeps the resting state gray like every other row.
*
* `hover:text-error` alone reaches the glyph as well as the label: Item's label
* span carries no colour of its own and the icon is passed `color="inherit"`,
* so both inherit the row's.
*/
const ROW_DANGER = `${ROW} hover:text-error`;
/**
* Panel two's entrance: 8px of travel from the right plus a fade, over 180ms.
*
* `starting:` is what makes this animate at all. The panel is newly INSERTED
* into an already-open popover — there is no display change to transition
* from, so without an @starting-style rule the browser has no "before" value
* and the panel would simply appear. Keying the element on the panel id (see
* AccountMenu) re-runs it when the merchant moves between branches.
*/
export const PANEL_TWO_MOTION = [
'transition-[opacity,translate] duration-200 ease-out',
'opacity-100 translate-x-0 starting:opacity-0 starting:translate-x-2',
].join(' ');
// =============================================================================
// Identity
// =============================================================================
/** Avatar, name, email. The only part of the menu that is not a list. */
export function AccountIdentity() {
const {user} = useSession();
return (
<HStack gap={3} vAlign="center" paddingInline={2} paddingBlock={2}>
{/* The avatar draws its own initials from the name; no data-URI portrait
to keep in sync, and a signed-out visit falls back to Avatar's own
person glyph rather than someone else's letter. */}
<Avatar name={user?.name || undefined} size="lg" tooltip={false} />
{/* min-w-0 so a long address truncates instead of widening the panel and
pushing the chevrons out of view. */}
<VStack gap={0.5} className="min-w-0">
<Text weight="medium" maxLines={1}>
{user?.name ?? 'Not signed in'}
</Text>
<Text type="supporting" size="sm" maxLines={1}>
{user?.email ?? 'Sign in to sync your workspace'}
</Text>
</VStack>
</HStack>
);
}
// =============================================================================
// Panel one
// =============================================================================
interface RootPanelProps {
/** Which branch is showing panel two, so its row can stay lit. */
activePanel: AccountPanelId | null;
/**
* `viaKeyboard` is how the cascade knows whether to move focus into panel
* two. See AccountMenu — a mouse user is already looking at it.
*/
onBranch: (panel: AccountPanelId, viaKeyboard: boolean) => void;
onLeaf: (leaf: AccountLeaf) => void;
}
/**
* Identity, four branches, sign-out.
*
* A branch row NEVER navigates — it only asks for panel two. That is the whole
* behavioural difference from the dropdown this replaces, and it is enforced
* by the config's types rather than by remembering to check here.
*/
export function AccountRootPanel({
activePanel,
onBranch,
onLeaf,
}: RootPanelProps) {
const {isAuthenticated} = useSession();
return (
<VStack gap={2} width="100%">
<AccountIdentity />
{ACCOUNT_ROOT_GROUPS.map((group, index) => (
<VStack gap={0.5} key={group[0]?.label ?? index} width="100%">
<Divider />
{group.map((row) =>
isBranch(row) ? (
<BranchRow
key={row.label}
branch={row}
isActive={activePanel === row.panel}
onSelect={onBranch}
/>
) : (
<LeafRow
key={row.label}
// Signed out — nothing to end, so the row offers the way in
// instead. The handler is already right: it clears an absent
// session and routes to /login either way. Only the wording
// and the danger colour would have lied.
leaf={
row.action === 'signOut' && !isAuthenticated
? {...row, label: 'Log in', tone: undefined}
: row
}
onSelect={onLeaf}
/>
),
)}
</VStack>
))}
</VStack>
);
}
// =============================================================================
// Panel two
// =============================================================================
interface SubPanelProps {
panel: AccountPanelId;
/** Display-only: there is no locale provider yet. See account-menu.ts. */
locale: LocaleOption['code'];
onLocale: (code: LocaleOption['code']) => void;
onLeaf: (leaf: AccountLeaf) => void;
}
/**
* The panel that opens beside panel one. Every row in it is a destination —
* this is the level at which clicking finally does something.
*/
export function AccountSubPanel({
panel,
locale,
onLocale,
onLeaf,
}: SubPanelProps) {
const {title, rows} = ACCOUNT_PANELS[panel];
const isLanguage = panel === 'language';
return (
<VStack gap={0.5} width="100%">
<HStack paddingInline={2} paddingBlock={1.5}>
<Text type="supporting" size="sm" weight="medium">
{title}
</Text>
</HStack>
{isLanguage ? (
<>
{LOCALE_OPTIONS.map((option) => (
<Item
key={option.code}
label={option.label}
density="balanced"
className={ROW}
isSelected={option.code === locale}
// The check is the only end content: a locale row goes nowhere,
// so a chevron would promise a panel three that does not exist.
endContent={
option.code === locale ? (
<Icon icon="check" size="sm" color="inherit" />
) : undefined
}
onClick={() => onLocale(option.code)}
/>
))}
<Divider />
</>
) : null}
{rows.map((leaf) => (
<LeafRow key={leaf.label} leaf={leaf} onSelect={onLeaf} />
))}
</VStack>
);
}
// =============================================================================
// Rows
// =============================================================================
function BranchRow({
branch,
isActive,
onSelect,
}: {
branch: AccountBranch;
isActive: boolean;
onSelect: (panel: AccountPanelId, viaKeyboard: boolean) => void;
}) {
return (
<Item
label={branch.label}
description={branch.description}
density="balanced"
className={ROW}
// Lit while its panel is open, so the pair reads as one selection rather
// than two unrelated surfaces that happen to be adjacent.
isSelected={isActive}
startContent={<Icon icon={branch.icon} color="inherit" />}
endContent={<Icon icon="chevronRight" size="sm" color="secondary" />}
// detail === 0 is a click the browser synthesised from Enter or Space —
// the one reliable signal, on a plain click handler, that no pointer was
// involved.
onClick={(event) => onSelect(branch.panel, event.detail === 0)}
/>
);
}
function LeafRow({
leaf,
onSelect,
}: {
leaf: AccountLeaf;
onSelect: (leaf: AccountLeaf) => void;
}) {
const isExternal = !!leaf.externalHref;
return (
<Item
label={leaf.label}
description={leaf.description}
density="balanced"
className={leaf.tone === 'danger' ? ROW_DANGER : ROW}
startContent={
leaf.icon ? <Icon icon={leaf.icon} color="inherit" /> : undefined
}
// The outbound glyph so a new tab is not a surprise. Internal rows get
// nothing: inside panel two every row already navigates, so a chevron
// on each of them would be decoration.
endContent={
isExternal ? (
<Icon icon="externalLink" size="sm" color="secondary" />
) : undefined
}
// External rows stay real links so middle-click and "open in new tab"
// behave; everything else routes through the handler so the menu closes
// before the destination lands.
href={leaf.externalHref}
target={isExternal ? '_blank' : undefined}
onClick={isExternal ? undefined : () => onSelect(leaf)}
/>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import {useCallback, useState} from 'react';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Dialog} from '@astryxdesign/core/Dialog';
import {Icon} from '@astryxdesign/core/Icon';
import {IconButton} from '@astryxdesign/core/IconButton';
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 {ICONS} from '@/shared/utils/icons';
import {ACCOUNT_PANELS, type AccountPanelId} from './account-menu';
import {useAccountMenu} from './AccountMenuProvider';
import {
AccountRootPanel,
AccountSubPanel,
PANEL_TWO_MOTION,
} from './AccountPanels';
import {useSidebar} from './SidebarProvider';
import {useAccountActions} from './useAccountActions';
/**
* The same menu, on a screen too narrow to put two panels side by side.
*
* Below the rail breakpoint the cascade becomes a drill-down: panel two slides
* in over panel one with a back button, which is the phone form of the same
* idea — a branch still opens a panel instead of navigating, and nothing routes
* until a leaf is chosen. The rows are literally the same components; only the
* container differs.
*
* ── Why it is mounted at the shell ────────────────────────────────────────
* Its trigger lives inside MobileNav's `<dialog>`. A second `<dialog>` nested
* in the first cannot show once the first closes, and the drawer MUST close —
* a sheet stacked on the drawer it came from is two overlays deep. So the
* trigger only asks (AccountMenuProvider) and the sheet is mounted beside the
* shell, out of the drawer's subtree entirely.
*/
/** Bottom sheet: full width, pinned to the bottom, top corners only. */
const SHEET_SURFACE = [
'rounded-t-xl rounded-b-none border-t border-border bg-popover',
// max-w-none because a native <dialog> ships a UA max-width of
// `calc(100% - 6px - 2em)` — measured, that left a 39px gutter down the
// right-hand side of a sheet that is supposed to span the viewport.
'max-w-none',
// 48px rows on touch. Item's balanced density lands at 36px, which is a
// pointer target. min-, not fixed height, so rows carrying a subtitle keep
// the 56px they already need.
'[&_.astryx-item]:min-h-12',
'transition-[opacity,translate] duration-200 ease-out',
'opacity-100 translate-y-0 starting:opacity-0 starting:translate-y-full',
].join(' ');
const CHIP = 'rounded-lg cursor-pointer';
/**
* The drawer's account chip. It closes the drawer and asks for the sheet —
* see the mounting note above for why it cannot open one itself.
*/
export function AccountSheetTrigger() {
const {user} = useSession();
const {closeDrawer} = useSidebar();
const {openSheet} = useAccountMenu();
return (
<Item
label={user?.name ?? 'Account'}
description={user?.email ?? 'Not signed in'}
labelLines={1}
descriptionLines={1}
density="balanced"
className={CHIP}
startContent={
<Avatar name={user?.name || undefined} size="sm" tooltip={false} />
}
endContent={<Icon icon={ICONS.switcher} size="sm" color="secondary" />}
onClick={() => {
closeDrawer();
openSheet();
}}
data-testid="account-sheet-trigger"
/>
);
}
export function AccountSheet() {
const {isSheetOpen, closeSheet, locale, setLocale} = useAccountMenu();
const [activePanel, setActivePanel] = useState<AccountPanelId | null>(null);
// Every way out of the sheet goes through here, so a re-open always starts
// at panel one. Written as one handler rather than an effect watching
// `isSheetOpen`: the reset belongs to the act of closing, not to a later
// render that notices the sheet has closed.
const dismiss = useCallback(() => {
setActivePanel(null);
closeSheet();
}, [closeSheet]);
const handleLeaf = useAccountActions(dismiss);
const back = useCallback(() => setActivePanel(null), []);
return (
<Dialog
isOpen={isSheetOpen}
onOpenChange={(open) => (open ? undefined : dismiss())}
// info: backdrop click and Escape both dismiss, and both close the whole
// menu rather than stepping back a level — the same contract the popover
// has on the desktop.
purpose="info"
width="100%"
maxHeight="85vh"
position={{bottom: 0, left: 0, right: 0}}
padding={4}
className={SHEET_SURFACE}
aria-label="Account menu"
>
{/* overflow-y-auto matters here, where eleven rows plus a header can
outgrow an 85vh cap on a short phone. overscroll-contain stops a
flick at the end of the list from scrolling the page underneath. */}
<VStack
gap={2}
width="100%"
className="overflow-y-auto overscroll-contain"
>
{activePanel ? (
<VStack gap={2} width="100%" className={PANEL_TWO_MOTION}>
<HStack gap={2} vAlign="center">
<IconButton
variant="ghost"
label="Back"
icon={<Icon icon="chevronLeft" />}
onClick={back}
/>
<Text weight="medium">{ACCOUNT_PANELS[activePanel].title}</Text>
</HStack>
<AccountSubPanel
panel={activePanel}
locale={locale}
onLocale={setLocale}
onLeaf={handleLeaf}
/>
</VStack>
) : (
<AccountRootPanel
activePanel={null}
onBranch={(panel) => setActivePanel(panel)}
onLeaf={handleLeaf}
/>
)}
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import {usePathname} from 'next/navigation';
import {
SideNav,
SideNavItem,
SideNavSection,
} from '@astryxdesign/core/SideNav';
import {Divider} from '@astryxdesign/core/Divider';
import {VStack} from '@astryxdesign/core/Layout';
import {AccountMenu} from './AccountMenu';
import {SideNavBrand} from './SideNavBrand';
import {NAV_RAIL_ID, useSidebar} from './SidebarProvider';
import {FOOTER_NAV, PRIMARY_NAV, isNavActive} from './nav-config';
/**
* 260px expanded, 84px collapsed, Settings pinned to the bottom.
*
* ── Why the collapsed rail is restyled ────────────────────────────────────
* Astryx's collapsed rail is --spacing-12 (48px) holding 32px items around
* 16px icons. At a glance that reads as a scrollbar with symbols in it, and a
* 32px hit area is under every touch-target guideline there is. The rail is
* widened to 84px with 44px items around 24px icons — the Linear/Cursor
* proportion, where a collapsed rail still reads as navigation.
*
* None of that is expressible through `defineTheme({components})`: the theme
* layer can only reach an element's own themeProps, and SideNav publishes no
* collapsed variant (`themeProps('side-nav')` takes only `mode`), while the
* icon size is hardcoded to `sm` inside SideNavItem. So the three geometry
* facts are set here, as token-backed Tailwind utilities scoped to this rail:
*
* w-21 84px = --spacing-1 × 21, the rail itself
* .astryx-side-nav-item 44px square, centred with margin-inline auto
* .astryx-icon 24px
*
* They win over Astryx's StyleX classes because astryx.css declares
* `@layer astryx-base` and Tailwind's utilities layer sorts after it —
* cascade layers outrank specificity, so no !important is involved.
*
* ── Why the collapse button is gone from here ─────────────────────────────
* `hasButton: false`. A control that lives inside the sidebar vanishes with
* the sidebar on mobile and, collapsed, sits in the one corner a user is
* least likely to look. NavMenuButton in the top bar owns all three states —
* see SidebarProvider.
*/
/** Applied only while collapsed. See the geometry note above. */
const COLLAPSED_RAIL = [
'w-21',
'[&_.astryx-side-nav-item]:size-11',
'[&_.astryx-side-nav-item]:mx-auto',
'[&_.astryx-icon]:size-6',
].join(' ');
/**
* 200ms ease-in-out on width. Astryx's own --duration-medium is 260ms and its
* --ease-standard is an ease-out; a rail that only decelerates into place
* reads as heavier than one eased at both ends, which is what the brief asks
* for. The logo, labels and icons animate on their own properties at the same
* duration so the whole rail arrives together.
*/
const RAIL_MOTION = 'transition-[width] duration-200 ease-in-out';
export function AppSideNav() {
const pathname = usePathname();
const {isCollapsed, toggle} = useSidebar();
return (
<SideNav
id={NAV_RAIL_ID}
className={`${RAIL_MOTION} ${isCollapsed ? COLLAPSED_RAIL : ''}`}
collapsible={{
isCollapsed,
// SideNav still drives its own children (icon-only items, tooltips)
// off this flag; it just no longer owns it.
onCollapsedChange: toggle,
hasButton: false,
}}
// Not resizable. Two reasons, one of them measurable: the drag handle
// renders in overlay mode inside a wrapper that comes out 1px wider than
// the panel it sits in, which gave the sidebar a permanent horizontal
// scrollbar along its bottom edge (measured: scrollWidth 261 in a 260px
// panel). And a rail whose width is a stored user preference cannot also
// be the fixed 260/84 pair the collapse animation moves between —
// resizing and collapsing were fighting for the same property.
header={<SideNavBrand />}
footer={
<VStack gap={3} width="100%">
<Divider />
<AccountMenu />
</VStack>
}
>
<SideNavSection title="Workspace" isHeaderHidden>
{PRIMARY_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
isSelected={isNavActive(pathname, item.href)}
/>
))}
</SideNavSection>
</SideNav>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import {TopNav} from '@astryxdesign/core/TopNav';
import {HStack} from '@astryxdesign/core/Layout';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {Link} from '@astryxdesign/core/Link';
import {ICONS} from '@/shared/utils/icons';
import {BrandMark} from '@/shared/components/brand/BrandLogo';
import {LoyalyAiToggle} from '@/features/loyaly-ai/components/LoyalyAiToggle';
import {NavMenuButton} from './NavMenuButton';
import {useSidebar} from './SidebarProvider';
/**
* Menu, Loyaly AI, notifications — in that order, at every width.
*
* The account avatar used to end this row. It has moved to the bottom-left
* corner of the rail (AccountMenu), where it is the anchor for a cascading
* panel rather than a dropdown. Two entry points to the same menu would put
* the second panel in a corner it has no room to open into.
*
* The menu button goes in `heading`, not `startContent`, and that placement is
* load-bearing rather than cosmetic: below AppShell's breakpoint TopNav
* re-renders in `mobile-bar` mode, which drops `startContent` entirely and
* keeps only `heading` and `endContent`. A menu button in startContent would
* therefore be present on desktop and silently absent on the one screen size
* that cannot do without it.
*
* The lockup rides beside it on mobile only. Above the breakpoint the sidebar
* starts at y=0 and already carries the brand in the window's top-left corner;
* repeating it 260px to the right would be two logos on one line.
*
* The store switcher and range picker used to sit here. They are query scope
* for one page's data, not application chrome, and now live in each page's own
* header (see @/components/scope), so this bar renders identically everywhere.
*/
export function AppTopNav() {
const {isDrawer} = useSidebar();
return (
<TopNav
label="Workspace"
heading={
<HStack gap={2} vAlign="center">
<NavMenuButton />
{isDrawer ? (
<Link href="/dashboard">
{/*
The heart alone, not the lockup. Measured at 320px — the
narrowest size in the support matrix — the 110px wordmark plus
the four end controls came to 322px and pushed the account
menu over the edge. The full lockup still appears at full size
in the drawer this button opens, and in the rail above the
breakpoint.
*/}
<BrandMark size={26} />
</Link>
) : null}
</HStack>
}
endContent={
<HStack gap={1} vAlign="center">
<LoyalyAiToggle />
<IconButton
icon={<Icon icon={ICONS.notifications} />}
label="Notifications"
variant="ghost"
/>
</HStack>
}
/>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import {useRef} from 'react';
import {MobileNav} from '@astryxdesign/core/MobileNav';
import {SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
import {Divider} from '@astryxdesign/core/Divider';
import {usePathname} from 'next/navigation';
import {BrandLogo} from '@/shared/components/brand/BrandLogo';
import {AccountSheetTrigger} from './AccountSheet';
import {NAV_DRAWER_ID, useSidebar} from './SidebarProvider';
import {FOOTER_NAV, PRIMARY_NAV, isNavActive} from './nav-config';
/**
* The mobile drawer: navigation only, mirroring the sidebar.
*
* ── The five ways out ─────────────────────────────────────────────────────
* Every one of them resolves to the same `setDrawerOpen(false)` in
* SidebarProvider, so none can leave the others stale:
*
* close button the X below, enlarged to 44px
* backdrop MobileNav closes on a click that lands on the <dialog>
* Escape the native `cancel` event, routed through onOpenChange
* nav item onClick, below — the href still does the navigating
* swipe left the handlers at the bottom of this file
*
* Focus trapping, the top-layer backdrop and focus restoration to the
* hamburger come from `<dialog>.showModal()` and are not reimplemented here.
*
* ── Why the close button is restyled ──────────────────────────────────────
* MobileNav always renders its own ghost X after the `header` slot, so adding
* a second one would give the drawer two close buttons. Instead the built-in
* is enlarged in place to a 44px target around a 24px glyph. It is a flex
* sibling ABOVE the scroll container, not inside it, so it stays put while
* the list scrolls without needing `position: sticky` at all.
*
* The selector reaches through MobileNav's DOM (dialog > drawer > header row)
* because that row carries no themeProps of its own. It is scoped to this one
* component's className, and every value in it is a spacing token.
*/
const DRAWER = [
// 80% of the viewport — enough of the page stays visible behind the
// backdrop that the drawer reads as temporary. `width` below caps it at
// 320px, so on a 430px phone it is 320px, not 344px.
'[&>div]:w-4/5',
// The close button: 44px, up from MobileNav's default 32px ghost square.
'[&>div>*:first-child>button]:size-11',
// Every glyph in the drawer to 24px. The second rule is for registry icons
// (the close X), which render as a span whose inner <svg> is sized in em —
// resizing only the span leaves a 20px glyph floating in a 24px box.
'[&_.astryx-icon]:size-6',
'[&_.astryx-icon>svg]:size-6',
// 48px rows. Astryx's nav items are 32px, which is a mouse target on a
// surface that only ever sees thumbs.
'[&_.astryx-side-nav-item]:h-12',
].join(' ');
/** Past this much horizontal travel a drag is a dismissal, not a scroll. */
const SWIPE_CLOSE_PX = 60;
/** Beyond this much vertical travel it was a scroll that drifted sideways. */
const SWIPE_DRIFT_PX = 45;
export function MobileMenu() {
const pathname = usePathname();
const {isDrawerOpen, setDrawerOpen, closeDrawer} = useSidebar();
const touchStart = useRef<{x: number; y: number} | null>(null);
return (
<MobileNav
id={NAV_DRAWER_ID}
label="Navigation"
side="start"
width={320}
isOpen={isDrawerOpen}
onOpenChange={setDrawerOpen}
className={DRAWER}
// The lockup, not a text title: the drawer is the only place on mobile
// where the brand can appear at full size, and the header row is going
// to be there for the close button regardless.
header={<BrandLogo height={26} />}
// Swipe-to-dismiss, deliberately without live drag: following the finger
// means fighting the dialog's own transform for the 250ms of the close
// transition, and a threshold gesture is indistinguishable from it at
// the speed people actually flick a drawer shut.
onTouchStart={(e) => {
const t = e.touches[0];
touchStart.current = t ? {x: t.clientX, y: t.clientY} : null;
}}
onTouchEnd={(e) => {
const start = touchStart.current;
const end = e.changedTouches[0];
touchStart.current = null;
if (!start || !end) return;
const dx = end.clientX - start.x;
const dy = Math.abs(end.clientY - start.y);
if (dx < -SWIPE_CLOSE_PX && dy < SWIPE_DRIFT_PX) {
closeDrawer();
}
}}
>
<SideNavSection title="Workspace" isHeaderHidden>
{PRIMARY_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
isSelected={isNavActive(pathname, item.href)}
// Closes on select. The href still does the navigating — this only
// dismisses the sheet so the destination is actually visible.
onClick={closeDrawer}
/>
))}
</SideNavSection>
<Divider />
{/* The same chip as the rail's, in the same last position. It closes the
drawer and asks the shell for the sheet — a sheet opened from inside
this <dialog> could not outlive it. See AccountSheet. */}
<AccountSheetTrigger />
</MobileNav>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/shared/utils/icons';
import {useSidebar} from './SidebarProvider';
/**
* The one control that is never absent.
*
* It renders in the top bar's leading slot at EVERY width — the header is the
* only region of the shell that survives all three navigation states, so it is
* the only place a control can live and still be reachable when the nav it
* governs is collapsed or gone. Anything mounted inside the sidebar disappears
* with the sidebar, which is exactly how the rail became unreopenable.
*
* Three states, one button:
* drawer closed ☰ → open the overlay
* drawer open ☰ → close it (aria-expanded carries the state)
* rail collapsed panel-open → expand to 260px
* rail expanded panel-close → collapse to 84px
*
* 44px square below the breakpoint: IconButton's `lg` is 36px, which clears
* neither the WCAG 2.2 target-size minimum nor a thumb. `size-11` is the
* spacing-11 token (44px) through the Tailwind bridge, and it sits in the
* utilities layer so it wins over Astryx's own sizing without !important.
*/
export function NavMenuButton() {
const {isDrawer, isDrawerOpen, isCollapsed, toggle, controlsId} =
useSidebar();
const label = isDrawer
? isDrawerOpen
? 'Close navigation menu'
: 'Open navigation menu'
: isCollapsed
? 'Expand navigation'
: 'Collapse navigation';
const icon = isDrawer
? ICONS.menu
: isCollapsed
? ICONS.sidebarOpen
: ICONS.sidebarClose;
return (
<IconButton
icon={<Icon icon={icon} size={isDrawer ? 'md' : 'sm'} />}
label={label}
tooltip={label}
variant="ghost"
size={isDrawer ? 'lg' : 'md'}
onClick={toggle}
// Expanded/collapsed is the drawer's state; above the breakpoint the rail
// is always present, so only its width changes and aria-expanded would be
// a lie. aria-controls still points at the rail so the relationship holds.
aria-expanded={isDrawer ? isDrawerOpen : undefined}
aria-controls={controlsId}
className={isDrawer ? 'size-11' : undefined}
data-testid="nav-menu-button"
/>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import {Link} from '@astryxdesign/core/Link';
import {HStack} from '@astryxdesign/core/Layout';
import {useSideNavCollapse} from '@astryxdesign/core/SideNav';
import {BrandLogo, BrandMark} from '@/shared/components/brand/BrandLogo';
/**
* SideNav's header slot: the lockup and the heart, cross-faded.
*
* ── Why both marks are always mounted ─────────────────────────────────────
* Swapping `{isCollapsed ? <Mark/> : <Logo/>}` cannot animate — React tears
* one element out and puts the other in, so the brand hard-cuts while the
* rail beside it is still 200ms from arriving, which is the single most
* noticeable jump in the whole collapse. Both marks render on every frame,
* stacked in the same 48px box, and only opacity and scale change. Next/Image
* also gets to keep both files warm, so neither swap costs a fetch.
*
* The box is a FIXED 48px tall and full-width in both states, which is what
* stops the nav below it from shifting: the heart is centred by the layer's
* own alignment rather than by the box changing shape. 48px also matches the
* top bar's height, so the mark sits on the same baseline as the header
* controls across the fold — the Linear/Vercel arrangement.
*
* ── One link, two images ──────────────────────────────────────────────────
* The layers are decoration inside a single anchor, not two anchors: two
* would mean two tab stops for one destination, one of them invisible.
* `pointer-events-none` keeps the images from eating the anchor's clicks, and
* only the lockup carries alt text — the heart passes `alt=""` so the
* accessible name stays "Loyaly.ai" rather than doubling.
*
* The 'Merchant OS' subtitle that used to sit under the lockup is gone. It
* could not survive the collapse without either changing the header's height
* (the jump above) or reserving a dead line in an 84px rail, and the product
* name is already carried by the document title and the lockup itself.
*/
/** Shared by both layers — they occupy the same box and differ only in fade. */
const LAYER = [
'absolute inset-0 pointer-events-none',
// `scale`, not `transform`: Tailwind v4's scale-* utilities set the
// standalone `scale` property, so a transition listed against `transform`
// animates nothing and the mark pops in at full size. Measured — computed
// transform stayed `none` while computed scale was already 0.95.
'transition-[opacity,scale] duration-200 ease-in-out',
// The lockup is 170px wide inside an 84px rail for the length of the
// collapse. Without this it squashes to 65px on the way out; with it, the
// rail's own `overflow: hidden` clips it instead, which is the same thing
// Linear does and the only one of the two that looks deliberate.
// max-w-none as well as shrink-0: Tailwind's preflight caps every img at
// `max-width: 100%`, which re-imposes the squash that shrink-0 just removed.
'[&_img]:shrink-0 [&_img]:max-w-none',
].join(' ');
export function SideNavBrand() {
const {isCollapsed} = useSideNavCollapse();
return (
// paddingInline 8px expanded so the lockup's left edge lands on the same
// 16px optical margin as the nav glyphs below it (8px from SideNav's own
// sticky header padding + 8px here); 0 collapsed, where the layer centres
// the heart in the 84px rail instead.
<HStack
className="relative h-12 w-full"
paddingInline={isCollapsed ? 0 : 2}
vAlign="center"
>
{/* The image alt is the link's accessible name — no `label` here, or it
would override it. */}
<Link href="/dashboard" className="absolute inset-0 block">
<HStack
className={`${LAYER} ${
isCollapsed ? 'opacity-0 scale-95' : 'opacity-100 scale-100'
}`}
vAlign="center"
hAlign="start"
>
{/* 34px tall → 170px wide. The rail is 260px and gives up 32px to
padding, so the lockup clears its box and still reads as the
largest thing in the sidebar. */}
<BrandLogo height={34} priority />
</HStack>
<HStack
className={`${LAYER} ${
isCollapsed ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
}`}
vAlign="center"
hAlign="center"
>
<BrandMark size={32} alt="" priority />
</HStack>
</Link>
</HStack>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {useBreakpoint, isSideNavInline} from '@/shared/hooks/useBreakpoint';
import {usePersistentTriState} from '@/shared/hooks/usePersistentFlag';
/**
* One source of truth for "where is the navigation right now".
*
* Before this, three components each owned a piece: SideNav held its own
* collapse flag, the layout held the drawer flag, and AppShell injected a
* hamburger only in its mobile top bar. Nothing could see the whole picture,
* which is how the sidebar became unreachable — collapsed on desktop with no
* control left to expand it, and closed on mobile with the toggle living in a
* slot that only exists below 640px.
*
* Hoisting both flags here means a single button (NavMenuButton) can render in
* the header at every width and always know what it should do, and the drawer
* can never be open in a layout that has no way to close it.
*
* ── The three states ──────────────────────────────────────────────────────
* drawer <640 overlay, closed by default, opened by the hamburger
* rail 640+ inline; collapsed (84px) or expanded (260px)
*
* `isDrawer` deliberately mirrors AppShell's own `mobileNav.breakpoint` of
* 'sm' via isSideNavInline — the two must agree or you get two hamburgers or
* none. See lib/breakpoints.
*/
/** The drawer's DOM id, so the header button's `aria-controls` resolves. */
export const NAV_DRAWER_ID = 'workspace-nav-drawer';
/** The inline rail's DOM id, for the same reason above the breakpoint. */
export const NAV_RAIL_ID = 'workspace-nav-rail';
const COLLAPSE_KEY = 'loyaly.sidenav.collapsed';
interface SidebarValue {
/** True below 640px, where the nav is an overlay rather than a column. */
isDrawer: boolean;
/** Rail state. Meaningless while `isDrawer` — the drawer is never a rail. */
isCollapsed: boolean;
isDrawerOpen: boolean;
setDrawerOpen: (open: boolean) => void;
closeDrawer: () => void;
/** Open/close below the breakpoint, collapse/expand above it. */
toggle: () => void;
/** Whichever element the header button currently controls. */
controlsId: string;
}
const SidebarContext = createContext<SidebarValue | null>(null);
export function SidebarProvider({children}: {children: React.ReactNode}) {
const bp = useBreakpoint();
const isDrawer = !isSideNavInline(bp);
// Tri-state, not a boolean: a tablet has to start collapsed WITHOUT that
// being a stored preference, or the first desktop visit inherits it.
const [storedCollapsed, setStoredCollapsed] =
usePersistentTriState(COLLAPSE_KEY);
const isCollapsed = storedCollapsed ?? bp === 'tablet';
const [isMenuOpen, setMenuOpen] = useState(false);
// DERIVED, not reset in an effect: a resize past the breakpoint would
// otherwise leave the drawer mounted over a layout whose hamburger has
// turned into a collapse button — a nav trap with no visible way out.
// Gating on the current breakpoint costs no extra render pass.
const isDrawerOpen = isDrawer && isMenuOpen;
const closeDrawer = useCallback(() => setMenuOpen(false), []);
const toggle = useCallback(() => {
if (isDrawer) {
setMenuOpen((open) => !open);
} else {
setStoredCollapsed(!isCollapsed);
}
}, [isDrawer, isCollapsed, setStoredCollapsed]);
// Body scroll lock. MobileNav's native <dialog> already clips the scroll on
// <html>, but iOS Safari still rubber-bands <body> underneath the top layer,
// which slides the page behind a drawer that stays put.
useEffect(() => {
if (!isDrawerOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [isDrawerOpen]);
const value = useMemo(
() => ({
isDrawer,
isCollapsed,
isDrawerOpen,
setDrawerOpen: setMenuOpen,
closeDrawer,
toggle,
controlsId: isDrawer ? NAV_DRAWER_ID : NAV_RAIL_ID,
}),
[isDrawer, isCollapsed, isDrawerOpen, closeDrawer, toggle],
);
return <SidebarContext value={value}>{children}</SidebarContext>;
}
export function useSidebar(): SidebarValue {
const ctx = useContext(SidebarContext);
if (!ctx) {
throw new Error('useSidebar must be used inside <SidebarProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,170 @@
'use client';
import {AppShell} from '@astryxdesign/core/AppShell';
import {
Layout,
LayoutContent,
LayoutHeader,
LayoutPanel,
VStack,
} from '@astryxdesign/core/Layout';
import {AccountDialogs} from '@/shared/layouts/workspace/AccountDialogs';
import {AccountMenuProvider} from '@/shared/layouts/workspace/AccountMenuProvider';
import {AccountSheet} from '@/shared/layouts/workspace/AccountSheet';
import {AppSideNav} from '@/shared/layouts/workspace/AppSideNav';
import {AppTopNav} from '@/shared/layouts/workspace/AppTopNav';
import {MobileMenu} from '@/shared/layouts/workspace/MobileMenu';
import {SidebarProvider, useSidebar} from '@/shared/layouts/workspace/SidebarProvider';
import {useWorkspaceShortcuts} from '@/shared/layouts/workspace/useWorkspaceShortcuts';
import {LoyalyAiPanel} from '@/features/loyaly-ai/components/LoyalyAiPanel';
import {LoyalyAiSlideOver} from '@/features/loyaly-ai/components/LoyalyAiSlideOver';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {
useBreakpoint,
isPanelInline,
isSideNavInline,
assistantWidth,
contentMaxWidth,
} from '@/shared/hooks/useBreakpoint';
/**
* The three-column shell, and the reason Loyaly AI survives navigation.
*
* Next's App Router preserves a layout's element identity across sibling route
* changes, so /dashboard → /staff swaps only {children}. Everything else here —
* nav, scroll position, Loyaly AI — is untouched.
*
* Structure, and why AppShell alone is not enough: AppShell renders
* Layout{header, start, content} internally and exposes no end/panel slot. The
* Loyaly AI column therefore comes from a NESTED Layout inside its children.
*
* AppShell(sideNav)
* └─ Layout(header = TopNav, content = page, end = LayoutPanel > Loyaly AI)
*
* The top nav is deliberately NOT passed to AppShell's `topNav` slot. That slot
* renders Layout{header} at the shell root, which spans the full viewport width
* and pushes the sidebar — and therefore the branding — 48px down the screen.
* Measured in the browser, the logo sat at y=64 with the top-left 260x48 region
* empty. Moving the bar into the content column's own header starts the sidebar
* at y=0, so the brand anchors in the window's top-left corner and the bar
* begins where the content does. This is the arrangement Linear, Cursor and
* Stripe use, and no amount of padding inside the sidebar could produce it.
*
* The one thing given up: with no `topNav`, AppShell drops the --radius-page
* corner it draws where the top bar meets the sidebar. The content area still
* paints elevated --color-background-surface (#0F0F10) against the #000 rails,
* and with a full-height sidebar that corner had nothing left to round.
*/
export function WorkspaceShell({children}: {children: React.ReactNode}) {
return (
<SidebarProvider>
{/* Above ShellFrame because the account menu's sheet and its two help
dialogs are mounted at the shell, out of the nav drawer's subtree —
see AccountMenuProvider. */}
<AccountMenuProvider>
<ShellFrame>{children}</ShellFrame>
</AccountMenuProvider>
</SidebarProvider>
);
}
/**
* Split from WorkspaceShell only so it can READ the sidebar context that
* WorkspaceShell mounts — a provider's own component sits above its value.
*/
function ShellFrame({children}: {children: React.ReactNode}) {
const bp = useBreakpoint();
const {isOpen, setIsOpen, panelMode, panelWidth} = useLoyalyAi();
const inline = isPanelInline(bp);
const showInlinePanel = inline && isOpen;
const isFullscreen = showInlinePanel && panelMode === 'fullscreen';
const isExpanded = showInlinePanel && panelMode === 'expanded';
let currentWidth: number | string = assistantWidth(bp);
if (showInlinePanel) {
if (isFullscreen) {
currentWidth = '100%';
} else if (isExpanded) {
currentWidth = Math.max(panelWidth, 800);
} else {
currentWidth = panelWidth;
}
}
// Both flags come from SidebarProvider now. AppShell keeps a copy of the
// open state for its own context (MobileNav reads it for aria wiring), but
// ours is the one that decides — uncontrolled, there would be no way to
// dismiss the drawer when a nav item is selected.
const {isDrawerOpen, setDrawerOpen} = useSidebar();
// Mounted once, here, so the bindings exist on every route and there is
// nowhere for two copies of them to disagree. See shortcuts.ts.
useWorkspaceShortcuts();
// Below the breakpoint the rail is gone — it lives in the drawer — so there
// is no top-left corner to anchor the brand to and nothing for the bar to
// start after. Hand the top nav back to AppShell there. Keeping it in the
// content column instead pushed the SideNav into AppShell's
// `autoMobileTopBar` fallback, which crops the lockup into a 48px bar.
const sideNavInline = isSideNavInline(bp);
return (
<AppShell
variant="elevated"
height="fill"
contentPadding={0}
topNav={sideNavInline ? undefined : <AppTopNav />}
sideNav={<AppSideNav />}
mobileNav={{
breakpoint: 'sm',
isOpen: isDrawerOpen,
onOpenChange: setDrawerOpen,
hasToggle: false,
content: <MobileMenu />,
}}
>
<Layout
height="fill"
header={
sideNavInline ? (
<LayoutHeader padding={0}>
<AppTopNav />
</LayoutHeader>
) : undefined
}
content={
!isFullscreen ? (
<LayoutContent padding={5}>
<VStack
width="100%"
maxWidth={contentMaxWidth(bp)}
className={bp === 'ultrawide' ? 'mx-auto' : undefined}
>
{children}
</VStack>
</LayoutContent>
) : undefined
}
end={
showInlinePanel ? (
<LayoutPanel
hasDivider
width={currentWidth as any}
padding={0}
role="complementary"
label="Loyaly AI"
className="transition-[width] duration-300 ease-out"
>
<LoyalyAiPanel onClose={() => setIsOpen(false)} />
</LayoutPanel>
) : undefined
}
/>
{!inline ? <LoyalyAiSlideOver /> : null}
<AccountSheet />
<AccountDialogs />
</AppShell>
);
}

View File

@@ -0,0 +1,168 @@
import {SETTINGS_NAV} from '@/features/settings/config/settingsNav';
import {ICONS} from '@/shared/utils/icons';
import type {IconType} from '@astryxdesign/core/Icon';
/**
* The account menu, as a two-level cascade rather than a flat list.
*
* Panel one is identity plus four branches and a way out. Every branch opens a
* SECOND panel beside the first — it never navigates. Only a leaf navigates,
* and leaves only exist in panel two (and on the sign-out row, which ends the
* session instead).
*
* That distinction is the whole shape of the file: `AccountBranch` carries a
* `panel`, `AccountLeaf` carries a destination or an `action`, and nothing
* carries both. A row cannot accidentally do both things because there is no
* type that permits it.
*/
export type AccountPanelId = 'settings' | 'profile' | 'language' | 'help';
/**
* Rows that resolve to something. `href` routes, `externalHref` opens a tab,
* `action` is handled by the menu itself.
*/
export interface AccountLeaf {
label: string;
icon?: IconType;
/** Shown under the label. Use it for a live value, not for decoration. */
description?: string;
/** Internal destination. */
href?: string;
/** Opens in a new tab instead of routing. Mutually exclusive with href. */
externalHref?: string;
/** Non-navigational rows: the session, and the two help surfaces. */
action?: 'signOut' | 'shortcuts' | 'about';
/** Only sign-out may carry semantic colour — see the monochrome rule. */
tone?: 'danger';
}
/** Rows that open panel two. The one thing they must never do is navigate. */
export interface AccountBranch {
label: string;
icon: IconType;
panel: AccountPanelId;
description?: string;
}
export type AccountRowSpec = AccountLeaf | AccountBranch;
export function isBranch(row: AccountRowSpec): row is AccountBranch {
return 'panel' in row;
}
/**
* Panel one, as two groups separated by a divider.
*
* Four branches and sign-out — deliberately short. Everything that used to sit
* here as a flat list of twelve destinations now lives one level down, where
* it is grouped by what it is rather than stacked by what fit.
*/
export const ACCOUNT_ROOT_GROUPS: AccountRowSpec[][] = [
[
{label: 'Settings', icon: ICONS.settings, panel: 'settings'},
{label: 'Profile', icon: ICONS.profile, panel: 'profile'},
{label: 'Language', icon: ICONS.language, panel: 'language'},
{label: 'Help', icon: ICONS.help, panel: 'help'},
],
[
{
label: 'Log out',
icon: ICONS.signOut,
action: 'signOut',
tone: 'danger',
},
],
];
export interface AccountPanel {
title: string;
rows: AccountLeaf[];
}
/**
* The locales the workspace offers, in the order PreferencesForm lists them.
*
* Display only: there is no locale provider yet, so selecting one marks the
* row and nothing else. The row that actually persists a choice is the
* "Language settings" leaf at the bottom of that panel, which routes to
* Preferences where the Selector writes it.
*/
export interface LocaleOption {
code: 'en' | 'hi' | 'kn' | 'ta';
label: string;
}
export const LOCALE_OPTIONS: LocaleOption[] = [
{code: 'en', label: 'English (US & India)'},
{code: 'hi', label: 'Hindi (हिंदी)'},
{code: 'kn', label: 'Kannada (ಕನ್ನಡ)'},
{code: 'ta', label: 'Tamil (தமிழ்)'},
];
/** The shipped default in PreferencesForm. */
export const DEFAULT_LOCALE: LocaleOption['code'] = 'en';
/**
* Panel two, keyed by the branch that opens it.
*
* `settings` is DERIVED from SETTINGS_NAV rather than restated: the settings
* sub-nav and this panel are the same eleven destinations, and a copy here is
* a copy that goes stale the first time a section is added.
*
* `language` holds only its trailing leaf — the locale rows are stateful and
* come from LOCALE_OPTIONS at render time.
*/
export const ACCOUNT_PANELS: Record<AccountPanelId, AccountPanel> = {
settings: {
title: 'Settings',
rows: SETTINGS_NAV.map(({label, href, icon}) => ({label, href, icon})),
},
profile: {
title: 'Profile',
rows: [
{label: 'Business Details', icon: ICONS.business, href: '/settings'},
{label: 'Subscription', icon: ICONS.billing, href: '/settings/billing'},
{label: 'Activity', icon: ICONS.analytics, href: '/activity'},
// Devices and Sessions are two views of the same Security screen today.
// They stay two rows because they are two questions a merchant asks;
// when Security splits into tabs, only the href moves.
{label: 'Devices', icon: ICONS.security, href: '/settings/security'},
{label: 'Sessions', icon: ICONS.sessions, href: '/settings/security'},
],
},
language: {
title: 'Language',
rows: [
{
label: 'Language settings',
icon: ICONS.preferences,
href: '/settings/preferences',
},
],
},
help: {
title: 'Help',
rows: [
// Placeholder URLs on the brand's own domain — point them at the real
// docs and support hosts when there are any.
{
label: 'Documentation',
icon: ICONS.docs,
externalHref: 'https://loyaly.ai/docs',
},
{label: 'Keyboard Shortcuts', icon: ICONS.keyboard, action: 'shortcuts'},
{
label: 'Report Bug',
icon: ICONS.alert,
externalHref: 'https://loyaly.ai/support/bug',
},
{
label: 'Contact Support',
icon: ICONS.chat,
externalHref: 'https://loyaly.ai/support',
},
{label: 'About Loyaly', icon: ICONS.info, action: 'about'},
],
},
};

View File

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

View File

@@ -0,0 +1,64 @@
/**
* Every keyboard shortcut the workspace answers to.
*
* One array, two consumers: useWorkspaceShortcuts binds them and
* KeyboardShortcutsDialog lists them. That is deliberate — a shortcuts sheet
* maintained separately from the handlers is a shortcuts sheet that lies, and
* this one is reachable from the account menu where a merchant will believe it.
*
* `keys` is Kbd's format: `mod` renders as ⌘ on macOS and Ctrl elsewhere, and
* `binding` below reads the same way.
*/
export type ShortcutId = 'toggleSidebar' | 'toggleLoyalyAi' | 'showShortcuts';
export interface Shortcut {
/** Absent for the ones the browser already implements. */
id?: ShortcutId;
group: string;
keys: string;
label: string;
/** The lowercase `event.key` this fires on, with mod held. */
binding?: string;
}
export const WORKSPACE_SHORTCUTS: Shortcut[] = [
{
id: 'toggleSidebar',
group: 'Navigation',
keys: 'mod+b',
label: 'Collapse or expand the sidebar',
binding: 'b',
},
{
id: 'toggleLoyalyAi',
group: 'Navigation',
keys: 'mod+j',
label: 'Show or hide Loyaly AI',
binding: 'j',
},
{
id: 'showShortcuts',
group: 'Help',
keys: 'mod+/',
label: 'Open this list',
binding: '/',
},
// No binding: the native popover and dialog light-dismiss already do these,
// and re-implementing them would only give the two a chance to disagree.
{
group: 'Overlays',
keys: 'escape',
label: 'Close the account menu, a panel or a dialog',
},
{
group: 'Overlays',
keys: 'tab',
label: 'Move through the rows of an open panel',
},
];
/** The groups, in the order they should be listed. */
export const SHORTCUT_GROUPS = [
...new Set(WORKSPACE_SHORTCUTS.map((s) => s.group)),
];

View File

@@ -0,0 +1,49 @@
'use client';
import {useCallback} from 'react';
import {useRouter} from 'next/navigation';
import {useSession} from '@/features/auth/providers/SessionProvider';
import type {AccountLeaf} from './account-menu';
import {useAccountMenu} from './AccountMenuProvider';
/**
* What a leaf row actually does, in one place.
*
* The popover and the mobile sheet are two containers around the same rows, so
* "close, then resolve" is written once here rather than twice. Closing first
* is deliberate in every branch: a menu left floating over the page it just
* opened is the thing this menu exists to stop doing.
*
* External rows never reach this — they stay real anchors so middle-click and
* "open in new tab" behave. See LeafRow in AccountPanels.
*/
export function useAccountActions(close: () => void) {
const router = useRouter();
const {logout} = useSession();
const {openDialog} = useAccountMenu();
return useCallback(
(leaf: AccountLeaf) => {
close();
if (leaf.action === 'signOut') {
// Ending the session is a server round trip (the cookie is httpOnly,
// so only the server can revoke it) and the redirect happens inside
// logout() once it has. Awaiting is deliberately skipped: the menu has
// already closed, and logout resolves even when the request fails.
void logout();
return;
}
if (leaf.action === 'shortcuts' || leaf.action === 'about') {
openDialog(leaf.action);
return;
}
if (leaf.href) {
router.push(leaf.href);
}
},
[close, logout, openDialog, router],
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import {useEffect} from 'react';
import {useLoyalyAi} from '@/features/loyaly-ai/providers/LoyalyAiProvider';
import {useAccountMenu} from './AccountMenuProvider';
import {useSidebar} from './SidebarProvider';
import {WORKSPACE_SHORTCUTS, type ShortcutId} from './shortcuts';
/** Where a keystroke belongs to the field, not the app. */
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
);
}
/**
* Binds the shortcuts listed in shortcuts.ts. Mounted once, by the shell.
*
* Every one of them drives a control that already exists — the sidebar toggle,
* the Loyaly AI toggle, the shortcuts dialog — so nothing here is reachable only
* by keyboard. That is the rule the Kbd guidance states: a shortcut supplements
* a visible control, it does not replace one.
*/
export function useWorkspaceShortcuts() {
const {toggle: toggleSidebar} = useSidebar();
const {toggle: toggleLoyalyAi} = useLoyalyAi();
const {openDialog} = useAccountMenu();
useEffect(() => {
const actions: Record<ShortcutId, () => void> = {
toggleSidebar,
toggleLoyalyAi,
showShortcuts: () => openDialog('shortcuts'),
};
const onKeyDown = (event: KeyboardEvent) => {
// metaKey on macOS, ctrlKey elsewhere — the same either/or `mod` renders
// as. Not `event.getModifierState`, which cannot express that choice.
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
if (isTyping(event.target)) return;
const key = event.key.toLowerCase();
const match = WORKSPACE_SHORTCUTS.find(
(shortcut) => shortcut.binding === key && shortcut.id,
);
if (!match?.id) return;
event.preventDefault();
actions[match.id]();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [toggleSidebar, toggleLoyalyAi, openDialog]);
}

46
src/shared/mock/rng.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* Seeded, deterministic pseudo-randomness.
*
* Every generator derives its seed from the store id and the metric name, so
* the same request always returns the same numbers. That matters more than it
* sounds: when the whole point of this phase is judging a design, numbers that
* reshuffle on every reload make it impossible to tell a layout change from a
* data change — and it keeps screenshots stable.
*/
export function hashSeed(...parts: (string | number)[]): number {
let h = 2166136261;
for (const part of parts) {
const s = String(part);
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
}
return h >>> 0;
}
/** mulberry32 — small, fast, good enough for fixtures. */
export function mulberry32(seed: number): () => number {
let a = seed;
return function next() {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function createRng(...parts: (string | number)[]) {
const next = mulberry32(hashSeed(...parts));
return {
next,
/** Integer in [min, max]. */
int: (min: number, max: number) =>
Math.floor(next() * (max - min + 1)) + min,
/** Float in [min, max). */
float: (min: number, max: number) => next() * (max - min) + min,
pick: <T>(arr: readonly T[]): T => arr[Math.floor(next() * arr.length)],
};
}

View File

@@ -0,0 +1,94 @@
'use client';
import {createContext, useContext, useMemo, useState} from 'react';
/**
* Workspace-wide query scope: which store, and over what period.
*
* This lives ABOVE the route tree (mounted in app/providers.tsx) for the same
* reason Loyaly AI does. A server-component page could not re-render from
* these controls without a router navigation, and a navigation would remount
* Loyaly AI. So scope is client state, and data panels read it.
*
* The controls that write it are NOT shell — they render in each page's own
* header (@/components/scope). Keeping the state up here is what lets a store
* or period chosen on the dashboard still be in force on Staff or LYTs, while
* a page that ignores scope simply shows no control for it.
*/
export type RangeKey = '7d' | '30d' | '90d' | 'mtd' | 'ytd' | 'custom';
export const RANGE_LABELS: Record<RangeKey, string> = {
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
mtd: 'Month to date',
ytd: 'Year to date',
custom: 'Custom range',
};
export interface CustomDateRange {
start: string;
end: string;
}
export interface StoreOption {
id: string;
name: string;
}
/**
* Store list for the switcher. Deliberately static: the switcher must render
* before any data loads, and a spinner in a page header on every load reads
* as jank. The authoritative list comes from /api/stores once Store lands.
*/
export const STORE_OPTIONS: StoreOption[] = [
{id: 'blr-indiranagar', name: 'Indiranagar Flagship'},
{id: 'blr-koramangala', name: 'Koramangala'},
{id: 'blr-whitefield', name: 'Whitefield'},
{id: 'che-anna-nagar', name: 'Anna Nagar'},
{id: 'hyd-jubilee', name: 'Jubilee Hills'},
];
export type StoreScope = 'all' | (string & {});
interface WorkspaceValue {
storeId: StoreScope;
setStoreId: (id: StoreScope) => void;
range: RangeKey;
setRange: (r: RangeKey) => void;
customRange: CustomDateRange | null;
setCustomRange: (cr: CustomDateRange | null) => void;
stores: StoreOption[];
}
const WorkspaceContext = createContext<WorkspaceValue | null>(null);
export function WorkspaceProvider({children}: {children: React.ReactNode}) {
const [storeId, setStoreId] = useState<StoreScope>('all');
const [range, setRange] = useState<RangeKey>('30d');
const [customRange, setCustomRange] = useState<CustomDateRange | null>(null);
const value = useMemo(
() => ({
storeId,
setStoreId,
range,
setRange,
customRange,
setCustomRange,
stores: STORE_OPTIONS,
}),
[storeId, range, customRange],
);
return <WorkspaceContext value={value}>{children}</WorkspaceContext>;
}
export function useWorkspace(): WorkspaceValue {
const ctx = useContext(WorkspaceContext);
if (!ctx) {
throw new Error('useWorkspace must be used inside <WorkspaceProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,114 @@
import type {NextRequest} from 'next/server';
import {getServerSession} from '@/features/auth/services/serverSession';
import type {ApiErrorCode, ApiFailure, ApiSuccess, RangeKey} from '@/shared/types/api';
const RANGES: RangeKey[] = ['7d', '30d', '90d', 'mtd', 'ytd'];
export interface Query {
range: RangeKey;
storeId: string;
/** Dev-only forced state. */
state?: 'error' | 'empty' | 'loading';
delayMs: number;
/**
* Request time, resolved once per request and threaded through every
* generator. Fixtures must never call Date.now() themselves, or two panels
* in the same render would disagree about what "today" is.
*/
nowMs: number;
}
/**
* The authorisation check every data route starts with.
*
* Returns a 401 Response to bail out with, or null to continue:
*
* const denied = await requireApiSession();
* if (denied) return denied;
*
* ── Why this exists when the proxy already gates /api ─────────────────────
* Next's own guidance is explicit that proxy coverage is not a substitute for
* checking inside the handler: a matcher change, a route move or a Server
* Function can all take a path out from under the proxy without anyone
* noticing, and the failure mode is silent — data starts being served to
* anonymous callers and nothing errors. This check cannot be routed around,
* because it runs where the data is.
*/
export async function requireApiSession(): Promise<Response | null> {
const session = await getServerSession();
if (session) return null;
return fail('unauthorized', 'Sign in to continue.', 401);
}
export function parseQuery(req: NextRequest): Query {
const p = req.nextUrl.searchParams;
const range = p.get('range');
const delay = Number(p.get('_delay') ?? 0);
const state = p.get('_state');
return {
range: RANGES.includes(range as RangeKey) ? (range as RangeKey) : '30d',
storeId: p.get('storeId') || 'all',
state:
state === 'error' || state === 'empty' || state === 'loading'
? state
: undefined,
delayMs: Number.isFinite(delay) ? Math.min(Math.max(delay, 0), 15000) : 0,
nowMs: Date.now(),
};
}
export function ok<T>(data: T, q: Query): Response {
const body: ApiSuccess<T> = {
data,
meta: {
generatedAt: new Date(q.nowMs).toISOString(),
range: q.range,
storeId: q.storeId,
},
};
return Response.json(body, {
// Fixtures are deterministic but time-derived; never let a CDN pin them.
headers: {'cache-control': 'no-store'},
});
}
export function fail(
code: ApiErrorCode,
message: string,
status = 500,
): Response {
const body: ApiFailure = {error: {code, message}};
return Response.json(body, {status, headers: {'cache-control': 'no-store'}});
}
/**
* Dev-only state simulation — what makes "loading / empty / error are properly
* handled" a checkable claim rather than an aspiration.
*
* ?_state=error → 500 ApiFailure
* ?_state=empty → empty payload
* ?_delay=3000 → stall, to inspect skeletons
*
* Returns a Response to short-circuit with, or null to continue. Compiled out
* of production behaviour by the NODE_ENV guard.
*/
export async function simulate(q: Query): Promise<Response | null> {
if (process.env.NODE_ENV === 'production') return null;
if (q.delayMs > 0) {
await new Promise((r) => setTimeout(r, q.delayMs));
}
if (q.state === 'error') {
return fail('internal', 'Simulated failure (?_state=error)');
}
if (q.state === 'loading') {
await new Promise((r) => setTimeout(r, 60000));
}
return null;
}
/** True when the caller asked for an empty payload. */
export function wantsEmpty(q: Query): boolean {
return process.env.NODE_ENV !== 'production' && q.state === 'empty';
}

View File

@@ -0,0 +1,164 @@
import type {ApiResponse, RangeKey} from '@/shared/types/api';
import {isFailure} from '@/shared/types/api';
/**
* The backend-swap seam. Nothing above this file knows a URL.
*
* Point NEXT_PUBLIC_API_BASE at a real host and every request bypasses the
* local route handlers. Node, Nest, Spring, Laravel — as long as responses
* match the envelope in shared/types/api, no feature file changes.
*
* Two shapes live here and they are not interchangeable:
*
* Endpoint<T> + fetchEndpoint declarative GETs, consumed by useResource,
* keyed by URL so a scope change refetches
* getJson / postJson imperative calls (login, logout, saves),
* where the caller needs the status back
*
* Credentials ride on every request: the session is an httpOnly cookie, so
* omitting them would authenticate nothing.
*/
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? '';
// ---------------------------------------------------------------------------
// Declarative endpoints — what useResource consumes
// ---------------------------------------------------------------------------
export interface Endpoint<T> {
path: string;
params: Record<string, string>;
/** Phantom field — carries the payload type through to useResource. */
__type?: T;
}
/** The query scope every analytics endpoint is filtered by. */
export interface Scope {
range: RangeKey;
storeId: string;
}
/** Build a scope-filtered endpoint. Feature repositories only. */
export function scopedEndpoint<T>(
path: string,
scope: Scope,
extra: Record<string, string> = {},
): Endpoint<T> {
return {path, params: {range: scope.range, storeId: scope.storeId, ...extra}};
}
export function endpointUrl(e: Endpoint<unknown>): string {
const qs = new URLSearchParams(e.params).toString();
return `${BASE}${e.path}${qs ? `?${qs}` : ''}`;
}
export async function fetchEndpoint<T>(
e: Endpoint<T>,
signal?: AbortSignal,
): Promise<ApiResponse<T>> {
const res = await fetch(endpointUrl(e), {
signal,
cache: 'no-store',
credentials: 'same-origin',
});
// A non-JSON body (proxy error page, 502 HTML) must surface as a typed
// failure rather than throwing a SyntaxError deep inside the hook.
let body: unknown;
try {
body = await res.json();
} catch {
return {
error: {code: 'internal', message: `Unexpected response (${res.status})`},
};
}
return body as ApiResponse<T>;
}
// ---------------------------------------------------------------------------
// Imperative calls — mutations and one-shot reads
// ---------------------------------------------------------------------------
/**
* A TRANSPORT outcome, not a domain one. `ok` means the request completed with
* a 2xx and a parseable envelope; turning `message`/`field` into something a
* user should read is the service layer's job.
*/
export interface HttpResult<T> {
ok: boolean;
status: number;
data?: T;
message?: string;
/** Server-attributed input name, when a failure belongs to one field. */
field?: string;
}
async function request<T>(
path: string,
init?: RequestInit,
): Promise<HttpResult<T>> {
let res: Response;
try {
res = await fetch(`${BASE}${path}`, {
cache: 'no-store',
credentials: 'same-origin',
...init,
});
} catch (err) {
// Offline, DNS, CORS — there is no status to report, so 0 stands for
// "never reached the server", which callers distinguish from a 4xx.
return {
ok: false,
status: 0,
message: (err as Error)?.message ?? 'Network request failed',
};
}
let body: unknown;
try {
body = await res.json();
} catch {
return {
ok: false,
status: res.status,
message: `Unexpected response (${res.status})`,
};
}
const envelope = body as ApiResponse<T> & {field?: string};
if (!res.ok || isFailure(envelope)) {
return {
ok: false,
status: res.status,
message: isFailure(envelope) ? envelope.error.message : undefined,
field: envelope.field,
};
}
return {ok: true, status: res.status, data: envelope.data};
}
export function getJson<T>(path: string): Promise<HttpResult<T>> {
return request<T>(path);
}
export function postJson<T>(
path: string,
body: unknown,
): Promise<HttpResult<T>> {
return request<T>(path, {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify(body),
});
}
export function patchJson<T>(
path: string,
body: unknown,
): Promise<HttpResult<T>> {
return request<T>(path, {
method: 'PATCH',
headers: {'content-type': 'application/json'},
body: JSON.stringify(body),
});
}

43
src/shared/types/api.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* The wire format.
*
* Imported by BOTH the route handlers and the client hooks, so any drift
* between server and client is a type error rather than a runtime surprise.
* When a real backend arrives this file is the negotiation artifact — it is
* already the contract, not a description of one.
*/
export type RangeKey = '7d' | '30d' | '90d' | 'mtd' | 'ytd' | 'custom';
export interface ApiMeta {
/** The SERVER's clock. Every relative time in the UI measures against it. */
generatedAt: string;
/**
* The scope the server actually applied, echoed back. Optional because not
* every endpoint is scoped — auth has no range and no store, and forcing it
* to invent one would make the envelope lie about what it answered.
*/
range?: RangeKey;
storeId?: string;
}
export interface ApiSuccess<T> {
data: T;
meta: ApiMeta;
}
export type ApiErrorCode =
| 'internal'
| 'not_found'
| 'bad_request'
| 'unauthorized';
export interface ApiFailure {
error: {code: ApiErrorCode; message: string};
}
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure;
export function isFailure<T>(r: ApiResponse<T>): r is ApiFailure {
return (r as ApiFailure).error !== undefined;
}

View File

@@ -0,0 +1,16 @@
import {version} from '../../../package.json';
/**
* What the About dialog is allowed to claim about this build.
*
* The version is read from package.json rather than restated, because a
* version number a human keeps in sync is a version number that is wrong.
*/
export const APP_NAME = 'Loyaly Merchant';
export const APP_VERSION = version;
/**
* 'development' in `next dev`, 'production' in a built server. Shown so a
* screenshot from a preview deploy is never mistaken for one from production.
*/
export const APP_ENVIRONMENT = process.env.NODE_ENV;

View File

@@ -0,0 +1,22 @@
/**
* Generates a clean UTF-8 CSV Blob.
*/
export function generateCsvBlob(
columns: {key: string; header: string}[],
data: Record<string, any>[]
): Blob {
const headers = columns.map((c) => `"${c.header.replace(/"/g, '""')}"`).join(',');
const rows = data.map((row) =>
columns
.map((col) => {
const val = row[col.key];
const strVal = val === null || val === undefined ? '' : String(val);
return `"${strVal.replace(/"/g, '""')}"`;
})
.join(',')
);
const csvContent = [headers, ...rows].join('\r\n');
return new Blob(['\uFEFF' + csvContent], {type: 'text/csv;charset=utf-8;'});
}

View File

@@ -0,0 +1,46 @@
/**
* Generates an Excel-compatible XML Spreadsheet Blob (.xlsx / .xls).
*/
export function generateExcelBlob(
title: string,
columns: {key: string; header: string}[],
data: Record<string, any>[]
): Blob {
const headerCells = columns
.map((c) => `<Cell><Data ss:Type="String">${c.header}</Data></Cell>`)
.join('');
const rowXML = data
.map(
(row) =>
`<Row>` +
columns
.map((col) => {
const val = row[col.key];
const isNum = typeof val === 'number';
const strVal = val === null || val === undefined ? '' : String(val);
return `<Cell><Data ss:Type="${isNum ? 'Number' : 'String'}">${strVal}</Data></Cell>`;
})
.join('') +
`</Row>`
)
.join('\n');
const xmlContent = `<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Worksheet ss:Name="${title.slice(0, 30)}">
<Table>
<Row>${headerCells}</Row>
${rowXML}
</Table>
</Worksheet>
</Workbook>`;
return new Blob([xmlContent], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8;',
});
}

View File

@@ -0,0 +1,77 @@
import {generateCsvBlob} from './csvExport';
import {generateExcelBlob} from './excelExport';
import {generatePdfBlob} from './pdfExport';
export type ExportFormat = 'pdf' | 'excel' | 'csv';
export interface ExportColumn {
key: string;
header: string;
}
export interface ExportDataParams {
filename: string;
title: string;
subtitle?: string;
columns: ExportColumn[];
data: Record<string, any>[];
format: ExportFormat;
}
function triggerBrowserDownload(blob: Blob, filename: string): void {
console.log('[exportManager] Blob generated successfully:', {
size: blob.size,
type: blob.type,
isBlob: blob instanceof Blob,
});
console.log('[exportManager] Triggering download link for:', filename);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
console.log('[exportManager] Executing link.click()...');
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export async function exportData({
filename,
title,
subtitle,
columns,
data,
format,
}: ExportDataParams): Promise<void> {
console.log('[exportData] Starting generation for format:', format, {filename, title});
// Artificial generation delay for smooth UX transition
await new Promise((res) => setTimeout(res, 450));
const dateTag = new Date().toISOString().slice(0, 10);
let blob: Blob;
let finalFilename = filename;
switch (format) {
case 'pdf':
blob = generatePdfBlob(title, subtitle, columns, data);
if (!finalFilename.endsWith('.pdf')) finalFilename = `${finalFilename}_${dateTag}.pdf`;
break;
case 'excel':
blob = generateExcelBlob(title, columns, data);
if (!finalFilename.endsWith('.xlsx')) finalFilename = `${finalFilename}_${dateTag}.xlsx`;
break;
case 'csv':
blob = generateCsvBlob(columns, data);
if (!finalFilename.endsWith('.csv')) finalFilename = `${finalFilename}_${dateTag}.csv`;
break;
}
triggerBrowserDownload(blob, finalFilename);
}

View File

@@ -0,0 +1,69 @@
/**
* Generates a clean, valid PDF 1.4 spec Blob.
*/
export function generatePdfBlob(
title: string,
subtitle: string | undefined,
columns: {key: string; header: string}[],
data: Record<string, any>[]
): Blob {
const dateStr = new Date().toISOString().slice(0, 10);
const cleanTitle = title.replace(/[()\\]/g, '');
const cleanSub = (subtitle || '').replace(/[()\\]/g, '');
let streamText = `BT /F1 16 Tf 40 750 Td (${cleanTitle}) Tj ET\n`;
streamText += `BT /F1 10 Tf 40 730 Td (LOYALY.AI Merchant OS Report - Date: ${dateStr}) Tj ET\n`;
if (cleanSub) {
streamText += `BT /F1 9 Tf 40 715 Td (${cleanSub}) Tj ET\n`;
}
let y = 680;
const colHeaders = columns.map((c) => c.header).join(' | ').replace(/[()\\]/g, '');
streamText += `BT /F1 11 Tf 40 ${y} Td (${colHeaders}) Tj ET\n`;
y -= 20;
data.forEach((row) => {
if (y > 50) {
const rowStr = columns
.map((c) => String(row[c.key] ?? ''))
.join(' | ')
.replace(/[()\\]/g, '');
streamText += `BT /F1 9 Tf 40 ${y} Td (${rowStr}) Tj ET\n`;
y -= 15;
}
});
const pdfBody = `%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length ${streamText.length} >>
stream
${streamText}endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000244 00000 n
0000000320 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
390
%%EOF`;
return new Blob([pdfBody], {type: 'application/pdf'});
}

View File

@@ -0,0 +1,80 @@
/**
* Formatting lives here so a rupee looks the same in a KPI, an axis tick and a
* tooltip. Locale is pinned to en-IN — this is an Indian retail product, and
* lakh/crore grouping is what merchants read.
*
* Every formatter is pure and locale-explicit: relying on the runtime default
* would make the server and client disagree and produce hydration mismatches.
*/
const NUM = new Intl.NumberFormat('en-IN');
const INR = new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
maximumFractionDigits: 0,
});
export function formatCount(v: number): string {
return NUM.format(Math.round(v));
}
export function formatInr(v: number): string {
return INR.format(Math.round(v));
}
/** Drops a trailing ".0" so axis ticks read ₹90k rather than ₹90.0k. */
function trim(n: number): string {
return n.toFixed(1).replace(/\.0$/, '');
}
/** Axis-scale currency: ₹1.2L rather than ₹1,20,000. */
export function formatInrCompact(v: number): string {
if (Math.abs(v) >= 10000000) return `₹${trim(v / 10000000)}Cr`;
if (Math.abs(v) >= 100000) return `₹${trim(v / 100000)}L`;
if (Math.abs(v) >= 1000) return `₹${trim(v / 1000)}k`;
return `₹${Math.round(v)}`;
}
export function formatCompact(v: number): string {
if (Math.abs(v) >= 10000000) return `${trim(v / 10000000)}Cr`;
if (Math.abs(v) >= 100000) return `${trim(v / 100000)}L`;
if (Math.abs(v) >= 1000) return `${trim(v / 1000)}k`;
return String(Math.round(v));
}
export function formatPct(v: number, digits = 1): string {
return `${v.toFixed(digits)}%`;
}
export function formatLyt(v: number): string {
return `${NUM.format(Math.round(v))} LYT`;
}
/** Signed delta for the KPI change chip. */
export function formatDelta(v: number): string {
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
}
/** "12 Aug" — short axis label for a yyyy-mm-dd key. */
export function formatDayLabel(iso: string | number): string {
const d = new Date(String(iso));
if (Number.isNaN(d.getTime())) return String(iso);
return new Intl.DateTimeFormat('en-IN', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
}).format(d);
}
export function formatByUnit(unit: 'count' | 'inr' | 'lyt' | 'pct') {
switch (unit) {
case 'inr':
return formatInr;
case 'lyt':
return formatLyt;
case 'pct':
return (v: number) => formatPct(v);
default:
return formatCount;
}
}

191
src/shared/utils/icons.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* The single icon map for the whole app.
*
* Astryx resolves exactly 26 SEMANTIC names through the theme's icon registry
* (`<Icon icon="search" />`). Everything else must be passed as a component.
* Rather than let feature files import from lucide-react ad hoc — which is how
* an icon set drifts — every non-semantic glyph is named once, here.
*
* Rule: `<Icon icon="search" />` for anything in SEMANTIC_ICONS,
* `<Icon icon={ICONS.stores} />` for everything else.
*/
import {
BadgeCheck,
Bell,
BookOpen,
Building2,
CalendarClock,
ChartLine,
ChevronsUpDown,
CircleAlert,
CircleHelp,
Clock,
Code,
Coins,
Columns3,
CreditCard,
Download,
Expand,
Footprints,
Gift,
Globe,
History,
IndianRupee,
Info,
Keyboard,
LayoutDashboard,
Lock,
LogOut,
Maximize2,
Menu,
MessageSquare,
Mic,
Minimize2,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
Paperclip,
Plus,
Percent,
Plug,
Send,
Settings,
ShieldCheck,
Shrink,
ShoppingCart,
Sliders,
Square,
SquarePen,
Sparkles,
Store,
TrendingDown,
TrendingUp,
Trophy,
User,
UserCheck,
Users,
UserX,
} from 'lucide-react';
import type {IconType} from '@astryxdesign/core/Icon';
export const ICONS = {
// Expansion & Resize
expand: Expand,
restore: Shrink,
fullscreen: Maximize2,
minimize: Minimize2,
// Navigation
dashboard: LayoutDashboard,
stores: Store,
lyts: Gift,
staff: Users,
settings: Settings,
// Settings Modules
business: Building2,
roles: ShieldCheck,
integrations: Plug,
api: Code,
security: Lock,
preferences: Sliders,
// Dashboard metrics
visitors: Footprints,
purchases: ShoppingCart,
revenue: IndianRupee,
activeRewards: BadgeCheck,
conversion: Percent,
lyt: Coins,
// Dashboard controls
compare: Columns3,
// Trend / delta
up: TrendingUp,
down: TrendingDown,
// Loyaly AI
ai: Sparkles,
analytics: ChartLine,
chat: MessageSquare,
mic: Mic,
send: Send,
panelClose: PanelRightClose,
panelOpen: PanelRightOpen,
newChat: SquarePen,
history: History,
attach: Paperclip,
plus: Plus,
stop: Square,
// Shell
// One control drives the sidebar in all three states, so it needs all three
// glyphs: the hamburger below the drawer breakpoint, and the panel pair
// above it. PanelLeft*, not PanelRight* — those belong to the Loyaly AI rail
// on the opposite edge, and pointing both controls the same way would make
// the header read as two buttons for one panel.
menu: Menu,
sidebarOpen: PanelLeftOpen,
sidebarClose: PanelLeftClose,
notifications: Bell,
// Account menu
language: Globe,
help: CircleHelp,
billing: CreditCard,
download: Download,
profile: User,
signOut: LogOut,
switcher: ChevronsUpDown,
// Help panel. `info` duplicates a semantic name on purpose: the account menu
// config types its icons as components, so it cannot pass the string form.
docs: BookOpen,
keyboard: Keyboard,
info: Info,
sessions: Clock,
// Staff / rewards
present: UserCheck,
absent: UserX,
late: Clock,
leaderboard: Trophy,
expiry: CalendarClock,
alert: CircleAlert,
} satisfies Record<string, IconType>;
export type IconKey = keyof typeof ICONS;
/**
* The names that resolve through the theme registry. Prefer these over an
* equivalent lucide import so the icon follows the theme.
*/
export const SEMANTIC_ICONS = [
'close',
'chevronDown',
'chevronLeft',
'chevronRight',
'check',
'success',
'error',
'warning',
'info',
'calendar',
'clock',
'externalLink',
'menu',
'moreHorizontal',
'search',
'arrowUp',
'arrowDown',
'arrowsUpDown',
'funnel',
'eyeSlash',
'viewColumns',
'copy',
'checkDouble',
'wrench',
'stop',
'microphone',
] as const;