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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
832ac32a09
commit
f8a9a28346
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -123,7 +129,7 @@ export function CitySearchSelect({
|
||||
{open && !disabled ? (
|
||||
<ul className={styles.dropdown} role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className={styles.noResults}>{t('customerProducts.form.fields.noCities')}</li>
|
||||
<li className={styles.noResults}>{emptyLabel}</li>
|
||||
) : (
|
||||
filtered.map((option) => (
|
||||
<li key={option.id}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={styles.root} role="status">
|
||||
<Icon className={styles.icon} size={36} strokeWidth={1.5} aria-hidden />
|
||||
<p className={styles.message}>{message}</p>
|
||||
{showAction && actionLabel ? (
|
||||
actionTo ? (
|
||||
<Link to={actionTo} className={styles.action} onClick={onAction}>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" className={styles.action} onClick={onAction}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyTableRow({
|
||||
colSpan,
|
||||
...props
|
||||
}: EmptyStateProps & { colSpan: number }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className={styles.cell}>
|
||||
<EmptyState {...props} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<EmptyState
|
||||
icon={Images}
|
||||
message={t('website.sliders.empty')}
|
||||
actionLabel={showAdd ? addText : undefined}
|
||||
onAction={showAdd ? onAddSlide : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function scrollBy(direction: -1 | 1) {
|
||||
const track = trackRef.current
|
||||
if (!track) return
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
|
||||
'categories.page.subtitle': 'دستهبندیها و زیردستههای محصولات خود را مدیریت کنید.',
|
||||
'categories.loading': 'در حال بارگذاری دستهبندیها...',
|
||||
'categories.empty': 'هنوز دستهبندیای نیست. با + یا هوش مصنوعی اضافه کنید.',
|
||||
'categories.empty': 'هنوز دستهبندیای ثبت نکردهاید.',
|
||||
'categories.add': 'افزودن دستهبندی',
|
||||
'categories.addSub': 'افزودن زیردسته',
|
||||
'categories.edit': 'ویرایش دستهبندی',
|
||||
@@ -2613,7 +2645,7 @@ const fa: Record<MessageKey, string> = {
|
||||
|
||||
'brands.page.subtitle': 'برندهای محصول فروشگاه خود را مدیریت کنید.',
|
||||
'brands.loading': 'در حال بارگذاری برندها...',
|
||||
'brands.empty': 'هنوز برندی نیست. برای افزودن روی + کلیک کنید.',
|
||||
'brands.empty': 'هنوز برندی ثبت نکردهاید.',
|
||||
'brands.add': 'افزودن برند',
|
||||
'brands.edit': 'ویرایش برند',
|
||||
'brands.deleteTitle': 'حذف برند',
|
||||
@@ -2685,7 +2717,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
|
||||
'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<MessageKey, string> = {
|
||||
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید',
|
||||
'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.',
|
||||
'login.error.access': 'به این پنل کسبوکار دسترسی ندارید.',
|
||||
'login.error.invalidCredentials': 'شماره موبایل یا رمز عبور نادرست است.',
|
||||
|
||||
'signup.title': 'ایجاد حساب',
|
||||
'signup.subtitle': 'ثبتنام برای {domain}',
|
||||
@@ -3942,7 +3974,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'password.rule.length': 'حداقل ۸ کاراکتر',
|
||||
'password.rule.upper': 'یک حرف بزرگ انگلیسی',
|
||||
'password.rule.number': 'یک عدد',
|
||||
'password.rule.special': 'یک کاراکتر ویژه',
|
||||
'password.rule.match': 'یکسان بودن رمز عبور',
|
||||
|
||||
'otp.back': 'بازگشت به ورود',
|
||||
'otp.title': 'ورود یکبارمصرف',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { consumeHandoff } from '../services/authService'
|
||||
import type { LoginResponse } from '../types/auth'
|
||||
|
||||
let inFlight: Promise<LoginResponse | null> | 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<LoginResponse | null> {
|
||||
if (!inFlight) {
|
||||
inFlight = (async () => {
|
||||
const ticket = takeHandoffTicketFromUrl()
|
||||
if (!ticket) return null
|
||||
try {
|
||||
return await consumeHandoff(ticket)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
}
|
||||
return inFlight
|
||||
}
|
||||
@@ -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<CityOption[]>([])
|
||||
const [cities, setCities] = useState<CityOption[]>([])
|
||||
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() {
|
||||
<div className={styles.locationRow}>
|
||||
<div className={`${styles.field} ${styles.col2}`}>
|
||||
<label htmlFor="add-country">{t('customerProducts.form.fields.country')}</label>
|
||||
<select
|
||||
<CitySearchSelect
|
||||
id="add-country"
|
||||
value={countrySlug}
|
||||
options={countries}
|
||||
value={countryId}
|
||||
onChange={(nextId) => void handleCountryChange(nextId)}
|
||||
disabled={loadingLocations}
|
||||
onChange={(e) => void handleCountryChange(e.target.value)}
|
||||
>
|
||||
<option value="">{t('customerProducts.form.fields.selectCountry')}</option>
|
||||
{countries.map((country) => (
|
||||
<option key={country.id} value={country.slug}>
|
||||
{getLocationOptionLabel(country, locale)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
placeholder={t('customerProducts.form.fields.searchCountry')}
|
||||
noResultsLabel={t('customerProducts.form.fields.noCountries')}
|
||||
clearLabel={t('customerProducts.form.fields.clearCountry')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
@@ -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')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
{isLoading ? (
|
||||
<div className={styles.list}>
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={styles.empty}>{t('blog.categories.empty')}</p>
|
||||
) : (
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderTree}
|
||||
message={t('blog.categories.empty')}
|
||||
actionLabel={t('categories.add')}
|
||||
onAction={() => openCreateModal()}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<BlogCategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
@@ -191,8 +199,8 @@ export function BlogCategoriesPage() {
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CategoryModal
|
||||
open={modalOpen}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FileText, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { BlogCard } from '../components/BlogCard'
|
||||
import { BlogCommentsModal } from '../components/BlogCommentsModal'
|
||||
@@ -181,21 +182,12 @@ export function BlogListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('blog.list.loading')}</p>
|
||||
) : blogs.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon} aria-hidden="true">
|
||||
<FileText size={28} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h3 className={styles.emptyTitle}>{t('blog.list.empty')}</h3>
|
||||
<p className={styles.emptyHint}>{t('blog.list.emptyHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.emptyCta}
|
||||
onClick={() => navigate('/blog/new')}
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.25} />
|
||||
{t('blog.list.emptyCta')}
|
||||
</button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
message={t('blog.list.empty')}
|
||||
actionLabel={t('blog.list.emptyCta')}
|
||||
actionTo="/blog/new"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.grid}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
{isLoading ? (
|
||||
<div className={styles.list}>
|
||||
<p className={styles.empty}>{t('brands.loading')}</p>
|
||||
) : brands.length === 0 ? (
|
||||
<p className={styles.empty}>{t('brands.empty')}</p>
|
||||
) : (
|
||||
brands.map((brand) => (
|
||||
</div>
|
||||
) : brands.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Award}
|
||||
message={t('brands.empty')}
|
||||
actionLabel={t('brands.add')}
|
||||
onAction={openCreateModal}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
{brands.map((brand) => (
|
||||
<BrandRow
|
||||
key={brand.id}
|
||||
brand={brand}
|
||||
@@ -173,9 +181,9 @@ export function BrandsPage() {
|
||||
if (item) setDeleteTarget(item)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BrandModal
|
||||
open={modalOpen}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Sparkles } from 'lucide-react'
|
||||
import { FolderTree, Plus, Sparkles } from 'lucide-react'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { CategoryModal } from '../components/CategoryModal'
|
||||
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
|
||||
import { CategoryTree } from '../components/CategoryTree'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { VariationsModal } from '../components/VariationsModal'
|
||||
@@ -439,12 +440,19 @@ export function CategoriesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
{isLoading ? (
|
||||
<div className={styles.list}>
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={styles.empty}>{t('categories.empty')}</p>
|
||||
) : (
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderTree}
|
||||
message={t('categories.empty')}
|
||||
actionLabel={t('categories.add')}
|
||||
onAction={() => openCreateModal()}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<CategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
@@ -463,8 +471,8 @@ export function CategoriesPage() {
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CategoryModal
|
||||
open={modalOpen}
|
||||
|
||||
@@ -1,28 +1,3 @@
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.emptyLink {
|
||||
margin-top: 4px;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.emptyLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status,
|
||||
.error {
|
||||
margin: 16px 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Package, Plus } from 'lucide-react'
|
||||
import { Package, Plus, RotateCcw, Search } from 'lucide-react'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { Pagination } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
@@ -14,28 +15,59 @@ import {
|
||||
listUserProducts,
|
||||
promoteUserProduct,
|
||||
updateUserProductStatus,
|
||||
type UserProductsListResponse,
|
||||
} from '../services/userProductsService'
|
||||
import type { UserProductStatus } from '../types/userProduct'
|
||||
import { parseIrtInput, formatIrtInput } from '../utils/irtPrice'
|
||||
import filterStyles from '../components/ListFiltersPanel.module.css'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CustomerProductsPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
const FETCH_PAGE_SIZE = 100
|
||||
const MAX_FETCH_PAGES = 25
|
||||
|
||||
type StatusFilter = 'all' | UserProductStatus
|
||||
|
||||
type AppliedFilters = {
|
||||
status: StatusFilter
|
||||
name: string
|
||||
owner: string
|
||||
minPrice: number | null
|
||||
maxPrice: number | null
|
||||
}
|
||||
|
||||
export function CustomerProductsPage() {
|
||||
const t = useT()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [data, setData] = useState<UserProductsListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [allProducts, setAllProducts] = useState<
|
||||
Awaited<ReturnType<typeof listUserProducts>>['items']
|
||||
>([])
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [busyAction, setBusyAction] = useState<
|
||||
'remove' | 'promote' | 'status' | null
|
||||
>(null)
|
||||
const [statusProductId, setStatusProductId] = useState<string | null>(null)
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({
|
||||
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<StatusFilter>('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 (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
@@ -177,11 +285,119 @@ export function CustomerProductsPage() {
|
||||
<div>
|
||||
<PageTitle en="CUSTOMER PRODUCTS">{t('customerProducts.title')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{t('customerProducts.subtitle', { count: total })}
|
||||
{t('customerProducts.subtitle', { count: totalFiltered })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={filterStyles.filtersPanel}>
|
||||
<div className={filterStyles.filtersTitle}>{t('customerProducts.filters.search')}</div>
|
||||
<form
|
||||
className={filterStyles.filtersGrid}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
applyFilters()
|
||||
}}
|
||||
>
|
||||
<div className={filterStyles.filtersInputs}>
|
||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
|
||||
<label htmlFor="filter-customer-products-name">{t('customerProducts.filters.name')}</label>
|
||||
<input
|
||||
id="filter-customer-products-name"
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
placeholder={t('customerProducts.filters.namePlaceholder')}
|
||||
aria-label={t('customerProducts.filters.name')}
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
|
||||
<label htmlFor="filter-customer-products-owner">{t('customerProducts.filters.owner')}</label>
|
||||
<input
|
||||
id="filter-customer-products-owner"
|
||||
value={draftOwner}
|
||||
onChange={(e) => setDraftOwner(e.target.value)}
|
||||
placeholder={t('customerProducts.filters.ownerPlaceholder')}
|
||||
aria-label={t('customerProducts.filters.owner')}
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol2}`}>
|
||||
<label htmlFor="filter-customer-products-min">{t('customerProducts.filters.priceMin')}</label>
|
||||
<input
|
||||
id="filter-customer-products-min"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draftMinPrice}
|
||||
onChange={(e) => setDraftMinPrice(formatIrtInput(e.target.value))}
|
||||
placeholder={t('customerProducts.filters.priceMinPlaceholder')}
|
||||
aria-label={t('customerProducts.filters.priceMin')}
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol2}`}>
|
||||
<label htmlFor="filter-customer-products-max">{t('customerProducts.filters.priceMax')}</label>
|
||||
<input
|
||||
id="filter-customer-products-max"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draftMaxPrice}
|
||||
onChange={(e) => setDraftMaxPrice(formatIrtInput(e.target.value))}
|
||||
placeholder={t('customerProducts.filters.priceMaxPlaceholder')}
|
||||
aria-label={t('customerProducts.filters.priceMax')}
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${filterStyles.field} ${filterStyles.fieldCol2}`}>
|
||||
<label htmlFor="filter-customer-products-status">{t('customerProducts.filters.status')}</label>
|
||||
<select
|
||||
id="filter-customer-products-status"
|
||||
value={draftStatus}
|
||||
onChange={(e) => setDraftStatus(e.target.value as StatusFilter)}
|
||||
aria-label={t('customerProducts.filters.status')}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="all">{t('customerProducts.filters.status.all')}</option>
|
||||
<option value="draft">{t('customerProducts.status.pending')}</option>
|
||||
<option value="published">{t('customerProducts.status.published')}</option>
|
||||
<option value="rejected">{t('customerProducts.status.rejected')}</option>
|
||||
<option value="archived">{t('customerProducts.status.archived')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={filterStyles.filterActions}>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
disabled={loading}
|
||||
aria-label={t('customerProducts.filters.search')}
|
||||
title={t('customerProducts.filters.search')}
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={loading}
|
||||
aria-label={t('customerProducts.filters.clear')}
|
||||
title={t('customerProducts.filters.clear')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
@@ -190,14 +406,13 @@ export function CustomerProductsPage() {
|
||||
|
||||
{loading ? <p className={styles.status}>{t('customerProducts.loading')}</p> : null}
|
||||
|
||||
{!loading && !error && products.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Package size={32} />
|
||||
<p>{t('customerProducts.empty')}</p>
|
||||
<Link to="/customer-products/new" className={styles.emptyLink}>
|
||||
{t('customerProducts.add')}
|
||||
</Link>
|
||||
</div>
|
||||
{!loading && !error && filteredProducts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
message={filtersActive ? t('products.list.emptyFiltered') : t('customerProducts.empty')}
|
||||
actionLabel={filtersActive ? undefined : t('customerProducts.add')}
|
||||
actionTo={filtersActive ? undefined : '/customer-products/new'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && products.length > 0 ? (
|
||||
@@ -220,7 +435,10 @@ export function CustomerProductsPage() {
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
onPageChange={(next) => {
|
||||
setPage(next)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -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 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
{t('customers.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
<EmptyTableRow
|
||||
colSpan={COLUMN_COUNT}
|
||||
icon={Users}
|
||||
message={t('customers.empty')}
|
||||
actionLabel={t('customers.add')}
|
||||
onAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
{isLoading ? (
|
||||
<div className={styles.list}>
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={styles.empty}>{t('instruction.categories.empty')}</p>
|
||||
) : (
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderTree}
|
||||
message={t('instruction.categories.empty')}
|
||||
actionLabel={t('categories.add')}
|
||||
onAction={() => openCreateModal()}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<InstructionCategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
@@ -191,8 +199,8 @@ export function InstructionCategoriesPage() {
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CategoryModal
|
||||
open={modalOpen}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Play, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { InstructionCard } from '../components/InstructionCard'
|
||||
import { InstructionCommentsModal } from '../components/InstructionCommentsModal'
|
||||
@@ -158,21 +159,12 @@ export function InstructionListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('instruction.list.loading')}</p>
|
||||
) : videos.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon} aria-hidden="true">
|
||||
<Play size={28} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h3 className={styles.emptyTitle}>{t('instruction.list.empty')}</h3>
|
||||
<p className={styles.emptyHint}>{t('instruction.list.emptyHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.emptyCta}
|
||||
onClick={() => navigate('/instructions/new')}
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.25} />
|
||||
{t('instruction.list.emptyCta')}
|
||||
</button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Play}
|
||||
message={t('instruction.list.empty')}
|
||||
actionLabel={t('instruction.list.emptyCta')}
|
||||
actionTo="/instructions/new"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.grid}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && invoiceTemplates.length === 0 ? (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={5}>
|
||||
<div className={styles.emptyState}>
|
||||
<SettingsIcon size={20} />
|
||||
<span>{t('invoiceTemplates.emptyTemplates')}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<EmptyTableRow
|
||||
colSpan={5}
|
||||
icon={FileStack}
|
||||
message={t('invoiceTemplates.emptyTemplates')}
|
||||
actionLabel={t('invoiceTemplates.addTemplate')}
|
||||
actionTo="/invoices/templates/new"
|
||||
/>
|
||||
) : null}
|
||||
{invoiceTemplates.map((template) => (
|
||||
<tr key={template.id}>
|
||||
@@ -364,14 +364,13 @@ export function InvoiceTemplatesPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && itemTemplates.length === 0 ? (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={6}>
|
||||
<div className={styles.emptyState}>
|
||||
<SettingsIcon size={20} />
|
||||
<span>{t('invoiceTemplates.emptyItems')}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<EmptyTableRow
|
||||
colSpan={6}
|
||||
icon={FileText}
|
||||
message={t('invoiceTemplates.emptyItems')}
|
||||
actionLabel={t('invoiceTemplates.addItem')}
|
||||
onAction={openCreateItem}
|
||||
/>
|
||||
) : null}
|
||||
{itemTemplates.map((template) => (
|
||||
<tr key={template.id}>
|
||||
|
||||
@@ -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() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading && invoices.length === 0 ? (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={7}>
|
||||
{t('invoices.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
<EmptyTableRow
|
||||
colSpan={7}
|
||||
icon={FileText}
|
||||
message={t('invoices.empty')}
|
||||
actionLabel={t('invoices.issue')}
|
||||
actionTo={issueHref}
|
||||
/>
|
||||
) : null}
|
||||
{invoices.map((invoice) => {
|
||||
const itemCount = invoice.items?.length ?? 0
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.page}>
|
||||
<BackgroundOrbs />
|
||||
<div className={styles.brand}>
|
||||
{logoUrl ? <img src={logoUrl} alt="" className={styles.logo} /> : null}
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.businessName}>{businessName || businessDomain}</span>
|
||||
<span className={styles.appName} lang="en" dir="ltr">
|
||||
{businessNameEn || businessDomain}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.brand}>
|
||||
{logoUrl ? <img src={logoUrl} alt="" className={styles.logo} /> : null}
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.businessName}>{businessName || businessDomain}</span>
|
||||
<span className={styles.appName}>{businessNameEn || businessDomain}</span>
|
||||
{(view === 'forgot' || view === 'otp') && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{view === 'forgot' ? t('forgot.back') : t('otp.back')}
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={styles.heading}>
|
||||
<h1 className={styles.title}>{heading.title}</h1>
|
||||
{heading.subtitle ? <p className={styles.subtitle}>{heading.subtitle}</p> : null}
|
||||
</div>
|
||||
<div className={styles.langSelect}>
|
||||
<LanguageSelect />
|
||||
@@ -215,9 +251,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'login' && (
|
||||
<>
|
||||
<h1 className={styles.title}>{t('login.welcome')}</h1>
|
||||
<p className={styles.subtitle}>{t('login.subtitle')}</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
@@ -305,21 +338,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'forgot' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{t('forgot.back')}
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>{t('forgot.title')}</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleResetPassword}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
@@ -407,24 +425,6 @@ export function LoginPage() {
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={newPassword}
|
||||
id="forgot-password-strength"
|
||||
labels={{
|
||||
title: t('password.strength'),
|
||||
empty: t('password.strength.empty'),
|
||||
weak: t('password.strength.weak'),
|
||||
fair: t('password.strength.fair'),
|
||||
good: t('password.strength.good'),
|
||||
strong: t('password.strength.strong'),
|
||||
rules: {
|
||||
length: t('password.rule.length'),
|
||||
upper: t('password.rule.upper'),
|
||||
number: t('password.rule.number'),
|
||||
special: t('password.rule.special'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
@@ -442,8 +442,28 @@ export function LoginPage() {
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
aria-describedby="forgot-password-strength"
|
||||
/>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={newPassword}
|
||||
confirmPassword={confirmNewPassword}
|
||||
id="forgot-password-strength"
|
||||
labels={{
|
||||
title: t('password.strength'),
|
||||
empty: t('password.strength.empty'),
|
||||
weak: t('password.strength.weak'),
|
||||
fair: t('password.strength.fair'),
|
||||
good: t('password.strength.good'),
|
||||
strong: t('password.strength.strong'),
|
||||
rules: {
|
||||
length: t('password.rule.length'),
|
||||
upper: t('password.rule.upper'),
|
||||
number: t('password.rule.number'),
|
||||
match: t('password.rule.match'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.resendRow}>
|
||||
@@ -466,7 +486,7 @@ export function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !isPasswordStrong(newPassword)}
|
||||
disabled={isSubmitting || !isPasswordStrong(newPassword) || newPassword !== confirmNewPassword}
|
||||
>
|
||||
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
|
||||
</button>
|
||||
@@ -478,21 +498,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'otp' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{t('otp.back')}
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>{t('otp.title')}</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleOtpLogin}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
|
||||
@@ -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 ? (
|
||||
<p className={styles.empty}>{t('products.list.loading')}</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className={styles.empty}>
|
||||
{appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')}
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
message={appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')}
|
||||
actionLabel={appliedName ? undefined : t('products.list.emptyCta')}
|
||||
actionTo={appliedName ? undefined : '/products/new'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
|
||||
@@ -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 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
{t('orders.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
<EmptyTableRow
|
||||
colSpan={COLUMN_COUNT}
|
||||
icon={ShoppingBag}
|
||||
message={t('orders.empty')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
{isLoading ? (
|
||||
<div className={styles.list}>
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={styles.empty}>{t('portfolio.categories.empty')}</p>
|
||||
) : (
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderTree}
|
||||
message={t('portfolio.categories.empty')}
|
||||
actionLabel={t('categories.add')}
|
||||
onAction={() => openCreateModal()}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<BlogCategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
@@ -191,8 +199,8 @@ export function PortfolioCategoriesPage() {
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CategoryModal
|
||||
open={modalOpen}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Briefcase, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { PortfolioCard } from '../components/PortfolioCard'
|
||||
import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal'
|
||||
@@ -189,7 +190,12 @@ export function PortfolioListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('portfolio.list.loading')}</p>
|
||||
) : portfolios.length === 0 ? (
|
||||
<p className={styles.empty}>{t('portfolio.list.empty')}</p>
|
||||
<EmptyState
|
||||
icon={Briefcase}
|
||||
message={t('portfolio.list.empty')}
|
||||
actionLabel={t('portfolio.list.emptyCta')}
|
||||
actionTo="/portfolios/new"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.gridCols4}>
|
||||
|
||||
@@ -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 && (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={COLUMN_COUNT}>
|
||||
{t('shoppingCards.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
<EmptyTableRow
|
||||
colSpan={COLUMN_COUNT}
|
||||
icon={ShoppingCart}
|
||||
message={t('shoppingCards.empty')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
<p className={styles.empty}>{t('storeItems.loading')}</p>
|
||||
) : listings.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon} aria-hidden="true">
|
||||
<Package size={28} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h3 className={styles.emptyTitle}>{t('storeItems.empty')}</h3>
|
||||
<p className={styles.emptyHint}>{t('storeItems.emptyHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.emptyCta}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.25} />
|
||||
{t('storeItems.add')}
|
||||
</button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
message={t('storeItems.empty')}
|
||||
actionLabel={t('storeItems.add')}
|
||||
onAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
) : filteredListings.length === 0 ? (
|
||||
<p className={styles.empty}>{t('storeItems.emptyFiltered')}</p>
|
||||
<EmptyState icon={Search} message={t('storeItems.emptyFiltered')} />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
|
||||
@@ -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 ? (
|
||||
<p className={styles.empty}>{t('website.specialItems.loading')}</p>
|
||||
) : specials.length === 0 ? (
|
||||
<p className={styles.empty}>{t('website.specialItems.empty')}</p>
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
message={t('website.specialItems.empty')}
|
||||
actionLabel={t('website.specialItems.addFab')}
|
||||
onAction={() => {
|
||||
setError('')
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.carousels}>
|
||||
{specials.map((special) => (
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
{isLoading ? (
|
||||
<div className={styles.list}>
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={styles.empty}>{t('video.categories.empty')}</p>
|
||||
) : (
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderTree}
|
||||
message={t('video.categories.empty')}
|
||||
actionLabel={t('categories.add')}
|
||||
onAction={() => openCreateModal()}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<VideoCategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
@@ -191,8 +199,8 @@ export function VideoCategoriesPage() {
|
||||
if (category) setDeleteTarget(category)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CategoryModal
|
||||
open={modalOpen}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Play, Plus } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { VideoCard } from '../components/VideoCard'
|
||||
import { VideoCommentsModal } from '../components/VideoCommentsModal'
|
||||
@@ -158,21 +159,12 @@ export function VideoListPage() {
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>{t('video.list.loading')}</p>
|
||||
) : videos.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon} aria-hidden="true">
|
||||
<Play size={28} strokeWidth={1.75} />
|
||||
</div>
|
||||
<h3 className={styles.emptyTitle}>{t('video.list.empty')}</h3>
|
||||
<p className={styles.emptyHint}>{t('video.list.emptyHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.emptyCta}
|
||||
onClick={() => navigate('/videos/new')}
|
||||
>
|
||||
<Plus size={18} strokeWidth={2.25} />
|
||||
{t('video.list.emptyCta')}
|
||||
</button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Play}
|
||||
message={t('video.list.empty')}
|
||||
actionLabel={t('video.list.emptyCta')}
|
||||
actionTo="/videos/new"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.grid}>
|
||||
|
||||
@@ -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 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
{t('website.contact.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
<EmptyTableRow
|
||||
colSpan={COLUMN_COUNT}
|
||||
icon={Mail}
|
||||
message={t('website.contact.empty')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
|
||||
@@ -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 ? (
|
||||
<p className={styles.empty}>{t('website.specialBrands.loading')}</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className={styles.empty}>{t('website.specialBrands.empty')}</p>
|
||||
<EmptyState
|
||||
icon={Award}
|
||||
message={t('website.specialBrands.empty')}
|
||||
actionLabel={t('website.specialBrands.addFab')}
|
||||
onAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.carousels}>
|
||||
{groups.map((group) => (
|
||||
|
||||
@@ -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 ? (
|
||||
<p className={styles.empty}>{t('website.specialCategories.loading')}</p>
|
||||
) : groups.length === 0 ? (
|
||||
<p className={styles.empty}>{t('website.specialCategories.empty')}</p>
|
||||
<EmptyState
|
||||
icon={FolderKanban}
|
||||
message={t('website.specialCategories.empty')}
|
||||
actionLabel={t('website.specialCategories.addFab')}
|
||||
onAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.carousels}>
|
||||
{groups.map((group) => (
|
||||
|
||||
@@ -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 ? (
|
||||
<p className={specialStyles.empty}>{t('website.staticImages.loading')}</p>
|
||||
) : visibleSlots.length === 0 ? (
|
||||
<p className={specialStyles.empty}>
|
||||
{slots.length === 0
|
||||
? t('website.staticImages.empty')
|
||||
: t('website.staticImages.emptyPage')}
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={ImageOff}
|
||||
message={
|
||||
slots.length === 0
|
||||
? t('website.staticImages.empty')
|
||||
: t('website.staticImages.emptyPage')
|
||||
}
|
||||
actionLabel={t('website.staticImages.refresh')}
|
||||
onAction={() => void handleRefreshSlots()}
|
||||
/>
|
||||
) : (
|
||||
visibleSlots.map((slot) => (
|
||||
<section key={slot.id} className={styles.slot}>
|
||||
|
||||
@@ -79,6 +79,15 @@ export async function updateProfile(payload: {
|
||||
})
|
||||
}
|
||||
|
||||
export async function consumeHandoff(ticket: string) {
|
||||
const data = await apiRequest<LoginResponse>('/auth/handoff/consume', {
|
||||
method: 'POST',
|
||||
body: { ticket },
|
||||
})
|
||||
setTokens(data.accessToken, data.refreshToken)
|
||||
return data
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearTokens()
|
||||
clearActiveBusiness()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -123,7 +129,7 @@ export function CitySearchSelect({
|
||||
{open && !disabled ? (
|
||||
<ul className={styles.dropdown} role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<li className={styles.noResults}>{t('myProducts.fields.noCities')}</li>
|
||||
<li className={styles.noResults}>{emptyLabel}</li>
|
||||
) : (
|
||||
filtered.map((option) => (
|
||||
<li key={option.id}>
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
|
||||
const displayName = displayUserName(user, locale, t('app.role.customer'))
|
||||
@@ -117,6 +118,38 @@ export function Header({
|
||||
setPasswordModalOpen(true)
|
||||
}
|
||||
|
||||
async function openBusinessDashboard(event: MouseEvent<HTMLAnchorElement>) {
|
||||
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 (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
@@ -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)
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard size={18} />
|
||||
</a>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.',
|
||||
'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید',
|
||||
'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.',
|
||||
'login.error.invalidCredentials': 'شماره موبایل یا رمز عبور نادرست است.',
|
||||
|
||||
'signup.title': 'ایجاد حساب',
|
||||
'signup.subtitle': 'بهعنوان مشتری {domain} ثبتنام کنید',
|
||||
@@ -757,7 +765,7 @@ const fa: Record<MessageKey, string> = {
|
||||
'password.rule.length': 'حداقل ۸ کاراکتر',
|
||||
'password.rule.upper': 'یک حرف بزرگ انگلیسی',
|
||||
'password.rule.number': 'یک عدد',
|
||||
'password.rule.special': 'یک کاراکتر ویژه',
|
||||
'password.rule.match': 'یکسان بودن رمز عبور',
|
||||
|
||||
'otp.back': 'بازگشت به ورود',
|
||||
'otp.title': 'ورود یکبارمصرف',
|
||||
|
||||
@@ -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<CityOption[]>([])
|
||||
const [cities, setCities] = useState<CityOption[]>([])
|
||||
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() {
|
||||
<div className={styles.locationRow}>
|
||||
<div className={`${styles.field} ${styles.col2}`}>
|
||||
<label htmlFor="add-country">{t('myProducts.fields.country')}</label>
|
||||
<select
|
||||
<CitySearchSelect
|
||||
id="add-country"
|
||||
value={countrySlug}
|
||||
options={countries}
|
||||
value={countryId}
|
||||
onChange={(nextId) => void handleCountryChange(nextId)}
|
||||
disabled={loadingLocations}
|
||||
onChange={(e) => void handleCountryChange(e.target.value)}
|
||||
>
|
||||
<option value="">{t('myProducts.fields.selectCountry')}</option>
|
||||
{countries.map((country) => (
|
||||
<option key={country.id} value={country.slug}>
|
||||
{getLocationOptionLabel(country, locale)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
placeholder={t('myProducts.fields.searchCountry')}
|
||||
noResultsLabel={t('myProducts.fields.noCountries')}
|
||||
clearLabel={t('myProducts.fields.clearCountry')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
@@ -679,7 +672,7 @@ export function AddMyProductPage() {
|
||||
options={cities}
|
||||
value={cityId}
|
||||
onChange={setCityId}
|
||||
disabled={!countrySlug || loadingLocations}
|
||||
disabled={!countryId || loadingLocations}
|
||||
placeholder={t('myProducts.fields.searchCity')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.page}>
|
||||
<BackgroundOrbs />
|
||||
<div className={styles.brand}>
|
||||
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.domain} lang={locale === 'fa' ? 'fa' : undefined}>
|
||||
{businessName || tenantDomain}
|
||||
</span>
|
||||
<span className={styles.appName} lang="en" dir="ltr">
|
||||
{businessNameEn || tenantDomain}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.brand}>
|
||||
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.domain}>{businessName || tenantDomain}</span>
|
||||
<span className={styles.appName}>{businessNameEn || tenantDomain}</span>
|
||||
{(view === 'forgot' || view === 'otp') && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{view === 'forgot' ? t('forgot.back') : t('otp.back')}
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={styles.heading}>
|
||||
<h1 className={styles.title}>{heading.title}</h1>
|
||||
{heading.subtitle ? <p className={styles.subtitle}>{heading.subtitle}</p> : null}
|
||||
</div>
|
||||
<div className={styles.langSelect}>
|
||||
<LanguageSelect />
|
||||
@@ -388,9 +428,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'login' && (
|
||||
<>
|
||||
<h1 className={styles.title}>{t('login.welcome')}</h1>
|
||||
<p className={styles.subtitle}>{t('login.subtitle')}</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
@@ -490,8 +527,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'signup' && (
|
||||
<>
|
||||
<h1 className={styles.title}>{t('signup.title')}</h1>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSignup}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
@@ -578,24 +613,6 @@ export function LoginPage() {
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={password}
|
||||
id="signup-password-strength"
|
||||
labels={{
|
||||
title: t('password.strength'),
|
||||
empty: t('password.strength.empty'),
|
||||
weak: t('password.strength.weak'),
|
||||
fair: t('password.strength.fair'),
|
||||
good: t('password.strength.good'),
|
||||
strong: t('password.strength.strong'),
|
||||
rules: {
|
||||
length: t('password.rule.length'),
|
||||
upper: t('password.rule.upper'),
|
||||
number: t('password.rule.number'),
|
||||
special: t('password.rule.special'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
@@ -613,14 +630,36 @@ export function LoginPage() {
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
aria-describedby="signup-password-strength signup-password-strength-rules"
|
||||
/>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
id="signup-password-strength"
|
||||
labels={{
|
||||
title: t('password.strength'),
|
||||
empty: t('password.strength.empty'),
|
||||
weak: t('password.strength.weak'),
|
||||
fair: t('password.strength.fair'),
|
||||
good: t('password.strength.good'),
|
||||
strong: t('password.strength.strong'),
|
||||
rules: {
|
||||
length: t('password.rule.length'),
|
||||
upper: t('password.rule.upper'),
|
||||
number: t('password.rule.number'),
|
||||
match: t('password.rule.match'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !isPasswordStrong(password)}
|
||||
disabled={
|
||||
isSubmitting || !isPasswordStrong(password) || password !== confirmPassword
|
||||
}
|
||||
>
|
||||
{isSubmitting ? t('signup.creating') : t('signup.create')}
|
||||
</button>
|
||||
@@ -642,21 +681,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'forgot' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{t('forgot.back')}
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>{t('forgot.title')}</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleResetPassword}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
@@ -746,24 +770,6 @@ export function LoginPage() {
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={newPassword}
|
||||
id="forgot-password-strength"
|
||||
labels={{
|
||||
title: t('password.strength'),
|
||||
empty: t('password.strength.empty'),
|
||||
weak: t('password.strength.weak'),
|
||||
fair: t('password.strength.fair'),
|
||||
good: t('password.strength.good'),
|
||||
strong: t('password.strength.strong'),
|
||||
rules: {
|
||||
length: t('password.rule.length'),
|
||||
upper: t('password.rule.upper'),
|
||||
number: t('password.rule.number'),
|
||||
special: t('password.rule.special'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
@@ -781,8 +787,28 @@ export function LoginPage() {
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
aria-describedby="forgot-password-strength"
|
||||
/>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={newPassword}
|
||||
confirmPassword={confirmNewPassword}
|
||||
id="forgot-password-strength"
|
||||
labels={{
|
||||
title: t('password.strength'),
|
||||
empty: t('password.strength.empty'),
|
||||
weak: t('password.strength.weak'),
|
||||
fair: t('password.strength.fair'),
|
||||
good: t('password.strength.good'),
|
||||
strong: t('password.strength.strong'),
|
||||
rules: {
|
||||
length: t('password.rule.length'),
|
||||
upper: t('password.rule.upper'),
|
||||
number: t('password.rule.number'),
|
||||
match: t('password.rule.match'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.resendRow}>
|
||||
@@ -803,7 +829,7 @@ export function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !isPasswordStrong(newPassword)}
|
||||
disabled={isSubmitting || !isPasswordStrong(newPassword) || newPassword !== confirmNewPassword}
|
||||
>
|
||||
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
|
||||
</button>
|
||||
@@ -815,21 +841,6 @@ export function LoginPage() {
|
||||
|
||||
{view === 'otp' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{t('otp.back')}
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>{t('otp.title')}</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleOtpLogin}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
|
||||
@@ -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<HandoffCreateResponse>('/auth/handoff', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearTokens()
|
||||
clearActiveBusiness()
|
||||
|
||||
@@ -6,5 +6,6 @@ export type {
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
HandoffCreateResponse,
|
||||
UserProfile,
|
||||
} from '@meshkee/dashboard-core'
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type {
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
HandoffCreateResponse,
|
||||
UserProfile,
|
||||
} from './types/auth'
|
||||
|
||||
|
||||
@@ -65,3 +65,8 @@ export interface OtpVerifyResponse {
|
||||
verified: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface HandoffCreateResponse {
|
||||
ticket: string
|
||||
expiresInSeconds: number
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DashboardLocale, Copy> = {
|
||||
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}
|
||||
>
|
||||
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label={copy.close}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<h3 id="password-reset-title" className={styles.title}>
|
||||
{title}
|
||||
{heading}
|
||||
</h3>
|
||||
<p className={styles.subtitle}>{subtitle}</p>
|
||||
<p className={styles.subtitle}>{description}</p>
|
||||
|
||||
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)} autoComplete="off">
|
||||
{error && (
|
||||
@@ -188,7 +223,7 @@ export function PasswordResetModal({
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="password-reset-current">Current password</label>
|
||||
<label htmlFor="password-reset-current">{copy.currentPassword}</label>
|
||||
<input
|
||||
id="password-reset-current"
|
||||
type="password"
|
||||
@@ -204,7 +239,7 @@ export function PasswordResetModal({
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="password-reset-new">New password</label>
|
||||
<label htmlFor="password-reset-new">{copy.newPassword}</label>
|
||||
<input
|
||||
id="password-reset-new"
|
||||
type="password"
|
||||
@@ -217,45 +252,11 @@ export function PasswordResetModal({
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
aria-describedby="password-reset-strength password-reset-rules"
|
||||
/>
|
||||
|
||||
<div className={styles.strength} id="password-reset-strength">
|
||||
<div className={styles.strengthHeader}>
|
||||
<span className={styles.strengthTitle}>Password strength</span>
|
||||
<span className={`${styles.strengthLabel} ${styles[`tone_${strength.tone}`]}`}>
|
||||
{strength.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.strengthTrack}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={strength.percent}
|
||||
aria-label="Password strength"
|
||||
>
|
||||
<div
|
||||
className={`${styles.strengthFill} ${styles[`fill_${strength.tone}`]}`}
|
||||
style={{ width: `${strength.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<ul className={styles.rules} id="password-reset-rules">
|
||||
{checks.map((check) => (
|
||||
<li
|
||||
key={check.id}
|
||||
className={`${styles.rule} ${check.met ? styles.ruleMet : ''}`}
|
||||
>
|
||||
<span className={styles.ruleDot} aria-hidden="true" />
|
||||
{check.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="password-reset-confirm">Confirm password</label>
|
||||
<label htmlFor="password-reset-confirm">{copy.confirmPassword}</label>
|
||||
<input
|
||||
id="password-reset-confirm"
|
||||
type="password"
|
||||
@@ -268,19 +269,27 @@ export function PasswordResetModal({
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
aria-describedby="password-reset-strength password-reset-strength-rules"
|
||||
/>
|
||||
|
||||
<PasswordStrengthMeter
|
||||
password={newPassword}
|
||||
confirmPassword={confirmPassword}
|
||||
labels={copy.strength}
|
||||
id="password-reset-strength"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
{copy.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !passwordValid}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Update password'}
|
||||
{isSubmitting ? copy.saving : copy.submit}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user