From 035ceb44a7dd49c035ebc3e262421ef9f2128c12 Mon Sep 17 00:00:00 2001 From: abhishek Date: Tue, 8 Sep 2026 19:01:40 +0530 Subject: [PATCH] profile page --- .../store-admin/pages/ShopProfilePage.tsx | 876 +++++++++++++----- src/index.css | 231 +++++ 2 files changed, 895 insertions(+), 212 deletions(-) diff --git a/src/features/store-admin/pages/ShopProfilePage.tsx b/src/features/store-admin/pages/ShopProfilePage.tsx index cf56aa3..f665bf6 100644 --- a/src/features/store-admin/pages/ShopProfilePage.tsx +++ b/src/features/store-admin/pages/ShopProfilePage.tsx @@ -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 = { + 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('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(null); const [form, setForm] = useState>({}); - const [saved, setSaved] = useState(false); + const [saved, setSaved] = useState(null); const [error, setError] = useState(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 & Record; + + /* 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; - 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)[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 ( - - - - - Your account is not linked to a business, so there is no profile to edit. - - - +
+ +
+
+

+ Your account is not linked to a business, so there is no store profile to show. +

+
+
+
); } + if (shop.isLoading) { + return ( +
+ + + Reading your store… + +
+ ); + } + + const isEditing = (card: CardId) => openCard === card && !save.isPending; + return ( - - 0 - ? { count: `${gaps.length} still to fill in` } - : {})} - /> +
+ - {shop.isLoading ? ( - - - Reading your business… - - - ) : ( - - {/* The four fields that are actually missing, first and on their own. - Opening with the twenty already-complete ones would bury them. */} - - 0 ? { note: `${gaps.length} missing` } : {})} + + {/* ── Header ─────────────────────────────────────────────────────── */} +
+ + {text('tenantimage') ? ( + + ) : ( + + )} + + +
+
+

{text('tenantname') || 'Your store'}

+ + + {isActive ? 'Active' : text('status') || 'Inactive'} + +
+ Store profile & basic information +
+ +
+ + {/* ── Tabs ───────────────────────────────────────────────────────── */} +
+ + +
+ + {error ? ( +
+
+

+ {error} +

+
+
+ ) : null} + + {tab === 'details' ? ( +
+ {/* ── 1. Basic ─────────────────────────────────────────────── */} + } + 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') ? ( + <> + -
- + + + + + + ) : ( + <> + + + + + + + + {isActive ? 'Active' : text('status') || 'Inactive'} + + } + /> + {/* Only when the record actually carries it — see the note at + the top about what the endpoint returns. */} + {created ? : null} + + )} + - + {/* ── 2. Contact ───────────────────────────────────────────── */} + } + 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') ? ( + <> + + + + + + + +