dashboard design changes

This commit is contained in:
2026-08-05 18:34:37 +05:30
parent 2c8c394795
commit 9d7bbad32c
92 changed files with 5104 additions and 608 deletions

View File

@@ -6,6 +6,11 @@
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3100
},
{
"name": "loyaly-mer-attach",
"url": "http://localhost:3100",
"port": 3100
}
]
}

View File

@@ -72,8 +72,16 @@ Gray is the accent. The **only** colour permitted is semantic: success, warning,
and with it on the client → an unpatchable class mismatch. Setting status on blur/submit
(the normal path) is unaffected. Don't server-render a field that already has a status.
- Component keys in `defineTheme({components})` are inconsistently cased: `text-input` but
`progressbar`. The CLI warns on unknown keys — **treat any `⚠ Unknown component` from
`theme:build` as an error**, it means the override silently did nothing.
`progressbar`. The CLI warns on unknown keys, but that warning is **not** reliable in
either direction:
- A *misspelled* key (`textinput`) is dropped silently — the CSS is never emitted.
- A key the CLI doesn't know but Astryx *does* use (`table-cell`, `table-header-cell`)
warns yet still emits correctly.
So never trust the warning alone. After any `components` change, grep the generated
`src/theme/loyaly.css` for the rule, then confirm the computed style in the browser.
Valid targets are whatever `themeProps('...')` is called with in `dist/<Component>/*.js`.
- `StyleOverrides` supports structural pseudo-classes, not just interaction ones —
`':first-child'` emits correctly (used for the table first-column lead-in).
## Conventions
- Follow the Astryx rules above: no raw `<div>` for layout, no hardcoded hex/px, component
@@ -81,4 +89,24 @@ Gray is the accent. The **only** colour permitted is semantic: success, warning,
- `src/lib/icons.ts` is the single icon map. Only the 26 semantic names resolve via
`<Icon icon="search"/>`; everything else is `<Icon icon={ICONS.stores}/>`. Never import a
lucide icon directly into a feature file.
## Settings spacing contract
Every Settings screen renders inside `src/features/settings/SettingsPage.tsx`. Do not add
page padding in an individual settings page — change the container instead.
| | value | why |
|---|---|---|
| Horizontal | 32px (`paddingInline={8}`) | clears the sub-nav and the Copilot rail |
| Top | 32px (`paddingBlock={8}`) | title never touches the header |
| Bottom | 48px (`className="pb-12"`) | last card is never flush to the fold |
| Header → body | 32px (`gap={8}`) | title block reads as its own layer |
| Between body blocks | 24px (`gap={6}`) | cards, tables, toolbars |
`SpacingStep` stops at 10 (40px) and Stack has no `paddingBlockEnd`, which is why the 48px
bottom goes through the Tailwind bridge — `pb-12` still resolves to `--spacing-12`, so no
raw pixel value enters the codebase. It sits in the `utilities` layer, which the cascade
puts after `astryx-base`, so it wins over `paddingBlock` without `!important`.
Card padding (24px) and table cell padding (12px, 20px first-column lead-in) are set once
in `loyalyTheme.ts` so every module agrees — not per page.
<!-- LOYALY:END -->

View File

