first commit

This commit is contained in:
2026-09-25 16:31:10 +05:30
parent e43cebb43e
commit 88f658fba9
378 changed files with 33242 additions and 115 deletions

View File

@@ -0,0 +1,58 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {Avatar} from '@astryxdesign/core/Avatar';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import {useSession} from '@/features/auth/providers/SessionProvider';
/**
* Who you are signed in as.
*
* Read from the SESSION rather than fetched: it is the same object the proxy
* verified and the server rendered with, so this cannot disagree with what the
* rest of the console believes about the current user, and it costs no request.
*
* A plain Card rather than PanelCard because there is nothing async here —
* PanelCard exists to wrap a Resource, and passing it a fake one to reuse the
* header would be the tail wagging the dog.
*
* There is no edit: a merchant cannot change their own role, and the platform
* serves no self-service profile endpoint.
*/
export function AccountCard() {
const {user} = useSession();
if (!user) return null;
return (
<Card>
<VStack gap={4}>
<SectionHeader
title="Account"
subtitle="The account this browser is signed in as."
level={2}
/>
<HStack gap={3} vAlign="center">
<Avatar name={user.name} size="md" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{user.name}
</Text>
<Text size="xsm" color="secondary">
{user.email}
</Text>
</VStack>
<Badge variant="neutral" label={user.role} />
{user.organisation ? (
<Text size="xsm" color="secondary">
{user.organisation}
</Text>
) : null}
</HStack>
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,383 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/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 '@/shared/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',
align: 'center',
width: pixel(120),
renderCell: (row) => (
<HStack hAlign="center">
<Button
size="sm"
variant="ghost"
label="Revoke"
onClick={() => handleRevokeKey(row.id, row.name)}
/>
</HStack>
),
},
];
const webhookColumns: TableColumn<WebhookEndpointItem>[] = [
{
key: 'url',
header: 'Endpoint URL',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.url}
</Text>
<Text size="sm" color="secondary">
Events: {row.events}
</Text>
</VStack>
),
},
{
key: 'secret',
header: 'Signing Secret',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.secret}
</Text>
),
},
{
key: 'status',
header: 'Health',
width: pixel(100),
renderCell: (row) => (
<Badge
variant={row.status === 'healthy' ? 'success' : 'error'}
label={row.status}
/>
),
},
];
const logColumns: TableColumn<WebhookLogItem>[] = [
{
key: 'event',
header: 'Event',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.event}
</Text>
),
},
{
key: 'url',
header: 'Target URL',
width: proportional(2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.url}
</Text>
),
},
{
key: 'code',
header: 'HTTP Code',
width: pixel(100),
renderCell: (row) => (
<Badge
variant={row.code === 200 ? 'success' : 'error'}
label={`${row.code} OK`}
/>
),
},
{
key: 'latency',
header: 'Latency',
width: pixel(90),
renderCell: (row) => <Text size="sm">{row.latency}</Text>,
},
{
key: 'timestamp',
header: 'Timestamp',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.timestamp}
</Text>
),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Developer API Keys"
subtitle="Manage authentication tokens for custom POS clients and server SDKs."
actions={
<Button
variant="primary"
size="sm"
label={isGenerating ? 'Cancel' : 'Generate Secret Key'}
onClick={() => setIsGenerating(!isGenerating)}
/>
}
>
<VStack gap={4}>
{isGenerating ? (
<HStack gap={3} vAlign="end" wrap="wrap">
<TextInput
label="API Key Description"
value={newKeyName}
onChange={setNewKeyName}
placeholder="e.g. Indiranagar Counter 2 POS"
/>
<Button
variant="primary"
size="sm"
label="Generate Token"
onClick={handleGenerateKey}
/>
</HStack>
) : null}
<ResponsiveTable
columns={keyColumns}
data={keys}
primaryKey="name"
/>
</VStack>
</StaticPanel>
<StaticPanel
title="Webhook Subscriptions & Endpoints"
subtitle="Real-time HTTP POST callbacks for order, redemption and staff events."
>
<VStack gap={4}>
<HStack gap={3} vAlign="end" wrap="wrap">
<TextInput
label="Add Webhook Endpoint URL"
value={newWebhookUrl}
onChange={setNewWebhookUrl}
placeholder="https://yourdomain.com/webhooks/loyaly"
/>
<Button
variant="primary"
size="sm"
label="Add Endpoint"
onClick={handleAddWebhook}
/>
</HStack>
<ResponsiveTable
columns={webhookColumns}
data={webhooks}
primaryKey="url"
/>
</VStack>
</StaticPanel>
<StaticPanel
title="Webhook Delivery Logs"
subtitle="Recent HTTP delivery attempts, latency and status response codes."
>
<ResponsiveTable
columns={logColumns}
data={WEBHOOK_LOGS}
primaryKey="event"
/>
</StaticPanel>
</VStack>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,157 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Switch} from '@astryxdesign/core/Switch';
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 '@/shared/components/patterns/PanelCard';
export interface EventNotification {
id: string;
label: string;
description: string;
email: boolean;
sms: boolean;
whatsapp: boolean;
push: boolean;
}
const DEFAULT_EVENTS: EventNotification[] = [
{
id: 'dailySummary',
label: 'Daily Performance Summary',
description: 'Daily morning dispatch with footfall, revenue and LYT redemptions.',
email: true,
sms: false,
whatsapp: true,
push: true,
},
{
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,
},
{
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,
},
{
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() {
const toast = useToast();
const [events, setEvents] = useState<EventNotification[]>(DEFAULT_EVENTS);
const [isSaving, setIsSaving] = useState(false);
const toggleChannel = (
id: string,
channel: 'email' | 'sms' | 'whatsapp' | 'push',
) => {
setEvents((prev) =>
prev.map((e) => (e.id === id ? {...e, [channel]: !e[channel]} : e)),
);
};
const handleSave = () => {
setIsSaving(true);
setTimeout(() => {
setIsSaving(false);
toast({body: 'Multi-channel notification settings saved'});
}, 400);
};
return (
<StaticPanel
title="Notification Preferences & Channels"
subtitle="Configure delivery channels (Email, SMS, WhatsApp, Push) for business alerts."
actions={
<Button
variant="primary"
size="sm"
label="Save Preferences"
isLoading={isSaving}
onClick={handleSave}
/>
}
>
<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>
{idx === events.length - 1 ? null : <Divider />}
</VStack>
))}
</VStack>
</StaticPanel>
);
}

