update some ui changes
This commit is contained in:
@@ -18,8 +18,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
'Footfall, campaign and LYT redemption analytics for Loyaly merchants.',
|
||||
start_url: '/dashboard',
|
||||
display: 'standalone',
|
||||
background_color: '#000000',
|
||||
theme_color: '#000000',
|
||||
background_color: '#F7F7F8',
|
||||
theme_color: '#F7F7F8',
|
||||
icons: [
|
||||
{src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any'},
|
||||
{src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any'},
|
||||
|
||||
@@ -5,9 +5,26 @@ import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {useSampleFallback} from '@/shared/mocks/withSample';
|
||||
import {buildSampleSales} from '@/shared/mocks/sampleData';
|
||||
|
||||
/** The workspace's real store ids a sample bill can belong to. */
|
||||
function useSampleSites(storeId: string): string[] {
|
||||
const {stores} = useWorkspace();
|
||||
return storeId === 'all' ? stores.filter((s) => s.id !== 'all').map((s) => s.id) : [storeId];
|
||||
}
|
||||
|
||||
export function useSales(): Resource<Sale[]> {
|
||||
return useResource(saleRepository.list(useScope()));
|
||||
const scope = useScope();
|
||||
const {storeId, range} = scope;
|
||||
const sites = useSampleSites(storeId);
|
||||
// SAMPLE while no sale is recorded for the scope — see useSampleFallback.
|
||||
return useSampleFallback(
|
||||
useResource(saleRepository.list(scope)),
|
||||
(now) => buildSampleSales(range, storeId, sites, now),
|
||||
[range, storeId, sites],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,7 +35,13 @@ export function useSales(): Resource<Sale[]> {
|
||||
*/
|
||||
export function useTodaySales(enabled = true): Resource<Sale[]> {
|
||||
const {storeId} = useScope();
|
||||
return useResource(
|
||||
const sites = useSampleSites(storeId);
|
||||
const real = useResource(
|
||||
enabled ? saleRepository.list({storeId, range: 'today'}) : null,
|
||||
);
|
||||
return useSampleFallback(
|
||||
real,
|
||||
(now) => buildSampleSales('today', storeId, sites, now),
|
||||
['today', storeId, sites],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {SampleTag} from '@/shared/mocks/SampleTag';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
@@ -53,21 +55,28 @@ export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
>
|
||||
{(kpis) => (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{kpis.map((k) => (
|
||||
<MetricCard
|
||||
key={k.id}
|
||||
label={k.label}
|
||||
value={k.value}
|
||||
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
|
||||
icon={KPI_ICON[k.id]}
|
||||
deltaPct={k.deltaPct}
|
||||
isRiseGood={k.isRiseGood}
|
||||
trend={k.trend}
|
||||
accent={KPI_ACCENT[k.id]}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
<VStack gap={2}>
|
||||
{resource.isSample ? (
|
||||
<HStack hAlign="end">
|
||||
<SampleTag show />
|
||||
</HStack>
|
||||
) : null}
|
||||
<Grid columns={columns} gap={4}>
|
||||
{kpis.map((k) => (
|
||||
<MetricCard
|
||||
key={k.id}
|
||||
label={k.label}
|
||||
value={k.value}
|
||||
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
|
||||
icon={KPI_ICON[k.id]}
|
||||
deltaPct={k.deltaPct}
|
||||
isRiseGood={k.isRiseGood}
|
||||
trend={k.trend}
|
||||
accent={KPI_ACCENT[k.id]}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</VStack>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@ export function useDashboardKpis(
|
||||
// The server's clock, from whichever response landed — every relative time
|
||||
// on this page measures against it rather than the device.
|
||||
const meta = footfall.meta ?? conversion.meta;
|
||||
const isSample = footfall.isSample || conversion.isSample;
|
||||
|
||||
if (footfall.status === 'error' || conversion.status === 'error') {
|
||||
return {
|
||||
@@ -97,6 +98,7 @@ export function useDashboardKpis(
|
||||
refetch,
|
||||
isRefreshing,
|
||||
meta,
|
||||
isSample,
|
||||
} as Resource<Kpi[]>;
|
||||
}, [footfall, conversion]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
FootfallReport,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
import type {VisitsPage} from '@/features/dashboard/types/visits';
|
||||
import {reportHasSignal} from '@/shared/hooks/resourceUtils';
|
||||
import {useSampleFallback} from '@/shared/mocks/withSample';
|
||||
import {buildSampleConversion, buildSampleFootfall} from '@/shared/mocks/sampleData';
|
||||
|
||||
/**
|
||||
* One hook per resource, scoped by the workspace's {site, range}.
|
||||
@@ -20,13 +23,29 @@ import type {VisitsPage} from '@/features/dashboard/types/visits';
|
||||
export function useFootfallReport(
|
||||
opts: {bucket?: string; compare?: boolean} = {},
|
||||
): Resource<FootfallReport> {
|
||||
return useResource(reportRepository.footfall(useScope(), opts));
|
||||
const scope = useScope();
|
||||
const {storeId, range} = scope;
|
||||
// SAMPLE while the report has no visitors — see useSampleFallback.
|
||||
return useSampleFallback(
|
||||
useResource(reportRepository.footfall(scope, opts)),
|
||||
(now) => buildSampleFootfall(range, storeId, now, opts.bucket),
|
||||
[range, storeId, opts.bucket],
|
||||
(d) => reportHasSignal(d, 'visitors'),
|
||||
);
|
||||
}
|
||||
|
||||
export function useConversionReport(
|
||||
opts: {bucket?: string; compare?: boolean} = {},
|
||||
): Resource<ConversionReport> {
|
||||
return useResource(reportRepository.conversion(useScope(), opts));
|
||||
const scope = useScope();
|
||||
const {storeId, range} = scope;
|
||||
// SAMPLE while the report has no purchases — see useSampleFallback.
|
||||
return useSampleFallback(
|
||||
useResource(reportRepository.conversion(scope, opts)),
|
||||
(now) => buildSampleConversion(range, storeId, now, opts.bucket),
|
||||
[range, storeId, opts.bucket],
|
||||
(d) => reportHasSignal(d, 'purchases') || reportHasSignal(d, 'revenue'),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRecentVisits(limit = 6): Resource<VisitsPage> {
|
||||
|
||||
@@ -34,7 +34,9 @@ export function ActivitiesPanel() {
|
||||
const [recording, setRecording] = useState(false);
|
||||
|
||||
const canRecord =
|
||||
activities.status === 'success' && activities.data.some((a) => a.isActive);
|
||||
activities.status === 'success' &&
|
||||
!activities.isSample &&
|
||||
activities.data.some((a) => a.isActive);
|
||||
|
||||
const columns: TableColumn<ActivityRow>[] = [
|
||||
{
|
||||
|
||||
@@ -9,18 +9,48 @@ import type {
|
||||
Campaign,
|
||||
Journey,
|
||||
} from '@/features/engagement/types/engagement';
|
||||
import {useSampleFallback} from '@/shared/mocks/withSample';
|
||||
import {
|
||||
buildSampleActivityRows,
|
||||
buildSampleCampaignRows,
|
||||
buildSampleJourney,
|
||||
} from '@/shared/mocks/sampleData';
|
||||
|
||||
// Each falls back to SAMPLE while the endpoint has nothing — see
|
||||
// useSampleFallback. The sample carries `attribution: 'estimated'`, so the
|
||||
// AttributionNote still shows.
|
||||
|
||||
export function useActivities(): Resource<ActivityRow[]> {
|
||||
return useResource(engagementRepository.activities(useScope()));
|
||||
const scope = useScope();
|
||||
const {storeId, range} = scope;
|
||||
return useSampleFallback(
|
||||
useResource(engagementRepository.activities(scope)),
|
||||
(now) => buildSampleActivityRows(range, storeId, now),
|
||||
[range, storeId],
|
||||
);
|
||||
}
|
||||
|
||||
export function useCampaigns(): Resource<Campaign[]> {
|
||||
return useResource(engagementRepository.campaigns(useScope()));
|
||||
const scope = useScope();
|
||||
const {storeId, range} = scope;
|
||||
return useSampleFallback(
|
||||
useResource(engagementRepository.campaigns(scope)),
|
||||
(now) => buildSampleCampaignRows(range, storeId, now),
|
||||
[range, storeId],
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty only when there are no stages at all; a stage of zero people is data. */
|
||||
export function useJourney(): Resource<Journey> {
|
||||
return useResource(engagementRepository.journey(useScope()), {
|
||||
isEmpty: (j) => j.stages.length === 0,
|
||||
});
|
||||
const scope = useScope();
|
||||
const {storeId, range} = scope;
|
||||
return useSampleFallback(
|
||||
useResource(engagementRepository.journey(scope), {
|
||||
isEmpty: (j) => j.stages.length === 0,
|
||||
}),
|
||||
(now) => buildSampleJourney(range, storeId, now),
|
||||
[range, storeId],
|
||||
// JourneyPanel treats a journey where nobody reached any stage as empty.
|
||||
(j) => j.stages.some((s) => s.customers > 0),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,8 +92,8 @@ export function PreferencesForm() {
|
||||
value={themeMode}
|
||||
onChange={(v) => setThemeMode(v as ThemeMode)}
|
||||
options={[
|
||||
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
|
||||
{label: 'Light Mode', value: 'light'},
|
||||
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
|
||||
{label: 'System OS Preference', value: 'system'},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -46,6 +46,7 @@ export function PanelCard<T>({
|
||||
subtitle={subtitle}
|
||||
actions={actions}
|
||||
level={headingLevel}
|
||||
isSample={resource.isSample}
|
||||
/>
|
||||
<AsyncBoundary resource={resource} loading={loading} empty={empty}>
|
||||
{children}
|
||||
|
||||
@@ -22,6 +22,7 @@ export function SectionHeader({
|
||||
subtitle,
|
||||
actions,
|
||||
level = 2,
|
||||
isSample: isSampleData = false,
|
||||
}: {
|
||||
title: string;
|
||||
/**
|
||||
@@ -33,9 +34,12 @@ export function SectionHeader({
|
||||
subtitle?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
level?: 2 | 3 | 4 | 5;
|
||||
/** The panel's resource is a sample standing in for an empty endpoint. */
|
||||
isSample?: boolean;
|
||||
}) {
|
||||
// Inside a <SampleScope>, every panel says so — see shared/mocks.
|
||||
const isSample = useInSampleScope();
|
||||
const inScope = useInSampleScope();
|
||||
const isSample = inScope || isSampleData;
|
||||
return (
|
||||
<HStack hAlign="between" vAlign="start" gap={3}>
|
||||
<VStack gap={0.5}>
|
||||
@@ -52,7 +56,11 @@ export function SectionHeader({
|
||||
<HStack gap={1} vAlign="center">
|
||||
<SampleTag
|
||||
show={isSample}
|
||||
description="The platform does not report this yet — showing an example"
|
||||
description={
|
||||
inScope
|
||||
? 'The platform does not report this yet — showing an example'
|
||||
: 'No data for this period yet — showing an example'
|
||||
}
|
||||
/>
|
||||
{actions}
|
||||
</HStack>
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {formatDelta} from '@/shared/utils/format';
|
||||
|
||||
/**
|
||||
* Modern illuminated period-over-period change pill badge.
|
||||
* Period-over-period change: an arrow and a signed percentage, in the
|
||||
* semantic colour for whether the move is good.
|
||||
*
|
||||
* Inline, not a pill. The pill's padding and border pushed "+10.3%" past an
|
||||
* 88px table column and it wrapped onto two lines; the colour and arrow carry
|
||||
* the meaning on their own. `nowrap` keeps the figure on one line wherever
|
||||
* it sits.
|
||||
*/
|
||||
export function MetricDelta({
|
||||
value,
|
||||
isRiseGood = true,
|
||||
size: _size = 'sm',
|
||||
size = 'sm',
|
||||
}: {
|
||||
value: number;
|
||||
isRiseGood?: boolean;
|
||||
@@ -18,30 +26,20 @@ export function MetricDelta({
|
||||
}) {
|
||||
const isFlat = Math.abs(value) < 0.05;
|
||||
const isGood = value > 0 === isRiseGood;
|
||||
|
||||
if (isFlat) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-zinc-500/10 dark:bg-zinc-400/10 text-zinc-600 dark:text-zinc-400 border border-zinc-500/15">
|
||||
<span className="w-1 h-1 rounded-full bg-zinc-400" />
|
||||
<span>0.0%</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeClass = isGood
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20'
|
||||
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20';
|
||||
const tone = isFlat ? 'text-secondary' : isGood ? 'text-success' : 'text-error';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold tracking-tight border transition-all ${badgeClass}`}
|
||||
>
|
||||
<Icon
|
||||
icon={value > 0 ? ICONS.up : ICONS.down}
|
||||
size="xsm"
|
||||
color={isGood ? 'success' : 'error'}
|
||||
/>
|
||||
<span>{formatDelta(value)}</span>
|
||||
</span>
|
||||
<HStack gap={0.5} vAlign="center" className={`inline-flex ${tone}`}>
|
||||
{isFlat ? null : (
|
||||
<Icon
|
||||
icon={value > 0 ? ICONS.up : ICONS.down}
|
||||
size="xsm"
|
||||
color={isGood ? 'success' : 'error'}
|
||||
/>
|
||||
)}
|
||||
<Text size={size} weight="semibold" color="inherit" textWrap="nowrap" hasTabularNumbers>
|
||||
{isFlat ? '0.0%' : formatDelta(value)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export function joinResources<A, B>(
|
||||
},
|
||||
isRefreshing: a.isRefreshing || b.isRefreshing,
|
||||
meta: a.meta ?? b.meta,
|
||||
isSample: a.isSample || b.isSample,
|
||||
};
|
||||
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};
|
||||
|
||||
@@ -35,6 +35,12 @@ export type Resource<T> = AsyncState<T> & {
|
||||
* wrong countdown to any user whose device clock is off.
|
||||
*/
|
||||
meta?: ApiMeta;
|
||||
/**
|
||||
* TEMPORARY — set by useSampleFallback() when the real endpoint had nothing
|
||||
* and an example is on screen instead. PanelCard shows the "Sample data"
|
||||
* token from it. Goes with shared/mocks.
|
||||
*/
|
||||
isSample?: boolean;
|
||||
};
|
||||
|
||||
/** What the fetch resolved to, tagged with the request it belongs to. */
|
||||
|
||||
@@ -57,6 +57,17 @@ import type {LoginAuditItem} from '@/features/settings/components/SecurityManage
|
||||
import type {
|
||||
RangeKey,
|
||||
} from '@/shared/types/api';
|
||||
import type {
|
||||
ConversionReport,
|
||||
FootfallReport,
|
||||
ReportBucket,
|
||||
} from '@/features/dashboard/types/reports';
|
||||
import type {Sale} from '@/features/commerce/types/sale';
|
||||
import type {
|
||||
ActivityRow,
|
||||
Campaign,
|
||||
Journey,
|
||||
} from '@/features/engagement/types/engagement';
|
||||
|
||||
// ===========================================================================
|
||||
// Seeded randomness
|
||||
@@ -1635,3 +1646,217 @@ export const SAMPLE_AI_EXPORT_ROWS = [
|
||||
|
||||
/** The assistant landing's store-pulse strip — no live telemetry feed yet. */
|
||||
export const SAMPLE_AI_PULSE = {footfallDeltaPct: 18.4, syncPct: 99.8};
|
||||
|
||||
// ===========================================================================
|
||||
// Fallbacks for REAL endpoints that came back empty
|
||||
//
|
||||
// Read through useSampleFallback() (withSample.ts): the real response always
|
||||
// wins when it carries anything, and the panel wears the "Sample data" token
|
||||
// whenever one of these is on screen. Shapes match the BFF payloads exactly,
|
||||
// so the panels render them with no special case.
|
||||
// ===========================================================================
|
||||
|
||||
/** Daily buckets from the shared timeseries, so every sample chart agrees. */
|
||||
function sampleDayBuckets(range: RangeKey, storeId: string, endMs: number): ReportBucket[] {
|
||||
return buildTimeseries(range, storeId, endMs).map((p, i) => {
|
||||
const rng = createRng('report-bucket', storeId, range, p.t, i);
|
||||
const newVisitors = Math.round(p.visitors * rng.float(0.28, 0.42));
|
||||
return {
|
||||
label: p.t,
|
||||
visitors: p.visitors,
|
||||
newVisitors,
|
||||
returningVisitors: p.visitors - newVisitors,
|
||||
purchases: p.purchases,
|
||||
revenue: p.revenue,
|
||||
conversion: p.conversion,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Days rolled into weeks, labelled by each week's first day. */
|
||||
function rollWeeks(days: ReportBucket[]): ReportBucket[] {
|
||||
const weeks: ReportBucket[] = [];
|
||||
days.forEach((d, i) => {
|
||||
if (i % 7 === 0) weeks.push({...d});
|
||||
else {
|
||||
const w = weeks[weeks.length - 1];
|
||||
w.visitors += d.visitors;
|
||||
w.newVisitors += d.newVisitors;
|
||||
w.returningVisitors += d.returningVisitors;
|
||||
w.purchases += d.purchases;
|
||||
w.revenue += d.revenue;
|
||||
}
|
||||
});
|
||||
return weeks.map((w) => ({
|
||||
...w,
|
||||
conversion: w.visitors ? Number(((w.purchases / w.visitors) * 100).toFixed(1)) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function sampleBuckets(range: RangeKey, storeId: string, endMs: number, bucket?: string) {
|
||||
const days = sampleDayBuckets(range, storeId, endMs);
|
||||
return bucket === 'week' ? rollWeeks(days) : days;
|
||||
}
|
||||
|
||||
const previousEnd = (range: RangeKey, endMs: number) => endMs - RANGE_DAYS[range] * 86400000;
|
||||
|
||||
export function buildSampleFootfall(
|
||||
range: RangeKey,
|
||||
storeId: string,
|
||||
endMs: number,
|
||||
bucket?: string,
|
||||
): FootfallReport {
|
||||
const buckets = sampleBuckets(range, storeId, endMs, bucket);
|
||||
const visits = buckets.reduce((a, b) => a + b.visitors, 0);
|
||||
const newVisitors = buckets.reduce((a, b) => a + b.newVisitors, 0);
|
||||
// Unique people, not bucket-visitors: regulars come more than once a window.
|
||||
const total = Math.round(visits * 0.72);
|
||||
const previousVisits = totalVisitors(range, storeId, previousEnd(range, endMs));
|
||||
return {
|
||||
total,
|
||||
newVisitors: Math.min(newVisitors, total),
|
||||
returningVisitors: Math.max(0, total - newVisitors),
|
||||
visits,
|
||||
buckets,
|
||||
timezone: 'Asia/Kolkata',
|
||||
previousTotal: Math.round(previousVisits * 0.72),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSampleConversion(
|
||||
range: RangeKey,
|
||||
storeId: string,
|
||||
endMs: number,
|
||||
bucket?: string,
|
||||
): ConversionReport {
|
||||
const buckets = sampleBuckets(range, storeId, endMs, bucket);
|
||||
const purchases = buckets.reduce((a, b) => a + b.purchases, 0);
|
||||
const revenue = buckets.reduce((a, b) => a + b.revenue, 0);
|
||||
const visitors = buckets.reduce((a, b) => a + b.visitors, 0);
|
||||
const prev = buildTimeseries(range, storeId, previousEnd(range, endMs));
|
||||
return {
|
||||
purchases,
|
||||
revenue,
|
||||
conversionPct: visitors ? Number(((purchases / (visitors * 0.72)) * 100).toFixed(1)) : null,
|
||||
basketSize: purchases ? Math.round(revenue / purchases) : null,
|
||||
buckets,
|
||||
timezone: 'Asia/Kolkata',
|
||||
previousPurchases: prev.reduce((a, p) => a + p.purchases, 0),
|
||||
previousRevenue: prev.reduce((a, p) => a + p.revenue, 0),
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE_CUSTOMERS = ['Priya S.', 'Arjun M.', 'Kavya R.', 'Rahul N.', 'Sneha P.', 'Vikram T.', 'Ananya K.', 'Rohan D.'];
|
||||
const SAMPLE_CASHIERS = ['Meena', 'Karthik', 'Divya', 'Suresh'];
|
||||
/** Keeps a 90-day all-stores list from building tens of thousands of rows. */
|
||||
const MAX_SAMPLE_SALES = 1500;
|
||||
const IST_OFFSET_MS = 5.5 * 3600000;
|
||||
|
||||
/**
|
||||
* Bills for the sale list. `siteIds` are the workspace's REAL store ids, so
|
||||
* Top performing outlets names the merchant's own shops.
|
||||
*/
|
||||
export function buildSampleSales(
|
||||
range: RangeKey,
|
||||
storeId: string,
|
||||
siteIds: string[],
|
||||
endMs: number,
|
||||
): Sale[] {
|
||||
const days = RANGE_DAYS[range];
|
||||
const sites = siteIds.length ? siteIds : [storeId];
|
||||
const perDayCap = Math.max(4, Math.floor(MAX_SAMPLE_SALES / days));
|
||||
const sales: Sale[] = [];
|
||||
|
||||
buildTimeseries(range, storeId, endMs).forEach((p, dayIdx) => {
|
||||
const rng = createRng('sale', storeId, range, p.t);
|
||||
const count = Math.min(perDayCap, Math.max(3, Math.round(p.purchases / 6)));
|
||||
const dayStart = Date.parse(`${p.t}T00:00:00Z`) - IST_OFFSET_MS;
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Shop hours 10:00–21:00 IST, with an evening lean.
|
||||
const hour = Math.min(21, Math.floor(10 + 11 * Math.sqrt(rng.next())));
|
||||
const at = dayStart + hour * 3600000 + rng.int(0, 59) * 60000;
|
||||
const purchasedLines = rng.int(1, 4);
|
||||
const enquiryLines = rng.next() < 0.3 ? rng.int(1, 2) : 0;
|
||||
const totalPaise = purchasedLines * rng.int(249, 1299) * 100;
|
||||
const known = rng.next() < 0.6;
|
||||
sales.push({
|
||||
id: `sample-${p.t}-${i}`,
|
||||
invoiceNo: `INV-${p.t.replaceAll('-', '').slice(2)}-${String(dayIdx * 100 + i).padStart(4, '0')}`,
|
||||
siteId: sites[rng.int(0, sites.length - 1)],
|
||||
customerRef: known ? `cust-${rng.int(100, 999)}` : null,
|
||||
customerLabel: known ? rng.pick(SAMPLE_CUSTOMERS) : null,
|
||||
staffName: rng.pick(SAMPLE_CASHIERS),
|
||||
totalPaise,
|
||||
currency: 'INR',
|
||||
status: rng.next() < 0.02 ? 'void' : 'completed',
|
||||
at: new Date(at).toISOString(),
|
||||
purchasedLines,
|
||||
enquiryLines,
|
||||
lines: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return sales.sort((a, b) => b.at.localeCompare(a.at));
|
||||
}
|
||||
|
||||
/** The engagement catalogue, from the same activity metrics the intelligence panels use. */
|
||||
export function buildSampleActivityRows(
|
||||
range: RangeKey,
|
||||
storeId: string,
|
||||
endMs: number,
|
||||
): ActivityRow[] {
|
||||
return buildActivityMetrics(range, storeId, endMs)
|
||||
.filter((m) => m.status !== 'draft')
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
kind: m.id,
|
||||
name: m.label,
|
||||
isActive: m.status === 'live',
|
||||
events: m.count,
|
||||
participants: m.impact.customers,
|
||||
anonymous: Math.round(m.impact.customers * 0.12),
|
||||
repeatVisitors: m.impact.repeatVisits,
|
||||
purchases: m.impact.purchases,
|
||||
attributedRevenue: m.impact.attributedRevenueInr,
|
||||
currency: 'INR',
|
||||
attribution: 'estimated' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSampleCampaignRows(
|
||||
range: RangeKey,
|
||||
storeId: string,
|
||||
endMs: number,
|
||||
): Campaign[] {
|
||||
const metrics = buildActivityMetrics(range, storeId, endMs);
|
||||
return buildCampaigns(range, storeId, endMs).map((c, i) => {
|
||||
const m = metrics.find((x) => x.id === c.activityId);
|
||||
const startDaysAgo = [24, 40, 18, 60][i % 4];
|
||||
const isActive = c.status === 'live';
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
startsAt: new Date(endMs - startDaysAgo * 86400000).toISOString(),
|
||||
endsAt: isActive ? null : new Date(endMs - 6 * 86400000).toISOString(),
|
||||
isActive,
|
||||
activities: [c.activityId],
|
||||
events: c.steps[0].value,
|
||||
participants: m?.impact.customers ?? c.steps[0].value,
|
||||
purchasers: c.steps[2].value,
|
||||
attributedRevenue: c.attributedRevenueInr,
|
||||
attribution: c.attribution,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSampleJourney(range: RangeKey, storeId: string, endMs: number): Journey {
|
||||
return {
|
||||
stages: buildJourney(range, storeId, endMs).map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
customers: s.value,
|
||||
})),
|
||||
attribution: 'estimated',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
* 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.
|
||||
* useSampleFallback() a real endpoint exists but returned nothing (or
|
||||
* failed); an example stands in and the panel wears
|
||||
* the "Sample data" token via `resource.isSample`.
|
||||
* Off with NEXT_PUBLIC_SAMPLE_FALLBACK=off, which
|
||||
* restores the plain empty states.
|
||||
*
|
||||
* Every sample value comes from shared/mocks/sampleData.ts — the single file
|
||||
* to edit, and eventually delete.
|
||||
@@ -65,3 +68,46 @@ export function useSampleResource<T>(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
}
|
||||
|
||||
/** Set NEXT_PUBLIC_SAMPLE_FALLBACK=off to show real empty states instead. */
|
||||
export const SAMPLE_FALLBACK = process.env.NEXT_PUBLIC_SAMPLE_FALLBACK !== 'off';
|
||||
|
||||
const hasRows = (d: unknown) => !Array.isArray(d) || d.length > 0;
|
||||
|
||||
/**
|
||||
* The real resource while it carries anything; otherwise a sample of the
|
||||
* same shape, marked `isSample`. Loading stays loading — a sample never
|
||||
* flashes in ahead of a real response.
|
||||
*
|
||||
* `hasData` decides what "carries anything" means: a report whose buckets
|
||||
* are all zero arrives as success but is still nothing to draw.
|
||||
*/
|
||||
export function useSampleFallback<T>(
|
||||
real: Resource<T>,
|
||||
build: (nowMs: number) => T,
|
||||
deps: readonly unknown[],
|
||||
hasData: (data: T) => boolean = hasRows,
|
||||
): Resource<T> {
|
||||
const needed =
|
||||
SAMPLE_FALLBACK &&
|
||||
(real.status === 'empty' ||
|
||||
real.status === 'error' ||
|
||||
(real.status === 'success' && !hasData(real.data)));
|
||||
const key = JSON.stringify(deps);
|
||||
const {refetch} = real;
|
||||
const sample = useMemo<Resource<T> | null>(() => {
|
||||
if (!needed) return null;
|
||||
const nowMs = sampleNowMs();
|
||||
return {
|
||||
status: 'success',
|
||||
data: build(nowMs),
|
||||
error: undefined,
|
||||
refetch,
|
||||
isRefreshing: false,
|
||||
meta: {generatedAt: new Date(nowMs).toISOString()},
|
||||
isSample: true,
|
||||
} as Resource<T>;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [needed, key, refetch]);
|
||||
return sample ?? real;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ export const THEME_MODE_COOKIE = 'loyaly.theme-mode';
|
||||
export const THEME_MODE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
/**
|
||||
* Dark is the product's identity, so it is what an unknown visitor gets —
|
||||
* not 'system'. Only an explicit choice moves off it.
|
||||
* Light is what an unknown visitor gets — not 'system', so the console looks
|
||||
* the same on every machine until somebody picks otherwise in Preferences.
|
||||
*/
|
||||
export const DEFAULT_THEME_MODE: ThemeMode = 'dark';
|
||||
export const DEFAULT_THEME_MODE: ThemeMode = 'light';
|
||||
|
||||
export function parseThemeMode(value: string | undefined | null): ThemeMode {
|
||||
return value === 'light' || value === 'dark' || value === 'system'
|
||||
|
||||
Reference in New Issue
Block a user