mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Polish business dashboard profile, FA layout, and pagination.
Add a real user profile page with multi-address CRUD, localize business profile, fix RTL category tree and login/sidebar logo fallbacks, and keep pagination controls centered with aligned page meta. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
26b6b0a1b6
commit
d8c9c0b017
@@ -3,8 +3,10 @@ import { Plus, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
AddressListEditor,
|
||||
createEmptyAddressItem,
|
||||
getLocationOptionLabel,
|
||||
matchCityByName,
|
||||
matchProvinceByName,
|
||||
useLocale,
|
||||
type AddressListItem,
|
||||
} from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
@@ -12,6 +14,7 @@ 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'
|
||||
@@ -44,12 +47,23 @@ const EMPTY_SOCIAL: BusinessSocialMedia = {
|
||||
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('')
|
||||
@@ -76,12 +90,47 @@ export function BusinessProfilePage() {
|
||||
[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('')
|
||||
@@ -111,7 +160,9 @@ export function BusinessProfilePage() {
|
||||
return {
|
||||
id: item.id,
|
||||
provinceSlug: province?.slug ?? '',
|
||||
province: province?.nameEn ?? item.province,
|
||||
province: province
|
||||
? getLocationOptionLabel(province, locale)
|
||||
: item.province,
|
||||
city: item.city,
|
||||
address: item.address,
|
||||
postalCode: item.postalCode,
|
||||
@@ -136,7 +187,7 @@ export function BusinessProfilePage() {
|
||||
const city = matchCityByName(item.city, cities)
|
||||
return {
|
||||
...item,
|
||||
city: city?.nameEn ?? item.city,
|
||||
city: city ? getLocationOptionLabel(city, locale) : item.city,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -153,7 +204,7 @@ export function BusinessProfilePage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load business profile.')
|
||||
setError(t('bizProfile.loadError'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -170,7 +221,7 @@ export function BusinessProfilePage() {
|
||||
const province = provinces.find((item) => item.slug === provinceSlug)
|
||||
updateAddress(index, {
|
||||
provinceSlug,
|
||||
province: province?.nameEn ?? '',
|
||||
province: province ? getLocationOptionLabel(province, locale) : '',
|
||||
city: '',
|
||||
})
|
||||
|
||||
@@ -261,14 +312,14 @@ export function BusinessProfilePage() {
|
||||
addresses: payloadAddresses,
|
||||
})
|
||||
|
||||
showToast('Business profile saved.', 'success')
|
||||
showToast(t('bizProfile.toast.saved'), 'success')
|
||||
dispatchBusinessProfileUpdated()
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save business profile.')
|
||||
setError(t('bizProfile.saveError'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -278,7 +329,7 @@ export function BusinessProfilePage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.status}>Loading business profile...</p>
|
||||
<p className={styles.status}>{t('bizProfile.loading')}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -289,10 +340,8 @@ export function BusinessProfilePage() {
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>Business profile</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Manage your storefront identity, contact details, and social links.
|
||||
</p>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.businessProfile')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('bizProfile.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -304,56 +353,60 @@ export function BusinessProfilePage() {
|
||||
|
||||
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Basic info</h3>
|
||||
<h3 className={styles.sectionTitle}>{t('bizProfile.section.basic')}</h3>
|
||||
<div className={formStyles.formGrid}>
|
||||
<div className={`${formStyles.field} ${styles.logoCol}`}>
|
||||
<label>Logo</label>
|
||||
<label>{t('bizProfile.logo')}</label>
|
||||
<ImageCropper
|
||||
value={logo}
|
||||
onChange={setLogo}
|
||||
outputFormat="png"
|
||||
accept="image/png"
|
||||
uploadLabel="Upload logo"
|
||||
hint="Transparent PNG recommended"
|
||||
changeLabel="Change logo"
|
||||
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">Activity categories</label>
|
||||
<label htmlFor="activity-categories">{t('bizProfile.categories')}</label>
|
||||
<MultiSelectDropdown
|
||||
id="activity-categories"
|
||||
options={categoryOptions}
|
||||
value={categoryIds}
|
||||
onChange={setCategoryIds}
|
||||
placeholder="Select activity categories"
|
||||
placeholder={t('bizProfile.categories.placeholder')}
|
||||
searchable
|
||||
disabled={categoryOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${formStyles.field} ${styles.halfRow}`}>
|
||||
<label htmlFor="name-fa">Name (FA)</label>
|
||||
<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">Name (EN)</label>
|
||||
<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">Email address</label>
|
||||
<label htmlFor="email">{t('bizProfile.email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
@@ -365,19 +418,19 @@ export function BusinessProfilePage() {
|
||||
</div>
|
||||
|
||||
<div className={`${formStyles.field} ${formStyles.col12}`}>
|
||||
<label>About us</label>
|
||||
<label>{t('bizProfile.about')}</label>
|
||||
<RichTextEditor value={about} onChange={setAbout} />
|
||||
</div>
|
||||
|
||||
<div className={`${formStyles.field} ${formStyles.col12}`}>
|
||||
<label>Our vision</label>
|
||||
<label>{t('bizProfile.vision')}</label>
|
||||
<RichTextEditor value={vision} onChange={setVision} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Contact</h3>
|
||||
<h3 className={styles.sectionTitle}>{t('bizProfile.section.contact')}</h3>
|
||||
|
||||
<div className={styles.duplicatorBlock}>
|
||||
<AddressListEditor
|
||||
@@ -388,23 +441,24 @@ export function BusinessProfilePage() {
|
||||
onProvinceChange={handleProvinceChange}
|
||||
onAdd={addAddress}
|
||||
onRemove={removeAddress}
|
||||
title="Addresses"
|
||||
locale={locale}
|
||||
labels={addressLabels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.duplicatorBlock}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h4 className={styles.subTitle}>Phone numbers</h4>
|
||||
<h4 className={styles.subTitle}>{t('bizProfile.phones')}</h4>
|
||||
<button type="button" className={styles.addBtn} onClick={addPhone}>
|
||||
<Plus size={16} />
|
||||
Add number
|
||||
{t('bizProfile.phones.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.phoneGrid}>
|
||||
<div className={styles.gridHeader}>
|
||||
<span>Type</span>
|
||||
<span>Phone number</span>
|
||||
<span>{t('bizProfile.phones.type')}</span>
|
||||
<span>{t('bizProfile.phones.number')}</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
@@ -419,22 +473,22 @@ export function BusinessProfilePage() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="cell">Cell number</option>
|
||||
<option value="landline">Landline</option>
|
||||
<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="Phone number"
|
||||
placeholder={t('bizProfile.phones.number')}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
onClick={() => removePhone(index)}
|
||||
aria-label="Remove phone number"
|
||||
aria-label={t('bizProfile.phones.remove')}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
@@ -445,25 +499,16 @@ export function BusinessProfilePage() {
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Social media</h3>
|
||||
<h3 className={styles.sectionTitle}>{t('bizProfile.section.social')}</h3>
|
||||
<div className={formStyles.formGrid}>
|
||||
{(
|
||||
[
|
||||
['whatsapp', 'WhatsApp'],
|
||||
['telegram', 'Telegram'],
|
||||
['instagram', 'Instagram'],
|
||||
['linkedin', 'LinkedIn'],
|
||||
['youtube', 'YouTube'],
|
||||
['aparat', 'Aparat'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
{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={`${label} link or ID`}
|
||||
placeholder={t('bizProfile.social.placeholder', { name: label })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -472,7 +517,7 @@ export function BusinessProfilePage() {
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="submit" className={styles.saveBtn} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save profile'}
|
||||
{isSaving ? t('bizProfile.saving') : t('bizProfile.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user