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')}
+
+
+
{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')}
-
@@ -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')}
-
-