diff --git a/src/app/(workspace)/commerce/page.tsx b/src/app/(workspace)/commerce/page.tsx index 0e5b169..3093196 100644 --- a/src/app/(workspace)/commerce/page.tsx +++ b/src/app/(workspace)/commerce/page.tsx @@ -1,175 +1,252 @@ 'use client'; -import {useState} from 'react'; +import {useMemo} from 'react'; import {VStack} from '@astryxdesign/core/Layout'; import {Grid} from '@astryxdesign/core/Grid'; import {PageHeader} from '@/shared/components/primitives/PageHeader'; import {ScopeControls} from '@/shared/components/scope/ScopeControls'; -import {ChartCard} from '@/shared/components/charts/ChartCard'; -import {BarChartView} from '@/shared/components/charts/BarChartView'; -import {PanelCard, StaticPanel} from '@/shared/components/patterns/PanelCard'; -import {List, ListItem} from '@astryxdesign/core/List'; +import {PanelCard} from '@/shared/components/patterns/PanelCard'; +import {MetricCard} from '@/shared/components/patterns/MetricCard'; import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; -import {SkeletonRows} from '@/shared/components/patterns/LoadingState'; -import {useSales} from '@/features/commerce/hooks/useSales'; -import {SaleDetailDialog} from '@/features/commerce/components/SaleDetailDialog'; -import {formatPaise} from '@/features/commerce/services/money'; -import {CHART} from '@/shared/components/charts/palette'; -import {useConversionReport} from '@/features/dashboard/hooks/useReports'; -import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; -import {formatBucketLabel, formatInrCompact} from '@/shared/utils/format'; -// TEMPORARY sample data — removal steps in shared/mocks/withSample.ts. -import {withSample} from '@/shared/mocks/withSample'; -import {SampleTag} from '@/shared/mocks/SampleTag'; -import {MOCK_CONVERSION_DAILY, hasSignal} from '@/features/dashboard/mocks/dashboardMock'; import { - MOCK_PAYMENT_METHODS, - MOCK_SALES, - MOCK_TOP_PRODUCTS, -} from '@/features/commerce/mocks/commerceMock'; + SkeletonChart, + SkeletonMetricGrid, +} from '@/shared/components/patterns/LoadingState'; +import {ICONS} from '@/shared/utils/icons'; +import {useScope} from '@/shared/hooks/useScope'; +import {useConversionReport} from '@/features/dashboard/hooks/useDashboard'; +import {BarChartView} from '@/shared/components/charts/BarChartView'; +import {useSales, useTodaySales} from '@/features/commerce/hooks/useSales'; +import {SalesTrendChart} from '@/features/commerce/components/SalesTrendChart'; +import { + PaymentMethodsPanel, + TopOutletsPanel, + TopProductsPanel, +} from '@/features/commerce/components/CommercePanels'; +import {buildSampleSales} from '@/shared/mocks/sampleData'; +import {sampleNowMs, withSample} from '@/shared/mocks/withSample'; +import {SampleNotice} from '@/shared/mocks/SampleNotice'; +import {SampleTag} from '@/shared/mocks/SampleTag'; +import {useWorkspace} from '@/shared/providers/WorkspaceProvider'; +import {shopToday} from '@/services/api/range'; +import { + salesFacts, + shopDay, + salesTrend, + summarise, + totalPaise, +} from '@/features/commerce/services/salesAnalysis'; +import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; +import { + formatCompact, + formatCount, + formatInr, + formatInrCompact, + formatPct, +} from '@/shared/utils/format'; +import type {Sale} from '@/features/commerce/types/sale'; -const formatPct = (v: number) => `${v}%`; +const noSales = ( + +); + +// Money arrives in paise; the cards show compact rupees ("₹1.24L") and whole +// rupees for the average and today's line ("₹667") — headlines, not a ledger. +const salesFormat = (paise: number) => formatInrCompact(paise / 100); +const rupeesFormat = (paise: number) => formatInr(paise / 100); /** - * Sales. + * Commerce — the merchant app's layout, on the platform's real sales. * - * ── What changed and why ───────────────────────────────────────────────── - * This page previously rendered eight panels — product leaderboards, payment - * method splits, stock levels, refund rates, hourly targets — every number of - * which came from a hardcoded service imported synchronously by ten - * components. None of it had an API, a loading state, or a way to become real. + * One source for the page: the sale list for the selected store and period + * (GET /api/sales, every page of it). The four cards, the trend charts and the + * outlet ranking are all derived from those rows — see salesAnalysis.ts — so a + * filter change moves all of them together and they cannot disagree. The one + * extra request is today's sales, for the Sales card's "Today" line, since a + * custom period need not include today. * - * The platform reports revenue and basket size through the conversion report, - * and nothing else on this page. Until a shop has history — and until the - * catalogue and payment resources exist — panels show clearly-tagged SAMPLE - * data (features/commerce/mocks) so the page explains itself. Real data - * replaces it automatically; removal steps are in shared/mocks/withSample.ts. + * The cards carry no percentage badge: the sale list has no previous period to + * compare against, and a change figure with nothing behind it would be + * invented. Their sparklines are the same rows, per day of the period. + * + * REAL: revenue, orders, average order, customers, enquiries, the revenue + * and order trends, and top outlets (all from + * the sale list), and conversion (the platform's conversion report). + * SAMPLE, and tagged as such: net sales and refunds, payment methods and top + * products — the sale list carries no refund, discount, payment method or + * product line. Their values live in shared/mocks/sampleData.ts. */ export default function CommercePage() { - const [openSale, setOpenSale] = useState(null); - const conversion = withSample( - useConversionReport({bucket: 'day'}), - MOCK_CONVERSION_DAILY, - (d) => hasSignal(d, 'revenue'), - ); - const sales = withSample(useSales(), MOCK_SALES, (d) => !!d?.length); + const realSales = useSales(); + const {range, custom, storeId} = useScope(); + const {stores} = useWorkspace(); + const isTodayRange = range === 'today'; + const today = useTodaySales(!isTodayRange); const scopeLabel = useScopeLabel(); + const conversion = useConversionReport({bucket: 'day'}); + + // The server's clock, never the device's — "today" is the shop's today. + const serverNow = + realSales.meta?.generatedAt ?? new Date(sampleNowMs()).toISOString(); + + // SAMPLE — stands in only when the platform returned no sales for the + // window, so a new shop sees the page populated. Spread across the stores + // in scope so Top performing outlets shows the merchant's own shop names. + const siteIds = useMemo( + () => + storeId === 'all' + ? stores.filter((s) => s.id !== 'all').map((s) => s.id) + : [storeId], + [storeId, stores], + ); + const sampleSales = useMemo( + () => buildSampleSales({siteIds, range, custom: custom ?? null, serverNow}), + [siteIds, range, custom, serverNow], + ); + const sales = withSample(realSales, sampleSales, (d) => !!d?.length); + const rows: Sale[] | undefined = + sales.status === 'success' || sales.status === 'empty' ? sales.data : undefined; + + const period = useMemo( + () => (serverNow ? {range, custom: custom ?? null, serverNow} : null), + [range, custom, serverNow], + ); + const summary = useMemo( + () => (rows && period ? summarise(rows, period) : null), + [rows, period], + ); + const trend = useMemo( + () => (rows && period ? salesTrend(rows, period) : []), + [rows, period], + ); + + const todayRows: Sale[] | undefined = isTodayRange + ? rows + : sales.isSample + ? sampleSales.filter((s) => shopDay(s.at) === shopToday(new Date(serverNow))) + : today.status === 'success' || today.status === 'empty' + ? today.data + : undefined; + // Revenue is rupees taken; orders is the NUMBER of bills. The captions say + // so, because "Sales" beside "Purchases" read as the same thing twice. + const todayCount = todayRows?.filter((s) => s.status !== 'void').length; + const revenueCaption = isTodayRange + ? 'Money taken today' + : todayRows === undefined + ? 'Money taken' + : `Today ${rupeesFormat(totalPaise(todayRows))}`; + const ordersCaption = isTodayRange + ? 'Bills recorded today' + : todayCount === undefined + ? 'Bills recorded' + : `Today ${formatCount(todayCount)} bills`; + + const facts = rows ? salesFacts(rows) : null; return ( - } - /> +
+ } + /> +
- } - > - {(report) => ( - - )} - - - } - loading={} - empty={ - - } - > - {/* - List/Item rather than Table: these rows open a detail view, and - Astryx's Table has no per-row action or custom cell renderer. Both - are approved dense-data patterns — this is the one that can be - clicked, so it is the one that fits. - */} - {(rows) => ( - - {rows.map((sale) => ( - setOpenSale(sale.id)} - label={sale.customerLabel ?? sale.customerRef ?? 'Not identified'} - description={[ - sale.invoiceNo, - sale.staffName ? `Served by ${sale.staffName}` : null, - `${sale.purchasedLines} purchased`, - // Only mentioned when there were any: "0 enquiries" on - // every row is noise that hides the ones that had some. - sale.enquiryLines > 0 ? `${sale.enquiryLines} enquiries` : null, - ] - .filter(Boolean) - .join(' · ')} - endContent={formatPaise(sale.totalPaise)} - /> - ))} - - )} - - - {openSale ? ( - setOpenSale(null)} /> + {sales.isSample ? ( + + No sales recorded in this period yet. The revenue, orders and outlet + figures below are examples until staff record sales in the merchant + app. + ) : null} - {/* - No platform resource exists yet for the catalogue or payment methods, - so these two are ALWAYS sample and always tagged. Swap each constant - for a hook when the endpoint ships. - */} - - } - > - - {MOCK_TOP_PRODUCTS.map((p, i) => ( - - ))} - - - - } - > - + - + + formatPct(v)} + icon={ICONS.analytics} + accent="cool" + unavailableNote="Not reported" + /> + + {/* SAMPLE — the sale list records no refund or discount. */} + + ) : sales.status === 'error' ? null : ( + + )} + + + } + resource={sales} + loading={} + empty={noSales} + > + {() => } + + + } + resource={sales} + loading={} + empty={noSales} + > + {() => ( + + )} + + + + + + + +
); } diff --git a/src/app/(workspace)/customers/page.tsx b/src/app/(workspace)/customers/page.tsx index 23a7246..b7c5cd4 100644 --- a/src/app/(workspace)/customers/page.tsx +++ b/src/app/(workspace)/customers/page.tsx @@ -1,24 +1,10 @@ -'use client'; - -import {VStack} from '@astryxdesign/core/Layout'; -import {PageHeader} from '@/shared/components/primitives/PageHeader'; -import {CustomerDirectory} from '@/features/customers/components/CustomerDirectory'; +import {redirect} from 'next/navigation'; /** - * The customer directory, from GET /api/visitors. - * - * Company-wide, not scoped by the store switcher: a customer belongs to the - * business, and somebody who first walked into one branch is the same person - * at another. + * Customers moved into the Dashboard (the "Customers" section, below Recent + * arrivals) and out of the top nav. This route stays so an existing bookmark + * or shared link still lands on the directory. */ export default function CustomersPage() { - return ( - - - - - ); + redirect('/dashboard#customers'); } diff --git a/src/app/(workspace)/dashboard/page.tsx b/src/app/(workspace)/dashboard/page.tsx index 859a0ad..7fb0f89 100644 --- a/src/app/(workspace)/dashboard/page.tsx +++ b/src/app/(workspace)/dashboard/page.tsx @@ -1,23 +1,42 @@ 'use client'; +import {useState} from 'react'; import {VStack} from '@astryxdesign/core/Layout'; +import {Collapsible} from '@astryxdesign/core/Collapsible'; +import {Text} from '@astryxdesign/core/Text'; import {Grid} from '@astryxdesign/core/Grid'; import {PageHeader} from '@/shared/components/primitives/PageHeader'; import {ScopeControls} from '@/shared/components/scope/ScopeControls'; import {ChartCard} from '@/shared/components/charts/ChartCard'; -import {AreaChartView} from '@/shared/components/charts/AreaChartView'; +import {CalendarHeatmap} from '@/shared/components/charts/CalendarHeatmap'; import {BarChartView} from '@/shared/components/charts/BarChartView'; +import {LineChartView} from '@/shared/components/charts/LineChartView'; +import {HeatmapGrid} from '@/shared/components/charts/HeatmapGrid'; import {KpiRow} from '@/features/dashboard/components/KpiRow'; import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed'; +import {CustomerDirectory} from '@/features/customers/components/CustomerDirectory'; +import {SectionHeader} from '@/shared/components/patterns/SectionHeader'; import {CustomerFlowChart} from '@/features/dashboard/components/CustomerFlowChart'; import {EngagementSection} from '@/features/engagement/components/EngagementSection'; +import {RewardUsageChart} from '@/features/dashboard/components/RewardUsageChart'; +import {PerformancePanel} from '@/features/dashboard/components/PerformancePanel'; +import {StoreComparisonPanel} from '@/features/dashboard/components/StoreComparison'; +import {StoreInsights} from '@/features/dashboard/components/StoreInsights'; import {CHART} from '@/shared/components/charts/palette'; import { useConversionReport, useDashboardKpis, + useDashboardPeakHours, + useDashboardRewardUsage, + useDashboardStoreComparison, useFootfallReport, useRecentVisits, + useStoreInsights, } from '@/features/dashboard/hooks/useDashboard'; +import {usePersistentFlag} from '@/shared/hooks/usePersistentFlag'; +import {useWorkspace} from '@/shared/providers/WorkspaceProvider'; +import type {Granularity} from '@/features/dashboard/types/dashboard'; +import {SampleScope} from '@/shared/mocks/SampleScope'; import {useArrivalStream} from '@/features/dashboard/hooks/useArrivalStream'; import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory'; import {greetingFor} from '@/features/dashboard/services/dashboardService'; @@ -32,9 +51,8 @@ import { MOCK_CONVERSION_WEEKLY, MOCK_FOOTFALL_DAILY, MOCK_FOOTFALL_WEEKLY, - hasSignal, - withSample, -} from '@/features/dashboard/mocks/dashboardMock'; +} from '@/shared/mocks/sampleData'; +import {hasSignal, withSample} from '@/shared/mocks/withSample'; import {SampleTag} from '@/shared/mocks/SampleTag'; const CHART_HEIGHT = 220; @@ -44,7 +62,7 @@ const CHART_HEIGHT = 220; * * Every number traces to a platform resource — footfall, conversion, arrivals. * The KPI row is always real. The charts fall back to clearly-tagged SAMPLE - * data (features/dashboard/mocks) only while a shop has no history, so a new + * data (shared/mocks/sampleData.ts) only while a shop has no history, so a new * merchant sees what each panel is for instead of an empty axis. * * To retire the sample, see `shared/mocks/withSample.ts`. @@ -66,6 +84,21 @@ export default function DashboardPage() { visits.isRefreshing ? undefined : visits.data?.cursor, ); const scopeLabel = useScopeLabel(); + const {storeId} = useWorkspace(); + + // Supporting analytics, ported from the merchant app. Collapsed by default + // and remembered — see CLAUDE.md. All but the first chart are SAMPLE: the + // platform reports no hourly heatmap, rewards, period rollup or per-store + // comparison, so those read shared/mocks/sampleData.ts inside . + const [isSupportingOpen, setSupportingOpen] = usePersistentFlag( + 'loyaly.dashboard.supporting-analytics', + false, + ); + const [granularity, setGranularity] = useState('weekly'); + const peak = useDashboardPeakHours(); + const rewardUsage = useDashboardRewardUsage(); + const comparison = useDashboardStoreComparison(); + const insights = useStoreInsights(); // Chart feeds: real when the platform has signal, sample otherwise. const footfallChart = withSample(footfall, MOCK_FOOTFALL_DAILY, (d) => @@ -117,16 +150,11 @@ export default function DashboardPage() { actions={} > {(report) => ( - ({label: b.label, value: b.visitors}))} + accent="cool" + unit="visitors" height={CHART_HEIGHT} - yFormat={formatCompact} - isInteger - series={[ - {key: 'visitors', label: 'Visitors', color: CHART.brand.cool}, - ]} /> )} @@ -235,9 +263,116 @@ export default function DashboardPage() { )} + + + + {/* Text, not Heading: Collapsible renders the trigger inside a + - ))} - - - + + + + {children} + + + ); +} + +/** + * The assistant's landing: greeting, store pulse, insight cards, quick + * queries. The composer is NOT here — Conversation docks it below the scroll + * region so it sits in the same place on every screen. + * + * Built from design-system components end to end so it reflows by itself: + * cards stretch to the panel, the pulse strip and the chip row wrap on a + * narrow phone, and nothing carries a fixed pixel size or a raw colour. The + * two brand accents come only through ACCENT (see CLAUDE.md). + * + * The pulse figures and the insight cards are SAMPLE (shared/mocks/ + * sampleData.ts), and say so — they used to be labelled "Live" and + * "Real-time" while being constants. + */ +export function EmptyState({onPick}: {onPick?: (prompt: string) => void}) { + return ( + + + + + Ready to optimize your stores? + + + Your proactive AI copilot for smarter retail decisions and instant analytics. + + + + + + + Footfall{' '} + + +{SAMPLE_AI_PULSE.footfallDeltaPct}% + + + + Sync{' '} + + {SAMPLE_AI_PULSE.syncPct}% + + + + + + + + + Active insights + + + + {SAMPLE_AI_INSIGHTS.map((item) => ( + onPick?.(item.prompt)} + padding={4} + > + + + + + + + + {item.title} + + + + + {item.description} + + + + {item.actionText} + + + + + + + ))} + + + + Quick queries + + {SUGGESTIONS.slice(0, 5).map((s) => ( + onPick?.(s.prompt)} /> + ))} + + + ); } diff --git a/src/features/loyaly-ai/components/MessageBubble.tsx b/src/features/loyaly-ai/components/MessageBubble.tsx index fb30639..fe95d5c 100644 --- a/src/features/loyaly-ai/components/MessageBubble.tsx +++ b/src/features/loyaly-ai/components/MessageBubble.tsx @@ -4,10 +4,11 @@ import { ChatMessage as ChatMessageRow, ChatMessageBubble, } from '@astryxdesign/core/Chat'; -import {VStack} from '@astryxdesign/core/Layout'; +import {HStack, VStack} from '@astryxdesign/core/Layout'; import {MessageContent} from './MessageContent'; import {TypingIndicator} from './TypingIndicator'; import {BrandMark} from '@/shared/components/brand/BrandLogo'; +import {ACCENT} from '@/shared/utils/accent'; import type {ChatMessage} from '@/features/loyaly-ai/types/chat'; /** @@ -39,9 +40,13 @@ export function MessageBubble({message}: {message: ChatMessage}) { density="compact" avatar={ isAssistant ? ( -
+ -
+ ) : undefined } > diff --git a/src/features/loyaly-ai/components/SuggestionChips.tsx b/src/features/loyaly-ai/components/SuggestionChips.tsx deleted file mode 100644 index 2cf85ef..0000000 --- a/src/features/loyaly-ai/components/SuggestionChips.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; - -import {HStack} from '@astryxdesign/core/Layout'; -import {SUGGESTIONS} from '@/features/loyaly-ai/utils/suggestions'; - -/** - * The opening moves, as a wrapped row of chips. - * - * Chips rather than the stacked button list this replaces: six full-width - * secondary buttons read as a menu of commands, which is what made the old - * panel feel like a dashboard. A wrapped row of quiet pills reads as - * suggestions — the same distinction ChatGPT, Claude and Gemini all make. - * - * Hand-built rather than Astryx's Token: Token is a data chip with a - * dismiss/selected vocabulary that does not apply here, and Button carries a - * control's weight. What is wanted is a quiet, fully-rounded, tappable label — - * three token-backed utilities, and no component contract to fight. - */ -const CHIP = [ - 'rounded-full border border-border bg-card', - 'px-3.5 py-2 text-sm text-secondary text-left', - 'transition-colors duration-150 cursor-pointer', - 'hover:bg-muted hover:text-primary hover:border-border-strong', - 'focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2', -].join(' '); - -export function SuggestionChips({onPick}: {onPick: (prompt: string) => void}) { - return ( - - {SUGGESTIONS.map((s) => ( - - ))} - - ); -} diff --git a/src/features/loyaly-ai/components/response/ActionToolbarBlock.tsx b/src/features/loyaly-ai/components/response/ActionToolbarBlock.tsx index 84a0e7c..a427127 100644 --- a/src/features/loyaly-ai/components/response/ActionToolbarBlock.tsx +++ b/src/features/loyaly-ai/components/response/ActionToolbarBlock.tsx @@ -8,6 +8,7 @@ import {ICONS} from '@/shared/utils/icons'; import type {ReportAction} from '@/features/loyaly-ai/types/chat'; import {exportData} from '@/shared/utils/export/exportManager'; +import {SAMPLE_AI_EXPORT_ROWS} from '@/shared/mocks/sampleData'; export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) { const router = useRouter(); @@ -36,11 +37,7 @@ export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) { {key: 'metric', header: 'Metric Name'}, {key: 'val', header: 'Value'}, ], - data: [ - {metric: 'Daily Revenue', val: '₹3,40,000'}, - {metric: 'Visitors', val: '1,284'}, - {metric: 'Purchases', val: '276'}, - ], + data: SAMPLE_AI_EXPORT_ROWS, format: 'csv', }); toast({body: '✓ CSV downloaded successfully'}); @@ -58,11 +55,7 @@ export function ActionToolbarBlock({actions}: {actions: ReportAction[]}) { {key: 'metric', header: 'Metric Name'}, {key: 'val', header: 'Value'}, ], - data: [ - {metric: 'Daily Revenue', val: '₹3,40,000'}, - {metric: 'Visitors', val: '1,284'}, - {metric: 'Purchases', val: '276'}, - ], + data: SAMPLE_AI_EXPORT_ROWS, format: 'pdf', }); toast({body: '✓ PDF downloaded successfully'}); diff --git a/src/features/loyaly-ai/components/response/InsightCardsBlock.tsx b/src/features/loyaly-ai/components/response/InsightCardsBlock.tsx index 402ff53..9734eef 100644 --- a/src/features/loyaly-ai/components/response/InsightCardsBlock.tsx +++ b/src/features/loyaly-ai/components/response/InsightCardsBlock.tsx @@ -3,6 +3,14 @@ import {Badge} from '@astryxdesign/core/Badge'; import type {ReportInsight} from '@/features/loyaly-ai/types/chat'; +/** + * One neutral card for every insight type. The type is carried by the Badge, + * which resolves through the semantic tokens; the tinted blue/amber/emerald/ + * purple borders it replaced were raw hues the monochrome rule forbids, and + * read as four different components. + */ +const NEUTRAL_CARD = 'border-border bg-surface'; + export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) { if (!insights || insights.length === 0) return null; @@ -13,28 +21,28 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) { icon: '💡', badgeVariant: 'info' as const, label: 'Opportunity', - borderClass: 'border-blue-500/30 bg-blue-500/[0.03]', + borderClass: NEUTRAL_CARD, }; case 'risk': return { icon: '⚠️', badgeVariant: 'warning' as const, label: 'Risk Alert', - borderClass: 'border-amber-500/30 bg-amber-500/[0.03]', + borderClass: NEUTRAL_CARD, }; case 'growth': return { icon: '📈', badgeVariant: 'success' as const, label: 'Growth Factor', - borderClass: 'border-emerald-500/30 bg-emerald-500/[0.03]', + borderClass: NEUTRAL_CARD, }; case 'best_performer': return { icon: '🏆', badgeVariant: 'success' as const, label: 'Top Performer', - borderClass: 'border-purple-500/30 bg-purple-500/[0.03]', + borderClass: NEUTRAL_CARD, }; } }; @@ -77,7 +85,7 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) { {insight.recommendedAction ? (
- Action: + Action: {insight.recommendedAction} diff --git a/src/features/loyaly-ai/components/response/KpiGridBlock.tsx b/src/features/loyaly-ai/components/response/KpiGridBlock.tsx index d363be8..80fce78 100644 --- a/src/features/loyaly-ai/components/response/KpiGridBlock.tsx +++ b/src/features/loyaly-ai/components/response/KpiGridBlock.tsx @@ -105,7 +105,7 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) { {kpi.trend} diff --git a/src/features/lyts/components/ActivityProgramme.tsx b/src/features/lyts/components/ActivityProgramme.tsx new file mode 100644 index 0000000..3e27153 --- /dev/null +++ b/src/features/lyts/components/ActivityProgramme.tsx @@ -0,0 +1,89 @@ +'use client'; + +import {VStack} from '@astryxdesign/core/Layout'; +import {StaticPanel} from '@/shared/components/patterns/PanelCard'; +import {StatPair, StatRow} from '@/shared/components/patterns/StatPair'; +import {ActivityGroups} from '@/features/dashboard/components/ActivityGroups'; +import {ActivityImpactTable} from '@/features/dashboard/components/ActivityImpactTable'; +import {useActivityMetrics} from '@/features/dashboard/hooks/useDashboard'; +import {formatCompact, formatLyt} from '@/shared/utils/format'; + +/** + * The activity ecosystem, as the Lyts page presents it. + * + * ── Why this lives here and not on the dashboard ────────────────────────── + * The two pages ask different questions of the same data. The dashboard asks + * "how are customers engaging and what is it worth?" and answers with six + * activities and their business impact. Lyts asks "what am I running, what + * does it cost me in LYTs, and is it working?" — which needs all ten, their + * status, and the LYT liability each one creates. + * + * ── One model, not two ──────────────────────────────────────────────────── + * Everything here reads `useActivityMetrics()` — the same hook, the same + * endpoint, the same fixtures the dashboard uses. If the dashboard says Visit + * is 35.3k, this page says 35.3k, because there is nowhere for a second + * number to come from. The grouped cards and the impact table are the exact + * components the dashboard's own activity work produced, rendered in + * `catalogue` variant rather than reimplemented. + * + * The summary strip is StatPairs rather than MetricCards on purpose: the KPI + * row directly above it is already four MetricCards about LYT liability, and + * a second row of the same object would read as eight equal headline metrics + * on a page whose headline is the programme, not the activities. + */ +export function ActivityProgramme() { + const metrics = useActivityMetrics(); + + return ( + + + {/* + Derived at render rather than fetched as its own summary endpoint. + A totals endpoint could disagree with the cards below it — this + cannot, because it is the same array. + */} + {metrics.status === 'success' || metrics.status === 'empty' ? ( + + m.status === 'live').length} of ${metrics.data.length}`} + /> + a + m.count, 0), + )} + /> + Math.max(a, m.impact.customers), + 0, + ), + )} + /> + a + m.lytsIssued, 0), + )} + align="end" + /> + + ) : null} + + + + + + + ); +} diff --git a/src/features/lyts/components/ExpiryAlerts.tsx b/src/features/lyts/components/ExpiryAlerts.tsx new file mode 100644 index 0000000..12e5999 --- /dev/null +++ b/src/features/lyts/components/ExpiryAlerts.tsx @@ -0,0 +1,85 @@ +'use client'; + +import {VStack} from '@astryxdesign/core/Layout'; +import {Banner} from '@astryxdesign/core/Banner'; +import {PanelCard} from '@/shared/components/patterns/PanelCard'; +import {SkeletonRows} from '@/shared/components/patterns/LoadingState'; +import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; +import {formatCompact, formatLyt} from '@/shared/utils/format'; +import {daysUntil} from '@/features/lyts/services/lytsService'; +import type {Reward} from '@/features/lyts/types/reward'; +import type {Resource} from '@/shared/hooks/useResource'; + +/** + * Rewards lapsing within a fortnight. + * + * This panel exists because expiry is the one thing on this page with a + * deadline. Everything else can be read whenever; an offer expiring in two + * days either gets extended today or it doesn't. + * + * Sorted soonest-first, and the description leads with outstanding liability + * rather than claim count — the actionable number is what customers are still + * holding, not how popular the offer was. + */ +const HORIZON_DAYS = 14; + +export function ExpiryAlerts({ + resource, + nowMs, +}: { + resource: Resource; + nowMs: number; +}) { + return ( + } + empty={ + + } + > + {(rewards) => { + const soon = rewards + .map((r) => ({r, days: daysUntil(r.expiresAt, nowMs)})) + .filter( + (x): x is {r: Reward; days: number} => + x.days !== null && x.days >= 0 && x.days <= HORIZON_DAYS, + ) + .sort((a, b) => a.days - b.days); + + if (soon.length === 0) { + return ( + + ); + } + + return ( + + {soon.map(({r, days}) => { + const outstanding = r.claimed - r.used; + return ( + + ); + })} + + ); + }} + + ); +} diff --git a/src/features/lyts/components/RewardCard.tsx b/src/features/lyts/components/RewardCard.tsx new file mode 100644 index 0000000..8a3c687 --- /dev/null +++ b/src/features/lyts/components/RewardCard.tsx @@ -0,0 +1,91 @@ +'use client'; + +import {Card} from '@astryxdesign/core/Card'; +import {VStack, HStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; +import {SectionHeader} from '@/shared/components/patterns/SectionHeader'; +import {Badge} from '@astryxdesign/core/Badge'; +import {ProgressBar} from '@astryxdesign/core/ProgressBar'; +import {Divider} from '@astryxdesign/core/Divider'; +import {Icon} from '@astryxdesign/core/Icon'; +import {HoverLift} from '@/shared/components/motion/HoverLift'; +import {StatPair, StatRow} from '@/shared/components/patterns/StatPair'; +import {ICONS} from '@/shared/utils/icons'; +import {formatCompact, formatLyt, formatPct} from '@/shared/utils/format'; +import {REWARD_STATUS} from '@/features/lyts/utils/rewardStatus'; +import {daysUntil, usageRate} from '@/features/lyts/services/lytsService'; +import type {Reward} from '@/features/lyts/types/reward'; + +/** + * One reward. + * + * The progress bar is claimed-vs-redeemed, and it is the point of the card: + * the unfilled portion is outstanding liability — LYTs a customer holds and + * can spend at any time. A reward claimed 800 times and redeemed 150 is not a + * popular offer, it is ₹78,000 of unbudgeted exposure. + */ +export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) { + const status = REWARD_STATUS[reward.status]; + const rate = usageRate(reward.claimed, reward.used); + const days = daysUntil(reward.expiresAt, nowMs); + const outstanding = reward.claimed - reward.used; + + return ( + + + + + + + {formatLyt(reward.costLyt)} + + + } + actions={} + /> + + + + + + {formatCompact(reward.used)} redeemed + + + {formatCompact(reward.claimed)} claimed + + + + + + + + + + + + + + ); +} diff --git a/src/features/lyts/components/RewardGrid.tsx b/src/features/lyts/components/RewardGrid.tsx new file mode 100644 index 0000000..a51584e --- /dev/null +++ b/src/features/lyts/components/RewardGrid.tsx @@ -0,0 +1,105 @@ +'use client'; + +import {useMemo, useState} from 'react'; +import {Grid} from '@astryxdesign/core/Grid'; +import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary'; +import {FilterBar} from '@/shared/components/patterns/FilterBar'; +import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion'; +import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; +import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState'; +import {RewardCard} from './RewardCard'; +import type {Reward, RewardStatus} from '@/features/lyts/types/reward'; +import type {Resource} from '@/shared/hooks/useResource'; + +type Filter = 'all' | RewardStatus; + +const REWARD_GRID_COLUMNS = {minWidth: 270, max: 3, repeat: 'fit'} as const; + +export function RewardGrid({ + resource, + nowMs, +}: { + resource: Resource; + nowMs: number; +}) { + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + + // Memoised on resource.data: `?? []` allocates a fresh array every render, + // which would make it an unstable dependency and recompute both memos below + // on every keystroke. + const rewards = useMemo(() => resource.data ?? [], [resource.data]); + + // Counts come from the unfiltered set so the chips still read "Expiring (2)" + // while another filter is active. + const counts = useMemo(() => { + const c: Record = { + all: rewards.length, + active: 0, + paused: 0, + expiring: 0, + expired: 0, + }; + for (const r of rewards) c[r.status] += 1; + return c; + }, [rewards]); + + const visible = useMemo(() => { + const q = query.trim().toLowerCase(); + return rewards.filter( + (r) => + (filter === 'all' || r.status === filter) && + (q === '' || r.name.toLowerCase().includes(q)), + ); + }, [rewards, query, filter]); + + return ( + + setFilter(v as Filter)} + filterLabel="Filter rewards by status" + options={[ + {value: 'all', label: 'All', hint: counts.all}, + {value: 'active', label: 'Active', hint: counts.active}, + {value: 'expiring', label: 'Expiring', hint: counts.expiring}, + {value: 'paused', label: 'Paused', hint: counts.paused}, + {value: 'expired', label: 'Expired', hint: counts.expired}, + ]} + resultCount={visible.length} + resultNoun={visible.length === 1 ? 'reward' : 'rewards'} + /> + + } + empty={ + + } + > + {() => + visible.length === 0 ? ( + + ) : ( + + {visible.map((r) => ( + + ))} + + ) + } + + + ); +} diff --git a/src/features/lyts/components/RewardPerformanceTable.tsx b/src/features/lyts/components/RewardPerformanceTable.tsx new file mode 100644 index 0000000..b785ff2 --- /dev/null +++ b/src/features/lyts/components/RewardPerformanceTable.tsx @@ -0,0 +1,143 @@ +'use client'; + +import {proportional, pixel} from '@astryxdesign/core/Table'; +import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable'; +import type {TableColumn} from '@astryxdesign/core/Table'; +import {Badge} from '@astryxdesign/core/Badge'; +import {Text} from '@astryxdesign/core/Text'; +import {HStack} from '@astryxdesign/core/Layout'; +import {PanelCard} from '@/shared/components/patterns/PanelCard'; +import {SkeletonRows} from '@/shared/components/patterns/LoadingState'; +import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; +import {MetricDelta} from '@/shared/components/primitives/MetricDelta'; +import {formatCompact, formatLyt} from '@/shared/utils/format'; +import {REWARD_STATUS} from '@/features/lyts/utils/rewardStatus'; +import {usageRate} from '@/features/lyts/services/lytsService'; +import type {Reward} from '@/features/lyts/types/reward'; +import type {Resource} from '@/shared/hooks/useResource'; + +interface Row extends Record { + id: string; + name: string; + status: Reward['status']; + costLyt: number; + claimed: number; + used: number; + rate: number; + outstanding: number; +} + +/** + * The whole catalogue as rows. + * + * A table rather than more cards: this is the view for comparing eight + * rewards on the same four numbers, and cards force the eye to travel in two + * dimensions to do a one-dimensional comparison. Sorted by redemption rate so + * the offers that aren't working surface at the bottom without hunting. + */ +export function RewardPerformanceTable({ + resource, +}: { + resource: Resource; +}) { + const columns: TableColumn[] = [ + { + key: 'name', + header: 'Reward', + width: proportional(2), + renderCell: (row) => ( + + + {row.name} + + + + ), + }, + { + key: 'costLyt', + header: 'Cost', + width: pixel(104), + align: 'end', + renderCell: (row) => {formatLyt(row.costLyt)}, + }, + { + key: 'claimed', + header: 'Claimed', + width: pixel(104), + align: 'end', + renderCell: (row) => {formatCompact(row.claimed)}, + }, + { + key: 'used', + header: 'Redeemed', + width: pixel(112), + align: 'end', + renderCell: (row) => {formatCompact(row.used)}, + }, + { + key: 'rate', + header: 'Rate', + width: pixel(112), + align: 'end', + renderCell: (row) => ( + + + + ), + }, + { + key: 'outstanding', + header: 'Outstanding', + width: pixel(140), + align: 'end', + renderCell: (row) => ( + {formatLyt(row.outstanding)} + ), + }, + ]; + + return ( + } + empty={ + + } + > + {(rewards) => { + const rows: Row[] = rewards + .map((r) => ({ + id: r.id, + name: r.name, + status: r.status, + costLyt: r.costLyt, + claimed: r.claimed, + used: r.used, + rate: usageRate(r.claimed, r.used), + outstanding: (r.claimed - r.used) * r.costLyt, + })) + .sort((a, b) => b.rate - a.rate); + + return ( + + ); + }} + + ); +} diff --git a/src/features/lyts/hooks/useLyts.ts b/src/features/lyts/hooks/useLyts.ts new file mode 100644 index 0000000..031b669 --- /dev/null +++ b/src/features/lyts/hooks/useLyts.ts @@ -0,0 +1,32 @@ +'use client'; + +import {useScope} from '@/shared/hooks/useScope'; +import {useSampleResource} from '@/shared/mocks/withSample'; +import { + buildLytActivity, + buildRedemptions, + buildRewards, +} from '@/shared/mocks/sampleData'; + +/** + * LYT data access — SAMPLE. + * + * The platform has no rewards, redemption or programme-activity resource, so + * these read shared/mocks/sampleData.ts locally. When the endpoints exist, + * swap each body for useResource(lytsRepository.x(useScope())). + */ + +export function useRewards() { + const {storeId, range} = useScope(); + return useSampleResource((now) => buildRewards(storeId, range, now), [storeId, range]); +} + +export function useRedemptions() { + const {storeId, range} = useScope(); + return useSampleResource((now) => buildRedemptions(storeId, range, now), [storeId, range]); +} + +export function useLytsActivity() { + const {storeId} = useScope(); + return useSampleResource((now) => buildLytActivity(storeId, now), [storeId]); +} diff --git a/src/features/lyts/services/lytsService.ts b/src/features/lyts/services/lytsService.ts new file mode 100644 index 0000000..0b18c6b --- /dev/null +++ b/src/features/lyts/services/lytsService.ts @@ -0,0 +1,53 @@ +import type {Reward} from '@/features/lyts/types/reward'; + +/** + * Domain rules for LYT rewards. + * + * The presentation mapping (status → badge tone) stays in utils/rewardStatus — + * it knows about badges. These are the calculations behind it, which know only + * about rewards, and which the expiry panel, the performance table and any + * future export all have to agree on. + */ + +/** Whole days until expiry; negative once lapsed, null if it never expires. */ +export function daysUntil( + expiresAt: string | null, + nowMs: number, +): number | null { + if (!expiresAt) return null; + return Math.ceil((new Date(expiresAt).getTime() - nowMs) / 86400000); +} + +/** + * Claimed → used conversion, as a percentage. + * + * Zero claims is 0%, not NaN: a reward nobody has taken up has a usage rate, + * and it is zero. Rounded to one decimal because the figure is read, not + * computed against. + */ +export function usageRate(claimed: number, used: number): number { + return claimed === 0 ? 0 : Number(((used / claimed) * 100).toFixed(1)); +} + +/** + * Rewards that need a decision, soonest deadline first. + * + * `withinDays` is the merchant's attention window rather than a fixed + * constant — the expiry panel asks for 7, a daily digest would ask for 1. + * Already-expired rewards are excluded: they are history, and mixing them into + * an alert list is how a panel about acting becomes a panel about reading. + */ +export function selectExpiring( + rewards: Reward[], + nowMs: number, + withinDays: number, +): Reward[] { + return rewards + .map((reward) => ({reward, days: daysUntil(reward.expiresAt, nowMs)})) + .filter( + (entry): entry is {reward: Reward; days: number} => + entry.days !== null && entry.days >= 0 && entry.days <= withinDays, + ) + .sort((a, b) => a.days - b.days) + .map((entry) => entry.reward); +} diff --git a/src/features/lyts/utils/rewardStatus.ts b/src/features/lyts/utils/rewardStatus.ts new file mode 100644 index 0000000..733516d --- /dev/null +++ b/src/features/lyts/utils/rewardStatus.ts @@ -0,0 +1,25 @@ +import type {RewardStatus} from '@/features/lyts/types/reward'; + +/** + * Reward status → badge treatment. + * + * Only `expiring` and `expired` carry colour. An active reward is the normal + * case and a paused one is a deliberate choice by the merchant — neither + * needs attention. Colouring all four would leave nothing standing out on a + * page whose entire job is surfacing the ones about to lapse. + */ +export const REWARD_STATUS: Record< + RewardStatus, + {label: string; variant: 'neutral' | 'warning' | 'error'} +> = { + active: {label: 'Active', variant: 'neutral'}, + paused: {label: 'Paused', variant: 'neutral'}, + expiring: {label: 'Expiring', variant: 'warning'}, + expired: {label: 'Expired', variant: 'error'}, +}; + +/* + * daysUntil / usageRate used to live here. They are calculations about + * rewards, not about badges, so they moved to services/lytsService — this + * module is the presentation mapping and nothing else. + */ diff --git a/src/features/settings/components/ApiWebhooksManager.tsx b/src/features/settings/components/ApiWebhooksManager.tsx index 09312b9..f9cc815 100644 --- a/src/features/settings/components/ApiWebhooksManager.tsx +++ b/src/features/settings/components/ApiWebhooksManager.tsx @@ -11,6 +11,13 @@ import {Button} from '@astryxdesign/core/Button'; import {TextInput} from '@astryxdesign/core/TextInput'; import {useToast} from '@astryxdesign/core/Toast'; import {StaticPanel} from '@/shared/components/patterns/PanelCard'; +import { + SAMPLE_API_KEYS, + SAMPLE_WEBHOOKS, + SAMPLE_WEBHOOK_LOGS, + sampleApiKeyPrefix, + sampleWebhookSecret, +} from '@/shared/mocks/sampleData'; export interface ApiKeyItem extends Record { id: string; @@ -38,73 +45,10 @@ export interface WebhookLogItem extends Record { timestamp: string; } -const INITIAL_KEYS: ApiKeyItem[] = [ - { - id: 'key-1', - name: 'Production POS Key', - keyPrefix: 'lmer_live_9f8a••••••••4b12', - created: '10 Jan 2026', - lastUsed: 'Just now', - status: 'active', - }, - { - id: 'key-2', - name: 'Staging Integration Key', - keyPrefix: 'lmer_test_3d2c••••••••8e91', - created: '15 Jun 2026', - lastUsed: 'Yesterday', - status: 'active', - }, -]; - -const INITIAL_WEBHOOKS: WebhookEndpointItem[] = [ - { - id: 'wh-1', - url: 'https://api.nearle.in/webhooks/loyaly', - events: 'order.created, lyt.redeemed', - secret: 'whsec_8841••••••••9932', - status: 'healthy', - }, - { - id: 'wh-2', - url: 'https://hooks.zapier.com/hooks/catch/19284', - events: 'staff.checkin, store.updated', - secret: 'whsec_7712••••••••1102', - status: 'healthy', - }, -]; - -const WEBHOOK_LOGS: WebhookLogItem[] = [ - { - id: 'log-101', - event: 'lyt.redeemed', - url: 'https://api.nearle.in/webhooks/loyaly', - code: 200, - latency: '142ms', - timestamp: 'Today, 15:42:01', - }, - { - id: 'log-102', - event: 'order.created', - url: 'https://api.nearle.in/webhooks/loyaly', - code: 200, - latency: '185ms', - timestamp: 'Today, 15:38:12', - }, - { - id: 'log-103', - event: 'staff.checkin', - url: 'https://hooks.zapier.com/hooks/catch/19284', - code: 200, - latency: '95ms', - timestamp: 'Today, 09:01:44', - }, -]; - export function ApiWebhooksManager() { const toast = useToast(); - const [keys, setKeys] = useState(INITIAL_KEYS); - const [webhooks, setWebhooks] = useState(INITIAL_WEBHOOKS); + const [keys, setKeys] = useState(SAMPLE_API_KEYS); + const [webhooks, setWebhooks] = useState(SAMPLE_WEBHOOKS); const [newKeyName, setNewKeyName] = useState(''); const [isGenerating, setIsGenerating] = useState(false); const [newWebhookUrl, setNewWebhookUrl] = useState(''); @@ -117,7 +61,7 @@ export function ApiWebhooksManager() { const createdKey: ApiKeyItem = { id: `key-${Date.now()}`, name: newKeyName, - keyPrefix: `lmer_live_${Math.random().toString(36).substring(2, 6)}••••••••${Math.random().toString(36).substring(2, 6)}`, + keyPrefix: sampleApiKeyPrefix(), created: 'Just now', lastUsed: 'Never', status: 'active', @@ -142,7 +86,7 @@ export function ApiWebhooksManager() { id: `wh-${Date.now()}`, url: newWebhookUrl, events: 'order.created, lyt.redeemed, staff.checkin', - secret: `whsec_${Math.random().toString(36).substring(2, 8)}••••`, + secret: sampleWebhookSecret(), status: 'healthy', }; setWebhooks((prev) => [newWh, ...prev]); @@ -374,7 +318,7 @@ export function ApiWebhooksManager() { > diff --git a/src/features/settings/components/BillingOverview.tsx b/src/features/settings/components/BillingOverview.tsx index aeb8908..f6c1d47 100644 --- a/src/features/settings/components/BillingOverview.tsx +++ b/src/features/settings/components/BillingOverview.tsx @@ -26,36 +26,13 @@ export interface InvoiceItem extends Record { downloadUrl: string; } -const INVOICES: InvoiceItem[] = [ - { - id: 'INV-2026-008', - date: '1 Aug 2026', - amount: 14999, - status: 'paid', - downloadUrl: '#', - }, - { - id: 'INV-2026-007', - date: '1 Jul 2026', - amount: 14999, - status: 'paid', - downloadUrl: '#', - }, - { - id: 'INV-2026-006', - date: '1 Jun 2026', - amount: 14999, - status: 'paid', - downloadUrl: '#', - }, -]; - import {DownloadDropdown} from '@/shared/components/patterns/DownloadDropdown'; +import {SAMPLE_BANK_ACCOUNT, SAMPLE_INVOICES} from '@/shared/mocks/sampleData'; export function BillingOverview() { const toast = useToast(); - const [bankAccount, setBankAccount] = useState('HDFC Bank •••• 8842'); - const [ifsc, setIfsc] = useState('HDFC0001234'); + const [bankAccount, setBankAccount] = useState(SAMPLE_BANK_ACCOUNT.account); + const [ifsc, setIfsc] = useState(SAMPLE_BANK_ACCOUNT.ifsc); const [isUpdatingBank, setIsUpdatingBank] = useState(false); const handleUpdateBank = () => { @@ -220,7 +197,7 @@ export function BillingOverview() { diff --git a/src/features/settings/components/BusinessForm.tsx b/src/features/settings/components/BusinessForm.tsx index 863317b..f659c91 100644 --- a/src/features/settings/components/BusinessForm.tsx +++ b/src/features/settings/components/BusinessForm.tsx @@ -11,6 +11,7 @@ import {Text} from '@astryxdesign/core/Text'; import {Divider} from '@astryxdesign/core/Divider'; import {useToast} from '@astryxdesign/core/Toast'; import {StaticPanel} from '@/shared/components/patterns/PanelCard'; +import {SAMPLE_BUSINESS} from '@/shared/mocks/sampleData'; export interface BusinessData { businessName: string; @@ -26,25 +27,11 @@ export interface BusinessData { lytsPerHundred: number; } -const DEFAULT_BUSINESS: BusinessData = { - businessName: 'Loyaly Retail Pvt Ltd', - legalEntity: 'Private Limited Company', - category: 'Retail & Quick Service Restaurant', - gstin: '29AABCL1234M1Z7', - pan: 'AABCL1234M', - phone: '+91 98450 12345', - email: 'aravind@nearle.in', - address: '100 Feet Road, Indiranagar', - city: 'Bengaluru, Karnataka', - pincode: '560038', - lytsPerHundred: 5, -}; - export function BusinessForm() { const toast = useToast(); - const [form, setForm] = useState(DEFAULT_BUSINESS); + const [form, setForm] = useState(SAMPLE_BUSINESS); const [isSaving, setIsSaving] = useState(false); - const [savedData, setSavedData] = useState(DEFAULT_BUSINESS); + const [savedData, setSavedData] = useState(SAMPLE_BUSINESS); const set = (key: K, val: BusinessData[K]) => setForm((f) => ({...f, [key]: val})); diff --git a/src/features/settings/components/IntegrationsGrid.tsx b/src/features/settings/components/IntegrationsGrid.tsx index c0078cd..33e0194 100644 --- a/src/features/settings/components/IntegrationsGrid.tsx +++ b/src/features/settings/components/IntegrationsGrid.tsx @@ -10,6 +10,7 @@ import {Button} from '@astryxdesign/core/Button'; import {TextInput} from '@astryxdesign/core/TextInput'; import {useToast} from '@astryxdesign/core/Toast'; import {StaticPanel} from '@/shared/components/patterns/PanelCard'; +import {SAMPLE_INTEGRATIONS} from '@/shared/mocks/sampleData'; export interface IntegrationApp { id: string; @@ -21,69 +22,9 @@ export interface IntegrationApp { lastSync?: string; } -const INITIAL_APPS: IntegrationApp[] = [ - { - id: 'shopify', - name: 'Shopify Storefront', - category: 'E-commerce POS', - description: 'Sync online customer orders, cart totals and auto-issue LYT points.', - status: 'connected', - apiKeyLabel: 'myshopify-store.myshopify.com', - lastSync: '5 mins ago', - }, - { - id: 'woocommerce', - name: 'WooCommerce', - category: 'E-commerce POS', - description: 'WordPress store integration for online checkout redemption.', - status: 'disconnected', - }, - { - id: 'razorpay', - name: 'Razorpay PG', - category: 'Payment Gateway', - description: 'Automated settlement reconciliation and online UPI payment sync.', - status: 'connected', - apiKeyLabel: 'rzp_live_••••••••8841', - lastSync: 'Just now', - }, - { - id: 'stripe', - name: 'Stripe Billing', - category: 'Payment Gateway', - description: 'International cards and subscription recurring billing handler.', - status: 'disconnected', - }, - { - id: 'whatsapp', - name: 'WhatsApp Business API', - category: 'Messaging Channel', - description: 'Send instant transaction receipts, reward expiry notices and OTPs.', - status: 'connected', - apiKeyLabel: '+91 98450 12345 (Verified)', - lastSync: '2 mins ago', - }, - { - id: 'google', - name: 'Google Business Profile', - category: 'Local Search & Maps', - description: 'Sync branch operating hours, store locations and customer reviews.', - status: 'connected', - apiKeyLabel: '5 locations synced', - lastSync: '1 hour ago', - }, - { - id: 'meta', - name: 'Meta Ads & Conversions', - category: 'Marketing & Ads', - description: 'Track ad attribution, return on ad spend (ROAS) and retargeting.', - status: 'disconnected', - }, -]; - export function IntegrationsGrid() { const toast = useToast(); - const [apps, setApps] = useState(INITIAL_APPS); + const [apps, setApps] = useState(SAMPLE_INTEGRATIONS); const [selectedApp, setSelectedApp] = useState(null); const [apiKeyInput, setApiKeyInput] = useState(''); diff --git a/src/features/settings/components/SecurityManager.tsx b/src/features/settings/components/SecurityManager.tsx index 57cf01c..d85c868 100644 --- a/src/features/settings/components/SecurityManager.tsx +++ b/src/features/settings/components/SecurityManager.tsx @@ -17,6 +17,7 @@ import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; import {useResource} from '@/shared/hooks/useResource'; import {securityRepository} from '@/features/settings/repositories/securityRepository'; import type {DeviceSession} from '@/features/settings/services/mapSession'; +import {SAMPLE_LOGIN_AUDIT} from '@/shared/mocks/sampleData'; export interface ActiveSessionItem extends Record { id: string; @@ -36,33 +37,6 @@ export interface LoginAuditItem extends Record { timestamp: string; } -const AUDIT_LOGS: LoginAuditItem[] = [ - { - id: 'aud-1', - user: 'aravind@nearle.in', - ip: '103.15.24.81', - device: 'macOS Chrome', - status: 'success', - timestamp: 'Today, 14:02', - }, - { - id: 'aud-2', - user: 'vikram@nearle.in', - ip: '106.51.72.19', - device: 'Windows Chrome', - status: 'success', - timestamp: 'Today, 11:15', - }, - { - id: 'aud-3', - user: 'aravind@nearle.in', - ip: '185.220.101.5', - device: 'Unknown Linux Device', - status: 'failed', - timestamp: 'Yesterday, 22:30', - }, -]; - export function SecurityManager() { const toast = useToast(); const [twoFactor, setTwoFactor] = useState(true); @@ -311,7 +285,7 @@ export function SecurityManager() { diff --git a/src/features/staff/components/AttendanceChart.tsx b/src/features/staff/components/AttendanceChart.tsx new file mode 100644 index 0000000..17a7f71 --- /dev/null +++ b/src/features/staff/components/AttendanceChart.tsx @@ -0,0 +1,65 @@ +'use client'; + +import {ChartCard} from '@/shared/components/charts/ChartCard'; +import {BarChartView} from '@/shared/components/charts/BarChartView'; +import {CHART} from '@/shared/components/charts/palette'; +import {formatCompact, formatDayLabel} from '@/shared/utils/format'; +import type {AttendancePoint} from '@/features/staff/types/staff'; +import type {Resource} from '@/shared/hooks/useResource'; + +/** + * Daily attendance, stacked. + * + * PRESENT IS GRAY. Colouring it green produced a wall of saturated bars that + * dominated the page and buried the only thing worth looking at — the amber + * and red slivers. Present is the baseline, not an event; the exceptions are + * what a merchant scans for, and they are what gets colour. + * + * This keeps the app's rule intact (colour marks what needs action) while + * still letting late and absent read instantly without a legend lookup. + * + * Stacked rather than grouped because the meaningful quantity is the roster + * total and how it splits — grouped bars would make you add three numbers to + * answer "how many were rostered on Friday". + */ +export function AttendanceChart({ + resource, +}: { + resource: Resource; +}) { + return ( + + {(d) => ( + + )} + + ); +} diff --git a/src/features/staff/components/Leaderboard.tsx b/src/features/staff/components/Leaderboard.tsx new file mode 100644 index 0000000..2939dd3 --- /dev/null +++ b/src/features/staff/components/Leaderboard.tsx @@ -0,0 +1,183 @@ +'use client'; + +import {proportional, pixel} from '@astryxdesign/core/Table'; +import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable'; +import type {TableColumn} from '@astryxdesign/core/Table'; +import {Avatar} from '@astryxdesign/core/Avatar'; +import {Badge} from '@astryxdesign/core/Badge'; +import {Text} from '@astryxdesign/core/Text'; +import {HStack, VStack} from '@astryxdesign/core/Layout'; +import {ProgressBar} from '@astryxdesign/core/ProgressBar'; +import {PanelCard} from '@/shared/components/patterns/PanelCard'; +import {SkeletonRows} from '@/shared/components/patterns/LoadingState'; +import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; +import {ATTENDANCE} from '@/features/staff/utils/attendance'; +import {useStoreName} from '@/features/stores/hooks/useStoreDirectory'; +import {formatCompact, formatPct} from '@/shared/utils/format'; +import type {Attendance, StaffMember} from '@/features/staff/types/staff'; +import type {Resource} from '@/shared/hooks/useResource'; + +interface Row extends Record { + id: string; + rank: number; + name: string; + role: string; + storeId: string; + attendance: Attendance; + salesCount: number; + rewardsIssued: number; + performanceScore: number; + punctualityPct: number; +} + +/** + * The whole team, ranked. + * + * A table because this is a one-dimensional comparison across many rows — + * cards would force the eye to travel in two dimensions to answer "who is + * third". Capped at the top 10: a leaderboard that lists everyone is a + * roster, and the roster already exists above. + */ +export function Leaderboard({ + resource, +}: { + resource: Resource; +}) { + // Resolved from the workspace store directory, not from a fixture — see + // useStoreDirectory. + const storeName = useStoreName(); + + const columns: TableColumn[] = [ + { + key: 'rank', + header: '#', + width: pixel(48), + renderCell: (row) => ( + + {row.rank} + + ), + }, + { + key: 'name', + header: 'Team member', + width: proportional(2), + renderCell: (row) => ( + + + + + {row.name} + + + {row.role} · {storeName(row.storeId)} + + + + ), + }, + { + key: 'attendance', + header: 'Today', + width: pixel(112), + renderCell: (row) => ( + + ), + }, + { + key: 'salesCount', + header: 'Sales', + width: pixel(88), + align: 'end', + renderCell: (row) => ( + {formatCompact(row.salesCount)} + ), + }, + { + key: 'rewardsIssued', + header: 'Rewards', + width: pixel(96), + align: 'end', + renderCell: (row) => ( + {formatCompact(row.rewardsIssued)} + ), + }, + { + key: 'punctualityPct', + header: 'Punctuality', + width: pixel(112), + align: 'end', + renderCell: (row) => ( + {formatPct(row.punctualityPct, 0)} + ), + }, + { + key: 'performanceScore', + header: 'Score', + // 140px was too narrow for a bar plus a 2-digit number side by side — + // the value wrapped under the bar and read as two separate figures. + width: pixel(180), + renderCell: (row) => ( + + + + {row.performanceScore} + + + ), + }, + ]; + + return ( + } + empty={ + + } + > + {(staff) => { + const rows: Row[] = [...staff] + .sort((a, b) => b.performanceScore - a.performanceScore) + .slice(0, 10) + .map((m, i) => ({ + id: m.id, + rank: i + 1, + name: m.name, + role: m.role, + storeId: m.storeId, + attendance: m.attendance, + salesCount: m.salesCount, + rewardsIssued: m.rewardsIssued, + performanceScore: m.performanceScore, + punctualityPct: m.punctualityPct, + })); + + return ( + + ); + }} + + ); +} diff --git a/src/features/staff/components/StaffCard.tsx b/src/features/staff/components/StaffCard.tsx new file mode 100644 index 0000000..170f069 --- /dev/null +++ b/src/features/staff/components/StaffCard.tsx @@ -0,0 +1,87 @@ +'use client'; + +import {Card} from '@astryxdesign/core/Card'; +import {VStack, HStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; +import {Badge} from '@astryxdesign/core/Badge'; +import {Avatar} from '@astryxdesign/core/Avatar'; +import {ProgressBar} from '@astryxdesign/core/ProgressBar'; +import {Divider} from '@astryxdesign/core/Divider'; +import {HoverLift} from '@/shared/components/motion/HoverLift'; +import {SectionHeader} from '@/shared/components/patterns/SectionHeader'; +import {StatPair, StatRow} from '@/shared/components/patterns/StatPair'; +import {ATTENDANCE} from '@/features/staff/utils/attendance'; +import {useStoreName} from '@/features/stores/hooks/useStoreDirectory'; +import {formatCompact, formatPct} from '@/shared/utils/format'; +import type {StaffMember} from '@/features/staff/types/staff'; + +/** + * One team member. + * + * The performance score gets a bar rather than a bare number because a score + * out of 100 is meaningless without a sense of the scale — 68 reads very + * differently as a filled bar than as a digit. Sales and rewards stay as + * plain figures; they are counts, not proportions. + */ +export function StaffCard({member}: {member: StaffMember}) { + const attendance = ATTENDANCE[member.attendance]; + // Resolved from the workspace store directory, not from a fixture — see + // useStoreDirectory. + const storeName = useStoreName(); + + return ( + + + + + {member.role} · {storeName(member.storeId)} + + } + actions={ + + } + /> + + + + + + + + Performance + + + {member.performanceScore}/100 + + + + + + + + + + + + + + + + ); +} diff --git a/src/features/staff/components/StaffGrid.tsx b/src/features/staff/components/StaffGrid.tsx new file mode 100644 index 0000000..7d008ba --- /dev/null +++ b/src/features/staff/components/StaffGrid.tsx @@ -0,0 +1,90 @@ +'use client'; + +import {useMemo, useState} from 'react'; +import {Grid} from '@astryxdesign/core/Grid'; +import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary'; +import {FilterBar} from '@/shared/components/patterns/FilterBar'; +import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion'; +import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; +import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState'; +import {StaffCard} from './StaffCard'; +import {ATTENDANCE} from '@/features/staff/utils/attendance'; +import { + countByAttendance, + selectStaff, +} from '@/features/staff/services/staffService'; +import type {StaffFilter} from '@/features/staff/services/staffService'; +import type {StaffMember} from '@/features/staff/types/staff'; +import type {Resource} from '@/shared/hooks/useResource'; + +const STAFF_GRID_COLUMNS = {minWidth: 300, max: 3, repeat: 'fit'} as const; + +export function StaffGrid({resource}: {resource: Resource}) { + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + + // `?? []` allocates a fresh array every render, which would make it an + // unstable dependency and recompute both memos on every keystroke. + const staff = useMemo(() => resource.data ?? [], [resource.data]); + + // Both derivations live in staffService: what counts as a match and what + // order the roster is in are product rules, and a redesign of this grid must + // not be able to change them by accident. The memos stay here because + // memoisation is a React concern, not a domain one. + const counts = useMemo(() => countByAttendance(staff), [staff]); + + const visible = useMemo( + () => selectStaff(staff, {filter, search: query}), + [staff, query, filter], + ); + + return ( + + setFilter(v as StaffFilter)} + filterLabel="Filter staff by attendance" + options={[ + {value: 'all', label: 'All', hint: counts.all}, + {value: 'present', label: ATTENDANCE.present.label, hint: counts.present}, + {value: 'late', label: ATTENDANCE.late.label, hint: counts.late}, + {value: 'absent', label: ATTENDANCE.absent.label, hint: counts.absent}, + {value: 'leave', label: ATTENDANCE.leave.label, hint: counts.leave}, + ]} + resultCount={visible.length} + resultNoun={visible.length === 1 ? 'person' : 'people'} + /> + + } + empty={ + + } + > + {() => + visible.length === 0 ? ( + + ) : ( + + {visible.map((m) => ( + + ))} + + ) + } + + + ); +} diff --git a/src/features/staff/components/StaffKpis.tsx b/src/features/staff/components/StaffKpis.tsx new file mode 100644 index 0000000..f47cf4e --- /dev/null +++ b/src/features/staff/components/StaffKpis.tsx @@ -0,0 +1,107 @@ +'use client'; + +import {Card} from '@astryxdesign/core/Card'; +import {Grid} from '@astryxdesign/core/Grid'; +import {VStack, HStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; +import {StatusDot} from '@astryxdesign/core/StatusDot'; +import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary'; +import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState'; +import {HoverLift} from '@/shared/components/motion/HoverLift'; +import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber'; +import {useMetricColumns} from '@/features/dashboard/components/KpiRow'; +import {ATTENDANCE} from '@/features/staff/utils/attendance'; +import {formatPct} from '@/shared/utils/format'; +import type {StaffSummary} from '@/features/staff/types/staff'; +import type {Resource} from '@/shared/hooks/useResource'; +import type {StatusDotVariant} from '@astryxdesign/core/StatusDot'; + +/** + * Attendance counts. + * + * Deliberately NOT MetricCard: these have no trend line and no + * period-over-period delta — "present today" has nothing meaningful to + * compare against last month. What they carry instead is a status dot and a + * share-of-total, so they get their own small card rather than a MetricCard + * with two empty slots. + */ +function CountCard({ + label, + value, + total, + tone, +}: { + label: string; + value: number; + total: number; + tone: StatusDotVariant; +}) { + const share = total === 0 ? 0 : (value / total) * 100; + + return ( + + {/* Padding and gaps match MetricCard so this row is the same height as + the dashboard KPI row (~112px). */} + + + + + + {label} + + + {/* Text, not Heading — a count is data, not a section title. */} + + String(Math.round(v))} + /> + + + {formatPct(share, 0)} of {total} rostered + + + + + ); +} + +export function StaffKpis({resource}: {resource: Resource}) { + const columns = useMetricColumns(); + + return ( + } + > + {(s) => ( + + + + + + + )} + + ); +} diff --git a/src/features/staff/hooks/useStaff.ts b/src/features/staff/hooks/useStaff.ts new file mode 100644 index 0000000..6e6ea09 --- /dev/null +++ b/src/features/staff/hooks/useStaff.ts @@ -0,0 +1,32 @@ +'use client'; + +import {useScope} from '@/shared/hooks/useScope'; +import {useSampleResource} from '@/shared/mocks/withSample'; +import { + buildAttendance, + buildStaff, + buildStaffSummary, +} from '@/shared/mocks/sampleData'; + +/** + * Shop-floor staff data — SAMPLE. + * + * The platform's /api/team is who can sign in, not who worked a shift, so + * attendance, sales-per-head and the leaderboard read + * shared/mocks/sampleData.ts locally. The Team table (features/team) is real. + */ + +export function useStaffList() { + const {storeId, range} = useScope(); + return useSampleResource(() => buildStaff(storeId, range), [storeId, range]); +} + +export function useStaffSummary() { + const {storeId, range} = useScope(); + return useSampleResource(() => buildStaffSummary(storeId, range), [storeId, range]); +} + +export function useStaffAttendance() { + const {storeId, range} = useScope(); + return useSampleResource((now) => buildAttendance(storeId, range, now), [storeId, range]); +} diff --git a/src/features/staff/services/staffService.ts b/src/features/staff/services/staffService.ts new file mode 100644 index 0000000..c49a8ce --- /dev/null +++ b/src/features/staff/services/staffService.ts @@ -0,0 +1,60 @@ +import type {Attendance, StaffMember} from '@/features/staff/types/staff'; + +/** + * Domain rules for the staff module. + * + * These were inline in StaffGrid's useMemo. They are rules, not rendering: + * "the roster is ordered by who is carrying the store" is a product decision + * that a redesign of the grid must not be able to change by accident, and a + * second surface (the leaderboard, an export, a mobile view) has to be able to + * reuse it without copying a filter chain out of a component. + * + * Framework-free on purpose — no React, no Astryx — so it is unit-testable and + * survives any change to how staff are displayed. + */ + +export type StaffFilter = Attendance | 'all'; + +export interface StaffQuery { + filter: StaffFilter; + /** Free text, matched against name and role. */ + search: string; +} + +/** + * Filter, then rank. + * + * Best performers first: the roster's default question is "who is carrying + * this store", not "who was added first". Ordering by score rather than name + * is the single decision that makes the grid answer it without a sort control. + */ +export function selectStaff( + staff: StaffMember[], + {filter, search}: StaffQuery, +): StaffMember[] { + const q = search.trim().toLowerCase(); + return staff + .filter( + (m) => + (filter === 'all' || m.attendance === filter) && + (q === '' || + m.name.toLowerCase().includes(q) || + m.role.toLowerCase().includes(q)), + ) + .sort((a, b) => b.performanceScore - a.performanceScore); +} + +/** How many people are in each attendance state. Drives the filter counts. */ +export function countByAttendance( + staff: StaffMember[], +): Record { + const counts: Record = { + all: staff.length, + present: 0, + late: 0, + absent: 0, + leave: 0, + }; + for (const member of staff) counts[member.attendance] += 1; + return counts; +} diff --git a/src/features/stores/components/StoreCard.tsx b/src/features/stores/components/StoreCard.tsx new file mode 100644 index 0000000..e537001 --- /dev/null +++ b/src/features/stores/components/StoreCard.tsx @@ -0,0 +1,82 @@ +'use client'; + +import {Card} from '@astryxdesign/core/Card'; +import {VStack, HStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; +import {SectionHeader} from '@/shared/components/patterns/SectionHeader'; +import {Badge} from '@astryxdesign/core/Badge'; +import {Button} from '@astryxdesign/core/Button'; +import {Divider} from '@astryxdesign/core/Divider'; +import {Icon} from '@astryxdesign/core/Icon'; +import {HoverLift} from '@/shared/components/motion/HoverLift'; +import {StatPair, StatRow} from '@/shared/components/patterns/StatPair'; +import {ICONS} from '@/shared/utils/icons'; +import {formatCompact, formatInrCompact, formatPct} from '@/shared/utils/format'; +import type {Store, StoreStatus} from '@/features/stores/types/store'; + +/** + * Status is the one thing on this card allowed colour, because it is the one + * thing that can require action. A closed or under-maintenance store is why a + * merchant opened this page; its visitor count is context. + */ +const STATUS: Record< + StoreStatus, + {label: string; variant: 'success' | 'warning' | 'error' | 'neutral'} +> = { + open: {label: 'Open', variant: 'success'}, + maintenance: {label: 'Maintenance', variant: 'warning'}, + closed: {label: 'Closed', variant: 'error'}, +}; + +export function StoreCard({store}: {store: Store}) { + const status = STATUS[store.status]; + + return ( + + + + + + + {store.staffCount} staff + + + } + actions={} + /> + + + + + + + + + + + + +