dashboard design changes
@@ -6,6 +6,11 @@
|
|||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"port": 3100
|
"port": 3100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "loyaly-mer-attach",
|
||||||
|
"url": "http://localhost:3100",
|
||||||
|
"port": 3100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
32
AGENTS.md
@@ -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
|
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.
|
(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
|
- Component keys in `defineTheme({components})` are inconsistently cased: `text-input` but
|
||||||
`progressbar`. The CLI warns on unknown keys — **treat any `⚠ Unknown component` from
|
`progressbar`. The CLI warns on unknown keys, but that warning is **not** reliable in
|
||||||
`theme:build` as an error**, it means the override silently did nothing.
|
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
|
## Conventions
|
||||||
- Follow the Astryx rules above: no raw `<div>` for layout, no hardcoded hex/px, component
|
- 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
|
- `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
|
`<Icon icon="search"/>`; everything else is `<Icon icon={ICONS.stores}/>`. Never import a
|
||||||
lucide icon directly into a feature file.
|
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 -->
|
<!-- LOYALY:END -->
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"start": "next start -p 3100",
|
"start": "next start -p 3100",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"theme:build": "astryx theme build src/theme/loyalyTheme.ts",
|
"theme:build": "astryx theme build src/theme/loyalyTheme.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev:preview": "next dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astryxdesign/core": "^0.2.0",
|
"@astryxdesign/core": "^0.2.0",
|
||||||
|
|||||||
BIN
public/brand/loyaly-logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/brand/loyaly-mark.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 5.7 KiB |
BIN
public/white-logo.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
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 {useState} from 'react';
|
||||||
import {VStack} from '@astryxdesign/core/Layout';
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
import {Grid} from '@astryxdesign/core/Grid';
|
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 {PageHeader} from '@/components/primitives/PageHeader';
|
||||||
|
import {ScopeControls} from '@/components/scope/ScopeControls';
|
||||||
|
import {ICONS} from '@/lib/icons';
|
||||||
import {ChartCard} from '@/components/charts/ChartCard';
|
import {ChartCard} from '@/components/charts/ChartCard';
|
||||||
import {AreaChartView} from '@/components/charts/AreaChartView';
|
import {AreaChartView} from '@/components/charts/AreaChartView';
|
||||||
import {LineChartView} from '@/components/charts/LineChartView';
|
import {LineChartView} from '@/components/charts/LineChartView';
|
||||||
@@ -27,18 +31,51 @@ import {
|
|||||||
} from '@/lib/format';
|
} from '@/lib/format';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The dashboard answers three questions in order, top to bottom:
|
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
|
||||||
* 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
|
|
||||||
*
|
*
|
||||||
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider,
|
* The page reads top-down as one narrowing question: headline numbers → the two
|
||||||
* so changing either control in the top nav re-queries the whole page without
|
* trends that drive them → the conversion story behind those → when and on what
|
||||||
* a route change — which is what keeps the Copilot mounted alongside it.
|
* 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() {
|
export default function DashboardPage() {
|
||||||
const {storeId, range} = useWorkspace();
|
const {storeId, range} = useWorkspace();
|
||||||
const [granularity, setGranularity] = useState<Granularity>('weekly');
|
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};
|
const scope = {range, storeId};
|
||||||
|
|
||||||
@@ -49,17 +86,46 @@ export default function DashboardPage() {
|
|||||||
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
|
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
|
||||||
const comparison = useResource(endpoints.dashboardStoreComparison(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 (
|
return (
|
||||||
<VStack gap={5}>
|
<VStack gap={5}>
|
||||||
|
{/* 1 + 2 — greeting, title, and the store / period / compare filters. */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
eyebrow={greetingFor(kpis.meta?.generatedAt)}
|
||||||
title="Dashboard"
|
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} />
|
<KpiRow resource={kpis} />
|
||||||
|
|
||||||
|
{/* 4 — primary analytics: the two series everything else explains. */}
|
||||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||||
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
|
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
|
||||||
{(d) => (
|
{(d) => (
|
||||||
@@ -84,7 +150,10 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* 5 — secondary analytics: the conversion story, in two readings. */}
|
||||||
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Visitors vs purchases"
|
title="Visitors vs purchases"
|
||||||
subtitle="The gap is the conversion opportunity"
|
subtitle="The gap is the conversion opportunity"
|
||||||
@@ -124,6 +193,7 @@ export default function DashboardPage() {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* 6 — operational analytics: when traffic lands, what it redeems. */}
|
||||||
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Peak hours"
|
title="Peak hours"
|
||||||
@@ -137,18 +207,27 @@ export default function DashboardPage() {
|
|||||||
<RewardUsageChart resource={rewards} />
|
<RewardUsageChart resource={rewards} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* 7 — period rollup. */}
|
||||||
<PerformancePanel
|
<PerformancePanel
|
||||||
scope={scope}
|
scope={scope}
|
||||||
granularity={granularity}
|
granularity={granularity}
|
||||||
onGranularityChange={setGranularity}
|
onGranularityChange={setGranularity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Comparing stores is meaningless when scoped to one of them. */}
|
{/* 8 — comparing stores is meaningless when scoped to one of them, which
|
||||||
{storeId === 'all' ? (
|
is why Compare is disabled rather than merely off in that case. */}
|
||||||
|
{isAllStores && isComparing ? (
|
||||||
<StoreComparisonPanel resource={comparison} />
|
<StoreComparisonPanel resource={comparison} />
|
||||||
) : null}
|
) : 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>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {AppShell} from '@astryxdesign/core/AppShell';
|
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 {AppSideNav} from '@/components/shell/AppSideNav';
|
||||||
import {AppTopNav} from '@/components/shell/AppTopNav';
|
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 {CopilotPanel} from '@/features/copilot/CopilotPanel';
|
||||||
import {CopilotSlideOver} from '@/features/copilot/CopilotSlideOver';
|
import {CopilotSlideOver} from '@/features/copilot/CopilotSlideOver';
|
||||||
import {useCopilot} from '@/features/copilot/CopilotProvider';
|
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.
|
* 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.
|
* nav, scroll position, the Copilot — is untouched.
|
||||||
*
|
*
|
||||||
* Structure, and why AppShell alone is not enough: AppShell renders
|
* Structure, and why AppShell alone is not enough: AppShell renders
|
||||||
* Layout{start, content} internally and exposes no end/panel slot. The Copilot
|
* Layout{header, start, content} internally and exposes no end/panel slot. The
|
||||||
* column therefore comes from a NESTED Layout inside AppShell's children,
|
* Copilot column therefore comes from a NESTED Layout inside its children.
|
||||||
* using its `end` slot.
|
|
||||||
*
|
*
|
||||||
* AppShell(topNav, sideNav)
|
* AppShell(sideNav)
|
||||||
* └─ Layout(content = page, end = LayoutPanel > CopilotPanel)
|
* └─ Layout(header = TopNav, content = page, end = LayoutPanel > Copilot)
|
||||||
*
|
*
|
||||||
* variant="elevated" is what produces the described visual for free: with both
|
* The top nav is deliberately NOT passed to AppShell's `topNav` slot. That slot
|
||||||
* navs present the shell root and rails paint --color-background-body (#000),
|
* renders Layout{header} at the shell root, which spans the full viewport width
|
||||||
* and the content area sits on an elevated --color-background-surface (#0F0F10)
|
* and pushes the sidebar — and therefore the branding — 48px down the screen.
|
||||||
* backdrop with --radius-page on the start corner.
|
* 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({
|
export default function WorkspaceLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
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 bp = useBreakpoint();
|
||||||
const {isOpen} = useCopilot();
|
const {isOpen} = useCopilot();
|
||||||
const inline = isPanelInline(bp);
|
const inline = isPanelInline(bp);
|
||||||
const showInlinePanel = inline && isOpen;
|
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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
@@ -47,16 +93,49 @@ export default function WorkspaceLayout({
|
|||||||
// Layout/LayoutContent own the padding so the Copilot panel can sit
|
// Layout/LayoutContent own the padding so the Copilot panel can sit
|
||||||
// flush against the workspace edge.
|
// flush against the workspace edge.
|
||||||
contentPadding={0}
|
contentPadding={0}
|
||||||
topNav={<AppTopNav />}
|
topNav={sideNavInline ? undefined : <AppTopNav />}
|
||||||
sideNav={<AppSideNav />}
|
sideNav={<AppSideNav />}
|
||||||
// Supplied explicitly rather than auto-generated: the default drawer
|
// 'sm' (640px) matches the mobile/tablet line in lib/breakpoints —
|
||||||
// mirrors sideNav only, which would leave the store and range controls
|
// AppShell's own threshold and ours have to agree or the hamburger and
|
||||||
// unreachable below 768px (they live in TopNav's collapsing start slot).
|
// the rail are both visible, or neither is.
|
||||||
mobileNav={{breakpoint: 'md', content: <MobileScopeNav />}}
|
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
|
<Layout
|
||||||
height="fill"
|
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={
|
end={
|
||||||
showInlinePanel ? (
|
showInlinePanel ? (
|
||||||
<LayoutPanel
|
<LayoutPanel
|
||||||
@@ -65,7 +144,7 @@ export default function WorkspaceLayout({
|
|||||||
// CopilotPanel supplies its own header and tab chrome.
|
// CopilotPanel supplies its own header and tab chrome.
|
||||||
padding={0}
|
padding={0}
|
||||||
role="complementary"
|
role="complementary"
|
||||||
label="AI Copilot"
|
label="Loyaly AI"
|
||||||
>
|
>
|
||||||
<CopilotPanel />
|
<CopilotPanel />
|
||||||
</LayoutPanel>
|
</LayoutPanel>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {useMetricColumns} from '@/features/dashboard/KpiRow';
|
|||||||
import {useResource} from '@/lib/api/useResource';
|
import {useResource} from '@/lib/api/useResource';
|
||||||
import {endpoints} from '@/lib/api/client';
|
import {endpoints} from '@/lib/api/client';
|
||||||
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
||||||
|
import {ScopeControls} from '@/components/scope/ScopeControls';
|
||||||
import {ICONS} from '@/lib/icons';
|
import {ICONS} from '@/lib/icons';
|
||||||
import {usageRate} from '@/features/lyts/reward-status';
|
import {usageRate} from '@/features/lyts/reward-status';
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +65,7 @@ export default function LytsPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Lyts"
|
title="Lyts"
|
||||||
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
|
description="Reward performance, redemption and outstanding LYT liability. 1 LYT = ₹1."
|
||||||
|
controls={<ScopeControls />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ExpiryAlerts resource={rewards} nowMs={nowMs} />
|
<ExpiryAlerts resource={rewards} nowMs={nowMs} />
|
||||||
@@ -194,7 +196,7 @@ export default function LytsPage() {
|
|||||||
|
|
||||||
<RewardPerformanceTable resource={rewards} />
|
<RewardPerformanceTable resource={rewards} />
|
||||||
|
|
||||||
<ActivityTimeline resource={activity} />
|
<ActivityTimeline resource={activity} limit={6} height={285} />
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 {SettingsPage} from '@/features/settings/SettingsPage';
|
||||||
import {Badge} from '@astryxdesign/core/Badge';
|
import {BillingOverview} from '@/features/settings/BillingOverview';
|
||||||
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';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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() {
|
export default function SettingsBillingPage() {
|
||||||
const profile = readProfile();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={5}>
|
<SettingsPage
|
||||||
<PageHeader
|
title="Billing & LYT Settlement"
|
||||||
title="Billing"
|
description="Subscription plans, quota consumption, settlement bank accounts and invoice history."
|
||||||
description="Plan, LYT settlement and invoices."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StaticPanel
|
|
||||||
title="Plan"
|
|
||||||
subtitle={profile.businessName}
|
|
||||||
actions={<Badge variant="success" label="Active" />}
|
|
||||||
>
|
>
|
||||||
<VStack gap={4}>
|
<BillingOverview />
|
||||||
<StatRow>
|
</SettingsPage>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
VStack,
|
||||||
} from '@astryxdesign/core/Layout';
|
} from '@astryxdesign/core/Layout';
|
||||||
import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
|
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 {Icon} from '@astryxdesign/core/Icon';
|
||||||
import {useRouter} from 'next/navigation';
|
import {useRouter} from 'next/navigation';
|
||||||
import {useBreakpoint} from '@/lib/breakpoints';
|
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
|
* and behaves exactly like a primary nav item — same selected treatment, same
|
||||||
* hover, same keyboard handling, no second definition to keep in sync.
|
* 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
|
* Below the laptop breakpoint a 240px panel would eat most of the width, so
|
||||||
* the sub-nav becomes a horizontal TabList above the content instead.
|
* the sub-nav collapses to a section picker above the content instead.
|
||||||
*/
|
*/
|
||||||
export default function SettingsLayout({
|
export default function SettingsLayout({
|
||||||
children,
|
children,
|
||||||
@@ -41,27 +41,36 @@ export default function SettingsLayout({
|
|||||||
|
|
||||||
if (isNarrow) {
|
if (isNarrow) {
|
||||||
const active =
|
const active =
|
||||||
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href))?.href ??
|
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href)) ??
|
||||||
SETTINGS_NAV[0].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 (
|
return (
|
||||||
<VStack gap={5}>
|
<VStack width="100%">
|
||||||
<TabList
|
{/* Only the picker is padded here — SettingsPage still owns every
|
||||||
value={active}
|
page gutter, so the container contract stays in one place. */}
|
||||||
onChange={(href) => router.push(href)}
|
<VStack paddingInline={8} width="100%" className="pt-8">
|
||||||
layout="fill"
|
<DropdownMenu
|
||||||
size="sm"
|
button={{
|
||||||
hasDivider
|
variant: 'secondary',
|
||||||
>
|
label: active.label,
|
||||||
{SETTINGS_NAV.map((s) => (
|
icon: <Icon icon={active.icon} size="sm" />,
|
||||||
<Tab
|
}}
|
||||||
key={s.href}
|
menuWidth={260}
|
||||||
value={s.href}
|
items={SETTINGS_NAV.map((s) => ({
|
||||||
label={s.label}
|
label: s.label,
|
||||||
icon={<Icon icon={s.icon} size="sm" />}
|
icon: s.icon,
|
||||||
|
onClick: () => router.push(s.href),
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
))}
|
</VStack>
|
||||||
</TabList>
|
|
||||||
{children}
|
{children}
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
@@ -71,8 +80,23 @@ export default function SettingsLayout({
|
|||||||
<Layout
|
<Layout
|
||||||
height="fill"
|
height="fill"
|
||||||
start={
|
start={
|
||||||
<LayoutPanel width={220} padding={0} role="navigation" label="Settings">
|
// 240px rather than 220: "Roles & Permissions" and "API & Webhooks"
|
||||||
<SideNav>
|
// 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>
|
<SideNavSection title="Settings" isHeaderHidden>
|
||||||
{SETTINGS_NAV.map((s) => (
|
{SETTINGS_NAV.map((s) => (
|
||||||
<SideNavItem
|
<SideNavItem
|
||||||
@@ -85,8 +109,12 @@ export default function SettingsLayout({
|
|||||||
))}
|
))}
|
||||||
</SideNavSection>
|
</SideNavSection>
|
||||||
</SideNav>
|
</SideNav>
|
||||||
|
</VStack>
|
||||||
</LayoutPanel>
|
</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>}
|
content={<LayoutContent padding={0}>{children}</LayoutContent>}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import {VStack} from '@astryxdesign/core/Layout';
|
import {SettingsPage} from '@/features/settings/SettingsPage';
|
||||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
|
||||||
import {NotificationsForm} from '@/features/settings/NotificationsForm';
|
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 (
|
return (
|
||||||
<VStack gap={5}>
|
<SettingsPage
|
||||||
<PageHeader
|
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
description="Choose which events are worth interrupting you for."
|
description="Delivery channels, instant alerts, weekly digest dispatches and trigger criteria."
|
||||||
/>
|
>
|
||||||
<NotificationsForm initialData={notifications} />
|
<NotificationsForm />
|
||||||
</VStack>
|
</SettingsPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,13 @@
|
|||||||
import {VStack} from '@astryxdesign/core/Layout';
|
import {SettingsPage} from '@/features/settings/SettingsPage';
|
||||||
import {PageHeader} from '@/components/primitives/PageHeader';
|
import {BusinessForm} from '@/features/settings/BusinessForm';
|
||||||
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();
|
|
||||||
|
|
||||||
|
export default function BusinessSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<VStack gap={5}>
|
<SettingsPage
|
||||||
<PageHeader
|
title="Business Settings"
|
||||||
title="Settings"
|
description="Company profile, GSTIN registration, registered address and LYT earn defaults."
|
||||||
description="Business details, contact information and LYT earn rate."
|
>
|
||||||
/>
|
<BusinessForm />
|
||||||
<ProfileForm initialData={profile} />
|
</SettingsPage>
|
||||||
</VStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {useResource} from '@/lib/api/useResource';
|
||||||
import {endpoints} from '@/lib/api/client';
|
import {endpoints} from '@/lib/api/client';
|
||||||
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
||||||
|
import {ScopeControls} from '@/components/scope/ScopeControls';
|
||||||
import {storeName} from '@/lib/mock/stores';
|
import {storeName} from '@/lib/mock/stores';
|
||||||
import {formatCompact} from '@/lib/format';
|
import {formatCompact} from '@/lib/format';
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export default function StaffPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Staff"
|
title="Staff"
|
||||||
description={`Attendance, sales contribution and performance across ${scopeLabel}.`}
|
description={`Attendance, sales contribution and performance across ${scopeLabel}.`}
|
||||||
|
controls={<ScopeControls />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StaffKpis resource={summary} />
|
<StaffKpis resource={summary} />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {useMetricColumns} from '@/features/dashboard/KpiRow';
|
|||||||
import {useResource} from '@/lib/api/useResource';
|
import {useResource} from '@/lib/api/useResource';
|
||||||
import {endpoints} from '@/lib/api/client';
|
import {endpoints} from '@/lib/api/client';
|
||||||
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
||||||
|
import {ScopeControls} from '@/components/scope/ScopeControls';
|
||||||
import {ICONS} from '@/lib/icons';
|
import {ICONS} from '@/lib/icons';
|
||||||
import {
|
import {
|
||||||
formatCompact,
|
formatCompact,
|
||||||
@@ -45,7 +46,7 @@ const STATUS: Record<
|
|||||||
* One store's dashboard.
|
* One store's dashboard.
|
||||||
*
|
*
|
||||||
* Deliberately the SAME chart components as the main dashboard, scoped by the
|
* 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 —
|
* 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.
|
* 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`
|
? `${store.data.staffCount} staff · ${formatPct(store.data.conversionPct)} conversion`
|
||||||
: 'Loading store…'
|
: 'Loading store…'
|
||||||
}
|
}
|
||||||
|
// Store scope is the route here, so only the period is selectable.
|
||||||
|
controls={<ScopeControls hasStore={false} />}
|
||||||
actions={
|
actions={
|
||||||
<HStack gap={2} vAlign="center">
|
<HStack gap={2} vAlign="center">
|
||||||
{store.data ? (
|
{store.data ? (
|
||||||
@@ -183,7 +186,7 @@ export default function StoreDetailPage({
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ActivityTimeline resource={activity} />
|
<ActivityTimeline resource={activity} limit={6} height={285} />
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
RANGE_LABELS,
|
RANGE_LABELS,
|
||||||
useWorkspace,
|
useWorkspace,
|
||||||
} from '@/components/shell/WorkspaceProvider';
|
} from '@/components/shell/WorkspaceProvider';
|
||||||
|
import {ScopeControls} from '@/components/scope/ScopeControls';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The store roster.
|
* 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
|
* 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
|
* leave a one-card page. The RANGE scope still applies, because the totals on
|
||||||
* each card have to be measured over some period.
|
* each card have to be measured over some period.
|
||||||
@@ -27,6 +28,7 @@ export default function StoresPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Store"
|
title="Store"
|
||||||
description={`Performance and status for every store in the network, over ${RANGE_LABELS[range].toLowerCase()}.`}
|
description={`Performance and status for every store in the network, over ${RANGE_LABELS[range].toLowerCase()}.`}
|
||||||
|
controls={<ScopeControls hasStore={false} />}
|
||||||
/>
|
/>
|
||||||
<StoreGrid resource={stores} />
|
<StoreGrid resource={stores} />
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
17
src/app/api/dashboard/briefing/route.ts
Normal 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
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 6.4 KiB |
BIN
src/app/icon.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
@@ -15,13 +15,27 @@ const inter = Inter({
|
|||||||
display: 'swap',
|
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 = {
|
export const metadata: Metadata = {
|
||||||
|
applicationName: 'Loyaly.ai Merchant OS',
|
||||||
title: {
|
title: {
|
||||||
default: 'Loyaly Merchant',
|
default: 'Loyaly.ai Merchant OS',
|
||||||
template: '%s · Loyaly Merchant',
|
template: '%s · Loyaly.ai',
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
'Footfall, campaign and LYT redemption analytics for Loyaly merchants.',
|
'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 = {
|
export const viewport: Viewport = {
|
||||||
|
|||||||
15
src/app/login/page.tsx
Normal 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
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
62
src/components/brand/BrandLogo.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,18 +2,23 @@
|
|||||||
|
|
||||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||||
import {Text} from '@astryxdesign/core/Text';
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
import {Divider} from '@astryxdesign/core/Divider';
|
|
||||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
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 {
|
export interface ActivityEntry {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
at: string;
|
at: string;
|
||||||
tone: StatusDotVariant;
|
/** Which kind of event this is — the glyph IS the category label. */
|
||||||
/** Announced with the dot; the dot's colour alone is not an accessible label. */
|
icon: IconKey;
|
||||||
|
tone: ActivityTone;
|
||||||
|
/** Announced with the icon; a glyph alone is not an accessible label. */
|
||||||
toneLabel: string;
|
toneLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,24 +29,34 @@ export interface ActivityEntry {
|
|||||||
* edge-to-edge, and wrapping each event in a Card would triple the vertical
|
* edge-to-edge, and wrapping each event in a Card would triple the vertical
|
||||||
* space while making the feed harder to scan.
|
* space while making the feed harder to scan.
|
||||||
*
|
*
|
||||||
* Tone is passed in rather than derived, because "which events deserve colour"
|
* The leading glyph is the event's TYPE — purchase, reward, staff, store,
|
||||||
* is a per-module editorial decision. On the dashboard only expiries are
|
* alert — not its status. A status dot could only encode severity, which meant
|
||||||
* amber; in Lyts the same rule applies to a different event set. What must
|
* five different kinds of event all rendered as the same grey circle and the
|
||||||
* stay constant is that MOST rows are neutral — if everything is coloured,
|
* feed could not be skimmed by category. The icon does that job, and colour is
|
||||||
* the one row that matters stops standing out.
|
* 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({
|
export function ActivityItem({entry}: {entry: ActivityEntry}) {
|
||||||
entry,
|
|
||||||
isLast,
|
|
||||||
}: {
|
|
||||||
entry: ActivityEntry;
|
|
||||||
isLast: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={0}>
|
<HStack
|
||||||
<HStack gap={3} vAlign="start" paddingBlock={2}>
|
gap={3}
|
||||||
<HStack paddingBlock={1}>
|
vAlign="start"
|
||||||
<StatusDot variant={entry.tone} label={entry.toneLabel} />
|
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>
|
</HStack>
|
||||||
<VStack gap={0.5} width="100%">
|
<VStack gap={0.5} width="100%">
|
||||||
<HStack gap={3} hAlign="between" vAlign="center">
|
<HStack gap={3} hAlign="between" vAlign="center">
|
||||||
@@ -57,21 +72,33 @@ export function ActivityItem({
|
|||||||
) : null}
|
) : null}
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</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 (
|
return (
|
||||||
<VStack gap={0}>
|
<VStack gap={0}>
|
||||||
{entries.map((e, i) => (
|
{shown.map((e) => (
|
||||||
<ActivityItem
|
<ActivityItem key={e.id} entry={e} />
|
||||||
key={e.id}
|
|
||||||
entry={e}
|
|
||||||
isLast={i === entries.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
|
|||||||
100
src/components/patterns/ResponsiveTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,11 +20,11 @@ export function StatPair({
|
|||||||
align?: 'start' | 'end';
|
align?: 'start' | 'end';
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<VStack gap={0} hAlign={align}>
|
<VStack gap={0.5} hAlign={align}>
|
||||||
<Text size="xsm" color="secondary">
|
<Text size="sm" color="secondary">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" weight="medium">
|
<Text size="base" weight="semibold">
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -5,27 +5,50 @@ import {Heading, Text} from '@astryxdesign/core/Text';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Every workspace page opens the same way: title, one line of orientation,
|
* 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
|
* then the filters and actions that belong to THIS page. Centralising it is
|
||||||
* five modules from drifting into five different header treatments.
|
* 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({
|
export function PageHeader({
|
||||||
|
eyebrow,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
controls,
|
||||||
actions,
|
actions,
|
||||||
}: {
|
}: {
|
||||||
|
eyebrow?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
controls?: React.ReactNode;
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<HStack hAlign="between" vAlign="start" gap={4}>
|
<HStack hAlign="between" vAlign="center" gap={4} wrap="wrap">
|
||||||
<VStack gap={1}>
|
<VStack gap={1}>
|
||||||
<Heading level={1}>{title}</Heading>
|
{eyebrow ? (
|
||||||
{description ? (
|
<Text size="sm" color="secondary">
|
||||||
<Text color="secondary">{description}</Text>
|
{eyebrow}
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Heading level={1}>{title}</Heading>
|
||||||
|
{description ? <Text color="secondary">{description}</Text> : null}
|
||||||
</VStack>
|
</VStack>
|
||||||
{actions ? <HStack gap={2}>{actions}</HStack> : null}
|
{controls || actions ? (
|
||||||
|
<HStack gap={2} vAlign="center" wrap="wrap">
|
||||||
|
{controls}
|
||||||
|
{actions}
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
106
src/components/scope/RangePicker.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
29
src/components/scope/ScopeControls.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,12 +3,16 @@
|
|||||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
import {ICONS} from '@/lib/icons';
|
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
|
* Store scope for the page that renders it. The selection lives in
|
||||||
* from WorkspaceProvider, so switching re-queries every panel without a route
|
* WorkspaceProvider, so switching re-queries every panel on the page without a
|
||||||
* change — which is what keeps the Copilot mounted.
|
* 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() {
|
export function StoreSwitcher() {
|
||||||
const {storeId, setStoreId, stores} = useWorkspace();
|
const {storeId, setStoreId, stores} = useWorkspace();
|
||||||
@@ -17,7 +21,7 @@ export function StoreSwitcher() {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
button={{
|
button={{
|
||||||
variant: 'ghost',
|
variant: 'secondary',
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
label: current ? current.name : 'All stores',
|
label: current ? current.name : 'All stores',
|
||||||
icon: <Icon icon={ICONS.stores} size="sm" />,
|
icon: <Icon icon={ICONS.stores} size="sm" />,
|
||||||
@@ -3,47 +3,84 @@
|
|||||||
import {usePathname} from 'next/navigation';
|
import {usePathname} from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
SideNav,
|
SideNav,
|
||||||
SideNavHeading,
|
|
||||||
SideNavItem,
|
SideNavItem,
|
||||||
SideNavSection,
|
SideNavSection,
|
||||||
} from '@astryxdesign/core/SideNav';
|
} from '@astryxdesign/core/SideNav';
|
||||||
import {NavIcon} from '@astryxdesign/core/NavIcon';
|
import {SideNavBrand} from './SideNavBrand';
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
import {NAV_RAIL_ID, useSidebar} from './SidebarProvider';
|
||||||
import {ICONS} from '@/lib/icons';
|
|
||||||
import {FOOTER_NAV, PRIMARY_NAV, isNavActive} from './nav-config';
|
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` /
|
* ── Why the collapsed rail is restyled ────────────────────────────────────
|
||||||
* `--color-overlay-hover` styles, which the theme already recoloured to
|
* Astryx's collapsed rail is --spacing-12 (48px) holding 32px items around
|
||||||
* #202124 / white-at-6%. There is deliberately no custom CSS here — a
|
* 16px icons. At a glance that reads as a scrollbar with symbols in it, and a
|
||||||
* hand-rolled active state would drift from the rest of the system.
|
* 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() {
|
export function AppSideNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const {isCollapsed, toggle} = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideNav
|
<SideNav
|
||||||
|
id={NAV_RAIL_ID}
|
||||||
|
className={`${RAIL_MOTION} ${isCollapsed ? COLLAPSED_RAIL : ''}`}
|
||||||
collapsible={{
|
collapsible={{
|
||||||
defaultIsCollapsed: false,
|
isCollapsed,
|
||||||
hasButton: true,
|
// SideNav still drives its own children (icon-only items, tooltips)
|
||||||
buttonLabel: 'Collapse navigation',
|
// off this flag; it just no longer owns it.
|
||||||
|
onCollapsedChange: toggle,
|
||||||
|
hasButton: false,
|
||||||
}}
|
}}
|
||||||
resizable={{
|
// Not resizable. Two reasons, one of them measurable: the drag handle
|
||||||
defaultWidth: 260,
|
// renders in overlay mode inside a wrapper that comes out 1px wider than
|
||||||
minWidth: 220,
|
// the panel it sits in, which gave the sidebar a permanent horizontal
|
||||||
maxWidth: 360,
|
// scrollbar along its bottom edge (measured: scrollWidth 261 in a 260px
|
||||||
autoSaveId: 'loyaly.sidenav.width',
|
// panel). And a rail whose width is a stored user preference cannot also
|
||||||
}}
|
// be the fixed 260/84 pair the collapse animation moves between —
|
||||||
header={
|
// resizing and collapsing were fighting for the same property.
|
||||||
<SideNavHeading
|
header={<SideNavBrand />}
|
||||||
heading="Loyaly"
|
|
||||||
subheading="Merchant"
|
|
||||||
icon={<NavIcon icon={<Icon icon={ICONS.lyt} size="sm" />} />}
|
|
||||||
headingHref="/dashboard"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
footer={
|
footer={
|
||||||
<SideNavSection title="Account" isHeaderHidden>
|
<SideNavSection title="Account" isHeaderHidden>
|
||||||
{FOOTER_NAV.map((item) => (
|
{FOOTER_NAV.map((item) => (
|
||||||
|
|||||||
@@ -4,33 +4,61 @@ import {TopNav} from '@astryxdesign/core/TopNav';
|
|||||||
import {HStack} from '@astryxdesign/core/Layout';
|
import {HStack} from '@astryxdesign/core/Layout';
|
||||||
import {IconButton} from '@astryxdesign/core/IconButton';
|
import {IconButton} from '@astryxdesign/core/IconButton';
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
|
import {Link} from '@astryxdesign/core/Link';
|
||||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||||
import {Divider} from '@astryxdesign/core/Divider';
|
|
||||||
import {ICONS} from '@/lib/icons';
|
import {ICONS} from '@/lib/icons';
|
||||||
import {GlobalSearch} from './GlobalSearch';
|
import {BrandMark} from '@/components/brand/BrandLogo';
|
||||||
import {StoreSwitcher} from './StoreSwitcher';
|
|
||||||
import {RangePicker} from './RangePicker';
|
|
||||||
import {CopilotToggle} from '@/features/copilot/CopilotToggle';
|
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
|
* Menu, search, notifications, profile — in that order, at every width.
|
||||||
* because they govern the whole workspace; identity and alerts live at the end.
|
*
|
||||||
|
* 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() {
|
export function AppTopNav() {
|
||||||
|
const {isDrawer} = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopNav
|
<TopNav
|
||||||
label="Workspace"
|
label="Workspace"
|
||||||
startContent={
|
heading={
|
||||||
<HStack gap={1} vAlign="center">
|
<HStack gap={2} vAlign="center">
|
||||||
<StoreSwitcher />
|
<NavMenuButton />
|
||||||
<Divider orientation="vertical" />
|
{isDrawer ? (
|
||||||
<RangePicker />
|
<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>
|
</HStack>
|
||||||
}
|
}
|
||||||
centerContent={<GlobalSearch />}
|
|
||||||
endContent={
|
endContent={
|
||||||
<HStack gap={1} vAlign="center">
|
<HStack gap={1} vAlign="center">
|
||||||
|
<GlobalSearch />
|
||||||
<CopilotToggle />
|
<CopilotToggle />
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<Icon icon={ICONS.notifications} />}
|
icon={<Icon icon={ICONS.notifications} />}
|
||||||
@@ -43,13 +71,20 @@ export function AppTopNav() {
|
|||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
isIconOnly: true,
|
isIconOnly: true,
|
||||||
label: 'Account',
|
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}
|
menuWidth={200}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
type: 'section',
|
type: 'section',
|
||||||
title: 'suriya@nearle.in',
|
title: 'aravind@nearle.in',
|
||||||
items: [
|
items: [
|
||||||
{label: 'Profile', icon: ICONS.profile},
|
{label: 'Profile', icon: ICONS.profile},
|
||||||
{label: 'Settings', icon: ICONS.settings},
|
{label: 'Settings', icon: ICONS.settings},
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {useRouter} from 'next/navigation';
|
|||||||
import {CommandPalette} from '@astryxdesign/core/CommandPalette';
|
import {CommandPalette} from '@astryxdesign/core/CommandPalette';
|
||||||
import {createStaticSource} from '@astryxdesign/core/Typeahead';
|
import {createStaticSource} from '@astryxdesign/core/Typeahead';
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
import {Button} from '@astryxdesign/core/Button';
|
||||||
|
import {IconButton} from '@astryxdesign/core/IconButton';
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
import {Icon} from '@astryxdesign/core/Icon';
|
||||||
|
import {useBreakpoint} from '@/lib/breakpoints';
|
||||||
import {FOOTER_NAV, PRIMARY_NAV} from './nav-config';
|
import {FOOTER_NAV, PRIMARY_NAV} from './nav-config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +22,7 @@ import {FOOTER_NAV, PRIMARY_NAV} from './nav-config';
|
|||||||
export function GlobalSearch() {
|
export function GlobalSearch() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isCompact = useBreakpoint() === 'mobile';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -46,6 +49,22 @@ export function GlobalSearch() {
|
|||||||
|
|
||||||
return (
|
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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -53,6 +72,7 @@ export function GlobalSearch() {
|
|||||||
icon={<Icon icon="search" size="sm" />}
|
icon={<Icon icon="search" size="sm" />}
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={setIsOpen}
|
||||||
|
|||||||
130
src/components/shell/MobileMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
63
src/components/shell/NavMenuButton.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
96
src/components/shell/SideNavBrand.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/shell/SidebarProvider.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -6,13 +6,17 @@ import {createContext, useContext, useMemo, useState} from 'react';
|
|||||||
* Workspace-wide query scope: which store, and over what period.
|
* Workspace-wide query scope: which store, and over what period.
|
||||||
*
|
*
|
||||||
* This lives ABOVE the route tree (mounted in app/providers.tsx) for the same
|
* 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,
|
* reason the Copilot does. A server-component page could not re-render from
|
||||||
* which outlives any individual page. A server-component page could not
|
* these controls without a router navigation, and a navigation would remount
|
||||||
* re-render from these controls without a router navigation, and a navigation
|
* the Copilot. So scope is client state, and data panels read it.
|
||||||
* 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> = {
|
export const RANGE_LABELS: Record<RangeKey, string> = {
|
||||||
'7d': 'Last 7 days',
|
'7d': 'Last 7 days',
|
||||||
@@ -20,8 +24,14 @@ export const RANGE_LABELS: Record<RangeKey, string> = {
|
|||||||
'90d': 'Last 90 days',
|
'90d': 'Last 90 days',
|
||||||
mtd: 'Month to date',
|
mtd: 'Month to date',
|
||||||
ytd: 'Year to date',
|
ytd: 'Year to date',
|
||||||
|
custom: 'Custom range',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface CustomDateRange {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreOption {
|
export interface StoreOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,7 +39,7 @@ export interface StoreOption {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Store list for the switcher. Deliberately static: the switcher must render
|
* 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.
|
* as jank. The authoritative list comes from /api/stores once Store lands.
|
||||||
*/
|
*/
|
||||||
export const STORE_OPTIONS: StoreOption[] = [
|
export const STORE_OPTIONS: StoreOption[] = [
|
||||||
@@ -47,6 +57,8 @@ interface WorkspaceValue {
|
|||||||
setStoreId: (id: StoreScope) => void;
|
setStoreId: (id: StoreScope) => void;
|
||||||
range: RangeKey;
|
range: RangeKey;
|
||||||
setRange: (r: RangeKey) => void;
|
setRange: (r: RangeKey) => void;
|
||||||
|
customRange: CustomDateRange | null;
|
||||||
|
setCustomRange: (cr: CustomDateRange | null) => void;
|
||||||
stores: StoreOption[];
|
stores: StoreOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +67,19 @@ const WorkspaceContext = createContext<WorkspaceValue | null>(null);
|
|||||||
export function WorkspaceProvider({children}: {children: React.ReactNode}) {
|
export function WorkspaceProvider({children}: {children: React.ReactNode}) {
|
||||||
const [storeId, setStoreId] = useState<StoreScope>('all');
|
const [storeId, setStoreId] = useState<StoreScope>('all');
|
||||||
const [range, setRange] = useState<RangeKey>('30d');
|
const [range, setRange] = useState<RangeKey>('30d');
|
||||||
|
const [customRange, setCustomRange] = useState<CustomDateRange | null>(null);
|
||||||
|
|
||||||
const value = useMemo(
|
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>;
|
return <WorkspaceContext value={value}>{children}</WorkspaceContext>;
|
||||||
|
|||||||
3
src/features/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export {LoginSplit as LoginForm} from './LoginSplit';
|
||||||
302
src/features/auth/LoginSplit.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,12 +31,12 @@ export function CopilotPanel({onDismiss}: {onDismiss?: () => void}) {
|
|||||||
>
|
>
|
||||||
<HStack gap={2} vAlign="center">
|
<HStack gap={2} vAlign="center">
|
||||||
<Icon icon={ICONS.ai} size="sm" />
|
<Icon icon={ICONS.ai} size="sm" />
|
||||||
<Heading level={2}>Copilot</Heading>
|
<Heading level={2}>Loyaly AI</Heading>
|
||||||
</HStack>
|
</HStack>
|
||||||
{onDismiss ? (
|
{onDismiss ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<Icon icon={ICONS.panelClose} />}
|
icon={<Icon icon={ICONS.panelClose} />}
|
||||||
label="Hide Copilot"
|
label="Hide Loyaly AI"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
|
|||||||
@@ -23,13 +23,19 @@ export function CopilotSlideOver() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileNav
|
<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}
|
isOpen={isSlideOverOpen}
|
||||||
onOpenChange={setSlideOverOpen}
|
onOpenChange={setSlideOverOpen}
|
||||||
side="end"
|
side="end"
|
||||||
// Full-bleed on mobile, where a 380px drawer would leave nothing
|
// Full-bleed on mobile, where a 380px drawer would leave nothing
|
||||||
// behind it worth looking at.
|
// behind it worth looking at.
|
||||||
width={bp === 'mobile' ? undefined : 380}
|
width={bp === 'mobile' ? undefined : 380}
|
||||||
label="AI Copilot"
|
label="Loyaly AI"
|
||||||
>
|
>
|
||||||
<CopilotPanel onDismiss={() => setSlideOverOpen(false)} />
|
<CopilotPanel onDismiss={() => setSlideOverOpen(false)} />
|
||||||
</MobileNav>
|
</MobileNav>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function CopilotToggle() {
|
|||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<Icon icon={isShown ? ICONS.panelClose : ICONS.panelOpen} />}
|
icon={<Icon icon={isShown ? ICONS.panelClose : ICONS.panelOpen} />}
|
||||||
label={isShown ? 'Hide Copilot' : 'Show Copilot'}
|
label={isShown ? 'Hide Loyaly AI' : 'Show Loyaly AI'}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => (inline ? toggle() : setSlideOverOpen(!isSlideOverOpen))}
|
onClick={() => (inline ? toggle() : setSlideOverOpen(!isSlideOverOpen))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,28 +4,56 @@ import {VStack, HStack} from '@astryxdesign/core/Layout';
|
|||||||
import {Card} from '@astryxdesign/core/Card';
|
import {Card} from '@astryxdesign/core/Card';
|
||||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||||
import {Banner} from '@astryxdesign/core/Banner';
|
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 {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 {ICONS} from '@/lib/icons';
|
||||||
|
import type {InsightSeverity} from '@/lib/api/contracts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 1 renders the panel's shape with representative content so the layout
|
* Reads the SAME briefing endpoint the dashboard does.
|
||||||
* can be judged. The insight list becomes `useResource(endpoints['copilot.insights'])`
|
*
|
||||||
* in Phase 2 — the markup below is already the success branch of that.
|
* 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() {
|
export function AiTab() {
|
||||||
|
const {storeId, range} = useWorkspace();
|
||||||
|
const briefing = useResource(endpoints.dashboardBriefing({range, storeId}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack gap={4} padding={4}>
|
<VStack gap={4} padding={4}>
|
||||||
|
<AsyncBoundary resource={briefing} loading={<SkeletonRows count={6} />}>
|
||||||
|
{(b) => (
|
||||||
|
<VStack gap={4}>
|
||||||
<Card variant="muted">
|
<Card variant="muted">
|
||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
<HStack gap={2} vAlign="center">
|
<HStack gap={2} vAlign="center">
|
||||||
<Icon icon={ICONS.ai} size="sm" />
|
<Icon icon={ICONS.ai} size="sm" />
|
||||||
<Heading level={3}>Daily summary</Heading>
|
<Heading level={3}>Summary</Heading>
|
||||||
</HStack>
|
</HStack>
|
||||||
<Text size="sm" color="secondary">
|
<Text size="sm" color="secondary">
|
||||||
Footfall is tracking 8% above last Tuesday across all five stores.
|
{b.summary || 'Not enough activity in this period to summarise.'}
|
||||||
LYT redemption is concentrated in Indiranagar, which is running at
|
|
||||||
2.3× the network average.
|
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -34,44 +62,61 @@ export function AiTab() {
|
|||||||
<Text size="sm" color="secondary">
|
<Text size="sm" color="secondary">
|
||||||
Needs attention
|
Needs attention
|
||||||
</Text>
|
</Text>
|
||||||
<Banner
|
{b.alerts.length === 0 ? (
|
||||||
status="warning"
|
<EmptyPanel
|
||||||
title="Sales dropped 12% at Whitefield"
|
icon="present"
|
||||||
description="Down against the trailing 4-week Tuesday average."
|
title="All clear"
|
||||||
|
description="Nothing in this scope needs a decision right now."
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
b.alerts.map((a) => (
|
||||||
<Banner
|
<Banner
|
||||||
status="error"
|
key={a.id}
|
||||||
title="3 rewards expire in 48 hours"
|
status={BANNER_STATUS[a.severity]}
|
||||||
description="Free Coffee, Combo 20%, Weekend Bonus."
|
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>
|
||||||
|
|
||||||
<VStack gap={2}>
|
<VStack gap={2}>
|
||||||
<Text size="sm" color="secondary">
|
<Text size="sm" color="secondary">
|
||||||
Recommendations
|
Open tasks
|
||||||
</Text>
|
</Text>
|
||||||
<Card>
|
{b.tasks
|
||||||
|
.filter((t) => !t.isDone)
|
||||||
|
.map((t) => (
|
||||||
|
<Card key={t.id}>
|
||||||
<VStack gap={1.5}>
|
<VStack gap={1.5}>
|
||||||
<HStack gap={2} vAlign="center">
|
<HStack gap={2} vAlign="center" hAlign="between">
|
||||||
<Text weight="medium">Extend Weekend Bonus</Text>
|
<Text weight="medium">{t.label}</Text>
|
||||||
<Badge variant="success" label="High confidence" />
|
<Text size="xsm" color="secondary" textWrap="nowrap">
|
||||||
|
{t.due}
|
||||||
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
{t.detail ? (
|
||||||
<Text size="sm" color="secondary">
|
<Text size="sm" color="secondary">
|
||||||
It converts at 34% — the best of any active reward — and expires
|
{t.detail}
|
||||||
Friday.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
) : null}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Card>
|
</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>
|
</VStack>
|
||||||
</Card>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
|
)}
|
||||||
|
</AsyncBoundary>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import {VStack} from '@astryxdesign/core/Layout';
|
||||||
|
import {Button} from '@astryxdesign/core/Button';
|
||||||
import {PanelCard} from '@/components/patterns/PanelCard';
|
import {PanelCard} from '@/components/patterns/PanelCard';
|
||||||
import {ActivityFeed} from '@/components/patterns/ActivityItem';
|
import {ActivityFeed} from '@/components/patterns/ActivityItem';
|
||||||
import {SkeletonRows} from '@/components/patterns/LoadingState';
|
import {SkeletonRows} from '@/components/patterns/LoadingState';
|
||||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
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 {ActivityEvent, ActivityKind} from '@/lib/api/contracts';
|
||||||
|
import type {IconKey} from '@/lib/icons';
|
||||||
import type {Resource} from '@/lib/api/useResource';
|
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
|
* Each event kind gets its own glyph, so the feed can be skimmed by category
|
||||||
* event in this feed a merchant may need to act on; redemptions, purchases,
|
* without reading a word of it.
|
||||||
* check-ins and openings are the shop working normally, and colouring them
|
*
|
||||||
* would make the one thing that matters harder to find.
|
* 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}> = {
|
const KIND: Record<
|
||||||
reward_redeemed: {tone: 'neutral', toneLabel: 'Redemption'},
|
ActivityKind,
|
||||||
purchase: {tone: 'neutral', toneLabel: 'Purchase'},
|
{icon: IconKey; tone: ActivityTone; toneLabel: string}
|
||||||
staff_checked_in: {tone: 'neutral', toneLabel: 'Attendance'},
|
> = {
|
||||||
store_opened: {tone: 'neutral', toneLabel: 'Store'},
|
purchase: {icon: 'purchases', tone: 'neutral', toneLabel: 'Purchase'},
|
||||||
reward_expired: {tone: 'warning', toneLabel: 'Expiry'},
|
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[] {
|
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({
|
export function ActivityTimeline({
|
||||||
resource,
|
resource,
|
||||||
|
title = 'Recent activity',
|
||||||
|
subtitle = 'Live feed across the selected store and period',
|
||||||
|
limit,
|
||||||
|
height,
|
||||||
|
viewAllHref,
|
||||||
}: {
|
}: {
|
||||||
resource: Resource<ActivityEvent[]>;
|
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 (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Recent activity"
|
title={title}
|
||||||
subtitle="Live feed across the selected store and period"
|
subtitle={subtitle}
|
||||||
resource={resource}
|
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={
|
empty={
|
||||||
<EmptyPanel
|
<EmptyPanel
|
||||||
icon="alert"
|
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>
|
</PanelCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
|||||||
import {MetricCard} from '@/components/patterns/MetricCard';
|
import {MetricCard} from '@/components/patterns/MetricCard';
|
||||||
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
||||||
import {ICONS} from '@/lib/icons';
|
import {ICONS} from '@/lib/icons';
|
||||||
import {useBreakpoint, isPanelInline} from '@/lib/breakpoints';
|
import {useBreakpoint, metricColumns} from '@/lib/breakpoints';
|
||||||
import {useCopilot} from '@/features/copilot/CopilotProvider';
|
import {useCopilot} from '@/features/copilot/CopilotProvider';
|
||||||
import {formatCompact, formatInrCompact} from '@/lib/format';
|
import {formatCompact, formatInrCompact} from '@/lib/format';
|
||||||
import type {Kpi, KpiId} from '@/lib/api/contracts';
|
import type {Kpi, KpiId} from '@/lib/api/contracts';
|
||||||
@@ -32,11 +32,9 @@ const KPI_ICON: Record<KpiId, IconType> = {
|
|||||||
export function useMetricColumns(): number {
|
export function useMetricColumns(): number {
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const {isOpen} = useCopilot();
|
const {isOpen} = useCopilot();
|
||||||
|
// The rule itself lives in lib/breakpoints alongside every other layout
|
||||||
if (bp === 'mobile') return 1;
|
// threshold, so a breakpoint change is one edit rather than a hunt.
|
||||||
if (bp === 'tablet') return 2;
|
return metricColumns(bp, isOpen);
|
||||||
if (bp === 'laptop') return 2;
|
|
||||||
return isPanelInline(bp) && isOpen ? 2 : 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {
|
export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) {
|
|||||||
title={reward.name}
|
title={reward.name}
|
||||||
subtitle={
|
subtitle={
|
||||||
<HStack gap={1.5} vAlign="center">
|
<HStack gap={1.5} vAlign="center">
|
||||||
<Icon icon={ICONS.lyt} size="xsm" color="secondary" />
|
<Icon icon={ICONS.lyt} size="sm" color="secondary" />
|
||||||
<Text size="sm" color="secondary">
|
<Text size="base" weight="medium">
|
||||||
{formatLyt(reward.costLyt)}
|
{formatLyt(reward.costLyt)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -47,7 +47,7 @@ export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) {
|
|||||||
actions={<Badge variant={status.variant} label={status.label} />}
|
actions={<Badge variant={status.variant} label={status.label} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VStack gap={1.5}>
|
<VStack gap={2}>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
value={rate}
|
value={rate}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -55,10 +55,10 @@ export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) {
|
|||||||
hasValueLabel={false}
|
hasValueLabel={false}
|
||||||
/>
|
/>
|
||||||
<HStack hAlign="between">
|
<HStack hAlign="between">
|
||||||
<Text size="xsm" color="secondary">
|
<Text size="sm" weight="medium">
|
||||||
{formatCompact(reward.used)} redeemed
|
{formatCompact(reward.used)} redeemed
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xsm" color="secondary">
|
<Text size="sm" weight="medium">
|
||||||
{formatCompact(reward.claimed)} claimed
|
{formatCompact(reward.claimed)} claimed
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'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 type {TableColumn} from '@astryxdesign/core/Table';
|
||||||
import {Badge} from '@astryxdesign/core/Badge';
|
import {Badge} from '@astryxdesign/core/Badge';
|
||||||
import {Text} from '@astryxdesign/core/Text';
|
import {Text} from '@astryxdesign/core/Text';
|
||||||
@@ -45,7 +46,7 @@ export function RewardPerformanceTable({
|
|||||||
width: proportional(2),
|
width: proportional(2),
|
||||||
renderCell: (row) => (
|
renderCell: (row) => (
|
||||||
<HStack gap={2} vAlign="center">
|
<HStack gap={2} vAlign="center">
|
||||||
<Text size="sm" weight="medium">
|
<Text size="base" weight="semibold">
|
||||||
{row.name}
|
{row.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -58,44 +59,42 @@ export function RewardPerformanceTable({
|
|||||||
{
|
{
|
||||||
key: 'costLyt',
|
key: 'costLyt',
|
||||||
header: 'Cost',
|
header: 'Cost',
|
||||||
width: pixel(96),
|
width: pixel(104),
|
||||||
align: 'end',
|
align: 'end',
|
||||||
renderCell: (row) => <Text size="sm">{formatLyt(row.costLyt)}</Text>,
|
renderCell: (row) => <Text size="base" weight="medium">{formatLyt(row.costLyt)}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'claimed',
|
key: 'claimed',
|
||||||
header: 'Claimed',
|
header: 'Claimed',
|
||||||
width: pixel(96),
|
width: pixel(104),
|
||||||
align: 'end',
|
align: 'end',
|
||||||
renderCell: (row) => <Text size="sm">{formatCompact(row.claimed)}</Text>,
|
renderCell: (row) => <Text size="base" weight="medium">{formatCompact(row.claimed)}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'used',
|
key: 'used',
|
||||||
header: 'Redeemed',
|
header: 'Redeemed',
|
||||||
width: pixel(104),
|
width: pixel(112),
|
||||||
align: 'end',
|
align: 'end',
|
||||||
renderCell: (row) => <Text size="sm">{formatCompact(row.used)}</Text>,
|
renderCell: (row) => <Text size="base" weight="medium">{formatCompact(row.used)}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'rate',
|
key: 'rate',
|
||||||
header: 'Rate',
|
header: 'Rate',
|
||||||
width: pixel(104),
|
width: pixel(112),
|
||||||
align: 'end',
|
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) => (
|
renderCell: (row) => (
|
||||||
<HStack hAlign="end">
|
<HStack hAlign="end">
|
||||||
<MetricDelta value={row.rate - 50} isRiseGood size="xsm" />
|
<MetricDelta value={row.rate - 50} isRiseGood size="sm" />
|
||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'outstanding',
|
key: 'outstanding',
|
||||||
header: 'Outstanding',
|
header: 'Outstanding',
|
||||||
width: pixel(128),
|
width: pixel(140),
|
||||||
align: 'end',
|
align: 'end',
|
||||||
renderCell: (row) => (
|
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);
|
.sort((a, b) => b.rate - a.rate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<ResponsiveTable
|
||||||
data={rows}
|
data={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
idKey="id"
|
idKey="id"
|
||||||
|
primaryKey="name"
|
||||||
density="compact"
|
density="compact"
|
||||||
hasHover
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
380
src/features/settings/ApiWebhooksManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/features/settings/BillingOverview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/features/settings/BusinessForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
src/features/settings/IntegrationsGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,110 +8,147 @@ import {Text} from '@astryxdesign/core/Text';
|
|||||||
import {Divider} from '@astryxdesign/core/Divider';
|
import {Divider} from '@astryxdesign/core/Divider';
|
||||||
import {useToast} from '@astryxdesign/core/Toast';
|
import {useToast} from '@astryxdesign/core/Toast';
|
||||||
import {StaticPanel} from '@/components/patterns/PanelCard';
|
import {StaticPanel} from '@/components/patterns/PanelCard';
|
||||||
import type {MerchantProfile} from '@/lib/api/contracts';
|
|
||||||
|
|
||||||
type NotificationKey = keyof MerchantProfile['notifications'];
|
export interface EventNotification {
|
||||||
|
id: string;
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
}[] = [
|
email: boolean;
|
||||||
|
sms: boolean;
|
||||||
|
whatsapp: boolean;
|
||||||
|
push: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EVENTS: EventNotification[] = [
|
||||||
{
|
{
|
||||||
key: 'expiryAlerts',
|
id: 'dailySummary',
|
||||||
label: 'Reward expiry',
|
label: 'Daily Performance Summary',
|
||||||
description:
|
description: 'Daily morning dispatch with footfall, revenue and LYT redemptions.',
|
||||||
'When an active reward is within 3 days of lapsing, with the outstanding LYT liability.',
|
email: true,
|
||||||
|
sms: false,
|
||||||
|
whatsapp: true,
|
||||||
|
push: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'dailySummary',
|
id: 'expiryAlerts',
|
||||||
label: 'Daily summary',
|
label: 'Reward Expiry Warning',
|
||||||
description:
|
description: 'Alert when active rewards are within 48 hours of expiration.',
|
||||||
'Footfall, revenue and redemption for the previous day, each morning at 09:00.',
|
email: true,
|
||||||
|
sms: true,
|
||||||
|
whatsapp: true,
|
||||||
|
push: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'staffAbsence',
|
id: 'staffAbsence',
|
||||||
label: 'Staff absence',
|
label: 'Staff Absence & Late Check-in',
|
||||||
description:
|
description: 'Instant alert when a scheduled shift starts without rostered staff.',
|
||||||
'When a store opens with fewer than half its rostered team present.',
|
email: true,
|
||||||
|
sms: true,
|
||||||
|
whatsapp: false,
|
||||||
|
push: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'lowConversion',
|
id: 'lowConversion',
|
||||||
label: 'Low conversion',
|
label: 'Low Conversion Drop Alert',
|
||||||
description:
|
description: 'Triggered if a store conversion rate drops below 15% threshold.',
|
||||||
'When a store stays below 15% conversion for two consecutive days.',
|
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({
|
export function NotificationsForm() {
|
||||||
initialData,
|
|
||||||
}: {
|
|
||||||
initialData: MerchantProfile['notifications'];
|
|
||||||
}) {
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [value, setValue] = useState(initialData);
|
const [events, setEvents] = useState<EventNotification[]>(DEFAULT_EVENTS);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
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);
|
setIsSaving(true);
|
||||||
try {
|
setTimeout(() => {
|
||||||
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 {
|
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
toast({body: 'Multi-channel notification settings saved'});
|
||||||
}
|
}, 400);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StaticPanel
|
<StaticPanel
|
||||||
title="Alerts"
|
title="Notification Preferences & Channels"
|
||||||
subtitle="What Loyaly tells you about, and when."
|
subtitle="Configure delivery channels (Email, SMS, WhatsApp, Push) for business alerts."
|
||||||
actions={
|
actions={
|
||||||
<HStack gap={2} vAlign="center">
|
|
||||||
{isDirty ? (
|
|
||||||
<Text size="sm" color="secondary">
|
|
||||||
Unsaved changes
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
label="Save"
|
label="Save Preferences"
|
||||||
isDisabled={!isDirty}
|
|
||||||
isLoading={isSaving}
|
isLoading={isSaving}
|
||||||
onClick={save}
|
onClick={handleSave}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<VStack gap={0}>
|
<VStack gap={4}>
|
||||||
{ALERTS.map((a, i) => (
|
{events.map((evt, idx) => (
|
||||||
<VStack key={a.key} gap={0}>
|
<VStack key={evt.id} gap={3}>
|
||||||
<HStack paddingBlock={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
|
<Switch
|
||||||
label={a.label}
|
label="Email"
|
||||||
description={a.description}
|
value={evt.email}
|
||||||
value={value[a.key]}
|
onChange={() => toggleChannel(evt.id, 'email')}
|
||||||
onChange={(checked) =>
|
/>
|
||||||
setValue((v) => ({...v, [a.key]: checked}))
|
<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>
|
</HStack>
|
||||||
{i === ALERTS.length - 1 ? null : <Divider />}
|
</HStack>
|
||||||
|
{idx === events.length - 1 ? null : <Divider />}
|
||||||
</VStack>
|
</VStack>
|
||||||
))}
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
145
src/features/settings/PreferencesForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
211
src/features/settings/RoleMatrix.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
src/features/settings/SecurityManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/features/settings/SettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
src/features/settings/StoreManagement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
src/features/settings/TeamManagement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,22 +17,70 @@ export interface SettingsSection {
|
|||||||
*/
|
*/
|
||||||
export const SETTINGS_NAV: SettingsSection[] = [
|
export const SETTINGS_NAV: SettingsSection[] = [
|
||||||
{
|
{
|
||||||
label: 'Profile',
|
label: 'Business',
|
||||||
href: '/settings',
|
href: '/settings',
|
||||||
|
icon: ICONS.business,
|
||||||
|
description: 'Company details, tax IDs, registered address and support.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Profile',
|
||||||
|
href: '/settings/profile',
|
||||||
icon: ICONS.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',
|
label: 'Notifications',
|
||||||
href: '/settings/notifications',
|
href: '/settings/notifications',
|
||||||
icon: ICONS.notifications,
|
icon: ICONS.notifications,
|
||||||
description: 'What Loyaly alerts you about, and when.',
|
description: 'Email, SMS, WhatsApp and Push alert channels.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Billing',
|
label: 'Billing',
|
||||||
href: '/settings/billing',
|
href: '/settings/billing',
|
||||||
icon: ICONS.revenue,
|
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.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'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 type {TableColumn} from '@astryxdesign/core/Table';
|
||||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||||
import {Badge} from '@astryxdesign/core/Badge';
|
import {Badge} from '@astryxdesign/core/Badge';
|
||||||
@@ -164,12 +165,12 @@ export function Leaderboard({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<ResponsiveTable
|
||||||
data={rows}
|
data={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
idKey="id"
|
idKey="id"
|
||||||
|
primaryKey="name"
|
||||||
density="compact"
|
density="compact"
|
||||||
hasHover
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export async function fetchEndpoint<T>(
|
|||||||
import type {
|
import type {
|
||||||
ActivityEvent,
|
ActivityEvent,
|
||||||
AttendancePoint,
|
AttendancePoint,
|
||||||
|
DashboardBriefing,
|
||||||
Granularity,
|
Granularity,
|
||||||
HourCell,
|
HourCell,
|
||||||
Kpi,
|
Kpi,
|
||||||
@@ -74,6 +75,8 @@ import type {
|
|||||||
|
|
||||||
export const endpoints = {
|
export const endpoints = {
|
||||||
dashboardKpis: (s: Scope) => endpoint<Kpi[]>('/api/dashboard/kpis', s),
|
dashboardKpis: (s: Scope) => endpoint<Kpi[]>('/api/dashboard/kpis', s),
|
||||||
|
dashboardBriefing: (s: Scope) =>
|
||||||
|
endpoint<DashboardBriefing>('/api/dashboard/briefing', s),
|
||||||
dashboardTimeseries: (s: Scope) =>
|
dashboardTimeseries: (s: Scope) =>
|
||||||
endpoint<TimePoint[]>('/api/dashboard/timeseries', s),
|
endpoint<TimePoint[]>('/api/dashboard/timeseries', s),
|
||||||
dashboardPeakHours: (s: Scope) =>
|
dashboardPeakHours: (s: Scope) =>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* already the contract, not a description of one.
|
* 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 {
|
export interface ApiMeta {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
@@ -216,3 +216,31 @@ export interface Insight {
|
|||||||
body: string;
|
body: string;
|
||||||
action?: {label: string; href: 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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,19 +3,45 @@
|
|||||||
import {useSyncExternalStore} from 'react';
|
import {useSyncExternalStore} from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell breakpoints. These are layout decisions, not content decisions —
|
* Shell breakpoints.
|
||||||
* they choose whether the Copilot is an inline panel or a slide-over, which
|
|
||||||
* is a React-tree difference CSS alone cannot express.
|
|
||||||
*
|
*
|
||||||
* Anything that CAN be done in CSS should be: use Grid/Stack responsive props
|
* These are LAYOUT decisions — they choose whether the Copilot is an inline
|
||||||
* or Tailwind breakpoints instead of reaching for this hook.
|
* 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 640–1024
|
||||||
|
* laptop 1024–1440
|
||||||
|
* desktop 1440–1920
|
||||||
|
* 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][] = [
|
const QUERIES: [Breakpoint, string][] = [
|
||||||
['desktop', '(min-width: 1440px)'],
|
['ultrawide', `(min-width: ${BREAKPOINT_MIN.ultrawide}px)`],
|
||||||
['laptop', '(min-width: 1200px)'],
|
['desktop', `(min-width: ${BREAKPOINT_MIN.desktop}px)`],
|
||||||
['tablet', '(min-width: 768px)'],
|
['laptop', `(min-width: ${BREAKPOINT_MIN.laptop}px)`],
|
||||||
|
['tablet', `(min-width: ${BREAKPOINT_MIN.tablet}px)`],
|
||||||
];
|
];
|
||||||
|
|
||||||
function read(): Breakpoint {
|
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
|
* 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
|
* 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
|
* dropping out — no reflow of the content column.
|
||||||
* flash, seed AppShell's `mobileNav.defaultIsMobile` from the UA header in the
|
|
||||||
* root server layout; the prop exists for exactly this.
|
|
||||||
*/
|
*/
|
||||||
export function useBreakpoint(): Breakpoint {
|
export function useBreakpoint(): Breakpoint {
|
||||||
return useSyncExternalStore(subscribe, read, () => 'desktop');
|
return useSyncExternalStore(subscribe, read, () => 'desktop');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the Copilot should render inline rather than as a slide-over. */
|
/**
|
||||||
export function isPanelInline(bp: Breakpoint): boolean {
|
* True when the side nav renders as an inline rail. Below this AppShell moves
|
||||||
return bp === 'laptop' || bp === 'desktop';
|
* 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 {
|
* True when the Copilot renders as a third column.
|
||||||
return bp === 'desktop' ? 380 : 320;
|
*
|
||||||
|
* Tablet is excluded deliberately: at 640–1024 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 380–420px.
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,25 +13,35 @@
|
|||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Bell,
|
Bell,
|
||||||
|
Building2,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
ChartLine,
|
ChartLine,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
Clock,
|
Clock,
|
||||||
|
Code,
|
||||||
Coins,
|
Coins,
|
||||||
|
Columns3,
|
||||||
Footprints,
|
Footprints,
|
||||||
Gift,
|
Gift,
|
||||||
IndianRupee,
|
IndianRupee,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Lock,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Menu,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Mic,
|
Mic,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
Percent,
|
Percent,
|
||||||
|
Plug,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
Sliders,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Store,
|
Store,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
@@ -52,6 +62,14 @@ export const ICONS = {
|
|||||||
staff: Users,
|
staff: Users,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
|
||||||
|
// Settings Modules
|
||||||
|
business: Building2,
|
||||||
|
roles: ShieldCheck,
|
||||||
|
integrations: Plug,
|
||||||
|
api: Code,
|
||||||
|
security: Lock,
|
||||||
|
preferences: Sliders,
|
||||||
|
|
||||||
// Dashboard metrics
|
// Dashboard metrics
|
||||||
visitors: Footprints,
|
visitors: Footprints,
|
||||||
purchases: ShoppingCart,
|
purchases: ShoppingCart,
|
||||||
@@ -60,6 +78,9 @@ export const ICONS = {
|
|||||||
conversion: Percent,
|
conversion: Percent,
|
||||||
lyt: Coins,
|
lyt: Coins,
|
||||||
|
|
||||||
|
// Dashboard controls
|
||||||
|
compare: Columns3,
|
||||||
|
|
||||||
// Trend / delta
|
// Trend / delta
|
||||||
up: TrendingUp,
|
up: TrendingUp,
|
||||||
down: TrendingDown,
|
down: TrendingDown,
|
||||||
@@ -74,6 +95,14 @@ export const ICONS = {
|
|||||||
panelOpen: PanelRightOpen,
|
panelOpen: PanelRightOpen,
|
||||||
|
|
||||||
// Shell
|
// 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,
|
notifications: Bell,
|
||||||
profile: User,
|
profile: User,
|
||||||
signOut: LogOut,
|
signOut: LogOut,
|
||||||
|
|||||||
249
src/lib/mock/briefing.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ export const RANGE_DAYS: Record<RangeKey, number> = {
|
|||||||
'90d': 90,
|
'90d': 90,
|
||||||
mtd: 21,
|
mtd: 21,
|
||||||
ytd: 120,
|
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[] {
|
export function buildActivity(storeId: string, endMs: number): ActivityEvent[] {
|
||||||
const rng = createRng('activity', storeId);
|
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];
|
const tpl = ACTIVITY[i % ACTIVITY.length];
|
||||||
|
if (i > 0) minutesAgo += rng.int(6, 40);
|
||||||
return {
|
return {
|
||||||
id: `a${i}`,
|
id: `a${i}`,
|
||||||
at: new Date(endMs - i * rng.int(6, 40) * 60000).toISOString(),
|
at: new Date(endMs - minutesAgo * 60000).toISOString(),
|
||||||
kind: tpl.kind,
|
kind: tpl.kind,
|
||||||
title: tpl.title,
|
title: tpl.title,
|
||||||
detail: tpl.detail,
|
detail: tpl.detail,
|
||||||
|
|||||||
@@ -115,11 +115,15 @@ export function buildLytActivity(
|
|||||||
endMs: number,
|
endMs: number,
|
||||||
): ActivityEvent[] {
|
): ActivityEvent[] {
|
||||||
const rng = createRng('lyt-activity', storeId);
|
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];
|
const tpl = LYT_ACTIVITY[i % LYT_ACTIVITY.length];
|
||||||
|
if (i > 0) minutesAgo += rng.int(8, 50);
|
||||||
return {
|
return {
|
||||||
id: `l${i}`,
|
id: `l${i}`,
|
||||||
at: new Date(endMs - i * rng.int(8, 50) * 60000).toISOString(),
|
at: new Date(endMs - minutesAgo * 60000).toISOString(),
|
||||||
kind: tpl.kind,
|
kind: tpl.kind,
|
||||||
title: tpl.title,
|
title: tpl.title,
|
||||||
detail: tpl.detail,
|
detail: tpl.detail,
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import type {MerchantProfile} from '@/lib/api/contracts';
|
|||||||
export function readProfile(): MerchantProfile {
|
export function readProfile(): MerchantProfile {
|
||||||
return {
|
return {
|
||||||
businessName: 'Loyaly Retail Pvt Ltd',
|
businessName: 'Loyaly Retail Pvt Ltd',
|
||||||
contactName: 'Suriya N',
|
contactName: 'Aravind',
|
||||||
email: 'suriya@nearle.in',
|
email: 'aravind@nearle.in',
|
||||||
phone: '+91 98450 12345',
|
phone: '+91 98450 12345',
|
||||||
gstin: '29AABCL1234M1Z7',
|
gstin: '29AABCL1234M1Z7',
|
||||||
timezone: 'Asia/Kolkata',
|
timezone: 'Asia/Kolkata',
|
||||||
|
|||||||
86
src/lib/usePersistentFlag.ts
Normal 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];
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* @generated by `astryx theme build` — do not edit manually.
|
* @generated by `astryx theme build` — do not edit manually.
|
||||||
* Source: src/theme/loyalyTheme.ts
|
* Source: src/theme/loyalyTheme.ts
|
||||||
* Command: astryx theme build 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 {
|
@layer reset {
|
||||||
@@ -528,7 +528,7 @@
|
|||||||
|
|
||||||
.astryx-card {
|
.astryx-card {
|
||||||
border-color: var(--color-border);
|
border-color: var(--color-border);
|
||||||
--astryx-card-padding: var(--spacing-3);
|
--astryx-card-padding: var(--spacing-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.astryx-section {
|
.astryx-section {
|
||||||
@@ -545,6 +545,24 @@
|
|||||||
border-color: var(--color-border-emphasized);
|
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-text.primary { color: var(--color-text-primary); }
|
||||||
|
|
||||||
.astryx-heading.primary { color: var(--color-text-primary); }
|
.astryx-heading.primary { color: var(--color-text-primary); }
|
||||||
|
|||||||
2
src/theme/loyaly.d.ts
vendored
@@ -2,7 +2,7 @@
|
|||||||
* @generated by `astryx theme build` — do not edit manually.
|
* @generated by `astryx theme build` — do not edit manually.
|
||||||
* Source: src/theme/loyalyTheme.ts
|
* Source: src/theme/loyalyTheme.ts
|
||||||
* Command: astryx theme build 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" />
|
/// <reference path="./loyaly.variants.d.ts" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @generated by `astryx theme build` — do not edit manually.
|
* @generated by `astryx theme build` — do not edit manually.
|
||||||
* Source: src/theme/loyalyTheme.ts
|
* Source: src/theme/loyalyTheme.ts
|
||||||
* Command: astryx theme build 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
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
2
src/theme/loyaly.variants.d.ts
vendored
@@ -2,7 +2,7 @@
|
|||||||
* @generated by `astryx theme build` — do not edit manually.
|
* @generated by `astryx theme build` — do not edit manually.
|
||||||
* Source: src/theme/loyalyTheme.ts
|
* Source: src/theme/loyalyTheme.ts
|
||||||
* Command: astryx theme build 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
|
// Generated by astryx theme build
|
||||||
|
|||||||
@@ -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: {
|
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.
|
// Status dots: neutral goes gray, semantic keeps hue.
|
||||||
|
|||||||