dashboard design changes
This commit is contained in:
3
src/features/auth/LoginForm.tsx
Normal file
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
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">
|
||||
<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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]>}) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
380
src/features/settings/ApiWebhooksManager.tsx
Normal file
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
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
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
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 {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>
|
||||
|
||||
145
src/features/settings/PreferencesForm.tsx
Normal file
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
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
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
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
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
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[] = [
|
||||
{
|
||||
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.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user