ligth theme design fix
This commit is contained in:
44
AGENTS.md
44
AGENTS.md
@@ -65,6 +65,50 @@ Gray is the accent. The **only** colour permitted is semantic: success, warning,
|
||||
- `/tokens` (dev-only route) is the audit page. After any theme change, load it and run the
|
||||
chromatic sweep in the browser console — it should report **0** non-semantic colour nodes.
|
||||
|
||||
## The two brand accents (exception to the above)
|
||||
Store intelligence carries exactly **two** non-semantic hues, and nothing else may add a third.
|
||||
|
||||
| | token | light | dark | used for |
|
||||
|---|---|---|---|---|
|
||||
| warm | `--color-brand-warm` | `#F4C430` | `#F4C430` | brand, rewards, the activities a merchant runs |
|
||||
| cool | `--color-brand-cool` | `#7C3AED` | `#A78BFA` | analytics, journeys, AI insight |
|
||||
|
||||
- Defined in `src/app/globals.css` in a Tailwind `@theme` block, **not** in `loyalyTheme.ts`.
|
||||
They are not Astryx tokens: no component variant resolves through them, and putting them in
|
||||
the theme would let Badge/Banner pick them up as part of the semantic language.
|
||||
- Three slots per hue. `-ink` is the readable one for text and icons (light mode darkens warm
|
||||
to `#8A6300`; raw `#F4C430` on white is ~1.7:1). `-soft` is an icon-chip tint only — never a
|
||||
card background. The base is for non-text marks: chart fills, dots, bars.
|
||||
- `src/shared/utils/accent.ts` is the only place a hue becomes a class name. Feature files use
|
||||
`ACCENT[accent].ink` / `.soft`, never `text-brand-warm-ink` by hand.
|
||||
- Charts stay monochrome by default. `CHART.brand.{warm,cool}` is opt-in per series and is used
|
||||
on exactly two charts (dashboard Footfall = cool, Revenue = warm). Never make it a ramp
|
||||
default — `Sparkline` reads `seriesAt(0)`, so that would turn every KPI card's trend line.
|
||||
- An activity's accent travels on its API payload (`ActivityMetric.accent`), not from grid
|
||||
position, so an activity is the same colour on every screen.
|
||||
|
||||
## Attribution is estimated — say so
|
||||
Everything downstream of `ActivityImpact.customers` (repeat visits, purchases, revenue) is
|
||||
**modelled**, not measured. No purchase is joined to a specific spin, selfie or challenge.
|
||||
|
||||
- The field is `attributedRevenueInr`, never `revenueInr`, and every payload carries
|
||||
`attribution: 'estimated' | 'observed'`.
|
||||
- Copy says *attributed*. Never "generated", "earned" or "drove". A merchant who reads
|
||||
"Selfie generated ₹3.4L" and spends against it is the failure this rule prevents.
|
||||
- `<AttributionNote basis={…} />` sits in the header of every panel that shows an attributed
|
||||
figure, and renders **nothing** when the basis is `'observed'` — so a real attribution
|
||||
backend retires the disclosure with no copy edit. Read the basis off the payload, never
|
||||
hardcode it.
|
||||
- The single sentence lives in `ATTRIBUTION_NOTE` (types/intelligence.ts) so three panels
|
||||
cannot drift.
|
||||
|
||||
## Dashboard supporting analytics are collapsed by default
|
||||
The six preserved supporting panels (conversion pair, peak hours, reward usage, period rollup,
|
||||
store comparison) sit inside a `Collapsible`, closed by default, state in
|
||||
`loyaly.dashboard.supporting-analytics`. Expanded they add ~1,700px desktop / ~3,100px mobile
|
||||
and push "What needs your attention" — the only actionable section — to 81% of the scroll.
|
||||
They are evidence, not the finding. Do not re-expand by default; do not delete them either.
|
||||
|
||||
## Known upstream issues (Astryx 0.2.0)
|
||||
- **`useEntryAnimation` breaks SSR hydration.** Its source assumes `'use client'` modules never
|
||||
run on the server; in the App Router they do. Any `FieldStatus` (i.e. `TextInput`/`TextArea`
|
||||
|
||||
@@ -8,13 +8,19 @@ import {useDashboardActivity} from '@/features/dashboard/hooks/useDashboard';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
|
||||
/**
|
||||
* The complete event history — where the dashboard's "View all" leads.
|
||||
* The complete EVENT history — where "View all" on the dashboard's recent
|
||||
* activity feed leads.
|
||||
*
|
||||
* Deliberately narrow. The grouped activity ecosystem — all ten activities,
|
||||
* their status, their LYT cost and their impact chains — lives on /lyts,
|
||||
* because that is where a merchant manages the programme. It briefly lived
|
||||
* here too; two homes for one dataset is exactly the duplication that lets
|
||||
* two screens drift apart, so this page kept the half that is genuinely its
|
||||
* own: the raw, chronological log of individual events.
|
||||
*
|
||||
* Not in the sidebar on purpose. It is a drill-down from one panel, the same
|
||||
* relationship /stores/[storeId] has to the store roster, and the nav is the
|
||||
* four modules the product is organised around. Adding a fifth entry for a log
|
||||
* would put an operational archive at the same level as Lyts and Staff, which
|
||||
* is the weighting this whole change is correcting.
|
||||
* modules the product is organised around.
|
||||
*
|
||||
* Reads the same resource and the same component the dashboard panel does,
|
||||
* uncapped: one feed implementation, two budgets.
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
import {useState} from 'react';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Collapsible} from '@astryxdesign/core/Collapsible';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
@@ -18,14 +17,24 @@ import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline
|
||||
import {RewardUsageChart} from '@/features/dashboard/components/RewardUsageChart';
|
||||
import {StoreComparisonPanel} from '@/features/dashboard/components/StoreComparison';
|
||||
import {PerformancePanel} from '@/features/dashboard/components/PerformancePanel';
|
||||
import {CustomerActivity} from '@/features/dashboard/components/CustomerActivity';
|
||||
import {CustomerJourney} from '@/features/dashboard/components/CustomerJourney';
|
||||
import {CampaignPerformance} from '@/features/dashboard/components/CampaignPerformance';
|
||||
import {StoreInsights} from '@/features/dashboard/components/StoreInsights';
|
||||
import {CHART} from '@/shared/components/charts/palette';
|
||||
import {
|
||||
useActivityMetrics,
|
||||
useCampaigns,
|
||||
useCustomerJourney,
|
||||
useDashboardActivity,
|
||||
useDashboardKpis,
|
||||
useDashboardPeakHours,
|
||||
useDashboardRewardUsage,
|
||||
useDashboardStoreComparison,
|
||||
useDashboardTimeseries,
|
||||
useStoreInsights,
|
||||
} from '@/features/dashboard/hooks/useDashboard';
|
||||
import {usePersistentFlag} from '@/shared/hooks/usePersistentFlag';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {greetingFor} from '@/features/dashboard/services/dashboardService';
|
||||
@@ -38,25 +47,39 @@ import {
|
||||
} from '@/shared/utils/format';
|
||||
|
||||
/**
|
||||
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
|
||||
* Store intelligence, in the order a merchant actually asks the questions:
|
||||
* what happened → why is it happening → what should I do next.
|
||||
*
|
||||
* The page reads top-down as one narrowing question: headline numbers → the two
|
||||
* trends that drive them → the conversion story behind those → when and on what
|
||||
* it happens → the period rollup → which store → what just happened.
|
||||
* what happened the four headline metrics, then the two trends behind them
|
||||
* why what customers DID (activity), where they stopped
|
||||
* (journey), which campaigns moved them (campaigns), and the
|
||||
* supporting analytics for all three
|
||||
* what next findings with one action each, then the raw event feed
|
||||
*
|
||||
* Operational widgets — quick actions, tasks, the AI briefing, top store/reward,
|
||||
* staff status — deliberately do NOT live here. The briefing endpoint they were
|
||||
* built on is still live and feeds Loyaly AI's AI tab, which is where that
|
||||
* class of content belongs: a panel you open to be told what to do, beside a
|
||||
* dashboard you read to work it out yourself.
|
||||
* The activity band is the change that makes this a Merchant OS rather than an
|
||||
* analytics page. Purchases are not the only thing that creates customer
|
||||
* value: a spin, a challenge, a referral or an event all produce engagement,
|
||||
* and every one of them is rendered here WITH its downstream chain — customers,
|
||||
* return visits, purchases — so no activity can be read as a vanity count.
|
||||
*
|
||||
* Recent activity keeps its compact form — six rows in a fixed box, full history
|
||||
* on /activity. The uncapped version reached 784px, taller than any chart on the
|
||||
* page, which is an operational log outweighing the analytics it sits among.
|
||||
* What did not change, on purpose: the sticky header, the store and range
|
||||
* controls, the KPI row, the Footfall and Revenue charts, the conversion pair,
|
||||
* peak hours, reward usage, the period rollup and store comparison. This is a
|
||||
* restructure of an already-sound page, not a rebuild — the new sections were
|
||||
* inserted at the points in the hierarchy where they answer the next question,
|
||||
* and the existing analytics moved below them because they are the evidence
|
||||
* for the activity story rather than the story itself.
|
||||
*
|
||||
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider, set
|
||||
* from this page's header. `series` is fetched once and shared by the four
|
||||
* charts drawn from it, so they cannot disagree.
|
||||
* Operational widgets — quick actions, tasks, the AI briefing — still do NOT
|
||||
* live here. "What needs your attention" is not that: it is four findings
|
||||
* computed from this page's own numbers, each with a single action, which is a
|
||||
* different thing from a to-do list a merchant has to maintain.
|
||||
*
|
||||
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider,
|
||||
* set from this page's header. `series` is fetched once and shared by the four
|
||||
* charts drawn from it, and the activity, journey, campaign and insight
|
||||
* fixtures are all derived from that same series server-side, so no two panels
|
||||
* on this page can report different numbers for the same fact.
|
||||
*/
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -66,6 +89,13 @@ export default function DashboardPage() {
|
||||
// dashboard, so Compare starts pressed and switches the panel off, rather
|
||||
// than hiding a panel that used to be there until someone finds the button.
|
||||
const [isComparing, setIsComparing] = useState(true);
|
||||
// Persisted, not component state: a merchant who wants the charts expanded
|
||||
// wants them expanded tomorrow too, and re-collapsing them on every visit is
|
||||
// the fastest way to make a disclosure control feel like an obstacle.
|
||||
const [isSupportingOpen, setSupportingOpen] = usePersistentFlag(
|
||||
'loyaly.dashboard.supporting-analytics',
|
||||
false,
|
||||
);
|
||||
|
||||
const kpis = useDashboardKpis();
|
||||
const series = useDashboardTimeseries();
|
||||
@@ -73,6 +103,10 @@ export default function DashboardPage() {
|
||||
const activity = useDashboardActivity();
|
||||
const rewards = useDashboardRewardUsage();
|
||||
const comparison = useDashboardStoreComparison();
|
||||
const activityMetrics = useActivityMetrics();
|
||||
const journey = useCustomerJourney();
|
||||
const campaigns = useCampaigns();
|
||||
const insights = useStoreInsights();
|
||||
|
||||
const isAllStores = storeId === 'all';
|
||||
const scopeLabel = useScopeLabel();
|
||||
@@ -92,7 +126,16 @@ export default function DashboardPage() {
|
||||
{/* 3 — headline numbers. */}
|
||||
<KpiRow resource={kpis} />
|
||||
|
||||
{/* 4 — primary analytics: the two series everything else explains. */}
|
||||
{/*
|
||||
4 — primary analytics: the two series everything else explains.
|
||||
|
||||
These two carry the brand hues and nothing else on the page does at
|
||||
chart scale. Footfall is cool because it is the system measuring the
|
||||
store; revenue is warm because it is the outcome the brand is for. One
|
||||
of each, on the two charts that matter most — that is the whole colour
|
||||
budget for analytics, and it is why the four charts further down stay
|
||||
monochrome.
|
||||
*/}
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
|
||||
{(d) => (
|
||||
@@ -101,7 +144,9 @@ export default function DashboardPage() {
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
series={[{key: 'visitors', label: 'Visitors'}]}
|
||||
series={[
|
||||
{key: 'visitors', label: 'Visitors', color: CHART.brand.cool},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
@@ -113,13 +158,57 @@ export default function DashboardPage() {
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatInrCompact}
|
||||
series={[{key: 'revenue', label: 'Revenue'}]}
|
||||
series={[
|
||||
{key: 'revenue', label: 'Revenue', color: CHART.brand.warmBar},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
{/* 5 — secondary analytics: the conversion story, in two readings. */}
|
||||
{/* 5 — what customers did, and what each activity was worth. Six of ten;
|
||||
the full ecosystem is on /activity. */}
|
||||
<CustomerActivity resource={activityMetrics} />
|
||||
|
||||
{/* 6 — where that engagement progresses, and where it stops. */}
|
||||
<CustomerJourney resource={journey} />
|
||||
|
||||
{/* 7 — which deliberate campaigns produced the movement above. */}
|
||||
<CampaignPerformance resource={campaigns} />
|
||||
|
||||
{/*
|
||||
8 — supporting analytics, COLLAPSED BY DEFAULT.
|
||||
|
||||
These six panels are preserved exactly as they were; what changed is
|
||||
their rank. Left expanded they run 1,724px on desktop and roughly
|
||||
3,000px on a phone, which put "What needs your attention" — the only
|
||||
section on the page a merchant can act on — at 81% of the scroll,
|
||||
behind four charts that explain rather than prompt. Evidence should be
|
||||
available on demand, not stand between the finding and the action.
|
||||
|
||||
Collapsed, not deleted, and the choice is remembered: a merchant who
|
||||
opens it once gets it open every visit thereafter. Nothing is
|
||||
unreachable and nothing was redesigned.
|
||||
*/}
|
||||
<Collapsible
|
||||
isOpen={isSupportingOpen}
|
||||
onOpenChange={setSupportingOpen}
|
||||
trigger={
|
||||
<VStack gap={0.5} hAlign="start">
|
||||
{/* Text, not Heading: Collapsible renders the trigger inside a
|
||||
<button>, and a heading in button content is invalid markup.
|
||||
The panels within carry their own h2s when expanded. */}
|
||||
<Text size="base" weight="semibold">
|
||||
Supporting analytics
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Conversion, peak hours, reward usage, the period rollup and store
|
||||
comparison
|
||||
</Text>
|
||||
</VStack>
|
||||
}
|
||||
>
|
||||
<VStack gap={5} className="pt-4">
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard
|
||||
title="Visitors vs purchases"
|
||||
@@ -160,7 +249,7 @@ export default function DashboardPage() {
|
||||
</ChartCard>
|
||||
</Grid>
|
||||
|
||||
{/* 6 — operational analytics: when traffic lands, what it redeems. */}
|
||||
{/* 9 — operational analytics: when traffic lands, what it redeems. */}
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard
|
||||
title="Peak hours"
|
||||
@@ -174,19 +263,25 @@ export default function DashboardPage() {
|
||||
<RewardUsageChart resource={rewards} />
|
||||
</Grid>
|
||||
|
||||
{/* 7 — period rollup. */}
|
||||
{/* 10 — period rollup. */}
|
||||
<PerformancePanel
|
||||
granularity={granularity}
|
||||
onGranularityChange={setGranularity}
|
||||
/>
|
||||
|
||||
{/* 8 — comparing stores is meaningless when scoped to one of them, which
|
||||
is why Compare is disabled rather than merely off in that case. */}
|
||||
{/* 11 — comparing stores is meaningless when scoped to one of them, which
|
||||
is why Compare is disabled rather than merely off in that case. */}
|
||||
{isAllStores && isComparing ? (
|
||||
<StoreComparisonPanel resource={comparison} />
|
||||
) : null}
|
||||
</VStack>
|
||||
</Collapsible>
|
||||
|
||||
{/* 9 — bounded feed: six rows, fixed box, full history on /activity. */}
|
||||
{/* 12 — what to do next. Last because it is the conclusion: every claim
|
||||
it makes is checkable against a panel above it. */}
|
||||
<StoreInsights resource={insights} />
|
||||
|
||||
{/* 13 — bounded feed: six rows, fixed box, full history on /activity. */}
|
||||
<ActivityTimeline
|
||||
resource={activity}
|
||||
subtitle="Latest events across the selected store and period"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {RewardGrid} from '@/features/lyts/components/RewardGrid';
|
||||
import {ExpiryAlerts} from '@/features/lyts/components/ExpiryAlerts';
|
||||
import {RewardPerformanceTable} from '@/features/lyts/components/RewardPerformanceTable';
|
||||
import {ActivityProgramme} from '@/features/lyts/components/ActivityProgramme';
|
||||
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
|
||||
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
|
||||
import {
|
||||
@@ -32,14 +33,23 @@ import {
|
||||
} from '@/shared/utils/format';
|
||||
|
||||
/**
|
||||
* The LYT programme.
|
||||
* The LYT programme — activities and the rewards they pay out in.
|
||||
*
|
||||
* Ordered by urgency rather than by data type: what's expiring, then the
|
||||
* headline numbers, then the catalogue, then the analysis. A merchant opening
|
||||
* this page is usually here because something needs extending or pausing.
|
||||
* headline numbers, then what customers can DO to earn, then the catalogue of
|
||||
* what they spend on, then the analysis. A merchant opening this page is
|
||||
* usually here because something needs extending or pausing.
|
||||
*
|
||||
* 1 LYT = ₹1, so "outstanding" figures are simultaneously a count and a
|
||||
* rupee liability — which is why they lead.
|
||||
*
|
||||
* ── The activity half ─────────────────────────────────────────────────────
|
||||
* Activities sit here, above rewards, because they are the earning side of
|
||||
* the same ledger: an activity issues LYTs, a reward spends them, and a
|
||||
* merchant tuning the programme is trading one against the other. The
|
||||
* dashboard shows six activities as a summary of engagement; this page is
|
||||
* where all ten are managed, and it reads the SAME activity model — see
|
||||
* ActivityProgramme.
|
||||
*/
|
||||
export default function LytsPage() {
|
||||
const rewards = useRewards();
|
||||
@@ -63,7 +73,7 @@ export default function LytsPage() {
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Lyts"
|
||||
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
|
||||
description="Customer activities and rewards — what earns LYTs, what spends them, and what is still outstanding. 1 LYT = ₹1."
|
||||
controls={<ScopeControls />}
|
||||
/>
|
||||
|
||||
@@ -136,6 +146,12 @@ export default function LytsPage() {
|
||||
}}
|
||||
</AsyncBoundary>
|
||||
|
||||
{/*
|
||||
The earning side, before the spending side. All ten activities, their
|
||||
status and their LYT cost — the same model the dashboard summarises.
|
||||
*/}
|
||||
<ActivityProgramme />
|
||||
|
||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||
<ChartCard
|
||||
title="Redemption"
|
||||
|
||||
17
src/app/api/dashboard/activity-metrics/route.ts
Normal file
17
src/app/api/dashboard/activity-metrics/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||
import {buildActivityMetrics} from '@/features/dashboard/mock/intelligence.mock';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireApiSession();
|
||||
if (denied) return denied;
|
||||
const q = parseQuery(req);
|
||||
const simulated = await simulate(q);
|
||||
if (simulated) return simulated;
|
||||
return ok(
|
||||
wantsEmpty(q) ? [] : buildActivityMetrics(q.range, q.storeId, q.nowMs),
|
||||
q,
|
||||
);
|
||||
}
|
||||
14
src/app/api/dashboard/campaigns/route.ts
Normal file
14
src/app/api/dashboard/campaigns/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||
import {buildCampaigns} from '@/features/dashboard/mock/intelligence.mock';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireApiSession();
|
||||
if (denied) return denied;
|
||||
const q = parseQuery(req);
|
||||
const simulated = await simulate(q);
|
||||
if (simulated) return simulated;
|
||||
return ok(wantsEmpty(q) ? [] : buildCampaigns(q.range, q.storeId, q.nowMs), q);
|
||||
}
|
||||
20
src/app/api/dashboard/insights/route.ts
Normal file
20
src/app/api/dashboard/insights/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||
import {buildInsights} from '@/features/dashboard/mock/intelligence.mock';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Separate from /briefing on purpose. The briefing is Loyaly AI's narrative
|
||||
* for the panel; these are store-intelligence findings computed from the
|
||||
* activity layer, and the dashboard must be able to render one without
|
||||
* pulling in the other.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireApiSession();
|
||||
if (denied) return denied;
|
||||
const q = parseQuery(req);
|
||||
const simulated = await simulate(q);
|
||||
if (simulated) return simulated;
|
||||
return ok(wantsEmpty(q) ? [] : buildInsights(q.range, q.storeId, q.nowMs), q);
|
||||
}
|
||||
14
src/app/api/dashboard/journey/route.ts
Normal file
14
src/app/api/dashboard/journey/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
|
||||
import {buildJourney} from '@/features/dashboard/mock/intelligence.mock';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireApiSession();
|
||||
if (denied) return denied;
|
||||
const q = parseQuery(req);
|
||||
const simulated = await simulate(q);
|
||||
if (simulated) return simulated;
|
||||
return ok(wantsEmpty(q) ? [] : buildJourney(q.range, q.storeId, q.nowMs), q);
|
||||
}
|
||||
@@ -19,6 +19,50 @@
|
||||
@import '@astryxdesign/core/tailwind-theme.css';
|
||||
@import 'tailwindcss/utilities.css' layer(utilities);
|
||||
|
||||
/*
|
||||
* The two-accent brand system.
|
||||
*
|
||||
* The workspace is monochrome by default and stays that way — see CLAUDE.md.
|
||||
* These are the ONLY two hues that are not semantic, and they exist because
|
||||
* store intelligence has two distinct voices to separate: what the brand gives
|
||||
* the customer (rewards, offers, the activities a merchant runs) and what the
|
||||
* system tells the merchant back (analytics, journeys, AI insight). Warm is
|
||||
* the first, cool is the second. Nothing else may introduce a hue.
|
||||
*
|
||||
* Declared here rather than in loyalyTheme.ts on purpose: these are NOT Astryx
|
||||
* tokens. Nothing in the design system reads them, no component variant
|
||||
* resolves through them, and putting them in the theme would let Badge/Banner
|
||||
* pick them up as if they were part of the semantic language.
|
||||
*
|
||||
* Each hue has three slots, because one hex cannot do three jobs:
|
||||
* base the mark itself — chart fill, dot, bar. Non-text, so 3:1 is the bar.
|
||||
* ink text and icons. #F4C430 on white is ~1.7:1, so light mode darkens
|
||||
* it to an amber that clears 4.5:1. Dark mode can use the raw hue.
|
||||
* soft a tint background for an icon chip. Never for a whole card.
|
||||
*
|
||||
* light-dark() rather than a media query: color-scheme is resolved on <html>
|
||||
* from the theme cookie, so these follow the same switch every Astryx token
|
||||
* does, including system mode.
|
||||
*/
|
||||
@theme {
|
||||
--color-brand-warm: light-dark(#f4c430, #f4c430);
|
||||
--color-brand-warm-ink: light-dark(#8a6300, #f4c430);
|
||||
--color-brand-warm-soft: light-dark(#f4c4302e, #f4c4301f);
|
||||
|
||||
/*
|
||||
* The bar-fill slot. Thirty solid #F4C430 bars across a white card is a lot
|
||||
* of saturated area — the chart shouted louder than the KPI row above it.
|
||||
* Same hue, less coverage: 72% over #FFFFFF lands on a soft gold that still
|
||||
* reads as Loyaly yellow at a glance. Dark keeps full strength, where alpha
|
||||
* over near-black would mute the hue into brown rather than soften it.
|
||||
*/
|
||||
--color-brand-warm-bar: light-dark(#f4c430b8, #f4c430);
|
||||
|
||||
--color-brand-cool: light-dark(#7c3aed, #a78bfa);
|
||||
--color-brand-cool-ink: light-dark(#6d28d9, #a78bfa);
|
||||
--color-brand-cool-soft: light-dark(#7c3aed1f, #a78bfa1f);
|
||||
}
|
||||
|
||||
/*
|
||||
* Paint the canvas before React mounts. AppShell sets these itself once it
|
||||
* renders, but this guarantees the very first frame — and any non-shell route
|
||||
@@ -48,6 +92,94 @@ html[data-theme='light'] {
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/*
|
||||
* The brand lockup, one file per theme.
|
||||
*
|
||||
* /white-logo.png is a WHITE wordmark. On the light theme's #FFFFFF sidebar
|
||||
* it was invisible — only the yellow heart survived, so the product had no
|
||||
* readable name in half its themes. /brand/loyaly-logo.png is the shipped
|
||||
* counterpart: same lockup, same yellow heart, dark wordmark. No new asset
|
||||
* and no CSS filter — the correct variant already existed and simply was
|
||||
* never wired up.
|
||||
*
|
||||
* BrandLogo renders BOTH and lets the cascade choose, because an <img src>
|
||||
* cannot be a light-dark() value and resolving it in React would need the
|
||||
* theme resolved on the server (mode may be the literal 'system').
|
||||
*
|
||||
* The three-way selector mirrors the color-scheme block above exactly:
|
||||
* bare rules are the light default, the media query handles system mode,
|
||||
* and an explicit data-theme wins over both. Anything less than all three
|
||||
* leaves system-mode users on the wrong mark.
|
||||
*
|
||||
* `display` is set HERE rather than with a `block` utility on the element:
|
||||
* utilities sit in a later layer and would beat `display: none`, so the
|
||||
* hidden variant would paint anyway.
|
||||
*/
|
||||
.brand-lockup-light {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-lockup-dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.brand-lockup-light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-lockup-dark {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='light'] .brand-lockup-light,
|
||||
html[data-theme='dark'] .brand-lockup-dark {
|
||||
display: block;
|
||||
}
|
||||
|
||||
html[data-theme='light'] .brand-lockup-dark,
|
||||
html[data-theme='dark'] .brand-lockup-light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Nested card surface — the activity tiles inside a panel.
|
||||
*
|
||||
* `Card variant="muted"` paints #F1F1F2 with NO border. On the dark theme
|
||||
* that is right: #202124 on a #171717 panel is a clean raised step. On the
|
||||
* light theme it is a grey block sitting on a white panel, with no edge —
|
||||
* which is exactly the "too grey, too flat" reading, and no amount of
|
||||
* spacing fixes it because the fill IS the separator.
|
||||
*
|
||||
* So light mode swaps the fill for the card white and separates with the
|
||||
* #E5E5E5 hairline instead. Same component, same token vocabulary; the
|
||||
* light theme gets contrast from an edge and the dark theme keeps getting
|
||||
* it from a fill, because that is what each background can actually carry.
|
||||
* Dark is byte-for-byte what it was.
|
||||
*/
|
||||
.card-nested {
|
||||
background-color: var(--color-background-card);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-nested {
|
||||
background-color: var(--color-background-muted);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='light'] .card-nested {
|
||||
background-color: var(--color-background-card);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .card-nested {
|
||||
background-color: var(--color-background-muted);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
* Global Data Table Inset Contract: 24px left lead-in, 32px right inset.
|
||||
*
|
||||
@@ -91,6 +223,21 @@ html[data-theme='light'] {
|
||||
stroke-width: 2.2px !important;
|
||||
}
|
||||
|
||||
/* Selected item contrast fix: ensure text & icons inside selected/active items remain high-contrast and legible */
|
||||
.astryx-item[aria-selected='true'],
|
||||
.astryx-item[aria-current='true'],
|
||||
.astryx-item[aria-current],
|
||||
.astryx-item.is-selected {
|
||||
color: var(--color-text-primary) !important;
|
||||
}
|
||||
|
||||
.astryx-item[aria-selected='true'] svg,
|
||||
.astryx-item[aria-current='true'] svg,
|
||||
.astryx-item[aria-current] svg,
|
||||
.astryx-item.is-selected svg {
|
||||
color: var(--color-text-primary) !important;
|
||||
}
|
||||
|
||||
/* Kill switch for our own CSS transitions. Framer Motion is handled by
|
||||
<MotionConfig reducedMotion="user">, recharts by useChartMotion(). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
146
src/features/dashboard/components/ActivityCard.tsx
Normal file
146
src/features/dashboard/components/ActivityCard.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {formatCompact, formatCount, formatLyt} from '@/shared/utils/format';
|
||||
import {ACTIVITY_ICON, impactChain} from '@/features/dashboard/services/activityService';
|
||||
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
|
||||
|
||||
/**
|
||||
* One activity, at a weight the dashboard can afford.
|
||||
*
|
||||
* DELIBERATELY QUIETER THAN A KPI CARD. It is `variant="muted"` inside a panel
|
||||
* rather than a white card on the page, the number is `display-3` against the
|
||||
* KPI row's own display-3 but in a card a third the height, and there is no
|
||||
* sparkline. Six activities rendered at KPI weight would out-shout the four
|
||||
* metrics that actually describe the business — the same reasoning that made
|
||||
* store comparison small multiples instead of five full charts.
|
||||
*
|
||||
* THE CHAIN IS NOT OPTIONAL. Every card ends with at least
|
||||
* "N customers → N purchases", because an activity count on its own is a
|
||||
* vanity metric: 724 spins is not a fact a merchant can do anything with until
|
||||
* they know what it was worth. `detail` opens the chain up to its full length
|
||||
* for the analytics page, where there is room for the middle of the story.
|
||||
*/
|
||||
/**
|
||||
* Status is a DOT, not a badge.
|
||||
*
|
||||
* Ten filled "Live" pills down a catalogue is a wall of green — the loudest
|
||||
* thing on a page whose subject is the numbers beside them, and a third hue
|
||||
* competing with the two the brand actually owns. An 8px dot carries the same
|
||||
* three states at a fraction of the ink, which is the project's own rule:
|
||||
* status → StatusDot, badge → counts and enumerated states.
|
||||
*/
|
||||
const STATUS: Record<
|
||||
ActivityMetric['status'],
|
||||
{label: string; variant: 'success' | 'warning' | 'neutral'}
|
||||
> = {
|
||||
live: {label: 'Live', variant: 'success'},
|
||||
paused: {label: 'Paused', variant: 'warning'},
|
||||
draft: {label: 'Draft', variant: 'neutral'},
|
||||
};
|
||||
|
||||
export function ActivityCard({
|
||||
metric,
|
||||
variant = 'summary',
|
||||
}: {
|
||||
metric: ActivityMetric;
|
||||
/**
|
||||
* `summary` — the dashboard's six. Count, delta, and the two ends of the
|
||||
* impact chain. No status: a summary of six live activities does not need
|
||||
* six "Live" badges competing with the numbers.
|
||||
*
|
||||
* `catalogue` — the Lyts ecosystem view. Adds what a merchant MANAGES
|
||||
* rather than reads: whether it is running, what it costs in LYTs, and the
|
||||
* full chain including reward claims and repeat visits.
|
||||
*/
|
||||
variant?: 'summary' | 'catalogue';
|
||||
}) {
|
||||
const accent = ACCENT[metric.accent];
|
||||
const isCatalogue = variant === 'catalogue';
|
||||
const chain = impactChain(metric, {compact: !isCatalogue});
|
||||
const status = STATUS[metric.status];
|
||||
|
||||
// `card-nested` keeps the dark theme's raised fill and gives the light theme
|
||||
// a white surface with a hairline edge instead — see globals.css.
|
||||
return (
|
||||
<Card variant="muted" className="card-nested">
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} vAlign="center" hAlign="between">
|
||||
<HStack gap={2} vAlign="center" className="min-w-0">
|
||||
{/*
|
||||
The only place the brand hue appears on this card. A tinted chip
|
||||
at 16px is enough to identify the activity at a glance; tinting
|
||||
the card itself is how a grid of six turns into a paint chart.
|
||||
*/}
|
||||
<HStack
|
||||
className={`${accent.soft} rounded-md p-1.5 shrink-0`}
|
||||
vAlign="center"
|
||||
>
|
||||
<Icon
|
||||
icon={ACTIVITY_ICON[metric.id]}
|
||||
size="sm"
|
||||
className={accent.ink}
|
||||
/>
|
||||
</HStack>
|
||||
<Text size="sm" weight="medium" className="truncate">
|
||||
{metric.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
{isCatalogue ? (
|
||||
<HStack gap={1.5} vAlign="center" className="shrink-0">
|
||||
<StatusDot variant={status.variant} label={status.label} />
|
||||
<Text size="xsm" color="secondary">
|
||||
{status.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<MetricDelta value={metric.deltaPct} size="xsm" />
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* Data, not a section title — Text rather than Heading, so a grid of
|
||||
activity counts stays out of the document outline. */}
|
||||
<HStack gap={2} vAlign="center" hAlign="between" wrap="wrap">
|
||||
<Text type="display-3">{formatCompact(metric.count)}</Text>
|
||||
{/* The catalogue traded its delta for the status badge above, so the
|
||||
trend comes back down here — a merchant deciding whether to keep
|
||||
an activity running needs both. */}
|
||||
{isCatalogue ? (
|
||||
<MetricDelta value={metric.deltaPct} size="xsm" />
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
{isCatalogue ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
{metric.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Text size="xsm" color="secondary">
|
||||
{chain
|
||||
.map((s) => `${formatCount(s.value)} ${s.label}`)
|
||||
.join(' → ')}
|
||||
</Text>
|
||||
|
||||
{/* The LYT link, and the one number on this card that is MEASURED
|
||||
rather than attributed — the ledger knows what it issued. Omitted
|
||||
where the activity grants nothing, rather than shown as zero. */}
|
||||
{isCatalogue && metric.lytsIssued > 0 ? (
|
||||
<HStack gap={1} vAlign="center">
|
||||
<Icon icon={ICONS.lyt} size="xsm" className={accent.ink} />
|
||||
<Text size="xsm" color="secondary">
|
||||
{formatLyt(metric.lytsIssued)} issued
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
68
src/features/dashboard/components/ActivityGroups.tsx
Normal file
68
src/features/dashboard/components/ActivityGroups.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ActivityCard} from './ActivityCard';
|
||||
import {AttributionNote} from './AttributionNote';
|
||||
import {ACTIVITY_GROUPS} from '@/features/dashboard/services/activityService';
|
||||
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* The complete ecosystem, grouped by the question each group answers.
|
||||
*
|
||||
* Ten activities laid out as one flat grid is a list of features. Split three
|
||||
* ways it becomes a diagnosis: engagement says what customers are doing,
|
||||
* growth says what is bringing people in, commerce says whether either turned
|
||||
* into business. A merchant with a flat conversion number and a rising
|
||||
* engagement number knows which of the three to look at.
|
||||
*
|
||||
* One resource, three panels. The panels share a single fetch, so the groups
|
||||
* can never disagree about a total, and a group with nothing in it renders its
|
||||
* own empty state rather than leaving a headed card with a void under it.
|
||||
*/
|
||||
export function ActivityGroups({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<ActivityMetric[]>;
|
||||
}) {
|
||||
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
{ACTIVITY_GROUPS.map((group) => (
|
||||
<PanelCard
|
||||
key={group.id}
|
||||
title={group.label}
|
||||
subtitle={group.question}
|
||||
actions={<AttributionNote basis={basis} />}
|
||||
resource={resource}
|
||||
loading={<SkeletonCardGrid count={4} height={128} minWidth={220} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="activityVisit"
|
||||
title={`No ${group.label.toLowerCase()} activity`}
|
||||
description="Activities appear here once customers start taking part."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(metrics) => (
|
||||
<Grid columns={{minWidth: 220, repeat: 'fit'}} gap={3}>
|
||||
{metrics
|
||||
.filter((m) => m.group === group.id)
|
||||
.map((m) => (
|
||||
// `catalogue` — this is the management view, so each card
|
||||
// adds what the dashboard summary has no room for: status,
|
||||
// LYTs issued, and the full impact chain.
|
||||
<ActivityCard key={m.id} metric={m} variant="catalogue" />
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</PanelCard>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
192
src/features/dashboard/components/ActivityImpactTable.tsx
Normal file
192
src/features/dashboard/components/ActivityImpactTable.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import {formatCount, formatInrCompact, formatPct} from '@/shared/utils/format';
|
||||
import {
|
||||
ACTIVITY_ICON,
|
||||
conversionPct,
|
||||
} from '@/features/dashboard/services/activityService';
|
||||
import {AttributionNote} from './AttributionNote';
|
||||
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* Every activity's chain, side by side.
|
||||
*
|
||||
* The cards above answer "how is Spin doing?". This answers the question they
|
||||
* cannot: "which activity is worth running?" — and that is a comparison across
|
||||
* rows, which is a table. Ten small charts would say the same thing in ten
|
||||
* times the space and still not let anyone rank the last column.
|
||||
*
|
||||
* Rows are ordered by the END of the chain, not the start. Sorting by
|
||||
* interactions puts Walk on top, which is the activity that converts worst;
|
||||
* sorting by attributed revenue puts the table in the order a merchant would
|
||||
* spend their next hour in.
|
||||
*/
|
||||
|
||||
/** A flattened row. The table generic needs an index signature; the domain
|
||||
* type deliberately does not have one. */
|
||||
interface ImpactRow extends Record<string, unknown> {
|
||||
id: string;
|
||||
label: string;
|
||||
accent: ActivityMetric['accent'];
|
||||
activityId: ActivityMetric['id'];
|
||||
count: number;
|
||||
purchases: number;
|
||||
conversion: number;
|
||||
revenueInr: number;
|
||||
}
|
||||
|
||||
function toRows(metrics: ActivityMetric[]): ImpactRow[] {
|
||||
return metrics
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.label,
|
||||
accent: m.accent,
|
||||
activityId: m.id,
|
||||
count: m.count,
|
||||
purchases: m.impact.purchases,
|
||||
conversion: conversionPct(m),
|
||||
revenueInr: m.impact.attributedRevenueInr,
|
||||
}))
|
||||
.sort((a, b) => b.revenueInr - a.revenueInr);
|
||||
}
|
||||
|
||||
const num = (v: number) => (
|
||||
<Text size="sm" className="tabular-nums">
|
||||
{formatCount(v)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const COLUMNS: TableColumn<ImpactRow>[] = [
|
||||
{
|
||||
key: 'label',
|
||||
header: 'Activity',
|
||||
width: proportional(1),
|
||||
// Label only. The one-line description belongs on the cards above, where
|
||||
// there is width for it; in a cell 120px wide it wrapped to four lines and
|
||||
// tripled the height of every row in a table whose whole job is to be
|
||||
// scanned down a column.
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Icon
|
||||
icon={ACTIVITY_ICON[row.activityId]}
|
||||
size="sm"
|
||||
className={ACCENT[row.accent].ink}
|
||||
/>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'count',
|
||||
// "Count", not "Interactions". Astryx table headers are nowrap + ellipsis,
|
||||
// so a header longer than its column is silently truncated to "Interacti…"
|
||||
// at EVERY width, because these columns are fixed px. The subtitle already
|
||||
// says what is being counted; the header only has to say which column it
|
||||
// is, and a short one lets the whole set fit the 525px the panel gives it
|
||||
// with Loyaly AI open.
|
||||
header: 'Count',
|
||||
align: 'end',
|
||||
// No delta chip. It cost ~55px, and with Loyaly AI open the content column
|
||||
// is ~625px — enough width that the LAST column, the attributed revenue
|
||||
// this table is sorted by, fell off the edge behind an internal scrollbar.
|
||||
// The trend is already on every card above; the ranking is only here.
|
||||
width: pixel(80),
|
||||
renderCell: (row) => num(row.count),
|
||||
},
|
||||
/*
|
||||
* FIVE columns, and the number is measured rather than chosen. Inside a
|
||||
* panel card with Loyaly AI open, the table gets 525px: 24px card padding
|
||||
* each side, plus the 24/32px cell inset contract from globals.css. Six
|
||||
* columns needed 580 and pushed `Attributed` — the column the table is
|
||||
* SORTED by — behind an internal scrollbar, which makes the ranking
|
||||
* invisible at exactly the width most merchants use.
|
||||
*
|
||||
* So the two intermediate chain steps are dropped here: customers and
|
||||
* repeat visits are on every card above and in the phone fallback. What
|
||||
* survives is what ranking needs — how much happened, what it produced,
|
||||
* how efficiently, and what it was worth.
|
||||
*/
|
||||
{
|
||||
key: 'purchases',
|
||||
header: 'Purchases',
|
||||
align: 'end',
|
||||
width: pixel(105),
|
||||
renderCell: (row) => num(row.purchases),
|
||||
},
|
||||
{
|
||||
key: 'conversion',
|
||||
header: 'Converted',
|
||||
align: 'end',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium" className="tabular-nums">
|
||||
{formatPct(row.conversion, 0)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'revenueInr',
|
||||
// "Revenue" would be a claim this data cannot support. The column ranks
|
||||
// the table, so it is the one header that most needs to be honest.
|
||||
header: 'Attributed',
|
||||
align: 'end',
|
||||
width: pixel(112),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="semibold" className="tabular-nums">
|
||||
{formatInrCompact(row.revenueInr)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function ActivityImpactTable({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<ActivityMetric[]>;
|
||||
}) {
|
||||
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Activity impact"
|
||||
subtitle="Interactions through to estimated attributed revenue, highest first"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={6} height={44} />}
|
||||
actions={<AttributionNote basis={basis} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="analytics"
|
||||
title="Nothing to compare yet"
|
||||
description="Once two or more activities have run, their conversion can be ranked here."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(metrics) => (
|
||||
<ResponsiveTable
|
||||
data={toRows(metrics)}
|
||||
columns={COLUMNS}
|
||||
idKey="id"
|
||||
primaryKey="label"
|
||||
density="balanced"
|
||||
// Conversion earns its place on the phone card: it is the one field
|
||||
// that ranks activities against each other, which is the whole
|
||||
// reason this table exists. Repeat visits is the one dropped.
|
||||
summaryKeys={['count', 'purchases', 'conversion', 'revenueInr']}
|
||||
/>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
44
src/features/dashboard/components/AttributionNote.tsx
Normal file
44
src/features/dashboard/components/AttributionNote.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Tooltip} from '@astryxdesign/core/Tooltip';
|
||||
import {ATTRIBUTION_NOTE} from '@/features/dashboard/types/intelligence';
|
||||
import type {AttributionBasis} from '@/features/dashboard/types/intelligence';
|
||||
|
||||
/**
|
||||
* The disclosure that has to sit beside any attributed figure.
|
||||
*
|
||||
* Every downstream number in the activity chain — repeat visits, purchases,
|
||||
* revenue — is MODELLED from observed footfall and purchase patterns. None of
|
||||
* it joins a till receipt to a specific spin or selfie. Rendering ₹6.5L next
|
||||
* to "Selfie" without saying so invites a merchant to read it as "selfies
|
||||
* earned me ₹6.5L", make a spend decision on it, and lose trust in the whole
|
||||
* product when the till disagrees.
|
||||
*
|
||||
* So the panels that show attributed figures carry this mark, and the copy
|
||||
* around them says "attributed", never "generated" or "earned".
|
||||
*
|
||||
* It renders NOTHING when the basis is `'observed'`. That is the point of the
|
||||
* flag: when a backend arrives that can join purchases to activity events, the
|
||||
* disclosure retires itself with no copy edit and no component removal.
|
||||
*
|
||||
* A quiet secondary glyph, not a warning — this is a footnote about method,
|
||||
* and an amber icon would rank it above the data it annotates.
|
||||
*/
|
||||
export function AttributionNote({basis}: {basis: AttributionBasis}) {
|
||||
if (basis === 'observed') return null;
|
||||
|
||||
return (
|
||||
<Tooltip content={ATTRIBUTION_NOTE}>
|
||||
{/*
|
||||
Focusable, so the note is reachable by keyboard and not only by hover —
|
||||
it is the only place the estimation is explained, which makes it
|
||||
content rather than decoration.
|
||||
*/}
|
||||
<HStack tabIndex={0} vAlign="center" className="rounded-sm">
|
||||
<Icon icon="info" size="sm" color="secondary" label="How this is calculated" />
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
129
src/features/dashboard/components/CampaignPerformance.tsx
Normal file
129
src/features/dashboard/components/CampaignPerformance.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import {formatCount, formatInrCompact} from '@/shared/utils/format';
|
||||
import {ACTIVITY_ICON} from '@/features/dashboard/services/activityService';
|
||||
import {AttributionNote} from './AttributionNote';
|
||||
import type {
|
||||
CampaignStatus,
|
||||
CampaignSummary,
|
||||
} from '@/features/dashboard/types/intelligence';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* A campaign is a funnel with a name, so each one is a ROW, not a chart.
|
||||
*
|
||||
* The temptation here is a second analytics module — participation over time,
|
||||
* a conversion gauge per campaign, a comparison bar. That is four more charts
|
||||
* for a question with one shape: how many took part, how many came back, how
|
||||
* many bought, what it earned. Four numbers fit on a row, and four rows fit in
|
||||
* the space one chart would have taken.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `live` is the only status a merchant can still act on, so it is the only one
|
||||
* that gets a filled badge. Ended and scheduled are stated, not highlighted.
|
||||
*/
|
||||
const STATUS: Record<
|
||||
CampaignStatus,
|
||||
{label: string; variant: 'success' | 'neutral'}
|
||||
> = {
|
||||
live: {label: 'Live', variant: 'success'},
|
||||
ended: {label: 'Ended', variant: 'neutral'},
|
||||
scheduled: {label: 'Scheduled', variant: 'neutral'},
|
||||
};
|
||||
|
||||
function CampaignRow({campaign}: {campaign: CampaignSummary}) {
|
||||
const accent = ACCENT[campaign.accent];
|
||||
const status = STATUS[campaign.status];
|
||||
|
||||
return (
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="start"
|
||||
paddingBlock={2}
|
||||
paddingInline={2}
|
||||
className="rounded-md transition-colors hover:bg-overlay-hover"
|
||||
>
|
||||
<HStack className={`${accent.soft} rounded-md p-1.5 shrink-0`} vAlign="center">
|
||||
<Icon
|
||||
icon={ACTIVITY_ICON[campaign.activityId]}
|
||||
size="sm"
|
||||
className={accent.ink}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={1} width="100%">
|
||||
<HStack gap={3} hAlign="between" vAlign="center" wrap="wrap">
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text size="sm" weight="medium">
|
||||
{campaign.name}
|
||||
</Text>
|
||||
<Badge variant={status.variant} label={status.label} />
|
||||
</HStack>
|
||||
{/*
|
||||
The row's outcome, at the end where the eye lands last — the same
|
||||
place a receipt puts it. And that is exactly why it cannot be a
|
||||
bare rupee figure: "Weekend Challenge … ₹5L" reads as money the
|
||||
campaign earned. It is money attributed to it, so the word rides
|
||||
with the number rather than living only in a tooltip.
|
||||
*/}
|
||||
<HStack gap={1} vAlign="center">
|
||||
<Text size="sm" weight="semibold">
|
||||
{formatInrCompact(campaign.attributedRevenueInr)}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{campaign.attribution === 'estimated' ? 'attributed (est.)' : 'attributed'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Text size="sm" color="secondary">
|
||||
{campaign.steps
|
||||
.map((s) => `${formatCount(s.value)} ${s.label.toLowerCase()}`)
|
||||
.join(' → ')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function CampaignPerformance({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<CampaignSummary[]>;
|
||||
}) {
|
||||
const basis = resource.data?.[0]?.attribution ?? 'estimated';
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Campaign performance"
|
||||
subtitle="Participants through to attributed revenue, per campaign"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={4} height={52} />}
|
||||
actions={<AttributionNote basis={basis} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="campaign"
|
||||
title="No campaigns running"
|
||||
description="Challenges, referral drives and events show their funnel here once they go live."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(campaigns) => (
|
||||
<VStack gap={0}>
|
||||
{campaigns.map((c) => (
|
||||
<CampaignRow key={c.id} campaign={c} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
80
src/features/dashboard/components/CustomerActivity.tsx
Normal file
80
src/features/dashboard/components/CustomerActivity.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ActivityCard} from './ActivityCard';
|
||||
import {AttributionNote} from './AttributionNote';
|
||||
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* The dashboard's activity summary — six of the ten, and no more.
|
||||
*
|
||||
* The full ecosystem is ten activities. Rendering all ten here, at equal
|
||||
* weight, is how a dashboard becomes a legend: the merchant is asked to hold
|
||||
* ten categories in their head before they have learned what any single one is
|
||||
* worth. So the payload carries all ten, this panel filters to the featured
|
||||
* six, and "View all activity" leads to the page that has room for the rest.
|
||||
*
|
||||
* One panel, not six cards on the page. The section reads as a single band of
|
||||
* secondary information sitting under the primary metrics and the two headline
|
||||
* charts, which is exactly its rank in the hierarchy.
|
||||
*/
|
||||
export function CustomerActivity({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<ActivityMetric[]>;
|
||||
}) {
|
||||
// Read off the payload rather than hardcoded, so the disclosure retires
|
||||
// itself the day a backend returns 'observed'.
|
||||
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Customer activity"
|
||||
subtitle="What customers did, and what it is estimated to have been worth"
|
||||
resource={resource}
|
||||
loading={<SkeletonCardGrid count={6} height={116} minWidth={200} />}
|
||||
actions={
|
||||
<>
|
||||
{/* Every figure after "customers" on these cards is attributed, not
|
||||
measured. The note says so once for the whole panel. */}
|
||||
<AttributionNote basis={basis} />
|
||||
{/*
|
||||
/lyts, not /activity. The full ecosystem is the Lyts page's job —
|
||||
all ten activities with their status and LYT cost — and /activity
|
||||
is the raw event log, which is a different question. Sending "view
|
||||
all" to the log would answer "what happened at 14:32" when the
|
||||
merchant asked "what else can customers do".
|
||||
*/}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
label="View all activity"
|
||||
href="/lyts"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="activityVisit"
|
||||
title="No activity recorded"
|
||||
description="Visits, spins, challenges and referrals appear here once customers start taking part."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(metrics) => (
|
||||
<Grid columns={{minWidth: 200, repeat: 'fit'}} gap={3}>
|
||||
{metrics
|
||||
.filter((m) => m.isFeatured)
|
||||
.map((m) => (
|
||||
<ActivityCard key={m.id} metric={m} />
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
114
src/features/dashboard/components/CustomerJourney.tsx
Normal file
114
src/features/dashboard/components/CustomerJourney.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import {Fragment} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import {formatCompact, formatPct} from '@/shared/utils/format';
|
||||
import type {JourneyStage} from '@/features/dashboard/types/intelligence';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* Visit → Engage → Purchase → Return → Refer, as five numbers and four arrows.
|
||||
*
|
||||
* NOT a Sankey, and not a node graph. A funnel of five stages carries exactly
|
||||
* five numbers and four ratios; a flow diagram spends 400px of vertical space
|
||||
* and a chart library rendering the same nine facts, and the merchant still
|
||||
* has to read the labels to know which band is which. The horizontal strip
|
||||
* says it in one line.
|
||||
*
|
||||
* The panel's actual job is the WEAKEST link, so it is called out rather than
|
||||
* left to be spotted: the stage with the lowest conversion from its
|
||||
* predecessor gets the badge. Everything else on this strip is context for
|
||||
* that one finding.
|
||||
*/
|
||||
export function CustomerJourney({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<JourneyStage[]>;
|
||||
}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title="Customer journey"
|
||||
subtitle="Where customers progress, and where they stop"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={2} height={56} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="journey"
|
||||
title="No journey to plot"
|
||||
description="Once visits and purchases are recorded the progression appears here."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(stages) => {
|
||||
// The weakest step, measured against the stage before it. The first
|
||||
// stage has nothing to convert from and can never be the answer.
|
||||
const weakest = stages.reduce<JourneyStage | undefined>(
|
||||
(worst, s) =>
|
||||
s.conversionPct === undefined
|
||||
? worst
|
||||
: worst?.conversionPct === undefined ||
|
||||
s.conversionPct < worst.conversionPct
|
||||
? s
|
||||
: worst,
|
||||
undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<HStack gap={4} vAlign="start" wrap="wrap">
|
||||
{stages.map((stage, i) => (
|
||||
<Fragment key={stage.id}>
|
||||
{i === 0 ? null : (
|
||||
// Decorative: the reading order already carries the
|
||||
// progression, so the arrow is not announced.
|
||||
//
|
||||
// Hidden below `sm`, where the strip wraps to two per row and
|
||||
// a connector ends up pointing at the start of a line or at
|
||||
// the stage above it — an arrow that lies about the order is
|
||||
// worse than no arrow, and stacked cards already read as a
|
||||
// sequence.
|
||||
<HStack className="hidden pt-6 sm:flex" vAlign="center">
|
||||
<Icon
|
||||
icon={ICONS.arrowRight}
|
||||
size="sm"
|
||||
className={ACCENT.cool.ink}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<VStack gap={1}>
|
||||
<Text size="sm" color="secondary">
|
||||
{stage.label}
|
||||
</Text>
|
||||
<Text type="display-3">{formatCompact(stage.value)}</Text>
|
||||
|
||||
{stage.conversionPct === undefined ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
Everyone starts here
|
||||
</Text>
|
||||
) : stage.id === weakest?.id ? (
|
||||
<Badge
|
||||
variant="warning"
|
||||
label={`${formatPct(stage.conversionPct, 0)} · biggest drop-off`}
|
||||
/>
|
||||
) : (
|
||||
<Text size="xsm" color="secondary">
|
||||
{formatPct(stage.conversionPct, 0)} of previous
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Fragment>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
108
src/features/dashboard/components/StoreInsights.tsx
Normal file
108
src/features/dashboard/components/StoreInsights.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {ACCENT} from '@/shared/utils/accent';
|
||||
import type {Insight, InsightSeverity} from '@/features/dashboard/types/dashboard';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import type {IconColor} from '@astryxdesign/core/Icon';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* What needs your attention — findings, not a chat box.
|
||||
*
|
||||
* The thing that makes an AI panel get ignored is a paragraph of narration
|
||||
* with nothing at the end of it. So the contract here is strict: every row
|
||||
* states a number the merchant can check against the panels above, says what
|
||||
* it means, and ends in ONE action. An insight with no action does not belong
|
||||
* on this panel; it belongs in whichever chart already shows it.
|
||||
*
|
||||
* Severity earns the only colour on the row. A warning is amber because a
|
||||
* warning is one of the three semantic states; everything else is a cool-tinted
|
||||
* bulb, which is this product's mark for "the system worked this out" and is
|
||||
* the same hue the analytics carry.
|
||||
*/
|
||||
const SEVERITY: Record<
|
||||
InsightSeverity,
|
||||
{icon: IconKey; color?: IconColor; className?: string}
|
||||
> = {
|
||||
error: {icon: 'alert', color: 'error'},
|
||||
warning: {icon: 'alert', color: 'warning'},
|
||||
success: {icon: 'leaderboard', color: 'success'},
|
||||
info: {icon: 'insight', className: ACCENT.cool.ink},
|
||||
};
|
||||
|
||||
function InsightRow({insight}: {insight: Insight}) {
|
||||
const tone = SEVERITY[insight.severity];
|
||||
|
||||
return (
|
||||
<HStack
|
||||
gap={3}
|
||||
vAlign="start"
|
||||
paddingBlock={2}
|
||||
paddingInline={2}
|
||||
className="rounded-md transition-colors hover:bg-overlay-hover"
|
||||
>
|
||||
<HStack paddingBlock={0.5}>
|
||||
<Icon
|
||||
icon={ICONS[tone.icon]}
|
||||
size="sm"
|
||||
color={tone.color}
|
||||
className={tone.className}
|
||||
label={insight.severity}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={1} width="100%">
|
||||
<Text size="sm" weight="medium">
|
||||
{insight.title}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{insight.body}
|
||||
</Text>
|
||||
{insight.action ? (
|
||||
<HStack>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label={insight.action.label}
|
||||
href={insight.action.href}
|
||||
/>
|
||||
</HStack>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoreInsights({resource}: {resource: Resource<Insight[]>}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title="What needs your attention"
|
||||
subtitle="Read from this period's activity, most urgent first"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={3} height={72} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="insight"
|
||||
title="Nothing needs attention"
|
||||
description="Findings appear here when an activity's performance moves enough to act on."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(insights) => (
|
||||
<VStack gap={0}>
|
||||
{insights.map((i) => (
|
||||
<InsightRow key={i.id} insight={i} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
@@ -56,3 +56,34 @@ export function useDashboardPerformance(granularity: Granularity) {
|
||||
export function useDashboardBriefing() {
|
||||
return useResource(dashboardRepository.briefing(useScope()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store intelligence — the activity layer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* All ten activities, always. The dashboard filters to `isFeatured` in the
|
||||
* component rather than asking the server for a subset: the same request then
|
||||
* serves the summary and the full Activity Analytics page, so the two cannot
|
||||
* report different counts for Spin, and moving an activity into or out of the
|
||||
* summary is a fixture change rather than an API change.
|
||||
*/
|
||||
export function useActivityMetrics(scope?: Scope) {
|
||||
const active = useScope();
|
||||
return useResource(dashboardRepository.activityMetrics(scope ?? active));
|
||||
}
|
||||
|
||||
export function useCustomerJourney(scope?: Scope) {
|
||||
const active = useScope();
|
||||
return useResource(dashboardRepository.journey(scope ?? active));
|
||||
}
|
||||
|
||||
export function useCampaigns(scope?: Scope) {
|
||||
const active = useScope();
|
||||
return useResource(dashboardRepository.campaigns(scope ?? active));
|
||||
}
|
||||
|
||||
export function useStoreInsights(scope?: Scope) {
|
||||
const active = useScope();
|
||||
return useResource(dashboardRepository.insights(scope ?? active));
|
||||
}
|
||||
|
||||
491
src/features/dashboard/mock/intelligence.mock.ts
Normal file
491
src/features/dashboard/mock/intelligence.mock.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import type {Insight} from '@/features/dashboard/types/dashboard';
|
||||
import type {
|
||||
ActivityId,
|
||||
ActivityMetric,
|
||||
CampaignSummary,
|
||||
JourneyStage,
|
||||
} from '@/features/dashboard/types/intelligence';
|
||||
import type {RangeKey} from '@/shared/types/api';
|
||||
import {createRng} from '@/shared/mock/rng';
|
||||
import {buildTimeseries} from './dashboard.mock';
|
||||
|
||||
/**
|
||||
* Activity fixtures, DERIVED rather than invented.
|
||||
*
|
||||
* Every number here is anchored to buildTimeseries() — the same generator the
|
||||
* Footfall and Revenue charts read. That is not tidiness: an activity layer
|
||||
* generated independently would let the dashboard claim 1,248 visitors in one
|
||||
* card and 3,400 visits in the card directly below it, and no merchant would
|
||||
* trust either number again. Activity counts are a share of real visitors,
|
||||
* journey stages are the real funnel, and campaign rows are slices of the
|
||||
* activity they run on.
|
||||
*
|
||||
* The chain is also enforced by construction: customers ≤ interactions,
|
||||
* repeat visits ≤ customers, purchases ≤ repeat visits. A fixture that can
|
||||
* produce more purchases than participants would make the funnel UI render
|
||||
* a widening cone, and the bug would look like a design problem.
|
||||
*/
|
||||
|
||||
interface ActivitySeed {
|
||||
id: ActivityId;
|
||||
label: string;
|
||||
description: string;
|
||||
group: ActivityMetric['group'];
|
||||
accent: ActivityMetric['accent'];
|
||||
isFeatured: boolean;
|
||||
/** Interactions per visitor. Above 1 means a visitor does it more than once. */
|
||||
perVisitor: number;
|
||||
/** Interactions per distinct customer — the repeat rate within the activity. */
|
||||
intensity: number;
|
||||
/** Fraction of participants who come back afterwards. */
|
||||
returnRate: number;
|
||||
/** Fraction of returning participants who then buy. */
|
||||
buyRate: number;
|
||||
/** Whether taking part can grant a reward. */
|
||||
hasRewards: boolean;
|
||||
/** LYTs granted per participating customer, on average. 0 where none. */
|
||||
lytsPerCustomer: number;
|
||||
status: ActivityMetric['status'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The ecosystem, in reading order.
|
||||
*
|
||||
* Accents alternate deliberately and are fixed per activity: warm is what the
|
||||
* brand gives the customer, cool is what the system measures back. Two hues
|
||||
* across ten rows is what keeps this from becoming a colour-coded legend
|
||||
* nobody can hold in their head.
|
||||
*
|
||||
* `isFeatured` is what the dashboard shows. Six is the ceiling — the summary
|
||||
* exists to be glanced at, and ten small cards is a second dashboard.
|
||||
*/
|
||||
const CATALOG: ActivitySeed[] = [
|
||||
{
|
||||
id: 'walk',
|
||||
label: 'Walk',
|
||||
description: 'Passers-by detected near the store',
|
||||
group: 'engagement',
|
||||
accent: 'warm',
|
||||
isFeatured: false,
|
||||
perVisitor: 1.9,
|
||||
intensity: 1.4,
|
||||
returnRate: 0.11,
|
||||
buyRate: 0.34,
|
||||
hasRewards: false,
|
||||
lytsPerCustomer: 0,
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
id: 'visit',
|
||||
label: 'Visit',
|
||||
description: 'Customers who checked in at the store',
|
||||
group: 'engagement',
|
||||
accent: 'warm',
|
||||
isFeatured: true,
|
||||
perVisitor: 1.0,
|
||||
intensity: 1.7,
|
||||
returnRate: 0.31,
|
||||
buyRate: 0.52,
|
||||
hasRewards: false,
|
||||
lytsPerCustomer: 12,
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
id: 'selfie',
|
||||
label: 'Selfie',
|
||||
description: 'In-store photos shared to the feed',
|
||||
group: 'engagement',
|
||||
accent: 'cool',
|
||||
isFeatured: true,
|
||||
perVisitor: 0.3,
|
||||
intensity: 1.8,
|
||||
returnRate: 0.32,
|
||||
buyRate: 0.47,
|
||||
hasRewards: true,
|
||||
lytsPerCustomer: 25,
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
id: 'spin',
|
||||
label: 'Spin',
|
||||
description: 'Reward wheel plays',
|
||||
group: 'engagement',
|
||||
accent: 'warm',
|
||||
isFeatured: true,
|
||||
perVisitor: 0.58,
|
||||
intensity: 1.76,
|
||||
returnRate: 0.23,
|
||||
buyRate: 0.43,
|
||||
hasRewards: true,
|
||||
lytsPerCustomer: 40,
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
id: 'scratch',
|
||||
label: 'Scratch',
|
||||
description: 'Scratch cards opened',
|
||||
group: 'engagement',
|
||||
accent: 'cool',
|
||||
isFeatured: false,
|
||||
perVisitor: 0.36,
|
||||
intensity: 1.5,
|
||||
returnRate: 0.19,
|
||||
buyRate: 0.38,
|
||||
hasRewards: true,
|
||||
lytsPerCustomer: 30,
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
id: 'challenge',
|
||||
label: 'Challenge',
|
||||
description: 'Multi-step tasks completed',
|
||||
group: 'engagement',
|
||||
accent: 'cool',
|
||||
isFeatured: true,
|
||||
perVisitor: 0.17,
|
||||
intensity: 1.2,
|
||||
returnRate: 0.48,
|
||||
buyRate: 0.55,
|
||||
hasRewards: true,
|
||||
lytsPerCustomer: 75,
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
id: 'friend',
|
||||
label: 'Referral',
|
||||
description: 'Friends invited by existing customers',
|
||||
group: 'growth',
|
||||
accent: 'warm',
|
||||
isFeatured: true,
|
||||
perVisitor: 0.07,
|
||||
intensity: 1.1,
|
||||
returnRate: 0.42,
|
||||
buyRate: 0.58,
|
||||
hasRewards: true,
|
||||
lytsPerCustomer: 120,
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
id: 'event',
|
||||
label: 'Event',
|
||||
description: 'Attendance at in-store events',
|
||||
group: 'growth',
|
||||
accent: 'cool',
|
||||
isFeatured: true,
|
||||
perVisitor: 0.11,
|
||||
intensity: 1.05,
|
||||
returnRate: 0.37,
|
||||
buyRate: 0.62,
|
||||
hasRewards: false,
|
||||
lytsPerCustomer: 50,
|
||||
status: 'paused',
|
||||
},
|
||||
{
|
||||
id: 'brand',
|
||||
label: 'Brand campaign',
|
||||
description: 'Reach from partner and brand pushes',
|
||||
group: 'growth',
|
||||
accent: 'warm',
|
||||
isFeatured: false,
|
||||
perVisitor: 0.24,
|
||||
intensity: 1.3,
|
||||
returnRate: 0.16,
|
||||
buyRate: 0.29,
|
||||
hasRewards: true,
|
||||
lytsPerCustomer: 20,
|
||||
status: 'draft',
|
||||
},
|
||||
{
|
||||
id: 'shop',
|
||||
label: 'Shop',
|
||||
description: 'Catalogue browsing that ended in a basket',
|
||||
group: 'commerce',
|
||||
accent: 'cool',
|
||||
isFeatured: false,
|
||||
perVisitor: 0.26,
|
||||
intensity: 1.15,
|
||||
returnRate: 0.35,
|
||||
buyRate: 0.71,
|
||||
hasRewards: false,
|
||||
lytsPerCustomer: 0,
|
||||
status: 'live',
|
||||
},
|
||||
];
|
||||
|
||||
function totalVisitors(range: RangeKey, storeId: string, endMs: number): number {
|
||||
return buildTimeseries(range, storeId, endMs).reduce(
|
||||
(a, p) => a + p.visitors,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function totalPurchases(range: RangeKey, storeId: string, endMs: number): number {
|
||||
return buildTimeseries(range, storeId, endMs).reduce(
|
||||
(a, p) => a + p.purchases,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One activity's numbers for a scope.
|
||||
*
|
||||
* Every step is clamped to at least 1 below its parent once the parent is
|
||||
* non-trivial, so the funnel narrows even for a quiet store where rounding
|
||||
* would otherwise collapse two stages onto the same value and make a 100%
|
||||
* conversion appear out of nowhere.
|
||||
*/
|
||||
function buildMetric(
|
||||
seed: ActivitySeed,
|
||||
visitors: number,
|
||||
range: RangeKey,
|
||||
storeId: string,
|
||||
): ActivityMetric {
|
||||
const rng = createRng('activity-metric', seed.id, storeId, range);
|
||||
const jitter = rng.float(0.88, 1.12);
|
||||
|
||||
const count = Math.max(1, Math.round(visitors * seed.perVisitor * jitter));
|
||||
const customers = Math.max(1, Math.round(count / seed.intensity));
|
||||
const repeatVisits = Math.round(customers * seed.returnRate);
|
||||
const purchases = Math.round(repeatVisits * seed.buyRate);
|
||||
const attributedRevenueInr = Math.round(purchases * rng.float(310, 540));
|
||||
|
||||
return {
|
||||
id: seed.id,
|
||||
label: seed.label,
|
||||
description: seed.description,
|
||||
group: seed.group,
|
||||
accent: seed.accent,
|
||||
count,
|
||||
status: seed.status,
|
||||
// Issued per PARTICIPATING CUSTOMER, not per interaction: a customer who
|
||||
// spins six times is not granted six rewards, and multiplying by `count`
|
||||
// would inflate the liability by the intensity factor on every activity.
|
||||
lytsIssued: Math.round(customers * seed.lytsPerCustomer),
|
||||
// Compared against the previous period of equal length, same convention as
|
||||
// the KPI deltas. Drawn rather than recomputed: the previous window's
|
||||
// activity split is not something the timeseries carries.
|
||||
deltaPct: Number(rng.float(-9, 24).toFixed(1)),
|
||||
impact: {
|
||||
customers,
|
||||
rewardClaims: seed.hasRewards
|
||||
? Math.round(customers * rng.float(0.36, 0.62))
|
||||
: undefined,
|
||||
repeatVisits,
|
||||
purchases,
|
||||
attributedRevenueInr,
|
||||
// Modelled, and says so. The whole chain above is derived from observed
|
||||
// footfall through fixed rates — no purchase here is joined to a
|
||||
// specific activity event, and the UI reads this field to disclose that.
|
||||
attribution: 'estimated',
|
||||
},
|
||||
isFeatured: seed.isFeatured,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildActivityMetrics(
|
||||
range: RangeKey,
|
||||
storeId: string,
|
||||
endMs: number,
|
||||
): ActivityMetric[] {
|
||||
const visitors = totalVisitors(range, storeId, endMs);
|
||||
return CATALOG.map((seed) => buildMetric(seed, visitors, range, storeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* The five-stage progression, built from the funnel that already exists.
|
||||
*
|
||||
* Visit and Purchase are NOT invented — they are the same visitor and purchase
|
||||
* totals the KPI row shows, so the first and third stage of the journey and
|
||||
* the first two KPI cards can never disagree. Engage and Return come from the
|
||||
* activity layer, Refer is the referral activity's own count.
|
||||
*/
|
||||
export function buildJourney(
|
||||
range: RangeKey,
|
||||
storeId: string,
|
||||
endMs: number,
|
||||
): JourneyStage[] {
|
||||
const visitors = totalVisitors(range, storeId, endMs);
|
||||
const purchases = totalPurchases(range, storeId, endMs);
|
||||
const metrics = buildActivityMetrics(range, storeId, endMs);
|
||||
const engaged = metrics
|
||||
.filter((m) => m.group === 'engagement' && m.id !== 'walk')
|
||||
.reduce((a, m) => Math.max(a, m.impact.customers), 0);
|
||||
|
||||
const referrals = metrics.find((m) => m.id === 'friend')?.count ?? 0;
|
||||
const returned = Math.round(purchases * 0.69);
|
||||
|
||||
const raw: {id: JourneyStage['id']; label: string; value: number}[] = [
|
||||
{id: 'visit', label: 'Visit', value: visitors},
|
||||
// Engagement cannot exceed footfall, and on a quiet store the strongest
|
||||
// single activity can round above it.
|
||||
{id: 'engage', label: 'Engage', value: Math.min(engaged, visitors)},
|
||||
{id: 'purchase', label: 'Purchase', value: purchases},
|
||||
{id: 'return', label: 'Return', value: returned},
|
||||
{id: 'refer', label: 'Refer', value: Math.min(referrals, returned)},
|
||||
];
|
||||
|
||||
return raw.map((stage, i) => ({
|
||||
...stage,
|
||||
conversionPct:
|
||||
i === 0 || raw[i - 1].value === 0
|
||||
? undefined
|
||||
: Number(((stage.value / raw[i - 1].value) * 100).toFixed(1)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Campaign name and framing per activity. The numbers come from the activity. */
|
||||
const CAMPAIGNS: {
|
||||
id: string;
|
||||
name: string;
|
||||
activityId: ActivityId;
|
||||
status: CampaignSummary['status'];
|
||||
/** Labels for the three funnel steps, in order. */
|
||||
steps: [string, string, string];
|
||||
}[] = [
|
||||
{
|
||||
id: 'c-challenge',
|
||||
name: 'Weekend Challenge',
|
||||
activityId: 'challenge',
|
||||
status: 'live',
|
||||
steps: ['Participants', 'Repeat visits', 'Purchases'],
|
||||
},
|
||||
{
|
||||
id: 'c-friend',
|
||||
name: 'Refer a Friend',
|
||||
activityId: 'friend',
|
||||
status: 'live',
|
||||
steps: ['Referrals', 'New customers', 'Purchases'],
|
||||
},
|
||||
{
|
||||
id: 'c-event',
|
||||
name: 'Saturday Event',
|
||||
activityId: 'event',
|
||||
status: 'ended',
|
||||
steps: ['Attendees', 'Repeat visits', 'Purchases'],
|
||||
},
|
||||
{
|
||||
id: 'c-spin',
|
||||
name: 'Spin & Win',
|
||||
activityId: 'spin',
|
||||
status: 'live',
|
||||
steps: ['Spins', 'Reward claims', 'Purchases'],
|
||||
},
|
||||
];
|
||||
|
||||
export function buildCampaigns(
|
||||
range: RangeKey,
|
||||
storeId: string,
|
||||
endMs: number,
|
||||
): CampaignSummary[] {
|
||||
const metrics = buildActivityMetrics(range, storeId, endMs);
|
||||
|
||||
return CAMPAIGNS.flatMap((c) => {
|
||||
const m = metrics.find((x) => x.id === c.activityId);
|
||||
if (!m) return [];
|
||||
|
||||
// The middle step differs by campaign shape: a referral drive converts to
|
||||
// new customers, a spin converts to claimed rewards, everything else to a
|
||||
// return visit. Reading it off the impact chain rather than drawing a
|
||||
// fresh number is what keeps a campaign row consistent with the activity
|
||||
// card above it.
|
||||
const middle =
|
||||
c.activityId === 'friend'
|
||||
? m.impact.customers
|
||||
: c.activityId === 'spin'
|
||||
? (m.impact.rewardClaims ?? m.impact.repeatVisits)
|
||||
: m.impact.repeatVisits;
|
||||
|
||||
return [
|
||||
{
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
activityId: m.id,
|
||||
accent: m.accent,
|
||||
status: c.status,
|
||||
steps: [
|
||||
{label: c.steps[0], value: m.count},
|
||||
{label: c.steps[1], value: middle},
|
||||
{label: c.steps[2], value: m.impact.purchases},
|
||||
],
|
||||
attributedRevenueInr: m.impact.attributedRevenueInr,
|
||||
attribution: m.impact.attribution,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const pct = (a: number, b: number) => (b === 0 ? 0 : (a / b) * 100);
|
||||
|
||||
/**
|
||||
* Insights, computed from the numbers actually on screen.
|
||||
*
|
||||
* Deliberately not a static list of sentences. Every claim below is derived
|
||||
* from the same fixtures the panels render, so an insight cannot contradict
|
||||
* the card next to it — which is the failure mode that makes merchants stop
|
||||
* reading an AI panel after the second week.
|
||||
*
|
||||
* Each one carries an action. An observation with no next step belongs in a
|
||||
* chart, not in a section called "What needs your attention".
|
||||
*/
|
||||
export function buildInsights(
|
||||
range: RangeKey,
|
||||
storeId: string,
|
||||
endMs: number,
|
||||
): Insight[] {
|
||||
const metrics = buildActivityMetrics(range, storeId, endMs);
|
||||
const by = (id: ActivityId) => metrics.find((m) => m.id === id)!;
|
||||
|
||||
const spin = by('spin');
|
||||
const challenge = by('challenge');
|
||||
const visit = by('visit');
|
||||
const friend = by('friend');
|
||||
|
||||
const spinConversion = pct(spin.impact.purchases, spin.impact.customers);
|
||||
const challengeReturn = pct(
|
||||
challenge.impact.repeatVisits,
|
||||
challenge.impact.customers,
|
||||
);
|
||||
const visitReturn = pct(visit.impact.repeatVisits, visit.impact.customers);
|
||||
const returnMultiple = visitReturn === 0 ? 0 : challengeReturn / visitReturn;
|
||||
const visitConversion = pct(visit.impact.purchases, visit.impact.customers);
|
||||
|
||||
const insights: Insight[] = [
|
||||
{
|
||||
id: 'i-spin',
|
||||
severity: spinConversion < 15 ? 'warning' : 'info',
|
||||
// The headline states the CONVERSION, not the direction of the count.
|
||||
// "Spin engagement is up 16%" reads as good news and is the wrong thing
|
||||
// to lead with — and it is also plainly wrong on a period where plays
|
||||
// fell, which is how a generated insight loses a merchant's trust.
|
||||
title: `Only ${spinConversion.toFixed(0)}% of spin players go on to buy`,
|
||||
body: `${spin.count.toLocaleString('en-IN')} spins reached ${spin.impact.customers.toLocaleString('en-IN')} customers and plays are ${spin.deltaPct >= 0 ? 'up' : 'down'} ${Math.abs(spin.deltaPct).toFixed(0)}%. The wheel is drawing plays without pulling anyone to the counter.`,
|
||||
action: {label: 'Refresh reward catalogue', href: '/lyts'},
|
||||
},
|
||||
{
|
||||
id: 'i-challenge',
|
||||
severity: 'success',
|
||||
title: `Challenge participants return ${returnMultiple.toFixed(1)}× more often`,
|
||||
body: `${challengeReturn.toFixed(0)}% of challenge participants came back this period against ${visitReturn.toFixed(0)}% of ordinary visitors. It is the strongest retention lever running.`,
|
||||
action: {label: 'Plan another challenge', href: '/activity'},
|
||||
},
|
||||
{
|
||||
id: 'i-visit',
|
||||
severity: visitConversion < 20 ? 'warning' : 'info',
|
||||
title: `Visit-to-purchase conversion sits at ${visitConversion.toFixed(0)}%`,
|
||||
body: `${visit.impact.customers.toLocaleString('en-IN')} customers checked in and ${visit.impact.purchases.toLocaleString('en-IN')} bought. A visit-triggered offer is the shortest path between the two.`,
|
||||
action: {label: 'Launch a visit offer', href: '/lyts'},
|
||||
},
|
||||
{
|
||||
id: 'i-friend',
|
||||
severity: 'info',
|
||||
title: `Referrals brought ${friend.impact.customers.toLocaleString('en-IN')} new customers`,
|
||||
body: `${friend.count.toLocaleString('en-IN')} referrals converted at ${pct(friend.impact.purchases, friend.impact.customers).toFixed(0)}% — the highest of any growth activity, on the smallest volume.`,
|
||||
action: {label: 'Promote the referral reward', href: '/lyts'},
|
||||
},
|
||||
];
|
||||
|
||||
// Most severe first, so the panel's top row is always the thing that most
|
||||
// needs attention rather than whichever activity happens to be listed first.
|
||||
const rank = {error: 0, warning: 1, success: 2, info: 3} as const;
|
||||
return insights.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
||||
}
|
||||
@@ -5,12 +5,18 @@ import type {
|
||||
DashboardBriefing,
|
||||
Granularity,
|
||||
HourCell,
|
||||
Insight,
|
||||
Kpi,
|
||||
PeriodPoint,
|
||||
RewardUsagePoint,
|
||||
StoreComparison,
|
||||
TimePoint,
|
||||
} from '@/features/dashboard/types/dashboard';
|
||||
import type {
|
||||
ActivityMetric,
|
||||
CampaignSummary,
|
||||
JourneyStage,
|
||||
} from '@/features/dashboard/types/intelligence';
|
||||
|
||||
/**
|
||||
* Every URL the dashboard knows, and the only place it knows them.
|
||||
@@ -48,4 +54,18 @@ export const dashboardRepository = {
|
||||
|
||||
performance: (scope: Scope, granularity: Granularity): Endpoint<PeriodPoint[]> =>
|
||||
scopedEndpoint('/api/dashboard/performance', scope, {granularity}),
|
||||
|
||||
// ---- Store intelligence: the activity layer -----------------------------
|
||||
|
||||
activityMetrics: (scope: Scope): Endpoint<ActivityMetric[]> =>
|
||||
scopedEndpoint('/api/dashboard/activity-metrics', scope),
|
||||
|
||||
journey: (scope: Scope): Endpoint<JourneyStage[]> =>
|
||||
scopedEndpoint('/api/dashboard/journey', scope),
|
||||
|
||||
campaigns: (scope: Scope): Endpoint<CampaignSummary[]> =>
|
||||
scopedEndpoint('/api/dashboard/campaigns', scope),
|
||||
|
||||
insights: (scope: Scope): Endpoint<Insight[]> =>
|
||||
scopedEndpoint('/api/dashboard/insights', scope),
|
||||
};
|
||||
|
||||
100
src/features/dashboard/services/activityService.ts
Normal file
100
src/features/dashboard/services/activityService.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Domain rules for the activity layer.
|
||||
*
|
||||
* Two things live here that would otherwise be duplicated in every panel that
|
||||
* renders an activity: which glyph an activity gets, and how its impact chain
|
||||
* is read out. Both are decisions about the DOMAIN, not about a layout — the
|
||||
* dashboard summary, the analytics page and a campaign row must all describe
|
||||
* Spin the same way, or the merchant is looking at three products.
|
||||
*/
|
||||
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconType} from '@astryxdesign/core/Icon';
|
||||
import type {
|
||||
ActivityGroup,
|
||||
ActivityId,
|
||||
ActivityMetric,
|
||||
} from '@/features/dashboard/types/intelligence';
|
||||
|
||||
export const ACTIVITY_ICON: Record<ActivityId, IconType> = {
|
||||
walk: ICONS.activityWalk,
|
||||
visit: ICONS.activityVisit,
|
||||
selfie: ICONS.activitySelfie,
|
||||
spin: ICONS.activitySpin,
|
||||
scratch: ICONS.activityScratch,
|
||||
brand: ICONS.activityBrand,
|
||||
challenge: ICONS.activityChallenge,
|
||||
friend: ICONS.activityFriend,
|
||||
shop: ICONS.activityShop,
|
||||
event: ICONS.activityEvent,
|
||||
};
|
||||
|
||||
/**
|
||||
* The three questions the grouping answers. The description is the point of
|
||||
* the group — a heading that only says "Engagement" tells a merchant nothing
|
||||
* they could not have guessed from the cards under it.
|
||||
*/
|
||||
export const ACTIVITY_GROUPS: {
|
||||
id: ActivityGroup;
|
||||
label: string;
|
||||
question: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'engagement',
|
||||
label: 'Engagement',
|
||||
question: 'What are customers doing in and around the store?',
|
||||
},
|
||||
{
|
||||
id: 'growth',
|
||||
label: 'Growth',
|
||||
question: 'What is bringing new people in?',
|
||||
},
|
||||
{
|
||||
id: 'commerce',
|
||||
label: 'Commerce',
|
||||
question: 'Is any of it converting into business?',
|
||||
},
|
||||
];
|
||||
|
||||
/** A step in the activity → customer → return → purchase chain. */
|
||||
export interface ImpactStep {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The impact chain as an ordered list, ready to render.
|
||||
*
|
||||
* `rewardClaims` is dropped rather than zeroed when an activity grants no
|
||||
* reward: a chain reading "→ 0 reward claims" states a failure where there is
|
||||
* only an absence, and a merchant reads those very differently.
|
||||
*
|
||||
* `compact` drops the middle of the chain for the dashboard's small cards,
|
||||
* which have room for the two ends of the story and not the whole of it. The
|
||||
* full chain always survives on /activity — this trims the summary, it never
|
||||
* decides what the data contains.
|
||||
*/
|
||||
export function impactChain(
|
||||
m: ActivityMetric,
|
||||
{compact = false}: {compact?: boolean} = {},
|
||||
): ImpactStep[] {
|
||||
const steps: ImpactStep[] = [
|
||||
{label: 'customers', value: m.impact.customers},
|
||||
];
|
||||
|
||||
if (!compact && m.impact.rewardClaims !== undefined) {
|
||||
steps.push({label: 'reward claims', value: m.impact.rewardClaims});
|
||||
}
|
||||
if (!compact) {
|
||||
steps.push({label: 'repeat visits', value: m.impact.repeatVisits});
|
||||
}
|
||||
steps.push({label: 'purchases', value: m.impact.purchases});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Share of participants who ended up buying — the number the chain exists for. */
|
||||
export function conversionPct(m: ActivityMetric): number {
|
||||
if (m.impact.customers === 0) return 0;
|
||||
return (m.impact.purchases / m.impact.customers) * 100;
|
||||
}
|
||||
184
src/features/dashboard/types/intelligence.ts
Normal file
184
src/features/dashboard/types/intelligence.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Store intelligence contracts — the activity layer.
|
||||
*
|
||||
* Sits beside dashboard.ts and answers a different question. Those types
|
||||
* describe what the STORE did (visitors, revenue, conversion). These describe
|
||||
* what CUSTOMERS did, and — the part that makes it intelligence rather than a
|
||||
* leaderboard — what each of those things was worth.
|
||||
*
|
||||
* THE ONE RULE THIS FILE ENCODES
|
||||
* An activity count on its own is a vanity metric. 724 spins tells a merchant
|
||||
* nothing they can act on. So `count` never travels alone: every activity
|
||||
* carries an `ActivityImpact` describing the chain
|
||||
*
|
||||
* activity → customers → return visits → purchases
|
||||
*
|
||||
* and every surface that renders an activity is expected to show at least one
|
||||
* downstream step. The numbers behind this are fixtures today; the SHAPE is
|
||||
* the contract a real backend has to satisfy, and it is deliberately not
|
||||
* "count plus a delta" — that would let the useful half be dropped silently.
|
||||
*/
|
||||
|
||||
import type {BrandAccent} from '@/shared/utils/accent';
|
||||
|
||||
/**
|
||||
* How an impact figure was arrived at. Required, never defaulted.
|
||||
*
|
||||
* This is the most important field in the file. Today every number in the
|
||||
* chain below is MODELLED — a share of observed footfall pushed through fixed
|
||||
* conversion rates — and a modelled ₹6.5L rendered in the same type as a real
|
||||
* one is how a dashboard quietly starts lying. Making the basis part of the
|
||||
* payload means the UI can label it honestly without guessing, and a backend
|
||||
* that later joins real till receipts to real activity events flips this to
|
||||
* `'observed'` and the disclosure disappears on its own. Nothing else has to
|
||||
* change: the field names already say "attributed", not "earned".
|
||||
*
|
||||
* estimated modelled from observed activity and purchase patterns
|
||||
* observed each purchase is joined to a specific activity event
|
||||
*/
|
||||
export type AttributionBasis = 'estimated' | 'observed';
|
||||
|
||||
/**
|
||||
* The one sentence the UI shows wherever an attributed figure appears. Defined
|
||||
* once so the disclosure cannot drift between the three panels that carry it.
|
||||
*/
|
||||
export const ATTRIBUTION_NOTE =
|
||||
'Attribution is estimated from observed customer activity and purchase patterns. It shows association, not proven cause.';
|
||||
|
||||
/**
|
||||
* The complete activity ecosystem. The dashboard summarises the six marked
|
||||
* `isFeatured`; all ten live on /activity.
|
||||
*/
|
||||
export type ActivityId =
|
||||
| 'walk'
|
||||
| 'visit'
|
||||
| 'selfie'
|
||||
| 'spin'
|
||||
| 'scratch'
|
||||
| 'brand'
|
||||
| 'challenge'
|
||||
| 'friend'
|
||||
| 'shop'
|
||||
| 'event';
|
||||
|
||||
/**
|
||||
* What the merchant is asking when they look at a group.
|
||||
*
|
||||
* engagement "what are customers doing?"
|
||||
* growth "what is bringing people in?"
|
||||
* commerce "is any of it converting?"
|
||||
*/
|
||||
export type ActivityGroup = 'engagement' | 'growth' | 'commerce';
|
||||
|
||||
/**
|
||||
* The chain that connects an interaction to money.
|
||||
*
|
||||
* Read every field after `customers` as ATTRIBUTED rather than caused. A
|
||||
* customer who spun the wheel and later bought something is an association;
|
||||
* whether the spin is why they bought is not knowable from this data, and the
|
||||
* UI must not phrase it as though it were.
|
||||
*/
|
||||
export interface ActivityImpact {
|
||||
/** Distinct customers behind the interactions. Directly observed. */
|
||||
customers: number;
|
||||
/** Rewards claimed off the back of it. Absent where the activity grants none. */
|
||||
rewardClaims?: number;
|
||||
/** Participants who returned to the store within the period. */
|
||||
repeatVisits: number;
|
||||
/** Purchases by those participants, attributed to this activity. */
|
||||
purchases: number;
|
||||
/**
|
||||
* Revenue on those purchases. Named `attributed` rather than `revenue`
|
||||
* because that is what it is — see AttributionBasis.
|
||||
*/
|
||||
attributedRevenueInr: number;
|
||||
attribution: AttributionBasis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the merchant is currently running the activity.
|
||||
*
|
||||
* Lives on the shared metric rather than on a Lyts-only type: the dashboard
|
||||
* and the Lyts catalogue must never disagree about whether Spin is live, and
|
||||
* two types would guarantee that they eventually do.
|
||||
*/
|
||||
export type ActivityStatus = 'live' | 'paused' | 'draft';
|
||||
|
||||
export interface ActivityMetric {
|
||||
id: ActivityId;
|
||||
label: string;
|
||||
/** One line on what the activity is — the merchant may not have run it yet. */
|
||||
description: string;
|
||||
group: ActivityGroup;
|
||||
/**
|
||||
* Fixed per activity, carried on the payload rather than derived from grid
|
||||
* position, so an activity keeps its colour across every screen it appears
|
||||
* on and a reordered grid does not repaint.
|
||||
*/
|
||||
accent: BrandAccent;
|
||||
/** Interactions recorded in the period — the headline number. */
|
||||
count: number;
|
||||
deltaPct: number;
|
||||
/**
|
||||
* Whether the activity is running. The dashboard summary does not show this
|
||||
* — a summary of six live activities does not need six "Live" badges — but
|
||||
* the Lyts catalogue is where a merchant turns things on and off, so it is
|
||||
* the same field rather than a second source of truth.
|
||||
*/
|
||||
status: ActivityStatus;
|
||||
/**
|
||||
* LYTs issued through this activity in the period. 1 LYT = ₹1, so this is
|
||||
* simultaneously a count and the rupee liability the activity created —
|
||||
* which is what connects the activity model to the Lyts programme.
|
||||
*
|
||||
* Directly observed, unlike everything in `impact` past `customers`: the
|
||||
* ledger knows exactly how many LYTs it issued and why.
|
||||
*/
|
||||
lytsIssued: number;
|
||||
impact: ActivityImpact;
|
||||
/** Surfaced on the dashboard summary. The rest are /activity only. */
|
||||
isFeatured: boolean;
|
||||
}
|
||||
|
||||
export type JourneyStageId =
|
||||
| 'visit'
|
||||
| 'engage'
|
||||
| 'purchase'
|
||||
| 'return'
|
||||
| 'refer';
|
||||
|
||||
export interface JourneyStage {
|
||||
id: JourneyStageId;
|
||||
label: string;
|
||||
/** Customers who reached this stage. Monotonically decreasing by definition. */
|
||||
value: number;
|
||||
/**
|
||||
* Share of the PREVIOUS stage that made it here. Undefined on the first
|
||||
* stage, which has nothing to convert from.
|
||||
*/
|
||||
conversionPct?: number;
|
||||
}
|
||||
|
||||
export type CampaignStatus = 'live' | 'ended' | 'scheduled';
|
||||
|
||||
export interface CampaignStep {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface CampaignSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
/** The activity it runs on — gives the row its icon and accent. */
|
||||
activityId: ActivityId;
|
||||
accent: BrandAccent;
|
||||
status: CampaignStatus;
|
||||
/**
|
||||
* Participants → engagement → conversion, in order. A campaign that is not
|
||||
* a funnel is not a campaign, so this is a list rather than named fields:
|
||||
* a referral drive ends at "new customers", an event ends at "purchases".
|
||||
*/
|
||||
steps: CampaignStep[];
|
||||
attributedRevenueInr: number;
|
||||
attribution: AttributionBasis;
|
||||
}
|
||||
89
src/features/lyts/components/ActivityProgramme.tsx
Normal file
89
src/features/lyts/components/ActivityProgramme.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {ActivityGroups} from '@/features/dashboard/components/ActivityGroups';
|
||||
import {ActivityImpactTable} from '@/features/dashboard/components/ActivityImpactTable';
|
||||
import {useActivityMetrics} from '@/features/dashboard/hooks/useDashboard';
|
||||
import {formatCompact, formatLyt} from '@/shared/utils/format';
|
||||
|
||||
/**
|
||||
* The activity ecosystem, as the Lyts page presents it.
|
||||
*
|
||||
* ── Why this lives here and not on the dashboard ──────────────────────────
|
||||
* The two pages ask different questions of the same data. The dashboard asks
|
||||
* "how are customers engaging and what is it worth?" and answers with six
|
||||
* activities and their business impact. Lyts asks "what am I running, what
|
||||
* does it cost me in LYTs, and is it working?" — which needs all ten, their
|
||||
* status, and the LYT liability each one creates.
|
||||
*
|
||||
* ── One model, not two ────────────────────────────────────────────────────
|
||||
* Everything here reads `useActivityMetrics()` — the same hook, the same
|
||||
* endpoint, the same fixtures the dashboard uses. If the dashboard says Visit
|
||||
* is 35.3k, this page says 35.3k, because there is nowhere for a second
|
||||
* number to come from. The grouped cards and the impact table are the exact
|
||||
* components the dashboard's own activity work produced, rendered in
|
||||
* `catalogue` variant rather than reimplemented.
|
||||
*
|
||||
* The summary strip is StatPairs rather than MetricCards on purpose: the KPI
|
||||
* row directly above it is already four MetricCards about LYT liability, and
|
||||
* a second row of the same object would read as eight equal headline metrics
|
||||
* on a page whose headline is the programme, not the activities.
|
||||
*/
|
||||
export function ActivityProgramme() {
|
||||
const metrics = useActivityMetrics();
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<StaticPanel
|
||||
title="Customer activities"
|
||||
subtitle="Every way a customer can earn, and what each one issues in LYTs"
|
||||
>
|
||||
{/*
|
||||
Derived at render rather than fetched as its own summary endpoint.
|
||||
A totals endpoint could disagree with the cards below it — this
|
||||
cannot, because it is the same array.
|
||||
*/}
|
||||
{metrics.status === 'success' || metrics.status === 'empty' ? (
|
||||
<StatRow>
|
||||
<StatPair
|
||||
label="Live activities"
|
||||
value={`${metrics.data.filter((m) => m.status === 'live').length} of ${metrics.data.length}`}
|
||||
/>
|
||||
<StatPair
|
||||
label="Total participation"
|
||||
value={formatCompact(
|
||||
metrics.data.reduce((a, m) => a + m.count, 0),
|
||||
)}
|
||||
/>
|
||||
<StatPair
|
||||
label="Customers reached"
|
||||
value={formatCompact(
|
||||
// The MAXIMUM, not the sum: one customer who visits, spins and
|
||||
// takes a selfie is one customer. Adding the per-activity
|
||||
// counts would claim three, and inflate "customers reached"
|
||||
// past the store's own footfall.
|
||||
metrics.data.reduce(
|
||||
(a, m) => Math.max(a, m.impact.customers),
|
||||
0,
|
||||
),
|
||||
)}
|
||||
/>
|
||||
<StatPair
|
||||
label="LYTs issued"
|
||||
value={formatLyt(
|
||||
metrics.data.reduce((a, m) => a + m.lytsIssued, 0),
|
||||
)}
|
||||
align="end"
|
||||
/>
|
||||
</StatRow>
|
||||
) : null}
|
||||
</StaticPanel>
|
||||
|
||||
<ActivityGroups resource={metrics} />
|
||||
|
||||
<ActivityImpactTable resource={metrics} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -58,18 +58,6 @@ export const SETTINGS_NAV: SettingsSection[] = [
|
||||
icon: ICONS.revenue,
|
||||
description: 'Subscription plans, payment methods and LYT settlement.',
|
||||
},
|
||||
{
|
||||
label: 'Integrations',
|
||||
href: '/settings/integrations',
|
||||
icon: ICONS.integrations,
|
||||
description: 'Connect Shopify, WooCommerce, Razorpay, WhatsApp and Meta.',
|
||||
},
|
||||
{
|
||||
label: 'API & Webhooks',
|
||||
href: '/settings/api',
|
||||
icon: ICONS.api,
|
||||
description: 'Developer API keys, webhook endpoints and delivery logs.',
|
||||
},
|
||||
{
|
||||
label: 'Security',
|
||||
href: '/settings/security',
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
/**
|
||||
* The two official Loyaly.ai assets — and the only place in the product where
|
||||
* The official Loyaly.ai assets — and the only place in the product where
|
||||
* brand colour is allowed to appear. Everything around them stays monochrome.
|
||||
*
|
||||
* Both render at their own intrinsic ratio: a caller sizes ONE axis and the
|
||||
* other is derived, so neither mark can be letterboxed or stretched. There is
|
||||
* Every mark renders at its own intrinsic ratio: a caller sizes ONE axis and
|
||||
* the other is derived, so nothing can be letterboxed or stretched. There is
|
||||
* deliberately no `fill` / `objectFit` escape hatch — that is how logos end up
|
||||
* distorted. Recolouring is likewise not a prop: the PNGs carry the mark.
|
||||
*
|
||||
* ── Two lockups, one component ────────────────────────────────────────────
|
||||
* The wordmark is baked into the PNG, so a white one is invisible on a white
|
||||
* sidebar — which is exactly what the light theme was shipping. Both files are
|
||||
* rendered and the cascade picks one (see `.brand-lockup-*` in globals.css);
|
||||
* the hidden variant is `display: none`, so it leaves the accessibility tree
|
||||
* and the alt text is announced exactly once.
|
||||
*
|
||||
* Resolving this in React instead was rejected: the mode may be the literal
|
||||
* 'system', which only CSS can resolve, and a client-side resolve would flash
|
||||
* the wrong mark on first paint.
|
||||
*/
|
||||
|
||||
// Intrinsic pixel dimensions of the shipped files, alpha-trimmed so the
|
||||
// wordmark optically centres (the supplied original carried 44px of dead
|
||||
// transparent space on its trailing edge).
|
||||
const LOGO = {src: '/white-logo.png', width: 1489, height: 248};
|
||||
const LOGO_DARK = {src: '/white-logo.png', width: 1489, height: 248};
|
||||
const LOGO_LIGHT = {src: '/brand/loyaly-logo.png', width: 596, height: 119};
|
||||
const MARK = {src: '/brand/loyaly-mark.png', width: 285, height: 256};
|
||||
|
||||
interface BrandProps {
|
||||
@@ -23,23 +35,46 @@ interface BrandProps {
|
||||
priority?: boolean;
|
||||
}
|
||||
|
||||
/** Full horizontal Loyaly.ai lockup. Sized by height; width follows. */
|
||||
/**
|
||||
* Full horizontal Loyaly.ai lockup. Sized by height; width follows.
|
||||
*
|
||||
* Both variants are laid out at the SAME derived width, so swapping themes
|
||||
* cannot shift the sidebar, the top bar or the AI panel header by a pixel.
|
||||
*/
|
||||
export function BrandLogo({
|
||||
height = 28,
|
||||
alt = 'Loyaly.ai',
|
||||
priority = false,
|
||||
}: BrandProps & {height?: number}) {
|
||||
// Both files carry the same lockup at the same aspect ratio; deriving the
|
||||
// width from each one's own intrinsics keeps that true even if a future
|
||||
// asset is re-exported at a different scale.
|
||||
const darkWidth = Math.round((height * LOGO_DARK.width) / LOGO_DARK.height);
|
||||
const lightWidth = Math.round((height * LOGO_LIGHT.width) / LOGO_LIGHT.height);
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={LOGO.src}
|
||||
alt={alt}
|
||||
height={height}
|
||||
width={Math.round((height * LOGO.width) / LOGO.height)}
|
||||
priority={priority}
|
||||
// Block display keeps the anchor that usually wraps this from painting a
|
||||
// hover underline in the leftover line box.
|
||||
className="block"
|
||||
/>
|
||||
<>
|
||||
{/* Dark wordmark on the light theme. The `block` display that used to
|
||||
live here — keeping the wrapping anchor from painting a hover
|
||||
underline in the leftover line box — is now set by the same rule
|
||||
that decides visibility. */}
|
||||
<Image
|
||||
src={LOGO_LIGHT.src}
|
||||
alt={alt}
|
||||
height={height}
|
||||
width={lightWidth}
|
||||
priority={priority}
|
||||
className="brand-lockup-light"
|
||||
/>
|
||||
<Image
|
||||
src={LOGO_DARK.src}
|
||||
alt={alt}
|
||||
height={height}
|
||||
width={darkWidth}
|
||||
priority={priority}
|
||||
className="brand-lockup-dark"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ const TONE = {
|
||||
/**
|
||||
* Areas fade to transparent rather than sitting as flat gray blocks — on a
|
||||
* near-black surface a solid fill swallows the gridlines and the series below.
|
||||
*
|
||||
* The ramp is 0.20 → 0.02, down from 0.38 → 0.03. That original pair was tuned
|
||||
* against the monochrome ramp, where the top stop is a mid-gray and 38% of it
|
||||
* is a whisper. Footfall now draws in brand purple, and 38% of a saturated hue
|
||||
* on a white card is a colour block — it read as the loudest element on the
|
||||
* page while the line that carries the actual shape read as its edge. Lower
|
||||
* stops put the line back in charge and leave the gridlines visible through
|
||||
* the fill, which is the whole reason the fill is a gradient.
|
||||
*/
|
||||
export function AreaChartView<T extends object>({
|
||||
data,
|
||||
@@ -46,8 +54,8 @@ export function AreaChartView<T extends object>({
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={c} stopOpacity={0.38} />
|
||||
<stop offset="100%" stopColor={c} stopOpacity={0.03} />
|
||||
<stop offset="0%" stopColor={c} stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor={c} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -37,6 +37,20 @@ export const CHART = {
|
||||
positive: 'var(--color-success)',
|
||||
negative: 'var(--color-error)',
|
||||
attention: 'var(--color-warning)',
|
||||
/**
|
||||
* The two brand accents, for the small number of marks that are allowed to
|
||||
* carry the brand rather than merely a value: the two headline dashboard
|
||||
* charts, the reward series, the journey bars. Opt in per series with
|
||||
* `series={[{key, label, color: CHART.brand.cool}]}` — never as a default,
|
||||
* or the ramp stops being monochrome and the sparklines in every KPI card
|
||||
* turn purple with it. Defined in globals.css, not the Astryx theme.
|
||||
*/
|
||||
brand: {
|
||||
warm: 'var(--color-brand-warm)',
|
||||
/** Softened warm, for large filled areas — bars. See the token's note. */
|
||||
warmBar: 'var(--color-brand-warm-bar)',
|
||||
cool: 'var(--color-brand-cool)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** The non-chromatic channel. */
|
||||
|
||||
37
src/shared/utils/accent.ts
Normal file
37
src/shared/utils/accent.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* The two-accent brand system, expressed as class names.
|
||||
*
|
||||
* There are exactly two non-semantic hues in this product — warm (Loyaly
|
||||
* yellow) and cool (purple) — and their values live in globals.css as
|
||||
* `--color-brand-*`. This file is the only place that turns one into markup,
|
||||
* so "which utility do I use for a warm icon?" has a single answer and a
|
||||
* feature file never writes `text-brand-warm-ink` by hand.
|
||||
*
|
||||
* Assignment is a property of the DATA, not of the position in a grid. Each
|
||||
* activity carries its own accent from the API, so the same activity is the
|
||||
* same colour on the dashboard, on the activity page and inside a campaign
|
||||
* row — and a grid that gains a row does not repaint itself.
|
||||
*
|
||||
* `ink` is the readable slot (icons, text); `soft` is a tint for an icon chip
|
||||
* only. Nothing here paints a card background: the cards stay white.
|
||||
*/
|
||||
|
||||
export type BrandAccent = 'warm' | 'cool';
|
||||
|
||||
export const ACCENT: Record<
|
||||
BrandAccent,
|
||||
{ink: string; soft: string; solid: string; chip: string}
|
||||
> = {
|
||||
warm: {
|
||||
ink: 'text-brand-warm-ink',
|
||||
soft: 'bg-brand-warm-soft',
|
||||
solid: 'bg-brand-warm',
|
||||
chip: 'bg-brand-warm-soft text-brand-warm-ink',
|
||||
},
|
||||
cool: {
|
||||
ink: 'text-brand-cool-ink',
|
||||
soft: 'bg-brand-cool-soft',
|
||||
solid: 'bg-brand-cool',
|
||||
chip: 'bg-brand-cool-soft text-brand-cool-ink',
|
||||
},
|
||||
};
|
||||
@@ -11,11 +11,14 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Camera,
|
||||
ChartLine,
|
||||
ChevronsUpDown,
|
||||
CircleAlert,
|
||||
@@ -25,6 +28,8 @@ import {
|
||||
Coins,
|
||||
Columns3,
|
||||
CreditCard,
|
||||
Disc3,
|
||||
DoorOpen,
|
||||
Download,
|
||||
Expand,
|
||||
Footprints,
|
||||
@@ -35,9 +40,11 @@ import {
|
||||
Info,
|
||||
Keyboard,
|
||||
LayoutDashboard,
|
||||
Lightbulb,
|
||||
Lock,
|
||||
LogOut,
|
||||
Maximize2,
|
||||
Megaphone,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
@@ -50,21 +57,26 @@ import {
|
||||
Plus,
|
||||
Percent,
|
||||
Plug,
|
||||
Route,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Shrink,
|
||||
ShoppingBag,
|
||||
ShoppingCart,
|
||||
Sliders,
|
||||
Square,
|
||||
SquarePen,
|
||||
Sparkles,
|
||||
Store,
|
||||
Target,
|
||||
Ticket,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Trophy,
|
||||
User,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
Users,
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
@@ -100,6 +112,30 @@ export const ICONS = {
|
||||
conversion: Percent,
|
||||
lyt: Coins,
|
||||
|
||||
// Customer activities.
|
||||
//
|
||||
// One glyph per activity in the loyalty ecosystem. Named with an `activity`
|
||||
// prefix rather than folded into the metric block above because several
|
||||
// collide with an existing concept and must not be merged with it: a `visit`
|
||||
// is a customer walking in, which is not the `visitors` KPI; `activityShop`
|
||||
// is the shop ACTIVITY, while `purchases` is the transaction metric.
|
||||
activityWalk: Footprints,
|
||||
activityVisit: DoorOpen,
|
||||
activitySelfie: Camera,
|
||||
activitySpin: Disc3,
|
||||
activityScratch: Ticket,
|
||||
activityBrand: Megaphone,
|
||||
activityChallenge: Target,
|
||||
activityFriend: UserPlus,
|
||||
activityShop: ShoppingBag,
|
||||
activityEvent: CalendarDays,
|
||||
|
||||
// Store intelligence
|
||||
journey: Route,
|
||||
campaign: Megaphone,
|
||||
insight: Lightbulb,
|
||||
arrowRight: ArrowRight,
|
||||
|
||||
// Dashboard controls
|
||||
compare: Columns3,
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @generated by `astryx theme build` — do not edit manually.
|
||||
* Source: src/theme/loyalyTheme.ts
|
||||
* Command: astryx theme build src/theme/loyalyTheme.ts
|
||||
* Generated: 2026-08-06T05:55:58.053Z
|
||||
* Generated: 2026-08-10T08:15:31.619Z
|
||||
*/
|
||||
|
||||
@layer reset {
|
||||
@@ -164,7 +164,7 @@
|
||||
--color-syntax-punctuation: light-dark(#a3a3a3, #525252);
|
||||
--color-syntax-background: light-dark(#fafafa, #0a0a0a);
|
||||
--color-background-surface: light-dark(#FFFFFF, #0F0F10);
|
||||
--color-background-body: light-dark(#F7F7F8, #000000);
|
||||
--color-background-body: light-dark(#FAFAFA, #000000);
|
||||
--color-background-card: light-dark(#FFFFFF, #171717);
|
||||
--color-background-popover: light-dark(#FFFFFF, #171717);
|
||||
--color-background-muted: light-dark(#F1F1F2, #202124);
|
||||
@@ -175,7 +175,7 @@
|
||||
--color-overlay-hover: light-dark(#0000000D, #FFFFFF0F);
|
||||
--color-overlay-pressed: light-dark(#0000001A, #FFFFFF1A);
|
||||
--color-text-primary: light-dark(#171717, #FFFFFF);
|
||||
--color-text-secondary: light-dark(#71717A, #A1A1AA);
|
||||
--color-text-secondary: light-dark(#737373, #A1A1AA);
|
||||
--color-text-disabled: light-dark(#A1A1AA, #71717A);
|
||||
--color-text-accent: light-dark(#171717, #FFFFFF);
|
||||
--color-on-dark: #FFFFFF;
|
||||
@@ -194,7 +194,7 @@
|
||||
--color-success-muted: light-dark(#DCFCE7, #22C55E29);
|
||||
--color-error-muted: light-dark(#FEE2E2, #EF444429);
|
||||
--color-warning-muted: light-dark(#FEF3C7, #F59E0B29);
|
||||
--color-border: light-dark(#00000014, #2F2F2F);
|
||||
--color-border: light-dark(#E5E5E5, #2F2F2F);
|
||||
--color-border-emphasized: light-dark(#D4D4D8, #3F3F46);
|
||||
--color-skeleton: light-dark(#EBEBEB, #27272A);
|
||||
--color-shadow: light-dark(#0000001A, #00000099);
|
||||
@@ -632,25 +632,30 @@
|
||||
}
|
||||
|
||||
.astryx-dropdown-menu-item:hover {
|
||||
background-color: #27272A;
|
||||
background-color: var(--color-background-muted);
|
||||
}
|
||||
|
||||
.astryx-dropdown-menu-item:focus-visible {
|
||||
background-color: #27272A;
|
||||
background-color: var(--color-background-muted);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.astryx-selector-option:hover {
|
||||
background-color: #27272A;
|
||||
background-color: var(--color-background-muted);
|
||||
}
|
||||
|
||||
.astryx-selector-option.selected {
|
||||
background-color: var(--color-background-raised);
|
||||
background-color: var(--color-background-muted);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.astryx-item:hover {
|
||||
background-color: #27272A;
|
||||
background-color: var(--color-background-muted);
|
||||
}
|
||||
|
||||
.astryx-item.selected {
|
||||
background-color: var(--color-background-muted);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.astryx-table-cell {
|
||||
|
||||
2
src/theme/loyaly.d.ts
vendored
2
src/theme/loyaly.d.ts
vendored
@@ -2,7 +2,7 @@
|
||||
* @generated by `astryx theme build` — do not edit manually.
|
||||
* Source: src/theme/loyalyTheme.ts
|
||||
* Command: astryx theme build src/theme/loyalyTheme.ts
|
||||
* Generated: 2026-08-06T05:55:58.054Z
|
||||
* Generated: 2026-08-10T08:15:31.620Z
|
||||
*/
|
||||
|
||||
/// <reference path="./loyaly.variants.d.ts" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @generated by `astryx theme build` — do not edit manually.
|
||||
* Source: src/theme/loyalyTheme.ts
|
||||
* Command: astryx theme build src/theme/loyalyTheme.ts
|
||||
* Generated: 2026-08-06T05:55:58.053Z
|
||||
* Generated: 2026-08-10T08:15:31.619Z
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -97,7 +97,7 @@ export const loyalyTheme = {
|
||||
"--color-syntax-punctuation": "light-dark(#a3a3a3, #525252)",
|
||||
"--color-syntax-background": "light-dark(#fafafa, #0a0a0a)",
|
||||
"--color-background-surface": "light-dark(#FFFFFF, #0F0F10)",
|
||||
"--color-background-body": "light-dark(#F7F7F8, #000000)",
|
||||
"--color-background-body": "light-dark(#FAFAFA, #000000)",
|
||||
"--color-background-card": "light-dark(#FFFFFF, #171717)",
|
||||
"--color-background-popover": "light-dark(#FFFFFF, #171717)",
|
||||
"--color-background-muted": "light-dark(#F1F1F2, #202124)",
|
||||
@@ -108,7 +108,7 @@ export const loyalyTheme = {
|
||||
"--color-overlay-hover": "light-dark(#0000000D, #FFFFFF0F)",
|
||||
"--color-overlay-pressed": "light-dark(#0000001A, #FFFFFF1A)",
|
||||
"--color-text-primary": "light-dark(#171717, #FFFFFF)",
|
||||
"--color-text-secondary": "light-dark(#71717A, #A1A1AA)",
|
||||
"--color-text-secondary": "light-dark(#737373, #A1A1AA)",
|
||||
"--color-text-disabled": "light-dark(#A1A1AA, #71717A)",
|
||||
"--color-text-accent": "light-dark(#171717, #FFFFFF)",
|
||||
"--color-on-dark": "#FFFFFF",
|
||||
@@ -127,7 +127,7 @@ export const loyalyTheme = {
|
||||
"--color-success-muted": "light-dark(#DCFCE7, #22C55E29)",
|
||||
"--color-error-muted": "light-dark(#FEE2E2, #EF444429)",
|
||||
"--color-warning-muted": "light-dark(#FEF3C7, #F59E0B29)",
|
||||
"--color-border": "light-dark(#00000014, #2F2F2F)",
|
||||
"--color-border": "light-dark(#E5E5E5, #2F2F2F)",
|
||||
"--color-border-emphasized": "light-dark(#D4D4D8, #3F3F46)",
|
||||
"--color-skeleton": "light-dark(#EBEBEB, #27272A)",
|
||||
"--color-shadow": "light-dark(#0000001A, #00000099)",
|
||||
|
||||
2
src/theme/loyaly.variants.d.ts
vendored
2
src/theme/loyaly.variants.d.ts
vendored
@@ -2,7 +2,7 @@
|
||||
* @generated by `astryx theme build` — do not edit manually.
|
||||
* Source: src/theme/loyalyTheme.ts
|
||||
* Command: astryx theme build src/theme/loyalyTheme.ts
|
||||
* Generated: 2026-08-06T05:55:58.053Z
|
||||
* Generated: 2026-08-10T08:15:31.616Z
|
||||
*/
|
||||
|
||||
// Generated by astryx theme build
|
||||
|
||||
@@ -127,7 +127,9 @@ export const loyalyTheme = defineTheme({
|
||||
|
||||
tokens: {
|
||||
// ---- Backgrounds ------------------------------------------------------
|
||||
'--color-background-body': ['#F7F7F8', G.black],
|
||||
// Light body is #FAFAFA: one step off the #FFFFFF card rather than two, so
|
||||
// a card reads as raised without the page looking dirty. Dark is untouched.
|
||||
'--color-background-body': ['#FAFAFA', G.black],
|
||||
'--color-background-surface': ['#FFFFFF', G.surface],
|
||||
'--color-background-card': ['#FFFFFF', G.card],
|
||||
'--color-background-popover': ['#FFFFFF', G.card],
|
||||
@@ -147,7 +149,9 @@ export const loyalyTheme = defineTheme({
|
||||
|
||||
// ---- Text -------------------------------------------------------------
|
||||
'--color-text-primary': ['#171717', G.white],
|
||||
'--color-text-secondary': ['#71717A', G.text2],
|
||||
// Light secondary is a true neutral #737373 rather than the zinc #71717A:
|
||||
// the zinc ramp carries a blue cast that reads cold beside a warm accent.
|
||||
'--color-text-secondary': ['#737373', G.text2],
|
||||
'--color-text-disabled': ['#A1A1AA', G.text3],
|
||||
'--color-on-dark': '#FFFFFF',
|
||||
'--color-on-light': '#171717',
|
||||
@@ -158,7 +162,19 @@ export const loyalyTheme = defineTheme({
|
||||
'--color-icon-disabled': ['#A1A1AA', G.text3],
|
||||
|
||||
// ---- Border -----------------------------------------------------------
|
||||
'--color-border': ['#00000014', G.border],
|
||||
/*
|
||||
* THE LIGHT-MODE FIX. This was #00000014 — black at 8% — which over a
|
||||
* white card computes to roughly #EBEBEB and over the body background to
|
||||
* something lighter still. That is why every card blended into its
|
||||
* neighbour and the light theme read as "flat and too white": the only
|
||||
* thing separating two white surfaces was a border you could not see.
|
||||
*
|
||||
* A solid #E5E5E5 is the smallest change that gives the light theme a
|
||||
* card edge, and it is what makes hierarchy possible without the heavy
|
||||
* shadows that would cost the design its calm. Dark keeps #2F2F2F, where
|
||||
* the inset rim in --shadow-* is already doing this job.
|
||||
*/
|
||||
'--color-border': ['#E5E5E5', G.border],
|
||||
// Must sit ABOVE --color-border, or inputs and switches become
|
||||
// indistinguishable from card edges at #2F2F2F.
|
||||
'--color-border-emphasized': ['#D4D4D8', G.g700],
|
||||
@@ -373,22 +389,26 @@ export const loyalyTheme = defineTheme({
|
||||
*/
|
||||
'dropdown-menu-item': {
|
||||
base: {
|
||||
':hover': {backgroundColor: G.g800},
|
||||
':focus-visible': {backgroundColor: G.g800, outline: 'none'},
|
||||
':hover': {backgroundColor: 'var(--color-background-muted)'},
|
||||
':focus-visible': {backgroundColor: 'var(--color-background-muted)', outline: 'none'},
|
||||
},
|
||||
},
|
||||
'selector-option': {
|
||||
base: {
|
||||
':hover': {backgroundColor: G.g800},
|
||||
':hover': {backgroundColor: 'var(--color-background-muted)'},
|
||||
},
|
||||
'selected:selected': {
|
||||
backgroundColor: 'var(--color-background-raised)',
|
||||
backgroundColor: 'var(--color-background-muted)',
|
||||
color: 'var(--color-text-primary)',
|
||||
},
|
||||
},
|
||||
item: {
|
||||
base: {
|
||||
':hover': {backgroundColor: G.g800},
|
||||
':hover': {backgroundColor: 'var(--color-background-muted)'},
|
||||
},
|
||||
'selected:selected': {
|
||||
backgroundColor: 'var(--color-background-muted)',
|
||||
color: 'var(--color-text-primary)',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user