update ui update and fix layout issue

This commit is contained in:
2026-08-06 13:21:10 +05:30
parent 92322bff16
commit e5f8144fb3
251 changed files with 9041 additions and 2119 deletions

View File

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

View File

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

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

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

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

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

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

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