update ui update and fix layout issue
This commit is contained in:
62
src/shared/components/brand/BrandLogo.tsx
Normal file
62
src/shared/components/brand/BrandLogo.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
/**
|
||||
* The two official Loyaly.ai assets — and the only place in the product where
|
||||
* brand colour is allowed to appear. Everything around them stays monochrome.
|
||||
*
|
||||
* Both render at their own intrinsic ratio: a caller sizes ONE axis and the
|
||||
* other is derived, so neither mark can be letterboxed or stretched. There is
|
||||
* deliberately no `fill` / `objectFit` escape hatch — that is how logos end up
|
||||
* distorted. Recolouring is likewise not a prop: the PNGs carry the mark.
|
||||
*/
|
||||
|
||||
// Intrinsic pixel dimensions of the shipped files, alpha-trimmed so the
|
||||
// wordmark optically centres (the supplied original carried 44px of dead
|
||||
// transparent space on its trailing edge).
|
||||
const LOGO = {src: '/white-logo.png', width: 1489, height: 248};
|
||||
const MARK = {src: '/brand/loyaly-mark.png', width: 285, height: 256};
|
||||
|
||||
interface BrandProps {
|
||||
/** Alternative text. Pass '' where a sibling element already names the brand. */
|
||||
alt?: string;
|
||||
/** Set on marks that paint above the fold, so Next preloads them. */
|
||||
priority?: boolean;
|
||||
}
|
||||
|
||||
/** Full horizontal Loyaly.ai lockup. Sized by height; width follows. */
|
||||
export function BrandLogo({
|
||||
height = 28,
|
||||
alt = 'Loyaly.ai',
|
||||
priority = false,
|
||||
}: BrandProps & {height?: number}) {
|
||||
return (
|
||||
<Image
|
||||
src={LOGO.src}
|
||||
alt={alt}
|
||||
height={height}
|
||||
width={Math.round((height * LOGO.width) / LOGO.height)}
|
||||
priority={priority}
|
||||
// Block display keeps the anchor that usually wraps this from painting a
|
||||
// hover underline in the leftover line box.
|
||||
className="block"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Heart symbol alone, for spaces too narrow for the lockup. Sized by width. */
|
||||
export function BrandMark({
|
||||
size = 28,
|
||||
alt = 'Loyaly.ai',
|
||||
priority = false,
|
||||
}: BrandProps & {size?: number}) {
|
||||
return (
|
||||
<Image
|
||||
src={MARK.src}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={Math.round((size * MARK.height) / MARK.width)}
|
||||
priority={priority}
|
||||
className="block"
|
||||
/>
|
||||
);
|
||||
}
|
||||
91
src/shared/components/charts/AreaChartView.tsx
Normal file
91
src/shared/components/charts/AreaChartView.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import {useId} from 'react';
|
||||
import {AreaChart, Area, ReferenceLine} from 'recharts';
|
||||
import {ChartFrame, chartFurniture} from './ChartFrame';
|
||||
import {useChartMotion} from './useChartMotion';
|
||||
import {CHART, dashFor, defaultEncoding, strokeWidthFor} from './palette';
|
||||
import type {ChartViewProps} from './types';
|
||||
|
||||
const TONE = {
|
||||
neutral: CHART.reference,
|
||||
positive: CHART.positive,
|
||||
negative: CHART.negative,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Areas fade to transparent rather than sitting as flat gray blocks — on a
|
||||
* near-black surface a solid fill swallows the gridlines and the series below.
|
||||
*/
|
||||
export function AreaChartView<T extends object>({
|
||||
data,
|
||||
xKey,
|
||||
series,
|
||||
height = 260,
|
||||
xFormat,
|
||||
yFormat,
|
||||
reference,
|
||||
isAnimated = true,
|
||||
isBare,
|
||||
}: ChartViewProps<T>) {
|
||||
const motion = useChartMotion(isAnimated);
|
||||
const uid = useId().replace(/:/g, '');
|
||||
|
||||
return (
|
||||
<ChartFrame height={height}>
|
||||
<AreaChart data={data} margin={{top: 8, right: 8, bottom: 0, left: 0}}>
|
||||
<defs>
|
||||
{series.map((s, i) => {
|
||||
const c = s.color ?? CHART.seriesAt(i);
|
||||
return (
|
||||
<linearGradient
|
||||
key={s.key}
|
||||
id={`fill-${uid}-${s.key}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={c} stopOpacity={0.38} />
|
||||
<stop offset="100%" stopColor={c} stopOpacity={0.03} />
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
{chartFurniture({xKey, xFormat, yFormat, isBare})}
|
||||
{reference ? (
|
||||
<ReferenceLine
|
||||
y={reference.y}
|
||||
stroke={TONE[reference.tone ?? 'neutral']}
|
||||
strokeDasharray="4 4"
|
||||
label={{
|
||||
value: reference.label,
|
||||
position: 'insideTopRight',
|
||||
fill: CHART.axis,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{series.map((s, i) => (
|
||||
<Area
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
stroke={s.color ?? CHART.seriesAt(i)}
|
||||
strokeWidth={strokeWidthFor(i)}
|
||||
strokeDasharray={dashFor(s.encoding ?? defaultEncoding(i))}
|
||||
fill={`url(#fill-${uid}-${s.key})`}
|
||||
// recharts defaults Area to fillOpacity 0.6, which multiplies the
|
||||
// gradient stops and leaves the fill nearly invisible on a
|
||||
// near-black card. The gradient already carries the opacity ramp.
|
||||
fillOpacity={1}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
{...motion}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
81
src/shared/components/charts/BarChartView.tsx
Normal file
81
src/shared/components/charts/BarChartView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import {BarChart, Bar, ReferenceLine} from 'recharts';
|
||||
import {ChartFrame, chartFurniture} from './ChartFrame';
|
||||
import {ChartDefs, useHatchIds} from './ChartDefs';
|
||||
import {useChartMotion} from './useChartMotion';
|
||||
import {CHART, defaultEncoding} from './palette';
|
||||
import type {ChartViewProps} from './types';
|
||||
|
||||
const TONE = {
|
||||
neutral: CHART.reference,
|
||||
positive: CHART.positive,
|
||||
negative: CHART.negative,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Bars cannot use a dash pattern, so the third series onward switches to a
|
||||
* hatch fill instead — same idea, different channel.
|
||||
*/
|
||||
export function BarChartView<T extends object>({
|
||||
data,
|
||||
xKey,
|
||||
series,
|
||||
height = 260,
|
||||
xFormat,
|
||||
yFormat,
|
||||
reference,
|
||||
isAnimated = true,
|
||||
isBare,
|
||||
isStacked = false,
|
||||
}: ChartViewProps<T> & {isStacked?: boolean}) {
|
||||
const motion = useChartMotion(isAnimated);
|
||||
const hatch = useHatchIds();
|
||||
|
||||
return (
|
||||
<ChartFrame height={height}>
|
||||
<BarChart data={data} margin={{top: 8, right: 8, bottom: 0, left: 0}}>
|
||||
<ChartDefs
|
||||
ids={hatch}
|
||||
colors={{diagonal: CHART.seriesAt(2), cross: CHART.seriesAt(3)}}
|
||||
/>
|
||||
{chartFurniture({xKey, xFormat, yFormat, isBare})}
|
||||
{reference ? (
|
||||
<ReferenceLine
|
||||
y={reference.y}
|
||||
stroke={TONE[reference.tone ?? 'neutral']}
|
||||
strokeDasharray="4 4"
|
||||
label={{
|
||||
value: reference.label,
|
||||
position: 'insideTopRight',
|
||||
fill: CHART.axis,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{series.map((s, i) => {
|
||||
const encoding = s.encoding ?? (i >= 2 ? 'hatch' : defaultEncoding(i));
|
||||
const solid = s.color ?? CHART.seriesAt(i);
|
||||
const fill =
|
||||
encoding === 'hatch'
|
||||
? `url(#${i === 2 ? hatch.diagonal : hatch.cross})`
|
||||
: solid;
|
||||
return (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
fill={fill}
|
||||
stroke={encoding === 'hatch' ? solid : undefined}
|
||||
strokeWidth={encoding === 'hatch' ? 1 : 0}
|
||||
stackId={isStacked ? 'stack' : undefined}
|
||||
radius={isStacked ? 0 : [3, 3, 0, 0]}
|
||||
maxBarSize={48}
|
||||
{...motion}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
48
src/shared/components/charts/ChartCard.tsx
Normal file
48
src/shared/components/charts/ChartCard.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonChart} from '@/shared/components/patterns/LoadingState';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* A PanelCard specialised for charts.
|
||||
*
|
||||
* The only thing a chart panel adds over the generic one is that its skeleton
|
||||
* must reserve the chart's exact plotting height — otherwise the card resizes
|
||||
* when data lands and the whole page shifts under the cursor.
|
||||
*
|
||||
* This is deliberately a thin wrapper rather than a parallel implementation.
|
||||
* A separate ChartCard / AnalyticsCard / TableCard, each with its own copy of
|
||||
* the header and async wiring, is the duplication the pattern kit exists to
|
||||
* remove.
|
||||
*/
|
||||
export function ChartCard<T>({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
resource,
|
||||
height = 260,
|
||||
empty,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: React.ReactNode;
|
||||
resource: Resource<T>;
|
||||
height?: number;
|
||||
empty?: React.ReactNode;
|
||||
children: (data: T) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={actions}
|
||||
resource={resource}
|
||||
loading={<SkeletonChart height={height} />}
|
||||
empty={empty}
|
||||
>
|
||||
{children}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
53
src/shared/components/charts/ChartDefs.tsx
Normal file
53
src/shared/components/charts/ChartDefs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import {useId} from 'react';
|
||||
|
||||
/**
|
||||
* SVG hatch patterns — the fill equivalent of a dash pattern.
|
||||
*
|
||||
* Once a bar or area chart needs a third series, luminance alone stops being
|
||||
* reliable (a mid-gray bar on a dark card is easy to confuse with its
|
||||
* neighbour). A hatch keeps the series separable without introducing hue.
|
||||
*
|
||||
* Pattern ids must be unique per chart instance, hence useId.
|
||||
*/
|
||||
export function useHatchIds() {
|
||||
const base = useId().replace(/:/g, '');
|
||||
return {
|
||||
diagonal: `hatch-d-${base}`,
|
||||
cross: `hatch-x-${base}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function ChartDefs({
|
||||
ids,
|
||||
colors,
|
||||
}: {
|
||||
ids: {diagonal: string; cross: string};
|
||||
colors: {diagonal: string; cross: string};
|
||||
}) {
|
||||
return (
|
||||
<defs>
|
||||
<pattern
|
||||
id={ids.diagonal}
|
||||
patternUnits="userSpaceOnUse"
|
||||
width={6}
|
||||
height={6}
|
||||
patternTransform="rotate(45)"
|
||||
>
|
||||
<rect width={6} height={6} fill="transparent" />
|
||||
<line x1={0} y1={0} x2={0} y2={6} stroke={colors.diagonal} strokeWidth={3} />
|
||||
</pattern>
|
||||
<pattern
|
||||
id={ids.cross}
|
||||
patternUnits="userSpaceOnUse"
|
||||
width={6}
|
||||
height={6}
|
||||
>
|
||||
<rect width={6} height={6} fill="transparent" />
|
||||
<line x1={0} y1={0} x2={0} y2={6} stroke={colors.cross} strokeWidth={2} />
|
||||
<line x1={0} y1={0} x2={6} y2={0} stroke={colors.cross} strokeWidth={2} />
|
||||
</pattern>
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
82
src/shared/components/charts/ChartFrame.tsx
Normal file
82
src/shared/components/charts/ChartFrame.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {CHART} from './palette';
|
||||
import {ChartTooltip} from './ChartTooltip';
|
||||
|
||||
/**
|
||||
* The axis/grid/tooltip furniture every cartesian chart shares.
|
||||
*
|
||||
* Centralising it is what stops nine charts across four modules from drifting
|
||||
* into nine slightly different tick sizes and grid weights. Every value here
|
||||
* is a token — nothing is a literal colour.
|
||||
*/
|
||||
|
||||
export const AXIS_TICK = {
|
||||
fill: CHART.axis,
|
||||
fontSize: 11,
|
||||
} as const;
|
||||
|
||||
export function chartFurniture({
|
||||
xKey,
|
||||
xFormat,
|
||||
yFormat,
|
||||
isBare,
|
||||
}: {
|
||||
xKey: string;
|
||||
xFormat?: (v: string | number) => string;
|
||||
yFormat?: (v: number) => string;
|
||||
isBare?: boolean;
|
||||
}) {
|
||||
if (isBare) return null;
|
||||
return (
|
||||
<>
|
||||
<CartesianGrid
|
||||
stroke={CHART.grid}
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tick={AXIS_TICK}
|
||||
tickLine={false}
|
||||
axisLine={{stroke: CHART.axisLine}}
|
||||
tickFormatter={xFormat}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
tick={AXIS_TICK}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={44}
|
||||
tickFormatter={yFormat ? (v: number) => yFormat(v) : undefined}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{fill: CHART.cursor, stroke: CHART.axisLine}}
|
||||
content={
|
||||
<ChartTooltip labelFormat={xFormat} valueFormat={yFormat} />
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartFrame({
|
||||
height = 260,
|
||||
children,
|
||||
}: {
|
||||
height?: number;
|
||||
children: React.ReactElement;
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
74
src/shared/components/charts/ChartTooltip.tsx
Normal file
74
src/shared/components/charts/ChartTooltip.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
|
||||
interface TooltipEntry {
|
||||
name?: string;
|
||||
value?: number | string;
|
||||
color?: string;
|
||||
dataKey?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* recharts' default tooltip is a bare div with inline styles that ignore the
|
||||
* theme. This renders the same data through Astryx surfaces so the tooltip
|
||||
* matches every other popover in the app.
|
||||
*/
|
||||
export function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
labelFormat,
|
||||
valueFormat,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: TooltipEntry[];
|
||||
label?: string | number;
|
||||
labelFormat?: (v: string | number) => string;
|
||||
valueFormat?: (v: number) => string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
return (
|
||||
<Card elevation="high" padding={3}>
|
||||
<VStack gap={1.5}>
|
||||
<Text size="sm" color="secondary">
|
||||
{labelFormat && label !== undefined ? labelFormat(label) : label}
|
||||
</Text>
|
||||
<VStack gap={1}>
|
||||
{payload.map((entry) => (
|
||||
<HStack
|
||||
key={String(entry.dataKey)}
|
||||
gap={3}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
>
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
{/* Swatch mirrors the series' assigned gray so the tooltip
|
||||
row is traceable back to the mark it describes. */}
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: entry.color,
|
||||
flex: 'none',
|
||||
}}
|
||||
/>
|
||||
<Text size="sm">{entry.name}</Text>
|
||||
</HStack>
|
||||
<Text size="sm" weight="medium">
|
||||
{typeof entry.value === 'number' && valueFormat
|
||||
? valueFormat(entry.value)
|
||||
: entry.value}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
83
src/shared/components/charts/HeatmapGrid.tsx
Normal file
83
src/shared/components/charts/HeatmapGrid.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Tooltip} from '@astryxdesign/core/Tooltip';
|
||||
|
||||
export interface HeatCell {
|
||||
day: number; // 0 = Monday
|
||||
hour: number; // 0–23
|
||||
value: number;
|
||||
}
|
||||
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
/**
|
||||
* Peak Hours, deliberately NOT recharts.
|
||||
*
|
||||
* A 7×24 grid of divs tinted with color-mix() is smaller, faster and
|
||||
* monochrome by construction — recharts has no heatmap primitive, and building
|
||||
* one out of a scatter with custom shapes would be more code for a worse
|
||||
* result. Intensity is opacity against the top of the gray ramp.
|
||||
*/
|
||||
export function HeatmapGrid({
|
||||
data,
|
||||
hourLabels = [0, 6, 12, 18, 23],
|
||||
}: {
|
||||
data: HeatCell[];
|
||||
hourLabels?: number[];
|
||||
}) {
|
||||
const max = data.reduce((m, c) => Math.max(m, c.value), 0) || 1;
|
||||
const byKey = new Map(data.map((c) => [`${c.day}-${c.hour}`, c.value]));
|
||||
|
||||
return (
|
||||
<VStack gap={1}>
|
||||
{DAYS.map((label, day) => (
|
||||
<HStack key={label} gap={1.5} vAlign="center">
|
||||
<HStack width={34}>
|
||||
<Text size="xsm" color="secondary">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack gap={0.5} width="100%">
|
||||
{Array.from({length: 24}, (_, hour) => {
|
||||
const value = byKey.get(`${day}-${hour}`) ?? 0;
|
||||
const pct = Math.round((value / max) * 100);
|
||||
return (
|
||||
<Tooltip
|
||||
key={hour}
|
||||
content={`${label} ${String(hour).padStart(2, '0')}:00 — ${value} visitors`}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 18,
|
||||
borderRadius: 3,
|
||||
background: `color-mix(in oklab, var(--color-data-gray-5) ${pct}%, transparent)`,
|
||||
boxShadow:
|
||||
pct === 0
|
||||
? 'inset 0 0 0 1px var(--color-border)'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
<HStack gap={1.5} width="100%">
|
||||
{/* Spacer matching the day-label gutter, so the hour ticks line up
|
||||
with the columns rather than the row start. */}
|
||||
<HStack width={34} />
|
||||
<HStack gap={0.5} width="100%" hAlign="between">
|
||||
{hourLabels.map((h) => (
|
||||
<Text key={h} size="xsm" color="secondary">
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</Text>
|
||||
))}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
67
src/shared/components/charts/LineChartView.tsx
Normal file
67
src/shared/components/charts/LineChartView.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import {LineChart, Line, ReferenceLine} from 'recharts';
|
||||
import {ChartFrame, chartFurniture} from './ChartFrame';
|
||||
import {useChartMotion} from './useChartMotion';
|
||||
import {
|
||||
CHART,
|
||||
dashFor,
|
||||
defaultEncoding,
|
||||
strokeWidthFor,
|
||||
} from './palette';
|
||||
import type {ChartViewProps} from './types';
|
||||
|
||||
const TONE = {
|
||||
neutral: CHART.reference,
|
||||
positive: CHART.positive,
|
||||
negative: CHART.negative,
|
||||
} as const;
|
||||
|
||||
export function LineChartView<T extends object>({
|
||||
data,
|
||||
xKey,
|
||||
series,
|
||||
height = 260,
|
||||
xFormat,
|
||||
yFormat,
|
||||
reference,
|
||||
isAnimated = true,
|
||||
isBare,
|
||||
}: ChartViewProps<T>) {
|
||||
const motion = useChartMotion(isAnimated);
|
||||
|
||||
return (
|
||||
<ChartFrame height={height}>
|
||||
<LineChart data={data} margin={{top: 8, right: 8, bottom: 0, left: 0}}>
|
||||
{chartFurniture({xKey, xFormat, yFormat, isBare})}
|
||||
{reference ? (
|
||||
<ReferenceLine
|
||||
y={reference.y}
|
||||
stroke={TONE[reference.tone ?? 'neutral']}
|
||||
strokeDasharray="4 4"
|
||||
label={{
|
||||
value: reference.label,
|
||||
position: 'insideTopRight',
|
||||
fill: CHART.axis,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{series.map((s, i) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
type="monotone"
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
stroke={s.color ?? CHART.seriesAt(i)}
|
||||
strokeWidth={strokeWidthFor(i)}
|
||||
strokeDasharray={dashFor(s.encoding ?? defaultEncoding(i))}
|
||||
dot={false}
|
||||
activeDot={{r: 3}}
|
||||
{...motion}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
49
src/shared/components/charts/Sparkline.tsx
Normal file
49
src/shared/components/charts/Sparkline.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import {LineChart, Line, ResponsiveContainer, YAxis} from 'recharts';
|
||||
import {CHART} from './palette';
|
||||
|
||||
/**
|
||||
* The 40px trend line inside a KPI card.
|
||||
*
|
||||
* Deliberately never animated: four of these animating on every dashboard load
|
||||
* is noise, and they are glanceable context rather than the subject. Astryx's
|
||||
* own dashboard template makes the same call.
|
||||
*/
|
||||
export function Sparkline<T extends object>({
|
||||
data,
|
||||
dataKey,
|
||||
height = 40,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
data: T[];
|
||||
dataKey: Extract<keyof T, string>;
|
||||
height?: number;
|
||||
/** Semantic tint, for a KPI that is meaningfully up or down. */
|
||||
tone?: 'neutral' | 'positive' | 'negative';
|
||||
}) {
|
||||
const stroke =
|
||||
tone === 'positive'
|
||||
? CHART.positive
|
||||
: tone === 'negative'
|
||||
? CHART.negative
|
||||
: CHART.seriesAt(0);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={data} margin={{top: 2, right: 0, bottom: 2, left: 0}}>
|
||||
{/* Domain padding keeps a flat-ish series from rendering as a line
|
||||
pinned to the top or bottom edge of the box. */}
|
||||
<YAxis hide domain={['dataMin - 1', 'dataMax + 1']} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={stroke}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
67
src/shared/components/charts/palette.ts
Normal file
67
src/shared/components/charts/palette.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The monochrome chart palette.
|
||||
*
|
||||
* recharts writes these straight onto SVG presentation attributes, where
|
||||
* `var()` resolves natively — so colours stay CSS custom properties end to end.
|
||||
* No getComputedStyle, no MutationObserver, no JS colour plumbing.
|
||||
*
|
||||
* THE ENCODING PROBLEM
|
||||
* Five gray steps carry about three distinguishable series by luminance alone.
|
||||
* Rather than reach for hue the moment a fourth series appears, the primitives
|
||||
* encode extra dimensions non-chromatically — dash pattern, stroke width, and
|
||||
* hatch fill. Colour stays reserved for meaning.
|
||||
*/
|
||||
|
||||
/** Ordered series ramp, lightest first — series 0 is the most important. */
|
||||
export const SERIES = [
|
||||
'var(--color-data-gray-5)', // #E4E4E7
|
||||
'var(--color-data-gray-4)', // #A1A1AA
|
||||
'var(--color-data-gray-3)', // #71717A
|
||||
'var(--color-data-gray-2)', // #52525B
|
||||
'var(--color-data-gray-1)', // #3F3F46
|
||||
] as const;
|
||||
|
||||
export const CHART = {
|
||||
series: SERIES,
|
||||
seriesAt: (i: number) => SERIES[i % SERIES.length],
|
||||
grid: 'var(--color-border)',
|
||||
axis: 'var(--color-text-secondary)',
|
||||
axisLine: 'var(--color-border-emphasized)',
|
||||
cursor: 'var(--color-overlay-hover)',
|
||||
reference: 'var(--color-text-disabled)',
|
||||
/**
|
||||
* Semantic colour. Used ONLY where the colour itself carries the meaning:
|
||||
* a delta against target, a threshold breach, an expiry warning. Never to
|
||||
* tell two ordinary series apart.
|
||||
*/
|
||||
positive: 'var(--color-success)',
|
||||
negative: 'var(--color-error)',
|
||||
attention: 'var(--color-warning)',
|
||||
} as const;
|
||||
|
||||
/** The non-chromatic channel. */
|
||||
export type SeriesEncoding = 'solid' | 'dashed' | 'dotted' | 'hatch';
|
||||
|
||||
const DASH: Record<SeriesEncoding, string | undefined> = {
|
||||
solid: undefined,
|
||||
dashed: '6 3',
|
||||
dotted: '2 3',
|
||||
hatch: undefined,
|
||||
};
|
||||
|
||||
export function dashFor(encoding: SeriesEncoding = 'solid') {
|
||||
return DASH[encoding];
|
||||
}
|
||||
|
||||
/**
|
||||
* Series beyond the second get progressively thinner as well as darker, so
|
||||
* they read as background context rather than competing for attention.
|
||||
*/
|
||||
export function strokeWidthFor(index: number) {
|
||||
return index === 0 ? 2 : index === 1 ? 1.75 : 1.5;
|
||||
}
|
||||
|
||||
/** Default encoding per series position, when the caller does not specify one. */
|
||||
export function defaultEncoding(index: number): SeriesEncoding {
|
||||
return index === 0 ? 'solid' : index === 1 ? 'dashed' : 'dotted';
|
||||
}
|
||||
37
src/shared/components/charts/types.ts
Normal file
37
src/shared/components/charts/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type {SeriesEncoding} from './palette';
|
||||
|
||||
/**
|
||||
* One prop shape shared by every chart view, so a module can swap a line for
|
||||
* bars without rewriting its data plumbing.
|
||||
*/
|
||||
export interface Series<T> {
|
||||
key: Extract<keyof T, string>;
|
||||
label: string;
|
||||
/**
|
||||
* Override the auto-assigned gray. Use ONLY when the colour carries meaning
|
||||
* (over/under target, positive/negative delta) — never to separate series.
|
||||
*/
|
||||
color?: string;
|
||||
/** The non-chromatic channel. Defaults by series position. */
|
||||
encoding?: SeriesEncoding;
|
||||
format?: (v: number) => string;
|
||||
}
|
||||
|
||||
export interface ChartViewProps<T> {
|
||||
data: T[];
|
||||
xKey: Extract<keyof T, string>;
|
||||
series: Series<T>[];
|
||||
/** Workspace default 260; Loyaly AI column 180; sparkline 40. */
|
||||
height?: number;
|
||||
yFormat?: (v: number) => string;
|
||||
xFormat?: (v: string | number) => string;
|
||||
reference?: {
|
||||
y: number;
|
||||
label: string;
|
||||
tone?: 'neutral' | 'positive' | 'negative';
|
||||
};
|
||||
/** Overridden to false by prefers-reduced-motion regardless of this value. */
|
||||
isAnimated?: boolean;
|
||||
/** Hide axes and grid — for dense small-multiples. */
|
||||
isBare?: boolean;
|
||||
}
|
||||
22
src/shared/components/charts/useChartMotion.ts
Normal file
22
src/shared/components/charts/useChartMotion.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import {useReducedMotion} from 'framer-motion';
|
||||
|
||||
/**
|
||||
* recharts drives its own animation, so <MotionConfig reducedMotion="user">
|
||||
* cannot reach it. This bridges the two: one hook, spread onto every animated
|
||||
* recharts primitive.
|
||||
*/
|
||||
export function useChartMotion(enabled = true) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
if (reduced || !enabled) {
|
||||
return {isAnimationActive: false as const};
|
||||
}
|
||||
return {
|
||||
isAnimationActive: true as const,
|
||||
animationDuration: 500,
|
||||
animationBegin: 0,
|
||||
animationEasing: 'ease-out' as const,
|
||||
};
|
||||
}
|
||||
80
src/shared/components/data/AsyncBoundary.tsx
Normal file
80
src/shared/components/data/AsyncBoundary.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* The one place loading / empty / error are rendered.
|
||||
*
|
||||
* Feature components receive resolved data only — they never branch on status
|
||||
* themselves. That is what stops four modules from inventing four different
|
||||
* error treatments, and it is why the states stay consistent as the app grows.
|
||||
*/
|
||||
export function AsyncBoundary<T>({
|
||||
resource,
|
||||
loading,
|
||||
empty,
|
||||
children,
|
||||
}: {
|
||||
resource: Resource<T>;
|
||||
loading: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
children: (data: T) => React.ReactNode;
|
||||
}) {
|
||||
if (resource.status === 'loading') return <>{loading}</>;
|
||||
|
||||
if (resource.status === 'error') {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<Banner
|
||||
status="error"
|
||||
title="Couldn’t load this"
|
||||
description={resource.error.message}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Retry"
|
||||
onClick={resource.refetch}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (resource.status === 'empty') {
|
||||
return (
|
||||
<>
|
||||
{empty ?? (
|
||||
<EmptyState
|
||||
title="Nothing to show yet"
|
||||
description="There’s no data for this store and period."
|
||||
isCompact
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Stale data stays visible during a refetch, dimmed and non-interactive so
|
||||
// it reads as "the previous answer" rather than the current one.
|
||||
//
|
||||
// The wrapper is ALWAYS rendered, never conditionally swapped for a
|
||||
// fragment. Changing the element shape between renders makes React unmount
|
||||
// and remount the whole subtree, which restarts every AnimatedNumber from
|
||||
// zero — the exact flicker this stale-while-revalidate path exists to avoid.
|
||||
return (
|
||||
<div
|
||||
aria-busy={resource.isRefreshing || undefined}
|
||||
style={{
|
||||
opacity: resource.isRefreshing ? 0.45 : 1,
|
||||
transition: 'opacity var(--duration-fast) var(--ease-standard)',
|
||||
pointerEvents: resource.isRefreshing ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
{children(resource.data)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/shared/components/help/AboutDialog.tsx
Normal file
86
src/shared/components/help/AboutDialog.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Item} from '@astryxdesign/core/Item';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {BrandLogo} from '@/shared/components/brand/BrandLogo';
|
||||
import {APP_ENVIRONMENT, APP_NAME, APP_VERSION} from '@/shared/utils/appInfo';
|
||||
|
||||
/**
|
||||
* About, opened from the account menu's Help panel.
|
||||
*
|
||||
* Everything on it is read from somewhere real — the version from
|
||||
* package.json, the environment from the build, the identity from the session.
|
||||
* Nothing is a placeholder, because the one job of an About dialog is to be
|
||||
* quotable in a support ticket.
|
||||
*/
|
||||
export function AboutDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const {user} = useSession();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={420}
|
||||
aria-label={`About ${APP_NAME}`}
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={`About ${APP_NAME}`}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
<HStack paddingInline={2} paddingBlock={2}>
|
||||
<BrandLogo height={28} alt="" />
|
||||
</HStack>
|
||||
|
||||
<VStack gap={0.5} width="100%">
|
||||
<Item
|
||||
label="Version"
|
||||
density="balanced"
|
||||
endContent={<Text type="supporting">{APP_VERSION}</Text>}
|
||||
/>
|
||||
<Item
|
||||
label="Environment"
|
||||
density="balanced"
|
||||
endContent={<Text type="supporting">{APP_ENVIRONMENT}</Text>}
|
||||
/>
|
||||
<Item
|
||||
label="Signed in as"
|
||||
density="balanced"
|
||||
endContent={
|
||||
<Text type="supporting">{user?.email ?? 'Not signed in'}</Text>
|
||||
}
|
||||
/>
|
||||
{user ? (
|
||||
<Item
|
||||
label="Organisation"
|
||||
density="balanced"
|
||||
endContent={<Text type="supporting">{user.organisation}</Text>}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
|
||||
<Item
|
||||
label="loyaly.ai"
|
||||
density="balanced"
|
||||
href="https://loyaly.ai"
|
||||
target="_blank"
|
||||
endContent={
|
||||
<Icon icon="externalLink" size="sm" color="secondary" />
|
||||
}
|
||||
/>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
65
src/shared/components/help/KeyboardShortcutsDialog.tsx
Normal file
65
src/shared/components/help/KeyboardShortcutsDialog.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {Item} from '@astryxdesign/core/Item';
|
||||
import {Kbd} from '@astryxdesign/core/Kbd';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {
|
||||
SHORTCUT_GROUPS,
|
||||
WORKSPACE_SHORTCUTS,
|
||||
} from '@/shared/layouts/workspace/shortcuts';
|
||||
|
||||
/**
|
||||
* The shortcuts sheet, opened from the account menu's Help panel (or by the
|
||||
* shortcut it lists for itself).
|
||||
*
|
||||
* It renders WORKSPACE_SHORTCUTS directly. Nothing is typed in twice, so the
|
||||
* list cannot describe a binding the app does not have — which is the failure
|
||||
* mode of every hand-written shortcuts dialog.
|
||||
*/
|
||||
export function KeyboardShortcutsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={480}
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Keyboard shortcuts"
|
||||
subtitle="Every binding the workspace answers to."
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{SHORTCUT_GROUPS.map((group) => (
|
||||
<VStack gap={1} key={group} width="100%">
|
||||
<HStack paddingInline={2}>
|
||||
<Text type="supporting" size="sm" weight="medium">
|
||||
{group}
|
||||
</Text>
|
||||
</HStack>
|
||||
{WORKSPACE_SHORTCUTS.filter(
|
||||
(shortcut) => shortcut.group === group,
|
||||
).map((shortcut) => (
|
||||
<Item
|
||||
key={shortcut.keys}
|
||||
label={shortcut.label}
|
||||
density="balanced"
|
||||
endContent={<Kbd keys={shortcut.keys} />}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
62
src/shared/components/motion/AnimatedNumber.tsx
Normal file
62
src/shared/components/motion/AnimatedNumber.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Count animation for KPI values.
|
||||
*
|
||||
* Two deliberate constraints:
|
||||
*
|
||||
* 1. NO count-up on first paint. Springs leave their origin slowly, so
|
||||
* animating 0 → 32,700 renders a literal "0" for the first couple of
|
||||
* hundred milliseconds — four wrong numbers on a dashboard merchants load
|
||||
* dozens of times a day. The initial value is therefore painted directly.
|
||||
* The animation exists to show that a number *moved*, which is only
|
||||
* meaningful when it had a previous value to move from.
|
||||
*
|
||||
* 2. Reduced motion skips the spring entirely rather than shortening it.
|
||||
* <MotionConfig reducedMotion="user"> governs motion.* props, but a spring
|
||||
* driving text content is outside its reach, so it is handled here.
|
||||
*/
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
format,
|
||||
}: {
|
||||
value: number;
|
||||
format: (v: number) => string;
|
||||
}) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
// Seeded with the real value, so the first frame is already correct.
|
||||
const raw = useMotionValue(value);
|
||||
const spring = useSpring(raw, {stiffness: 120, damping: 24, mass: 0.5});
|
||||
const text = useTransform(spring, (v) => format(v));
|
||||
|
||||
const isFirst = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirst.current) {
|
||||
isFirst.current = false;
|
||||
return;
|
||||
}
|
||||
if (reduced) {
|
||||
// jump() moves the spring's internal state too, so no frame is scheduled.
|
||||
raw.jump(value);
|
||||
spring.jump(value);
|
||||
return;
|
||||
}
|
||||
raw.set(value);
|
||||
}, [value, reduced, raw, spring]);
|
||||
|
||||
if (reduced) {
|
||||
return <>{format(value)}</>;
|
||||
}
|
||||
return <motion.span>{text}</motion.span>;
|
||||
}
|
||||
31
src/shared/components/motion/HoverLift.tsx
Normal file
31
src/shared/components/motion/HoverLift.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import {motion} from 'framer-motion';
|
||||
|
||||
/**
|
||||
* Card hover: a 2px rise and a shadow step, 120ms.
|
||||
*
|
||||
* Deliberately restrained — on a dashboard where a dozen cards share a screen,
|
||||
* anything larger reads as the page twitching. <MotionConfig reducedMotion="user">
|
||||
* in providers.tsx already neutralises the transform for users who ask for it,
|
||||
* so there is no per-component guard here.
|
||||
*/
|
||||
export function HoverLift({
|
||||
children,
|
||||
isEnabled = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isEnabled?: boolean;
|
||||
}) {
|
||||
if (!isEnabled) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{y: -2}}
|
||||
transition={{duration: 0.12, ease: 'easeOut'}}
|
||||
style={{height: '100%'}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
105
src/shared/components/patterns/ActivityItem.tsx
Normal file
105
src/shared/components/patterns/ActivityItem.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
|
||||
/** Icon tones. Only `warning` carries colour — see the note on ActivityFeed. */
|
||||
export type ActivityTone = 'neutral' | 'warning';
|
||||
|
||||
export interface ActivityEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
at: string;
|
||||
/** Which kind of event this is — the glyph IS the category label. */
|
||||
icon: IconKey;
|
||||
tone: ActivityTone;
|
||||
/** Announced with the icon; a glyph alone is not an accessible label. */
|
||||
toneLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in a chronological feed.
|
||||
*
|
||||
* Rows, not cards: Astryx's own guidance is that dense sequential data reads
|
||||
* edge-to-edge, and wrapping each event in a Card would triple the vertical
|
||||
* space while making the feed harder to scan.
|
||||
*
|
||||
* The leading glyph is the event's TYPE — purchase, reward, staff, store,
|
||||
* alert — not its status. A status dot could only encode severity, which meant
|
||||
* five different kinds of event all rendered as the same grey circle and the
|
||||
* feed could not be skimmed by category. The icon does that job, and colour is
|
||||
* left to do the one job it is good at: marking the row that needs action.
|
||||
*
|
||||
* Hover is `--color-overlay-hover`, the same white-at-6% the sidebar nav uses.
|
||||
* It tracks the eye across a dense row; it does not imply the row is clickable,
|
||||
* because there is no per-event destination to send anyone to.
|
||||
*/
|
||||
export function ActivityItem({entry}: {entry: ActivityEntry}) {
|
||||
return (
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="start"
|
||||
paddingBlock={1}
|
||||
paddingInline={2}
|
||||
// Token-backed utilities: --color-overlay-hover is bridged into Tailwind
|
||||
// by tailwind-theme.css, so this is the theme's hover, not a new value.
|
||||
className="rounded-md transition-colors hover:bg-overlay-hover"
|
||||
>
|
||||
<HStack paddingBlock={0.5}>
|
||||
<Icon
|
||||
icon={ICONS[entry.icon]}
|
||||
size="sm"
|
||||
color={entry.tone === 'warning' ? 'warning' : 'secondary'}
|
||||
label={entry.toneLabel}
|
||||
/>
|
||||
</HStack>
|
||||
<VStack gap={0.5} width="100%">
|
||||
<HStack gap={3} hAlign="between" vAlign="center">
|
||||
<Text size="sm" weight="medium">
|
||||
{entry.title}
|
||||
</Text>
|
||||
<Timestamp value={entry.at} format="relative" isLive />
|
||||
</HStack>
|
||||
{entry.detail ? (
|
||||
<Text size="sm" color="secondary">
|
||||
{entry.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The list wrapper.
|
||||
*
|
||||
* Dividers are gone: with a hover background doing the row separation, rules
|
||||
* between every entry were two separators doing one job, and each one cost
|
||||
* vertical space on a panel whose whole problem was height.
|
||||
*
|
||||
* `limit` is deliberately applied HERE rather than by each caller slicing its
|
||||
* own array — a feed that quietly grows without bound is what put this panel
|
||||
* at 784px on the dashboard, and one place to cap it is one place to get right.
|
||||
*/
|
||||
export function ActivityFeed({
|
||||
entries,
|
||||
limit,
|
||||
}: {
|
||||
entries: ActivityEntry[];
|
||||
limit?: number;
|
||||
}) {
|
||||
const shown = limit ? entries.slice(0, limit) : entries;
|
||||
|
||||
return (
|
||||
<VStack gap={0}>
|
||||
{shown.map((e) => (
|
||||
<ActivityItem key={e.id} entry={e} />
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
36
src/shared/components/patterns/CollectionRegion.tsx
Normal file
36
src/shared/components/patterns/CollectionRegion.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Heading} from '@astryxdesign/core/Text';
|
||||
import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
|
||||
|
||||
/**
|
||||
* A labelled region wrapping a grid of entity cards.
|
||||
*
|
||||
* The card grids (stores, rewards, staff) are visually self-explanatory — the
|
||||
* filter bar above already says "5 stores" — so they carry no visible heading.
|
||||
* But each card titles itself with an h3, and an h3 with no h2 above it is a
|
||||
* hole in the document outline: screen-reader users navigating by heading jump
|
||||
* from the page title straight into individual cards with no sense of what
|
||||
* collection they are in.
|
||||
*
|
||||
* So the heading exists, and is hidden. This is one of the few legitimate uses
|
||||
* of visually-hidden text: the information is genuinely redundant for sighted
|
||||
* users and genuinely missing for everyone else.
|
||||
*/
|
||||
export function CollectionRegion({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={4} as="section">
|
||||
<VisuallyHidden>
|
||||
<Heading level={2}>{label}</Heading>
|
||||
</VisuallyHidden>
|
||||
{children}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
198
src/shared/components/patterns/DownloadDropdown.tsx
Normal file
198
src/shared/components/patterns/DownloadDropdown.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import {useState, useRef, useEffect} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {exportData, ExportFormat, ExportColumn} from '@/shared/utils/export/exportManager';
|
||||
|
||||
export interface DownloadDropdownProps {
|
||||
filename: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
columns: ExportColumn[];
|
||||
data: Record<string, any>[];
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export function DownloadDropdown({
|
||||
filename,
|
||||
title,
|
||||
subtitle,
|
||||
columns,
|
||||
data,
|
||||
variant = 'primary',
|
||||
size = 'sm',
|
||||
}: DownloadDropdownProps) {
|
||||
const toast = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [loadingFormat, setLoadingFormat] = useState<ExportFormat | null>(null);
|
||||
const [coords, setCoords] = useState<{top: number; left: number} | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const buttonWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Close dropdown on click outside or window/container scroll
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
buttonWrapperRef.current &&
|
||||
!buttonWrapperRef.current.contains(target) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, {capture: true, passive: true});
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, {capture: true});
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!isOpen && buttonWrapperRef.current) {
|
||||
const rect = buttonWrapperRef.current.getBoundingClientRect();
|
||||
const leftPos = Math.max(12, rect.right - 224);
|
||||
|
||||
// Smart vertical flip: if space below < 180px, open UPWARDS above button
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const menuHeight = 168; // 3 items padding height
|
||||
let topPos: number;
|
||||
|
||||
if (spaceBelow < menuHeight && rect.top > menuHeight) {
|
||||
topPos = rect.top - menuHeight - 6;
|
||||
} else {
|
||||
topPos = rect.bottom + 6;
|
||||
}
|
||||
|
||||
setCoords({top: topPos, left: leftPos});
|
||||
}
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleSelectFormat = async (fmt: ExportFormat) => {
|
||||
console.log('[DownloadDropdown] Selected format:', fmt);
|
||||
setIsOpen(false);
|
||||
setLoadingFormat(fmt);
|
||||
|
||||
try {
|
||||
await exportData({
|
||||
filename,
|
||||
title,
|
||||
subtitle,
|
||||
columns,
|
||||
data,
|
||||
format: fmt,
|
||||
});
|
||||
|
||||
const label = fmt === 'pdf' ? 'PDF' : fmt === 'excel' ? 'Excel' : 'CSV';
|
||||
console.log(`[DownloadDropdown] Success toast for ${label}`);
|
||||
toast({
|
||||
body: `✓ ${label} downloaded successfully`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[DownloadDropdown] Export failed:', err);
|
||||
toast({
|
||||
body: '❌ Download failed. Unable to generate file.',
|
||||
});
|
||||
} finally {
|
||||
setLoadingFormat(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonLabel = () => {
|
||||
if (loadingFormat) {
|
||||
const fmtName = loadingFormat === 'pdf' ? 'PDF' : loadingFormat === 'excel' ? 'Excel' : 'CSV';
|
||||
return `Generating ${fmtName}...`;
|
||||
}
|
||||
return 'Download';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-block text-left">
|
||||
<div ref={buttonWrapperRef}>
|
||||
<Button
|
||||
size={size}
|
||||
variant={variant}
|
||||
label={getButtonLabel()}
|
||||
icon={<Icon icon={ICONS.download} size="sm" />}
|
||||
isLoading={loadingFormat !== null}
|
||||
isDisabled={loadingFormat !== null}
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && !loadingFormat && mounted && coords
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${coords.top}px`,
|
||||
left: `${coords.left}px`,
|
||||
zIndex: 99999,
|
||||
}}
|
||||
className="w-56 rounded-xl border border-zinc-700 bg-[#18181b] backdrop-blur-xl shadow-2xl z-[99999] py-1.5 divide-y divide-zinc-800 select-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
|
||||
onClick={() => handleSelectFormat('pdf')}
|
||||
>
|
||||
<span className="text-xl">📄</span>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-white">PDF Document</div>
|
||||
<div className="text-xs text-zinc-300">.pdf file format</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
|
||||
onClick={() => handleSelectFormat('excel')}
|
||||
>
|
||||
<span className="text-xl">📊</span>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-white">Excel Spreadsheet</div>
|
||||
<div className="text-xs text-zinc-300">.xlsx file format</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-4 py-3 hover:bg-white/10 flex items-center gap-3 transition-colors cursor-pointer"
|
||||
onClick={() => handleSelectFormat('csv')}
|
||||
>
|
||||
<span className="text-xl">📑</span>
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-white">CSV Data File</div>
|
||||
<div className="text-xs text-zinc-300">.csv file format</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/shared/components/patterns/EmptyPanel.tsx
Normal file
48
src/shared/components/patterns/EmptyPanel.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import {EmptyState} from '@astryxdesign/core/EmptyState';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
|
||||
/**
|
||||
* Astryx's EmptyState with our icon convention applied.
|
||||
*
|
||||
* The icon is named, NOT passed as a component. Two reasons:
|
||||
*
|
||||
* 1. RSC safety. A lucide icon is a function/forwardRef, and React refuses to
|
||||
* serialise functions across the server/client boundary — passing
|
||||
* `icon={ICONS.revenue}` from a Server Component throws
|
||||
* "Functions cannot be passed directly to Client Components". A string key
|
||||
* crosses fine, so this component works from either side.
|
||||
* 2. It enforces the central map. There is no way to pass an ad-hoc icon,
|
||||
* which is how an icon set drifts.
|
||||
*
|
||||
* `description` is deliberately required. An empty state that only says
|
||||
* "No data" tells a merchant nothing about whether something is broken,
|
||||
* filtered out, or genuinely hasn't happened yet — which is the entire reason
|
||||
* to render one instead of a blank box.
|
||||
*/
|
||||
export function EmptyPanel({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
isCompact = true,
|
||||
}: {
|
||||
icon: IconKey;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: React.ReactNode;
|
||||
isCompact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Icon icon={ICONS[icon]} size="lg" color="secondary" />}
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
isCompact={isCompact}
|
||||
/>
|
||||
);
|
||||
}
|
||||
97
src/shared/components/patterns/FilterBar.tsx
Normal file
97
src/shared/components/patterns/FilterBar.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
|
||||
export interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Shown after the label, e.g. a count. */
|
||||
hint?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search plus a single-select filter, above a collection.
|
||||
*
|
||||
* Store, Lyts and Staff all present "a grid of things you can narrow down",
|
||||
* so the control strip is defined once. A SegmentedControl rather than a
|
||||
* dropdown because these sets are small and always visible — a merchant
|
||||
* scanning for the one closed store should not have to open a menu to
|
||||
* discover that "closed" is an option, or to see there are none.
|
||||
*
|
||||
* The result count is part of the bar rather than floating above the grid:
|
||||
* when a filter returns nothing, the count is the fastest explanation of why
|
||||
* the space below is empty.
|
||||
*/
|
||||
export function FilterBar({
|
||||
query,
|
||||
onQueryChange,
|
||||
placeholder = 'Search…',
|
||||
filterValue,
|
||||
onFilterChange,
|
||||
options,
|
||||
filterLabel,
|
||||
resultCount,
|
||||
resultNoun,
|
||||
actions,
|
||||
}: {
|
||||
query: string;
|
||||
onQueryChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
filterValue: string;
|
||||
onFilterChange: (v: string) => void;
|
||||
options: FilterOption[];
|
||||
/** Accessible name for the filter group — never rendered visually. */
|
||||
filterLabel: string;
|
||||
resultCount?: number;
|
||||
resultNoun?: string;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<HStack gap={3} vAlign="center" wrap="wrap" hAlign="between">
|
||||
<HStack gap={3} vAlign="center" wrap="wrap">
|
||||
<TextInput
|
||||
label={placeholder}
|
||||
isLabelHidden
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
startIcon="search"
|
||||
hasClear
|
||||
size="sm"
|
||||
width={260}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={filterValue}
|
||||
onChange={onFilterChange}
|
||||
label={filterLabel}
|
||||
size="sm"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<SegmentedControlItem
|
||||
key={o.value}
|
||||
value={o.value}
|
||||
label={
|
||||
o.hint !== undefined ? `${o.label} (${o.hint})` : o.label
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SegmentedControl>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={3} vAlign="center">
|
||||
{resultCount !== undefined ? (
|
||||
<Text size="sm" color="secondary">
|
||||
{resultCount} {resultNoun ?? 'results'}
|
||||
</Text>
|
||||
) : null}
|
||||
{actions}
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
89
src/shared/components/patterns/LoadingState.tsx
Normal file
89
src/shared/components/patterns/LoadingState.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Skeleton} from '@astryxdesign/core/Skeleton';
|
||||
|
||||
/**
|
||||
* The named loading shapes.
|
||||
*
|
||||
* Skeletons were previously written inline with hand-picked pixel heights —
|
||||
* 38 here, 16/30/40 there — which meant every panel resolved with a small
|
||||
* layout jump because the placeholder never quite matched the real content.
|
||||
*
|
||||
* A skeleton's only job is to reserve the right space. Naming the shapes makes
|
||||
* that checkable: if a panel jumps on resolve, its skeleton is wrong, and
|
||||
* there is one place to fix it.
|
||||
*/
|
||||
|
||||
/** Rows of equal height — activity feeds, table bodies, list panels. */
|
||||
export function SkeletonRows({
|
||||
count = 5,
|
||||
height = 38,
|
||||
}: {
|
||||
count?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
{Array.from({length: count}, (_, i) => (
|
||||
<Skeleton key={i} height={height} width="100%" />
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches MetricCard: label, value, sparkline. */
|
||||
export function SkeletonMetric() {
|
||||
return (
|
||||
<Card>
|
||||
<VStack gap={3}>
|
||||
<Skeleton height={16} width="45%" />
|
||||
<Skeleton height={30} width="65%" />
|
||||
<Skeleton height={40} width="100%" />
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** A grid of metric placeholders, matching the real grid's column count. */
|
||||
export function SkeletonMetricGrid({
|
||||
count = 4,
|
||||
columns,
|
||||
}: {
|
||||
count?: number;
|
||||
columns: number;
|
||||
}) {
|
||||
return (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{Array.from({length: count}, (_, i) => (
|
||||
<SkeletonMetric key={i} />
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reserves a chart's exact plotting height so the card cannot jump. */
|
||||
export function SkeletonChart({height = 260}: {height?: number}) {
|
||||
return <Skeleton height={height} width="100%" />;
|
||||
}
|
||||
|
||||
/** A grid of card placeholders — store grid, reward grid, staff grid. */
|
||||
export function SkeletonCardGrid({
|
||||
count = 6,
|
||||
height = 190,
|
||||
minWidth = 320,
|
||||
}: {
|
||||
count?: number;
|
||||
height?: number;
|
||||
minWidth?: number;
|
||||
}) {
|
||||
return (
|
||||
<Grid columns={{minWidth, repeat: 'fit'}} gap={4}>
|
||||
{Array.from({length: count}, (_, i) => (
|
||||
<Skeleton key={i} height={height} width="100%" />
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
85
src/shared/components/patterns/MetricCard.tsx
Normal file
85
src/shared/components/patterns/MetricCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import type {IconType} from '@astryxdesign/core/Icon';
|
||||
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
|
||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
||||
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
|
||||
import {Sparkline} from '@/shared/components/charts/Sparkline';
|
||||
|
||||
/**
|
||||
* A single headline number.
|
||||
*
|
||||
* Extracted from the dashboard's private KpiCard because the same object is
|
||||
* needed by Staff (present/absent/late/total) and by the Store detail header —
|
||||
* and a second hand-written copy is exactly how two screens start disagreeing
|
||||
* about how a metric looks.
|
||||
*
|
||||
* Two deliberate constraints carried over from the dashboard:
|
||||
*
|
||||
* - The trend line is GRAY. The delta chip already states whether the
|
||||
* direction is good; tinting the whole line repeats that in the loudest
|
||||
* possible way and would make metric rows the most colourful thing on a
|
||||
* monochrome page.
|
||||
* - Sparklines never animate. A row of them firing on every load is noise.
|
||||
*/
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
format,
|
||||
icon,
|
||||
deltaPct,
|
||||
isRiseGood = true,
|
||||
trend,
|
||||
footer,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
format: (v: number) => string;
|
||||
icon?: IconType;
|
||||
deltaPct?: number;
|
||||
isRiseGood?: boolean;
|
||||
/** 14-ish points. Omit for metrics with no meaningful history. */
|
||||
trend?: {v: number}[];
|
||||
/** Replaces the sparkline — e.g. a share-of-total caption. */
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<HoverLift>
|
||||
<Card>
|
||||
<VStack gap={3}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
{icon ? <Icon icon={icon} size="sm" color="secondary" /> : null}
|
||||
<Text size="sm" color="secondary">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="center" hAlign="between">
|
||||
{/*
|
||||
Text, NOT Heading. A KPI value is data, not a section title —
|
||||
marking it up as an h2 puts "32.7k" into the document outline
|
||||
and makes screen-reader heading navigation read a list of
|
||||
numbers. `display-3` gives the visual weight without the
|
||||
semantics.
|
||||
*/}
|
||||
<Text type="display-3">
|
||||
<AnimatedNumber value={value} format={format} />
|
||||
</Text>
|
||||
{deltaPct !== undefined ? (
|
||||
<MetricDelta value={deltaPct} isRiseGood={isRiseGood} />
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
{footer ??
|
||||
(trend && trend.length > 1 ? (
|
||||
<Sparkline data={trend} dataKey="v" />
|
||||
) : null)}
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
);
|
||||
}
|
||||
88
src/shared/components/patterns/PanelCard.tsx
Normal file
88
src/shared/components/patterns/PanelCard.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SectionHeader} from './SectionHeader';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* The standard workspace panel: a card with a header and asynchronous content.
|
||||
*
|
||||
* This is the shape every analytics surface in the app shares — charts,
|
||||
* tables, feeds, grids. Rather than a separate ChartCard, AnalyticsCard,
|
||||
* TableCard and FeedCard (four near-identical components, which is the
|
||||
* duplication we are trying to remove), there is one panel and callers vary
|
||||
* the loading shape.
|
||||
*
|
||||
* The async wiring lives HERE rather than in each panel's body so that feature
|
||||
* components only ever receive resolved data. That is what stops each module
|
||||
* inventing its own error treatment.
|
||||
*/
|
||||
export function PanelCard<T>({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
resource,
|
||||
loading,
|
||||
empty,
|
||||
headingLevel = 2,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: React.ReactNode;
|
||||
resource: Resource<T>;
|
||||
loading: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
headingLevel?: 2 | 3 | 4 | 5;
|
||||
children: (data: T) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<VStack gap={4}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={actions}
|
||||
level={headingLevel}
|
||||
/>
|
||||
<AsyncBoundary resource={resource} loading={loading} empty={empty}>
|
||||
{children}
|
||||
</AsyncBoundary>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A panel with no asynchronous content — static or already-resolved data.
|
||||
* Same chrome, so a static panel and a loaded one are visually identical.
|
||||
*/
|
||||
export function StaticPanel({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
headingLevel = 2,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: React.ReactNode;
|
||||
headingLevel?: 2 | 3 | 4 | 5;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<VStack gap={4}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={actions}
|
||||
level={headingLevel}
|
||||
/>
|
||||
{children}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
100
src/shared/components/patterns/ResponsiveTable.tsx
Normal file
100
src/shared/components/patterns/ResponsiveTable.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import {Table} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
|
||||
/**
|
||||
* A table on wide screens; a list of cards on a phone.
|
||||
*
|
||||
* A six-column table cannot be made to work at 375px. Horizontal scroll is the
|
||||
* usual fallback, but it hides the columns that matter most — status and
|
||||
* actions sit on the right, which is exactly what is off-screen — and it puts
|
||||
* a scroll gesture inside a vertically scrolling page, which fights the thumb.
|
||||
*
|
||||
* So below `tablet` the same column definitions are re-rendered as stacked
|
||||
* label/value rows. Crucially it is the SAME `columns` array: the cell
|
||||
* renderers, formatting and badges are reused verbatim, so the two
|
||||
* presentations cannot drift and adding a column updates both.
|
||||
*
|
||||
* `primaryKey` names the column that becomes the card's heading — usually the
|
||||
* entity's name. `summaryKeys`, when given, limits which of the remaining
|
||||
* columns appear on the card, because a phone card listing nine fields is just
|
||||
* a table rotated 90°.
|
||||
*/
|
||||
export function ResponsiveTable<T extends Record<string, unknown>>({
|
||||
data,
|
||||
columns,
|
||||
idKey,
|
||||
primaryKey,
|
||||
summaryKeys,
|
||||
density = 'spacious',
|
||||
}: {
|
||||
data: T[];
|
||||
columns: TableColumn<T>[];
|
||||
/** Row identity. Falls back to the array index when a row has no id field. */
|
||||
idKey?: keyof T & string;
|
||||
primaryKey: string;
|
||||
summaryKeys?: string[];
|
||||
density?: 'compact' | 'balanced' | 'spacious';
|
||||
}) {
|
||||
const bp = useBreakpoint();
|
||||
|
||||
if (bp !== 'mobile') {
|
||||
return (
|
||||
<Table
|
||||
data={data}
|
||||
columns={columns}
|
||||
idKey={idKey}
|
||||
density={density}
|
||||
hasHover
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const primary = columns.find((c) => c.key === primaryKey);
|
||||
const rest = columns.filter(
|
||||
(c) =>
|
||||
c.key !== primaryKey &&
|
||||
(summaryKeys ? summaryKeys.includes(c.key) : true),
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
{data.map((row, i) => (
|
||||
<Card key={idKey ? String(row[idKey]) : i} variant="muted">
|
||||
<VStack gap={3}>
|
||||
{primary ? (
|
||||
<VStack gap={0}>
|
||||
{primary.renderCell
|
||||
? primary.renderCell(row)
|
||||
: String(row[primary.key] ?? '')}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{rest.length ? <Divider /> : null}
|
||||
|
||||
<VStack gap={2}>
|
||||
{rest.map((col) => (
|
||||
<HStack key={col.key} hAlign="between" vAlign="center" gap={3}>
|
||||
<Text size="sm" color="secondary">
|
||||
{typeof col.header === 'string' ? col.header : col.key}
|
||||
</Text>
|
||||
{/* The real cell renderer — badges, deltas and progress bars
|
||||
all survive the transposition unchanged. */}
|
||||
{col.renderCell
|
||||
? col.renderCell(row)
|
||||
: <Text size="sm">{String(row[col.key] ?? '')}</Text>}
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
54
src/shared/components/patterns/SectionHeader.tsx
Normal file
54
src/shared/components/patterns/SectionHeader.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Heading, Text} from '@astryxdesign/core/Text';
|
||||
|
||||
/**
|
||||
* Title + optional subtitle + optional trailing actions.
|
||||
*
|
||||
* This block was being hand-built in ChartCard, ActivityTimeline, Loyaly AI
|
||||
* panel and AiTab, each with slightly different heading levels and gaps. It is
|
||||
* the single most repeated shape in the app, so it gets one definition.
|
||||
*
|
||||
* `level` exists because the same visual treatment has to sit at different
|
||||
* depths in the document outline — a panel inside a page is an h4 even though
|
||||
* it looks identical to an h3 elsewhere. Screen-reader users navigate by that
|
||||
* outline, so it must stay accurate.
|
||||
*/
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
level = 2,
|
||||
}: {
|
||||
title: string;
|
||||
/**
|
||||
* A plain string gets the standard secondary treatment. A node is rendered
|
||||
* as-is, which is what lets entity cards — whose "subtitle" is an icon plus
|
||||
* a value — share this component instead of rebuilding the same
|
||||
* title / sub / trailing-action arrangement.
|
||||
*/
|
||||
subtitle?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
level?: 2 | 3 | 4 | 5;
|
||||
}) {
|
||||
return (
|
||||
<HStack hAlign="between" vAlign="start" gap={3}>
|
||||
<VStack gap={0.5}>
|
||||
<Heading level={level}>{title}</Heading>
|
||||
{typeof subtitle === 'string' ? (
|
||||
<Text size="sm" color="secondary">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : (
|
||||
subtitle
|
||||
)}
|
||||
</VStack>
|
||||
{actions ? (
|
||||
<HStack gap={1} vAlign="center">
|
||||
{actions}
|
||||
</HStack>
|
||||
) : null}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
41
src/shared/components/patterns/StatPair.tsx
Normal file
41
src/shared/components/patterns/StatPair.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
|
||||
/**
|
||||
* A caption above a value.
|
||||
*
|
||||
* The smallest repeated unit in the app — it appears four to six times inside
|
||||
* every store card, reward card and staff card. Left as inline markup it is
|
||||
* where label sizing quietly drifts between modules.
|
||||
*/
|
||||
export function StatPair({
|
||||
label,
|
||||
value,
|
||||
align = 'start',
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
align?: 'start' | 'end';
|
||||
}) {
|
||||
return (
|
||||
<VStack gap={0.5} hAlign={align}>
|
||||
<Text size="sm" color="secondary">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="base" weight="semibold">
|
||||
{value}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row of StatPairs, evenly distributed — the footer of most entity cards. */
|
||||
export function StatRow({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<HStack gap={4} hAlign="between" wrap="wrap">
|
||||
{children}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
56
src/shared/components/primitives/MetricDelta.tsx
Normal file
56
src/shared/components/primitives/MetricDelta.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {formatDelta} from '@/shared/utils/format';
|
||||
|
||||
/**
|
||||
* Period-over-period change.
|
||||
*
|
||||
* Direction and goodness are separate ideas: a rise in refunds is bad, a rise
|
||||
* in revenue is good. The arrow shows direction, the colour shows whether that
|
||||
* direction is good — which is why `isRiseGood` is a required part of the KPI
|
||||
* contract rather than an assumption baked in here.
|
||||
*
|
||||
* This is one of the few places semantic colour is allowed, because the colour
|
||||
* IS the information.
|
||||
*/
|
||||
export function MetricDelta({
|
||||
value,
|
||||
isRiseGood = true,
|
||||
size = 'sm',
|
||||
}: {
|
||||
value: number;
|
||||
isRiseGood?: boolean;
|
||||
size?: 'xsm' | 'sm';
|
||||
}) {
|
||||
const isFlat = Math.abs(value) < 0.05;
|
||||
const isGood = value > 0 === isRiseGood;
|
||||
|
||||
// Icon exposes semantic colours as props; Text does not (its union stops at
|
||||
// primary/secondary/disabled/accent). For the label we go through the
|
||||
// Tailwind bridge instead, which maps --color-success/--color-error into
|
||||
// utilities — still a token, never a literal.
|
||||
const textClass = isFlat
|
||||
? 'text-secondary'
|
||||
: isGood
|
||||
? 'text-success'
|
||||
: 'text-error';
|
||||
|
||||
return (
|
||||
<HStack gap={0.5} vAlign="center">
|
||||
{isFlat ? null : (
|
||||
<Icon
|
||||
icon={value > 0 ? ICONS.up : ICONS.down}
|
||||
size="xsm"
|
||||
color={isGood ? 'success' : 'error'}
|
||||
/>
|
||||
)}
|
||||
<Text size={size} weight="medium" color="inherit" className={textClass}>
|
||||
{isFlat ? '—' : formatDelta(value)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
54
src/shared/components/primitives/PageHeader.tsx
Normal file
54
src/shared/components/primitives/PageHeader.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Heading, Text} from '@astryxdesign/core/Text';
|
||||
|
||||
/**
|
||||
* Every workspace page opens the same way: title, one line of orientation,
|
||||
* then the filters and actions that belong to THIS page. Centralising it is
|
||||
* what keeps five modules from drifting into five different header treatments.
|
||||
*
|
||||
* Two trailing slots, deliberately distinct:
|
||||
* `controls` — filters that scope the page's data (store, period, compare).
|
||||
* Own row beneath the title, because a page can have several
|
||||
* and they would otherwise crowd the heading and wrap badly.
|
||||
* `actions` — page-level verbs (export, add store). Trailing edge of the
|
||||
* title row, where a primary action is conventionally found.
|
||||
*
|
||||
* `eyebrow` is the small line ABOVE the title — a greeting, a breadcrumb-ish
|
||||
* context line. It sits inside the heading block rather than above the whole
|
||||
* header so it cannot drift away from the title it belongs to.
|
||||
*/
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
controls,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
controls?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<HStack hAlign="between" vAlign="center" gap={4} wrap="wrap">
|
||||
<VStack gap={1}>
|
||||
{eyebrow ? (
|
||||
<Text size="sm" color="secondary">
|
||||
{eyebrow}
|
||||
</Text>
|
||||
) : null}
|
||||
<Heading level={1} className="font-bold">{title}</Heading>
|
||||
{description ? <Text color="secondary">{description}</Text> : null}
|
||||
</VStack>
|
||||
{controls || actions ? (
|
||||
<HStack gap={2} vAlign="center" wrap="wrap">
|
||||
{controls}
|
||||
{actions}
|
||||
</HStack>
|
||||
) : null}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
106
src/shared/components/scope/RangePicker.tsx
Normal file
106
src/shared/components/scope/RangePicker.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Dialog} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {RANGE_LABELS, useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import type {RangeKey} from '@/shared/providers/WorkspaceProvider';
|
||||
|
||||
const ORDER: RangeKey[] = ['7d', '30d', '90d', 'mtd', 'ytd'];
|
||||
|
||||
/** Period scope for the charts and KPIs on the page that renders it. */
|
||||
export function RangePicker() {
|
||||
const {range, setRange, customRange, setCustomRange} = useWorkspace();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [startDate, setStartDate] = useState(customRange?.start || '2026-08-01');
|
||||
const [endDate, setEndDate] = useState(customRange?.end || '2026-08-05');
|
||||
|
||||
const getLabel = () => {
|
||||
if (range === 'custom' && customRange?.start && customRange?.end) {
|
||||
return `${customRange.start} – ${customRange.end}`;
|
||||
}
|
||||
return RANGE_LABELS[range] || 'Select period';
|
||||
};
|
||||
|
||||
const handleApplyCustom = () => {
|
||||
setCustomRange({start: startDate, end: endDate});
|
||||
setRange('custom');
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu
|
||||
button={{
|
||||
variant: 'secondary',
|
||||
size: 'sm',
|
||||
label: getLabel(),
|
||||
icon: <Icon icon="calendar" size="sm" />,
|
||||
}}
|
||||
items={[
|
||||
...ORDER.map((key) => ({
|
||||
label: RANGE_LABELS[key],
|
||||
onClick: () => setRange(key),
|
||||
})),
|
||||
{type: 'divider' as const},
|
||||
{
|
||||
label: 'Custom range...',
|
||||
onClick: () => setIsDialogOpen(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
isOpen={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
width={400}
|
||||
purpose="info"
|
||||
>
|
||||
<VStack gap={4} padding={4}>
|
||||
<VStack gap={1}>
|
||||
<Heading level={3}>Custom Date Range</Heading>
|
||||
<Text size="sm" color="secondary">
|
||||
Select start and end dates to query performance data.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={3}>
|
||||
<TextInput
|
||||
type="text"
|
||||
label="Start Date"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
/>
|
||||
<TextInput
|
||||
type="text"
|
||||
label="End Date"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<HStack hAlign="end" gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Cancel"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Apply Range"
|
||||
onClick={handleApplyCustom}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/shared/components/scope/ScopeControls.tsx
Normal file
29
src/shared/components/scope/ScopeControls.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import {StoreSwitcher} from './StoreSwitcher';
|
||||
import {RangePicker} from './RangePicker';
|
||||
|
||||
/**
|
||||
* The store + period controls a data page puts in its own header.
|
||||
*
|
||||
* These read and write WorkspaceProvider, so the selection still persists
|
||||
* across navigation — what changed is where they are *stated*. A page that
|
||||
* queries by store and period declares those controls itself; a page that
|
||||
* does not (Settings) shows none, instead of the shell implying a scope that
|
||||
* nothing on screen honours.
|
||||
*
|
||||
* Returns a fragment: PageHeader's `controls` slot supplies the row.
|
||||
*/
|
||||
export function ScopeControls({
|
||||
/** Off for pages that read the period but deliberately ignore store scope. */
|
||||
hasStore = true,
|
||||
}: {
|
||||
hasStore?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{hasStore ? <StoreSwitcher /> : null}
|
||||
<RangePicker />
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
src/shared/components/scope/StoreSwitcher.tsx
Normal file
43
src/shared/components/scope/StoreSwitcher.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
|
||||
/**
|
||||
* Store scope for the page that renders it. The selection lives in
|
||||
* WorkspaceProvider, so switching re-queries every panel on the page without a
|
||||
* route change — which is what keeps Loyaly AI mounted — and the choice
|
||||
* carries to the next page that also scopes by store.
|
||||
*
|
||||
* `secondary` rather than `ghost`: in a page header these are the row, not
|
||||
* decoration on a bar, and they need a visible edge to read as controls.
|
||||
*/
|
||||
export function StoreSwitcher() {
|
||||
const {storeId, setStoreId, stores} = useWorkspace();
|
||||
const current = stores.find((s) => s.id === storeId);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
button={{
|
||||
variant: 'secondary',
|
||||
size: 'sm',
|
||||
label: current ? current.name : 'All stores',
|
||||
icon: <Icon icon={ICONS.stores} size="sm" />,
|
||||
}}
|
||||
items={[
|
||||
{label: 'All stores', onClick: () => setStoreId('all')},
|
||||
{type: 'divider'},
|
||||
{
|
||||
type: 'section',
|
||||
title: 'Stores',
|
||||
items: stores.map((s) => ({
|
||||
label: s.name,
|
||||
onClick: () => setStoreId(s.id),
|
||||
})),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
125
src/shared/hooks/useBreakpoint.ts
Normal file
125
src/shared/hooks/useBreakpoint.ts
Normal 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;
|
||||
}
|
||||
94
src/shared/hooks/usePersistentFlag.ts
Normal file
94
src/shared/hooks/usePersistentFlag.ts
Normal 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];
|
||||
}
|
||||
144
src/shared/hooks/useResource.ts
Normal file
144
src/shared/hooks/useResource.ts
Normal 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>;
|
||||
}
|
||||
22
src/shared/hooks/useScope.ts
Normal file
22
src/shared/hooks/useScope.ts
Normal 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]);
|
||||
}
|
||||
26
src/shared/layouts/ProtectedLayout.tsx
Normal file
26
src/shared/layouts/ProtectedLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/shared/layouts/PublicLayout.tsx
Normal file
16
src/shared/layouts/PublicLayout.tsx
Normal 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>;
|
||||
}
|
||||
26
src/shared/layouts/workspace/AccountDialogs.tsx
Normal file
26
src/shared/layouts/workspace/AccountDialogs.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
244
src/shared/layouts/workspace/AccountMenu.tsx
Normal file
244
src/shared/layouts/workspace/AccountMenu.tsx
Normal 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,
|
||||
},
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
src/shared/layouts/workspace/AccountMenuProvider.tsx
Normal file
81
src/shared/layouts/workspace/AccountMenuProvider.tsx
Normal 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;
|
||||
}
|
||||
298
src/shared/layouts/workspace/AccountPanels.tsx
Normal file
298
src/shared/layouts/workspace/AccountPanels.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
154
src/shared/layouts/workspace/AccountSheet.tsx
Normal file
154
src/shared/layouts/workspace/AccountSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/shared/layouts/workspace/AppSideNav.tsx
Normal file
107
src/shared/layouts/workspace/AppSideNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/shared/layouts/workspace/AppTopNav.tsx
Normal file
73
src/shared/layouts/workspace/AppTopNav.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
123
src/shared/layouts/workspace/MobileMenu.tsx
Normal file
123
src/shared/layouts/workspace/MobileMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/shared/layouts/workspace/NavMenuButton.tsx
Normal file
63
src/shared/layouts/workspace/NavMenuButton.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
96
src/shared/layouts/workspace/SideNavBrand.tsx
Normal file
96
src/shared/layouts/workspace/SideNavBrand.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/shared/layouts/workspace/SidebarProvider.tsx
Normal file
123
src/shared/layouts/workspace/SidebarProvider.tsx
Normal 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;
|
||||
}
|
||||
170
src/shared/layouts/workspace/WorkspaceShell.tsx
Normal file
170
src/shared/layouts/workspace/WorkspaceShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
src/shared/layouts/workspace/account-menu.ts
Normal file
168
src/shared/layouts/workspace/account-menu.ts
Normal 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'},
|
||||
],
|
||||
},
|
||||
};
|
||||
32
src/shared/layouts/workspace/nav-config.ts
Normal file
32
src/shared/layouts/workspace/nav-config.ts
Normal 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}/`);
|
||||
}
|
||||
64
src/shared/layouts/workspace/shortcuts.ts
Normal file
64
src/shared/layouts/workspace/shortcuts.ts
Normal 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)),
|
||||
];
|
||||
49
src/shared/layouts/workspace/useAccountActions.ts
Normal file
49
src/shared/layouts/workspace/useAccountActions.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
57
src/shared/layouts/workspace/useWorkspaceShortcuts.ts
Normal file
57
src/shared/layouts/workspace/useWorkspaceShortcuts.ts
Normal 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
46
src/shared/mock/rng.ts
Normal 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)],
|
||||
};
|
||||
}
|
||||
94
src/shared/providers/WorkspaceProvider.tsx
Normal file
94
src/shared/providers/WorkspaceProvider.tsx
Normal 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;
|
||||
}
|
||||
114
src/shared/services/apiRoute.ts
Normal file
114
src/shared/services/apiRoute.ts
Normal 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';
|
||||
}
|
||||
164
src/shared/services/httpClient.ts
Normal file
164
src/shared/services/httpClient.ts
Normal 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
43
src/shared/types/api.ts
Normal 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;
|
||||
}
|
||||
16
src/shared/utils/appInfo.ts
Normal file
16
src/shared/utils/appInfo.ts
Normal 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;
|
||||
22
src/shared/utils/export/csvExport.ts
Normal file
22
src/shared/utils/export/csvExport.ts
Normal 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;'});
|
||||
}
|
||||
46
src/shared/utils/export/excelExport.ts
Normal file
46
src/shared/utils/export/excelExport.ts
Normal 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;',
|
||||
});
|
||||
}
|
||||
77
src/shared/utils/export/exportManager.ts
Normal file
77
src/shared/utils/export/exportManager.ts
Normal 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);
|
||||
}
|
||||
69
src/shared/utils/export/pdfExport.ts
Normal file
69
src/shared/utils/export/pdfExport.ts
Normal 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'});
|
||||
}
|
||||
80
src/shared/utils/format.ts
Normal file
80
src/shared/utils/format.ts
Normal 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
191
src/shared/utils/icons.ts
Normal 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;
|
||||
Reference in New Issue
Block a user