mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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>
528 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|