update api and docker
This commit is contained in:
@@ -125,6 +125,7 @@ COPY --chown=nextjs:nodejs .env ./.env
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 80
|
||||
|
||||
# NO HEALTHCHECK, on purpose.
|
||||
#
|
||||
|
||||
@@ -39,7 +39,7 @@ http {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 80 3000;
|
||||
server_name localhost;
|
||||
|
||||
# Serve Next.js compiled static assets directly via Nginx
|
||||
|
||||
@@ -24,15 +24,8 @@ import {
|
||||
TopOutletsPanel,
|
||||
TopProductsPanel,
|
||||
} from '@/features/commerce/components/CommercePanels';
|
||||
import {buildSampleSales} from '@/shared/mocks/sampleData';
|
||||
import {sampleNowMs, withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleNotice} from '@/shared/mocks/SampleNotice';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {shopToday} from '@/services/api/range';
|
||||
import {
|
||||
salesFacts,
|
||||
shopDay,
|
||||
salesTrend,
|
||||
summarise,
|
||||
totalPaise,
|
||||
@@ -82,33 +75,16 @@ const rupeesFormat = (paise: number) => formatInr(paise / 100);
|
||||
* product line. Their values live in shared/mocks/sampleData.ts.
|
||||
*/
|
||||
export default function CommercePage() {
|
||||
const realSales = useSales();
|
||||
const {range, custom, storeId} = useScope();
|
||||
const {stores} = useWorkspace();
|
||||
const sales = useSales();
|
||||
const {range, custom} = useScope();
|
||||
const isTodayRange = range === 'today';
|
||||
const today = useTodaySales(!isTodayRange);
|
||||
const scopeLabel = useScopeLabel();
|
||||
const conversion = useConversionReport({bucket: 'day'});
|
||||
|
||||
// The server's clock, never the device's — "today" is the shop's today.
|
||||
const serverNow =
|
||||
realSales.meta?.generatedAt ?? new Date(sampleNowMs()).toISOString();
|
||||
const serverNow = sales.meta?.generatedAt;
|
||||
|
||||
// SAMPLE — stands in only when the platform returned no sales for the
|
||||
// window, so a new shop sees the page populated. Spread across the stores
|
||||
// in scope so Top performing outlets shows the merchant's own shop names.
|
||||
const siteIds = useMemo(
|
||||
() =>
|
||||
storeId === 'all'
|
||||
? stores.filter((s) => s.id !== 'all').map((s) => s.id)
|
||||
: [storeId],
|
||||
[storeId, stores],
|
||||
);
|
||||
const sampleSales = useMemo(
|
||||
() => buildSampleSales({siteIds, range, custom: custom ?? null, serverNow}),
|
||||
[siteIds, range, custom, serverNow],
|
||||
);
|
||||
const sales = withSample(realSales, sampleSales, (d) => !!d?.length);
|
||||
const rows: Sale[] | undefined =
|
||||
sales.status === 'success' || sales.status === 'empty' ? sales.data : undefined;
|
||||
|
||||
@@ -127,9 +103,7 @@ export default function CommercePage() {
|
||||
|
||||
const todayRows: Sale[] | undefined = isTodayRange
|
||||
? rows
|
||||
: sales.isSample
|
||||
? sampleSales.filter((s) => shopDay(s.at) === shopToday(new Date(serverNow)))
|
||||
: today.status === 'success' || today.status === 'empty'
|
||||
: today.status === 'success' || today.status === 'empty'
|
||||
? today.data
|
||||
: undefined;
|
||||
// Revenue is rupees taken; orders is the NUMBER of bills. The captions say
|
||||
@@ -159,14 +133,6 @@ export default function CommercePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sales.isSample ? (
|
||||
<SampleNotice>
|
||||
No sales recorded in this period yet. The revenue, orders and outlet
|
||||
figures below are examples until staff record sales in the merchant
|
||||
app.
|
||||
</SampleNotice>
|
||||
) : null}
|
||||
|
||||
{summary && facts ? (
|
||||
<Grid columns={{minWidth: 220, max: 4, repeat: 'fit'}} gap={4}>
|
||||
<MetricCard
|
||||
@@ -212,7 +178,6 @@ export default function CommercePage() {
|
||||
<PanelCard
|
||||
title="Revenue trend"
|
||||
subtitle={isTodayRange ? 'Hourly sales so far today' : 'Daily sales across the selected period'}
|
||||
actions={<SampleTag show={sales.isSample} />}
|
||||
resource={sales}
|
||||
loading={<SkeletonChart height={220} />}
|
||||
empty={noSales}
|
||||
@@ -223,7 +188,6 @@ export default function CommercePage() {
|
||||
<PanelCard
|
||||
title="Orders volume trend"
|
||||
subtitle={isTodayRange ? 'Orders per hour so far today' : 'Orders per day across the selected period'}
|
||||
actions={<SampleTag show={sales.isSample} />}
|
||||
resource={sales}
|
||||
loading={<SkeletonChart height={220} />}
|
||||
empty={noSales}
|
||||
@@ -243,7 +207,7 @@ export default function CommercePage() {
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<PaymentMethodsPanel sales={sales} />
|
||||
<TopOutletsPanel sales={sales} isSample={sales.isSample} />
|
||||
<TopOutletsPanel sales={sales} />
|
||||
</Grid>
|
||||
|
||||
<TopProductsPanel />
|
||||
|
||||
@@ -45,15 +45,11 @@ import {
|
||||
formatCompact,
|
||||
formatInrCompact,
|
||||
} from '@/shared/utils/format';
|
||||
// TEMPORARY — see the header of this file for how to remove.
|
||||
import {
|
||||
MOCK_CONVERSION_DAILY,
|
||||
MOCK_CONVERSION_WEEKLY,
|
||||
MOCK_FOOTFALL_DAILY,
|
||||
MOCK_FOOTFALL_WEEKLY,
|
||||
} from '@/shared/mocks/sampleData';
|
||||
import {hasSignal, withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
emptyUnless,
|
||||
joinResources,
|
||||
reportHasSignal,
|
||||
} from '@/shared/hooks/resourceUtils';
|
||||
|
||||
const CHART_HEIGHT = 220;
|
||||
|
||||
@@ -61,11 +57,9 @@ const CHART_HEIGHT = 220;
|
||||
* The dashboard, as a READ MODEL over the platform.
|
||||
*
|
||||
* Every number traces to a platform resource — footfall, conversion, arrivals.
|
||||
* The KPI row is always real. The charts fall back to clearly-tagged SAMPLE
|
||||
* data (shared/mocks/sampleData.ts) only while a shop has no history, so a new
|
||||
* merchant sees what each panel is for instead of an empty axis.
|
||||
*
|
||||
* To retire the sample, see `shared/mocks/withSample.ts`.
|
||||
* The KPI row and the top charts are always real: a chart with no signal for
|
||||
* the period shows its empty state, never sample data. Only the supporting
|
||||
* panels the platform has no endpoint for read shared/mocks/sampleData.ts.
|
||||
*
|
||||
* Bucket labels are rendered as STRINGS — local wall time with no offset;
|
||||
* parsing one into a Date shifts every label on the axis.
|
||||
@@ -100,32 +94,22 @@ export default function DashboardPage() {
|
||||
const comparison = useDashboardStoreComparison();
|
||||
const insights = useStoreInsights();
|
||||
|
||||
// Chart feeds: real when the platform has signal, sample otherwise.
|
||||
const footfallChart = withSample(footfall, MOCK_FOOTFALL_DAILY, (d) =>
|
||||
hasSignal(d, 'visitors'),
|
||||
);
|
||||
const revenueChart = withSample(conversion, MOCK_CONVERSION_DAILY, (d) =>
|
||||
hasSignal(d, 'revenue'),
|
||||
);
|
||||
const mixChart = withSample(footfall, MOCK_FOOTFALL_DAILY, (d) =>
|
||||
hasSignal(d, 'newVisitors') || hasSignal(d, 'returningVisitors'),
|
||||
);
|
||||
// Two-report panels go sample as a PAIR — never half real, half invented.
|
||||
const flowIsSample =
|
||||
!hasSignal(footfall.data, 'visitors') || !(conversion.data?.purchases ?? 0);
|
||||
// While the partner report is still loading, don't flash the sample.
|
||||
const flowChart = withSample(
|
||||
// Chart feeds: real reports only. A period with no signal renders the
|
||||
// panel's empty state rather than a flat line.
|
||||
const footfallChart = emptyUnless(footfall, (d) => reportHasSignal(d, 'visitors'));
|
||||
const revenueChart = emptyUnless(conversion, (d) => reportHasSignal(d, 'revenue'));
|
||||
const mixChart = emptyUnless(
|
||||
footfall,
|
||||
MOCK_FOOTFALL_DAILY,
|
||||
() => conversion.status === 'loading' || !flowIsSample,
|
||||
(d) => reportHasSignal(d, 'newVisitors') || reportHasSignal(d, 'returningVisitors'),
|
||||
);
|
||||
const weeklyIsSample =
|
||||
!hasSignal(weeklyFootfall.data, 'visitors') ||
|
||||
!hasSignal(weeklyConversion.data, 'purchases');
|
||||
const weeklyChart = withSample(
|
||||
weeklyFootfall,
|
||||
MOCK_FOOTFALL_WEEKLY,
|
||||
() => weeklyConversion.status === 'loading' || !weeklyIsSample,
|
||||
// Two-report panels need both reports; empty unless visitors AND purchases.
|
||||
const flowChart = emptyUnless(
|
||||
joinResources(footfall, conversion),
|
||||
([f, c]) => reportHasSignal(f, 'visitors') && reportHasSignal(c, 'purchases'),
|
||||
);
|
||||
const weeklyChart = emptyUnless(
|
||||
joinResources(weeklyFootfall, weeklyConversion),
|
||||
([f, c]) => reportHasSignal(f, 'visitors') && reportHasSignal(c, 'purchases'),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -147,7 +131,6 @@ export default function DashboardPage() {
|
||||
subtitle="Visitors per day"
|
||||
resource={footfallChart}
|
||||
height={CHART_HEIGHT}
|
||||
actions={<SampleTag show={footfallChart.isSample} />}
|
||||
>
|
||||
{(report) => (
|
||||
<CalendarHeatmap
|
||||
@@ -164,7 +147,6 @@ export default function DashboardPage() {
|
||||
subtitle="Daily takings"
|
||||
resource={revenueChart}
|
||||
height={CHART_HEIGHT}
|
||||
actions={<SampleTag show={revenueChart.isSample} />}
|
||||
>
|
||||
{(report) => (
|
||||
<BarChartView
|
||||
@@ -187,16 +169,11 @@ export default function DashboardPage() {
|
||||
subtitle="Share of visitors who came back, and who bought"
|
||||
resource={flowChart}
|
||||
height={CHART_HEIGHT}
|
||||
actions={<SampleTag show={flowChart.isSample} />}
|
||||
>
|
||||
{(report) => (
|
||||
{([footfallReport, conversionReport]) => (
|
||||
<CustomerFlowChart
|
||||
footfall={report}
|
||||
conversion={
|
||||
flowIsSample || !conversion.data
|
||||
? MOCK_CONVERSION_DAILY
|
||||
: conversion.data
|
||||
}
|
||||
footfall={footfallReport}
|
||||
conversion={conversionReport}
|
||||
height={CHART_HEIGHT}
|
||||
/>
|
||||
)}
|
||||
@@ -207,14 +184,10 @@ export default function DashboardPage() {
|
||||
subtitle="Visitors against purchases, week by week"
|
||||
resource={weeklyChart}
|
||||
height={CHART_HEIGHT}
|
||||
actions={<SampleTag show={weeklyChart.isSample} />}
|
||||
>
|
||||
{(report) => {
|
||||
{([report, conversionReport]) => {
|
||||
const purchases = new Map(
|
||||
(weeklyIsSample
|
||||
? MOCK_CONVERSION_WEEKLY
|
||||
: weeklyConversion.data ?? MOCK_CONVERSION_WEEKLY
|
||||
).buckets.map((b) => [b.label, b.purchases]),
|
||||
conversionReport.buckets.map((b) => [b.label, b.purchases]),
|
||||
);
|
||||
const rows = report.buckets.map((b) => ({
|
||||
label: b.label,
|
||||
@@ -244,7 +217,6 @@ export default function DashboardPage() {
|
||||
subtitle="Who walked in, split by whether the platform had seen them before"
|
||||
resource={mixChart}
|
||||
height={CHART_HEIGHT}
|
||||
actions={<SampleTag show={mixChart.isSample} />}
|
||||
>
|
||||
{(report) => (
|
||||
<BarChartView
|
||||
@@ -284,7 +256,7 @@ export default function DashboardPage() {
|
||||
>
|
||||
<VStack gap={5} className="pt-4">
|
||||
{/* Real: the same two reports as the Customer flow card, joined by
|
||||
bucket, and sample as a PAIR for the same reason. There is no
|
||||
bucket. There is no
|
||||
per-day Conversion line beside it — deriving purchases ÷
|
||||
visitors here would invent a definition the reports may not
|
||||
share (see kpiBuilder). */}
|
||||
@@ -293,14 +265,10 @@ export default function DashboardPage() {
|
||||
subtitle="The gap is the conversion opportunity"
|
||||
resource={flowChart}
|
||||
height={CHART_HEIGHT}
|
||||
actions={<SampleTag show={flowChart.isSample} />}
|
||||
>
|
||||
{(report) => {
|
||||
>
|
||||
{([report, conversionReport]) => {
|
||||
const purchases = new Map(
|
||||
(flowIsSample || !conversion.data
|
||||
? MOCK_CONVERSION_DAILY
|
||||
: conversion.data
|
||||
).buckets.map((b) => [b.label, b.purchases]),
|
||||
conversionReport.buckets.map((b) => [b.label, b.purchases]),
|
||||
);
|
||||
return (
|
||||
<LineChartView
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function GET(req: NextRequest) {
|
||||
try {
|
||||
return (await engagementApi.impact(token, window, toSiteParam(query.storeId))) ?? [];
|
||||
} catch (err) {
|
||||
if (err instanceof UpstreamError && err.status === 404) {
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -27,8 +27,8 @@ export async function GET(req: NextRequest) {
|
||||
} catch (err) {
|
||||
// If the upstream platform has not deployed /api/activities or /api/activities/impact yet,
|
||||
// answer with an empty list so the activities panel renders its clean empty state
|
||||
// rather than failing with 404.
|
||||
if (err instanceof UpstreamError && err.status === 404) {
|
||||
// rather than failing. Only a missing route — a real 404 still surfaces.
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function GET(req: NextRequest) {
|
||||
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
|
||||
return (list ?? []).map(toCampaign);
|
||||
} catch (err) {
|
||||
if (err instanceof UpstreamError && err.status === 404) {
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function GET(req: NextRequest) {
|
||||
// If the upstream platform has not deployed /api/floor/visits yet,
|
||||
// answer with an empty list so the floor screen renders its clean empty state
|
||||
// rather than failing with 404.
|
||||
if (err instanceof UpstreamError && err.status === 404) {
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return {items: []};
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function GET(req: NextRequest) {
|
||||
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UpstreamError && err.status === 404) {
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return {stages: [], attribution: 'observed'};
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -89,8 +89,9 @@ export async function GET(req: NextRequest) {
|
||||
} catch (err) {
|
||||
// If the upstream platform has not deployed /api/sales yet,
|
||||
// answer with an empty list so the sales screen renders cleanly
|
||||
// rather than failing with 404.
|
||||
if (err instanceof UpstreamError && err.status === 404) {
|
||||
// rather than failing. Only a missing ROUTE — a 404 for a shop that
|
||||
// does not exist is a real answer and still surfaces.
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return {items: []};
|
||||
}
|
||||
throw err;
|
||||
@@ -119,6 +120,7 @@ export async function GET(req: NextRequest) {
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: {
|
||||
idempotencyKey?: unknown;
|
||||
clientCreatedAt?: unknown;
|
||||
visitId?: unknown;
|
||||
visitorId?: unknown;
|
||||
invoiceNo?: unknown;
|
||||
@@ -157,7 +159,7 @@ export async function POST(req: NextRequest) {
|
||||
site: typeof body.site === 'string' ? body.site : undefined,
|
||||
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
|
||||
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
|
||||
client_created_at: new Date().toISOString(),
|
||||
client_created_at: draftTime(body.clientCreatedAt),
|
||||
lines,
|
||||
}),
|
||||
);
|
||||
@@ -171,10 +173,16 @@ export async function POST(req: NextRequest) {
|
||||
sale: result.sale ? toSale(result.sale) : null,
|
||||
},
|
||||
},
|
||||
{status: 201, headers: {'cache-control': 'no-store'}},
|
||||
// The platform's own status: 201 for a new sale, 200 for a replay.
|
||||
{
|
||||
status: result.status === 'already_processed' ? 200 : 201,
|
||||
headers: {'cache-control': 'no-store'},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UpstreamError && err.status === 404) {
|
||||
// 404 "No such endpoint." or 405 — the deployed platform has no sale
|
||||
// writer yet. Any other 404 (a visit that is not this shop's) is real.
|
||||
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
@@ -193,3 +201,17 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the sale was drafted on the device, as the dialog stamped it. Falls
|
||||
* back to now for a missing or unparseable value, and for one in the future —
|
||||
* a device clock ahead of the server must not date a sale tomorrow.
|
||||
*/
|
||||
function draftTime(value: unknown): string {
|
||||
const now = Date.now();
|
||||
if (typeof value !== 'string') return new Date(now).toISOString();
|
||||
const t = Date.parse(value);
|
||||
return Number.isFinite(t) && t <= now
|
||||
? new Date(t).toISOString()
|
||||
: new Date(now).toISOString();
|
||||
}
|
||||
|
||||
23
src/app/api/settings/profile/route.ts
Normal file
23
src/app/api/settings/profile/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {fail} from '@/shared/services/apiRoute';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET / PATCH /api/settings/profile — the merchant's business profile.
|
||||
*
|
||||
* The platform has no profile endpoint yet (docs/API-STATUS.md), so there is
|
||||
* nothing to forward to. Without this file Next.js answered its own HTML 404
|
||||
* page, which the settings screen could only report as "Unexpected response".
|
||||
* This says what is actually true, in the envelope the screen reads. Once the
|
||||
* platform ships the route, replace both handlers with `serveUpstream` /
|
||||
* `proxyUpstream` calls against it.
|
||||
*/
|
||||
const NOT_YET = 'Business profile is not available on the platform yet.';
|
||||
|
||||
export function GET() {
|
||||
return fail('not_deployed', NOT_YET, 501);
|
||||
}
|
||||
|
||||
export function PATCH() {
|
||||
return fail('not_deployed', NOT_YET, 501);
|
||||
}
|
||||
@@ -15,13 +15,11 @@ import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
|
||||
import {SampleScope} from '@/shared/mocks/SampleScope';
|
||||
import {useSampleResource} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {
|
||||
SAMPLE_DISCOUNT_RATE,
|
||||
SAMPLE_PAYMENT_MIX,
|
||||
SAMPLE_REFUND_RATE,
|
||||
buildTopProducts,
|
||||
storeName as sampleStoreName,
|
||||
type SampleProduct,
|
||||
type SampleStockStatus,
|
||||
} from '@/shared/mocks/sampleData';
|
||||
@@ -93,24 +91,14 @@ export function PaymentMethodsPanel({sales}: {sales: Resource<Sale[]>}) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TopOutletsPanel({
|
||||
sales,
|
||||
isSample = false,
|
||||
}: {
|
||||
sales: Resource<Sale[]>;
|
||||
isSample?: boolean;
|
||||
}) {
|
||||
export function TopOutletsPanel({sales}: {sales: Resource<Sale[]>}) {
|
||||
const {stores} = useWorkspace();
|
||||
// Sample sales fall back to the fixture roster when the merchant has no
|
||||
// stores loaded, so those ids resolve through sampleStoreName.
|
||||
const name = (id: string) =>
|
||||
stores.find((s) => s.id === id)?.name ?? (isSample ? sampleStoreName(id) : id);
|
||||
const name = (id: string) => stores.find((s) => s.id === id)?.name ?? id;
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Top performing outlets"
|
||||
subtitle="Sales by store in the selected period"
|
||||
actions={<SampleTag show={isSample} />}
|
||||
resource={sales}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={noSales}
|
||||
|
||||
@@ -76,6 +76,9 @@ export function SaleEntryDialog({
|
||||
* A ref rather than state: it must survive every re-render unchanged.
|
||||
*/
|
||||
const idempotencyKey = useRef<string | null>(null);
|
||||
/** When the sale was first submitted — kept with the key, so a retry
|
||||
* reports the original moment rather than the moment it got through. */
|
||||
const clientCreatedAt = useRef<string | null>(null);
|
||||
|
||||
const [lines, setLines] = useState<DraftLine[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
@@ -147,10 +150,12 @@ export function SaleEntryDialog({
|
||||
* fresh key.
|
||||
*/
|
||||
idempotencyKey.current ??= newIdempotencyKey();
|
||||
clientCreatedAt.current ??= new Date().toISOString();
|
||||
|
||||
// Through httpClient, so the request carries this tab's session header.
|
||||
const res = await saleRepository.create({
|
||||
idempotencyKey: idempotencyKey.current,
|
||||
clientCreatedAt: clientCreatedAt.current,
|
||||
// Taken from the floor context, never typed. §4: do not ask for a
|
||||
// visit id the flow already knows. Staff identity is not sent at
|
||||
// all — the platform derives it from the session.
|
||||
|
||||
@@ -16,9 +16,6 @@ import {formatCount, formatInr} from '@/shared/utils/format';
|
||||
import {useActivities} from '@/features/engagement/hooks/useEngagement';
|
||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
||||
import {RecordActivityDialog} from '@/features/engagement/components/RecordActivityDialog';
|
||||
import {withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {MOCK_ACTIVITIES} from '@/shared/mocks/sampleData';
|
||||
import type {ActivityRow} from '@/features/engagement/types/engagement';
|
||||
|
||||
/** Null is "not reported", which is not the same fact as zero. */
|
||||
@@ -34,8 +31,6 @@ function maybe(v: number | null, format: (n: number) => string = formatCount): s
|
||||
export function ActivitiesPanel() {
|
||||
const toast = useToast();
|
||||
const activities = useActivities();
|
||||
// Recording always acts on the REAL catalogue, never the sample.
|
||||
const shown = withSample(activities, MOCK_ACTIVITIES, (d) => !!d?.length);
|
||||
const [recording, setRecording] = useState(false);
|
||||
|
||||
const canRecord =
|
||||
@@ -104,11 +99,9 @@ export function ActivitiesPanel() {
|
||||
actions={
|
||||
canRecord ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => setRecording(true)} label="Record activity" />
|
||||
) : (
|
||||
<SampleTag show={shown.isSample} />
|
||||
)
|
||||
) : null
|
||||
}
|
||||
resource={shown}
|
||||
resource={activities}
|
||||
loading={<SkeletonRows count={4} height={44} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
|
||||
@@ -13,14 +13,11 @@ import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {formatCount, formatInr} from '@/shared/utils/format';
|
||||
import {useCampaigns} from '@/features/engagement/hooks/useEngagement';
|
||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
||||
import {withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {MOCK_CAMPAIGNS} from '@/shared/mocks/sampleData';
|
||||
import type {Campaign} from '@/features/engagement/types/engagement';
|
||||
|
||||
/** Each campaign's funnel over the dashboard's window. Revenue is attributed. */
|
||||
export function CampaignsPanel() {
|
||||
const campaigns = withSample(useCampaigns(), MOCK_CAMPAIGNS, (d) => !!d?.length);
|
||||
const campaigns = useCampaigns();
|
||||
|
||||
const columns: TableColumn<Campaign>[] = [
|
||||
{
|
||||
@@ -82,7 +79,6 @@ export function CampaignsPanel() {
|
||||
<PanelCard
|
||||
title="Campaigns"
|
||||
subtitle="Each campaign's funnel in this period"
|
||||
actions={<SampleTag show={campaigns.isSample} />}
|
||||
resource={campaigns}
|
||||
loading={<SkeletonRows count={3} height={44} />}
|
||||
empty={
|
||||
|
||||
@@ -8,9 +8,7 @@ import {CHART} from '@/shared/components/charts/palette';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {formatCompact} from '@/shared/utils/format';
|
||||
import {useJourney} from '@/features/engagement/hooks/useEngagement';
|
||||
import {withSample} from '@/shared/mocks/withSample';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {MOCK_JOURNEY} from '@/shared/mocks/sampleData';
|
||||
import {emptyUnless} from '@/shared/hooks/resourceUtils';
|
||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
||||
|
||||
/**
|
||||
@@ -21,15 +19,15 @@ import {AttributionNote} from '@/features/engagement/components/AttributionNote'
|
||||
* and a funnel shape would imply a drop-off that did not happen.
|
||||
*/
|
||||
export function JourneyPanel() {
|
||||
const journey = withSample(useJourney(), MOCK_JOURNEY, (d) =>
|
||||
!!d?.stages.some((s) => s.customers > 0),
|
||||
// Real only: a period where nobody reached any stage shows the empty state.
|
||||
const journey = emptyUnless(useJourney(), (d) =>
|
||||
d.stages.some((s) => s.customers > 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Customer journey"
|
||||
subtitle="Distinct people who reached each stage in this period"
|
||||
actions={<SampleTag show={journey.isSample} />}
|
||||
resource={journey}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
|
||||
@@ -71,8 +71,30 @@ export class UpstreamError extends Error {
|
||||
get isUnsupported(): boolean {
|
||||
return this.status === 501;
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform running here does not have this route at all — it is newer
|
||||
* than the deployed server. Not "this record does not exist".
|
||||
*
|
||||
* Two ways that shows up, and neither carries a code of its own:
|
||||
* - no pattern matches the path: the server's catch-all answers 404
|
||||
* `not_found` with exactly {@link MISSING_ROUTE_MESSAGE}
|
||||
* (Behavision cmd/behavision-server/main.go). A missing merchant, shop
|
||||
* or sale is also 404 `not_found`, but worded differently — so this is
|
||||
* the one place that matches on prose, and it matches the whole string.
|
||||
* - the path exists for another method only: Go's mux answers 405.
|
||||
*/
|
||||
get isRouteMissing(): boolean {
|
||||
return (
|
||||
this.status === 405 ||
|
||||
(this.status === 404 && this.message === MISSING_ROUTE_MESSAGE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The platform catch-all's wording for a path it has no route for. */
|
||||
export const MISSING_ROUTE_MESSAGE = 'No such endpoint.';
|
||||
|
||||
export type QueryValue = string | number | boolean | undefined | null;
|
||||
|
||||
export interface UpstreamRequest {
|
||||
|
||||
54
src/shared/hooks/resourceUtils.ts
Normal file
54
src/shared/hooks/resourceUtils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {ReportBucket} from '@/features/dashboard/types/reports';
|
||||
|
||||
/**
|
||||
* Settles a resource as 'empty' when it arrived but carries nothing worth
|
||||
* drawing — a report whose every bucket is zero is an empty chart, not a flat
|
||||
* line, and should render the panel's empty state.
|
||||
*/
|
||||
export function emptyUnless<T>(
|
||||
resource: Resource<T>,
|
||||
hasData: (data: T) => boolean,
|
||||
): Resource<T> {
|
||||
if (resource.status === 'success' && !hasData(resource.data)) {
|
||||
return {...resource, status: 'empty'};
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
/** True when the report carries at least one non-zero value for `key`. */
|
||||
export function reportHasSignal(
|
||||
report: {buckets: ReportBucket[]} | undefined,
|
||||
key: keyof Omit<ReportBucket, 'label'>,
|
||||
): boolean {
|
||||
return !!report && report.buckets.some((b) => b[key] > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two resources as one, for a panel that needs both. Errors win, then
|
||||
* loading — a chart never draws with half its input.
|
||||
*/
|
||||
export function joinResources<A, B>(
|
||||
a: Resource<A>,
|
||||
b: Resource<B>,
|
||||
): Resource<[A, B]> {
|
||||
const common = {
|
||||
refetch: () => {
|
||||
a.refetch();
|
||||
b.refetch();
|
||||
},
|
||||
isRefreshing: a.isRefreshing || b.isRefreshing,
|
||||
meta: a.meta ?? b.meta,
|
||||
};
|
||||
if (a.status === 'error') return {...common, status: 'error', data: undefined, error: a.error};
|
||||
if (b.status === 'error') return {...common, status: 'error', data: undefined, error: b.error};
|
||||
if (a.status === 'loading' || b.status === 'loading') {
|
||||
return {...common, status: 'loading', data: undefined, error: undefined};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
status: a.status === 'empty' || b.status === 'empty' ? 'empty' : 'success',
|
||||
data: [a.data, b.data],
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
@@ -35,16 +35,6 @@ import type {
|
||||
CampaignSummary,
|
||||
JourneyStage,
|
||||
} from '@/features/dashboard/types/intelligence';
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
ReportBucket,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
import type {
|
||||
ActivityRow,
|
||||
Campaign,
|
||||
Journey,
|
||||
} from '@/features/engagement/types/engagement';
|
||||
import type {
|
||||
Reward,
|
||||
RewardStatus,
|
||||
@@ -67,8 +57,6 @@ import type {LoginAuditItem} from '@/features/settings/components/SecurityManage
|
||||
import type {
|
||||
RangeKey,
|
||||
} from '@/shared/types/api';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
import {DEFAULT_TZ, shiftDay, shopToday, toReportWindow} from '@/services/api/range';
|
||||
|
||||
// ===========================================================================
|
||||
// Seeded randomness
|
||||
@@ -1343,236 +1331,6 @@ export function buildAttendance(
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Dashboard charts — empty-shop fallback reports
|
||||
// (was features/dashboard/mocks/dashboardMock.ts)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* TEMPORARY sample data for the dashboard charts.
|
||||
*
|
||||
* ── Why this exists ──────────────────────────────────────────────────────
|
||||
* A new merchant's reports come back empty or near-empty, and an empty axis
|
||||
* teaches nothing about what the panel is for. Until the platform has history
|
||||
* for a shop, each chart falls back to this sample and wears a "Sample data"
|
||||
* token so it can never be mistaken for the merchant's own numbers.
|
||||
*
|
||||
* ── Rules ────────────────────────────────────────────────────────────────
|
||||
* - Real data always wins — see `shared/mocks/withSample.ts`.
|
||||
* - The KPI row never uses this — a headline number must be real or absent.
|
||||
* - Values are deterministic (no Math.random) so SSR and hydration agree.
|
||||
*
|
||||
* ── Removing it ──────────────────────────────────────────────────────────
|
||||
* See `shared/mocks/withSample.ts` — one procedure for every mock folder.
|
||||
*/
|
||||
|
||||
|
||||
/** A smooth, repeatable wave: weekly rhythm plus a gentle upward drift. */
|
||||
function wave(i: number, base: number, amp: number): number {
|
||||
const weekly = Math.sin((i / 7) * Math.PI * 2 - 1.2);
|
||||
const drift = i * 0.012;
|
||||
const ripple = Math.sin(i * 1.7) * 0.18;
|
||||
return Math.max(0, Math.round(base * (1 + drift) + amp * (weekly + ripple)));
|
||||
}
|
||||
|
||||
function dayBuckets(days: number): ReportBucket[] {
|
||||
// Fixed anchor so the labels are stable across renders and runtimes.
|
||||
const start = Date.UTC(2026, 7, 27);
|
||||
return Array.from({length: days}, (_, i) => {
|
||||
const d = new Date(start + i * 86_400_000);
|
||||
const visitors = wave(i, 140, 42);
|
||||
const newVisitors = Math.round(visitors * (0.38 + 0.06 * Math.sin(i / 3)));
|
||||
const purchases = Math.round(visitors * (0.22 + 0.04 * Math.sin(i / 4 + 1)));
|
||||
const revenue = purchases * (620 + Math.round(90 * Math.sin(i / 5)));
|
||||
return {
|
||||
// Same shape as the platform's day buckets: a bare local date.
|
||||
label: d.toISOString().slice(0, 10),
|
||||
visitors,
|
||||
newVisitors,
|
||||
returningVisitors: visitors - newVisitors,
|
||||
purchases,
|
||||
revenue,
|
||||
conversion: Number(((purchases / visitors) * 100).toFixed(1)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function weekBuckets(days: ReportBucket[]): ReportBucket[] {
|
||||
const weeks: ReportBucket[] = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
const slice = days.slice(i, i + 7);
|
||||
const sum = (k: keyof ReportBucket) =>
|
||||
slice.reduce((acc, b) => acc + (b[k] as number), 0);
|
||||
const visitors = sum('visitors');
|
||||
const purchases = sum('purchases');
|
||||
weeks.push({
|
||||
label: `W${Math.floor(i / 7) + 1}`,
|
||||
visitors,
|
||||
newVisitors: sum('newVisitors'),
|
||||
returningVisitors: sum('returningVisitors'),
|
||||
purchases,
|
||||
revenue: sum('revenue'),
|
||||
conversion: Number(((purchases / visitors) * 100).toFixed(1)),
|
||||
});
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
const DAYS = dayBuckets(30);
|
||||
const WEEKS = weekBuckets(DAYS);
|
||||
|
||||
function footfallFrom(buckets: ReportBucket[]): FootfallReport {
|
||||
const visits = buckets.reduce((a, b) => a + b.visitors, 0);
|
||||
const newVisitors = buckets.reduce((a, b) => a + b.newVisitors, 0);
|
||||
// Unique people < visit events: regulars come more than once.
|
||||
const total = Math.round(visits * 0.46);
|
||||
return {
|
||||
total,
|
||||
newVisitors,
|
||||
returningVisitors: Math.max(0, Math.round(total * 0.9) - newVisitors),
|
||||
visits,
|
||||
buckets,
|
||||
timezone: null,
|
||||
previousTotal: Math.round(total * 0.88),
|
||||
};
|
||||
}
|
||||
|
||||
function conversionFrom(buckets: ReportBucket[], total: number): ConversionReport {
|
||||
const purchases = buckets.reduce((a, b) => a + b.purchases, 0);
|
||||
const revenue = buckets.reduce((a, b) => a + b.revenue, 0);
|
||||
return {
|
||||
purchases,
|
||||
revenue,
|
||||
conversionPct: Number(((purchases / total) * 100).toFixed(1)),
|
||||
basketSize: Math.round(revenue / purchases),
|
||||
buckets,
|
||||
timezone: null,
|
||||
previousPurchases: Math.round(purchases * 0.91),
|
||||
previousRevenue: Math.round(revenue * 0.86),
|
||||
};
|
||||
}
|
||||
|
||||
export const MOCK_FOOTFALL_DAILY = footfallFrom(DAYS);
|
||||
export const MOCK_FOOTFALL_WEEKLY = footfallFrom(WEEKS);
|
||||
export const MOCK_CONVERSION_DAILY = conversionFrom(DAYS, MOCK_FOOTFALL_DAILY.total);
|
||||
export const MOCK_CONVERSION_WEEKLY = conversionFrom(WEEKS, MOCK_FOOTFALL_WEEKLY.total);
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// Engagement — empty-shop fallback activities, campaigns, journey
|
||||
// (was features/engagement/mocks/engagementMock.ts)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* TEMPORARY sample data for the engagement panels (activities, campaigns,
|
||||
* journey), shown only while the platform returns none for this shop.
|
||||
*
|
||||
* Attribution stays `'estimated'` on every row, so the AttributionNote still
|
||||
* renders — sample data obeys the same disclosure rule as real data.
|
||||
*
|
||||
* Removal: see `shared/mocks/withSample.ts`.
|
||||
*/
|
||||
|
||||
|
||||
export const MOCK_ACTIVITIES: ActivityRow[] = [
|
||||
{
|
||||
id: 'sample-selfie',
|
||||
kind: 'selfie',
|
||||
name: 'Selfie wall',
|
||||
isActive: true,
|
||||
events: 412,
|
||||
participants: 318,
|
||||
anonymous: 24,
|
||||
repeatVisitors: 142,
|
||||
purchases: 96,
|
||||
attributedRevenue: 61_400,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-spin',
|
||||
kind: 'spin',
|
||||
name: 'Spin the wheel',
|
||||
isActive: true,
|
||||
events: 286,
|
||||
participants: 231,
|
||||
anonymous: 12,
|
||||
repeatVisitors: 118,
|
||||
purchases: 74,
|
||||
attributedRevenue: 44_900,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-challenge',
|
||||
kind: 'challenge',
|
||||
name: 'Weekend challenge',
|
||||
isActive: true,
|
||||
events: 138,
|
||||
participants: 102,
|
||||
anonymous: 0,
|
||||
repeatVisitors: 61,
|
||||
purchases: 38,
|
||||
attributedRevenue: 27_300,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-referral',
|
||||
kind: 'referral',
|
||||
name: 'Refer a friend',
|
||||
isActive: false,
|
||||
events: 54,
|
||||
participants: 47,
|
||||
anonymous: 0,
|
||||
repeatVisitors: 29,
|
||||
purchases: 18,
|
||||
attributedRevenue: 12_800,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated',
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_CAMPAIGNS: Campaign[] = [
|
||||
{
|
||||
id: 'sample-festive',
|
||||
name: 'Festive week',
|
||||
startsAt: '2026-09-15T00:00:00Z',
|
||||
endsAt: null,
|
||||
isActive: true,
|
||||
activities: ['selfie', 'spin'],
|
||||
events: 520,
|
||||
participants: 388,
|
||||
purchasers: 131,
|
||||
attributedRevenue: 82_600,
|
||||
attribution: 'estimated',
|
||||
},
|
||||
{
|
||||
id: 'sample-monsoon',
|
||||
name: 'Monsoon rewards',
|
||||
startsAt: '2026-08-28T00:00:00Z',
|
||||
endsAt: '2026-09-10T00:00:00Z',
|
||||
isActive: false,
|
||||
activities: ['challenge'],
|
||||
events: 214,
|
||||
participants: 163,
|
||||
purchasers: 57,
|
||||
attributedRevenue: 34_100,
|
||||
attribution: 'estimated',
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_JOURNEY: Journey = {
|
||||
stages: [
|
||||
{id: 'visit', label: 'Visited', customers: 1_240},
|
||||
{id: 'engage', label: 'Took part', customers: 562},
|
||||
{id: 'buy', label: 'Bought', customers: 298},
|
||||
{id: 'return', label: 'Came back', customers: 211},
|
||||
{id: 'refer', label: 'Referred', customers: 47},
|
||||
],
|
||||
attribution: 'estimated',
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Commerce — what the sales resource does not carry
|
||||
// ===========================================================================
|
||||
@@ -1595,79 +1353,6 @@ export const SAMPLE_PAYMENT_MIX: {method: string; pct: number}[] = [
|
||||
export const SAMPLE_DISCOUNT_RATE = 0.042;
|
||||
export const SAMPLE_REFUND_RATE = 0.022;
|
||||
|
||||
/**
|
||||
* Example sale list for a shop that has recorded no sales in the window —
|
||||
* the Commerce page's cards, trend charts and outlet ranking all derive from
|
||||
* it, exactly as they would from GET /api/sales.
|
||||
*
|
||||
* Sales are spread across the stores the merchant really has (so Top
|
||||
* performing outlets shows their own shop names), weighted to a lunch and an
|
||||
* evening peak, and never placed after `serverNow` — Today stops at this hour.
|
||||
*/
|
||||
export function buildSampleSales({
|
||||
siteIds,
|
||||
range,
|
||||
custom,
|
||||
serverNow,
|
||||
}: {
|
||||
/** The real store ids in scope; the fixture roster when there are none. */
|
||||
siteIds: string[];
|
||||
range: RangeKey;
|
||||
custom: {from: string; to: string} | null;
|
||||
serverNow: string;
|
||||
}): Sale[] {
|
||||
const sites = siteIds.length ? siteIds : STORE_SEED.map((s) => s.id);
|
||||
const now = new Date(serverNow);
|
||||
const today = shopToday(now);
|
||||
const nowHour = Number(
|
||||
new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: DEFAULT_TZ,
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(now),
|
||||
);
|
||||
const {from, to} = toReportWindow(range, now, undefined, custom);
|
||||
// Opening hours, weighted: a lunch and an evening rush.
|
||||
const HOURS = [10, 11, 12, 12, 13, 13, 14, 15, 16, 17, 18, 18, 19, 19, 20, 21];
|
||||
const CUSTOMERS = ['Priya S.', 'Rahul M.', 'Ananya K.', 'Vikram R.', 'Meera N.', 'Arjun P.', 'Kavya D.', 'Rohan T.'];
|
||||
const STAFF = ['Divya', 'Karthik', 'Sneha', 'Imran', 'Lakshmi'];
|
||||
const out: Sale[] = [];
|
||||
|
||||
for (let day = from; day <= to; day = shiftDay(day, 1)) {
|
||||
sites.forEach((siteId, i) => {
|
||||
const rng = createRng('commerce-sales', siteId, day);
|
||||
// First store busiest, the rest tapering — a ranking worth reading.
|
||||
const weight = Math.max(0.45, 1 - i * 0.18);
|
||||
const count = Math.round(rng.int(6, 14) * weight);
|
||||
for (let n = 0; n < count; n++) {
|
||||
const hour = rng.pick(HOURS);
|
||||
if (day === today && hour > nowHour) continue;
|
||||
const minute = rng.int(0, 59);
|
||||
const purchasedLines = rng.int(1, 4);
|
||||
const repeat = rng.next() < 0.7;
|
||||
const c = rng.int(0, CUSTOMERS.length - 1);
|
||||
out.push({
|
||||
id: `sample-${siteId}-${day}-${n}`,
|
||||
invoiceNo: `INV-${day.replaceAll('-', '').slice(2)}-${String(i * 100 + n).padStart(3, '0')}`,
|
||||
siteId,
|
||||
customerRef: repeat ? `sample-cust-${siteId}-${c}` : null,
|
||||
customerLabel: repeat ? CUSTOMERS[c] : null,
|
||||
staffName: rng.pick(STAFF),
|
||||
// ₹180–₹2,400 per bill, in integer paise.
|
||||
totalPaise: rng.int(180, 2400) * 100,
|
||||
currency: 'INR',
|
||||
status: rng.next() < 0.03 ? 'void' : 'completed',
|
||||
at: `${day}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00+05:30`,
|
||||
purchasedLines,
|
||||
enquiryLines: rng.next() < 0.35 ? rng.int(1, 2) : 0,
|
||||
lines: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type SampleStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock';
|
||||
|
||||
export interface SampleProduct extends Record<string, unknown> {
|
||||
|
||||
@@ -1,47 +1,25 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TEMPORARY — the two ways a page reads sample data.
|
||||
* TEMPORARY — how a page reads sample data.
|
||||
*
|
||||
* withSample() real endpoint first; the sample stands in only when the
|
||||
* platform answered with NO signal (a brand-new shop).
|
||||
* useSampleResource() no endpoint exists at all; the sample IS the data, and
|
||||
* the panel sits inside a <SampleScope>.
|
||||
*
|
||||
* A section that HAS a real endpoint never falls back to sample data: an
|
||||
* empty response renders the panel's empty state.
|
||||
*
|
||||
* Every sample value comes from shared/mocks/sampleData.ts — the single file
|
||||
* to edit, and eventually delete.
|
||||
*
|
||||
* ── Removing sample data ─────────────────────────────────────────────────
|
||||
* Delete this folder, then fix the call sites TypeScript points at:
|
||||
* `withSample(x, …)` → `x`, `useSampleResource(…)` → the real hook, and drop
|
||||
* `useSampleResource(…)` → the real hook, and drop
|
||||
* each <SampleTag/>, <SampleScope> and <SampleNotice>.
|
||||
*/
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {ReportBucket} from '@/features/dashboard/types/reports';
|
||||
|
||||
export type SampleAware<T> = Resource<T> & {isSample: boolean};
|
||||
|
||||
export function withSample<T>(
|
||||
resource: Resource<T>,
|
||||
sample: T,
|
||||
isUsable: (data: T | undefined) => boolean,
|
||||
): SampleAware<T> {
|
||||
const settled = resource.status === 'success' || resource.status === 'empty';
|
||||
if (settled && !isUsable(resource.data)) {
|
||||
return {...resource, status: 'success', data: sample, error: undefined, isSample: true};
|
||||
}
|
||||
return {...resource, isSample: false};
|
||||
}
|
||||
|
||||
/** True when the report carries at least one non-zero value for `key`. */
|
||||
export function hasSignal(
|
||||
report: {buckets: ReportBucket[]} | undefined,
|
||||
key: keyof Omit<ReportBucket, 'label'>,
|
||||
): boolean {
|
||||
return !!report && report.buckets.some((b) => b[key] > 0);
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
|
||||
@@ -81,6 +81,17 @@ export function failureFrom(err: unknown): UpstreamFailure {
|
||||
reason: 'not_supported',
|
||||
};
|
||||
}
|
||||
// Checked before the generic 404 below: a route the deployed platform
|
||||
// does not have yet is a version gap, not a missing record, and a panel
|
||||
// must not tell somebody their data is gone when the server is just old.
|
||||
if (err.isRouteMissing) {
|
||||
return {
|
||||
status: 501,
|
||||
code: 'not_deployed',
|
||||
message: 'This needs a newer platform version than the one running.',
|
||||
reason: 'not_deployed',
|
||||
};
|
||||
}
|
||||
// A 404 is also what another tenant's data returns, always — so it must
|
||||
// never be phrased as "deleted".
|
||||
return {
|
||||
|
||||
@@ -30,7 +30,10 @@ export type ApiErrorCode =
|
||||
| 'internal'
|
||||
| 'not_found'
|
||||
| 'bad_request'
|
||||
| 'unauthorized';
|
||||
| 'unauthorized'
|
||||
/** The deployed platform is older than this console and has no route for
|
||||
* the request. Not a fault — render "not available yet", never red. */
|
||||
| 'not_deployed';
|
||||
|
||||
export interface ApiFailure {
|
||||
error: {code: ApiErrorCode; message: string};
|
||||
|
||||
Reference in New Issue
Block a user