profile page
This commit is contained in:
@@ -1,41 +1,105 @@
|
||||
/**
|
||||
* The merchant's own business record — the first screen able to write it.
|
||||
* The store's own record — read first, edited second.
|
||||
*
|
||||
* Nothing in Fiesta updated the `tenants` table. Every field was set once at
|
||||
* onboarding by a Nearle Admin and could never be changed by anyone, which is
|
||||
* why, measured across 200 merchants on 31 Aug 2026:
|
||||
* ── What this is, and what it is not ────────────────────────────────────────
|
||||
*
|
||||
* A STORE profile. The record is a business: a shop, its branch, how to reach
|
||||
* it, and the licence it is legally shown under. Nothing here is about the
|
||||
* person signed in — their own name, mobile and sign-in live on their account
|
||||
* page, and mixing the two is how a merchant ends up editing themselves when
|
||||
* they meant to edit the shop.
|
||||
*
|
||||
* ── Why it stopped being a form ─────────────────────────────────────────────
|
||||
*
|
||||
* It was one column of twenty text inputs, every field drawn as a box whether
|
||||
* or not anybody was editing. Two things were wrong with that. It asked
|
||||
* questions that were already answered — measured across 200 merchants,
|
||||
* `tenantname`, `address` and `primarycontact` are complete on essentially all
|
||||
* of them — and it hid the ones that were not, because a filled box and an
|
||||
* empty box look the same until you read them.
|
||||
*
|
||||
* tenantname 200/200 tenantimage 18/200
|
||||
* primarycontact 199/200 licenseno 0/200
|
||||
* address 200/200 tenantinfo 1/200
|
||||
*
|
||||
* The boring fields are complete because onboarding collects them. What is
|
||||
* missing is the shop's PUBLIC FACE — the photograph a shopper sees, the licence
|
||||
* they are legally shown, the description. So this page leads with those four
|
||||
* and keeps the rest below, rather than opening with twenty fields somebody has
|
||||
* already filled in.
|
||||
* So the default state is now a reading surface: labelled values, grouped by
|
||||
* what they are about, with an Edit on the card you actually want to change.
|
||||
* The empty ones say so in words, and the licence — a display requirement for a
|
||||
* food business in India, and empty on every merchant measured — says so in
|
||||
* amber.
|
||||
*
|
||||
* ── What the platform does not store yet ────────────────────────────────────
|
||||
*
|
||||
* Two things the design asks for do not exist behind it, and are shown as
|
||||
* absent rather than invented: a separate TRADE LICENCE (the `tenants` table
|
||||
* has one licence column, `licenseno`, which is where FSSAI goes), and a store
|
||||
* manager (`firstname`, `lastname` and `Accountname` come back empty for every
|
||||
* merchant read so far). `created` does exist and is now returned — the column
|
||||
* was always there and the endpoint simply never selected it.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { CheckCircle2, ImageOff } from 'lucide-react';
|
||||
import {
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
Check,
|
||||
Info,
|
||||
Pencil,
|
||||
Phone,
|
||||
ShieldCheck,
|
||||
Sliders,
|
||||
Store,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { tenantsApi } from '@/api/tenants';
|
||||
import type { TenantInfo } from '@/api/types';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useOwnTenant } from '@/queries/hooks';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { profileGaps, PUBLIC_FACE_FIELDS } from '../shopProfile';
|
||||
import { branchLabel } from '../format';
|
||||
|
||||
type Tab = 'details' | 'settings';
|
||||
|
||||
/** Which card is open for editing. Only ever one — see `openCard`. */
|
||||
type CardId = 'basic' | 'contact' | 'licence' | 'more';
|
||||
|
||||
/** Every field this page may write, so a save cannot send anything else. */
|
||||
const EDITABLE = [
|
||||
'tenantname',
|
||||
'tenanttype',
|
||||
'primarycontact',
|
||||
'primaryemail',
|
||||
'address',
|
||||
'suburb',
|
||||
'city',
|
||||
'state',
|
||||
'postcode',
|
||||
'licenseno',
|
||||
'registrationno',
|
||||
'tenantinfo',
|
||||
'minorder',
|
||||
'tenantimage',
|
||||
] as const;
|
||||
|
||||
type Editable = (typeof EDITABLE)[number];
|
||||
|
||||
const CARD_FIELDS: Record<CardId, readonly Editable[]> = {
|
||||
basic: ['tenantname', 'tenanttype'],
|
||||
contact: ['primarycontact', 'primaryemail', 'address', 'suburb', 'city', 'state', 'postcode'],
|
||||
licence: ['licenseno'],
|
||||
more: ['tenantinfo', 'minorder', 'registrationno'],
|
||||
};
|
||||
|
||||
export function ShopProfilePage() {
|
||||
const { user } = useAuth();
|
||||
@@ -43,243 +107,631 @@ export function ShopProfilePage() {
|
||||
const shop = useOwnTenant(tenantid || undefined);
|
||||
const client = useQueryClient();
|
||||
|
||||
const [tab, setTab] = useState<Tab>('details');
|
||||
/**
|
||||
* One card at a time.
|
||||
*
|
||||
* "Edit store" opens the first; a card's own Edit opens that one. Letting
|
||||
* four cards be open together would put the page back where it started — a
|
||||
* screen of boxes — which is the thing this redesign removes.
|
||||
*/
|
||||
const [openCard, setOpenCard] = useState<CardId | null>(null);
|
||||
const [form, setForm] = useState<Record<string, string>>({});
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [saved, setSaved] = useState<CardId | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/* Seeded once the record arrives, and only for fields the merchant may set.
|
||||
Re-seeding on every render would fight the person typing. */
|
||||
const record = (shop.data ?? {}) as Partial<TenantInfo> & Record<string, unknown>;
|
||||
|
||||
/* Seeded when the record arrives and again whenever a card is opened, so an
|
||||
abandoned edit does not survive into the next one. Never on every render —
|
||||
that would fight the person typing. */
|
||||
useEffect(() => {
|
||||
if (!shop.data) return;
|
||||
const record = shop.data as unknown as Record<string, unknown>;
|
||||
setForm({
|
||||
tenantname: String(record['tenantname'] ?? ''),
|
||||
tenantimage: String(record['tenantimage'] ?? ''),
|
||||
licenseno: String(record['licenseno'] ?? ''),
|
||||
tenantinfo: String(record['tenantinfo'] ?? ''),
|
||||
primarycontact: String(record['primarycontact'] ?? ''),
|
||||
primaryemail: String(record['primaryemail'] ?? ''),
|
||||
registrationno: String(record['registrationno'] ?? ''),
|
||||
address: String(record['address'] ?? ''),
|
||||
suburb: String(record['suburb'] ?? ''),
|
||||
city: String(record['city'] ?? ''),
|
||||
state: String(record['state'] ?? ''),
|
||||
postcode: String(record['postcode'] ?? ''),
|
||||
minorder: String(record['minorder'] ?? ''),
|
||||
});
|
||||
}, [shop.data]);
|
||||
|
||||
const gaps = useMemo(() => profileGaps(shop.data ?? {}), [shop.data]);
|
||||
setForm(
|
||||
Object.fromEntries(
|
||||
EDITABLE.map((key) => [key, String((record as Record<string, unknown>)[key] ?? '')]),
|
||||
),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shop.data, openCard]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (card: CardId) =>
|
||||
tenantsApi.updateProfile({
|
||||
tenantid,
|
||||
/* Only this card's fields. Sending the whole form would write back
|
||||
values nobody looked at — and `updatetenant` takes what it is given,
|
||||
so a stale field in state becomes a stale field in the record. */
|
||||
...Object.fromEntries(
|
||||
Object.entries(form).map(([key, value]) =>
|
||||
// Numbers as numbers. `minorder` sent as "150" would be written to
|
||||
// an integer column as text and rejected by the driver.
|
||||
key === 'minorder' ? [key, Number(value) || 0] : [key, value.trim()],
|
||||
CARD_FIELDS[card].map((key) =>
|
||||
// Numbers as numbers: "150" sent to an integer column is rejected
|
||||
// by the driver.
|
||||
key === 'minorder'
|
||||
? [key, Number(form[key]) || 0]
|
||||
: [key, (form[key] ?? '').trim()],
|
||||
),
|
||||
),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (_result, card) => {
|
||||
setError(null);
|
||||
setSaved(true);
|
||||
setSaved(card);
|
||||
setOpenCard(null);
|
||||
await client.invalidateQueries({ queryKey: queryKeys.tenants.all });
|
||||
},
|
||||
onError: (cause) => {
|
||||
setSaved(false);
|
||||
setSaved(null);
|
||||
setError(errorMessage(cause));
|
||||
},
|
||||
});
|
||||
|
||||
function set(key: string) {
|
||||
function set(key: Editable) {
|
||||
return (value: string) => {
|
||||
setSaved(false);
|
||||
setSaved(null);
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
}
|
||||
|
||||
const text = (key: keyof TenantInfo): string => String(record[key] ?? '').trim();
|
||||
const hasLicence = text('licenseno') !== '';
|
||||
const isActive = (text('status') || 'active').toLowerCase() === 'active';
|
||||
|
||||
const created = useMemo(() => {
|
||||
const raw = String(record['created'] ?? '');
|
||||
if (!raw) return '';
|
||||
const at = new Date(raw);
|
||||
return Number.isNaN(at.getTime())
|
||||
? ''
|
||||
: at.toLocaleString('en-IN', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}, [record]);
|
||||
|
||||
if (!tenantid) {
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader title="Shop profile" />
|
||||
<Card padding={4} elevation="low">
|
||||
<Text type="body" color="secondary">
|
||||
Your account is not linked to a business, so there is no profile to edit.
|
||||
</Text>
|
||||
</Card>
|
||||
</VStack>
|
||||
<div className="sp-page">
|
||||
<PageHeader title="Store profile" />
|
||||
<div className="sp-card">
|
||||
<div className="sp-card-body">
|
||||
<p className="sp-empty">
|
||||
Your account is not linked to a business, so there is no store profile to show.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (shop.isLoading) {
|
||||
return (
|
||||
<div className="sp-page">
|
||||
<PageHeader title="Store profile" />
|
||||
<Text type="body" color="secondary">
|
||||
Reading your store…
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEditing = (card: CardId) => openCard === card && !save.isPending;
|
||||
|
||||
return (
|
||||
<PageBody measure="reading">
|
||||
<PageHeader
|
||||
title="Shop profile"
|
||||
{...(gaps.length > 0
|
||||
? { count: `${gaps.length} still to fill in` }
|
||||
: {})}
|
||||
/>
|
||||
<div className="sp-page">
|
||||
<PageHeader title="Store profile" />
|
||||
|
||||
{shop.isLoading ? (
|
||||
<Card padding={4} elevation="low">
|
||||
<Text type="body" color="secondary">
|
||||
Reading your business…
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<VStack gap={3}>
|
||||
{/* The four fields that are actually missing, first and on their own.
|
||||
Opening with the twenty already-complete ones would bury them. */}
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader
|
||||
title="What shoppers see"
|
||||
{...(gaps.length > 0 ? { note: `${gaps.length} missing` } : {})}
|
||||
<VStack gap={2}>
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<header className="sp-head">
|
||||
<span className="sp-mark" aria-hidden>
|
||||
{text('tenantimage') ? (
|
||||
<img src={text('tenantimage')} alt="" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<Store size={24} />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h2 className="sp-name">{text('tenantname') || 'Your store'}</h2>
|
||||
<span className="sp-pill" {...(isActive ? {} : { 'data-tone': 'muted' })}>
|
||||
<Check size={11} strokeWidth={3} />
|
||||
{isActive ? 'Active' : text('status') || 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="sp-sub">Store profile & basic information</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Edit store"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Pencil size={14} />}
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => {
|
||||
setTab('details');
|
||||
setOpenCard('basic');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* The four facts worth knowing before reading the cards. */}
|
||||
<div className="sp-summary" style={{ gridColumn: '1 / -1' }}>
|
||||
<Fact label="Branch" value={branchLabel(text('locationname')) || '—'} />
|
||||
<Fact label="City" value={text('city') || '—'} />
|
||||
<Fact
|
||||
label="Store manager"
|
||||
value={
|
||||
[text('firstname'), text('lastname')].filter(Boolean).join(' ') ||
|
||||
String(record['Accountname'] ?? '').trim() ||
|
||||
'—'
|
||||
}
|
||||
/>
|
||||
<Card padding={3} elevation="low">
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} align="center">
|
||||
{form['tenantimage'] ? (
|
||||
<img
|
||||
src={form['tenantimage']}
|
||||
alt=""
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 10,
|
||||
objectFit: 'cover',
|
||||
border: '1px solid var(--color-line)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 10,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
border: '1px dashed var(--color-line)',
|
||||
color: 'var(--color-ink-4)',
|
||||
}}
|
||||
>
|
||||
<ImageOff size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<Fact
|
||||
label="FSSAI / trade licence"
|
||||
value={
|
||||
<span className="sp-pill" data-tone={hasLicence ? undefined : 'warn'}>
|
||||
{hasLicence ? <ShieldCheck size={11} /> : <TriangleAlert size={11} />}
|
||||
{hasLicence ? 'On file' : 'Missing'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Tabs ───────────────────────────────────────────────────────── */}
|
||||
<div className="sp-tabs" role="tablist" aria-label="Store profile sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className="sp-tab"
|
||||
aria-selected={tab === 'details'}
|
||||
onClick={() => setTab('details')}
|
||||
>
|
||||
Store details
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className="sp-tab"
|
||||
aria-selected={tab === 'settings'}
|
||||
onClick={() => setTab('settings')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="sp-card">
|
||||
<div className="sp-card-body">
|
||||
<p className="sp-empty" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === 'details' ? (
|
||||
<div className="sp-cards">
|
||||
{/* ── 1. Basic ─────────────────────────────────────────────── */}
|
||||
<ProfileCard
|
||||
icon={<Building2 size={15} />}
|
||||
title="Basic information"
|
||||
isEditing={isEditing('basic')}
|
||||
isSaved={saved === 'basic'}
|
||||
isSaving={save.isPending && openCard === 'basic'}
|
||||
onEdit={() => setOpenCard('basic')}
|
||||
onCancel={() => setOpenCard(null)}
|
||||
onSave={() => save.mutate('basic')}
|
||||
>
|
||||
{isEditing('basic') ? (
|
||||
<>
|
||||
<Field label="Store name">
|
||||
<TextInput
|
||||
label="Shop photo"
|
||||
value={form['tenantimage'] ?? ''}
|
||||
onChange={set('tenantimage')}
|
||||
placeholder="https://…"
|
||||
label="Store name"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={form['tenantname'] ?? ''}
|
||||
onChange={set('tenantname')}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
</Field>
|
||||
<Field label="Store type">
|
||||
<TextInput
|
||||
label="Store type"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={form['tenanttype'] ?? ''}
|
||||
onChange={set('tenanttype')}
|
||||
placeholder="Departmental store, pharmacy, bakery…"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Row label="Store name" value={text('tenantname')} />
|
||||
<Row label="Branch" value={branchLabel(text('locationname'))} />
|
||||
<Row label="Registered as" value={text('companyname')} />
|
||||
<Row
|
||||
label="Store manager"
|
||||
value={[text('firstname'), text('lastname')].filter(Boolean).join(' ')}
|
||||
/>
|
||||
<Row label="Store type" value={text('tenanttype')} />
|
||||
<Row
|
||||
label="Status"
|
||||
value={
|
||||
<span className="sp-pill" {...(isActive ? {} : { 'data-tone': 'muted' })}>
|
||||
<Check size={11} strokeWidth={3} />
|
||||
{isActive ? 'Active' : text('status') || 'Inactive'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{/* Only when the record actually carries it — see the note at
|
||||
the top about what the endpoint returns. */}
|
||||
{created ? <Row label="Created on" value={created} /> : null}
|
||||
</>
|
||||
)}
|
||||
</ProfileCard>
|
||||
|
||||
<TextInput
|
||||
label="Shop name"
|
||||
value={form['tenantname'] ?? ''}
|
||||
onChange={set('tenantname')}
|
||||
/>
|
||||
{/* ── 2. Contact ───────────────────────────────────────────── */}
|
||||
<ProfileCard
|
||||
icon={<Phone size={15} />}
|
||||
title="Contact information"
|
||||
isEditing={isEditing('contact')}
|
||||
isSaved={saved === 'contact'}
|
||||
isSaving={save.isPending && openCard === 'contact'}
|
||||
onEdit={() => setOpenCard('contact')}
|
||||
onCancel={() => setOpenCard(null)}
|
||||
onSave={() => save.mutate('contact')}
|
||||
>
|
||||
{isEditing('contact') ? (
|
||||
<>
|
||||
<Field label="Phone">
|
||||
<TextInput
|
||||
label="Phone"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={form['primarycontact'] ?? ''}
|
||||
onChange={set('primarycontact')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<TextInput
|
||||
label="Email"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={form['primaryemail'] ?? ''}
|
||||
onChange={set('primaryemail')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Address">
|
||||
<TextArea
|
||||
label="Address"
|
||||
isLabelHidden
|
||||
rows={3}
|
||||
value={form['address'] ?? ''}
|
||||
onChange={set('address')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Area">
|
||||
<TextInput
|
||||
label="Area"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={form['suburb'] ?? ''}
|
||||
onChange={set('suburb')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="City">
|
||||
<TextInput
|
||||
label="City"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={form['city'] ?? ''}
|
||||
onChange={set('city')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="State">
|
||||
<TextInput
|
||||
label="State"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={form['state'] ?? ''}
|
||||
onChange={set('state')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Postcode">
|
||||
<TextInput
|
||||
label="Postcode"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={form['postcode'] ?? ''}
|
||||
onChange={set('postcode')}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Row label="Phone" value={text('primarycontact')} isMono />
|
||||
<Row label="Email" value={text('primaryemail')} />
|
||||
<Row label="Address" value={text('address')} />
|
||||
<Row
|
||||
label="Map reference"
|
||||
value={
|
||||
text('latitude') && text('longitude')
|
||||
? `${text('latitude')}, ${text('longitude')}`
|
||||
: ''
|
||||
}
|
||||
isMono
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ProfileCard>
|
||||
|
||||
<TextInput
|
||||
label="FSSAI / trade licence"
|
||||
value={form['licenseno'] ?? ''}
|
||||
onChange={set('licenseno')}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="About the shop"
|
||||
value={form['tenantinfo'] ?? ''}
|
||||
onChange={set('tenantinfo')}
|
||||
placeholder="A line or two — what you sell, what you are known for."
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<SectionHeader title="Contact and address" />
|
||||
<Card padding={3} elevation="low">
|
||||
<VStack gap={2}>
|
||||
<HStack gap={1.5}>
|
||||
{/* ── 3. Licence ───────────────────────────────────────────── */}
|
||||
<ProfileCard
|
||||
icon={<ShieldCheck size={15} />}
|
||||
title="FSSAI / trade licence"
|
||||
badge={
|
||||
hasLicence ? null : (
|
||||
<span className="sp-pill" data-tone="warn">
|
||||
<TriangleAlert size={11} />
|
||||
Missing
|
||||
</span>
|
||||
)
|
||||
}
|
||||
isEditing={isEditing('licence')}
|
||||
isSaved={saved === 'licence'}
|
||||
isSaving={save.isPending && openCard === 'licence'}
|
||||
onEdit={() => setOpenCard('licence')}
|
||||
onCancel={() => setOpenCard(null)}
|
||||
onSave={() => save.mutate('licence')}
|
||||
>
|
||||
{isEditing('licence') ? (
|
||||
<Field label="FSSAI number">
|
||||
<TextInput
|
||||
label="Phone"
|
||||
value={form['primarycontact'] ?? ''}
|
||||
onChange={set('primarycontact')}
|
||||
label="FSSAI number"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={form['licenseno'] ?? ''}
|
||||
onChange={set('licenseno')}
|
||||
placeholder="14-digit FSSAI licence"
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={form['primaryemail'] ?? ''}
|
||||
onChange={set('primaryemail')}
|
||||
/>
|
||||
</HStack>
|
||||
<TextInput
|
||||
label="Address"
|
||||
value={form['address'] ?? ''}
|
||||
onChange={set('address')}
|
||||
/>
|
||||
<HStack gap={1.5}>
|
||||
<TextInput label="Area" value={form['suburb'] ?? ''} onChange={set('suburb')} />
|
||||
<TextInput label="City" value={form['city'] ?? ''} onChange={set('city')} />
|
||||
</HStack>
|
||||
<HStack gap={1.5}>
|
||||
<TextInput label="State" value={form['state'] ?? ''} onChange={set('state')} />
|
||||
<TextInput
|
||||
label="Postcode"
|
||||
value={form['postcode'] ?? ''}
|
||||
onChange={set('postcode')}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack gap={1.5}>
|
||||
<TextInput
|
||||
label="Registration number"
|
||||
value={form['registrationno'] ?? ''}
|
||||
onChange={set('registrationno')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Minimum order (₹)"
|
||||
value={Number(form['minorder']) || 0}
|
||||
onChange={(value) => set('minorder')(String(value ?? 0))}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Row label="FSSAI number" value={text('licenseno')} isMono />
|
||||
{/* Honest about the shape of the record: there is one licence
|
||||
column behind this page, not two. */}
|
||||
<Row label="Trade licence" value="" empty="Not stored on this platform yet" />
|
||||
{!hasLicence ? (
|
||||
<p
|
||||
className="sp-empty"
|
||||
style={{ textAlign: 'left', color: 'var(--color-warning, #b7860b)' }}
|
||||
>
|
||||
A food business in India has to display its FSSAI number, and shoppers see it
|
||||
on your store page. Nothing else on this screen is legally required.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ProfileCard>
|
||||
|
||||
{error ? (
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<HStack gap={1.5} align="center">
|
||||
<Button
|
||||
label={save.isPending ? 'Saving…' : 'Save profile'}
|
||||
variant="primary"
|
||||
isLoading={save.isPending}
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
/>
|
||||
{saved ? (
|
||||
<HStack gap={0.5} align="center">
|
||||
<CheckCircle2 size={15} style={{ color: 'var(--color-success, #10b981)' }} />
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Saved
|
||||
</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
{/* Said plainly, because a form that sends every field it renders
|
||||
could otherwise be assumed to erase the ones left blank. */}
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
A field left blank is not changed. {PUBLIC_FACE_FIELDS.length} of these are what
|
||||
shoppers see.
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</PageBody>
|
||||
{/* ── 4. Additional ────────────────────────────────────────── */}
|
||||
<ProfileCard
|
||||
icon={<Info size={15} />}
|
||||
title="Additional information"
|
||||
isEditing={isEditing('more')}
|
||||
isSaved={saved === 'more'}
|
||||
isSaving={save.isPending && openCard === 'more'}
|
||||
onEdit={() => setOpenCard('more')}
|
||||
onCancel={() => setOpenCard(null)}
|
||||
onSave={() => save.mutate('more')}
|
||||
>
|
||||
{isEditing('more') ? (
|
||||
<>
|
||||
<Field label="Store description">
|
||||
<TextArea
|
||||
label="Store description"
|
||||
isLabelHidden
|
||||
rows={4}
|
||||
value={form['tenantinfo'] ?? ''}
|
||||
onChange={set('tenantinfo')}
|
||||
placeholder="A line or two a shopper reads before ordering."
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Minimum order value (₹)">
|
||||
<NumberInput
|
||||
label="Minimum order value"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={Number(form['minorder']) || 0}
|
||||
onChange={(value) => set('minorder')(String(value ?? 0))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Registration number">
|
||||
<TextInput
|
||||
label="Registration number"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={form['registrationno'] ?? ''}
|
||||
onChange={set('registrationno')}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : text('tenantinfo') ||
|
||||
text('registrationno') ||
|
||||
Number(record['minorder'] ?? 0) > 0 ? (
|
||||
<>
|
||||
<Row label="Description" value={text('tenantinfo')} />
|
||||
<Row
|
||||
label="Minimum order"
|
||||
value={
|
||||
Number(record['minorder'] ?? 0) > 0 ? `₹${Number(record['minorder'])}` : ''
|
||||
}
|
||||
empty="No minimum"
|
||||
/>
|
||||
<Row label="Registration number" value={text('registrationno')} isMono />
|
||||
</>
|
||||
) : (
|
||||
<p className="sp-empty">No information added</p>
|
||||
)}
|
||||
</ProfileCard>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Settings ─────────────────────────────────────────────────
|
||||
Deliberately not a second copy of the cards above. What belongs
|
||||
here is the configuration a merchant CANNOT change from the
|
||||
console — where the store appears in the app, which category it
|
||||
trades under, whether it is approved — because knowing what is set
|
||||
and who sets it is more useful than a page of disabled inputs. */
|
||||
<div className="sp-cards">
|
||||
<ProfileCard icon={<Sliders size={15} />} title="Store configuration" span="full">
|
||||
<Row label="App location" value={text('applocation')} />
|
||||
<Row
|
||||
label="Category"
|
||||
value={String(record['subcategoryname'] ?? '') || `Category ${record['categoryid'] ?? '—'}`}
|
||||
/>
|
||||
<Row
|
||||
label="Approved"
|
||||
value={
|
||||
<span className="sp-pill" {...(record['approved'] ? {} : { 'data-tone': 'muted' })}>
|
||||
<BadgeCheck size={11} />
|
||||
{record['approved'] ? 'Approved' : 'Awaiting approval'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Row label="Store id" value={String(record['tenantid'] ?? '')} isMono />
|
||||
<Row label="Branch id" value={String(record['locationid'] ?? '')} isMono />
|
||||
<p className="sp-empty" style={{ textAlign: 'left' }}>
|
||||
These are set by Nearle when your store is onboarded. Ask your account manager if
|
||||
any of them need to change.
|
||||
</p>
|
||||
</ProfileCard>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Pieces ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Fact({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="sp-summary-label">{label}</span>
|
||||
<span className="sp-summary-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled value, with the empty case written out.
|
||||
*
|
||||
* "Not provided" rather than a blank or a dash: a shop reading its own record
|
||||
* needs to know the difference between "we do not hold this" and "the page did
|
||||
* not draw it".
|
||||
*/
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
empty = 'Not provided',
|
||||
isMono,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
empty?: string;
|
||||
isMono?: boolean;
|
||||
}) {
|
||||
const isBlank = typeof value !== 'object' && String(value ?? '').trim() === '';
|
||||
return (
|
||||
<div className="sp-row">
|
||||
<span className="sp-label">{label}</span>
|
||||
<span
|
||||
className="sp-value"
|
||||
{...(isBlank ? { 'data-empty': 'yes' } : {})}
|
||||
{...(isMono && !isBlank ? { 'data-mono': 'yes' } : {})}
|
||||
>
|
||||
{isBlank ? empty : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="sp-field">
|
||||
<span className="sp-field-label">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileCard({
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
span,
|
||||
isEditing = false,
|
||||
isSaved = false,
|
||||
isSaving = false,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
badge?: ReactNode;
|
||||
span?: 'full';
|
||||
isEditing?: boolean;
|
||||
isSaved?: boolean;
|
||||
isSaving?: boolean;
|
||||
onEdit?: () => void;
|
||||
onCancel?: () => void;
|
||||
onSave?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="sp-card" {...(span ? { 'data-span': span } : {})}>
|
||||
<div className="sp-card-head">
|
||||
<span className="sp-card-icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
<h3 className="sp-card-title">{title}</h3>
|
||||
{badge}
|
||||
{isSaved && !isEditing ? (
|
||||
<span className="sp-pill">
|
||||
<Check size={11} strokeWidth={3} />
|
||||
Saved
|
||||
</span>
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
label="Cancel"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<X size={14} />}
|
||||
isDisabled={isSaving}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<Button
|
||||
label={isSaving ? 'Saving…' : 'Save'}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
isDisabled={isSaving}
|
||||
onClick={onSave}
|
||||
/>
|
||||
</div>
|
||||
) : onEdit ? (
|
||||
<Button label="Edit" variant="secondary" size="sm" icon={<Pencil size={13} />} onClick={onEdit} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="sp-card-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
231
src/index.css
231
src/index.css
@@ -1786,3 +1786,234 @@ main {
|
||||
background: var(--color-success-tint, #eaf7ef);
|
||||
color: var(--color-success, #1f9d55);
|
||||
}
|
||||
|
||||
/* ══ Store profile ═════════════════════════════════════════════════════════
|
||||
The merchant's own record, read first and edited second.
|
||||
|
||||
It was one long column of inputs — twenty fields, every one of them drawn as
|
||||
a text box whether or not anybody was editing it — so the screen asked a
|
||||
question it had already been answered and buried the four facts that were
|
||||
actually missing. What is here now is a reading surface: cards of labelled
|
||||
values, and an Edit on the card you want to change.
|
||||
|
||||
The palette is the console's, spent deliberately: purple for what is active
|
||||
or actionable, green only for a live status, amber only where a legal field
|
||||
is empty, navy for headings, slate for the labels beside them. The page sits
|
||||
on the faintest lavender so the white cards have something to be white
|
||||
against. */
|
||||
|
||||
.sp-page {
|
||||
/* A tint, not a colour: brand purple at 3% is enough to lift a white card
|
||||
off the page and not enough to read as a purple screen.
|
||||
|
||||
Tinted INSIDE the content column rather than bled out to the gutter with a
|
||||
negative margin. The gutter is 16px under 1024px and 24px over it, and the
|
||||
column's right edge is where the assistant panel's gap begins — a bleed
|
||||
wide enough to reach the page edge on a desktop would tint that gap on a
|
||||
laptop and sit under the panel. The brief is explicit that the panel is not
|
||||
to be touched, and a background is not worth the risk of moving it. */
|
||||
background: color-mix(in oklab, var(--color-brand) 3%, #ffffff);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
}
|
||||
@media (max-width: 720px) { .sp-page { padding: 12px; } }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.sp-head {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 16px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
}
|
||||
|
||||
/* The store, not the person. A shop mark rather than an avatar — this record
|
||||
is a business and a face on it would say otherwise. */
|
||||
.sp-mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
.sp-mark img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.sp-name {
|
||||
margin: 0;
|
||||
font: 600 20px/1.25 var(--font-sans);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
.sp-sub { font: 400 13px/1.45 var(--font-sans); color: var(--color-ink-3); }
|
||||
|
||||
.sp-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font: 600 11.5px/1.6 var(--font-sans);
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
color: var(--color-success, #1f9d55);
|
||||
background: var(--color-success-tint, #eaf7ef);
|
||||
}
|
||||
.sp-pill[data-tone='warn'] {
|
||||
color: var(--color-warning, #b7860b);
|
||||
background: color-mix(in oklab, var(--color-warning, #b7860b) 12%, transparent);
|
||||
}
|
||||
.sp-pill[data-tone='muted'] { color: var(--color-ink-3); background: var(--color-surface-sunken); }
|
||||
|
||||
/* The four facts worth knowing before reading anything else. A row, not four
|
||||
cards: they are one sentence about the shop, not four separate objects. */
|
||||
.sp-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin-top: 14px;
|
||||
border-top: 1px solid var(--color-line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.sp-summary > * + * { border-left: 1px solid var(--color-line); padding-left: 16px; }
|
||||
.sp-summary > * { padding-right: 16px; min-width: 0; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sp-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 14px; }
|
||||
.sp-summary > *:nth-child(odd) { border-left: 0; padding-left: 0; }
|
||||
}
|
||||
|
||||
.sp-summary-label {
|
||||
display: block;
|
||||
font: 500 11px/1.4 var(--font-sans);
|
||||
letter-spacing: 0.045em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
.sp-summary-value {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font: 500 13.5px/1.45 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Tabs ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.sp-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
.sp-tab {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 10px 2px 11px;
|
||||
margin-bottom: -1px;
|
||||
font: 500 13.5px/1.4 var(--font-sans);
|
||||
color: var(--color-ink-3);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.sp-tab:hover { color: var(--color-ink-1); }
|
||||
.sp-tab[aria-selected='true'] {
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
border-bottom-color: var(--color-brand);
|
||||
}
|
||||
.sp-tab:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; }
|
||||
|
||||
/* ── Cards ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.sp-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) { .sp-cards { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
.sp-card {
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 14px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sp-card[data-span='full'] { grid-column: 1 / -1; }
|
||||
|
||||
.sp-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
.sp-card-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--color-brand-tint);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
.sp-card-title {
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font: 600 14px/1.4 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.sp-card-body { padding: 4px 16px 8px; }
|
||||
|
||||
/* Label left, value right, on one baseline — the alignment is the whole point
|
||||
of reading a record rather than a form. Divided by a hairline rather than
|
||||
boxed: twelve boxes in a card is what made the old screen feel like a form
|
||||
even when nothing was editable. */
|
||||
.sp-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 34%) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-line) 60%, transparent);
|
||||
}
|
||||
.sp-card-body > .sp-row:last-child { border-bottom: 0; }
|
||||
|
||||
.sp-label { font: 400 12.5px/1.5 var(--font-sans); color: var(--color-ink-3); }
|
||||
.sp-value {
|
||||
font: 500 13.5px/1.55 var(--font-sans);
|
||||
color: var(--color-ink-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sp-value[data-empty='yes'] { font-weight: 400; color: var(--color-ink-4); }
|
||||
.sp-value[data-mono='yes'] { font-family: var(--font-mono); font-size: 13px; }
|
||||
|
||||
.sp-empty {
|
||||
padding: 18px 0 22px;
|
||||
font: 400 13px/1.6 var(--font-sans);
|
||||
color: var(--color-ink-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* A field being edited. Only ever drawn when somebody asked to edit — the
|
||||
default state of this page is text. */
|
||||
.sp-field { display: flex; flex-direction: column; gap: 5px; padding: 10px 0; }
|
||||
.sp-field-label {
|
||||
font: 500 12px/1.4 var(--font-sans);
|
||||
color: var(--color-ink-2, var(--color-ink-1));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user