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.',
|
'Footfall, campaign and LYT redemption analytics for Loyaly merchants.',
|
||||||
start_url: '/dashboard',
|
start_url: '/dashboard',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
background_color: '#000000',
|
background_color: '#F7F7F8',
|
||||||
theme_color: '#000000',
|
theme_color: '#F7F7F8',
|
||||||
icons: [
|
icons: [
|
||||||
{src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any'},
|
{src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any'},
|
||||||
{src: '/icons/icon-512.png', sizes: '512x512', 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 {useScope} from '@/shared/hooks/useScope';
|
||||||
import type {Resource} from '@/shared/hooks/useResource';
|
import type {Resource} from '@/shared/hooks/useResource';
|
||||||
import type {Sale} from '@/features/commerce/types/sale';
|
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[]> {
|
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[]> {
|
export function useTodaySales(enabled = true): Resource<Sale[]> {
|
||||||
const {storeId} = useScope();
|
const {storeId} = useScope();
|
||||||
return useResource(
|
const sites = useSampleSites(storeId);
|
||||||
|
const real = useResource(
|
||||||
enabled ? saleRepository.list({storeId, range: 'today'}) : null,
|
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';
|
'use client';
|
||||||
|
|
||||||
import {Grid} from '@astryxdesign/core/Grid';
|
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 {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||||
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
import {MetricCard} from '@/shared/components/patterns/MetricCard';
|
||||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||||
@@ -53,6 +55,12 @@ export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {
|
|||||||
loading={<SkeletonMetricGrid columns={columns} />}
|
loading={<SkeletonMetricGrid columns={columns} />}
|
||||||
>
|
>
|
||||||
{(kpis) => (
|
{(kpis) => (
|
||||||
|
<VStack gap={2}>
|
||||||
|
{resource.isSample ? (
|
||||||
|
<HStack hAlign="end">
|
||||||
|
<SampleTag show />
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
<Grid columns={columns} gap={4}>
|
<Grid columns={columns} gap={4}>
|
||||||
{kpis.map((k) => (
|
{kpis.map((k) => (
|
||||||
<MetricCard
|
<MetricCard
|
||||||
@@ -68,6 +76,7 @@ export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</VStack>
|
||||||
)}
|
)}
|
||||||
</AsyncBoundary>
|
</AsyncBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function useDashboardKpis(
|
|||||||
// The server's clock, from whichever response landed — every relative time
|
// The server's clock, from whichever response landed — every relative time
|
||||||
// on this page measures against it rather than the device.
|
// on this page measures against it rather than the device.
|
||||||
const meta = footfall.meta ?? conversion.meta;
|
const meta = footfall.meta ?? conversion.meta;
|
||||||
|
const isSample = footfall.isSample || conversion.isSample;
|
||||||
|
|
||||||
if (footfall.status === 'error' || conversion.status === 'error') {
|
if (footfall.status === 'error' || conversion.status === 'error') {
|
||||||
return {
|
return {
|
||||||
@@ -97,6 +98,7 @@ export function useDashboardKpis(
|
|||||||
refetch,
|
refetch,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
meta,
|
meta,
|
||||||
|
isSample,
|
||||||
} as Resource<Kpi[]>;
|
} as Resource<Kpi[]>;
|
||||||
}, [footfall, conversion]);
|
}, [footfall, conversion]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type {
|
|||||||
FootfallReport,
|
FootfallReport,
|
||||||
} from '@/features/dashboard/types/reports';
|
} from '@/features/dashboard/types/reports';
|
||||||
import type {VisitsPage} from '@/features/dashboard/types/visits';
|
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}.
|
* 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(
|
export function useFootfallReport(
|
||||||
opts: {bucket?: string; compare?: boolean} = {},
|
opts: {bucket?: string; compare?: boolean} = {},
|
||||||
): Resource<FootfallReport> {
|
): 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(
|
export function useConversionReport(
|
||||||
opts: {bucket?: string; compare?: boolean} = {},
|
opts: {bucket?: string; compare?: boolean} = {},
|
||||||
): Resource<ConversionReport> {
|
): 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> {
|
export function useRecentVisits(limit = 6): Resource<VisitsPage> {
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export function ActivitiesPanel() {
|
|||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
|
|
||||||
const canRecord =
|
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>[] = [
|
const columns: TableColumn<ActivityRow>[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,18 +9,48 @@ import type {
|
|||||||
Campaign,
|
Campaign,
|
||||||
Journey,
|
Journey,
|
||||||
} from '@/features/engagement/types/engagement';
|
} 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[]> {
|
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[]> {
|
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. */
|
/** Empty only when there are no stages at all; a stage of zero people is data. */
|
||||||
export function useJourney(): Resource<Journey> {
|
export function useJourney(): Resource<Journey> {
|
||||||
return useResource(engagementRepository.journey(useScope()), {
|
const scope = useScope();
|
||||||
|
const {storeId, range} = scope;
|
||||||
|
return useSampleFallback(
|
||||||
|
useResource(engagementRepository.journey(scope), {
|
||||||
isEmpty: (j) => j.stages.length === 0,
|
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}
|
value={themeMode}
|
||||||
onChange={(v) => setThemeMode(v as ThemeMode)}
|
onChange={(v) => setThemeMode(v as ThemeMode)}
|
||||||
options={[
|
options={[
|
||||||
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
|
|
||||||
{label: 'Light Mode', value: 'light'},
|
{label: 'Light Mode', value: 'light'},
|
||||||
|
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
|
||||||
{label: 'System OS Preference', value: 'system'},
|
{label: 'System OS Preference', value: 'system'},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function PanelCard<T>({
|
|||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
level={headingLevel}
|
level={headingLevel}
|
||||||
|
isSample={resource.isSample}
|
||||||
/>
|
/>
|
||||||
<AsyncBoundary resource={resource} loading={loading} empty={empty}>
|
<AsyncBoundary resource={resource} loading={loading} empty={empty}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function SectionHeader({
|
|||||||
subtitle,
|
subtitle,
|
||||||
actions,
|
actions,
|
||||||
level = 2,
|
level = 2,
|
||||||
|
isSample: isSampleData = false,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
/**
|
/**
|
||||||
@@ -33,9 +34,12 @@ export function SectionHeader({
|
|||||||
subtitle?: React.ReactNode;
|
subtitle?: React.ReactNode;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
level?: 2 | 3 | 4 | 5;
|
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.
|
// Inside a <SampleScope>, every panel says so — see shared/mocks.
|
||||||
const isSample = useInSampleScope();
|
const inScope = useInSampleScope();
|
||||||
|
const isSample = inScope || isSampleData;
|
||||||
return (
|
return (
|
||||||
<HStack hAlign="between" vAlign="start" gap={3}>
|
<HStack hAlign="between" vAlign="start" gap={3}>
|
||||||
<VStack gap={0.5}>
|
<VStack gap={0.5}>
|
||||||
@@ -52,7 +56,11 @@ export function SectionHeader({
|
|||||||
<HStack gap={1} vAlign="center">
|
<HStack gap={1} vAlign="center">
|
||||||
<SampleTag
|
<SampleTag
|
||||||
show={isSample}
|
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}
|
{actions}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import {HStack} from '@astryxdesign/core/Layout';
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
import {ICONS} from '@/shared/utils/icons';
|
import {ICONS} from '@/shared/utils/icons';
|
||||||
import {formatDelta} from '@/shared/utils/format';
|
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({
|
export function MetricDelta({
|
||||||
value,
|
value,
|
||||||
isRiseGood = true,
|
isRiseGood = true,
|
||||||
size: _size = 'sm',
|
size = 'sm',
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
isRiseGood?: boolean;
|
isRiseGood?: boolean;
|
||||||
@@ -18,30 +26,20 @@ export function MetricDelta({
|
|||||||
}) {
|
}) {
|
||||||
const isFlat = Math.abs(value) < 0.05;
|
const isFlat = Math.abs(value) < 0.05;
|
||||||
const isGood = value > 0 === isRiseGood;
|
const isGood = value > 0 === isRiseGood;
|
||||||
|
const tone = isFlat ? 'text-secondary' : isGood ? 'text-success' : 'text-error';
|
||||||
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';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<HStack gap={0.5} vAlign="center" className={`inline-flex ${tone}`}>
|
||||||
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}`}
|
{isFlat ? null : (
|
||||||
>
|
|
||||||
<Icon
|
<Icon
|
||||||
icon={value > 0 ? ICONS.up : ICONS.down}
|
icon={value > 0 ? ICONS.up : ICONS.down}
|
||||||
size="xsm"
|
size="xsm"
|
||||||
color={isGood ? 'success' : 'error'}
|
color={isGood ? 'success' : 'error'}
|
||||||
/>
|
/>
|
||||||
<span>{formatDelta(value)}</span>
|
)}
|
||||||
</span>
|
<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,
|
isRefreshing: a.isRefreshing || b.isRefreshing,
|
||||||
meta: a.meta ?? b.meta,
|
meta: a.meta ?? b.meta,
|
||||||
|
isSample: a.isSample || b.isSample,
|
||||||
};
|
};
|
||||||
if (a.status === 'error') return {...common, status: 'error', data: undefined, error: a.error};
|
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 (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.
|
* wrong countdown to any user whose device clock is off.
|
||||||
*/
|
*/
|
||||||
meta?: ApiMeta;
|
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. */
|
/** 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 {
|
import type {
|
||||||
RangeKey,
|
RangeKey,
|
||||||
} from '@/shared/types/api';
|
} 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
|
// Seeded randomness
|
||||||
@@ -1635,3 +1646,217 @@ export const SAMPLE_AI_EXPORT_ROWS = [
|
|||||||
|
|
||||||
/** The assistant landing's store-pulse strip — no live telemetry feed yet. */
|
/** The assistant landing's store-pulse strip — no live telemetry feed yet. */
|
||||||
export const SAMPLE_AI_PULSE = {footfallDeltaPct: 18.4, syncPct: 99.8};
|
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
|
* 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
|
* useSampleFallback() a real endpoint exists but returned nothing (or
|
||||||
* empty response renders the panel's empty state.
|
* 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
|
* Every sample value comes from shared/mocks/sampleData.ts — the single file
|
||||||
* to edit, and eventually delete.
|
* to edit, and eventually delete.
|
||||||
@@ -65,3 +68,46 @@ export function useSampleResource<T>(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [key]);
|
}, [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;
|
export const THEME_MODE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dark is the product's identity, so it is what an unknown visitor gets —
|
* Light is what an unknown visitor gets — not 'system', so the console looks
|
||||||
* not 'system'. Only an explicit choice moves off it.
|
* 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 {
|
export function parseThemeMode(value: string | undefined | null): ThemeMode {
|
||||||
return value === 'light' || value === 'dark' || value === 'system'
|
return value === 'light' || value === 'dark' || value === 'system'
|
||||||
|
|||||||
Reference in New Issue
Block a user