Files
dashboards/apps/business/src/pages/BusinessProfilePage.tsx
T
Alireza HassaniandCursor 6c52ce1247 Polish dashboard page chrome and user access UI.
Rename Customers to Users with FA/EN titles, compact headers and filter bars across apps, and refine manager role copy plus a customer-header link to the business dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 00:00:09 +03:30

528 lines
18 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import {
AddressListEditor,
createEmptyAddressItem,
getLocationOptionLabel,
matchCityByName,
matchProvinceByName,
useLocale,
type AddressListItem,
} from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { ImageCropper } from '../components/ImageCropper'
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
import { RichTextEditor } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import { dispatchBusinessProfileUpdated } from '../lib/businessContext'
import { listBusinessActivityCategories } from '../services/businessActivityCategoryService'
import type { BusinessActivityCategory } from '../services/businessActivityCategoryService'
import {
getBusinessProfile,
updateBusinessProfile,
type BusinessPhoneNumber,
type BusinessSocialMedia,
} from '../services/businessProfileService'
import {
listCitiesByProvinceSlug,
listIranProvinces,
type CityOption,
} from '../services/citiesService'
import { resolveDataUrlToMediaId } from '../services/mediaService'
import { flattenBusinessActivityCategories } from '../utils/businessCategories'
import pageStyles from '../components/PageContent.module.css'
import formStyles from './AddNewProductPage.module.css'
import styles from './BusinessProfilePage.module.css'
type AddressDraft = AddressListItem
const EMPTY_SOCIAL: BusinessSocialMedia = {
whatsapp: '',
telegram: '',
instagram: '',
linkedin: '',
youtube: '',
aparat: '',
}
const SOCIAL_FIELDS = [
['whatsapp', 'WhatsApp'],
['telegram', 'Telegram'],
['instagram', 'Instagram'],
['linkedin', 'LinkedIn'],
['youtube', 'YouTube'],
['aparat', 'Aparat'],
] as const
function createEmptyPhone(): BusinessPhoneNumber {
return { type: 'cell', number: '' }
}
export function BusinessProfilePage() {
const { showToast } = useToast()
const { locale } = useLocale()
const t = useT()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const [provinces, setProvinces] = useState<CityOption[]>([])
const [citiesByProvince, setCitiesByProvince] = useState<Record<string, CityOption[]>>({})
const [activityCategories, setActivityCategories] = useState<BusinessActivityCategory[]>([])
const [categoryIds, setCategoryIds] = useState<string[]>([])
const [nameEn, setNameEn] = useState('')
const [nameFa, setNameFa] = useState('')
const [email, setEmail] = useState('')
const [about, setAbout] = useState('')
const [vision, setVision] = useState('')
const [logo, setLogo] = useState<string | null>(null)
const [logoMediaId, setLogoMediaId] = useState<string | null>(null)
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
const [phoneNumbers, setPhoneNumbers] = useState<BusinessPhoneNumber[]>([
createEmptyPhone(),
])
const [socialMedia, setSocialMedia] = useState<BusinessSocialMedia>(EMPTY_SOCIAL)
const categoryOptions = useMemo(
() => flattenBusinessActivityCategories(activityCategories),
[activityCategories],
)
const addressLabels = useMemo(
() => ({
title: t('bizProfile.addresses'),
addLabel: t('bizProfile.addresses.add'),
province: t('bizProfile.province'),
city: t('bizProfile.city'),
address: t('bizProfile.address'),
postalCode: t('bizProfile.postalCode'),
landline: t('bizProfile.landline'),
selectProvince: t('bizProfile.selectProvince'),
selectCity: t('bizProfile.selectCity'),
streetPlaceholder: t('bizProfile.streetPlaceholder'),
postalPlaceholder: t('bizProfile.postalCode'),
landlinePlaceholder: t('bizProfile.landline'),
removeAriaLabel: t('bizProfile.removeAddress'),
}),
[t],
)
useEffect(() => {
const controller = new AbortController()
void loadData(controller.signal)
return () => controller.abort()
}, [])
useEffect(() => {
setAddresses((prev) =>
prev.map((item) => {
if (!item.provinceSlug) return item
const province = provinces.find((p) => p.slug === item.provinceSlug)
const cities = citiesByProvince[item.provinceSlug] ?? []
const city = matchCityByName(item.city, cities)
return {
...item,
province: province ? getLocationOptionLabel(province, locale) : item.province,
city: city ? getLocationOptionLabel(city, locale) : item.city,
}
}),
)
}, [locale, provinces, citiesByProvince])
async function loadData(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const [categories, profileData, provinceItems] = await Promise.all([
listBusinessActivityCategories(signal),
getBusinessProfile(signal),
listIranProvinces(signal),
])
setProvinces(provinceItems)
setActivityCategories(categories)
setCategoryIds(profileData.profile.categoryIds)
setNameEn(profileData.profile.nameEn)
setNameFa(profileData.profile.nameFa)
setEmail(profileData.profile.emails[0] ?? '')
setAbout(profileData.profile.about)
setVision(profileData.profile.vision)
setLogo(profileData.profile.logoUrl)
setLogoMediaId(profileData.profile.logoMediaId)
const nextAddresses =
profileData.addresses.length > 0
? profileData.addresses.map((item) => {
const province = matchProvinceByName(item.province, provinceItems)
return {
id: item.id,
provinceSlug: province?.slug ?? '',
province: province
? getLocationOptionLabel(province, locale)
: item.province,
city: item.city,
address: item.address,
postalCode: item.postalCode,
landline: item.landline ?? '',
}
})
: [createEmptyAddressItem()]
const slugs = [...new Set(nextAddresses.map((item) => item.provinceSlug).filter(Boolean))]
const cityGroups = await Promise.all(
slugs.map(async (slug) => ({
slug,
cities: await listCitiesByProvinceSlug(slug, signal),
})),
)
const citiesMap = Object.fromEntries(cityGroups.map((group) => [group.slug, group.cities]))
setAddresses(
nextAddresses.map((item) => {
if (!item.provinceSlug) return item
const cities = citiesMap[item.provinceSlug] ?? []
const city = matchCityByName(item.city, cities)
return {
...item,
city: city ? getLocationOptionLabel(city, locale) : item.city,
}
}),
)
setCitiesByProvince(citiesMap)
setPhoneNumbers(
profileData.profile.phoneNumbers.length > 0
? profileData.profile.phoneNumbers
: [createEmptyPhone()],
)
setSocialMedia({ ...EMPTY_SOCIAL, ...profileData.profile.socialMedia })
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('bizProfile.loadError'))
}
} finally {
setIsLoading(false)
}
}
function updateAddress(index: number, patch: Partial<AddressDraft>) {
setAddresses((prev) =>
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
)
}
async function handleProvinceChange(index: number, provinceSlug: string) {
const province = provinces.find((item) => item.slug === provinceSlug)
updateAddress(index, {
provinceSlug,
province: province ? getLocationOptionLabel(province, locale) : '',
city: '',
})
if (provinceSlug && !citiesByProvince[provinceSlug]) {
const cities = await listCitiesByProvinceSlug(provinceSlug)
setCitiesByProvince((prev) => ({ ...prev, [provinceSlug]: cities }))
}
}
function addAddress() {
setAddresses((prev) => [...prev, createEmptyAddressItem()])
}
function removeAddress(index: number) {
setAddresses((prev) =>
prev.length === 1 ? [createEmptyAddressItem()] : prev.filter((_, i) => i !== index),
)
}
function updatePhone(index: number, patch: Partial<BusinessPhoneNumber>) {
setPhoneNumbers((prev) =>
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
)
}
function addPhone() {
setPhoneNumbers((prev) => [...prev, createEmptyPhone()])
}
function removePhone(index: number) {
setPhoneNumbers((prev) =>
prev.length === 1 ? [createEmptyPhone()] : prev.filter((_, i) => i !== index),
)
}
function updateSocial(field: keyof BusinessSocialMedia, value: string) {
setSocialMedia((prev) => ({ ...prev, [field]: value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setIsSaving(true)
setError('')
try {
const nextLogoMediaId = await resolveDataUrlToMediaId(
logo,
'business-logo.png',
logoMediaId,
)
const payloadAddresses = addresses
.filter(
(item) =>
item.province.trim() ||
item.city.trim() ||
item.address.trim() ||
item.postalCode.trim(),
)
.map((item) => ({
id: item.id,
province: item.province.trim(),
city: item.city.trim(),
address: item.address.trim(),
postalCode: item.postalCode.trim(),
landline: item.landline?.trim() || null,
}))
const payloadPhones = phoneNumbers
.map((item) => ({
type: item.type,
number: item.number.trim(),
}))
.filter((item) => item.number)
const trimmedEmail = email.trim()
await updateBusinessProfile({
nameEn: nameEn.trim(),
nameFa: nameFa.trim(),
about,
vision,
emails: trimmedEmail ? [trimmedEmail] : [],
phoneNumbers: payloadPhones,
socialMedia,
logoMediaId: nextLogoMediaId,
categoryIds,
addresses: payloadAddresses,
})
showToast(t('bizProfile.toast.saved'), 'success')
dispatchBusinessProfileUpdated()
await loadData()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('bizProfile.saveError'))
}
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>{t('bizProfile.loading')}</p>
</main>
)
}
return (
<main className={pageStyles.content}>
<Breadcrumbs items={[{ label: 'Dashboard', href: '/' }, { label: 'Business Profile' }]} />
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="BUSINESS PROFILE">{t('title.businessProfile')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('bizProfile.subtitle')}</p>
</div>
</div>
{error && (
<div className={styles.alertError} role="alert">
{error}
</div>
)}
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)}>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>{t('bizProfile.section.basic')}</h3>
<div className={formStyles.formGrid}>
<div className={`${formStyles.field} ${styles.logoCol}`}>
<label>{t('bizProfile.logo')}</label>
<ImageCropper
value={logo}
onChange={setLogo}
outputFormat="png"
accept="image/png"
uploadLabel={t('bizProfile.logo.upload')}
hint={t('bizProfile.logo.hint')}
changeLabel={t('bizProfile.logo.change')}
/>
</div>
<div className={styles.basicAside}>
<div className={`${formStyles.field} ${styles.fullRow}`}>
<label htmlFor="activity-categories">{t('bizProfile.categories')}</label>
<MultiSelectDropdown
id="activity-categories"
options={categoryOptions}
value={categoryIds}
onChange={setCategoryIds}
placeholder={t('bizProfile.categories.placeholder')}
searchable
disabled={categoryOptions.length === 0}
/>
</div>
<div className={`${formStyles.field} ${styles.halfRow}`}>
<label htmlFor="name-fa">{t('bizProfile.nameFa')}</label>
<input
id="name-fa"
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
dir="rtl"
lang="fa"
className="faText"
/>
</div>
<div className={`${formStyles.field} ${styles.halfRow}`}>
<label htmlFor="name-en">{t('bizProfile.nameEn')}</label>
<input
id="name-en"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
dir="ltr"
lang="en"
/>
</div>
<div className={`${formStyles.field} ${styles.halfRow}`}>
<label htmlFor="email">{t('bizProfile.email')}</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="info@example.com"
/>
</div>
</div>
<div className={`${formStyles.field} ${formStyles.col12}`}>
<label>{t('bizProfile.about')}</label>
<RichTextEditor value={about} onChange={setAbout} />
</div>
<div className={`${formStyles.field} ${formStyles.col12}`}>
<label>{t('bizProfile.vision')}</label>
<RichTextEditor value={vision} onChange={setVision} />
</div>
</div>
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>{t('bizProfile.section.contact')}</h3>
<div className={styles.duplicatorBlock}>
<AddressListEditor
addresses={addresses}
provinces={provinces}
citiesByProvince={citiesByProvince}
onAddressChange={updateAddress}
onProvinceChange={handleProvinceChange}
onAdd={addAddress}
onRemove={removeAddress}
locale={locale}
labels={addressLabels}
/>
</div>
<div className={styles.duplicatorBlock}>
<div className={styles.sectionHeader}>
<h4 className={styles.subTitle}>{t('bizProfile.phones')}</h4>
<button type="button" className={styles.addBtn} onClick={addPhone}>
<Plus size={16} />
{t('bizProfile.phones.add')}
</button>
</div>
<div className={styles.phoneGrid}>
<div className={styles.gridHeader}>
<span>{t('bizProfile.phones.type')}</span>
<span>{t('bizProfile.phones.number')}</span>
<span />
</div>
{phoneNumbers.map((item, index) => (
<div key={`phone-${index}`} className={styles.gridRow}>
<select
className={styles.selectField}
value={item.type}
onChange={(e) =>
updatePhone(index, {
type: e.target.value as BusinessPhoneNumber['type'],
})
}
>
<option value="cell">{t('bizProfile.phones.cell')}</option>
<option value="landline">{t('bizProfile.phones.landline')}</option>
</select>
<input
className={styles.textField}
value={item.number}
onChange={(e) => updatePhone(index, { number: e.target.value })}
placeholder={t('bizProfile.phones.number')}
/>
<button
type="button"
className={styles.removeBtn}
onClick={() => removePhone(index)}
aria-label={t('bizProfile.phones.remove')}
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
</div>
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>{t('bizProfile.section.social')}</h3>
<div className={formStyles.formGrid}>
{SOCIAL_FIELDS.map(([key, label]) => (
<div key={key} className={`${formStyles.field} ${formStyles.col4}`}>
<label htmlFor={`social-${key}`}>{label}</label>
<input
id={`social-${key}`}
value={socialMedia[key]}
onChange={(e) => updateSocial(key, e.target.value)}
placeholder={t('bizProfile.social.placeholder', { name: label })}
/>
</div>
))}
</div>
</section>
<div className={styles.actions}>
<button type="submit" className={styles.saveBtn} disabled={isSaving}>
{isSaving ? t('bizProfile.saving') : t('bizProfile.save')}
</button>
</div>
</form>
</main>
)
}