update ui and remove mock data

This commit is contained in:
2026-09-09 19:50:52 +05:30
parent 5ce580dced
commit bfe4d3d2f2
172 changed files with 4280 additions and 8775 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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());
}

View File

@@ -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;
}

View 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: {}}),
};

View File

@@ -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);
}

View 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;
}