331 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|