View File

@@ -0,0 +1,154 @@
'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 '@/shared/components/patterns/PanelCard';
import {useThemeMode} from '@/shared/providers/ThemeModeProvider';
import type {ThemeMode} from '@/shared/theme/themeMode';
/** Theme mode is deliberately absent — it lives in ThemeModeProvider. */
export interface SystemPreferences {
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 = {
currency: 'INR',
language: 'en',
dateFormat: 'DD/MM/YYYY',
defaultLanding: '/dashboard',
};
export function PreferencesForm() {
const toast = useToast();
/**
* Theme is the one preference that does NOT go through the save cycle.
* It repaints the workspace the instant it changes and persists itself to
* the theme-mode cookie, so there is nothing left for Save to commit — and
* a mode you can see but have not "saved" would be the confusing state.
* The rest of the form is still mock local state.
*/
const {mode: themeMode, setMode: setThemeMode} = useThemeMode();
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={themeMode}
onChange={(v) => setThemeMode(v as ThemeMode)}
options={[
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
{label: 'Light Mode', value: 'light'},
{label: 'System OS Preference', value: 'system'},
]}
/>
<Selector
label="Reporting Base Currency"
value={prefs.currency}
onChange={(v) => set('currency', v as SystemPreferences['currency'])}
options={[
{label: 'INR (₹ Indian Rupee)', value: 'INR'},
{label: 'USD ($ US Dollar)', value: 'USD'},
{label: 'EUR (€ Euro)', value: 'EUR'},
{label: 'GBP (£ British Pound)', value: 'GBP'},
]}
/>
</FormLayout>
<FormLayout direction="horizontal">
<Selector
label="Workspace Language"
value={prefs.language}
onChange={(v) => set('language', v as SystemPreferences['language'])}
options={[
{label: 'English (US & India)', value: 'en'},
{label: 'Hindi (हिंदी)', value: 'hi'},
{label: 'Kannada (ಕನ್ನಡ)', value: 'kn'},
{label: 'Tamil (தமிழ்)', value: 'ta'},
]}
/>
<Selector
label="Date Formatting"
value={prefs.dateFormat}
onChange={(v) => set('dateFormat', v as SystemPreferences['dateFormat'])}
options={[
{label: 'DD/MM/YYYY (e.g. 05/08/2026)', value: 'DD/MM/YYYY'},
{label: 'MM/DD/YYYY (e.g. 08/05/2026)', value: 'MM/DD/YYYY'},
{label: 'YYYY-MM-DD (e.g. 2026-08-05)', value: 'YYYY-MM-DD'},
]}
/>
</FormLayout>
<FormLayout direction="horizontal">
<Selector
label="Default Home Landing Module"
value={prefs.defaultLanding}
onChange={(v) => set('defaultLanding', v as SystemPreferences['defaultLanding'])}
options={[
{label: 'Dashboard Overview', value: '/dashboard'},
{label: 'Store Locations', value: '/stores'},
{label: 'Lyts & Rewards Catalogue', value: '/lyts'},
{label: 'Staff Roster & Performance', value: '/staff'},
]}
/>
</FormLayout>
</VStack>
</StaticPanel>
);
}