@@ -8,7 +8,8 @@
"start": "next start -p 3100",
"lint": "eslint",
"theme:build": "astryx theme build src/theme/loyalyTheme.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"dev:preview": "next dev"
},
"dependencies": {
"@astryxdesign/core": "^0.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/white-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -0,0 +1,45 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/components/primitives/PageHeader';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {storeName} from '@/lib/mock/stores';
/**
* The complete event history — where the dashboard's "View all" leads.
*
* 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.
*
* Reads the same resource and the same component the dashboard panel does,
* uncapped: one feed implementation, two budgets.
*/
export default function ActivityPage() {
const {storeId, range} = useWorkspace();
const activity = useResource(endpoints.dashboardActivity({range, storeId}));
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
return (
<VStack gap={5}>
<PageHeader
title="Activity"
description={`Every recorded event across ${scopeLabel}, newest first.`}
controls={<ScopeControls />}
/>
<ActivityTimeline
resource={activity}
title="All activity"
subtitle="Redemptions, purchases, attendance, store events and expiries"
/>
</VStack>
);
}

View File

@@ -3,7 +3,11 @@
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 {PageHeader} from '@/components/primitives/PageHeader';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ICONS} from '@/lib/icons';
import {ChartCard} from '@/components/charts/ChartCard';
import {AreaChartView} from '@/components/charts/AreaChartView';
import {LineChartView} from '@/components/charts/LineChartView';
@@ -27,18 +31,51 @@ import {
} from '@/lib/format';
/**
* The dashboard answers three questions in order, top to bottom:
* 1. How is the business performing? → KPI row
* 2. What is the shape of that? → footfall / revenue / conversion
* 3. What needs attention, and when? → peak hours, rewards, stores, feed
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
*
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider,
* so changing either control in the top nav re-queries the whole page without
* a route change — which is what keeps the Copilot mounted alongside it.
* 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.
*
* 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 the Copilot'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.
*
* 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.
*
* 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.
*/
/**
* Derived from the SERVER's clock, carried on the response meta.
*
* Calling Date.now() during render is impure and disagrees between the SSR and
* hydration passes. Before meta lands there is no instant to reason about, so
* the greeting is the time-independent one; both strings are a single line, so
* the swap costs no layout shift. getHours() resolves the server's instant in
* the viewer's zone, which is the hour the merchant is actually living in.
*/
function greetingFor(generatedAt?: string): string {
if (!generatedAt) return 'Welcome back';
const hour = new Date(generatedAt).getHours();
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
}
export default function DashboardPage() {
const {storeId, range} = useWorkspace();
const [granularity, setGranularity] = useState<Granularity>('weekly');
// On by default: store comparison has always been part of the all-stores
// 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);
const scope = {range, storeId};
@@ -49,17 +86,46 @@ export default function DashboardPage() {
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
const isAllStores = storeId === 'all';
const scopeLabel = isAllStores ? 'all stores' : storeName(storeId);
return (
<VStack gap={5}>
{/* 1 + 2 — greeting, title, and the store / period / compare filters. */}
<PageHeader
eyebrow={greetingFor(kpis.meta?.generatedAt)}
title="Dashboard"
description={`How the business is performing across ${scopeLabel}, what needs attention, and what to do next.`}
description={`Business performance across ${scopeLabel}.`}
controls={
<>
<ScopeControls />
<Button
size="sm"
// Pressed state is the raised gray of `primary` in this theme —
// a toggle needs a visible on-state, and it must not be colour.
variant={isComparing ? 'primary' : 'secondary'}
label="Compare"
icon={<Icon icon={ICONS.compare} size="sm" />}
aria-pressed={isComparing}
isDisabled={!isAllStores}
// With a tooltip present, Button uses aria-disabled rather than
// the native attribute, so the reason stays reachable by keyboard
// instead of the control just going dead.
tooltip={
isAllStores
? undefined
: 'Comparing stores needs the All stores scope'
}
onClick={() => setIsComparing((v) => !v)}
/>
</>
}
/>
{/* 3 — headline numbers. */}
<KpiRow resource={kpis} />
{/* 4 — primary analytics: the two series everything else explains. */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
{(d) => (
@@ -84,7 +150,10 @@ export default function DashboardPage() {
/>
)}
</ChartCard>
</Grid>
{/* 5 — secondary analytics: the conversion story, in two readings. */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Visitors vs purchases"
subtitle="The gap is the conversion opportunity"
@@ -124,6 +193,7 @@ export default function DashboardPage() {
</ChartCard>
</Grid>
{/* 6 — operational analytics: when traffic lands, what it redeems. */}
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
<ChartCard
title="Peak hours"
@@ -137,18 +207,27 @@ export default function DashboardPage() {
<RewardUsageChart resource={rewards} />
</Grid>
{/* 7 — period rollup. */}
<PerformancePanel
scope={scope}
granularity={granularity}
onGranularityChange={setGranularity}
/>
{/* Comparing stores is meaningless when scoped to one of them. */}
{storeId === 'all' ? (
{/* 8 — 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}
<ActivityTimeline resource={activity} />
{/* 9 — bounded feed: six rows, fixed box, full history on /activity. */}
<ActivityTimeline
resource={activity}
subtitle="Latest events across the selected store and period"
limit={6}
height={285}
viewAllHref="/activity"
/>
</VStack>
);
}

View File

@@ -1,14 +1,27 @@
'use client';
import {AppShell} from '@astryxdesign/core/AppShell';
import {Layout, LayoutContent, LayoutPanel} from '@astryxdesign/core/Layout';
import {
Layout,
LayoutContent,
LayoutHeader,
LayoutPanel,
VStack,
} from '@astryxdesign/core/Layout';
import {AppSideNav} from '@/components/shell/AppSideNav';
import {AppTopNav} from '@/components/shell/AppTopNav';
import {MobileScopeNav} from '@/components/shell/MobileScopeNav';
import {MobileMenu} from '@/components/shell/MobileMenu';
import {SidebarProvider, useSidebar} from '@/components/shell/SidebarProvider';
import {CopilotPanel} from '@/features/copilot/CopilotPanel';
import {CopilotSlideOver} from '@/features/copilot/CopilotSlideOver';
import {useCopilot} from '@/features/copilot/CopilotProvider';
import {useBreakpoint, isPanelInline, copilotWidth} from '@/lib/breakpoints';
import {
useBreakpoint,
isPanelInline,
isSideNavInline,
copilotWidth,
contentMaxWidth,
} from '@/lib/breakpoints';
/**
* The three-column shell, and the reason the Copilot survives navigation.
@@ -18,28 +31,61 @@ import {useBreakpoint, isPanelInline, copilotWidth} from '@/lib/breakpoints';
* nav, scroll position, the Copilot — is untouched.
*
* Structure, and why AppShell alone is not enough: AppShell renders
* Layout{start, content} internally and exposes no end/panel slot. The Copilot
* column therefore comes from a NESTED Layout inside AppShell's children,
* using its `end` slot.
* Layout{header, start, content} internally and exposes no end/panel slot. The
* Copilot column therefore comes from a NESTED Layout inside its children.
*
* AppShell(topNav, sideNav)
* └─ Layout(content = page, end = LayoutPanel > CopilotPanel)
* AppShell(sideNav)
* └─ Layout(header = TopNav, content = page, end = LayoutPanel > Copilot)
*
* variant="elevated" is what produces the described visual for free: with both
* navs present the shell root and rails paint --color-background-body (#000),
* and the content area sits on an elevated --color-background-surface (#0F0F10)
* backdrop with --radius-page on the start corner.
* The top nav is deliberately NOT passed to AppShell's `topNav` slot. That slot
* renders Layout{header} at the shell root, which spans the full viewport width
* and pushes the sidebar — and therefore the branding — 48px down the screen.
* Measured in the browser, the logo sat at y=64 with the top-left 260x48 region
* empty. Moving the bar into the content column's own header starts the sidebar
* at y=0, so the brand anchors in the window's top-left corner and the bar
* begins where the content does. This is the arrangement Linear, Cursor and
* Stripe use, and no amount of padding inside the sidebar could produce it.
*
* The one thing given up: with no `topNav`, AppShell drops the --radius-page
* corner it draws where the top bar meets the sidebar. The content area still
* paints elevated --color-background-surface (#0F0F10) against the #000 rails,
* and with a full-height sidebar that corner had nothing left to round.
*/
export default function WorkspaceLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SidebarProvider>
<WorkspaceShell>{children}</WorkspaceShell>
</SidebarProvider>
);
}
/**
* Split out from the layout only so it can READ the sidebar context that the
* layout mounts — a provider's own component sits above its value.
*/
function WorkspaceShell({children}: {children: React.ReactNode}) {
const bp = useBreakpoint();
const {isOpen} = useCopilot();
const inline = isPanelInline(bp);
const showInlinePanel = inline && isOpen;
// Both flags come from SidebarProvider now. AppShell keeps a copy of the
// open state for its own context (MobileNav reads it for aria wiring), but
// ours is the one that decides — uncontrolled, there would be no way to
// dismiss the drawer when a nav item is selected.
const {isDrawerOpen, setDrawerOpen} = useSidebar();
// Below the breakpoint the rail is gone — it lives in the drawer — so there
// is no top-left corner to anchor the brand to and nothing for the bar to
// start after. Hand the top nav back to AppShell there. Keeping it in the
// content column instead pushed the SideNav into AppShell's
// `autoMobileTopBar` fallback, which crops the lockup into a 48px bar.
const sideNavInline = isSideNavInline(bp);
return (
<AppShell
variant="elevated"
@@ -47,16 +93,49 @@ export default function WorkspaceLayout({
// Layout/LayoutContent own the padding so the Copilot panel can sit
// flush against the workspace edge.
contentPadding={0}
topNav={<AppTopNav />}
topNav={sideNavInline ? undefined : <AppTopNav />}
sideNav={<AppSideNav />}
// Supplied explicitly rather than auto-generated: the default drawer
// mirrors sideNav only, which would leave the store and range controls
// unreachable below 768px (they live in TopNav's collapsing start slot).
mobileNav={{breakpoint: 'md', content: <MobileScopeNav />}}
// 'sm' (640px) matches the mobile/tablet line in lib/breakpoints —
// AppShell's own threshold and ours have to agree or the hamburger and
// the rail are both visible, or neither is.
mobileNav={{
breakpoint: 'sm',
isOpen: isDrawerOpen,
onOpenChange: setDrawerOpen,
// hasToggle: false — AppShell would otherwise inject a SECOND
// hamburger into TopNav's mobile bar, next to the one in `heading`
// that works at every width. See AppTopNav.
hasToggle: false,
content: <MobileMenu />,
}}
>
<Layout
height="fill"
content={<LayoutContent padding={5}>{children}</LayoutContent>}
header={
sideNavInline ? (
<LayoutHeader padding={0}>
<AppTopNav />
</LayoutHeader>
) : undefined
}
content={
<LayoutContent padding={5}>
{/*
Above 1920 the workspace is capped and centred. Uncapped, a
30-day line chart stretches across ~1900px, which flattens every
trend it exists to show, and body copy runs well past the ~90ch
where reading breaks down. Below the cap this is a no-op —
maxWidth only binds once there is more width than content wants.
*/}
<VStack
width="100%"
maxWidth={contentMaxWidth(bp)}
className={bp === 'ultrawide' ? 'mx-auto' : undefined}
>
{children}
</VStack>
</LayoutContent>
}
end={
showInlinePanel ? (
<LayoutPanel
@@ -65,7 +144,7 @@ export default function WorkspaceLayout({
// CopilotPanel supplies its own header and tab chrome.
padding={0}
role="complementary"
label="AI Copilot"
label="Loyaly AI"
>
<CopilotPanel />
</LayoutPanel>

View File

@@ -19,6 +19,7 @@ import {useMetricColumns} from '@/features/dashboard/KpiRow';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ICONS} from '@/lib/icons';
import {usageRate} from '@/features/lyts/reward-status';
import {
@@ -64,6 +65,7 @@ export default function LytsPage() {
<PageHeader
title="Lyts"
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
controls={<ScopeControls />}
/>
<ExpiryAlerts resource={rewards} nowMs={nowMs} />
@@ -194,7 +196,7 @@ export default function LytsPage() {
<RewardPerformanceTable resource={rewards} />
<ActivityTimeline resource={activity} />
<ActivityTimeline resource={activity} limit={6} height={285} />
</VStack>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {ApiWebhooksManager} from '@/features/settings/ApiWebhooksManager';
export default function ApiSettingsPage() {
return (
<SettingsPage
title="API & Webhooks"
description="Developer credentials, secret signing tokens, webhook subscriptions and dispatch audit logs."
>
<ApiWebhooksManager />
</SettingsPage>
);
}

View File

@@ -1,60 +1,13 @@
import {VStack} from '@astryxdesign/core/Layout';
import {Badge} from '@astryxdesign/core/Badge';
import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {PageHeader} from '@/components/primitives/PageHeader';
import {StaticPanel} from '@/components/patterns/PanelCard';
import {StatPair, StatRow} from '@/components/patterns/StatPair';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {readProfile} from '@/lib/mock/settings';
import {formatInr} from '@/lib/format';
import {SettingsPage} from '@/features/settings/SettingsPage';
import {BillingOverview} from '@/features/settings/BillingOverview';
/**
* Billing is read-only for now.
*
* The plan and settlement figures are real shapes, but there is no payment
* provider wired up — so rather than render fake "Change plan" flows that do
* nothing, the actions are absent and the invoice list says plainly that it
* is not connected yet. A disabled button that never becomes enabled is worse
* than no button.
*/
export default function SettingsBillingPage() {
const profile = readProfile();
return (
<VStack gap={5}>
<PageHeader
title="Billing"
description="Plan, LYT settlement and invoices."
/>
<StaticPanel
title="Plan"
subtitle={profile.businessName}
actions={<Badge variant="success" label="Active" />}
<SettingsPage
title="Billing & LYT Settlement"
description="Subscription plans, quota consumption, settlement bank accounts and invoice history."
>
<VStack gap={4}>
<StatRow>
<StatPair label="Plan" value="Growth — 5 stores" />
<StatPair label="Billed" value="Monthly" />
<StatPair label="Next charge" value="1 Sep 2026" />
<StatPair label="Amount" value={formatInr(14999)} align="end" />
</StatRow>
<Divider />
<Text size="sm" color="secondary">
LYTs are settled monthly against redemptions, at 1 LYT = 1. Your
outstanding liability is shown on the Lyts page.
</Text>
</VStack>
</StaticPanel>
<StaticPanel title="Invoices" subtitle="Past statements">
<EmptyPanel
icon="revenue"
title="No billing provider connected"
description="Invoices appear here once payments are wired up."
/>
</StaticPanel>
</VStack>
<BillingOverview />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {IntegrationsGrid} from '@/features/settings/IntegrationsGrid';
export default function IntegrationsSettingsPage() {
return (
<SettingsPage
title="Integrations & Connectors"
description="E-commerce POS sync, payment gateways, WhatsApp marketing and ad channels."
>
<IntegrationsGrid />
</SettingsPage>
);
}

View File

@@ -8,7 +8,7 @@ import {
VStack,
} from '@astryxdesign/core/Layout';
import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
import {TabList, Tab} from '@astryxdesign/core/TabList';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {useRouter} from 'next/navigation';
import {useBreakpoint} from '@/lib/breakpoints';
@@ -26,8 +26,8 @@ import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/settings-nav';
* and behaves exactly like a primary nav item — same selected treatment, same
* hover, same keyboard handling, no second definition to keep in sync.
*
* Below the laptop breakpoint a 220px panel would eat most of the width, so
* the sub-nav becomes a horizontal TabList above the content instead.
* Below the laptop breakpoint a 240px panel would eat most of the width, so
* the sub-nav collapses to a section picker above the content instead.
*/
export default function SettingsLayout({
children,
@@ -41,27 +41,36 @@ export default function SettingsLayout({
if (isNarrow) {
const active =
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href))?.href ??
SETTINGS_NAV[0].href;
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href)) ??
SETTINGS_NAV[0];
// A dropdown, not a TabList.
//
// This was a TabList when Settings had three sections. At eleven it stops
// working: `layout="fill"` cannot fit eleven labels in a tablet-width
// column, so the strip wrapped into a full-height vertical list and
// squeezed the page content out entirely. A picker is what every dense
// settings UI uses at this width, and it stays one tap regardless of how
// many sections the module grows to.
return (
<VStack gap={5}>
<TabList
value={active}
onChange={(href) => router.push(href)}
layout="fill"
size="sm"
hasDivider
>
{SETTINGS_NAV.map((s) => (
<Tab
key={s.href}
value={s.href}
label={s.label}
icon={<Icon icon={s.icon} size="sm" />}
<VStack width="100%">
{/* Only the picker is padded here — SettingsPage still owns every
page gutter, so the container contract stays in one place. */}
<VStack paddingInline={8} width="100%" className="pt-8">
<DropdownMenu
button={{
variant: 'secondary',
label: active.label,
icon: <Icon icon={active.icon} size="sm" />,
}}
menuWidth={260}
items={SETTINGS_NAV.map((s) => ({
label: s.label,
icon: s.icon,
onClick: () => router.push(s.href),
}))}
/>
))}
</TabList>
</VStack>
{children}
</VStack>
);
@@ -71,8 +80,23 @@ export default function SettingsLayout({
<Layout
height="fill"
start={
<LayoutPanel width={220} padding={0} role="navigation" label="Settings">
<SideNav>
// 240px rather than 220: "Roles & Permissions" and "API & Webhooks"
// were wrapping once the panel gained its own inset padding.
<LayoutPanel width={240} padding={0} role="navigation" label="Settings">
{/*
The panel's own inset. Without it the nav items sat flush against
the workspace rail on one side and the content gutter on the other,
and the first item started hard against the top of the viewport.
paddingBlock 6 (24px) is what gives the list somewhere to begin.
*/}
<VStack paddingInline={3} paddingBlock={6} width="100%">
{/*
w-full because SideNav's own width is a fixed 260px — inside a
240px panel that already spends 24px on padding it overhung by
32px and gave the sub-nav a horizontal scrollbar along its
bottom edge (measured: scrollWidth 272 in a 240px panel).
*/}
<SideNav className="w-full">
<SideNavSection title="Settings" isHeaderHidden>
{SETTINGS_NAV.map((s) => (
<SideNavItem
@@ -85,8 +109,12 @@ export default function SettingsLayout({
))}
</SideNavSection>
</SideNav>
</VStack>
</LayoutPanel>
}
// padding stays 0 here on purpose — SettingsPage owns the page gutters,
// so every screen gets the identical container whether it renders in
// this branch or the narrow picker one above.
content={<LayoutContent padding={0}>{children}</LayoutContent>}
/>
);

View File

@@ -1,19 +1,13 @@
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/components/primitives/PageHeader';
import {SettingsPage} from '@/features/settings/SettingsPage';
import {NotificationsForm} from '@/features/settings/NotificationsForm';
import {readProfile} from '@/lib/mock/settings';
/** Server Component — same direct-read hybrid as the profile page. */
export default function SettingsNotificationsPage() {
const {notifications} = readProfile();
export default function NotificationsSettingsPage() {
return (
<VStack gap={5}>
<PageHeader
<SettingsPage
title="Notifications"
description="Choose which events are worth interrupting you for."
/>
<NotificationsForm initialData={notifications} />
</VStack>
description="Delivery channels, instant alerts, weekly digest dispatches and trigger criteria."
>
<NotificationsForm />
</SettingsPage>
);
}

View File

@@ -1,30 +1,13 @@
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/components/primitives/PageHeader';
import {ProfileForm} from '@/features/settings/ProfileForm';
import {readProfile} from '@/lib/mock/settings';
/**
* A SERVER Component — the one place in the app that fetches on the server.
*
* Every other page fetches on the client because its scope (store, range)
* changes from controls that live above it in the tree, and a route
* navigation would remount the Copilot. Settings has none of that: there is
* exactly one server-known record at page load, so reading it directly here
* (no HTTP hop, no loading skeleton, no empty-input flash) is strictly better.
*
* The same readProfile() backs /api/settings/profile, so the two transports
* cannot drift.
*/
export default function SettingsProfilePage() {
const profile = readProfile();
import {SettingsPage} from '@/features/settings/SettingsPage';
import {BusinessForm} from '@/features/settings/BusinessForm';
export default function BusinessSettingsPage() {
return (
<VStack gap={5}>
<PageHeader
title="Settings"
description="Business details, contact information and LYT earn rate."
/>
<ProfileForm initialData={profile} />
</VStack>
<SettingsPage
title="Business Settings"
description="Company profile, GSTIN registration, registered address and LYT earn defaults."
>
<BusinessForm />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {PreferencesForm} from '@/features/settings/PreferencesForm';
export default function PreferencesSettingsPage() {
return (
<SettingsPage
title="Workspace Preferences"
description="Theme customization, reporting currency, localized language and default landing views."
>
<PreferencesForm />
</SettingsPage>
);
}

View File

@@ -0,0 +1,16 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {ProfileForm} from '@/features/settings/ProfileForm';
import {readProfile} from '@/lib/mock/settings';
export default function MerchantProfilePage() {
const profile = readProfile();
return (
<SettingsPage
title="Personal Profile"
description="Your user credentials, contact details, account email and timezone preference."
>
<ProfileForm initialData={profile} />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {RoleMatrix} from '@/features/settings/RoleMatrix';
export default function RolesSettingsPage() {
return (
<SettingsPage
title="Roles & Permissions"
description="Enterprise role definition, module permission matrix and access control boundaries."
>
<RoleMatrix />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {SecurityManager} from '@/features/settings/SecurityManager';
export default function SecuritySettingsPage() {
return (
<SettingsPage
title="Security & Audit Logs"
description="Two-Factor authentication, password management, active login sessions and security audit history."
>
<SecurityManager />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {StoreManagement} from '@/features/settings/StoreManagement';
export default function StoreSettingsPage() {
return (
<SettingsPage
title="Store Locations"
description="Branch operations, outlet directory, operating schedules and manager assignments."
>
<StoreManagement />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/SettingsPage';
import {TeamManagement} from '@/features/settings/TeamManagement';
export default function TeamSettingsPage() {
return (
<SettingsPage
title="Team & Staff"
description="Manage employee access, invite new team members, assign store locations and reset credentials."
>
<TeamManagement />
</SettingsPage>
);
}

View File

@@ -12,6 +12,7 @@ import {Leaderboard} from '@/features/staff/Leaderboard';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {storeName} from '@/lib/mock/stores';
import {formatCompact} from '@/lib/format';
@@ -40,6 +41,7 @@ export default function StaffPage() {
<PageHeader
title="Staff"
description={`Attendance, sales contribution and performance across ${scopeLabel}.`}
controls={<ScopeControls />}
/>
<StaffKpis resource={summary} />

View File

@@ -23,6 +23,7 @@ import {useMetricColumns} from '@/features/dashboard/KpiRow';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
import {ICONS} from '@/lib/icons';
import {
formatCompact,
@@ -45,7 +46,7 @@ const STATUS: Record<
* One store's dashboard.
*
* Deliberately the SAME chart components as the main dashboard, scoped by the
* route's storeId rather than the top nav's. Building store-specific chart
* route's storeId rather than the shared scope's. Building store-specific chart
* variants would double the surface area and guarantee the two drift apart —
* the only thing that differs here is which store the query asks about.
*/
@@ -83,6 +84,8 @@ export default function StoreDetailPage({
? `${store.data.staffCount} staff · ${formatPct(store.data.conversionPct)} conversion`
: 'Loading store…'
}
// Store scope is the route here, so only the period is selectable.
controls={<ScopeControls hasStore={false} />}
actions={
<HStack gap={2} vAlign="center">
{store.data ? (
@@ -183,7 +186,7 @@ export default function StoreDetailPage({
</ChartCard>
</Grid>
<ActivityTimeline resource={activity} />
<ActivityTimeline resource={activity} limit={6} height={285} />
</VStack>
);
}

View File

@@ -9,11 +9,12 @@ import {
RANGE_LABELS,
useWorkspace,
} from '@/components/shell/WorkspaceProvider';
import {ScopeControls} from '@/components/scope/ScopeControls';
/**
* The store roster.
*
* This page deliberately ignores the top nav's STORE scope — a list of every
* This page deliberately ignores the shared STORE scope — a list of every
* store is the whole point, and narrowing it to the one already selected would
* leave a one-card page. The RANGE scope still applies, because the totals on
* each card have to be measured over some period.
@@ -27,6 +28,7 @@ export default function StoresPage() {
<PageHeader
title="Store"
description={`Performance and status for every store in the network, over ${RANGE_LABELS[range].toLowerCase()}.`}
controls={<ScopeControls hasStore={false} />}
/>
<StoreGrid resource={stores} />
</VStack>

View File

@@ -0,0 +1,17 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, simulate, wantsEmpty} from '@/lib/api/server';
import {buildBriefing} from '@/lib/mock/briefing';
export const dynamic = 'force-dynamic';
const EMPTY = {summary: '', alerts: [], tasks: []};
export async function GET(req: NextRequest) {
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(
wantsEmpty(q) ? EMPTY : buildBriefing(q.storeId, q.range, q.nowMs),
q,
);
}

BIN
src/app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
src/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -15,13 +15,27 @@ const inter = Inter({
display: 'swap',
});
/**
* Icons and the web manifest are NOT declared here. They come from the app-dir
* file conventions — favicon.ico, icon.png, apple-icon.png and manifest.ts,
* all siblings of this file — which Next hashes and emits the <link> tags for.
* Adding an `icons` key would shadow those with unversioned paths.
*/
export const metadata: Metadata = {
applicationName: 'Loyaly.ai Merchant OS',
title: {
default: 'Loyaly Merchant',
template: '%s · Loyaly Merchant',
default: 'Loyaly.ai Merchant OS',
template: '%s · Loyaly.ai',
},
description:
'Footfall, campaign and LYT redemption analytics for Loyaly merchants.',
appleWebApp: {
capable: true,
title: 'Loyaly',
// The mark sits on black in apple-icon.png; match it so the notch area
// does not seam against the canvas when installed.
statusBarStyle: 'black-translucent',
},
};
export const viewport: Viewport = {

15
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
import type {Metadata} from 'next';
import {LoginSplit} from '@/features/auth/LoginSplit';
export const metadata: Metadata = {
title: 'Sign in · Loyaly.ai',
};
/**
* Sits outside the (workspace) group on purpose: no shell, no nav, no store
* scope. Uses the official Astryx Login Split template layout tailored for Loyaly.
*/
export default function LoginPage() {
return <LoginSplit />;
}

34
src/app/manifest.ts Normal file
View File

@@ -0,0 +1,34 @@
import type {MetadataRoute} from 'next';
/**
* PWA install metadata. Next serves this at /manifest.webmanifest and injects
* the <link rel="manifest"> itself — no tag to hand-write in the layout.
*
* The tab/apple-touch icons come from the app-dir file conventions instead
* (favicon.ico, icon.png, apple-icon.png); these entries are only what the
* installer and launcher use. `maskable` is a separate file rather than a
* second purpose on the same one: launchers crop a maskable icon to their own
* shape, so the heart is inset into the 80% safe zone in that copy only.
*/
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Loyaly Merchant OS',
short_name: 'Loyaly',
description:
'Footfall, campaign and LYT redemption analytics for Loyaly merchants.',
start_url: '/dashboard',
display: 'standalone',
background_color: '#000000',
theme_color: '#000000',
icons: [
{src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any'},
{src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any'},
{
src: '/icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
};
}

View File

@@ -0,0 +1,62 @@
import Image from 'next/image';
/**
* The two 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
* deliberately no `fill` / `objectFit` escape hatch — that is how logos end up
* distorted. Recolouring is likewise not a prop: the PNGs carry the mark.
*/
// 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 MARK = {src: '/brand/loyaly-mark.png', width: 285, height: 256};
interface BrandProps {
/** Alternative text. Pass '' where a sibling element already names the brand. */
alt?: string;
/** Set on marks that paint above the fold, so Next preloads them. */
priority?: boolean;
}
/** Full horizontal Loyaly.ai lockup. Sized by height; width follows. */
export function BrandLogo({
height = 28,
alt = 'Loyaly.ai',
priority = false,
}: BrandProps & {height?: number}) {
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"
/>
);
}
/** Heart symbol alone, for spaces too narrow for the lockup. Sized by width. */
export function BrandMark({
size = 28,
alt = 'Loyaly.ai',
priority = false,
}: BrandProps & {size?: number}) {
return (
<Image
src={MARK.src}
alt={alt}
width={size}
height={Math.round((size * MARK.height) / MARK.width)}
priority={priority}
className="block"
/>
);
}

View File

@@ -2,18 +2,23 @@
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Divider} from '@astryxdesign/core/Divider';
import {Icon} from '@astryxdesign/core/Icon';
import {Timestamp} from '@astryxdesign/core/Timestamp';
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
import {ICONS} from '@/lib/icons';
import type {IconKey} from '@/lib/icons';
/** Icon tones. Only `warning` carries colour — see the note on ActivityFeed. */
export type ActivityTone = 'neutral' | 'warning';
export interface ActivityEntry {
id: string;
title: string;
detail?: string;
at: string;
tone: StatusDotVariant;
/** Announced with the dot; the dot's colour alone is not an accessible label. */
/** Which kind of event this is — the glyph IS the category label. */
icon: IconKey;
tone: ActivityTone;
/** Announced with the icon; a glyph alone is not an accessible label. */
toneLabel: string;
}
@@ -24,24 +29,34 @@ export interface ActivityEntry {
* edge-to-edge, and wrapping each event in a Card would triple the vertical
* space while making the feed harder to scan.
*
* Tone is passed in rather than derived, because "which events deserve colour"
* is a per-module editorial decision. On the dashboard only expiries are
* amber; in Lyts the same rule applies to a different event set. What must
* stay constant is that MOST rows are neutral — if everything is coloured,
* the one row that matters stops standing out.
* The leading glyph is the event's TYPE — purchase, reward, staff, store,
* alert — not its status. A status dot could only encode severity, which meant
* five different kinds of event all rendered as the same grey circle and the
* feed could not be skimmed by category. The icon does that job, and colour is
* left to do the one job it is good at: marking the row that needs action.
*
* Hover is `--color-overlay-hover`, the same white-at-6% the sidebar nav uses.
* It tracks the eye across a dense row; it does not imply the row is clickable,
* because there is no per-event destination to send anyone to.
*/
export function ActivityItem({
entry,
isLast,
}: {
entry: ActivityEntry;
isLast: boolean;
}) {
export function ActivityItem({entry}: {entry: ActivityEntry}) {
return (
<VStack gap={0}>
<HStack gap={3} vAlign="start" paddingBlock={2}>
<HStack paddingBlock={1}>
<StatusDot variant={entry.tone} label={entry.toneLabel} />
<HStack
gap={3}
vAlign="start"
paddingBlock={1}
paddingInline={2}
// Token-backed utilities: --color-overlay-hover is bridged into Tailwind
// by tailwind-theme.css, so this is the theme's hover, not a new value.
className="rounded-md transition-colors hover:bg-overlay-hover"
>
<HStack paddingBlock={0.5}>
<Icon
icon={ICONS[entry.icon]}
size="sm"
color={entry.tone === 'warning' ? 'warning' : 'secondary'}
label={entry.toneLabel}
/>
</HStack>
<VStack gap={0.5} width="100%">
<HStack gap={3} hAlign="between" vAlign="center">
@@ -57,21 +72,33 @@ export function ActivityItem({
) : null}
</VStack>
</HStack>
{isLast ? null : <Divider />}
</VStack>
);
}
/** The list wrapper — owns the divider-between-but-not-after rule. */
export function ActivityFeed({entries}: {entries: ActivityEntry[]}) {
/**
* The list wrapper.
*
* Dividers are gone: with a hover background doing the row separation, rules
* between every entry were two separators doing one job, and each one cost
* vertical space on a panel whose whole problem was height.
*
* `limit` is deliberately applied HERE rather than by each caller slicing its
* own array — a feed that quietly grows without bound is what put this panel
* at 784px on the dashboard, and one place to cap it is one place to get right.
*/
export function ActivityFeed({
entries,
limit,
}: {
entries: ActivityEntry[];
limit?: number;
}) {
const shown = limit ? entries.slice(0, limit) : entries;
return (
<VStack gap={0}>
{entries.map((e, i) => (
<ActivityItem
key={e.id}
entry={e}
isLast={i === entries.length - 1}
/>
{shown.map((e) => (
<ActivityItem key={e.id} entry={e} />
))}
</VStack>
);

View File

@@ -0,0 +1,100 @@
'use client';
import {Table} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {useBreakpoint} from '@/lib/breakpoints';
/**
* A table on wide screens; a list of cards on a phone.
*
* A six-column table cannot be made to work at 375px. Horizontal scroll is the
* usual fallback, but it hides the columns that matter most — status and
* actions sit on the right, which is exactly what is off-screen — and it puts
* a scroll gesture inside a vertically scrolling page, which fights the thumb.
*
* So below `tablet` the same column definitions are re-rendered as stacked
* label/value rows. Crucially it is the SAME `columns` array: the cell
* renderers, formatting and badges are reused verbatim, so the two
* presentations cannot drift and adding a column updates both.
*
* `primaryKey` names the column that becomes the card's heading — usually the
* entity's name. `summaryKeys`, when given, limits which of the remaining
* columns appear on the card, because a phone card listing nine fields is just
* a table rotated 90°.
*/
export function ResponsiveTable<T extends Record<string, unknown>>({
data,
columns,
idKey,
primaryKey,
summaryKeys,
density = 'spacious',
}: {
data: T[];
columns: TableColumn<T>[];
/** Row identity. Falls back to the array index when a row has no id field. */
idKey?: keyof T & string;
primaryKey: string;
summaryKeys?: string[];
density?: 'compact' | 'balanced' | 'spacious';
}) {
const bp = useBreakpoint();
if (bp !== 'mobile') {
return (
<Table
data={data}
columns={columns}
idKey={idKey}
density={density}
hasHover
/>
);
}
const primary = columns.find((c) => c.key === primaryKey);
const rest = columns.filter(
(c) =>
c.key !== primaryKey &&
(summaryKeys ? summaryKeys.includes(c.key) : true),
);
return (
<VStack gap={3}>
{data.map((row, i) => (
<Card key={idKey ? String(row[idKey]) : i} variant="muted">
<VStack gap={3}>
{primary ? (
<VStack gap={0}>
{primary.renderCell
? primary.renderCell(row)
: String(row[primary.key] ?? '')}
</VStack>
) : null}
{rest.length ? <Divider /> : null}
<VStack gap={2}>
{rest.map((col) => (
<HStack key={col.key} hAlign="between" vAlign="center" gap={3}>
<Text size="sm" color="secondary">
{typeof col.header === 'string' ? col.header : col.key}
</Text>
{/* The real cell renderer — badges, deltas and progress bars
all survive the transposition unchanged. */}
{col.renderCell
? col.renderCell(row)
: <Text size="sm">{String(row[col.key] ?? '')}</Text>}
</HStack>
))}
</VStack>
</VStack>
</Card>
))}
</VStack>
);
}

View File

@@ -20,11 +20,11 @@ export function StatPair({
align?: 'start' | 'end';
}) {
return (
<VStack gap={0} hAlign={align}>
<Text size="xsm" color="secondary">
<VStack gap={0.5} hAlign={align}>
<Text size="sm" color="secondary">
{label}
</Text>
<Text size="sm" weight="medium">
<Text size="base" weight="semibold">
{value}
</Text>
</VStack>

View File

@@ -5,27 +5,50 @@ import {Heading, Text} from '@astryxdesign/core/Text';
/**
* Every workspace page opens the same way: title, one line of orientation,
* and page-level actions on the trailing edge. Centralising it is what keeps
* five modules from drifting into five different header treatments.
* then the filters and actions that belong to THIS page. Centralising it is
* what keeps five modules from drifting into five different header treatments.
*
* Two trailing slots, deliberately distinct:
* `controls` — filters that scope the page's data (store, period, compare).
* Own row beneath the title, because a page can have several
* and they would otherwise crowd the heading and wrap badly.
* `actions` — page-level verbs (export, add store). Trailing edge of the
* title row, where a primary action is conventionally found.
*
* `eyebrow` is the small line ABOVE the title — a greeting, a breadcrumb-ish
* context line. It sits inside the heading block rather than above the whole
* header so it cannot drift away from the title it belongs to.
*/
export function PageHeader({
eyebrow,
title,
description,
controls,
actions,
}: {
eyebrow?: string;
title: string;
description?: string;
controls?: React.ReactNode;
actions?: React.ReactNode;
}) {
return (
<HStack hAlign="between" vAlign="start" gap={4}>
<HStack hAlign="between" vAlign="center" gap={4} wrap="wrap">
<VStack gap={1}>
<Heading level={1}>{title}</Heading>
{description ? (
<Text color="secondary">{description}</Text>
{eyebrow ? (
<Text size="sm" color="secondary">
{eyebrow}
</Text>
) : null}
<Heading level={1}>{title}</Heading>
{description ? <Text color="secondary">{description}</Text> : null}
</VStack>
{actions ? <HStack gap={2}>{actions}</HStack> : null}
{controls || actions ? (
<HStack gap={2} vAlign="center" wrap="wrap">
{controls}
{actions}
</HStack>
) : null}
</HStack>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import {useState} from 'react';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {Dialog} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Button} from '@astryxdesign/core/Button';
import {RANGE_LABELS, useWorkspace} from '@/components/shell/WorkspaceProvider';
import type {RangeKey} from '@/components/shell/WorkspaceProvider';
const ORDER: RangeKey[] = ['7d', '30d', '90d', 'mtd', 'ytd'];
/** Period scope for the charts and KPIs on the page that renders it. */
export function RangePicker() {
const {range, setRange, customRange, setCustomRange} = useWorkspace();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [startDate, setStartDate] = useState(customRange?.start || '2026-08-01');
const [endDate, setEndDate] = useState(customRange?.end || '2026-08-05');
const getLabel = () => {
if (range === 'custom' && customRange?.start && customRange?.end) {
return `${customRange.start} ${customRange.end}`;
}
return RANGE_LABELS[range] || 'Select period';
};
const handleApplyCustom = () => {
setCustomRange({start: startDate, end: endDate});
setRange('custom');
setIsDialogOpen(false);
};
return (
<>
<DropdownMenu
button={{
variant: 'secondary',
size: 'sm',
label: getLabel(),
icon: <Icon icon="calendar" size="sm" />,
}}
items={[
...ORDER.map((key) => ({
label: RANGE_LABELS[key],
onClick: () => setRange(key),
})),
{type: 'divider' as const},
{
label: 'Custom range...',
onClick: () => setIsDialogOpen(true),
},
]}
/>
<Dialog
isOpen={isDialogOpen}
onOpenChange={setIsDialogOpen}
width={400}
purpose="info"
>
<VStack gap={4} padding={4}>
<VStack gap={1}>
<Heading level={3}>Custom Date Range</Heading>
<Text size="sm" color="secondary">
Select start and end dates to query performance data.
</Text>
</VStack>
<VStack gap={3}>
<TextInput
type="text"
label="Start Date"
value={startDate}
onChange={setStartDate}
/>
<TextInput
type="text"
label="End Date"
value={endDate}
onChange={setEndDate}
/>
</VStack>
<HStack hAlign="end" gap={2}>
<Button
variant="secondary"
size="sm"
label="Cancel"
onClick={() => setIsDialogOpen(false)}
/>
<Button
variant="primary"
size="sm"
label="Apply Range"
onClick={handleApplyCustom}
/>
</HStack>
</VStack>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import {StoreSwitcher} from './StoreSwitcher';
import {RangePicker} from './RangePicker';
/**
* The store + period controls a data page puts in its own header.
*
* These read and write WorkspaceProvider, so the selection still persists
* across navigation — what changed is where they are *stated*. A page that
* queries by store and period declares those controls itself; a page that
* does not (Settings) shows none, instead of the shell implying a scope that
* nothing on screen honours.
*
* Returns a fragment: PageHeader's `controls` slot supplies the row.
*/
export function ScopeControls({
/** Off for pages that read the period but deliberately ignore store scope. */
hasStore = true,
}: {
hasStore?: boolean;
}) {
return (
<>
{hasStore ? <StoreSwitcher /> : null}
<RangePicker />
</>
);
}

View File

@@ -3,12 +3,16 @@
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import {useWorkspace} from './WorkspaceProvider';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
/**
* Scope selector for the whole workspace. Everything downstream reads storeId
* from WorkspaceProvider, so switching re-queries every panel without a route
* change which is what keeps the Copilot mounted.
* Store scope for the page that renders it. The selection lives in
* WorkspaceProvider, so switching re-queries every panel on the page without a
* route change which is what keeps the Copilot mounted and the choice
* carries to the next page that also scopes by store.
*
* `secondary` rather than `ghost`: in a page header these are the row, not
* decoration on a bar, and they need a visible edge to read as controls.
*/
export function StoreSwitcher() {
const {storeId, setStoreId, stores} = useWorkspace();
@@ -17,7 +21,7 @@ export function StoreSwitcher() {
return (
<DropdownMenu
button={{
variant: 'ghost',
variant: 'secondary',
size: 'sm',
label: current ? current.name : 'All stores',
icon: <Icon icon={ICONS.stores} size="sm" />,

View File

@@ -3,47 +3,84 @@
import {usePathname} from 'next/navigation';
import {
SideNav,
SideNavHeading,
SideNavItem,
SideNavSection,
} from '@astryxdesign/core/SideNav';
import {NavIcon} from '@astryxdesign/core/NavIcon';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import {SideNavBrand} from './SideNavBrand';
import {NAV_RAIL_ID, useSidebar} from './SidebarProvider';
import {FOOTER_NAV, PRIMARY_NAV, isNavActive} from './nav-config';
/**
* 260px, collapsible, Settings pinned to the bottom.
* 260px expanded, 84px collapsed, Settings pinned to the bottom.
*
* The active indicator and hover animation are SideNavItem's own `selected` /
* `--color-overlay-hover` styles, which the theme already recoloured to
* #202124 / white-at-6%. There is deliberately no custom CSS here — a
* hand-rolled active state would drift from the rest of the system.
* ── Why the collapsed rail is restyled ────────────────────────────────────
* Astryx's collapsed rail is --spacing-12 (48px) holding 32px items around
* 16px icons. At a glance that reads as a scrollbar with symbols in it, and a
* 32px hit area is under every touch-target guideline there is. The rail is
* widened to 84px with 44px items around 24px icons — the Linear/Cursor
* proportion, where a collapsed rail still reads as navigation.
*
* None of that is expressible through `defineTheme({components})`: the theme
* layer can only reach an element's own themeProps, and SideNav publishes no
* collapsed variant (`themeProps('side-nav')` takes only `mode`), while the
* icon size is hardcoded to `sm` inside SideNavItem. So the three geometry
* facts are set here, as token-backed Tailwind utilities scoped to this rail:
*
* w-21 84px = --spacing-1 × 21, the rail itself
* .astryx-side-nav-item 44px square, centred with margin-inline auto
* .astryx-icon 24px
*
* They win over Astryx's StyleX classes because astryx.css declares
* `@layer astryx-base` and Tailwind's utilities layer sorts after it —
* cascade layers outrank specificity, so no !important is involved.
*
* ── Why the collapse button is gone from here ─────────────────────────────
* `hasButton: false`. A control that lives inside the sidebar vanishes with
* the sidebar on mobile and, collapsed, sits in the one corner a user is
* least likely to look. NavMenuButton in the top bar owns all three states —
* see SidebarProvider.
*/
/** Applied only while collapsed. See the geometry note above. */
const COLLAPSED_RAIL = [
'w-21',
'[&_.astryx-side-nav-item]:size-11',
'[&_.astryx-side-nav-item]:mx-auto',
'[&_.astryx-icon]:size-6',
].join(' ');
/**
* 200ms ease-in-out on width. Astryx's own --duration-medium is 260ms and its
* --ease-standard is an ease-out; a rail that only decelerates into place
* reads as heavier than one eased at both ends, which is what the brief asks
* for. The logo, labels and icons animate on their own properties at the same
* duration so the whole rail arrives together.
*/
const RAIL_MOTION = 'transition-[width] duration-200 ease-in-out';
export function AppSideNav() {
const pathname = usePathname();
const {isCollapsed, toggle} = useSidebar();
return (
<SideNav
id={NAV_RAIL_ID}
className={`${RAIL_MOTION} ${isCollapsed ? COLLAPSED_RAIL : ''}`}
collapsible={{
defaultIsCollapsed: false,
hasButton: true,
buttonLabel: 'Collapse navigation',
isCollapsed,
// SideNav still drives its own children (icon-only items, tooltips)
// off this flag; it just no longer owns it.
onCollapsedChange: toggle,
hasButton: false,
}}
resizable={{
defaultWidth: 260,
minWidth: 220,
maxWidth: 360,
autoSaveId: 'loyaly.sidenav.width',
}}
header={
<SideNavHeading
heading="Loyaly"
subheading="Merchant"
icon={<NavIcon icon={<Icon icon={ICONS.lyt} size="sm" />} />}
headingHref="/dashboard"
/>
}
// Not resizable. Two reasons, one of them measurable: the drag handle
// renders in overlay mode inside a wrapper that comes out 1px wider than
// the panel it sits in, which gave the sidebar a permanent horizontal
// scrollbar along its bottom edge (measured: scrollWidth 261 in a 260px
// panel). And a rail whose width is a stored user preference cannot also
// be the fixed 260/84 pair the collapse animation moves between —
// resizing and collapsing were fighting for the same property.
header={<SideNavBrand />}
footer={
<SideNavSection title="Account" isHeaderHidden>
{FOOTER_NAV.map((item) => (

View File

@@ -4,33 +4,61 @@ import {TopNav} from '@astryxdesign/core/TopNav';
import {HStack} from '@astryxdesign/core/Layout';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {Link} from '@astryxdesign/core/Link';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Divider} from '@astryxdesign/core/Divider';
import {ICONS} from '@/lib/icons';
import {GlobalSearch} from './GlobalSearch';
import {StoreSwitcher} from './StoreSwitcher';
import {RangePicker} from './RangePicker';
import {BrandMark} from '@/components/brand/BrandLogo';
import {CopilotToggle} from '@/features/copilot/CopilotToggle';
import {GlobalSearch} from './GlobalSearch';
import {NavMenuButton} from './NavMenuButton';
import {useSidebar} from './SidebarProvider';
/**
* Compact application bar. Scope controls (store, range) live at the start
* because they govern the whole workspace; identity and alerts live at the end.
* Menu, search, notifications, profile — in that order, at every width.
*
* The menu button goes in `heading`, not `startContent`, and that placement is
* load-bearing rather than cosmetic: below AppShell's breakpoint TopNav
* re-renders in `mobile-bar` mode, which drops `startContent` entirely and
* keeps only `heading` and `endContent`. A menu button in startContent would
* therefore be present on desktop and silently absent on the one screen size
* that cannot do without it.
*
* The lockup rides beside it on mobile only. Above the breakpoint the sidebar
* starts at y=0 and already carries the brand in the window's top-left corner;
* repeating it 260px to the right would be two logos on one line.
*
* The store switcher and range picker used to sit here. They are query scope
* for one page's data, not application chrome, and now live in each page's own
* header (see @/components/scope), so this bar renders identically everywhere.
*/
export function AppTopNav() {
const {isDrawer} = useSidebar();
return (
<TopNav
label="Workspace"
startContent={
<HStack gap={1} vAlign="center">
<StoreSwitcher />
<Divider orientation="vertical" />
<RangePicker />
heading={
<HStack gap={2} vAlign="center">
<NavMenuButton />
{isDrawer ? (
<Link href="/dashboard">
{/*
The heart alone, not the lockup. Measured at 320px — the
narrowest size in the support matrix — the 110px wordmark plus
the four end controls came to 322px and pushed the account
menu over the edge. The full lockup still appears at full size
in the drawer this button opens, and in the rail above the
breakpoint.
*/}
<BrandMark size={26} />
</Link>
) : null}
</HStack>
}
centerContent={<GlobalSearch />}
endContent={
<HStack gap={1} vAlign="center">
<GlobalSearch />
<CopilotToggle />
<IconButton
icon={<Icon icon={ICONS.notifications} />}
@@ -43,13 +71,20 @@ export function AppTopNav() {
variant: 'ghost',
isIconOnly: true,
label: 'Account',
icon: <Avatar name="Suriya N" size="sm" tooltip={false} />,
icon: (
<Avatar
name="Aravind"
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='16' fill='%23e2e8f0'/><text x='16' y='21' font-family='sans-serif' font-size='14' font-weight='bold' fill='%230f172a' text-anchor='middle'>A</text></svg>"
size="sm"
tooltip={false}
/>
),
}}
menuWidth={200}
items={[
{
type: 'section',
title: 'suriya@nearle.in',
title: 'aravind@nearle.in',
items: [
{label: 'Profile', icon: ICONS.profile},
{label: 'Settings', icon: ICONS.settings},

View File

@@ -5,7 +5,9 @@ import {useRouter} from 'next/navigation';
import {CommandPalette} from '@astryxdesign/core/CommandPalette';
import {createStaticSource} from '@astryxdesign/core/Typeahead';
import {Button} from '@astryxdesign/core/Button';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {useBreakpoint} from '@/lib/breakpoints';
import {FOOTER_NAV, PRIMARY_NAV} from './nav-config';
/**
@@ -20,6 +22,7 @@ import {FOOTER_NAV, PRIMARY_NAV} from './nav-config';
export function GlobalSearch() {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const isCompact = useBreakpoint() === 'mobile';
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -46,6 +49,22 @@ export function GlobalSearch() {
return (
<>
{/*
Icon-only on phones. The ⌘K affordance is the point of the wide form,
and a phone has no ⌘ — spending 110px of a 375px bar advertising a
shortcut that cannot be pressed would push the profile menu off it.
*/}
{isCompact ? (
<IconButton
variant="ghost"
size="lg"
label="Search"
tooltip="Search"
icon={<Icon icon="search" />}
onClick={() => setIsOpen(true)}
className="size-11"
/>
) : (
<Button
variant="secondary"
size="sm"
@@ -53,6 +72,7 @@ export function GlobalSearch() {
icon={<Icon icon="search" size="sm" />}
onClick={() => setIsOpen(true)}
/>
)}
<CommandPalette
isOpen={isOpen}
onOpenChange={setIsOpen}

View File

@@ -0,0 +1,130 @@
'use client';
import {useRef} from 'react';
import {MobileNav} from '@astryxdesign/core/MobileNav';
import {SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
import {Divider} from '@astryxdesign/core/Divider';
import {usePathname} from 'next/navigation';
import {BrandLogo} from '@/components/brand/BrandLogo';
import {NAV_DRAWER_ID, useSidebar} from './SidebarProvider';
import {FOOTER_NAV, PRIMARY_NAV, isNavActive} from './nav-config';
/**
* The mobile drawer: navigation only, mirroring the sidebar.
*
* ── The five ways out ─────────────────────────────────────────────────────
* Every one of them resolves to the same `setDrawerOpen(false)` in
* SidebarProvider, so none can leave the others stale:
*
* close button the X below, enlarged to 44px
* backdrop MobileNav closes on a click that lands on the <dialog>
* Escape the native `cancel` event, routed through onOpenChange
* nav item onClick, below — the href still does the navigating
* swipe left the handlers at the bottom of this file
*
* Focus trapping, the top-layer backdrop and focus restoration to the
* hamburger come from `<dialog>.showModal()` and are not reimplemented here.
*
* ── Why the close button is restyled ──────────────────────────────────────
* MobileNav always renders its own ghost X after the `header` slot, so adding
* a second one would give the drawer two close buttons. Instead the built-in
* is enlarged in place to a 44px target around a 24px glyph. It is a flex
* sibling ABOVE the scroll container, not inside it, so it stays put while
* the list scrolls without needing `position: sticky` at all.
*
* The selector reaches through MobileNav's DOM (dialog > drawer > header row)
* because that row carries no themeProps of its own. It is scoped to this one
* component's className, and every value in it is a spacing token.
*/
const DRAWER = [
// 80% of the viewport — enough of the page stays visible behind the
// backdrop that the drawer reads as temporary. `width` below caps it at
// 320px, so on a 430px phone it is 320px, not 344px.
'[&>div]:w-4/5',
// The close button: 44px, up from MobileNav's default 32px ghost square.
'[&>div>*:first-child>button]:size-11',
// Every glyph in the drawer to 24px. The second rule is for registry icons
// (the close X), which render as a span whose inner <svg> is sized in em —
// resizing only the span leaves a 20px glyph floating in a 24px box.
'[&_.astryx-icon]:size-6',
'[&_.astryx-icon>svg]:size-6',
// 48px rows. Astryx's nav items are 32px, which is a mouse target on a
// surface that only ever sees thumbs.
'[&_.astryx-side-nav-item]:h-12',
].join(' ');
/** Past this much horizontal travel a drag is a dismissal, not a scroll. */
const SWIPE_CLOSE_PX = 60;
/** Beyond this much vertical travel it was a scroll that drifted sideways. */
const SWIPE_DRIFT_PX = 45;
export function MobileMenu() {
const pathname = usePathname();
const {isDrawerOpen, setDrawerOpen, closeDrawer} = useSidebar();
const touchStart = useRef<{x: number; y: number} | null>(null);
return (
<MobileNav
id={NAV_DRAWER_ID}
label="Navigation"
side="start"
width={320}
isOpen={isDrawerOpen}
onOpenChange={setDrawerOpen}
className={DRAWER}
// The lockup, not a text title: the drawer is the only place on mobile
// where the brand can appear at full size, and the header row is going
// to be there for the close button regardless.
header={<BrandLogo height={26} />}
// Swipe-to-dismiss, deliberately without live drag: following the finger
// means fighting the dialog's own transform for the 250ms of the close
// transition, and a threshold gesture is indistinguishable from it at
// the speed people actually flick a drawer shut.
onTouchStart={(e) => {
const t = e.touches[0];
touchStart.current = t ? {x: t.clientX, y: t.clientY} : null;
}}
onTouchEnd={(e) => {
const start = touchStart.current;
const end = e.changedTouches[0];
touchStart.current = null;
if (!start || !end) return;
const dx = end.clientX - start.x;
const dy = Math.abs(end.clientY - start.y);
if (dx < -SWIPE_CLOSE_PX && dy < SWIPE_DRIFT_PX) {
closeDrawer();
}
}}
>
<SideNavSection title="Workspace" isHeaderHidden>
{PRIMARY_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
isSelected={isNavActive(pathname, item.href)}
// Closes on select. The href still does the navigating — this only
// dismisses the sheet so the destination is actually visible.
onClick={closeDrawer}
/>
))}
</SideNavSection>
<Divider />
<SideNavSection title="Account" isHeaderHidden>
{FOOTER_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
isSelected={isNavActive(pathname, item.href)}
onClick={closeDrawer}
/>
))}
</SideNavSection>
</MobileNav>
);
}

View File

@@ -1,73 +0,0 @@
'use client';
import {MobileNav} from '@astryxdesign/core/MobileNav';
import {SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {usePathname} from 'next/navigation';
import {FOOTER_NAV, PRIMARY_NAV, isNavActive} from './nav-config';
import {StoreSwitcher} from './StoreSwitcher';
import {RangePicker} from './RangePicker';
/**
* The mobile drawer.
*
* AppShell will auto-generate a drawer that mirrors the sidebar, but that
* drawer contains navigation ONLY — and the store switcher and range picker
* live in TopNav's `startContent`, which collapses below 768px. The result was
* that scope could not be changed at all on a phone, on a product where every
* page's numbers depend on it.
*
* So the drawer is supplied explicitly: scope first (it governs everything
* below it), then the same nav the sidebar shows.
*/
export function MobileScopeNav() {
const pathname = usePathname();
return (
<MobileNav label="Menu" side="start" width={300}>
<VStack gap={3} padding={4}>
<Text size="xsm" color="secondary">
Viewing
</Text>
<VStack gap={2} width="100%">
<HStack width="100%">
<StoreSwitcher />
</HStack>
<HStack width="100%">
<RangePicker />
</HStack>
</VStack>
</VStack>
<Divider />
<SideNavSection title="Workspace" isHeaderHidden>
{PRIMARY_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
isSelected={isNavActive(pathname, item.href)}
/>
))}
</SideNavSection>
<Divider />
<SideNavSection title="Account" isHeaderHidden>
{FOOTER_NAV.map((item) => (
<SideNavItem
key={item.href}
label={item.label}
icon={item.icon}
href={item.href}
isSelected={isNavActive(pathname, item.href)}
/>
))}
</SideNavSection>
</MobileNav>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';
import {ICONS} from '@/lib/icons';
import {useSidebar} from './SidebarProvider';
/**
* The one control that is never absent.
*
* It renders in the top bar's leading slot at EVERY width — the header is the
* only region of the shell that survives all three navigation states, so it is
* the only place a control can live and still be reachable when the nav it
* governs is collapsed or gone. Anything mounted inside the sidebar disappears
* with the sidebar, which is exactly how the rail became unreopenable.
*
* Three states, one button:
* drawer closed ☰ → open the overlay
* drawer open ☰ → close it (aria-expanded carries the state)
* rail collapsed panel-open → expand to 260px
* rail expanded panel-close → collapse to 84px
*
* 44px square below the breakpoint: IconButton's `lg` is 36px, which clears
* neither the WCAG 2.2 target-size minimum nor a thumb. `size-11` is the
* spacing-11 token (44px) through the Tailwind bridge, and it sits in the
* utilities layer so it wins over Astryx's own sizing without !important.
*/
export function NavMenuButton() {
const {isDrawer, isDrawerOpen, isCollapsed, toggle, controlsId} =
useSidebar();
const label = isDrawer
? isDrawerOpen
? 'Close navigation menu'
: 'Open navigation menu'
: isCollapsed
? 'Expand navigation'
: 'Collapse navigation';
const icon = isDrawer
? ICONS.menu
: isCollapsed
? ICONS.sidebarOpen
: ICONS.sidebarClose;
return (
<IconButton
icon={<Icon icon={icon} size={isDrawer ? 'md' : 'sm'} />}
label={label}
tooltip={label}
variant="ghost"
size={isDrawer ? 'lg' : 'md'}
onClick={toggle}
// Expanded/collapsed is the drawer's state; above the breakpoint the rail
// is always present, so only its width changes and aria-expanded would be
// a lie. aria-controls still points at the rail so the relationship holds.
aria-expanded={isDrawer ? isDrawerOpen : undefined}
aria-controls={controlsId}
className={isDrawer ? 'size-11' : undefined}
data-testid="nav-menu-button"
/>
);
}

View File

@@ -1,28 +0,0 @@
'use client';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {RANGE_LABELS, useWorkspace} from './WorkspaceProvider';
import type {RangeKey} from './WorkspaceProvider';
const ORDER: RangeKey[] = ['7d', '30d', '90d', 'mtd', 'ytd'];
/** Period scope for every chart and KPI in the workspace. */
export function RangePicker() {
const {range, setRange} = useWorkspace();
return (
<DropdownMenu
button={{
variant: 'ghost',
size: 'sm',
label: RANGE_LABELS[range],
icon: <Icon icon="calendar" size="sm" />,
}}
items={ORDER.map((key) => ({
label: RANGE_LABELS[key],
onClick: () => setRange(key),
}))}
/>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import {Link} from '@astryxdesign/core/Link';
import {HStack} from '@astryxdesign/core/Layout';
import {useSideNavCollapse} from '@astryxdesign/core/SideNav';
import {BrandLogo, BrandMark} from '@/components/brand/BrandLogo';
/**
* SideNav's header slot: the lockup and the heart, cross-faded.
*
* ── Why both marks are always mounted ─────────────────────────────────────
* Swapping `{isCollapsed ? <Mark/> : <Logo/>}` cannot animate — React tears
* one element out and puts the other in, so the brand hard-cuts while the
* rail beside it is still 200ms from arriving, which is the single most
* noticeable jump in the whole collapse. Both marks render on every frame,
* stacked in the same 48px box, and only opacity and scale change. Next/Image
* also gets to keep both files warm, so neither swap costs a fetch.
*
* The box is a FIXED 48px tall and full-width in both states, which is what
* stops the nav below it from shifting: the heart is centred by the layer's
* own alignment rather than by the box changing shape. 48px also matches the
* top bar's height, so the mark sits on the same baseline as the header
* controls across the fold — the Linear/Vercel arrangement.
*
* ── One link, two images ──────────────────────────────────────────────────
* The layers are decoration inside a single anchor, not two anchors: two
* would mean two tab stops for one destination, one of them invisible.
* `pointer-events-none` keeps the images from eating the anchor's clicks, and
* only the lockup carries alt text — the heart passes `alt=""` so the
* accessible name stays "Loyaly.ai" rather than doubling.
*
* The 'Merchant OS' subtitle that used to sit under the lockup is gone. It
* could not survive the collapse without either changing the header's height
* (the jump above) or reserving a dead line in an 84px rail, and the product
* name is already carried by the document title and the lockup itself.
*/
/** Shared by both layers — they occupy the same box and differ only in fade. */
const LAYER = [
'absolute inset-0 pointer-events-none',
// `scale`, not `transform`: Tailwind v4's scale-* utilities set the
// standalone `scale` property, so a transition listed against `transform`
// animates nothing and the mark pops in at full size. Measured — computed
// transform stayed `none` while computed scale was already 0.95.
'transition-[opacity,scale] duration-200 ease-in-out',
// The lockup is 170px wide inside an 84px rail for the length of the
// collapse. Without this it squashes to 65px on the way out; with it, the
// rail's own `overflow: hidden` clips it instead, which is the same thing
// Linear does and the only one of the two that looks deliberate.
// max-w-none as well as shrink-0: Tailwind's preflight caps every img at
// `max-width: 100%`, which re-imposes the squash that shrink-0 just removed.
'[&_img]:shrink-0 [&_img]:max-w-none',
].join(' ');
export function SideNavBrand() {
const {isCollapsed} = useSideNavCollapse();
return (
// paddingInline 8px expanded so the lockup's left edge lands on the same
// 16px optical margin as the nav glyphs below it (8px from SideNav's own
// sticky header padding + 8px here); 0 collapsed, where the layer centres
// the heart in the 84px rail instead.
<HStack
className="relative h-12 w-full"
paddingInline={isCollapsed ? 0 : 2}
vAlign="center"
>
{/* The image alt is the link's accessible name — no `label` here, or it
would override it. */}
<Link href="/dashboard" className="absolute inset-0 block">
<HStack
className={`${LAYER} ${
isCollapsed ? 'opacity-0 scale-95' : 'opacity-100 scale-100'
}`}
vAlign="center"
hAlign="start"
>
{/* 34px tall → 170px wide. The rail is 260px and gives up 32px to
padding, so the lockup clears its box and still reads as the
largest thing in the sidebar. */}
<BrandLogo height={34} priority />
</HStack>
<HStack
className={`${LAYER} ${
isCollapsed ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
}`}
vAlign="center"
hAlign="center"
>
<BrandMark size={32} alt="" priority />
</HStack>
</Link>
</HStack>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {useBreakpoint, isSideNavInline} from '@/lib/breakpoints';
import {usePersistentTriState} from '@/lib/usePersistentFlag';
/**
* One source of truth for "where is the navigation right now".
*
* Before this, three components each owned a piece: SideNav held its own
* collapse flag, the layout held the drawer flag, and AppShell injected a
* hamburger only in its mobile top bar. Nothing could see the whole picture,
* which is how the sidebar became unreachable — collapsed on desktop with no
* control left to expand it, and closed on mobile with the toggle living in a
* slot that only exists below 640px.
*
* Hoisting both flags here means a single button (NavMenuButton) can render in
* the header at every width and always know what it should do, and the drawer
* can never be open in a layout that has no way to close it.
*
* ── The three states ──────────────────────────────────────────────────────
* drawer <640 overlay, closed by default, opened by the hamburger
* rail 640+ inline; collapsed (84px) or expanded (260px)
*
* `isDrawer` deliberately mirrors AppShell's own `mobileNav.breakpoint` of
* 'sm' via isSideNavInline — the two must agree or you get two hamburgers or
* none. See lib/breakpoints.
*/
/** The drawer's DOM id, so the header button's `aria-controls` resolves. */
export const NAV_DRAWER_ID = 'workspace-nav-drawer';
/** The inline rail's DOM id, for the same reason above the breakpoint. */
export const NAV_RAIL_ID = 'workspace-nav-rail';
const COLLAPSE_KEY = 'loyaly.sidenav.collapsed';
interface SidebarValue {
/** True below 640px, where the nav is an overlay rather than a column. */
isDrawer: boolean;
/** Rail state. Meaningless while `isDrawer` — the drawer is never a rail. */
isCollapsed: boolean;
isDrawerOpen: boolean;
setDrawerOpen: (open: boolean) => void;
closeDrawer: () => void;
/** Open/close below the breakpoint, collapse/expand above it. */
toggle: () => void;
/** Whichever element the header button currently controls. */
controlsId: string;
}
const SidebarContext = createContext<SidebarValue | null>(null);
export function SidebarProvider({children}: {children: React.ReactNode}) {
const bp = useBreakpoint();
const isDrawer = !isSideNavInline(bp);
// Tri-state, not a boolean: a tablet has to start collapsed WITHOUT that
// being a stored preference, or the first desktop visit inherits it.
const [storedCollapsed, setStoredCollapsed] =
usePersistentTriState(COLLAPSE_KEY);
const isCollapsed = storedCollapsed ?? bp === 'tablet';
const [isMenuOpen, setMenuOpen] = useState(false);
// DERIVED, not reset in an effect: a resize past the breakpoint would
// otherwise leave the drawer mounted over a layout whose hamburger has
// turned into a collapse button — a nav trap with no visible way out.
// Gating on the current breakpoint costs no extra render pass.
const isDrawerOpen = isDrawer && isMenuOpen;
const closeDrawer = useCallback(() => setMenuOpen(false), []);
const toggle = useCallback(() => {
if (isDrawer) {
setMenuOpen((open) => !open);
} else {
setStoredCollapsed(!isCollapsed);
}
}, [isDrawer, isCollapsed, setStoredCollapsed]);
// Body scroll lock. MobileNav's native <dialog> already clips the scroll on
// <html>, but iOS Safari still rubber-bands <body> underneath the top layer,
// which slides the page behind a drawer that stays put.
useEffect(() => {
if (!isDrawerOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [isDrawerOpen]);
const value = useMemo(
() => ({
isDrawer,
isCollapsed,
isDrawerOpen,
setDrawerOpen: setMenuOpen,
closeDrawer,
toggle,
controlsId: isDrawer ? NAV_DRAWER_ID : NAV_RAIL_ID,
}),
[isDrawer, isCollapsed, isDrawerOpen, closeDrawer, toggle],
);
return <SidebarContext value={value}>{children}</SidebarContext>;
}
export function useSidebar(): SidebarValue {
const ctx = useContext(SidebarContext);
if (!ctx) {
throw new Error('useSidebar must be used inside <SidebarProvider>');
}
return ctx;
}

View File

@@ -6,13 +6,17 @@ import {createContext, useContext, useMemo, useState} from 'react';
* Workspace-wide query scope: which store, and over what period.
*
* This lives ABOVE the route tree (mounted in app/providers.tsx) for the same
* reason the Copilot does — the controls that drive it sit in the top nav,
* which outlives any individual page. A server-component page could not
* re-render from these controls without a router navigation, and a navigation
* would remount the Copilot. So scope is client state, and data panels read it.
* reason the Copilot does. A server-component page could not re-render from
* these controls without a router navigation, and a navigation would remount
* the Copilot. So scope is client state, and data panels read it.
*
* The controls that write it are NOT shell — they render in each page's own
* header (@/components/scope). Keeping the state up here is what lets a store
* or period chosen on the dashboard still be in force on Staff or LYTs, while
* a page that ignores scope simply shows no control for it.
*/
export type RangeKey = '7d' | '30d' | '90d' | 'mtd' | 'ytd';
export type RangeKey = '7d' | '30d' | '90d' | 'mtd' | 'ytd' | 'custom';
export const RANGE_LABELS: Record<RangeKey, string> = {
'7d': 'Last 7 days',
@@ -20,8 +24,14 @@ export const RANGE_LABELS: Record<RangeKey, string> = {
'90d': 'Last 90 days',
mtd: 'Month to date',
ytd: 'Year to date',
custom: 'Custom range',
};
export interface CustomDateRange {
start: string;
end: string;
}
export interface StoreOption {
id: string;
name: string;
@@ -29,7 +39,7 @@ export interface StoreOption {
/**
* Store list for the switcher. Deliberately static: the switcher must render
* before any data loads, and a spinner in the top nav on every page load reads
* before any data loads, and a spinner in a page header on every load reads
* as jank. The authoritative list comes from /api/stores once Store lands.
*/
export const STORE_OPTIONS: StoreOption[] = [
@@ -47,6 +57,8 @@ interface WorkspaceValue {
setStoreId: (id: StoreScope) => void;
range: RangeKey;
setRange: (r: RangeKey) => void;
customRange: CustomDateRange | null;
setCustomRange: (cr: CustomDateRange | null) => void;
stores: StoreOption[];
}
@@ -55,10 +67,19 @@ const WorkspaceContext = createContext<WorkspaceValue | null>(null);
export function WorkspaceProvider({children}: {children: React.ReactNode}) {
const [storeId, setStoreId] = useState<StoreScope>('all');
const [range, setRange] = useState<RangeKey>('30d');
const [customRange, setCustomRange] = useState<CustomDateRange | null>(null);
const value = useMemo(
() => ({storeId, setStoreId, range, setRange, stores: STORE_OPTIONS}),
[storeId, range],
() => ({
storeId,
setStoreId,
range,
setRange,
customRange,
setCustomRange,
stores: STORE_OPTIONS,
}),
[storeId, range, customRange],
);
return <WorkspaceContext value={value}>{children}</WorkspaceContext>;

View File

@@ -0,0 +1,3 @@
'use client';
export {LoginSplit as LoginForm} from './LoginSplit';

View File

@@ -0,0 +1,302 @@
'use client';
import {useState} from 'react';
import {useRouter} from 'next/navigation';
import {BrandLogo, BrandMark} from '@/components/brand/BrandLogo';
const HERO_IMAGE_URL =
'https://images.unsplash.com/photo-1556742049-0a670fc8078a?q=80&w=1400&auto=format&fit=crop';
const HERO_FALLBACK_URL =
'https://images.unsplash.com/photo-1441986300917-64674bd600d8?q=80&w=1400&auto=format&fit=crop';
function GoogleIcon() {
return (
<svg width={18} height={18} viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"
/>
</svg>
);
}
function MicrosoftIcon() {
return (
<svg width={18} height={18} viewBox="0 0 23 23">
<path fill="#f35325" d="M1 1h10v10H1z" />
<path fill="#81bc06" d="M12 1h10v10H1z" />
<path fill="#05a6f0" d="M1 12h10v10H1z" />
<path fill="#ffba08" d="M12 12h10v10H1z" />
</svg>
);
}
function CheckIcon() {
return (
<svg
width={13}
height={13}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-white"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
export function LoginSplit() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const [errors, setErrors] = useState<{email?: string; password?: string}>({});
const [isLoading, setIsLoading] = useState(false);
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
const next: typeof errors = {};
if (!/^\S+@\S+\.\S+$/.test(email)) {
next.email = 'Enter a valid email address';
}
if (password.length < 8) {
next.password = 'Password must be at least 8 characters';
}
setErrors(next);
if (Object.keys(next).length > 0) return;
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
router.push('/dashboard');
}, 700);
};
return (
<div className="min-h-screen w-full relative flex items-center justify-center p-4 sm:p-6 lg:p-10 bg-[#0C0D12] overflow-hidden selection:bg-white selection:text-black">
{/* Rich Ambient Background Layers */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `
radial-gradient(circle at 50% -10%, rgba(255, 255, 255, 0.09) 0%, transparent 55%),
radial-gradient(circle at 85% 20%, rgba(245, 158, 11, 0.06) 0%, transparent 45%),
radial-gradient(circle at 15% 80%, rgba(99, 102, 241, 0.06) 0%, transparent 50%)
`,
}}
/>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_0%,rgba(0,0,0,0.6)_100%)] pointer-events-none" />
{/* Main Split Container */}
<div className="w-full max-w-[1360px] min-h-[760px] lg:h-[82vh] lg:max-h-[840px] rounded-[24px] bg-[#14151B]/95 border border-white/[0.1] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.85)] backdrop-blur-2xl flex flex-col md:flex-row overflow-hidden relative z-10">
{/* LEFT PANEL: Enterprise Visual (55% Desktop / 40% Tablet / Hidden Mobile) */}
<div className="hidden md:flex md:w-[40%] lg:w-[55%] h-full p-3 relative">
<div className="w-full h-full rounded-[20px] overflow-hidden relative border border-white/10 bg-[#090A0E]">
{/* Main Retail Store Hero Image */}
<img
src={HERO_IMAGE_URL}
alt="Loyaly Merchant Operating System"
className="w-full h-full object-cover object-center"
onError={(e) => {
(e.target as HTMLImageElement).src = HERO_FALLBACK_URL;
}}
/>
{/* Subtle Gradient Overlays */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent pointer-events-none" />
<div className="absolute inset-0 bg-gradient-to-l from-[#0E0E10]/70 via-transparent to-transparent pointer-events-none" />
{/* Clean Enterprise Brand Watermark (Bottom-Left) */}
<div className="absolute bottom-6 left-6 flex items-center gap-2.5 px-3.5 py-2 rounded-xl bg-black/50 backdrop-blur-md border border-white/10 text-white/80">
<BrandMark size={16} priority />
<span className="text-[11px] font-medium tracking-wide text-neutral-300">
Loyaly.ai · Merchant Operating System
</span>
</div>
</div>
</div>
{/* RIGHT PANEL: Authentication (45% Desktop / 60% Tablet / 100% Mobile) */}
<div className="w-full md:w-[60%] lg:w-[45%] h-full p-8 sm:p-10 lg:p-12 flex flex-col justify-between overflow-y-auto">
{/* Top Brand Header */}
<div className="flex items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-3">
<BrandLogo height={42} priority />
<div className="h-4 w-px bg-white/20" />
<span className="text-xs font-semibold tracking-wide text-neutral-400 uppercase">
Merchant OS
</span>
</div>
<span className="px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase rounded-full bg-white/[0.06] border border-white/10 text-neutral-300">
v2.4 Enterprise
</span>
</div>
{/* Form Content */}
<div className="my-auto py-4">
<div className="mb-8">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-white mb-2.5">
Welcome back
</h1>
<p className="text-sm text-neutral-400 leading-relaxed max-w-md">
Access your Merchant Operating System to manage stores, rewards, staff and AI insights.
</p>
</div>
<form onSubmit={handleLogin} noValidate className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1.5">
Email address
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@company.com"
className={`w-full h-11 px-3.5 rounded-xl bg-white/[0.04] border ${
errors.email ? 'border-red-500/80' : 'border-white/10'
} text-white text-sm placeholder:text-neutral-500 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20 transition-all`}
/>
{errors.email && (
<p className="text-xs text-red-400 mt-1">{errors.email}</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1.5">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setErrors({});
}}
placeholder="Enter your password"
className={`w-full h-11 px-3.5 rounded-xl bg-white/[0.04] border ${
errors.password ? 'border-red-500/80' : 'border-white/10'
} text-white text-sm placeholder:text-neutral-500 focus:outline-none focus:border-white/30 focus:ring-1 focus:ring-white/20 transition-all`}
/>
{errors.password && (
<p className="text-xs text-red-400 mt-1">{errors.password}</p>
)}
</div>
{/* Controls Row */}
<div className="flex items-center justify-between pt-1">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/5 text-white focus:ring-0 focus:ring-offset-0 cursor-pointer accent-white"
/>
<span className="text-xs text-neutral-300 group-hover:text-white transition-colors">
Remember me
</span>
</label>
<a
href="#"
className="text-xs text-neutral-400 hover:text-white transition-colors font-medium"
>
Forgot password?
</a>
</div>
{/* Primary CTA */}
<button
type="submit"
disabled={isLoading}
className="w-full h-11 mt-2 rounded-xl bg-white text-black font-semibold text-sm hover:bg-neutral-200 active:scale-[0.99] transition-all shadow-md shadow-white/5 flex items-center justify-center gap-2 disabled:opacity-70"
>
{isLoading ? (
<span className="inline-block w-4 h-4 border-2 border-black/30 border-t-black rounded-full animate-spin" />
) : (
<>
Continue
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</>
)}
</button>
{/* OAuth Divider */}
<div className="relative my-5 text-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/[0.08]" />
</div>
<span className="relative px-3 text-xs text-neutral-500 bg-[#14151B]">
or continue with
</span>
</div>
{/* OAuth Buttons */}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className="h-11 rounded-xl bg-white/[0.04] border border-white/10 hover:bg-white/[0.08] hover:border-white/20 text-white text-xs font-medium transition-all flex items-center justify-center gap-2.5"
>
<GoogleIcon />
Google
</button>
<button
type="button"
className="h-11 rounded-xl bg-white/[0.04] border border-white/10 hover:bg-white/[0.08] hover:border-white/20 text-white text-xs font-medium transition-all flex items-center justify-center gap-2.5"
>
<MicrosoftIcon />
Microsoft
</button>
</div>
</form>
</div>
{/* Bottom Footer Access Note */}
<div className="pt-6 border-t border-white/[0.06] mt-4">
<p className="text-xs text-neutral-400">
Need access?{' '}
<a href="#" className="text-white hover:underline font-medium">
Contact your organization administrator.
</a>
</p>
</div>
</div>
</div>
{/* Page Footer Terms */}
<div className="absolute bottom-3 text-center w-full pointer-events-auto">
<p className="text-[11px] text-neutral-500">
By continuing, you agree to Loyaly&apos;s{' '}
<a href="#" className="text-neutral-400 hover:text-white transition-colors underline">
Terms of Service
</a>{' '}
and{' '}
<a href="#" className="text-neutral-400 hover:text-white transition-colors underline">
Privacy Policy
</a>
.
</p>
</div>
</div>
);
}

View File

@@ -31,12 +31,12 @@ export function CopilotPanel({onDismiss}: {onDismiss?: () => void}) {
>
<HStack gap={2} vAlign="center">
<Icon icon={ICONS.ai} size="sm" />
<Heading level={2}>Copilot</Heading>
<Heading level={2}>Loyaly AI</Heading>
</HStack>
{onDismiss ? (
<IconButton
icon={<Icon icon={ICONS.panelClose} />}
label="Hide Copilot"
label="Hide Loyaly AI"
variant="ghost"
size="sm"
onClick={onDismiss}

View File

@@ -23,13 +23,19 @@ export function CopilotSlideOver() {
return (
<MobileNav
// Explicit, because MobileNav otherwise takes its id from AppShell's
// mobile context — which every MobileNav in the tree shares, so this
// drawer and the navigation drawer both rendered id="…" identical.
// A duplicate id makes the menu button's aria-controls resolve to
// whichever one the DOM happens to reach first.
id="copilot-slide-over"
isOpen={isSlideOverOpen}
onOpenChange={setSlideOverOpen}
side="end"
// Full-bleed on mobile, where a 380px drawer would leave nothing
// behind it worth looking at.
width={bp === 'mobile' ? undefined : 380}
label="AI Copilot"
label="Loyaly AI"
>
<CopilotPanel onDismiss={() => setSlideOverOpen(false)} />
</MobileNav>

View File

@@ -22,7 +22,7 @@ export function CopilotToggle() {
return (
<IconButton
icon={<Icon icon={isShown ? ICONS.panelClose : ICONS.panelOpen} />}
label={isShown ? 'Hide Copilot' : 'Show Copilot'}
label={isShown ? 'Hide Loyaly AI' : 'Show Loyaly AI'}
variant="ghost"
onClick={() => (inline ? toggle() : setSlideOverOpen(!isSlideOverOpen))}
/>

View File

@@ -4,28 +4,56 @@ import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Card} from '@astryxdesign/core/Card';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {SkeletonRows} from '@/components/patterns/LoadingState';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import {useResource} from '@/lib/api/useResource';
import {endpoints} from '@/lib/api/client';
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
import {ICONS} from '@/lib/icons';
import type {InsightSeverity} from '@/lib/api/contracts';
/**
* Phase 1 renders the panel's shape with representative content so the layout
* can be judged. The insight list becomes `useResource(endpoints['copilot.insights'])`
* in Phase 2 — the markup below is already the success branch of that.
* Reads the SAME briefing endpoint the dashboard does.
*
* This panel used to hold hardcoded Phase-1 copy, which was harmless while the
* dashboard had no narrative of its own. Now that it does, static copy here is
* actively wrong: the panel claimed "3 rewards expire in 48 hours" beside a
* dashboard saying one, from the same screen. Two AI surfaces disagreeing about
* the business is worse than one of them not existing.
*
* Scope comes from WorkspaceProvider, so the Copilot follows the store and
* period the merchant is looking at rather than reporting on something else.
*/
const BANNER_STATUS: Record<
InsightSeverity,
'error' | 'warning' | 'info' | 'success'
> = {
error: 'error',
warning: 'warning',
info: 'info',
success: 'success',
};
export function AiTab() {
const {storeId, range} = useWorkspace();
const briefing = useResource(endpoints.dashboardBriefing({range, storeId}));
return (
<VStack gap={4} padding={4}>
<AsyncBoundary resource={briefing} loading={<SkeletonRows count={6} />}>
{(b) => (
<VStack gap={4}>
<Card variant="muted">
<VStack gap={2}>
<HStack gap={2} vAlign="center">
<Icon icon={ICONS.ai} size="sm" />
<Heading level={3}>Daily summary</Heading>
<Heading level={3}>Summary</Heading>
</HStack>
<Text size="sm" color="secondary">
Footfall is tracking 8% above last Tuesday across all five stores.
LYT redemption is concentrated in Indiranagar, which is running at
2.3× the network average.
{b.summary || 'Not enough activity in this period to summarise.'}
</Text>
</VStack>
</Card>
@@ -34,44 +62,61 @@ export function AiTab() {
<Text size="sm" color="secondary">
Needs attention
</Text>
<Banner
status="warning"
title="Sales dropped 12% at Whitefield"
description="Down against the trailing 4-week Tuesday average."
{b.alerts.length === 0 ? (
<EmptyPanel
icon="present"
title="All clear"
description="Nothing in this scope needs a decision right now."
/>
) : (
b.alerts.map((a) => (
<Banner
status="error"
title="3 rewards expire in 48 hours"
description="Free Coffee, Combo 20%, Weekend Bonus."
key={a.id}
status={BANNER_STATUS[a.severity]}
title={a.title}
description={a.body}
endContent={
a.action ? (
<Button
variant="secondary"
size="sm"
label={a.action.label}
href={a.action.href}
/>
) : undefined
}
/>
))
)}
</VStack>
<VStack gap={2}>
<Text size="sm" color="secondary">
Recommendations
Open tasks
</Text>
<Card>
{b.tasks
.filter((t) => !t.isDone)
.map((t) => (
<Card key={t.id}>
<VStack gap={1.5}>
<HStack gap={2} vAlign="center">
<Text weight="medium">Extend Weekend Bonus</Text>
<Badge variant="success" label="High confidence" />
<HStack gap={2} vAlign="center" hAlign="between">
<Text weight="medium">{t.label}</Text>
<Text size="xsm" color="secondary" textWrap="nowrap">
{t.due}
</Text>
</HStack>
{t.detail ? (
<Text size="sm" color="secondary">
It converts at 34% the best of any active reward and expires
Friday.
{t.detail}
</Text>
) : null}
</VStack>
</Card>
<Card>
<VStack gap={1.5}>
<Text weight="medium">Add evening cover at Koramangala</Text>
<Text size="sm" color="secondary">
Peak footfall starts at 18:30 but only two staff are rostered
after 18:00.
</Text>
))}
</VStack>
</Card>
</VStack>
)}
</AsyncBoundary>
</VStack>
);
}

View File

@@ -1,26 +1,34 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Button} from '@astryxdesign/core/Button';
import {PanelCard} from '@/components/patterns/PanelCard';
import {ActivityFeed} from '@/components/patterns/ActivityItem';
import {SkeletonRows} from '@/components/patterns/LoadingState';
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
import type {ActivityEntry} from '@/components/patterns/ActivityItem';
import type {ActivityEntry, ActivityTone} from '@/components/patterns/ActivityItem';
import type {ActivityEvent, ActivityKind} from '@/lib/api/contracts';
import type {IconKey} from '@/lib/icons';
import type {Resource} from '@/lib/api/useResource';
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
/**
* Only one activity kind carries semantic colour. An expiry is the single
* event in this feed a merchant may need to act on; redemptions, purchases,
* check-ins and openings are the shop working normally, and colouring them
* would make the one thing that matters harder to find.
* Each event kind gets its own glyph, so the feed can be skimmed by category
* without reading a word of it.
*
* Only one kind carries semantic colour. An expiry is the single event here a
* merchant may need to act on; redemptions, purchases, check-ins and openings
* are the shop working normally, and colouring them would make the one thing
* that matters harder to find.
*/
const KIND: Record<ActivityKind, {tone: StatusDotVariant; toneLabel: string}> = {
reward_redeemed: {tone: 'neutral', toneLabel: 'Redemption'},
purchase: {tone: 'neutral', toneLabel: 'Purchase'},
staff_checked_in: {tone: 'neutral', toneLabel: 'Attendance'},
store_opened: {tone: 'neutral', toneLabel: 'Store'},
reward_expired: {tone: 'warning', toneLabel: 'Expiry'},
const KIND: Record<
ActivityKind,
{icon: IconKey; tone: ActivityTone; toneLabel: string}
> = {
purchase: {icon: 'purchases', tone: 'neutral', toneLabel: 'Purchase'},
reward_redeemed: {icon: 'lyts', tone: 'neutral', toneLabel: 'Reward'},
staff_checked_in: {icon: 'staff', tone: 'neutral', toneLabel: 'Staff'},
store_opened: {icon: 'stores', tone: 'neutral', toneLabel: 'Store'},
reward_expired: {icon: 'alert', tone: 'warning', toneLabel: 'Alert'},
};
function toEntries(events: ActivityEvent[]): ActivityEntry[] {
@@ -33,17 +41,52 @@ function toEntries(events: ActivityEvent[]): ActivityEntry[] {
}));
}
/**
* The feed panel, in two sizes.
*
* Bounded by default: `limit` caps the rows and `height` caps the box, because
* an uncapped feed is the one panel on a dashboard that grows without limit as
* the business gets busier. It reached 784px — taller than any chart on the
* page — which is how an operational log ends up outweighing the insights it
* was meant to support.
*
* Pass `viewAllHref` and the header offers the full history instead. Pass
* neither and it renders everything, which is what the Activity page wants.
*/
export function ActivityTimeline({
resource,
title = 'Recent activity',
subtitle = 'Live feed across the selected store and period',
limit,
height,
viewAllHref,
}: {
resource: Resource<ActivityEvent[]>;
title?: string;
subtitle?: string;
/** Rows to show. Omit for the complete history. */
limit?: number;
/** Fixed content height in px. The list scrolls inside it rather than pushing
* the card taller, so the panel's footprint is the same on every load. */
height?: number;
viewAllHref?: string;
}) {
return (
<PanelCard
title="Recent activity"
subtitle="Live feed across the selected store and period"
title={title}
subtitle={subtitle}
resource={resource}
loading={<SkeletonRows count={5} />}
loading={<SkeletonRows count={limit ?? 6} />}
actions={
viewAllHref ? (
<Button
variant="ghost"
size="sm"
label="View all"
href={viewAllHref}
/>
) : undefined
}
empty={
<EmptyPanel
icon="alert"
@@ -52,7 +95,15 @@ export function ActivityTimeline({
/>
}
>
{(events) => <ActivityFeed entries={toEntries(events)} />}
{(events) =>
height ? (
<VStack height={height} isScrollable>
<ActivityFeed entries={toEntries(events)} limit={limit} />
</VStack>
) : (
<ActivityFeed entries={toEntries(events)} limit={limit} />
)
}
</PanelCard>
);
}

View File

@@ -5,7 +5,7 @@ import {AsyncBoundary} from '@/components/data/AsyncBoundary';
import {MetricCard} from '@/components/patterns/MetricCard';
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
import {ICONS} from '@/lib/icons';
import {useBreakpoint, isPanelInline} from '@/lib/breakpoints';
import {useBreakpoint, metricColumns} from '@/lib/breakpoints';
import {useCopilot} from '@/features/copilot/CopilotProvider';
import {formatCompact, formatInrCompact} from '@/lib/format';
import type {Kpi, KpiId} from '@/lib/api/contracts';
@@ -32,11 +32,9 @@ const KPI_ICON: Record<KpiId, IconType> = {
export function useMetricColumns(): number {
const bp = useBreakpoint();
const {isOpen} = useCopilot();
if (bp === 'mobile') return 1;
if (bp === 'tablet') return 2;
if (bp === 'laptop') return 2;
return isPanelInline(bp) && isOpen ? 2 : 4;
// The rule itself lives in lib/breakpoints alongside every other layout
// threshold, so a breakpoint change is one edit rather than a hunt.
return metricColumns(bp, isOpen);
}
export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {

View File

@@ -38,8 +38,8 @@ export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) {
title={reward.name}
subtitle={
<HStack gap={1.5} vAlign="center">
<Icon icon={ICONS.lyt} size="xsm" color="secondary" />
<Text size="sm" color="secondary">
<Icon icon={ICONS.lyt} size="sm" color="secondary" />
<Text size="base" weight="medium">
{formatLyt(reward.costLyt)}
</Text>
</HStack>
@@ -47,7 +47,7 @@ export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) {
actions={<Badge variant={status.variant} label={status.label} />}
/>
<VStack gap={1.5}>
<VStack gap={2}>
<ProgressBar
value={rate}
max={100}
@@ -55,10 +55,10 @@ export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) {
hasValueLabel={false}
/>
<HStack hAlign="between">
<Text size="xsm" color="secondary">
<Text size="sm" weight="medium">
{formatCompact(reward.used)} redeemed
</Text>
<Text size="xsm" color="secondary">
<Text size="sm" weight="medium">
{formatCompact(reward.claimed)} claimed
</Text>
</HStack>

View File

@@ -1,6 +1,7 @@
'use client';
import {Table, proportional, pixel} from '@astryxdesign/core/Table';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {Badge} from '@astryxdesign/core/Badge';
import {Text} from '@astryxdesign/core/Text';
@@ -45,7 +46,7 @@ export function RewardPerformanceTable({
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
<Text size="base" weight="semibold">
{row.name}
</Text>
<Badge
@@ -58,44 +59,42 @@ export function RewardPerformanceTable({
{
key: 'costLyt',
header: 'Cost',
width: pixel(96),
width: pixel(104),
align: 'end',
renderCell: (row) => <Text size="sm">{formatLyt(row.costLyt)}</Text>,
renderCell: (row) => <Text size="base" weight="medium">{formatLyt(row.costLyt)}</Text>,
},
{
key: 'claimed',
header: 'Claimed',
width: pixel(96),
width: pixel(104),
align: 'end',
renderCell: (row) => <Text size="sm">{formatCompact(row.claimed)}</Text>,
renderCell: (row) => <Text size="base" weight="medium">{formatCompact(row.claimed)}</Text>,
},
{
key: 'used',
header: 'Redeemed',
width: pixel(104),
width: pixel(112),
align: 'end',
renderCell: (row) => <Text size="sm">{formatCompact(row.used)}</Text>,
renderCell: (row) => <Text size="base" weight="medium">{formatCompact(row.used)}</Text>,
},
{
key: 'rate',
header: 'Rate',
width: pixel(104),
width: pixel(112),
align: 'end',
// Measured against a 50% house benchmark, so the arrow says "better or
// worse than a reward should do" rather than "up or down since when?".
renderCell: (row) => (
<HStack hAlign="end">
<MetricDelta value={row.rate - 50} isRiseGood size="xsm" />
<MetricDelta value={row.rate - 50} isRiseGood size="sm" />
</HStack>
),
},
{
key: 'outstanding',
header: 'Outstanding',
width: pixel(128),
width: pixel(140),
align: 'end',
renderCell: (row) => (
<Text size="sm">{formatLyt(row.outstanding)}</Text>
<Text size="base" weight="semibold">{formatLyt(row.outstanding)}</Text>
),
},
];
@@ -129,12 +128,12 @@ export function RewardPerformanceTable({
.sort((a, b) => b.rate - a.rate);
return (
<Table
<ResponsiveTable
data={rows}
columns={columns}
idKey="id"
primaryKey="name"
density="compact"
hasHover
/>
);
}}

View File

@@ -0,0 +1,380 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
export interface ApiKeyItem extends Record<string, unknown> {
id: string;
name: string;
keyPrefix: string;
created: string;
lastUsed: string;
status: 'active' | 'revoked';
}
export interface WebhookEndpointItem extends Record<string, unknown> {
id: string;
url: string;
events: string;
secret: string;
status: 'healthy' | 'failing';
}
export interface WebhookLogItem extends Record<string, unknown> {
id: string;
event: string;
url: string;
code: number;
latency: string;
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<ApiKeyItem[]>(INITIAL_KEYS);
const [webhooks, setWebhooks] = useState<WebhookEndpointItem[]>(INITIAL_WEBHOOKS);
const [newKeyName, setNewKeyName] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [newWebhookUrl, setNewWebhookUrl] = useState('');
const handleGenerateKey = () => {
if (!newKeyName.trim()) {
toast({type: 'error', body: 'Key name is required'});
return;
}
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)}`,
created: 'Just now',
lastUsed: 'Never',
status: 'active',
};
setKeys((prev) => [createdKey, ...prev]);
setNewKeyName('');
setIsGenerating(false);
toast({body: `API Key "${newKeyName}" generated successfully`});
};
const handleRevokeKey = (id: string, name: string) => {
setKeys((prev) => prev.filter((k) => k.id !== id));
toast({body: `Revoked API Key "${name}"`});
};
const handleAddWebhook = () => {
if (!newWebhookUrl.trim()) {
toast({type: 'error', body: 'Webhook URL is required'});
return;
}
const newWh: WebhookEndpointItem = {
id: `wh-${Date.now()}`,
url: newWebhookUrl,
events: 'order.created, lyt.redeemed, staff.checkin',
secret: `whsec_${Math.random().toString(36).substring(2, 8)}••••`,
status: 'healthy',
};
setWebhooks((prev) => [newWh, ...prev]);
setNewWebhookUrl('');
toast({body: 'Webhook endpoint registered successfully'});
};
const keyColumns: TableColumn<ApiKeyItem>[] = [
{
key: 'name',
header: 'Key Name',
width: proportional(1.5),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="sm" color="secondary">
{row.keyPrefix}
</Text>
</VStack>
),
},
{
key: 'created',
header: 'Created Date',
width: proportional(1.2),
renderCell: (row) => <Text size="sm">{row.created}</Text>,
},
{
key: 'lastUsed',
header: 'Last Active',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.lastUsed}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: pixel(100),
renderCell: (row) => (
<Badge
variant={row.status === 'active' ? 'success' : 'error'}
label={row.status}
/>
),
},
{
key: 'actions',
header: 'Action',
width: pixel(100),
renderCell: (row) => (
<Button
size="sm"
variant="ghost"
label="Revoke"
onClick={() => handleRevokeKey(row.id, row.name)}
/>
),
},
];
const webhookColumns: TableColumn<WebhookEndpointItem>[] = [
{
key: 'url',
header: 'Endpoint URL',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.url}
</Text>
<Text size="sm" color="secondary">
Events: {row.events}
</Text>
</VStack>
),
},
{
key: 'secret',
header: 'Signing Secret',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.secret}
</Text>
),
},
{
key: 'status',
header: 'Health',
width: pixel(100),
renderCell: (row) => (
<Badge
variant={row.status === 'healthy' ? 'success' : 'error'}
label={row.status}
/>
),
},
];
const logColumns: TableColumn<WebhookLogItem>[] = [
{
key: 'event',
header: 'Event',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.event}
</Text>
),
},
{
key: 'url',
header: 'Target URL',
width: proportional(2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.url}
</Text>
),
},
{
key: 'code',
header: 'HTTP Code',
width: pixel(100),
renderCell: (row) => (
<Badge
variant={row.code === 200 ? 'success' : 'error'}
label={`${row.code} OK`}
/>
),
},
{
key: 'latency',
header: 'Latency',
width: pixel(90),
renderCell: (row) => <Text size="sm">{row.latency}</Text>,
},
{
key: 'timestamp',
header: 'Timestamp',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.timestamp}
</Text>
),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Developer API Keys"
subtitle="Manage authentication tokens for custom POS clients and server SDKs."
actions={
<Button
variant="primary"
size="sm"
label={isGenerating ? 'Cancel' : 'Generate Secret Key'}
onClick={() => setIsGenerating(!isGenerating)}
/>
}
>
<VStack gap={4}>
{isGenerating ? (
<HStack gap={3} vAlign="end" wrap="wrap">
<TextInput
label="API Key Description"
value={newKeyName}
onChange={setNewKeyName}
placeholder="e.g. Indiranagar Counter 2 POS"
/>
<Button
variant="primary"
size="sm"
label="Generate Token"
onClick={handleGenerateKey}
/>
</HStack>
) : null}
<ResponsiveTable
columns={keyColumns}
data={keys}
primaryKey="name"
/>
</VStack>
</StaticPanel>
<StaticPanel
title="Webhook Subscriptions & Endpoints"
subtitle="Real-time HTTP POST callbacks for order, redemption and staff events."
>
<VStack gap={4}>
<HStack gap={3} vAlign="end" wrap="wrap">
<TextInput
label="Add Webhook Endpoint URL"
value={newWebhookUrl}
onChange={setNewWebhookUrl}
placeholder="https://yourdomain.com/webhooks/loyaly"
/>
<Button
variant="primary"
size="sm"
label="Add Endpoint"
onClick={handleAddWebhook}
/>
</HStack>
<ResponsiveTable
columns={webhookColumns}
data={webhooks}
primaryKey="url"
/>
</VStack>
</StaticPanel>
<StaticPanel
title="Webhook Delivery Logs"
subtitle="Recent HTTP delivery attempts, latency and status response codes."
>
<ResponsiveTable
columns={logColumns}
data={WEBHOOK_LOGS}
primaryKey="event"
/>
</StaticPanel>
</VStack>
);
}

View File

@@ -0,0 +1,219 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {Divider} from '@astryxdesign/core/Divider';
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
import {StatPair, StatRow} from '@/components/patterns/StatPair';
import {formatInr} from '@/lib/format';
export interface InvoiceItem extends Record<string, unknown> {
id: string;
date: string;
amount: number;
status: 'paid' | 'pending';
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: '#',
},
];
export function BillingOverview() {
const toast = useToast();
const [bankAccount, setBankAccount] = useState('HDFC Bank •••• 8842');
const [ifsc, setIfsc] = useState('HDFC0001234');
const [isUpdatingBank, setIsUpdatingBank] = useState(false);
const handleDownloadInvoice = (invId: string) => {
toast({body: `Downloading statement ${invId}...`});
};
const handleUpdateBank = () => {
setIsUpdatingBank(true);
setTimeout(() => {
setIsUpdatingBank(false);
toast({body: 'Settlement bank account details updated'});
}, 400);
};
const columns: TableColumn<InvoiceItem>[] = [
{
key: 'id',
header: 'Invoice ID',
width: proportional(1.5),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.id}
</Text>
),
},
{
key: 'date',
header: 'Billed Date',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.date}
</Text>
),
},
{
key: 'amount',
header: 'Amount',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" weight="medium">
{formatInr(row.amount)}
</Text>
),
},
{
key: 'status',
header: 'Status',
width: pixel(110),
renderCell: (row) => (
<Badge
variant={row.status === 'paid' ? 'success' : 'warning'}
label={row.status}
/>
),
},
{
key: 'actions',
header: 'Statement',
width: pixel(130),
renderCell: (row) => (
<Button
size="sm"
variant="ghost"
label="Download PDF"
onClick={() => handleDownloadInvoice(row.id)}
/>
),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Subscription & Quota Usage"
subtitle="Enterprise Growth Plan details, active limit quotas and payment instruments."
actions={<Badge variant="success" label="Subscription Active" />}
>
<VStack gap={4}>
<StatRow>
<StatPair label="Active Plan" value="Growth Enterprise" />
<StatPair label="Billing Frequency" value="Monthly" />
<StatPair label="Next Renewal" value="1 Sep 2026" />
<StatPair label="Recurring Amount" value={formatInr(14999)} align="end" />
</StatRow>
<Divider />
<VStack gap={3}>
<Heading level={3}>Usage Quotas</Heading>
<VStack gap={2}>
<HStack hAlign="between" vAlign="center">
<Text size="sm">Store Locations (5 of 10 used)</Text>
<Text size="sm" weight="medium">
50%
</Text>
</HStack>
<ProgressBar value={50} max={100} label="Usage" isLabelHidden hasValueLabel={false} />
</VStack>
<VStack gap={2}>
<HStack hAlign="between" vAlign="center">
<Text size="sm">Staff User Seats (5 of 15 used)</Text>
<Text size="sm" weight="medium">
33%
</Text>
</HStack>
<ProgressBar value={33} max={100} label="Usage" isLabelHidden hasValueLabel={false} />
</VStack>
<VStack gap={2}>
<HStack hAlign="between" vAlign="center">
<Text size="sm">API Calls / Month (142,500 of 500,000)</Text>
<Text size="sm" weight="medium">
28.5%
</Text>
</HStack>
<ProgressBar value={28.5} max={100} label="Usage" isLabelHidden hasValueLabel={false} />
</VStack>
</VStack>
</VStack>
</StaticPanel>
<StaticPanel
title="LYT Settlement Bank Details"
subtitle="LYTs redeemed at store counters are settled directly into your registered bank account (1 LYT = ₹1)."
actions={
<Button
variant="secondary"
size="sm"
label="Save Bank Info"
isLoading={isUpdatingBank}
onClick={handleUpdateBank}
/>
}
>
<VStack gap={4}>
<HStack gap={4} wrap="wrap">
<TextInput
label="Settlement Bank & Account"
value={bankAccount}
onChange={setBankAccount}
/>
<TextInput
label="IFSC Code"
value={ifsc}
onChange={setIfsc}
/>
</HStack>
<Text size="sm" color="secondary">
Settlement dispatches take place every Monday morning at 06:00 IST. Net liability is automatically credited.
</Text>
</VStack>
</StaticPanel>
<StaticPanel title="Invoice Statements & Payment History" subtitle="Download tax invoices and monthly receipts.">
<ResponsiveTable
columns={columns}
data={INVOICES}
primaryKey="id"
/>
</StaticPanel>
</VStack>
);
}

View File

@@ -0,0 +1,175 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {FormLayout} from '@astryxdesign/core/FormLayout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Selector} from '@astryxdesign/core/Selector';
import {NumberInput} from '@astryxdesign/core/NumberInput';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
export interface BusinessData {
businessName: string;
legalEntity: string;
category: string;
gstin: string;
pan: string;
phone: string;
email: string;
address: string;
city: string;
pincode: string;
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<BusinessData>(DEFAULT_BUSINESS);
const [isSaving, setIsSaving] = useState(false);
const [savedData, setSavedData] = useState<BusinessData>(DEFAULT_BUSINESS);
const set = <K extends keyof BusinessData>(key: K, val: BusinessData[K]) =>
setForm((f) => ({...f, [key]: val}));
const isDirty = JSON.stringify(form) !== JSON.stringify(savedData);
const handleSave = () => {
setIsSaving(true);
setTimeout(() => {
setSavedData(form);
setIsSaving(false);
toast({body: 'Business details updated successfully'});
}, 400);
};
return (
<StaticPanel
title="Business Information"
subtitle="Official company details, tax registration and operating address."
actions={
<HStack gap={2} vAlign="center">
{isDirty ? (
<Text size="sm" color="secondary">
Unsaved changes
</Text>
) : null}
<Button
variant="secondary"
size="sm"
label="Reset"
isDisabled={!isDirty || isSaving}
onClick={() => setForm(savedData)}
/>
<Button
variant="primary"
size="sm"
label="Save changes"
isDisabled={!isDirty}
isLoading={isSaving}
onClick={handleSave}
/>
</HStack>
}
>
<VStack gap={5}>
<FormLayout direction="horizontal">
<TextInput
label="Business name"
value={form.businessName}
onChange={(v) => set('businessName', v)}
isRequired
/>
<Selector
label="Legal entity structure"
value={form.legalEntity}
onChange={(v) => set('legalEntity', v)}
options={[
'Private Limited Company',
'Sole Proprietorship',
'Partnership Firm',
'Limited Liability Partnership (LLP)',
]}
/>
</FormLayout>
<FormLayout direction="horizontal">
<TextInput
label="GSTIN"
value={form.gstin}
onChange={(v) => set('gstin', v.toUpperCase())}
description="15-digit Tax Registration Number"
/>
<TextInput
label="PAN"
value={form.pan}
onChange={(v) => set('pan', v.toUpperCase())}
description="10-digit Permanent Account Number"
/>
</FormLayout>
<FormLayout direction="horizontal">
<TextInput
label="Support email"
value={form.email}
onChange={(v) => set('email', v)}
isRequired
/>
<TextInput
label="Support phone"
value={form.phone}
onChange={(v) => set('phone', v)}
/>
</FormLayout>
<Divider />
<FormLayout direction="horizontal">
<TextInput
label="Registered address"
value={form.address}
onChange={(v) => set('address', v)}
/>
<TextInput
label="City & State"
value={form.city}
onChange={(v) => set('city', v)}
/>
</FormLayout>
<FormLayout direction="horizontal">
<TextInput
label="Pincode"
value={form.pincode}
onChange={(v) => set('pincode', v)}
/>
<NumberInput
label="Default LYTs per ₹100 spent"
value={form.lytsPerHundred}
onChange={(v) => set('lytsPerHundred', v ?? 0)}
min={0}
max={100}
description="1 LYT = ₹1. Basket ₹1,000 earns 50 LYTs (5%)."
/>
</FormLayout>
</VStack>
</StaticPanel>
);
}

View File

@@ -0,0 +1,213 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Card} from '@astryxdesign/core/Card';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
export interface IntegrationApp {
id: string;
name: string;
category: string;
description: string;
status: 'connected' | 'disconnected' | 'syncing';
apiKeyLabel?: string;
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<IntegrationApp[]>(INITIAL_APPS);
const [selectedApp, setSelectedApp] = useState<IntegrationApp | null>(null);
const [apiKeyInput, setApiKeyInput] = useState('');
const toggleConnection = (app: IntegrationApp) => {
if (app.status === 'connected') {
setApps((prev) =>
prev.map((a) =>
a.id === app.id ? {...a, status: 'disconnected', lastSync: undefined} : a,
),
);
toast({body: `Disconnected ${app.name}`});
} else {
setSelectedApp(app);
setApiKeyInput('');
}
};
const handleConfirmConnect = () => {
if (!selectedApp) return;
setApps((prev) =>
prev.map((a) =>
a.id === selectedApp.id
? {
...a,
status: 'connected',
apiKeyLabel: apiKeyInput || 'Configured',
lastSync: 'Just now',
}
: a,
),
);
toast({body: `Successfully connected ${selectedApp.name}`});
setSelectedApp(null);
};
return (
<VStack gap={5}>
<StaticPanel
title="Third-Party Integrations & Connectors"
subtitle="Connect E-commerce storefronts, payment gateways, messaging services and advertising platforms."
>
<VStack gap={4}>
{selectedApp ? (
<Card variant="muted">
<VStack gap={3}>
<Heading level={3}>Connect {selectedApp.name}</Heading>
<Text size="sm" color="secondary">
{selectedApp.description}
</Text>
<TextInput
label="API Key / Store URL / Identifier"
value={apiKeyInput}
onChange={setApiKeyInput}
placeholder="Paste live secret key or domain URL"
/>
<HStack gap={2}>
<Button
variant="primary"
size="sm"
label="Establish Connection"
onClick={handleConfirmConnect}
/>
<Button
variant="secondary"
size="sm"
label="Cancel"
onClick={() => setSelectedApp(null)}
/>
</HStack>
</VStack>
</Card>
) : null}
<Grid columns={{minWidth: 320, max: 2, repeat: 'fit'}} gap={4}>
{apps.map((app) => {
const isConn = app.status === 'connected';
return (
<Card key={app.id}>
<VStack gap={3}>
<HStack hAlign="between" vAlign="start">
<VStack gap={0.5}>
<Text weight="medium" size="sm">
{app.name}
</Text>
<Text size="sm" color="secondary">
{app.category}
</Text>
</VStack>
<Badge
variant={isConn ? 'success' : 'neutral'}
label={isConn ? 'Connected' : 'Disconnected'}
/>
</HStack>
<Text size="sm" color="secondary">
{app.description}
</Text>
{isConn && app.apiKeyLabel ? (
<HStack hAlign="between" vAlign="center">
<Text size="sm" color="secondary">
{app.apiKeyLabel}
</Text>
{app.lastSync ? (
<Text size="sm" color="secondary">
Synced {app.lastSync}
</Text>
) : null}
</HStack>
) : null}
<Button
variant={isConn ? 'secondary' : 'primary'}
size="sm"
label={isConn ? 'Disconnect' : 'Connect & Configure'}
onClick={() => toggleConnection(app)}
/>
</VStack>
</Card>
);
})}
</Grid>
</VStack>
</StaticPanel>
</VStack>
);
}

View File

@@ -8,110 +8,147 @@ import {Text} from '@astryxdesign/core/Text';
import {Divider} from '@astryxdesign/core/Divider';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
import type {MerchantProfile} from '@/lib/api/contracts';
type NotificationKey = keyof MerchantProfile['notifications'];
/**
* Each toggle names the alert AND what triggers it. "Low conversion" alone is
* a setting a merchant has to guess at; "when a store drops below 15% for two
* days" is one they can decide about.
*/
const ALERTS: {
key: NotificationKey;
export interface EventNotification {
id: string;
label: string;
description: string;
}[] = [
email: boolean;
sms: boolean;
whatsapp: boolean;
push: boolean;
}
const DEFAULT_EVENTS: EventNotification[] = [
{
key: 'expiryAlerts',
label: 'Reward expiry',
description:
'When an active reward is within 3 days of lapsing, with the outstanding LYT liability.',
id: 'dailySummary',
label: 'Daily Performance Summary',
description: 'Daily morning dispatch with footfall, revenue and LYT redemptions.',
email: true,
sms: false,
whatsapp: true,
push: true,
},
{
key: 'dailySummary',
label: 'Daily summary',
description:
'Footfall, revenue and redemption for the previous day, each morning at 09:00.',
id: 'expiryAlerts',
label: 'Reward Expiry Warning',
description: 'Alert when active rewards are within 48 hours of expiration.',
email: true,
sms: true,
whatsapp: true,
push: true,
},
{
key: 'staffAbsence',
label: 'Staff absence',
description:
'When a store opens with fewer than half its rostered team present.',
id: 'staffAbsence',
label: 'Staff Absence & Late Check-in',
description: 'Instant alert when a scheduled shift starts without rostered staff.',
email: true,
sms: true,
whatsapp: false,
push: true,
},
{
key: 'lowConversion',
label: 'Low conversion',
description:
'When a store stays below 15% conversion for two consecutive days.',
id: 'lowConversion',
label: 'Low Conversion Drop Alert',
description: 'Triggered if a store conversion rate drops below 15% threshold.',
email: true,
sms: false,
whatsapp: true,
push: false,
},
{
id: 'settlementReceipt',
label: 'LYT Settlement Receipts',
description: 'Weekly payout summary and bank credit confirmations.',
email: true,
sms: true,
whatsapp: true,
push: false,
},
{
id: 'securityAlerts',
label: 'Security & New Login Alerts',
description: 'Immediate notification on unrecognized IP or new device login.',
email: true,
sms: true,
whatsapp: true,
push: true,
},
];
export function NotificationsForm({
initialData,
}: {
initialData: MerchantProfile['notifications'];
}) {
export function NotificationsForm() {
const toast = useToast();
const [value, setValue] = useState(initialData);
const [events, setEvents] = useState<EventNotification[]>(DEFAULT_EVENTS);
const [isSaving, setIsSaving] = useState(false);
const isDirty = JSON.stringify(value) !== JSON.stringify(initialData);
const toggleChannel = (
id: string,
channel: 'email' | 'sms' | 'whatsapp' | 'push',
) => {
setEvents((prev) =>
prev.map((e) => (e.id === id ? {...e, [channel]: !e[channel]} : e)),
);
};
async function save() {
const handleSave = () => {
setIsSaving(true);
try {
const res = await fetch('/api/settings/profile', {
method: 'PATCH',
headers: {'content-type': 'application/json'},
body: JSON.stringify({notifications: value}),
});
if (!res.ok) throw new Error(`Save failed (${res.status})`);
toast({body: 'Notification preferences saved'});
} catch (err) {
toast({type: 'error', body: (err as Error).message});
} finally {
setTimeout(() => {
setIsSaving(false);
}
}
toast({body: 'Multi-channel notification settings saved'});
}, 400);
};
return (
<StaticPanel
title="Alerts"
subtitle="What Loyaly tells you about, and when."
title="Notification Preferences & Channels"
subtitle="Configure delivery channels (Email, SMS, WhatsApp, Push) for business alerts."
actions={
<HStack gap={2} vAlign="center">
{isDirty ? (
<Text size="sm" color="secondary">
Unsaved changes
</Text>
) : null}
<Button
variant="primary"
size="sm"
label="Save"
isDisabled={!isDirty}
label="Save Preferences"
isLoading={isSaving}
onClick={save}
onClick={handleSave}
/>
</HStack>
}
>
<VStack gap={0}>
{ALERTS.map((a, i) => (
<VStack key={a.key} gap={0}>
<HStack paddingBlock={3}>
<VStack gap={4}>
{events.map((evt, idx) => (
<VStack key={evt.id} gap={3}>
<HStack hAlign="between" vAlign="center" wrap="wrap" gap={3}>
<VStack gap={0.5} width={380}>
<Text size="sm" weight="medium">
{evt.label}
</Text>
<Text size="sm" color="secondary">
{evt.description}
</Text>
</VStack>
<HStack gap={3} vAlign="center" wrap="wrap">
<Switch
label={a.label}
description={a.description}
value={value[a.key]}
onChange={(checked) =>
setValue((v) => ({...v, [a.key]: checked}))
}
label="Email"
value={evt.email}
onChange={() => toggleChannel(evt.id, 'email')}
/>
<Switch
label="SMS"
value={evt.sms}
onChange={() => toggleChannel(evt.id, 'sms')}
/>
<Switch
label="WhatsApp"
value={evt.whatsapp}
onChange={() => toggleChannel(evt.id, 'whatsapp')}
/>
<Switch
label="Push"
value={evt.push}
onChange={() => toggleChannel(evt.id, 'push')}
/>
</HStack>
{i === ALERTS.length - 1 ? null : <Divider />}
</HStack>
{idx === events.length - 1 ? null : <Divider />}
</VStack>
))}
</VStack>

View File

@@ -0,0 +1,145 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {FormLayout} from '@astryxdesign/core/FormLayout';
import {Selector} from '@astryxdesign/core/Selector';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
export interface SystemPreferences {
theme: 'dark' | 'light' | 'system';
currency: 'INR' | 'USD' | 'EUR' | 'GBP';
language: 'en' | 'hi' | 'kn' | 'ta';
dateFormat: 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD';
defaultLanding: '/dashboard' | '/stores' | '/lyts' | '/staff';
}
const DEFAULT_PREFS: SystemPreferences = {
theme: 'dark',
currency: 'INR',
language: 'en',
dateFormat: 'DD/MM/YYYY',
defaultLanding: '/dashboard',
};
export function PreferencesForm() {
const toast = useToast();
const [prefs, setPrefs] = useState<SystemPreferences>(DEFAULT_PREFS);
const [savedPrefs, setSavedPrefs] = useState<SystemPreferences>(DEFAULT_PREFS);
const [isSaving, setIsSaving] = useState(false);
const set = <K extends keyof SystemPreferences>(
key: K,
val: SystemPreferences[K],
) => setPrefs((p) => ({...p, [key]: val}));
const isDirty = JSON.stringify(prefs) !== JSON.stringify(savedPrefs);
const handleSave = () => {
setIsSaving(true);
setTimeout(() => {
setSavedPrefs(prefs);
setIsSaving(false);
toast({body: 'Workspace preferences updated'});
}, 400);
};
return (
<StaticPanel
title="Workspace Display & Formatting Preferences"
subtitle="Customize theme mode, reporting currency, language and default start screen."
actions={
<HStack gap={2} vAlign="center">
{isDirty ? (
<Text size="sm" color="secondary">
Unsaved changes
</Text>
) : null}
<Button
variant="secondary"
size="sm"
label="Reset"
isDisabled={!isDirty || isSaving}
onClick={() => setPrefs(savedPrefs)}
/>
<Button
variant="primary"
size="sm"
label="Save Preferences"
isDisabled={!isDirty}
isLoading={isSaving}
onClick={handleSave}
/>
</HStack>
}
>
<VStack gap={5}>
<FormLayout direction="horizontal">
<Selector
label="Theme Interface Mode"
value={prefs.theme}
onChange={(v) => set('theme', v as SystemPreferences['theme'])}
options={[
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
{label: 'Light Mode', value: 'light'},
{label: 'System OS Preference', value: 'system'},
]}
/>
<Selector
label="Reporting Base Currency"
value={prefs.currency}
onChange={(v) => set('currency', v as SystemPreferences['currency'])}
options={[
{label: 'INR (₹ Indian Rupee)', value: 'INR'},
{label: 'USD ($ US Dollar)', value: 'USD'},
{label: 'EUR (€ Euro)', value: 'EUR'},
{label: 'GBP (£ British Pound)', value: 'GBP'},
]}
/>
</FormLayout>
<FormLayout direction="horizontal">
<Selector
label="Workspace Language"
value={prefs.language}
onChange={(v) => set('language', v as SystemPreferences['language'])}
options={[
{label: 'English (US & India)', value: 'en'},
{label: 'Hindi (हिंदी)', value: 'hi'},
{label: 'Kannada (ಕನ್ನಡ)', value: 'kn'},
{label: 'Tamil (தமிழ்)', value: 'ta'},
]}
/>
<Selector
label="Date Formatting"
value={prefs.dateFormat}
onChange={(v) => set('dateFormat', v as SystemPreferences['dateFormat'])}
options={[
{label: 'DD/MM/YYYY (e.g. 05/08/2026)', value: 'DD/MM/YYYY'},
{label: 'MM/DD/YYYY (e.g. 08/05/2026)', value: 'MM/DD/YYYY'},
{label: 'YYYY-MM-DD (e.g. 2026-08-05)', value: 'YYYY-MM-DD'},
]}
/>
</FormLayout>
<FormLayout direction="horizontal">
<Selector
label="Default Home Landing Module"
value={prefs.defaultLanding}
onChange={(v) => set('defaultLanding', v as SystemPreferences['defaultLanding'])}
options={[
{label: 'Dashboard Overview', value: '/dashboard'},
{label: 'Store Locations', value: '/stores'},
{label: 'Lyts & Rewards Catalogue', value: '/lyts'},
{label: 'Staff Roster & Performance', value: '/staff'},
]}
/>
</FormLayout>
</VStack>
</StaticPanel>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
export interface PermissionRow extends Record<string, unknown> {
module: string;
description: string;
Owner: boolean;
Manager: boolean;
Cashier: boolean;
Marketing: boolean;
Support: boolean;
}
const INITIAL_MATRIX: PermissionRow[] = [
{
module: 'Dashboard & Analytics',
description: 'View sales timeseries, footfall, conversion trends and KPIs',
Owner: true,
Manager: true,
Cashier: false,
Marketing: true,
Support: false,
},
{
module: 'Store Management',
description: 'Add, edit, disable stores and change operating hours',
Owner: true,
Manager: true,
Cashier: false,
Marketing: false,
Support: false,
},
{
module: 'Staff Directory',
description: 'Manage staff rosters, assign stores, reset passwords',
Owner: true,
Manager: true,
Cashier: false,
Marketing: false,
Support: true,
},
{
module: 'Rewards & LYTs',
description: 'Create rewards, issue LYTs, adjust redemption rules',
Owner: true,
Manager: true,
Cashier: true,
Marketing: true,
Support: false,
},
{
module: 'Billing & Settlement',
description: 'View invoices, change payment methods, set bank account',
Owner: true,
Manager: false,
Cashier: false,
Marketing: false,
Support: false,
},
{
module: 'API Keys & Webhooks',
description: 'Generate developer keys and manage webhook event triggers',
Owner: true,
Manager: false,
Cashier: false,
Marketing: false,
Support: false,
},
{
module: 'Security & Audit Logs',
description: 'Revoke active sessions, 2FA settings and login history',
Owner: true,
Manager: true,
Cashier: false,
Marketing: false,
Support: false,
},
];
export function RoleMatrix() {
const toast = useToast();
const [matrix, setMatrix] = useState<PermissionRow[]>(INITIAL_MATRIX);
const [isSaving, setIsSaving] = useState(false);
const togglePermission = (
moduleName: string,
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support',
) => {
if (role === 'Owner') return; // Owner permissions locked
setMatrix((prev) =>
prev.map((item) =>
item.module === moduleName ? {...item, [role]: !item[role]} : item,
),
);
};
const handleSave = () => {
setIsSaving(true);
setTimeout(() => {
setIsSaving(false);
toast({body: 'Role permission matrix updated successfully'});
}, 400);
};
const renderCheck = (
row: PermissionRow,
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support',
) => {
const isGranted = row[role];
return (
<Button
size="sm"
variant={isGranted ? 'primary' : 'secondary'}
isDisabled={role === 'Owner'}
label={isGranted ? 'Access Granted' : 'No Access'}
onClick={() => togglePermission(row.module, role)}
/>
);
};
const columns: TableColumn<PermissionRow>[] = [
{
key: 'module',
header: 'Module / Feature Area',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.module}
</Text>
<Text size="sm" color="secondary">
{row.description}
</Text>
</VStack>
),
},
{
key: 'Owner',
header: 'Owner',
width: pixel(120),
renderCell: (row) => renderCheck(row, 'Owner'),
},
{
key: 'Manager',
header: 'Manager',
width: pixel(120),
renderCell: (row) => renderCheck(row, 'Manager'),
},
{
key: 'Cashier',
header: 'Cashier',
width: pixel(120),
renderCell: (row) => renderCheck(row, 'Cashier'),
},
{
key: 'Marketing',
header: 'Marketing',
width: pixel(120),
renderCell: (row) => renderCheck(row, 'Marketing'),
},
{
key: 'Support',
header: 'Support',
width: pixel(120),
renderCell: (row) => renderCheck(row, 'Support'),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Role Access Control Matrix"
subtitle="Configure granular module access levels for Owner, Manager, Cashier, Marketing and Support roles."
actions={
<Button
variant="primary"
size="sm"
label="Save Permissions"
isLoading={isSaving}
onClick={handleSave}
/>
}
>
<VStack gap={4}>
<HStack gap={2} wrap="wrap">
<Badge variant="info" label="Owner (Full Root Access)" />
<Badge variant="neutral" label="Manager (Operational Controls)" />
<Badge variant="neutral" label="Cashier (POS & Rewards)" />
<Badge variant="neutral" label="Marketing (Campaigns & Analytics)" />
<Badge variant="neutral" label="Support (Staff Roster View)" />
</HStack>
<ResponsiveTable
columns={columns}
data={matrix}
primaryKey="module"
/>
</VStack>
</StaticPanel>
</VStack>
);
}

View File

@@ -0,0 +1,303 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {Switch} from '@astryxdesign/core/Switch';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
export interface ActiveSessionItem extends Record<string, unknown> {
id: string;
device: string;
location: string;
ip: string;
lastActive: string;
isCurrent: boolean;
}
export interface LoginAuditItem extends Record<string, unknown> {
id: string;
user: string;
ip: string;
device: string;
status: 'success' | 'failed';
timestamp: string;
}
const INITIAL_SESSIONS: ActiveSessionItem[] = [
{
id: 'sess-1',
device: 'macOS Chrome 126',
location: 'Bengaluru, India',
ip: '103.15.24.81',
lastActive: 'Active Now',
isCurrent: true,
},
{
id: 'sess-2',
device: 'iOS Safari 17',
location: 'Bengaluru, India',
ip: '49.207.210.12',
lastActive: '2 hours ago',
isCurrent: false,
},
{
id: 'sess-3',
device: 'Windows Edge 125',
location: 'Bengaluru, India',
ip: '106.51.72.19',
lastActive: 'Yesterday, 18:40',
isCurrent: false,
},
];
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);
const [sessions, setSessions] = useState<ActiveSessionItem[]>(INITIAL_SESSIONS);
const [currentPass, setCurrentPass] = useState('');
const [newPass, setNewPass] = useState('');
const [confirmPass, setConfirmPass] = useState('');
const [isChangingPass, setIsChangingPass] = useState(false);
const handleChangePassword = () => {
if (!currentPass || !newPass) {
toast({type: 'error', body: 'Please fill in all password fields'});
return;
}
if (newPass !== confirmPass) {
toast({type: 'error', body: 'New passwords do not match'});
return;
}
setIsChangingPass(true);
setTimeout(() => {
setIsChangingPass(false);
setCurrentPass('');
setNewPass('');
setConfirmPass('');
toast({body: 'Password updated successfully'});
}, 400);
};
const handleRevokeSession = (id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id));
toast({body: 'Session revoked successfully'});
};
const handleRevokeAllOther = () => {
setSessions((prev) => prev.filter((s) => s.isCurrent));
toast({body: 'All other active sessions have been terminated'});
};
const sessionColumns: TableColumn<ActiveSessionItem>[] = [
{
key: 'device',
header: 'Device / Browser',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{row.device}
</Text>
{row.isCurrent ? <Badge variant="success" label="Current Session" /> : null}
</HStack>
<Text size="sm" color="secondary">
{row.location} {row.ip}
</Text>
</VStack>
),
},
{
key: 'lastActive',
header: 'Last Active',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.lastActive}
</Text>
),
},
{
key: 'actions',
header: 'Action',
width: pixel(110),
renderCell: (row) =>
row.isCurrent ? null : (
<Button
size="sm"
variant="ghost"
label="Revoke"
onClick={() => handleRevokeSession(row.id)}
/>
),
},
];
const auditColumns: TableColumn<LoginAuditItem>[] = [
{
key: 'user',
header: 'Account User',
width: proportional(1.5),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.user}
</Text>
),
},
{
key: 'device',
header: 'Device & IP',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm">{row.device}</Text>
<Text size="sm" color="secondary">
{row.ip}
</Text>
</VStack>
),
},
{
key: 'status',
header: 'Result',
width: pixel(110),
renderCell: (row) => (
<Badge
variant={row.status === 'success' ? 'success' : 'error'}
label={row.status === 'success' ? 'Success' : 'Failed'}
/>
),
},
{
key: 'timestamp',
header: 'Timestamp',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.timestamp}
</Text>
),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Two-Factor Authentication (2FA)"
subtitle="Require an authenticator app code (TOTP) or SMS OTP during sign-in."
actions={<Badge variant={twoFactor ? 'success' : 'warning'} label={twoFactor ? '2FA Enabled' : '2FA Disabled'} />}
>
<VStack gap={3}>
<Switch
label="Enable Two-Factor Authentication"
description="Protect your merchant account with an extra verification step on new devices."
value={twoFactor}
onChange={(v) => {
setTwoFactor(v);
toast({body: `2FA ${v ? 'enabled' : 'disabled'}`});
}}
/>
</VStack>
</StaticPanel>
<StaticPanel
title="Change Password"
subtitle="Ensure your password is at least 12 characters long with mixed case and numbers."
actions={
<Button
variant="primary"
size="sm"
label="Update Password"
isLoading={isChangingPass}
onClick={handleChangePassword}
/>
}
>
<VStack gap={4}>
<TextInput
label="Current Password"
type="password"
value={currentPass}
onChange={setCurrentPass}
/>
<HStack gap={4} wrap="wrap">
<TextInput
label="New Password"
type="password"
value={newPass}
onChange={setNewPass}
/>
<TextInput
label="Confirm New Password"
type="password"
value={confirmPass}
onChange={setConfirmPass}
/>
</HStack>
</VStack>
</StaticPanel>
<StaticPanel
title="Active Sessions"
subtitle="Devices currently logged into your Loyaly merchant workspace."
actions={
<Button
variant="secondary"
size="sm"
label="Revoke Other Sessions"
onClick={handleRevokeAllOther}
/>
}
>
<ResponsiveTable
columns={sessionColumns}
data={sessions}
primaryKey="device"
/>
</StaticPanel>
<StaticPanel title="Login Audit Trail" subtitle="Recent account authentication events and security attempts.">
<ResponsiveTable
columns={auditColumns}
data={AUDIT_LOGS}
primaryKey="user"
/>
</StaticPanel>
</VStack>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/components/primitives/PageHeader';
/**
* The container every Settings screen sits in.
*
* WHY THIS EXISTS
* The Settings layout nests its own `Layout` inside the workspace shell, and
* that nested `LayoutContent` was set to `padding={0}` so the sub-nav panel
* could sit flush. The side effect was that it also cancelled the workspace's
* own 20px gutter, so every Settings page rendered hard against the sub-nav on
* the left and the Copilot divider on the right, with the title flush to the
* top of the viewport. Eleven pages each fixing that themselves is how the
* spacing drifts, so it is fixed once, here.
*
* THE SPACING CONTRACT
* Horizontal 32px (spacing-8) — clears the sub-nav and the Copilot rail
* Top 32px (spacing-8) — the title never touches the header
* Bottom 48px (pb-12) — the last card is never flush to the fold
* Header → body 32px (spacing-8) — title block reads as its own layer
* Between blocks 24px (spacing-6) — cards, tables and toolbars
*
* The bottom padding goes through the Tailwind bridge rather than a prop
* because Astryx's `SpacingStep` union stops at 10 (40px) and there is no
* `paddingBlockEnd` on Stack. `pb-12` resolves to `--spacing-12` (48px), so it
* is still a token — no raw pixel value enters the codebase. It lands in the
* `utilities` layer, which the cascade puts after `astryx-base`, so it wins
* over the `paddingBlock` set below without `!important`.
*
* Anything that needs a different rhythm should take a prop here rather than
* override locally — that is the whole point of a single container.
*/
export function SettingsPage({
title,
description,
actions,
toolbar,
children,
}: {
title: string;
description?: string;
/** Page-level verbs — Save, Add store, Rotate key. */
actions?: React.ReactNode;
/** Filters or segmented controls that scope this page's content. */
toolbar?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<VStack
as="section"
paddingInline={8}
paddingBlock={8}
gap={8}
className="pb-12"
width="100%"
>
<PageHeader
title={title}
description={description}
actions={actions}
/>
{/* 24px between the body's own blocks — tighter than the 32px that
separates the header from the body, so the page reads as
"title, then a group of related panels" rather than a flat list. */}
<VStack gap={6} width="100%">
{toolbar}
{children}
</VStack>
</VStack>
);
}

View File

@@ -0,0 +1,276 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Selector} from '@astryxdesign/core/Selector';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
export interface StoreItem extends Record<string, unknown> {
id: string;
name: string;
location: string;
manager: string;
staffCount: number;
hours: string;
status: 'active' | 'maintenance' | 'disabled';
}
const INITIAL_STORES: StoreItem[] = [
{
id: 'blr-indiranagar',
name: 'Indiranagar Flagship',
location: '100 Feet Road, Indiranagar, Bengaluru',
manager: 'Vikram Seth',
staffCount: 14,
hours: '08:00 23:00',
status: 'active',
},
{
id: 'blr-koramangala',
name: 'Koramangala 80ft',
location: '80 Feet Road, Koramangala 4th Block, Bengaluru',
manager: 'Priya Sharma',
staffCount: 9,
hours: '09:00 22:30',
status: 'active',
},
{
id: 'blr-whitefield',
name: 'Whitefield Main',
location: 'ITPL Main Road, Whitefield, Bengaluru',
manager: 'Deepa Nair',
staffCount: 6,
hours: '10:00 22:00',
status: 'maintenance',
},
{
id: 'blr-jayanagar',
name: 'Jayanagar 4th Block',
location: '11th Main Rd, Jayanagar, Bengaluru',
manager: 'Suresh T',
staffCount: 8,
hours: '09:00 21:30',
status: 'active',
},
{
id: 'blr-mgroad',
name: 'MG Road Express',
location: 'Church Street, Off MG Road, Bengaluru',
manager: 'Unassigned',
staffCount: 0,
hours: '10:00 21:00',
status: 'disabled',
},
];
export function StoreManagement() {
const toast = useToast();
const [stores, setStores] = useState<StoreItem[]>(INITIAL_STORES);
const [isAdding, setIsAdding] = useState(false);
const [name, setName] = useState('');
const [location, setLocation] = useState('');
const [manager, setManager] = useState('Vikram Seth');
const [hours, setHours] = useState('09:00 22:00');
const handleAddStore = () => {
if (!name.trim()) {
toast({type: 'error', body: 'Store name is required'});
return;
}
const newStore: StoreItem = {
id: `blr-${name.toLowerCase().replace(/\s+/g, '')}`,
name,
location: location || 'Bengaluru, Karnataka',
manager,
staffCount: 1,
hours,
status: 'active',
};
setStores((prev) => [newStore, ...prev]);
setName('');
setLocation('');
setIsAdding(false);
toast({body: `Store "${name}" added successfully`});
};
const handleToggleStatus = (id: string) => {
setStores((prev) =>
prev.map((s) => {
if (s.id !== id) return s;
const next = s.status === 'active' ? 'disabled' : 'active';
toast({body: `${s.name} status updated to ${next}`});
return {...s, status: next};
}),
);
};
const handleRemove = (id: string, storeName: string) => {
setStores((prev) => prev.filter((s) => s.id !== id));
toast({body: `Store "${storeName}" removed`});
};
const columns: TableColumn<StoreItem>[] = [
{
key: 'name',
header: 'Store Location',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="sm" color="secondary">
{row.location}
</Text>
</VStack>
),
},
{
key: 'manager',
header: 'Store Manager',
width: proportional(1.2),
renderCell: (row) => <Text size="sm">{row.manager}</Text>,
},
{
key: 'hours',
header: 'Operating Hours',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.hours}
</Text>
),
},
{
key: 'staffCount',
header: 'Staff Count',
width: pixel(100),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.staffCount} staff
</Text>
),
},
{
key: 'status',
header: 'Status',
width: pixel(120),
renderCell: (row) => {
const variant =
row.status === 'active'
? 'success'
: row.status === 'maintenance'
? 'warning'
: 'error';
return <Badge variant={variant} label={row.status} />;
},
},
{
key: 'actions',
header: 'Actions',
width: pixel(150),
renderCell: (row) => (
<HStack paddingInline={3}>
<DropdownMenu
button={{
variant: 'secondary',
size: 'sm',
label: 'Manage',
icon: <Icon icon="moreHorizontal" size="sm" />,
}}
menuWidth={180}
items={[
{
label: row.status === 'active' ? 'Disable store' : 'Enable store',
onClick: () => handleToggleStatus(row.id),
},
{type: 'divider'},
{
label: 'Remove store',
onClick: () => handleRemove(row.id, row.name),
},
]}
/>
</HStack>
),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Store Locations & Outlets"
subtitle="Manage active branches, operating hours, assigned managers and status."
actions={
<Button
variant="primary"
size="sm"
label={isAdding ? 'Cancel' : 'Add Store Location'}
onClick={() => setIsAdding(!isAdding)}
/>
}
>
<VStack gap={4}>
{isAdding ? (
<VStack gap={3} padding={4}>
<Heading level={3}>Add New Branch Location</Heading>
<HStack gap={3} vAlign="end" wrap="wrap">
<TextInput
label="Store Name"
value={name}
onChange={setName}
placeholder="e.g. HSR Layout 27th Main"
/>
<TextInput
label="Address / Landmark"
value={location}
onChange={setLocation}
placeholder="HSR Layout, Bengaluru"
/>
<Selector
label="Store Manager"
value={manager}
onChange={(v) => setManager(v)}
options={[
'Vikram Seth',
'Priya Sharma',
'Deepa Nair',
'Unassigned',
]}
/>
<TextInput
label="Hours"
value={hours}
onChange={setHours}
placeholder="09:00 22:00"
/>
<Button
variant="primary"
size="sm"
label="Create Store"
onClick={handleAddStore}
/>
</HStack>
</VStack>
) : null}
<ResponsiveTable
columns={columns}
data={stores}
primaryKey="name"
/>
</VStack>
</StaticPanel>
</VStack>
);
}

View File

@@ -0,0 +1,297 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {Icon} from '@astryxdesign/core/Icon';
import {Avatar} from '@astryxdesign/core/Avatar';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Selector} from '@astryxdesign/core/Selector';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
export interface StaffUser extends Record<string, unknown> {
id: string;
name: string;
email: string;
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support';
storeName: string;
status: 'active' | 'suspended' | 'invited';
lastLogin: string;
}
const INITIAL_STAFF: StaffUser[] = [
{
id: 'usr-1',
name: 'Aravind',
email: 'aravind@nearle.in',
role: 'Owner',
storeName: 'All stores',
status: 'active',
lastLogin: 'Just now',
},
{
id: 'usr-2',
name: 'Vikram Seth',
email: 'vikram@nearle.in',
role: 'Manager',
storeName: 'Indiranagar Flagship',
status: 'active',
lastLogin: 'Today, 14:20',
},
{
id: 'usr-3',
name: 'Priya Sharma',
email: 'priya@nearle.in',
role: 'Cashier',
storeName: 'Koramangala 80ft',
status: 'active',
lastLogin: 'Today, 09:15',
},
{
id: 'usr-4',
name: 'Rahul Verma',
email: 'rahul@nearle.in',
role: 'Marketing',
storeName: 'All stores',
status: 'invited',
lastLogin: 'Pending accept',
},
{
id: 'usr-5',
name: 'Deepa Nair',
email: 'deepa@nearle.in',
role: 'Cashier',
storeName: 'Whitefield Main',
status: 'suspended',
lastLogin: '3 days ago',
},
];
export function TeamManagement() {
const toast = useToast();
const [staffList, setStaffList] = useState<StaffUser[]>(INITIAL_STAFF);
const [isAdding, setIsAdding] = useState(false);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [role, setRole] = useState<'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support'>('Manager');
const [storeName, setStoreName] = useState('Indiranagar Flagship');
const handleAddStaff = () => {
if (!name.trim() || !email.trim()) {
toast({type: 'error', body: 'Name and Email are required'});
return;
}
const newUser: StaffUser = {
id: `usr-${Date.now()}`,
name,
email,
role,
storeName,
status: 'invited',
lastLogin: 'Pending accept',
};
setStaffList((prev) => [newUser, ...prev]);
setName('');
setEmail('');
setIsAdding(false);
toast({body: `Invitation sent to ${email}`});
};
const handleToggleStatus = (id: string) => {
setStaffList((prev) =>
prev.map((s) => {
if (s.id !== id) return s;
const nextStatus = s.status === 'suspended' ? 'active' : 'suspended';
toast({body: `${s.name} status updated to ${nextStatus}`});
return {...s, status: nextStatus};
}),
);
};
const handleResetPassword = (emailStr: string) => {
toast({body: `Password reset link sent to ${emailStr}`});
};
const handleRemove = (id: string, nameStr: string) => {
setStaffList((prev) => prev.filter((s) => s.id !== id));
toast({body: `${nameStr} removed from team`});
};
const columns: TableColumn<StaffUser>[] = [
{
key: 'name',
header: 'Staff Member',
width: proportional(2),
renderCell: (row) => (
<HStack gap={3} vAlign="center">
<Avatar name={row.name} size="sm" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="sm" color="secondary">
{row.email}
</Text>
</VStack>
</HStack>
),
},
{
key: 'role',
header: 'Role',
width: proportional(1),
renderCell: (row) => (
<Badge
variant={row.role === 'Owner' ? 'info' : 'neutral'}
label={row.role}
/>
),
},
{
key: 'storeName',
header: 'Assigned Store',
width: proportional(1.5),
renderCell: (row) => <Text size="sm">{row.storeName}</Text>,
},
{
key: 'status',
header: 'Status',
width: pixel(110),
renderCell: (row) => {
const v =
row.status === 'active'
? 'success'
: row.status === 'suspended'
? 'error'
: 'warning';
return <Badge variant={v} label={row.status} />;
},
},
{
key: 'lastLogin',
header: 'Last Login',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.lastLogin}
</Text>
),
},
{
key: 'actions',
header: 'Actions',
width: pixel(150),
renderCell: (row) =>
row.role === 'Owner' ? (
<Text size="sm" color="secondary">
Primary Owner
</Text>
) : (
<HStack paddingInline={3}>
<DropdownMenu
button={{
variant: 'secondary',
size: 'sm',
label: 'Manage',
icon: <Icon icon="moreHorizontal" size="sm" />,
}}
menuWidth={180}
items={[
{
label: row.status === 'suspended' ? 'Reactivate staff' : 'Suspend staff',
onClick: () => handleToggleStatus(row.id),
},
{
label: 'Reset password',
onClick: () => handleResetPassword(row.email),
},
{type: 'divider'},
{
label: 'Remove from team',
onClick: () => handleRemove(row.id, row.name),
},
]}
/>
</HStack>
),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Team Directory & Staff Access"
subtitle="Manage store assignments, role permissions, password resets and account statuses."
actions={
<Button
variant="primary"
size="sm"
label={isAdding ? 'Cancel' : 'Add Staff Member'}
onClick={() => setIsAdding(!isAdding)}
/>
}
>
<VStack gap={4}>
{isAdding ? (
<VStack gap={3} padding={4}>
<Heading level={3}>Invite New Staff Member</Heading>
<HStack gap={3} vAlign="end" wrap="wrap">
<TextInput
label="Full Name"
value={name}
onChange={setName}
placeholder="e.g. Ananya Rao"
/>
<TextInput
label="Work Email"
value={email}
onChange={setEmail}
placeholder="ananya@nearle.in"
/>
<Selector
label="Role"
value={role}
onChange={(v) =>
setRole(v as 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support')
}
options={['Manager', 'Cashier', 'Marketing', 'Support']}
/>
<Selector
label="Assigned Store"
value={storeName}
onChange={(v) => setStoreName(v)}
options={[
'All stores',
'Indiranagar Flagship',
'Koramangala 80ft',
'Whitefield Main',
'Jayanagar 4th Block',
]}
/>
<Button
variant="primary"
size="sm"
label="Send Invite"
onClick={handleAddStaff}
/>
</HStack>
</VStack>
) : null}
<ResponsiveTable
columns={columns}
data={staffList}
primaryKey="name"
/>
</VStack>
</StaticPanel>
</VStack>
);
}

View File

@@ -17,22 +17,70 @@ export interface SettingsSection {
*/
export const SETTINGS_NAV: SettingsSection[] = [
{
label: 'Profile',
label: 'Business',
href: '/settings',
icon: ICONS.business,
description: 'Company details, tax IDs, registered address and support.',
},
{
label: 'Profile',
href: '/settings/profile',
icon: ICONS.profile,
description: 'Business details, contact and tax registration.',
description: 'Merchant contact details, timezone and personal info.',
},
{
label: 'Team & Staff',
href: '/settings/team',
icon: ICONS.staff,
description: 'Staff directory, store assignments, roles and status.',
},
{
label: 'Stores',
href: '/settings/stores',
icon: ICONS.stores,
description: 'Manage store locations, operating hours and managers.',
},
{
label: 'Roles & Permissions',
href: '/settings/roles',
icon: ICONS.roles,
description: 'Access levels, module permissions and role matrices.',
},
{
label: 'Notifications',
href: '/settings/notifications',
icon: ICONS.notifications,
description: 'What Loyaly alerts you about, and when.',
description: 'Email, SMS, WhatsApp and Push alert channels.',
},
{
label: 'Billing',
href: '/settings/billing',
icon: ICONS.revenue,
description: 'Plan, invoices and LYT settlement.',
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',
icon: ICONS.security,
description: '2FA authentication, active sessions and audit history.',
},
{
label: 'Preferences',
href: '/settings/preferences',
icon: ICONS.preferences,
description: 'System theme, default currency, language and formats.',
},
];

View File

@@ -1,6 +1,7 @@
'use client';
import {Table, proportional, pixel} from '@astryxdesign/core/Table';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/components/patterns/ResponsiveTable';
import type {TableColumn} from '@astryxdesign/core/Table';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Badge} from '@astryxdesign/core/Badge';
@@ -164,12 +165,12 @@ export function Leaderboard({
}));
return (
<Table
<ResponsiveTable
data={rows}
columns={columns}
idKey="id"
primaryKey="name"
density="compact"
hasHover
/>
);
}}

View File

@@ -59,6 +59,7 @@ export async function fetchEndpoint<T>(
import type {
ActivityEvent,
AttendancePoint,
DashboardBriefing,
Granularity,
HourCell,
Kpi,
@@ -74,6 +75,8 @@ import type {
export const endpoints = {
dashboardKpis: (s: Scope) => endpoint<Kpi[]>('/api/dashboard/kpis', s),
dashboardBriefing: (s: Scope) =>
endpoint<DashboardBriefing>('/api/dashboard/briefing', s),
dashboardTimeseries: (s: Scope) =>
endpoint<TimePoint[]>('/api/dashboard/timeseries', s),
dashboardPeakHours: (s: Scope) =>

View File

@@ -7,7 +7,7 @@
* already the contract, not a description of one.
*/
export type RangeKey = '7d' | '30d' | '90d' | 'mtd' | 'ytd';
export type RangeKey = '7d' | '30d' | '90d' | 'mtd' | 'ytd' | 'custom';
export interface ApiMeta {
generatedAt: string;
@@ -216,3 +216,31 @@ export interface Insight {
body: string;
action?: {label: string; href: string};
}
/** One item on the day's operational checklist. */
export interface DashboardTask {
id: string;
label: string;
detail?: string;
/** Plain-language deadline — "before 18:00", "today". Never a raw date. */
due: string;
isDone: boolean;
action?: {label: string; href: string};
}
/**
* The operational half of the dashboard, in one payload.
*
* Summary, alerts and tasks ship together because they are three renderings of
* the same reasoning pass: the narrative states what happened, the alerts say
* which parts of it need a decision, and the tasks are what that decision
* costs. Splitting them across three endpoints would let a merchant read a
* summary generated from one snapshot beside alerts generated from another.
*/
export interface DashboardBriefing {
/** One-paragraph narrative of the selected scope and period. */
summary: string;
/** Most severe first. */
alerts: Insight[];
tasks: DashboardTask[];
}

View File

@@ -3,19 +3,45 @@
import {useSyncExternalStore} from 'react';
/**
* Shell breakpoints. These are layout decisions, not content decisions —
* they choose whether the Copilot is an inline panel or a slide-over, which
* is a React-tree difference CSS alone cannot express.
* Shell breakpoints.
*
* Anything that CAN be done in CSS should be: use Grid/Stack responsive props
* or Tailwind breakpoints instead of reaching for this hook.
* These are LAYOUT decisions — they choose whether the Copilot is an inline
* panel or a sheet, whether a table renders as rows or cards. Those are React
* tree differences that CSS cannot express.
*
* Anything that CAN be done in CSS should be: use Grid's responsive `columns`
* or a Tailwind breakpoint instead of reaching for this hook. Every consumer
* here costs a client-side re-render on resize.
*/
export type Breakpoint = 'mobile' | 'tablet' | 'laptop' | 'desktop';
export type Breakpoint =
| 'mobile'
| 'tablet'
| 'laptop'
| 'desktop'
| 'ultrawide';
/**
* Ordered widest-first — `read()` returns the first match.
*
* mobile <640 phones
* tablet 6401024
* laptop 10241440
* desktop 14401920
* ultrawide >1920 content gets capped rather than stretched
*/
export const BREAKPOINT_MIN = {
ultrawide: 1920,
desktop: 1440,
laptop: 1024,
tablet: 640,
mobile: 0,
} as const;
const QUERIES: [Breakpoint, string][] = [
['desktop', '(min-width: 1440px)'],
['laptop', '(min-width: 1200px)'],
['tablet', '(min-width: 768px)'],
['ultrawide', `(min-width: ${BREAKPOINT_MIN.ultrawide}px)`],
['desktop', `(min-width: ${BREAKPOINT_MIN.desktop}px)`],
['laptop', `(min-width: ${BREAKPOINT_MIN.laptop}px)`],
['tablet', `(min-width: ${BREAKPOINT_MIN.tablet}px)`],
];
function read(): Breakpoint {
@@ -34,20 +60,66 @@ function subscribe(onChange: () => void) {
/**
* SSR resolves to 'desktop'. The shell is flex-based, so when a narrow client
* corrects on the first frame the only visible change is the Copilot panel
* dropping out — no reflow of the content column. If that ever reads as a
* flash, seed AppShell's `mobileNav.defaultIsMobile` from the UA header in the
* root server layout; the prop exists for exactly this.
* dropping out — no reflow of the content column.
*/
export function useBreakpoint(): Breakpoint {
return useSyncExternalStore(subscribe, read, () => 'desktop');
}
/** True when the Copilot should render inline rather than as a slide-over. */
export function isPanelInline(bp: Breakpoint): boolean {
return bp === 'laptop' || bp === 'desktop';
/**
* True when the side nav renders as an inline rail. Below this AppShell moves
* it into the drawer, which is also the point where the workspace shell has to
* put the top bar back at the shell root — see (workspace)/layout.tsx.
*
* Must stay in sync with AppShell's own `mobileNav.breakpoint`, which is
* 'sm' (640px) — the same line as the mobile/tablet boundary.
*/
export function isSideNavInline(bp: Breakpoint): boolean {
return bp !== 'mobile';
}
/** Copilot panel width per breakpoint. Laptop trades panel width for workspace. */
export function copilotWidth(bp: Breakpoint): number {
return bp === 'desktop' ? 380 : 320;
/**
* True when the Copilot renders as a third column.
*
* Tablet is excluded deliberately: at 6401024 a 320px panel leaves under
* 400px of workspace once the rail is out, which is narrower than a single
* chart needs. There it becomes a sheet, same as on mobile.
*/
export function isPanelInline(bp: Breakpoint): boolean {
return bp === 'laptop' || bp === 'desktop' || bp === 'ultrawide';
}
/** Copilot panel width. Laptop trades panel width for workspace. */
export function copilotWidth(bp: Breakpoint): number {
if (bp === 'ultrawide') return 420;
if (bp === 'desktop') return 380;
return 320;
}
/**
* Columns for a four-up metric row.
*
* Four KPIs must lay out 4-up, 2×2, or stacked — never 3+1, which reads as a
* broken grid. Grid's `repeat: 'fit'` cannot express that: at a ~800px
* workspace it yields exactly three tracks whatever minWidth you choose, and
* the fourth card drops to an orphan row. So the count is computed.
*/
export function metricColumns(bp: Breakpoint, isPanelOpen: boolean): number {
if (bp === 'mobile') return 1;
if (bp === 'tablet') return 2;
if (bp === 'laptop') return 2;
// Desktop and ultrawide fit four unless the Copilot is taking 380420px.
return isPanelInline(bp) && isPanelOpen ? 2 : 4;
}
/**
* Max content width above `desktop`.
*
* On a 2560px monitor an uncapped workspace stretches a 30-day line chart
* across ~1900px, which flattens every trend it is supposed to show, and runs
* body text past the ~90ch where reading breaks down. Capping and centring
* costs nothing below the cap.
*/
export function contentMaxWidth(bp: Breakpoint): number | undefined {
return bp === 'ultrawide' ? 1600 : undefined;
}

View File

@@ -13,25 +13,35 @@
import {
BadgeCheck,
Bell,
Building2,
CalendarClock,
ChartLine,
ChevronsUpDown,
CircleAlert,
Clock,
Code,
Coins,
Columns3,
Footprints,
Gift,
IndianRupee,
LayoutDashboard,
Lock,
LogOut,
Menu,
MessageSquare,
Mic,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
Percent,
Plug,
Send,
Settings,
ShieldCheck,
ShoppingCart,
Sliders,
Sparkles,
Store,
TrendingDown,
@@ -52,6 +62,14 @@ export const ICONS = {
staff: Users,
settings: Settings,
// Settings Modules
business: Building2,
roles: ShieldCheck,
integrations: Plug,
api: Code,
security: Lock,
preferences: Sliders,
// Dashboard metrics
visitors: Footprints,
purchases: ShoppingCart,
@@ -60,6 +78,9 @@ export const ICONS = {
conversion: Percent,
lyt: Coins,
// Dashboard controls
compare: Columns3,
// Trend / delta
up: TrendingUp,
down: TrendingDown,
@@ -74,6 +95,14 @@ export const ICONS = {
panelOpen: PanelRightOpen,
// Shell
// One control drives the sidebar in all three states, so it needs all three
// glyphs: the hamburger below the drawer breakpoint, and the panel pair
// above it. PanelLeft*, not PanelRight* — those belong to the Copilot rail
// on the opposite edge, and pointing both controls the same way would make
// the header read as two buttons for one panel.
menu: Menu,
sidebarOpen: PanelLeftOpen,
sidebarClose: PanelLeftClose,
notifications: Bell,
profile: User,
signOut: LogOut,

249
src/lib/mock/briefing.ts Normal file
View File

@@ -0,0 +1,249 @@
import type {
DashboardBriefing,
DashboardTask,
Insight,
RangeKey,
} from '@/lib/api/contracts';
import {buildTimeseries} from './dashboard';
import {buildStoreComparison, buildRewardUsage} from './analytics';
import {buildStaffSummary} from './staff';
import {buildRewards} from './lyts';
import {storeName} from './stores';
/**
* The narrative half of the dashboard.
*
* Every sentence here is DERIVED from the same generators the charts read —
* buildTimeseries, buildStoreComparison, buildRewardUsage, buildStaffSummary,
* buildRewards. Nothing is invented. That is the whole point: a summary that
* says footfall rose while the footfall chart above it falls is worse than no
* summary at all, and independent fixtures are how that happens. When these
* become a real model call, the model gets fed the same aggregates.
*
* It also means the copy moves when the scope does. Switch to one store and
* the summary talks about that store; switch the range and the percentages
* follow, because they are recomputed from the series rather than templated.
*/
const RUPEES = new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
maximumFractionDigits: 0,
notation: 'compact',
});
const COUNT = new Intl.NumberFormat('en-IN', {
notation: 'compact',
maximumFractionDigits: 1,
});
const RANGE_WORD: Record<RangeKey, string> = {
'7d': 'the last 7 days',
'30d': 'the last 30 days',
'90d': 'the last 90 days',
mtd: 'the month so far',
ytd: 'the year so far',
// The generators receive a RangeKey, not the custom window's endpoints, so
// the narrative cannot name the dates yet. Reads correctly either way; give
// buildBriefing the start/end once the query carries them.
custom: 'the selected period',
};
/** Totals for a window, plus how it moved against the window before it. */
function windowTotals(range: RangeKey, storeId: string, endMs: number) {
const points = buildTimeseries(range, storeId, endMs);
const half = Math.floor(points.length / 2);
const sum = (xs: typeof points, k: 'visitors' | 'purchases' | 'revenue') =>
xs.reduce((a, p) => a + p[k], 0);
const recent = points.slice(half);
const prior = points.slice(0, half);
const visitors = sum(points, 'visitors');
const purchases = sum(points, 'purchases');
const revenue = sum(points, 'revenue');
// Compared half-to-half rather than against a separately generated previous
// period: the two halves come from one series, so the direction stated in
// prose is guaranteed to be the direction drawn on the chart.
const priorRevenue = sum(prior, 'revenue');
const revenueDeltaPct = priorRevenue
? ((sum(recent, 'revenue') - priorRevenue) / priorRevenue) * 100
: 0;
return {
visitors,
purchases,
revenue,
conversionPct: visitors ? (purchases / visitors) * 100 : 0,
revenueDeltaPct,
};
}
function buildAlerts(
storeId: string,
range: RangeKey,
endMs: number,
): Insight[] {
const alerts: Insight[] = [];
// 1. Rewards about to expire — the only thing on this dashboard with a
// hard deadline, so it outranks everything else.
const rewards = buildRewards(storeId, range, endMs);
const expiring = rewards.filter((r) => {
if (!r.expiresAt) return false;
const days = (Date.parse(r.expiresAt) - endMs) / 86400000;
return days > 0 && days <= 3;
});
if (expiring.length > 0) {
alerts.push({
id: 'alert-expiring',
severity: 'error',
title: `${expiring.length} reward${expiring.length > 1 ? 's' : ''} expiring within 72 hours`,
body: expiring.map((r) => r.name).join(', ') + '.',
action: {label: 'Review rewards', href: '/lyts'},
});
}
// 2. Staff absence, measured against the roster rather than a fixed count —
// two absent out of four is a different day from two out of twenty.
const staff = buildStaffSummary(storeId, range);
const missing = staff.absent + staff.onLeave;
if (staff.total > 0 && missing / staff.total >= 0.2) {
alerts.push({
id: 'alert-staffing',
severity: 'warning',
title: `${missing} of ${staff.total} staff are not on the floor`,
body: `${staff.absent} absent, ${staff.onLeave} on leave, ${staff.late} late. Cover may be short at peak.`,
action: {label: 'Open staff', href: '/staff'},
});
}
// 3. The weakest store, but only when there is a spread worth acting on.
if (storeId === 'all') {
const stores = buildStoreComparison(range, endMs);
if (stores.length > 1) {
const sorted = [...stores].sort((a, b) => a.revenueInr - b.revenueInr);
const worst = sorted[0];
const best = sorted[sorted.length - 1];
const gapPct = best.revenueInr
? ((best.revenueInr - worst.revenueInr) / best.revenueInr) * 100
: 0;
if (gapPct >= 25) {
alerts.push({
id: 'alert-store-gap',
severity: 'warning',
title: `${worst.name} is ${Math.round(gapPct)}% behind ${best.name}`,
body: `${RUPEES.format(worst.revenueInr)} against ${RUPEES.format(best.revenueInr)} over ${RANGE_WORD[range]}.`,
action: {label: 'Compare stores', href: '/stores'},
});
}
}
}
// 4. A reward people claim and never redeem is unspent liability sitting on
// the books — worth surfacing even though nothing is technically broken.
const usage = buildRewardUsage(storeId, range);
const dead = usage
.filter((r) => r.claimed >= 100 && r.usageRatePct < 30)
.sort((a, b) => a.usageRatePct - b.usageRatePct)[0];
if (dead) {
alerts.push({
id: 'alert-dead-reward',
severity: 'info',
title: `${dead.name} is claimed but not redeemed`,
body: `${COUNT.format(dead.claimed)} claims, ${Math.round(dead.usageRatePct)}% redeemed. The rest is outstanding liability.`,
action: {label: 'Open LYTs', href: '/lyts'},
});
}
return alerts;
}
function buildTasks(
storeId: string,
range: RangeKey,
endMs: number,
): DashboardTask[] {
const staff = buildStaffSummary(storeId, range);
const rewards = buildRewards(storeId, range, endMs);
const expiring = rewards.filter((r) => {
if (!r.expiresAt) return false;
const days = (Date.parse(r.expiresAt) - endMs) / 86400000;
return days > 0 && days <= 3;
});
const tasks: DashboardTask[] = [
{
id: 'task-roster',
label: 'Confirm the evening roster',
detail:
staff.late > 0
? `${staff.late} arrived late today`
: 'Peak footfall starts at 18:30',
due: 'before 17:00',
isDone: false,
action: {label: 'Staff', href: '/staff'},
},
{
id: 'task-float',
label: 'Reconcile the till float',
due: 'end of day',
isDone: false,
},
];
if (expiring.length > 0) {
tasks.unshift({
id: 'task-expiring',
label: `Decide on ${expiring.length} expiring reward${expiring.length > 1 ? 's' : ''}`,
detail: 'Extend, or let them lapse and clear the liability',
due: 'today',
isDone: false,
action: {label: 'Review', href: '/lyts'},
});
}
// Deterministic and already ticked, so the list reads as a real day in
// progress rather than an empty form.
tasks.push({
id: 'task-open',
label: 'Open-of-day checks',
detail: storeId === 'all' ? 'All stores reported' : storeName(storeId),
due: 'morning',
isDone: true,
});
return tasks;
}
export function buildBriefing(
storeId: string,
range: RangeKey,
endMs: number,
): DashboardBriefing {
const t = windowTotals(range, storeId, endMs);
const scope = storeId === 'all' ? 'across all stores' : `at ${storeName(storeId)}`;
const dir = t.revenueDeltaPct >= 0 ? 'up' : 'down';
const magnitude = Math.abs(Math.round(t.revenueDeltaPct));
const stores = storeId === 'all' ? buildStoreComparison(range, endMs) : [];
const leader = stores.length
? [...stores].sort((a, b) => b.revenueInr - a.revenueInr)[0]
: null;
const sentences = [
`Over ${RANGE_WORD[range]} ${scope}, ${COUNT.format(t.visitors)} visits turned into ${COUNT.format(t.purchases)} purchases — a ${t.conversionPct.toFixed(1)}% conversion rate — and ${RUPEES.format(t.revenue)} in revenue.`,
`Revenue in the back half of the period is ${dir} ${magnitude}% against the front half.`,
];
if (leader) {
sentences.push(
`${leader.name} is carrying the network at ${RUPEES.format(leader.revenueInr)}.`,
);
}
return {
summary: sentences.join(' '),
alerts: buildAlerts(storeId, range, endMs),
tasks: buildTasks(storeId, range, endMs),
};
}

View File

@@ -14,6 +14,10 @@ export const RANGE_DAYS: Record<RangeKey, number> = {
'90d': 90,
mtd: 21,
ytd: 120,
// Placeholder. Query only carries the RangeKey, so a custom window's real
// span is not visible to the generators — swap for (end - start) in days
// once parseQuery threads the dates through.
custom: 30,
};
/**
@@ -182,13 +186,28 @@ const ACTIVITY: {kind: ActivityEvent['kind']; title: string; detail: string}[] =
},
];
/**
* 48 events, not 12.
*
* The dashboard shows six of these and the Activity page shows the rest, so
* the fixture has to be long enough for "View all" to lead somewhere. Callers
* cap what they render — the endpoint returns the history.
*
* The gap is ACCUMULATED rather than drawn per index. `endMs - i * rng.int(…)`
* redraws the spacing on every row, so row 3 could land closer to now than
* row 2 and the feed arrived out of order — invisible at 12 rows on a panel
* with no ordering claim, obvious on a page headed "newest first".
*/
export function buildActivity(storeId: string, endMs: number): ActivityEvent[] {
const rng = createRng('activity', storeId);
return Array.from({length: 12}, (_, i) => {
let minutesAgo = 0;
return Array.from({length: 48}, (_, i) => {
const tpl = ACTIVITY[i % ACTIVITY.length];
if (i > 0) minutesAgo += rng.int(6, 40);
return {
id: `a${i}`,
at: new Date(endMs - i * rng.int(6, 40) * 60000).toISOString(),
at: new Date(endMs - minutesAgo * 60000).toISOString(),
kind: tpl.kind,
title: tpl.title,
detail: tpl.detail,

View File

@@ -115,11 +115,15 @@ export function buildLytActivity(
endMs: number,
): ActivityEvent[] {
const rng = createRng('lyt-activity', storeId);
return Array.from({length: 10}, (_, i) => {
// Accumulated, not redrawn per index — same ordering bug as buildActivity.
let minutesAgo = 0;
return Array.from({length: 24}, (_, i) => {
const tpl = LYT_ACTIVITY[i % LYT_ACTIVITY.length];
if (i > 0) minutesAgo += rng.int(8, 50);
return {
id: `l${i}`,
at: new Date(endMs - i * rng.int(8, 50) * 60000).toISOString(),
at: new Date(endMs - minutesAgo * 60000).toISOString(),
kind: tpl.kind,
title: tpl.title,
detail: tpl.detail,

View File

@@ -12,8 +12,8 @@ import type {MerchantProfile} from '@/lib/api/contracts';
export function readProfile(): MerchantProfile {
return {
businessName: 'Loyaly Retail Pvt Ltd',
contactName: 'Suriya N',
email: 'suriya@nearle.in',
contactName: 'Aravind',
email: 'aravind@nearle.in',
phone: '+91 98450 12345',
gstin: '29AABCL1234M1Z7',
timezone: 'Asia/Kolkata',

View File

@@ -0,0 +1,86 @@
'use client';
import {useCallback, useSyncExternalStore} from 'react';
/**
* A boolean persisted in localStorage, read without a hydration mismatch.
*
* useSyncExternalStore already solves the hydration problem on its own:
* React uses `getServerSnapshot` for the SSR render AND for the first client
* render, so the two markups match, then immediately re-reads `getSnapshot`
* and re-renders with the stored value. No `isHydrated` flag is needed — and
* adding one means a setState inside an effect, which React's lint rule
* rejects and which costs an extra render pass to do what the hook already
* does natively.
*
* Writes notify every subscriber, so two components reading the same key never
* disagree, and the `storage` event keeps other tabs in step.
*/
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((l) => l());
}
function subscribe(onChange: () => void) {
listeners.add(onChange);
window.addEventListener('storage', onChange);
return () => {
listeners.delete(onChange);
window.removeEventListener('storage', onChange);
};
}
/**
* The same store, but "never set" stays distinguishable from "set to false".
*
* The sidebar needs that distinction: its default is per-device (a tablet
* starts collapsed, a desktop starts expanded), so a stored `false` has to
* mean "this merchant expanded it" and not "no preference yet". Collapsing
* the two would either strand tablets expanded or force desktops to re-expand
* on every first visit.
*/
export function usePersistentTriState(
key: string,
): [boolean | null, (next: boolean) => void] {
const getSnapshot = useCallback(() => {
try {
const raw = window.localStorage.getItem(key);
return raw === null ? null : raw === 'true';
} catch {
// Private mode / storage disabled — degrade to "no preference" rather
// than taking the whole shell down over a preference.
return null;
}
}, [key]);
const getServerSnapshot = useCallback(() => null, []);
const value = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
const set = useCallback(
(next: boolean) => {
try {
window.localStorage.setItem(key, String(next));
} catch {
/* ignore — the notify below still updates this session */
}
emit();
},
[key],
);
return [value, set];
}
export function usePersistentFlag(
key: string,
fallback = false,
): [boolean, (next: boolean) => void] {
const [stored, set] = usePersistentTriState(key);
return [stored ?? fallback, set];
}

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-05T07:29:49.937Z
* Generated: 2026-08-05T11:18:10.756Z
*/
@layer reset {
@@ -528,7 +528,7 @@
.astryx-card {
border-color: var(--color-border);
--astryx-card-padding: var(--spacing-3);
--astryx-card-padding: var(--spacing-6);
}
.astryx-section {
@@ -545,6 +545,24 @@
border-color: var(--color-border-emphasized);
}
.astryx-table-cell {
padding-inline-start: var(--spacing-3);
padding-inline-end: var(--spacing-3);
}
.astryx-table-cell:first-child {
padding-inline-start: var(--spacing-5);
}
.astryx-table-header-cell {
padding-inline-start: var(--spacing-3);
padding-inline-end: var(--spacing-3);
}
.astryx-table-header-cell:first-child {
padding-inline-start: var(--spacing-5);
}
.astryx-text.primary { color: var(--color-text-primary); }
.astryx-heading.primary { color: var(--color-text-primary); }

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-05T07:29:49.945Z
* Generated: 2026-08-05T11:18:10.758Z
*/
/// <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-05T07:29:49.937Z
* Generated: 2026-08-05T11:18:10.756Z
*/
/**

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-05T07:29:49.889Z
* Generated: 2026-08-05T11:18:10.750Z
*/
// Generated by astryx theme build

View File

@@ -262,9 +262,48 @@ export const loyalyTheme = defineTheme({
},
},
// Cards: dark gray background, thin gray border.
// Cards: dark gray background, thin gray border, and room to breathe.
//
// Astryx's default card padding is 11px, which puts a card title about a
// character's width from its own border — the single biggest reason the
// UI read as unfinished. 24px is the smallest value where a title, a
// subtitle and a trailing badge all sit comfortably inside the frame.
// Set here rather than per-page so every card in every module agrees.
card: {
base: {borderColor: 'var(--color-border)'},
base: {
borderColor: 'var(--color-border)',
padding: 'var(--spacing-6)',
},
},
// Table cells.
//
// `density` sets the vertical rhythm; this sets the horizontal inset,
// which density does not touch. Without it a row's first value started
// exactly where the card border ended, so the table read as a bare grid
// rather than as content inside a panel.
//
// `table-cell` / `table-header-cell` are Astryx's own themeProps targets —
// verified against dist/Table/*.js rather than guessed, because an unknown
// key here fails silently.
// 12px on interior cells, 20px lead-in on the first column. A flat 16px
// everywhere added ~96px to a six-column table, which matters because
// Settings tables live in a ~556px card once the sub-nav and Copilot have
// taken their width. The lead-in is where the eye enters the row, so that
// is where the extra space earns its place.
'table-cell': {
base: {
paddingInlineStart: 'var(--spacing-3)',
paddingInlineEnd: 'var(--spacing-3)',
':first-child': {paddingInlineStart: 'var(--spacing-5)'},
},
},
'table-header-cell': {
base: {
paddingInlineStart: 'var(--spacing-3)',
paddingInlineEnd: 'var(--spacing-3)',
':first-child': {paddingInlineStart: 'var(--spacing-5)'},
},
},
// Status dots: neutral goes gray, semantic keeps hue.