166 lines
5.3 KiB
TypeScript
166 lines
5.3 KiB
TypeScript
'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>
|
|
);
|
|
}
|