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:
Alireza Hassani
2026-08-19 19:46:57 +03:30
co-authored by Cursor
parent 832ac32a09
commit f8a9a28346
55 changed files with 1264 additions and 907 deletions
@@ -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>
+16 -8
View File
@@ -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}
+7 -15
View File
@@ -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 {
+18 -10
View File
@@ -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}
+16 -8
View File
@@ -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;
+272 -54
View File
@@ -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}
+9 -6
View File
@@ -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}>
+9 -6
View File
@@ -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
+42 -16
View File
@@ -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;
}
+62 -57
View File
@@ -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">
+8 -4
View File
@@ -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}>
+7 -6
View File
@@ -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 {
+8 -16
View File
@@ -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}>
+11 -2
View File
@@ -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}
+7 -15
View File
@@ -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}>