update ui update and fix layout issue
This commit is contained in:
@@ -3,16 +3,16 @@
|
||||
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 {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 '@/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';
|
||||
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
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
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 {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 '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
import type {Store, StoreStatus} from '@/features/stores/types/store';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
type Filter = 'all' | StoreStatus;
|
||||
|
||||
@@ -19,7 +19,7 @@ type Filter = 'all' | StoreStatus;
|
||||
* 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.
|
||||
* 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;
|
||||
|
||||
38
src/features/stores/hooks/useStoreDirectory.ts
Normal file
38
src/features/stores/hooks/useStoreDirectory.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import {useCallback} from 'react';
|
||||
import {
|
||||
resolveScopeLabel,
|
||||
resolveStoreName,
|
||||
} from '@/features/stores/services/storeService';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
|
||||
/**
|
||||
* Resolving a store id to a name, without reaching into fixtures.
|
||||
*
|
||||
* ── The violation this replaces ───────────────────────────────────────────
|
||||
* Four pages and two components imported `storeName()` straight out of
|
||||
* `lib/mock/stores`, which meant rendered output depended on a fixture module:
|
||||
* the day the mock is deleted, every one of those files breaks. That is
|
||||
* exactly the coupling the "no mock data in components" rule exists to stop.
|
||||
*
|
||||
* The directory to ask is the one the store SWITCHER already reads —
|
||||
* WorkspaceProvider's list. Today it is a constant; when it comes from
|
||||
* `/api/stores`, neither this hook nor its callers change.
|
||||
*
|
||||
* The hook is only the React binding; the rules are in storeService, so a
|
||||
* server component or a test can resolve the same names without it.
|
||||
*/
|
||||
export function useStoreName(): (storeId: string) => string {
|
||||
const {stores} = useWorkspace();
|
||||
return useCallback(
|
||||
(storeId: string) => resolveStoreName(stores, storeId),
|
||||
[stores],
|
||||
);
|
||||
}
|
||||
|
||||
/** "all stores", or the selected store's name. */
|
||||
export function useScopeLabel(): string {
|
||||
const {stores, storeId} = useWorkspace();
|
||||
return resolveScopeLabel(stores, storeId);
|
||||
}
|
||||
28
src/features/stores/hooks/useStores.ts
Normal file
28
src/features/stores/hooks/useStores.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import {storeRepository} from '@/features/stores/repositories/storeRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
|
||||
/**
|
||||
* Store data access. See features/dashboard/hooks/useDashboard for the pattern.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The directory always asks for ALL stores regardless of the workspace filter:
|
||||
* a page whose job is to compare locations cannot be filtered down to one, or
|
||||
* it has nothing to compare. The period still applies.
|
||||
*/
|
||||
export function useStoreList() {
|
||||
const {range} = useScope();
|
||||
return useResource(storeRepository.list({range, storeId: 'all'}));
|
||||
}
|
||||
|
||||
export function useStore(storeId: string) {
|
||||
const {range} = useScope();
|
||||
return useResource(storeRepository.byId({range, storeId}, storeId), {
|
||||
// A single object is never "empty" — without this, useResource's default
|
||||
// array check would call a populated store empty.
|
||||
isEmpty: () => false,
|
||||
});
|
||||
}
|
||||
119
src/features/stores/mock/stores.mock.ts
Normal file
119
src/features/stores/mock/stores.mock.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
}
|
||||
11
src/features/stores/repositories/storeRepository.ts
Normal file
11
src/features/stores/repositories/storeRepository.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import type {Store} from '@/features/stores/types/store';
|
||||
|
||||
/** Store endpoints. See dashboardRepository for the layer's contract. */
|
||||
export const storeRepository = {
|
||||
list: (scope: Scope): Endpoint<Store[]> => scopedEndpoint('/api/stores', scope),
|
||||
|
||||
byId: (scope: Scope, storeId: string): Endpoint<Store> =>
|
||||
scopedEndpoint(`/api/stores/${storeId}`, scope),
|
||||
};
|
||||
38
src/features/stores/services/storeService.ts
Normal file
38
src/features/stores/services/storeService.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function resolveStoreName(
|
||||
stores: StoreOption[],
|
||||
storeId: string,
|
||||
): string {
|
||||
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.
|
||||
*/
|
||||
export function resolveScopeLabel(
|
||||
stores: StoreOption[],
|
||||
storeId: string,
|
||||
): string {
|
||||
return storeId === ALL_STORES
|
||||
? 'all stores'
|
||||
: resolveStoreName(stores, storeId);
|
||||
}
|
||||
20
src/features/stores/types/store.ts
Normal file
20
src/features/stores/types/store.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Store directory contracts.
|
||||
*
|
||||
* The wire shape for this feature. Imported by BOTH its route handlers and its
|
||||
* components, so a server/client drift is a type error rather than a runtime
|
||||
* surprise. When a real backend arrives, this file is the negotiation artifact.
|
||||
*/
|
||||
|
||||
export type StoreStatus = 'open' | 'closed' | 'maintenance';
|
||||
|
||||
export interface Store {
|
||||
id: string;
|
||||
name: string;
|
||||
status: StoreStatus;
|
||||
visitors: number;
|
||||
purchases: number;
|
||||
revenueInr: number;
|
||||
conversionPct: number;
|
||||
staffCount: number;
|
||||
}
|
||||
Reference in New Issue
Block a user