Add FA/EN locale, home activity charts, and theme-aware polish.

Ship shared LocaleProvider, business/customer i18n, branding defaultLocale, curated home tiles with dual 30-day charts, and chart/page aura tokens; refresh PROJECT_CONTEXT.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-01 09:35:27 +03:30
co-authored by Cursor
parent f5b2193ba1
commit 66004a0fba
112 changed files with 4338 additions and 1061 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
@@ -89,13 +89,6 @@
cursor: pointer;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.profileInfo {
display: flex;
flex-direction: column;
@@ -18,8 +18,6 @@ export function Header() {
user?.cellNumber ||
'Super Admin'
const avatarSeed = encodeURIComponent(user?.cellNumber ?? 'admin')
useEffect(() => {
if (!menuOpen) return
@@ -88,11 +86,6 @@ export function Header() {
aria-expanded={menuOpen}
aria-haspopup="menu"
>
<img
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${avatarSeed}`}
alt={displayName}
className={styles.avatar}
/>
<div className={styles.profileInfo}>
<span className={styles.name}>{displayName}</span>
<span className={styles.role}>Super Administrator</span>
@@ -71,3 +71,12 @@
transform: translateX(2px);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
}
:global([dir='rtl']) .arrowBtn {
transform: scaleX(-1);
}
:global([dir='rtl']) .card:hover .arrowBtn {
transform: scaleX(-1) translateX(2px);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
}
+6 -5
View File
@@ -36,8 +36,9 @@
--field-padding-y: 9px;
--field-padding-x: 12px;
--field-height: 38px;
--font-en: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'IRANYekan', 'IranYekan', 'Yekan', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
--card-hover-lift: -4px;
--card-hover-shadow: 0 16px 48px rgba(var(--primary-rgb) / 0.14);
--card-hover-transition: transform 0.25s ease, box-shadow 0.25s ease;
@@ -50,7 +51,7 @@ body,
}
body {
font-family: var(--font-en);
font-family: var(--font-ui);
color: var(--text-primary);
background:
radial-gradient(ellipse 80% 60% at 10% 0%, rgba(99, 102, 241, 0.18) 0%, transparent 55%),
@@ -126,7 +127,7 @@ select[multiple] option {
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']),
textarea {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-size: var(--field-font-size);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
@@ -147,12 +148,12 @@ textarea:focus {
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder,
textarea::placeholder {
font-family: var(--font-en);
font-family: var(--font-ui);
}
[dir='rtl'],
:lang(fa),
.faText {
font-family: var(--font-fa), var(--font-en);
font-family: var(--font-ui);
font-weight: 400;
}
@@ -402,6 +402,47 @@
padding-right: 8px;
}
.thLocale,
.tdLocale {
width: 1%;
white-space: nowrap;
text-align: center;
}
.tdLocale {
padding-left: 8px;
padding-right: 8px;
}
.localeSelect {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y)
var(--field-padding-x);
font-size: var(--field-font-size);
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
background-color: rgba(255, 255, 255, 0.7);
color: var(--text-primary);
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--select-arrow-offset) center;
background-size: var(--select-arrow-size);
cursor: pointer;
}
.localeSelect:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.localeSelect:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.td {
font-size: 13px;
color: var(--text-primary);
+110 -4
View File
@@ -55,7 +55,10 @@ import {
} from '../utils/businessPrimaryColors'
import {
getBusinessSettings,
updateBusinessBranding,
updateBusinessDefaultLocale,
updateBusinessPrimaryColor,
type DashboardLocale,
} from '../services/businessSettingsService'
import { flattenBusinessCategories } from '../utils/categories'
import { Pagination } from '@meshkee/dashboard-ui'
@@ -63,6 +66,11 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './BusinessesPage.module.css'
const DEFAULT_PAGE_SIZE = 10
const DEFAULT_LOCALE: DashboardLocale = 'fa'
function normalizeDefaultLocale(value: unknown): DashboardLocale {
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
}
function formatDate(value: string) {
const d = new Date(value)
@@ -190,6 +198,7 @@ export function BusinessesPage() {
const [editPrimaryColor, setEditPrimaryColor] = useState<BusinessPrimaryColorId>(
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
)
const [editDefaultLocale, setEditDefaultLocale] = useState<DashboardLocale>(DEFAULT_LOCALE)
const [editLoadingSettings, setEditLoadingSettings] = useState(false)
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
@@ -216,6 +225,7 @@ export function BusinessesPage() {
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null)
const [savingColorId, setSavingColorId] = useState<string | null>(null)
const [savingLocaleId, setSavingLocaleId] = useState<string | null>(null)
const [migrateOpen, setMigrateOpen] = useState(false)
const [migrateBusiness, setMigrateBusiness] = useState<BusinessListItem | null>(null)
@@ -327,6 +337,7 @@ export function BusinessesPage() {
setEditBusiness(b)
setEditName(b.name)
setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor))
setEditDefaultLocale(normalizeDefaultLocale(b.defaultLocale))
setEditError('')
setEditOpen(true)
setEditLoadingSettings(true)
@@ -335,6 +346,7 @@ export function BusinessesPage() {
void getBusinessSettings(b.id, controller.signal)
.then((data) => {
setEditPrimaryColor(data.settings.branding.primaryColor)
setEditDefaultLocale(normalizeDefaultLocale(data.settings.branding.defaultLocale))
})
.catch((err) => {
if (isAbortError(err)) return
@@ -405,20 +417,76 @@ export function BusinessesPage() {
}
}
async function handleDefaultLocaleChange(b: BusinessListItem, defaultLocale: DashboardLocale) {
const currentLocale = normalizeDefaultLocale(b.defaultLocale)
if (currentLocale === defaultLocale) return
setSavingLocaleId(b.id)
setError('')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, defaultLocale } : item,
),
}
})
if (editBusiness?.id === b.id) {
setEditDefaultLocale(defaultLocale)
}
try {
await updateBusinessDefaultLocale(b.id, defaultLocale)
showToast(`Default language updated for "${b.name}".`, 'success')
} catch (err) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, defaultLocale: currentLocale } : item,
),
}
})
if (editBusiness?.id === b.id) {
setEditDefaultLocale(currentLocale)
}
showToast(
err instanceof ApiError ? err.message : 'Unable to update default language.',
'error',
)
} finally {
setSavingLocaleId(null)
}
}
async function submitEdit() {
if (!editBusiness) return
setEditSubmitting(true)
setEditError('')
try {
await updateBusiness(editBusiness.id, { name: editName })
await updateBusinessPrimaryColor(editBusiness.id, editPrimaryColor)
await updateBusinessBranding(editBusiness.id, {
primaryColor: editPrimaryColor,
defaultLocale: editDefaultLocale,
})
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === editBusiness.id
? { ...item, name: editName.trim(), primaryColor: editPrimaryColor }
? {
...item,
name: editName.trim(),
primaryColor: editPrimaryColor,
defaultLocale: editDefaultLocale,
}
: item,
),
}
@@ -828,13 +896,14 @@ export function BusinessesPage() {
<th className={styles.th}>Domain</th>
<th className={styles.th}>Owner</th>
<th className={`${styles.th} ${styles.thTheme}`}>Theme</th>
<th className={`${styles.th} ${styles.thLocale}`}>Lang</th>
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={styles.td} colSpan={6}>
<td className={styles.td} colSpan={7}>
Loading...
</td>
</tr>
@@ -842,7 +911,7 @@ export function BusinessesPage() {
{!loading && data?.items?.length === 0 && (
<tr>
<td className={styles.td} colSpan={6}>
<td className={styles.td} colSpan={7}>
No results found.
</td>
</tr>
@@ -900,6 +969,23 @@ export function BusinessesPage() {
}
/>
</td>
<td className={`${styles.td} ${styles.tdLocale}`}>
<select
className={styles.localeSelect}
value={normalizeDefaultLocale(b.defaultLocale)}
disabled={savingLocaleId === b.id}
aria-label={`Default language for ${b.name}`}
onChange={(e) =>
void handleDefaultLocaleChange(
b,
normalizeDefaultLocale(e.target.value),
)
}
>
<option value="fa">FA</option>
<option value="en">EN</option>
</select>
</td>
<td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}>
<span className={styles.toggleInActions}>
@@ -1058,6 +1144,26 @@ export function BusinessesPage() {
/>
)}
</div>
<div className={styles.field}>
<label htmlFor="edit-default-locale">Default dashboard language</label>
{editLoadingSettings ? (
<p className={styles.helperText}>Loading language...</p>
) : (
<select
id="edit-default-locale"
className={styles.localeSelect}
value={editDefaultLocale}
disabled={editSubmitting}
onChange={(e) => setEditDefaultLocale(normalizeDefaultLocale(e.target.value))}
>
<option value="fa">Farsi (FA)</option>
<option value="en">English (EN)</option>
</select>
)}
<p className={styles.helperText}>
Business and customer dashboards open in this language.
</p>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
+32 -2
View File
@@ -13,6 +13,8 @@ function buildFormData(user: ReturnType<typeof useAuth>['user']): ProfileFormDat
return {
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
firstNameEn: user?.firstNameEn ?? '',
lastNameEn: user?.lastNameEn ?? '',
cellNumber: user ? formatCellForDisplay(user.cellNumber) : '',
email: user?.email ?? '',
about: user?.profile?.about ?? emptyProfile.about,
@@ -85,23 +87,51 @@ export function ProfilePage() {
<h3 className={styles.sectionTitle}>General</h3>
<div className={styles.grid}>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="firstName">Name</label>
<label htmlFor="firstName">First name (FA)</label>
<input
id="firstName"
type="text"
value={form.firstName}
onChange={(e) => updateField('firstName', e.target.value)}
placeholder="First name"
dir="rtl"
lang="fa"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="lastName">Last name</label>
<label htmlFor="lastName">Last name (FA)</label>
<input
id="lastName"
type="text"
value={form.lastName}
onChange={(e) => updateField('lastName', e.target.value)}
placeholder="Last name"
dir="rtl"
lang="fa"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="firstNameEn">First name (EN)</label>
<input
id="firstNameEn"
type="text"
value={form.firstNameEn}
onChange={(e) => updateField('firstNameEn', e.target.value)}
placeholder="First name"
dir="ltr"
lang="en"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="lastNameEn">Last name (EN)</label>
<input
id="lastNameEn"
type="text"
value={form.lastNameEn}
onChange={(e) => updateField('lastNameEn', e.target.value)}
placeholder="Last name"
dir="ltr"
lang="en"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
@@ -1,8 +1,11 @@
import { apiRequest } from '../lib/api'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
export type DashboardLocale = 'en' | 'fa'
export interface BrandingSettings {
primaryColor: BusinessPrimaryColorId
defaultLocale: DashboardLocale
}
export interface BusinessSettings {
@@ -41,3 +44,27 @@ export async function updateBusinessPrimaryColor(
},
})
}
export async function updateBusinessDefaultLocale(
businessId: string,
defaultLocale: DashboardLocale,
) {
return apiRequest<BusinessSettingsResponse>(`/businesses/${businessId}/settings`, {
method: 'PATCH',
auth: true,
body: {
branding: { defaultLocale },
},
})
}
export async function updateBusinessBranding(
businessId: string,
branding: Partial<BrandingSettings>,
) {
return apiRequest<BusinessSettingsResponse>(`/businesses/${businessId}/settings`, {
method: 'PATCH',
auth: true,
body: { branding },
})
}
@@ -9,6 +9,8 @@ export async function updateProfile(data: ProfileFormData) {
body: {
firstName: data.firstName,
lastName: data.lastName,
firstNameEn: data.firstNameEn,
lastNameEn: data.lastNameEn,
email: data.email || null,
about: data.about,
city: data.city,
+2
View File
@@ -17,6 +17,8 @@ export interface AuthUser {
email: string | null
firstName: string | null
lastName: string | null
firstNameEn: string | null
lastNameEn: string | null
cellVerifiedAt: string | null
roles: string[]
dashboard: DashboardType
+2
View File
@@ -1,4 +1,5 @@
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
import type { DashboardLocale } from '../services/businessSettingsService'
export interface BusinessOwnerInfo {
name: string | null
@@ -21,6 +22,7 @@ export interface BusinessListItem {
ownerCellNumber: string | null
isActive: boolean
primaryColor: BusinessPrimaryColorId
defaultLocale: DashboardLocale
oldBusinessId: string | null
}
+2
View File
@@ -12,6 +12,8 @@ export interface UserProfile {
export interface ProfileFormData {
firstName: string
lastName: string
firstNameEn: string
lastNameEn: string
cellNumber: string
email: string
about: string
@@ -0,0 +1,42 @@
import {
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
getBusinessPrimaryColorTokens,
type BusinessPrimaryColorId,
} from './businessPrimaryColors'
const CSS_VAR_DEFAULTS: Record<string, string> = {
'--primary': '#3b82f6',
'--primary-glow': '#3b82f6',
'--primary-light': '#dbeafe',
'--primary-dark': '#2563eb',
'--primary-rgb': '59 130 246',
'--primary-dark-rgb': '37 99 235',
'--chart-accent': '#06b6d4',
'--chart-accent-dark': '#0891b2',
'--chart-accent-rgb': '6 182 212',
'--chart-accent-dark-rgb': '8 145 178',
}
export function applyBusinessPrimaryColor(colorId?: BusinessPrimaryColorId | null) {
const root = document.documentElement
const tokens = getBusinessPrimaryColorTokens(colorId ?? DEFAULT_BUSINESS_PRIMARY_COLOR_ID)
root.style.setProperty('--primary', tokens.primary)
root.style.setProperty('--primary-glow', tokens.primaryGlow)
root.style.setProperty('--primary-light', tokens.primaryLight)
root.style.setProperty('--primary-dark', tokens.primaryDark)
root.style.setProperty('--primary-rgb', tokens.primaryRgb)
root.style.setProperty('--primary-dark-rgb', tokens.primaryDarkRgb)
root.style.setProperty('--chart-accent', tokens.chartAccent)
root.style.setProperty('--chart-accent-dark', tokens.chartAccentDark)
root.style.setProperty('--chart-accent-rgb', tokens.chartAccentRgb)
root.style.setProperty('--chart-accent-dark-rgb', tokens.chartAccentDarkRgb)
}
export function resetBusinessPrimaryColor() {
const root = document.documentElement
for (const [name, value] of Object.entries(CSS_VAR_DEFAULTS)) {
root.style.setProperty(name, value)
}
}
@@ -19,8 +19,28 @@ export type BusinessPrimaryColorTokens = {
primaryLight: string
primaryGlow: string
primaryRgb: string
primaryDarkRgb: string
/** Second chart series color (red→purple, blue→cyan, …). */
chartAccent: string
chartAccentDark: string
chartAccentRgb: string
chartAccentDarkRgb: string
}
const PURPLE_ACCENT = {
chartAccent: '#a855f7',
chartAccentDark: '#9333ea',
chartAccentRgb: '168 85 247',
chartAccentDarkRgb: '147 51 234',
} as const
const CYAN_ACCENT = {
chartAccent: '#06b6d4',
chartAccentDark: '#0891b2',
chartAccentRgb: '6 182 212',
chartAccentDarkRgb: '8 145 178',
} as const
export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
BusinessPrimaryColorId,
BusinessPrimaryColorTokens
@@ -32,6 +52,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#fee2e2',
primaryGlow: '#ef4444',
primaryRgb: '239 68 68',
primaryDarkRgb: '220 38 38',
...PURPLE_ACCENT,
},
yellow: {
label: 'Yellow',
@@ -40,6 +62,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#fef9c3',
primaryGlow: '#eab308',
primaryRgb: '234 179 8',
primaryDarkRgb: '202 138 4',
...PURPLE_ACCENT,
},
black: {
label: 'Black',
@@ -48,6 +72,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#e2e8f0',
primaryGlow: '#334155',
primaryRgb: '30 41 59',
primaryDarkRgb: '15 23 42',
...CYAN_ACCENT,
},
cyan: {
label: 'Cyan',
@@ -56,6 +82,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#cffafe',
primaryGlow: '#06b6d4',
primaryRgb: '6 182 212',
primaryDarkRgb: '8 145 178',
...PURPLE_ACCENT,
},
purple: {
label: 'Purple',
@@ -64,6 +92,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#f3e8ff',
primaryGlow: '#a855f7',
primaryRgb: '168 85 247',
primaryDarkRgb: '147 51 234',
...CYAN_ACCENT,
},
'light-blue': {
label: 'Light Blue',
@@ -72,6 +102,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#e0f2fe',
primaryGlow: '#38bdf8',
primaryRgb: '56 189 248',
primaryDarkRgb: '14 165 233',
...CYAN_ACCENT,
},
'dark-blue': {
label: 'Dark Blue',
@@ -80,6 +112,8 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryLight: '#dbeafe',
primaryGlow: '#3b82f6',
primaryRgb: '59 130 246',
primaryDarkRgb: '37 99 235',
...CYAN_ACCENT,
},
}
@@ -93,3 +127,11 @@ export function normalizeBusinessPrimaryColorId(value: unknown): BusinessPrimary
return DEFAULT_BUSINESS_PRIMARY_COLOR_ID
}
export function getBusinessPrimaryColorTokens(
colorId: BusinessPrimaryColorId | undefined | null,
): BusinessPrimaryColorTokens {
return BUSINESS_PRIMARY_COLOR_PALETTE[
normalizeBusinessPrimaryColorId(colorId ?? DEFAULT_BUSINESS_PRIMARY_COLOR_ID)
]
}