first commit
This commit is contained in:
82
src/features/stores/StoreCard.tsx
Normal file
82
src/features/stores/StoreCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/features/stores/StoreGrid.tsx
Normal file
108
src/features/stores/StoreGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user