From f8a9a28346da44bcf996adcd496f92eab61cd6de Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 19 Aug 2026 19:46:57 +0330 Subject: [PATCH] Add shared empty states, customer product filters, and SSO handoff across dashboards. Standardize list empty UI, searchable country fields, customer-products filter row, password/login polish, and one-time business dashboard handoff from the customer app. Co-authored-by: Cursor --- .../components/CitySearchSelect.module.css | 10 +- .../src/components/CitySearchSelect.tsx | 10 +- .../src/components/EmptyState.module.css | 53 +++ apps/business/src/components/EmptyState.tsx | 52 +++ .../src/components/WebsiteSliderGallery.tsx | 14 +- apps/business/src/context/AuthContext.tsx | 20 +- apps/business/src/i18n/messages.ts | 122 ++++--- apps/business/src/lib/handoff.ts | 32 ++ .../src/pages/AddCustomerProductPage.tsx | 33 +- .../business/src/pages/BlogCategoriesPage.tsx | 24 +- apps/business/src/pages/BlogListPage.tsx | 22 +- apps/business/src/pages/BlogPage.module.css | 79 ----- apps/business/src/pages/BrandsPage.tsx | 28 +- apps/business/src/pages/CategoriesPage.tsx | 24 +- .../src/pages/CustomerProductsPage.module.css | 25 -- .../src/pages/CustomerProductsPage.tsx | 326 +++++++++++++++--- apps/business/src/pages/CustomersPage.tsx | 15 +- .../src/pages/InstructionCategoriesPage.tsx | 24 +- .../src/pages/InstructionListPage.tsx | 22 +- .../src/pages/InvoiceTemplatesPage.module.css | 9 - .../src/pages/InvoiceTemplatesPage.tsx | 33 +- apps/business/src/pages/InvoicesPage.tsx | 15 +- apps/business/src/pages/LoginPage.module.css | 58 +++- apps/business/src/pages/LoginPage.tsx | 119 ++++--- apps/business/src/pages/MyProductsPage.tsx | 12 +- apps/business/src/pages/OrdersPage.tsx | 13 +- .../src/pages/PortfolioCategoriesPage.tsx | 24 +- apps/business/src/pages/PortfolioListPage.tsx | 10 +- apps/business/src/pages/ShoppingCardsPage.tsx | 13 +- .../src/pages/StoreItemsPage.module.css | 79 ----- apps/business/src/pages/StoreItemsPage.tsx | 24 +- apps/business/src/pages/StoreSpecialsPage.tsx | 13 +- .../src/pages/VideoCategoriesPage.tsx | 24 +- apps/business/src/pages/VideoListPage.tsx | 22 +- .../business/src/pages/WebsiteContactPage.tsx | 13 +- .../src/pages/WebsiteSpecialBrandsPage.tsx | 10 +- .../pages/WebsiteSpecialCategoriesPage.tsx | 10 +- .../src/pages/WebsiteStaticImagesPage.tsx | 18 +- apps/business/src/services/authService.ts | 9 + .../components/CitySearchSelect.module.css | 10 +- .../src/components/CitySearchSelect.tsx | 10 +- apps/customer/src/components/Header.tsx | 41 ++- .../src/components/Sidebar.module.css | 13 +- apps/customer/src/i18n/messages.ts | 12 +- apps/customer/src/pages/AddMyProductPage.tsx | 33 +- apps/customer/src/pages/LoginPage.module.css | 58 +++- apps/customer/src/pages/LoginPage.tsx | 167 ++++----- apps/customer/src/services/authService.ts | 8 + apps/customer/src/types/auth.ts | 1 + docs/PROJECT_CONTEXT.md | 5 +- packages/dashboard-core/src/index.ts | 1 + packages/dashboard-core/src/types/auth.ts | 5 + .../components/PasswordResetModal.module.css | 88 +---- .../src/components/PasswordResetModal.tsx | 217 ++++++------ .../src/components/PasswordStrengthMeter.tsx | 39 ++- 55 files changed, 1264 insertions(+), 907 deletions(-) create mode 100644 apps/business/src/components/EmptyState.module.css create mode 100644 apps/business/src/components/EmptyState.tsx create mode 100644 apps/business/src/lib/handoff.ts diff --git a/apps/business/src/components/CitySearchSelect.module.css b/apps/business/src/components/CitySearchSelect.module.css index bf18b57..a3f02cc 100644 --- a/apps/business/src/components/CitySearchSelect.module.css +++ b/apps/business/src/components/CitySearchSelect.module.css @@ -81,10 +81,12 @@ } .dropdown { - position: static; - margin-top: 6px; - max-height: none; - overflow: visible; + position: absolute; + top: calc(100% + 6px); + inset-inline: 0; + z-index: 120; + max-height: min(280px, 50vh); + overflow-y: auto; background: var(--elevated-surface); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); diff --git a/apps/business/src/components/CitySearchSelect.tsx b/apps/business/src/components/CitySearchSelect.tsx index eccaeb6..6340b29 100644 --- a/apps/business/src/components/CitySearchSelect.tsx +++ b/apps/business/src/components/CitySearchSelect.tsx @@ -14,6 +14,8 @@ interface CitySearchSelectProps { onChange: (value: string) => void disabled?: boolean placeholder?: string + noResultsLabel?: string + clearLabel?: string id?: string } @@ -23,6 +25,8 @@ export function CitySearchSelect({ onChange, disabled = false, placeholder, + noResultsLabel, + clearLabel, id, }: CitySearchSelectProps) { const t = useT() @@ -34,6 +38,8 @@ export function CitySearchSelect({ const selected = options.find((option) => option.id === value) const selectedLabel = selected ? getLocationOptionLabel(selected, locale) : '' const searchPlaceholder = placeholder ?? t('customerProducts.form.fields.searchCity') + const emptyLabel = noResultsLabel ?? t('customerProducts.form.fields.noCities') + const clearAriaLabel = clearLabel ?? t('customerProducts.form.fields.clearCity') const filtered = options.filter((option) => { const q = query.trim().toLowerCase() @@ -108,7 +114,7 @@ export function CitySearchSelect({ type="button" className={styles.clearBtn} onClick={() => onChange('')} - aria-label={t('customerProducts.form.fields.clearCity')} + aria-label={clearAriaLabel} > @@ -123,7 +129,7 @@ export function CitySearchSelect({ {open && !disabled ? (
    {filtered.length === 0 ? ( -
  • {t('customerProducts.form.fields.noCities')}
  • +
  • {emptyLabel}
  • ) : ( filtered.map((option) => (
  • diff --git a/apps/business/src/components/EmptyState.module.css b/apps/business/src/components/EmptyState.module.css new file mode 100644 index 0000000..2209ee9 --- /dev/null +++ b/apps/business/src/components/EmptyState.module.css @@ -0,0 +1,53 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + width: 100%; + padding: 48px 24px; + text-align: center; + border-block: 1px solid color-mix(in srgb, var(--text-muted) 42%, transparent); +} + +.icon { + flex-shrink: 0; + color: var(--text-primary); +} + +.message { + margin: 0; + max-width: 36rem; + font-family: var(--font-ui); + font-size: 14px; + font-weight: 500; + line-height: 1.7; + color: var(--text-secondary); +} + +.action { + appearance: none; + display: inline; + margin: 0; + padding: 0; + border: none; + background: none; + font-family: var(--font-ui); + font-size: 14px; + font-weight: 600; + line-height: 1.5; + color: var(--primary); + text-decoration: none; + cursor: pointer; +} + +.action:hover { + text-decoration: underline; +} + +.cell { + padding: 0 !important; + background: transparent !important; + border-bottom: none !important; + vertical-align: middle; +} diff --git a/apps/business/src/components/EmptyState.tsx b/apps/business/src/components/EmptyState.tsx new file mode 100644 index 0000000..ab07534 --- /dev/null +++ b/apps/business/src/components/EmptyState.tsx @@ -0,0 +1,52 @@ +import type { LucideIcon } from 'lucide-react' +import { Link } from 'react-router-dom' +import styles from './EmptyState.module.css' + +export interface EmptyStateProps { + icon: LucideIcon + message: string + actionLabel?: string + actionTo?: string + onAction?: () => void +} + +export function EmptyState({ + icon: Icon, + message, + actionLabel, + actionTo, + onAction, +}: EmptyStateProps) { + const showAction = Boolean(actionLabel && (actionTo || onAction)) + + return ( +
    + +

    {message}

    + {showAction && actionLabel ? ( + actionTo ? ( + + {actionLabel} + + ) : ( + + ) + ) : null} +
    + ) +} + +export function EmptyTableRow({ + colSpan, + ...props +}: EmptyStateProps & { colSpan: number }) { + return ( + + + + + + ) +} diff --git a/apps/business/src/components/WebsiteSliderGallery.tsx b/apps/business/src/components/WebsiteSliderGallery.tsx index 5095bd4..bf80237 100644 --- a/apps/business/src/components/WebsiteSliderGallery.tsx +++ b/apps/business/src/components/WebsiteSliderGallery.tsx @@ -1,8 +1,9 @@ -import { ChevronLeft, ChevronRight, Plus } from 'lucide-react' +import { ChevronLeft, ChevronRight, Images, Plus } from 'lucide-react' import { useRef } from 'react' import { useLocale } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' import type { WebsiteSliderSlide } from '../types/websiteSlider' +import { EmptyState } from './EmptyState' import { Tooltip } from './Tooltip' import { WebsiteSliderSlideCard } from './WebsiteSliderSlideCard' import styles from './WebsiteSliderGallery.module.css' @@ -33,6 +34,17 @@ export function WebsiteSliderGallery({ const showNav = slides.length > 0 const addText = addLabel ?? t('website.sliders.addSlide') + if (slides.length === 0) { + return ( + + ) + } + function scrollBy(direction: -1 | 1) { const track = trackRef.current if (!track) return diff --git a/apps/business/src/context/AuthContext.tsx b/apps/business/src/context/AuthContext.tsx index 6e48d60..e125b44 100644 --- a/apps/business/src/context/AuthContext.tsx +++ b/apps/business/src/context/AuthContext.tsx @@ -18,6 +18,7 @@ import { loginWithOtp as loginWithOtpRequest, logout as logoutRequest, } from '../services/authService' +import { completeHandoffIfPresent } from '../lib/handoff' import { resolveTenantByDomain } from '../services/tenantService' import type { AuthUser } from '../types/auth' @@ -93,12 +94,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { const controller = new AbortController() async function init() { - if (!getAccessToken()) { - if (!controller.signal.aborted) setIsLoading(false) - return - } - try { + const handedOff = await completeHandoffIfPresent() + if (controller.signal.aborted) return + + if (handedOff) { + await assertBusinessAccess(handedOff.user) + if (controller.signal.aborted) return + setUser(handedOff.user) + return + } + + if (!getAccessToken()) { + return + } + const { user: currentUser } = await fetchCurrentUser(controller.signal) if (controller.signal.aborted) return diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index e23ba9b..871ddc6 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -281,7 +281,8 @@ const en = { 'products.list.search': 'Search', 'products.list.clearFilters': 'Clear filters', 'products.list.loading': 'Loading products...', - 'products.list.empty': 'No products found.', + 'products.list.empty': 'You have not added any products yet.', + 'products.list.emptyCta': 'Add product', 'products.list.emptyFiltered': 'No products match your filters.', 'products.list.errorLoad': 'Unable to load products.', 'products.list.errorDelete': 'Unable to delete product.', @@ -294,7 +295,19 @@ const en = { 'customerProducts.title': 'Customer products', 'customerProducts.subtitle': '{count} products submitted by customers.', - 'customerProducts.empty': 'No customer products yet.', + 'customerProducts.empty': 'You have not added any products yet.', + 'customerProducts.filters.name': 'Product name', + 'customerProducts.filters.owner': 'User name or number', + 'customerProducts.filters.namePlaceholder': 'Name (title)', + 'customerProducts.filters.ownerPlaceholder': 'User name or cell number', + 'customerProducts.filters.priceMin': 'Min price (IRT)', + 'customerProducts.filters.priceMax': 'Max price (IRT)', + 'customerProducts.filters.priceMinPlaceholder': 'Min price', + 'customerProducts.filters.priceMaxPlaceholder': 'Max price', + 'customerProducts.filters.status': 'Status', + 'customerProducts.filters.status.all': 'All statuses', + 'customerProducts.filters.search': 'Search', + 'customerProducts.filters.clear': 'Clear filters', 'customerProducts.form.title': 'My Products', 'customerProducts.form.empty': 'You have not added any products yet.', 'customerProducts.form.add': 'Add product', @@ -356,6 +369,9 @@ const en = { 'customerProducts.form.fields.location': 'Location', 'customerProducts.form.fields.country': 'Country', 'customerProducts.form.fields.selectCountry': 'Select country', + 'customerProducts.form.fields.searchCountry': 'Search country…', + 'customerProducts.form.fields.clearCountry': 'Clear country', + 'customerProducts.form.fields.noCountries': 'No countries found', 'customerProducts.form.fields.province': 'Province', 'customerProducts.form.fields.selectProvince': 'Select province', 'customerProducts.form.fields.city': 'City', @@ -540,7 +556,7 @@ const en = { '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.empty': 'You have not added any categories yet.', 'categories.add': 'Add Category', 'categories.addSub': 'Add Sub Category', 'categories.edit': 'Edit Category', @@ -632,7 +648,7 @@ const en = { 'brands.page.subtitle': 'Manage product brands for your store catalog.', 'brands.loading': 'Loading brands...', - 'brands.empty': 'No brands yet. Click + to add one.', + 'brands.empty': 'You have not added any brands yet.', 'brands.add': 'Add Brand', 'brands.edit': 'Edit Brand', 'brands.deleteTitle': 'Delete Brand', @@ -704,7 +720,7 @@ const en = { 'storeItems.excel.importSuccess': 'Updated {updated} variants. Unchanged: {unchanged}.', 'storeItems.excel.importEmpty': 'Choose an .xlsx file exported from this page.', 'storeItems.loading': 'Loading store items...', - 'storeItems.empty': "You don't have any products for sale yet.", + 'storeItems.empty': 'You have not added any products for sale yet.', 'storeItems.emptyHint': 'Add products from your catalog to start selling.', 'storeItems.emptyFiltered': 'No store items match your filters.', 'storeItems.pagination': @@ -990,7 +1006,7 @@ const en = { 'blog.list.loading': 'Loading blog posts...', 'blog.list.empty': 'No blog posts yet.', 'blog.list.emptyHint': 'Share news, stories, and updates with your audience.', - 'blog.list.emptyCta': 'Write your first post', + 'blog.list.emptyCta': 'Add blog', 'blog.list.errorLoad': 'Unable to load blog posts.', 'blog.list.errorDelete': 'Unable to delete blog post.', 'blog.list.errorVerify': 'Unable to update blog verification.', @@ -1020,7 +1036,7 @@ const en = { 'blog.categories.title': 'Blog Categories', 'blog.categories.subtitle': 'Organize your blog posts into categories and subcategories.', - 'blog.categories.empty': 'No categories yet. Click + to add one.', + 'blog.categories.empty': 'You have not added any categories yet.', 'blog.categories.errorLoad': 'Unable to load categories.', 'blog.categories.errorSave': 'Unable to save category.', 'blog.categories.errorDelete': 'Unable to delete category.', @@ -1074,7 +1090,7 @@ const en = { 'video.list.loading': 'Loading videos...', 'video.list.empty': 'No videos yet.', 'video.list.emptyHint': 'Share embedded videos from YouTube or Aparat with your audience.', - 'video.list.emptyCta': 'Add your first video', + 'video.list.emptyCta': 'Add video', 'video.list.errorLoad': 'Unable to load videos.', 'video.list.errorDelete': 'Unable to delete video.', 'video.list.toast.removed': 'Video removed.', @@ -1097,7 +1113,7 @@ const en = { 'video.categories.title': 'Video Categories', 'video.categories.subtitle': 'Organize your videos into categories and subcategories.', - 'video.categories.empty': 'No categories yet. Click + to add one.', + 'video.categories.empty': 'You have not added any categories yet.', 'video.categories.errorLoad': 'Unable to load categories.', 'video.categories.errorSave': 'Unable to save category.', 'video.categories.errorDelete': 'Unable to delete category.', @@ -1152,7 +1168,7 @@ const en = { 'instruction.list.loading': 'Loading instructions...', 'instruction.list.empty': 'No instructions yet.', 'instruction.list.emptyHint': 'Publish tutorials with rich text and optional video for your audience.', - 'instruction.list.emptyCta': 'Add your first instruction', + 'instruction.list.emptyCta': 'Add instruction', 'instruction.list.errorLoad': 'Unable to load instructions.', 'instruction.list.errorDelete': 'Unable to delete instruction.', 'instruction.list.toast.removed': 'Instruction removed.', @@ -1175,7 +1191,7 @@ const en = { 'instruction.categories.title': 'Instruction Categories', 'instruction.categories.subtitle': 'Organize your instructions into categories and subcategories.', - 'instruction.categories.empty': 'No categories yet. Click + to add one.', + 'instruction.categories.empty': 'You have not added any categories yet.', 'instruction.categories.errorLoad': 'Unable to load categories.', 'instruction.categories.errorSave': 'Unable to save category.', 'instruction.categories.errorDelete': 'Unable to delete category.', @@ -1228,7 +1244,8 @@ const en = { 'portfolio.list.subtitle': '{count} items · View, edit and manage your portfolio content.', 'portfolio.list.loading': 'Loading portfolios...', - 'portfolio.list.empty': 'No portfolio items found.', + 'portfolio.list.empty': 'You have not added any portfolio items yet.', + 'portfolio.list.emptyCta': 'Add portfolio', 'portfolio.list.errorLoad': 'Unable to load portfolios.', 'portfolio.list.errorDelete': 'Unable to delete portfolio.', 'portfolio.list.errorMove': 'Unable to move portfolio.', @@ -1255,7 +1272,7 @@ const en = { 'portfolio.categories.title': 'Portfolio Categories', 'portfolio.categories.subtitle': 'Organize your portfolio items into categories and subcategories.', - 'portfolio.categories.empty': 'No categories yet. Click + to add one.', + 'portfolio.categories.empty': 'You have not added any categories yet.', 'portfolio.categories.errorLoad': 'Unable to load categories.', 'portfolio.categories.errorSave': 'Unable to save category.', 'portfolio.categories.errorDelete': 'Unable to delete category.', @@ -1351,6 +1368,7 @@ const en = { 'website.sliders.subtitle': 'Manage homepage banner slides in a 9:4 gallery.', 'website.sliders.loading': 'Loading slides...', + 'website.sliders.empty': 'You have not added any slides yet.', 'website.sliders.errorLoad': 'Unable to load slides.', 'website.sliders.errorUpload': 'Unable to upload slide image.', 'website.sliders.errorAdd': 'Unable to add slide.', @@ -1457,8 +1475,7 @@ const en = { 'website.specialCategories.subtitle': 'Curate featured product categories into groups for your website homepage.', 'website.specialCategories.loading': 'Loading special category groups...', - 'website.specialCategories.empty': - 'No special category groups yet. Use the + button to create one, then add categories to each carousel.', + 'website.specialCategories.empty': 'You have not added any special category groups yet.', 'website.specialCategories.errorLoad': 'Unable to load special category groups.', 'website.specialCategories.errorCreate': 'Unable to create special category group.', 'website.specialCategories.errorUpdate': 'Unable to update special category group.', @@ -1486,8 +1503,7 @@ const en = { 'website.specialBrands.subtitle': 'Curate featured brands into groups for your website homepage.', 'website.specialBrands.loading': 'Loading special brand groups...', - 'website.specialBrands.empty': - 'No special brand groups yet. Use the + button to create one, then add brands to each carousel.', + 'website.specialBrands.empty': 'You have not added any special brand groups yet.', 'website.specialBrands.errorLoad': 'Unable to load special brand groups.', 'website.specialBrands.errorCreate': 'Unable to create special brand group.', 'website.specialBrands.errorUpdate': 'Unable to update special brand group.', @@ -1515,8 +1531,7 @@ const en = { 'website.specialItems.subtitle': 'Curate featured store items into categories for your website carousels.', 'website.specialItems.loading': 'Loading special categories...', - 'website.specialItems.empty': - 'No special categories yet. Use the + button to create one, then add store items to each carousel.', + 'website.specialItems.empty': 'You have not added any special categories yet.', 'website.specialItems.errorLoad': 'Unable to load special categories.', 'website.specialItems.errorCreate': 'Unable to create special category.', 'website.specialItems.errorUpdate': 'Unable to update special category.', @@ -1705,7 +1720,7 @@ const en = { 'invoices.countOne': '{count} invoice', 'invoices.countMany': '{count} invoices', 'invoices.loading': 'Loading…', - 'invoices.empty': 'No invoices yet.', + 'invoices.empty': 'You have not issued any invoices yet.', 'invoices.col.name': 'Name', 'invoices.col.customer': 'Customer', 'invoices.col.issued': 'Issued', @@ -1840,7 +1855,7 @@ const en = { 'invoiceTemplates.col.keyPoints': 'Key points', 'invoiceTemplates.col.accounts': 'Accounts', 'invoiceTemplates.col.actions': 'Actions', - 'invoiceTemplates.emptyTemplates': 'No invoice templates yet. Add one to speed up issuing invoices.', + 'invoiceTemplates.emptyTemplates': 'You have not added any invoice templates yet.', 'invoiceTemplates.section.items.title': 'Invoice item templates', 'invoiceTemplates.section.items.desc': 'Reusable line items you can drop into invoice templates or when issuing an invoice.', @@ -1853,7 +1868,7 @@ const en = { 'invoiceTemplates.col.worktime': 'Worktime', 'invoiceTemplates.col.price': 'Price', 'invoiceTemplates.col.discounted': 'Discounted', - 'invoiceTemplates.emptyItems': 'No predefined items yet.', + 'invoiceTemplates.emptyItems': 'You have not added any predefined items yet.', 'invoiceTemplates.editTemplate': 'Edit', 'invoiceTemplates.removeTemplate': 'Remove', 'invoiceTemplates.editItem': 'Edit', @@ -1917,6 +1932,7 @@ const en = { 'login.error.cellEnglishDigits': 'Please use English digits for the mobile number.', 'login.error.smsProvider': 'SMS service is temporarily unavailable. Please try again later.', 'login.error.access': 'You do not have access to this business dashboard.', + 'login.error.invalidCredentials': 'Invalid mobile number or password.', 'signup.title': 'Create account', 'signup.subtitle': 'Register for {domain}', @@ -1960,7 +1976,7 @@ const en = { 'password.rule.length': 'At least 8 characters', 'password.rule.upper': 'One uppercase letter', 'password.rule.number': 'One number', - 'password.rule.special': 'One special character', + 'password.rule.match': 'Passwords match', 'otp.back': 'Back to sign in', 'otp.title': 'One-time login', @@ -2264,7 +2280,8 @@ const fa: Record = { 'products.list.search': 'جستجو', 'products.list.clearFilters': 'پاک کردن فیلترها', 'products.list.loading': 'در حال بارگذاری محصولات...', - 'products.list.empty': 'محصولی یافت نشد.', + 'products.list.empty': 'هنوز محصولی ثبت نکرده‌اید.', + 'products.list.emptyCta': 'افزودن محصول', 'products.list.emptyFiltered': 'هیچ محصولی با فیلترهای شما مطابقت ندارد.', 'products.list.errorLoad': 'بارگذاری محصولات ممکن نشد.', 'products.list.errorDelete': 'حذف محصول ممکن نشد.', @@ -2277,7 +2294,19 @@ const fa: Record = { 'customerProducts.title': 'محصولات مشتریان', 'customerProducts.subtitle': '{count} محصول ثبت‌شده توسط مشتریان.', - 'customerProducts.empty': 'هنوز محصول مشتری ثبت نشده است.', + 'customerProducts.empty': 'هنوز محصولی ثبت نکرده‌اید.', + 'customerProducts.filters.name': 'نام محصول', + 'customerProducts.filters.owner': 'نام کاربر یا شماره', + 'customerProducts.filters.namePlaceholder': 'نام (عنوان)', + 'customerProducts.filters.ownerPlaceholder': 'نام کاربر یا شماره موبایل', + 'customerProducts.filters.priceMin': 'حداقل قیمت', + 'customerProducts.filters.priceMax': 'حداکثر قیمت', + 'customerProducts.filters.priceMinPlaceholder': 'حداقل قیمت', + 'customerProducts.filters.priceMaxPlaceholder': 'حداکثر قیمت', + 'customerProducts.filters.status': 'وضعیت', + 'customerProducts.filters.status.all': 'همه وضعیت‌ها', + 'customerProducts.filters.search': 'جستجو', + 'customerProducts.filters.clear': 'پاک کردن فیلترها', 'customerProducts.form.title': 'محصولات من', 'customerProducts.form.empty': 'هنوز محصولی ثبت نکرده‌اید.', 'customerProducts.form.add': 'افزودن محصول', @@ -2339,6 +2368,9 @@ const fa: Record = { 'customerProducts.form.fields.location': 'موقعیت', 'customerProducts.form.fields.country': 'کشور', 'customerProducts.form.fields.selectCountry': 'انتخاب کشور', + 'customerProducts.form.fields.searchCountry': 'جستجوی کشور…', + 'customerProducts.form.fields.clearCountry': 'پاک کردن کشور', + 'customerProducts.form.fields.noCountries': 'کشوری پیدا نشد', 'customerProducts.form.fields.province': 'استان', 'customerProducts.form.fields.selectProvince': 'انتخاب استان', 'customerProducts.form.fields.city': 'شهر', @@ -2521,7 +2553,7 @@ const fa: Record = { 'categories.page.subtitle': 'دسته‌بندی‌ها و زیردسته‌های محصولات خود را مدیریت کنید.', 'categories.loading': 'در حال بارگذاری دسته‌بندی‌ها...', - 'categories.empty': 'هنوز دسته‌بندی‌ای نیست. با + یا هوش مصنوعی اضافه کنید.', + 'categories.empty': 'هنوز دسته‌بندی‌ای ثبت نکرده‌اید.', 'categories.add': 'افزودن دسته‌بندی', 'categories.addSub': 'افزودن زیردسته', 'categories.edit': 'ویرایش دسته‌بندی', @@ -2613,7 +2645,7 @@ const fa: Record = { 'brands.page.subtitle': 'برندهای محصول فروشگاه خود را مدیریت کنید.', 'brands.loading': 'در حال بارگذاری برندها...', - 'brands.empty': 'هنوز برندی نیست. برای افزودن روی + کلیک کنید.', + 'brands.empty': 'هنوز برندی ثبت نکرده‌اید.', 'brands.add': 'افزودن برند', 'brands.edit': 'ویرایش برند', 'brands.deleteTitle': 'حذف برند', @@ -2685,7 +2717,7 @@ const fa: Record = { 'storeItems.excel.importSuccess': '{updated} تنوع به‌روز شد. بدون تغییر: {unchanged}.', 'storeItems.excel.importEmpty': 'یک فایل xlsx که از این صفحه گرفته‌اید انتخاب کنید.', 'storeItems.loading': 'در حال بارگذاری محصولات فروشگاه...', - 'storeItems.empty': 'هنوز هیچ محصولی برای فروش ندارید', + 'storeItems.empty': 'هنوز محصولی برای فروش ثبت نکرده‌اید.', 'storeItems.emptyHint': 'از کاتالوگ محصول اضافه کنید.', 'storeItems.emptyFiltered': 'هیچ محصولی با فیلترهای شما مطابقت ندارد.', 'storeItems.pagination': @@ -2971,7 +3003,7 @@ const fa: Record = { 'blog.list.loading': 'در حال بارگذاری مطالب بلاگ...', 'blog.list.empty': 'هنوز مطلب بلاگی ندارید.', 'blog.list.emptyHint': 'اخبار، داستان‌ها و به‌روزرسانی‌ها را با مخاطبان خود به اشتراک بگذارید.', - 'blog.list.emptyCta': 'اولین مطلب را بنویسید', + 'blog.list.emptyCta': 'افزودن مطلب', 'blog.list.errorLoad': 'بارگذاری مطالب بلاگ ممکن نشد.', 'blog.list.errorDelete': 'حذف مطلب بلاگ ممکن نشد.', 'blog.list.errorVerify': 'به‌روزرسانی وضعیت تأیید بلاگ ممکن نشد.', @@ -3001,7 +3033,7 @@ const fa: Record = { 'blog.categories.title': 'دسته‌بندی‌های بلاگ', 'blog.categories.subtitle': 'مطالب بلاگ را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.', - 'blog.categories.empty': 'هنوز دسته‌بندی‌ای نیست. برای افزودن روی + کلیک کنید.', + 'blog.categories.empty': 'هنوز دسته‌بندی‌ای ثبت نکرده‌اید.', 'blog.categories.errorLoad': 'بارگذاری دسته‌بندی‌ها ممکن نشد.', 'blog.categories.errorSave': 'ذخیره دسته‌بندی ممکن نشد.', 'blog.categories.errorDelete': 'حذف دسته‌بندی ممکن نشد.', @@ -3055,7 +3087,7 @@ const fa: Record = { 'video.list.loading': 'در حال بارگذاری ویدیوها...', 'video.list.empty': 'هنوز ویدیویی نیست.', 'video.list.emptyHint': 'ویدیوهای جاسازی‌شده از یوتیوب یا آپارات را با مخاطبان به اشتراک بگذارید.', - 'video.list.emptyCta': 'اولین ویدیو را اضافه کنید', + 'video.list.emptyCta': 'افزودن ویدیو', 'video.list.errorLoad': 'بارگذاری ویدیوها ممکن نشد.', 'video.list.errorDelete': 'حذف ویدیو ممکن نشد.', 'video.list.toast.removed': 'ویدیو حذف شد.', @@ -3078,7 +3110,7 @@ const fa: Record = { 'video.categories.title': 'دسته‌بندی‌های ویدیو', 'video.categories.subtitle': 'ویدیوها را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.', - 'video.categories.empty': 'هنوز دسته‌بندی‌ای نیست. برای افزودن روی + بزنید.', + 'video.categories.empty': 'هنوز دسته‌بندی‌ای ثبت نکرده‌اید.', 'video.categories.errorLoad': 'بارگذاری دسته‌بندی‌ها ممکن نشد.', 'video.categories.errorSave': 'ذخیره دسته‌بندی ممکن نشد.', 'video.categories.errorDelete': 'حذف دسته‌بندی ممکن نشد.', @@ -3133,7 +3165,7 @@ const fa: Record = { 'instruction.list.loading': 'در حال بارگذاری آموزش‌ها...', 'instruction.list.empty': 'هنوز آموزشی نیست.', 'instruction.list.emptyHint': 'آموزش‌ها را با متن غنی و ویدیوی اختیاری برای مخاطبان منتشر کنید.', - 'instruction.list.emptyCta': 'اولین آموزش را اضافه کنید', + 'instruction.list.emptyCta': 'افزودن آموزش', 'instruction.list.errorLoad': 'بارگذاری آموزش‌ها ممکن نشد.', 'instruction.list.errorDelete': 'حذف آموزش ممکن نشد.', 'instruction.list.toast.removed': 'آموزش حذف شد.', @@ -3156,7 +3188,7 @@ const fa: Record = { 'instruction.categories.title': 'دسته‌بندی‌های آموزش', 'instruction.categories.subtitle': 'آموزش‌ها را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.', - 'instruction.categories.empty': 'هنوز دسته‌بندی‌ای نیست. برای افزودن روی + بزنید.', + 'instruction.categories.empty': 'هنوز دسته‌بندی‌ای ثبت نکرده‌اید.', 'instruction.categories.errorLoad': 'بارگذاری دسته‌بندی‌ها ممکن نشد.', 'instruction.categories.errorSave': 'ذخیره دسته‌بندی ممکن نشد.', 'instruction.categories.errorDelete': 'حذف دسته‌بندی ممکن نشد.', @@ -3210,7 +3242,8 @@ const fa: Record = { 'portfolio.list.subtitle': '{count} مورد · مشاهده، ویرایش و مدیریت محتوای نمونه کار.', 'portfolio.list.loading': 'در حال بارگذاری نمونه کارها...', - 'portfolio.list.empty': 'نمونه کاری یافت نشد.', + 'portfolio.list.empty': 'هنوز نمونه کاری ثبت نکرده‌اید.', + 'portfolio.list.emptyCta': 'افزودن نمونه کار', 'portfolio.list.errorLoad': 'بارگذاری نمونه کارها ممکن نشد.', 'portfolio.list.errorDelete': 'حذف نمونه کار ممکن نشد.', 'portfolio.list.errorMove': 'جابه‌جایی نمونه کار ممکن نشد.', @@ -3237,7 +3270,7 @@ const fa: Record = { 'portfolio.categories.title': 'دسته‌بندی‌های نمونه کار', 'portfolio.categories.subtitle': 'نمونه کارها را در دسته‌بندی‌ها و زیردسته‌ها سازماندهی کنید.', - 'portfolio.categories.empty': 'هنوز دسته‌بندی‌ای نیست. برای افزودن روی + کلیک کنید.', + 'portfolio.categories.empty': 'هنوز دسته‌بندی‌ای ثبت نکرده‌اید.', 'portfolio.categories.errorLoad': 'بارگذاری دسته‌بندی‌ها ممکن نشد.', 'portfolio.categories.errorSave': 'ذخیره دسته‌بندی ممکن نشد.', 'portfolio.categories.errorDelete': 'حذف دسته‌بندی ممکن نشد.', @@ -3333,6 +3366,7 @@ const fa: Record = { 'website.sliders.subtitle': 'اسلایدهای بنر صفحه اصلی را در گالری ۹:۴ مدیریت کنید.', 'website.sliders.loading': 'در حال بارگذاری اسلایدها...', + 'website.sliders.empty': 'هنوز اسلایدی ثبت نکرده‌اید.', 'website.sliders.errorLoad': 'بارگذاری اسلایدها ممکن نشد.', 'website.sliders.errorUpload': 'آپلود تصویر اسلاید ممکن نشد.', 'website.sliders.errorAdd': 'افزودن اسلاید ممکن نشد.', @@ -3439,8 +3473,7 @@ const fa: Record = { 'website.specialCategories.subtitle': 'دسته‌بندی‌های ویژه محصول را برای صفحه اصلی وب‌سایت در گروه‌ها گردآوری کنید.', 'website.specialCategories.loading': 'در حال بارگذاری گروه‌های دسته ویژه...', - 'website.specialCategories.empty': - 'هنوز گروه دسته ویژه‌ای نیست. با دکمه + یکی بسازید، سپس دسته‌ها را به هر کاروسل اضافه کنید.', + 'website.specialCategories.empty': 'هنوز گروه دسته ویژه‌ای ثبت نکرده‌اید.', 'website.specialCategories.errorLoad': 'بارگذاری گروه‌های دسته ویژه ممکن نشد.', 'website.specialCategories.errorCreate': 'ایجاد گروه دسته ویژه ممکن نشد.', 'website.specialCategories.errorUpdate': 'به‌روزرسانی گروه دسته ویژه ممکن نشد.', @@ -3468,8 +3501,7 @@ const fa: Record = { 'website.specialBrands.subtitle': 'برندهای ویژه را برای صفحه اصلی وب‌سایت در گروه‌ها گردآوری کنید.', 'website.specialBrands.loading': 'در حال بارگذاری گروه‌های برند ویژه...', - 'website.specialBrands.empty': - 'هنوز گروه برند ویژه‌ای نیست. با دکمه + یکی بسازید، سپس برندها را به هر کاروسل اضافه کنید.', + 'website.specialBrands.empty': 'هنوز گروه برند ویژه‌ای ثبت نکرده‌اید.', 'website.specialBrands.errorLoad': 'بارگذاری گروه‌های برند ویژه ممکن نشد.', 'website.specialBrands.errorCreate': 'ایجاد گروه برند ویژه ممکن نشد.', 'website.specialBrands.errorUpdate': 'به‌روزرسانی گروه برند ویژه ممکن نشد.', @@ -3496,8 +3528,7 @@ const fa: Record = { 'website.specialItems.subtitle': 'محصولات ویژه فروشگاه را برای کاروسل‌های وب‌سایت در دسته‌ها گردآوری کنید.', 'website.specialItems.loading': 'در حال بارگذاری دسته‌های ویژه...', - 'website.specialItems.empty': - 'هنوز دسته ویژه‌ای نیست. با دکمه + یکی بسازید، سپس محصولات فروشگاه را به هر کاروسل اضافه کنید.', + 'website.specialItems.empty': 'هنوز دسته ویژه‌ای ثبت نکرده‌اید.', 'website.specialItems.errorLoad': 'بارگذاری دسته‌های ویژه ممکن نشد.', 'website.specialItems.errorCreate': 'ایجاد دسته ویژه ممکن نشد.', 'website.specialItems.errorUpdate': 'به‌روزرسانی دسته ویژه ممکن نشد.', @@ -3822,7 +3853,7 @@ const fa: Record = { 'invoiceTemplates.col.keyPoints': 'نکات کلیدی', 'invoiceTemplates.col.accounts': 'حساب‌ها', 'invoiceTemplates.col.actions': 'عملیات', - 'invoiceTemplates.emptyTemplates': 'هنوز قالب فاکتوری ندارید. یکی اضافه کنید تا صدور فاکتور سریع‌تر شود.', + 'invoiceTemplates.emptyTemplates': 'هنوز قالب فاکتوری ثبت نکرده‌اید.', 'invoiceTemplates.section.items.title': 'قالب‌های آیتم فاکتور', 'invoiceTemplates.section.items.desc': 'آیتم‌های قابل استفاده مجدد که می‌توانید در قالب‌های فاکتور یا هنگام صدور فاکتور استفاده کنید.', @@ -3835,7 +3866,7 @@ const fa: Record = { 'invoiceTemplates.col.worktime': 'ساعت کاری', 'invoiceTemplates.col.price': 'قیمت', 'invoiceTemplates.col.discounted': 'با تخفیف', - 'invoiceTemplates.emptyItems': 'هنوز آیتم از پیش تعریف‌شده‌ای ندارید.', + 'invoiceTemplates.emptyItems': 'هنوز آیتم از پیش تعریف‌شده‌ای ثبت نکرده‌اید.', 'invoiceTemplates.editTemplate': 'ویرایش', 'invoiceTemplates.removeTemplate': 'حذف', 'invoiceTemplates.editItem': 'ویرایش', @@ -3899,6 +3930,7 @@ const fa: Record = { 'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید', 'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.', 'login.error.access': 'به این پنل کسب‌وکار دسترسی ندارید.', + 'login.error.invalidCredentials': 'شماره موبایل یا رمز عبور نادرست است.', 'signup.title': 'ایجاد حساب', 'signup.subtitle': 'ثبت‌نام برای {domain}', @@ -3942,7 +3974,7 @@ const fa: Record = { 'password.rule.length': 'حداقل ۸ کاراکتر', 'password.rule.upper': 'یک حرف بزرگ انگلیسی', 'password.rule.number': 'یک عدد', - 'password.rule.special': 'یک کاراکتر ویژه', + 'password.rule.match': 'یکسان بودن رمز عبور', 'otp.back': 'بازگشت به ورود', 'otp.title': 'ورود یک‌بارمصرف', diff --git a/apps/business/src/lib/handoff.ts b/apps/business/src/lib/handoff.ts new file mode 100644 index 0000000..ff52b71 --- /dev/null +++ b/apps/business/src/lib/handoff.ts @@ -0,0 +1,32 @@ +import { consumeHandoff } from '../services/authService' +import type { LoginResponse } from '../types/auth' + +let inFlight: Promise | null = null + +function takeHandoffTicketFromUrl(): string | null { + const params = new URLSearchParams(window.location.search) + const ticket = params.get('handoff')?.trim() + if (!ticket) return null + + params.delete('handoff') + const query = params.toString() + const next = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}` + window.history.replaceState({}, '', next) + return ticket +} + +/** Consumes `?handoff=` at most once per page load (safe under React Strict Mode). */ +export function completeHandoffIfPresent(): Promise { + if (!inFlight) { + inFlight = (async () => { + const ticket = takeHandoffTicketFromUrl() + if (!ticket) return null + try { + return await consumeHandoff(ticket) + } catch { + return null + } + })() + } + return inFlight +} diff --git a/apps/business/src/pages/AddCustomerProductPage.tsx b/apps/business/src/pages/AddCustomerProductPage.tsx index 3eaf58f..26a5832 100644 --- a/apps/business/src/pages/AddCustomerProductPage.tsx +++ b/apps/business/src/pages/AddCustomerProductPage.tsx @@ -2,8 +2,6 @@ import { useEffect, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { Check, ClipboardList, Images, SlidersHorizontal } from 'lucide-react' import { - getLocationOptionLabel, - useLocale, type CityOption, } from '@meshkee/dashboard-ui' import { Breadcrumbs } from '../components/Breadcrumbs' @@ -96,7 +94,6 @@ function isPriceCurrency( export function AddCustomerProductPage() { const t = useT() - const { locale } = useLocale() const navigate = useNavigate() const { id } = useParams<{ id?: string }>() const isEdit = Boolean(id) @@ -119,7 +116,6 @@ export function AddCustomerProductPage() { const [countries, setCountries] = useState([]) const [cities, setCities] = useState([]) - const [countrySlug, setCountrySlug] = useState('') const [countryId, setCountryId] = useState('') const [cityId, setCityId] = useState('') const [deliveryNote, setDeliveryNote] = useState('') @@ -220,7 +216,6 @@ export function AddCustomerProductPage() { : 'IRT', ) setPriceByExpert(product.priceByExpert === true) - setCountrySlug(product.countrySlug) setCountryId(product.countryId) setCityId(product.cityId) setDeliveryNote(product.deliveryNote ?? '') @@ -282,9 +277,10 @@ export function AddCustomerProductPage() { return () => controller.abort() }, [categoryId, t]) - async function handleCountryChange(nextSlug: string) { - setCountrySlug(nextSlug) - setCountryId(countries.find((item) => item.slug === nextSlug)?.id ?? '') + async function handleCountryChange(nextCountryId: string) { + setCountryId(nextCountryId) + const country = countries.find((item) => item.id === nextCountryId) + const nextSlug = country?.slug ?? '' setCityId('') setCities([]) if (!nextSlug) return @@ -675,19 +671,16 @@ export function AddCustomerProductPage() {
    - + placeholder={t('customerProducts.form.fields.searchCountry')} + noResultsLabel={t('customerProducts.form.fields.noCountries')} + clearLabel={t('customerProducts.form.fields.clearCountry')} + />
    @@ -697,7 +690,7 @@ export function AddCustomerProductPage() { options={cities} value={cityId} onChange={setCityId} - disabled={!countrySlug || loadingLocations} + disabled={!countryId || loadingLocations} placeholder={t('customerProducts.form.fields.searchCity')} />
    diff --git a/apps/business/src/pages/BlogCategoriesPage.tsx b/apps/business/src/pages/BlogCategoriesPage.tsx index 9304e82..66dcb18 100644 --- a/apps/business/src/pages/BlogCategoriesPage.tsx +++ b/apps/business/src/pages/BlogCategoriesPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react' -import { Plus } from 'lucide-react' +import { FolderTree, Plus } from 'lucide-react' import { CategoryModal } from '../components/CategoryModal' import { BlogCategoryTree } from '../components/BlogCategoryTree' import { Breadcrumbs } from '../components/Breadcrumbs' +import { EmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import type { Category, CategoryFormData } from '../types/category' @@ -171,12 +172,19 @@ export function BlogCategoriesPage() {
    )} -
    - {isLoading ? ( + {isLoading ? ( +

    {t('categories.loading')}

    - ) : categories.length === 0 ? ( -

    {t('blog.categories.empty')}

    - ) : ( +
    + ) : categories.length === 0 ? ( + openCreateModal()} + /> + ) : ( +
    - )} -
    +
    + )} {t('blog.list.loading')}

    ) : blogs.length === 0 ? ( -
    - -

    {t('blog.list.empty')}

    -

    {t('blog.list.emptyHint')}

    - -
    + ) : ( <>
    diff --git a/apps/business/src/pages/BlogPage.module.css b/apps/business/src/pages/BlogPage.module.css index 0455ef9..fbd88fd 100644 --- a/apps/business/src/pages/BlogPage.module.css +++ b/apps/business/src/pages/BlogPage.module.css @@ -3,85 +3,6 @@ text-align: center; font-size: 14px; color: var(--text-secondary); - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: var(--radius); -} - -.emptyState { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - padding: 56px 28px; - text-align: center; - background: var(--glass-bg); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); - border: 1px solid var(--glass-border); - border-radius: var(--radius); - box-shadow: var(--glass-shadow); -} - -.emptyIcon { - width: 56px; - height: 56px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 6px; - border-radius: 16px; - color: var(--primary); - background: linear-gradient( - 145deg, - rgba(255, 255, 255, 0.72) 0%, - rgba(var(--primary-rgb) / 0.12) 100% - ); - border: 1px solid rgba(255, 255, 255, 0.45); - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); -} - -.emptyTitle { - margin: 0; - font-size: 17px; - font-weight: 700; - color: var(--text-primary); - line-height: 1.4; -} - -.emptyHint { - margin: 0 0 8px; - max-width: 360px; - font-size: 13px; - line-height: 1.65; - color: var(--text-secondary); -} - -.emptyCta { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 42px; - padding: 10px 20px; - font-size: 14px; - font-weight: 600; - color: #fff; - text-align: center; - background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); - border-radius: 999px; - box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.32); - transition: transform 0.2s, box-shadow 0.2s; -} - -button.emptyCta { - text-align: center; -} - -.emptyCta:hover { - transform: translateY(-1px); - box-shadow: 0 12px 26px rgba(var(--primary-rgb) / 0.4); } .error { diff --git a/apps/business/src/pages/BrandsPage.tsx b/apps/business/src/pages/BrandsPage.tsx index f65062c..1c4ee2c 100644 --- a/apps/business/src/pages/BrandsPage.tsx +++ b/apps/business/src/pages/BrandsPage.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react' -import { Plus } from 'lucide-react' +import { Award, Plus } from 'lucide-react' import { useToast } from '../context/ToastContext' import { BrandModal } from '../components/BrandModal' import { BrandRow } from '../components/BrandRow' import { Breadcrumbs } from '../components/Breadcrumbs' +import { EmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import type { Brand, BrandFormData } from '../types/brand' @@ -154,13 +155,20 @@ export function BrandsPage() {
    )} -
    - {isLoading ? ( + {isLoading ? ( +

    {t('brands.loading')}

    - ) : brands.length === 0 ? ( -

    {t('brands.empty')}

    - ) : ( - brands.map((brand) => ( +
    + ) : brands.length === 0 ? ( + + ) : ( +
    + {brands.map((brand) => ( - )) - )} -
    + ))} +
    + )} )} -
    - {isLoading ? ( + {isLoading ? ( +

    {t('categories.loading')}

    - ) : categories.length === 0 ? ( -

    {t('categories.empty')}

    - ) : ( +
    + ) : categories.length === 0 ? ( + openCreateModal()} + /> + ) : ( +
    - )} -
    +
    + )} (null) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [page, setPage] = useState(1) + const [allProducts, setAllProducts] = useState< + Awaited>['items'] + >([]) const [busyId, setBusyId] = useState(null) const [busyAction, setBusyAction] = useState< 'remove' | 'promote' | 'status' | null >(null) const [statusProductId, setStatusProductId] = useState(null) + const [appliedFilters, setAppliedFilters] = useState({ + status: 'all', + name: '', + owner: '', + minPrice: null, + maxPrice: null, + }) + + const [draftName, setDraftName] = useState('') + const [draftOwner, setDraftOwner] = useState('') + const [draftMinPrice, setDraftMinPrice] = useState('') + const [draftMaxPrice, setDraftMaxPrice] = useState('') + const [draftStatus, setDraftStatus] = useState('all') + + const appliedStatus = appliedFilters.status + useEffect(() => { const controller = new AbortController() @@ -43,12 +75,27 @@ export function CustomerProductsPage() { setLoading(true) setError('') try { - const response = await listUserProducts( - { page, pageSize: PAGE_SIZE }, - controller.signal, - ) - if (controller.signal.aborted) return - setData(response) + const statusParam = appliedStatus === 'all' ? undefined : appliedStatus + + const collected: typeof allProducts = [] + let serverTotal = 0 + let cursorPage = 1 + + for (let i = 0; i < MAX_FETCH_PAGES; i += 1) { + const response = await listUserProducts( + { page: cursorPage, pageSize: FETCH_PAGE_SIZE, status: statusParam }, + controller.signal, + ) + if (controller.signal.aborted) return + + serverTotal = response.total + collected.push(...response.items) + + if (collected.length >= serverTotal || response.items.length === 0) break + cursorPage += 1 + } + + setAllProducts(collected) } catch (err) { if (isAbortError(err) || controller.signal.aborted) return setError( @@ -61,12 +108,68 @@ export function CustomerProductsPage() { void load() return () => controller.abort() - }, [page, t]) + }, [appliedStatus, t]) - const products = data?.items ?? [] - const total = data?.total ?? 0 - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) - const statusProduct = products.find((item) => item.id === statusProductId) + useEffect(() => { + // Keep current page valid after filtering. + setPage(1) + }, [appliedFilters.name, appliedFilters.owner, appliedFilters.minPrice, appliedFilters.maxPrice, appliedFilters.status]) + + const filteredProducts = useMemo(() => { + const nameQ = appliedFilters.name.trim() + const ownerQ = appliedFilters.owner.trim() + const nameLower = nameQ.toLowerCase() + const ownerLower = ownerQ.toLowerCase() + const min = appliedFilters.minPrice + const max = appliedFilters.maxPrice + + return allProducts.filter((product) => { + if (appliedFilters.status !== 'all' && product.status !== appliedFilters.status) { + return false + } + + if (nameQ) { + const titleFa = product.titleFa ?? product.title ?? '' + const titleEn = product.titleEn ?? product.title ?? '' + const inTitle = + titleFa.includes(nameQ) || + titleEn.toLowerCase().includes(nameLower) || + product.title.toLowerCase().includes(nameLower) + if (!inTitle) return false + } + + if (ownerQ) { + const ownerNameFa = product.ownerNameFa ?? product.ownerName ?? '' + const ownerNameEn = product.ownerName ?? '' + const ownerCell = product.ownerCell ?? '' + const inOwner = + ownerNameFa.includes(ownerQ) || + ownerNameEn.toLowerCase().includes(ownerLower) || + ownerCell.includes(ownerQ) + if (!inOwner) return false + } + + if (min != null) { + if (product.price == null || product.price < min) return false + } + if (max != null) { + if (product.price == null || product.price > max) return false + } + + return true + }) + }, [allProducts, appliedFilters]) + + const totalFiltered = filteredProducts.length + const totalPages = Math.max(1, Math.ceil(totalFiltered / PAGE_SIZE)) + const products = filteredProducts.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + const statusProduct = allProducts.find((item) => item.id === statusProductId) + const filtersActive = + appliedFilters.status !== 'all' || + Boolean(appliedFilters.name.trim()) || + Boolean(appliedFilters.owner.trim()) || + appliedFilters.minPrice != null || + appliedFilters.maxPrice != null function handleEdit(id: string) { navigate(`/customer-products/${id}/edit`) @@ -79,15 +182,7 @@ export function CustomerProductsPage() { setBusyAction('remove') try { await deleteUserProduct(id) - setData((prev) => - prev - ? { - ...prev, - items: prev.items.filter((item) => item.id !== id), - total: Math.max(0, prev.total - 1), - } - : prev, - ) + setAllProducts((prev) => prev.filter((item) => item.id !== id)) showToast(t('customerProducts.removeSuccess'), 'success') } catch (err) { showToast( @@ -107,15 +202,8 @@ export function CustomerProductsPage() { setBusyAction('promote') try { const response = await promoteUserProduct(id) - setData((prev) => - prev - ? { - ...prev, - items: prev.items.map((item) => - item.id === id ? { ...item, ...response.product } : item, - ), - } - : prev, + setAllProducts((prev) => + prev.map((item) => (item.id === id ? { ...item, ...response.product } : item)), ) showToast(t('customerProducts.promoteSuccess'), 'success') } catch (err) { @@ -137,17 +225,10 @@ export function CustomerProductsPage() { setBusyAction('status') try { const response = await updateUserProductStatus(statusProductId, status) - setData((prev) => - prev - ? { - ...prev, - items: prev.items.map((item) => - item.id === statusProductId - ? { ...item, ...response.product } - : item, - ), - } - : prev, + setAllProducts((prev) => + prev.map((item) => + item.id === statusProductId ? { ...item, ...response.product } : item, + ), ) showToast(t('customerProducts.statusSuccess'), 'success') setStatusProductId(null) @@ -164,6 +245,33 @@ export function CustomerProductsPage() { } } + function applyFilters() { + setAppliedFilters({ + status: draftStatus, + name: draftName.trim(), + owner: draftOwner.trim(), + minPrice: parseIrtInput(draftMinPrice), + maxPrice: parseIrtInput(draftMaxPrice), + }) + setPage(1) + } + + function clearFilters() { + setDraftName('') + setDraftOwner('') + setDraftMinPrice('') + setDraftMaxPrice('') + setDraftStatus('all') + setAppliedFilters({ + status: 'all', + name: '', + owner: '', + minPrice: null, + maxPrice: null, + }) + setPage(1) + } + return (
    {t('customerProducts.title')}

    - {t('customerProducts.subtitle', { count: total })} + {t('customerProducts.subtitle', { count: totalFiltered })}

    +
    +
    {t('customerProducts.filters.search')}
    +
    { + e.preventDefault() + applyFilters() + }} + > +
    +
    + + setDraftName(e.target.value)} + placeholder={t('customerProducts.filters.namePlaceholder')} + aria-label={t('customerProducts.filters.name')} + autoComplete="off" + disabled={loading} + /> +
    + +
    + + setDraftOwner(e.target.value)} + placeholder={t('customerProducts.filters.ownerPlaceholder')} + aria-label={t('customerProducts.filters.owner')} + autoComplete="off" + disabled={loading} + /> +
    + +
    + + setDraftMinPrice(formatIrtInput(e.target.value))} + placeholder={t('customerProducts.filters.priceMinPlaceholder')} + aria-label={t('customerProducts.filters.priceMin')} + autoComplete="off" + disabled={loading} + /> +
    + +
    + + setDraftMaxPrice(formatIrtInput(e.target.value))} + placeholder={t('customerProducts.filters.priceMaxPlaceholder')} + aria-label={t('customerProducts.filters.priceMax')} + autoComplete="off" + disabled={loading} + /> +
    + +
    + + +
    +
    + +
    + + +
    +
    +
    + {error ? (
    {error} @@ -190,14 +406,13 @@ export function CustomerProductsPage() { {loading ?

    {t('customerProducts.loading')}

    : null} - {!loading && !error && products.length === 0 ? ( -
    - -

    {t('customerProducts.empty')}

    - - {t('customerProducts.add')} - -
    + {!loading && !error && filteredProducts.length === 0 ? ( + ) : null} {!loading && products.length > 0 ? ( @@ -220,7 +435,10 @@ export function CustomerProductsPage() { { + setPage(next) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }} disabled={loading} /> ) : null} diff --git a/apps/business/src/pages/CustomersPage.tsx b/apps/business/src/pages/CustomersPage.tsx index bbbceca..effc78b 100644 --- a/apps/business/src/pages/CustomersPage.tsx +++ b/apps/business/src/pages/CustomersPage.tsx @@ -1,12 +1,13 @@ import { useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { MessageSquare, Pencil, Plus, Receipt, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react' +import { MessageSquare, Pencil, Plus, Receipt, RotateCcw, Search, Shield, Ticket, Trash2, Users } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { AddCustomerModal } from '../components/AddCustomerModal' import { Breadcrumbs } from '../components/Breadcrumbs' import { ChangeUserAccessModal } from '../components/ChangeUserAccessModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { EditCustomerModal } from '../components/EditCustomerModal' +import { EmptyTableRow } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { Pagination } from '../components/Pagination' import { ToggleSwitch } from '../components/ToggleSwitch' @@ -440,11 +441,13 @@ export function CustomersPage() { )} {!loading && data?.items?.length === 0 && ( - - - {t('customers.empty')} - - + setCreateOpen(true)} + /> )} {!loading && diff --git a/apps/business/src/pages/InstructionCategoriesPage.tsx b/apps/business/src/pages/InstructionCategoriesPage.tsx index 0d9287a..620c04d 100644 --- a/apps/business/src/pages/InstructionCategoriesPage.tsx +++ b/apps/business/src/pages/InstructionCategoriesPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react' -import { Plus } from 'lucide-react' +import { FolderTree, Plus } from 'lucide-react' import { CategoryModal } from '../components/CategoryModal' import { InstructionCategoryTree } from '../components/InstructionCategoryTree' import { Breadcrumbs } from '../components/Breadcrumbs' +import { EmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import type { Category, CategoryFormData } from '../types/category' @@ -171,12 +172,19 @@ export function InstructionCategoriesPage() {
    )} -
    - {isLoading ? ( + {isLoading ? ( +

    {t('categories.loading')}

    - ) : categories.length === 0 ? ( -

    {t('instruction.categories.empty')}

    - ) : ( +
    + ) : categories.length === 0 ? ( + openCreateModal()} + /> + ) : ( +
    - )} -
    +
    + )} {t('instruction.list.loading')}

    ) : videos.length === 0 ? ( -
    - -

    {t('instruction.list.empty')}

    -

    {t('instruction.list.emptyHint')}

    - -
    + ) : ( <>
    diff --git a/apps/business/src/pages/InvoiceTemplatesPage.module.css b/apps/business/src/pages/InvoiceTemplatesPage.module.css index 52d5978..ff93d54 100644 --- a/apps/business/src/pages/InvoiceTemplatesPage.module.css +++ b/apps/business/src/pages/InvoiceTemplatesPage.module.css @@ -30,15 +30,6 @@ text-overflow: ellipsis; } -.emptyState { - display: flex; - align-items: center; - gap: 10px; - padding: 18px 4px; - color: var(--text-secondary); - font-size: 13px; -} - .topTextPreview { overflow: hidden; text-overflow: ellipsis; diff --git a/apps/business/src/pages/InvoiceTemplatesPage.tsx b/apps/business/src/pages/InvoiceTemplatesPage.tsx index 4be8a16..eb5c70d 100644 --- a/apps/business/src/pages/InvoiceTemplatesPage.tsx +++ b/apps/business/src/pages/InvoiceTemplatesPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { FileStack, FileText, Pencil, Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react' +import { FileStack, FileText, Pencil, Plus, Trash2 } from 'lucide-react' import { Breadcrumbs } from '../components/Breadcrumbs' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' +import { EmptyTableRow } from '../components/EmptyState' import { Modal } from '../components/Modal' import { PageTitle } from '../components/PageTitle' import { isEmptyRichText, richTextToPlain } from '../components/RichTextEditor' @@ -256,14 +257,13 @@ export function InvoiceTemplatesPage() { {!loading && invoiceTemplates.length === 0 ? ( - - -
    - - {t('invoiceTemplates.emptyTemplates')} -
    - - + ) : null} {invoiceTemplates.map((template) => ( @@ -364,14 +364,13 @@ export function InvoiceTemplatesPage() { {!loading && itemTemplates.length === 0 ? ( - - -
    - - {t('invoiceTemplates.emptyItems')} -
    - - + ) : null} {itemTemplates.map((template) => ( diff --git a/apps/business/src/pages/InvoicesPage.tsx b/apps/business/src/pages/InvoicesPage.tsx index 8027173..2e33bd3 100644 --- a/apps/business/src/pages/InvoicesPage.tsx +++ b/apps/business/src/pages/InvoicesPage.tsx @@ -1,10 +1,11 @@ import { useEffect, useMemo, useState } from 'react' import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom' -import { Copy, Eye, Pencil, Plus, Trash2 } from 'lucide-react' +import { Copy, Eye, FileText, Pencil, Plus, Trash2 } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { Pagination } from '../components/Pagination' import { Breadcrumbs } from '../components/Breadcrumbs' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' +import { EmptyTableRow } from '../components/EmptyState' import { Modal } from '../components/Modal' import { PageTitle } from '../components/PageTitle' import { isEmptyRichText } from '../components/RichTextEditor' @@ -283,11 +284,13 @@ export function InvoicesPage() { {!loading && invoices.length === 0 ? ( - - - {t('invoices.empty')} - - + ) : null} {invoices.map((invoice) => { const itemCount = invoice.items?.length ?? 0 diff --git a/apps/business/src/pages/LoginPage.module.css b/apps/business/src/pages/LoginPage.module.css index 057dda5..b8b2e96 100644 --- a/apps/business/src/pages/LoginPage.module.css +++ b/apps/business/src/pages/LoginPage.module.css @@ -6,7 +6,7 @@ justify-content: center; padding: 24px; position: relative; - gap: 20px; + gap: 16px; } .card { @@ -14,7 +14,7 @@ z-index: 1; width: 100%; max-width: 420px; - padding: 36px 32px 32px; + padding: 24px 32px 28px; background: rgba(255, 255, 255, 0.75); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); @@ -26,40 +26,64 @@ .brand { display: flex; align-items: center; - justify-content: flex-start; + justify-content: center; + gap: 16px; + position: relative; + z-index: 1; + width: 100%; + max-width: 420px; +} + +.cardHeader { + display: flex; + align-items: flex-start; gap: 12px; margin-bottom: 28px; } +.heading { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + gap: 2px; +} + .langSelect { + flex-shrink: 0; margin-inline-start: auto; } .logo { display: block; - width: 48px; - height: 48px; + width: 64px; + height: 64px; object-fit: contain; background: transparent; + flex-shrink: 0; + border-radius: var(--radius-sm); } .brandText { display: flex; flex-direction: column; + text-align: start; } .businessName { - font-size: 15px; - font-weight: 600; + font-size: 20px; + font-weight: 700; color: var(--text-primary); - line-height: 1.2; + line-height: 1.25; } .appName { - font-size: 12px; + font-size: 13px; font-weight: 500; color: var(--text-muted); margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.14em; } .poweredBy { @@ -77,18 +101,20 @@ } .title { - font-size: 24px; + font-size: 20px; font-weight: 700; color: var(--text-primary); - text-align: center; - margin-bottom: 6px; + text-align: start; + margin: 0; + line-height: 1.2; } .subtitle { - font-size: 14px; + font-size: 13px; color: var(--text-secondary); - text-align: center; - margin-bottom: 28px; + text-align: start; + margin: 0; + line-height: 1.3; } .backBtn { @@ -98,7 +124,7 @@ font-size: 13px; font-weight: 500; color: var(--text-secondary); - margin-bottom: 16px; + margin-bottom: 12px; transition: color 0.2s; } diff --git a/apps/business/src/pages/LoginPage.tsx b/apps/business/src/pages/LoginPage.tsx index cae91e3..1844ca2 100644 --- a/apps/business/src/pages/LoginPage.tsx +++ b/apps/business/src/pages/LoginPage.tsx @@ -87,6 +87,10 @@ export function LoginPage() { err.message.includes('Unable to send SMS') ) { setError(t('login.error.smsProvider')) + } else if ( + err.message.includes('Invalid cell number or password') + ) { + setError(t('login.error.invalidCredentials')) } else { setError(err.message) } @@ -198,15 +202,47 @@ export function LoginPage() { } } + const heading = + view === 'login' + ? { title: t('login.welcome'), subtitle: t('login.subtitle') } + : view === 'forgot' + ? { + title: t('forgot.title'), + subtitle: smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode'), + } + : { + title: t('otp.title'), + subtitle: smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode'), + } + return (
    +
    + {logoUrl ? : null} +
    + {businessName || businessDomain} + + {businessNameEn || businessDomain} + +
    +
    -
    - {logoUrl ? : null} -
    - {businessName || businessDomain} - {businessNameEn || businessDomain} + {(view === 'forgot' || view === 'otp') && ( + + )} +
    +
    +

    {heading.title}

    + {heading.subtitle ?

    {heading.subtitle}

    : null}
    @@ -215,9 +251,6 @@ export function LoginPage() { {view === 'login' && ( <> -

    {t('login.welcome')}

    -

    {t('login.subtitle')}

    -
    {error && (
    @@ -305,21 +338,6 @@ export function LoginPage() { {view === 'forgot' && ( <> - - -

    {t('forgot.title')}

    -

    - {smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')} -

    - {error && (
    @@ -407,24 +425,6 @@ export function LoginPage() { {showPassword ? : }
    -
    @@ -442,8 +442,28 @@ export function LoginPage() { required minLength={8} disabled={isSubmitting} + aria-describedby="forgot-password-strength" />
    +
    @@ -466,7 +486,7 @@ export function LoginPage() { @@ -478,21 +498,6 @@ export function LoginPage() { {view === 'otp' && ( <> - - -

    {t('otp.title')}

    -

    - {smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')} -

    - {error && (
    diff --git a/apps/business/src/pages/MyProductsPage.tsx b/apps/business/src/pages/MyProductsPage.tsx index bd5a6b9..bfbde4c 100644 --- a/apps/business/src/pages/MyProductsPage.tsx +++ b/apps/business/src/pages/MyProductsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' -import { Plus, RotateCcw, Search, Sparkles } from 'lucide-react' +import { Package, Plus, RotateCcw, Search, Sparkles } from 'lucide-react' +import { EmptyState } from '../components/EmptyState' import { ProductCard } from '../components/ProductCard' import { AddProductByAiModal } from '../components/AddProductByAiModal' import { Pagination } from '../components/Pagination' @@ -257,9 +258,12 @@ export function MyProductsPage() { {isLoading ? (

    {t('products.list.loading')}

    ) : products.length === 0 ? ( -

    - {appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')} -

    + ) : ( <>
    diff --git a/apps/business/src/pages/OrdersPage.tsx b/apps/business/src/pages/OrdersPage.tsx index 0b6180e..889715e 100644 --- a/apps/business/src/pages/OrdersPage.tsx +++ b/apps/business/src/pages/OrdersPage.tsx @@ -1,9 +1,10 @@ import { useEffect, useMemo, useState } from 'react' -import { CreditCard, Eye, RotateCcw, Search, Trash2 } from 'lucide-react' +import { CreditCard, Eye, RotateCcw, Search, ShoppingBag, Trash2 } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { Breadcrumbs } from '../components/Breadcrumbs' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' +import { EmptyTableRow } from '../components/EmptyState' import { OrderItemsModal } from '../components/OrderItemsModal' import { OrderStepModal } from '../components/OrderStepModal' import { OrderTransactionsModal } from '../components/OrderTransactionsModal' @@ -468,11 +469,11 @@ export function OrdersPage() { )} {!loading && data?.items?.length === 0 && ( - - - {t('orders.empty')} - - + )} {!loading && diff --git a/apps/business/src/pages/PortfolioCategoriesPage.tsx b/apps/business/src/pages/PortfolioCategoriesPage.tsx index aac8fe2..f414ddb 100644 --- a/apps/business/src/pages/PortfolioCategoriesPage.tsx +++ b/apps/business/src/pages/PortfolioCategoriesPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react' -import { Plus } from 'lucide-react' +import { FolderTree, Plus } from 'lucide-react' import { CategoryModal } from '../components/CategoryModal' import { BlogCategoryTree } from '../components/BlogCategoryTree' import { Breadcrumbs } from '../components/Breadcrumbs' +import { EmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import type { Category, CategoryFormData } from '../types/category' @@ -171,12 +172,19 @@ export function PortfolioCategoriesPage() {
    )} -
    - {isLoading ? ( + {isLoading ? ( +

    {t('categories.loading')}

    - ) : categories.length === 0 ? ( -

    {t('portfolio.categories.empty')}

    - ) : ( +
    + ) : categories.length === 0 ? ( + openCreateModal()} + /> + ) : ( +
    - )} -
    +
    + )} {t('portfolio.list.loading')}

    ) : portfolios.length === 0 ? ( -

    {t('portfolio.list.empty')}

    + ) : ( <>
    diff --git a/apps/business/src/pages/ShoppingCardsPage.tsx b/apps/business/src/pages/ShoppingCardsPage.tsx index 9585961..d86685e 100644 --- a/apps/business/src/pages/ShoppingCardsPage.tsx +++ b/apps/business/src/pages/ShoppingCardsPage.tsx @@ -1,10 +1,11 @@ import { useEffect, useMemo, useState } from 'react' -import { Play, RotateCcw, Search, Trash2 } from 'lucide-react' +import { Play, RotateCcw, Search, ShoppingCart, Trash2 } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { useLocale } from '@meshkee/dashboard-ui' import { Breadcrumbs } from '../components/Breadcrumbs' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' +import { EmptyTableRow } from '../components/EmptyState' import { Pagination } from '../components/Pagination' import { Tooltip } from '../components/Tooltip' import { useToast } from '../context/ToastContext' @@ -343,11 +344,11 @@ export function ShoppingCardsPage() { )} {!loading && data?.items?.length === 0 && ( - - - {t('shoppingCards.empty')} - - + )} {!loading && diff --git a/apps/business/src/pages/StoreItemsPage.module.css b/apps/business/src/pages/StoreItemsPage.module.css index 0571160..f406558 100644 --- a/apps/business/src/pages/StoreItemsPage.module.css +++ b/apps/business/src/pages/StoreItemsPage.module.css @@ -9,85 +9,6 @@ text-align: center; font-size: 14px; color: var(--text-secondary); - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: var(--radius); -} - -.emptyState { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - padding: 56px 28px; - text-align: center; - background: var(--glass-bg); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); - border: 1px solid var(--glass-border); - border-radius: var(--radius); - box-shadow: var(--glass-shadow); -} - -.emptyIcon { - width: 56px; - height: 56px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 6px; - border-radius: 16px; - color: var(--primary); - background: linear-gradient( - 145deg, - rgba(255, 255, 255, 0.72) 0%, - rgba(var(--primary-rgb) / 0.12) 100% - ); - border: 1px solid rgba(255, 255, 255, 0.45); - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); -} - -.emptyTitle { - margin: 0; - font-size: 17px; - font-weight: 700; - color: var(--text-primary); - line-height: 1.4; -} - -.emptyHint { - margin: 0 0 8px; - max-width: 360px; - font-size: 13px; - line-height: 1.65; - color: var(--text-secondary); -} - -.emptyCta { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 42px; - padding: 10px 20px; - font-size: 14px; - font-weight: 600; - color: #fff; - text-align: center; - background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); - border-radius: 999px; - box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.32); - transition: transform 0.2s, box-shadow 0.2s; -} - -button.emptyCta { - text-align: center; -} - -.emptyCta:hover { - transform: translateY(-1px); - box-shadow: 0 12px 26px rgba(var(--primary-rgb) / 0.4); } .error { diff --git a/apps/business/src/pages/StoreItemsPage.tsx b/apps/business/src/pages/StoreItemsPage.tsx index 5d22fd3..ac80b0e 100644 --- a/apps/business/src/pages/StoreItemsPage.tsx +++ b/apps/business/src/pages/StoreItemsPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { Download, Loader2, Package, Plus, RotateCcw, Search, Upload } from 'lucide-react' import { Breadcrumbs } from '../components/Breadcrumbs' +import { EmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { CreateStoreItemsModal } from '../components/CreateStoreItemsModal' @@ -482,23 +483,14 @@ function StoreItemsPageContent() { {isLoading ? (

    {t('storeItems.loading')}

    ) : listings.length === 0 ? ( -
    - -

    {t('storeItems.empty')}

    -

    {t('storeItems.emptyHint')}

    - -
    + setCreateOpen(true)} + /> ) : filteredListings.length === 0 ? ( -

    {t('storeItems.emptyFiltered')}

    + ) : ( <>
    diff --git a/apps/business/src/pages/StoreSpecialsPage.tsx b/apps/business/src/pages/StoreSpecialsPage.tsx index 8311546..18ccd07 100644 --- a/apps/business/src/pages/StoreSpecialsPage.tsx +++ b/apps/business/src/pages/StoreSpecialsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' -import { Plus } from 'lucide-react' +import { Package, Plus } from 'lucide-react' import { Breadcrumbs } from '../components/Breadcrumbs' +import { EmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { EditStoreItemsModal } from '../components/EditStoreItemsModal' @@ -318,7 +319,15 @@ export function StoreSpecialsPage() { {isLoading ? (

    {t('website.specialItems.loading')}

    ) : specials.length === 0 ? ( -

    {t('website.specialItems.empty')}

    + { + setError('') + setCreateOpen(true) + }} + /> ) : (
    {specials.map((special) => ( diff --git a/apps/business/src/pages/VideoCategoriesPage.tsx b/apps/business/src/pages/VideoCategoriesPage.tsx index 33364b5..ec3ff5f 100644 --- a/apps/business/src/pages/VideoCategoriesPage.tsx +++ b/apps/business/src/pages/VideoCategoriesPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react' -import { Plus } from 'lucide-react' +import { FolderTree, Plus } from 'lucide-react' import { CategoryModal } from '../components/CategoryModal' import { VideoCategoryTree } from '../components/VideoCategoryTree' import { Breadcrumbs } from '../components/Breadcrumbs' +import { EmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import type { Category, CategoryFormData } from '../types/category' @@ -171,12 +172,19 @@ export function VideoCategoriesPage() {
    )} -
    - {isLoading ? ( + {isLoading ? ( +

    {t('categories.loading')}

    - ) : categories.length === 0 ? ( -

    {t('video.categories.empty')}

    - ) : ( +
    + ) : categories.length === 0 ? ( + openCreateModal()} + /> + ) : ( +
    - )} -
    +
    + )} {t('video.list.loading')}

    ) : videos.length === 0 ? ( -
    - -

    {t('video.list.empty')}

    -

    {t('video.list.emptyHint')}

    - -
    + ) : ( <>
    diff --git a/apps/business/src/pages/WebsiteContactPage.tsx b/apps/business/src/pages/WebsiteContactPage.tsx index 1387caf..6083c16 100644 --- a/apps/business/src/pages/WebsiteContactPage.tsx +++ b/apps/business/src/pages/WebsiteContactPage.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react' -import { RotateCcw, Search } from 'lucide-react' +import { Mail, RotateCcw, Search } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { Breadcrumbs } from '../components/Breadcrumbs' +import { EmptyTableRow } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal' import { Pagination } from '../components/Pagination' @@ -203,11 +204,11 @@ export function WebsiteContactPage() { )} {!loading && data?.items.length === 0 && ( - - - {t('website.contact.empty')} - - + )} {!loading && diff --git a/apps/business/src/pages/WebsiteSpecialBrandsPage.tsx b/apps/business/src/pages/WebsiteSpecialBrandsPage.tsx index 863536c..ceac115 100644 --- a/apps/business/src/pages/WebsiteSpecialBrandsPage.tsx +++ b/apps/business/src/pages/WebsiteSpecialBrandsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' -import { Plus } from 'lucide-react' +import { Award, Plus } from 'lucide-react' import { Breadcrumbs } from '../components/Breadcrumbs' +import { EmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { PickWebsiteBrandsModal } from '../components/PickWebsiteBrandsModal' @@ -215,7 +216,12 @@ export function WebsiteSpecialBrandsPage() { {isLoading ? (

    {t('website.specialBrands.loading')}

    ) : groups.length === 0 ? ( -

    {t('website.specialBrands.empty')}

    + setCreateOpen(true)} + /> ) : (
    {groups.map((group) => ( diff --git a/apps/business/src/pages/WebsiteSpecialCategoriesPage.tsx b/apps/business/src/pages/WebsiteSpecialCategoriesPage.tsx index 806ed2b..ab4d232 100644 --- a/apps/business/src/pages/WebsiteSpecialCategoriesPage.tsx +++ b/apps/business/src/pages/WebsiteSpecialCategoriesPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' -import { Plus } from 'lucide-react' +import { FolderKanban, Plus } from 'lucide-react' import { Breadcrumbs } from '../components/Breadcrumbs' +import { EmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { PickWebsiteCategoriesModal } from '../components/PickWebsiteCategoriesModal' @@ -215,7 +216,12 @@ export function WebsiteSpecialCategoriesPage() { {isLoading ? (

    {t('website.specialCategories.loading')}

    ) : groups.length === 0 ? ( -

    {t('website.specialCategories.empty')}

    + setCreateOpen(true)} + /> ) : (
    {groups.map((group) => ( diff --git a/apps/business/src/pages/WebsiteStaticImagesPage.tsx b/apps/business/src/pages/WebsiteStaticImagesPage.tsx index f9e0348..34f2cdd 100644 --- a/apps/business/src/pages/WebsiteStaticImagesPage.tsx +++ b/apps/business/src/pages/WebsiteStaticImagesPage.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useState } from 'react' -import { Pencil, RefreshCw, Trash2 } from 'lucide-react' +import { ImageOff, Pencil, RefreshCw, Trash2 } from 'lucide-react' import { AddWebsiteSliderSlideModal, type WebsiteSliderSlideFormData, } from '../components/AddWebsiteSliderSlideModal' import { Breadcrumbs } from '../components/Breadcrumbs' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' +import { EmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { Tooltip } from '../components/Tooltip' import { @@ -351,11 +352,16 @@ export function WebsiteStaticImagesPage() { {isLoading ? (

    {t('website.staticImages.loading')}

    ) : visibleSlots.length === 0 ? ( -

    - {slots.length === 0 - ? t('website.staticImages.empty') - : t('website.staticImages.emptyPage')} -

    + void handleRefreshSlots()} + /> ) : ( visibleSlots.map((slot) => (
    diff --git a/apps/business/src/services/authService.ts b/apps/business/src/services/authService.ts index ed56adf..66538ce 100644 --- a/apps/business/src/services/authService.ts +++ b/apps/business/src/services/authService.ts @@ -79,6 +79,15 @@ export async function updateProfile(payload: { }) } +export async function consumeHandoff(ticket: string) { + const data = await apiRequest('/auth/handoff/consume', { + method: 'POST', + body: { ticket }, + }) + setTokens(data.accessToken, data.refreshToken) + return data +} + export function logout() { clearTokens() clearActiveBusiness() diff --git a/apps/customer/src/components/CitySearchSelect.module.css b/apps/customer/src/components/CitySearchSelect.module.css index bf18b57..a3f02cc 100644 --- a/apps/customer/src/components/CitySearchSelect.module.css +++ b/apps/customer/src/components/CitySearchSelect.module.css @@ -81,10 +81,12 @@ } .dropdown { - position: static; - margin-top: 6px; - max-height: none; - overflow: visible; + position: absolute; + top: calc(100% + 6px); + inset-inline: 0; + z-index: 120; + max-height: min(280px, 50vh); + overflow-y: auto; background: var(--elevated-surface); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); diff --git a/apps/customer/src/components/CitySearchSelect.tsx b/apps/customer/src/components/CitySearchSelect.tsx index 96e587b..837d632 100644 --- a/apps/customer/src/components/CitySearchSelect.tsx +++ b/apps/customer/src/components/CitySearchSelect.tsx @@ -14,6 +14,8 @@ interface CitySearchSelectProps { onChange: (value: string) => void disabled?: boolean placeholder?: string + noResultsLabel?: string + clearLabel?: string id?: string } @@ -23,6 +25,8 @@ export function CitySearchSelect({ onChange, disabled = false, placeholder, + noResultsLabel, + clearLabel, id, }: CitySearchSelectProps) { const t = useT() @@ -34,6 +38,8 @@ export function CitySearchSelect({ const selected = options.find((option) => option.id === value) const selectedLabel = selected ? getLocationOptionLabel(selected, locale) : '' const searchPlaceholder = placeholder ?? t('myProducts.fields.searchCity') + const emptyLabel = noResultsLabel ?? t('myProducts.fields.noCities') + const clearAriaLabel = clearLabel ?? t('myProducts.fields.clearCity') const filtered = options.filter((option) => { const q = query.trim().toLowerCase() @@ -108,7 +114,7 @@ export function CitySearchSelect({ type="button" className={styles.clearBtn} onClick={() => onChange('')} - aria-label={t('myProducts.fields.clearCity')} + aria-label={clearAriaLabel} > @@ -123,7 +129,7 @@ export function CitySearchSelect({ {open && !disabled ? (
      {filtered.length === 0 ? ( -
    • {t('myProducts.fields.noCities')}
    • +
    • {emptyLabel}
    • ) : ( filtered.map((option) => (
    • diff --git a/apps/customer/src/components/Header.tsx b/apps/customer/src/components/Header.tsx index b99d56b..5183543 100644 --- a/apps/customer/src/components/Header.tsx +++ b/apps/customer/src/components/Header.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type MouseEvent } from 'react' import { Link, useNavigate } from 'react-router-dom' import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut, LayoutDashboard } from 'lucide-react' import { LanguageSelect, PasswordResetModal, useLocale } from '@meshkee/dashboard-ui' @@ -7,7 +7,7 @@ import { useTenantBranding } from '../context/TenantBrandingContext' import { useT } from '../i18n/useT' import { getActiveBusinessId } from '../lib/businessContext' import { getBusinessDashboardUrl } from '../lib/config' -import { changePassword } from '../services/authService' +import { changePassword, createHandoff } from '../services/authService' import meshkeeLogo from '../assets/meshkee-logo.png' import styles from './Header.module.css' @@ -56,6 +56,7 @@ export function Header({ const t = useT() const [menuOpen, setMenuOpen] = useState(false) const [passwordModalOpen, setPasswordModalOpen] = useState(false) + const [handoffBusy, setHandoffBusy] = useState(false) const menuRef = useRef(null) const displayName = displayUserName(user, locale, t('app.role.customer')) @@ -117,6 +118,38 @@ export function Header({ setPasswordModalOpen(true) } + async function openBusinessDashboard(event: MouseEvent) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return + } + + event.preventDefault() + if (handoffBusy) return + + const baseUrl = getBusinessDashboardUrl() + const popup = window.open('about:blank', '_blank') + setHandoffBusy(true) + try { + const { ticket } = await createHandoff() + const url = new URL(baseUrl) + url.searchParams.set('handoff', ticket) + if (popup) { + popup.opener = null + popup.location.replace(url.toString()) + } else { + window.open(url.toString(), '_blank', 'noopener,noreferrer') + } + } catch { + if (popup) { + popup.location.replace(baseUrl) + } else { + window.open(baseUrl, '_blank', 'noopener,noreferrer') + } + } finally { + setHandoffBusy(false) + } + } + return ( <>
      @@ -171,6 +204,10 @@ export function Header({ rel="noopener noreferrer" aria-label={t('header.openBusinessDashboard')} title={t('header.openBusinessDashboard')} + aria-busy={handoffBusy} + onClick={(event) => { + void openBusinessDashboard(event) + }} > diff --git a/apps/customer/src/components/Sidebar.module.css b/apps/customer/src/components/Sidebar.module.css index e4dd746..b8f116a 100644 --- a/apps/customer/src/components/Sidebar.module.css +++ b/apps/customer/src/components/Sidebar.module.css @@ -29,8 +29,9 @@ height: 40px; object-fit: contain; flex-shrink: 0; - background: transparent; - border-radius: 8px; + background: none; + border-radius: 0; + mix-blend-mode: multiply; } .brandFallback { @@ -49,9 +50,9 @@ } .brandLogoUploaded { - border-radius: 8px; - object-fit: cover; - background: transparent; + border-radius: 0; + object-fit: contain; + background: none; } .brandText { @@ -118,7 +119,6 @@ .navItem.active { background: rgba(var(--primary-rgb) / 0.1); color: var(--primary); - border-color: var(--primary); } .navItem.active:hover { @@ -177,7 +177,6 @@ .subNavActive { background: rgba(var(--primary-rgb) / 0.1); color: var(--primary); - border-color: var(--primary); } .subNavActive:hover { diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts index c832e67..56a5d5e 100644 --- a/apps/customer/src/i18n/messages.ts +++ b/apps/customer/src/i18n/messages.ts @@ -231,6 +231,9 @@ const en = { 'myProducts.fields.location': 'Location', 'myProducts.fields.country': 'Country', 'myProducts.fields.selectCountry': 'Select country', + 'myProducts.fields.searchCountry': 'Search country…', + 'myProducts.fields.clearCountry': 'Clear country', + 'myProducts.fields.noCountries': 'No countries found', 'myProducts.fields.province': 'Province', 'myProducts.fields.selectProvince': 'Select province', 'myProducts.fields.city': 'City', @@ -316,6 +319,7 @@ const en = { 'login.error.sendCode': 'Unable to send verification code.', 'login.error.cellEnglishDigits': 'Please use English digits for the mobile number.', 'login.error.smsProvider': 'SMS service is temporarily unavailable. Please try again later.', + 'login.error.invalidCredentials': 'Invalid mobile number or password.', 'signup.title': 'Create account', 'signup.subtitle': 'Register as a customer of {domain}', @@ -368,7 +372,7 @@ const en = { 'password.rule.length': 'At least 8 characters', 'password.rule.upper': 'One uppercase letter', 'password.rule.number': 'One number', - 'password.rule.special': 'One special character', + 'password.rule.match': 'Passwords match', 'otp.back': 'Back to sign in', 'otp.title': 'One-time login', @@ -620,6 +624,9 @@ const fa: Record = { 'myProducts.fields.location': 'موقعیت', 'myProducts.fields.country': 'کشور', 'myProducts.fields.selectCountry': 'انتخاب کشور', + 'myProducts.fields.searchCountry': 'جستجوی کشور…', + 'myProducts.fields.clearCountry': 'پاک کردن کشور', + 'myProducts.fields.noCountries': 'کشوری پیدا نشد', 'myProducts.fields.province': 'استان', 'myProducts.fields.selectProvince': 'انتخاب استان', 'myProducts.fields.city': 'شهر', @@ -705,6 +712,7 @@ const fa: Record = { 'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.', 'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید', 'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.', + 'login.error.invalidCredentials': 'شماره موبایل یا رمز عبور نادرست است.', 'signup.title': 'ایجاد حساب', 'signup.subtitle': 'به‌عنوان مشتری {domain} ثبت‌نام کنید', @@ -757,7 +765,7 @@ const fa: Record = { 'password.rule.length': 'حداقل ۸ کاراکتر', 'password.rule.upper': 'یک حرف بزرگ انگلیسی', 'password.rule.number': 'یک عدد', - 'password.rule.special': 'یک کاراکتر ویژه', + 'password.rule.match': 'یکسان بودن رمز عبور', 'otp.back': 'بازگشت به ورود', 'otp.title': 'ورود یک‌بارمصرف', diff --git a/apps/customer/src/pages/AddMyProductPage.tsx b/apps/customer/src/pages/AddMyProductPage.tsx index 69d6834..719d885 100644 --- a/apps/customer/src/pages/AddMyProductPage.tsx +++ b/apps/customer/src/pages/AddMyProductPage.tsx @@ -2,8 +2,6 @@ import { useEffect, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { Check, ClipboardList, Images, SlidersHorizontal } from 'lucide-react' import { - getLocationOptionLabel, - useLocale, useToast, type CityOption, } from '@meshkee/dashboard-ui' @@ -94,7 +92,6 @@ function isPriceCurrency( export function AddMyProductPage() { const t = useT() - const { locale } = useLocale() const navigate = useNavigate() const { id } = useParams<{ id?: string }>() const isEdit = Boolean(id) @@ -117,7 +114,6 @@ export function AddMyProductPage() { const [countries, setCountries] = useState([]) const [cities, setCities] = useState([]) - const [countrySlug, setCountrySlug] = useState('') const [countryId, setCountryId] = useState('') const [cityId, setCityId] = useState('') const [deliveryNote, setDeliveryNote] = useState('') @@ -218,7 +214,6 @@ export function AddMyProductPage() { : 'IRT', ) setPriceByExpert(product.priceByExpert === true) - setCountrySlug(product.countrySlug) setCountryId(product.countryId) setCityId(product.cityId) setDeliveryNote(product.deliveryNote ?? '') @@ -280,9 +275,10 @@ export function AddMyProductPage() { return () => controller.abort() }, [categoryId, t]) - async function handleCountryChange(nextSlug: string) { - setCountrySlug(nextSlug) - setCountryId(countries.find((item) => item.slug === nextSlug)?.id ?? '') + async function handleCountryChange(nextCountryId: string) { + setCountryId(nextCountryId) + const country = countries.find((item) => item.id === nextCountryId) + const nextSlug = country?.slug ?? '' setCityId('') setCities([]) if (!nextSlug) return @@ -657,19 +653,16 @@ export function AddMyProductPage() {
      - + placeholder={t('myProducts.fields.searchCountry')} + noResultsLabel={t('myProducts.fields.noCountries')} + clearLabel={t('myProducts.fields.clearCountry')} + />
      @@ -679,7 +672,7 @@ export function AddMyProductPage() { options={cities} value={cityId} onChange={setCityId} - disabled={!countrySlug || loadingLocations} + disabled={!countryId || loadingLocations} placeholder={t('myProducts.fields.searchCity')} />
      diff --git a/apps/customer/src/pages/LoginPage.module.css b/apps/customer/src/pages/LoginPage.module.css index e6e8206..e72241c 100644 --- a/apps/customer/src/pages/LoginPage.module.css +++ b/apps/customer/src/pages/LoginPage.module.css @@ -6,7 +6,7 @@ justify-content: center; padding: 24px; position: relative; - gap: 20px; + gap: 16px; } .card { @@ -14,7 +14,7 @@ z-index: 1; width: 100%; max-width: 420px; - padding: 36px 32px 32px; + padding: 24px 32px 28px; background: var(--glass-bg); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); @@ -26,40 +26,64 @@ .brand { display: flex; align-items: center; - justify-content: flex-start; + justify-content: center; + gap: 16px; + position: relative; + z-index: 1; + width: 100%; + max-width: 420px; +} + +.cardHeader { + display: flex; + align-items: flex-start; gap: 12px; margin-bottom: 28px; } +.heading { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + gap: 2px; +} + .langSelect { + flex-shrink: 0; margin-inline-start: auto; } .logo { display: block; - width: 48px; - height: 48px; + width: 64px; + height: 64px; object-fit: contain; background: transparent; + flex-shrink: 0; + border-radius: var(--radius-sm); } .brandText { display: flex; flex-direction: column; + text-align: start; } .domain { - font-size: 15px; - font-weight: 600; + font-size: 20px; + font-weight: 700; color: var(--text-primary); - line-height: 1.2; + line-height: 1.25; } .appName { - font-size: 12px; + font-size: 13px; font-weight: 500; color: var(--text-muted); margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.14em; } .poweredBy { @@ -77,18 +101,20 @@ } .title { - font-size: 24px; + font-size: 20px; font-weight: 700; color: var(--text-primary); - text-align: center; - margin-bottom: 6px; + text-align: start; + margin: 0; + line-height: 1.2; } .subtitle { - font-size: 14px; + font-size: 13px; color: var(--text-secondary); - text-align: center; - margin-bottom: 28px; + text-align: start; + margin: 0; + line-height: 1.3; } .backBtn { @@ -98,7 +124,7 @@ font-size: 13px; font-weight: 500; color: var(--text-secondary); - margin-bottom: 16px; + margin-bottom: 12px; transition: color 0.2s; } diff --git a/apps/customer/src/pages/LoginPage.tsx b/apps/customer/src/pages/LoginPage.tsx index 66144fd..c3ee5bd 100644 --- a/apps/customer/src/pages/LoginPage.tsx +++ b/apps/customer/src/pages/LoginPage.tsx @@ -140,6 +140,10 @@ export function LoginPage() { err.message.includes('Unable to send SMS') ) { setError(t('login.error.smsProvider')) + } else if ( + err.message.includes('Invalid cell number or password') + ) { + setError(t('login.error.invalidCredentials')) } else { setError(err.message) } @@ -371,15 +375,51 @@ export function LoginPage() { } } + const heading = + view === 'login' + ? { title: t('login.welcome'), subtitle: t('login.subtitle') } + : view === 'signup' + ? { title: t('signup.title'), subtitle: null } + : view === 'forgot' + ? { + title: t('forgot.title'), + subtitle: smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode'), + } + : { + title: t('otp.title'), + subtitle: smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode'), + } + return (
      +
      + +
      + + {businessName || tenantDomain} + + + {businessNameEn || tenantDomain} + +
      +
      -
      - -
      - {businessName || tenantDomain} - {businessNameEn || tenantDomain} + {(view === 'forgot' || view === 'otp') && ( + + )} +
      +
      +

      {heading.title}

      + {heading.subtitle ?

      {heading.subtitle}

      : null}
      @@ -388,9 +428,6 @@ export function LoginPage() { {view === 'login' && ( <> -

      {t('login.welcome')}

      -

      {t('login.subtitle')}

      - {error && (
      @@ -490,8 +527,6 @@ export function LoginPage() { {view === 'signup' && ( <> -

      {t('signup.title')}

      - {error && (
      @@ -578,24 +613,6 @@ export function LoginPage() { {showPassword ? : }
      -
      @@ -613,14 +630,36 @@ export function LoginPage() { required minLength={8} disabled={isSubmitting} + aria-describedby="signup-password-strength signup-password-strength-rules" />
      +
      @@ -642,21 +681,6 @@ export function LoginPage() { {view === 'forgot' && ( <> - - -

      {t('forgot.title')}

      -

      - {smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')} -

      - {error && (
      @@ -746,24 +770,6 @@ export function LoginPage() { {showPassword ? : }
      -
      @@ -781,8 +787,28 @@ export function LoginPage() { required minLength={8} disabled={isSubmitting} + aria-describedby="forgot-password-strength" />
      +
      @@ -803,7 +829,7 @@ export function LoginPage() { @@ -815,21 +841,6 @@ export function LoginPage() { {view === 'otp' && ( <> - - -

      {t('otp.title')}

      -

      - {smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')} -

      - {error && (
      diff --git a/apps/customer/src/services/authService.ts b/apps/customer/src/services/authService.ts index cfcfd1d..e5af56e 100644 --- a/apps/customer/src/services/authService.ts +++ b/apps/customer/src/services/authService.ts @@ -7,6 +7,7 @@ import type { OtpSendResponse, OtpVerifyResponse, RegisterResponse, + HandoffCreateResponse, UserProfile, } from '../types/auth' @@ -105,6 +106,13 @@ export async function resetPassword(cellNumber: string, code: string, newPasswor }) } +export async function createHandoff() { + return apiRequest('/auth/handoff', { + method: 'POST', + auth: true, + }) +} + export function logout() { clearTokens() clearActiveBusiness() diff --git a/apps/customer/src/types/auth.ts b/apps/customer/src/types/auth.ts index 4c6ade7..7ec25b8 100644 --- a/apps/customer/src/types/auth.ts +++ b/apps/customer/src/types/auth.ts @@ -6,5 +6,6 @@ export type { OtpSendResponse, OtpVerifyResponse, RegisterResponse, + HandoffCreateResponse, UserProfile, } from '@meshkee/dashboard-core' diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 9e8e17d..210db83 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -3,7 +3,7 @@ > **For AI agents:** Read this file at the start of a new chat before making changes. > Update this document when a major feature is completed or architecture changes. -Last updated: August 18, 2026 +Last updated: August 19, 2026 --- @@ -236,8 +236,9 @@ Business and customer login pages share the same SMS-backed flows (super-admin s | One-time login | `POST /auth/login-otp` | Code only → tokens + marks cell verified | | Forgot password | `POST /auth/reset-password` | Code + new password | | Verify only | `POST /auth/verify-otp` | Marks verified; no tokens | +| Dashboard SSO | `POST /auth/handoff` + `POST /auth/handoff/consume` | Admin icon on customer dashboard; 60s one-time ticket | -Auth helpers: `apps/*/src/services/authService.ts` (`login`, `loginWithOtp`, `resetPassword`, `sendOtp`, `register`). `AuthContext` exposes `login` + `loginWithOtp`. +Auth helpers: `apps/*/src/services/authService.ts` (`login`, `loginWithOtp`, `resetPassword`, `sendOtp`, `register`, `createHandoff`). `AuthContext` exposes `login` + `loginWithOtp`. Customer header “open business dashboard” uses a one-time SSO ticket so staff do not log in again. --- diff --git a/packages/dashboard-core/src/index.ts b/packages/dashboard-core/src/index.ts index 5b3705f..88f0c6d 100644 --- a/packages/dashboard-core/src/index.ts +++ b/packages/dashboard-core/src/index.ts @@ -9,6 +9,7 @@ export type { OtpSendResponse, OtpVerifyResponse, RegisterResponse, + HandoffCreateResponse, UserProfile, } from './types/auth' diff --git a/packages/dashboard-core/src/types/auth.ts b/packages/dashboard-core/src/types/auth.ts index 20ebd9c..b413c42 100644 --- a/packages/dashboard-core/src/types/auth.ts +++ b/packages/dashboard-core/src/types/auth.ts @@ -65,3 +65,8 @@ export interface OtpVerifyResponse { verified: boolean message: string } + +export interface HandoffCreateResponse { + ticket: string + expiresInSeconds: number +} diff --git a/packages/dashboard-ui/src/components/PasswordResetModal.module.css b/packages/dashboard-ui/src/components/PasswordResetModal.module.css index 711b5f6..43d5ba3 100644 --- a/packages/dashboard-ui/src/components/PasswordResetModal.module.css +++ b/packages/dashboard-ui/src/components/PasswordResetModal.module.css @@ -22,6 +22,7 @@ border: 1px solid var(--glass-border); border-radius: var(--radius); box-shadow: 0 20px 60px rgba(var(--primary-rgb) / 0.16); + font-family: var(--font-ui); } .title { @@ -29,6 +30,7 @@ font-weight: 700; color: var(--text-primary); margin-bottom: 6px; + padding-inline-end: 36px; } .subtitle { @@ -85,90 +87,6 @@ opacity: 0.7; } -.strength { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 6px; -} - -.strengthHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.strengthTitle { - font-size: 11px; - font-weight: 500; - color: var(--text-muted); -} - -.strengthLabel { - font-size: 11px; - font-weight: 600; -} - -.tone_empty { color: var(--text-muted); } -.tone_weak { color: #dc2626; } -.tone_fair { color: #d97706; } -.tone_good { color: #2563eb; } -.tone_strong { color: #16a34a; } - -.strengthTrack { - height: 6px; - border-radius: 999px; - background: rgba(148, 163, 184, 0.28); - overflow: hidden; -} - -.strengthFill { - height: 100%; - border-radius: inherit; - transition: width 0.22s ease, background-color 0.22s ease; -} - -.fill_empty { background: transparent; } -.fill_weak { background: #ef4444; } -.fill_fair { background: #f59e0b; } -.fill_good { background: #3b82f6; } -.fill_strong { background: #22c55e; } - -.rules { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 4px 10px; - margin: 0; - padding: 0; - list-style: none; -} - -.rule { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - color: var(--text-muted); - transition: color 0.15s ease; -} - -.ruleMet { - color: #15803d; -} - -.ruleDot { - width: 6px; - height: 6px; - border-radius: 50%; - background: rgba(148, 163, 184, 0.55); - flex-shrink: 0; -} - -.ruleMet .ruleDot { - background: #22c55e; -} - .error, .success { padding: 10px 12px; @@ -234,7 +152,7 @@ .closeBtn { position: absolute; top: 14px; - right: 14px; + inset-inline-end: 14px; width: 32px; height: 32px; display: flex; diff --git a/packages/dashboard-ui/src/components/PasswordResetModal.tsx b/packages/dashboard-ui/src/components/PasswordResetModal.tsx index d59776b..c111ba4 100644 --- a/packages/dashboard-ui/src/components/PasswordResetModal.tsx +++ b/packages/dashboard-ui/src/components/PasswordResetModal.tsx @@ -1,6 +1,14 @@ -import { useEffect, useState, type FormEvent } from 'react' +import { useContext, useEffect, useState, type FormEvent } from 'react' import { createPortal } from 'react-dom' import { X } from 'lucide-react' +import type { DashboardLocale } from '@meshkee/dashboard-core' +import { LocaleContext } from '../context/LocaleContext' +import { + PasswordStrengthMeter, + getPasswordValidationError, + isPasswordStrong, + type PasswordStrengthLabels, +} from './PasswordStrengthMeter' import styles from './PasswordResetModal.module.css' export type ChangePasswordHandler = ( @@ -18,67 +26,94 @@ export interface PasswordResetModalProps { const ANIMATION_MS = 220 -const PASSWORD_RULES = [ - { - id: 'length', - label: 'At least 8 characters', - test: (value: string) => value.length >= 8, - }, - { - id: 'upper', - label: 'One uppercase letter', - test: (value: string) => /[A-Z]/.test(value), - }, - { - id: 'number', - label: 'One number', - test: (value: string) => /\d/.test(value), - }, - { - id: 'special', - label: 'One special character', - test: (value: string) => /[^A-Za-z0-9]/.test(value), - }, -] as const - -type StrengthTone = 'empty' | 'weak' | 'fair' | 'good' | 'strong' - -function getPasswordChecks(password: string) { - return PASSWORD_RULES.map((rule) => ({ - id: rule.id, - label: rule.label, - met: rule.test(password), - })) +type Copy = { + title: string + subtitle: string + currentPassword: string + newPassword: string + confirmPassword: string + cancel: string + submit: string + saving: string + close: string + mismatch: string + unable: string + mustInclude: (list: string) => string + strength: PasswordStrengthLabels } -function getStrengthMeta(metCount: number, hasInput: boolean): { - tone: StrengthTone - label: string - percent: number -} { - if (!hasInput || metCount === 0) { - return { tone: 'empty', label: 'Enter a password', percent: 0 } - } - - if (metCount === 1) return { tone: 'weak', label: 'Weak', percent: 25 } - if (metCount === 2) return { tone: 'fair', label: 'Fair', percent: 50 } - if (metCount === 3) return { tone: 'good', label: 'Good', percent: 75 } - return { tone: 'strong', label: 'Strong', percent: 100 } -} - -function getPasswordValidationError(password: string): string | null { - const unmet = PASSWORD_RULES.filter((rule) => !rule.test(password)) - if (unmet.length === 0) return null - return `Password must include: ${unmet.map((rule) => rule.label.toLowerCase()).join(', ')}.` +const COPY: Record = { + en: { + title: 'Change password', + subtitle: 'Enter your current password and choose a new one.', + currentPassword: 'Current password', + newPassword: 'New password', + confirmPassword: 'Confirm password', + cancel: 'Cancel', + submit: 'Update password', + saving: 'Saving...', + close: 'Close', + mismatch: 'New password and confirmation do not match.', + unable: 'Unable to change password.', + mustInclude: (list) => `Password must include: ${list}.`, + strength: { + title: 'Password strength', + empty: 'Enter a password', + weak: 'Weak', + fair: 'Fair', + good: 'Good', + strong: 'Strong', + rules: { + length: 'At least 8 characters', + upper: 'One uppercase letter', + number: 'One number', + match: 'Passwords match', + }, + }, + }, + fa: { + title: 'تغییر رمز عبور', + subtitle: 'رمز عبور فعلی را وارد کنید و رمز جدیدی انتخاب کنید.', + currentPassword: 'رمز عبور فعلی', + newPassword: 'رمز عبور جدید', + confirmPassword: 'تأیید رمز عبور', + cancel: 'انصراف', + submit: 'به‌روزرسانی رمز عبور', + saving: 'در حال ذخیره...', + close: 'بستن', + mismatch: 'رمزهای عبور یکسان نیستند.', + unable: 'تغییر رمز عبور ممکن نشد.', + mustInclude: (list) => `رمز عبور باید شامل این موارد باشد: ${list}.`, + strength: { + title: 'قدرت رمز عبور', + empty: 'رمز عبور را وارد کنید', + weak: 'ضعیف', + fair: 'متوسط', + good: 'خوب', + strong: 'قوی', + rules: { + length: 'حداقل ۸ کاراکتر', + upper: 'یک حرف بزرگ انگلیسی', + number: 'یک عدد', + match: 'یکسان بودن رمز عبور', + }, + }, + }, } export function PasswordResetModal({ open, onClose, onChangePassword, - title = 'Change password', - subtitle = 'Enter your current password and choose a new one.', + title, + subtitle, }: PasswordResetModalProps) { + const locale = useContext(LocaleContext)?.locale ?? 'en' + const copy = COPY[locale] + const dir = locale === 'fa' ? 'rtl' : 'ltr' + const heading = title ?? copy.title + const description = subtitle ?? copy.subtitle + const [mounted, setMounted] = useState(open) const [closing, setClosing] = useState(false) const [currentPassword, setCurrentPassword] = useState('') @@ -88,10 +123,8 @@ export function PasswordResetModal({ const [success, setSuccess] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) - const checks = getPasswordChecks(newPassword) - const metCount = checks.filter((check) => check.met).length - const strength = getStrengthMeta(metCount, newPassword.length > 0) - const passwordValid = metCount === PASSWORD_RULES.length + const passwordValid = + isPasswordStrong(newPassword) && confirmPassword.length > 0 && newPassword === confirmPassword useEffect(() => { if (open) { @@ -126,14 +159,14 @@ export function PasswordResetModal({ setError('') setSuccess('') - const validationError = getPasswordValidationError(newPassword) - if (validationError) { - setError(validationError) + const unmet = getPasswordValidationError(newPassword, copy.strength) + if (unmet) { + setError(copy.mustInclude(unmet)) return } if (newPassword !== confirmPassword) { - setError('New password and confirmation do not match.') + setError(copy.mismatch) return } @@ -146,7 +179,7 @@ export function PasswordResetModal({ setNewPassword('') setConfirmPassword('') } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to change password.') + setError(err instanceof Error ? err.message : copy.unable) } finally { setIsSubmitting(false) } @@ -165,15 +198,17 @@ export function PasswordResetModal({ role="dialog" aria-modal="true" aria-labelledby="password-reset-title" + lang={locale} + dir={dir} > -

      - {title} + {heading}

      -

      {subtitle}

      +

      {description}

      void handleSubmit(e)} autoComplete="off"> {error && ( @@ -188,7 +223,7 @@ export function PasswordResetModal({ )}
      - +
      - + - -
      -
      - Password strength - - {strength.label} - -
      -
      -
      -
      -
        - {checks.map((check) => ( -
      • -
      • - ))} -
      -
      - + + +
      diff --git a/packages/dashboard-ui/src/components/PasswordStrengthMeter.tsx b/packages/dashboard-ui/src/components/PasswordStrengthMeter.tsx index c0c2581..7c14fb9 100644 --- a/packages/dashboard-ui/src/components/PasswordStrengthMeter.tsx +++ b/packages/dashboard-ui/src/components/PasswordStrengthMeter.tsx @@ -13,13 +13,9 @@ const PASSWORD_RULES = [ id: 'number', test: (value: string) => /\d/.test(value), }, - { - id: 'special', - test: (value: string) => /[^A-Za-z0-9]/.test(value), - }, ] as const -export type PasswordRuleId = (typeof PASSWORD_RULES)[number]['id'] +export type PasswordRuleId = (typeof PASSWORD_RULES)[number]['id'] | 'match' export type StrengthTone = 'empty' | 'weak' | 'fair' | 'good' | 'strong' export type PasswordStrengthLabels = { @@ -43,16 +39,31 @@ const DEFAULT_LABELS: PasswordStrengthLabels = { length: 'At least 8 characters', upper: 'One uppercase letter', number: 'One number', - special: 'One special character', + match: 'Passwords match', }, } -export function getPasswordChecks(password: string, labels: PasswordStrengthLabels = DEFAULT_LABELS) { - return PASSWORD_RULES.map((rule) => ({ - id: rule.id, - label: labels.rules[rule.id], - met: rule.test(password), - })) +function passwordsMatch(password: string, confirmPassword: string) { + return confirmPassword.length > 0 && password === confirmPassword +} + +export function getPasswordChecks( + password: string, + labels: PasswordStrengthLabels = DEFAULT_LABELS, + confirmPassword = '', +) { + return [ + ...PASSWORD_RULES.map((rule) => ({ + id: rule.id, + label: labels.rules[rule.id], + met: rule.test(password), + })), + { + id: 'match' as const, + label: labels.rules.match, + met: passwordsMatch(password, confirmPassword), + }, + ] } export function getStrengthMeta( @@ -88,16 +99,18 @@ export function getPasswordValidationError( export interface PasswordStrengthMeterProps { password: string + confirmPassword?: string labels?: PasswordStrengthLabels id?: string } export function PasswordStrengthMeter({ password, + confirmPassword = '', labels = DEFAULT_LABELS, id = 'password-strength', }: PasswordStrengthMeterProps) { - const checks = getPasswordChecks(password, labels) + const checks = getPasswordChecks(password, labels, confirmPassword) const metCount = checks.filter((check) => check.met).length const strength = getStrengthMeta(metCount, password.length > 0, labels)