dashboard design changes
This commit is contained in:
45
src/app/(workspace)/activity/page.tsx
Normal file
45
src/app/(workspace)/activity/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/app/(workspace)/settings/api/page.tsx
Normal file
13
src/app/(workspace)/settings/api/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />}
|
||||
>
|
||||
<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>
|
||||
<SettingsPage
|
||||
title="Billing & LYT Settlement"
|
||||
description="Subscription plans, quota consumption, settlement bank accounts and invoice history."
|
||||
>
|
||||
<BillingOverview />
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/app/(workspace)/settings/integrations/page.tsx
Normal file
13
src/app/(workspace)/settings/integrations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />}
|
||||
/>
|
||||
))}
|
||||
</TabList>
|
||||
<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),
|
||||
}))}
|
||||
/>
|
||||
</VStack>
|
||||
{children}
|
||||
</VStack>
|
||||
);
|
||||
@@ -71,22 +80,41 @@ export default function SettingsLayout({
|
||||
<Layout
|
||||
height="fill"
|
||||
start={
|
||||
<LayoutPanel width={220} padding={0} role="navigation" label="Settings">
|
||||
<SideNav>
|
||||
<SideNavSection title="Settings" isHeaderHidden>
|
||||
{SETTINGS_NAV.map((s) => (
|
||||
<SideNavItem
|
||||
key={s.href}
|
||||
label={s.label}
|
||||
icon={s.icon}
|
||||
href={s.href}
|
||||
isSelected={isSettingsActive(pathname, s.href)}
|
||||
/>
|
||||
))}
|
||||
</SideNavSection>
|
||||
</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
|
||||
key={s.href}
|
||||
label={s.label}
|
||||
icon={s.icon}
|
||||
href={s.href}
|
||||
isSelected={isSettingsActive(pathname, s.href)}
|
||||
/>
|
||||
))}
|
||||
</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>}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
title="Notifications"
|
||||
description="Choose which events are worth interrupting you for."
|
||||
/>
|
||||
<NotificationsForm initialData={notifications} />
|
||||
</VStack>
|
||||
<SettingsPage
|
||||
title="Notifications"
|
||||
description="Delivery channels, instant alerts, weekly digest dispatches and trigger criteria."
|
||||
>
|
||||
<NotificationsForm />
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/app/(workspace)/settings/preferences/page.tsx
Normal file
13
src/app/(workspace)/settings/preferences/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(workspace)/settings/profile/page.tsx
Normal file
16
src/app/(workspace)/settings/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/roles/page.tsx
Normal file
13
src/app/(workspace)/settings/roles/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/security/page.tsx
Normal file
13
src/app/(workspace)/settings/security/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/stores/page.tsx
Normal file
13
src/app/(workspace)/settings/stores/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/(workspace)/settings/team/page.tsx
Normal file
13
src/app/(workspace)/settings/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user