diff --git a/AGENTS.md b/AGENTS.md
index f97cfcf..cf6a1e4 100644
--- a/AGENTS.md
+++ b/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.
+- `` 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`
diff --git a/src/app/(workspace)/activity/page.tsx b/src/app/(workspace)/activity/page.tsx
index eac1f24..940caa0 100644
--- a/src/app/(workspace)/activity/page.tsx
+++ b/src/app/(workspace)/activity/page.tsx
@@ -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.
diff --git a/src/app/(workspace)/dashboard/page.tsx b/src/app/(workspace)/dashboard/page.tsx
index 0271e45..5474cac 100644
--- a/src/app/(workspace)/dashboard/page.tsx
+++ b/src/app/(workspace)/dashboard/page.tsx
@@ -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. */}
- {/* 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.
+ */}
{(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},
+ ]}
/>
)}
@@ -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},
+ ]}
/>
)}
- {/* 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. */}
+
+
+ {/* 6 — where that engagement progresses, and where it stops. */}
+
+
+ {/* 7 — which deliberate campaigns produced the movement above. */}
+
+
+ {/*
+ 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.
+ */}
+
+ {/* Text, not Heading: Collapsible renders the trigger inside a
+
- {/* 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. */}
+
+
+ {/* 13 — bounded feed: six rows, fixed box, full history on /activity. */}
}
/>
@@ -136,6 +146,12 @@ export default function LytsPage() {
}}
+ {/*
+ The earning side, before the spending side. All ten activities, their
+ status and their LYT cost — the same model the dashboard summarises.
+ */}
+
+
+ * 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
+ * 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
, recharts by useChartMotion(). */
@media (prefers-reduced-motion: reduce) {
diff --git a/src/features/dashboard/components/ActivityCard.tsx b/src/features/dashboard/components/ActivityCard.tsx
new file mode 100644
index 0000000..6fbc54e
--- /dev/null
+++ b/src/features/dashboard/components/ActivityCard.tsx
@@ -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 (
+
+
+
+
+ {/*
+ 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.
+ */}
+
+
+
+
+ {metric.label}
+
+
+ {isCatalogue ? (
+
+
+
+ {status.label}
+
+
+ ) : (
+
+ )}
+
+
+ {/* Data, not a section title — Text rather than Heading, so a grid of
+ activity counts stays out of the document outline. */}
+
+ {formatCompact(metric.count)}
+ {/* 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 ? (
+
+ ) : null}
+
+
+ {isCatalogue ? (
+
+ {metric.description}
+
+ ) : null}
+
+
+ {chain
+ .map((s) => `${formatCount(s.value)} ${s.label}`)
+ .join(' → ')}
+
+
+ {/* 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 ? (
+
+
+
+ {formatLyt(metric.lytsIssued)} issued
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/features/dashboard/components/ActivityGroups.tsx b/src/features/dashboard/components/ActivityGroups.tsx
new file mode 100644
index 0000000..9a5c96b
--- /dev/null
+++ b/src/features/dashboard/components/ActivityGroups.tsx
@@ -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;
+}) {
+ const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
+
+ return (
+
+ {ACTIVITY_GROUPS.map((group) => (
+ }
+ resource={resource}
+ loading={}
+ empty={
+
+ }
+ >
+ {(metrics) => (
+
+ {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.
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/src/features/dashboard/components/ActivityImpactTable.tsx b/src/features/dashboard/components/ActivityImpactTable.tsx
new file mode 100644
index 0000000..d473bf3
--- /dev/null
+++ b/src/features/dashboard/components/ActivityImpactTable.tsx
@@ -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 {
+ 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) => (
+
+ {formatCount(v)}
+
+);
+
+const COLUMNS: TableColumn[] = [
+ {
+ 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) => (
+
+
+
+ {row.label}
+
+
+ ),
+ },
+ {
+ 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) => (
+
+ {formatPct(row.conversion, 0)}
+
+ ),
+ },
+ {
+ 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) => (
+
+ {formatInrCompact(row.revenueInr)}
+
+ ),
+ },
+];
+
+export function ActivityImpactTable({
+ resource,
+}: {
+ resource: Resource;
+}) {
+ const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
+
+ return (
+ }
+ actions={}
+ empty={
+
+ }
+ >
+ {(metrics) => (
+
+ )}
+
+ );
+}
diff --git a/src/features/dashboard/components/AttributionNote.tsx b/src/features/dashboard/components/AttributionNote.tsx
new file mode 100644
index 0000000..2f7dccd
--- /dev/null
+++ b/src/features/dashboard/components/AttributionNote.tsx
@@ -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 (
+
+ {/*
+ 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.
+ */}
+
+
+
+
+ );
+}
diff --git a/src/features/dashboard/components/CampaignPerformance.tsx b/src/features/dashboard/components/CampaignPerformance.tsx
new file mode 100644
index 0000000..99069ce
--- /dev/null
+++ b/src/features/dashboard/components/CampaignPerformance.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ {campaign.name}
+
+
+
+ {/*
+ 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.
+ */}
+
+
+ {formatInrCompact(campaign.attributedRevenueInr)}
+
+
+ {campaign.attribution === 'estimated' ? 'attributed (est.)' : 'attributed'}
+
+
+
+
+
+ {campaign.steps
+ .map((s) => `${formatCount(s.value)} ${s.label.toLowerCase()}`)
+ .join(' → ')}
+
+
+
+ );
+}
+
+export function CampaignPerformance({
+ resource,
+}: {
+ resource: Resource;
+}) {
+ const basis = resource.data?.[0]?.attribution ?? 'estimated';
+
+ return (
+ }
+ actions={}
+ empty={
+
+ }
+ >
+ {(campaigns) => (
+
+ {campaigns.map((c) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/features/dashboard/components/CustomerActivity.tsx b/src/features/dashboard/components/CustomerActivity.tsx
new file mode 100644
index 0000000..34e1bc9
--- /dev/null
+++ b/src/features/dashboard/components/CustomerActivity.tsx
@@ -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;
+}) {
+ // 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 (
+ }
+ actions={
+ <>
+ {/* Every figure after "customers" on these cards is attributed, not
+ measured. The note says so once for the whole panel. */}
+
+ {/*
+ /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".
+ */}
+
+ >
+ }
+ empty={
+
+ }
+ >
+ {(metrics) => (
+
+ {metrics
+ .filter((m) => m.isFeatured)
+ .map((m) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/features/dashboard/components/CustomerJourney.tsx b/src/features/dashboard/components/CustomerJourney.tsx
new file mode 100644
index 0000000..2569020
--- /dev/null
+++ b/src/features/dashboard/components/CustomerJourney.tsx
@@ -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;
+}) {
+ return (
+ }
+ empty={
+
+ }
+ >
+ {(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(
+ (worst, s) =>
+ s.conversionPct === undefined
+ ? worst
+ : worst?.conversionPct === undefined ||
+ s.conversionPct < worst.conversionPct
+ ? s
+ : worst,
+ undefined,
+ );
+
+ return (
+
+ {stages.map((stage, i) => (
+
+ {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.
+
+
+
+ )}
+
+
+
+ {stage.label}
+
+ {formatCompact(stage.value)}
+
+ {stage.conversionPct === undefined ? (
+
+ Everyone starts here
+
+ ) : stage.id === weakest?.id ? (
+
+ ) : (
+
+ {formatPct(stage.conversionPct, 0)} of previous
+
+ )}
+
+
+ ))}
+
+ );
+ }}
+
+ );
+}
diff --git a/src/features/dashboard/components/StoreInsights.tsx b/src/features/dashboard/components/StoreInsights.tsx
new file mode 100644
index 0000000..d29e615
--- /dev/null
+++ b/src/features/dashboard/components/StoreInsights.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {insight.title}
+
+
+ {insight.body}
+
+ {insight.action ? (
+
+
+
+ ) : null}
+
+
+ );
+}
+
+export function StoreInsights({resource}: {resource: Resource}) {
+ return (
+ }
+ empty={
+
+ }
+ >
+ {(insights) => (
+
+ {insights.map((i) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/features/dashboard/hooks/useDashboard.ts b/src/features/dashboard/hooks/useDashboard.ts
index 3dae082..72407f5 100644
--- a/src/features/dashboard/hooks/useDashboard.ts
+++ b/src/features/dashboard/hooks/useDashboard.ts
@@ -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));
+}
diff --git a/src/features/dashboard/mock/intelligence.mock.ts b/src/features/dashboard/mock/intelligence.mock.ts
new file mode 100644
index 0000000..6a667af
--- /dev/null
+++ b/src/features/dashboard/mock/intelligence.mock.ts
@@ -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]);
+}
diff --git a/src/features/dashboard/repositories/dashboardRepository.ts b/src/features/dashboard/repositories/dashboardRepository.ts
index 4a2ba3c..cbfca65 100644
--- a/src/features/dashboard/repositories/dashboardRepository.ts
+++ b/src/features/dashboard/repositories/dashboardRepository.ts
@@ -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 =>
scopedEndpoint('/api/dashboard/performance', scope, {granularity}),
+
+ // ---- Store intelligence: the activity layer -----------------------------
+
+ activityMetrics: (scope: Scope): Endpoint =>
+ scopedEndpoint('/api/dashboard/activity-metrics', scope),
+
+ journey: (scope: Scope): Endpoint =>
+ scopedEndpoint('/api/dashboard/journey', scope),
+
+ campaigns: (scope: Scope): Endpoint =>
+ scopedEndpoint('/api/dashboard/campaigns', scope),
+
+ insights: (scope: Scope): Endpoint =>
+ scopedEndpoint('/api/dashboard/insights', scope),
};
diff --git a/src/features/dashboard/services/activityService.ts b/src/features/dashboard/services/activityService.ts
new file mode 100644
index 0000000..01e62a6
--- /dev/null
+++ b/src/features/dashboard/services/activityService.ts
@@ -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 = {
+ 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;
+}
diff --git a/src/features/dashboard/types/intelligence.ts b/src/features/dashboard/types/intelligence.ts
new file mode 100644
index 0000000..59c484a
--- /dev/null
+++ b/src/features/dashboard/types/intelligence.ts
@@ -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;
+}
diff --git a/src/features/lyts/components/ActivityProgramme.tsx b/src/features/lyts/components/ActivityProgramme.tsx
new file mode 100644
index 0000000..3e27153
--- /dev/null
+++ b/src/features/lyts/components/ActivityProgramme.tsx
@@ -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 (
+
+
+ {/*
+ 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' ? (
+
+ m.status === 'live').length} of ${metrics.data.length}`}
+ />
+ a + m.count, 0),
+ )}
+ />
+ Math.max(a, m.impact.customers),
+ 0,
+ ),
+ )}
+ />
+ a + m.lytsIssued, 0),
+ )}
+ align="end"
+ />
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/settings/config/settingsNav.ts b/src/features/settings/config/settingsNav.ts
index f832051..afd72f7 100644
--- a/src/features/settings/config/settingsNav.ts
+++ b/src/features/settings/config/settingsNav.ts
@@ -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',
diff --git a/src/shared/components/brand/BrandLogo.tsx b/src/shared/components/brand/BrandLogo.tsx
index 555565c..f94dfb7 100644
--- a/src/shared/components/brand/BrandLogo.tsx
+++ b/src/shared/components/brand/BrandLogo.tsx
@@ -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 (
-
+ <>
+ {/* 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. */}
+
+
+ >
);
}
diff --git a/src/shared/components/charts/AreaChartView.tsx b/src/shared/components/charts/AreaChartView.tsx
index 44f343f..d47dfe2 100644
--- a/src/shared/components/charts/AreaChartView.tsx
+++ b/src/shared/components/charts/AreaChartView.tsx
@@ -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({
data,
@@ -46,8 +54,8 @@ export function AreaChartView({
x2="0"
y2="1"
>
-
-
+
+
);
})}
diff --git a/src/shared/components/charts/palette.ts b/src/shared/components/charts/palette.ts
index 0ed1513..3ab407a 100644
--- a/src/shared/components/charts/palette.ts
+++ b/src/shared/components/charts/palette.ts
@@ -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. */
diff --git a/src/shared/utils/accent.ts b/src/shared/utils/accent.ts
new file mode 100644
index 0000000..b169e28
--- /dev/null
+++ b/src/shared/utils/accent.ts
@@ -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',
+ },
+};
diff --git a/src/shared/utils/icons.ts b/src/shared/utils/icons.ts
index 3775a16..c144e95 100644
--- a/src/shared/utils/icons.ts
+++ b/src/shared/utils/icons.ts
@@ -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,
diff --git a/src/theme/loyaly.css b/src/theme/loyaly.css
index a433891..143081a 100644
--- a/src/theme/loyaly.css
+++ b/src/theme/loyaly.css
@@ -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 {
diff --git a/src/theme/loyaly.d.ts b/src/theme/loyaly.d.ts
index 7ae1e61..a1fa90b 100644
--- a/src/theme/loyaly.d.ts
+++ b/src/theme/loyaly.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.054Z
+ * Generated: 2026-08-10T08:15:31.620Z
*/
///
diff --git a/src/theme/loyaly.js b/src/theme/loyaly.js
index 5ad18d7..6cbafae 100644
--- a/src/theme/loyaly.js
+++ b/src/theme/loyaly.js
@@ -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)",
diff --git a/src/theme/loyaly.variants.d.ts b/src/theme/loyaly.variants.d.ts
index 4ec9a2f..eaca85b 100644
--- a/src/theme/loyaly.variants.d.ts
+++ b/src/theme/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.616Z
*/
// Generated by astryx theme build
diff --git a/src/theme/loyalyTheme.ts b/src/theme/loyalyTheme.ts
index 12060fb..856d9f7 100644
--- a/src/theme/loyalyTheme.ts
+++ b/src/theme/loyalyTheme.ts
@@ -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)',
},
},