update api and docker
This commit is contained in:
@@ -125,6 +125,7 @@ COPY --chown=nextjs:nodejs .env ./.env
|
|||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
# NO HEALTHCHECK, on purpose.
|
# NO HEALTHCHECK, on purpose.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80 3000;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
# Serve Next.js compiled static assets directly via Nginx
|
# Serve Next.js compiled static assets directly via Nginx
|
||||||
|
|||||||
@@ -24,15 +24,8 @@ import {
|
|||||||
TopOutletsPanel,
|
TopOutletsPanel,
|
||||||
TopProductsPanel,
|
TopProductsPanel,
|
||||||
} from '@/features/commerce/components/CommercePanels';
|
} 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 {
|
import {
|
||||||
salesFacts,
|
salesFacts,
|
||||||
shopDay,
|
|
||||||
salesTrend,
|
salesTrend,
|
||||||
summarise,
|
summarise,
|
||||||
totalPaise,
|
totalPaise,
|
||||||
@@ -82,33 +75,16 @@ const rupeesFormat = (paise: number) => formatInr(paise / 100);
|
|||||||
* product line. Their values live in shared/mocks/sampleData.ts.
|
* product line. Their values live in shared/mocks/sampleData.ts.
|
||||||
*/
|
*/
|
||||||
export default function CommercePage() {
|
export default function CommercePage() {
|
||||||
const realSales = useSales();
|
const sales = useSales();
|
||||||
const {range, custom, storeId} = useScope();
|
const {range, custom} = useScope();
|
||||||
const {stores} = useWorkspace();
|
|
||||||
const isTodayRange = range === 'today';
|
const isTodayRange = range === 'today';
|
||||||
const today = useTodaySales(!isTodayRange);
|
const today = useTodaySales(!isTodayRange);
|
||||||
const scopeLabel = useScopeLabel();
|
const scopeLabel = useScopeLabel();
|
||||||
const conversion = useConversionReport({bucket: 'day'});
|
const conversion = useConversionReport({bucket: 'day'});
|
||||||
|
|
||||||
// The server's clock, never the device's — "today" is the shop's today.
|
// The server's clock, never the device's — "today" is the shop's today.
|
||||||
const serverNow =
|
const serverNow = sales.meta?.generatedAt;
|
||||||
realSales.meta?.generatedAt ?? new Date(sampleNowMs()).toISOString();
|
|
||||||
|
|
||||||
// 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 =
|
const rows: Sale[] | undefined =
|
||||||
sales.status === 'success' || sales.status === 'empty' ? sales.data : undefined;
|
sales.status === 'success' || sales.status === 'empty' ? sales.data : undefined;
|
||||||
|
|
||||||
@@ -127,9 +103,7 @@ export default function CommercePage() {
|
|||||||
|
|
||||||
const todayRows: Sale[] | undefined = isTodayRange
|
const todayRows: Sale[] | undefined = isTodayRange
|
||||||
? rows
|
? rows
|
||||||
: sales.isSample
|
: today.status === 'success' || today.status === 'empty'
|
||||||
? sampleSales.filter((s) => shopDay(s.at) === shopToday(new Date(serverNow)))
|
|
||||||
: today.status === 'success' || today.status === 'empty'
|
|
||||||
? today.data
|
? today.data
|
||||||
: undefined;
|
: undefined;
|
||||||
// Revenue is rupees taken; orders is the NUMBER of bills. The captions say
|
// Revenue is rupees taken; orders is the NUMBER of bills. The captions say
|
||||||
@@ -159,14 +133,6 @@ export default function CommercePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{summary && facts ? (
|
||||||
<Grid columns={{minWidth: 220, max: 4, repeat: 'fit'}} gap={4}>
|
<Grid columns={{minWidth: 220, max: 4, repeat: 'fit'}} gap={4}>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
@@ -212,7 +178,6 @@ export default function CommercePage() {
|
|||||||
<PanelCard
|
<PanelCard
|
||||||
title="Revenue trend"
|
title="Revenue trend"
|
||||||
subtitle={isTodayRange ? 'Hourly sales so far today' : 'Daily sales across the selected period'}
|
subtitle={isTodayRange ? 'Hourly sales so far today' : 'Daily sales across the selected period'}
|
||||||
actions={<SampleTag show={sales.isSample} />}
|
|
||||||
resource={sales}
|
resource={sales}
|
||||||
loading={<SkeletonChart height={220} />}
|
loading={<SkeletonChart height={220} />}
|
||||||
empty={noSales}
|
empty={noSales}
|
||||||
@@ -223,7 +188,6 @@ export default function CommercePage() {
|
|||||||
<PanelCard
|
<PanelCard
|
||||||
title="Orders volume trend"
|
title="Orders volume trend"
|
||||||
subtitle={isTodayRange ? 'Orders per hour so far today' : 'Orders per day across the selected period'}
|
subtitle={isTodayRange ? 'Orders per hour so far today' : 'Orders per day across the selected period'}
|
||||||
actions={<SampleTag show={sales.isSample} />}
|
|
||||||
resource={sales}
|
resource={sales}
|
||||||
loading={<SkeletonChart height={220} />}
|
loading={<SkeletonChart height={220} />}
|
||||||
empty={noSales}
|
empty={noSales}
|
||||||
@@ -243,7 +207,7 @@ export default function CommercePage() {
|
|||||||
|
|
||||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||||
<PaymentMethodsPanel sales={sales} />
|
<PaymentMethodsPanel sales={sales} />
|
||||||
<TopOutletsPanel sales={sales} isSample={sales.isSample} />
|
<TopOutletsPanel sales={sales} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TopProductsPanel />
|
<TopProductsPanel />
|
||||||
|
|||||||
@@ -45,15 +45,11 @@ import {
|
|||||||
formatCompact,
|
formatCompact,
|
||||||
formatInrCompact,
|
formatInrCompact,
|
||||||
} from '@/shared/utils/format';
|
} from '@/shared/utils/format';
|
||||||
// TEMPORARY — see the header of this file for how to remove.
|
|
||||||
import {
|
import {
|
||||||
MOCK_CONVERSION_DAILY,
|
emptyUnless,
|
||||||
MOCK_CONVERSION_WEEKLY,
|
joinResources,
|
||||||
MOCK_FOOTFALL_DAILY,
|
reportHasSignal,
|
||||||
MOCK_FOOTFALL_WEEKLY,
|
} from '@/shared/hooks/resourceUtils';
|
||||||
} from '@/shared/mocks/sampleData';
|
|
||||||
import {hasSignal, withSample} from '@/shared/mocks/withSample';
|
|
||||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
|
||||||
|
|
||||||
const CHART_HEIGHT = 220;
|
const CHART_HEIGHT = 220;
|
||||||
|
|
||||||
@@ -61,11 +57,9 @@ const CHART_HEIGHT = 220;
|
|||||||
* The dashboard, as a READ MODEL over the platform.
|
* The dashboard, as a READ MODEL over the platform.
|
||||||
*
|
*
|
||||||
* Every number traces to a platform resource — footfall, conversion, arrivals.
|
* Every number traces to a platform resource — footfall, conversion, arrivals.
|
||||||
* The KPI row is always real. The charts fall back to clearly-tagged SAMPLE
|
* The KPI row and the top charts are always real: a chart with no signal for
|
||||||
* data (shared/mocks/sampleData.ts) only while a shop has no history, so a new
|
* the period shows its empty state, never sample data. Only the supporting
|
||||||
* merchant sees what each panel is for instead of an empty axis.
|
* panels the platform has no endpoint for read shared/mocks/sampleData.ts.
|
||||||
*
|
|
||||||
* To retire the sample, see `shared/mocks/withSample.ts`.
|
|
||||||
*
|
*
|
||||||
* Bucket labels are rendered as STRINGS — local wall time with no offset;
|
* Bucket labels are rendered as STRINGS — local wall time with no offset;
|
||||||
* parsing one into a Date shifts every label on the axis.
|
* parsing one into a Date shifts every label on the axis.
|
||||||
@@ -100,32 +94,22 @@ export default function DashboardPage() {
|
|||||||
const comparison = useDashboardStoreComparison();
|
const comparison = useDashboardStoreComparison();
|
||||||
const insights = useStoreInsights();
|
const insights = useStoreInsights();
|
||||||
|
|
||||||
// Chart feeds: real when the platform has signal, sample otherwise.
|
// Chart feeds: real reports only. A period with no signal renders the
|
||||||
const footfallChart = withSample(footfall, MOCK_FOOTFALL_DAILY, (d) =>
|
// panel's empty state rather than a flat line.
|
||||||
hasSignal(d, 'visitors'),
|
const footfallChart = emptyUnless(footfall, (d) => reportHasSignal(d, 'visitors'));
|
||||||
);
|
const revenueChart = emptyUnless(conversion, (d) => reportHasSignal(d, 'revenue'));
|
||||||
const revenueChart = withSample(conversion, MOCK_CONVERSION_DAILY, (d) =>
|
const mixChart = emptyUnless(
|
||||||
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(
|
|
||||||
footfall,
|
footfall,
|
||||||
MOCK_FOOTFALL_DAILY,
|
(d) => reportHasSignal(d, 'newVisitors') || reportHasSignal(d, 'returningVisitors'),
|
||||||
() => conversion.status === 'loading' || !flowIsSample,
|
|
||||||
);
|
);
|
||||||
const weeklyIsSample =
|
// Two-report panels need both reports; empty unless visitors AND purchases.
|
||||||
!hasSignal(weeklyFootfall.data, 'visitors') ||
|
const flowChart = emptyUnless(
|
||||||
!hasSignal(weeklyConversion.data, 'purchases');
|
joinResources(footfall, conversion),
|
||||||
const weeklyChart = withSample(
|
([f, c]) => reportHasSignal(f, 'visitors') && reportHasSignal(c, 'purchases'),
|
||||||
weeklyFootfall,
|
);
|
||||||
MOCK_FOOTFALL_WEEKLY,
|
const weeklyChart = emptyUnless(
|
||||||
() => weeklyConversion.status === 'loading' || !weeklyIsSample,
|
joinResources(weeklyFootfall, weeklyConversion),
|
||||||
|
([f, c]) => reportHasSignal(f, 'visitors') && reportHasSignal(c, 'purchases'),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -147,7 +131,6 @@ export default function DashboardPage() {
|
|||||||
subtitle="Visitors per day"
|
subtitle="Visitors per day"
|
||||||
resource={footfallChart}
|
resource={footfallChart}
|
||||||
height={CHART_HEIGHT}
|
height={CHART_HEIGHT}
|
||||||
actions={<SampleTag show={footfallChart.isSample} />}
|
|
||||||
>
|
>
|
||||||
{(report) => (
|
{(report) => (
|
||||||
<CalendarHeatmap
|
<CalendarHeatmap
|
||||||
@@ -164,7 +147,6 @@ export default function DashboardPage() {
|
|||||||
subtitle="Daily takings"
|
subtitle="Daily takings"
|
||||||
resource={revenueChart}
|
resource={revenueChart}
|
||||||
height={CHART_HEIGHT}
|
height={CHART_HEIGHT}
|
||||||
actions={<SampleTag show={revenueChart.isSample} />}
|
|
||||||
>
|
>
|
||||||
{(report) => (
|
{(report) => (
|
||||||
<BarChartView
|
<BarChartView
|
||||||
@@ -187,16 +169,11 @@ export default function DashboardPage() {
|
|||||||
subtitle="Share of visitors who came back, and who bought"
|
subtitle="Share of visitors who came back, and who bought"
|
||||||
resource={flowChart}
|
resource={flowChart}
|
||||||
height={CHART_HEIGHT}
|
height={CHART_HEIGHT}
|
||||||
actions={<SampleTag show={flowChart.isSample} />}
|
|
||||||
>
|
>
|
||||||
{(report) => (
|
{([footfallReport, conversionReport]) => (
|
||||||
<CustomerFlowChart
|
<CustomerFlowChart
|
||||||
footfall={report}
|
footfall={footfallReport}
|
||||||
conversion={
|
conversion={conversionReport}
|
||||||
flowIsSample || !conversion.data
|
|
||||||
? MOCK_CONVERSION_DAILY
|
|
||||||
: conversion.data
|
|
||||||
}
|
|
||||||
height={CHART_HEIGHT}
|
height={CHART_HEIGHT}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -207,14 +184,10 @@ export default function DashboardPage() {
|
|||||||
subtitle="Visitors against purchases, week by week"
|
subtitle="Visitors against purchases, week by week"
|
||||||
resource={weeklyChart}
|
resource={weeklyChart}
|
||||||
height={CHART_HEIGHT}
|
height={CHART_HEIGHT}
|
||||||
actions={<SampleTag show={weeklyChart.isSample} />}
|
|
||||||
>
|
>
|
||||||
{(report) => {
|
{([report, conversionReport]) => {
|
||||||
const purchases = new Map(
|
const purchases = new Map(
|
||||||
(weeklyIsSample
|
conversionReport.buckets.map((b) => [b.label, b.purchases]),
|
||||||
? MOCK_CONVERSION_WEEKLY
|
|
||||||
: weeklyConversion.data ?? MOCK_CONVERSION_WEEKLY
|
|
||||||
).buckets.map((b) => [b.label, b.purchases]),
|
|
||||||
);
|
);
|
||||||
const rows = report.buckets.map((b) => ({
|
const rows = report.buckets.map((b) => ({
|
||||||
label: b.label,
|
label: b.label,
|
||||||
@@ -244,7 +217,6 @@ export default function DashboardPage() {
|
|||||||
subtitle="Who walked in, split by whether the platform had seen them before"
|
subtitle="Who walked in, split by whether the platform had seen them before"
|
||||||
resource={mixChart}
|
resource={mixChart}
|
||||||
height={CHART_HEIGHT}
|
height={CHART_HEIGHT}
|
||||||
actions={<SampleTag show={mixChart.isSample} />}
|
|
||||||
>
|
>
|
||||||
{(report) => (
|
{(report) => (
|
||||||
<BarChartView
|
<BarChartView
|
||||||
@@ -284,7 +256,7 @@ export default function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<VStack gap={5} className="pt-4">
|
<VStack gap={5} className="pt-4">
|
||||||
{/* Real: the same two reports as the Customer flow card, joined by
|
{/* 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 ÷
|
per-day Conversion line beside it — deriving purchases ÷
|
||||||
visitors here would invent a definition the reports may not
|
visitors here would invent a definition the reports may not
|
||||||
share (see kpiBuilder). */}
|
share (see kpiBuilder). */}
|
||||||
@@ -293,14 +265,10 @@ export default function DashboardPage() {
|
|||||||
subtitle="The gap is the conversion opportunity"
|
subtitle="The gap is the conversion opportunity"
|
||||||
resource={flowChart}
|
resource={flowChart}
|
||||||
height={CHART_HEIGHT}
|
height={CHART_HEIGHT}
|
||||||
actions={<SampleTag show={flowChart.isSample} />}
|
>
|
||||||
>
|
{([report, conversionReport]) => {
|
||||||
{(report) => {
|
|
||||||
const purchases = new Map(
|
const purchases = new Map(
|
||||||
(flowIsSample || !conversion.data
|
conversionReport.buckets.map((b) => [b.label, b.purchases]),
|
||||||
? MOCK_CONVERSION_DAILY
|
|
||||||
: conversion.data
|
|
||||||
).buckets.map((b) => [b.label, b.purchases]),
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<LineChartView
|
<LineChartView
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function GET(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
return (await engagementApi.impact(token, window, toSiteParam(query.storeId))) ?? [];
|
return (await engagementApi.impact(token, window, toSiteParam(query.storeId))) ?? [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UpstreamError && err.status === 404) {
|
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export async function GET(req: NextRequest) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If the upstream platform has not deployed /api/activities or /api/activities/impact yet,
|
// 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
|
// answer with an empty list so the activities panel renders its clean empty state
|
||||||
// rather than failing with 404.
|
// rather than failing. Only a missing route — a real 404 still surfaces.
|
||||||
if (err instanceof UpstreamError && err.status === 404) {
|
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
|
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
|
||||||
return (list ?? []).map(toCampaign);
|
return (list ?? []).map(toCampaign);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UpstreamError && err.status === 404) {
|
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export async function GET(req: NextRequest) {
|
|||||||
// If the upstream platform has not deployed /api/floor/visits yet,
|
// 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
|
// answer with an empty list so the floor screen renders its clean empty state
|
||||||
// rather than failing with 404.
|
// rather than failing with 404.
|
||||||
if (err instanceof UpstreamError && err.status === 404) {
|
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||||
return {items: []};
|
return {items: []};
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function GET(req: NextRequest) {
|
|||||||
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
|
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UpstreamError && err.status === 404) {
|
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||||
return {stages: [], attribution: 'observed'};
|
return {stages: [], attribution: 'observed'};
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ export async function GET(req: NextRequest) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If the upstream platform has not deployed /api/sales yet,
|
// If the upstream platform has not deployed /api/sales yet,
|
||||||
// answer with an empty list so the sales screen renders cleanly
|
// answer with an empty list so the sales screen renders cleanly
|
||||||
// rather than failing with 404.
|
// rather than failing. Only a missing ROUTE — a 404 for a shop that
|
||||||
if (err instanceof UpstreamError && err.status === 404) {
|
// does not exist is a real answer and still surfaces.
|
||||||
|
if (err instanceof UpstreamError && err.isRouteMissing) {
|
||||||
return {items: []};
|
return {items: []};
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -119,6 +120,7 @@ export async function GET(req: NextRequest) {
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
let body: {
|
let body: {
|
||||||
idempotencyKey?: unknown;
|
idempotencyKey?: unknown;
|
||||||
|
clientCreatedAt?: unknown;
|
||||||
visitId?: unknown;
|
visitId?: unknown;
|
||||||
visitorId?: unknown;
|
visitorId?: unknown;
|
||||||
invoiceNo?: unknown;
|
invoiceNo?: unknown;
|
||||||
@@ -157,7 +159,7 @@ export async function POST(req: NextRequest) {
|
|||||||
site: typeof body.site === 'string' ? body.site : undefined,
|
site: typeof body.site === 'string' ? body.site : undefined,
|
||||||
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
|
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
|
||||||
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
|
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
|
||||||
client_created_at: new Date().toISOString(),
|
client_created_at: draftTime(body.clientCreatedAt),
|
||||||
lines,
|
lines,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -171,10 +173,16 @@ export async function POST(req: NextRequest) {
|
|||||||
sale: result.sale ? toSale(result.sale) : null,
|
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) {
|
} 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(
|
return Response.json(
|
||||||
{
|
{
|
||||||
error: {
|
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 {MetricDelta} from '@/shared/components/primitives/MetricDelta';
|
||||||
import {SampleScope} from '@/shared/mocks/SampleScope';
|
import {SampleScope} from '@/shared/mocks/SampleScope';
|
||||||
import {useSampleResource} from '@/shared/mocks/withSample';
|
import {useSampleResource} from '@/shared/mocks/withSample';
|
||||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
|
||||||
import {
|
import {
|
||||||
SAMPLE_DISCOUNT_RATE,
|
SAMPLE_DISCOUNT_RATE,
|
||||||
SAMPLE_PAYMENT_MIX,
|
SAMPLE_PAYMENT_MIX,
|
||||||
SAMPLE_REFUND_RATE,
|
SAMPLE_REFUND_RATE,
|
||||||
buildTopProducts,
|
buildTopProducts,
|
||||||
storeName as sampleStoreName,
|
|
||||||
type SampleProduct,
|
type SampleProduct,
|
||||||
type SampleStockStatus,
|
type SampleStockStatus,
|
||||||
} from '@/shared/mocks/sampleData';
|
} from '@/shared/mocks/sampleData';
|
||||||
@@ -93,24 +91,14 @@ export function PaymentMethodsPanel({sales}: {sales: Resource<Sale[]>}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopOutletsPanel({
|
export function TopOutletsPanel({sales}: {sales: Resource<Sale[]>}) {
|
||||||
sales,
|
|
||||||
isSample = false,
|
|
||||||
}: {
|
|
||||||
sales: Resource<Sale[]>;
|
|
||||||
isSample?: boolean;
|
|
||||||
}) {
|
|
||||||
const {stores} = useWorkspace();
|
const {stores} = useWorkspace();
|
||||||
// Sample sales fall back to the fixture roster when the merchant has no
|
const name = (id: string) => stores.find((s) => s.id === id)?.name ?? id;
|
||||||
// stores loaded, so those ids resolve through sampleStoreName.
|
|
||||||
const name = (id: string) =>
|
|
||||||
stores.find((s) => s.id === id)?.name ?? (isSample ? sampleStoreName(id) : id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Top performing outlets"
|
title="Top performing outlets"
|
||||||
subtitle="Sales by store in the selected period"
|
subtitle="Sales by store in the selected period"
|
||||||
actions={<SampleTag show={isSample} />}
|
|
||||||
resource={sales}
|
resource={sales}
|
||||||
loading={<SkeletonRows count={4} />}
|
loading={<SkeletonRows count={4} />}
|
||||||
empty={noSales}
|
empty={noSales}
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export function SaleEntryDialog({
|
|||||||
* A ref rather than state: it must survive every re-render unchanged.
|
* A ref rather than state: it must survive every re-render unchanged.
|
||||||
*/
|
*/
|
||||||
const idempotencyKey = useRef<string | null>(null);
|
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 [lines, setLines] = useState<DraftLine[]>([]);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -147,10 +150,12 @@ export function SaleEntryDialog({
|
|||||||
* fresh key.
|
* fresh key.
|
||||||
*/
|
*/
|
||||||
idempotencyKey.current ??= newIdempotencyKey();
|
idempotencyKey.current ??= newIdempotencyKey();
|
||||||
|
clientCreatedAt.current ??= new Date().toISOString();
|
||||||
|
|
||||||
// Through httpClient, so the request carries this tab's session header.
|
// Through httpClient, so the request carries this tab's session header.
|
||||||
const res = await saleRepository.create({
|
const res = await saleRepository.create({
|
||||||
idempotencyKey: idempotencyKey.current,
|
idempotencyKey: idempotencyKey.current,
|
||||||
|
clientCreatedAt: clientCreatedAt.current,
|
||||||
// Taken from the floor context, never typed. §4: do not ask for a
|
// 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
|
// visit id the flow already knows. Staff identity is not sent at
|
||||||
// all — the platform derives it from the session.
|
// 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 {useActivities} from '@/features/engagement/hooks/useEngagement';
|
||||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
||||||
import {RecordActivityDialog} from '@/features/engagement/components/RecordActivityDialog';
|
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';
|
import type {ActivityRow} from '@/features/engagement/types/engagement';
|
||||||
|
|
||||||
/** Null is "not reported", which is not the same fact as zero. */
|
/** 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() {
|
export function ActivitiesPanel() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const activities = useActivities();
|
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 [recording, setRecording] = useState(false);
|
||||||
|
|
||||||
const canRecord =
|
const canRecord =
|
||||||
@@ -104,11 +99,9 @@ export function ActivitiesPanel() {
|
|||||||
actions={
|
actions={
|
||||||
canRecord ? (
|
canRecord ? (
|
||||||
<Button size="sm" variant="secondary" onClick={() => setRecording(true)} label="Record activity" />
|
<Button size="sm" variant="secondary" onClick={() => setRecording(true)} label="Record activity" />
|
||||||
) : (
|
) : null
|
||||||
<SampleTag show={shown.isSample} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
resource={shown}
|
resource={activities}
|
||||||
loading={<SkeletonRows count={4} height={44} />}
|
loading={<SkeletonRows count={4} height={44} />}
|
||||||
empty={
|
empty={
|
||||||
<EmptyPanel
|
<EmptyPanel
|
||||||
|
|||||||
@@ -13,14 +13,11 @@ import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
|||||||
import {formatCount, formatInr} from '@/shared/utils/format';
|
import {formatCount, formatInr} from '@/shared/utils/format';
|
||||||
import {useCampaigns} from '@/features/engagement/hooks/useEngagement';
|
import {useCampaigns} from '@/features/engagement/hooks/useEngagement';
|
||||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
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';
|
import type {Campaign} from '@/features/engagement/types/engagement';
|
||||||
|
|
||||||
/** Each campaign's funnel over the dashboard's window. Revenue is attributed. */
|
/** Each campaign's funnel over the dashboard's window. Revenue is attributed. */
|
||||||
export function CampaignsPanel() {
|
export function CampaignsPanel() {
|
||||||
const campaigns = withSample(useCampaigns(), MOCK_CAMPAIGNS, (d) => !!d?.length);
|
const campaigns = useCampaigns();
|
||||||
|
|
||||||
const columns: TableColumn<Campaign>[] = [
|
const columns: TableColumn<Campaign>[] = [
|
||||||
{
|
{
|
||||||
@@ -82,7 +79,6 @@ export function CampaignsPanel() {
|
|||||||
<PanelCard
|
<PanelCard
|
||||||
title="Campaigns"
|
title="Campaigns"
|
||||||
subtitle="Each campaign's funnel in this period"
|
subtitle="Each campaign's funnel in this period"
|
||||||
actions={<SampleTag show={campaigns.isSample} />}
|
|
||||||
resource={campaigns}
|
resource={campaigns}
|
||||||
loading={<SkeletonRows count={3} height={44} />}
|
loading={<SkeletonRows count={3} height={44} />}
|
||||||
empty={
|
empty={
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import {CHART} from '@/shared/components/charts/palette';
|
|||||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||||
import {formatCompact} from '@/shared/utils/format';
|
import {formatCompact} from '@/shared/utils/format';
|
||||||
import {useJourney} from '@/features/engagement/hooks/useEngagement';
|
import {useJourney} from '@/features/engagement/hooks/useEngagement';
|
||||||
import {withSample} from '@/shared/mocks/withSample';
|
import {emptyUnless} from '@/shared/hooks/resourceUtils';
|
||||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
|
||||||
import {MOCK_JOURNEY} from '@/shared/mocks/sampleData';
|
|
||||||
import {AttributionNote} from '@/features/engagement/components/AttributionNote';
|
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.
|
* and a funnel shape would imply a drop-off that did not happen.
|
||||||
*/
|
*/
|
||||||
export function JourneyPanel() {
|
export function JourneyPanel() {
|
||||||
const journey = withSample(useJourney(), MOCK_JOURNEY, (d) =>
|
// Real only: a period where nobody reached any stage shows the empty state.
|
||||||
!!d?.stages.some((s) => s.customers > 0),
|
const journey = emptyUnless(useJourney(), (d) =>
|
||||||
|
d.stages.some((s) => s.customers > 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Customer journey"
|
title="Customer journey"
|
||||||
subtitle="Distinct people who reached each stage in this period"
|
subtitle="Distinct people who reached each stage in this period"
|
||||||
actions={<SampleTag show={journey.isSample} />}
|
|
||||||
resource={journey}
|
resource={journey}
|
||||||
empty={
|
empty={
|
||||||
<EmptyPanel
|
<EmptyPanel
|
||||||
|
|||||||
@@ -71,8 +71,30 @@ export class UpstreamError extends Error {
|
|||||||
get isUnsupported(): boolean {
|
get isUnsupported(): boolean {
|
||||||
return this.status === 501;
|
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 type QueryValue = string | number | boolean | undefined | null;
|
||||||
|
|
||||||
export interface UpstreamRequest {
|
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,
|
CampaignSummary,
|
||||||
JourneyStage,
|
JourneyStage,
|
||||||
} from '@/features/dashboard/types/intelligence';
|
} 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 {
|
import type {
|
||||||
Reward,
|
Reward,
|
||||||
RewardStatus,
|
RewardStatus,
|
||||||
@@ -67,8 +57,6 @@ import type {LoginAuditItem} from '@/features/settings/components/SecurityManage
|
|||||||
import type {
|
import type {
|
||||||
RangeKey,
|
RangeKey,
|
||||||
} from '@/shared/types/api';
|
} from '@/shared/types/api';
|
||||||
import type {Sale} from '@/features/commerce/types/sale';
|
|
||||||
import {DEFAULT_TZ, shiftDay, shopToday, toReportWindow} from '@/services/api/range';
|
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Seeded randomness
|
// 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
|
// 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_DISCOUNT_RATE = 0.042;
|
||||||
export const SAMPLE_REFUND_RATE = 0.022;
|
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 type SampleStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock';
|
||||||
|
|
||||||
export interface SampleProduct extends Record<string, unknown> {
|
export interface SampleProduct extends Record<string, unknown> {
|
||||||
|
|||||||
@@ -1,47 +1,25 @@
|
|||||||
'use client';
|
'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
|
* useSampleResource() no endpoint exists at all; the sample IS the data, and
|
||||||
* the panel sits inside a <SampleScope>.
|
* 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
|
* Every sample value comes from shared/mocks/sampleData.ts — the single file
|
||||||
* to edit, and eventually delete.
|
* to edit, and eventually delete.
|
||||||
*
|
*
|
||||||
* ── Removing sample data ─────────────────────────────────────────────────
|
* ── Removing sample data ─────────────────────────────────────────────────
|
||||||
* Delete this folder, then fix the call sites TypeScript points at:
|
* 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>.
|
* each <SampleTag/>, <SampleScope> and <SampleNotice>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {useMemo} from 'react';
|
import {useMemo} from 'react';
|
||||||
import type {Resource} from '@/shared/hooks/useResource';
|
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;
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,17 @@ export function failureFrom(err: unknown): UpstreamFailure {
|
|||||||
reason: 'not_supported',
|
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
|
// A 404 is also what another tenant's data returns, always — so it must
|
||||||
// never be phrased as "deleted".
|
// never be phrased as "deleted".
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export type ApiErrorCode =
|
|||||||
| 'internal'
|
| 'internal'
|
||||||
| 'not_found'
|
| 'not_found'
|
||||||
| 'bad_request'
|
| '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 {
|
export interface ApiFailure {
|
||||||
error: {code: ApiErrorCode; message: string};
|
error: {code: ApiErrorCode; message: string};
|
||||||
|
|||||||
Reference in New Issue
Block a user