View File

@@ -0,0 +1,165 @@
'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 {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 '@/shared/components/patterns/PanelCard';
import {settingsService} from '@/features/settings/services/settingsService';
import type {ProfileFieldErrors} from '@/features/settings/services/settingsService';
import type {MerchantProfile} from '@/features/settings/types/settings';
/**
* Merchant profile.
*
* `initialData` arrives from a Server Component that read the record
* DIRECTLY — no HTTP round trip — so the form paints filled on first render
* rather than flashing empty inputs. This is the hybrid path the data layer
* was designed for: dashboards fetch on the client because their scope
* changes constantly, but a settings form has exactly one server-known state
* at page load and should use it.
*
* Status is only ever set on submit, never at initial paint — Astryx's
* FieldStatus breaks SSR hydration when it is server-rendered with a status
* already applied (see AGENTS.md).
*/
export function ProfileForm({initialData}: {initialData: MerchantProfile}) {
const toast = useToast();
const [form, setForm] = useState(initialData);
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<ProfileFieldErrors>({});
const set = <K extends keyof MerchantProfile>(
key: K,
value: MerchantProfile[K],
) => setForm((f) => ({...f, [key]: value}));
const isDirty = JSON.stringify(form) !== JSON.stringify(initialData);
async function save() {
// Validation and transport both live in settingsService — this component
// decides what to render, never what a valid profile is or where it goes.
// The raw fetch that used to sit here was the last one in the codebase.
setIsSaving(true);
const result = await settingsService.saveProfile(form);
setIsSaving(false);
if (result.ok) {
setErrors({});
toast({body: 'Profile saved'});
return;
}
setErrors(result.errors);
if (result.message) {
toast({type: 'error', body: result.message});
}
}
return (
<StaticPanel
title="Business profile"
subtitle="Shown on receipts and used for LYT settlement."
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(initialData);
setErrors({});
}}
/>
<Button
variant="primary"
size="sm"
label="Save changes"
isDisabled={!isDirty}
isLoading={isSaving}
onClick={save}
/>
</HStack>
}
>
<VStack gap={5}>
<FormLayout direction="horizontal">
<TextInput
label="Business name"
value={form.businessName}
onChange={(v) => set('businessName', v)}
isRequired
status={
errors.businessName
? {type: 'error', message: errors.businessName}
: undefined
}
/>
<TextInput
label="Contact name"
value={form.contactName}
onChange={(v) => set('contactName', v)}
/>
</FormLayout>
<FormLayout direction="horizontal">
<TextInput
label="Email"
type="email"
value={form.email}
onChange={(v) => set('email', v)}
isRequired
status={
errors.email ? {type: 'error', message: errors.email} : undefined
}
/>
<TextInput
label="Phone"
value={form.phone}
onChange={(v) => set('phone', v)}
/>
</FormLayout>
<Divider />
<FormLayout direction="horizontal">
<TextInput
label="GSTIN"
value={form.gstin}
onChange={(v) => set('gstin', v.toUpperCase())}
description="15-character GST registration number"
status={
errors.gstin ? {type: 'error', message: errors.gstin} : undefined
}
/>
<TextInput
label="Timezone"
value={form.timezone}
onChange={(v) => set('timezone', v)}
description="All reports are rendered in this zone"
/>
</FormLayout>
<NumberInput
label="LYTs per ₹100 spent"
value={form.lytsPerHundred}
onChange={(v) => set('lytsPerHundred', v ?? 0)}
min={0}
max={100}
description="1 LYT = ₹1. At 5, a ₹1,000 basket earns 50 LYTs — ₹50 of future liability."
width={320}
/>
</VStack>
</StaticPanel>
);
}

View File

