update ui and remove mock data
This commit is contained in:
@@ -1,82 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {SectionHeader} from '@/shared/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 '@/shared/components/motion/HoverLift';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {formatCompact, formatInrCompact, formatPct} from '@/shared/utils/format';
|
||||
import type {Store, StoreStatus} from '@/features/stores/types/store';
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {FilterBar} from '@/shared/components/patterns/FilterBar';
|
||||
import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {StoreCard} from './StoreCard';
|
||||
import type {Store, StoreStatus} from '@/features/stores/types/store';
|
||||
import type {Resource} from '@/shared/hooks/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 Loyaly AI 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>
|
||||
);
|
||||
}
|
||||
17
src/features/stores/hooks/useSites.ts
Normal file
17
src/features/stores/hooks/useSites.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {siteRepository} from '@/features/stores/repositories/siteRepository';
|
||||
import type {Site} from '@/features/stores/types/site';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* The estate, from the platform.
|
||||
*
|
||||
* Unscoped on purpose — the site list is what the scope selector is FOR, so it
|
||||
* cannot itself be filtered by the selection. That also means one fetch per
|
||||
* session rather than one per store change.
|
||||
*/
|
||||
export function useSites(): Resource<Site[]> {
|
||||
return useResource(siteRepository.list());
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import type {Store} from '@/features/stores/types/store';
|
||||
import type {RangeKey} from '@/shared/types/api';
|
||||
import {buildTimeseries} from '@/features/dashboard/mock/dashboard.mock';
|
||||
|
||||
/**
|
||||
* The store roster. Names, staffing and status are fixed so the switcher, the
|
||||
* comparison panel and the Store module all agree; the numbers derive from the
|
||||
* shared timeseries so a store's card and its dashboard cannot disagree.
|
||||
*/
|
||||
export const STORE_SEED: {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string;
|
||||
staffCount: number;
|
||||
status: Store['status'];
|
||||
/**
|
||||
* Traffic multiplier relative to a typical store. This is the ONLY place a
|
||||
* store's size is defined — every generator multiplies by it, so a store's
|
||||
* dashboard, its comparison card and its row in the Store module cannot
|
||||
* disagree about how big it is.
|
||||
*
|
||||
* The spread is deliberate: the flagship genuinely outperforms and
|
||||
* Whitefield genuinely struggles (it's also the one Loyaly AI flags).
|
||||
* Fixtures that are uniformly average make a comparison view look like it
|
||||
* works while showing nothing.
|
||||
*/
|
||||
scale: number;
|
||||
}[] = [
|
||||
{
|
||||
id: 'blr-indiranagar',
|
||||
name: 'Indiranagar Flagship',
|
||||
city: 'Bengaluru',
|
||||
staffCount: 14,
|
||||
status: 'open',
|
||||
scale: 1.6,
|
||||
},
|
||||
{
|
||||
id: 'blr-koramangala',
|
||||
name: 'Koramangala',
|
||||
city: 'Bengaluru',
|
||||
staffCount: 9,
|
||||
status: 'open',
|
||||
scale: 1.0,
|
||||
},
|
||||
{
|
||||
id: 'blr-whitefield',
|
||||
name: 'Whitefield',
|
||||
city: 'Bengaluru',
|
||||
staffCount: 7,
|
||||
status: 'maintenance',
|
||||
scale: 0.62,
|
||||
},
|
||||
{
|
||||
id: 'che-anna-nagar',
|
||||
name: 'Anna Nagar',
|
||||
city: 'Chennai',
|
||||
staffCount: 8,
|
||||
status: 'open',
|
||||
scale: 0.85,
|
||||
},
|
||||
{
|
||||
id: 'hyd-jubilee',
|
||||
name: 'Jubilee Hills',
|
||||
city: 'Hyderabad',
|
||||
staffCount: 11,
|
||||
status: 'closed',
|
||||
scale: 1.15,
|
||||
},
|
||||
];
|
||||
|
||||
export function storeName(id: string): string {
|
||||
return STORE_SEED.find((s) => s.id === id)?.name ?? 'All stores';
|
||||
}
|
||||
|
||||
export function storeExists(id: string): boolean {
|
||||
return STORE_SEED.some((s) => s.id === id);
|
||||
}
|
||||
|
||||
/** Traffic multiplier for a store id; 'all' is the sum of every store. */
|
||||
export function storeScale(id: string): number {
|
||||
if (id === 'all') {
|
||||
return STORE_SEED.reduce((a, s) => a + s.scale, 0);
|
||||
}
|
||||
return STORE_SEED.find((s) => s.id === id)?.scale ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Totals per store, derived from the SAME buildTimeseries() the dashboard
|
||||
* charts use. Generating these independently is how a store's card ends up
|
||||
* claiming a different revenue than the store's own dashboard.
|
||||
*/
|
||||
export function buildStores(range: RangeKey, endMs: number): Store[] {
|
||||
return STORE_SEED.map((s) => {
|
||||
const points = buildTimeseries(range, s.id, endMs);
|
||||
const visitors = points.reduce((a, p) => a + p.visitors, 0);
|
||||
const purchases = points.reduce((a, p) => a + p.purchases, 0);
|
||||
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
visitors,
|
||||
purchases,
|
||||
revenueInr: points.reduce((a, p) => a + p.revenue, 0),
|
||||
conversionPct: visitors
|
||||
? Number(((purchases / visitors) * 100).toFixed(1))
|
||||
: 0,
|
||||
staffCount: s.staffCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildStore(
|
||||
id: string,
|
||||
range: RangeKey,
|
||||
endMs: number,
|
||||
): Store | null {
|
||||
return buildStores(range, endMs).find((s) => s.id === id) ?? null;
|
||||
}
|
||||
9
src/features/stores/repositories/siteRepository.ts
Normal file
9
src/features/stores/repositories/siteRepository.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type {Endpoint} from '@/shared/services/httpClient';
|
||||
import type {Site} from '@/features/stores/types/site';
|
||||
|
||||
/**
|
||||
* The estate endpoint. Unscoped: the site list is what scope is chosen FROM.
|
||||
*/
|
||||
export const siteRepository = {
|
||||
list: (): Endpoint<Site[]> => ({path: '/api/sites', params: {}}),
|
||||
};
|
||||
@@ -1,21 +1,15 @@
|
||||
import type {StoreOption} from '@/shared/providers/WorkspaceProvider';
|
||||
|
||||
/** The sentinel the workspace uses for "not filtered to one store". */
|
||||
export const ALL_STORES = 'all';
|
||||
|
||||
/**
|
||||
* Domain rules for the store directory.
|
||||
* Resolving a site id to the name a person reads.
|
||||
*
|
||||
* Framework-free so the same resolution is available to a server component, a
|
||||
* test or an export — the React binding lives in hooks/useStoreDirectory.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve an id to a display name.
|
||||
* The directory is whatever the switcher is showing — now GET /api/sites — so
|
||||
* a page never reaches past the provider for a name, and nothing imports a
|
||||
* fixture to render a label.
|
||||
*
|
||||
* Falls back to the id rather than to "Unknown store": an id at least tells a
|
||||
* developer which store lost its label, and a merchant only ever sees it if
|
||||
* the directory and the data genuinely disagree.
|
||||
* An unresolved id returns the id itself rather than "Unknown store": the id
|
||||
* is the platform's immutable slug, so it is at least true and searchable,
|
||||
* which "Unknown" is not.
|
||||
*/
|
||||
export function resolveStoreName(
|
||||
stores: StoreOption[],
|
||||
@@ -24,15 +18,11 @@ export function resolveStoreName(
|
||||
return stores.find((s) => s.id === storeId)?.name ?? storeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The phrase pages put mid-sentence in a subtitle: "all stores", or the name.
|
||||
* Lowercase because every call site embeds it in running text.
|
||||
*/
|
||||
/** "all stores", or the selected site's name. */
|
||||
export function resolveScopeLabel(
|
||||
stores: StoreOption[],
|
||||
storeId: string,
|
||||
): string {
|
||||
return storeId === ALL_STORES
|
||||
? 'all stores'
|
||||
: resolveStoreName(stores, storeId);
|
||||
if (storeId === 'all') return 'all stores';
|
||||
return resolveStoreName(stores, storeId);
|
||||
}
|
||||
|
||||
22
src/features/stores/types/site.ts
Normal file
22
src/features/stores/types/site.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* A shop, as the console consumes it.
|
||||
*
|
||||
* `id` is the platform's SLUG, not its uuid, and that is deliberate: the slug
|
||||
* is immutable, readable, and is what every scoped request sends as `?site=`.
|
||||
* The uuid is kept alongside for routes that prefer an opaque key, but nothing
|
||||
* should key on `name` — display names are expected to change.
|
||||
*
|
||||
* Health fields are nullable rather than zero-defaulted. A deployment that
|
||||
* does not report camera health is not a deployment with zero cameras up, and
|
||||
* rendering "0/0 cameras" for "not reported" is how a working estate looks
|
||||
* broken.
|
||||
*/
|
||||
export interface Site {
|
||||
id: string;
|
||||
uuid: string;
|
||||
name: string;
|
||||
isOnline: boolean | null;
|
||||
camerasTotal: number | null;
|
||||
camerasUp: number | null;
|
||||
fractionBelowGate: number | null;
|
||||
}
|
||||
Reference in New Issue
Block a user