update ui and remove mock data
This commit is contained in:
65
src/shared/components/patterns/FeatureUnavailable.tsx
Normal file
65
src/shared/components/patterns/FeatureUnavailable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)],
|
||||
};
|
||||
}
|
||||
@@ -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
106
src/shared/services/bff.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user