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

@@ -0,0 +1,65 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/shared/utils/icons';
/**
* "This needs a backend resource that does not exist yet."
*
* ── Why this exists instead of fixture data ──────────────────────────────
* Several screens in this console were built against a loyalty domain — LYT
* balances, reward catalogues, engagement activities, product inventory — that
* the Loyaly platform does not expose. The choice was to keep inventing
* numbers or to say so. Invented numbers are worse than a blank panel: a
* merchant cannot tell them from real ones, and the first time they act on one
* the whole product loses its credibility.
*
* So the screen stays, its layout stays, and the panel states plainly what it
* is waiting for. When the endpoint ships, one hook replaces one component.
*
* This is NOT an error state. The platform's own vocabulary has a status for
* exactly this — 501, "the feature is off for this deployment, not an error" —
* and the treatment is deliberately calm: secondary text, no red, no alarm.
*/
export function FeatureUnavailable({
title,
description,
endpoints,
}: {
title: string;
description: string;
/** The API resources this section needs. Naming them turns a dead end into
* a work item somebody can pick up. */
endpoints?: string[];
}) {
return (
<Card>
<VStack gap={3}>
<HStack gap={2} vAlign="center">
<Icon icon={ICONS.info} size="sm" color="secondary" />
<Heading level={2}>{title}</Heading>
</HStack>
<Text size="sm" color="secondary">
{description}
</Text>
{endpoints?.length ? (
<VStack gap={1}>
<Text size="xsm" color="secondary" weight="medium">
Required backend resources
</Text>
{endpoints.map((e) => (
<Text key={e} size="xsm" color="disabled" className="font-mono">
{e}
</Text>
))}
</VStack>
) : null}
</VStack>
</Card>
);
}

View File