@@ -0,0 +1,221 @@
'use client';
import {useState} from 'react';
import {proportional} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/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 '@/shared/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 (
<HStack hAlign="center" width="full">
<div className="w-[108px] flex justify-center">
<Button
size="sm"
variant={isGranted ? 'primary' : 'secondary'}
isDisabled={role === 'Owner'}
label={isGranted ? 'Granted' : 'No Access'}
onClick={() => togglePermission(row.module, role)}
className="w-full flex justify-center text-center"
/>
</div>
</HStack>
);
};
const columns: TableColumn<PermissionRow>[] = [
{
key: 'module',
header: 'Module / Feature Area',
width: proportional(3),
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',
align: 'center',
width: proportional(1),
renderCell: (row) => renderCheck(row, 'Owner'),
},
{
key: 'Manager',
header: 'Manager',
align: 'center',
width: proportional(1),
renderCell: (row) => renderCheck(row, 'Manager'),
},
{
key: 'Cashier',
header: 'Cashier',
align: 'center',
width: proportional(1),
renderCell: (row) => renderCheck(row, 'Cashier'),
},
{
key: 'Marketing',
header: 'Marketing',
align: 'center',
width: proportional(1),
renderCell: (row) => renderCheck(row, 'Marketing'),
},
{
key: 'Support',
header: 'Support',
align: 'center',
width: proportional(1),
renderCell: (row) => renderCheck(row, 'Support'),
},
];
return (
<VStack gap={5}>
<StaticPanel
title="Role Access Control Matrix"
subtitle="Configure granular module access levels for Owner, Manager, Cashier, Marketing and Support roles."
actions={
<Button
variant="primary"
size="sm"
label="Save Permissions"
isLoading={isSaving}
onClick={handleSave}
/>
}
>
<VStack gap={4}>
<HStack gap={2} wrap="wrap">
<Badge variant="info" label="Owner (Full Root Access)" />
<Badge variant="neutral" label="Manager (Operational Controls)" />
<Badge variant="neutral" label="Cashier (POS & Rewards)" />
<Badge variant="neutral" label="Marketing (Campaigns & Analytics)" />
<Badge variant="neutral" label="Support (Staff Roster View)" />
</HStack>
<ResponsiveTable
columns={columns}
data={matrix}
primaryKey="module"
/>
</VStack>
</StaticPanel>
</VStack>
);
}

View File

@@ -0,0 +1,320 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/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 {PanelCard, StaticPanel} from '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {useResource} from '@/shared/hooks/useResource';
import {securityRepository} from '@/features/settings/repositories/securityRepository';
import type {DeviceSession} from '@/features/settings/services/mapSession';
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 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);
/**
* Real devices, from GET /api/auth/sessions.
*
* This was three invented rows in useState, with a `location` and an `ip` the
* platform does not return at all — a merchant checking for an unfamiliar
* sign-in was reading fiction. Revoking one spliced the local array and the
* row came back on refresh.
*
* Everything else in this file — the 2FA switch, the password form and the
* audit trail — is still local-only. Those have no endpoint on the platform
* and are deliberately left as they were rather than deleted.
*/
const sessions = useResource(securityRepository.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 = async (id: string) => {
const res = await securityRepository.revoke(id);
if (!res.ok) {
toast({type: 'error', body: res.message ?? 'Could not sign that device out.'});
return;
}
// Re-read rather than splice: the platform decides what is still live, and
// a locally filtered array is a guess that survives until the next refresh.
sessions.refetch();
toast({body: 'That device has been signed out.'});
};
const handleRevokeAllOther = async () => {
const res = await securityRepository.revokeOthers();
if (!res.ok) {
toast({type: 'error', body: res.message ?? 'Could not sign the other devices out.'});
return;
}
sessions.refetch();
toast({body: 'Every other device has been signed out.'});
};
const sessionColumns: TableColumn<DeviceSession & Record<string, unknown>>[] = [
{
key: 'device',
header: 'Device',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{row.device}
</Text>
{row.isCurrent ? (
<Badge variant="success" label="This browser" />
) : null}
</HStack>
),
},
{
key: 'lastUsedAt',
header: 'Last used',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{new Date(row.lastUsedAt).toLocaleString()}
</Text>
),
},
{
key: 'actions',
header: 'Action',
align: 'center',
width: pixel(120),
// The current session CAN be revoked upstream, but doing it from this
// table signs you out mid-page with no warning. Signing out is what the
// account menu is for.
renderCell: (row) =>
row.isCurrent ? null : (
<HStack hAlign="center">
<Button
size="sm"
variant="ghost"
label="Sign out"
onClick={() => void handleRevokeSession(row.id)}
/>
</HStack>
),
},
];
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>
<PanelCard
title="Active sessions"
subtitle="Every device signed in as you."
resource={sessions}
loading={<SkeletonRows count={3} />}
empty={
<EmptyPanel
icon="security"
title="No other devices"
description="Only this browser is signed in."
/>
}
actions={
<Button
variant="secondary"
size="sm"
label="Sign out everywhere else"
onClick={() => void handleRevokeAllOther()}
/>
}
>
{(rows) => (
<ResponsiveTable
columns={sessionColumns}
data={rows as (DeviceSession & Record<string, unknown>)[]}
idKey="id"
primaryKey="device"
/>
)}
</PanelCard>
<StaticPanel title="Login Audit Trail" subtitle="Recent account authentication events and security attempts.">
<ResponsiveTable
columns={auditColumns}
data={AUDIT_LOGS}
primaryKey="user"
/>
</StaticPanel>
</VStack>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/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 Loyaly AI 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 Loyaly AI rail
* Top 32px (spacing-8) — the title never touches the header
* Bottom 48px (pb-12) — the last card is never flush to the fold
* Header → body 32px (spacing-8) — title block reads as its own layer
* Between blocks 24px (spacing-6) — cards, tables and toolbars
*
* The bottom padding goes through the Tailwind bridge rather than a prop
* because Astryx's `SpacingStep` union stops at 10 (40px) and there is no
* `paddingBlockEnd` on Stack. `pb-12` resolves to `--spacing-12` (48px), so it
* is still a token — no raw pixel value enters the codebase. It lands in the
* `utilities` layer, which the cascade puts after `astryx-base`, so it wins
* over the `paddingBlock` set below without `!important`.
*
* Anything that needs a different rhythm should take a prop here rather than
* override locally — that is the whole point of a single container.
*/
export function SettingsPage({
title,
description,
actions,
toolbar,
children,
}: {
title: string;
description?: string;
/** Page-level verbs — Save, Add store, Rotate key. */
actions?: React.ReactNode;
/** Filters or segmented controls that scope this page's content. */
toolbar?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<VStack
as="section"
paddingInline={8}
paddingBlock={8}
gap={8}
className="pb-12"
width="100%"
>
<PageHeader
title={title}
description={description}
actions={actions}
/>
{/* 24px between the body's own blocks — tighter than the 32px that
separates the header from the body, so the page reads as
"title, then a group of related panels" rather than a flat list. */}
<VStack gap={6} width="100%">
{toolbar}
{children}
</VStack>
</VStack>
);
}

