profile page

This commit is contained in:
2026-09-08 19:01:40 +05:30
parent 33c4542ddf
commit 035ceb44a7
2 changed files with 895 additions and 212 deletions

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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));
}