Files
daily_console_web/src/features/onboarding/steps/StoreInfoStep.tsx
2026-09-17 17:57:21 +05:30

331 lines
11 KiB
TypeScript

import { useRef, useState } from 'react';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { Selector } from '@astryxdesign/core/Selector';
import { Text } from '@astryxdesign/core/Text';
import { TextInput } from '@astryxdesign/core/TextInput';
import { TimeInput } from '@astryxdesign/core/TimeInput';
import { createISOTimeString, type ISOTimeString } from '@astryxdesign/core/utils';
import { VStack } from '@astryxdesign/core/VStack';
import { ImagePlus, Upload } from 'lucide-react';
import type { StoreDraft } from '../onboardingState';
import type { Errors } from '../validation';
/**
* The one step that is a form, kept to three short groups.
*
* Grouped rather than listed because twelve fields in a column reads as a tax
* return. Basics, then where the shop is, then how it trades — each of which a
* shopkeeper can answer without looking anything up.
*
* Fields are marked required with an asterisk and validated on submit, not on
* every keystroke: telling somebody their email is invalid while they are on
* the third character is correcting them for not having finished typing.
*/
export interface StoreInfoStepProps {
value: StoreDraft;
errors: Errors;
onChange: (next: StoreDraft) => void;
}
/** Matches `tenants.tenanttype`, which the platform already stores. */
const STORE_TYPES = [
{ value: 'grocery', label: 'Grocery / kirana' },
{ value: 'supermarket', label: 'Supermarket' },
{ value: 'pharmacy', label: 'Pharmacy' },
{ value: 'bakery', label: 'Bakery' },
{ value: 'restaurant', label: 'Restaurant' },
{ value: 'other', label: 'Something else' },
];
const MAX_LOGO_BYTES = 2 * 1024 * 1024;
/**
* "08:00" as the picker wants it.
*
* The draft stores plain strings so it can be serialised into localStorage and
* posted to Fiesta unchanged; TimeInput wants a branded ISOTimeString. Undefined
* for anything unparseable, which shows an empty picker rather than throwing on
* a value somebody's older draft is carrying.
*/
function asTime(value: string): ISOTimeString | undefined {
return createISOTimeString(value) ?? undefined;
}
export function StoreInfoStep({ value, errors, onChange }: StoreInfoStepProps) {
const set = <K extends keyof StoreDraft>(key: K) => (next: StoreDraft[K]) =>
onChange({ ...value, [key]: next });
return (
<VStack gap={3} className="ob-panel">
<Group title="Basic information">
<div className="form-grid">
<TextInput
label={<Required>Store name</Required> as unknown as string}
value={value.tenantname}
onChange={set('tenantname')}
placeholder="e.g. Suriya Stores"
description="This is the name shoppers see."
{...(errors['tenantname'] ? { error: errors['tenantname'] } : {})}
/>
<TextInput
label={<Required>Phone number</Required> as unknown as string}
value={value.primarycontact}
onChange={set('primarycontact')}
placeholder="98765 43210"
{...(errors['primarycontact'] ? { error: errors['primarycontact'] } : {})}
/>
<TextInput
label="Store email"
type="email"
value={value.primaryemail}
onChange={set('primaryemail')}
placeholder="orders@yourshop.in"
description="Optional. Where order notifications go."
{...(errors['primaryemail'] ? { error: errors['primaryemail'] } : {})}
/>
<Selector
label={<Required>Store type</Required> as unknown as string}
value={value.tenanttype}
onChange={set('tenanttype')}
placeholder="Choose one"
options={STORE_TYPES}
{...(errors['tenanttype'] ? { error: errors['tenanttype'] } : {})}
/>
</div>
<LogoField value={value.tenantimage} onChange={set('tenantimage')} />
</Group>
<Group title="Store address" note="How deliveries and customers find you.">
<VStack gap={2}>
<TextInput
label={<Required>Address</Required> as unknown as string}
value={value.address}
onChange={set('address')}
placeholder="Shop number, street, area"
{...(errors['address'] ? { error: errors['address'] } : {})}
/>
<div className="form-grid">
<TextInput
label={<Required>City</Required> as unknown as string}
value={value.city}
onChange={set('city')}
{...(errors['city'] ? { error: errors['city'] } : {})}
/>
<TextInput
label={<Required>Pincode</Required> as unknown as string}
value={value.postcode}
onChange={set('postcode')}
placeholder="641001"
{...(errors['postcode'] ? { error: errors['postcode'] } : {})}
/>
<TextInput label="State" value={value.state} onChange={set('state')} />
</div>
</VStack>
</Group>
<Group title="Opening hours" note="When your shop takes orders.">
<div className="form-grid">
<TimeInput
label={<Required>Opens at</Required> as unknown as string}
value={asTime(value.opentime)}
onChange={(next) => set('opentime')(String(next ?? value.opentime))}
{...(errors['opentime'] ? { description: errors['opentime'] } : {})}
/>
<TimeInput
label={<Required>Closes at</Required> as unknown as string}
value={asTime(value.closetime)}
onChange={(next) => set('closetime')(String(next ?? value.closetime))}
{...(errors['closetime'] ? { description: errors['closetime'] } : {})}
/>
</div>
</Group>
</VStack>
);
}
/* ── Pieces ───────────────────────────────────────────────────────────────── */
function Group({
title,
note,
children,
}: {
title: string;
note?: string;
children: React.ReactNode;
}) {
return (
<Card padding={3}>
<VStack gap={2}>
<VStack gap={0}>
<Text type="label" size="sm" weight="semibold">
{title}
</Text>
{note ? (
<Text type="body" size="xsm" color="secondary">
{note}
</Text>
) : null}
</VStack>
{children}
</VStack>
</Card>
);
}
/** The asterisk carries a text alternative, so it is not colour-and-glyph only. */
function Required({ children }: { children: React.ReactNode }) {
return (
<span>
{children}{' '}
<span style={{ color: 'var(--color-error, #d64545)' }} aria-hidden>
*
</span>
<span className="sr-only"> (required)</span>
</span>
);
}
/**
* The logo.
*
* Read as a data URI rather than uploaded, deliberately: `tenants.tenantimage`
* holds a string and there is no image endpoint for a merchant to post to — the
* console's own S3 helper is disabled ("S3 not enabled, skipping image store").
* A URL box would be the honest alternative and is offered underneath, but a
* shopkeeper photographing their shopfront has a file, not a link.
*
* 2 MB is enforced here because the field is a database column: a 6 MB photo
* base64s to 8 MB of text and fails on write, long after the person has moved
* on.
*/
function LogoField({ value, onChange }: { value: string; onChange: (next: string) => void }) {
const input = useRef<HTMLInputElement>(null);
const [problem, setProblem] = useState<string | null>(null);
const [isReading, setIsReading] = useState(false);
function take(file: File | undefined) {
if (!file) return;
setProblem(null);
if (!/^image\/(png|jpe?g|webp)$/.test(file.type)) {
setProblem('That is not an image. Choose a JPG, PNG or WebP file.');
return;
}
if (file.size > MAX_LOGO_BYTES) {
setProblem(
`That file is ${(file.size / 1024 / 1024).toFixed(1)} MB. The limit is 2 MB — try a smaller photo.`,
);
return;
}
setIsReading(true);
const reader = new FileReader();
reader.onload = () => {
onChange(String(reader.result ?? ''));
setIsReading(false);
};
reader.onerror = () => {
setProblem('That file could not be read. Try another one.');
setIsReading(false);
};
reader.readAsDataURL(file);
}
return (
<VStack gap={1}>
<Text type="label" size="xsm" color="secondary">
Store logo
</Text>
<HStack gap={2} align="center" wrap="wrap">
{value ? (
<img
src={value}
alt="Your store logo"
style={{
width: 72,
height: 72,
borderRadius: 14,
objectFit: 'cover',
border: '1px solid var(--color-line)',
flex: 'none',
}}
/>
) : (
<span
aria-hidden
style={{
width: 72,
height: 72,
borderRadius: 14,
display: 'grid',
placeItems: 'center',
border: '1.5px dashed var(--color-slate-300)',
color: 'var(--color-ink-4)',
background: 'var(--color-surface-subtle)',
flex: 'none',
}}
>
<ImagePlus size={22} />
</span>
)}
<VStack gap={0.5}>
<button
type="button"
onClick={() => input.current?.click()}
disabled={isReading}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
height: 32,
padding: '0 12px',
borderRadius: 12,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
color: 'var(--color-ink-1)',
font: '500 13px/1 var(--font-sans)',
cursor: isReading ? 'progress' : 'pointer',
}}
>
<Upload size={13} />
{isReading ? 'Reading…' : value ? 'Change logo' : 'Upload logo'}
</button>
<Text type="body" size="xsm" color="secondary">
JPG, PNG or WebP, up to 2 MB. Optional.
</Text>
</VStack>
<input
ref={input}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => take(event.target.files?.[0])}
style={{ display: 'none' }}
aria-hidden
tabIndex={-1}
/>
</HStack>
{/* The honest alternative: the field is a string, so a hosted image works
just as well and costs the database nothing. */}
<TextInput
label="…or paste an image link"
isLabelHidden
size="sm"
value={value.startsWith('data:') ? '' : value}
onChange={onChange}
placeholder="https://…"
/>
{problem ? (
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }} role="alert">
{problem}
</Text>
) : null}
</VStack>
);
}