@@ -4,7 +4,7 @@ import {useCallback, useEffect, useRef, useState} from 'react';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Icon} from '@astryxdesign/core/Icon';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Item} from '@astryxdesign/core/Item';
import {Text} from '@astryxdesign/core/Text';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {layerAnimations} from '@astryxdesign/core/Layer';
import {usePopover} from '@astryxdesign/core/Popover';
@@ -151,46 +151,42 @@ export function AccountMenu() {
data-testid="account-menu-trigger"
/>
) : (
<Item
ref={triggerRef}
// role="button" rather than Item's default: given a role, Item skips
// the inner <button> it would otherwise wrap the label in and puts
// the handler on the root — which is the element the ARIA below has
// to describe, and the element the popover is anchored to. With the
// inner button in play those would be two different nodes.
<HStack
ref={triggerRef as any}
role="button"
tabIndex={0}
onClick={toggle}
// A native <button> synthesises click from Enter and Space;
// role="button" does not, so it is spelled out.
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggle();
}
}}
vAlign="center"
hAlign="between"
gap={2}
className={`w-full px-2.5 py-2 rounded-lg transition-all duration-150 cursor-pointer border ${
isOpen
? 'bg-neutral border-border-emphasized shadow-2xs'
: 'border-transparent hover:bg-overlay-hover hover:border-border'
}`}
{...triggerProps}
label={label}
description={user?.email ?? 'Not signed in'}
labelLines={1}
descriptionLines={1}
density="balanced"
className={CHIP}
// isHighlighted, not isSelected: Item turns isSelected into
// aria-current on a root that has no role permitting aria-selected,
// and "current item" is not what an open menu means. aria-expanded
// above already says it; this only has to look lit.
isHighlighted={isOpen}
startContent={
<Avatar name={user?.name || undefined} size="sm" tooltip={false} />
}
// The switcher glyph, not a chevron: this opens a panel of its own,
// it does not expand in place.
endContent={
<Icon icon={ICONS.switcher} size="sm" color="secondary" />
}
data-testid="account-menu-trigger"
/>
>
<HStack gap={2} vAlign="center" className="min-w-0 flex-1">
<Avatar name={user?.name || undefined} size="sm" tooltip={false} />
<VStack gap={0} className="min-w-0 flex-1">
<Text size="sm" weight="semibold" color="primary" className="truncate">
{label}
</Text>
<Text size="xsm" color="secondary" className="truncate">
{user?.email ?? 'Not signed in'}
</Text>
</VStack>
</HStack>
<Icon icon={ICONS.switcher} size="sm" color="secondary" className="shrink-0 opacity-60" />
</HStack>
)}
{render(

View File

@@ -17,7 +17,7 @@ export const PRIMARY_NAV: NavEntry[] = [
{label: 'Commerce', href: '/commerce', icon: ICONS.commerce},
{label: 'Store', href: '/stores', icon: ICONS.stores},
{label: 'Lyts', href: '/lyts', icon: ICONS.lyts},
{label: 'Leaderboard', href: '/staff', icon: ICONS.staff},
{label: 'Leaderboard', href: '/staff', icon: ICONS.leaderboard},
];
/** Pinned to the bottom of the sidebar via SideNav's `footer` slot. */

View File

@@ -1,46 +0,0 @@
/**
* Seeded, deterministic pseudo-randomness.
*
* Every generator derives its seed from the store id and the metric name, so
* the same request always returns the same numbers. That matters more than it
* sounds: when the whole point of this phase is judging a design, numbers that
* reshuffle on every reload make it impossible to tell a layout change from a
* data change — and it keeps screenshots stable.
*/
export function hashSeed(...parts: (string | number)[]): number {
let h = 2166136261;
for (const part of parts) {
const s = String(part);
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
}
return h >>> 0;
}
/** mulberry32 — small, fast, good enough for fixtures. */
export function mulberry32(seed: number): () => number {
let a = seed;
return function next() {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function createRng(...parts: (string | number)[]) {
const next = mulberry32(hashSeed(...parts));
return {
next,
/** Integer in [min, max]. */
int: (min: number, max: number) =>
Math.floor(next() * (max - min + 1)) + min,
/** Float in [min, max). */
float: (min: number, max: number) => next() * (max - min) + min,
pick: <T>(arr: readonly T[]): T => arr[Math.floor(next() * arr.length)],
};
}

View File

@@ -1,6 +1,8 @@
'use client';
import {createContext, useContext, useMemo, useState} from 'react';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {useSites} from '@/features/stores/hooks/useSites';
/**
* Workspace-wide query scope: which store, and over what period.
@@ -38,17 +40,12 @@ export interface StoreOption {
}
/**
* Store list for the switcher. Deliberately static: the switcher must render
* before any data loads, and a spinner in a page header on every load reads
* as jank. The authoritative list comes from /api/stores once Store lands.
* The "everything" scope. Present before any request completes because the
* switcher has to render something on the first frame, and because it is a
* real selection rather than a placeholder: upstream expresses it as the
* ABSENCE of a `site` filter, so it needs no server round trip to be valid.
*/
export const STORE_OPTIONS: StoreOption[] = [
{id: 'blr-indiranagar', name: 'Indiranagar Flagship'},
{id: 'blr-koramangala', name: 'Koramangala'},
{id: 'blr-whitefield', name: 'Whitefield'},
{id: 'che-anna-nagar', name: 'Anna Nagar'},
{id: 'hyd-jubilee', name: 'Jubilee Hills'},
];
export const ALL_STORES: StoreOption = {id: 'all', name: 'All stores'};
export type StoreScope = 'all' | (string & {});
@@ -59,7 +56,11 @@ interface WorkspaceValue {
setRange: (r: RangeKey) => void;
customRange: CustomDateRange | null;
setCustomRange: (cr: CustomDateRange | null) => void;
/** The estate, from GET /api/sites. Empty until it loads. */
stores: StoreOption[];
/** True while the estate is still loading — the switcher shows it rather
* than pretending the merchant has no shops. */
isLoadingStores: boolean;
}
const WorkspaceContext = createContext<WorkspaceValue | null>(null);
@@ -69,6 +70,27 @@ export function WorkspaceProvider({children}: {children: React.ReactNode}) {
const [range, setRange] = useState<RangeKey>('30d');
const [customRange, setCustomRange] = useState<CustomDateRange | null>(null);
/*
* The estate is FETCHED, never hardcoded. It used to be a literal array of
* five Bengaluru shops, which meant every scoped request in the app was
* filtered by a store id that only existed in this file.
*
* Gated on the session because this provider sits above the whole route
* tree, including /login: asking for the estate before anyone has signed in
* would put a guaranteed 401 in the console on every visit to the sign-in
* page.
*/
const {status} = useSession();
const sites = useSites();
const isAuthenticated = status === 'authenticated';
const stores = useMemo<StoreOption[]>(() => {
if (!isAuthenticated || !sites.data) return [ALL_STORES];
// `id` here is the platform's immutable slug — see Site. It is what goes
// out as `?site=` on every scoped request.
return [ALL_STORES, ...sites.data.map((s) => ({id: s.id, name: s.name}))];
}, [isAuthenticated, sites.data]);
const value = useMemo(
() => ({
storeId,
@@ -77,9 +99,10 @@ export function WorkspaceProvider({children}: {children: React.ReactNode}) {
setRange,
customRange,
setCustomRange,
stores: STORE_OPTIONS,
stores,
isLoadingStores: isAuthenticated && sites.status === 'loading',
}),
[storeId, range, customRange],
[storeId, range, customRange, stores, isAuthenticated, sites.status],
);
return <WorkspaceContext value={value}>{children}</WorkspaceContext>;

106
src/shared/services/bff.ts Normal file
View File

@@ -0,0 +1,106 @@
import 'server-only';
import type {NextRequest} from 'next/server';
import {UpstreamError} from '@/services/api/apiClient';
import {NoSessionError, withUpstream} from '@/features/auth/services/upstreamSession';
import {ok, parseQuery, type Query} from '@/shared/services/apiRoute';
import type {ApiErrorCode} from '@/shared/types/api';
/**
* The adapter every data route is made of.
*
* A BFF route in this app does exactly four things, and this function is all
* four so that a route file is only ever a URL and a mapping:
*
* 1. refuse anonymous requests
* 2. call the platform with a valid token, refreshing once if it expired
* 3. translate the platform's answer into the shape the UI already consumes
* 4. translate the platform's errors into this app's envelope
*
* Why translate at all, rather than letting the browser call the platform
* directly? Because ~20 `useResource` call sites depend on `{data, meta}`, and
* `meta.generatedAt` in particular is load-bearing: every relative timestamp,
* every countdown and the header greeting measure against the SERVER's clock,
* never the device's. Wrapping here is a few lines; rewriting every consumer
* to carry its own clock is not.
*/
/** Platform status → the envelope's code. The UI branches on these four. */
function toCode(status: number): ApiErrorCode {
if (status === 404) return 'not_found';
if (status === 401 || status === 403) return 'unauthorized';
if (status === 400 || status === 409 || status === 422) return 'bad_request';
return 'internal';
}
export interface UpstreamFailure {
status: number;
code: ApiErrorCode;
/** The platform's own wording — written for a person, so prefer it. */
message: string;
/** The platform's stable code, passed through for clients that branch on it. */
reason?: string;
}
export function failureFrom(err: unknown): UpstreamFailure {
if (err instanceof NoSessionError) {
return {status: 401, code: 'unauthorized', message: err.message};
}
if (err instanceof UpstreamError) {
// 501 is "the feature is off for this deployment", not a fault. It is
// surfaced as its own reason so a panel can say "not available here"
// instead of showing a red error for a system working as configured.
if (err.isUnsupported) {
return {
status: 501,
code: 'internal',
message: err.message,
reason: 'not_supported',
};
}
// A 404 is also what another tenant's data returns, always — so it must
// never be phrased as "deleted".
return {
status: err.status || 502,
code: toCode(err.status),
message: err.message,
reason: err.code,
};
}
return {
status: 500,
code: 'internal',
message: 'Something went wrong. Please try again.',
};
}
export function failResponse(err: unknown): Response {
const f = failureFrom(err);
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
/**
* Serve one platform read as an enveloped response.
*
* `map` exists so the platform's shape and the UI's shape can differ without a
* component knowing. Where they already agree, omit it.
*/
export async function serveUpstream<U, T = U>(
req: NextRequest,
fetcher: (accessToken: string, query: Query) => Promise<U>,
map?: (upstream: U, query: Query) => T,
): Promise<Response> {
const query = parseQuery(req);
try {
const upstream = await withUpstream((token) => fetcher(token, query));
const data = (map ? map(upstream, query) : upstream) as T;
return ok(data, query);
} catch (err) {
return failResponse(err);
}
}