Add customer and business user-product flows across dashboards.

Ship my-products / customer-products UI with gallery uploads, status controls, module gating, and related shared UI polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-10 00:23:03 +03:30
co-authored by Cursor
parent c5bdaad16b
commit 1271d96539
96 changed files with 10532 additions and 345 deletions
@@ -32,21 +32,20 @@
.imageWrap {
position: relative;
width: 100%;
aspect-ratio: 3 / 2;
overflow: hidden;
background: rgba(148, 163, 184, 0.08);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
height: auto;
}
.imagePlaceholder {
width: 100%;
height: 100%;
aspect-ratio: 3 / 2;
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.12) 0%,
@@ -1,15 +1,28 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import { useT } from '../i18n/useT'
import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './CategoryAiPromptModal.module.css'
export const DEFAULT_CATEGORY_AI_PROMPT = `Create a practical product category tree for my store.
export function buildCategoryAiPrompt(locale: 'fa' | 'en') {
if (locale === 'fa') {
return `یک درخت دسته‌بندی محصول کاربردی برای فروشگاه من بساز.
نام انگلیسی، نام فارسی با خط پارسی، و توضیح کوتاه انگلیسی را برای هر دسته اضافه کن.
۳ تا ۵ دسته اصلی با زیردسته‌های مرتبط بساز تا مرور برای خریداران راحت‌تر شود.`
}
return `Create a practical product category tree for my store.
Include English names, Farsi names in Persian script, and short English descriptions.
Use 3-5 main categories with relevant subcategories where it helps shoppers browse.`
}
export const DEFAULT_CATEGORY_AI_PROMPT = buildCategoryAiPrompt('en')
interface CategoryAiPromptModalProps {
open: boolean
@@ -26,16 +39,19 @@ export function CategoryAiPromptModal({
onRun,
isRunning,
}: CategoryAiPromptModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [prompt, setPrompt] = useState(DEFAULT_CATEGORY_AI_PROMPT)
const [prompt, setPrompt] = useState(() => buildCategoryAiPrompt(isFa ? 'fa' : 'en'))
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setPrompt(DEFAULT_CATEGORY_AI_PROMPT)
setPrompt(buildCategoryAiPrompt(isFa ? 'fa' : 'en'))
setError('')
} else if (mounted) {
setClosing(true)
@@ -45,7 +61,7 @@ export function CategoryAiPromptModal({
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
}, [open, mounted, isFa])
useEffect(() => {
if (!mounted || closing) return
@@ -60,7 +76,7 @@ export function CategoryAiPromptModal({
e.preventDefault()
const trimmed = prompt.trim()
if (trimmed.length < 10) {
setError('Prompt must be at least 10 characters.')
setError(t('products.form.aiPromptTooShort'))
return
}
@@ -73,7 +89,7 @@ export function CategoryAiPromptModal({
} else if (err instanceof Error) {
setError(err.message)
} else {
setError('Unable to generate categories with AI.')
setError(t('categories.ai.error'))
}
}
}
@@ -91,22 +107,22 @@ export function CategoryAiPromptModal({
role="dialog"
aria-modal="true"
aria-labelledby="category-ai-prompt-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
<h2 id="category-ai-prompt-title" className={modalStyles.title}>
Fill Categories with AI
{t('categories.ai.title')}
</h2>
<p className={styles.subtitle}>
Edit the prompt below, then run it to generate and save a category tree.
</p>
<p className={styles.subtitle}>{t('categories.ai.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={isRunning}
aria-label="Close"
aria-label={t('common.close')}
>
<X size={20} />
</button>
@@ -114,7 +130,7 @@ export function CategoryAiPromptModal({
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
<label htmlFor="category-ai-prompt">Prompt</label>
<label htmlFor="category-ai-prompt">{t('products.form.aiPrompt')}</label>
<textarea
id="category-ai-prompt"
className={aiStyles.aiPromptTextarea}
@@ -123,6 +139,8 @@ export function CategoryAiPromptModal({
rows={8}
disabled={isRunning}
required
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
/>
</div>
@@ -135,30 +153,20 @@ export function CategoryAiPromptModal({
onClick={onClose}
disabled={isRunning}
>
Cancel
{t('categories.modal.cancel')}
</button>
<button
type="submit"
className={aiStyles.aiBtn}
disabled={isRunning || prompt.trim().length < 10}
>
{isRunning ? (
<>
<Sparkles size={16} />
Running...
</>
) : (
<>
<Sparkles size={16} />
Run
</>
)}
<Sparkles size={16} />
{isRunning ? t('products.form.aiGenerating') : t('products.form.aiRun')}
</button>
</div>
</form>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
@@ -11,6 +11,11 @@
padding: 20px;
}
/* Stack above VariationsModal / TechnicalFormModal (z-index 1000–1010) */
.overlayStacked {
z-index: 1200;
}
.modal {
width: 100%;
max-width: 480px;
@@ -0,0 +1,159 @@
.wrapper {
position: relative;
}
.inputWrap {
display: flex;
align-items: center;
gap: 6px;
min-height: var(--field-height);
padding: 0 var(--field-padding-x);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.inputWrapOpen,
.inputWrap:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.inputWrapDisabled {
opacity: 0.55;
cursor: not-allowed;
}
.searchIcon {
flex-shrink: 0;
color: var(--text-muted);
}
.input {
flex: 1;
min-width: 0;
padding: var(--field-padding-y) 0;
font-size: var(--field-font-size);
font-family: var(--font-ui);
line-height: 1.4;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
}
.input::placeholder {
font-family: var(--font-ui);
color: var(--text-muted);
opacity: 1;
}
.input:disabled {
cursor: not-allowed;
}
.clearBtn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.clearBtn:hover {
background: rgba(148, 163, 184, 0.2);
color: var(--text-secondary);
}
.chevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
inset-inline: 0;
max-height: 260px;
overflow-y: auto;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
z-index: 30;
list-style: none;
padding: 6px;
margin: 0;
}
.option {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
width: 100%;
padding: 8px 10px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
text-align: start;
color: var(--text-primary);
border-radius: 8px;
transition: background 0.15s;
box-sizing: border-box;
}
.optionChild {
border-inline-start: 2px solid rgba(var(--primary-rgb) / 0.35);
border-start-start-radius: 0;
border-end-start-radius: 0;
background: rgba(var(--primary-rgb) / 0.03);
padding-inline-start: 14px;
}
.option:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
.optionChild:hover {
background: rgba(var(--primary-rgb) / 0.1);
}
.optionSelected {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
font-weight: 500;
}
.optionChild.optionSelected {
border-inline-start-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.14);
}
.optionLabel {
font-weight: 500;
}
.optionSecondary {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
}
.noResults {
padding: 12px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
@@ -0,0 +1,246 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { UserProductCategoryOption } from '../services/userProductsService'
import styles from './CategorySearchSelect.module.css'
export interface FlatCategoryOption extends UserProductCategoryOption {
depth: number
}
interface CategorySearchSelectProps {
options: UserProductCategoryOption[]
value: string
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
id?: string
}
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
if (isFa) {
return category.nameFa?.trim() || category.name
}
return category.name || category.nameFa?.trim() || ''
}
export function flattenCategoryTree(
categories: UserProductCategoryOption[],
): FlatCategoryOption[] {
const byParent = new Map<string | null, UserProductCategoryOption[]>()
for (const category of categories) {
const parentKey = category.parentId
const list = byParent.get(parentKey) ?? []
list.push(category)
byParent.set(parentKey, list)
}
for (const list of byParent.values()) {
list.sort((a, b) => {
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
return aLabel
})
}
const result: FlatCategoryOption[] = []
const ids = new Set(categories.map((item) => item.id))
function walk(parentId: string | null, depth: number) {
const children = byParent.get(parentId) ?? []
for (const child of children) {
result.push({ ...child, depth })
walk(child.id, depth + 1)
}
}
walk(null, 0)
// Orphans whose parent is missing from the list
for (const category of categories) {
if (category.parentId && !ids.has(category.parentId)) {
if (!result.some((item) => item.id === category.id)) {
result.push({ ...category, depth: 0 })
walk(category.id, 1)
}
}
}
return result
}
function filterCategoryTree(
flat: FlatCategoryOption[],
query: string,
): FlatCategoryOption[] {
const q = query.trim().toLowerCase()
if (!q) return flat
const byId = new Map(flat.map((item) => [item.id, item]))
const matchedIds = new Set<string>()
for (const item of flat) {
const nameEn = item.name.toLowerCase()
const nameFa = (item.nameFa || '').toLowerCase()
if (nameEn.includes(q) || nameFa.includes(q) || (item.nameFa || '').includes(query.trim())) {
matchedIds.add(item.id)
let parentId = item.parentId
while (parentId) {
matchedIds.add(parentId)
parentId = byId.get(parentId)?.parentId ?? null
}
}
}
return flat.filter((item) => matchedIds.has(item.id))
}
export function CategorySearchSelect({
options,
value,
onChange,
disabled = false,
placeholder,
id,
}: CategorySearchSelectProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const flat = useMemo(() => flattenCategoryTree(options), [options])
const filtered = useMemo(() => filterCategoryTree(flat, query), [flat, query])
const selected = flat.find((option) => option.id === value)
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
const searchPlaceholder = placeholder ?? t('customerProducts.form.fields.searchCategory')
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
useEffect(() => {
if (disabled) {
setOpen(false)
setQuery('')
}
}, [disabled])
function selectOption(nextId: string) {
onChange(nextId)
setOpen(false)
setQuery('')
}
return (
<div
className={styles.wrapper}
ref={containerRef}
dir={isFa ? 'rtl' : 'ltr'}
>
<div
className={[
styles.inputWrap,
open ? styles.inputWrapOpen : '',
disabled ? styles.inputWrapDisabled : '',
]
.filter(Boolean)
.join(' ')}
>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={styles.input}
disabled={disabled}
placeholder={selected ? selectedLabel : searchPlaceholder}
value={open ? query : selectedLabel}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('customerProducts.form.fields.clearCategory')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<ul className={styles.dropdown} role="listbox">
{filtered.length === 0 ? (
<li className={styles.noResults}>{t('customerProducts.form.fields.noCategories')}</li>
) : (
filtered.map((option) => (
<li key={option.id}>
<button
type="button"
className={[
styles.option,
option.depth > 0 ? styles.optionChild : '',
value === option.id ? styles.optionSelected : '',
]
.filter(Boolean)
.join(' ')}
style={
option.depth > 0
? {
marginInlineStart: `${option.depth * 22}px`,
}
: undefined
}
onClick={() => selectOption(option.id)}
role="option"
aria-selected={value === option.id}
>
<span className={styles.optionLabel}>
{categoryLabel(option, isFa)}
</span>
{!isFa && option.nameFa ? (
<span className={styles.optionSecondary}>{option.nameFa}</span>
) : null}
{isFa && option.name && option.name !== option.nameFa ? (
<span className={styles.optionSecondary} dir="ltr">
{option.name}
</span>
) : null}
</button>
</li>
))
)}
</ul>
) : null}
</div>
)
}
@@ -0,0 +1,129 @@
.wrapper {
position: relative;
}
.inputWrap {
display: flex;
align-items: center;
gap: 6px;
min-height: var(--field-height);
padding: 0 var(--field-padding-x);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.inputWrapOpen,
.inputWrap:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.inputWrapDisabled {
opacity: 0.55;
cursor: not-allowed;
}
.searchIcon {
flex-shrink: 0;
color: var(--text-muted);
}
.input {
flex: 1;
min-width: 0;
padding: var(--field-padding-y) 0;
font-size: var(--field-font-size);
font-family: var(--font-ui);
line-height: 1.4;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
}
.input::placeholder {
font-family: var(--font-ui);
color: var(--text-muted);
opacity: 1;
}
.input:disabled {
cursor: not-allowed;
}
.clearBtn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.clearBtn:hover {
background: rgba(148, 163, 184, 0.2);
color: var(--text-secondary);
}
.chevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
inset-inline: 0;
max-height: 220px;
overflow-y: auto;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
z-index: 20;
list-style: none;
padding: 6px;
margin: 0;
}
.option {
display: flex;
align-items: center;
width: 100%;
padding: 7px 10px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
text-align: start;
color: var(--text-primary);
border-radius: 8px;
transition: background 0.15s;
}
.option:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
.optionSelected {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
font-weight: 500;
}
.noResults {
padding: 12px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
@@ -0,0 +1,146 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import {
getLocationOptionLabel,
useLocale,
type CityOption,
} from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import styles from './CitySearchSelect.module.css'
interface CitySearchSelectProps {
options: CityOption[]
value: string
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
id?: string
}
export function CitySearchSelect({
options,
value,
onChange,
disabled = false,
placeholder,
id,
}: CitySearchSelectProps) {
const t = useT()
const { locale } = useLocale()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const selected = options.find((option) => option.id === value)
const selectedLabel = selected ? getLocationOptionLabel(selected, locale) : ''
const searchPlaceholder = placeholder ?? t('customerProducts.form.fields.searchCity')
const filtered = options.filter((option) => {
const q = query.trim().toLowerCase()
if (!q) return true
return (
option.nameEn.toLowerCase().includes(q) ||
option.nameFa.includes(query.trim()) ||
option.slug.toLowerCase().includes(q)
)
})
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
useEffect(() => {
if (disabled) {
setOpen(false)
setQuery('')
}
}, [disabled])
function selectOption(nextId: string) {
onChange(nextId)
setOpen(false)
setQuery('')
}
return (
<div className={styles.wrapper} ref={containerRef}>
<div
className={[
styles.inputWrap,
open ? styles.inputWrapOpen : '',
disabled ? styles.inputWrapDisabled : '',
]
.filter(Boolean)
.join(' ')}
>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={styles.input}
disabled={disabled}
placeholder={selected ? selectedLabel : searchPlaceholder}
value={open ? query : selectedLabel}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('customerProducts.form.fields.clearCity')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<ul className={styles.dropdown} role="listbox">
{filtered.length === 0 ? (
<li className={styles.noResults}>{t('customerProducts.form.fields.noCities')}</li>
) : (
filtered.map((option) => (
<li key={option.id}>
<button
type="button"
className={`${styles.option} ${value === option.id ? styles.optionSelected : ''}`}
onClick={() => selectOption(option.id)}
role="option"
aria-selected={value === option.id}
>
{getLocationOptionLabel(option, locale)}
</button>
</li>
))
)}
</ul>
) : null}
</div>
)
}
+9
View File
@@ -14,6 +14,7 @@ import {
ChevronDown,
Building2,
Pencil,
Package,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
@@ -129,6 +130,14 @@ export function Sidebar() {
],
},
{ type: 'link', id: 'customers', icon: Users, labelKey: 'nav.customers', to: '/customers' },
{
type: 'link',
id: 'customer-products',
icon: Package,
labelKey: 'nav.customerProducts',
to: '/customer-products',
moduleId: 'customer_products',
},
{
type: 'group',
id: 'blog',
@@ -1,11 +1,28 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './CategoryAiPromptModal.module.css'
export function buildTechnicalFormAiPrompt(categoryName: string) {
export function buildTechnicalFormAiPrompt(
categoryName: string,
locale: 'fa' | 'en' = 'en',
) {
if (locale === 'fa') {
return `برای دسته‌بندی محصول «${categoryName}» فیلدهای فرم اطلاعات فنی پیشنهاد بده.
برای هر فیلد این موارد را مشخص کن:
- برچسب کوتاه انگلیسی (مثلاً Screen Size، Material، Color)
- نوع ورودی: text، textarea، select یا multi_select
- الزامی بودن یا نبودن
- برای select / multi_select: فهرست گزینه‌ها
روی مشخصات و ویژگی‌هایی تمرکز کن که خریداران معمولاً هنگام مقایسه این نوع محصول بررسی می‌کنند.`
}
return `Suggest technical data form fields for the "${categoryName}" product category.
For each field include:
@@ -34,16 +51,21 @@ export function TechnicalFormAiPromptModal({
onRun,
isRunning,
}: TechnicalFormAiPromptModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [prompt, setPrompt] = useState(() => buildTechnicalFormAiPrompt(categoryName))
const [prompt, setPrompt] = useState(() =>
buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'),
)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setPrompt(buildTechnicalFormAiPrompt(categoryName))
setPrompt(buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'))
setError('')
} else if (mounted) {
setClosing(true)
@@ -53,7 +75,7 @@ export function TechnicalFormAiPromptModal({
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, categoryName])
}, [open, mounted, categoryName, isFa])
useEffect(() => {
if (!mounted || closing) return
@@ -68,7 +90,7 @@ export function TechnicalFormAiPromptModal({
e.preventDefault()
const trimmed = prompt.trim()
if (trimmed.length < 10) {
setError('Prompt must be at least 10 characters.')
setError(t('products.form.aiPromptTooShort'))
return
}
setError('')
@@ -78,7 +100,7 @@ export function TechnicalFormAiPromptModal({
if (err instanceof Error) {
setError(err.message)
} else {
setError('Unable to generate technical form with AI.')
setError(t('categories.technical.ai.error'))
}
}
}
@@ -87,7 +109,7 @@ export function TechnicalFormAiPromptModal({
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
className={`${modalStyles.overlay} ${modalStyles.overlayStacked} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
role="presentation"
>
@@ -96,22 +118,22 @@ export function TechnicalFormAiPromptModal({
role="dialog"
aria-modal="true"
aria-labelledby="tech-form-ai-prompt-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
<h2 id="tech-form-ai-prompt-title" className={modalStyles.title}>
Generate Technical Form with AI
{t('categories.technical.ai.title')}
</h2>
<p className={styles.subtitle}>
Edit the prompt below, then run it to fill the form fields.
</p>
<p className={styles.subtitle}>{t('categories.technical.ai.subtitle')}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={isRunning}
aria-label="Close"
aria-label={t('common.close')}
>
<X size={20} />
</button>
@@ -119,7 +141,7 @@ export function TechnicalFormAiPromptModal({
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
<label htmlFor="tech-form-ai-prompt">Prompt</label>
<label htmlFor="tech-form-ai-prompt">{t('products.form.aiPrompt')}</label>
<textarea
id="tech-form-ai-prompt"
className={aiStyles.aiPromptTextarea}
@@ -128,10 +150,12 @@ export function TechnicalFormAiPromptModal({
rows={8}
disabled={isRunning}
required
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
/>
</div>
{error && <p className={styles.error}>{error}</p>}
{error ? <p className={styles.error}>{error}</p> : null}
<div className={modalStyles.actions}>
<button
@@ -140,7 +164,7 @@ export function TechnicalFormAiPromptModal({
onClick={onClose}
disabled={isRunning}
>
Cancel
{t('categories.modal.cancel')}
</button>
<button
type="submit"
@@ -148,13 +172,12 @@ export function TechnicalFormAiPromptModal({
disabled={isRunning || prompt.trim().length < 10}
>
<Sparkles size={16} />
{isRunning ? 'Generating…' : 'Run'}
{isRunning ? t('products.form.aiGenerating') : t('products.form.aiRun')}
</button>
</div>
</form>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
@@ -156,5 +156,23 @@
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.actionsRow {
justify-content: space-between;
width: 100%;
/* Physical layout: Save leftmost, Cancel next, AI on the right */
direction: ltr;
}
.actionsMain {
display: flex;
align-items: center;
gap: 10px;
}
.actionsAi {
flex-shrink: 0;
white-space: nowrap;
margin-inline-start: auto;
}
@@ -1,8 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Sparkles, Trash2, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import { createId } from '../utils/id'
import { Tooltip } from './Tooltip'
import styles from './VariationsModal.module.css'
import fieldStyles from './TechnicalFormModal.module.css'
import aiStyles from '../styles/ai.module.css'
@@ -14,6 +18,7 @@ interface TechnicalFormModalProps {
isLoading?: boolean
isSaving?: boolean
isGenerating?: boolean
escapeDisabled?: boolean
error?: string
onClose: () => void
onChange: (fields: TechnicalFormFieldDraft[]) => void
@@ -24,11 +29,11 @@ interface TechnicalFormModalProps {
const ANIMATION_MS = 220
const FIELD_TYPE_LABELS: Record<TechnicalFieldType, string> = {
text: 'Text',
textarea: 'Textarea',
select: 'Select',
multi_select: 'Multi',
const FIELD_TYPE_KEYS: Record<TechnicalFieldType, BusinessMessageKey> = {
text: 'categories.technical.type.text',
textarea: 'categories.technical.type.textarea',
select: 'categories.technical.type.select',
multi_select: 'categories.technical.type.multi_select',
}
function createEmptyField(): TechnicalFormFieldDraft {
@@ -48,6 +53,7 @@ export function TechnicalFormModal({
isLoading = false,
isSaving = false,
isGenerating = false,
escapeDisabled = false,
error = '',
onClose,
onChange,
@@ -55,6 +61,9 @@ export function TechnicalFormModal({
onGenerate,
onGenerateClick,
}: TechnicalFormModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const optionInputRefs = useRef<Record<string, (HTMLInputElement | null)[]>>({})
@@ -82,13 +91,13 @@ export function TechnicalFormModal({
}, [fields])
useEffect(() => {
if (!mounted || closing) return
if (!mounted || closing || escapeDisabled) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
}, [mounted, closing, escapeDisabled, onClose])
if (!mounted) return null
@@ -180,22 +189,29 @@ export function TechnicalFormModal({
role="dialog"
aria-modal="true"
aria-labelledby="technical-form-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<div>
<h3 id="technical-form-title" className={styles.title}>
Technical Data Form
{t('categories.technical.title')}
</h3>
{categoryName && <p className={styles.subtitle}>{categoryName}</p>}
{categoryName ? <p className={styles.subtitle}>{categoryName}</p> : null}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<button
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
type="button"
>
<X size={20} />
</button>
</div>
<div className={styles.body}>
{isLoading ? (
<p className={styles.emptyText}>Loading form...</p>
<p className={styles.emptyText}>{t('categories.technical.loading')}</p>
) : (
<>
<div className={fieldStyles.fieldList}>
@@ -206,16 +222,16 @@ export function TechnicalFormModal({
id={`label-${field.id}`}
type="text"
className={fieldStyles.compactInput}
placeholder="Field label"
placeholder={t('categories.technical.fieldLabel')}
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
aria-label="Field label"
aria-label={t('categories.technical.fieldLabel')}
/>
<select
id={`type-${field.id}`}
className={fieldStyles.compactInput}
value={field.type}
aria-label="Input type"
aria-label={t('categories.technical.inputType')}
onChange={(e) => {
const type = e.target.value as TechnicalFieldType
updateField(field.id, {
@@ -229,13 +245,16 @@ export function TechnicalFormModal({
})
}}
>
{Object.entries(FIELD_TYPE_LABELS).map(([value, label]) => (
{(Object.keys(FIELD_TYPE_KEYS) as TechnicalFieldType[]).map((value) => (
<option key={value} value={value}>
{label}
{t(FIELD_TYPE_KEYS[value])}
</option>
))}
</select>
<label className={fieldStyles.requiredCell} title="Required">
<label
className={fieldStyles.requiredCell}
title={t('categories.technical.required')}
>
<input
type="checkbox"
checked={field.isRequired}
@@ -243,23 +262,27 @@ export function TechnicalFormModal({
updateField(field.id, { isRequired: e.target.checked })
}
/>
<span>Required</span>
<span>{t('categories.technical.required')}</span>
</label>
<div className={fieldStyles.removeCell}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => removeField(field.id)}
aria-label="Remove field"
>
<Trash2 size={15} />
</button>
<Tooltip label={t('categories.technical.removeField')}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => removeField(field.id)}
aria-label={t('categories.technical.removeField')}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</div>
{(field.type === 'select' || field.type === 'multi_select') && (
<div className={fieldStyles.optionsBlock}>
<span className={fieldStyles.optionsLabel}>Options</span>
<span className={fieldStyles.optionsLabel}>
{t('categories.technical.options')}
</span>
<div className={fieldStyles.customValues}>
{field.options.map((option, optionIndex) => (
<div key={optionIndex} className={fieldStyles.customRow}>
@@ -271,7 +294,9 @@ export function TechnicalFormModal({
optionInputRefs.current[field.id][optionIndex] = el
}}
type="text"
placeholder={`Option ${optionIndex + 1}`}
placeholder={t('categories.technical.optionN', {
n: optionIndex + 1,
})}
value={option}
onChange={(e) =>
updateOption(field.id, optionIndex, e.target.value)
@@ -282,14 +307,16 @@ export function TechnicalFormModal({
/>
{field.options.length > 1 && (
<div className={fieldStyles.removeCell}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => removeOption(field.id, optionIndex)}
aria-label="Remove option"
>
<X size={16} />
</button>
<Tooltip label={t('categories.technical.removeOption')}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => removeOption(field.id, optionIndex)}
aria-label={t('categories.technical.removeOption')}
>
<X size={16} />
</button>
</Tooltip>
</div>
)}
{field.options.length <= 1 && <div />}
@@ -301,7 +328,7 @@ export function TechnicalFormModal({
onClick={() => addOption(field.id)}
>
<Plus size={14} />
Add option
{t('categories.technical.addOption')}
</button>
</div>
</div>
@@ -310,56 +337,59 @@ export function TechnicalFormModal({
))}
{!fields.length && (
<p className={styles.emptyText}>
No fields yet. Add text, textarea, select, or multi-select inputs.
</p>
<p className={styles.emptyText}>{t('categories.technical.empty')}</p>
)}
</div>
<div className={fieldStyles.toolbar}>
{(onGenerateClick ?? onGenerate) && (
<button
type="button"
className={aiStyles.aiBtn}
onClick={onGenerateClick ?? onGenerate}
disabled={isLoading || isSaving || isGenerating}
>
<Sparkles size={16} />
{isGenerating ? 'Generating…' : 'Generate with AI'}
</button>
)}
<button type="button" className={styles.addBtn} onClick={addField}>
<Plus size={18} />
Add field
{t('categories.technical.addField')}
</button>
</div>
</>
)}
{error && <p className={styles.errorText}>{error}</p>}
{error ? <p className={styles.errorText}>{error}</p> : null}
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSaving || isGenerating}
>
Cancel
</button>
<button
type="button"
className={styles.submitBtn}
onClick={onSave}
disabled={!canSave}
>
{isSaving ? 'Saving…' : 'Save form'}
</button>
<div className={`${styles.actions} ${fieldStyles.actionsRow}`}>
<div className={fieldStyles.actionsMain}>
<button
type="button"
className={styles.submitBtn}
onClick={onSave}
disabled={!canSave}
>
{isSaving
? t('categories.technical.saving')
: t('categories.technical.save')}
</button>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSaving || isGenerating}
>
{t('categories.modal.cancel')}
</button>
</div>
{(onGenerateClick ?? onGenerate) ? (
<button
type="button"
className={`${aiStyles.aiBtn} ${fieldStyles.actionsAi}`}
onClick={onGenerateClick ?? onGenerate}
disabled={isLoading || isSaving || isGenerating}
>
<Sparkles size={16} />
{isGenerating
? t('products.form.aiGenerating')
: t('categories.technical.generateAi')}
</button>
) : null}
</div>
</div>
</div>
</div>
,
document.body,
</div>,
document.body,
)
}
+11 -14
View File
@@ -4,10 +4,9 @@
}
.tip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(4px);
position: fixed;
z-index: 10000;
transform: translate(-50%, calc(-100% + 4px));
padding: 6px 10px;
font-family: var(--font-ui);
font-size: 12px;
@@ -19,8 +18,7 @@
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
z-index: 50;
background: rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
@@ -28,6 +26,12 @@
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.12);
}
.tipVisible {
opacity: 1;
visibility: visible;
transform: translate(-50%, -100%);
}
.tip::after {
content: '';
position: absolute;
@@ -35,12 +39,5 @@
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: rgba(255, 255, 255, 0.75);
}
.wrap:hover .tip,
.wrap:focus-within .tip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
border-top-color: rgba(255, 255, 255, 0.95);
}
+64 -5
View File
@@ -1,4 +1,5 @@
import type { ReactElement } from 'react'
import { useEffect, useRef, useState, type ReactElement } from 'react'
import { createPortal } from 'react-dom'
import styles from './Tooltip.module.css'
interface TooltipProps {
@@ -7,12 +8,70 @@ interface TooltipProps {
}
export function Tooltip({ label, children }: TooltipProps) {
const wrapRef = useRef<HTMLSpanElement>(null)
const [visible, setVisible] = useState(false)
const [coords, setCoords] = useState({ top: 0, left: 0 })
function updatePosition() {
const el = wrapRef.current
if (!el) return
const rect = el.getBoundingClientRect()
setCoords({
top: rect.top - 8,
left: rect.left + rect.width / 2,
})
}
function show() {
updatePosition()
setVisible(true)
}
function hide() {
setVisible(false)
}
useEffect(() => {
if (!visible) return
function onReposition() {
updatePosition()
}
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
return () => {
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
}
}, [visible])
return (
<span className={styles.wrap}>
<span
ref={wrapRef}
className={styles.wrap}
onMouseEnter={show}
onMouseLeave={hide}
onFocusCapture={show}
onBlurCapture={(e) => {
if (!wrapRef.current?.contains(e.relatedTarget as Node | null)) {
hide()
}
}}
>
{children}
<span className={styles.tip} role="tooltip">
{label}
</span>
{visible
? createPortal(
<span
className={`${styles.tip} ${styles.tipVisible}`}
role="tooltip"
style={{ top: coords.top, left: coords.left }}
>
{label}
</span>,
document.body,
)
: null}
</span>
)
}
@@ -0,0 +1,252 @@
.card {
display: flex;
flex-direction: column;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
transition: var(--card-hover-transition, transform 0.2s, box-shadow 0.2s);
overflow: hidden;
}
.mainLink {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
color: inherit;
text-decoration: none;
}
.imageWrap {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
background: var(--card-media-bg, rgba(148, 163, 184, 0.12));
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.imagePlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(148, 163, 184, 0.75);
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.14) 0%,
rgba(148, 163, 184, 0.05) 100%
);
}
.badge {
position: absolute;
top: 8px;
inset-inline-start: 8px;
padding: 3px 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.03em;
border-radius: 50px;
border: 1px solid rgba(255, 255, 255, 0.22);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
z-index: 1;
}
.badge[data-status='draft'] {
color: #fff;
background: linear-gradient(
145deg,
rgba(251, 191, 36, 0.55) 0%,
rgba(245, 158, 11, 0.32) 100%
);
border-color: rgba(251, 191, 36, 0.35);
}
.badge[data-status='published'] {
color: #047857;
background: linear-gradient(
145deg,
rgba(167, 243, 208, 0.55) 0%,
rgba(52, 211, 153, 0.28) 100%
);
border-color: rgba(110, 231, 183, 0.4);
}
.badge[data-status='archived'] {
color: #e2e8f0;
background: linear-gradient(
145deg,
rgba(148, 163, 184, 0.45) 0%,
rgba(100, 116, 139, 0.28) 100%
);
border-color: rgba(148, 163, 184, 0.35);
}
.badge[data-status='rejected'] {
color: #fff;
background: linear-gradient(
145deg,
rgba(239, 68, 68, 0.55) 0%,
rgba(220, 38, 38, 0.32) 100%
);
border-color: rgba(239, 68, 68, 0.35);
}
.promotedBadge {
position: absolute;
top: 8px;
inset-inline-end: 8px;
padding: 3px 8px;
font-size: 10px;
font-weight: 600;
color: #fff;
border-radius: 50px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: linear-gradient(
145deg,
rgba(99, 102, 241, 0.7) 0%,
rgba(168, 85, 247, 0.45) 100%
);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
z-index: 1;
}
.body {
padding: 10px 10px 8px;
flex: 1;
}
.title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
margin: 0 0 3px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.secondary {
font-family: var(--font-ui);
font-size: 12px;
color: var(--text-secondary);
margin: 0 0 6px;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.categoryChip {
display: inline-block;
margin-bottom: 6px;
padding: 2px 8px;
font-size: 10px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.12);
border-radius: 50px;
}
.owner {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 6px;
font-size: 11px;
color: var(--text-muted);
min-width: 0;
}
.owner span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metaRow {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
/* Physical LTR: price left, location right */
direction: ltr;
}
.location {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 0 auto;
font-size: 11px;
color: var(--text-muted);
min-width: 0;
}
.location span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.price {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
flex-shrink: 0;
}
.controls {
display: flex;
align-items: center;
justify-content: space-around;
padding: 8px 6px 10px;
border-top: 1px solid rgba(148, 163, 184, 0.15);
overflow: visible;
}
.controls button {
position: relative;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.controls button:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.controls button:disabled {
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
.controls button.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Check,
ImageOff,
MapPin,
Megaphone,
Pencil,
Trash2,
User,
} from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { UserProductListItem } from '../types/userProduct'
import { formatIrtPrice } from '../utils/irtPrice'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
import styles from './UserProductCard.module.css'
interface UserProductCardProps {
product: UserProductListItem
to?: string
onEdit: (id: string) => void
onPromote: (id: string) => void
onChangeStatus: (id: string) => void
onRemove: (id: string) => void
busyAction?: 'remove' | 'promote' | 'status' | null
}
export function UserProductCard({
product,
to,
onEdit,
onPromote,
onChangeStatus,
onRemove,
busyAction = null,
}: UserProductCardProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const titleFa = product.titleFa || product.title
const titleEn = product.titleEn?.trim() || ''
const title = isFa ? titleFa : titleEn || titleFa
const secondary = isFa ? titleEn : titleEn ? titleFa : ''
const city = isFa ? product.cityNameFa || product.cityName : product.cityName
const category = isFa
? product.categoryNameFa || product.categoryName
: product.categoryName
const owner = isFa ? product.ownerNameFa || product.ownerName : product.ownerName
const imageSrc = product.imageUrl?.trim() || ''
const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed
const isBusy = busyAction != null
const currency = (product.priceCurrency || 'IRT').toUpperCase()
const priceLabel =
product.price == null
? t('customerProducts.priceUnavailable')
: currency === 'IRT'
? formatIrtPrice(product.price)
: `${product.price.toLocaleString('en-US')} ${currency}`
useEffect(() => {
setImageFailed(false)
}, [imageSrc])
const statusLabel =
product.status === 'published'
? t('customerProducts.status.published')
: product.status === 'archived'
? t('customerProducts.status.archived')
: product.status === 'rejected'
? t('customerProducts.status.rejected')
: t('customerProducts.status.pending')
const mediaAndBody = (
<>
<div className={styles.imageWrap}>
{showImage ? (
<img
src={imageSrc}
alt={title}
className={styles.image}
loading="lazy"
onError={() => setImageFailed(true)}
/>
) : (
<div className={styles.imagePlaceholder} aria-hidden="true">
<ImageOff size={28} strokeWidth={1.5} />
</div>
)}
<span className={styles.badge} data-status={product.status}>
{statusLabel}
</span>
{product.promoted ? (
<span className={styles.promotedBadge}>{t('customerProducts.promoted')}</span>
) : null}
</div>
<div className={styles.body} dir={isFa ? 'rtl' : 'ltr'}>
<h3 className={styles.title}>{title}</h3>
{secondary ? <p className={styles.secondary}>{secondary}</p> : null}
{category ? <span className={styles.categoryChip}>{category}</span> : null}
{owner ? (
<p className={styles.owner}>
<User size={12} aria-hidden="true" />
<span>{owner}</span>
</p>
) : null}
<div className={styles.metaRow}>
<p className={styles.price}>{priceLabel}</p>
<p className={styles.location} dir={isFa ? 'rtl' : 'ltr'}>
<MapPin size={12} aria-hidden="true" />
<span>{city}</span>
</p>
</div>
</div>
</>
)
return (
<article className={styles.card} data-card-hover>
{to ? (
<Link to={to} className={styles.mainLink}>
{mediaAndBody}
</Link>
) : (
mediaAndBody
)}
<div className={styles.controls}>
<Tooltip label={t('customerProducts.edit')}>
<button
type="button"
onClick={() => onEdit(product.id)}
aria-label={t('customerProducts.edit')}
disabled={isBusy}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip
label={
product.promoted
? t('customerProducts.promoted')
: t('customerProducts.promote')
}
>
<button
type="button"
onClick={() => onPromote(product.id)}
aria-label={t('customerProducts.promote')}
disabled={isBusy || product.promoted}
>
<Megaphone size={16} />
</button>
</Tooltip>
<Tooltip label={t('customerProducts.statusChange')}>
<button
type="button"
onClick={() => onChangeStatus(product.id)}
aria-label={t('customerProducts.statusChange')}
disabled={isBusy}
>
<Check size={16} />
</button>
</Tooltip>
<Tooltip label={t('customerProducts.remove')}>
<button
type="button"
className={styles.danger}
onClick={() => onRemove(product.id)}
aria-label={t('customerProducts.remove')}
disabled={isBusy}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</article>
)
}
@@ -0,0 +1,65 @@
.hint {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.options {
display: grid;
gap: 8px;
}
.option {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.55);
cursor: pointer;
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
text-align: start;
transition: border-color 0.15s, background 0.15s;
}
.option:hover:not(:disabled) {
border-color: rgba(var(--primary-rgb) / 0.35);
}
.option[data-selected='true'] {
border-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
}
.option:disabled {
opacity: 0.65;
cursor: wait;
}
.radio {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 50%;
border: 2px solid rgba(148, 163, 184, 0.55);
background: transparent;
box-shadow: inset 0 0 0 0 transparent;
transition: border-color 0.15s, box-shadow 0.15s;
}
.option[data-selected='true'] .radio {
border-color: var(--primary);
box-shadow: inset 0 0 0 4px var(--primary);
}
.saving {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-ui);
}
@@ -0,0 +1,108 @@
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { UserProductStatus } from '../types/userProduct'
import { useT } from '../i18n/useT'
import modalStyles from './CategoryModal.module.css'
import styles from './UserProductStatusModal.module.css'
const STATUSES: UserProductStatus[] = [
'draft',
'published',
'rejected',
'archived',
]
interface UserProductStatusModalProps {
open: boolean
currentStatus: UserProductStatus
saving?: boolean
onClose: () => void
onSave: (status: UserProductStatus) => void
}
export function UserProductStatusModal({
open,
currentStatus,
saving = false,
onClose,
onSave,
}: UserProductStatusModalProps) {
const t = useT()
if (!open) return null
function statusLabel(value: UserProductStatus) {
if (value === 'draft') return t('customerProducts.status.pending')
if (value === 'published') return t('customerProducts.status.published')
if (value === 'rejected') return t('customerProducts.status.rejected')
return t('customerProducts.status.archived')
}
function handleSelect(value: UserProductStatus) {
if (saving) return
if (value === currentStatus) {
onClose()
return
}
onSave(value)
}
return createPortal(
<div
className={modalStyles.overlay}
role="presentation"
onClick={saving ? undefined : onClose}
>
<div
className={modalStyles.modal}
role="dialog"
aria-modal="true"
aria-labelledby="user-product-status-title"
onClick={(e) => e.stopPropagation()}
>
<div className={modalStyles.header}>
<h2 id="user-product-status-title" className={modalStyles.title}>
{t('customerProducts.statusModalTitle')}
</h2>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('customerProducts.form.cancel')}
disabled={saving}
>
<X size={18} />
</button>
</div>
<div className={modalStyles.form}>
<p className={styles.hint}>{t('customerProducts.statusModalHint')}</p>
<div className={styles.options} role="radiogroup">
{STATUSES.map((value) => {
const selected = value === currentStatus
return (
<button
key={value}
type="button"
className={styles.option}
role="radio"
aria-checked={selected}
data-selected={selected ? 'true' : undefined}
disabled={saving}
onClick={() => handleSelect(value)}
>
<span className={styles.radio} aria-hidden />
<span>{statusLabel(value)}</span>
</button>
)
})}
</div>
{saving ? (
<p className={styles.saving}>{t('customerProducts.form.submitting')}</p>
) : null}
</div>
</div>
</div>,
document.body,
)
}