View File

@@ -0,0 +1,221 @@
'use client';
import {useState} from 'react';
import {proportional, pixel} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {useToast} from '@astryxdesign/core/Toast';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {hasRole} from '@/features/auth/services/roles';
import {useSites} from '@/features/stores/hooks/useSites';
import {OpenShopDialog} from '@/features/stores/components/OpenShopDialog';
import {RenameShopDialog} from '@/features/stores/components/RenameShopDialog';
import {RemoveShopDialog} from '@/features/stores/components/RemoveShopDialog';
import type {Site} from '@/features/stores/types/site';
type SiteRow = Site & Record<string, unknown>;
/**
* The company's shops, from the platform — open, rename, remove.
*
* This screen used to be a hardcoded list of five Bengaluru shops held in
* local state: adding one "worked" until the page reloaded. It now reads
* GET /api/sites and writes through POST / PATCH / DELETE on the same
* resource, and every write re-reads rather than splicing local state.
*
* Who is offered what mirrors the platform's own rules — opening and removing
* a shop is the owner's, renaming is a manager's too — but the platform is
* what enforces them; hiding a button is courtesy, not security.
*
* Cameras and the shop PC are set up per shop on the Store page, not here:
* this is the directory, that is the equipment.
*/
export function StoreManagement() {
const toast = useToast();
const sites = useSites();
const {refreshStores} = useWorkspace();
const {user} = useSession();
const isOwner = hasRole(user?.role, 'owner');
const isManager = hasRole(user?.role, 'manager');
const [opening, setOpening] = useState(false);
const [renaming, setRenaming] = useState<Site | null>(null);
const [removing, setRemoving] = useState<Site | null>(null);
/** The settings table and the global switcher each hold a copy; refresh both. */
function reload() {
sites.refetch();
refreshStores();
}
const columns: TableColumn<SiteRow>[] = [
{
key: 'name',
header: 'Shop',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="sm" color="secondary" className="font-mono">
{row.id}
</Text>
</VStack>
),
},
{
key: 'timezone',
header: 'Timezone',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.timezone || '—'}
</Text>
),
},
{
key: 'isOnline',
header: 'Shop PC',
width: pixel(150),
renderCell: (row) => (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={row.isOnline === null ? 'neutral' : row.isOnline ? 'success' : 'error'}
label={row.isOnline === null ? 'Not reported' : row.isOnline ? 'Online' : 'Offline'}
/>
<Text size="sm" color="secondary">
{row.isOnline === null ? 'Not reported' : row.isOnline ? 'Online' : 'Offline'}
</Text>
</HStack>
),
},
{
key: 'camerasUp',
header: 'Cameras',
width: pixel(110),
renderCell: (row) => (
<Text size="sm" weight="medium">
{row.camerasUp === null || row.camerasTotal === null
? '—'
: `${row.camerasUp}/${row.camerasTotal} up`}
</Text>
),
},
{
key: 'actions',
header: 'Actions',
align: 'center',
width: pixel(140),
renderCell: (row) =>
isManager ? (
<HStack hAlign="center">
<DropdownMenu
button={{
variant: 'secondary',
size: 'sm',
label: 'Manage',
icon: <Icon icon="moreHorizontal" size="sm" />,
}}
menuWidth={180}
items={[
{label: 'Rename', onClick: () => setRenaming(row)},
...(isOwner
? [
{type: 'divider' as const},
{label: 'Remove shop', onClick: () => setRemoving(row)},
]
: []),
]}
/>
</HStack>
) : null,
},
];
return (
<VStack gap={5}>
<PanelCard
title="Shops"
subtitle="Every shop in your company. Cameras and the shop PC are set up on the Store page."
actions={
isOwner ? (
<Button
variant="primary"
size="sm"
label="Open a shop"
onClick={() => setOpening(true)}
/>
) : undefined
}
resource={sites}
loading={<SkeletonRows count={4} height={52} />}
empty={
<EmptyPanel
icon="stores"
title="No shops yet"
description={
isOwner
? 'Open your first shop, then add its cameras on the Store page.'
: 'Your company has no shops yet. The owner opens them.'
}
/>
}
>
{(rows) => (
<ResponsiveTable
columns={columns}
data={rows as SiteRow[]}
idKey="id"
primaryKey="name"
/>
)}
</PanelCard>
{opening ? (
<OpenShopDialog
onClose={() => setOpening(false)}
onOpened={() => {
setOpening(false);
toast({body: 'Shop opened. Add its cameras on the Store page.'});
reload();
}}
/>
) : null}
{renaming ? (
<RenameShopDialog
site={renaming}
onClose={() => setRenaming(null)}
onSaved={() => {
setRenaming(null);
toast({body: 'Shop updated'});
reload();
}}
/>
) : null}
{removing ? (
<RemoveShopDialog
site={removing}
onClose={() => setRemoving(null)}
onRemoved={() => {
setRemoving(null);
toast({body: 'Shop removed'});
reload();
}}
/>
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import {useState} from 'react';
import {proportional} from '@astryxdesign/core/Table';
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 {StatusDot} from '@astryxdesign/core/StatusDot';
import {Avatar} from '@astryxdesign/core/Avatar';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {useInvitations, useTeam} from '@/features/team/hooks/useTeam';
import {teamRepository} from '@/features/team/repositories/teamRepository';
import {AddMemberDialog} from '@/features/team/components/AddMemberDialog';
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
import type {TeamMember} from '@/features/team/types/team';
/**
* Who has access to this console.
*
* ── What this replaced ───────────────────────────────────────────────────
* Five invented people — `aravind@nearle.in`, `Vikram Seth`, `Priya Sharma` —
* held in `useState`, with store names (`Indiranagar Flagship`,
* `Koramangala 80ft`) that no endpoint supplies, roles (`Cashier`,
* `Marketing`, `Support`) that do not exist upstream, and four controls that
* changed nothing: Add Staff, Suspend, Reset password and Remove all mutated
* local state and were lost on refresh. A merchant could not tell any of it
* from the real thing.
*
* ── Why the actions are here now ─────────────────────────────────────────
* This was read-only, because `PATCH /api/team/{id}`, the invitation endpoints
* and `POST /api/team/members` all answered 404 on the deployed platform when
* it was built. They do not any more, so the screen does what it always
* described: lists who has access and changes it.
*
* Deactivating revokes every session that person holds IMMEDIATELY — it is not
* a flag that takes effect at their next sign-in. The platform also refuses to
* remove the last active owner, answering `409 last_owner`; that message is
* surfaced verbatim rather than flattened into "something went wrong", because
* it names a rule the merchant can act on.
*/
interface TeamRow extends Record<string, unknown> {
id: string;
name: string;
email: string;
role: string;
status: string;
lastLogin: string;
}
/**
* Read off the payload, never inferred. The platform sends a boolean; there is
* no "invited" or "suspended" state on this endpoint, so the screen does not
* invent one.
*/
function toRow(m: TeamMember): TeamRow {
return {
id: m.id,
name: m.name,
email: m.email,
role: m.role,
status: m.active ? 'Active' : 'Inactive',
// "Never" is a fact worth showing: an account created and never used is
// exactly the row a manager wants to notice.
lastLogin: m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString() : 'Never',
};
}
const COLUMNS: TableColumn<TeamRow>[] = [
{
key: 'name',
header: 'Member',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Avatar name={row.name} size="sm" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="xsm" color="secondary">
{row.email}
</Text>
</VStack>
</HStack>
),
},
{
key: 'role',
header: 'Role',
width: proportional(1),
renderCell: (row) => <Badge variant="neutral" label={row.role} />,
},
{
key: 'status',
header: 'Access',
width: proportional(1),
renderCell: (row) => (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={row.status === 'Active' ? 'success' : 'error'}
label={row.status}
/>
<Text size="sm" color="secondary">
{row.status}
</Text>
</HStack>
),
},
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1), align: 'end'},
];
export function TeamManagement() {
const team = useTeam();
const invitations = useInvitations();
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reset, setReset] = useState<{label: string; value: string} | null>(
null,
);
function refresh() {
team.refetch();
invitations.refetch();
}
async function setActive(id: string, active: boolean) {
setError(null);
const res = await teamRepository.update(id, {active});
// `last_owner` is the one failure worth repeating word for word: it names a
// rule rather than a fault, and the merchant can act on it.
if (!res.ok) setError(res.message ?? 'Could not change that account.');
refresh();
}
async function resetPassword(id: string, email: string) {
setError(null);
const res = await teamRepository.resetPassword(id);
if (!res.ok || !res.data) {
setError(res.message ?? 'Could not reset that password.');
return;
}
setReset({label: `New password for ${email}`, value: res.data.password});
}
return (
<PanelCard
title="Console access"
subtitle="Everybody who can sign in to this company's dashboard."
resource={team}
loading={<SkeletonRows count={4} />}
empty={
<EmptyPanel
icon="staff"
title="No team members yet"
description="People appear here once they accept an invitation."
/>
}
>
{(members) => (
<VStack gap={4}>
<ResponsiveTable
data={members.map(toRow)}
idKey="id"
primaryKey="name"
summaryKeys={['role', 'status']}
columns={COLUMNS}
/>
{error ? <Banner status="error" title={error} /> : null}
{reset ? (
<SecretOnce value={reset.value} label={reset.label} />
) : null}
<HStack gap={2} hAlign="between" vAlign="center">
<Text size="xsm" color="secondary">
An invitation lets somebody choose their own password; a created
login shows one once. Deactivating signs that person out at once.
</Text>
<Button size="sm" onClick={() => setAdding(true)} label="Add someone" />
</HStack>
{members.some((m) => !m.active) ? (
<VStack gap={2}>
<Text size="xsm" color="secondary">
Deactivated
</Text>
{members
.filter((m) => !m.active)
.map((m) => (
<HStack key={m.id} gap={2} vAlign="center" hAlign="between">
<Text size="sm">{m.name}</Text>
<Button
size="sm"
variant="secondary"
onClick={() => void setActive(m.id, true)}
label="Restore access"
/>
</HStack>
))}
</VStack>
) : null}
<VStack gap={2}>
{members
.filter((m) => m.active)
.map((m) => (
<HStack key={m.id} gap={2} vAlign="center" hAlign="between">
<Text size="sm" color="secondary">
{m.email}
</Text>
<HStack gap={2}>
<Button
size="sm"
variant="secondary"
onClick={() => void resetPassword(m.id, m.email)}
label="Reset password"
/>
<Button
size="sm"
variant="secondary"
onClick={() => void setActive(m.id, false)}
label="Deactivate"
/>
</HStack>
</HStack>
))}
</VStack>
{invitations.data && invitations.data.length > 0 ? (
<VStack gap={2}>
<Text size="xsm" color="secondary">
Invited, not yet joined
</Text>
{invitations.data.map((inv) => (
<HStack key={inv.id} gap={2} vAlign="center" hAlign="between">
<Text size="sm" color="secondary">
{inv.email} · {inv.role}
</Text>
<Button
size="sm"
variant="secondary"
onClick={() => {
void teamRepository
.revokeInvitation(inv.id)
.then(() => invitations.refetch());
}}
label="Withdraw"
/>
</HStack>
))}
</VStack>
) : null}
{adding ? (
<AddMemberDialog
onClose={() => setAdding(false)}
onDone={() => {
setAdding(false);
refresh();
}}
/>
) : null}
</VStack>
)}
</PanelCard>
);
}

