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 ? (