Improve business profile branding uploads and super-admin link menus.

Show separate light/dark logo and favicon croppers in a compact grid, support favicon saves, and keep super-admin overflow dashboard links in English.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 00:51:12 +03:30
co-authored by Cursor
parent 5e799eddf9
commit 468bb87fcf
8 changed files with 163 additions and 79 deletions
@@ -172,3 +172,45 @@
.changeBtn:hover { .changeBtn:hover {
background: rgba(var(--primary-rgb) / 0.08); background: rgba(var(--primary-rgb) / 0.08);
} }
.compact .preview,
.compact .uploadZone,
.compact .cropArea {
width: 100%;
}
.compact .cropPanel {
width: 100%;
max-width: none;
margin-inline: 0;
align-self: stretch;
}
.compact .changeBtn {
align-self: stretch;
justify-content: center;
}
.compact .uploadZone {
gap: 4px;
font-size: 11px;
}
.compact .hint {
font-size: 10px;
text-align: center;
line-height: 1.3;
}
.compact .changeBtn {
display: flex;
padding: 6px 10px;
font-size: 11px;
}
.compact .removeBtn {
top: 4px;
right: 4px;
width: 22px;
height: 22px;
}
+21 -11
View File
@@ -15,6 +15,8 @@ interface ImageCropperProps {
uploadLabel?: string uploadLabel?: string
hint?: string hint?: string
changeLabel?: string changeLabel?: string
/** Smaller upload/preview frame for narrow grid columns. */
compact?: boolean
} }
export function ImageCropper({ export function ImageCropper({
@@ -26,6 +28,7 @@ export function ImageCropper({
uploadLabel, uploadLabel,
hint, hint,
changeLabel, changeLabel,
compact = false,
}: ImageCropperProps) { }: ImageCropperProps) {
const t = useT() const t = useT()
const resolvedUploadLabel = uploadLabel ?? t('products.form.thumbnailUpload') const resolvedUploadLabel = uploadLabel ?? t('products.form.thumbnailUpload')
@@ -81,18 +84,25 @@ export function ImageCropper({
const isPortrait = aspect < 1 const isPortrait = aspect < 1
const maxHeight = isPortrait ? 'min(360px, 42vh)' : 'min(280px, 36vh)' const maxHeight = isPortrait ? 'min(360px, 42vh)' : 'min(280px, 36vh)'
const frameStyle: React.CSSProperties = { const frameStyle: React.CSSProperties = compact
aspectRatio: String(aspect), ? {
width: `min(100%, calc(${maxHeight} * ${aspect}))`, aspectRatio: String(aspect),
height: 'auto', width: '100%',
maxHeight, height: 'auto',
minHeight: 0, minHeight: 0,
marginInline: 'auto', }
alignSelf: 'center', : {
} aspectRatio: String(aspect),
width: `min(100%, calc(${maxHeight} * ${aspect}))`,
height: 'auto',
maxHeight,
minHeight: 0,
marginInline: 'auto',
alignSelf: 'center',
}
return ( return (
<div className={styles.wrapper}> <div className={`${styles.wrapper} ${compact ? styles.compact : ''}`}>
{value && !imageSrc && ( {value && !imageSrc && (
<div className={styles.preview} style={frameStyle}> <div className={styles.preview} style={frameStyle}>
<img src={value} alt={resolvedUploadLabel} className={styles.previewImg} /> <img src={value} alt={resolvedUploadLabel} className={styles.previewImg} />
@@ -109,7 +119,7 @@ export function ImageCropper({
{!value && !imageSrc && ( {!value && !imageSrc && (
<label className={styles.uploadZone} style={frameStyle}> <label className={styles.uploadZone} style={frameStyle}>
<ImagePlus size={28} /> <ImagePlus size={compact ? 20 : 28} />
<span>{resolvedUploadLabel}</span> <span>{resolvedUploadLabel}</span>
<span className={styles.hint}>{resolvedHint}</span> <span className={styles.hint}>{resolvedHint}</span>
<input type="file" accept={accept} onChange={handleFile} hidden /> <input type="file" accept={accept} onChange={handleFile} hidden />
+12 -6
View File
@@ -147,9 +147,12 @@ const en = {
'bizProfile.logo.upload': 'Upload logo', 'bizProfile.logo.upload': 'Upload logo',
'bizProfile.logo.hint': 'Transparent PNG recommended', 'bizProfile.logo.hint': 'Transparent PNG recommended',
'bizProfile.logo.change': 'Change logo', 'bizProfile.logo.change': 'Change logo',
'bizProfile.logo.light': 'Light mode', 'bizProfile.logo.lightTheme': 'Logo for light theme',
'bizProfile.logo.dark': 'Dark mode', 'bizProfile.logo.darkTheme': 'Logo for dark theme',
'bizProfile.logo.modeSwitch': 'Logo for light or dark mode', 'bizProfile.favicon': 'Favicon',
'bizProfile.favicon.upload': 'Upload favicon',
'bizProfile.favicon.hint': 'Square PNG recommended',
'bizProfile.favicon.change': 'Change favicon',
'bizProfile.categories': 'Activity categories', 'bizProfile.categories': 'Activity categories',
'bizProfile.categories.placeholder': 'Select activity categories', 'bizProfile.categories.placeholder': 'Select activity categories',
'bizProfile.nameFa': 'Name (FA)', 'bizProfile.nameFa': 'Name (FA)',
@@ -2384,9 +2387,12 @@ const fa: Record<MessageKey, string> = {
'bizProfile.logo.upload': 'آپلود لوگو', 'bizProfile.logo.upload': 'آپلود لوگو',
'bizProfile.logo.hint': 'PNG شفاف توصیه می‌شود', 'bizProfile.logo.hint': 'PNG شفاف توصیه می‌شود',
'bizProfile.logo.change': 'تغییر لوگو', 'bizProfile.logo.change': 'تغییر لوگو',
'bizProfile.logo.light': 'حالت روشن', 'bizProfile.logo.lightTheme': 'لوگو در تم روشن',
'bizProfile.logo.dark': 'حالت تیره', 'bizProfile.logo.darkTheme': 'لوگو در تم تیره',
'bizProfile.logo.modeSwitch': 'لوگوی حالت روشن یا تیره', 'bizProfile.favicon': 'فاوآیکن',
'bizProfile.favicon.upload': 'آپلود فاوآیکن',
'bizProfile.favicon.hint': 'PNG مربعی توصیه می‌شود',
'bizProfile.favicon.change': 'تغییر فاوآیکن',
'bizProfile.categories': 'دسته‌بندی‌های فعالیت', 'bizProfile.categories': 'دسته‌بندی‌های فعالیت',
'bizProfile.categories.placeholder': 'انتخاب دسته‌بندی‌های فعالیت', 'bizProfile.categories.placeholder': 'انتخاب دسته‌بندی‌های فعالیت',
'bizProfile.nameFa': 'نام (فارسی)', 'bizProfile.nameFa': 'نام (فارسی)',
@@ -40,28 +40,21 @@
margin-bottom: 0; margin-bottom: 0;
} }
.logoCol { .logoLightCol,
.logoDarkCol,
.faviconCol {
grid-column: span 12; grid-column: span 12;
} min-width: 0;
.logoModeRow {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center;
gap: 10px;
margin-bottom: 10px;
} }
.logoModeLabel { .mediaFieldLabel {
display: block;
margin-bottom: 8px;
font-size: 12px; font-size: 12px;
font-weight: 500;
color: var(--text-muted);
transition: color 0.15s;
}
.logoModeLabelActive {
color: var(--text-primary);
font-weight: 600; font-weight: 600;
color: var(--text-secondary);
} }
.basicAside { .basicAside {
@@ -70,6 +63,7 @@
grid-template-columns: repeat(12, 1fr); grid-template-columns: repeat(12, 1fr);
gap: 12px 16px; gap: 12px 16px;
align-content: start; align-content: start;
min-width: 0;
} }
.fullRow { .fullRow {
@@ -233,12 +227,20 @@
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
.logoCol { .logoLightCol {
grid-column: span 2; grid-column: span 2;
} }
.logoDarkCol {
grid-column: span 2;
}
.faviconCol {
grid-column: span 1;
}
.basicAside { .basicAside {
grid-column: span 10; grid-column: span 7;
} }
.halfRow { .halfRow {
+45 -24
View File
@@ -14,7 +14,6 @@ import { PageTitle } from '../components/PageTitle'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { MultiSelectDropdown } from '../components/MultiSelectDropdown' import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
import { Switch } from '../components/Switch'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
@@ -83,7 +82,8 @@ export function BusinessProfilePage() {
const [logoMediaId, setLogoMediaId] = useState<string | null>(null) const [logoMediaId, setLogoMediaId] = useState<string | null>(null)
const [logoDark, setLogoDark] = useState<string | null>(null) const [logoDark, setLogoDark] = useState<string | null>(null)
const [logoDarkMediaId, setLogoDarkMediaId] = useState<string | null>(null) const [logoDarkMediaId, setLogoDarkMediaId] = useState<string | null>(null)
const [logoModeDark, setLogoModeDark] = useState(false) const [favicon, setFavicon] = useState<string | null>(null)
const [faviconMediaId, setFaviconMediaId] = useState<string | null>(null)
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()]) const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
const [phoneNumbers, setPhoneNumbers] = useState<BusinessPhoneNumber[]>([ const [phoneNumbers, setPhoneNumbers] = useState<BusinessPhoneNumber[]>([
createEmptyPhone(), createEmptyPhone(),
@@ -159,6 +159,10 @@ export function BusinessProfilePage() {
setLogoMediaId(profileData.profile.logoMediaId) setLogoMediaId(profileData.profile.logoMediaId)
setLogoDark(profileData.profile.logoDarkUrl) setLogoDark(profileData.profile.logoDarkUrl)
setLogoDarkMediaId(profileData.profile.logoDarkMediaId) setLogoDarkMediaId(profileData.profile.logoDarkMediaId)
setFavicon(
profileData.profile.faviconMediaId ? profileData.profile.faviconUrl : null,
)
setFaviconMediaId(profileData.profile.faviconMediaId)
const nextAddresses = const nextAddresses =
profileData.addresses.length > 0 profileData.addresses.length > 0
@@ -284,6 +288,11 @@ export function BusinessProfilePage() {
'business-logo-dark.png', 'business-logo-dark.png',
logoDarkMediaId, logoDarkMediaId,
) )
const nextFaviconMediaId = await resolveDataUrlToMediaId(
favicon,
'business-favicon.png',
faviconMediaId,
)
const payloadAddresses = addresses const payloadAddresses = addresses
.filter( .filter(
@@ -321,6 +330,7 @@ export function BusinessProfilePage() {
socialMedia, socialMedia,
logoMediaId: nextLogoMediaId, logoMediaId: nextLogoMediaId,
logoDarkMediaId: nextLogoDarkMediaId, logoDarkMediaId: nextLogoDarkMediaId,
faviconMediaId: nextFaviconMediaId,
categoryIds, categoryIds,
addresses: payloadAddresses, addresses: payloadAddresses,
}) })
@@ -368,29 +378,12 @@ export function BusinessProfilePage() {
<section className={styles.section}> <section className={styles.section}>
<h3 className={styles.sectionTitle}>{t('bizProfile.section.basic')}</h3> <h3 className={styles.sectionTitle}>{t('bizProfile.section.basic')}</h3>
<div className={formStyles.formGrid}> <div className={formStyles.formGrid}>
<div className={`${formStyles.field} ${styles.logoCol}`}> <div className={`${formStyles.field} ${styles.logoLightCol}`}>
<label>{t('bizProfile.logo')}</label> <label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label>
<div className={styles.logoModeRow}>
<span
className={`${styles.logoModeLabel} ${!logoModeDark ? styles.logoModeLabelActive : ''}`}
>
{t('bizProfile.logo.light')}
</span>
<Switch
checked={logoModeDark}
onChange={setLogoModeDark}
aria-label={t('bizProfile.logo.modeSwitch')}
/>
<span
className={`${styles.logoModeLabel} ${logoModeDark ? styles.logoModeLabelActive : ''}`}
>
{t('bizProfile.logo.dark')}
</span>
</div>
<ImageCropper <ImageCropper
key={logoModeDark ? 'logo-dark' : 'logo-light'} compact
value={logoModeDark ? logoDark : logo} value={logo}
onChange={logoModeDark ? setLogoDark : setLogo} onChange={setLogo}
outputFormat="png" outputFormat="png"
accept="image/png" accept="image/png"
uploadLabel={t('bizProfile.logo.upload')} uploadLabel={t('bizProfile.logo.upload')}
@@ -399,6 +392,34 @@ export function BusinessProfilePage() {
/> />
</div> </div>
<div className={`${formStyles.field} ${styles.logoDarkCol}`}>
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.darkTheme')}</label>
<ImageCropper
compact
value={logoDark}
onChange={setLogoDark}
outputFormat="png"
accept="image/png"
uploadLabel={t('bizProfile.logo.upload')}
hint={t('bizProfile.logo.hint')}
changeLabel={t('bizProfile.logo.change')}
/>
</div>
<div className={`${formStyles.field} ${styles.faviconCol}`}>
<label className={styles.mediaFieldLabel}>{t('bizProfile.favicon')}</label>
<ImageCropper
compact
value={favicon}
onChange={setFavicon}
outputFormat="png"
accept="image/png"
uploadLabel={t('bizProfile.favicon.upload')}
hint={t('bizProfile.favicon.hint')}
changeLabel={t('bizProfile.favicon.change')}
/>
</div>
<div className={styles.basicAside}> <div className={styles.basicAside}>
<div className={`${formStyles.field} ${styles.fullRow}`}> <div className={`${formStyles.field} ${styles.fullRow}`}>
<label htmlFor="activity-categories">{t('bizProfile.categories')}</label> <label htmlFor="activity-categories">{t('bizProfile.categories')}</label>
@@ -59,6 +59,7 @@ export interface UpdateBusinessProfilePayload {
socialMedia?: Partial<BusinessSocialMedia> socialMedia?: Partial<BusinessSocialMedia>
logoMediaId?: string | null logoMediaId?: string | null
logoDarkMediaId?: string | null logoDarkMediaId?: string | null
faviconMediaId?: string | null
categoryIds?: string[] categoryIds?: string[]
addresses?: Array<{ addresses?: Array<{
id?: string id?: string
@@ -101,6 +102,12 @@ export async function updateBusinessProfile(payload: UpdateBusinessProfilePayloa
: payload.logoDarkMediaId : payload.logoDarkMediaId
? Number(payload.logoDarkMediaId) ? Number(payload.logoDarkMediaId)
: null, : null,
faviconMediaId:
payload.faviconMediaId === undefined
? undefined
: payload.faviconMediaId
? Number(payload.faviconMediaId)
: null,
}, },
}) })
} }
@@ -61,21 +61,21 @@ export function BusinessActionsOverflowMenu({
? ([ ? ([
{ {
key: 'website', key: 'website',
label: 'باز کردن وب‌سایت', label: 'Open website',
detail: apex, detail: apex,
icon: Globe, icon: Globe,
onClick: () => openHost(apex), onClick: () => openHost(apex),
}, },
{ {
key: 'business', key: 'business',
label: 'داشبورد کسب‌وکار', label: 'Business dashboard',
detail: `business.${apex}`, detail: `business.${apex}`,
icon: Building2, icon: Building2,
onClick: () => openHost(`business.${apex}`), onClick: () => openHost(`business.${apex}`),
}, },
{ {
key: 'customer', key: 'customer',
label: 'داشبورد مشتری', label: 'Customer dashboard',
detail: `customer.${apex}`, detail: `customer.${apex}`,
icon: UserRound, icon: UserRound,
onClick: () => openHost(`customer.${apex}`), onClick: () => openHost(`customer.${apex}`),
@@ -151,8 +151,8 @@ export function BusinessActionsOverflowMenu({
e.stopPropagation() e.stopPropagation()
toggleOpen() toggleOpen()
}} }}
title="عملیات بیشتر" title="More actions"
aria-label={`عملیات بیشتر برای ${businessName}`} aria-label={`More actions for ${businessName}`}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
> >
@@ -166,9 +166,7 @@ export function BusinessActionsOverflowMenu({
ref={popoverRef} ref={popoverRef}
className={styles.menu} className={styles.menu}
role="menu" role="menu"
aria-label={`عملیات بیشتر برای ${businessName}`} aria-label={`More actions for ${businessName}`}
lang="fa"
dir="rtl"
style={{ style={{
top: popoverPos.top, top: popoverPos.top,
left: popoverPos.left, left: popoverPos.left,
@@ -206,7 +204,7 @@ export function BusinessActionsOverflowMenu({
}} }}
> >
<Users size={16} /> <Users size={16} />
<span>همه کاربران</span> <span>All users</span>
</button> </button>
<button <button
type="button" type="button"
@@ -220,7 +218,7 @@ export function BusinessActionsOverflowMenu({
}} }}
> >
<Eraser size={16} /> <Eraser size={16} />
<span>پاک‌سازی داده</span> <span>Wipe data</span>
</button> </button>
<button <button
type="button" type="button"
@@ -232,7 +230,7 @@ export function BusinessActionsOverflowMenu({
}} }}
> >
<Trash2 size={16} /> <Trash2 size={16} />
<span>حذف</span> <span>Remove</span>
</button> </button>
</div>, </div>,
document.body, document.body,
@@ -109,21 +109,21 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
const items = [ const items = [
{ {
key: 'website', key: 'website',
label: 'باز کردن وب‌سایت', label: 'Open website',
detail: websiteHost, detail: websiteHost,
icon: Globe, icon: Globe,
onClick: () => openHost(websiteHost), onClick: () => openHost(websiteHost),
}, },
{ {
key: 'business', key: 'business',
label: 'داشبورد کسب‌وکار', label: 'Business dashboard',
detail: businessHost, detail: businessHost,
icon: Building2, icon: Building2,
onClick: () => openHost(businessHost), onClick: () => openHost(businessHost),
}, },
{ {
key: 'customer', key: 'customer',
label: 'داشبورد مشتری', label: 'Customer dashboard',
detail: customerHost, detail: customerHost,
icon: UserRound, icon: UserRound,
onClick: () => openHost(customerHost), onClick: () => openHost(customerHost),
@@ -141,8 +141,8 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
e.stopPropagation() e.stopPropagation()
toggleOpen() toggleOpen()
}} }}
title="عملیات بیشتر" title="More actions"
aria-label={`عملیات بیشتر برای ${apex}`} aria-label={`More actions for ${apex}`}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
> >
@@ -156,9 +156,7 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
ref={popoverRef} ref={popoverRef}
className={styles.menu} className={styles.menu}
role="menu" role="menu"
aria-label={`عملیات بیشتر برای ${apex}`} aria-label={`More actions for ${apex}`}
lang="fa"
dir="rtl"
style={{ style={{
top: popoverPos.top, top: popoverPos.top,
left: popoverPos.left, left: popoverPos.left,
@@ -196,7 +194,7 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
}} }}
> >
<Trash2 size={16} /> <Trash2 size={16} />
<span>حذف</span> <span>Remove</span>
</button> </button>
</div>, </div>,
document.body, document.body,