diff --git a/Dockerfile b/Dockerfile
index d82e645..5bd9a91 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -125,6 +125,7 @@ COPY --chown=nextjs:nodejs .env ./.env
USER nextjs
EXPOSE 3000
+EXPOSE 80
# NO HEALTHCHECK, on purpose.
#
diff --git a/nginx.conf b/nginx.conf
index a95ed20..e3e8b3e 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -39,7 +39,7 @@ http {
}
server {
- listen 80;
+ listen 80 3000;
server_name localhost;
# Serve Next.js compiled static assets directly via Nginx
diff --git a/src/app/(workspace)/commerce/page.tsx b/src/app/(workspace)/commerce/page.tsx
index 3093196..8d4cb31 100644
--- a/src/app/(workspace)/commerce/page.tsx
+++ b/src/app/(workspace)/commerce/page.tsx
@@ -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() {
/>
- {sales.isSample ? (
-
- No sales recorded in this period yet. The revenue, orders and outlet
- figures below are examples until staff record sales in the merchant
- app.
-
- ) : null}
-
{summary && facts ? (
}
resource={sales}
loading={}
empty={noSales}
@@ -223,7 +188,6 @@ export default function CommercePage() {
}
resource={sales}
loading={}
empty={noSales}
@@ -243,7 +207,7 @@ export default function CommercePage() {
-
+
diff --git a/src/app/(workspace)/dashboard/page.tsx b/src/app/(workspace)/dashboard/page.tsx
index 7fb0f89..0036810 100644
--- a/src/app/(workspace)/dashboard/page.tsx
+++ b/src/app/(workspace)/dashboard/page.tsx
@@ -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={}
>
{(report) => (
}
>
{(report) => (
}
>
- {(report) => (
+ {([footfallReport, conversionReport]) => (
)}
@@ -207,14 +184,10 @@ export default function DashboardPage() {
subtitle="Visitors against purchases, week by week"
resource={weeklyChart}
height={CHART_HEIGHT}
- actions={}
>
- {(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={}
>
{(report) => (
{/* 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={}
- >
- {(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 (
}) {
);
}
-export function TopOutletsPanel({
- sales,
- isSample = false,
-}: {
- sales: Resource;
- isSample?: boolean;
-}) {
+export function TopOutletsPanel({sales}: {sales: Resource}) {
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 (
}
resource={sales}
loading={}
empty={noSales}
diff --git a/src/features/commerce/components/SaleEntryDialog.tsx b/src/features/commerce/components/SaleEntryDialog.tsx
index 3533e23..9933f89 100644
--- a/src/features/commerce/components/SaleEntryDialog.tsx
+++ b/src/features/commerce/components/SaleEntryDialog.tsx
@@ -76,6 +76,9 @@ export function SaleEntryDialog({
* A ref rather than state: it must survive every re-render unchanged.
*/
const idempotencyKey = useRef(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(null);
const [lines, setLines] = useState([]);
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.
diff --git a/src/features/engagement/components/ActivitiesPanel.tsx b/src/features/engagement/components/ActivitiesPanel.tsx
index 852ab97..56bf925 100644
--- a/src/features/engagement/components/ActivitiesPanel.tsx
+++ b/src/features/engagement/components/ActivitiesPanel.tsx
@@ -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 ? (