diff --git a/src/app/(workspace)/settings/page.tsx b/src/app/(workspace)/settings/page.tsx index c7edf8a..05d795a 100644 --- a/src/app/(workspace)/settings/page.tsx +++ b/src/app/(workspace)/settings/page.tsx @@ -1,13 +1,15 @@ import {SettingsPage} from '@/features/settings/components/SettingsPage'; -import {BusinessForm} from '@/features/settings/components/BusinessForm'; +import {AccountCard} from '@/features/settings/components/AccountCard'; +import {SecurityManager} from '@/features/settings/components/SecurityManager'; +import {VStack} from '@astryxdesign/core/Layout'; -export default function BusinessSettingsPage() { +export default function AccountSettingsPage() { return ( - - + + + + + ); } diff --git a/src/app/(workspace)/settings/profile/page.tsx b/src/app/(workspace)/settings/profile/page.tsx deleted file mode 100644 index baa414c..0000000 --- a/src/app/(workspace)/settings/profile/page.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import {SettingsPage} from '@/features/settings/components/SettingsPage'; -import {ProfileForm} from '@/features/settings/components/ProfileForm'; -import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable'; -import {settingsServerRepository} from '@/features/settings/repositories/settingsServerRepository'; - -/** - * Server Component: the record is read on the server and handed to the form as - * its initial state, so the inputs paint filled rather than flashing empty. - * The form then saves through the client repository over HTTP. - * - * The read goes through a repository rather than the fixture module the page - * used to import — a page that knows the shape of a mock is a page that breaks - * the day the mock is deleted. - */ -export default async function MerchantProfilePage() { - const profile = await settingsServerRepository.getProfile(); - - return ( - - {profile ? ( - - ) : ( - - )} - - ); -} diff --git a/src/app/(workspace)/settings/security/page.tsx b/src/app/(workspace)/settings/security/page.tsx deleted file mode 100644 index e1eaf9e..0000000 --- a/src/app/(workspace)/settings/security/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {SettingsPage} from '@/features/settings/components/SettingsPage'; -import {SecurityManager} from '@/features/settings/components/SecurityManager'; - -export default function SecuritySettingsPage() { - return ( - - - - ); -} diff --git a/src/app/(workspace)/settings/stores/page.tsx b/src/app/(workspace)/settings/stores/page.tsx deleted file mode 100644 index 1d42aa0..0000000 --- a/src/app/(workspace)/settings/stores/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {SettingsPage} from '@/features/settings/components/SettingsPage'; -import {StoreManagement} from '@/features/settings/components/StoreManagement'; - -export default function StoreSettingsPage() { - return ( - - - - ); -} diff --git a/src/app/(workspace)/stores/page.tsx b/src/app/(workspace)/stores/page.tsx index 9a99fd2..57a4d8d 100644 --- a/src/app/(workspace)/stores/page.tsx +++ b/src/app/(workspace)/stores/page.tsx @@ -1,94 +1,43 @@ 'use client'; -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 {StatusDot} from '@astryxdesign/core/StatusDot'; +import {VStack} from '@astryxdesign/core/Layout'; +import {Divider} from '@astryxdesign/core/Divider'; import {PageHeader} from '@/shared/components/primitives/PageHeader'; import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary'; import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState'; import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; -import {StatPair, StatRow} from '@/shared/components/patterns/StatPair'; import {useSites} from '@/features/stores/hooks/useSites'; -import {formatPct} from '@/shared/utils/format'; +import {useSession} from '@/features/auth/providers/SessionProvider'; +import {ShopSection} from '@/features/stores/components/ShopSection'; /** - * The estate, from GET /api/sites. - * - * Health fields are nullable and rendered as "—" when the platform does not - * report them. A deployment that sends no camera health is not a deployment - * with zero cameras up, and printing "0/0" for "not reported" makes a working - * estate look broken. + * The screen somebody opens to find out whether their shops are WORKING. + * Each shop: its health in one line, then its cameras as pictures, with the + * actions that make a shop real - add a camera, set up the PC, prove the + * camera can see a face. */ export default function StoresPage() { const sites = useSites(); + const {user} = useSession(); + const canManage = user?.role === 'owner' || user?.role === 'manager'; return ( - - - + + } - empty={ - - } + loading={} + empty={} > {(rows) => ( - - {rows.map((site) => ( - - - - {site.name} - {site.isOnline === null ? null : ( - - - - {site.isOnline ? 'Online' : 'Offline'} - - - )} - - - - {site.id} - - - - - - - - + + {rows.map((site, i) => ( + + {i > 0 && } + + ))} - + )} diff --git a/src/app/api/auth/sessions/[id]/route.ts b/src/app/api/auth/sessions/[id]/route.ts new file mode 100644 index 0000000..538872c --- /dev/null +++ b/src/app/api/auth/sessions/[id]/route.ts @@ -0,0 +1,10 @@ +import type {NextRequest} from 'next/server'; +import {authApi} from '@/services/api/authApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +export async function DELETE(req: NextRequest, ctx: {params: Promise<{id: string}>}) { + const {id} = await ctx.params; + return proxyUpstream(req, (token) => authApi.revokeSession(token, id)); +} diff --git a/src/app/api/auth/sessions/revoke-others/route.ts b/src/app/api/auth/sessions/revoke-others/route.ts new file mode 100644 index 0000000..76698c7 --- /dev/null +++ b/src/app/api/auth/sessions/revoke-others/route.ts @@ -0,0 +1,10 @@ +import type {NextRequest} from 'next/server'; +import {authApi} from '@/services/api/authApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** Keeps the calling device signed in; everything else is out immediately. */ +export async function POST(req: NextRequest) { + return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token)); +} diff --git a/src/app/api/auth/sessions/route.ts b/src/app/api/auth/sessions/route.ts new file mode 100644 index 0000000..7fb2784 --- /dev/null +++ b/src/app/api/auth/sessions/route.ts @@ -0,0 +1,10 @@ +import type {NextRequest} from 'next/server'; +import {authApi} from '@/services/api/authApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** GET /api/auth/sessions - every device signed in as this person. */ +export async function GET(req: NextRequest) { + return proxyUpstream(req, (token) => authApi.sessions(token)); +} diff --git a/src/app/api/cameras/[id]/check/route.ts b/src/app/api/cameras/[id]/check/route.ts new file mode 100644 index 0000000..e04e1a2 --- /dev/null +++ b/src/app/api/cameras/[id]/check/route.ts @@ -0,0 +1,17 @@ +import type {NextRequest} from 'next/server'; +import {sitesApi} from '@/services/api/sitesApi'; +import {proxyUpstream} from '@/shared/services/bff'; +import {toCamera} from '@/features/stores/services/mapCamera'; + +export const dynamic = 'force-dynamic'; + +/** POST /api/cameras/{id}/check {kind: connection|placement}. The shop PC + * claims the job on its next sync; poll the camera list for the result. */ +export async function POST(req: NextRequest, ctx: {params: Promise<{id: string}>}) { + const {id} = await ctx.params; + return proxyUpstream( + req, + (token, body) => sitesApi.checkCamera(token, id, body.kind === 'placement' ? 'placement' : 'connection'), + {map: toCamera, status: 202}, + ); +} diff --git a/src/app/api/cameras/[id]/route.ts b/src/app/api/cameras/[id]/route.ts new file mode 100644 index 0000000..44f1e1b --- /dev/null +++ b/src/app/api/cameras/[id]/route.ts @@ -0,0 +1,21 @@ +import type {NextRequest} from 'next/server'; +import {sitesApi} from '@/services/api/sitesApi'; +import type {ApiCameraInput} from '@/services/api/types'; +import {proxyUpstream} from '@/shared/services/bff'; +import {toCamera} from '@/features/stores/services/mapCamera'; + +export const dynamic = 'force-dynamic'; + +type Ctx = {params: Promise<{id: string}>}; + +export async function PATCH(req: NextRequest, ctx: Ctx) { + const {id} = await ctx.params; + return proxyUpstream(req, (token, body) => sitesApi.updateCamera(token, id, body as ApiCameraInput), { + map: toCamera, + }); +} + +export async function DELETE(req: NextRequest, ctx: Ctx) { + const {id} = await ctx.params; + return proxyUpstream(req, (token) => sitesApi.deleteCamera(token, id)); +} diff --git a/src/app/api/cameras/route.ts b/src/app/api/cameras/route.ts new file mode 100644 index 0000000..b111cc4 --- /dev/null +++ b/src/app/api/cameras/route.ts @@ -0,0 +1,26 @@ +import type {NextRequest} from 'next/server'; +import {sitesApi} from '@/services/api/sitesApi'; +import type {ApiCameraInput} from '@/services/api/types'; +import {proxyUpstream} from '@/shared/services/bff'; +import {toCamera} from '@/features/stores/services/mapCamera'; + +export const dynamic = 'force-dynamic'; + +/** GET /api/cameras?site= - every camera, or one shop's. */ +export async function GET(req: NextRequest) { + return proxyUpstream(req, (token, _body, params) => sitesApi.cameras(token, params.get('site') || undefined), { + map: (cams) => cams.map(toCamera), + }); +} + +/** POST /api/cameras {site, ...camera} - add a camera to a shop. */ +export async function POST(req: NextRequest) { + return proxyUpstream( + req, + (token, body) => { + const {site, ...input} = body as {site?: string} & ApiCameraInput; + return sitesApi.addCamera(token, String(site ?? ''), input); + }, + {map: toCamera, status: 201}, + ); +} diff --git a/src/app/api/faces/route.ts b/src/app/api/faces/route.ts deleted file mode 100644 index 0443c48..0000000 --- a/src/app/api/faces/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type {NextRequest} from 'next/server'; -import {upstreamRaw} from '@/services/api/apiClient'; -import {withUpstream} from '@/features/auth/services/upstreamSession'; -import {failResponse} from '@/shared/services/bff'; - -export const dynamic = 'force-dynamic'; - -/** - * GET /api/faces?src=/api/faces/.jpg — an authenticated photo, proxied. - * - * A browser `` cannot send an Authorization header, and the platform's - * own image URLs require one. The alternatives were fetch + createObjectURL + - * revoke-on-unmount at every avatar — which leaks hundreds of copies of one - * photograph on a screen left open all afternoon — or this: one hop through - * the origin that already holds the token. - * - * ── Why `src` is validated rather than trusted ─────────────────────────── - * An unchecked pass-through would be an open proxy that attaches the - * merchant's bearer token to any URL an attacker can get into a page. Only - * same-origin platform paths under /api/faces/ are forwarded. - * - * Every hand-out of a photo is written to the platform's audit log, so this - * must be requested once per screen rather than once per component: two - * components asking for the same face puts two rows in "who looked at my - * customers" for one glance at one person. - */ -export async function GET(req: NextRequest) { - const src = new URL(req.url).searchParams.get('src') ?? ''; - - // Relative, no traversal, and inside the faces namespace. Anything else is - // refused rather than sanitised — a "cleaned" attacker-supplied URL is still - // attacker-supplied. - if (!src.startsWith('/api/faces/') || src.includes('..')) { - return Response.json( - {error: {code: 'bad_request', message: 'Not a valid image reference.'}}, - {status: 400}, - ); - } - - try { - const upstream = await withUpstream((token) => - upstreamRaw({path: src, accessToken: token}), - ); - - return new Response(upstream.body, { - status: 200, - headers: { - 'content-type': upstream.headers.get('content-type') ?? 'image/jpeg', - // Private: this is one merchant's customer, and a shared cache holding - // it would serve it across tenants. - 'cache-control': 'private, max-age=300', - }, - }); - } catch (err) { - return failResponse(err); - } -} diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts new file mode 100644 index 0000000..6d0cdf9 --- /dev/null +++ b/src/app/api/images/route.ts @@ -0,0 +1,38 @@ +import type {NextRequest} from 'next/server'; +import {upstreamRaw} from '@/services/api/apiClient'; +import {withUpstream} from '@/features/auth/services/upstreamSession'; +import {failResponse} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** + * The browser cannot put a bearer on an , so pictures the platform + * serves with the session - faces, camera snapshots - come through here. + * Presigned bucket links are absolute and load directly; they never come here. + */ +const ALLOWED = [/^\/api\/faces\/[^/?]+$/, /^\/api\/cameras\/[^/?]+\/snapshot\.jpg$/, /^\/api\/visitors\/[^/?]+\/image$/]; + +export function isProxyableImage(src: string): boolean { + return !src.includes('..') && ALLOWED.some((re) => re.test(src.split('?')[0])); +} + +export async function GET(req: NextRequest) { + const src = req.nextUrl.searchParams.get('src') ?? ''; + if (!isProxyableImage(src)) { + return Response.json({error: {code: 'bad_request', message: 'Not a valid image reference.'}}, {status: 400}); + } + try { + const upstream = await withUpstream((token) => upstreamRaw({path: src, accessToken: token})); + return new Response(upstream.body, { + status: 200, + headers: { + 'content-type': upstream.headers.get('content-type') ?? 'image/jpeg', + // Private: one merchant's customer or shop floor. A shared cache + // holding it would serve it across tenants. + 'cache-control': 'private, max-age=60', + }, + }); + } catch (err) { + return failResponse(err); + } +} diff --git a/src/app/api/sites/[site]/enrolment-code/route.ts b/src/app/api/sites/[site]/enrolment-code/route.ts new file mode 100644 index 0000000..a567b99 --- /dev/null +++ b/src/app/api/sites/[site]/enrolment-code/route.ts @@ -0,0 +1,19 @@ +import type {NextRequest} from 'next/server'; +import {sitesApi} from '@/services/api/sitesApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** POST /api/sites/{site}/enrolment-code - the code a new shop PC types. */ +export async function POST(req: NextRequest, ctx: {params: Promise<{site: string}>}) { + const {site} = await ctx.params; + return proxyUpstream( + req, + (token, body) => + sitesApi.enrolmentCode(token, site, { + label: typeof body.label === 'string' ? body.label : undefined, + ttl_hours: typeof body.ttl_hours === 'number' ? body.ttl_hours : undefined, + }), + {status: 201}, + ); +} diff --git a/src/app/api/sites/route.ts b/src/app/api/sites/route.ts index 20289db..d39b5bd 100644 --- a/src/app/api/sites/route.ts +++ b/src/app/api/sites/route.ts @@ -6,33 +6,24 @@ import type {Site} from '@/features/stores/types/site'; export const dynamic = 'force-dynamic'; -/** - * GET /api/sites — the estate. - * - * This is the most load-bearing read in the console: the site switcher scopes - * every other request in the app, so a hardcoded list here meant every screen - * was filtered by a store that might not exist. - * - * `slug` is carried through as the identifier the UI keys on because it is - * IMMUTABLE upstream and safe to persist in a URL or a saved report, while the - * display name is expected to change. - */ function toSite(s: ApiSite): Site { return { - // Slug first: it is immutable and is what every scoped request sends as - // `?site=`. `site_id` is the uuid — the server does not send a bare `id`. - id: s.slug || s.site_id, + id: s.slug, uuid: s.site_id, name: s.name, - isOnline: s.online ?? null, - camerasTotal: s.cameras_total ?? null, - camerasUp: s.cameras_up ?? null, - fractionBelowGate: s.fraction_below_gate ?? null, + timezone: s.timezone, + isOnline: s.online, + lastHeartbeatAt: s.last_heartbeat_at ?? null, + lastEventAt: s.last_event_at ?? null, + recognitionModel: s.recognition_model ?? null, + camerasTotal: s.cameras_total, + camerasUp: s.cameras_up, + fractionBelowGate: s.fraction_below_gate, + queued: s.queued, + dropped: s.dropped, }; } export async function GET(req: NextRequest) { - return serveUpstream(req, (token) => sitesApi.list(token), (sites) => - sites.map(toSite), - ); + return serveUpstream(req, (token) => sitesApi.list(token), (sites) => sites.map(toSite)); } diff --git a/src/app/api/team/[id]/password/route.ts b/src/app/api/team/[id]/password/route.ts new file mode 100644 index 0000000..58becca --- /dev/null +++ b/src/app/api/team/[id]/password/route.ts @@ -0,0 +1,13 @@ +import type {NextRequest} from 'next/server'; +import {teamApi} from '@/services/api/teamApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** POST /api/team/{id}/password - new password shown ONCE; signs them out everywhere. */ +export async function POST(req: NextRequest, ctx: {params: Promise<{id: string}>}) { + const {id} = await ctx.params; + return proxyUpstream(req, (token, body) => + teamApi.resetPassword(token, id, typeof body.password === 'string' && body.password ? body.password : undefined), + ); +} diff --git a/src/app/api/team/[id]/route.ts b/src/app/api/team/[id]/route.ts new file mode 100644 index 0000000..af1d079 --- /dev/null +++ b/src/app/api/team/[id]/route.ts @@ -0,0 +1,18 @@ +import type {NextRequest} from 'next/server'; +import {teamApi} from '@/services/api/teamApi'; +import type {ApiRole} from '@/services/api/types'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** PATCH /api/team/{id} {role?, active?}. Deactivating revokes every session + * that person holds, in the same transaction, server-side. */ +export async function PATCH(req: NextRequest, ctx: {params: Promise<{id: string}>}) { + const {id} = await ctx.params; + return proxyUpstream(req, (token, body) => + teamApi.update(token, id, { + role: typeof body.role === 'string' ? (body.role as ApiRole) : undefined, + active: typeof body.active === 'boolean' ? body.active : undefined, + }), + ); +} diff --git a/src/app/api/team/invitations/[id]/route.ts b/src/app/api/team/invitations/[id]/route.ts new file mode 100644 index 0000000..017756a --- /dev/null +++ b/src/app/api/team/invitations/[id]/route.ts @@ -0,0 +1,10 @@ +import type {NextRequest} from 'next/server'; +import {teamApi} from '@/services/api/teamApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +export async function DELETE(req: NextRequest, ctx: {params: Promise<{id: string}>}) { + const {id} = await ctx.params; + return proxyUpstream(req, (token) => teamApi.revokeInvitation(token, id)); +} diff --git a/src/app/api/team/invitations/route.ts b/src/app/api/team/invitations/route.ts new file mode 100644 index 0000000..9e57ed9 --- /dev/null +++ b/src/app/api/team/invitations/route.ts @@ -0,0 +1,25 @@ +import type {NextRequest} from 'next/server'; +import {teamApi} from '@/services/api/teamApi'; +import type {ApiRole} from '@/services/api/types'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { + return proxyUpstream(req, (token) => teamApi.invitations(token)); +} + +/** POST /api/team/invitations - the code is in the response ONCE. */ +export async function POST(req: NextRequest) { + return proxyUpstream( + req, + (token, body) => + teamApi.invite(token, { + email: String(body.email ?? ''), + full_name: typeof body.full_name === 'string' ? body.full_name : undefined, + role: String(body.role ?? 'staff') as ApiRole, + ttl_hours: typeof body.ttl_hours === 'number' ? body.ttl_hours : undefined, + }), + {status: 201}, + ); +} diff --git a/src/app/api/team/members/route.ts b/src/app/api/team/members/route.ts new file mode 100644 index 0000000..e03d4d9 --- /dev/null +++ b/src/app/api/team/members/route.ts @@ -0,0 +1,22 @@ +import type {NextRequest} from 'next/server'; +import {teamApi} from '@/services/api/teamApi'; +import type {ApiRole} from '@/services/api/types'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** POST /api/team/members - create an account. The password is in the + * response ONCE and nowhere else. */ +export async function POST(req: NextRequest) { + return proxyUpstream( + req, + (token, body) => + teamApi.createMember(token, { + email: String(body.email ?? ''), + full_name: String(body.full_name ?? ''), + role: String(body.role ?? 'staff') as ApiRole, + password: typeof body.password === 'string' && body.password ? body.password : undefined, + }), + {status: 201}, + ); +} diff --git a/src/app/api/visits/route.ts b/src/app/api/visits/route.ts index 8ba282d..4de0af1 100644 --- a/src/app/api/visits/route.ts +++ b/src/app/api/visits/route.ts @@ -38,7 +38,7 @@ function toArrival(a: ApiArrival): Arrival { url: a.image.url ? a.image.url.startsWith('http') ? a.image.url - : `/api/faces?src=${encodeURIComponent(a.image.url)}` + : `/api/images?src=${encodeURIComponent(a.image.url)}` : null, reason: a.image.reason ?? null, } diff --git a/src/features/settings/components/AccountCard.tsx b/src/features/settings/components/AccountCard.tsx new file mode 100644 index 0000000..06d4804 --- /dev/null +++ b/src/features/settings/components/AccountCard.tsx @@ -0,0 +1,31 @@ +'use client'; + +import {VStack, HStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; +import {Avatar} from '@astryxdesign/core/Avatar'; +import {Badge} from '@astryxdesign/core/Badge'; +import {Card} from '@astryxdesign/core/Card'; +import {useSession} from '@/features/auth/providers/SessionProvider'; +import {roleLabel} from '@/features/team/components/AddMemberDialog'; + +/** Who you are signed in as. Read-only: the platform has no profile editor + * yet, and a form that does not save is worse than none. */ +export function AccountCard() { + const {user} = useSession(); + if (!user) return null; + return ( + + + + + + {user.name} + + + {user.email} + {user.organisation} + + + + ); +} diff --git a/src/features/settings/components/BusinessForm.tsx b/src/features/settings/components/BusinessForm.tsx deleted file mode 100644 index 863317b..0000000 --- a/src/features/settings/components/BusinessForm.tsx +++ /dev/null @@ -1,175 +0,0 @@ -'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(DEFAULT_BUSINESS); - const [isSaving, setIsSaving] = useState(false); - const [savedData, setSavedData] = useState(DEFAULT_BUSINESS); - - const set = (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 ( - - {isDirty ? ( - - Unsaved changes - - ) : null} -