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
@@ -18,6 +18,7 @@ import { MyProductsPage } from './pages/MyProductsPage'
|
|||||||
import { ProductDetailsPage } from './pages/ProductDetailsPage'
|
import { ProductDetailsPage } from './pages/ProductDetailsPage'
|
||||||
import { ProductSettingsPage } from './pages/ProductSettingsPage'
|
import { ProductSettingsPage } from './pages/ProductSettingsPage'
|
||||||
import { BusinessProfilePage } from './pages/BusinessProfilePage'
|
import { BusinessProfilePage } from './pages/BusinessProfilePage'
|
||||||
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
import { StorePage } from './pages/StorePage'
|
import { StorePage } from './pages/StorePage'
|
||||||
import { StoreItemsPage } from './pages/StoreItemsPage'
|
import { StoreItemsPage } from './pages/StoreItemsPage'
|
||||||
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
|
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
|
||||||
@@ -66,6 +67,7 @@ function App() {
|
|||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<PageLayout />}>
|
<Route element={<PageLayout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="business-profile" element={<BusinessProfilePage />} />
|
<Route path="business-profile" element={<BusinessProfilePage />} />
|
||||||
<Route path="products" element={<ProductsPage />} />
|
<Route path="products" element={<ProductsPage />} />
|
||||||
<Route path="products/categories" element={<CategoriesPage />} />
|
<Route path="products/categories" element={<CategoriesPage />} />
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function BlogCategoryRow({
|
|||||||
: category.nameFa
|
: category.nameFa
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.row} style={{ marginLeft: depth * 8 }}>
|
<div className={styles.row} style={{ marginInlineStart: depth * 8 }}>
|
||||||
<div
|
<div
|
||||||
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
|
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
|
||||||
onClick={hasChildren ? onToggle : undefined}
|
onClick={hasChildren ? onToggle : undefined}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function CategoryRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.row}
|
className={styles.row}
|
||||||
style={{ marginLeft: depth * 8 }}
|
style={{ marginInlineStart: depth * 8 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
|
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
.childrenInner {
|
.childrenInner {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-left: 20px;
|
margin-inline-start: 20px;
|
||||||
padding-left: 20px;
|
padding-inline-start: 20px;
|
||||||
border-left: 2px solid rgba(148, 163, 184, 0.3);
|
border-inline-start: 2px solid rgba(148, 163, 184, 0.3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,26 @@
|
|||||||
background: rgba(255, 255, 255, 0.75);
|
background: rgba(255, 255, 255, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brandEditBtn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(var(--primary-rgb) / 0.08);
|
||||||
|
border: 1px dashed rgba(var(--primary-rgb) / 0.35);
|
||||||
|
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandEditBtn:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.14);
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: rgba(var(--primary-rgb) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.brandText {
|
.brandText {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Building2,
|
Building2,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
@@ -25,7 +26,7 @@ import {
|
|||||||
} from '../lib/businessContext'
|
} from '../lib/businessContext'
|
||||||
import { isAbortError } from '../lib/api'
|
import { isAbortError } from '../lib/api'
|
||||||
import { getBusinessProfile } from '../services/businessProfileService'
|
import { getBusinessProfile } from '../services/businessProfileService'
|
||||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
import { Tooltip } from './Tooltip'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
|
|
||||||
interface NavChild {
|
interface NavChild {
|
||||||
@@ -118,7 +119,6 @@ export function Sidebar() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
|
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
|
||||||
{ type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
|
|
||||||
{
|
{
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'blog',
|
id: 'blog',
|
||||||
@@ -166,6 +166,7 @@ export function Sidebar() {
|
|||||||
{ labelKey: 'nav.website.ePayment', to: '/website/e-payment' },
|
{ labelKey: 'nav.website.ePayment', to: '/website/e-payment' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ type: 'link', id: 'settings', icon: Settings, labelKey: 'nav.settings', to: '/settings' },
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -218,11 +219,23 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<aside className={styles.sidebar}>
|
<aside className={styles.sidebar}>
|
||||||
<div className={styles.brand}>
|
<div className={styles.brand}>
|
||||||
|
{brandLogoUrl ? (
|
||||||
<img
|
<img
|
||||||
src={brandLogoUrl ?? meshkeeLogo}
|
src={brandLogoUrl}
|
||||||
alt={brandName || t('app.storeFallback')}
|
alt={brandName || t('app.storeFallback')}
|
||||||
className={`${styles.brandLogo} ${brandLogoUrl ? styles.brandLogoUploaded : ''}`}
|
className={`${styles.brandLogo} ${styles.brandLogoUploaded}`}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Tooltip label={t('sidebar.addLogo')}>
|
||||||
|
<NavLink
|
||||||
|
to="/business-profile"
|
||||||
|
className={styles.brandEditBtn}
|
||||||
|
aria-label={t('sidebar.addLogo')}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</NavLink>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<div className={styles.brandText}>
|
<div className={styles.brandText}>
|
||||||
<span className={styles.brandName}>{brandName || fallbackBusinessName}</span>
|
<span className={styles.brandName}>{brandName || fallbackBusinessName}</span>
|
||||||
<span className={styles.brandDomain}>{businessDomain}</span>
|
<span className={styles.brandDomain}>{businessDomain}</span>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const en = {
|
|||||||
|
|
||||||
'nav.home': 'Home',
|
'nav.home': 'Home',
|
||||||
'nav.businessProfile': 'Business Profile',
|
'nav.businessProfile': 'Business Profile',
|
||||||
|
'sidebar.addLogo': 'Add logo',
|
||||||
'nav.products': 'Products',
|
'nav.products': 'Products',
|
||||||
'nav.products.overview': 'Overview',
|
'nav.products.overview': 'Overview',
|
||||||
'nav.products.list': 'My Products',
|
'nav.products.list': 'My Products',
|
||||||
@@ -62,6 +63,81 @@ const en = {
|
|||||||
'header.setting': 'Setting',
|
'header.setting': 'Setting',
|
||||||
'header.changePassword': 'Change password',
|
'header.changePassword': 'Change password',
|
||||||
|
|
||||||
|
'profile.title': 'My Profile',
|
||||||
|
'profile.subtitle': 'Manage your personal information and contact details.',
|
||||||
|
'profile.section.general': 'General',
|
||||||
|
'profile.section.social': 'Social media',
|
||||||
|
'profile.firstName': 'First name (FA)',
|
||||||
|
'profile.lastName': 'Last name (FA)',
|
||||||
|
'profile.firstNameEn': 'First name (EN)',
|
||||||
|
'profile.lastNameEn': 'Last name (EN)',
|
||||||
|
'profile.mobile': 'Mobile number',
|
||||||
|
'profile.email': 'Email',
|
||||||
|
'profile.emailPlaceholder': 'you@example.com',
|
||||||
|
'profile.about': 'About yourself',
|
||||||
|
'profile.addresses': 'Addresses',
|
||||||
|
'profile.addresses.add': 'Add address',
|
||||||
|
'profile.addresses.loading': 'Loading addresses...',
|
||||||
|
'profile.addresses.remove': 'Remove address',
|
||||||
|
'profile.addresses.error.load': 'Unable to load addresses.',
|
||||||
|
'profile.addresses.error.incomplete': 'Complete province, city, and address for each row you start.',
|
||||||
|
'profile.province': 'Province',
|
||||||
|
'profile.city': 'City',
|
||||||
|
'profile.postalCode': 'Postal code',
|
||||||
|
'profile.address': 'Address',
|
||||||
|
'profile.landline': 'Landline',
|
||||||
|
'profile.selectProvince': 'Select province',
|
||||||
|
'profile.selectCity': 'Select city',
|
||||||
|
'profile.streetPlaceholder': 'Street address',
|
||||||
|
'profile.instagram': 'Instagram',
|
||||||
|
'profile.telegram': 'Telegram ID',
|
||||||
|
'profile.linkedin': 'LinkedIn',
|
||||||
|
'profile.save': 'Save changes',
|
||||||
|
'profile.saving': 'Saving...',
|
||||||
|
'profile.toast.success': 'Profile saved successfully.',
|
||||||
|
'profile.error.update': 'Unable to save profile.',
|
||||||
|
|
||||||
|
'bizProfile.subtitle': 'Manage your storefront identity, contact details, and social links.',
|
||||||
|
'bizProfile.loading': 'Loading business profile...',
|
||||||
|
'bizProfile.loadError': 'Unable to load business profile.',
|
||||||
|
'bizProfile.saveError': 'Unable to save business profile.',
|
||||||
|
'bizProfile.toast.saved': 'Business profile saved.',
|
||||||
|
'bizProfile.section.basic': 'Basic info',
|
||||||
|
'bizProfile.section.contact': 'Contact',
|
||||||
|
'bizProfile.section.social': 'Social media',
|
||||||
|
'bizProfile.logo': 'Logo',
|
||||||
|
'bizProfile.logo.upload': 'Upload logo',
|
||||||
|
'bizProfile.logo.hint': 'Transparent PNG recommended',
|
||||||
|
'bizProfile.logo.change': 'Change logo',
|
||||||
|
'bizProfile.categories': 'Activity categories',
|
||||||
|
'bizProfile.categories.placeholder': 'Select activity categories',
|
||||||
|
'bizProfile.nameFa': 'Name (FA)',
|
||||||
|
'bizProfile.nameEn': 'Name (EN)',
|
||||||
|
'bizProfile.email': 'Email address',
|
||||||
|
'bizProfile.about': 'About us',
|
||||||
|
'bizProfile.vision': 'Our vision',
|
||||||
|
'bizProfile.addresses': 'Addresses',
|
||||||
|
'bizProfile.addresses.add': 'Add address',
|
||||||
|
'bizProfile.province': 'Province',
|
||||||
|
'bizProfile.city': 'City',
|
||||||
|
'bizProfile.address': 'Address',
|
||||||
|
'bizProfile.postalCode': 'Postal code',
|
||||||
|
'bizProfile.landline': 'Landline',
|
||||||
|
'bizProfile.selectProvince': 'Select province',
|
||||||
|
'bizProfile.selectCity': 'Select city',
|
||||||
|
'bizProfile.streetPlaceholder': 'Street address',
|
||||||
|
'bizProfile.removeAddress': 'Remove address',
|
||||||
|
'bizProfile.phones': 'Phone numbers',
|
||||||
|
'bizProfile.phones.add': 'Add number',
|
||||||
|
'bizProfile.phones.type': 'Type',
|
||||||
|
'bizProfile.phones.number': 'Phone number',
|
||||||
|
'bizProfile.phones.cell': 'Cell number',
|
||||||
|
'bizProfile.phones.landline': 'Landline',
|
||||||
|
'bizProfile.phones.remove': 'Remove phone number',
|
||||||
|
'bizProfile.social.placeholder': '{name} link or ID',
|
||||||
|
'bizProfile.save': 'Save profile',
|
||||||
|
'bizProfile.saving': 'Saving...',
|
||||||
|
|
||||||
'bc.dashboard': 'Dashboard',
|
'bc.dashboard': 'Dashboard',
|
||||||
'bc.editProduct': 'Edit Product',
|
'bc.editProduct': 'Edit Product',
|
||||||
'bc.productDetails': 'Product Details',
|
'bc.productDetails': 'Product Details',
|
||||||
@@ -193,7 +269,7 @@ const en = {
|
|||||||
'products.form.save': 'Save Product',
|
'products.form.save': 'Save Product',
|
||||||
'products.form.update': 'Update Product',
|
'products.form.update': 'Update Product',
|
||||||
|
|
||||||
'categories.page.subtitle': 'Manage your digital product categories and subcategories.',
|
'categories.page.subtitle': 'Manage your product categories and subcategories.',
|
||||||
'categories.loading': 'Loading categories...',
|
'categories.loading': 'Loading categories...',
|
||||||
'categories.empty': 'No categories yet. Use + or AI to add categories.',
|
'categories.empty': 'No categories yet. Use + or AI to add categories.',
|
||||||
'categories.add': 'Add Category',
|
'categories.add': 'Add Category',
|
||||||
@@ -873,6 +949,7 @@ const en = {
|
|||||||
|
|
||||||
'title.signIn': 'Sign in',
|
'title.signIn': 'Sign in',
|
||||||
'title.home': 'Home',
|
'title.home': 'Home',
|
||||||
|
'title.profile': 'Profile',
|
||||||
'title.businessProfile': 'Business Profile',
|
'title.businessProfile': 'Business Profile',
|
||||||
'title.products': 'Products',
|
'title.products': 'Products',
|
||||||
'title.myProducts': 'My Products',
|
'title.myProducts': 'My Products',
|
||||||
@@ -993,6 +1070,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
|
|
||||||
'nav.home': 'خانه',
|
'nav.home': 'خانه',
|
||||||
'nav.businessProfile': 'پروفایل کسبوکار',
|
'nav.businessProfile': 'پروفایل کسبوکار',
|
||||||
|
'sidebar.addLogo': 'افزودن لوگو',
|
||||||
'nav.products': 'محصولات',
|
'nav.products': 'محصولات',
|
||||||
'nav.products.overview': 'نمای کلی',
|
'nav.products.overview': 'نمای کلی',
|
||||||
'nav.products.list': 'محصولات من',
|
'nav.products.list': 'محصولات من',
|
||||||
@@ -1043,6 +1121,81 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'header.setting': 'تنظیمات',
|
'header.setting': 'تنظیمات',
|
||||||
'header.changePassword': 'تغییر رمز عبور',
|
'header.changePassword': 'تغییر رمز عبور',
|
||||||
|
|
||||||
|
'profile.title': 'پروفایل من',
|
||||||
|
'profile.subtitle': 'اطلاعات شخصی و راههای ارتباطی خود را مدیریت کنید.',
|
||||||
|
'profile.section.general': 'عمومی',
|
||||||
|
'profile.section.social': 'شبکههای اجتماعی',
|
||||||
|
'profile.firstName': 'نام (فارسی)',
|
||||||
|
'profile.lastName': 'نام خانوادگی (فارسی)',
|
||||||
|
'profile.firstNameEn': 'نام (انگلیسی)',
|
||||||
|
'profile.lastNameEn': 'نام خانوادگی (انگلیسی)',
|
||||||
|
'profile.mobile': 'شماره موبایل',
|
||||||
|
'profile.email': 'ایمیل',
|
||||||
|
'profile.emailPlaceholder': 'you@example.com',
|
||||||
|
'profile.about': 'درباره من',
|
||||||
|
'profile.addresses': 'آدرسها',
|
||||||
|
'profile.addresses.add': 'افزودن آدرس',
|
||||||
|
'profile.addresses.loading': 'در حال بارگذاری آدرسها...',
|
||||||
|
'profile.addresses.remove': 'حذف آدرس',
|
||||||
|
'profile.addresses.error.load': 'بارگذاری آدرسها ممکن نشد.',
|
||||||
|
'profile.addresses.error.incomplete': 'برای هر ردیفی که شروع کردهاید، استان، شهر و آدرس را کامل کنید.',
|
||||||
|
'profile.province': 'استان',
|
||||||
|
'profile.city': 'شهر',
|
||||||
|
'profile.postalCode': 'کد پستی',
|
||||||
|
'profile.address': 'آدرس',
|
||||||
|
'profile.landline': 'تلفن ثابت',
|
||||||
|
'profile.selectProvince': 'انتخاب استان',
|
||||||
|
'profile.selectCity': 'انتخاب شهر',
|
||||||
|
'profile.streetPlaceholder': 'آدرس خیابان',
|
||||||
|
'profile.instagram': 'اینستاگرام',
|
||||||
|
'profile.telegram': 'آیدی تلگرام',
|
||||||
|
'profile.linkedin': 'لینکدین',
|
||||||
|
'profile.save': 'ذخیره تغییرات',
|
||||||
|
'profile.saving': 'در حال ذخیره...',
|
||||||
|
'profile.toast.success': 'پروفایل با موفقیت ذخیره شد.',
|
||||||
|
'profile.error.update': 'ذخیره پروفایل ممکن نشد.',
|
||||||
|
|
||||||
|
'bizProfile.subtitle': 'هویت فروشگاه، اطلاعات تماس و لینکهای شبکههای اجتماعی را مدیریت کنید.',
|
||||||
|
'bizProfile.loading': 'در حال بارگذاری پروفایل کسبوکار...',
|
||||||
|
'bizProfile.loadError': 'بارگذاری پروفایل کسبوکار ممکن نشد.',
|
||||||
|
'bizProfile.saveError': 'ذخیره پروفایل کسبوکار ممکن نشد.',
|
||||||
|
'bizProfile.toast.saved': 'پروفایل کسبوکار ذخیره شد.',
|
||||||
|
'bizProfile.section.basic': 'اطلاعات پایه',
|
||||||
|
'bizProfile.section.contact': 'تماس',
|
||||||
|
'bizProfile.section.social': 'شبکههای اجتماعی',
|
||||||
|
'bizProfile.logo': 'لوگو',
|
||||||
|
'bizProfile.logo.upload': 'آپلود لوگو',
|
||||||
|
'bizProfile.logo.hint': 'PNG شفاف توصیه میشود',
|
||||||
|
'bizProfile.logo.change': 'تغییر لوگو',
|
||||||
|
'bizProfile.categories': 'دستهبندیهای فعالیت',
|
||||||
|
'bizProfile.categories.placeholder': 'انتخاب دستهبندیهای فعالیت',
|
||||||
|
'bizProfile.nameFa': 'نام (فارسی)',
|
||||||
|
'bizProfile.nameEn': 'نام (انگلیسی)',
|
||||||
|
'bizProfile.email': 'آدرس ایمیل',
|
||||||
|
'bizProfile.about': 'درباره ما',
|
||||||
|
'bizProfile.vision': 'چشمانداز ما',
|
||||||
|
'bizProfile.addresses': 'آدرسها',
|
||||||
|
'bizProfile.addresses.add': 'افزودن آدرس',
|
||||||
|
'bizProfile.province': 'استان',
|
||||||
|
'bizProfile.city': 'شهر',
|
||||||
|
'bizProfile.address': 'آدرس',
|
||||||
|
'bizProfile.postalCode': 'کد پستی',
|
||||||
|
'bizProfile.landline': 'تلفن ثابت',
|
||||||
|
'bizProfile.selectProvince': 'انتخاب استان',
|
||||||
|
'bizProfile.selectCity': 'انتخاب شهر',
|
||||||
|
'bizProfile.streetPlaceholder': 'آدرس خیابان',
|
||||||
|
'bizProfile.removeAddress': 'حذف آدرس',
|
||||||
|
'bizProfile.phones': 'شمارههای تماس',
|
||||||
|
'bizProfile.phones.add': 'افزودن شماره',
|
||||||
|
'bizProfile.phones.type': 'نوع',
|
||||||
|
'bizProfile.phones.number': 'شماره تلفن',
|
||||||
|
'bizProfile.phones.cell': 'شماره موبایل',
|
||||||
|
'bizProfile.phones.landline': 'تلفن ثابت',
|
||||||
|
'bizProfile.phones.remove': 'حذف شماره تماس',
|
||||||
|
'bizProfile.social.placeholder': 'لینک یا آیدی {name}',
|
||||||
|
'bizProfile.save': 'ذخیره پروفایل',
|
||||||
|
'bizProfile.saving': 'در حال ذخیره...',
|
||||||
|
|
||||||
'bc.dashboard': 'داشبورد',
|
'bc.dashboard': 'داشبورد',
|
||||||
'bc.editProduct': 'ویرایش محصول',
|
'bc.editProduct': 'ویرایش محصول',
|
||||||
'bc.productDetails': 'جزئیات محصول',
|
'bc.productDetails': 'جزئیات محصول',
|
||||||
@@ -1174,7 +1327,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'products.form.save': 'ذخیره محصول',
|
'products.form.save': 'ذخیره محصول',
|
||||||
'products.form.update': 'بهروزرسانی محصول',
|
'products.form.update': 'بهروزرسانی محصول',
|
||||||
|
|
||||||
'categories.page.subtitle': 'دستهبندیها و زیردستههای محصولات دیجیتال خود را مدیریت کنید.',
|
'categories.page.subtitle': 'دستهبندیها و زیردستههای محصولات خود را مدیریت کنید.',
|
||||||
'categories.loading': 'در حال بارگذاری دستهبندیها...',
|
'categories.loading': 'در حال بارگذاری دستهبندیها...',
|
||||||
'categories.empty': 'هنوز دستهبندیای نیست. با + یا هوش مصنوعی اضافه کنید.',
|
'categories.empty': 'هنوز دستهبندیای نیست. با + یا هوش مصنوعی اضافه کنید.',
|
||||||
'categories.add': 'افزودن دستهبندی',
|
'categories.add': 'افزودن دستهبندی',
|
||||||
@@ -1853,6 +2006,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
|
|
||||||
'title.signIn': 'ورود',
|
'title.signIn': 'ورود',
|
||||||
'title.home': 'خانه',
|
'title.home': 'خانه',
|
||||||
|
'title.profile': 'پروفایل',
|
||||||
'title.businessProfile': 'پروفایل کسبوکار',
|
'title.businessProfile': 'پروفایل کسبوکار',
|
||||||
'title.products': 'محصولات',
|
'title.products': 'محصولات',
|
||||||
'title.myProducts': 'محصولات من',
|
'title.myProducts': 'محصولات من',
|
||||||
@@ -2036,6 +2190,7 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
|
|||||||
const t = (key: MessageKey) => translate(locale, key)
|
const t = (key: MessageKey) => translate(locale, key)
|
||||||
return [
|
return [
|
||||||
{ match: '/login', labels: [t('title.signIn')] },
|
{ match: '/login', labels: [t('title.signIn')] },
|
||||||
|
{ match: '/profile', labels: [t('title.profile')] },
|
||||||
{ match: '/business-profile', labels: [t('title.businessProfile')] },
|
{ match: '/business-profile', labels: [t('title.businessProfile')] },
|
||||||
{ match: '/products/categories', labels: [t('title.products'), t('title.categories')] },
|
{ match: '/products/categories', labels: [t('title.products'), t('title.categories')] },
|
||||||
{ match: '/products/brands', labels: [t('title.products'), t('title.brands')] },
|
{ match: '/products/brands', labels: [t('title.products'), t('title.brands')] },
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { Plus, Trash2 } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
AddressListEditor,
|
AddressListEditor,
|
||||||
createEmptyAddressItem,
|
createEmptyAddressItem,
|
||||||
|
getLocationOptionLabel,
|
||||||
matchCityByName,
|
matchCityByName,
|
||||||
matchProvinceByName,
|
matchProvinceByName,
|
||||||
|
useLocale,
|
||||||
type AddressListItem,
|
type AddressListItem,
|
||||||
} from '@meshkee/dashboard-ui'
|
} from '@meshkee/dashboard-ui'
|
||||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||||
@@ -12,6 +14,7 @@ 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 { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { dispatchBusinessProfileUpdated } from '../lib/businessContext'
|
import { dispatchBusinessProfileUpdated } from '../lib/businessContext'
|
||||||
import { listBusinessActivityCategories } from '../services/businessActivityCategoryService'
|
import { listBusinessActivityCategories } from '../services/businessActivityCategoryService'
|
||||||
@@ -44,12 +47,23 @@ const EMPTY_SOCIAL: BusinessSocialMedia = {
|
|||||||
aparat: '',
|
aparat: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SOCIAL_FIELDS = [
|
||||||
|
['whatsapp', 'WhatsApp'],
|
||||||
|
['telegram', 'Telegram'],
|
||||||
|
['instagram', 'Instagram'],
|
||||||
|
['linkedin', 'LinkedIn'],
|
||||||
|
['youtube', 'YouTube'],
|
||||||
|
['aparat', 'Aparat'],
|
||||||
|
] as const
|
||||||
|
|
||||||
function createEmptyPhone(): BusinessPhoneNumber {
|
function createEmptyPhone(): BusinessPhoneNumber {
|
||||||
return { type: 'cell', number: '' }
|
return { type: 'cell', number: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BusinessProfilePage() {
|
export function BusinessProfilePage() {
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const t = useT()
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -76,12 +90,47 @@ export function BusinessProfilePage() {
|
|||||||
[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(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
void loadData(controller.signal)
|
void loadData(controller.signal)
|
||||||
return () => controller.abort()
|
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) {
|
async function loadData(signal?: AbortSignal) {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
@@ -111,7 +160,9 @@ export function BusinessProfilePage() {
|
|||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
provinceSlug: province?.slug ?? '',
|
provinceSlug: province?.slug ?? '',
|
||||||
province: province?.nameEn ?? item.province,
|
province: province
|
||||||
|
? getLocationOptionLabel(province, locale)
|
||||||
|
: item.province,
|
||||||
city: item.city,
|
city: item.city,
|
||||||
address: item.address,
|
address: item.address,
|
||||||
postalCode: item.postalCode,
|
postalCode: item.postalCode,
|
||||||
@@ -136,7 +187,7 @@ export function BusinessProfilePage() {
|
|||||||
const city = matchCityByName(item.city, cities)
|
const city = matchCityByName(item.city, cities)
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
city: city?.nameEn ?? item.city,
|
city: city ? getLocationOptionLabel(city, locale) : item.city,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -153,7 +204,7 @@ export function BusinessProfilePage() {
|
|||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError('Unable to load business profile.')
|
setError(t('bizProfile.loadError'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -170,7 +221,7 @@ export function BusinessProfilePage() {
|
|||||||
const province = provinces.find((item) => item.slug === provinceSlug)
|
const province = provinces.find((item) => item.slug === provinceSlug)
|
||||||
updateAddress(index, {
|
updateAddress(index, {
|
||||||
provinceSlug,
|
provinceSlug,
|
||||||
province: province?.nameEn ?? '',
|
province: province ? getLocationOptionLabel(province, locale) : '',
|
||||||
city: '',
|
city: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -261,14 +312,14 @@ export function BusinessProfilePage() {
|
|||||||
addresses: payloadAddresses,
|
addresses: payloadAddresses,
|
||||||
})
|
})
|
||||||
|
|
||||||
showToast('Business profile saved.', 'success')
|
showToast(t('bizProfile.toast.saved'), 'success')
|
||||||
dispatchBusinessProfileUpdated()
|
dispatchBusinessProfileUpdated()
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError('Unable to save business profile.')
|
setError(t('bizProfile.saveError'))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
@@ -278,7 +329,7 @@ export function BusinessProfilePage() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<main className={pageStyles.content}>
|
<main className={pageStyles.content}>
|
||||||
<p className={styles.status}>Loading business profile...</p>
|
<p className={styles.status}>{t('bizProfile.loading')}</p>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -289,10 +340,8 @@ export function BusinessProfilePage() {
|
|||||||
|
|
||||||
<div className={pageStyles.pageHeader}>
|
<div className={pageStyles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={pageStyles.pageTitle}>Business profile</h2>
|
<h2 className={pageStyles.pageTitle}>{t('title.businessProfile')}</h2>
|
||||||
<p className={pageStyles.pageSubtitle}>
|
<p className={pageStyles.pageSubtitle}>{t('bizProfile.subtitle')}</p>
|
||||||
Manage your storefront identity, contact details, and social links.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -304,56 +353,60 @@ export function BusinessProfilePage() {
|
|||||||
|
|
||||||
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)}>
|
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||||
<section className={styles.section}>
|
<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.formGrid}>
|
||||||
<div className={`${formStyles.field} ${styles.logoCol}`}>
|
<div className={`${formStyles.field} ${styles.logoCol}`}>
|
||||||
<label>Logo</label>
|
<label>{t('bizProfile.logo')}</label>
|
||||||
<ImageCropper
|
<ImageCropper
|
||||||
value={logo}
|
value={logo}
|
||||||
onChange={setLogo}
|
onChange={setLogo}
|
||||||
outputFormat="png"
|
outputFormat="png"
|
||||||
accept="image/png"
|
accept="image/png"
|
||||||
uploadLabel="Upload logo"
|
uploadLabel={t('bizProfile.logo.upload')}
|
||||||
hint="Transparent PNG recommended"
|
hint={t('bizProfile.logo.hint')}
|
||||||
changeLabel="Change logo"
|
changeLabel={t('bizProfile.logo.change')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">Activity categories</label>
|
<label htmlFor="activity-categories">{t('bizProfile.categories')}</label>
|
||||||
<MultiSelectDropdown
|
<MultiSelectDropdown
|
||||||
id="activity-categories"
|
id="activity-categories"
|
||||||
options={categoryOptions}
|
options={categoryOptions}
|
||||||
value={categoryIds}
|
value={categoryIds}
|
||||||
onChange={setCategoryIds}
|
onChange={setCategoryIds}
|
||||||
placeholder="Select activity categories"
|
placeholder={t('bizProfile.categories.placeholder')}
|
||||||
searchable
|
searchable
|
||||||
disabled={categoryOptions.length === 0}
|
disabled={categoryOptions.length === 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${formStyles.field} ${styles.halfRow}`}>
|
<div className={`${formStyles.field} ${styles.halfRow}`}>
|
||||||
<label htmlFor="name-fa">Name (FA)</label>
|
<label htmlFor="name-fa">{t('bizProfile.nameFa')}</label>
|
||||||
<input
|
<input
|
||||||
id="name-fa"
|
id="name-fa"
|
||||||
value={nameFa}
|
value={nameFa}
|
||||||
onChange={(e) => setNameFa(e.target.value)}
|
onChange={(e) => setNameFa(e.target.value)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
|
lang="fa"
|
||||||
|
className="faText"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${formStyles.field} ${styles.halfRow}`}>
|
<div className={`${formStyles.field} ${styles.halfRow}`}>
|
||||||
<label htmlFor="name-en">Name (EN)</label>
|
<label htmlFor="name-en">{t('bizProfile.nameEn')}</label>
|
||||||
<input
|
<input
|
||||||
id="name-en"
|
id="name-en"
|
||||||
value={nameEn}
|
value={nameEn}
|
||||||
onChange={(e) => setNameEn(e.target.value)}
|
onChange={(e) => setNameEn(e.target.value)}
|
||||||
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${formStyles.field} ${styles.halfRow}`}>
|
<div className={`${formStyles.field} ${styles.halfRow}`}>
|
||||||
<label htmlFor="email">Email address</label>
|
<label htmlFor="email">{t('bizProfile.email')}</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -365,19 +418,19 @@ export function BusinessProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${formStyles.field} ${formStyles.col12}`}>
|
<div className={`${formStyles.field} ${formStyles.col12}`}>
|
||||||
<label>About us</label>
|
<label>{t('bizProfile.about')}</label>
|
||||||
<RichTextEditor value={about} onChange={setAbout} />
|
<RichTextEditor value={about} onChange={setAbout} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${formStyles.field} ${formStyles.col12}`}>
|
<div className={`${formStyles.field} ${formStyles.col12}`}>
|
||||||
<label>Our vision</label>
|
<label>{t('bizProfile.vision')}</label>
|
||||||
<RichTextEditor value={vision} onChange={setVision} />
|
<RichTextEditor value={vision} onChange={setVision} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>Contact</h3>
|
<h3 className={styles.sectionTitle}>{t('bizProfile.section.contact')}</h3>
|
||||||
|
|
||||||
<div className={styles.duplicatorBlock}>
|
<div className={styles.duplicatorBlock}>
|
||||||
<AddressListEditor
|
<AddressListEditor
|
||||||
@@ -388,23 +441,24 @@ export function BusinessProfilePage() {
|
|||||||
onProvinceChange={handleProvinceChange}
|
onProvinceChange={handleProvinceChange}
|
||||||
onAdd={addAddress}
|
onAdd={addAddress}
|
||||||
onRemove={removeAddress}
|
onRemove={removeAddress}
|
||||||
title="Addresses"
|
locale={locale}
|
||||||
|
labels={addressLabels}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.duplicatorBlock}>
|
<div className={styles.duplicatorBlock}>
|
||||||
<div className={styles.sectionHeader}>
|
<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}>
|
<button type="button" className={styles.addBtn} onClick={addPhone}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Add number
|
{t('bizProfile.phones.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.phoneGrid}>
|
<div className={styles.phoneGrid}>
|
||||||
<div className={styles.gridHeader}>
|
<div className={styles.gridHeader}>
|
||||||
<span>Type</span>
|
<span>{t('bizProfile.phones.type')}</span>
|
||||||
<span>Phone number</span>
|
<span>{t('bizProfile.phones.number')}</span>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -419,22 +473,22 @@ export function BusinessProfilePage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="cell">Cell number</option>
|
<option value="cell">{t('bizProfile.phones.cell')}</option>
|
||||||
<option value="landline">Landline</option>
|
<option value="landline">{t('bizProfile.phones.landline')}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className={styles.textField}
|
className={styles.textField}
|
||||||
value={item.number}
|
value={item.number}
|
||||||
onChange={(e) => updatePhone(index, { number: e.target.value })}
|
onChange={(e) => updatePhone(index, { number: e.target.value })}
|
||||||
placeholder="Phone number"
|
placeholder={t('bizProfile.phones.number')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.removeBtn}
|
className={styles.removeBtn}
|
||||||
onClick={() => removePhone(index)}
|
onClick={() => removePhone(index)}
|
||||||
aria-label="Remove phone number"
|
aria-label={t('bizProfile.phones.remove')}
|
||||||
>
|
>
|
||||||
<Trash2 size={15} />
|
<Trash2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -445,25 +499,16 @@ export function BusinessProfilePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.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}>
|
<div className={formStyles.formGrid}>
|
||||||
{(
|
{SOCIAL_FIELDS.map(([key, label]) => (
|
||||||
[
|
|
||||||
['whatsapp', 'WhatsApp'],
|
|
||||||
['telegram', 'Telegram'],
|
|
||||||
['instagram', 'Instagram'],
|
|
||||||
['linkedin', 'LinkedIn'],
|
|
||||||
['youtube', 'YouTube'],
|
|
||||||
['aparat', 'Aparat'],
|
|
||||||
] as const
|
|
||||||
).map(([key, label]) => (
|
|
||||||
<div key={key} className={`${formStyles.field} ${formStyles.col4}`}>
|
<div key={key} className={`${formStyles.field} ${formStyles.col4}`}>
|
||||||
<label htmlFor={`social-${key}`}>{label}</label>
|
<label htmlFor={`social-${key}`}>{label}</label>
|
||||||
<input
|
<input
|
||||||
id={`social-${key}`}
|
id={`social-${key}`}
|
||||||
value={socialMedia[key]}
|
value={socialMedia[key]}
|
||||||
onChange={(e) => updateSocial(key, e.target.value)}
|
onChange={(e) => updateSocial(key, e.target.value)}
|
||||||
placeholder={`${label} link or ID`}
|
placeholder={t('bizProfile.social.placeholder', { name: label })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -472,7 +517,7 @@ export function BusinessProfilePage() {
|
|||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button type="submit" className={styles.saveBtn} disabled={isSaving}>
|
<button type="submit" className={styles.saveBtn} disabled={isSaving}>
|
||||||
{isSaving ? 'Saving...' : 'Save profile'}
|
{isSaving ? t('bizProfile.saving') : t('bizProfile.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -197,17 +197,32 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination > div:first-child {
|
.pagination > div:first-child {
|
||||||
|
grid-column: 1;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 38px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
transform: translateY(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination > nav {
|
.pagination > nav {
|
||||||
|
grid-column: 2;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagerBtns {
|
.pagerBtns {
|
||||||
|
|||||||
@@ -181,6 +181,7 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: white;
|
color: white;
|
||||||
|
text-align: center;
|
||||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
|
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
verifyOtp,
|
verifyOtp,
|
||||||
} from '../services/authService'
|
} from '../services/authService'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
|
||||||
import styles from './LoginPage.module.css'
|
import styles from './LoginPage.module.css'
|
||||||
|
|
||||||
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
||||||
@@ -222,7 +221,7 @@ export function LoginPage() {
|
|||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.brand}>
|
<div className={styles.brand}>
|
||||||
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
|
{logoUrl ? <img src={logoUrl} alt="" className={styles.logo} /> : null}
|
||||||
<div className={styles.brandText}>
|
<div className={styles.brandText}>
|
||||||
<span className={styles.businessName}>{businessName || businessDomain}</span>
|
<span className={styles.businessName}>{businessName || businessDomain}</span>
|
||||||
<span className={styles.appName}>{t('app.poweredBy')}</span>
|
<span className={styles.appName}>{t('app.poweredBy')}</span>
|
||||||
|
|||||||
@@ -26,30 +26,35 @@
|
|||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding: 12px 4px;
|
padding: 0 4px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
direction: ltr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.paginationMeta {
|
.paginationMeta {
|
||||||
grid-column: 3;
|
grid-column: 1;
|
||||||
justify-self: end;
|
justify-self: start;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
line-height: 1;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 38px;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
|
transform: translateY(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination > nav {
|
.pagination > nav {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
align-self: center;
|
height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
|
|||||||
@@ -270,17 +270,32 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination > nav {
|
.pagination > nav {
|
||||||
|
grid-column: 2;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination > div:first-child {
|
.pagination > div:first-child {
|
||||||
|
grid-column: 1;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 38px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
transform: translateY(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagerBtns {
|
.pagerBtns {
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alertError {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 28px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--blur-glass));
|
||||||
|
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col3 {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col4 {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullWidth {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field textarea {
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:disabled,
|
||||||
|
.field input.readOnly {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: var(--field-padding-y) 18px;
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.col3 {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col4 {
|
||||||
|
grid-column: span 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.section {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col3,
|
||||||
|
.col4,
|
||||||
|
.fullWidth {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AddressListEditor,
|
||||||
|
createEmptyAddressItem,
|
||||||
|
getLocationOptionLabel,
|
||||||
|
matchCityByName,
|
||||||
|
matchProvinceByName,
|
||||||
|
useLocale,
|
||||||
|
type AddressListItem,
|
||||||
|
} from '@meshkee/dashboard-ui'
|
||||||
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||||
|
import pageStyles from '../components/PageContent.module.css'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||||
|
import {
|
||||||
|
createAddress,
|
||||||
|
listAddresses,
|
||||||
|
removeAddress,
|
||||||
|
updateAddress,
|
||||||
|
} from '../services/addressService'
|
||||||
|
import { updateProfile } from '../services/authService'
|
||||||
|
import {
|
||||||
|
listCitiesByProvinceSlug,
|
||||||
|
listIranProvinces,
|
||||||
|
type CityOption,
|
||||||
|
} from '../services/citiesService'
|
||||||
|
import styles from './ProfilePage.module.css'
|
||||||
|
|
||||||
|
type AddressDraft = AddressListItem
|
||||||
|
|
||||||
|
function isAddressFilled(item: AddressDraft) {
|
||||||
|
return Boolean(
|
||||||
|
item.province.trim() && item.city.trim() && item.address.trim(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const { user, setUser } = useAuth()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [lastName, setLastName] = useState('')
|
||||||
|
const [firstNameEn, setFirstNameEn] = useState('')
|
||||||
|
const [lastNameEn, setLastNameEn] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [about, setAbout] = useState('')
|
||||||
|
const [instagram, setInstagram] = useState('')
|
||||||
|
const [telegramId, setTelegramId] = useState('')
|
||||||
|
const [linkedin, setLinkedin] = useState('')
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [provinces, setProvinces] = useState<CityOption[]>([])
|
||||||
|
const [citiesByProvince, setCitiesByProvince] = useState<Record<string, CityOption[]>>({})
|
||||||
|
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
|
||||||
|
const [initialAddressIds, setInitialAddressIds] = useState<string[]>([])
|
||||||
|
const [addressesLoading, setAddressesLoading] = useState(true)
|
||||||
|
|
||||||
|
const addressLabels = useMemo(
|
||||||
|
() => ({
|
||||||
|
title: t('profile.addresses'),
|
||||||
|
addLabel: t('profile.addresses.add'),
|
||||||
|
loading: t('profile.addresses.loading'),
|
||||||
|
province: t('profile.province'),
|
||||||
|
city: t('profile.city'),
|
||||||
|
address: t('profile.address'),
|
||||||
|
postalCode: t('profile.postalCode'),
|
||||||
|
landline: t('profile.landline'),
|
||||||
|
selectProvince: t('profile.selectProvince'),
|
||||||
|
selectCity: t('profile.selectCity'),
|
||||||
|
streetPlaceholder: t('profile.streetPlaceholder'),
|
||||||
|
postalPlaceholder: t('profile.postalCode'),
|
||||||
|
landlinePlaceholder: t('profile.landline'),
|
||||||
|
removeAriaLabel: t('profile.addresses.remove'),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
|
setFirstName(user.firstName ?? '')
|
||||||
|
setLastName(user.lastName ?? '')
|
||||||
|
setFirstNameEn(user.firstNameEn ?? '')
|
||||||
|
setLastNameEn(user.lastNameEn ?? '')
|
||||||
|
setEmail(user.email ?? '')
|
||||||
|
setAbout(user.profile.about ?? '')
|
||||||
|
setInstagram(user.profile.instagram ?? '')
|
||||||
|
setTelegramId(user.profile.telegramId ?? '')
|
||||||
|
setLinkedin(user.profile.linkedin ?? '')
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
void loadAddresses(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 loadAddresses(signal?: AbortSignal) {
|
||||||
|
setAddressesLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [provinceItems, addressData] = await Promise.all([
|
||||||
|
listIranProvinces(signal),
|
||||||
|
listAddresses(signal),
|
||||||
|
])
|
||||||
|
|
||||||
|
setProvinces(provinceItems)
|
||||||
|
|
||||||
|
const nextAddresses =
|
||||||
|
addressData.items.length > 0
|
||||||
|
? addressData.items.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]))
|
||||||
|
|
||||||
|
const localized = 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)
|
||||||
|
setAddresses(localized)
|
||||||
|
setInitialAddressIds(
|
||||||
|
addressData.items.map((item) => item.id).filter(Boolean),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
|
setError(err instanceof ApiError ? err.message : t('profile.addresses.error.load'))
|
||||||
|
} finally {
|
||||||
|
setAddressesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAddressDraft(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)
|
||||||
|
updateAddressDraft(index, {
|
||||||
|
provinceSlug,
|
||||||
|
province: province ? getLocationOptionLabel(province, locale) : '',
|
||||||
|
city: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (provinceSlug && !citiesByProvince[provinceSlug]) {
|
||||||
|
const cities = await listCitiesByProvinceSlug(provinceSlug)
|
||||||
|
setCitiesByProvince((prev) => ({ ...prev, [provinceSlug]: cities }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAddressRow() {
|
||||||
|
setAddresses((prev) => [...prev, createEmptyAddressItem()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAddressRow(index: number) {
|
||||||
|
setAddresses((prev) =>
|
||||||
|
prev.length === 1 ? [createEmptyAddressItem()] : prev.filter((_, i) => i !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAddresses() {
|
||||||
|
const filled = addresses.filter(isAddressFilled)
|
||||||
|
const keptIds = new Set(filled.map((item) => item.id).filter(Boolean) as string[])
|
||||||
|
|
||||||
|
for (const id of initialAddressIds) {
|
||||||
|
if (!keptIds.has(id)) {
|
||||||
|
await removeAddress(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of filled) {
|
||||||
|
const payload = {
|
||||||
|
province: item.province.trim(),
|
||||||
|
city: item.city.trim(),
|
||||||
|
address: item.address.trim(),
|
||||||
|
postalCode: item.postalCode.trim() || undefined,
|
||||||
|
landline: item.landline.trim() || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id) {
|
||||||
|
await updateAddress(item.id, payload)
|
||||||
|
} else {
|
||||||
|
await createAddress(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setIsSaving(true)
|
||||||
|
|
||||||
|
const incomplete = addresses.some(
|
||||||
|
(item) =>
|
||||||
|
(item.province.trim() ||
|
||||||
|
item.city.trim() ||
|
||||||
|
item.address.trim() ||
|
||||||
|
item.postalCode.trim() ||
|
||||||
|
item.landline.trim()) &&
|
||||||
|
!isAddressFilled(item),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (incomplete) {
|
||||||
|
setError(t('profile.addresses.error.incomplete'))
|
||||||
|
setIsSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateProfile({
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
firstNameEn: firstNameEn.trim(),
|
||||||
|
lastNameEn: lastNameEn.trim(),
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
about: about.trim(),
|
||||||
|
instagram: instagram.trim(),
|
||||||
|
telegramId: telegramId.trim(),
|
||||||
|
linkedin: linkedin.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await syncAddresses()
|
||||||
|
await loadAddresses()
|
||||||
|
|
||||||
|
setUser(result.user)
|
||||||
|
showToast(t('profile.toast.success'), 'success')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : t('profile.error.update'))
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={pageStyles.content}>
|
||||||
|
<Breadcrumbs items={[{ label: 'Dashboard', href: '/' }, { label: 'Profile' }]} />
|
||||||
|
|
||||||
|
<div className={pageStyles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<h2 className={pageStyles.pageTitle}>{t('profile.title')}</h2>
|
||||||
|
<p className={pageStyles.pageSubtitle}>{t('profile.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.alertError} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>{t('profile.section.general')}</h3>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<div className={`${styles.field} ${styles.col3}`}>
|
||||||
|
<label htmlFor="profile-first">{t('profile.firstName')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-first"
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
dir="rtl"
|
||||||
|
lang="fa"
|
||||||
|
className="faText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.field} ${styles.col3}`}>
|
||||||
|
<label htmlFor="profile-last">{t('profile.lastName')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-last"
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
dir="rtl"
|
||||||
|
lang="fa"
|
||||||
|
className="faText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.field} ${styles.col3}`}>
|
||||||
|
<label htmlFor="profile-first-en">{t('profile.firstNameEn')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-first-en"
|
||||||
|
type="text"
|
||||||
|
value={firstNameEn}
|
||||||
|
onChange={(e) => setFirstNameEn(e.target.value)}
|
||||||
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.field} ${styles.col3}`}>
|
||||||
|
<label htmlFor="profile-last-en">{t('profile.lastNameEn')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-last-en"
|
||||||
|
type="text"
|
||||||
|
value={lastNameEn}
|
||||||
|
onChange={(e) => setLastNameEn(e.target.value)}
|
||||||
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.field} ${styles.col3}`}>
|
||||||
|
<label htmlFor="profile-cell">{t('profile.mobile')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-cell"
|
||||||
|
type="tel"
|
||||||
|
value={user ? formatCellForDisplay(user.cellNumber) : ''}
|
||||||
|
readOnly
|
||||||
|
className={styles.readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.field} ${styles.col3}`}>
|
||||||
|
<label htmlFor="profile-email">{t('profile.email')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder={t('profile.emailPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.field} ${styles.fullWidth}`}>
|
||||||
|
<label htmlFor="profile-about">{t('profile.about')}</label>
|
||||||
|
<textarea
|
||||||
|
id="profile-about"
|
||||||
|
value={about}
|
||||||
|
onChange={(e) => setAbout(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<AddressListEditor
|
||||||
|
addresses={addresses}
|
||||||
|
provinces={provinces}
|
||||||
|
citiesByProvince={citiesByProvince}
|
||||||
|
onAddressChange={updateAddressDraft}
|
||||||
|
onProvinceChange={handleProvinceChange}
|
||||||
|
onAdd={addAddressRow}
|
||||||
|
onRemove={removeAddressRow}
|
||||||
|
loading={addressesLoading}
|
||||||
|
locale={locale}
|
||||||
|
labels={addressLabels}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>{t('profile.section.social')}</h3>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<div className={`${styles.field} ${styles.col4}`}>
|
||||||
|
<label htmlFor="profile-instagram">{t('profile.instagram')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-instagram"
|
||||||
|
type="text"
|
||||||
|
value={instagram}
|
||||||
|
onChange={(e) => setInstagram(e.target.value)}
|
||||||
|
placeholder="@username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.field} ${styles.col4}`}>
|
||||||
|
<label htmlFor="profile-telegram">{t('profile.telegram')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-telegram"
|
||||||
|
type="text"
|
||||||
|
value={telegramId}
|
||||||
|
onChange={(e) => setTelegramId(e.target.value)}
|
||||||
|
placeholder="@username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.field} ${styles.col4}`}>
|
||||||
|
<label htmlFor="profile-linkedin">{t('profile.linkedin')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-linkedin"
|
||||||
|
type="url"
|
||||||
|
value={linkedin}
|
||||||
|
onChange={(e) => setLinkedin(e.target.value)}
|
||||||
|
placeholder="https://linkedin.com/in/username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" className={styles.saveBtn} disabled={isSaving || addressesLoading}>
|
||||||
|
<Save size={18} />
|
||||||
|
{isSaving ? t('profile.saving') : t('profile.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -227,8 +227,6 @@ export function StoreSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<section className={sharedStyles.panel}>
|
<section className={sharedStyles.panel}>
|
||||||
<h3 className={sharedStyles.sectionTitle}>{t('storeSettings.sales')}</h3>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className={sharedStyles.status}>{t('productSettings.loading')}</p>
|
<p className={sharedStyles.status}>{t('productSettings.loading')}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -91,13 +91,32 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination > nav {
|
.pagination > nav {
|
||||||
|
grid-column: 2;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination > div:first-child {
|
||||||
|
grid-column: 1;
|
||||||
|
justify-self: start;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 38px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
transform: translateY(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagerBtns {
|
.pagerBtns {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { apiRequest } from '../lib/api'
|
||||||
|
|
||||||
|
export interface UserAddress {
|
||||||
|
id: string
|
||||||
|
label: string | null
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
address: string
|
||||||
|
postalCode: string | null
|
||||||
|
landline: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAddressInput {
|
||||||
|
label?: string
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
address: string
|
||||||
|
postalCode?: string
|
||||||
|
landline?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAddresses(signal?: AbortSignal) {
|
||||||
|
return apiRequest<{ items: UserAddress[] }>('/auth/addresses', { auth: true, signal })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAddress(input: UserAddressInput) {
|
||||||
|
return apiRequest<{ address: UserAddress }>('/auth/addresses', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
body: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAddress(addressId: string, input: UserAddressInput) {
|
||||||
|
return apiRequest<{ address: UserAddress }>(`/auth/addresses/${addressId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
auth: true,
|
||||||
|
body: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAddress(addressId: string) {
|
||||||
|
return apiRequest<{ message: string }>(`/auth/addresses/${addressId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiRequest, setTokens, clearTokens } from '../lib/api'
|
import { apiRequest, setTokens, clearTokens } from '../lib/api'
|
||||||
import { clearActiveBusiness } from '../lib/businessContext'
|
import { clearActiveBusiness } from '../lib/businessContext'
|
||||||
import type {
|
import type {
|
||||||
|
AuthUser,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
MeResponse,
|
MeResponse,
|
||||||
OtpSendResponse,
|
OtpSendResponse,
|
||||||
@@ -61,6 +62,24 @@ export async function changePassword(currentPassword: string, newPassword: strin
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(payload: {
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
firstNameEn?: string
|
||||||
|
lastNameEn?: string
|
||||||
|
email?: string | null
|
||||||
|
about?: string
|
||||||
|
instagram?: string
|
||||||
|
telegramId?: string
|
||||||
|
linkedin?: string
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ message: string; user: AuthUser }>('/auth/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
auth: true,
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
clearTokens()
|
clearTokens()
|
||||||
clearActiveBusiness()
|
clearActiveBusiness()
|
||||||
|
|||||||
@@ -8,9 +8,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.paginationInline {
|
.paginationInline {
|
||||||
margin-top: 0;
|
margin: 0;
|
||||||
padding-top: 0;
|
padding: 0;
|
||||||
justify-content: flex-end;
|
min-height: 38px;
|
||||||
|
height: 38px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navBtn {
|
.navBtn {
|
||||||
|
|||||||
Reference in New Issue
Block a user