ligth theme design fix

This commit is contained in:
2026-08-10 15:33:13 +05:30
parent 6cc3c9a0b7
commit 5ce580dced
34 changed files with 2399 additions and 87 deletions

View File

@@ -65,6 +65,50 @@ Gray is the accent. The **only** colour permitted is semantic: success, warning,
- `/tokens` (dev-only route) is the audit page. After any theme change, load it and run the
chromatic sweep in the browser console — it should report **0** non-semantic colour nodes.
## The two brand accents (exception to the above)
Store intelligence carries exactly **two** non-semantic hues, and nothing else may add a third.
| | token | light | dark | used for |
|---|---|---|---|---|
| warm | `--color-brand-warm` | `#F4C430` | `#F4C430` | brand, rewards, the activities a merchant runs |
| cool | `--color-brand-cool` | `#7C3AED` | `#A78BFA` | analytics, journeys, AI insight |
- Defined in `src/app/globals.css` in a Tailwind `@theme` block, **not** in `loyalyTheme.ts`.
They are not Astryx tokens: no component variant resolves through them, and putting them in
the theme would let Badge/Banner pick them up as part of the semantic language.
- Three slots per hue. `-ink` is the readable one for text and icons (light mode darkens warm
to `#8A6300`; raw `#F4C430` on white is ~1.7:1). `-soft` is an icon-chip tint only — never a
card background. The base is for non-text marks: chart fills, dots, bars.
- `src/shared/utils/accent.ts` is the only place a hue becomes a class name. Feature files use
`ACCENT[accent].ink` / `.soft`, never `text-brand-warm-ink` by hand.
- Charts stay monochrome by default. `CHART.brand.{warm,cool}` is opt-in per series and is used
on exactly two charts (dashboard Footfall = cool, Revenue = warm). Never make it a ramp
default — `Sparkline` reads `seriesAt(0)`, so that would turn every KPI card's trend line.
- An activity's accent travels on its API payload (`ActivityMetric.accent`), not from grid
position, so an activity is the same colour on every screen.
## Attribution is estimated — say so
Everything downstream of `ActivityImpact.customers` (repeat visits, purchases, revenue) is
**modelled**, not measured. No purchase is joined to a specific spin, selfie or challenge.
- The field is `attributedRevenueInr`, never `revenueInr`, and every payload carries
`attribution: 'estimated' | 'observed'`.
- Copy says *attributed*. Never "generated", "earned" or "drove". A merchant who reads
"Selfie generated ₹3.4L" and spends against it is the failure this rule prevents.
- `<AttributionNote basis={…} />` sits in the header of every panel that shows an attributed
figure, and renders **nothing** when the basis is `'observed'` — so a real attribution
backend retires the disclosure with no copy edit. Read the basis off the payload, never
hardcode it.
- The single sentence lives in `ATTRIBUTION_NOTE` (types/intelligence.ts) so three panels
cannot drift.
## Dashboard supporting analytics are collapsed by default
The six preserved supporting panels (conversion pair, peak hours, reward usage, period rollup,
store comparison) sit inside a `Collapsible`, closed by default, state in
`loyaly.dashboard.supporting-analytics`. Expanded they add ~1,700px desktop / ~3,100px mobile
and push "What needs your attention" — the only actionable section — to 81% of the scroll.
They are evidence, not the finding. Do not re-expand by default; do not delete them either.
## Known upstream issues (Astryx 0.2.0)
- **`useEntryAnimation` breaks SSR hydration.** Its source assumes `'use client'` modules never
run on the server; in the App Router they do. Any `FieldStatus` (i.e. `TextInput`/`TextArea`

View File

@@ -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.

View File

@@ -3,11 +3,10 @@
import {useState} from 'react';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {Collapsible} from '@astryxdesign/core/Collapsible';
import {Text} from '@astryxdesign/core/Text';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ICONS} from '@/shared/utils/icons';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {LineChartView} from '@/shared/components/charts/LineChartView';
@@ -18,14 +17,24 @@ import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline
import {RewardUsageChart} from '@/features/dashboard/components/RewardUsageChart';
import {StoreComparisonPanel} from '@/features/dashboard/components/StoreComparison';
import {PerformancePanel} from '@/features/dashboard/components/PerformancePanel';
import {CustomerActivity} from '@/features/dashboard/components/CustomerActivity';
import {CustomerJourney} from '@/features/dashboard/components/CustomerJourney';
import {CampaignPerformance} from '@/features/dashboard/components/CampaignPerformance';
import {StoreInsights} from '@/features/dashboard/components/StoreInsights';
import {CHART} from '@/shared/components/charts/palette';
import {
useActivityMetrics,
useCampaigns,
useCustomerJourney,
useDashboardActivity,
useDashboardKpis,
useDashboardPeakHours,
useDashboardRewardUsage,
useDashboardStoreComparison,
useDashboardTimeseries,
useStoreInsights,
} from '@/features/dashboard/hooks/useDashboard';
import {usePersistentFlag} from '@/shared/hooks/usePersistentFlag';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {greetingFor} from '@/features/dashboard/services/dashboardService';
@@ -38,25 +47,39 @@ import {
} from '@/shared/utils/format';
/**
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
* Store intelligence, in the order a merchant actually asks the questions:
* what happened → why is it happening → what should I do next.
*
* The page reads top-down as one narrowing question: headline numbers → the two
* trends that drive them → the conversion story behind those → when and on what
* it happens → the period rollup → which store → what just happened.
* what happened the four headline metrics, then the two trends behind them
* why what customers DID (activity), where they stopped
* (journey), which campaigns moved them (campaigns), and the
* supporting analytics for all three
* what next findings with one action each, then the raw event feed
*
* Operational widgets — quick actions, tasks, the AI briefing, top store/reward,
* staff status — deliberately do NOT live here. The briefing endpoint they were
* built on is still live and feeds Loyaly AI's AI tab, which is where that
* class of content belongs: a panel you open to be told what to do, beside a
* dashboard you read to work it out yourself.
* The activity band is the change that makes this a Merchant OS rather than an
* analytics page. Purchases are not the only thing that creates customer
* value: a spin, a challenge, a referral or an event all produce engagement,
* and every one of them is rendered here WITH its downstream chain — customers,
* return visits, purchases — so no activity can be read as a vanity count.
*
* Recent activity keeps its compact form — six rows in a fixed box, full history
* on /activity. The uncapped version reached 784px, taller than any chart on the
* page, which is an operational log outweighing the analytics it sits among.
* What did not change, on purpose: the sticky header, the store and range
* controls, the KPI row, the Footfall and Revenue charts, the conversion pair,
* peak hours, reward usage, the period rollup and store comparison. This is a
* restructure of an already-sound page, not a rebuild — the new sections were
* inserted at the points in the hierarchy where they answer the next question,
* and the existing analytics moved below them because they are the evidence
* for the activity story rather than the story itself.
*
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider, set
* from this page's header. `series` is fetched once and shared by the four
* charts drawn from it, so they cannot disagree.
* Operational widgets — quick actions, tasks, the AI briefing — still do NOT
* live here. "What needs your attention" is not that: it is four findings
* computed from this page's own numbers, each with a single action, which is a
* different thing from a to-do list a merchant has to maintain.
*
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider,
* set from this page's header. `series` is fetched once and shared by the four
* charts drawn from it, and the activity, journey, campaign and insight
* fixtures are all derived from that same series server-side, so no two panels
* on this page can report different numbers for the same fact.
*/
export default function DashboardPage() {
@@ -66,6 +89,13 @@ export default function DashboardPage() {
// dashboard, so Compare starts pressed and switches the panel off, rather
// than hiding a panel that used to be there until someone finds the button.
const [isComparing, setIsComparing] = useState(true);
// Persisted, not component state: a merchant who wants the charts expanded
// wants them expanded tomorrow too, and re-collapsing them on every visit is
// the fastest way to make a disclosure control feel like an obstacle.
const [isSupportingOpen, setSupportingOpen] = usePersistentFlag(
'loyaly.dashboard.supporting-analytics',
false,
);
const kpis = useDashboardKpis();
const series = useDashboardTimeseries();
@@ -73,6 +103,10 @@ export default function DashboardPage() {
const activity = useDashboardActivity();
const rewards = useDashboardRewardUsage();
const comparison = useDashboardStoreComparison();
const activityMetrics = useActivityMetrics();
const journey = useCustomerJourney();
const campaigns = useCampaigns();
const insights = useStoreInsights();
const isAllStores = storeId === 'all';
const scopeLabel = useScopeLabel();
@@ -92,7 +126,16 @@ export default function DashboardPage() {
{/* 3 — headline numbers. */}
<KpiRow resource={kpis} />
{/* 4 — primary analytics: the two series everything else explains. */}
{/*
4 — primary analytics: the two series everything else explains.
These two carry the brand hues and nothing else on the page does at
chart scale. Footfall is cool because it is the system measuring the
store; revenue is warm because it is the outcome the brand is for. One
of each, on the two charts that matter most — that is the whole colour
budget for analytics, and it is why the four charts further down stay
monochrome.
*/}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
{(d) => (
@@ -101,7 +144,9 @@ export default function DashboardPage() {
xKey="t"
xFormat={formatDayLabel}
yFormat={formatCompact}
series={[{key: 'visitors', label: 'Visitors'}]}
series={[
{key: 'visitors', label: 'Visitors', color: CHART.brand.cool},
]}
/>
)}
</ChartCard>
@@ -113,13 +158,57 @@ export default function DashboardPage() {
xKey="t"
xFormat={formatDayLabel}
yFormat={formatInrCompact}
series={[{key: 'revenue', label: 'Revenue'}]}
series={[
{key: 'revenue', label: 'Revenue', color: CHART.brand.warmBar},
]}
/>
)}
</ChartCard>
</Grid>
{/* 5 — secondary analytics: the conversion story, in two readings. */}
{/* 5 — what customers did, and what each activity was worth. Six of ten;
the full ecosystem is on /activity. */}
<CustomerActivity resource={activityMetrics} />
{/* 6 — where that engagement progresses, and where it stops. */}
<CustomerJourney resource={journey} />
{/* 7 — which deliberate campaigns produced the movement above. */}
<CampaignPerformance resource={campaigns} />
{/*
8 — supporting analytics, COLLAPSED BY DEFAULT.
These six panels are preserved exactly as they were; what changed is
their rank. Left expanded they run 1,724px on desktop and roughly
3,000px on a phone, which put "What needs your attention" — the only
section on the page a merchant can act on — at 81% of the scroll,
behind four charts that explain rather than prompt. Evidence should be
available on demand, not stand between the finding and the action.
Collapsed, not deleted, and the choice is remembered: a merchant who
opens it once gets it open every visit thereafter. Nothing is
unreachable and nothing was redesigned.
*/}
<Collapsible
isOpen={isSupportingOpen}
onOpenChange={setSupportingOpen}
trigger={
<VStack gap={0.5} hAlign="start">
{/* Text, not Heading: Collapsible renders the trigger inside a
<button>, and a heading in button content is invalid markup.
The panels within carry their own h2s when expanded. */}
<Text size="base" weight="semibold">
Supporting analytics
</Text>
<Text size="sm" color="secondary">
Conversion, peak hours, reward usage, the period rollup and store
comparison
</Text>
</VStack>
}
>
<VStack gap={5} className="pt-4">
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Visitors vs purchases"
@@ -160,7 +249,7 @@ export default function DashboardPage() {
</ChartCard>
</Grid>
{/* 6 — operational analytics: when traffic lands, what it redeems. */}
{/* 9 — operational analytics: when traffic lands, what it redeems. */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Peak hours"
@@ -174,19 +263,25 @@ export default function DashboardPage() {
<RewardUsageChart resource={rewards} />
</Grid>
{/* 7 — period rollup. */}
{/* 10 — period rollup. */}
<PerformancePanel
granularity={granularity}
onGranularityChange={setGranularity}
/>
{/* 8 — comparing stores is meaningless when scoped to one of them, which
is why Compare is disabled rather than merely off in that case. */}
{/* 11 — comparing stores is meaningless when scoped to one of them, which
is why Compare is disabled rather than merely off in that case. */}
{isAllStores && isComparing ? (
<StoreComparisonPanel resource={comparison} />
) : null}
</VStack>
</Collapsible>
{/* 9 — bounded feed: six rows, fixed box, full history on /activity. */}
{/* 12 — what to do next. Last because it is the conclusion: every claim
it makes is checkable against a panel above it. */}
<StoreInsights resource={insights} />
{/* 13 — bounded feed: six rows, fixed box, full history on /activity. */}
<ActivityTimeline
resource={activity}
subtitle="Latest events across the selected store and period"

View File

@@ -14,6 +14,7 @@ import {BarChartView} from '@/shared/components/charts/BarChartView';
import {RewardGrid} from '@/features/lyts/components/RewardGrid';
import {ExpiryAlerts} from '@/features/lyts/components/ExpiryAlerts';
import {RewardPerformanceTable} from '@/features/lyts/components/RewardPerformanceTable';
import {ActivityProgramme} from '@/features/lyts/components/ActivityProgramme';
import {ActivityTimeline} from '@/features/dashboard/components/ActivityTimeline';
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
import {
@@ -32,14 +33,23 @@ import {
} from '@/shared/utils/format';
/**
* The LYT programme.
* The LYT programme — activities and the rewards they pay out in.
*
* Ordered by urgency rather than by data type: what's expiring, then the
* headline numbers, then the catalogue, then the analysis. A merchant opening
* this page is usually here because something needs extending or pausing.
* headline numbers, then what customers can DO to earn, then the catalogue of
* what they spend on, then the analysis. A merchant opening this page is
* usually here because something needs extending or pausing.
*
* 1 LYT = ₹1, so "outstanding" figures are simultaneously a count and a
* rupee liability — which is why they lead.
*
* ── The activity half ─────────────────────────────────────────────────────
* Activities sit here, above rewards, because they are the earning side of
* the same ledger: an activity issues LYTs, a reward spends them, and a
* merchant tuning the programme is trading one against the other. The
* dashboard shows six activities as a summary of engagement; this page is
* where all ten are managed, and it reads the SAME activity model — see
* ActivityProgramme.
*/
export default function LytsPage() {
const rewards = useRewards();
@@ -63,7 +73,7 @@ export default function LytsPage() {
<VStack gap={5}>
<PageHeader
title="Lyts"
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
description="Customer activities and rewards — what earns LYTs, what spends them, and what is still outstanding. 1 LYT = ₹1."
controls={<ScopeControls />}
/>
@@ -136,6 +146,12 @@ export default function LytsPage() {
}}
</AsyncBoundary>
{/*
The earning side, before the spending side. All ten activities, their
status and their LYT cost — the same model the dashboard summarises.
*/}
<ActivityProgramme />
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Redemption"

View File

@@ -0,0 +1,17 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildActivityMetrics} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(
wantsEmpty(q) ? [] : buildActivityMetrics(q.range, q.storeId, q.nowMs),
q,
);
}

View File

@@ -0,0 +1,14 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildCampaigns} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildCampaigns(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -0,0 +1,20 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildInsights} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
/**
* Separate from /briefing on purpose. The briefing is Loyaly AI's narrative
* for the panel; these are store-intelligence findings computed from the
* activity layer, and the dashboard must be able to render one without
* pulling in the other.
*/
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildInsights(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -0,0 +1,14 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildJourney} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildJourney(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -19,6 +19,50 @@
@import '@astryxdesign/core/tailwind-theme.css';
@import 'tailwindcss/utilities.css' layer(utilities);
/*
* The two-accent brand system.
*
* The workspace is monochrome by default and stays that way — see CLAUDE.md.
* These are the ONLY two hues that are not semantic, and they exist because
* store intelligence has two distinct voices to separate: what the brand gives
* the customer (rewards, offers, the activities a merchant runs) and what the
* system tells the merchant back (analytics, journeys, AI insight). Warm is
* the first, cool is the second. Nothing else may introduce a hue.
*
* Declared here rather than in loyalyTheme.ts on purpose: these are NOT Astryx
* tokens. Nothing in the design system reads them, no component variant
* resolves through them, and putting them in the theme would let Badge/Banner
* pick them up as if they were part of the semantic language.
*
* Each hue has three slots, because one hex cannot do three jobs:
* base the mark itself — chart fill, dot, bar. Non-text, so 3:1 is the bar.
* ink text and icons. #F4C430 on white is ~1.7:1, so light mode darkens
* it to an amber that clears 4.5:1. Dark mode can use the raw hue.
* soft a tint background for an icon chip. Never for a whole card.
*
* light-dark() rather than a media query: color-scheme is resolved on <html>
* from the theme cookie, so these follow the same switch every Astryx token
* does, including system mode.
*/
@theme {
--color-brand-warm: light-dark(#f4c430, #f4c430);
--color-brand-warm-ink: light-dark(#8a6300, #f4c430);
--color-brand-warm-soft: light-dark(#f4c4302e, #f4c4301f);
/*
* The bar-fill slot. Thirty solid #F4C430 bars across a white card is a lot
* of saturated area — the chart shouted louder than the KPI row above it.
* Same hue, less coverage: 72% over #FFFFFF lands on a soft gold that still
* reads as Loyaly yellow at a glance. Dark keeps full strength, where alpha
* over near-black would mute the hue into brown rather than soften it.
*/
--color-brand-warm-bar: light-dark(#f4c430b8, #f4c430);
--color-brand-cool: light-dark(#7c3aed, #a78bfa);
--color-brand-cool-ink: light-dark(#6d28d9, #a78bfa);
--color-brand-cool-soft: light-dark(#7c3aed1f, #a78bfa1f);
}
/*
* Paint the canvas before React mounts. AppShell sets these itself once it
* renders, but this guarantees the very first frame — and any non-shell route
@@ -48,6 +92,94 @@ html[data-theme='light'] {
}
@layer components {
/*
* The brand lockup, one file per theme.
*
* /white-logo.png is a WHITE wordmark. On the light theme's #FFFFFF sidebar
* it was invisible — only the yellow heart survived, so the product had no
* readable name in half its themes. /brand/loyaly-logo.png is the shipped
* counterpart: same lockup, same yellow heart, dark wordmark. No new asset
* and no CSS filter — the correct variant already existed and simply was
* never wired up.
*
* BrandLogo renders BOTH and lets the cascade choose, because an <img src>
* cannot be a light-dark() value and resolving it in React would need the
* theme resolved on the server (mode may be the literal 'system').
*
* The three-way selector mirrors the color-scheme block above exactly:
* bare rules are the light default, the media query handles system mode,
* and an explicit data-theme wins over both. Anything less than all three
* leaves system-mode users on the wrong mark.
*
* `display` is set HERE rather than with a `block` utility on the element:
* utilities sit in a later layer and would beat `display: none`, so the
* hidden variant would paint anyway.
*/
.brand-lockup-light {
display: block;
}
.brand-lockup-dark {
display: none;
}
@media (prefers-color-scheme: dark) {
.brand-lockup-light {
display: none;
}
.brand-lockup-dark {
display: block;
}
}
html[data-theme='light'] .brand-lockup-light,
html[data-theme='dark'] .brand-lockup-dark {
display: block;
}
html[data-theme='light'] .brand-lockup-dark,
html[data-theme='dark'] .brand-lockup-light {
display: none;
}
/*
* Nested card surface — the activity tiles inside a panel.
*
* `Card variant="muted"` paints #F1F1F2 with NO border. On the dark theme
* that is right: #202124 on a #171717 panel is a clean raised step. On the
* light theme it is a grey block sitting on a white panel, with no edge —
* which is exactly the "too grey, too flat" reading, and no amount of
* spacing fixes it because the fill IS the separator.
*
* So light mode swaps the fill for the card white and separates with the
* #E5E5E5 hairline instead. Same component, same token vocabulary; the
* light theme gets contrast from an edge and the dark theme keeps getting
* it from a fill, because that is what each background can actually carry.
* Dark is byte-for-byte what it was.
*/
.card-nested {
background-color: var(--color-background-card);
border: 1px solid var(--color-border);
}
@media (prefers-color-scheme: dark) {
.card-nested {
background-color: var(--color-background-muted);
border-color: transparent;
}
}
html[data-theme='light'] .card-nested {
background-color: var(--color-background-card);
border-color: var(--color-border);
}
html[data-theme='dark'] .card-nested {
background-color: var(--color-background-muted);
border-color: transparent;
}
/*
* Global Data Table Inset Contract: 24px left lead-in, 32px right inset.
*
@@ -91,6 +223,21 @@ html[data-theme='light'] {
stroke-width: 2.2px !important;
}
/* Selected item contrast fix: ensure text & icons inside selected/active items remain high-contrast and legible */
.astryx-item[aria-selected='true'],
.astryx-item[aria-current='true'],
.astryx-item[aria-current],
.astryx-item.is-selected {
color: var(--color-text-primary) !important;
}
.astryx-item[aria-selected='true'] svg,
.astryx-item[aria-current='true'] svg,
.astryx-item[aria-current] svg,
.astryx-item.is-selected svg {
color: var(--color-text-primary) !important;
}
/* Kill switch for our own CSS transitions. Framer Motion is handled by
<MotionConfig reducedMotion="user">, recharts by useChartMotion(). */
@media (prefers-reduced-motion: reduce) {

View File

@@ -0,0 +1,146 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {MetricDelta} from '@/shared/components/primitives/MetricDelta';
import {ACCENT} from '@/shared/utils/accent';
import {ICONS} from '@/shared/utils/icons';
import {formatCompact, formatCount, formatLyt} from '@/shared/utils/format';
import {ACTIVITY_ICON, impactChain} from '@/features/dashboard/services/activityService';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
/**
* One activity, at a weight the dashboard can afford.
*
* DELIBERATELY QUIETER THAN A KPI CARD. It is `variant="muted"` inside a panel
* rather than a white card on the page, the number is `display-3` against the
* KPI row's own display-3 but in a card a third the height, and there is no
* sparkline. Six activities rendered at KPI weight would out-shout the four
* metrics that actually describe the business — the same reasoning that made
* store comparison small multiples instead of five full charts.
*
* THE CHAIN IS NOT OPTIONAL. Every card ends with at least
* "N customers → N purchases", because an activity count on its own is a
* vanity metric: 724 spins is not a fact a merchant can do anything with until
* they know what it was worth. `detail` opens the chain up to its full length
* for the analytics page, where there is room for the middle of the story.
*/
/**
* Status is a DOT, not a badge.
*
* Ten filled "Live" pills down a catalogue is a wall of green — the loudest
* thing on a page whose subject is the numbers beside them, and a third hue
* competing with the two the brand actually owns. An 8px dot carries the same
* three states at a fraction of the ink, which is the project's own rule:
* status → StatusDot, badge → counts and enumerated states.
*/
const STATUS: Record<
ActivityMetric['status'],
{label: string; variant: 'success' | 'warning' | 'neutral'}
> = {
live: {label: 'Live', variant: 'success'},
paused: {label: 'Paused', variant: 'warning'},
draft: {label: 'Draft', variant: 'neutral'},
};
export function ActivityCard({
metric,
variant = 'summary',
}: {
metric: ActivityMetric;
/**
* `summary` — the dashboard's six. Count, delta, and the two ends of the
* impact chain. No status: a summary of six live activities does not need
* six "Live" badges competing with the numbers.
*
* `catalogue` — the Lyts ecosystem view. Adds what a merchant MANAGES
* rather than reads: whether it is running, what it costs in LYTs, and the
* full chain including reward claims and repeat visits.
*/
variant?: 'summary' | 'catalogue';
}) {
const accent = ACCENT[metric.accent];
const isCatalogue = variant === 'catalogue';
const chain = impactChain(metric, {compact: !isCatalogue});
const status = STATUS[metric.status];
// `card-nested` keeps the dark theme's raised fill and gives the light theme
// a white surface with a hairline edge instead — see globals.css.
return (
<Card variant="muted" className="card-nested">
<VStack gap={2}>
<HStack gap={2} vAlign="center" hAlign="between">
<HStack gap={2} vAlign="center" className="min-w-0">
{/*
The only place the brand hue appears on this card. A tinted chip
at 16px is enough to identify the activity at a glance; tinting
the card itself is how a grid of six turns into a paint chart.
*/}
<HStack
className={`${accent.soft} rounded-md p-1.5 shrink-0`}
vAlign="center"
>
<Icon
icon={ACTIVITY_ICON[metric.id]}
size="sm"
className={accent.ink}
/>
</HStack>
<Text size="sm" weight="medium" className="truncate">
{metric.label}
</Text>
</HStack>
{isCatalogue ? (
<HStack gap={1.5} vAlign="center" className="shrink-0">
<StatusDot variant={status.variant} label={status.label} />
<Text size="xsm" color="secondary">
{status.label}
</Text>
</HStack>
) : (
<MetricDelta value={metric.deltaPct} size="xsm" />
)}
</HStack>
{/* Data, not a section title — Text rather than Heading, so a grid of
activity counts stays out of the document outline. */}
<HStack gap={2} vAlign="center" hAlign="between" wrap="wrap">
<Text type="display-3">{formatCompact(metric.count)}</Text>
{/* The catalogue traded its delta for the status badge above, so the
trend comes back down here — a merchant deciding whether to keep
an activity running needs both. */}
{isCatalogue ? (
<MetricDelta value={metric.deltaPct} size="xsm" />
) : null}
</HStack>
{isCatalogue ? (
<Text size="xsm" color="secondary">
{metric.description}
</Text>
) : null}
<Text size="xsm" color="secondary">
{chain
.map((s) => `${formatCount(s.value)} ${s.label}`)
.join(' → ')}
</Text>
{/* The LYT link, and the one number on this card that is MEASURED
rather than attributed — the ledger knows what it issued. Omitted
where the activity grants nothing, rather than shown as zero. */}
{isCatalogue && metric.lytsIssued > 0 ? (
<HStack gap={1} vAlign="center">
<Icon icon={ICONS.lyt} size="xsm" className={accent.ink} />
<Text size="xsm" color="secondary">
{formatLyt(metric.lytsIssued)} issued
</Text>
</HStack>
) : null}
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ActivityCard} from './ActivityCard';
import {AttributionNote} from './AttributionNote';
import {ACTIVITY_GROUPS} from '@/features/dashboard/services/activityService';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* The complete ecosystem, grouped by the question each group answers.
*
* Ten activities laid out as one flat grid is a list of features. Split three
* ways it becomes a diagnosis: engagement says what customers are doing,
* growth says what is bringing people in, commerce says whether either turned
* into business. A merchant with a flat conversion number and a rising
* engagement number knows which of the three to look at.
*
* One resource, three panels. The panels share a single fetch, so the groups
* can never disagree about a total, and a group with nothing in it renders its
* own empty state rather than leaving a headed card with a void under it.
*/
export function ActivityGroups({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<VStack gap={5}>
{ACTIVITY_GROUPS.map((group) => (
<PanelCard
key={group.id}
title={group.label}
subtitle={group.question}
actions={<AttributionNote basis={basis} />}
resource={resource}
loading={<SkeletonCardGrid count={4} height={128} minWidth={220} />}
empty={
<EmptyPanel
icon="activityVisit"
title={`No ${group.label.toLowerCase()} activity`}
description="Activities appear here once customers start taking part."
/>
}
>
{(metrics) => (
<Grid columns={{minWidth: 220, repeat: 'fit'}} gap={3}>
{metrics
.filter((m) => m.group === group.id)
.map((m) => (
// `catalogue` — this is the management view, so each card
// adds what the dashboard summary has no room for: status,
// LYTs issued, and the full impact chain.
<ActivityCard key={m.id} metric={m} variant="catalogue" />
))}
</Grid>
)}
</PanelCard>
))}
</VStack>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import {proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ACCENT} from '@/shared/utils/accent';
import {formatCount, formatInrCompact, formatPct} from '@/shared/utils/format';
import {
ACTIVITY_ICON,
conversionPct,
} from '@/features/dashboard/services/activityService';
import {AttributionNote} from './AttributionNote';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Every activity's chain, side by side.
*
* The cards above answer "how is Spin doing?". This answers the question they
* cannot: "which activity is worth running?" — and that is a comparison across
* rows, which is a table. Ten small charts would say the same thing in ten
* times the space and still not let anyone rank the last column.
*
* Rows are ordered by the END of the chain, not the start. Sorting by
* interactions puts Walk on top, which is the activity that converts worst;
* sorting by attributed revenue puts the table in the order a merchant would
* spend their next hour in.
*/
/** A flattened row. The table generic needs an index signature; the domain
* type deliberately does not have one. */
interface ImpactRow extends Record<string, unknown> {
id: string;
label: string;
accent: ActivityMetric['accent'];
activityId: ActivityMetric['id'];
count: number;
purchases: number;
conversion: number;
revenueInr: number;
}
function toRows(metrics: ActivityMetric[]): ImpactRow[] {
return metrics
.map((m) => ({
id: m.id,
label: m.label,
accent: m.accent,
activityId: m.id,
count: m.count,
purchases: m.impact.purchases,
conversion: conversionPct(m),
revenueInr: m.impact.attributedRevenueInr,
}))
.sort((a, b) => b.revenueInr - a.revenueInr);
}
const num = (v: number) => (
<Text size="sm" className="tabular-nums">
{formatCount(v)}
</Text>
);
const COLUMNS: TableColumn<ImpactRow>[] = [
{
key: 'label',
header: 'Activity',
width: proportional(1),
// Label only. The one-line description belongs on the cards above, where
// there is width for it; in a cell 120px wide it wrapped to four lines and
// tripled the height of every row in a table whose whole job is to be
// scanned down a column.
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Icon
icon={ACTIVITY_ICON[row.activityId]}
size="sm"
className={ACCENT[row.accent].ink}
/>
<Text size="sm" weight="medium">
{row.label}
</Text>
</HStack>
),
},
{
key: 'count',
// "Count", not "Interactions". Astryx table headers are nowrap + ellipsis,
// so a header longer than its column is silently truncated to "Interacti…"
// at EVERY width, because these columns are fixed px. The subtitle already
// says what is being counted; the header only has to say which column it
// is, and a short one lets the whole set fit the 525px the panel gives it
// with Loyaly AI open.
header: 'Count',
align: 'end',
// No delta chip. It cost ~55px, and with Loyaly AI open the content column
// is ~625px — enough width that the LAST column, the attributed revenue
// this table is sorted by, fell off the edge behind an internal scrollbar.
// The trend is already on every card above; the ranking is only here.
width: pixel(80),
renderCell: (row) => num(row.count),
},
/*
* FIVE columns, and the number is measured rather than chosen. Inside a
* panel card with Loyaly AI open, the table gets 525px: 24px card padding
* each side, plus the 24/32px cell inset contract from globals.css. Six
* columns needed 580 and pushed `Attributed` — the column the table is
* SORTED by — behind an internal scrollbar, which makes the ranking
* invisible at exactly the width most merchants use.
*
* So the two intermediate chain steps are dropped here: customers and
* repeat visits are on every card above and in the phone fallback. What
* survives is what ranking needs — how much happened, what it produced,
* how efficiently, and what it was worth.
*/
{
key: 'purchases',
header: 'Purchases',
align: 'end',
width: pixel(105),
renderCell: (row) => num(row.purchases),
},
{
key: 'conversion',
header: 'Converted',
align: 'end',
width: pixel(100),
renderCell: (row) => (
<Text size="sm" weight="medium" className="tabular-nums">
{formatPct(row.conversion, 0)}
</Text>
),
},
{
key: 'revenueInr',
// "Revenue" would be a claim this data cannot support. The column ranks
// the table, so it is the one header that most needs to be honest.
header: 'Attributed',
align: 'end',
width: pixel(112),
renderCell: (row) => (
<Text size="sm" weight="semibold" className="tabular-nums">
{formatInrCompact(row.revenueInr)}
</Text>
),
},
];
export function ActivityImpactTable({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<PanelCard
title="Activity impact"
subtitle="Interactions through to estimated attributed revenue, highest first"
resource={resource}
loading={<SkeletonRows count={6} height={44} />}
actions={<AttributionNote basis={basis} />}
empty={
<EmptyPanel
icon="analytics"
title="Nothing to compare yet"
description="Once two or more activities have run, their conversion can be ranked here."
/>
}
>
{(metrics) => (
<ResponsiveTable
data={toRows(metrics)}
columns={COLUMNS}
idKey="id"
primaryKey="label"
density="balanced"
// Conversion earns its place on the phone card: it is the one field
// that ranks activities against each other, which is the whole
// reason this table exists. Repeat visits is the one dropped.
summaryKeys={['count', 'purchases', 'conversion', 'revenueInr']}
/>
)}
</PanelCard>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import {HStack} from '@astryxdesign/core/Layout';
import {Icon} from '@astryxdesign/core/Icon';
import {Tooltip} from '@astryxdesign/core/Tooltip';
import {ATTRIBUTION_NOTE} from '@/features/dashboard/types/intelligence';
import type {AttributionBasis} from '@/features/dashboard/types/intelligence';
/**
* The disclosure that has to sit beside any attributed figure.
*
* Every downstream number in the activity chain — repeat visits, purchases,
* revenue — is MODELLED from observed footfall and purchase patterns. None of
* it joins a till receipt to a specific spin or selfie. Rendering ₹6.5L next
* to "Selfie" without saying so invites a merchant to read it as "selfies
* earned me ₹6.5L", make a spend decision on it, and lose trust in the whole
* product when the till disagrees.
*
* So the panels that show attributed figures carry this mark, and the copy
* around them says "attributed", never "generated" or "earned".
*
* It renders NOTHING when the basis is `'observed'`. That is the point of the
* flag: when a backend arrives that can join purchases to activity events, the
* disclosure retires itself with no copy edit and no component removal.
*
* A quiet secondary glyph, not a warning — this is a footnote about method,
* and an amber icon would rank it above the data it annotates.
*/
export function AttributionNote({basis}: {basis: AttributionBasis}) {
if (basis === 'observed') return null;
return (
<Tooltip content={ATTRIBUTION_NOTE}>
{/*
Focusable, so the note is reachable by keyboard and not only by hover —
it is the only place the estimation is explained, which makes it
content rather than decoration.
*/}
<HStack tabIndex={0} vAlign="center" className="rounded-sm">
<Icon icon="info" size="sm" color="secondary" label="How this is calculated" />
</HStack>
</Tooltip>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Badge} from '@astryxdesign/core/Badge';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ACCENT} from '@/shared/utils/accent';
import {formatCount, formatInrCompact} from '@/shared/utils/format';
import {ACTIVITY_ICON} from '@/features/dashboard/services/activityService';
import {AttributionNote} from './AttributionNote';
import type {
CampaignStatus,
CampaignSummary,
} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* A campaign is a funnel with a name, so each one is a ROW, not a chart.
*
* The temptation here is a second analytics module — participation over time,
* a conversion gauge per campaign, a comparison bar. That is four more charts
* for a question with one shape: how many took part, how many came back, how
* many bought, what it earned. Four numbers fit on a row, and four rows fit in
* the space one chart would have taken.
*/
/**
* `live` is the only status a merchant can still act on, so it is the only one
* that gets a filled badge. Ended and scheduled are stated, not highlighted.
*/
const STATUS: Record<
CampaignStatus,
{label: string; variant: 'success' | 'neutral'}
> = {
live: {label: 'Live', variant: 'success'},
ended: {label: 'Ended', variant: 'neutral'},
scheduled: {label: 'Scheduled', variant: 'neutral'},
};
function CampaignRow({campaign}: {campaign: CampaignSummary}) {
const accent = ACCENT[campaign.accent];
const status = STATUS[campaign.status];
return (
<HStack
gap={3}
vAlign="start"
paddingBlock={2}
paddingInline={2}
className="rounded-md transition-colors hover:bg-overlay-hover"
>
<HStack className={`${accent.soft} rounded-md p-1.5 shrink-0`} vAlign="center">
<Icon
icon={ACTIVITY_ICON[campaign.activityId]}
size="sm"
className={accent.ink}
/>
</HStack>
<VStack gap={1} width="100%">
<HStack gap={3} hAlign="between" vAlign="center" wrap="wrap">
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{campaign.name}
</Text>
<Badge variant={status.variant} label={status.label} />
</HStack>
{/*
The row's outcome, at the end where the eye lands last — the same
place a receipt puts it. And that is exactly why it cannot be a
bare rupee figure: "Weekend Challenge … ₹5L" reads as money the
campaign earned. It is money attributed to it, so the word rides
with the number rather than living only in a tooltip.
*/}
<HStack gap={1} vAlign="center">
<Text size="sm" weight="semibold">
{formatInrCompact(campaign.attributedRevenueInr)}
</Text>
<Text size="xsm" color="secondary">
{campaign.attribution === 'estimated' ? 'attributed (est.)' : 'attributed'}
</Text>
</HStack>
</HStack>
<Text size="sm" color="secondary">
{campaign.steps
.map((s) => `${formatCount(s.value)} ${s.label.toLowerCase()}`)
.join(' → ')}
</Text>
</VStack>
</HStack>
);
}
export function CampaignPerformance({
resource,
}: {
resource: Resource<CampaignSummary[]>;
}) {
const basis = resource.data?.[0]?.attribution ?? 'estimated';
return (
<PanelCard
title="Campaign performance"
subtitle="Participants through to attributed revenue, per campaign"
resource={resource}
loading={<SkeletonRows count={4} height={52} />}
actions={<AttributionNote basis={basis} />}
empty={
<EmptyPanel
icon="campaign"
title="No campaigns running"
description="Challenges, referral drives and events show their funnel here once they go live."
/>
}
>
{(campaigns) => (
<VStack gap={0}>
{campaigns.map((c) => (
<CampaignRow key={c.id} campaign={c} />
))}
</VStack>
)}
</PanelCard>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import {Grid} from '@astryxdesign/core/Grid';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ActivityCard} from './ActivityCard';
import {AttributionNote} from './AttributionNote';
import type {ActivityMetric} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* The dashboard's activity summary — six of the ten, and no more.
*
* The full ecosystem is ten activities. Rendering all ten here, at equal
* weight, is how a dashboard becomes a legend: the merchant is asked to hold
* ten categories in their head before they have learned what any single one is
* worth. So the payload carries all ten, this panel filters to the featured
* six, and "View all activity" leads to the page that has room for the rest.
*
* One panel, not six cards on the page. The section reads as a single band of
* secondary information sitting under the primary metrics and the two headline
* charts, which is exactly its rank in the hierarchy.
*/
export function CustomerActivity({
resource,
}: {
resource: Resource<ActivityMetric[]>;
}) {
// Read off the payload rather than hardcoded, so the disclosure retires
// itself the day a backend returns 'observed'.
const basis = resource.data?.[0]?.impact.attribution ?? 'estimated';
return (
<PanelCard
title="Customer activity"
subtitle="What customers did, and what it is estimated to have been worth"
resource={resource}
loading={<SkeletonCardGrid count={6} height={116} minWidth={200} />}
actions={
<>
{/* Every figure after "customers" on these cards is attributed, not
measured. The note says so once for the whole panel. */}
<AttributionNote basis={basis} />
{/*
/lyts, not /activity. The full ecosystem is the Lyts page's job —
all ten activities with their status and LYT cost — and /activity
is the raw event log, which is a different question. Sending "view
all" to the log would answer "what happened at 14:32" when the
merchant asked "what else can customers do".
*/}
<Button
variant="ghost"
size="sm"
label="View all activity"
href="/lyts"
/>
</>
}
empty={
<EmptyPanel
icon="activityVisit"
title="No activity recorded"
description="Visits, spins, challenges and referrals appear here once customers start taking part."
/>
}
>
{(metrics) => (
<Grid columns={{minWidth: 200, repeat: 'fit'}} gap={3}>
{metrics
.filter((m) => m.isFeatured)
.map((m) => (
<ActivityCard key={m.id} metric={m} />
))}
</Grid>
)}
</PanelCard>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import {Fragment} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Badge} from '@astryxdesign/core/Badge';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ICONS} from '@/shared/utils/icons';
import {ACCENT} from '@/shared/utils/accent';
import {formatCompact, formatPct} from '@/shared/utils/format';
import type {JourneyStage} from '@/features/dashboard/types/intelligence';
import type {Resource} from '@/shared/hooks/useResource';
/**
* Visit → Engage → Purchase → Return → Refer, as five numbers and four arrows.
*
* NOT a Sankey, and not a node graph. A funnel of five stages carries exactly
* five numbers and four ratios; a flow diagram spends 400px of vertical space
* and a chart library rendering the same nine facts, and the merchant still
* has to read the labels to know which band is which. The horizontal strip
* says it in one line.
*
* The panel's actual job is the WEAKEST link, so it is called out rather than
* left to be spotted: the stage with the lowest conversion from its
* predecessor gets the badge. Everything else on this strip is context for
* that one finding.
*/
export function CustomerJourney({
resource,
}: {
resource: Resource<JourneyStage[]>;
}) {
return (
<PanelCard
title="Customer journey"
subtitle="Where customers progress, and where they stop"
resource={resource}
loading={<SkeletonRows count={2} height={56} />}
empty={
<EmptyPanel
icon="journey"
title="No journey to plot"
description="Once visits and purchases are recorded the progression appears here."
/>
}
>
{(stages) => {
// The weakest step, measured against the stage before it. The first
// stage has nothing to convert from and can never be the answer.
const weakest = stages.reduce<JourneyStage | undefined>(
(worst, s) =>
s.conversionPct === undefined
? worst
: worst?.conversionPct === undefined ||
s.conversionPct < worst.conversionPct
? s
: worst,
undefined,
);
return (
<HStack gap={4} vAlign="start" wrap="wrap">
{stages.map((stage, i) => (
<Fragment key={stage.id}>
{i === 0 ? null : (
// Decorative: the reading order already carries the
// progression, so the arrow is not announced.
//
// Hidden below `sm`, where the strip wraps to two per row and
// a connector ends up pointing at the start of a line or at
// the stage above it — an arrow that lies about the order is
// worse than no arrow, and stacked cards already read as a
// sequence.
<HStack className="hidden pt-6 sm:flex" vAlign="center">
<Icon
icon={ICONS.arrowRight}
size="sm"
className={ACCENT.cool.ink}
/>
</HStack>
)}
<VStack gap={1}>
<Text size="sm" color="secondary">
{stage.label}
</Text>
<Text type="display-3">{formatCompact(stage.value)}</Text>
{stage.conversionPct === undefined ? (
<Text size="xsm" color="secondary">
Everyone starts here
</Text>
) : stage.id === weakest?.id ? (
<Badge
variant="warning"
label={`${formatPct(stage.conversionPct, 0)} · biggest drop-off`}
/>
) : (
<Text size="xsm" color="secondary">
{formatPct(stage.conversionPct, 0)} of previous
</Text>
)}
</VStack>
</Fragment>
))}
</HStack>
);
}}
</PanelCard>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Icon} from '@astryxdesign/core/Icon';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ICONS} from '@/shared/utils/icons';
import {ACCENT} from '@/shared/utils/accent';
import type {Insight, InsightSeverity} from '@/features/dashboard/types/dashboard';
import type {IconKey} from '@/shared/utils/icons';
import type {IconColor} from '@astryxdesign/core/Icon';
import type {Resource} from '@/shared/hooks/useResource';
/**
* What needs your attention — findings, not a chat box.
*
* The thing that makes an AI panel get ignored is a paragraph of narration
* with nothing at the end of it. So the contract here is strict: every row
* states a number the merchant can check against the panels above, says what
* it means, and ends in ONE action. An insight with no action does not belong
* on this panel; it belongs in whichever chart already shows it.
*
* Severity earns the only colour on the row. A warning is amber because a
* warning is one of the three semantic states; everything else is a cool-tinted
* bulb, which is this product's mark for "the system worked this out" and is
* the same hue the analytics carry.
*/
const SEVERITY: Record<
InsightSeverity,
{icon: IconKey; color?: IconColor; className?: string}
> = {
error: {icon: 'alert', color: 'error'},
warning: {icon: 'alert', color: 'warning'},
success: {icon: 'leaderboard', color: 'success'},
info: {icon: 'insight', className: ACCENT.cool.ink},
};
function InsightRow({insight}: {insight: Insight}) {
const tone = SEVERITY[insight.severity];
return (
<HStack
gap={3}
vAlign="start"
paddingBlock={2}
paddingInline={2}
className="rounded-md transition-colors hover:bg-overlay-hover"
>
<HStack paddingBlock={0.5}>
<Icon
icon={ICONS[tone.icon]}
size="sm"
color={tone.color}
className={tone.className}
label={insight.severity}
/>
</HStack>
<VStack gap={1} width="100%">
<Text size="sm" weight="medium">
{insight.title}
</Text>
<Text size="sm" color="secondary">
{insight.body}
</Text>
{insight.action ? (
<HStack>
<Button
variant="secondary"
size="sm"
label={insight.action.label}
href={insight.action.href}
/>
</HStack>
) : null}
</VStack>
</HStack>
);
}
export function StoreInsights({resource}: {resource: Resource<Insight[]>}) {
return (
<PanelCard
title="What needs your attention"
subtitle="Read from this period's activity, most urgent first"
resource={resource}
loading={<SkeletonRows count={3} height={72} />}
empty={
<EmptyPanel
icon="insight"
title="Nothing needs attention"
description="Findings appear here when an activity's performance moves enough to act on."
/>
}
>
{(insights) => (
<VStack gap={0}>
{insights.map((i) => (
<InsightRow key={i.id} insight={i} />
))}
</VStack>
)}
</PanelCard>
);
}

View File

@@ -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));
}

View File

@@ -0,0 +1,491 @@
import type {Insight} from '@/features/dashboard/types/dashboard';
import type {
ActivityId,
ActivityMetric,
CampaignSummary,
JourneyStage,
} from '@/features/dashboard/types/intelligence';
import type {RangeKey} from '@/shared/types/api';
import {createRng} from '@/shared/mock/rng';
import {buildTimeseries} from './dashboard.mock';
/**
* Activity fixtures, DERIVED rather than invented.
*
* Every number here is anchored to buildTimeseries() — the same generator the
* Footfall and Revenue charts read. That is not tidiness: an activity layer
* generated independently would let the dashboard claim 1,248 visitors in one
* card and 3,400 visits in the card directly below it, and no merchant would
* trust either number again. Activity counts are a share of real visitors,
* journey stages are the real funnel, and campaign rows are slices of the
* activity they run on.
*
* The chain is also enforced by construction: customers ≤ interactions,
* repeat visits ≤ customers, purchases ≤ repeat visits. A fixture that can
* produce more purchases than participants would make the funnel UI render
* a widening cone, and the bug would look like a design problem.
*/
interface ActivitySeed {
id: ActivityId;
label: string;
description: string;
group: ActivityMetric['group'];
accent: ActivityMetric['accent'];
isFeatured: boolean;
/** Interactions per visitor. Above 1 means a visitor does it more than once. */
perVisitor: number;
/** Interactions per distinct customer — the repeat rate within the activity. */
intensity: number;
/** Fraction of participants who come back afterwards. */
returnRate: number;
/** Fraction of returning participants who then buy. */
buyRate: number;
/** Whether taking part can grant a reward. */
hasRewards: boolean;
/** LYTs granted per participating customer, on average. 0 where none. */
lytsPerCustomer: number;
status: ActivityMetric['status'];
}
/**
* The ecosystem, in reading order.
*
* Accents alternate deliberately and are fixed per activity: warm is what the
* brand gives the customer, cool is what the system measures back. Two hues
* across ten rows is what keeps this from becoming a colour-coded legend
* nobody can hold in their head.
*
* `isFeatured` is what the dashboard shows. Six is the ceiling — the summary
* exists to be glanced at, and ten small cards is a second dashboard.
*/
const CATALOG: ActivitySeed[] = [
{
id: 'walk',
label: 'Walk',
description: 'Passers-by detected near the store',
group: 'engagement',
accent: 'warm',
isFeatured: false,
perVisitor: 1.9,
intensity: 1.4,
returnRate: 0.11,
buyRate: 0.34,
hasRewards: false,
lytsPerCustomer: 0,
status: 'live',
},
{
id: 'visit',
label: 'Visit',
description: 'Customers who checked in at the store',
group: 'engagement',
accent: 'warm',
isFeatured: true,
perVisitor: 1.0,
intensity: 1.7,
returnRate: 0.31,
buyRate: 0.52,
hasRewards: false,
lytsPerCustomer: 12,
status: 'live',
},
{
id: 'selfie',
label: 'Selfie',
description: 'In-store photos shared to the feed',
group: 'engagement',
accent: 'cool',
isFeatured: true,
perVisitor: 0.3,
intensity: 1.8,
returnRate: 0.32,
buyRate: 0.47,
hasRewards: true,
lytsPerCustomer: 25,
status: 'live',
},
{
id: 'spin',
label: 'Spin',
description: 'Reward wheel plays',
group: 'engagement',
accent: 'warm',
isFeatured: true,
perVisitor: 0.58,
intensity: 1.76,
returnRate: 0.23,
buyRate: 0.43,
hasRewards: true,
lytsPerCustomer: 40,
status: 'live',
},
{
id: 'scratch',
label: 'Scratch',
description: 'Scratch cards opened',
group: 'engagement',
accent: 'cool',
isFeatured: false,
perVisitor: 0.36,
intensity: 1.5,
returnRate: 0.19,
buyRate: 0.38,
hasRewards: true,
lytsPerCustomer: 30,
status: 'live',
},
{
id: 'challenge',
label: 'Challenge',
description: 'Multi-step tasks completed',
group: 'engagement',
accent: 'cool',
isFeatured: true,
perVisitor: 0.17,
intensity: 1.2,
returnRate: 0.48,
buyRate: 0.55,
hasRewards: true,
lytsPerCustomer: 75,
status: 'live',
},
{
id: 'friend',
label: 'Referral',
description: 'Friends invited by existing customers',
group: 'growth',
accent: 'warm',
isFeatured: true,
perVisitor: 0.07,
intensity: 1.1,
returnRate: 0.42,
buyRate: 0.58,
hasRewards: true,
lytsPerCustomer: 120,
status: 'live',
},
{
id: 'event',
label: 'Event',
description: 'Attendance at in-store events',
group: 'growth',
accent: 'cool',
isFeatured: true,
perVisitor: 0.11,
intensity: 1.05,
returnRate: 0.37,
buyRate: 0.62,
hasRewards: false,
lytsPerCustomer: 50,
status: 'paused',
},
{
id: 'brand',
label: 'Brand campaign',
description: 'Reach from partner and brand pushes',
group: 'growth',
accent: 'warm',
isFeatured: false,
perVisitor: 0.24,
intensity: 1.3,
returnRate: 0.16,
buyRate: 0.29,
hasRewards: true,
lytsPerCustomer: 20,
status: 'draft',
},
{
id: 'shop',
label: 'Shop',
description: 'Catalogue browsing that ended in a basket',
group: 'commerce',
accent: 'cool',
isFeatured: false,
perVisitor: 0.26,
intensity: 1.15,
returnRate: 0.35,
buyRate: 0.71,
hasRewards: false,
lytsPerCustomer: 0,
status: 'live',
},
];
function totalVisitors(range: RangeKey, storeId: string, endMs: number): number {
return buildTimeseries(range, storeId, endMs).reduce(
(a, p) => a + p.visitors,
0,
);
}
function totalPurchases(range: RangeKey, storeId: string, endMs: number): number {
return buildTimeseries(range, storeId, endMs).reduce(
(a, p) => a + p.purchases,
0,
);
}
/**
* One activity's numbers for a scope.
*
* Every step is clamped to at least 1 below its parent once the parent is
* non-trivial, so the funnel narrows even for a quiet store where rounding
* would otherwise collapse two stages onto the same value and make a 100%
* conversion appear out of nowhere.
*/
function buildMetric(
seed: ActivitySeed,
visitors: number,
range: RangeKey,
storeId: string,
): ActivityMetric {
const rng = createRng('activity-metric', seed.id, storeId, range);
const jitter = rng.float(0.88, 1.12);
const count = Math.max(1, Math.round(visitors * seed.perVisitor * jitter));
const customers = Math.max(1, Math.round(count / seed.intensity));
const repeatVisits = Math.round(customers * seed.returnRate);
const purchases = Math.round(repeatVisits * seed.buyRate);
const attributedRevenueInr = Math.round(purchases * rng.float(310, 540));
return {
id: seed.id,
label: seed.label,
description: seed.description,
group: seed.group,
accent: seed.accent,
count,
status: seed.status,
// Issued per PARTICIPATING CUSTOMER, not per interaction: a customer who
// spins six times is not granted six rewards, and multiplying by `count`
// would inflate the liability by the intensity factor on every activity.
lytsIssued: Math.round(customers * seed.lytsPerCustomer),
// Compared against the previous period of equal length, same convention as
// the KPI deltas. Drawn rather than recomputed: the previous window's
// activity split is not something the timeseries carries.
deltaPct: Number(rng.float(-9, 24).toFixed(1)),
impact: {
customers,
rewardClaims: seed.hasRewards
? Math.round(customers * rng.float(0.36, 0.62))
: undefined,
repeatVisits,
purchases,
attributedRevenueInr,
// Modelled, and says so. The whole chain above is derived from observed
// footfall through fixed rates — no purchase here is joined to a
// specific activity event, and the UI reads this field to disclose that.
attribution: 'estimated',
},
isFeatured: seed.isFeatured,
};
}
export function buildActivityMetrics(
range: RangeKey,
storeId: string,
endMs: number,
): ActivityMetric[] {
const visitors = totalVisitors(range, storeId, endMs);
return CATALOG.map((seed) => buildMetric(seed, visitors, range, storeId));
}
/**
* The five-stage progression, built from the funnel that already exists.
*
* Visit and Purchase are NOT invented — they are the same visitor and purchase
* totals the KPI row shows, so the first and third stage of the journey and
* the first two KPI cards can never disagree. Engage and Return come from the
* activity layer, Refer is the referral activity's own count.
*/
export function buildJourney(
range: RangeKey,
storeId: string,
endMs: number,
): JourneyStage[] {
const visitors = totalVisitors(range, storeId, endMs);
const purchases = totalPurchases(range, storeId, endMs);
const metrics = buildActivityMetrics(range, storeId, endMs);
const engaged = metrics
.filter((m) => m.group === 'engagement' && m.id !== 'walk')
.reduce((a, m) => Math.max(a, m.impact.customers), 0);
const referrals = metrics.find((m) => m.id === 'friend')?.count ?? 0;
const returned = Math.round(purchases * 0.69);
const raw: {id: JourneyStage['id']; label: string; value: number}[] = [
{id: 'visit', label: 'Visit', value: visitors},
// Engagement cannot exceed footfall, and on a quiet store the strongest
// single activity can round above it.
{id: 'engage', label: 'Engage', value: Math.min(engaged, visitors)},
{id: 'purchase', label: 'Purchase', value: purchases},
{id: 'return', label: 'Return', value: returned},
{id: 'refer', label: 'Refer', value: Math.min(referrals, returned)},
];
return raw.map((stage, i) => ({
...stage,
conversionPct:
i === 0 || raw[i - 1].value === 0
? undefined
: Number(((stage.value / raw[i - 1].value) * 100).toFixed(1)),
}));
}
/** Campaign name and framing per activity. The numbers come from the activity. */
const CAMPAIGNS: {
id: string;
name: string;
activityId: ActivityId;
status: CampaignSummary['status'];
/** Labels for the three funnel steps, in order. */
steps: [string, string, string];
}[] = [
{
id: 'c-challenge',
name: 'Weekend Challenge',
activityId: 'challenge',
status: 'live',
steps: ['Participants', 'Repeat visits', 'Purchases'],
},
{
id: 'c-friend',
name: 'Refer a Friend',
activityId: 'friend',
status: 'live',
steps: ['Referrals', 'New customers', 'Purchases'],
},
{
id: 'c-event',
name: 'Saturday Event',
activityId: 'event',
status: 'ended',
steps: ['Attendees', 'Repeat visits', 'Purchases'],
},
{
id: 'c-spin',
name: 'Spin & Win',
activityId: 'spin',
status: 'live',
steps: ['Spins', 'Reward claims', 'Purchases'],
},
];
export function buildCampaigns(
range: RangeKey,
storeId: string,
endMs: number,
): CampaignSummary[] {
const metrics = buildActivityMetrics(range, storeId, endMs);
return CAMPAIGNS.flatMap((c) => {
const m = metrics.find((x) => x.id === c.activityId);
if (!m) return [];
// The middle step differs by campaign shape: a referral drive converts to
// new customers, a spin converts to claimed rewards, everything else to a
// return visit. Reading it off the impact chain rather than drawing a
// fresh number is what keeps a campaign row consistent with the activity
// card above it.
const middle =
c.activityId === 'friend'
? m.impact.customers
: c.activityId === 'spin'
? (m.impact.rewardClaims ?? m.impact.repeatVisits)
: m.impact.repeatVisits;
return [
{
id: c.id,
name: c.name,
activityId: m.id,
accent: m.accent,
status: c.status,
steps: [
{label: c.steps[0], value: m.count},
{label: c.steps[1], value: middle},
{label: c.steps[2], value: m.impact.purchases},
],
attributedRevenueInr: m.impact.attributedRevenueInr,
attribution: m.impact.attribution,
},
];
});
}
const pct = (a: number, b: number) => (b === 0 ? 0 : (a / b) * 100);
/**
* Insights, computed from the numbers actually on screen.
*
* Deliberately not a static list of sentences. Every claim below is derived
* from the same fixtures the panels render, so an insight cannot contradict
* the card next to it — which is the failure mode that makes merchants stop
* reading an AI panel after the second week.
*
* Each one carries an action. An observation with no next step belongs in a
* chart, not in a section called "What needs your attention".
*/
export function buildInsights(
range: RangeKey,
storeId: string,
endMs: number,
): Insight[] {
const metrics = buildActivityMetrics(range, storeId, endMs);
const by = (id: ActivityId) => metrics.find((m) => m.id === id)!;
const spin = by('spin');
const challenge = by('challenge');
const visit = by('visit');
const friend = by('friend');
const spinConversion = pct(spin.impact.purchases, spin.impact.customers);
const challengeReturn = pct(
challenge.impact.repeatVisits,
challenge.impact.customers,
);
const visitReturn = pct(visit.impact.repeatVisits, visit.impact.customers);
const returnMultiple = visitReturn === 0 ? 0 : challengeReturn / visitReturn;
const visitConversion = pct(visit.impact.purchases, visit.impact.customers);
const insights: Insight[] = [
{
id: 'i-spin',
severity: spinConversion < 15 ? 'warning' : 'info',
// The headline states the CONVERSION, not the direction of the count.
// "Spin engagement is up 16%" reads as good news and is the wrong thing
// to lead with — and it is also plainly wrong on a period where plays
// fell, which is how a generated insight loses a merchant's trust.
title: `Only ${spinConversion.toFixed(0)}% of spin players go on to buy`,
body: `${spin.count.toLocaleString('en-IN')} spins reached ${spin.impact.customers.toLocaleString('en-IN')} customers and plays are ${spin.deltaPct >= 0 ? 'up' : 'down'} ${Math.abs(spin.deltaPct).toFixed(0)}%. The wheel is drawing plays without pulling anyone to the counter.`,
action: {label: 'Refresh reward catalogue', href: '/lyts'},
},
{
id: 'i-challenge',
severity: 'success',
title: `Challenge participants return ${returnMultiple.toFixed(1)}× more often`,
body: `${challengeReturn.toFixed(0)}% of challenge participants came back this period against ${visitReturn.toFixed(0)}% of ordinary visitors. It is the strongest retention lever running.`,
action: {label: 'Plan another challenge', href: '/activity'},
},
{
id: 'i-visit',
severity: visitConversion < 20 ? 'warning' : 'info',
title: `Visit-to-purchase conversion sits at ${visitConversion.toFixed(0)}%`,
body: `${visit.impact.customers.toLocaleString('en-IN')} customers checked in and ${visit.impact.purchases.toLocaleString('en-IN')} bought. A visit-triggered offer is the shortest path between the two.`,
action: {label: 'Launch a visit offer', href: '/lyts'},
},
{
id: 'i-friend',
severity: 'info',
title: `Referrals brought ${friend.impact.customers.toLocaleString('en-IN')} new customers`,
body: `${friend.count.toLocaleString('en-IN')} referrals converted at ${pct(friend.impact.purchases, friend.impact.customers).toFixed(0)}% — the highest of any growth activity, on the smallest volume.`,
action: {label: 'Promote the referral reward', href: '/lyts'},
},
];
// Most severe first, so the panel's top row is always the thing that most
// needs attention rather than whichever activity happens to be listed first.
const rank = {error: 0, warning: 1, success: 2, info: 3} as const;
return insights.sort((a, b) => rank[a.severity] - rank[b.severity]);
}

View File

@@ -5,12 +5,18 @@ import type {
DashboardBriefing,
Granularity,
HourCell,
Insight,
Kpi,
PeriodPoint,
RewardUsagePoint,
StoreComparison,
TimePoint,
} from '@/features/dashboard/types/dashboard';
import type {
ActivityMetric,
CampaignSummary,
JourneyStage,
} from '@/features/dashboard/types/intelligence';
/**
* Every URL the dashboard knows, and the only place it knows them.
@@ -48,4 +54,18 @@ export const dashboardRepository = {
performance: (scope: Scope, granularity: Granularity): Endpoint<PeriodPoint[]> =>
scopedEndpoint('/api/dashboard/performance', scope, {granularity}),
// ---- Store intelligence: the activity layer -----------------------------
activityMetrics: (scope: Scope): Endpoint<ActivityMetric[]> =>
scopedEndpoint('/api/dashboard/activity-metrics', scope),
journey: (scope: Scope): Endpoint<JourneyStage[]> =>
scopedEndpoint('/api/dashboard/journey', scope),
campaigns: (scope: Scope): Endpoint<CampaignSummary[]> =>
scopedEndpoint('/api/dashboard/campaigns', scope),
insights: (scope: Scope): Endpoint<Insight[]> =>
scopedEndpoint('/api/dashboard/insights', scope),
};

View File

@@ -0,0 +1,100 @@
/**
* Domain rules for the activity layer.
*
* Two things live here that would otherwise be duplicated in every panel that
* renders an activity: which glyph an activity gets, and how its impact chain
* is read out. Both are decisions about the DOMAIN, not about a layout — the
* dashboard summary, the analytics page and a campaign row must all describe
* Spin the same way, or the merchant is looking at three products.
*/
import {ICONS} from '@/shared/utils/icons';
import type {IconType} from '@astryxdesign/core/Icon';
import type {
ActivityGroup,
ActivityId,
ActivityMetric,
} from '@/features/dashboard/types/intelligence';
export const ACTIVITY_ICON: Record<ActivityId, IconType> = {
walk: ICONS.activityWalk,
visit: ICONS.activityVisit,
selfie: ICONS.activitySelfie,
spin: ICONS.activitySpin,
scratch: ICONS.activityScratch,
brand: ICONS.activityBrand,
challenge: ICONS.activityChallenge,
friend: ICONS.activityFriend,
shop: ICONS.activityShop,
event: ICONS.activityEvent,
};
/**
* The three questions the grouping answers. The description is the point of
* the group — a heading that only says "Engagement" tells a merchant nothing
* they could not have guessed from the cards under it.
*/
export const ACTIVITY_GROUPS: {
id: ActivityGroup;
label: string;
question: string;
}[] = [
{
id: 'engagement',
label: 'Engagement',
question: 'What are customers doing in and around the store?',
},
{
id: 'growth',
label: 'Growth',
question: 'What is bringing new people in?',
},
{
id: 'commerce',
label: 'Commerce',
question: 'Is any of it converting into business?',
},
];
/** A step in the activity → customer → return → purchase chain. */
export interface ImpactStep {
label: string;
value: number;
}
/**
* The impact chain as an ordered list, ready to render.
*
* `rewardClaims` is dropped rather than zeroed when an activity grants no
* reward: a chain reading "→ 0 reward claims" states a failure where there is
* only an absence, and a merchant reads those very differently.
*
* `compact` drops the middle of the chain for the dashboard's small cards,
* which have room for the two ends of the story and not the whole of it. The
* full chain always survives on /activity — this trims the summary, it never
* decides what the data contains.
*/
export function impactChain(
m: ActivityMetric,
{compact = false}: {compact?: boolean} = {},
): ImpactStep[] {
const steps: ImpactStep[] = [
{label: 'customers', value: m.impact.customers},
];
if (!compact && m.impact.rewardClaims !== undefined) {
steps.push({label: 'reward claims', value: m.impact.rewardClaims});
}
if (!compact) {
steps.push({label: 'repeat visits', value: m.impact.repeatVisits});
}
steps.push({label: 'purchases', value: m.impact.purchases});
return steps;
}
/** Share of participants who ended up buying — the number the chain exists for. */
export function conversionPct(m: ActivityMetric): number {
if (m.impact.customers === 0) return 0;
return (m.impact.purchases / m.impact.customers) * 100;
}

View File

@@ -0,0 +1,184 @@
/**
* Store intelligence contracts — the activity layer.
*
* Sits beside dashboard.ts and answers a different question. Those types
* describe what the STORE did (visitors, revenue, conversion). These describe
* what CUSTOMERS did, and — the part that makes it intelligence rather than a
* leaderboard — what each of those things was worth.
*
* THE ONE RULE THIS FILE ENCODES
* An activity count on its own is a vanity metric. 724 spins tells a merchant
* nothing they can act on. So `count` never travels alone: every activity
* carries an `ActivityImpact` describing the chain
*
* activity → customers → return visits → purchases
*
* and every surface that renders an activity is expected to show at least one
* downstream step. The numbers behind this are fixtures today; the SHAPE is
* the contract a real backend has to satisfy, and it is deliberately not
* "count plus a delta" — that would let the useful half be dropped silently.
*/
import type {BrandAccent} from '@/shared/utils/accent';
/**
* How an impact figure was arrived at. Required, never defaulted.
*
* This is the most important field in the file. Today every number in the
* chain below is MODELLED — a share of observed footfall pushed through fixed
* conversion rates — and a modelled ₹6.5L rendered in the same type as a real
* one is how a dashboard quietly starts lying. Making the basis part of the
* payload means the UI can label it honestly without guessing, and a backend
* that later joins real till receipts to real activity events flips this to
* `'observed'` and the disclosure disappears on its own. Nothing else has to
* change: the field names already say "attributed", not "earned".
*
* estimated modelled from observed activity and purchase patterns
* observed each purchase is joined to a specific activity event
*/
export type AttributionBasis = 'estimated' | 'observed';
/**
* The one sentence the UI shows wherever an attributed figure appears. Defined
* once so the disclosure cannot drift between the three panels that carry it.
*/
export const ATTRIBUTION_NOTE =
'Attribution is estimated from observed customer activity and purchase patterns. It shows association, not proven cause.';
/**
* The complete activity ecosystem. The dashboard summarises the six marked
* `isFeatured`; all ten live on /activity.
*/
export type ActivityId =
| 'walk'
| 'visit'
| 'selfie'
| 'spin'
| 'scratch'
| 'brand'
| 'challenge'
| 'friend'
| 'shop'
| 'event';
/**
* What the merchant is asking when they look at a group.
*
* engagement "what are customers doing?"
* growth "what is bringing people in?"
* commerce "is any of it converting?"
*/
export type ActivityGroup = 'engagement' | 'growth' | 'commerce';
/**
* The chain that connects an interaction to money.
*
* Read every field after `customers` as ATTRIBUTED rather than caused. A
* customer who spun the wheel and later bought something is an association;
* whether the spin is why they bought is not knowable from this data, and the
* UI must not phrase it as though it were.
*/
export interface ActivityImpact {
/** Distinct customers behind the interactions. Directly observed. */
customers: number;
/** Rewards claimed off the back of it. Absent where the activity grants none. */
rewardClaims?: number;
/** Participants who returned to the store within the period. */
repeatVisits: number;
/** Purchases by those participants, attributed to this activity. */
purchases: number;
/**
* Revenue on those purchases. Named `attributed` rather than `revenue`
* because that is what it is — see AttributionBasis.
*/
attributedRevenueInr: number;
attribution: AttributionBasis;
}
/**
* Whether the merchant is currently running the activity.
*
* Lives on the shared metric rather than on a Lyts-only type: the dashboard
* and the Lyts catalogue must never disagree about whether Spin is live, and
* two types would guarantee that they eventually do.
*/
export type ActivityStatus = 'live' | 'paused' | 'draft';
export interface ActivityMetric {
id: ActivityId;
label: string;
/** One line on what the activity is — the merchant may not have run it yet. */
description: string;
group: ActivityGroup;
/**
* Fixed per activity, carried on the payload rather than derived from grid
* position, so an activity keeps its colour across every screen it appears
* on and a reordered grid does not repaint.
*/
accent: BrandAccent;
/** Interactions recorded in the period — the headline number. */
count: number;
deltaPct: number;
/**
* Whether the activity is running. The dashboard summary does not show this
* — a summary of six live activities does not need six "Live" badges — but
* the Lyts catalogue is where a merchant turns things on and off, so it is
* the same field rather than a second source of truth.
*/
status: ActivityStatus;
/**
* LYTs issued through this activity in the period. 1 LYT = ₹1, so this is
* simultaneously a count and the rupee liability the activity created —
* which is what connects the activity model to the Lyts programme.
*
* Directly observed, unlike everything in `impact` past `customers`: the
* ledger knows exactly how many LYTs it issued and why.
*/
lytsIssued: number;
impact: ActivityImpact;
/** Surfaced on the dashboard summary. The rest are /activity only. */
isFeatured: boolean;
}
export type JourneyStageId =
| 'visit'
| 'engage'
| 'purchase'
| 'return'
| 'refer';
export interface JourneyStage {
id: JourneyStageId;
label: string;
/** Customers who reached this stage. Monotonically decreasing by definition. */
value: number;
/**
* Share of the PREVIOUS stage that made it here. Undefined on the first
* stage, which has nothing to convert from.
*/
conversionPct?: number;
}
export type CampaignStatus = 'live' | 'ended' | 'scheduled';
export interface CampaignStep {
label: string;
value: number;
}
export interface CampaignSummary {
id: string;
name: string;
/** The activity it runs on — gives the row its icon and accent. */
activityId: ActivityId;
accent: BrandAccent;
status: CampaignStatus;
/**
* Participants → engagement → conversion, in order. A campaign that is not
* a funnel is not a campaign, so this is a list rather than named fields:
* a referral drive ends at "new customers", an event ends at "purchases".
*/
steps: CampaignStep[];
attributedRevenueInr: number;
attribution: AttributionBasis;
}

View File

@@ -0,0 +1,89 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {ActivityGroups} from '@/features/dashboard/components/ActivityGroups';
import {ActivityImpactTable} from '@/features/dashboard/components/ActivityImpactTable';
import {useActivityMetrics} from '@/features/dashboard/hooks/useDashboard';
import {formatCompact, formatLyt} from '@/shared/utils/format';
/**
* The activity ecosystem, as the Lyts page presents it.
*
* ── Why this lives here and not on the dashboard ──────────────────────────
* The two pages ask different questions of the same data. The dashboard asks
* "how are customers engaging and what is it worth?" and answers with six
* activities and their business impact. Lyts asks "what am I running, what
* does it cost me in LYTs, and is it working?" — which needs all ten, their
* status, and the LYT liability each one creates.
*
* ── One model, not two ────────────────────────────────────────────────────
* Everything here reads `useActivityMetrics()` — the same hook, the same
* endpoint, the same fixtures the dashboard uses. If the dashboard says Visit
* is 35.3k, this page says 35.3k, because there is nowhere for a second
* number to come from. The grouped cards and the impact table are the exact
* components the dashboard's own activity work produced, rendered in
* `catalogue` variant rather than reimplemented.
*
* The summary strip is StatPairs rather than MetricCards on purpose: the KPI
* row directly above it is already four MetricCards about LYT liability, and
* a second row of the same object would read as eight equal headline metrics
* on a page whose headline is the programme, not the activities.
*/
export function ActivityProgramme() {
const metrics = useActivityMetrics();
return (
<VStack gap={5}>
<StaticPanel
title="Customer activities"
subtitle="Every way a customer can earn, and what each one issues in LYTs"
>
{/*
Derived at render rather than fetched as its own summary endpoint.
A totals endpoint could disagree with the cards below it — this
cannot, because it is the same array.
*/}
{metrics.status === 'success' || metrics.status === 'empty' ? (
<StatRow>
<StatPair
label="Live activities"
value={`${metrics.data.filter((m) => m.status === 'live').length} of ${metrics.data.length}`}
/>
<StatPair
label="Total participation"
value={formatCompact(
metrics.data.reduce((a, m) => a + m.count, 0),
)}
/>
<StatPair
label="Customers reached"
value={formatCompact(
// The MAXIMUM, not the sum: one customer who visits, spins and
// takes a selfie is one customer. Adding the per-activity
// counts would claim three, and inflate "customers reached"
// past the store's own footfall.
metrics.data.reduce(
(a, m) => Math.max(a, m.impact.customers),
0,
),
)}
/>
<StatPair
label="LYTs issued"
value={formatLyt(
metrics.data.reduce((a, m) => a + m.lytsIssued, 0),
)}
align="end"
/>
</StatRow>
) : null}
</StaticPanel>
<ActivityGroups resource={metrics} />
<ActivityImpactTable resource={metrics} />
</VStack>
);
}

View File

@@ -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',

View File

@@ -1,19 +1,31 @@
import Image from 'next/image';
/**
* The two official Loyaly.ai assets — and the only place in the product where
* The official Loyaly.ai assets — and the only place in the product where
* brand colour is allowed to appear. Everything around them stays monochrome.
*
* Both render at their own intrinsic ratio: a caller sizes ONE axis and the
* other is derived, so neither mark can be letterboxed or stretched. There is
* Every mark renders at its own intrinsic ratio: a caller sizes ONE axis and
* the other is derived, so nothing can be letterboxed or stretched. There is
* deliberately no `fill` / `objectFit` escape hatch — that is how logos end up
* distorted. Recolouring is likewise not a prop: the PNGs carry the mark.
*
* ── Two lockups, one component ────────────────────────────────────────────
* The wordmark is baked into the PNG, so a white one is invisible on a white
* sidebar — which is exactly what the light theme was shipping. Both files are
* rendered and the cascade picks one (see `.brand-lockup-*` in globals.css);
* the hidden variant is `display: none`, so it leaves the accessibility tree
* and the alt text is announced exactly once.
*
* Resolving this in React instead was rejected: the mode may be the literal
* 'system', which only CSS can resolve, and a client-side resolve would flash
* the wrong mark on first paint.
*/
// Intrinsic pixel dimensions of the shipped files, alpha-trimmed so the
// wordmark optically centres (the supplied original carried 44px of dead
// transparent space on its trailing edge).
const LOGO = {src: '/white-logo.png', width: 1489, height: 248};
const LOGO_DARK = {src: '/white-logo.png', width: 1489, height: 248};
const LOGO_LIGHT = {src: '/brand/loyaly-logo.png', width: 596, height: 119};
const MARK = {src: '/brand/loyaly-mark.png', width: 285, height: 256};
interface BrandProps {
@@ -23,23 +35,46 @@ interface BrandProps {
priority?: boolean;
}
/** Full horizontal Loyaly.ai lockup. Sized by height; width follows. */
/**
* Full horizontal Loyaly.ai lockup. Sized by height; width follows.
*
* Both variants are laid out at the SAME derived width, so swapping themes
* cannot shift the sidebar, the top bar or the AI panel header by a pixel.
*/
export function BrandLogo({
height = 28,
alt = 'Loyaly.ai',
priority = false,
}: BrandProps & {height?: number}) {
// Both files carry the same lockup at the same aspect ratio; deriving the
// width from each one's own intrinsics keeps that true even if a future
// asset is re-exported at a different scale.
const darkWidth = Math.round((height * LOGO_DARK.width) / LOGO_DARK.height);
const lightWidth = Math.round((height * LOGO_LIGHT.width) / LOGO_LIGHT.height);
return (
<Image
src={LOGO.src}
alt={alt}
height={height}
width={Math.round((height * LOGO.width) / LOGO.height)}
priority={priority}
// Block display keeps the anchor that usually wraps this from painting a
// hover underline in the leftover line box.
className="block"
/>
<>
{/* Dark wordmark on the light theme. The `block` display that used to
live here — keeping the wrapping anchor from painting a hover
underline in the leftover line box — is now set by the same rule
that decides visibility. */}
<Image
src={LOGO_LIGHT.src}
alt={alt}
height={height}
width={lightWidth}
priority={priority}
className="brand-lockup-light"
/>
<Image
src={LOGO_DARK.src}
alt={alt}
height={height}
width={darkWidth}
priority={priority}
className="brand-lockup-dark"
/>
</>
);
}

View File

@@ -16,6 +16,14 @@ const TONE = {
/**
* Areas fade to transparent rather than sitting as flat gray blocks — on a
* near-black surface a solid fill swallows the gridlines and the series below.
*
* The ramp is 0.20 → 0.02, down from 0.38 → 0.03. That original pair was tuned
* against the monochrome ramp, where the top stop is a mid-gray and 38% of it
* is a whisper. Footfall now draws in brand purple, and 38% of a saturated hue
* on a white card is a colour block — it read as the loudest element on the
* page while the line that carries the actual shape read as its edge. Lower
* stops put the line back in charge and leave the gridlines visible through
* the fill, which is the whole reason the fill is a gradient.
*/
export function AreaChartView<T extends object>({
data,
@@ -46,8 +54,8 @@ export function AreaChartView<T extends object>({
x2="0"
y2="1"
>
<stop offset="0%" stopColor={c} stopOpacity={0.38} />
<stop offset="100%" stopColor={c} stopOpacity={0.03} />
<stop offset="0%" stopColor={c} stopOpacity={0.2} />
<stop offset="100%" stopColor={c} stopOpacity={0.02} />
</linearGradient>
);
})}

View File

@@ -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. */

View File

@@ -0,0 +1,37 @@
/**
* The two-accent brand system, expressed as class names.
*
* There are exactly two non-semantic hues in this product — warm (Loyaly
* yellow) and cool (purple) — and their values live in globals.css as
* `--color-brand-*`. This file is the only place that turns one into markup,
* so "which utility do I use for a warm icon?" has a single answer and a
* feature file never writes `text-brand-warm-ink` by hand.
*
* Assignment is a property of the DATA, not of the position in a grid. Each
* activity carries its own accent from the API, so the same activity is the
* same colour on the dashboard, on the activity page and inside a campaign
* row — and a grid that gains a row does not repaint itself.
*
* `ink` is the readable slot (icons, text); `soft` is a tint for an icon chip
* only. Nothing here paints a card background: the cards stay white.
*/
export type BrandAccent = 'warm' | 'cool';
export const ACCENT: Record<
BrandAccent,
{ink: string; soft: string; solid: string; chip: string}
> = {
warm: {
ink: 'text-brand-warm-ink',
soft: 'bg-brand-warm-soft',
solid: 'bg-brand-warm',
chip: 'bg-brand-warm-soft text-brand-warm-ink',
},
cool: {
ink: 'text-brand-cool-ink',
soft: 'bg-brand-cool-soft',
solid: 'bg-brand-cool',
chip: 'bg-brand-cool-soft text-brand-cool-ink',
},
};

View File

@@ -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,

View File

@@ -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 {

View File

@@ -2,7 +2,7 @@
* @generated by `astryx theme build` — do not edit manually.
* Source: src/theme/loyalyTheme.ts
* Command: astryx theme build src/theme/loyalyTheme.ts
* Generated: 2026-08-06T05:55:58.054Z
* Generated: 2026-08-10T08:15:31.620Z
*/
/// <reference path="./loyaly.variants.d.ts" />

View File

@@ -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)",

View File

@@ -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

View File

@@ -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)',
},
},