update api and docker

This commit is contained in:
2026-09-28 19:44:05 +05:30
parent f6d81ceaa8
commit 0fee985fd1
22 changed files with 203 additions and 492 deletions

View File

@@ -125,6 +125,7 @@ COPY --chown=nextjs:nodejs .env ./.env
USER nextjs
EXPOSE 3000
EXPOSE 80
# NO HEALTHCHECK, on purpose.
#

View File

@@ -39,7 +39,7 @@ http {
}
server {
listen 80;
listen 80 3000;
server_name localhost;
# Serve Next.js compiled static assets directly via Nginx

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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