diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx index 6e82b64..3dee895 100644 --- a/apps/business/src/App.tsx +++ b/apps/business/src/App.tsx @@ -18,6 +18,7 @@ import { MyProductsPage } from './pages/MyProductsPage' import { ProductDetailsPage } from './pages/ProductDetailsPage' import { ProductSettingsPage } from './pages/ProductSettingsPage' import { BusinessProfilePage } from './pages/BusinessProfilePage' +import { ProfilePage } from './pages/ProfilePage' import { StorePage } from './pages/StorePage' import { StoreItemsPage } from './pages/StoreItemsPage' import { StoreSpecialsPage } from './pages/StoreSpecialsPage' @@ -66,6 +67,7 @@ function App() { }> }> } /> + } /> } /> } /> } /> diff --git a/apps/business/src/components/BlogCategoryRow.tsx b/apps/business/src/components/BlogCategoryRow.tsx index 012a2c7..4ae0237 100644 --- a/apps/business/src/components/BlogCategoryRow.tsx +++ b/apps/business/src/components/BlogCategoryRow.tsx @@ -37,7 +37,7 @@ export function BlogCategoryRow({ : category.nameFa return ( -
+
- {brandName + {brandLogoUrl ? ( + {brandName + ) : ( + + + + + + )}
{brandName || fallbackBusinessName} {businessDomain} diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 8fd8328..18c3296 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -12,6 +12,7 @@ const en = { 'nav.home': 'Home', 'nav.businessProfile': 'Business Profile', + 'sidebar.addLogo': 'Add logo', 'nav.products': 'Products', 'nav.products.overview': 'Overview', 'nav.products.list': 'My Products', @@ -62,6 +63,81 @@ const en = { 'header.setting': 'Setting', '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.editProduct': 'Edit Product', 'bc.productDetails': 'Product Details', @@ -193,7 +269,7 @@ const en = { 'products.form.save': 'Save 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.empty': 'No categories yet. Use + or AI to add categories.', 'categories.add': 'Add Category', @@ -873,6 +949,7 @@ const en = { 'title.signIn': 'Sign in', 'title.home': 'Home', + 'title.profile': 'Profile', 'title.businessProfile': 'Business Profile', 'title.products': 'Products', 'title.myProducts': 'My Products', @@ -993,6 +1070,7 @@ const fa: Record = { 'nav.home': 'خانه', 'nav.businessProfile': 'پروفایل کسب‌وکار', + 'sidebar.addLogo': 'افزودن لوگو', 'nav.products': 'محصولات', 'nav.products.overview': 'نمای کلی', 'nav.products.list': 'محصولات من', @@ -1043,6 +1121,81 @@ const fa: Record = { 'header.setting': 'تنظیمات', '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.editProduct': 'ویرایش محصول', 'bc.productDetails': 'جزئیات محصول', @@ -1174,7 +1327,7 @@ const fa: Record = { 'products.form.save': 'ذخیره محصول', 'products.form.update': 'به‌روزرسانی محصول', - 'categories.page.subtitle': 'دسته‌بندی‌ها و زیردسته‌های محصولات دیجیتال خود را مدیریت کنید.', + 'categories.page.subtitle': 'دسته‌بندی‌ها و زیردسته‌های محصولات خود را مدیریت کنید.', 'categories.loading': 'در حال بارگذاری دسته‌بندی‌ها...', 'categories.empty': 'هنوز دسته‌بندی‌ای نیست. با + یا هوش مصنوعی اضافه کنید.', 'categories.add': 'افزودن دسته‌بندی', @@ -1853,6 +2006,7 @@ const fa: Record = { 'title.signIn': 'ورود', 'title.home': 'خانه', + 'title.profile': 'پروفایل', 'title.businessProfile': 'پروفایل کسب‌وکار', 'title.products': 'محصولات', 'title.myProducts': 'محصولات من', @@ -2036,6 +2190,7 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR const t = (key: MessageKey) => translate(locale, key) return [ { match: '/login', labels: [t('title.signIn')] }, + { match: '/profile', labels: [t('title.profile')] }, { match: '/business-profile', labels: [t('title.businessProfile')] }, { match: '/products/categories', labels: [t('title.products'), t('title.categories')] }, { match: '/products/brands', labels: [t('title.products'), t('title.brands')] }, diff --git a/apps/business/src/pages/BusinessProfilePage.tsx b/apps/business/src/pages/BusinessProfilePage.tsx index 5cf3a35..16650d8 100644 --- a/apps/business/src/pages/BusinessProfilePage.tsx +++ b/apps/business/src/pages/BusinessProfilePage.tsx @@ -3,8 +3,10 @@ import { Plus, Trash2 } from 'lucide-react' import { AddressListEditor, createEmptyAddressItem, + getLocationOptionLabel, matchCityByName, matchProvinceByName, + useLocale, type AddressListItem, } from '@meshkee/dashboard-ui' import { Breadcrumbs } from '../components/Breadcrumbs' @@ -12,6 +14,7 @@ import { ImageCropper } from '../components/ImageCropper' import { MultiSelectDropdown } from '../components/MultiSelectDropdown' import { RichTextEditor } from '../components/RichTextEditor' import { useToast } from '../context/ToastContext' +import { useT } from '../i18n/useT' import { ApiError } from '../lib/api' import { dispatchBusinessProfileUpdated } from '../lib/businessContext' import { listBusinessActivityCategories } from '../services/businessActivityCategoryService' @@ -44,12 +47,23 @@ const EMPTY_SOCIAL: BusinessSocialMedia = { aparat: '', } +const SOCIAL_FIELDS = [ + ['whatsapp', 'WhatsApp'], + ['telegram', 'Telegram'], + ['instagram', 'Instagram'], + ['linkedin', 'LinkedIn'], + ['youtube', 'YouTube'], + ['aparat', 'Aparat'], +] as const + function createEmptyPhone(): BusinessPhoneNumber { return { type: 'cell', number: '' } } export function BusinessProfilePage() { const { showToast } = useToast() + const { locale } = useLocale() + const t = useT() const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState('') @@ -76,12 +90,47 @@ export function BusinessProfilePage() { [activityCategories], ) + const addressLabels = useMemo( + () => ({ + title: t('bizProfile.addresses'), + addLabel: t('bizProfile.addresses.add'), + province: t('bizProfile.province'), + city: t('bizProfile.city'), + address: t('bizProfile.address'), + postalCode: t('bizProfile.postalCode'), + landline: t('bizProfile.landline'), + selectProvince: t('bizProfile.selectProvince'), + selectCity: t('bizProfile.selectCity'), + streetPlaceholder: t('bizProfile.streetPlaceholder'), + postalPlaceholder: t('bizProfile.postalCode'), + landlinePlaceholder: t('bizProfile.landline'), + removeAriaLabel: t('bizProfile.removeAddress'), + }), + [t], + ) + useEffect(() => { const controller = new AbortController() void loadData(controller.signal) return () => controller.abort() }, []) + useEffect(() => { + setAddresses((prev) => + prev.map((item) => { + if (!item.provinceSlug) return item + const province = provinces.find((p) => p.slug === item.provinceSlug) + const cities = citiesByProvince[item.provinceSlug] ?? [] + const city = matchCityByName(item.city, cities) + return { + ...item, + province: province ? getLocationOptionLabel(province, locale) : item.province, + city: city ? getLocationOptionLabel(city, locale) : item.city, + } + }), + ) + }, [locale, provinces, citiesByProvince]) + async function loadData(signal?: AbortSignal) { setIsLoading(true) setError('') @@ -111,7 +160,9 @@ export function BusinessProfilePage() { return { id: item.id, provinceSlug: province?.slug ?? '', - province: province?.nameEn ?? item.province, + province: province + ? getLocationOptionLabel(province, locale) + : item.province, city: item.city, address: item.address, postalCode: item.postalCode, @@ -136,7 +187,7 @@ export function BusinessProfilePage() { const city = matchCityByName(item.city, cities) return { ...item, - city: city?.nameEn ?? item.city, + city: city ? getLocationOptionLabel(city, locale) : item.city, } }), ) @@ -153,7 +204,7 @@ export function BusinessProfilePage() { if (err instanceof ApiError) { setError(err.message) } else { - setError('Unable to load business profile.') + setError(t('bizProfile.loadError')) } } finally { setIsLoading(false) @@ -170,7 +221,7 @@ export function BusinessProfilePage() { const province = provinces.find((item) => item.slug === provinceSlug) updateAddress(index, { provinceSlug, - province: province?.nameEn ?? '', + province: province ? getLocationOptionLabel(province, locale) : '', city: '', }) @@ -261,14 +312,14 @@ export function BusinessProfilePage() { addresses: payloadAddresses, }) - showToast('Business profile saved.', 'success') + showToast(t('bizProfile.toast.saved'), 'success') dispatchBusinessProfileUpdated() await loadData() } catch (err) { if (err instanceof ApiError) { setError(err.message) } else { - setError('Unable to save business profile.') + setError(t('bizProfile.saveError')) } } finally { setIsSaving(false) @@ -278,7 +329,7 @@ export function BusinessProfilePage() { if (isLoading) { return (
-

Loading business profile...

+

{t('bizProfile.loading')}

) } @@ -289,10 +340,8 @@ export function BusinessProfilePage() {
-

Business profile

-

- Manage your storefront identity, contact details, and social links. -

+

{t('title.businessProfile')}

+

{t('bizProfile.subtitle')}

@@ -304,56 +353,60 @@ export function BusinessProfilePage() {
void handleSubmit(e)}>
-

Basic info

+

{t('bizProfile.section.basic')}

- +
- +
- + setNameFa(e.target.value)} dir="rtl" + lang="fa" + className="faText" />
- + setNameEn(e.target.value)} + dir="ltr" + lang="en" />
- +
- +
- +
-

Contact

+

{t('bizProfile.section.contact')}

-

Phone numbers

+

{t('bizProfile.phones')}

- Type - Phone number + {t('bizProfile.phones.type')} + {t('bizProfile.phones.number')}
@@ -419,22 +473,22 @@ export function BusinessProfilePage() { }) } > - - + + updatePhone(index, { number: e.target.value })} - placeholder="Phone number" + placeholder={t('bizProfile.phones.number')} /> @@ -445,25 +499,16 @@ export function BusinessProfilePage() {
-

Social media

+

{t('bizProfile.section.social')}

- {( - [ - ['whatsapp', 'WhatsApp'], - ['telegram', 'Telegram'], - ['instagram', 'Instagram'], - ['linkedin', 'LinkedIn'], - ['youtube', 'YouTube'], - ['aparat', 'Aparat'], - ] as const - ).map(([key, label]) => ( + {SOCIAL_FIELDS.map(([key, label]) => (
updateSocial(key, e.target.value)} - placeholder={`${label} link or ID`} + placeholder={t('bizProfile.social.placeholder', { name: label })} />
))} @@ -472,7 +517,7 @@ export function BusinessProfilePage() {
diff --git a/apps/business/src/pages/CustomersPage.module.css b/apps/business/src/pages/CustomersPage.module.css index 903b5ce..0b0d55d 100644 --- a/apps/business/src/pages/CustomersPage.module.css +++ b/apps/business/src/pages/CustomersPage.module.css @@ -197,17 +197,32 @@ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; + min-height: 38px; color: var(--text-secondary); - font-weight: 600; + font-weight: 500; font-size: 12px; + box-sizing: border-box; } .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); } .pagination > nav { + grid-column: 2; justify-self: center; + height: 38px; + margin: 0; + padding: 0; } .pagerBtns { diff --git a/apps/business/src/pages/LoginPage.module.css b/apps/business/src/pages/LoginPage.module.css index 7bfab2a..b1e0c98 100644 --- a/apps/business/src/pages/LoginPage.module.css +++ b/apps/business/src/pages/LoginPage.module.css @@ -181,6 +181,7 @@ font-size: 15px; font-weight: 600; color: white; + text-align: center; 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); diff --git a/apps/business/src/pages/LoginPage.tsx b/apps/business/src/pages/LoginPage.tsx index 158a1cb..a57aea0 100644 --- a/apps/business/src/pages/LoginPage.tsx +++ b/apps/business/src/pages/LoginPage.tsx @@ -15,7 +15,6 @@ import { verifyOtp, } from '../services/authService' import { useT } from '../i18n/useT' -import meshkeeLogo from '../assets/meshkee-logo.png' import styles from './LoginPage.module.css' type AuthView = 'login' | 'signup' | 'forgot' | 'otp' @@ -222,7 +221,7 @@ export function LoginPage() {
- + {logoUrl ? : null}
{businessName || businessDomain} {t('app.poweredBy')} diff --git a/apps/business/src/pages/MyProductsPage.module.css b/apps/business/src/pages/MyProductsPage.module.css index ee8ab61..a7d04a4 100644 --- a/apps/business/src/pages/MyProductsPage.module.css +++ b/apps/business/src/pages/MyProductsPage.module.css @@ -26,30 +26,35 @@ .pagination { margin-top: 20px; - padding: 12px 4px; + padding: 0 4px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; + min-height: 38px; color: var(--text-secondary); - font-weight: 600; + font-weight: 500; font-size: 12px; - direction: ltr; } .paginationMeta { - grid-column: 3; - justify-self: end; - display: flex; + grid-column: 1; + justify-self: start; + display: inline-flex; align-items: center; height: 38px; - line-height: 1; + margin: 0; + padding: 0; + line-height: 38px; font-family: var(--font-ui); + transform: translateY(2px); } .pagination > nav { grid-column: 2; justify-self: center; - align-self: center; + height: 38px; + margin: 0; + padding: 0; } @media (min-width: 640px) { diff --git a/apps/business/src/pages/OrdersPage.module.css b/apps/business/src/pages/OrdersPage.module.css index 170bc0a..6bb3760 100644 --- a/apps/business/src/pages/OrdersPage.module.css +++ b/apps/business/src/pages/OrdersPage.module.css @@ -270,17 +270,32 @@ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; + min-height: 38px; color: var(--text-secondary); - font-weight: 600; + font-weight: 500; font-size: 12px; + box-sizing: border-box; } .pagination > nav { + grid-column: 2; 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 { diff --git a/apps/business/src/pages/ProfilePage.module.css b/apps/business/src/pages/ProfilePage.module.css new file mode 100644 index 0000000..ce18e45 --- /dev/null +++ b/apps/business/src/pages/ProfilePage.module.css @@ -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; + } +} diff --git a/apps/business/src/pages/ProfilePage.tsx b/apps/business/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..6a77d06 --- /dev/null +++ b/apps/business/src/pages/ProfilePage.tsx @@ -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([]) + const [citiesByProvince, setCitiesByProvince] = useState>({}) + const [addresses, setAddresses] = useState([createEmptyAddressItem()]) + const [initialAddressIds, setInitialAddressIds] = useState([]) + 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) { + 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 ( +
+ + +
+
+

{t('profile.title')}

+

{t('profile.subtitle')}

+
+
+ +
void handleSubmit(e)}> + {error && ( +
+ {error} +
+ )} + +
+

{t('profile.section.general')}

+
+
+ + setFirstName(e.target.value)} + dir="rtl" + lang="fa" + className="faText" + /> +
+
+ + setLastName(e.target.value)} + dir="rtl" + lang="fa" + className="faText" + /> +
+
+ + setFirstNameEn(e.target.value)} + dir="ltr" + lang="en" + /> +
+
+ + setLastNameEn(e.target.value)} + dir="ltr" + lang="en" + /> +
+
+ + +
+
+ + setEmail(e.target.value)} + placeholder={t('profile.emailPlaceholder')} + /> +
+
+ +