setup onboarding page
This commit is contained in:
330
src/features/onboarding/steps/StoreInfoStep.tsx
Normal file
330
src/features/onboarding/steps/StoreInfoStep.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
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} elevation="low">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user