View File

@@ -0,0 +1,78 @@
import {ICONS} from '@/shared/utils/icons';
import type {IconType} from '@astryxdesign/core/Icon';
export interface SettingsSection {
label: string;
href: string;
icon: IconType;
description: string;
}
/**
* Settings sub-navigation.
*
* Same shape as the primary nav-config for the same reason: adding a section
* should be a one-line push here plus a route folder, with nothing in the
* layout changing.
*/
export const SETTINGS_NAV: SettingsSection[] = [
{
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: '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: 'Email, SMS, WhatsApp and Push alert channels.',
},
{
label: 'Billing',
href: '/settings/billing',
icon: ICONS.revenue,
description: 'Subscription plans, payment methods and LYT settlement.',
},
{
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.',
},
];
/** Exact match only — /settings must not stay active on /settings/billing. */
export function isSettingsActive(pathname: string, href: string): boolean {
return pathname === href;
}

View File

@@ -0,0 +1,16 @@
import {deleteJson, postJson, type Endpoint} from '@/shared/services/httpClient';
import type {DeviceSession} from '@/features/settings/services/mapSession';
/** Signed-in devices, and the two ways to end them. */
export const securityRepository = {
sessions: (): Endpoint<DeviceSession[]> => ({
path: '/api/auth/sessions',
params: {},
}),
revoke: (id: string) =>
deleteJson<null>(`/api/auth/sessions/${encodeURIComponent(id)}`),
/** Keeps this browser signed in — see the route. */
revokeOthers: () => postJson<null>('/api/auth/sessions/revoke-others', {}),
};

