first commit

This commit is contained in:
2026-08-05 15:26:50 +05:30
parent c000e5dc95
commit 2c8c394795
126 changed files with 10824 additions and 302 deletions

View File

@@ -0,0 +1,82 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {SectionHeader} from '@/components/patterns/SectionHeader';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {Divider} from '@astryxdesign/core/Divider';
import {Icon} from '@astryxdesign/core/Icon';
import {HoverLift} from '@/components/motion/HoverLift';
import {StatPair, StatRow} from '@/components/patterns/StatPair';
import {ICONS} from '@/lib/icons';
import {formatCompact, formatInrCompact, formatPct} from '@/lib/format';
import type {Store, StoreStatus} from '@/lib/api/contracts';
/**
* Status is the one thing on this card allowed colour, because it is the one
* thing that can require action. A closed or under-maintenance store is why a
* merchant opened this page; its visitor count is context.
*/
const STATUS: Record<
StoreStatus,
{label: string; variant: 'success' | 'warning' | 'error' | 'neutral'}
> = {
open: {label: 'Open', variant: 'success'},
maintenance: {label: 'Maintenance', variant: 'warning'},
closed: {label: 'Closed', variant: 'error'},
};
export function StoreCard({store}: {store: Store}) {
const status = STATUS[store.status];
return (
<HoverLift>
<Card>
<VStack gap={4} height="100%">
<SectionHeader
level={3}
title={store.name}
subtitle={
<HStack gap={1.5} vAlign="center">
<Icon icon={ICONS.staff} size="xsm" color="secondary" />
<Text size="sm" color="secondary">
{store.staffCount} staff
</Text>
</HStack>
}
actions={<Badge variant={status.variant} label={status.label} />}
/>
<Divider />
<StatRow>
<StatPair label="Visitors" value={formatCompact(store.visitors)} />
<StatPair
label="Purchases"
value={formatCompact(store.purchases)}
/>
</StatRow>
<StatRow>
<StatPair
label="Revenue"
value={formatInrCompact(store.revenueInr)}
/>
<StatPair
label="Conversion"
value={formatPct(store.conversionPct)}
/>
</StatRow>
<Button
variant="secondary"
size="sm"
label="Open dashboard"
href={`/stores/${store.id}`}
/>
</VStack>
</Card>
</HoverLift>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {FilterBar} from '@/components/patterns/FilterBar';
import {CollectionRegion} from '@/components/patterns/CollectionRegion';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {SkeletonCardGrid} from '@/components/patterns/LoadingState';
import {StoreCard} from './StoreCard';
import type {Store, StoreStatus} from '@/lib/api/contracts';
import type {Resource} from '@/lib/api/useResource';
type Filter = 'all' | StoreStatus;
/**
* Three-up on desktop, per spec.
*
* minWidth 270 is what actually yields three tracks once the 16px gaps are
* subtracted from a ~880px workspace — 300 silently collapsed it to two.
* Capped at 3 so a wide monitor doesn't stretch the roster into a thin strip,
* and it steps down to two when the Copilot panel is taking 380px.
*/
const STORE_GRID_COLUMNS = {minWidth: 270, max: 3, repeat: 'fit'} as const;
export function StoreGrid({resource}: {resource: Resource<Store[]>}) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<Filter>('all');
// Memoised on resource.data: `?? []` allocates a fresh array every render,
// which would make it an unstable dependency and recompute both memos below
// on every keystroke.
const stores = useMemo(() => resource.data ?? [], [resource.data]);
// Counts come from the unfiltered set so the chips still read "Closed (1)"
// while the closed filter is active — otherwise every chip but the selected
// one shows zero, which is useless.
const counts = useMemo(() => {
const c: Record<Filter, number> = {
all: stores.length,
open: 0,
maintenance: 0,
closed: 0,
};
for (const s of stores) c[s.status] += 1;
return c;
}, [stores]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return stores.filter(
(s) =>
(filter === 'all' || s.status === filter) &&
(q === '' || s.name.toLowerCase().includes(q)),
);
}, [stores, query, filter]);
return (
<CollectionRegion label="All stores">
<FilterBar
query={query}
onQueryChange={setQuery}
placeholder="Search stores…"
filterValue={filter}
onFilterChange={(v) => setFilter(v as Filter)}
filterLabel="Filter stores by status"
options={[
{value: 'all', label: 'All', hint: counts.all},
{value: 'open', label: 'Open', hint: counts.open},
{value: 'maintenance', label: 'Maintenance', hint: counts.maintenance},
{value: 'closed', label: 'Closed', hint: counts.closed},
]}
resultCount={visible.length}
resultNoun={visible.length === 1 ? 'store' : 'stores'}
/>
<AsyncBoundary
resource={resource}
loading={<SkeletonCardGrid count={6} height={240} />}
empty={
<EmptyPanel
icon="stores"
title="No stores yet"
description="Stores appear here once they are added to your Loyaly account."
/>
}
>
{() =>
visible.length === 0 ? (
// A filter returning nothing is a different situation from having
// no stores at all, and needs a different way out.
<EmptyPanel
icon="stores"
title="No stores match"
description="Try a different status filter or clear the search."
/>
) : (
<Grid columns={STORE_GRID_COLUMNS} gap={4}>
{visible.map((s) => (
<StoreCard key={s.id} store={s} />
))}
</Grid>
)
}
</AsyncBoundary>
</CollectionRegion>
);
}