dashboard design changes

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,213 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Card} from '@astryxdesign/core/Card';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Button} from '@astryxdesign/core/Button';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
import {StaticPanel} from '@/components/patterns/PanelCard';
export interface IntegrationApp {
id: string;
name: string;
category: string;
description: string;
status: 'connected' | 'disconnected' | 'syncing';
apiKeyLabel?: string;
lastSync?: string;
}
const INITIAL_APPS: IntegrationApp[] = [
{
id: 'shopify',
name: 'Shopify Storefront',
category: 'E-commerce POS',
description: 'Sync online customer orders, cart totals and auto-issue LYT points.',
status: 'connected',
apiKeyLabel: 'myshopify-store.myshopify.com',
lastSync: '5 mins ago',
},
{
id: 'woocommerce',
name: 'WooCommerce',
category: 'E-commerce POS',
description: 'WordPress store integration for online checkout redemption.',
status: 'disconnected',
},
{
id: 'razorpay',
name: 'Razorpay PG',
category: 'Payment Gateway',
description: 'Automated settlement reconciliation and online UPI payment sync.',
status: 'connected',
apiKeyLabel: 'rzp_live_••••••••8841',
lastSync: 'Just now',
},
{
id: 'stripe',
name: 'Stripe Billing',
category: 'Payment Gateway',
description: 'International cards and subscription recurring billing handler.',
status: 'disconnected',
},
{
id: 'whatsapp',
name: 'WhatsApp Business API',
category: 'Messaging Channel',
description: 'Send instant transaction receipts, reward expiry notices and OTPs.',
status: 'connected',
apiKeyLabel: '+91 98450 12345 (Verified)',
lastSync: '2 mins ago',
},
{
id: 'google',
name: 'Google Business Profile',
category: 'Local Search & Maps',
description: 'Sync branch operating hours, store locations and customer reviews.',
status: 'connected',
apiKeyLabel: '5 locations synced',
lastSync: '1 hour ago',
},
{
id: 'meta',
name: 'Meta Ads & Conversions',
category: 'Marketing & Ads',
description: 'Track ad attribution, return on ad spend (ROAS) and retargeting.',
status: 'disconnected',
},
];
export function IntegrationsGrid() {
const toast = useToast();
const [apps, setApps] = useState<IntegrationApp[]>(INITIAL_APPS);
const [selectedApp, setSelectedApp] = useState<IntegrationApp | null>(null);
const [apiKeyInput, setApiKeyInput] = useState('');
const toggleConnection = (app: IntegrationApp) => {
if (app.status === 'connected') {
setApps((prev) =>
prev.map((a) =>
a.id === app.id ? {...a, status: 'disconnected', lastSync: undefined} : a,
),
);
toast({body: `Disconnected ${app.name}`});
} else {
setSelectedApp(app);
setApiKeyInput('');
}
};
const handleConfirmConnect = () => {
if (!selectedApp) return;
setApps((prev) =>
prev.map((a) =>
a.id === selectedApp.id
? {
...a,
status: 'connected',
apiKeyLabel: apiKeyInput || 'Configured',
lastSync: 'Just now',
}
: a,
),
);
toast({body: `Successfully connected ${selectedApp.name}`});
setSelectedApp(null);
};
return (
<VStack gap={5}>
<StaticPanel
title="Third-Party Integrations & Connectors"
subtitle="Connect E-commerce storefronts, payment gateways, messaging services and advertising platforms."
>
<VStack gap={4}>
{selectedApp ? (
<Card variant="muted">
<VStack gap={3}>
<Heading level={3}>Connect {selectedApp.name}</Heading>
<Text size="sm" color="secondary">
{selectedApp.description}
</Text>
<TextInput
label="API Key / Store URL / Identifier"
value={apiKeyInput}
onChange={setApiKeyInput}
placeholder="Paste live secret key or domain URL"
/>
<HStack gap={2}>
<Button
variant="primary"
size="sm"
label="Establish Connection"
onClick={handleConfirmConnect}
/>
<Button
variant="secondary"
size="sm"
label="Cancel"
onClick={() => setSelectedApp(null)}
/>
</HStack>
</VStack>
</Card>
) : null}
<Grid columns={{minWidth: 320, max: 2, repeat: 'fit'}} gap={4}>
{apps.map((app) => {
const isConn = app.status === 'connected';
return (
<Card key={app.id}>
<VStack gap={3}>
<HStack hAlign="between" vAlign="start">
<VStack gap={0.5}>
<Text weight="medium" size="sm">
{app.name}
</Text>
<Text size="sm" color="secondary">
{app.category}
</Text>
</VStack>
<Badge
variant={isConn ? 'success' : 'neutral'}
label={isConn ? 'Connected' : 'Disconnected'}
/>
</HStack>
<Text size="sm" color="secondary">
{app.description}
</Text>
{isConn && app.apiKeyLabel ? (
<HStack hAlign="between" vAlign="center">
<Text size="sm" color="secondary">
{app.apiKeyLabel}
</Text>
{app.lastSync ? (
<Text size="sm" color="secondary">
Synced {app.lastSync}
</Text>
) : null}
</HStack>
) : null}
<Button
variant={isConn ? 'secondary' : 'primary'}
size="sm"
label={isConn ? 'Disconnect' : 'Connect & Configure'}
onClick={() => toggleConnection(app)}
/>
</VStack>
</Card>
);
})}
</Grid>
</VStack>
</StaticPanel>
</VStack>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,22 +17,70 @@ export interface SettingsSection {
*/
export const SETTINGS_NAV: SettingsSection[] = [
{
label: 'Profile',
label: 'Business',
href: '/settings',
icon: ICONS.business,
description: 'Company details, tax IDs, registered address and support.',
},
{
label: 'Profile',
href: '/settings/profile',
icon: ICONS.profile,
description: 'Business details, contact and tax registration.',
description: 'Merchant contact details, timezone and personal info.',
},
{
label: 'Team & Staff',
href: '/settings/team',
icon: ICONS.staff,
description: 'Staff directory, store assignments, roles and status.',
},
{
label: 'Stores',
href: '/settings/stores',
icon: ICONS.stores,
description: 'Manage store locations, operating hours and managers.',
},
{
label: 'Roles & Permissions',
href: '/settings/roles',
icon: ICONS.roles,
description: 'Access levels, module permissions and role matrices.',
},
{
label: 'Notifications',
href: '/settings/notifications',
icon: ICONS.notifications,
description: 'What Loyaly alerts you about, and when.',
description: 'Email, SMS, WhatsApp and Push alert channels.',
},
{
label: 'Billing',
href: '/settings/billing',
icon: ICONS.revenue,
description: 'Plan, invoices and LYT settlement.',
description: 'Subscription plans, payment methods and LYT settlement.',
},
{
label: 'Integrations',
href: '/settings/integrations',
icon: ICONS.integrations,
description: 'Connect Shopify, WooCommerce, Razorpay, WhatsApp and Meta.',
},
{
label: 'API & Webhooks',
href: '/settings/api',
icon: ICONS.api,
description: 'Developer API keys, webhook endpoints and delivery logs.',
},
{
label: 'Security',
href: '/settings/security',
icon: ICONS.security,
description: '2FA authentication, active sessions and audit history.',
},
{
label: 'Preferences',
href: '/settings/preferences',
icon: ICONS.preferences,
description: 'System theme, default currency, language and formats.',
},
];

View File

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