View File

@@ -0,0 +1,16 @@
import {getJson, patchJson} from '@/shared/services/httpClient';
import type {MerchantProfile} from '@/features/settings/types/settings';
/**
* Settings endpoints. See dashboardRepository for the layer's contract.
*
* Imperative rather than Endpoint-shaped: a settings record has one state at
* page load and changes only when the user saves it, so there is nothing for
* useResource's scope-keyed refetching to do.
*/
export const settingsRepository = {
profile: () => getJson<MerchantProfile>('/api/settings/profile'),
updateProfile: (patch: Partial<MerchantProfile>) =>
patchJson<MerchantProfile>('/api/settings/profile', patch),
};

View File

@@ -0,0 +1,22 @@
import 'server-only';
import type {MerchantProfile} from '@/features/settings/types/settings';
/**
* The merchant profile.
*
* There is no business-profile resource in the platform contract — GET
* /api/auth/me returns the signed-in USER (name, email, role, tenant), not the
* company's GSTIN, timezone, currency or LYT accrual rate. The fixture that
* used to answer this has been removed, so this returns null and the form
* renders its unavailable state.
*
* When the resource ships, this function is the only thing that changes.
*
* Required: GET/PATCH /api/settings/profile — business name, GSTIN, timezone,
* currency, notification preferences.
*/
export const settingsServerRepository = {
async getProfile(): Promise<MerchantProfile | null> {
return null;
},
};

