PNG/WebP uploads no longer bake to black on JPEG; product croppers can zoom out with white padding, and SEO tools move into the product overflow menu. Co-authored-by: Cursor <cursoragent@cursor.com>
576 lines
19 KiB
TypeScript
576 lines
19 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { Globe, Package, Plus, RotateCcw, Search, Sparkles } from 'lucide-react'
|
|
import { EmptyState, ListEmptyState } from '../components/EmptyState'
|
|
import { ProductCard } from '../components/ProductCard'
|
|
import { AddProductByAiModal } from '../components/AddProductByAiModal'
|
|
import { Pagination } from '../components/Pagination'
|
|
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
|
import { ProductVariantsModal } from '../components/ProductVariantsModal'
|
|
import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal'
|
|
import { ProductCommentsModal } from '../components/ProductCommentsModal'
|
|
import { ProductSeoModal } from '../components/ProductSeoModal'
|
|
import { TranslationModal } from '../components/TranslationModal'
|
|
import { TranslateAllLanguageModal } from '../components/TranslateAllLanguageModal'
|
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
|
import { PageTitle } from '../components/PageTitle'
|
|
import { useToast } from '../context/ToastContext'
|
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
|
import { useT } from '../i18n/useT'
|
|
import { ApiError } from '../lib/api'
|
|
import { translateAllProducts } from '../services/translationService'
|
|
import type { TranslationLocale } from '../types/translation'
|
|
import {
|
|
deleteProduct,
|
|
listProducts,
|
|
PRODUCTS_PER_PAGE,
|
|
} from '../services/productService'
|
|
import type { Product } from '../types/product'
|
|
import { hasBusinessModule } from '../utils/businessModules'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import filterStyles from '../components/ListFiltersPanel.module.css'
|
|
import styles from './MyProductsPage.module.css'
|
|
import aiStyles from '../styles/ai.module.css'
|
|
|
|
function readListQuery(searchParams: URLSearchParams) {
|
|
const name = searchParams.get('q')?.trim() ?? ''
|
|
const pageRaw = Number(searchParams.get('page'))
|
|
const page = Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1
|
|
return { name, page }
|
|
}
|
|
|
|
export function MyProductsPage() {
|
|
const t = useT()
|
|
const navigate = useNavigate()
|
|
const { showToast } = useToast()
|
|
const { enabledModules } = useTenantBranding()
|
|
const isMultiLanguageEnabled = hasBusinessModule(enabledModules, 'multilanguage_data')
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const { name: appliedName, page: currentPage } = readListQuery(searchParams)
|
|
|
|
const [products, setProducts] = useState<Product[]>([])
|
|
const [totalProducts, setTotalProducts] = useState(0)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [variantCounts, setVariantCounts] = useState<Record<string, number>>({})
|
|
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
|
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null)
|
|
const [variantsTarget, setVariantsTarget] = useState<{
|
|
id: string
|
|
name: string
|
|
categoryId: string
|
|
} | null>(null)
|
|
const [commentsTarget, setCommentsTarget] = useState<{ id: string; name: string } | null>(null)
|
|
const [seoTarget, setSeoTarget] = useState<{ id: string; name: string } | null>(null)
|
|
const [technicalTarget, setTechnicalTarget] = useState<{
|
|
id: string
|
|
name: string
|
|
categoryId: string
|
|
} | null>(null)
|
|
const [aiModalOpen, setAiModalOpen] = useState(false)
|
|
const [translatingAll, setTranslatingAll] = useState(false)
|
|
const [translateAllOpen, setTranslateAllOpen] = useState(false)
|
|
const [aiMenuOpen, setAiMenuOpen] = useState(false)
|
|
const aiFabGroupRef = useRef<HTMLDivElement>(null)
|
|
const aiMenuCloseTimerRef = useRef<number | null>(null)
|
|
const [translateTarget, setTranslateTarget] = useState<Product | null>(null)
|
|
const [draftName, setDraftName] = useState(appliedName)
|
|
|
|
const totalPages = Math.max(1, Math.ceil(totalProducts / PRODUCTS_PER_PAGE))
|
|
|
|
useEffect(() => {
|
|
setDraftName(appliedName)
|
|
}, [appliedName])
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
void loadProducts(currentPage, controller.signal)
|
|
return () => controller.abort()
|
|
}, [currentPage, appliedName])
|
|
|
|
useEffect(() => {
|
|
if (!isMultiLanguageEnabled) setTranslateTarget(null)
|
|
}, [isMultiLanguageEnabled])
|
|
|
|
useEffect(() => {
|
|
if (!aiMenuOpen) return
|
|
const onPointerDown = (event: PointerEvent) => {
|
|
if (!aiFabGroupRef.current?.contains(event.target as Node)) {
|
|
setAiMenuOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('pointerdown', onPointerDown)
|
|
return () => document.removeEventListener('pointerdown', onPointerDown)
|
|
}, [aiMenuOpen])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (aiMenuCloseTimerRef.current !== null) {
|
|
window.clearTimeout(aiMenuCloseTimerRef.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
function openAiFabMenu() {
|
|
if (aiMenuCloseTimerRef.current !== null) {
|
|
window.clearTimeout(aiMenuCloseTimerRef.current)
|
|
aiMenuCloseTimerRef.current = null
|
|
}
|
|
setAiMenuOpen(true)
|
|
}
|
|
|
|
function scheduleCloseAiFabMenu() {
|
|
if (aiMenuCloseTimerRef.current !== null) {
|
|
window.clearTimeout(aiMenuCloseTimerRef.current)
|
|
}
|
|
aiMenuCloseTimerRef.current = window.setTimeout(() => {
|
|
setAiMenuOpen(false)
|
|
aiMenuCloseTimerRef.current = null
|
|
}, 160)
|
|
}
|
|
|
|
function writeListQuery(name: string, page: number) {
|
|
const next = new URLSearchParams()
|
|
if (name) next.set('q', name)
|
|
if (page > 1) next.set('page', String(page))
|
|
setSearchParams(next, { replace: true })
|
|
}
|
|
|
|
async function loadProducts(page: number, signal?: AbortSignal) {
|
|
setIsLoading(true)
|
|
setError('')
|
|
try {
|
|
const data = await listProducts(
|
|
page,
|
|
PRODUCTS_PER_PAGE,
|
|
signal,
|
|
appliedName ? { name: appliedName } : undefined,
|
|
)
|
|
setProducts(data.items)
|
|
setTotalProducts(data.total)
|
|
setVariantCounts(
|
|
Object.fromEntries(data.items.map((product) => [product.id, product.variantCount ?? 0])),
|
|
)
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('products.list.errorLoad'))
|
|
}
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
function handleEdit(id: string) {
|
|
navigate(`/products/edit/${id}`)
|
|
}
|
|
|
|
function handleTranslate(id: string) {
|
|
if (!isMultiLanguageEnabled) return
|
|
const product = products.find((p) => p.id === id)
|
|
if (product) setTranslateTarget(product)
|
|
}
|
|
|
|
async function handleTranslateAllProducts(locale: TranslationLocale) {
|
|
if (translatingAll || products.length === 0) return
|
|
|
|
setTranslatingAll(true)
|
|
setError('')
|
|
|
|
try {
|
|
const result = await translateAllProducts(locale)
|
|
await loadProducts(currentPage)
|
|
|
|
const langLabel = t(locale === 'en' ? 'translations.lang.en' : 'translations.lang.ar')
|
|
if (result.translated === 0 && result.failed > 0) {
|
|
showToast(t('products.list.translateAll.error'), 'error')
|
|
} else if (result.translated === 0) {
|
|
showToast(t('products.list.translateAll.none'), 'info')
|
|
} else {
|
|
showToast(
|
|
t('products.list.translateAll.done', {
|
|
translated: result.translated,
|
|
lang: langLabel,
|
|
}),
|
|
'success',
|
|
)
|
|
}
|
|
setTranslateAllOpen(false)
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('products.list.translateAll.error'))
|
|
}
|
|
} finally {
|
|
setTranslatingAll(false)
|
|
}
|
|
}
|
|
|
|
function handleComments(id: string) {
|
|
const product = products.find((p) => p.id === id)
|
|
if (product) {
|
|
setCommentsTarget({ id, name: product.nameFa || product.nameEn })
|
|
}
|
|
}
|
|
|
|
function handleSeo(id: string) {
|
|
const product = products.find((p) => p.id === id)
|
|
if (product) {
|
|
setSeoTarget({ id, name: product.nameFa || product.nameEn })
|
|
}
|
|
}
|
|
|
|
function handleQuickInfo(id: string) {
|
|
navigate(`/products/detail/${id}`)
|
|
}
|
|
|
|
function handleVariations(id: string) {
|
|
const product = products.find((p) => p.id === id)
|
|
if (product) {
|
|
setVariantsTarget({
|
|
id,
|
|
name: product.nameFa || product.nameEn,
|
|
categoryId: product.categoryId,
|
|
})
|
|
}
|
|
}
|
|
|
|
function handleTechnicalInfo(id: string) {
|
|
const product = products.find((p) => p.id === id)
|
|
if (product) {
|
|
setTechnicalTarget({
|
|
id,
|
|
name: product.nameFa || product.nameEn,
|
|
categoryId: product.categoryId,
|
|
})
|
|
}
|
|
}
|
|
|
|
function handleRemoveRequest(id: string) {
|
|
const product = products.find((p) => p.id === id)
|
|
if (product) {
|
|
setDeleteTarget({ id, name: product.nameFa || product.nameEn })
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!deleteTarget) return
|
|
|
|
setIsDeleting(true)
|
|
setError('')
|
|
|
|
try {
|
|
await deleteProduct(deleteTarget.id)
|
|
const nextTotal = totalProducts - 1
|
|
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PRODUCTS_PER_PAGE))
|
|
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
|
|
setDeleteTarget(null)
|
|
writeListQuery(appliedName, nextPage)
|
|
await loadProducts(nextPage)
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('products.list.errorDelete'))
|
|
}
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}
|
|
|
|
function handlePageChange(page: number) {
|
|
writeListQuery(appliedName, page)
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
}
|
|
|
|
function applyFilters() {
|
|
writeListQuery(draftName.trim(), 1)
|
|
}
|
|
|
|
function clearFilters() {
|
|
setDraftName('')
|
|
writeListQuery('', 1)
|
|
}
|
|
|
|
function handleCommentCountChange(count: number) {
|
|
if (!commentsTarget) return
|
|
setCommentCounts((prev) => ({
|
|
...prev,
|
|
[commentsTarget.id]: count,
|
|
}))
|
|
}
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<Breadcrumbs
|
|
items={[
|
|
{ label: 'Dashboard', href: '/' },
|
|
{ label: 'Products', href: '/products' },
|
|
{ label: 'My Products' },
|
|
]}
|
|
/>
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<PageTitle en="PRODUCTS">{t('title.myProducts')}</PageTitle>
|
|
<p className={pageStyles.pageSubtitle}>
|
|
{t('products.list.subtitle', { count: totalProducts })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={filterStyles.filtersPanel}>
|
|
<div className={filterStyles.filtersTitle}>{t('products.list.filters')}</div>
|
|
<form className={filterStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
|
|
<div className={filterStyles.filtersInputs}>
|
|
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
|
|
<input
|
|
id="filter-product-name"
|
|
value={draftName}
|
|
onChange={(e) => setDraftName(e.target.value)}
|
|
placeholder={t('products.list.filterNamePlaceholder')}
|
|
aria-label={t('products.list.filterName')}
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className={filterStyles.filterActions}>
|
|
<button
|
|
type="submit"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
|
disabled={isLoading}
|
|
aria-label={t('products.list.search')}
|
|
title={t('products.list.search')}
|
|
>
|
|
<Search size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
|
onClick={clearFilters}
|
|
disabled={isLoading}
|
|
aria-label={t('products.list.clearFilters')}
|
|
title={t('products.list.clearFilters')}
|
|
>
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className={styles.error} role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<p className={styles.empty}>{t('products.list.loading')}</p>
|
|
) : products.length === 0 ? (
|
|
appliedName ? (
|
|
<EmptyState message={t('products.list.emptyFiltered')} />
|
|
) : (
|
|
<ListEmptyState
|
|
entity="products"
|
|
icon={Package}
|
|
actionLabel={t('products.list.emptyCta')}
|
|
actionTo="/products/new"
|
|
/>
|
|
)
|
|
) : (
|
|
<>
|
|
<div className={styles.grid}>
|
|
{products.map((product) => (
|
|
<ProductCard
|
|
key={product.id}
|
|
product={product}
|
|
commentCount={commentCounts[product.id] ?? product.commentCount}
|
|
variantCount={variantCounts[product.id] ?? product.variantCount ?? 0}
|
|
onEdit={handleEdit}
|
|
onTranslate={handleTranslate}
|
|
onQuickInfo={handleQuickInfo}
|
|
onComments={handleComments}
|
|
onSeo={handleSeo}
|
|
onTechnicalInfo={handleTechnicalInfo}
|
|
onVariations={handleVariations}
|
|
onRemove={handleRemoveRequest}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className={styles.pagination}>
|
|
<div className={styles.paginationMeta}>
|
|
{t('products.list.pagination', {
|
|
page: currentPage,
|
|
totalPages,
|
|
perPage: PRODUCTS_PER_PAGE,
|
|
total: totalProducts,
|
|
})}
|
|
</div>
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={handlePageChange}
|
|
disabled={isLoading}
|
|
variant="inline"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<ConfirmDeleteModal
|
|
open={!!deleteTarget}
|
|
title={t('products.list.deleteTitle')}
|
|
message={
|
|
deleteTarget
|
|
? t('products.list.deleteMessage', { name: deleteTarget.name })
|
|
: ''
|
|
}
|
|
onConfirm={confirmDelete}
|
|
onCancel={() => !isDeleting && setDeleteTarget(null)}
|
|
/>
|
|
|
|
{variantsTarget && (
|
|
<ProductVariantsModal
|
|
open={!!variantsTarget}
|
|
productId={variantsTarget.id}
|
|
categoryId={variantsTarget.categoryId}
|
|
productName={variantsTarget.name}
|
|
onClose={() => setVariantsTarget(null)}
|
|
onVariantsChange={(count) => {
|
|
setVariantCounts((prev) => ({
|
|
...prev,
|
|
[variantsTarget.id]: count,
|
|
}))
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{commentsTarget && (
|
|
<ProductCommentsModal
|
|
open={!!commentsTarget}
|
|
productId={commentsTarget.id}
|
|
productName={commentsTarget.name}
|
|
onClose={() => setCommentsTarget(null)}
|
|
onCountChange={handleCommentCountChange}
|
|
/>
|
|
)}
|
|
|
|
{seoTarget && (
|
|
<ProductSeoModal
|
|
open={!!seoTarget}
|
|
productId={seoTarget.id}
|
|
productName={seoTarget.name}
|
|
onClose={() => setSeoTarget(null)}
|
|
/>
|
|
)}
|
|
|
|
{technicalTarget && (
|
|
<ProductTechnicalInfoModal
|
|
open={!!technicalTarget}
|
|
productId={technicalTarget.id}
|
|
productName={technicalTarget.name}
|
|
categoryId={technicalTarget.categoryId}
|
|
onClose={() => setTechnicalTarget(null)}
|
|
/>
|
|
)}
|
|
|
|
<AddProductByAiModal
|
|
open={aiModalOpen}
|
|
onClose={() => setAiModalOpen(false)}
|
|
onCreated={(productId) => navigate(`/products/edit/${productId}`)}
|
|
/>
|
|
|
|
{translateTarget && (
|
|
<TranslationModal
|
|
open={!!translateTarget}
|
|
entityType="product"
|
|
entityId={translateTarget.id}
|
|
entityName={translateTarget.nameFa || translateTarget.nameEn}
|
|
onClose={() => setTranslateTarget(null)}
|
|
onChanged={(count) => {
|
|
const targetId = translateTarget.id
|
|
setProducts((prev) =>
|
|
prev.map((product) =>
|
|
product.id === targetId
|
|
? { ...product, translationCount: count }
|
|
: product,
|
|
),
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<TranslateAllLanguageModal
|
|
open={translateAllOpen}
|
|
title={t('products.list.translateAll.pickTitle')}
|
|
subtitle={t('products.list.translateAll.pickSubtitle')}
|
|
running={translatingAll}
|
|
runLabel={t('products.list.translateAll.run')}
|
|
runningLabel={t('products.list.translateAll.running')}
|
|
onClose={() => !translatingAll && setTranslateAllOpen(false)}
|
|
onConfirm={(locale) => void handleTranslateAllProducts(locale)}
|
|
/>
|
|
|
|
<div className={styles.fabDock}>
|
|
<div
|
|
ref={aiFabGroupRef}
|
|
className={aiStyles.aiFabGroup}
|
|
data-open={aiMenuOpen ? 'true' : undefined}
|
|
onMouseEnter={openAiFabMenu}
|
|
onMouseLeave={scheduleCloseAiFabMenu}
|
|
>
|
|
<div className={aiStyles.aiFabMenu} role="menu">
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className={aiStyles.aiFabMenuItem}
|
|
onClick={() => {
|
|
setAiMenuOpen(false)
|
|
setAiModalOpen(true)
|
|
}}
|
|
disabled={translatingAll}
|
|
>
|
|
<Sparkles size={16} />
|
|
<span>{t('products.list.addByAi')}</span>
|
|
</button>
|
|
{isMultiLanguageEnabled ? (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className={aiStyles.aiFabMenuItem}
|
|
onClick={() => {
|
|
setAiMenuOpen(false)
|
|
setTranslateAllOpen(true)
|
|
}}
|
|
disabled={translatingAll || products.length === 0}
|
|
>
|
|
<Globe size={16} />
|
|
<span>{t('products.list.translateAll')}</span>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={aiStyles.aiFabStrip}
|
|
aria-expanded={aiMenuOpen}
|
|
aria-haspopup="menu"
|
|
aria-label={t('products.list.aiMenu')}
|
|
onClick={() => setAiMenuOpen((open) => !open)}
|
|
disabled={translatingAll}
|
|
>
|
|
<Sparkles size={18} />
|
|
<span>{t('products.list.aiMenu')}</span>
|
|
</button>
|
|
</div>
|
|
<Link to="/products/new" className={styles.addFab} aria-label={t('products.list.addNew')}>
|
|
<Plus size={24} />
|
|
</Link>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|