Add logo aspect ratios and businesses renewal sort with low-SMS alert.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e020c74fb1
commit
c24f6c06f7
@@ -150,6 +150,7 @@ const en = {
|
|||||||
'bizProfile.logo.change': 'Change logo',
|
'bizProfile.logo.change': 'Change logo',
|
||||||
'bizProfile.logo.lightTheme': 'Logo for light theme',
|
'bizProfile.logo.lightTheme': 'Logo for light theme',
|
||||||
'bizProfile.logo.darkTheme': 'Logo for dark theme',
|
'bizProfile.logo.darkTheme': 'Logo for dark theme',
|
||||||
|
'bizProfile.logo.aspectAria': 'Logo aspect ratio',
|
||||||
'bizProfile.favicon': 'Favicon',
|
'bizProfile.favicon': 'Favicon',
|
||||||
'bizProfile.favicon.upload': 'Upload favicon',
|
'bizProfile.favicon.upload': 'Upload favicon',
|
||||||
'bizProfile.favicon.hint': 'Square PNG recommended',
|
'bizProfile.favicon.hint': 'Square PNG recommended',
|
||||||
@@ -2695,6 +2696,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'bizProfile.logo.change': 'تغییر لوگو',
|
'bizProfile.logo.change': 'تغییر لوگو',
|
||||||
'bizProfile.logo.lightTheme': 'لوگو در تم روشن',
|
'bizProfile.logo.lightTheme': 'لوگو در تم روشن',
|
||||||
'bizProfile.logo.darkTheme': 'لوگو در تم تیره',
|
'bizProfile.logo.darkTheme': 'لوگو در تم تیره',
|
||||||
|
'bizProfile.logo.aspectAria': 'نسبت تصویر لوگو',
|
||||||
'bizProfile.favicon': 'فاوآیکن',
|
'bizProfile.favicon': 'فاوآیکن',
|
||||||
'bizProfile.favicon.upload': 'آپلود فاوآیکن',
|
'bizProfile.favicon.upload': 'آپلود فاوآیکن',
|
||||||
'bizProfile.favicon.hint': 'PNG مربعی توصیه میشود',
|
'bizProfile.favicon.hint': 'PNG مربعی توصیه میشود',
|
||||||
|
|||||||
@@ -40,8 +40,30 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoLightCol,
|
.logosBlock {
|
||||||
.logoDarkCol,
|
grid-column: span 12;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logosPair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px 16px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoField {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.faviconCol {
|
.faviconCol {
|
||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -229,12 +251,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
.logoLightCol {
|
.logosBlock {
|
||||||
grid-column: span 2;
|
grid-column: span 4;
|
||||||
}
|
|
||||||
|
|
||||||
.logoDarkCol {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.faviconCol {
|
.faviconCol {
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ import styles from './BusinessProfilePage.module.css'
|
|||||||
|
|
||||||
type AddressDraft = AddressListItem
|
type AddressDraft = AddressListItem
|
||||||
|
|
||||||
|
type LogoAspectId = '1:1' | '3:2' | '16:9'
|
||||||
|
|
||||||
|
const LOGO_ASPECTS: { id: LogoAspectId; aspect: number }[] = [
|
||||||
|
{ id: '1:1', aspect: 1 },
|
||||||
|
{ id: '3:2', aspect: 3 / 2 },
|
||||||
|
{ id: '16:9', aspect: 16 / 9 },
|
||||||
|
]
|
||||||
|
|
||||||
const EMPTY_SOCIAL: BusinessSocialMedia = {
|
const EMPTY_SOCIAL: BusinessSocialMedia = {
|
||||||
whatsapp: '',
|
whatsapp: '',
|
||||||
telegram: '',
|
telegram: '',
|
||||||
@@ -82,6 +90,7 @@ 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 [logoAspect, setLogoAspect] = useState<LogoAspectId>('1:1')
|
||||||
const [favicon, setFavicon] = useState<string | null>(null)
|
const [favicon, setFavicon] = useState<string | null>(null)
|
||||||
const [faviconMediaId, setFaviconMediaId] = useState<string | null>(null)
|
const [faviconMediaId, setFaviconMediaId] = useState<string | null>(null)
|
||||||
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
|
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
|
||||||
@@ -94,6 +103,22 @@ export function BusinessProfilePage() {
|
|||||||
() => flattenBusinessActivityCategories(activityCategories),
|
() => flattenBusinessActivityCategories(activityCategories),
|
||||||
[activityCategories],
|
[activityCategories],
|
||||||
)
|
)
|
||||||
|
const selectedLogoAspect =
|
||||||
|
LOGO_ASPECTS.find((option) => option.id === logoAspect)?.aspect ?? 1
|
||||||
|
|
||||||
|
function handleLogoAspectChange(next: LogoAspectId) {
|
||||||
|
if (next === logoAspect) return
|
||||||
|
setLogoAspect(next)
|
||||||
|
// Applied crops are locked to the old ratio — drop them so the user recrops.
|
||||||
|
if (logo) {
|
||||||
|
setLogo(null)
|
||||||
|
setLogoMediaId(null)
|
||||||
|
}
|
||||||
|
if (logoDark) {
|
||||||
|
setLogoDark(null)
|
||||||
|
setLogoDarkMediaId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addressLabels = useMemo(
|
const addressLabels = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -378,32 +403,60 @@ 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.logoLightCol}`}>
|
<div className={styles.logosBlock}>
|
||||||
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label>
|
<div className={styles.logosPair}>
|
||||||
<ImageCropper
|
<div className={`${formStyles.field} ${styles.logoField}`}>
|
||||||
compact
|
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.lightTheme')}</label>
|
||||||
value={logo}
|
<ImageCropper
|
||||||
onChange={setLogo}
|
compact
|
||||||
outputFormat="png"
|
value={logo}
|
||||||
accept="image/png"
|
onChange={setLogo}
|
||||||
uploadLabel={t('bizProfile.logo.upload')}
|
aspect={selectedLogoAspect}
|
||||||
hint={t('bizProfile.logo.hint')}
|
outputFormat="png"
|
||||||
changeLabel={t('bizProfile.logo.change')}
|
accept="image/png"
|
||||||
/>
|
uploadLabel={t('bizProfile.logo.upload')}
|
||||||
</div>
|
hint={t('bizProfile.logo.hint')}
|
||||||
|
changeLabel={t('bizProfile.logo.change')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={`${formStyles.field} ${styles.logoDarkCol}`}>
|
<div className={`${formStyles.field} ${styles.logoField}`}>
|
||||||
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.darkTheme')}</label>
|
<label className={styles.mediaFieldLabel}>{t('bizProfile.logo.darkTheme')}</label>
|
||||||
<ImageCropper
|
<ImageCropper
|
||||||
compact
|
compact
|
||||||
value={logoDark}
|
value={logoDark}
|
||||||
onChange={setLogoDark}
|
onChange={setLogoDark}
|
||||||
outputFormat="png"
|
aspect={selectedLogoAspect}
|
||||||
accept="image/png"
|
outputFormat="png"
|
||||||
uploadLabel={t('bizProfile.logo.upload')}
|
accept="image/png"
|
||||||
hint={t('bizProfile.logo.hint')}
|
uploadLabel={t('bizProfile.logo.upload')}
|
||||||
changeLabel={t('bizProfile.logo.change')}
|
hint={t('bizProfile.logo.hint')}
|
||||||
/>
|
changeLabel={t('bizProfile.logo.change')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={formStyles.aspectSwitch}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={t('bizProfile.logo.aspectAria')}
|
||||||
|
>
|
||||||
|
{LOGO_ASPECTS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={logoAspect === option.id}
|
||||||
|
className={`${formStyles.aspectSwitchBtn} ${
|
||||||
|
logoAspect === option.id ? formStyles.aspectSwitchBtnSelected : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleLogoAspectChange(option.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{option.id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${formStyles.field} ${styles.faviconCol}`}>
|
<div className={`${formStyles.field} ${styles.faviconCol}`}>
|
||||||
@@ -412,6 +465,7 @@ export function BusinessProfilePage() {
|
|||||||
compact
|
compact
|
||||||
value={favicon}
|
value={favicon}
|
||||||
onChange={setFavicon}
|
onChange={setFavicon}
|
||||||
|
aspect={1}
|
||||||
outputFormat="png"
|
outputFormat="png"
|
||||||
accept="image/png"
|
accept="image/png"
|
||||||
uploadLabel={t('bizProfile.favicon.upload')}
|
uploadLabel={t('bizProfile.favicon.upload')}
|
||||||
|
|||||||
@@ -58,6 +58,43 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lowSmsAlert {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.28);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lowSmsAlertLabel {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lowSmsAlertLink {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lowSmsAlertLink:hover {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lowSmsAlertBalance {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -440,6 +477,13 @@
|
|||||||
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
|
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.btnPrimary {
|
.btnPrimary {
|
||||||
color: white;
|
color: white;
|
||||||
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
|
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ export function BusinessesPage() {
|
|||||||
const [draftName, setDraftName] = useState('')
|
const [draftName, setDraftName] = useState('')
|
||||||
const [draftDomain, setDraftDomain] = useState('')
|
const [draftDomain, setDraftDomain] = useState('')
|
||||||
const [draftCategory, setDraftCategory] = useState('')
|
const [draftCategory, setDraftCategory] = useState('')
|
||||||
|
const [draftSort, setDraftSort] = useState<'created' | 'renewal'>('created')
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const [editBusiness, setEditBusiness] = useState<BusinessListItem | null>(null)
|
const [editBusiness, setEditBusiness] = useState<BusinessListItem | null>(null)
|
||||||
@@ -484,7 +485,7 @@ export function BusinessesPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
controller.abort()
|
controller.abort()
|
||||||
}
|
}
|
||||||
}, [page, pageSize, appliedFilters.name, appliedFilters.domain, appliedFilters.category])
|
}, [page, pageSize, appliedFilters.name, appliedFilters.domain, appliedFilters.category, appliedFilters.sort])
|
||||||
|
|
||||||
const totalPages = useMemo(() => {
|
const totalPages = useMemo(() => {
|
||||||
const total = data?.total ?? 0
|
const total = data?.total ?? 0
|
||||||
@@ -509,6 +510,7 @@ export function BusinessesPage() {
|
|||||||
...(draftName.trim() ? { name: draftName.trim() } : {}),
|
...(draftName.trim() ? { name: draftName.trim() } : {}),
|
||||||
...(draftDomain.trim() ? { domain: draftDomain.trim() } : {}),
|
...(draftDomain.trim() ? { domain: draftDomain.trim() } : {}),
|
||||||
...(draftCategory.trim() ? { category: draftCategory.trim() } : {}),
|
...(draftCategory.trim() ? { category: draftCategory.trim() } : {}),
|
||||||
|
...(draftSort !== 'created' ? { sort: draftSort } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +518,7 @@ export function BusinessesPage() {
|
|||||||
setDraftName('')
|
setDraftName('')
|
||||||
setDraftDomain('')
|
setDraftDomain('')
|
||||||
setDraftCategory('')
|
setDraftCategory('')
|
||||||
|
setDraftSort('created')
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setAppliedFilters({})
|
setAppliedFilters({})
|
||||||
}
|
}
|
||||||
@@ -1503,6 +1506,32 @@ export function BusinessesPage() {
|
|||||||
aria-label="Category"
|
aria-label="Category"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<select
|
||||||
|
id="filter-sort"
|
||||||
|
value={draftSort}
|
||||||
|
onChange={(e) => {
|
||||||
|
const sort = e.target.value === 'renewal' ? 'renewal' : 'created'
|
||||||
|
setDraftSort(sort)
|
||||||
|
setPage(1)
|
||||||
|
setAppliedFilters((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
if (sort === 'created') {
|
||||||
|
delete next.sort
|
||||||
|
} else {
|
||||||
|
next.sort = sort
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
aria-label="Sort by"
|
||||||
|
title="Sort by"
|
||||||
|
>
|
||||||
|
<option value="created">Date created</option>
|
||||||
|
<option value="renewal">Nearest renewal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.filterActions}>
|
<div className={styles.filterActions}>
|
||||||
@@ -1529,6 +1558,43 @@ export function BusinessesPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{data?.lowSmsBusinesses && data.lowSmsBusinesses.length > 0 && (
|
||||||
|
<div className={styles.lowSmsAlert} role="status">
|
||||||
|
<span className={styles.lowSmsAlertLabel}>
|
||||||
|
Low SMS credits (< {data.lowSmsThreshold ?? 100}):
|
||||||
|
</span>{' '}
|
||||||
|
{data.lowSmsBusinesses.map((b, i) => (
|
||||||
|
<span key={String(b.id)}>
|
||||||
|
{i > 0 ? ', ' : ''}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.lowSmsAlertLink}
|
||||||
|
onClick={() => {
|
||||||
|
const match = data.items.find((item) => String(item.id) === String(b.id))
|
||||||
|
if (match) {
|
||||||
|
openSmsSettings(match)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDraftName(b.name)
|
||||||
|
setPage(1)
|
||||||
|
setAppliedFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: b.name,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.name}
|
||||||
|
{b.domain ? ` (${b.domain})` : ''}
|
||||||
|
</button>
|
||||||
|
<span className={styles.lowSmsAlertBalance}>
|
||||||
|
{' '}
|
||||||
|
· {b.smsBalance.toLocaleString('en-US')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.tablePanel}>
|
<div className={styles.tablePanel}>
|
||||||
<div className={styles.tableWrap}>
|
<div className={styles.tableWrap}>
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableHeader}>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface ListBusinessesParams {
|
|||||||
name?: string
|
name?: string
|
||||||
domain?: string
|
domain?: string
|
||||||
category?: string
|
category?: string
|
||||||
|
/** Default: created (newest first). renewal = nearest next/overdue renewal. */
|
||||||
|
sort?: 'created' | 'renewal'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateBusinessPayload {
|
export interface UpdateBusinessPayload {
|
||||||
@@ -61,6 +63,7 @@ export async function listBusinesses(params: ListBusinessesParams, signal?: Abor
|
|||||||
if (params.name) q.set('name', params.name)
|
if (params.name) q.set('name', params.name)
|
||||||
if (params.domain) q.set('domain', params.domain)
|
if (params.domain) q.set('domain', params.domain)
|
||||||
if (params.category) q.set('category', params.category)
|
if (params.category) q.set('category', params.category)
|
||||||
|
if (params.sort && params.sort !== 'created') q.set('sort', params.sort)
|
||||||
|
|
||||||
const query = q.toString()
|
const query = q.toString()
|
||||||
const path = `/businesses${query ? `?${query}` : ''}`
|
const path = `/businesses${query ? `?${query}` : ''}`
|
||||||
|
|||||||
@@ -137,5 +137,13 @@ export interface BusinessesListResponse {
|
|||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
|
/** Businesses with prepaid SMS credits below lowSmsThreshold (all pages). */
|
||||||
|
lowSmsBusinesses?: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
domain: string | null
|
||||||
|
smsBalance: number
|
||||||
|
}>
|
||||||
|
lowSmsThreshold?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user