View File

@@ -0,0 +1,25 @@
import type {ApiDeviceSession} from '@/services/api/types';
/** A signed-in device, as the settings screen consumes it. */
export interface DeviceSession {
id: string;
device: string;
createdAt: string;
lastUsedAt: string;
expiresAt: string;
/** The browser reading this page. Warn before signing it out. */
isCurrent: boolean;
}
export function toDeviceSession(s: ApiDeviceSession): DeviceSession {
return {
id: s.id,
// The platform records whatever the client sent at sign-in. An empty label
// is possible, and "Unknown device" is more honest than a blank row.
device: s.device || 'Unknown device',
createdAt: s.created_at,
lastUsedAt: s.last_used_at,
expiresAt: s.expires_at,
isCurrent: s.current,
};
}

View File

@@ -0,0 +1,55 @@
import {settingsRepository} from '@/features/settings/repositories/settingsRepository';
import type {MerchantProfile} from '@/features/settings/types/settings';
/**
* Domain rules for the settings module.
*
* The validation lives here rather than in the form for the same reason the
* login rules live in authService: a rule that only exists inside a component
* cannot be reused by a second screen, cannot be unit-tested without mounting
* React, and quietly disappears the day the screen is redesigned.
*/
export type ProfileFieldErrors = Partial<Record<keyof MerchantProfile, string>>;
export function validateProfile(profile: MerchantProfile): ProfileFieldErrors {
const errors: ProfileFieldErrors = {};
if (!profile.businessName.trim()) {
errors.businessName = 'Business name is required';
}
if (!/^\S+@\S+\.\S+$/.test(profile.email)) {
errors.email = 'Enter a valid email address';
}
// Optional, but a partial GSTIN is worse than none — it will fail at
// settlement time rather than here.
if (profile.gstin && profile.gstin.length !== 15) {
errors.gstin = 'GSTIN is 15 characters';
}
return errors;
}
export const settingsService = {
validateProfile,
/**
* Validate, then save. Returns the field errors rather than throwing —
* invalid input is an expected outcome of a form submission, not an
* exception, and the caller has to render them either way.
*/
async saveProfile(
profile: MerchantProfile,
): Promise<{ok: true} | {ok: false; errors: ProfileFieldErrors; message?: string}> {
const errors = validateProfile(profile);
if (Object.keys(errors).length > 0) return {ok: false, errors};
const res = await settingsRepository.updateProfile(profile);
if (!res.ok) {
return {
ok: false,
errors: {},
message: res.message ?? 'Could not save profile',
};
}
return {ok: true};
},
};

View File

@@ -0,0 +1,26 @@
/**
* Merchant settings contracts.
*
* The wire shape for this feature. Imported by BOTH its route handlers and its
* components, so a server/client drift is a type error rather than a runtime
* surprise. When a real backend arrives, this file is the negotiation artifact.
*/
export interface MerchantProfile {
businessName: string;
contactName: string;
email: string;
phone: string;
gstin: string;
/** IANA zone — all reporting timestamps are rendered in it. */
timezone: string;
currency: 'INR';
/** Default LYTs granted per ₹100 spent. */
lytsPerHundred: number;
notifications: {
expiryAlerts: boolean;
dailySummary: boolean;
staffAbsence: boolean;
lowConversion: boolean;
};
}