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
+7
View File
@@ -23,6 +23,9 @@ import { StorePage } from './pages/StorePage'
import { StoreItemsPage } from './pages/StoreItemsPage'
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
import { CustomersPage } from './pages/CustomersPage'
import { CustomerProductsPage } from './pages/CustomerProductsPage'
import { CustomerProductDetailsPage } from './pages/CustomerProductDetailsPage'
import { AddCustomerProductPage } from './pages/AddCustomerProductPage'
import { OrdersPage } from './pages/OrdersPage'
import { LoginPage } from './pages/LoginPage'
import { ShoppingCardsPage } from './pages/ShoppingCardsPage'
@@ -83,6 +86,10 @@ function App() {
<Route path="store/cards" element={<ShoppingCardsPage />} />
<Route path="store/settings" element={<StoreSettingsPage />} />
<Route path="customers" element={<CustomersPage />} />
<Route path="customer-products" element={<CustomerProductsPage />} />
<Route path="customer-products/new" element={<AddCustomerProductPage />} />
<Route path="customer-products/:id/edit" element={<AddCustomerProductPage />} />
<Route path="customer-products/:id" element={<CustomerProductDetailsPage />} />
<Route path="blog" element={<BlogPage />} />
<Route path="blog/list" element={<BlogListPage />} />
<Route path="blog/detail/:id" element={<BlogDetailsPage />} />
@@ -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
</>
)}
{isRunning ? t('products.form.aiGenerating') : t('products.form.aiRun')}
</button>
</div>
</form>
</div>
</div>
,
</div>,
document.body,
)
}
@@ -11,6 +11,11 @@
padding: 20px;
}
/* Stack above VariationsModal / TechnicalFormModal (z-index 10001010) */
.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>
,
</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}>
<Tooltip label={t('categories.technical.removeField')}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => removeField(field.id)}
aria-label="Remove field"
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}>
<Tooltip label={t('categories.technical.removeOption')}>
<button
type="button"
className={styles.removeRowBtn}
onClick={() => removeOption(field.id, optionIndex)}
aria-label="Remove option"
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>
<div className={`${styles.actions} ${fieldStyles.actionsRow}`}>
<div className={fieldStyles.actionsMain}>
<button
type="button"
className={styles.submitBtn}
onClick={onSave}
disabled={!canSave}
>
{isSaving ? 'Saving…' : 'Save form'}
{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,
)
}
+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);
}
+63 -4
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">
{visible
? createPortal(
<span
className={`${styles.tip} ${styles.tipVisible}`}
role="tooltip"
style={{ top: coords.top, left: coords.left }}
>
{label}
</span>
</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,
)
}
+381
View File
@@ -28,6 +28,7 @@ const en = {
'nav.store.cards': 'Shopping Cards',
'nav.store.settings': 'Settings',
'nav.customers': 'Customers',
'nav.customerProducts': 'Customer products',
'nav.settings': 'Settings',
'nav.blog': 'Blog',
'nav.blog.overview': 'Overview',
@@ -241,6 +242,163 @@ const en = {
'Are you sure you want to delete "{name}"? This action cannot be undone.',
'products.list.addByAi': 'Add product by AI',
'products.list.addNew': 'Add new product',
'customerProducts.title': 'Customer products',
'customerProducts.subtitle': '{count} products submitted by customers.',
'customerProducts.empty': 'No customer products yet.',
'customerProducts.form.title': 'My Products',
'customerProducts.form.empty': 'You have not added any products yet.',
'customerProducts.form.add': 'Add product',
'customerProducts.form.addTitle': 'Add product',
'customerProducts.form.addSubtitle': 'Submit a new product for review.',
'customerProducts.form.addComingSoon': 'The add form will be designed next.',
'customerProducts.form.backToList': 'Back to my products',
'customerProducts.form.editTitle': 'Edit product',
'customerProducts.form.editSubtitle': 'Update your listing details, then save.',
'customerProducts.form.detailsTitle': 'Product details',
'customerProducts.form.detailsSubtitle': 'Review your submitted listing.',
'customerProducts.form.edit': 'Edit product',
'customerProducts.form.yes': 'Yes',
'customerProducts.form.editSoon': 'Edit product will be available soon.',
'customerProducts.form.remove': 'Remove product',
'customerProducts.form.removeConfirm': 'Remove this product from your stock?',
'customerProducts.form.removeSuccess': 'Product removed.',
'customerProducts.form.promote': 'Promote product',
'customerProducts.form.promoted': 'Promoted',
'customerProducts.form.promoteSuccess': 'Product promoted.',
'customerProducts.form.priceUnavailable': 'Price on request',
'customerProducts.form.status.pending': 'Pending',
'customerProducts.form.status.published': 'Approved',
'customerProducts.form.status.archived': 'Archived',
'customerProducts.form.stepperLabel': 'Add product steps',
'customerProducts.form.step.basics': 'Basics',
'customerProducts.form.step.basicsHint': 'Category, names, description, price, and location.',
'customerProducts.form.step.basicsPlaceholder': 'Basic fields will go here.',
'customerProducts.form.step.images': 'Images',
'customerProducts.form.step.imagesHint': 'Add a cropped thumbnail and gallery photos.',
'customerProducts.form.step.details': 'Details',
'customerProducts.form.step.detailsHint': 'Description and technical information for the selected category.',
'customerProducts.form.step.detailsPlaceholder': 'Details and technical fields will go here.',
'customerProducts.form.step.technical': 'Technical data',
'customerProducts.form.step.technicalHint':
'Choose condition, add optional notes, then fill the category technical form.',
'customerProducts.form.optional': '(optional)',
'customerProducts.form.loading': 'Loading products…',
'customerProducts.form.fields.category': 'Category',
'customerProducts.form.fields.selectCategory': 'Select category',
'customerProducts.form.fields.searchCategory': 'Search category…',
'customerProducts.form.fields.clearCategory': 'Clear category',
'customerProducts.form.fields.noCategories': 'No categories found',
'customerProducts.form.fields.titleFa': 'Name (FA)',
'customerProducts.form.fields.titleFaPlaceholder': 'Product name in Farsi',
'customerProducts.form.fields.titleEn': 'Name (EN)',
'customerProducts.form.fields.titleEnPlaceholder': 'Product name in English',
'customerProducts.form.fields.description': 'Description',
'customerProducts.form.fields.descriptionPlaceholder': 'Short description of your product',
'customerProducts.form.fields.price': 'Desired price',
'customerProducts.form.fields.priceSuggested': 'Your suggested price',
'customerProducts.form.fields.priceByExpert': 'I want an expert to set the price',
'customerProducts.form.fields.pricePlaceholder': 'e.g. 1,500,000',
'customerProducts.form.fields.priceUnit': 'Unit',
'customerProducts.form.fields.priceUnit.IRT': 'IRT',
'customerProducts.form.fields.priceUnit.USD': 'Dollar',
'customerProducts.form.fields.priceUnit.EUR': 'EURO',
'customerProducts.form.fields.priceUnit.AED': 'AED',
'customerProducts.form.fields.location': 'Location',
'customerProducts.form.fields.country': 'Country',
'customerProducts.form.fields.selectCountry': 'Select country',
'customerProducts.form.fields.province': 'Province',
'customerProducts.form.fields.selectProvince': 'Select province',
'customerProducts.form.fields.city': 'City',
'customerProducts.form.fields.selectCity': 'Select city',
'customerProducts.form.fields.searchCity': 'Search city…',
'customerProducts.form.fields.clearCity': 'Clear city',
'customerProducts.form.fields.noCities': 'No cities found',
'customerProducts.form.fields.deliveryNote': 'Pickup / delivery note',
'customerProducts.form.fields.deliveryNotePlaceholder': 'e.g. pickup only, evening delivery…',
'customerProducts.form.fields.district': 'District',
'customerProducts.form.fields.selectDistrict': 'Select district',
'customerProducts.form.fields.condition': 'Condition',
'customerProducts.form.fields.technicalNotes': 'Technical notes',
'customerProducts.form.fields.technicalNotesPlaceholder': 'Optional extra technical details…',
'customerProducts.form.condition.new': 'New',
'customerProducts.form.condition.stock': 'Stock',
'customerProducts.form.condition.needs_repair': 'Needs repair',
'customerProducts.form.condition.scrap': 'Scrap',
'customerProducts.form.images.thumbnail': 'Thumbnail',
'customerProducts.form.images.thumbnailUpload': 'Upload thumbnail',
'customerProducts.form.images.thumbnailHint': '3:2 landscape works best',
'customerProducts.form.images.thumbnailChange': 'Change thumbnail',
'customerProducts.form.images.thumbnailRemove': 'Remove thumbnail',
'customerProducts.form.images.zoom': 'Zoom',
'customerProducts.form.images.applyCrop': 'Apply crop',
'customerProducts.form.images.gallery': 'Gallery',
'customerProducts.form.images.galleryAdd': 'Add photos',
'customerProducts.form.images.galleryHint': 'You can select multiple images.',
'customerProducts.form.images.galleryRemove': 'Remove image {index}',
'customerProducts.form.technical.select': 'Select…',
'customerProducts.form.technical.needCategory': 'Choose a category in step 1 to load technical fields.',
'customerProducts.form.technical.empty': 'This category has no technical fields yet.',
'customerProducts.form.technical.categoryForm': 'Category technical form',
'customerProducts.form.technical.loading': 'Loading technical fields…',
'customerProducts.form.cancel': 'Cancel',
'customerProducts.form.next': 'Next',
'customerProducts.form.back': 'Back',
'customerProducts.form.submit': 'Submit',
'customerProducts.form.save': 'Save changes',
'customerProducts.form.submitting': 'Submitting…',
'customerProducts.form.submitSuccess': 'Product submitted for review.',
'customerProducts.form.updateSuccess': 'Product updated.',
'customerProducts.form.error.load': 'Unable to load your products.',
'customerProducts.form.error.loadDetail': 'Unable to load this product.',
'customerProducts.form.error.loadLocations': 'Unable to load locations.',
'customerProducts.form.error.loadCategories': 'Unable to load categories.',
'customerProducts.form.error.loadTechnicalForm': 'Unable to load the category technical form.',
'customerProducts.form.error.categoryRequired': 'Please select a category.',
'customerProducts.form.error.titleFaRequired': 'Please enter the Farsi name.',
'customerProducts.form.error.locationRequired': 'Please select country and city.',
'customerProducts.form.error.priceInvalid': 'Please enter a valid price.',
'customerProducts.form.error.conditionRequired': 'Please select a condition.',
'customerProducts.form.error.technicalRequired': 'Please fill required technical fields.',
'customerProducts.form.error.submit': 'Unable to submit the product.',
'customerProducts.form.error.update': 'Unable to update the product.',
'customerProducts.form.error.remove': 'Unable to remove the product.',
'customerProducts.form.error.promote': 'Unable to promote the product.',
'customerProducts.add': 'Add product',
'customerProducts.detailsTitle': 'Product details',
'customerProducts.detailsSubtitle': 'Review a customer-submitted listing.',
'customerProducts.backToList': 'Back to customer products',
'customerProducts.owner': 'Customer',
'customerProducts.location': 'Location',
'customerProducts.promote': 'Promote product',
'customerProducts.promoted': 'Promoted',
'customerProducts.promoteSuccess': 'Product promoted.',
'customerProducts.remove': 'Remove product',
'customerProducts.removeConfirm': 'Delete this customer product?',
'customerProducts.removeSuccess': 'Product deleted.',
'customerProducts.statusChange': 'Change status',
'customerProducts.statusModalTitle': 'Change product status',
'customerProducts.statusModalSave': 'Update status',
'customerProducts.statusModalHint': 'Choose how this listing should appear.',
'customerProducts.status.pending': 'Pending',
'customerProducts.status.published': 'Approved',
'customerProducts.status.rejected': 'Rejected',
'customerProducts.status.archived': 'Archived',
'customerProducts.statusSuccess': 'Status updated.',
'customerProducts.loading': 'Loading products…',
'customerProducts.error.load': 'Unable to load customer products.',
'customerProducts.error.loadDetail': 'Unable to load this product.',
'customerProducts.error.remove': 'Unable to delete the product.',
'customerProducts.error.promote': 'Unable to promote the product.',
'customerProducts.error.status': 'Unable to update status.',
'customerProducts.yes': 'Yes',
'customerProducts.edit': 'Edit product',
'customerProducts.approve': 'Approve product',
'customerProducts.editSoon': 'Customer product editing comes next.',
'customerProducts.toast.approved': 'Product approved.',
'customerProducts.priceUnavailable': 'Price on request',
'products.card.edit': 'Edit product',
'products.card.quickInfo': 'Quick info',
'products.card.variations': 'Manage variations',
@@ -301,6 +459,10 @@ const en = {
'categories.addSub': 'Add Sub Category',
'categories.edit': 'Edit Category',
'categories.addByAi': 'Fill categories by AI',
'categories.ai.title': 'Fill Categories with AI',
'categories.ai.subtitle':
'Edit the prompt below, then run it to generate and save a category tree.',
'categories.ai.error': 'Unable to generate categories with AI.',
'categories.deleteTitle': 'Delete Category',
'categories.deleteMessage':
'Are you sure you want to delete "{name}"? Subcategories will also be removed.',
@@ -325,6 +487,31 @@ const en = {
'categories.select.empty': 'No categories found',
'categories.select.clear': 'Clear selection',
'categories.technical.title': 'Technical Data Form',
'categories.technical.loading': 'Loading form...',
'categories.technical.empty':
'No fields yet. Add text, textarea, select, or multi-select inputs.',
'categories.technical.fieldLabel': 'Field label',
'categories.technical.inputType': 'Input type',
'categories.technical.required': 'Required',
'categories.technical.removeField': 'Remove field',
'categories.technical.options': 'Options',
'categories.technical.optionN': 'Option {n}',
'categories.technical.removeOption': 'Remove option',
'categories.technical.addOption': 'Add option',
'categories.technical.addField': 'Add field',
'categories.technical.generateAi': 'Generate with AI',
'categories.technical.save': 'Save form',
'categories.technical.saving': 'Saving…',
'categories.technical.type.text': 'Text',
'categories.technical.type.textarea': 'Textarea',
'categories.technical.type.select': 'Select',
'categories.technical.type.multi_select': 'Multi',
'categories.technical.ai.title': 'Generate Technical Form with AI',
'categories.technical.ai.subtitle':
'Edit the prompt below, then run it to fill the form fields.',
'categories.technical.ai.error': 'Unable to generate technical form with AI.',
'brands.page.subtitle': 'Manage product brands for your store catalog.',
'brands.loading': 'Loading brands...',
'brands.empty': 'No brands yet. Click + to add one.',
@@ -1009,6 +1196,7 @@ const en = {
'title.orders': 'My Orders',
'title.shoppingCards': 'Shopping Cards',
'title.customers': 'Customers',
'title.customerProducts': 'Customer products',
'title.blog': 'Blog',
'title.myBlogs': 'My Blogs',
'title.addBlog': 'Add New Blog',
@@ -1143,6 +1331,7 @@ const fa: Record<MessageKey, string> = {
'nav.store.cards': 'کارت‌های خرید',
'nav.store.settings': 'تنظیمات',
'nav.customers': 'مشتریان',
'nav.customerProducts': 'محصولات مشتریان',
'nav.settings': 'تنظیمات',
'nav.blog': 'بلاگ',
'nav.blog.overview': 'نمای کلی',
@@ -1356,6 +1545,163 @@ const fa: Record<MessageKey, string> = {
'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
'products.list.addByAi': 'افزودن محصول با هوش مصنوعی',
'products.list.addNew': 'افزودن محصول جدید',
'customerProducts.title': 'محصولات مشتریان',
'customerProducts.subtitle': '{count} محصول ثبت‌شده توسط مشتریان.',
'customerProducts.empty': 'هنوز محصول مشتری ثبت نشده است.',
'customerProducts.form.title': 'محصولات من',
'customerProducts.form.empty': 'هنوز محصولی ثبت نکرده‌اید.',
'customerProducts.form.add': 'افزودن محصول',
'customerProducts.form.addTitle': 'افزودن محصول',
'customerProducts.form.addSubtitle': 'محصول جدید را برای بررسی ارسال کنید.',
'customerProducts.form.addComingSoon': 'فرم افزودن در مرحله بعد طراحی می‌شود.',
'customerProducts.form.backToList': 'بازگشت به محصولات من',
'customerProducts.form.editTitle': 'ویرایش محصول',
'customerProducts.form.editSubtitle': 'جزئیات آگهی را به‌روز کنید و ذخیره کنید.',
'customerProducts.form.detailsTitle': 'جزئیات محصول',
'customerProducts.form.detailsSubtitle': 'جزئیات آگهی ثبت‌شده را ببینید.',
'customerProducts.form.edit': 'ویرایش محصول',
'customerProducts.form.yes': 'بله',
'customerProducts.form.editSoon': 'ویرایش محصول به‌زودی در دسترس خواهد بود.',
'customerProducts.form.remove': 'حذف محصول',
'customerProducts.form.removeConfirm': 'این محصول از موجودی شما حذف شود؟',
'customerProducts.form.removeSuccess': 'محصول حذف شد.',
'customerProducts.form.promote': 'پروموت محصول',
'customerProducts.form.promoted': 'پروموت شده',
'customerProducts.form.promoteSuccess': 'محصول پروموت شد.',
'customerProducts.form.priceUnavailable': 'قیمت اعلام نشده',
'customerProducts.form.status.pending': 'در انتظار تأیید',
'customerProducts.form.status.published': 'تأیید شده',
'customerProducts.form.status.archived': 'بایگانی',
'customerProducts.form.stepperLabel': 'مراحل افزودن محصول',
'customerProducts.form.step.basics': 'اطلاعات پایه',
'customerProducts.form.step.basicsHint': 'دسته‌بندی، نام، توضیحات، قیمت و موقعیت.',
'customerProducts.form.step.basicsPlaceholder': 'فیلدهای پایه اینجا قرار می‌گیرند.',
'customerProducts.form.step.images': 'تصاویر',
'customerProducts.form.step.imagesHint': 'تصویر شاخص با برش و گالری تصاویر را اضافه کنید.',
'customerProducts.form.step.details': 'جزئیات',
'customerProducts.form.step.detailsHint': 'توضیحات و اطلاعات فنی بر اساس دسته‌بندی انتخاب‌شده.',
'customerProducts.form.step.detailsPlaceholder': 'جزئیات و فیلدهای فنی اینجا قرار می‌گیرند.',
'customerProducts.form.step.technical': 'اطلاعات فنی',
'customerProducts.form.step.technicalHint':
'وضعیت را انتخاب کنید، یادداشت اختیاری بنویسید و فرم فنی دسته‌بندی را تکمیل کنید.',
'customerProducts.form.optional': '(اختیاری)',
'customerProducts.form.loading': 'در حال بارگذاری محصولات…',
'customerProducts.form.fields.category': 'دسته‌بندی',
'customerProducts.form.fields.selectCategory': 'انتخاب دسته‌بندی',
'customerProducts.form.fields.searchCategory': 'جستجوی دسته‌بندی…',
'customerProducts.form.fields.clearCategory': 'پاک کردن دسته‌بندی',
'customerProducts.form.fields.noCategories': 'دسته‌بندی‌ای پیدا نشد',
'customerProducts.form.fields.titleFa': 'نام (فارسی)',
'customerProducts.form.fields.titleFaPlaceholder': 'نام محصول به فارسی',
'customerProducts.form.fields.titleEn': 'نام (انگلیسی)',
'customerProducts.form.fields.titleEnPlaceholder': 'نام محصول به انگلیسی',
'customerProducts.form.fields.description': 'توضیحات',
'customerProducts.form.fields.descriptionPlaceholder': 'توضیح کوتاه درباره محصول',
'customerProducts.form.fields.price': 'قیمت پیشنهادی',
'customerProducts.form.fields.priceSuggested': 'قیمت پیشنهادی شما',
'customerProducts.form.fields.priceByExpert': 'می‌خواهم قیمت توسط کارشناس مشخص شود',
'customerProducts.form.fields.pricePlaceholder': 'مثلاً ۱٬۵۰۰٬۰۰۰',
'customerProducts.form.fields.priceUnit': 'واحد',
'customerProducts.form.fields.priceUnit.IRT': 'IRT',
'customerProducts.form.fields.priceUnit.USD': 'Dollar',
'customerProducts.form.fields.priceUnit.EUR': 'EURO',
'customerProducts.form.fields.priceUnit.AED': 'AED',
'customerProducts.form.fields.location': 'موقعیت',
'customerProducts.form.fields.country': 'کشور',
'customerProducts.form.fields.selectCountry': 'انتخاب کشور',
'customerProducts.form.fields.province': 'استان',
'customerProducts.form.fields.selectProvince': 'انتخاب استان',
'customerProducts.form.fields.city': 'شهر',
'customerProducts.form.fields.selectCity': 'انتخاب شهر',
'customerProducts.form.fields.searchCity': 'جستجوی شهر…',
'customerProducts.form.fields.clearCity': 'پاک کردن شهر',
'customerProducts.form.fields.noCities': 'شهری پیدا نشد',
'customerProducts.form.fields.deliveryNote': 'یادداشت تحویل / دریافت',
'customerProducts.form.fields.deliveryNotePlaceholder': 'مثلاً فقط حضوری، تحویل عصر…',
'customerProducts.form.fields.district': 'منطقه',
'customerProducts.form.fields.selectDistrict': 'انتخاب منطقه',
'customerProducts.form.fields.condition': 'وضعیت',
'customerProducts.form.fields.technicalNotes': 'توضیحات فنی',
'customerProducts.form.fields.technicalNotesPlaceholder': 'جزئیات فنی اختیاری…',
'customerProducts.form.condition.new': 'نو',
'customerProducts.form.condition.stock': 'استوک',
'customerProducts.form.condition.needs_repair': 'نیاز به تعمیر',
'customerProducts.form.condition.scrap': 'اوراق',
'customerProducts.form.images.thumbnail': 'تصویر شاخص',
'customerProducts.form.images.thumbnailUpload': 'آپلود تصویر شاخص',
'customerProducts.form.images.thumbnailHint': 'نسبت ۳:۲ افقی بهتر است',
'customerProducts.form.images.thumbnailChange': 'تغییر تصویر شاخص',
'customerProducts.form.images.thumbnailRemove': 'حذف تصویر شاخص',
'customerProducts.form.images.zoom': 'بزرگنمایی',
'customerProducts.form.images.applyCrop': 'اعمال برش',
'customerProducts.form.images.gallery': 'گالری',
'customerProducts.form.images.galleryAdd': 'افزودن عکس',
'customerProducts.form.images.galleryHint': 'می‌توانید چند تصویر انتخاب کنید.',
'customerProducts.form.images.galleryRemove': 'حذف تصویر {index}',
'customerProducts.form.technical.select': 'انتخاب کنید…',
'customerProducts.form.technical.needCategory': 'برای نمایش فیلدهای فنی، در مرحله ۱ دسته‌بندی را انتخاب کنید.',
'customerProducts.form.technical.empty': 'برای این دسته‌بندی هنوز فیلد فنی تعریف نشده است.',
'customerProducts.form.technical.categoryForm': 'فرم فنی دسته‌بندی',
'customerProducts.form.technical.loading': 'در حال بارگذاری فیلدهای فنی…',
'customerProducts.form.cancel': 'انصراف',
'customerProducts.form.next': 'بعدی',
'customerProducts.form.back': 'قبلی',
'customerProducts.form.submit': 'ارسال',
'customerProducts.form.save': 'ذخیره تغییرات',
'customerProducts.form.submitting': 'در حال ارسال…',
'customerProducts.form.submitSuccess': 'محصول برای بررسی ارسال شد.',
'customerProducts.form.updateSuccess': 'محصول به‌روز شد.',
'customerProducts.form.error.load': 'بارگذاری محصولات ممکن نشد.',
'customerProducts.form.error.loadDetail': 'بارگذاری این محصول ممکن نشد.',
'customerProducts.form.error.loadLocations': 'بارگذاری موقعیت‌ها ممکن نشد.',
'customerProducts.form.error.loadCategories': 'بارگذاری دسته‌بندی‌ها ممکن نشد.',
'customerProducts.form.error.loadTechnicalForm': 'بارگذاری فرم فنی دسته‌بندی ممکن نشد.',
'customerProducts.form.error.categoryRequired': 'لطفاً دسته‌بندی را انتخاب کنید.',
'customerProducts.form.error.titleFaRequired': 'لطفاً نام فارسی را وارد کنید.',
'customerProducts.form.error.locationRequired': 'لطفاً کشور و شهر را انتخاب کنید.',
'customerProducts.form.error.priceInvalid': 'لطفاً قیمت معتبر وارد کنید.',
'customerProducts.form.error.conditionRequired': 'لطفاً وضعیت را انتخاب کنید.',
'customerProducts.form.error.technicalRequired': 'لطفاً فیلدهای فنی الزامی را تکمیل کنید.',
'customerProducts.form.error.submit': 'ارسال محصول ممکن نشد.',
'customerProducts.form.error.update': 'به‌روزرسانی محصول ممکن نشد.',
'customerProducts.form.error.remove': 'حذف محصول ممکن نشد.',
'customerProducts.form.error.promote': 'پروموت محصول ممکن نشد.',
'customerProducts.add': 'افزودن محصول',
'customerProducts.detailsTitle': 'جزئیات محصول',
'customerProducts.detailsSubtitle': 'جزئیات محصول ثبت‌شده توسط مشتری را ببینید.',
'customerProducts.backToList': 'بازگشت به محصولات مشتریان',
'customerProducts.owner': 'مشتری',
'customerProducts.location': 'موقعیت',
'customerProducts.promote': 'پروموت محصول',
'customerProducts.promoted': 'پروموت شده',
'customerProducts.promoteSuccess': 'محصول پروموت شد.',
'customerProducts.remove': 'حذف محصول',
'customerProducts.removeConfirm': 'این محصول مشتری حذف شود؟',
'customerProducts.removeSuccess': 'محصول حذف شد.',
'customerProducts.statusChange': 'تغییر وضعیت',
'customerProducts.statusModalTitle': 'تغییر وضعیت محصول',
'customerProducts.statusModalSave': 'به‌روزرسانی وضعیت',
'customerProducts.statusModalHint': 'وضعیت نمایش این آگهی را انتخاب کنید.',
'customerProducts.status.pending': 'در انتظار تأیید',
'customerProducts.status.published': 'تأیید شده',
'customerProducts.status.rejected': 'رد شده',
'customerProducts.status.archived': 'بایگانی',
'customerProducts.statusSuccess': 'وضعیت به‌روز شد.',
'customerProducts.loading': 'در حال بارگذاری محصولات…',
'customerProducts.error.load': 'بارگذاری محصولات مشتریان ممکن نشد.',
'customerProducts.error.loadDetail': 'بارگذاری این محصول ممکن نشد.',
'customerProducts.error.remove': 'حذف محصول ممکن نشد.',
'customerProducts.error.promote': 'پروموت محصول ممکن نشد.',
'customerProducts.error.status': 'به‌روزرسانی وضعیت ممکن نشد.',
'customerProducts.yes': 'بله',
'customerProducts.edit': 'ویرایش محصول',
'customerProducts.approve': 'تأیید محصول',
'customerProducts.editSoon': 'ویرایش محصول مشتری در مرحله بعد اضافه می‌شود.',
'customerProducts.toast.approved': 'محصول تأیید شد.',
'customerProducts.priceUnavailable': 'قیمت اعلام نشده',
'products.card.edit': 'ویرایش محصول',
'products.card.quickInfo': 'اطلاعات سریع',
'products.card.variations': 'مدیریت تنوع‌ها',
@@ -1416,6 +1762,10 @@ const fa: Record<MessageKey, string> = {
'categories.addSub': 'افزودن زیردسته',
'categories.edit': 'ویرایش دسته‌بندی',
'categories.addByAi': 'پر کردن دسته‌ها با هوش مصنوعی',
'categories.ai.title': 'پر کردن دسته‌ها با هوش مصنوعی',
'categories.ai.subtitle':
'پرامپت زیر را ویرایش کنید، سپس اجرا کنید تا درخت دسته‌بندی ساخته و ذخیره شود.',
'categories.ai.error': 'تولید دسته‌بندی با هوش مصنوعی ممکن نشد.',
'categories.deleteTitle': 'حذف دسته‌بندی',
'categories.deleteMessage':
'آیا از حذف «{name}» مطمئن هستید؟ زیردسته‌ها نیز حذف می‌شوند.',
@@ -1440,6 +1790,31 @@ const fa: Record<MessageKey, string> = {
'categories.select.empty': 'دسته‌بندی‌ای یافت نشد',
'categories.select.clear': 'پاک کردن انتخاب',
'categories.technical.title': 'فرم اطلاعات فنی',
'categories.technical.loading': 'در حال بارگذاری فرم...',
'categories.technical.empty':
'هنوز فیلدی نیست. فیلد متنی، چندخطی، انتخابی یا چندانتخابی اضافه کنید.',
'categories.technical.fieldLabel': 'برچسب فیلد',
'categories.technical.inputType': 'نوع ورودی',
'categories.technical.required': 'الزامی',
'categories.technical.removeField': 'حذف فیلد',
'categories.technical.options': 'گزینه‌ها',
'categories.technical.optionN': 'گزینه {n}',
'categories.technical.removeOption': 'حذف گزینه',
'categories.technical.addOption': 'افزودن گزینه',
'categories.technical.addField': 'افزودن فیلد',
'categories.technical.generateAi': 'تولید با هوش مصنوعی',
'categories.technical.save': 'ذخیره فرم',
'categories.technical.saving': 'در حال ذخیره…',
'categories.technical.type.text': 'متنی',
'categories.technical.type.textarea': 'چندخطی',
'categories.technical.type.select': 'انتخابی',
'categories.technical.type.multi_select': 'چندانتخابی',
'categories.technical.ai.title': 'تولید فرم فنی با هوش مصنوعی',
'categories.technical.ai.subtitle':
'پرامپت زیر را ویرایش کنید، سپس اجرا کنید تا فیلدهای فرم پر شوند.',
'categories.technical.ai.error': 'تولید فرم فنی با هوش مصنوعی ممکن نشد.',
'brands.page.subtitle': 'برندهای محصول فروشگاه خود را مدیریت کنید.',
'brands.loading': 'در حال بارگذاری برندها...',
'brands.empty': 'هنوز برندی نیست. برای افزودن روی + کلیک کنید.',
@@ -2123,6 +2498,7 @@ const fa: Record<MessageKey, string> = {
'title.orders': 'سفارش‌های من',
'title.shoppingCards': 'کارت‌های خرید',
'title.customers': 'مشتریان',
'title.customerProducts': 'محصولات مشتریان',
'title.blog': 'بلاگ',
'title.myBlogs': 'بلاگ‌های من',
'title.addBlog': 'افزودن بلاگ',
@@ -2255,6 +2631,7 @@ const BREADCRUMB_LABEL_KEYS: Record<string, MessageKey> = {
'Shipping Fees': 'nav.store.shipping',
'Shopping Cards': 'nav.store.cards',
Customers: 'nav.customers',
'Customer products': 'nav.customerProducts',
Blog: 'nav.blog',
'My Blogs': 'nav.blog.list',
'Add New Blog': 'nav.blog.new',
@@ -2323,6 +2700,10 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/store/settings', labels: [t('title.store'), t('title.settings')] },
{ match: '/store', labels: [t('title.store')] },
{ match: '/customers', labels: [t('title.customers')] },
{ match: '/customer-products/new', labels: [t('title.customerProducts'), t('customerProducts.add')] },
{ match: /^\/customer-products\/[^/]+\/edit$/, labels: [t('title.customerProducts'), t('customerProducts.edit')] },
{ match: /^\/customer-products\/[^/]+$/, labels: [t('title.customerProducts'), t('customerProducts.detailsTitle')] },
{ match: '/customer-products', labels: [t('title.customerProducts')] },
{ match: '/blog/list', labels: [t('title.blog'), t('title.myBlogs')] },
{ match: /^\/blog\/detail\/[^/]+$/, labels: [t('title.blog'), t('title.blogDetails')] },
{ match: '/blog/new', labels: [t('title.blog'), t('title.addBlog')] },
@@ -0,0 +1,605 @@
.root {
display: flex;
flex-direction: column;
gap: 0;
font-family: var(--font-ui);
}
.shell {
display: flex;
align-items: stretch;
gap: 12px;
}
.iconRail {
width: 128px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 16px 12px;
border: 1px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius);
background: color-mix(in srgb, var(--glass-bg) 70%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-sizing: border-box;
}
.iconRailInner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
text-align: center;
}
.iconRailGlyph {
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
margin-bottom: 2px;
}
.iconRailLabelFa {
display: block;
width: 100%;
font-size: 12px;
font-weight: 700;
font-family: var(--font-ui);
color: var(--text-primary);
line-height: 1.2;
text-align: center;
}
.iconRailLabelEn {
display: block;
width: 100%;
margin-top: -6px;
font-size: 9px;
font-weight: 700;
font-family: var(--font-en);
color: var(--text-muted);
line-height: 1.25;
letter-spacing: 0.1em;
/* Compensate letter-spacing so centered Latin text doesnt drift */
padding-inline-start: 0.1em;
text-transform: uppercase;
text-align: center;
direction: ltr;
unicode-bidi: isolate;
}
.card {
flex: 1;
min-width: 0;
background: var(--glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
padding: 28px 28px 24px;
}
@media (max-width: 640px) {
.shell {
flex-direction: column;
}
.iconRail {
width: 100%;
min-height: 88px;
}
.card {
padding: 22px 18px 20px;
}
}
.stepper {
display: flex;
align-items: flex-end;
width: 100%;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
}
.stepGroup {
display: flex;
align-items: flex-end;
flex: 1;
min-width: 0;
}
.stepGroup:last-child {
flex: 0 0 auto;
}
.stepUnit {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.stepDot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
background: rgba(148, 163, 184, 0.2);
color: var(--text-muted);
border: 2px solid transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.stepLabel {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
transition: color 0.2s;
}
.stepActive .stepDot {
background: rgba(var(--primary-rgb) / 0.15);
color: var(--primary);
border-color: var(--primary);
}
.stepActive .stepLabel {
color: var(--primary);
}
.stepDone .stepDot {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.stepDone .stepLabel {
color: var(--text-secondary);
}
.connector {
flex: 1;
height: 2px;
min-width: 24px;
margin: 0 8px 13px;
background: rgba(148, 163, 184, 0.3);
border-radius: 1px;
transition: background 0.2s;
}
.connectorDone {
background: var(--primary);
}
.body {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
}
.stepTitle {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.stepDesc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
.form {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 8px;
}
.fieldRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.priceRow {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px;
align-items: end;
}
.checkRow {
display: inline-flex;
align-items: center;
align-self: end;
gap: 8px;
height: var(--field-height);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
box-sizing: border-box;
}
.checkRow input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
flex-shrink: 0;
margin: 0;
}
.fieldRowTriple {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.locationRow {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px;
align-items: end;
}
.col2 {
grid-column: span 2;
}
.col3 {
grid-column: span 3;
}
.col4 {
grid-column: span 4;
}
.col6 {
grid-column: span 6;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.optional {
font-weight: 400;
color: var(--text-muted);
}
.field input,
.field select,
.field textarea {
width: 100%;
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.field input,
.field select {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
}
.field textarea {
padding: var(--field-padding-y) var(--field-padding-x);
resize: vertical;
min-height: 96px;
line-height: 1.5;
}
.field input::placeholder,
.field textarea::placeholder {
font-family: var(--font-ui);
color: var(--text-muted);
opacity: 1;
}
.field select {
appearance: none;
-webkit-appearance: none;
padding-inline-end: var(--select-padding-end);
background-color: var(--surface);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--select-arrow-offset) center;
background-size: var(--select-arrow-size);
cursor: pointer;
}
:global([dir='rtl']) .field select {
background-position: left var(--select-arrow-offset) center;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.sectionDivider {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sectionDivider::before,
.sectionDivider::after {
content: '';
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.25);
}
.thumbnailBlock {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.thumbnailBlock > .field {
grid-column: 5 / span 4;
}
@media (max-width: 720px) {
.thumbnailBlock > .field {
grid-column: 1 / -1;
}
}
.chipGrid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
padding: 7px 14px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-secondary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 50px;
transition: all 0.15s;
}
.chip:hover {
border-color: var(--primary);
color: var(--primary);
}
.chipSelected {
background: rgba(var(--primary-rgb) / 0.12);
border-color: var(--primary);
color: var(--primary);
}
.conditionFieldset {
margin: 0;
padding: 0;
border: none;
}
.conditionFieldset legend {
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.radioGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.radioCard {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: var(--field-height);
padding: var(--field-padding-y) 8px;
font-size: 12px;
font-family: var(--font-ui);
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
text-align: center;
}
.radioCard:hover {
border-color: var(--primary);
}
.radioCard:has(input:checked) {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.16);
}
.radioCard input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary);
flex-shrink: 0;
}
.radioCard span {
min-width: 0;
line-height: 1.25;
}
@media (max-width: 720px) {
.radioGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.inlineStatus {
margin: 4px 0 0;
font-size: 13px;
color: var(--text-muted);
}
.placeholder {
margin-top: 8px;
padding: 28px 20px;
text-align: center;
font-size: 13px;
color: var(--text-muted);
background: color-mix(in srgb, var(--surface) 70%, transparent);
border: 1px dashed var(--glass-border);
border-radius: var(--radius-sm);
}
.error {
margin-top: 8px;
font-size: 13px;
color: #fca5a5;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.28);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.ghostBtn,
.secondaryBtn,
.primaryBtn {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 600;
line-height: 1.4;
border-radius: var(--radius-sm);
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s, background 0.2s, color 0.2s;
white-space: nowrap;
}
.ghostBtn {
padding: 10px 4px;
color: var(--text-secondary);
text-decoration: none;
background: transparent;
}
.ghostBtn:hover {
color: var(--primary);
}
.secondaryBtn {
padding: 10px 18px;
color: var(--text-primary);
background: rgba(148, 163, 184, 0.16);
border: 1px solid var(--glass-border);
}
.secondaryBtn:hover {
background: rgba(148, 163, 184, 0.24);
}
.primaryBtn {
margin-inline-start: auto;
padding: 10px 22px;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
}
.primaryBtn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
}
@media (max-width: 720px) {
.fieldRow,
.fieldRowTriple,
.priceRow,
.locationRow {
grid-template-columns: 1fr;
}
.col2,
.col3,
.col4,
.col6 {
grid-column: auto;
}
}
@media (max-width: 520px) {
.stepLabel {
font-size: 11px;
}
.connector {
min-width: 12px;
margin: 0 4px 13px;
}
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.primaryBtn,
.secondaryBtn,
.ghostBtn {
width: 100%;
margin-inline-start: 0;
text-align: center;
}
}
@@ -0,0 +1,891 @@
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'
import { useToast } from '../context/ToastContext'
import pageStyles from '../components/PageContent.module.css'
import { CitySearchSelect } from '../components/CitySearchSelect'
import { CategorySearchSelect } from '../components/CategorySearchSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { useT } from '../i18n/useT'
import { translate } from '../i18n/messages'
import { ApiError, isAbortError } from '../lib/api'
import {
listCitiesByCountrySlug,
listCountries,
} from '../services/citiesService'
import {
buildTechnicalValuesPayload,
createUserProduct,
getUserProduct,
getUserProductCategoryTechnicalForm,
listUserProductCategories,
updateUserProduct,
type TechnicalFormField,
type TechnicalFormValues,
type UserProductCategoryOption,
type UserProductCondition,
type UserProductPriceCurrency,
type UserProductTechnicalValueInput,
} from '../services/userProductsService'
import { resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import styles from './AddCustomerProductPage.module.css'
type StepId = 1 | 2 | 3
const PRICE_UNITS: UserProductPriceCurrency[] = ['IRT', 'USD', 'EUR', 'AED']
const CONDITIONS: UserProductCondition[] = [
'new',
'stock',
'needs_repair',
'scrap',
]
function fieldLabel(field: TechnicalFormField) {
return field.label
}
function optionLabel(option: TechnicalFormField['options'][number]) {
return option.label
}
function mapTechnicalValues(
items: UserProductTechnicalValueInput[],
): TechnicalFormValues {
const values: TechnicalFormValues = {}
for (const item of items) {
if (item.textValue != null) {
values[item.fieldId] = item.textValue
continue
}
if (item.optionId) {
values[item.fieldId] = item.optionId
continue
}
if (item.optionIds?.length) {
values[item.fieldId] = item.optionIds
}
}
return values
}
function isCondition(value: string | null | undefined): value is UserProductCondition {
return (
value === 'new' ||
value === 'stock' ||
value === 'needs_repair' ||
value === 'scrap'
)
}
function isPriceCurrency(
value: string | null | undefined,
): value is UserProductPriceCurrency {
return (
value === 'IRT' || value === 'USD' || value === 'EUR' || value === 'AED'
)
}
export function AddCustomerProductPage() {
const t = useT()
const { locale } = useLocale()
const navigate = useNavigate()
const { id } = useParams<{ id?: string }>()
const isEdit = Boolean(id)
const { showToast } = useToast()
const [loaded, setLoaded] = useState(!isEdit)
const [step, setStep] = useState<StepId>(1)
const [stepError, setStepError] = useState('')
const [submitting, setSubmitting] = useState(false)
const pendingTechnicalValuesRef = useRef<TechnicalFormValues | null>(null)
const [categories, setCategories] = useState<UserProductCategoryOption[]>([])
const [loadingCategories, setLoadingCategories] = useState(false)
const [categoryId, setCategoryId] = useState('')
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [description, setDescription] = useState('')
const [priceInput, setPriceInput] = useState('')
const [priceUnit, setPriceUnit] = useState<UserProductPriceCurrency>('IRT')
const [priceByExpert, setPriceByExpert] = useState(false)
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('')
const [loadingLocations, setLoadingLocations] = useState(false)
const [thumbnail, setThumbnail] = useState<string | null>(null)
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [gallery, setGallery] = useState<string[]>([])
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [condition, setCondition] = useState<UserProductCondition>('new')
const [technicalNotes, setTechnicalNotes] = useState('')
const [technicalFields, setTechnicalFields] = useState<TechnicalFormField[]>([])
const [technicalValues, setTechnicalValues] = useState<TechnicalFormValues>({})
const [loadingTechnicalForm, setLoadingTechnicalForm] = useState(false)
const steps: { id: StepId; label: string }[] = [
{ id: 1, label: t('customerProducts.form.step.basics') },
{ id: 2, label: t('customerProducts.form.step.images') },
{ id: 3, label: t('customerProducts.form.step.technical') },
]
const stepIcon =
step === 1 ? (
<ClipboardList size={40} strokeWidth={1.4} />
) : step === 2 ? (
<Images size={40} strokeWidth={1.4} />
) : (
<SlidersHorizontal size={40} strokeWidth={1.4} />
)
const stepLabelKey =
step === 1
? 'customerProducts.form.step.basics'
: step === 2
? 'customerProducts.form.step.images'
: 'customerProducts.form.step.technical'
const stepNameFa = translate('fa', stepLabelKey)
const stepNameEn = translate('en', stepLabelKey)
useEffect(() => {
const controller = new AbortController()
setLoadingLocations(true)
void listCountries(controller.signal)
.then((items) => {
if (!controller.signal.aborted) setCountries(items)
})
.catch((err) => {
if (!isAbortError(err)) setStepError(t('customerProducts.form.error.loadLocations'))
})
.finally(() => {
if (!controller.signal.aborted) setLoadingLocations(false)
})
return () => controller.abort()
}, [t])
useEffect(() => {
const controller = new AbortController()
setLoadingCategories(true)
void listUserProductCategories(controller.signal)
.then((response) => {
if (!controller.signal.aborted) setCategories(response.items)
})
.catch((err) => {
if (!isAbortError(err)) setStepError(t('customerProducts.form.error.loadCategories'))
})
.finally(() => {
if (!controller.signal.aborted) setLoadingCategories(false)
})
return () => controller.abort()
}, [t])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadProduct() {
setStepError('')
try {
const response = await getUserProduct(id!, controller.signal)
if (controller.signal.aborted) return
const product = response.product
pendingTechnicalValuesRef.current = mapTechnicalValues(
product.technicalValues ?? [],
)
setCategoryId(product.categoryId ?? '')
setTitleFa(product.titleFa ?? product.title ?? '')
setTitleEn(product.titleEn ?? '')
setDescription(product.description ?? '')
setPriceInput(
product.price != null ? formatIrtInput(String(product.price)) : '',
)
setPriceUnit(
isPriceCurrency(product.priceCurrency)
? product.priceCurrency
: 'IRT',
)
setPriceByExpert(product.priceByExpert === true)
setCountrySlug(product.countrySlug)
setCountryId(product.countryId)
setCityId(product.cityId)
setDeliveryNote(product.deliveryNote ?? '')
setThumbnail(product.imageUrl)
setFeaturedMediaId(product.featuredMediaId)
setGallery((product.images ?? []).map((item) => item.url))
setGalleryMediaIds(product.galleryMediaIds ?? [])
setCondition(isCondition(product.condition) ? product.condition : 'new')
setTechnicalNotes(product.technicalNotes ?? '')
if (product.countrySlug) {
setCities(await listCitiesByCountrySlug(product.countrySlug))
}
if (!controller.signal.aborted) setLoaded(true)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
showToast(
err instanceof ApiError ? err.message : t('customerProducts.form.error.load'),
'error',
)
navigate('/customer-products', { replace: true })
}
}
void loadProduct()
return () => controller.abort()
}, [isEdit, id, navigate, showToast, t])
useEffect(() => {
if (!pendingTechnicalValuesRef.current) {
setTechnicalValues({})
}
setTechnicalFields([])
if (!categoryId) return
const controller = new AbortController()
setLoadingTechnicalForm(true)
void getUserProductCategoryTechnicalForm(categoryId, controller.signal)
.then((response) => {
if (controller.signal.aborted) return
setTechnicalFields(response.form?.fields ?? [])
if (pendingTechnicalValuesRef.current) {
setTechnicalValues(pendingTechnicalValuesRef.current)
pendingTechnicalValuesRef.current = null
}
})
.catch((err) => {
if (!isAbortError(err)) {
setTechnicalFields([])
setStepError(t('customerProducts.form.error.loadTechnicalForm'))
}
})
.finally(() => {
if (!controller.signal.aborted) setLoadingTechnicalForm(false)
})
return () => controller.abort()
}, [categoryId, t])
async function handleCountryChange(nextSlug: string) {
setCountrySlug(nextSlug)
setCountryId(countries.find((item) => item.slug === nextSlug)?.id ?? '')
setCityId('')
setCities([])
if (!nextSlug) return
setLoadingLocations(true)
try {
setCities(await listCitiesByCountrySlug(nextSlug))
} catch {
setStepError(t('customerProducts.form.error.loadLocations'))
} finally {
setLoadingLocations(false)
}
}
function validateStep1() {
if (!categoryId) return t('customerProducts.form.error.categoryRequired')
if (!titleFa.trim()) return t('customerProducts.form.error.titleFaRequired')
if (!countryId || !cityId) {
return t('customerProducts.form.error.locationRequired')
}
const price = parseIrtInput(priceInput)
if (priceInput.trim() && (price === null || price < 0)) {
return t('customerProducts.form.error.priceInvalid')
}
return ''
}
function validateStep3() {
if (!condition) return t('customerProducts.form.error.conditionRequired')
for (const field of technicalFields) {
if (!field.isRequired) continue
const value = technicalValues[field.id]
const ok =
field.type === 'multi_select'
? Array.isArray(value) && value.length > 0
: typeof value === 'string' && value.trim().length > 0
if (!ok) {
return t('customerProducts.form.error.technicalRequired')
}
}
return ''
}
function goNext() {
setStepError('')
if (step === 1) {
const error = validateStep1()
if (error) {
setStepError(error)
return
}
setStep(2)
return
}
if (step === 2) setStep(3)
}
function goBack() {
setStepError('')
if (step === 2) setStep(1)
if (step === 3) setStep(2)
}
async function handleSubmit() {
setStepError('')
const validationError = validateStep3()
if (validationError) {
setStepError(validationError)
return
}
const price = parseIrtInput(priceInput)
setSubmitting(true)
try {
let nextFeaturedMediaId = featuredMediaId
if (thumbnail?.startsWith('data:')) {
nextFeaturedMediaId = await resolveDataUrlToMediaId(
thumbnail,
'user-product-thumbnail.jpg',
featuredMediaId,
)
} else if (!thumbnail) {
nextFeaturedMediaId = null
}
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(
gallery,
galleryMediaIds,
)
const payload = {
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
description: description.trim() || undefined,
categoryId,
price: price ?? undefined,
priceCurrency: priceUnit,
priceByExpert,
countryId,
cityId,
deliveryNote: deliveryNote.trim() || undefined,
condition,
technicalNotes: technicalNotes.trim() || undefined,
technicalValues: buildTechnicalValuesPayload(technicalFields, technicalValues),
featuredMediaId: nextFeaturedMediaId || undefined,
galleryMediaIds: nextGalleryMediaIds,
}
if (isEdit && id) {
await updateUserProduct(id, payload)
showToast(t('customerProducts.form.updateSuccess'), 'success')
} else {
await createUserProduct(payload)
showToast(t('customerProducts.form.submitSuccess'), 'success')
}
navigate('/customer-products')
} catch (err) {
setStepError(
err instanceof ApiError
? err.message
: isEdit
? t('customerProducts.form.error.update')
: t('customerProducts.form.error.submit'),
)
} finally {
setSubmitting(false)
}
}
function setTechnicalField(fieldId: string, value: string | string[]) {
setTechnicalValues((prev) => ({ ...prev, [fieldId]: value }))
}
function toggleMultiOption(fieldId: string, optionId: string) {
setTechnicalValues((prev) => {
const current = prev[fieldId]
const selected = Array.isArray(current) ? current : []
const next = selected.includes(optionId)
? selected.filter((item) => item !== optionId)
: [...selected, optionId]
return { ...prev, [fieldId]: next }
})
}
function renderTechnicalField(field: TechnicalFormField) {
const value = technicalValues[field.id]
const id = `tech-${field.id}`
if (field.type === 'textarea') {
return (
<textarea
id={id}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
rows={4}
/>
)
}
if (field.type === 'select') {
return (
<select
id={id}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
>
<option value="">{t('customerProducts.form.technical.select')}</option>
{field.options.map((option) => (
<option key={option.id} value={option.id}>
{optionLabel(option)}
</option>
))}
</select>
)
}
if (field.type === 'multi_select') {
const selected = Array.isArray(value) ? value : []
return (
<div className={styles.chipGrid}>
{field.options.map((option) => (
<button
key={option.id}
type="button"
className={`${styles.chip} ${selected.includes(option.id) ? styles.chipSelected : ''}`}
onClick={() => toggleMultiOption(field.id, option.id)}
>
{optionLabel(option)}
</button>
))}
</div>
)
}
return (
<input
id={id}
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
/>
)
}
if (!loaded) {
return (
<main className={pageStyles.content}>
<p className={styles.inlineStatus}>{t('customerProducts.form.loading')}</p>
</main>
)
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('customerProducts.title'), href: '/customer-products' },
{
label: isEdit
? t('customerProducts.edit')
: t('customerProducts.add'),
},
]}
/>
<div className={styles.shell}>
<aside className={styles.iconRail} aria-hidden>
<div className={styles.iconRailInner}>
<span className={styles.iconRailGlyph}>{stepIcon}</span>
<span className={styles.iconRailLabelFa}>{stepNameFa}</span>
<span className={styles.iconRailLabelEn}>{stepNameEn}</span>
</div>
</aside>
<div className={styles.card}>
<div className={styles.root}>
<nav className={styles.stepper} aria-label={t('customerProducts.form.stepperLabel')}>
{steps.map((item, index) => {
const isDone = step > item.id
const isActive = step === item.id
const connectorDone = step > item.id
return (
<div key={item.id} className={styles.stepGroup}>
<div
className={[
styles.stepUnit,
isActive ? styles.stepActive : '',
isDone ? styles.stepDone : '',
]
.filter(Boolean)
.join(' ')}
>
<span className={styles.stepLabel}>{item.label}</span>
<span className={styles.stepDot} aria-hidden>
{isDone ? <Check size={14} /> : index + 1}
</span>
</div>
{index < steps.length - 1 && (
<div
className={[
styles.connector,
connectorDone ? styles.connectorDone : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden
/>
)}
</div>
)
})}
</nav>
<div className={styles.body} key={step}>
{step === 1 && (
<>
<h2 className={styles.stepTitle}>
{isEdit ? t('customerProducts.form.editTitle') : t('customerProducts.form.step.basics')}
</h2>
<p className={styles.stepDesc}>
{isEdit
? t('customerProducts.form.editSubtitle')
: t('customerProducts.form.step.basicsHint')}
</p>
<div className={styles.form}>
<div className={styles.field}>
<label htmlFor="add-category">{t('customerProducts.form.fields.category')}</label>
<CategorySearchSelect
id="add-category"
options={categories}
value={categoryId}
onChange={setCategoryId}
disabled={loadingCategories}
placeholder={t('customerProducts.form.fields.searchCategory')}
/>
</div>
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="add-title-fa">{t('customerProducts.form.fields.titleFa')}</label>
<input
id="add-title-fa"
type="text"
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
placeholder={t('customerProducts.form.fields.titleFaPlaceholder')}
/>
</div>
<div className={styles.field}>
<label htmlFor="add-title-en">{t('customerProducts.form.fields.titleEn')}</label>
<input
id="add-title-en"
type="text"
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
placeholder={t('customerProducts.form.fields.titleEnPlaceholder')}
dir="ltr"
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="add-description">{t('customerProducts.form.fields.description')}</label>
<textarea
id="add-description"
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('customerProducts.form.fields.descriptionPlaceholder')}
/>
</div>
<div className={styles.priceRow}>
<label
className={`${styles.checkRow} ${styles.col6}`}
htmlFor="add-price-by-expert"
>
<input
id="add-price-by-expert"
type="checkbox"
checked={priceByExpert}
onChange={(e) => setPriceByExpert(e.target.checked)}
/>
<span>{t('customerProducts.form.fields.priceByExpert')}</span>
</label>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="add-price">
{priceByExpert
? t('customerProducts.form.fields.priceSuggested')
: t('customerProducts.form.fields.price')}
</label>
<input
id="add-price"
type="text"
inputMode="numeric"
value={priceInput}
onChange={(e) => setPriceInput(formatIrtInput(e.target.value))}
placeholder={t('customerProducts.form.fields.pricePlaceholder')}
dir="ltr"
/>
</div>
<div className={`${styles.field} ${styles.col2}`}>
<label htmlFor="add-price-unit">{t('customerProducts.form.fields.priceUnit')}</label>
<select
id="add-price-unit"
value={priceUnit}
onChange={(e) =>
setPriceUnit(e.target.value as UserProductPriceCurrency)
}
>
{PRICE_UNITS.map((unit) => (
<option key={unit} value={unit}>
{t(`customerProducts.form.fields.priceUnit.${unit}`)}
</option>
))}
</select>
</div>
</div>
<div className={styles.sectionDivider}>
<span>{t('customerProducts.form.fields.location')}</span>
</div>
<div className={styles.locationRow}>
<div className={`${styles.field} ${styles.col2}`}>
<label htmlFor="add-country">{t('customerProducts.form.fields.country')}</label>
<select
id="add-country"
value={countrySlug}
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>
</div>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="add-city">{t('customerProducts.form.fields.city')}</label>
<CitySearchSelect
id="add-city"
options={cities}
value={cityId}
onChange={setCityId}
disabled={!countrySlug || loadingLocations}
placeholder={t('customerProducts.form.fields.searchCity')}
/>
</div>
<div className={`${styles.field} ${styles.col6}`}>
<label htmlFor="add-delivery-note">
{t('customerProducts.form.fields.deliveryNote')}
<span className={styles.optional}> {t('customerProducts.form.optional')}</span>
</label>
<input
id="add-delivery-note"
type="text"
value={deliveryNote}
onChange={(e) => setDeliveryNote(e.target.value)}
placeholder={t('customerProducts.form.fields.deliveryNotePlaceholder')}
/>
</div>
</div>
</div>
</>
)}
{step === 2 && (
<>
<h2 className={styles.stepTitle}>{t('customerProducts.form.step.images')}</h2>
<p className={styles.stepDesc}>{t('customerProducts.form.step.imagesHint')}</p>
<div className={styles.form}>
<div className={styles.thumbnailBlock}>
<div className={styles.field}>
<label>{t('customerProducts.form.images.thumbnail')}</label>
<ImageCropper
value={thumbnail}
onChange={(value) => {
setThumbnail(value)
if (!value || value.startsWith('data:')) {
setFeaturedMediaId(null)
}
}}
aspect={1}
hint={t('customerProducts.form.images.thumbnailHint')}
/>
</div>
</div>
<div className={styles.sectionDivider}>
<span>{t('customerProducts.form.images.gallery')}</span>
</div>
<div className={styles.field}>
<ImageUploader
images={gallery}
onChange={(next) => {
const prevUrlToId = new Map(
gallery.map((url, index) => [
url,
galleryMediaIds[index] ?? '',
]),
)
setGallery(next)
setGalleryMediaIds(
next.map((url) =>
url.startsWith('data:')
? ''
: prevUrlToId.get(url) ?? '',
),
)
}}
/>
</div>
</div>
</>
)}
{step === 3 && (
<>
<h2 className={styles.stepTitle}>{t('customerProducts.form.step.technical')}</h2>
<p className={styles.stepDesc}>{t('customerProducts.form.step.technicalHint')}</p>
<div className={styles.form}>
<fieldset className={styles.conditionFieldset}>
<legend>{t('customerProducts.form.fields.condition')}</legend>
<div className={styles.radioGrid} role="radiogroup">
{CONDITIONS.map((value) => (
<label key={value} className={styles.radioCard}>
<input
type="radio"
name="product-condition"
value={value}
checked={condition === value}
onChange={() => setCondition(value)}
/>
<span>{t(`customerProducts.form.condition.${value}`)}</span>
</label>
))}
</div>
</fieldset>
<div className={styles.field}>
<label htmlFor="add-technical-notes">
{t('customerProducts.form.fields.technicalNotes')}
<span className={styles.optional}> {t('customerProducts.form.optional')}</span>
</label>
<textarea
id="add-technical-notes"
rows={4}
value={technicalNotes}
onChange={(e) => setTechnicalNotes(e.target.value)}
placeholder={t('customerProducts.form.fields.technicalNotesPlaceholder')}
/>
</div>
<div className={styles.sectionDivider}>
<span>{t('customerProducts.form.technical.categoryForm')}</span>
</div>
{!categoryId ? (
<div className={styles.placeholder}>
{t('customerProducts.form.technical.needCategory')}
</div>
) : loadingTechnicalForm ? (
<p className={styles.inlineStatus}>{t('customerProducts.form.technical.loading')}</p>
) : technicalFields.length === 0 ? (
<div className={styles.placeholder}>{t('customerProducts.form.technical.empty')}</div>
) : (
technicalFields.map((field) => (
<div key={field.id} className={styles.field}>
<label htmlFor={`tech-${field.id}`}>
{fieldLabel(field)}
{field.isRequired ? (
' *'
) : (
<span className={styles.optional}> {t('customerProducts.form.optional')}</span>
)}
</label>
{renderTechnicalField(field)}
</div>
))
)}
</div>
</>
)}
{stepError ? (
<div className={styles.error} role="alert">
{stepError}
</div>
) : null}
</div>
<div className={styles.actions}>
{step === 1 ? (
<Link to="/customer-products" className={styles.ghostBtn}>
{t('customerProducts.form.cancel')}
</Link>
) : (
<button
type="button"
className={styles.secondaryBtn}
onClick={goBack}
disabled={submitting}
>
{t('customerProducts.form.back')}
</button>
)}
{step < 3 ? (
<button type="button" className={styles.primaryBtn} onClick={goNext}>
{t('customerProducts.form.next')}
</button>
) : (
<button
type="button"
className={styles.primaryBtn}
onClick={() => void handleSubmit()}
disabled={submitting || loadingTechnicalForm}
>
{submitting
? t('customerProducts.form.submitting')
: isEdit
? t('customerProducts.form.save')
: t('customerProducts.form.submit')}
</button>
)}
</div>
</div>
</div>
</div>
</main>
)
}
@@ -7,7 +7,6 @@
width: 100%;
max-width: 640px;
margin: 0 auto 20px;
aspect-ratio: 3 / 2;
overflow: hidden;
border-radius: var(--radius);
background: rgba(148, 163, 184, 0.08);
@@ -15,15 +14,14 @@
}
.heroImage {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
width: 100%;
height: auto;
}
.heroPlaceholder {
width: 100%;
height: 100%;
aspect-ratio: 3 / 2;
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.12) 0%,
@@ -528,6 +528,7 @@ export function CategoriesPage() {
isGenerating={technicalGenerating}
error={technicalError}
onClose={() => !technicalSaving && !technicalGenerating && setTechnicalTarget(null)}
escapeDisabled={techAiPromptOpen}
onChange={(fields) => {
setCategoryTechnicalFields((prev) => ({
...prev,
@@ -0,0 +1,338 @@
.headerRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.editBtn,
.secondaryBtn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--field-height);
padding: var(--field-padding-y) 14px;
border-radius: var(--radius-sm);
font-size: var(--field-font-size);
font-weight: 600;
font-family: var(--font-ui);
white-space: nowrap;
}
.editBtn {
color: #fff;
background: var(--primary);
}
.editBtn:hover {
filter: brightness(1.05);
}
.secondaryBtn {
color: var(--text-primary);
background: rgba(148, 163, 184, 0.14);
border: 1px solid rgba(148, 163, 184, 0.2);
}
.secondaryBtn:hover {
background: rgba(148, 163, 184, 0.22);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 320px) 1fr;
gap: 28px;
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);
padding: 24px;
}
.gallery {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 320px;
}
.mainImage {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.15);
overflow: hidden;
background: #ffffff;
}
.galleryThumbs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.galleryThumb {
aspect-ratio: 1 / 1;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.15);
overflow: hidden;
background: #ffffff;
}
.galleryThumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.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);
}
.badge {
position: absolute;
top: 10px;
inset-inline-start: 10px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 50px;
border: 1px solid rgba(255, 255, 255, 0.22);
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%
);
}
.badge[data-status='published'] {
color: #047857;
background: linear-gradient(
145deg,
rgba(167, 243, 208, 0.55) 0%,
rgba(52, 211, 153, 0.28) 100%
);
}
.badge[data-status='archived'] {
color: #e2e8f0;
background: linear-gradient(
145deg,
rgba(148, 163, 184, 0.45) 0%,
rgba(100, 116, 139, 0.28) 100%
);
}
.badge[data-status='rejected'] {
color: #fff;
background: linear-gradient(
145deg,
rgba(239, 68, 68, 0.55) 0%,
rgba(220, 38, 38, 0.32) 100%
);
}
.details {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.categoryChip {
align-self: flex-start;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.15);
border-radius: 50px;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
font-family: var(--font-ui);
}
.secondary {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.summaryRow {
display: flex;
align-items: center;
gap: 12px 16px;
flex-wrap: wrap;
direction: ltr;
}
.price {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.summaryMeta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 14px;
margin-inline-start: auto;
min-width: 0;
}
.metaItem {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-ui);
min-width: 0;
}
.metaGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px 16px;
margin: 4px 0 0;
}
.metaGrid dt {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 4px;
}
.metaGrid dd {
margin: 0;
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-ui);
}
.location {
display: inline-flex;
align-items: center;
gap: 6px;
}
.section {
padding-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.15);
}
.section h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.prose {
margin: 0;
font-size: 13px;
line-height: 1.65;
color: var(--text-primary);
white-space: pre-wrap;
font-family: var(--font-ui);
}
.techGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin: 0;
}
.techGrid dt {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 4px;
}
.techGrid dd {
margin: 0;
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-ui);
}
.status {
margin: 24px 0;
font-size: 14px;
color: var(--text-secondary);
}
.error {
margin: 24px 0;
font-size: 14px;
color: #ef4444;
}
.backLink {
color: var(--primary);
font-weight: 600;
text-decoration: none;
}
.backLink:hover {
text-decoration: underline;
}
@media (max-width: 800px) {
.headerRow {
flex-direction: column;
}
.layout {
grid-template-columns: 1fr;
padding: 16px;
}
.gallery {
max-width: none;
}
}
@@ -0,0 +1,331 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ImageOff, MapPin, Pencil, User } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { UserProductStatusModal } from '../components/UserProductStatusModal'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
getUserProduct,
getUserProductCategoryTechnicalForm,
updateUserProductStatus,
type TechnicalFormField,
type UserProductDetail,
type UserProductTechnicalValueInput,
} from '../services/userProductsService'
import type { UserProductStatus } from '../types/userProduct'
import { formatIrtPrice } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import styles from './CustomerProductDetailsPage.module.css'
function formatTechnicalValue(
field: TechnicalFormField,
value: UserProductTechnicalValueInput | undefined,
): string {
if (!value) return '—'
if (value.textValue != null && value.textValue.trim()) return value.textValue
if (value.optionId) {
return field.options.find((option) => option.id === value.optionId)?.label ?? value.optionId
}
if (value.optionIds?.length) {
return value.optionIds
.map(
(optionId) =>
field.options.find((option) => option.id === optionId)?.label ?? optionId,
)
.join(', ')
}
return '—'
}
export function CustomerProductDetailsPage() {
const { id } = useParams<{ id: string }>()
const t = useT()
const { locale } = useLocale()
const navigate = useNavigate()
const { showToast } = useToast()
const isFa = locale === 'fa'
const [product, setProduct] = useState<UserProductDetail | null>(null)
const [technicalFields, setTechnicalFields] = useState<TechnicalFormField[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [statusOpen, setStatusOpen] = useState(false)
const [statusSaving, setStatusSaving] = useState(false)
useEffect(() => {
if (!id) return
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const response = await getUserProduct(id!, controller.signal)
if (controller.signal.aborted) return
setProduct(response.product)
if (response.product.categoryId) {
const formResponse = await getUserProductCategoryTechnicalForm(
response.product.categoryId,
controller.signal,
)
if (controller.signal.aborted) return
setTechnicalFields(formResponse.form?.fields ?? [])
} else {
setTechnicalFields([])
}
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
const message =
err instanceof ApiError
? err.message
: t('customerProducts.error.loadDetail')
setError(message)
setProduct(null)
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => controller.abort()
}, [id, t])
const technicalRows = useMemo(() => {
if (!product) return []
const byField = new Map(
(product.technicalValues ?? []).map((item) => [item.fieldId, item]),
)
return technicalFields.map((field) => ({
id: field.id,
label: field.label,
value: formatTechnicalValue(field, byField.get(field.id)),
}))
}, [product, technicalFields])
async function handleStatusSave(status: UserProductStatus) {
if (!product) return
setStatusSaving(true)
try {
const response = await updateUserProductStatus(product.id, status)
setProduct((prev) => (prev ? { ...prev, ...response.product } : prev))
showToast(t('customerProducts.statusSuccess'), 'success')
setStatusOpen(false)
} catch (err) {
showToast(
err instanceof ApiError
? err.message
: t('customerProducts.error.status'),
'error',
)
} finally {
setStatusSaving(false)
}
}
if (loading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>{t('customerProducts.loading')}</p>
</main>
)
}
if (error || !product) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || t('customerProducts.error.loadDetail')}</p>
<Link to="/customer-products" className={styles.backLink}>
{t('customerProducts.backToList')}
</Link>
</main>
)
}
const title = isFa ? product.titleFa || product.title : product.titleEn || product.title
const secondary = isFa ? product.titleEn || '' : product.titleFa || ''
const city = isFa ? product.cityNameFa || product.cityName : product.cityName
const country = isFa
? product.countryNameFa || product.countryName
: product.countryName
const category = isFa
? product.categoryNameFa || product.categoryName
: product.categoryName
const owner = isFa ? product.ownerNameFa || product.ownerName : product.ownerName
const imageSrc = product.imageUrl?.trim() || ''
const galleryImages = (product.images ?? [])
.map((item) => item.url?.trim())
.filter((url): url is string => Boolean(url))
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 currency = (product.priceCurrency || 'IRT').toUpperCase()
const priceLabel =
product.price == null
? t('customerProducts.priceUnavailable')
: currency === 'IRT'
? formatIrtPrice(product.price)
: `${Number(product.price).toLocaleString('en-US')} ${currency}`
const conditionKey = product.condition
? `customerProducts.form.condition.${product.condition}`
: ''
const conditionLabel = conditionKey ? t(conditionKey) : '—'
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('customerProducts.title'), href: '/customer-products' },
{ label: title },
]}
/>
<div className={styles.headerRow}>
<div>
<h2 className={pageStyles.pageTitle}>{t('customerProducts.detailsTitle')}</h2>
<p className={pageStyles.pageSubtitle}>{t('customerProducts.detailsSubtitle')}</p>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.secondaryBtn}
onClick={() => setStatusOpen(true)}
>
{t('customerProducts.statusChange')}
</button>
<button
type="button"
className={styles.editBtn}
onClick={() => navigate(`/customer-products/${product.id}/edit`)}
>
<Pencil size={16} />
{t('customerProducts.edit')}
</button>
</div>
</div>
<div className={styles.layout} dir={isFa ? 'rtl' : 'ltr'}>
<div className={styles.gallery}>
<div className={styles.mainImage}>
{imageSrc ? (
<img src={imageSrc} alt={title} className={styles.image} />
) : (
<div className={styles.imagePlaceholder}>
<ImageOff size={36} strokeWidth={1.5} />
</div>
)}
<span className={styles.badge} data-status={product.status}>
{statusLabel}
</span>
</div>
{galleryImages.length > 0 ? (
<div className={styles.galleryThumbs}>
{galleryImages.map((url) => (
<div key={url} className={styles.galleryThumb}>
<img src={url} alt="" />
</div>
))}
</div>
) : null}
</div>
<div className={styles.details}>
{category ? <span className={styles.categoryChip}>{category}</span> : null}
<h1 className={styles.title}>{title}</h1>
{secondary ? <p className={styles.secondary}>{secondary}</p> : null}
<div className={styles.summaryRow}>
<p className={styles.price} dir="ltr">
{priceLabel}
</p>
<div className={styles.summaryMeta} dir={isFa ? 'rtl' : 'ltr'}>
{owner ? (
<span className={styles.metaItem}>
<User size={14} aria-hidden />
{owner}
</span>
) : null}
<span className={styles.metaItem}>
<MapPin size={14} aria-hidden />
{[city, country].filter(Boolean).join(isFa ? '، ' : ', ') || '—'}
</span>
</div>
</div>
<dl className={styles.metaGrid}>
<div>
<dt>{t('customerProducts.form.fields.country')}</dt>
<dd>{country || '—'}</dd>
</div>
<div>
<dt>{t('customerProducts.form.fields.city')}</dt>
<dd>{city || '—'}</dd>
</div>
<div>
<dt>{t('customerProducts.form.fields.condition')}</dt>
<dd>{conditionLabel}</dd>
</div>
{product.priceByExpert ? (
<div>
<dt>{t('customerProducts.form.fields.priceByExpert')}</dt>
<dd>{t('customerProducts.yes')}</dd>
</div>
) : null}
</dl>
{product.description ? (
<section className={styles.section}>
<h3>{t('customerProducts.form.fields.description')}</h3>
<p className={styles.prose}>{product.description}</p>
</section>
) : null}
{product.deliveryNote ? (
<section className={styles.section}>
<h3>{t('customerProducts.form.fields.deliveryNote')}</h3>
<p className={styles.prose}>{product.deliveryNote}</p>
</section>
) : null}
{product.technicalNotes ? (
<section className={styles.section}>
<h3>{t('customerProducts.form.fields.technicalNotes')}</h3>
<p className={styles.prose}>{product.technicalNotes}</p>
</section>
) : null}
{technicalRows.length > 0 ? (
<section className={styles.section}>
<h3>{t('customerProducts.form.technical.categoryForm')}</h3>
<dl className={styles.techGrid}>
{technicalRows.map((row) => (
<div key={row.id}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
</section>
) : null}
</div>
</div>
<UserProductStatusModal
open={statusOpen}
currentStatus={product.status}
saving={statusSaving}
onClose={() => setStatusOpen(false)}
onSave={(status) => void handleStatusSave(status)}
/>
</main>
)
}
@@ -0,0 +1,84 @@
.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;
font-size: 14px;
color: var(--text-secondary);
}
.error {
color: #ef4444;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.fabDock {
position: fixed;
inset-inline-end: 32px;
bottom: 32px;
z-index: 110;
}
.addFab {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.addFab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1280px) {
.grid {
grid-template-columns: repeat(6, 1fr);
}
}
@@ -0,0 +1,248 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Package, Plus } from 'lucide-react'
import { Pagination } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { UserProductCard } from '../components/UserProductCard'
import { UserProductStatusModal } from '../components/UserProductStatusModal'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
deleteUserProduct,
listUserProducts,
promoteUserProduct,
updateUserProductStatus,
type UserProductsListResponse,
} from '../services/userProductsService'
import type { UserProductStatus } from '../types/userProduct'
import pageStyles from '../components/PageContent.module.css'
import styles from './CustomerProductsPage.module.css'
const PAGE_SIZE = 24
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 [busyId, setBusyId] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<
'remove' | 'promote' | 'status' | null
>(null)
const [statusProductId, setStatusProductId] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const response = await listUserProducts(
{ page, pageSize: PAGE_SIZE },
controller.signal,
)
if (controller.signal.aborted) return
setData(response)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(
err instanceof ApiError ? err.message : t('customerProducts.error.load'),
)
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => controller.abort()
}, [page, 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)
function handleEdit(id: string) {
navigate(`/customer-products/${id}/edit`)
}
async function handleRemove(id: string) {
if (!window.confirm(t('customerProducts.removeConfirm'))) return
setBusyId(id)
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,
)
showToast(t('customerProducts.removeSuccess'), 'success')
} catch (err) {
showToast(
err instanceof ApiError
? err.message
: t('customerProducts.error.remove'),
'error',
)
} finally {
setBusyId(null)
setBusyAction(null)
}
}
async function handlePromote(id: string) {
setBusyId(id)
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,
)
showToast(t('customerProducts.promoteSuccess'), 'success')
} catch (err) {
showToast(
err instanceof ApiError
? err.message
: t('customerProducts.error.promote'),
'error',
)
} finally {
setBusyId(null)
setBusyAction(null)
}
}
async function handleStatusSave(status: UserProductStatus) {
if (!statusProductId) return
setBusyId(statusProductId)
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,
)
showToast(t('customerProducts.statusSuccess'), 'success')
setStatusProductId(null)
} catch (err) {
showToast(
err instanceof ApiError
? err.message
: t('customerProducts.error.status'),
'error',
)
} finally {
setBusyId(null)
setBusyAction(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('customerProducts.title') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>{t('customerProducts.title')}</h2>
<p className={pageStyles.pageSubtitle}>
{t('customerProducts.subtitle', { count: total })}
</p>
</div>
</div>
{error ? (
<div className={styles.error} role="alert">
{error}
</div>
) : null}
{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>
) : null}
{!loading && products.length > 0 ? (
<>
<div className={styles.grid}>
{products.map((product) => (
<UserProductCard
key={product.id}
product={product}
to={`/customer-products/${product.id}`}
onEdit={handleEdit}
onPromote={handlePromote}
onChangeStatus={setStatusProductId}
onRemove={handleRemove}
busyAction={busyId === product.id ? busyAction : null}
/>
))}
</div>
{totalPages > 1 ? (
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
/>
) : null}
</>
) : null}
<div className={styles.fabDock}>
<Link
to="/customer-products/new"
className={styles.addFab}
aria-label={t('customerProducts.add')}
>
<Plus size={22} />
</Link>
</div>
<UserProductStatusModal
open={Boolean(statusProduct)}
currentStatus={statusProduct?.status ?? 'draft'}
saving={busyAction === 'status'}
onClose={() => setStatusProductId(null)}
onSave={(status) => void handleStatusSave(status)}
/>
</main>
)
}
@@ -18,3 +18,17 @@ export async function listCitiesByProvinceSlug(parentSlug: string, signal?: Abor
)
return data.items
}
export async function listCountries(signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>('/cities?level=country', { signal })
return data.items
}
export async function listCitiesByCountrySlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
{ signal },
)
return data.items
}
@@ -0,0 +1,218 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { UserProductListItem, UserProductStatus } from '../types/userProduct'
export type UserProductCondition = 'new' | 'stock' | 'needs_repair' | 'scrap'
export type UserProductPriceCurrency = 'IRT' | 'USD' | 'EUR' | 'AED'
export type TechnicalFieldType = 'text' | 'textarea' | 'select' | 'multi_select'
export interface UserProductCategoryOption {
id: string
name: string
nameFa: string | null
parentId: string | null
}
export interface TechnicalFormFieldOption {
id: string
label: string
value: string
sortOrder: number
}
export interface TechnicalFormField {
id: string
label: string
key: string
type: TechnicalFieldType
isRequired: boolean
sortOrder: number
options: TechnicalFormFieldOption[]
}
export interface CategoryTechnicalForm {
id: string
categoryId: string
fields: TechnicalFormField[]
}
export type TechnicalFormValues = Record<string, string | string[]>
export interface UserProductTechnicalValueInput {
fieldId: string
textValue?: string
optionId?: string
optionIds?: string[]
}
export interface CreateUserProductInput {
titleFa: string
titleEn?: string
description?: string
categoryId: string
price?: number
priceCurrency?: UserProductPriceCurrency
priceByExpert?: boolean
countryId: string
cityId: string
deliveryNote?: string
condition: UserProductCondition
technicalNotes?: string
technicalValues?: UserProductTechnicalValueInput[]
featuredMediaId?: string
galleryMediaIds?: string[]
}
export type UpdateUserProductInput = CreateUserProductInput
export interface UserProductDetail extends UserProductListItem {
countryId: string
cityId: string
countrySlug: string
featuredMediaId: string | null
galleryMediaIds: string[]
images: Array<{ mediaId: string; url: string }>
technicalValues: UserProductTechnicalValueInput[]
deliveryNote?: string | null
technicalNotes?: string | null
description?: string | null
titleEn?: string | null
priceCurrency?: string | null
priceByExpert?: boolean
promoted?: boolean
condition?: string | null
categoryId?: string | null
}
export interface ListUserProductsParams {
page?: number
pageSize?: number
status?: UserProductStatus
}
export interface UserProductsListResponse {
items: UserProductListItem[]
total: number
page: number
pageSize: number
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/user-products${suffix}`
}
export function buildTechnicalValuesPayload(
fields: TechnicalFormField[],
values: TechnicalFormValues,
): UserProductTechnicalValueInput[] {
const payload: UserProductTechnicalValueInput[] = []
for (const field of fields) {
const value = values[field.id]
if (field.type === 'text' || field.type === 'textarea') {
const text = typeof value === 'string' ? value.trim() : ''
if (!text) continue
payload.push({ fieldId: field.id, textValue: text })
continue
}
if (field.type === 'select') {
const optionId = typeof value === 'string' ? value.trim() : ''
if (!optionId) continue
payload.push({ fieldId: field.id, optionId })
continue
}
const optionIds = Array.isArray(value) ? value.filter(Boolean) : []
if (!optionIds.length) continue
payload.push({ fieldId: field.id, optionIds })
}
return payload
}
export async function listUserProducts(
params: ListUserProductsParams = {},
signal?: AbortSignal,
) {
const q = new URLSearchParams()
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
if (params.status) q.set('status', params.status)
const query = q.toString()
return apiRequest<UserProductsListResponse>(
`${businessPath()}${query ? `?${query}` : ''}`,
{ auth: true, signal },
)
}
export async function getUserProduct(productId: string, signal?: AbortSignal) {
return apiRequest<{ product: UserProductDetail }>(
businessPath(`/${productId}`),
{ auth: true, signal },
)
}
export async function createUserProduct(input: CreateUserProductInput) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(),
{ method: 'POST', auth: true, body: input },
)
}
export async function updateUserProduct(
productId: string,
input: UpdateUserProductInput,
) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}`),
{ method: 'PATCH', auth: true, body: input },
)
}
export async function updateUserProductStatus(
productId: string,
status: UserProductStatus,
) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}/status`),
{ method: 'PATCH', auth: true, body: { status } },
)
}
export async function promoteUserProduct(productId: string) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}/promote`),
{ method: 'POST', auth: true },
)
}
export async function deleteUserProduct(productId: string) {
return apiRequest<{ message: string }>(businessPath(`/${productId}`), {
method: 'DELETE',
auth: true,
})
}
export async function listUserProductCategories(signal?: AbortSignal) {
return apiRequest<{ items: UserProductCategoryOption[] }>(
businessPath('/categories'),
{ auth: true, signal },
)
}
export async function getUserProductCategoryTechnicalForm(
categoryId: string,
signal?: AbortSignal,
) {
return apiRequest<{ form: CategoryTechnicalForm | null }>(
businessPath(`/categories/${categoryId}/technical-form`),
{ auth: true, signal },
)
}
+30
View File
@@ -0,0 +1,30 @@
export type UserProductStatus = 'draft' | 'published' | 'archived' | 'rejected'
export type UserProductPriceCurrency = 'IRT' | 'USD' | 'EUR' | 'AED'
export interface UserProductListItem {
id: string
title: string
titleFa?: string | null
titleEn?: string | null
description?: string | null
price: number | null
priceCurrency?: UserProductPriceCurrency | string | null
priceByExpert?: boolean
promoted?: boolean
status: UserProductStatus
condition?: string | null
cityName: string
cityNameFa?: string | null
countryName?: string | null
countryNameFa?: string | null
imageUrl: string | null
categoryId?: string | null
categoryName?: string | null
categoryNameFa?: string | null
ownerId?: string
ownerName: string
ownerNameFa?: string
ownerCell?: string
createdAt?: string
}
+19 -4
View File
@@ -1,5 +1,5 @@
/** Optional CMS modules a business can have. Always-on areas are not listed. */
export const BUSINESS_MODULE_IDS = [
/** Optional business-dashboard CMS modules. Always-on areas are not listed. */
export const BUSINESS_DASHBOARD_MODULE_IDS = [
'products',
'store',
'portfolio',
@@ -8,6 +8,18 @@ export const BUSINESS_MODULE_IDS = [
'videos',
] as const
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
export const CUSTOMER_MODULE_IDS = ['customer_products'] as const
/** All optional modules stored in `settings.modules.enabled`. */
export const BUSINESS_MODULE_IDS = [
...BUSINESS_DASHBOARD_MODULE_IDS,
...CUSTOMER_MODULE_IDS,
] as const
export type BusinessDashboardModuleId =
(typeof BUSINESS_DASHBOARD_MODULE_IDS)[number]
export type CustomerModuleId = (typeof CUSTOMER_MODULE_IDS)[number]
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number]
/** Home dashboard chart slots (super-admin selectable). */
@@ -21,9 +33,12 @@ export const HOME_CHART_IDS = [
export type HomeChartId = (typeof HOME_CHART_IDS)[number]
/** Existing tenants without saved modules keep every module enabled. */
/**
* Existing tenants without saved modules keep every business-dashboard module
* enabled. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
...BUSINESS_MODULE_IDS,
...BUSINESS_DASHBOARD_MODULE_IDS,
]
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
+1
View File
@@ -15,6 +15,7 @@
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.2.3",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
+10
View File
@@ -8,11 +8,15 @@ import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
import { ProtectedRoute } from './components/ProtectedRoute'
import { GuestRoute } from './components/GuestRoute'
import { PageLayout } from './components/PageLayout'
import { AddMyProductLayout } from './components/AddMyProductLayout'
import { HomePage } from './pages/HomePage'
import { ProfilePage } from './pages/ProfilePage'
import { OrdersPage } from './pages/OrdersPage'
import { AddressesPage } from './pages/AddressesPage'
import { FavoritesPage } from './pages/FavoritesPage'
import { MyProductsPage } from './pages/MyProductsPage'
import { MyProductDetailsPage } from './pages/MyProductDetailsPage'
import { AddMyProductPage } from './pages/AddMyProductPage'
import { LoginPage } from './pages/LoginPage'
import { CheckoutLayout } from './components/checkout/CheckoutLayout'
import { CheckoutFlow } from './pages/checkout/CheckoutFlow'
@@ -51,12 +55,18 @@ function App() {
</Route>
<Route element={<ProtectedRoute />}>
<Route element={<AddMyProductLayout />}>
<Route path="my-products/new" element={<AddMyProductPage />} />
<Route path="my-products/:id/edit" element={<AddMyProductPage />} />
</Route>
<Route element={<PageLayout />}>
<Route index element={<HomePage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="addresses" element={<AddressesPage />} />
<Route path="orders" element={<OrdersPage />} />
<Route path="favorites" element={<FavoritesPage />} />
<Route path="my-products" element={<MyProductsPage />} />
<Route path="my-products/:id" element={<MyProductDetailsPage />} />
</Route>
</Route>
</Routes>
@@ -0,0 +1,42 @@
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
position: relative;
background-color: var(--bg-gradient-mid);
background-image: linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-mid) 50%,
var(--bg-gradient-end) 100%
);
background-attachment: fixed;
background-repeat: no-repeat;
background-size: cover;
}
.main {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 32px 24px 48px;
}
.container {
width: 60%;
max-width: none;
min-width: 320px;
}
@media (max-width: 900px) {
.container {
width: 90%;
}
}
@media (max-width: 480px) {
.main {
padding: 20px 16px 32px;
}
}
@@ -0,0 +1,16 @@
import { Outlet } from 'react-router-dom'
import { Header } from './Header'
import styles from './AddMyProductLayout.module.css'
export function AddMyProductLayout() {
return (
<div className={styles.page}>
<Header hideMenu showBrand />
<main className={styles.main}>
<div className={styles.container}>
<Outlet />
</div>
</main>
</div>
)
}
@@ -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('myProducts.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('myProducts.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('myProducts.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('myProducts.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('myProducts.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('myProducts.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>
)
}
@@ -27,7 +27,7 @@
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
background: #ffffff;
background: var(--card-media-bg);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
@@ -54,10 +54,10 @@
letter-spacing: 0.03em;
border-radius: 50px;
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.82);
background: var(--elevated-surface);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12);
box-shadow: var(--glass-shadow);
z-index: 1;
}
+46 -3
View File
@@ -16,6 +16,49 @@
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.brandLogo {
width: 36px;
height: 36px;
object-fit: contain;
flex-shrink: 0;
border-radius: 8px;
background: transparent;
}
.brandText {
display: flex;
flex-direction: column;
min-width: 0;
}
.brandTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.brandSubtitle {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menuBtn {
@@ -87,7 +130,7 @@
padding-block: 8px;
padding-inline: 14px 12px;
border-radius: 50px;
background: rgba(255, 255, 255, 0.5);
background: var(--surface);
border: 1px solid var(--glass-border);
cursor: pointer;
transition: box-shadow 0.2s, border-color 0.2s;
@@ -131,12 +174,12 @@
inset-inline-end: 0;
min-width: 180px;
padding: 6px;
background: rgba(255, 255, 255, 0.95);
background: var(--elevated-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: 0 12px 32px rgba(31, 38, 135, 0.12);
box-shadow: var(--glass-shadow);
z-index: 60;
animation: dropdownIn 0.15s ease;
}
+33 -1
View File
@@ -3,8 +3,10 @@ import { Link, useNavigate } from 'react-router-dom'
import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut } from 'lucide-react'
import { LanguageSelect, PasswordResetModal, useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { useT } from '../i18n/useT'
import { changePassword } from '../services/authService'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Header.module.css'
function displayUserName(
@@ -30,16 +32,30 @@ function displayUserName(
return localized || other || user.cellNumber || fallback
}
export function Header() {
interface HeaderProps {
/** Hide the mobile sidebar hamburger (e.g. full-width flows without a sidebar). */
hideMenu?: boolean
/** Show website logo + name on the start side (visual right in RTL). */
showBrand?: boolean
}
export function Header({ hideMenu = false, showBrand = false }: HeaderProps) {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
const t = useT()
const [menuOpen, setMenuOpen] = useState(false)
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const displayName = displayUserName(user, locale, t('app.role.customer'))
const brandTitle = locale === 'en'
? businessNameEn || businessName
: businessName || businessNameEn
const brandSubtitle = locale === 'en'
? (businessName && businessName !== brandTitle ? businessName : '')
: (businessNameEn && businessNameEn !== brandTitle ? businessNameEn : '')
useEffect(() => {
if (!menuOpen) return
@@ -77,9 +93,25 @@ export function Header() {
<>
<header className={styles.header}>
<div className={styles.left}>
{showBrand ? (
<div className={styles.brand}>
<img
src={logoUrl || meshkeeLogo}
alt=""
className={styles.brandLogo}
/>
<div className={styles.brandText}>
<span className={styles.brandTitle}>{brandTitle || t('app.storeFallback')}</span>
{brandSubtitle ? (
<span className={styles.brandSubtitle}>{brandSubtitle}</span>
) : null}
</div>
</div>
) : !hideMenu ? (
<button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
<Menu size={22} />
</button>
) : null}
</div>
<div className={styles.right}>
@@ -0,0 +1,170 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.uploadZone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 0;
border: 2px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 70%, transparent);
color: var(--text-secondary);
font-size: 14px;
font-family: var(--font-ui);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
}
.uploadZone:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.06);
color: var(--primary);
}
.hint {
font-size: 12px;
color: var(--text-muted);
}
.preview {
position: relative;
width: 100%;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--glass-border);
background: var(--card-media-bg);
}
.previewImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.removeBtn {
position: absolute;
top: 8px;
inset-inline-end: 8px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(15, 23, 42, 0.6);
color: white;
transition: background 0.2s;
}
.removeBtn:hover {
background: #ef4444;
}
.cropPanel {
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
overflow: hidden;
background: color-mix(in srgb, var(--surface) 80%, transparent);
}
.cropArea {
position: relative;
width: 100%;
min-height: 160px;
max-height: 420px;
background: #1e293b;
}
.cropAreaPortrait {
max-height: none;
min-height: 0;
}
.cropControls {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.zoomLabel {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.zoomLabel input {
flex: 1;
accent-color: var(--primary);
}
.cropActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.cancelBtn {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-secondary);
border-radius: 8px;
transition: background 0.2s;
}
.cancelBtn:hover {
background: rgba(148, 163, 184, 0.15);
}
.applyBtn {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-ui);
color: white;
background: var(--primary);
border-radius: 8px;
transition: background 0.2s;
}
.applyBtn:hover {
background: var(--primary-dark);
}
.applyBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.changeBtn {
display: inline-flex;
align-self: flex-start;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--primary);
border: 1px solid rgba(var(--primary-rgb) / 0.3);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.changeBtn:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
@@ -0,0 +1,175 @@
import { useCallback, useEffect, useState } from 'react'
import Cropper, { type Area } from 'react-easy-crop'
import { ImagePlus, X } from 'lucide-react'
import { getCroppedImage } from '../utils/cropImage'
import { useT } from '../i18n/useT'
import styles from './ImageCropper.module.css'
interface ImageCropperProps {
value: string | null
onChange: (value: string | null) => void
aspect?: number
outputFormat?: 'jpeg' | 'png'
accept?: string
uploadLabel?: string
hint?: string
changeLabel?: string
}
export function ImageCropper({
value,
onChange,
aspect = 1,
outputFormat = 'jpeg',
accept = 'image/*',
uploadLabel,
hint,
changeLabel,
}: ImageCropperProps) {
const t = useT()
const resolvedUploadLabel = uploadLabel ?? t('myProducts.images.thumbnailUpload')
const resolvedHint = hint ?? t('myProducts.images.thumbnailHint')
const resolvedChangeLabel = changeLabel ?? t('myProducts.images.thumbnailChange')
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
useEffect(() => {
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedArea(null)
}, [aspect])
const onCropComplete = useCallback((_: Area, pixels: Area) => {
setCroppedArea(pixels)
}, [])
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
setImageSrc(reader.result as string)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedArea(null)
}
reader.readAsDataURL(file)
e.target.value = ''
}
async function applyCrop() {
if (!imageSrc || !croppedArea) return
const cropped = await getCroppedImage(imageSrc, croppedArea, outputFormat)
onChange(cropped)
setImageSrc(null)
setZoom(1)
setCrop({ x: 0, y: 0 })
}
function cancelCrop() {
setImageSrc(null)
setZoom(1)
setCrop({ x: 0, y: 0 })
}
function removeThumbnail() {
onChange(null)
}
const isPortrait = aspect < 1
const frameStyle: React.CSSProperties = isPortrait
? {
aspectRatio: `${aspect}`,
height: 'min(480px, 65vh)',
width: 'auto',
maxWidth: '100%',
marginInline: 'auto',
}
: {
aspectRatio: String(aspect),
width: '100%',
height: 'auto',
}
return (
<div className={styles.wrapper}>
{value && !imageSrc && (
<div className={styles.preview} style={{ aspectRatio: String(aspect) }}>
<img src={value} alt={resolvedUploadLabel} className={styles.previewImg} />
<button
type="button"
className={styles.removeBtn}
onClick={removeThumbnail}
aria-label={t('myProducts.images.thumbnailRemove')}
>
<X size={16} />
</button>
</div>
)}
{!value && !imageSrc && (
<label className={styles.uploadZone} style={frameStyle}>
<ImagePlus size={28} />
<span>{resolvedUploadLabel}</span>
<span className={styles.hint}>{resolvedHint}</span>
<input type="file" accept={accept} onChange={handleFile} hidden />
</label>
)}
{imageSrc && (
<div className={styles.cropPanel}>
<div
className={`${styles.cropArea} ${isPortrait ? styles.cropAreaPortrait : ''}`}
style={frameStyle}
>
<Cropper
key={String(aspect)}
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={aspect}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
<div className={styles.cropControls}>
<label className={styles.zoomLabel}>
{t('myProducts.images.zoom')}
<input
type="range"
min={1}
max={3}
step={0.05}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
/>
</label>
<div className={styles.cropActions}>
<button type="button" className={styles.cancelBtn} onClick={cancelCrop}>
{t('myProducts.cancel')}
</button>
<button
type="button"
className={styles.applyBtn}
onClick={() => void applyCrop()}
disabled={!croppedArea}
>
{t('myProducts.images.applyCrop')}
</button>
</div>
</div>
</div>
)}
{value && !imageSrc && (
<label className={styles.changeBtn}>
{resolvedChangeLabel}
<input type="file" accept={accept} onChange={handleFile} hidden />
</label>
)}
</div>
)
}
@@ -0,0 +1,83 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.grid {
--thumb-height: 140px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
}
.item {
position: relative;
height: var(--thumb-height);
width: fit-content;
max-width: 100%;
flex: 0 0 auto;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--glass-border);
background: var(--card-media-bg);
display: flex;
align-items: center;
justify-content: center;
}
.item img {
height: var(--thumb-height);
width: auto;
display: block;
object-fit: contain;
}
.removeBtn {
position: absolute;
top: 6px;
inset-inline-end: 6px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(15, 23, 42, 0.65);
color: white;
transition: background 0.2s;
}
.removeBtn:hover {
background: #ef4444;
}
.addBtn {
height: var(--thumb-height);
width: var(--thumb-height);
flex: 0 0 var(--thumb-height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border: 2px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 70%, transparent);
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
transition: border-color 0.2s, color 0.2s, background 0.2s;
}
.addBtn:hover {
border-color: var(--primary);
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.06);
}
.hint {
font-size: 12px;
color: var(--text-muted);
}
@@ -0,0 +1,77 @@
import { useRef } from 'react'
import { ImagePlus, X } from 'lucide-react'
import { useT } from '../i18n/useT'
import styles from './ImageUploader.module.css'
interface ImageUploaderProps {
images: string[]
onChange: (images: string[]) => void
}
export function ImageUploader({ images, onChange }: ImageUploaderProps) {
const t = useT()
const inputRef = useRef<HTMLInputElement>(null)
function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const readers = files.map(
(file) =>
new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(file)
}),
)
Promise.all(readers).then((results) => {
onChange([...images, ...results])
})
e.target.value = ''
}
function removeImage(index: number) {
onChange(images.filter((_, i) => i !== index))
}
return (
<div className={styles.wrapper}>
<div className={styles.grid}>
{images.map((src, index) => (
<div key={`${src.slice(0, 32)}-${index}`} className={styles.item}>
<img src={src} alt={t('myProducts.images.gallery')} />
<button
type="button"
className={styles.removeBtn}
onClick={() => removeImage(index)}
aria-label={t('myProducts.images.galleryRemove', { index: index + 1 })}
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
className={styles.addBtn}
onClick={() => inputRef.current?.click()}
>
<ImagePlus size={24} />
<span>{t('myProducts.images.galleryAdd')}</span>
</button>
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={handleFiles}
/>
<p className={styles.hint}>{t('myProducts.images.galleryHint')}</p>
</div>
)
}
@@ -48,7 +48,7 @@
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.55);
background: var(--surface);
}
.itemThumb {
@@ -57,7 +57,7 @@
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
background: #fff;
background: var(--card-media-bg);
border: 1px solid rgba(148, 163, 184, 0.15);
}
@@ -62,11 +62,32 @@
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 24px;
margin-top: 24px;
}
.col6 {
grid-column: span 6;
display: flex;
min-height: 0;
}
.col6 > *,
.col4 > *,
.col3 > * {
flex: 1;
width: 100%;
min-height: 100%;
}
.col4 {
grid-column: span 4;
display: flex;
min-height: 0;
}
.col3 {
grid-column: span 3;
display: flex;
min-height: 0;
}
@media (max-width: 1400px) {
@@ -80,6 +101,12 @@
.grid {
grid-template-columns: repeat(2, 1fr);
}
.col6,
.col4,
.col3 {
grid-column: span 12;
}
}
@media (max-width: 768px) {
@@ -107,7 +134,9 @@
gap: 16px;
}
.col6 {
.col6,
.col4,
.col3 {
grid-column: span 12;
}
}
+8 -1
View File
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { Home, User, MapPin, ShoppingBag, Heart, HelpCircle, LogOut } from 'lucide-react'
import { Home, User, MapPin, ShoppingBag, Heart, Package, HelpCircle, LogOut } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { useT } from '../i18n/useT'
import { getActiveBusinessDomain } from '../lib/businessContext'
import { isAbortError } from '../lib/api'
import { getWebsiteBusinessInfo } from '../services/websiteService'
import { hasBusinessModule } from '../utils/businessModules'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Sidebar.module.css'
@@ -14,6 +16,7 @@ export function Sidebar() {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
const { enabledModules } = useTenantBranding()
const t = useT()
const [brandName, setBrandName] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null)
@@ -21,6 +24,7 @@ export function Sidebar() {
const businessDomain = getActiveBusinessDomain()
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? t('app.storeFallback')
const displayName = brandName || fallbackBusinessName
const showMyProducts = hasBusinessModule(enabledModules, 'customer_products')
const navItems = [
{ icon: Home, label: t('nav.home'), to: '/' },
@@ -28,6 +32,9 @@ export function Sidebar() {
{ icon: MapPin, label: t('nav.addresses'), to: '/addresses' },
{ icon: ShoppingBag, label: t('nav.orders'), to: '/orders' },
{ icon: Heart, label: t('nav.favorites'), to: '/favorites' },
...(showMyProducts
? [{ icon: Package, label: t('nav.myProducts'), to: '/my-products' }]
: []),
]
useEffect(() => {
@@ -19,7 +19,7 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.55);
background: var(--elevated-surface);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
+12 -15
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-size: 12px;
font-weight: 500;
@@ -18,13 +17,18 @@
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: var(--elevated-surface);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.12);
box-shadow: var(--glass-shadow);
}
.tipVisible {
opacity: 1;
visibility: visible;
transform: translate(-50%, -100%);
}
.tip::after {
@@ -34,12 +38,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: var(--elevated-surface);
}
+63 -4
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">
{visible
? createPortal(
<span
className={`${styles.tip} ${styles.tipVisible}`}
role="tooltip"
style={{ top: coords.top, left: coords.left }}
>
{label}
</span>
</span>,
document.body,
)
: null}
</span>
)
}
@@ -0,0 +1,236 @@
.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);
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);
}
.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;
}
.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;
}
.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;
}
.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,161 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ImageOff, MapPin, Megaphone, Pencil, Trash2 } 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
onRemove?: (id: string) => void
onPromote?: (id: string) => void
busyAction?: 'remove' | 'promote' | null
}
export function UserProductCard({
product,
to,
onEdit,
onRemove,
onPromote,
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 imageSrc = product.imageUrl?.trim() || ''
const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed
const currency = (product.priceCurrency || 'IRT').toUpperCase()
const priceLabel =
product.price == null
? t('myProducts.priceUnavailable')
: currency === 'IRT'
? formatIrtPrice(product.price)
: `${product.price.toLocaleString('en-US')} ${currency}`
const showControls = Boolean(onEdit || onRemove || onPromote)
const isBusy = busyAction != null
useEffect(() => {
setImageFailed(false)
}, [imageSrc])
const statusLabel =
product.status === 'published'
? t('myProducts.status.published')
: product.status === 'archived'
? t('myProducts.status.archived')
: product.status === 'rejected'
? t('myProducts.status.rejected')
: t('myProducts.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('myProducts.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}
<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
)}
{showControls ? (
<div className={styles.controls}>
{onEdit ? (
<Tooltip label={t('myProducts.edit')}>
<button
type="button"
onClick={() => onEdit(product.id)}
aria-label={t('myProducts.edit')}
disabled={isBusy}
>
<Pencil size={16} />
</button>
</Tooltip>
) : null}
{onPromote ? (
<Tooltip
label={
product.promoted ? t('myProducts.promoted') : t('myProducts.promote')
}
>
<button
type="button"
onClick={() => onPromote(product.id)}
aria-label={t('myProducts.promote')}
disabled={isBusy || product.promoted}
>
<Megaphone size={16} />
</button>
</Tooltip>
) : null}
{onRemove ? (
<Tooltip label={t('myProducts.remove')}>
<button
type="button"
className={styles.danger}
onClick={() => onRemove(product.id)}
aria-label={t('myProducts.remove')}
disabled={isBusy}
>
<Trash2 size={16} />
</button>
</Tooltip>
) : null}
</div>
) : null}
</article>
)
}
@@ -20,7 +20,7 @@
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
background: rgba(255, 255, 255, 0.92);
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
@@ -100,7 +100,7 @@
align-items: center;
gap: 12px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.6);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: var(--radius-sm);
}
@@ -185,7 +185,7 @@
line-height: 1.4;
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.7);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
@@ -213,7 +213,7 @@
line-height: 1.5;
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.7);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
@@ -233,7 +233,7 @@
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.7);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 50px;
transition: all 0.15s;
@@ -5,7 +5,7 @@
padding: 14px;
border: 2px solid rgba(var(--primary-rgb) / 0.2);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.7);
background: var(--surface);
}
.formInModal {
@@ -60,7 +60,7 @@
font-size: var(--field-font-size);
font-family: var(--font-fa);
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.85);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
@@ -141,7 +141,7 @@
padding: 14px;
border: 2px dashed rgba(148, 163, 184, 0.45);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.45);
background: var(--surface);
color: var(--primary);
font-size: 13px;
font-weight: 600;
@@ -4,18 +4,13 @@
flex-direction: column;
position: relative;
direction: rtl;
font-family: var(--font-fa);
/* Soften brand color in page wash — keep primary accents, less saturated bg */
background-color: #f8f6f6;
background-image:
radial-gradient(ellipse 520px 520px at calc(100% - 40px) -60px, rgba(var(--primary-rgb) / 0.1), transparent 72%),
radial-gradient(ellipse 420px 420px at 18% calc(100% + 20px), rgba(var(--primary-rgb) / 0.07), transparent 72%),
radial-gradient(ellipse 320px 320px at -40px 42%, rgba(var(--primary-rgb) / 0.05), transparent 72%),
linear-gradient(
font-family: var(--font-ui);
background-color: var(--bg-gradient-mid);
background-image: linear-gradient(
135deg,
color-mix(in srgb, var(--primary-light) 22%, #ffffff) 0%,
color-mix(in srgb, var(--primary-light) 10%, #ffffff) 50%,
#fafafa 100%
var(--bg-gradient-start) 0%,
var(--bg-gradient-mid) 50%,
var(--bg-gradient-end) 100%
);
background-attachment: fixed;
background-repeat: no-repeat;
@@ -119,12 +114,12 @@
}
.card {
background: rgba(255, 255, 255, 0.75);
background: var(--glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
box-shadow: var(--glass-shadow);
padding: 28px 28px 24px;
}
@@ -5,6 +5,13 @@ import { resolveTenantByDomain } from '../services/tenantService'
import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme'
import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
function applyThemeMode(mode: 'light' | 'dark' | undefined) {
document.documentElement.setAttribute(
'data-theme',
mode === 'dark' ? 'dark' : 'light',
)
}
export function CustomerThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const controller = new AbortController()
@@ -13,12 +20,15 @@ export function CustomerThemeProvider({ children }: { children: ReactNode }) {
async function loadTheme() {
try {
const tenant = await resolveTenantByDomain(domain, controller.signal)
if (controller.signal.aborted) return
applyBusinessPrimaryColor(
normalizeBusinessPrimaryColorId(tenant.primaryColor),
)
applyThemeMode(tenant.themeMode)
} catch (err) {
if (isAbortError(err)) return
applyBusinessPrimaryColor(undefined)
applyThemeMode('light')
}
}
@@ -13,12 +13,18 @@ import { isAbortError } from '../lib/api'
import { getTenantDomain } from '../lib/config'
import { getWebsiteBusinessInfo } from '../services/websiteService'
import { resolveTenantByDomain } from '../services/tenantService'
import {
DEFAULT_ENABLED_BUSINESS_MODULES,
normalizeEnabledBusinessModules,
type BusinessModuleId,
} from '../utils/businessModules'
interface TenantBrandingContextValue {
businessName: string
businessNameEn: string
logoUrl: string | null
faviconUrl: string | null
enabledModules: BusinessModuleId[]
}
const TenantBrandingContext = createContext<TenantBrandingContextValue | null>(null)
@@ -39,6 +45,9 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
const [businessNameEn, setBusinessNameEn] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
const [enabledModules, setEnabledModules] = useState<BusinessModuleId[]>(
DEFAULT_ENABLED_BUSINESS_MODULES,
)
const defaultLocaleAppliedRef = useRef(false)
const domain = getTenantDomain()
@@ -75,6 +84,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
setBusinessNameEn(nameEn || domain)
setLogoUrl(nextLogo)
setFaviconUrl(nextFavicon)
setEnabledModules(normalizeEnabledBusinessModules(tenant.enabledModules))
applyDocumentFavicon(nextFavicon)
if (!defaultLocaleAppliedRef.current) {
@@ -91,6 +101,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
setBusinessNameEn(domain)
setLogoUrl(null)
setFaviconUrl(null)
setEnabledModules([...DEFAULT_ENABLED_BUSINESS_MODULES])
applyDocumentFavicon(null)
}
}
@@ -103,8 +114,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
}, [domain, setLocale])
const value = useMemo(
() => ({ businessName, businessNameEn, logoUrl, faviconUrl }),
[businessName, businessNameEn, logoUrl, faviconUrl],
() => ({ businessName, businessNameEn, logoUrl, faviconUrl, enabledModules }),
[businessName, businessNameEn, logoUrl, faviconUrl, enabledModules],
)
return (
+264
View File
@@ -12,6 +12,7 @@ const en = {
'nav.addresses': 'My Addresses',
'nav.orders': 'My Orders',
'nav.favorites': 'My Favorites',
'nav.myProducts': 'My Products',
'nav.help': 'Help Center',
'nav.logout': 'Logout',
@@ -30,12 +31,19 @@ const en = {
'home.card.addresses.title': 'My Addresses',
'home.card.addresses.desc': 'Manage your shipping addresses for checkout and deliveries.',
'home.card.addresses.link': 'View addresses',
'home.card.addresses.count': 'addresses',
'home.card.orders.title': 'My Orders',
'home.card.orders.desc': 'Track your orders, view order history and order details.',
'home.card.orders.link': 'View orders',
'home.card.orders.count': 'orders',
'home.card.favorites.title': 'My Favorites',
'home.card.favorites.desc': 'Browse and manage your saved favorite products.',
'home.card.favorites.link': 'View favorites',
'home.card.favorites.count': 'favorites',
'home.card.myProducts.title': 'My Products',
'home.card.myProducts.desc': 'List and manage products you have submitted.',
'home.card.myProducts.link': 'View my products',
'home.card.myProducts.count': 'products',
'profile.title': 'My Profile',
'profile.subtitle': 'Update your personal information and contact details.',
@@ -154,6 +162,128 @@ const en = {
'favorites.inStock': '{count} in stock',
'favorites.variantOne': '1 variant',
'favorites.variantMany': '{count} variants',
'myProducts.title': 'My Products',
'myProducts.subtitle':
'The products below are yours and will be sold on our website after supervisor approval.',
'myProducts.empty': 'You have not added any products yet.',
'myProducts.add': 'Add product',
'myProducts.addTitle': 'Add product',
'myProducts.addSubtitle': 'Submit a new product for review.',
'myProducts.addComingSoon': 'The add form will be designed next.',
'myProducts.backToList': 'Back to my products',
'myProducts.editTitle': 'Edit product',
'myProducts.editSubtitle': 'Update your listing details, then save.',
'myProducts.detailsTitle': 'Product details',
'myProducts.detailsSubtitle': 'Review your submitted listing.',
'myProducts.edit': 'Edit product',
'myProducts.yes': 'Yes',
'myProducts.editSoon': 'Edit product will be available soon.',
'myProducts.remove': 'Remove product',
'myProducts.removeConfirm': 'Remove this product from your stock?',
'myProducts.removeSuccess': 'Product removed.',
'myProducts.promote': 'Promote product',
'myProducts.promoted': 'Promoted',
'myProducts.promoteSuccess': 'Product promoted.',
'myProducts.priceUnavailable': 'Price on request',
'myProducts.status.pending': 'Pending',
'myProducts.status.published': 'Approved',
'myProducts.status.rejected': 'Rejected',
'myProducts.status.archived': 'Archived',
'myProducts.stepperLabel': 'Add product steps',
'myProducts.step.basics': 'Basics',
'myProducts.step.basicsHint': 'Category, names, description, price, and location.',
'myProducts.step.basicsPlaceholder': 'Basic fields will go here.',
'myProducts.step.images': 'Images',
'myProducts.step.imagesHint': 'Add a cropped thumbnail and gallery photos.',
'myProducts.step.details': 'Details',
'myProducts.step.detailsHint': 'Description and technical information for the selected category.',
'myProducts.step.detailsPlaceholder': 'Details and technical fields will go here.',
'myProducts.step.technical': 'Technical data',
'myProducts.step.technicalHint':
'Choose condition, add optional notes, then fill the category technical form.',
'myProducts.optional': '(optional)',
'myProducts.loading': 'Loading products…',
'myProducts.fields.category': 'Category',
'myProducts.fields.selectCategory': 'Select category',
'myProducts.fields.searchCategory': 'Search category…',
'myProducts.fields.clearCategory': 'Clear category',
'myProducts.fields.noCategories': 'No categories found',
'myProducts.fields.titleFa': 'Name (FA)',
'myProducts.fields.titleFaPlaceholder': 'Product name in Farsi',
'myProducts.fields.titleEn': 'Name (EN)',
'myProducts.fields.titleEnPlaceholder': 'Product name in English',
'myProducts.fields.description': 'Description',
'myProducts.fields.descriptionPlaceholder': 'Short description of your product',
'myProducts.fields.price': 'Desired price',
'myProducts.fields.priceSuggested': 'Your suggested price',
'myProducts.fields.priceByExpert': 'I want an expert to set the price',
'myProducts.fields.pricePlaceholder': 'e.g. 1,500,000',
'myProducts.fields.priceUnit': 'Unit',
'myProducts.fields.priceUnit.IRT': 'IRT',
'myProducts.fields.priceUnit.USD': 'Dollar',
'myProducts.fields.priceUnit.EUR': 'EURO',
'myProducts.fields.priceUnit.AED': 'AED',
'myProducts.fields.location': 'Location',
'myProducts.fields.country': 'Country',
'myProducts.fields.selectCountry': 'Select country',
'myProducts.fields.province': 'Province',
'myProducts.fields.selectProvince': 'Select province',
'myProducts.fields.city': 'City',
'myProducts.fields.selectCity': 'Select city',
'myProducts.fields.searchCity': 'Search city…',
'myProducts.fields.clearCity': 'Clear city',
'myProducts.fields.noCities': 'No cities found',
'myProducts.fields.deliveryNote': 'Pickup / delivery note',
'myProducts.fields.deliveryNotePlaceholder': 'e.g. pickup only, evening delivery…',
'myProducts.fields.district': 'District',
'myProducts.fields.selectDistrict': 'Select district',
'myProducts.fields.condition': 'Condition',
'myProducts.fields.technicalNotes': 'Technical notes',
'myProducts.fields.technicalNotesPlaceholder': 'Optional extra technical details…',
'myProducts.condition.new': 'New',
'myProducts.condition.stock': 'Stock',
'myProducts.condition.needs_repair': 'Needs repair',
'myProducts.condition.scrap': 'Scrap',
'myProducts.images.thumbnail': 'Thumbnail',
'myProducts.images.thumbnailUpload': 'Upload thumbnail',
'myProducts.images.thumbnailHint': '3:2 landscape works best',
'myProducts.images.thumbnailChange': 'Change thumbnail',
'myProducts.images.thumbnailRemove': 'Remove thumbnail',
'myProducts.images.zoom': 'Zoom',
'myProducts.images.applyCrop': 'Apply crop',
'myProducts.images.gallery': 'Gallery',
'myProducts.images.galleryAdd': 'Add photos',
'myProducts.images.galleryHint': 'You can select multiple images.',
'myProducts.images.galleryRemove': 'Remove image {index}',
'myProducts.technical.select': 'Select…',
'myProducts.technical.needCategory': 'Choose a category in step 1 to load technical fields.',
'myProducts.technical.empty': 'This category has no technical fields yet.',
'myProducts.technical.categoryForm': 'Category technical form',
'myProducts.technical.loading': 'Loading technical fields…',
'myProducts.cancel': 'Cancel',
'myProducts.next': 'Next',
'myProducts.back': 'Back',
'myProducts.submit': 'Submit',
'myProducts.save': 'Save changes',
'myProducts.submitting': 'Submitting…',
'myProducts.submitSuccess': 'Product submitted for review.',
'myProducts.updateSuccess': 'Product updated.',
'myProducts.error.load': 'Unable to load your products.',
'myProducts.error.loadDetail': 'Unable to load this product.',
'myProducts.error.loadLocations': 'Unable to load locations.',
'myProducts.error.loadCategories': 'Unable to load categories.',
'myProducts.error.loadTechnicalForm': 'Unable to load the category technical form.',
'myProducts.error.categoryRequired': 'Please select a category.',
'myProducts.error.titleFaRequired': 'Please enter the Farsi name.',
'myProducts.error.locationRequired': 'Please select country and city.',
'myProducts.error.priceInvalid': 'Please enter a valid price.',
'myProducts.error.conditionRequired': 'Please select a condition.',
'myProducts.error.technicalRequired': 'Please fill required technical fields.',
'myProducts.error.submit': 'Unable to submit the product.',
'myProducts.error.update': 'Unable to update the product.',
'myProducts.error.remove': 'Unable to remove the product.',
'myProducts.error.promote': 'Unable to promote the product.',
'storeItems.price.contact': 'Contact for price',
'title.signIn': 'Sign in',
@@ -261,6 +391,7 @@ const fa: Record<MessageKey, string> = {
'nav.addresses': 'آدرس‌های من',
'nav.orders': 'سفارش‌های من',
'nav.favorites': 'علاقه‌مندی‌ها',
'nav.myProducts': 'محصولات من',
'nav.help': 'مرکز راهنما',
'nav.logout': 'خروج',
@@ -279,12 +410,19 @@ const fa: Record<MessageKey, string> = {
'home.card.addresses.title': 'آدرس‌های من',
'home.card.addresses.desc': 'آدرس‌های ارسال برای تسویه‌حساب و تحویل را مدیریت کنید.',
'home.card.addresses.link': 'مشاهده آدرس‌ها',
'home.card.addresses.count': 'آدرس',
'home.card.orders.title': 'سفارش‌های من',
'home.card.orders.desc': 'سفارش‌ها را پیگیری کنید و تاریخچه و جزئیات را ببینید.',
'home.card.orders.link': 'مشاهده سفارش‌ها',
'home.card.orders.count': 'سفارش',
'home.card.favorites.title': 'علاقه‌مندی‌ها',
'home.card.favorites.desc': 'محصولات ذخیره‌شده مورد علاقه‌تان را ببینید و مدیریت کنید.',
'home.card.favorites.link': 'مشاهده علاقه‌مندی‌ها',
'home.card.favorites.count': 'علاقه‌مندی',
'home.card.myProducts.title': 'محصولات من',
'home.card.myProducts.desc': 'محصولاتی که ثبت کرده‌اید را ببینید و مدیریت کنید.',
'home.card.myProducts.link': 'مشاهده محصولات من',
'home.card.myProducts.count': 'محصول',
'profile.title': 'پروفایل من',
'profile.subtitle': 'اطلاعات شخصی و راه‌های ارتباطی خود را به‌روزرسانی کنید.',
@@ -403,6 +541,128 @@ const fa: Record<MessageKey, string> = {
'favorites.inStock': '{count} موجود',
'favorites.variantOne': '۱ تنوع',
'favorites.variantMany': '{count} تنوع',
'myProducts.title': 'محصولات من',
'myProducts.subtitle':
'محصولات زیر، محصولات شما هستند که توسط وبسایت ما بعد از تایید ناظر به فروش خواهد رسید.',
'myProducts.empty': 'هنوز محصولی ثبت نکرده‌اید.',
'myProducts.add': 'افزودن محصول',
'myProducts.addTitle': 'افزودن محصول',
'myProducts.addSubtitle': 'محصول جدید را برای بررسی ارسال کنید.',
'myProducts.addComingSoon': 'فرم افزودن در مرحله بعد طراحی می‌شود.',
'myProducts.backToList': 'بازگشت به محصولات من',
'myProducts.editTitle': 'ویرایش محصول',
'myProducts.editSubtitle': 'جزئیات آگهی را به‌روز کنید و ذخیره کنید.',
'myProducts.detailsTitle': 'جزئیات محصول',
'myProducts.detailsSubtitle': 'جزئیات آگهی ثبت‌شده را ببینید.',
'myProducts.edit': 'ویرایش محصول',
'myProducts.yes': 'بله',
'myProducts.editSoon': 'ویرایش محصول به‌زودی در دسترس خواهد بود.',
'myProducts.remove': 'حذف محصول',
'myProducts.removeConfirm': 'این محصول از موجودی شما حذف شود؟',
'myProducts.removeSuccess': 'محصول حذف شد.',
'myProducts.promote': 'پروموت محصول',
'myProducts.promoted': 'پروموت شده',
'myProducts.promoteSuccess': 'محصول پروموت شد.',
'myProducts.priceUnavailable': 'قیمت اعلام نشده',
'myProducts.status.pending': 'در انتظار تأیید',
'myProducts.status.published': 'تأیید شده',
'myProducts.status.rejected': 'رد شده',
'myProducts.status.archived': 'بایگانی',
'myProducts.stepperLabel': 'مراحل افزودن محصول',
'myProducts.step.basics': 'اطلاعات پایه',
'myProducts.step.basicsHint': 'دسته‌بندی، نام، توضیحات، قیمت و موقعیت.',
'myProducts.step.basicsPlaceholder': 'فیلدهای پایه اینجا قرار می‌گیرند.',
'myProducts.step.images': 'تصاویر',
'myProducts.step.imagesHint': 'تصویر شاخص با برش و گالری تصاویر را اضافه کنید.',
'myProducts.step.details': 'جزئیات',
'myProducts.step.detailsHint': 'توضیحات و اطلاعات فنی بر اساس دسته‌بندی انتخاب‌شده.',
'myProducts.step.detailsPlaceholder': 'جزئیات و فیلدهای فنی اینجا قرار می‌گیرند.',
'myProducts.step.technical': 'اطلاعات فنی',
'myProducts.step.technicalHint':
'وضعیت را انتخاب کنید، یادداشت اختیاری بنویسید و فرم فنی دسته‌بندی را تکمیل کنید.',
'myProducts.optional': '(اختیاری)',
'myProducts.loading': 'در حال بارگذاری محصولات…',
'myProducts.fields.category': 'دسته‌بندی',
'myProducts.fields.selectCategory': 'انتخاب دسته‌بندی',
'myProducts.fields.searchCategory': 'جستجوی دسته‌بندی…',
'myProducts.fields.clearCategory': 'پاک کردن دسته‌بندی',
'myProducts.fields.noCategories': 'دسته‌بندی‌ای پیدا نشد',
'myProducts.fields.titleFa': 'نام (فارسی)',
'myProducts.fields.titleFaPlaceholder': 'نام محصول به فارسی',
'myProducts.fields.titleEn': 'نام (انگلیسی)',
'myProducts.fields.titleEnPlaceholder': 'نام محصول به انگلیسی',
'myProducts.fields.description': 'توضیحات',
'myProducts.fields.descriptionPlaceholder': 'توضیح کوتاه درباره محصول',
'myProducts.fields.price': 'قیمت پیشنهادی',
'myProducts.fields.priceSuggested': 'قیمت پیشنهادی شما',
'myProducts.fields.priceByExpert': 'می‌خواهم قیمت توسط کارشناس مشخص شود',
'myProducts.fields.pricePlaceholder': 'مثلاً ۱٬۵۰۰٬۰۰۰',
'myProducts.fields.priceUnit': 'واحد',
'myProducts.fields.priceUnit.IRT': 'IRT',
'myProducts.fields.priceUnit.USD': 'Dollar',
'myProducts.fields.priceUnit.EUR': 'EURO',
'myProducts.fields.priceUnit.AED': 'AED',
'myProducts.fields.location': 'موقعیت',
'myProducts.fields.country': 'کشور',
'myProducts.fields.selectCountry': 'انتخاب کشور',
'myProducts.fields.province': 'استان',
'myProducts.fields.selectProvince': 'انتخاب استان',
'myProducts.fields.city': 'شهر',
'myProducts.fields.selectCity': 'انتخاب شهر',
'myProducts.fields.searchCity': 'جستجوی شهر…',
'myProducts.fields.clearCity': 'پاک کردن شهر',
'myProducts.fields.noCities': 'شهری پیدا نشد',
'myProducts.fields.deliveryNote': 'یادداشت تحویل / دریافت',
'myProducts.fields.deliveryNotePlaceholder': 'مثلاً فقط حضوری، تحویل عصر…',
'myProducts.fields.district': 'منطقه',
'myProducts.fields.selectDistrict': 'انتخاب منطقه',
'myProducts.fields.condition': 'وضعیت',
'myProducts.fields.technicalNotes': 'توضیحات فنی',
'myProducts.fields.technicalNotesPlaceholder': 'جزئیات فنی اختیاری…',
'myProducts.condition.new': 'نو',
'myProducts.condition.stock': 'استوک',
'myProducts.condition.needs_repair': 'نیاز به تعمیر',
'myProducts.condition.scrap': 'اوراق',
'myProducts.images.thumbnail': 'تصویر شاخص',
'myProducts.images.thumbnailUpload': 'آپلود تصویر شاخص',
'myProducts.images.thumbnailHint': 'نسبت ۳:۲ افقی بهتر است',
'myProducts.images.thumbnailChange': 'تغییر تصویر شاخص',
'myProducts.images.thumbnailRemove': 'حذف تصویر شاخص',
'myProducts.images.zoom': 'بزرگنمایی',
'myProducts.images.applyCrop': 'اعمال برش',
'myProducts.images.gallery': 'گالری',
'myProducts.images.galleryAdd': 'افزودن عکس',
'myProducts.images.galleryHint': 'می‌توانید چند تصویر انتخاب کنید.',
'myProducts.images.galleryRemove': 'حذف تصویر {index}',
'myProducts.technical.select': 'انتخاب کنید…',
'myProducts.technical.needCategory': 'برای نمایش فیلدهای فنی، در مرحله ۱ دسته‌بندی را انتخاب کنید.',
'myProducts.technical.empty': 'برای این دسته‌بندی هنوز فیلد فنی تعریف نشده است.',
'myProducts.technical.categoryForm': 'فرم فنی دسته‌بندی',
'myProducts.technical.loading': 'در حال بارگذاری فیلدهای فنی…',
'myProducts.cancel': 'انصراف',
'myProducts.next': 'بعدی',
'myProducts.back': 'قبلی',
'myProducts.submit': 'ارسال',
'myProducts.save': 'ذخیره تغییرات',
'myProducts.submitting': 'در حال ارسال…',
'myProducts.submitSuccess': 'محصول برای بررسی ارسال شد.',
'myProducts.updateSuccess': 'محصول به‌روز شد.',
'myProducts.error.load': 'بارگذاری محصولات ممکن نشد.',
'myProducts.error.loadDetail': 'بارگذاری این محصول ممکن نشد.',
'myProducts.error.loadLocations': 'بارگذاری موقعیت‌ها ممکن نشد.',
'myProducts.error.loadCategories': 'بارگذاری دسته‌بندی‌ها ممکن نشد.',
'myProducts.error.loadTechnicalForm': 'بارگذاری فرم فنی دسته‌بندی ممکن نشد.',
'myProducts.error.categoryRequired': 'لطفاً دسته‌بندی را انتخاب کنید.',
'myProducts.error.titleFaRequired': 'لطفاً نام فارسی را وارد کنید.',
'myProducts.error.locationRequired': 'لطفاً کشور و شهر را انتخاب کنید.',
'myProducts.error.priceInvalid': 'لطفاً قیمت معتبر وارد کنید.',
'myProducts.error.conditionRequired': 'لطفاً وضعیت را انتخاب کنید.',
'myProducts.error.technicalRequired': 'لطفاً فیلدهای فنی الزامی را تکمیل کنید.',
'myProducts.error.submit': 'ارسال محصول ممکن نشد.',
'myProducts.error.update': 'به‌روزرسانی محصول ممکن نشد.',
'myProducts.error.remove': 'حذف محصول ممکن نشد.',
'myProducts.error.promote': 'پروموت محصول ممکن نشد.',
'storeItems.price.contact': 'برای قیمت، تماس بگیرید',
'title.signIn': 'ورود',
@@ -533,6 +793,10 @@ export function getCustomerRouteTitleRules(locale: DashboardLocale) {
{ match: '/addresses', labels: [t('nav.addresses')] },
{ match: '/orders', labels: [t('nav.orders')] },
{ match: '/favorites', labels: [t('nav.favorites')] },
{ match: '/my-products/new', labels: [t('nav.myProducts'), t('myProducts.addTitle')] },
{ match: /^\/my-products\/[^/]+\/edit$/, labels: [t('nav.myProducts'), t('myProducts.editTitle')] },
{ match: /^\/my-products\/[^/]+$/, labels: [t('nav.myProducts'), t('myProducts.detailsTitle')] },
{ match: '/my-products', labels: [t('nav.myProducts')] },
{ match: '/', labels: [t('nav.home')] },
]
}
+65
View File
@@ -5,4 +5,69 @@
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'YekanBakh', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
/* Neutral light surfaces (theme color stays on accents only) */
--bg-gradient-start: #f4f5f7;
--bg-gradient-mid: #eef0f3;
--bg-gradient-end: #e8eaee;
--glass-bg: rgba(255, 255, 255, 0.72);
--glass-border: rgba(148, 163, 184, 0.28);
--glass-shadow: 0 8px 32px rgba(15, 23, 42, 0.08);
--surface: rgba(255, 255, 255, 0.82);
--card-media-bg: #ffffff;
--elevated-surface: rgba(255, 255, 255, 0.96);
--icon-bg: color-mix(in srgb, var(--primary) 14%, #ffffff);
--icon-bg-end: color-mix(in srgb, var(--primary) 8%, #f1f5f9);
}
/*
* Dark mode: neutral dark gray backgrounds.
* Brand/theme color (--primary) is for accents only not page/card backgrounds.
*/
html[data-theme='dark'] {
color-scheme: dark;
--bg-gradient-start: #2a2d34;
--bg-gradient-mid: #22252b;
--bg-gradient-end: #1a1d23;
--glass-bg: rgba(45, 49, 57, 0.9);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
--modal-overlay-bg: rgba(0, 0, 0, 0.6);
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-muted: #94a3b8;
--border-color: rgba(255, 255, 255, 0.12);
--surface: rgba(55, 59, 68, 0.95);
--card-media-bg: #32363f;
--elevated-surface: #3a3e48;
--card-hover-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
/* Icon chip: clear primary tint on dark gray (not muddy page wash) */
--icon-bg: rgba(var(--primary-rgb) / 0.22);
--icon-bg-end: rgba(var(--primary-rgb) / 0.12);
}
html[data-theme='dark'] select {
background-color: var(--surface);
color: var(--text-primary);
border-color: var(--border-color);
}
html[data-theme='dark']
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']),
html[data-theme='dark'] textarea {
background-color: var(--surface);
color: var(--text-primary);
border-color: var(--border-color);
}
/* Soft neutral aura — no theme-color wash on the page */
html[data-theme='dark'] body::before {
background:
radial-gradient(ellipse 520px 520px at 72% 18%, rgba(255, 255, 255, 0.04), transparent 72%),
radial-gradient(ellipse 420px 420px at 22% 82%, rgba(255, 255, 255, 0.03), transparent 72%);
}
+4
View File
@@ -15,5 +15,9 @@ export const customerRouteTitleRules: RouteTitleRule[] = [
{ match: '/addresses', labels: ['My Addresses'] },
{ match: '/orders', labels: ['My Orders'] },
{ match: '/favorites', labels: ['My Favorites'] },
{ match: '/my-products/new', labels: ['My Products', 'Add product'] },
{ match: /^\/my-products\/[^/]+\/edit$/, labels: ['My Products', 'Edit product'] },
{ match: /^\/my-products\/[^/]+$/, labels: ['My Products', 'Product details'] },
{ match: '/my-products', labels: ['My Products'] },
{ match: '/', labels: ['Home'] },
]
@@ -0,0 +1,605 @@
.root {
display: flex;
flex-direction: column;
gap: 0;
font-family: var(--font-ui);
}
.shell {
display: flex;
align-items: stretch;
gap: 12px;
}
.iconRail {
width: 128px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 16px 12px;
border: 1px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius);
background: color-mix(in srgb, var(--glass-bg) 70%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-sizing: border-box;
}
.iconRailInner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
text-align: center;
}
.iconRailGlyph {
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
margin-bottom: 2px;
}
.iconRailLabelFa {
display: block;
width: 100%;
font-size: 12px;
font-weight: 700;
font-family: var(--font-ui);
color: var(--text-primary);
line-height: 1.2;
text-align: center;
}
.iconRailLabelEn {
display: block;
width: 100%;
margin-top: -6px;
font-size: 9px;
font-weight: 700;
font-family: var(--font-en);
color: var(--text-muted);
line-height: 1.25;
letter-spacing: 0.1em;
/* Compensate letter-spacing so centered Latin text doesnt drift */
padding-inline-start: 0.1em;
text-transform: uppercase;
text-align: center;
direction: ltr;
unicode-bidi: isolate;
}
.card {
flex: 1;
min-width: 0;
background: var(--glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
padding: 28px 28px 24px;
}
@media (max-width: 640px) {
.shell {
flex-direction: column;
}
.iconRail {
width: 100%;
min-height: 88px;
}
.card {
padding: 22px 18px 20px;
}
}
.stepper {
display: flex;
align-items: flex-end;
width: 100%;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
}
.stepGroup {
display: flex;
align-items: flex-end;
flex: 1;
min-width: 0;
}
.stepGroup:last-child {
flex: 0 0 auto;
}
.stepUnit {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.stepDot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
background: rgba(148, 163, 184, 0.2);
color: var(--text-muted);
border: 2px solid transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.stepLabel {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
transition: color 0.2s;
}
.stepActive .stepDot {
background: rgba(var(--primary-rgb) / 0.15);
color: var(--primary);
border-color: var(--primary);
}
.stepActive .stepLabel {
color: var(--primary);
}
.stepDone .stepDot {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.stepDone .stepLabel {
color: var(--text-secondary);
}
.connector {
flex: 1;
height: 2px;
min-width: 24px;
margin: 0 8px 13px;
background: rgba(148, 163, 184, 0.3);
border-radius: 1px;
transition: background 0.2s;
}
.connectorDone {
background: var(--primary);
}
.body {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
}
.stepTitle {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.stepDesc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
.form {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 8px;
}
.fieldRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.priceRow {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px;
align-items: end;
}
.checkRow {
display: inline-flex;
align-items: center;
align-self: end;
gap: 8px;
height: var(--field-height);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
box-sizing: border-box;
}
.checkRow input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
flex-shrink: 0;
margin: 0;
}
.fieldRowTriple {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.locationRow {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px;
align-items: end;
}
.col2 {
grid-column: span 2;
}
.col3 {
grid-column: span 3;
}
.col4 {
grid-column: span 4;
}
.col6 {
grid-column: span 6;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.optional {
font-weight: 400;
color: var(--text-muted);
}
.field input,
.field select,
.field textarea {
width: 100%;
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.field input,
.field select {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
}
.field textarea {
padding: var(--field-padding-y) var(--field-padding-x);
resize: vertical;
min-height: 96px;
line-height: 1.5;
}
.field input::placeholder,
.field textarea::placeholder {
font-family: var(--font-ui);
color: var(--text-muted);
opacity: 1;
}
.field select {
appearance: none;
-webkit-appearance: none;
padding-inline-end: var(--select-padding-end);
background-color: var(--surface);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--select-arrow-offset) center;
background-size: var(--select-arrow-size);
cursor: pointer;
}
:global([dir='rtl']) .field select {
background-position: left var(--select-arrow-offset) center;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.sectionDivider {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sectionDivider::before,
.sectionDivider::after {
content: '';
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.25);
}
.thumbnailBlock {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.thumbnailBlock > .field {
grid-column: 5 / span 4;
}
@media (max-width: 720px) {
.thumbnailBlock > .field {
grid-column: 1 / -1;
}
}
.chipGrid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
padding: 7px 14px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-secondary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 50px;
transition: all 0.15s;
}
.chip:hover {
border-color: var(--primary);
color: var(--primary);
}
.chipSelected {
background: rgba(var(--primary-rgb) / 0.12);
border-color: var(--primary);
color: var(--primary);
}
.conditionFieldset {
margin: 0;
padding: 0;
border: none;
}
.conditionFieldset legend {
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.radioGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.radioCard {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: var(--field-height);
padding: var(--field-padding-y) 8px;
font-size: 12px;
font-family: var(--font-ui);
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
text-align: center;
}
.radioCard:hover {
border-color: var(--primary);
}
.radioCard:has(input:checked) {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.16);
}
.radioCard input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary);
flex-shrink: 0;
}
.radioCard span {
min-width: 0;
line-height: 1.25;
}
@media (max-width: 720px) {
.radioGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.inlineStatus {
margin: 4px 0 0;
font-size: 13px;
color: var(--text-muted);
}
.placeholder {
margin-top: 8px;
padding: 28px 20px;
text-align: center;
font-size: 13px;
color: var(--text-muted);
background: color-mix(in srgb, var(--surface) 70%, transparent);
border: 1px dashed var(--glass-border);
border-radius: var(--radius-sm);
}
.error {
margin-top: 8px;
font-size: 13px;
color: #fca5a5;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.28);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.ghostBtn,
.secondaryBtn,
.primaryBtn {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 600;
line-height: 1.4;
border-radius: var(--radius-sm);
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s, background 0.2s, color 0.2s;
white-space: nowrap;
}
.ghostBtn {
padding: 10px 4px;
color: var(--text-secondary);
text-decoration: none;
background: transparent;
}
.ghostBtn:hover {
color: var(--primary);
}
.secondaryBtn {
padding: 10px 18px;
color: var(--text-primary);
background: rgba(148, 163, 184, 0.16);
border: 1px solid var(--glass-border);
}
.secondaryBtn:hover {
background: rgba(148, 163, 184, 0.24);
}
.primaryBtn {
margin-inline-start: auto;
padding: 10px 22px;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
}
.primaryBtn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
}
@media (max-width: 720px) {
.fieldRow,
.fieldRowTriple,
.priceRow,
.locationRow {
grid-template-columns: 1fr;
}
.col2,
.col3,
.col4,
.col6 {
grid-column: auto;
}
}
@media (max-width: 520px) {
.stepLabel {
font-size: 11px;
}
.connector {
min-width: 12px;
margin: 0 4px 13px;
}
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.primaryBtn,
.secondaryBtn,
.ghostBtn {
width: 100%;
margin-inline-start: 0;
text-align: center;
}
}
@@ -0,0 +1,872 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { Check, ClipboardList, Images, SlidersHorizontal } from 'lucide-react'
import {
getLocationOptionLabel,
useLocale,
useToast,
type CityOption,
} from '@meshkee/dashboard-ui'
import { CitySearchSelect } from '../components/CitySearchSelect'
import { CategorySearchSelect } from '../components/CategorySearchSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { useT } from '../i18n/useT'
import { translate } from '../i18n/messages'
import { ApiError, isAbortError } from '../lib/api'
import {
listCitiesByCountrySlug,
listCountries,
} from '../services/citiesService'
import {
buildTechnicalValuesPayload,
createMyUserProduct,
getMyUserProduct,
getMyUserProductCategoryTechnicalForm,
listMyUserProductCategories,
updateMyUserProduct,
type TechnicalFormField,
type TechnicalFormValues,
type UserProductCategoryOption,
type UserProductCondition,
type UserProductPriceCurrency,
type UserProductTechnicalValueInput,
} from '../services/userProductsService'
import { resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import styles from './AddMyProductPage.module.css'
type StepId = 1 | 2 | 3
const PRICE_UNITS: UserProductPriceCurrency[] = ['IRT', 'USD', 'EUR', 'AED']
const CONDITIONS: UserProductCondition[] = [
'new',
'stock',
'needs_repair',
'scrap',
]
function fieldLabel(field: TechnicalFormField) {
return field.label
}
function optionLabel(option: TechnicalFormField['options'][number]) {
return option.label
}
function mapTechnicalValues(
items: UserProductTechnicalValueInput[],
): TechnicalFormValues {
const values: TechnicalFormValues = {}
for (const item of items) {
if (item.textValue != null) {
values[item.fieldId] = item.textValue
continue
}
if (item.optionId) {
values[item.fieldId] = item.optionId
continue
}
if (item.optionIds?.length) {
values[item.fieldId] = item.optionIds
}
}
return values
}
function isCondition(value: string | null | undefined): value is UserProductCondition {
return (
value === 'new' ||
value === 'stock' ||
value === 'needs_repair' ||
value === 'scrap'
)
}
function isPriceCurrency(
value: string | null | undefined,
): value is UserProductPriceCurrency {
return (
value === 'IRT' || value === 'USD' || value === 'EUR' || value === 'AED'
)
}
export function AddMyProductPage() {
const t = useT()
const { locale } = useLocale()
const navigate = useNavigate()
const { id } = useParams<{ id?: string }>()
const isEdit = Boolean(id)
const { showToast } = useToast()
const [loaded, setLoaded] = useState(!isEdit)
const [step, setStep] = useState<StepId>(1)
const [stepError, setStepError] = useState('')
const [submitting, setSubmitting] = useState(false)
const pendingTechnicalValuesRef = useRef<TechnicalFormValues | null>(null)
const [categories, setCategories] = useState<UserProductCategoryOption[]>([])
const [loadingCategories, setLoadingCategories] = useState(false)
const [categoryId, setCategoryId] = useState('')
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [description, setDescription] = useState('')
const [priceInput, setPriceInput] = useState('')
const [priceUnit, setPriceUnit] = useState<UserProductPriceCurrency>('IRT')
const [priceByExpert, setPriceByExpert] = useState(false)
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('')
const [loadingLocations, setLoadingLocations] = useState(false)
const [thumbnail, setThumbnail] = useState<string | null>(null)
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [gallery, setGallery] = useState<string[]>([])
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [condition, setCondition] = useState<UserProductCondition>('new')
const [technicalNotes, setTechnicalNotes] = useState('')
const [technicalFields, setTechnicalFields] = useState<TechnicalFormField[]>([])
const [technicalValues, setTechnicalValues] = useState<TechnicalFormValues>({})
const [loadingTechnicalForm, setLoadingTechnicalForm] = useState(false)
const steps: { id: StepId; label: string }[] = [
{ id: 1, label: t('myProducts.step.basics') },
{ id: 2, label: t('myProducts.step.images') },
{ id: 3, label: t('myProducts.step.technical') },
]
const stepIcon =
step === 1 ? (
<ClipboardList size={40} strokeWidth={1.4} />
) : step === 2 ? (
<Images size={40} strokeWidth={1.4} />
) : (
<SlidersHorizontal size={40} strokeWidth={1.4} />
)
const stepLabelKey =
step === 1
? 'myProducts.step.basics'
: step === 2
? 'myProducts.step.images'
: 'myProducts.step.technical'
const stepNameFa = translate('fa', stepLabelKey)
const stepNameEn = translate('en', stepLabelKey)
useEffect(() => {
const controller = new AbortController()
setLoadingLocations(true)
void listCountries(controller.signal)
.then((items) => {
if (!controller.signal.aborted) setCountries(items)
})
.catch((err) => {
if (!isAbortError(err)) setStepError(t('myProducts.error.loadLocations'))
})
.finally(() => {
if (!controller.signal.aborted) setLoadingLocations(false)
})
return () => controller.abort()
}, [t])
useEffect(() => {
const controller = new AbortController()
setLoadingCategories(true)
void listMyUserProductCategories(controller.signal)
.then((response) => {
if (!controller.signal.aborted) setCategories(response.items)
})
.catch((err) => {
if (!isAbortError(err)) setStepError(t('myProducts.error.loadCategories'))
})
.finally(() => {
if (!controller.signal.aborted) setLoadingCategories(false)
})
return () => controller.abort()
}, [t])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadProduct() {
setStepError('')
try {
const response = await getMyUserProduct(id!, controller.signal)
if (controller.signal.aborted) return
const product = response.product
pendingTechnicalValuesRef.current = mapTechnicalValues(
product.technicalValues ?? [],
)
setCategoryId(product.categoryId ?? '')
setTitleFa(product.titleFa ?? product.title ?? '')
setTitleEn(product.titleEn ?? '')
setDescription(product.description ?? '')
setPriceInput(
product.price != null ? formatIrtInput(String(product.price)) : '',
)
setPriceUnit(
isPriceCurrency(product.priceCurrency)
? product.priceCurrency
: 'IRT',
)
setPriceByExpert(product.priceByExpert === true)
setCountrySlug(product.countrySlug)
setCountryId(product.countryId)
setCityId(product.cityId)
setDeliveryNote(product.deliveryNote ?? '')
setThumbnail(product.imageUrl)
setFeaturedMediaId(product.featuredMediaId)
setGallery((product.images ?? []).map((item) => item.url))
setGalleryMediaIds(product.galleryMediaIds ?? [])
setCondition(isCondition(product.condition) ? product.condition : 'new')
setTechnicalNotes(product.technicalNotes ?? '')
if (product.countrySlug) {
setCities(await listCitiesByCountrySlug(product.countrySlug))
}
if (!controller.signal.aborted) setLoaded(true)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
showToast(
err instanceof ApiError ? err.message : t('myProducts.error.load'),
'error',
)
navigate('/my-products', { replace: true })
}
}
void loadProduct()
return () => controller.abort()
}, [isEdit, id, navigate, showToast, t])
useEffect(() => {
if (!pendingTechnicalValuesRef.current) {
setTechnicalValues({})
}
setTechnicalFields([])
if (!categoryId) return
const controller = new AbortController()
setLoadingTechnicalForm(true)
void getMyUserProductCategoryTechnicalForm(categoryId, controller.signal)
.then((response) => {
if (controller.signal.aborted) return
setTechnicalFields(response.form?.fields ?? [])
if (pendingTechnicalValuesRef.current) {
setTechnicalValues(pendingTechnicalValuesRef.current)
pendingTechnicalValuesRef.current = null
}
})
.catch((err) => {
if (!isAbortError(err)) {
setTechnicalFields([])
setStepError(t('myProducts.error.loadTechnicalForm'))
}
})
.finally(() => {
if (!controller.signal.aborted) setLoadingTechnicalForm(false)
})
return () => controller.abort()
}, [categoryId, t])
async function handleCountryChange(nextSlug: string) {
setCountrySlug(nextSlug)
setCountryId(countries.find((item) => item.slug === nextSlug)?.id ?? '')
setCityId('')
setCities([])
if (!nextSlug) return
setLoadingLocations(true)
try {
setCities(await listCitiesByCountrySlug(nextSlug))
} catch {
setStepError(t('myProducts.error.loadLocations'))
} finally {
setLoadingLocations(false)
}
}
function validateStep1() {
if (!categoryId) return t('myProducts.error.categoryRequired')
if (!titleFa.trim()) return t('myProducts.error.titleFaRequired')
if (!countryId || !cityId) {
return t('myProducts.error.locationRequired')
}
const price = parseIrtInput(priceInput)
if (priceInput.trim() && (price === null || price < 0)) {
return t('myProducts.error.priceInvalid')
}
return ''
}
function validateStep3() {
if (!condition) return t('myProducts.error.conditionRequired')
for (const field of technicalFields) {
if (!field.isRequired) continue
const value = technicalValues[field.id]
const ok =
field.type === 'multi_select'
? Array.isArray(value) && value.length > 0
: typeof value === 'string' && value.trim().length > 0
if (!ok) {
return t('myProducts.error.technicalRequired')
}
}
return ''
}
function goNext() {
setStepError('')
if (step === 1) {
const error = validateStep1()
if (error) {
setStepError(error)
return
}
setStep(2)
return
}
if (step === 2) setStep(3)
}
function goBack() {
setStepError('')
if (step === 2) setStep(1)
if (step === 3) setStep(2)
}
async function handleSubmit() {
setStepError('')
const validationError = validateStep3()
if (validationError) {
setStepError(validationError)
return
}
const price = parseIrtInput(priceInput)
setSubmitting(true)
try {
let nextFeaturedMediaId = featuredMediaId
if (thumbnail?.startsWith('data:')) {
nextFeaturedMediaId = await resolveDataUrlToMediaId(
thumbnail,
'user-product-thumbnail.jpg',
featuredMediaId,
)
} else if (!thumbnail) {
nextFeaturedMediaId = null
}
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(
gallery,
galleryMediaIds,
)
const payload = {
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
description: description.trim() || undefined,
categoryId,
price: price ?? undefined,
priceCurrency: priceUnit,
priceByExpert,
countryId,
cityId,
deliveryNote: deliveryNote.trim() || undefined,
condition,
technicalNotes: technicalNotes.trim() || undefined,
technicalValues: buildTechnicalValuesPayload(technicalFields, technicalValues),
featuredMediaId: nextFeaturedMediaId || undefined,
galleryMediaIds: nextGalleryMediaIds,
}
if (isEdit && id) {
await updateMyUserProduct(id, payload)
showToast(t('myProducts.updateSuccess'), 'success')
} else {
await createMyUserProduct(payload)
showToast(t('myProducts.submitSuccess'), 'success')
}
navigate('/my-products')
} catch (err) {
setStepError(
err instanceof ApiError
? err.message
: isEdit
? t('myProducts.error.update')
: t('myProducts.error.submit'),
)
} finally {
setSubmitting(false)
}
}
function setTechnicalField(fieldId: string, value: string | string[]) {
setTechnicalValues((prev) => ({ ...prev, [fieldId]: value }))
}
function toggleMultiOption(fieldId: string, optionId: string) {
setTechnicalValues((prev) => {
const current = prev[fieldId]
const selected = Array.isArray(current) ? current : []
const next = selected.includes(optionId)
? selected.filter((item) => item !== optionId)
: [...selected, optionId]
return { ...prev, [fieldId]: next }
})
}
function renderTechnicalField(field: TechnicalFormField) {
const value = technicalValues[field.id]
const id = `tech-${field.id}`
if (field.type === 'textarea') {
return (
<textarea
id={id}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
rows={4}
/>
)
}
if (field.type === 'select') {
return (
<select
id={id}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
>
<option value="">{t('myProducts.technical.select')}</option>
{field.options.map((option) => (
<option key={option.id} value={option.id}>
{optionLabel(option)}
</option>
))}
</select>
)
}
if (field.type === 'multi_select') {
const selected = Array.isArray(value) ? value : []
return (
<div className={styles.chipGrid}>
{field.options.map((option) => (
<button
key={option.id}
type="button"
className={`${styles.chip} ${selected.includes(option.id) ? styles.chipSelected : ''}`}
onClick={() => toggleMultiOption(field.id, option.id)}
>
{optionLabel(option)}
</button>
))}
</div>
)
}
return (
<input
id={id}
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
/>
)
}
if (!loaded) {
return <p className={styles.inlineStatus}>{t('myProducts.loading')}</p>
}
return (
<div className={styles.shell}>
<aside className={styles.iconRail} aria-hidden>
<div className={styles.iconRailInner}>
<span className={styles.iconRailGlyph}>{stepIcon}</span>
<span className={styles.iconRailLabelFa}>{stepNameFa}</span>
<span className={styles.iconRailLabelEn}>{stepNameEn}</span>
</div>
</aside>
<div className={styles.card}>
<div className={styles.root}>
<nav className={styles.stepper} aria-label={t('myProducts.stepperLabel')}>
{steps.map((item, index) => {
const isDone = step > item.id
const isActive = step === item.id
const connectorDone = step > item.id
return (
<div key={item.id} className={styles.stepGroup}>
<div
className={[
styles.stepUnit,
isActive ? styles.stepActive : '',
isDone ? styles.stepDone : '',
]
.filter(Boolean)
.join(' ')}
>
<span className={styles.stepLabel}>{item.label}</span>
<span className={styles.stepDot} aria-hidden>
{isDone ? <Check size={14} /> : index + 1}
</span>
</div>
{index < steps.length - 1 && (
<div
className={[
styles.connector,
connectorDone ? styles.connectorDone : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden
/>
)}
</div>
)
})}
</nav>
<div className={styles.body} key={step}>
{step === 1 && (
<>
<h2 className={styles.stepTitle}>
{isEdit ? t('myProducts.editTitle') : t('myProducts.step.basics')}
</h2>
<p className={styles.stepDesc}>
{isEdit
? t('myProducts.editSubtitle')
: t('myProducts.step.basicsHint')}
</p>
<div className={styles.form}>
<div className={styles.field}>
<label htmlFor="add-category">{t('myProducts.fields.category')}</label>
<CategorySearchSelect
id="add-category"
options={categories}
value={categoryId}
onChange={setCategoryId}
disabled={loadingCategories}
placeholder={t('myProducts.fields.searchCategory')}
/>
</div>
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="add-title-fa">{t('myProducts.fields.titleFa')}</label>
<input
id="add-title-fa"
type="text"
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
placeholder={t('myProducts.fields.titleFaPlaceholder')}
/>
</div>
<div className={styles.field}>
<label htmlFor="add-title-en">{t('myProducts.fields.titleEn')}</label>
<input
id="add-title-en"
type="text"
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
placeholder={t('myProducts.fields.titleEnPlaceholder')}
dir="ltr"
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="add-description">{t('myProducts.fields.description')}</label>
<textarea
id="add-description"
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('myProducts.fields.descriptionPlaceholder')}
/>
</div>
<div className={styles.priceRow}>
<label
className={`${styles.checkRow} ${styles.col6}`}
htmlFor="add-price-by-expert"
>
<input
id="add-price-by-expert"
type="checkbox"
checked={priceByExpert}
onChange={(e) => setPriceByExpert(e.target.checked)}
/>
<span>{t('myProducts.fields.priceByExpert')}</span>
</label>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="add-price">
{priceByExpert
? t('myProducts.fields.priceSuggested')
: t('myProducts.fields.price')}
</label>
<input
id="add-price"
type="text"
inputMode="numeric"
value={priceInput}
onChange={(e) => setPriceInput(formatIrtInput(e.target.value))}
placeholder={t('myProducts.fields.pricePlaceholder')}
dir="ltr"
/>
</div>
<div className={`${styles.field} ${styles.col2}`}>
<label htmlFor="add-price-unit">{t('myProducts.fields.priceUnit')}</label>
<select
id="add-price-unit"
value={priceUnit}
onChange={(e) =>
setPriceUnit(e.target.value as UserProductPriceCurrency)
}
>
{PRICE_UNITS.map((unit) => (
<option key={unit} value={unit}>
{t(`myProducts.fields.priceUnit.${unit}`)}
</option>
))}
</select>
</div>
</div>
<div className={styles.sectionDivider}>
<span>{t('myProducts.fields.location')}</span>
</div>
<div className={styles.locationRow}>
<div className={`${styles.field} ${styles.col2}`}>
<label htmlFor="add-country">{t('myProducts.fields.country')}</label>
<select
id="add-country"
value={countrySlug}
disabled={loadingLocations}
onChange={(e) => void handleCountryChange(e.target.value)}
>
<option value="">{t('myProducts.fields.selectCountry')}</option>
{countries.map((country) => (
<option key={country.id} value={country.slug}>
{getLocationOptionLabel(country, locale)}
</option>
))}
</select>
</div>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="add-city">{t('myProducts.fields.city')}</label>
<CitySearchSelect
id="add-city"
options={cities}
value={cityId}
onChange={setCityId}
disabled={!countrySlug || loadingLocations}
placeholder={t('myProducts.fields.searchCity')}
/>
</div>
<div className={`${styles.field} ${styles.col6}`}>
<label htmlFor="add-delivery-note">
{t('myProducts.fields.deliveryNote')}
<span className={styles.optional}> {t('myProducts.optional')}</span>
</label>
<input
id="add-delivery-note"
type="text"
value={deliveryNote}
onChange={(e) => setDeliveryNote(e.target.value)}
placeholder={t('myProducts.fields.deliveryNotePlaceholder')}
/>
</div>
</div>
</div>
</>
)}
{step === 2 && (
<>
<h2 className={styles.stepTitle}>{t('myProducts.step.images')}</h2>
<p className={styles.stepDesc}>{t('myProducts.step.imagesHint')}</p>
<div className={styles.form}>
<div className={styles.thumbnailBlock}>
<div className={styles.field}>
<label>{t('myProducts.images.thumbnail')}</label>
<ImageCropper
value={thumbnail}
onChange={(value) => {
setThumbnail(value)
if (!value || value.startsWith('data:')) {
setFeaturedMediaId(null)
}
}}
aspect={1}
hint={t('myProducts.images.thumbnailHint')}
/>
</div>
</div>
<div className={styles.sectionDivider}>
<span>{t('myProducts.images.gallery')}</span>
</div>
<div className={styles.field}>
<ImageUploader
images={gallery}
onChange={(next) => {
const prevUrlToId = new Map(
gallery.map((url, index) => [
url,
galleryMediaIds[index] ?? '',
]),
)
setGallery(next)
setGalleryMediaIds(
next.map((url) =>
url.startsWith('data:')
? ''
: prevUrlToId.get(url) ?? '',
),
)
}}
/>
</div>
</div>
</>
)}
{step === 3 && (
<>
<h2 className={styles.stepTitle}>{t('myProducts.step.technical')}</h2>
<p className={styles.stepDesc}>{t('myProducts.step.technicalHint')}</p>
<div className={styles.form}>
<fieldset className={styles.conditionFieldset}>
<legend>{t('myProducts.fields.condition')}</legend>
<div className={styles.radioGrid} role="radiogroup">
{CONDITIONS.map((value) => (
<label key={value} className={styles.radioCard}>
<input
type="radio"
name="product-condition"
value={value}
checked={condition === value}
onChange={() => setCondition(value)}
/>
<span>{t(`myProducts.condition.${value}`)}</span>
</label>
))}
</div>
</fieldset>
<div className={styles.field}>
<label htmlFor="add-technical-notes">
{t('myProducts.fields.technicalNotes')}
<span className={styles.optional}> {t('myProducts.optional')}</span>
</label>
<textarea
id="add-technical-notes"
rows={4}
value={technicalNotes}
onChange={(e) => setTechnicalNotes(e.target.value)}
placeholder={t('myProducts.fields.technicalNotesPlaceholder')}
/>
</div>
<div className={styles.sectionDivider}>
<span>{t('myProducts.technical.categoryForm')}</span>
</div>
{!categoryId ? (
<div className={styles.placeholder}>
{t('myProducts.technical.needCategory')}
</div>
) : loadingTechnicalForm ? (
<p className={styles.inlineStatus}>{t('myProducts.technical.loading')}</p>
) : technicalFields.length === 0 ? (
<div className={styles.placeholder}>{t('myProducts.technical.empty')}</div>
) : (
technicalFields.map((field) => (
<div key={field.id} className={styles.field}>
<label htmlFor={`tech-${field.id}`}>
{fieldLabel(field)}
{field.isRequired ? (
' *'
) : (
<span className={styles.optional}> {t('myProducts.optional')}</span>
)}
</label>
{renderTechnicalField(field)}
</div>
))
)}
</div>
</>
)}
{stepError ? (
<div className={styles.error} role="alert">
{stepError}
</div>
) : null}
</div>
<div className={styles.actions}>
{step === 1 ? (
<Link to="/my-products" className={styles.ghostBtn}>
{t('myProducts.cancel')}
</Link>
) : (
<button
type="button"
className={styles.secondaryBtn}
onClick={goBack}
disabled={submitting}
>
{t('myProducts.back')}
</button>
)}
{step < 3 ? (
<button type="button" className={styles.primaryBtn} onClick={goNext}>
{t('myProducts.next')}
</button>
) : (
<button
type="button"
className={styles.primaryBtn}
onClick={() => void handleSubmit()}
disabled={submitting || loadingTechnicalForm}
>
{submitting
? t('myProducts.submitting')
: isEdit
? t('myProducts.save')
: t('myProducts.submit')}
</button>
)}
</div>
</div>
</div>
</div>
)
}
@@ -54,7 +54,7 @@
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.55);
background: var(--elevated-surface);
}
.rowLine {
+124 -33
View File
@@ -1,49 +1,129 @@
import { CalendarDays, User, MapPin, ShoppingBag, Heart } from 'lucide-react'
import { useEffect, useState } from 'react'
import { CalendarDays, User, MapPin, ShoppingBag, Heart, Package } from 'lucide-react'
import { SectionCard, useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { useT } from '../i18n/useT'
import type { CustomerMessageKey } from '../i18n/messages'
import { listAddresses } from '../services/addressService'
import { listFavorites } from '../services/favoritesService'
import { listOrders } from '../services/orderService'
import { listMyUserProducts } from '../services/userProductsService'
import { hasBusinessModule } from '../utils/businessModules'
import styles from '../components/PageContent.module.css'
type CountKey = 'myProducts' | 'addresses' | 'orders' | 'favorites'
type HomeSection = {
icon: typeof User
titleKey: CustomerMessageKey
descKey: CustomerMessageKey
countLabelKey?: CustomerMessageKey
href: string
countKey?: CountKey
colClass: string
moduleGated?: boolean
}
const baseSections: HomeSection[] = [
{
icon: Package,
titleKey: 'home.card.myProducts.title',
descKey: 'home.card.myProducts.desc',
countLabelKey: 'home.card.myProducts.count',
href: '/my-products',
countKey: 'myProducts',
colClass: styles.col6,
moduleGated: true,
},
{
icon: User,
titleKey: 'home.card.profile.title',
descKey: 'home.card.profile.desc',
href: '/profile',
colClass: styles.col3,
},
{
icon: MapPin,
titleKey: 'home.card.addresses.title',
descKey: 'home.card.addresses.desc',
countLabelKey: 'home.card.addresses.count',
href: '/addresses',
countKey: 'addresses',
colClass: styles.col3,
},
{
icon: ShoppingBag,
titleKey: 'home.card.orders.title',
descKey: 'home.card.orders.desc',
countLabelKey: 'home.card.orders.count',
href: '/orders',
countKey: 'orders',
colClass: styles.col3,
},
{
icon: Heart,
titleKey: 'home.card.favorites.title',
descKey: 'home.card.favorites.desc',
countLabelKey: 'home.card.favorites.count',
href: '/favorites',
countKey: 'favorites',
colClass: styles.col3,
},
]
type SectionCounts = Partial<Record<CountKey, number>>
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
const [myProducts, addresses, orders, favorites] = await Promise.all([
listMyUserProducts({ page: 1, pageSize: 1 }, signal)
.then((r) => r.total)
.catch(() => null),
listAddresses(signal)
.then((r) => r.items.length)
.catch(() => null),
listOrders({ page: 1, pageSize: 1 }, signal)
.then((r) => r.total)
.catch(() => null),
listFavorites({ page: 1, pageSize: 1 }, signal)
.then((r) => r.total)
.catch(() => null),
])
const counts: SectionCounts = {}
if (myProducts !== null) counts.myProducts = myProducts
if (addresses !== null) counts.addresses = addresses
if (orders !== null) counts.orders = orders
if (favorites !== null) counts.favorites = favorites
return counts
}
export function HomePage() {
const { user } = useAuth()
const { locale } = useLocale()
const { enabledModules } = useTenantBranding()
const t = useT()
const [counts, setCounts] = useState<SectionCounts>({})
const showMyProducts = hasBusinessModule(enabledModules, 'customer_products')
useEffect(() => {
const controller = new AbortController()
void loadSectionCounts(controller.signal).then((next) => {
if (!controller.signal.aborted) setCounts(next)
})
return () => controller.abort()
}, [])
const firstName =
(locale === 'en' ? user?.firstNameEn : user?.firstName) ||
user?.firstName ||
user?.firstNameEn ||
t('home.welcomeFallback')
const sections = [
{
icon: User,
title: t('home.card.profile.title'),
description: t('home.card.profile.desc'),
linkText: t('home.card.profile.link'),
href: '/profile',
},
{
icon: MapPin,
title: t('home.card.addresses.title'),
description: t('home.card.addresses.desc'),
linkText: t('home.card.addresses.link'),
href: '/addresses',
},
{
icon: ShoppingBag,
title: t('home.card.orders.title'),
description: t('home.card.orders.desc'),
linkText: t('home.card.orders.link'),
href: '/orders',
},
{
icon: Heart,
title: t('home.card.favorites.title'),
description: t('home.card.favorites.desc'),
linkText: t('home.card.favorites.link'),
href: '/favorites',
},
]
const sections = baseSections.filter(
(section) => !section.moduleGated || showMyProducts,
)
const formattedDate = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
month: 'long',
@@ -68,9 +148,20 @@ export function HomePage() {
</div>
</div>
<div className={styles.gridHome}>
<div className={styles.grid12}>
{sections.map((section) => (
<SectionCard key={section.href} {...section} />
<div key={section.href} className={section.colClass}>
<SectionCard
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
href={section.href}
count={section.countKey ? counts[section.countKey] : undefined}
countLabel={
section.countLabelKey ? t(section.countLabelKey) : undefined
}
/>
</div>
))}
</div>
</main>
+4 -4
View File
@@ -15,12 +15,12 @@
width: 100%;
max-width: 420px;
padding: 36px 32px 32px;
background: rgba(255, 255, 255, 0.75);
background: var(--glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
box-shadow: var(--glass-shadow);
}
.brand {
@@ -148,8 +148,8 @@
line-height: 1.4;
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(148, 163, 184, 0.35);
background: var(--surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
@@ -0,0 +1,327 @@
.headerRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}
.editBtn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--field-height);
padding: var(--field-padding-y) 14px;
border-radius: var(--radius-sm);
font-size: var(--field-font-size);
font-weight: 600;
font-family: var(--font-ui);
color: #fff;
background: var(--primary);
white-space: nowrap;
flex-shrink: 0;
}
.editBtn:hover {
filter: brightness(1.05);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 320px) 1fr;
gap: 28px;
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);
padding: 24px;
}
.gallery {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 320px;
}
.mainImage {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.15);
overflow: hidden;
background: var(--card-media-bg, rgba(148, 163, 184, 0.12));
}
.galleryThumbs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.galleryThumb {
aspect-ratio: 1 / 1;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.15);
overflow: hidden;
background: var(--card-media-bg, rgba(148, 163, 184, 0.12));
}
.galleryThumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.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);
}
.badge {
position: absolute;
top: 10px;
inset-inline-start: 10px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
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%
);
}
.badge[data-status='published'] {
color: #047857;
background: linear-gradient(
145deg,
rgba(167, 243, 208, 0.55) 0%,
rgba(52, 211, 153, 0.28) 100%
);
}
.badge[data-status='archived'] {
color: #e2e8f0;
background: linear-gradient(
145deg,
rgba(148, 163, 184, 0.45) 0%,
rgba(100, 116, 139, 0.28) 100%
);
}
.promotedBadge {
position: absolute;
top: 10px;
inset-inline-end: 10px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
color: #fff;
border-radius: 50px;
background: linear-gradient(
145deg,
rgba(99, 102, 241, 0.7) 0%,
rgba(168, 85, 247, 0.45) 100%
);
z-index: 1;
}
.details {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.categoryChip {
align-self: flex-start;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.15);
border-radius: 50px;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
font-family: var(--font-ui);
}
.secondary {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.summaryRow {
display: flex;
align-items: center;
gap: 12px 16px;
flex-wrap: wrap;
direction: ltr;
}
.price {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.summaryMeta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 14px;
margin-inline-start: auto;
min-width: 0;
}
.metaItem {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-ui);
min-width: 0;
}
.metaGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px 16px;
margin: 4px 0 0;
}
.metaGrid dt {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 4px;
}
.metaGrid dd {
margin: 0;
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-ui);
}
.location {
display: inline-flex;
align-items: center;
gap: 6px;
}
.section {
padding-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.15);
}
.section h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.prose {
margin: 0;
font-size: 13px;
line-height: 1.65;
color: var(--text-primary);
white-space: pre-wrap;
font-family: var(--font-ui);
}
.techGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin: 0;
}
.techGrid dt {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 4px;
}
.techGrid dd {
margin: 0;
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-ui);
}
.status,
.error {
margin: 24px 0;
font-size: 14px;
color: var(--text-secondary);
}
.error {
color: #ef4444;
}
.backLink {
color: var(--primary);
font-weight: 600;
text-decoration: none;
}
.backLink:hover {
text-decoration: underline;
}
@media (max-width: 800px) {
.headerRow {
flex-direction: column;
}
.layout {
grid-template-columns: 1fr;
padding: 16px;
}
.gallery {
max-width: none;
}
}
@@ -0,0 +1,285 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ImageOff, MapPin, Pencil } from 'lucide-react'
import { Breadcrumbs, useLocale, useToast } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
getMyUserProduct,
getMyUserProductCategoryTechnicalForm,
type TechnicalFormField,
type UserProductDetail,
type UserProductTechnicalValueInput,
} from '../services/userProductsService'
import { formatIrtPrice } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import styles from './MyProductDetailsPage.module.css'
function formatTechnicalValue(
field: TechnicalFormField,
value: UserProductTechnicalValueInput | undefined,
): string {
if (!value) return '—'
if (value.textValue != null && value.textValue.trim()) return value.textValue
if (value.optionId) {
return field.options.find((option) => option.id === value.optionId)?.label ?? value.optionId
}
if (value.optionIds?.length) {
return value.optionIds
.map(
(optionId) =>
field.options.find((option) => option.id === optionId)?.label ?? optionId,
)
.join(', ')
}
return '—'
}
export function MyProductDetailsPage() {
const { id } = useParams<{ id: string }>()
const t = useT()
const { locale } = useLocale()
const navigate = useNavigate()
const { showToast } = useToast()
const isFa = locale === 'fa'
const [product, setProduct] = useState<UserProductDetail | null>(null)
const [technicalFields, setTechnicalFields] = useState<TechnicalFormField[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
if (!id) return
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const response = await getMyUserProduct(id!, controller.signal)
if (controller.signal.aborted) return
setProduct(response.product)
if (response.product.categoryId) {
const formResponse = await getMyUserProductCategoryTechnicalForm(
response.product.categoryId,
controller.signal,
)
if (controller.signal.aborted) return
setTechnicalFields(formResponse.form?.fields ?? [])
} else {
setTechnicalFields([])
}
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
const message =
err instanceof ApiError ? err.message : t('myProducts.error.loadDetail')
setError(message)
setProduct(null)
showToast(message, 'error')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => controller.abort()
}, [id, showToast, t])
const technicalRows = useMemo(() => {
if (!product) return []
const byField = new Map(
(product.technicalValues ?? []).map((item) => [item.fieldId, item]),
)
return technicalFields.map((field) => ({
id: field.id,
label: field.label,
value: formatTechnicalValue(field, byField.get(field.id)),
}))
}, [product, technicalFields])
if (loading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>{t('myProducts.loading')}</p>
</main>
)
}
if (error || !product) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || t('myProducts.error.loadDetail')}</p>
<Link to="/my-products" className={styles.backLink}>
{t('myProducts.backToList')}
</Link>
</main>
)
}
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 country = isFa
? product.countryNameFa || product.countryName
: product.countryName
const category = isFa
? product.categoryNameFa || product.categoryName
: product.categoryName
const location = [city, country].filter(Boolean).join(isFa ? '، ' : ', ')
const statusLabel =
product.status === 'published'
? t('myProducts.status.published')
: product.status === 'archived'
? t('myProducts.status.archived')
: product.status === 'rejected'
? t('myProducts.status.rejected')
: t('myProducts.status.pending')
const conditionLabel = product.condition
? t(`myProducts.condition.${product.condition}`)
: '—'
const imageSrc = product.imageUrl?.trim() || ''
const galleryImages = (product.images ?? [])
.map((item) => item.url?.trim())
.filter((url): url is string => Boolean(url))
const currency = (product.priceCurrency || 'IRT').toUpperCase()
const priceLabel =
product.price == null
? t('myProducts.priceUnavailable')
: currency === 'IRT'
? formatIrtPrice(product.price)
: `${Number(product.price).toLocaleString('en-US')} ${currency}`
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('myProducts.title'), href: '/my-products' },
{ label: title },
]}
/>
<div className={styles.headerRow}>
<div>
<h2 className={pageStyles.pageTitle}>{t('myProducts.detailsTitle')}</h2>
<p className={pageStyles.pageSubtitle}>{t('myProducts.detailsSubtitle')}</p>
</div>
<button
type="button"
className={styles.editBtn}
onClick={() => navigate(`/my-products/${product.id}/edit`)}
>
<Pencil size={16} />
{t('myProducts.edit')}
</button>
</div>
<div className={styles.layout} dir={isFa ? 'rtl' : 'ltr'}>
<div className={styles.gallery}>
<div className={styles.mainImage}>
{imageSrc ? (
<img src={imageSrc} alt={title} className={styles.image} />
) : (
<div className={styles.imagePlaceholder}>
<ImageOff size={36} strokeWidth={1.5} />
</div>
)}
<span className={styles.badge} data-status={product.status}>
{statusLabel}
</span>
{product.promoted ? (
<span className={styles.promotedBadge}>{t('myProducts.promoted')}</span>
) : null}
</div>
{galleryImages.length > 0 ? (
<div className={styles.galleryThumbs}>
{galleryImages.map((url) => (
<div key={url} className={styles.galleryThumb}>
<img src={url} alt="" />
</div>
))}
</div>
) : null}
</div>
<div className={styles.details}>
{category ? <span className={styles.categoryChip}>{category}</span> : null}
<h1 className={styles.title}>{title}</h1>
{secondary ? <p className={styles.secondary}>{secondary}</p> : null}
<div className={styles.summaryRow}>
<p className={styles.price} dir="ltr">
{priceLabel}
</p>
<div className={styles.summaryMeta} dir={isFa ? 'rtl' : 'ltr'}>
<span className={styles.metaItem}>
<MapPin size={14} aria-hidden />
{location || '—'}
</span>
</div>
</div>
<dl className={styles.metaGrid}>
<div>
<dt>{t('myProducts.fields.country')}</dt>
<dd>{country || '—'}</dd>
</div>
<div>
<dt>{t('myProducts.fields.city')}</dt>
<dd>{city || '—'}</dd>
</div>
<div>
<dt>{t('myProducts.fields.condition')}</dt>
<dd>{conditionLabel}</dd>
</div>
{product.priceByExpert ? (
<div>
<dt>{t('myProducts.fields.priceByExpert')}</dt>
<dd>{t('myProducts.yes')}</dd>
</div>
) : null}
</dl>
{product.description ? (
<section className={styles.section}>
<h3>{t('myProducts.fields.description')}</h3>
<p className={styles.prose}>{product.description}</p>
</section>
) : null}
{product.deliveryNote ? (
<section className={styles.section}>
<h3>{t('myProducts.fields.deliveryNote')}</h3>
<p className={styles.prose}>{product.deliveryNote}</p>
</section>
) : null}
{product.technicalNotes ? (
<section className={styles.section}>
<h3>{t('myProducts.fields.technicalNotes')}</h3>
<p className={styles.prose}>{product.technicalNotes}</p>
</section>
) : null}
{technicalRows.length > 0 ? (
<section className={styles.section}>
<h3>{t('myProducts.technical.categoryForm')}</h3>
<dl className={styles.techGrid}>
{technicalRows.map((row) => (
<div key={row.id}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
</section>
) : null}
</div>
</div>
</main>
)
}
@@ -0,0 +1,103 @@
.error {
margin-bottom: 16px;
padding: 12px 14px;
font-size: 13px;
color: #b91c1c;
background: rgba(254, 226, 226, 0.85);
border: 1px solid rgba(248, 113, 113, 0.45);
border-radius: var(--radius-sm);
}
.status {
margin: 8px 0 16px;
font-size: 13px;
color: var(--text-muted);
}
.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;
font-size: 13px;
font-weight: 600;
color: var(--primary);
text-decoration: none;
}
.emptyLink:hover {
text-decoration: underline;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.fabDock {
position: fixed;
inset-inline-end: 32px;
inset-inline-start: auto;
bottom: 32px;
z-index: 110;
}
.addFab {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.addFab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1280px) {
.grid {
grid-template-columns: repeat(6, 1fr);
}
}
@media (max-width: 768px) {
.fabDock {
inset-inline-end: 20px;
bottom: 20px;
}
.addFab {
width: 52px;
height: 52px;
}
}
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Package, Plus } from 'lucide-react'
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
import { UserProductCard } from '../components/UserProductCard'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
deleteMyUserProduct,
listMyUserProducts,
promoteMyUserProduct,
type MyUserProductsListResponse,
} from '../services/userProductsService'
import pageStyles from '../components/PageContent.module.css'
import styles from './MyProductsPage.module.css'
const PAGE_SIZE = 24
export function MyProductsPage() {
const t = useT()
const navigate = useNavigate()
const { showToast } = useToast()
const [data, setData] = useState<MyUserProductsListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [page, setPage] = useState(1)
const [busyId, setBusyId] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<'remove' | 'promote' | null>(null)
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const response = await listMyUserProducts(
{ page, pageSize: PAGE_SIZE },
controller.signal,
)
if (controller.signal.aborted) return
setData(response)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : t('myProducts.error.load'))
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => controller.abort()
}, [page, t])
const products = data?.items ?? []
const total = data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
function handleEdit(id: string) {
navigate(`/my-products/${id}/edit`)
}
async function handleRemove(id: string) {
if (!window.confirm(t('myProducts.removeConfirm'))) return
setBusyId(id)
setBusyAction('remove')
try {
await deleteMyUserProduct(id)
setData((prev) =>
prev
? {
...prev,
items: prev.items.filter((item) => item.id !== id),
total: Math.max(0, prev.total - 1),
}
: prev,
)
showToast(t('myProducts.removeSuccess'), 'success')
} catch (err) {
showToast(
err instanceof ApiError ? err.message : t('myProducts.error.remove'),
'error',
)
} finally {
setBusyId(null)
setBusyAction(null)
}
}
async function handlePromote(id: string) {
setBusyId(id)
setBusyAction('promote')
try {
const response = await promoteMyUserProduct(id)
setData((prev) =>
prev
? {
...prev,
items: prev.items.map((item) =>
item.id === id ? { ...item, ...response.product } : item,
),
}
: prev,
)
showToast(t('myProducts.promoteSuccess'), 'success')
} catch (err) {
showToast(
err instanceof ApiError ? err.message : t('myProducts.error.promote'),
'error',
)
} finally {
setBusyId(null)
setBusyAction(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('myProducts.title') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>{t('myProducts.title')}</h2>
<p className={pageStyles.pageSubtitle}>{t('myProducts.subtitle')}</p>
</div>
</div>
{error ? (
<div className={styles.error} role="alert">
{error}
</div>
) : null}
{loading ? <p className={styles.status}>{t('myProducts.loading')}</p> : null}
{!loading && !error && products.length === 0 ? (
<div className={styles.empty}>
<Package size={32} />
<p>{t('myProducts.empty')}</p>
<Link to="/my-products/new" className={styles.emptyLink}>
{t('myProducts.add')}
</Link>
</div>
) : null}
{!loading && products.length > 0 ? (
<>
<div className={styles.grid}>
{products.map((product) => (
<UserProductCard
key={product.id}
product={product}
to={`/my-products/${product.id}`}
onEdit={handleEdit}
onRemove={handleRemove}
onPromote={handlePromote}
busyAction={busyId === product.id ? busyAction : null}
/>
))}
</div>
{totalPages > 1 ? (
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
/>
) : null}
</>
) : null}
<div className={styles.fabDock}>
<Link
to="/my-products/new"
className={styles.addFab}
aria-label={t('myProducts.add')}
title={t('myProducts.add')}
>
<Plus size={24} />
</Link>
</div>
</main>
)
}
@@ -80,7 +80,7 @@
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.35);
background: var(--elevated-surface);
}
.td {
@@ -282,7 +282,7 @@
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.35);
background: var(--elevated-surface);
color: var(--text-secondary);
font-weight: 700;
font-size: 12px;
@@ -125,7 +125,7 @@
align-items: center;
gap: 14px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.6);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: var(--radius-sm);
}
@@ -299,7 +299,7 @@
padding: 16px 12px;
border: 2px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.5);
background: var(--surface);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
text-align: center;
@@ -345,7 +345,7 @@
padding: 12px 14px;
border: 2px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.5);
background: var(--surface);
cursor: pointer;
text-align: left;
transition: border-color 0.2s, background 0.2s;
@@ -435,7 +435,7 @@
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.8);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
@@ -482,7 +482,7 @@
padding: 14px;
border: 2px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.5);
background: var(--surface);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
text-align: right;
@@ -538,7 +538,7 @@
padding: 12px 14px;
border: 1.5px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.65);
background: var(--surface);
cursor: pointer;
text-align: right;
font-size: 13px;
@@ -765,7 +765,7 @@
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.8);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
+21 -2
View File
@@ -3,14 +3,33 @@ import type { CityOption } from '@meshkee/dashboard-ui'
export type { CityOption }
export async function listIranProvinces(signal?: AbortSignal) {
export async function listCountries(signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>('/cities?level=country', { signal })
return data.items
}
/** Cities under a country (direct + via provinces). Province is optional in the tree. */
export async function listCitiesByCountrySlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
'/cities?level=province&parentSlug=iran',
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
{ signal },
)
return data.items
}
export async function listProvincesByCountrySlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
`/cities?level=province&parentSlug=${encodeURIComponent(parentSlug)}`,
{ signal },
)
return data.items
}
/** @deprecated Prefer listProvincesByCountrySlug('iran') */
export async function listIranProvinces(signal?: AbortSignal) {
return listProvincesByCountrySlug('iran', signal)
}
export async function listCitiesByProvinceSlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
+155
View File
@@ -0,0 +1,155 @@
import {
clearTokens,
getAccessToken,
setTokens,
} from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { ensureJpegUploadFile, ensureUploadFile } from '../utils/imageUpload'
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
export interface MediaItem {
id: string
publicUrl: string
fileName: string
originalFileName: string
mimeType: string
width: number | null
height: number | null
}
function isDataUrl(value: string) {
return value.startsWith('data:')
}
function businessMediaPath() {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/my-user-products/media`
}
async function refreshAccessToken() {
const refreshToken = localStorage.getItem('meshkee_customer_refresh_token')
if (!refreshToken) return false
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
if (!response.ok) {
clearTokens()
return false
}
const data = await response.json()
setTokens(data.accessToken, data.refreshToken)
return true
}
function parseUploadError(payload: unknown, status: number) {
if (payload && typeof payload === 'object') {
const message = (payload as { message?: string | string[] }).message
if (Array.isArray(message)) {
return message.join(', ')
}
if (typeof message === 'string' && message) {
return message
}
}
return `Upload failed with status ${status}`
}
export async function uploadMyUserProductMedia(
files: File[],
signal?: AbortSignal,
): Promise<MediaItem[]> {
if (!files.length) return []
const send = async () => {
const formData = new FormData()
files.forEach((file) => formData.append('files', file))
const headers = new Headers()
const accessToken = getAccessToken()
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
return fetch(`${API_BASE_URL}${businessMediaPath()}`, {
method: 'POST',
headers,
body: formData,
signal,
})
}
let response = await send()
if (response.status === 401) {
const refreshed = await refreshAccessToken()
if (refreshed) {
response = await send()
}
}
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(parseUploadError(payload, response.status))
}
return (payload.items as Array<Record<string, unknown>>).map((item) => ({
id: String(item.id),
publicUrl: String(item.publicUrl),
fileName: String(item.fileName),
originalFileName: String(item.originalFileName),
mimeType: String(item.mimeType),
width: typeof item.width === 'number' ? item.width : null,
height: typeof item.height === 'number' ? item.height : null,
}))
}
export async function resolveDataUrlToMediaId(
value: string | null,
filename: string,
existingMediaId?: string | null,
): Promise<string | null> {
if (!value) return null
if (!isDataUrl(value)) {
return existingMediaId ?? null
}
const file = filename.toLowerCase().endsWith('.png')
? await ensureUploadFile(value, filename)
: await ensureJpegUploadFile(value, filename)
const uploaded = await uploadMyUserProductMedia([file])
return uploaded[0]?.id ?? null
}
export async function resolveDataUrlsToMediaIds(
values: string[],
existingMediaIds: string[],
): Promise<string[]> {
const resolved: string[] = []
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (isDataUrl(value)) {
const file = await ensureJpegUploadFile(
value,
`user-product-image-${index + 1}.jpg`,
)
const uploaded = await uploadMyUserProductMedia([file])
if (uploaded[0]) resolved.push(uploaded[0].id)
} else if (existingMediaIds[index]) {
resolved.push(existingMediaIds[index])
}
}
return resolved
}
@@ -1,6 +1,9 @@
import { apiRequest } from '../lib/api'
import type { DashboardLocale } from '@meshkee/dashboard-core'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
import type { BusinessModuleId } from '../utils/businessModules'
export type DashboardThemeMode = 'light' | 'dark'
export interface ResolvedTenant {
id: string
@@ -10,6 +13,8 @@ export interface ResolvedTenant {
domain: string
primaryColor: BusinessPrimaryColorId
defaultLocale?: DashboardLocale
themeMode?: DashboardThemeMode
enabledModules?: BusinessModuleId[]
logoUrl?: string | null
faviconUrl?: string | null
}
@@ -0,0 +1,213 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { UserProductListItem } from '../types/userProduct'
export type UserProductCondition = 'new' | 'stock' | 'needs_repair' | 'scrap'
export type UserProductPriceCurrency = 'IRT' | 'USD' | 'EUR' | 'AED'
export type TechnicalFieldType = 'text' | 'textarea' | 'select' | 'multi_select'
export interface UserProductCategoryOption {
id: string
name: string
nameFa: string | null
parentId: string | null
}
export interface TechnicalFormFieldOption {
id: string
label: string
value: string
sortOrder: number
}
export interface TechnicalFormField {
id: string
label: string
key: string
type: TechnicalFieldType
isRequired: boolean
sortOrder: number
options: TechnicalFormFieldOption[]
}
export interface CategoryTechnicalForm {
id: string
categoryId: string
fields: TechnicalFormField[]
}
export type TechnicalFormValues = Record<string, string | string[]>
export interface UserProductTechnicalValueInput {
fieldId: string
textValue?: string
optionId?: string
optionIds?: string[]
}
export interface CreateUserProductInput {
titleFa: string
titleEn?: string
description?: string
categoryId: string
price?: number
priceCurrency?: UserProductPriceCurrency
priceByExpert?: boolean
countryId: string
cityId: string
deliveryNote?: string
condition: UserProductCondition
technicalNotes?: string
technicalValues?: UserProductTechnicalValueInput[]
featuredMediaId?: string
galleryMediaIds?: string[]
}
export type UpdateUserProductInput = CreateUserProductInput
export interface UserProductDetail extends UserProductListItem {
countryId: string
cityId: string
countrySlug: string
featuredMediaId: string | null
galleryMediaIds: string[]
images: Array<{ mediaId: string; url: string }>
technicalValues: UserProductTechnicalValueInput[]
deliveryNote?: string | null
technicalNotes?: string | null
}
export interface ListMyUserProductsParams {
page?: number
pageSize?: number
}
export interface MyUserProductsListResponse {
items: UserProductListItem[]
total: number
page: number
pageSize: number
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/my-user-products${suffix}`
}
export function buildTechnicalValuesPayload(
fields: TechnicalFormField[],
values: TechnicalFormValues,
): UserProductTechnicalValueInput[] {
const payload: UserProductTechnicalValueInput[] = []
for (const field of fields) {
const value = values[field.id]
if (field.type === 'text' || field.type === 'textarea') {
const text = typeof value === 'string' ? value.trim() : ''
if (!text) continue
payload.push({ fieldId: field.id, textValue: text })
continue
}
if (field.type === 'select') {
const optionId = typeof value === 'string' ? value.trim() : ''
if (!optionId) continue
payload.push({ fieldId: field.id, optionId })
continue
}
const optionIds = Array.isArray(value) ? value.filter(Boolean) : []
if (!optionIds.length) continue
payload.push({ fieldId: field.id, optionIds })
}
return payload
}
export async function listMyUserProducts(
params: ListMyUserProductsParams = {},
signal?: AbortSignal,
) {
const q = new URLSearchParams()
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
const query = q.toString()
return apiRequest<MyUserProductsListResponse>(
`${businessPath()}${query ? `?${query}` : ''}`,
{ auth: true, signal },
)
}
export async function createMyUserProduct(input: CreateUserProductInput) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(),
{
method: 'POST',
auth: true,
body: input,
},
)
}
export async function getMyUserProduct(
productId: string,
signal?: AbortSignal,
) {
return apiRequest<{ product: UserProductDetail }>(
businessPath(`/${productId}`),
{ auth: true, signal },
)
}
export async function updateMyUserProduct(
productId: string,
input: UpdateUserProductInput,
) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}`),
{
method: 'PATCH',
auth: true,
body: input,
},
)
}
export async function deleteMyUserProduct(productId: string) {
return apiRequest<{ message: string }>(businessPath(`/${productId}`), {
method: 'DELETE',
auth: true,
})
}
export async function promoteMyUserProduct(productId: string) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}/promote`),
{
method: 'POST',
auth: true,
},
)
}
export async function listMyUserProductCategories(signal?: AbortSignal) {
return apiRequest<{ items: UserProductCategoryOption[] }>(
businessPath('/categories'),
{ auth: true, signal },
)
}
export async function getMyUserProductCategoryTechnicalForm(
categoryId: string,
signal?: AbortSignal,
) {
return apiRequest<{ form: CategoryTechnicalForm | null }>(
businessPath(`/categories/${categoryId}/technical-form`),
{ auth: true, signal },
)
}
+26
View File
@@ -0,0 +1,26 @@
export type UserProductStatus = 'draft' | 'published' | 'archived' | 'rejected'
export type UserProductPriceCurrency = 'IRT' | 'USD' | 'EUR' | 'AED'
export interface UserProductListItem {
id: string
title: string
titleFa?: string | null
titleEn?: string | null
description?: string | null
price: number | null
priceCurrency?: UserProductPriceCurrency | string | null
priceByExpert?: boolean
promoted?: boolean
status: UserProductStatus
condition?: string | null
cityName: string
cityNameFa?: string | null
countryName?: string | null
countryNameFa?: string | null
imageUrl: string | null
categoryId?: string | null
categoryName?: string | null
categoryNameFa?: string | null
createdAt?: string
}
@@ -0,0 +1,53 @@
/** Optional modules that can gate customer-dashboard sections. */
export const CUSTOMER_MODULE_IDS = ['customer_products'] as const
export type CustomerModuleId = (typeof CUSTOMER_MODULE_IDS)[number]
/** All optional module ids returned on tenant resolve (business + customer). */
export const BUSINESS_MODULE_IDS = [
'products',
'store',
'portfolio',
'blog',
'warehouse',
'videos',
...CUSTOMER_MODULE_IDS,
] as const
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number]
/** Legacy tenants without modules: business modules on, customer modules opt-in. */
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
'products',
'store',
'portfolio',
'blog',
'warehouse',
'videos',
]
const MODULE_ID_SET = new Set<string>(BUSINESS_MODULE_IDS)
export function isBusinessModuleId(value: unknown): value is BusinessModuleId {
return typeof value === 'string' && MODULE_ID_SET.has(value)
}
export function normalizeEnabledBusinessModules(value: unknown): BusinessModuleId[] {
if (!Array.isArray(value)) {
return [...DEFAULT_ENABLED_BUSINESS_MODULES]
}
const selected = new Set<BusinessModuleId>()
for (const item of value) {
if (isBusinessModuleId(item)) selected.add(item)
}
return BUSINESS_MODULE_IDS.filter((id) => selected.has(id))
}
export function hasBusinessModule(
enabledModules: readonly BusinessModuleId[] | null | undefined,
moduleId: BusinessModuleId,
): boolean {
return normalizeEnabledBusinessModules(enabledModules).includes(moduleId)
}
+46
View File
@@ -0,0 +1,46 @@
import type { Area } from 'react-easy-crop'
function createImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', reject)
image.src = url
})
}
export async function getCroppedImage(
imageSrc: string,
pixelCrop: Area,
format: 'jpeg' | 'png' = 'jpeg',
): Promise<string> {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
if (format === 'png') {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height,
)
if (format === 'png') {
return canvas.toDataURL('image/png')
}
return canvas.toDataURL('image/jpeg', 0.92)
}
+84
View File
@@ -0,0 +1,84 @@
const ALLOWED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'image/gif',
])
function parseDataUrl(dataUrl: string): { mime: string; bytes: Uint8Array } {
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/)
if (!match) {
throw new Error('Invalid image data')
}
const mime = match[1] === 'image/jpg' ? 'image/jpeg' : match[1]
const binary = atob(match[2])
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return { mime, bytes }
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', () => reject(new Error('Could not load image')))
image.src = src
})
}
export function dataUrlToFile(dataUrl: string, filename: string): File {
const { mime, bytes } = parseDataUrl(dataUrl)
const copy = new Uint8Array(bytes)
return new File([copy], filename, { type: mime })
}
export async function ensureUploadFile(dataUrl: string, filename: string): Promise<File> {
if (dataUrl.startsWith('data:')) {
const mime = dataUrl.slice(5, dataUrl.indexOf(';'))
const normalized = mime === 'image/jpg' ? 'image/jpeg' : mime
if (ALLOWED_IMAGE_TYPES.has(normalized)) {
const file = dataUrlToFile(dataUrl, filename)
if (file.size > 0) {
return file
}
}
}
const image = await loadImage(dataUrl)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('Could not prepare image for upload')
}
const wantsPng = filename.toLowerCase().endsWith('.png')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
if (wantsPng) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
ctx.drawImage(image, 0, 0)
const outputDataUrl = wantsPng
? canvas.toDataURL('image/png')
: canvas.toDataURL('image/jpeg', 0.92)
const safeName = filename.replace(/\.[^.]+$/, '') || 'image'
const extension = wantsPng ? 'png' : 'jpg'
return dataUrlToFile(outputDataUrl, `${safeName}.${extension}`)
}
export async function ensureJpegUploadFile(
dataUrl: string,
filename: string,
): Promise<File> {
const safeName = filename.replace(/\.[^.]+$/, '') || 'image'
return ensureUploadFile(dataUrl, `${safeName}.jpg`)
}
@@ -43,12 +43,12 @@
z-index: 1200;
transform: translateX(-50%);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
flex-direction: column;
gap: 10px;
width: max-content;
min-width: 196px;
max-width: 280px;
padding: 8px 10px;
padding: 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.78);
@@ -57,6 +57,63 @@
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.16);
}
.section {
display: flex;
flex-direction: column;
gap: 6px;
}
.sectionLabel {
margin: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
}
.themeRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.themeOption {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 32px;
padding: 0 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(255, 255, 255, 0.55);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
}
.themeOption:hover {
border-color: rgba(148, 163, 184, 0.45);
color: var(--text-primary);
}
.themeOptionSelected {
border-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb) / 0.12);
}
.colorRow {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 6px;
}
.option {
width: 28px;
height: 28px;
@@ -1,15 +1,19 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Moon, Sun } from 'lucide-react'
import {
BUSINESS_PRIMARY_COLOR_IDS,
BUSINESS_PRIMARY_COLOR_PALETTE,
type BusinessPrimaryColorId,
} from '../utils/businessPrimaryColors'
import type { DashboardThemeMode } from '../services/businessSettingsService'
import styles from './PrimaryColorSwatchControl.module.css'
interface PrimaryColorSwatchControlProps {
value: BusinessPrimaryColorId
themeMode: DashboardThemeMode
onChange: (value: BusinessPrimaryColorId) => void
onThemeModeChange: (value: DashboardThemeMode) => void
disabled?: boolean
}
@@ -20,7 +24,9 @@ type PopoverPosition = {
export function PrimaryColorSwatchControl({
value,
themeMode,
onChange,
onThemeModeChange,
disabled = false,
}: PrimaryColorSwatchControlProps) {
const [open, setOpen] = useState(false)
@@ -60,6 +66,12 @@ export function PrimaryColorSwatchControl({
setOpen(false)
}
function selectThemeMode(mode: DashboardThemeMode) {
if (mode !== themeMode) {
onThemeModeChange(mode)
}
}
useEffect(() => {
if (!open) return
@@ -99,10 +111,10 @@ export function PrimaryColorSwatchControl({
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
onClick={toggleOpen}
disabled={disabled}
aria-label={`Dashboard primary color: ${tokens.label}`}
aria-haspopup="listbox"
aria-label={`Dashboard theme: ${themeMode}, color: ${tokens.label}`}
aria-haspopup="dialog"
aria-expanded={open}
title={`Theme: ${tokens.label}`}
title={`Theme: ${themeMode} · ${tokens.label}`}
>
<span
className={styles.swatchDot}
@@ -117,13 +129,42 @@ export function PrimaryColorSwatchControl({
<div
ref={popoverRef}
className={styles.popover}
role="listbox"
aria-label="Choose primary color"
role="dialog"
aria-label="Choose dashboard theme and primary color"
style={{
top: popoverPos.top,
left: popoverPos.left,
}}
>
<div className={styles.section}>
<p className={styles.sectionLabel}>Theme</p>
<div className={styles.themeRow} role="radiogroup" aria-label="Theme mode">
<button
type="button"
role="radio"
aria-checked={themeMode === 'light'}
className={`${styles.themeOption} ${themeMode === 'light' ? styles.themeOptionSelected : ''}`}
onClick={() => selectThemeMode('light')}
>
<Sun size={14} aria-hidden="true" />
<span>Light</span>
</button>
<button
type="button"
role="radio"
aria-checked={themeMode === 'dark'}
className={`${styles.themeOption} ${themeMode === 'dark' ? styles.themeOptionSelected : ''}`}
onClick={() => selectThemeMode('dark')}
>
<Moon size={14} aria-hidden="true" />
<span>Dark</span>
</button>
</div>
</div>
<div className={styles.section}>
<p className={styles.sectionLabel}>Color</p>
<div className={styles.colorRow} role="listbox" aria-label="Primary color">
{BUSINESS_PRIMARY_COLOR_IDS.map((colorId) => {
const option = BUSINESS_PRIMARY_COLOR_PALETTE[colorId]
const selected = colorId === value
@@ -146,6 +187,8 @@ export function PrimaryColorSwatchControl({
</button>
)
})}
</div>
</div>
</div>,
document.body,
)}
@@ -459,13 +459,27 @@
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.modulesSection {
margin-top: 14px;
}
.modulesSection .entityGroupLabel {
margin: 0;
}
.modulesChecks {
display: flex;
flex-direction: column;
gap: 10px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px 16px;
margin-top: 10px;
}
@media (max-width: 560px) {
.modulesChecks {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.modulesCharts {
display: flex;
flex-direction: column;
+91 -7
View File
@@ -53,11 +53,16 @@ import {
updateBusinessDefaultLocale,
updateBusinessModules,
updateBusinessPrimaryColor,
updateBusinessThemeMode,
type DashboardLocale,
type DashboardThemeMode,
} from '../services/businessSettingsService'
import {
BUSINESS_DASHBOARD_MODULE_IDS,
BUSINESS_DASHBOARD_MODULE_LABELS,
BUSINESS_MODULE_IDS,
BUSINESS_MODULE_LABELS,
CUSTOMER_MODULE_IDS,
CUSTOMER_MODULE_LABELS,
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_HOME_CHARTS,
HOME_CHART_IDS,
@@ -74,11 +79,16 @@ import styles from './BusinessesPage.module.css'
const DEFAULT_PAGE_SIZE = 10
const DEFAULT_LOCALE: DashboardLocale = 'fa'
const DEFAULT_THEME_MODE: DashboardThemeMode = 'light'
function normalizeDefaultLocale(value: unknown): DashboardLocale {
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
}
function normalizeThemeMode(value: unknown): DashboardThemeMode {
return value === 'dark' || value === 'light' ? value : DEFAULT_THEME_MODE
}
function emptyModuleSelection(
enabled: BusinessModuleId[] = DEFAULT_ENABLED_BUSINESS_MODULES,
): Record<BusinessModuleId, boolean> {
@@ -400,7 +410,7 @@ export function BusinessesPage() {
try {
await updateBusinessPrimaryColor(b.id, primaryColor)
showToast(`Theme updated for "${b.name}".`, 'success')
showToast(`Theme color updated for "${b.name}".`, 'success')
} catch (err) {
setData((prev) => {
if (!prev) return prev
@@ -413,7 +423,53 @@ export function BusinessesPage() {
})
showToast(
err instanceof ApiError ? err.message : 'Unable to update theme.',
err instanceof ApiError ? err.message : 'Unable to update theme color.',
'error',
)
} finally {
setSavingColorId(null)
}
}
async function handleThemeModeChange(
b: BusinessListItem,
themeMode: DashboardThemeMode,
) {
const currentMode = normalizeThemeMode(b.themeMode)
if (currentMode === themeMode) return
setSavingColorId(b.id)
setError('')
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, themeMode } : item,
),
}
})
try {
await updateBusinessThemeMode(b.id, themeMode)
showToast(
`${themeMode === 'dark' ? 'Dark' : 'Light'} theme set for "${b.name}".`,
'success',
)
} catch (err) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === b.id ? { ...item, themeMode: currentMode } : item,
),
}
})
showToast(
err instanceof ApiError ? err.message : 'Unable to update theme mode.',
'error',
)
} finally {
@@ -1103,10 +1159,14 @@ export function BusinessesPage() {
<td className={`${styles.td} ${styles.tdTheme}`}>
<PrimaryColorSwatchControl
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
themeMode={normalizeThemeMode(b.themeMode)}
disabled={savingColorId === b.id}
onChange={(primaryColor) =>
void handlePrimaryColorChange(b, primaryColor)
}
onThemeModeChange={(themeMode) =>
void handleThemeModeChange(b, themeMode)
}
/>
</td>
<td className={`${styles.td} ${styles.tdLocale}`}>
@@ -1384,11 +1444,13 @@ export function BusinessesPage() {
</p>
) : null}
<p className={styles.helperText}>
Choose which optional modules this business can use. Customers, website,
and other general sections stay available for every business.
Choose which optional modules this business can use. Home, profile,
website, and other general sections stay available for every business.
</p>
<div className={styles.modulesSection}>
<p className={styles.entityGroupLabel}>Business modules</p>
<div className={styles.modulesChecks}>
{BUSINESS_MODULE_IDS.map((id) => (
{BUSINESS_DASHBOARD_MODULE_IDS.map((id) => (
<label key={id} className={styles.entityCheck}>
<input
type="checkbox"
@@ -1401,10 +1463,32 @@ export function BusinessesPage() {
}))
}
/>
<span>{BUSINESS_MODULE_LABELS[id]}</span>
<span>{BUSINESS_DASHBOARD_MODULE_LABELS[id]}</span>
</label>
))}
</div>
</div>
<div className={styles.modulesSection}>
<p className={styles.entityGroupLabel}>Customer modules</p>
<div className={styles.modulesChecks}>
{CUSTOMER_MODULE_IDS.map((id) => (
<label key={id} className={styles.entityCheck}>
<input
type="checkbox"
checked={modulesSelected[id]}
disabled={modulesSubmitting}
onChange={(e) =>
setModulesSelected((prev) => ({
...prev,
[id]: e.target.checked,
}))
}
/>
<span>{CUSTOMER_MODULE_LABELS[id]}</span>
</label>
))}
</div>
</div>
<div className={styles.modulesCharts}>
<p className={styles.entityGroupLabel}>Home charts</p>
<div className={styles.field}>
@@ -4,9 +4,12 @@ import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
export type DashboardLocale = 'en' | 'fa'
export type DashboardThemeMode = 'light' | 'dark'
export interface BrandingSettings {
primaryColor: BusinessPrimaryColorId
defaultLocale: DashboardLocale
themeMode: DashboardThemeMode
}
export interface ModulesSettings {
@@ -52,6 +55,19 @@ export async function updateBusinessPrimaryColor(
})
}
export async function updateBusinessThemeMode(
businessId: string,
themeMode: DashboardThemeMode,
) {
return apiRequest<BusinessSettingsResponse>(`/businesses/${businessId}/settings`, {
method: 'PATCH',
auth: true,
body: {
branding: { themeMode },
},
})
}
export async function updateBusinessDefaultLocale(
businessId: string,
defaultLocale: DashboardLocale,
+5 -1
View File
@@ -1,6 +1,9 @@
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
import type { DashboardLocale } from '../services/businessSettingsService'
import type {
DashboardLocale,
DashboardThemeMode,
} from '../services/businessSettingsService'
export interface BusinessOwnerInfo {
name: string | null
@@ -28,6 +31,7 @@ export interface BusinessListItem {
ownerCellNumber: string | null
isActive: boolean
primaryColor: BusinessPrimaryColorId
themeMode: DashboardThemeMode
defaultLocale: DashboardLocale
enabledModules: BusinessModuleId[]
moduleCount: number
+32 -5
View File
@@ -1,5 +1,5 @@
/** Optional CMS modules a business can have. Always-on areas are not listed. */
export const BUSINESS_MODULE_IDS = [
/** Optional business-dashboard CMS modules. Always-on areas are not listed. */
export const BUSINESS_DASHBOARD_MODULE_IDS = [
'products',
'store',
'portfolio',
@@ -8,9 +8,24 @@ export const BUSINESS_MODULE_IDS = [
'videos',
] as const
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
export const CUSTOMER_MODULE_IDS = ['customer_products'] as const
/** All optional modules stored in `settings.modules.enabled`. */
export const BUSINESS_MODULE_IDS = [
...BUSINESS_DASHBOARD_MODULE_IDS,
...CUSTOMER_MODULE_IDS,
] as const
export type BusinessDashboardModuleId =
(typeof BUSINESS_DASHBOARD_MODULE_IDS)[number]
export type CustomerModuleId = (typeof CUSTOMER_MODULE_IDS)[number]
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number]
export const BUSINESS_MODULE_LABELS: Record<BusinessModuleId, string> = {
export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
BusinessDashboardModuleId,
string
> = {
products: 'Products',
store: 'Store',
portfolio: 'Portfolio',
@@ -19,6 +34,15 @@ export const BUSINESS_MODULE_LABELS: Record<BusinessModuleId, string> = {
videos: 'Videos',
}
export const CUSTOMER_MODULE_LABELS: Record<CustomerModuleId, string> = {
customer_products: 'Customer products',
}
export const BUSINESS_MODULE_LABELS: Record<BusinessModuleId, string> = {
...BUSINESS_DASHBOARD_MODULE_LABELS,
...CUSTOMER_MODULE_LABELS,
}
/** Home dashboard chart slots (super-admin selectable). */
export const HOME_CHART_IDS = [
'none',
@@ -38,9 +62,12 @@ export const HOME_CHART_LABELS: Record<HomeChartId, string> = {
products_added_1y: 'Added product in last year',
}
/** Existing tenants without saved modules keep every module enabled. */
/**
* Existing tenants without saved modules keep every business-dashboard module
* enabled. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
...BUSINESS_MODULE_IDS,
...BUSINESS_DASHBOARD_MODULE_IDS,
]
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
+16 -4
View File
@@ -3,7 +3,7 @@
> **For AI agents:** Read this file at the start of a new chat before making changes.
> Update this document when a major feature is completed or architecture changes.
Last updated: August 7, 2026
Last updated: August 9, 2026
---
@@ -165,6 +165,10 @@ Add to `/etc/hosts` (one line per tenant):
| `/addresses` | Addresses |
| `/orders` | Orders |
| `/favorites` | Favorites |
| `/my-products` | Customer stock listings (gated by `customer_products`) — list API |
| `/my-products/:id` | User product details |
| `/my-products/new` | Add user product (3 steps: basics, images, technical) — create API |
| `/my-products/:id/edit` | Edit user product — update API |
### Business (`apps/business`)
@@ -184,6 +188,10 @@ Add to `/etc/hosts` (one line per tenant):
| `/store/items` | Store items (product variants) | Yes |
| `/store/settings` | Online sell + order process steps | Yes |
| `/customers` | Business customers list | Yes |
| `/customer-products` | Customer user-product listings (admin API) | Yes |
| `/customer-products/new` | Admin create user product (under admin name) | Yes |
| `/customer-products/:id` | Customer user-product details | Yes |
| `/customer-products/:id/edit` | Admin edit user product | Yes |
| `/store/orders` | Orders list + filters | Yes |
| `/blog` | Blog hub | Yes |
| `/blog/list` | My Blogs grid | Yes |
@@ -257,9 +265,9 @@ Products accept optional `brandId` on create/update.
### Business settings
- `GET/PATCH /businesses/:businessId/settings``branding`, `dashboard`, `store`, `modules`
- `modules.enabled`: optional CMS modules (`products`, `store`, `portfolio`, `blog`, `warehouse`, `videos`); missing key → all enabled (legacy). Super-admin only for modules PATCH.
- `modules.enabled`: optional modules — business: `products`, `store`, `portfolio`, `blog`, `warehouse`, `videos`; customer: `customer_products`. Missing key → all business modules enabled (legacy); customer modules stay opt-in. Super-admin only for modules PATCH.
- `modules.charts`: two home chart slots (`none`, `orders_30d`, `customers_joined_1y`, `blog_views_30d`, `products_added_1y`); `none` hides that slot; defaults orders + customers.
- Always-on (not in modules list): customers, website, profile, settings, home
- Always-on (not in modules list): business — customers, website, profile, settings, home; customer — home, profile, addresses, orders, favorites
- Tenant public: `GET /tenants/:host` includes `enabledModules` + `homeCharts`
### Portfolios
@@ -301,6 +309,10 @@ Products accept optional `brandId` on create/update.
### Media
- `POST /businesses/:businessId/media` — upload to S3
### User products (customer listings)
- Customer (own stock): `GET/POST /businesses/:businessId/my-user-products`, `GET/PATCH/DELETE .../:productId`, `POST .../:productId/promote`, categories + technical-form + media
- Business admin (all listings): `GET/POST /businesses/:businessId/user-products`, `GET/PATCH/DELETE .../:productId`, `PATCH .../:productId/status` (`draft` | `published` | `rejected` | `archived`), `POST .../:productId/promote`, categories + technical-form. Admin create is attributed to the admin user.
---
## Feature status (business app)
@@ -441,7 +453,7 @@ Global input sizing lives in `packages/dashboard-core/src/styles/tokens.css` (cu
- **Farsi text:** Customer app → Yekan Bakh (`src/fonts/yekanbakh.css`, `--font-fa` in `index.css`); business/super-admin → IranYekan (`iranyekan.css`, `.faText`). Input + placeholder must share the same stack — see `.cursor/rules/ui-farsi-fonts.mdc`
- **Per-business theme:** Super Admin sets `branding.primaryColor`; business app applies via `BusinessThemeProvider`
- **Per-business modules:** Super Admin **Modules** column on Businesses list; stored in `settings.modules.enabled`; business Home + Sidebar hide disabled modules
- **Per-business modules:** Super Admin **Modules** column on Businesses list; stored in `settings.modules.enabled` (business + customer sections); business Home + Sidebar hide disabled business modules; `customer_products` gates customer My Products (`/my-products`, `/my-products/new`) which call `businesses/:id/my-user-products`
- **RTL:** Farsi inputs use `dir="rtl"` and the apps `--font-fa`
- **IRT prices:** comma-separated thousands + `IRT` suffix — see `.cursor/rules/ui-irt-price.mdc`
- **Toasts:** see `.cursor/rules/ui-toasts.mdc`
+4 -3
View File
@@ -40,6 +40,7 @@
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.2.3",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
@@ -1287,9 +1288,9 @@
}
},
"node_modules/react-easy-crop": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.2.tgz",
"integrity": "sha512-b0HOicSvLYoNk1yvZTwjH8sNjF5uD9xLtWrSDnv+fx1dXTbwd8bNLQaP6gjXw//A+tni9Mw5KDmPNOEjKF55NQ==",
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.3.tgz",
"integrity": "sha512-ebimG3OGlzizjxEZ77Cj9CVLcg5vDZ6QVz8ud1rx4WmsCDWHIROEhgMi0GpJ/jKAuwHrH0M+QZUK4hyvxbYhOA==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1"
@@ -48,6 +48,8 @@
--card-hover-lift: -4px;
--card-hover-shadow: 0 16px 48px rgba(var(--primary-rgb) / 0.14);
--card-hover-transition: transform 0.25s ease, box-shadow 0.25s ease;
--icon-bg: color-mix(in srgb, var(--primary) 14%, #ffffff);
--icon-bg-end: color-mix(in srgb, var(--primary) 8%, #f1f5f9);
}
html {
@@ -4,7 +4,7 @@ import styles from './AddressListEditor.module.css'
export type CityOption = {
id: string
parentId: string | null
level: 'country' | 'province' | 'city'
level: 'country' | 'province' | 'city' | 'district'
nameFa: string
nameEn: string
landlineCode: string | null
@@ -4,9 +4,16 @@
align-items: center;
height: 38px;
border-radius: 50px;
background: rgba(255, 255, 255, 0.5);
background: linear-gradient(
145deg,
color-mix(in srgb, var(--elevated-surface) 78%, transparent) 0%,
color-mix(in srgb, var(--glass-bg) 55%, transparent) 55%,
color-mix(in srgb, var(--surface) 42%, transparent) 100%
);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid var(--glass-border);
transition: box-shadow 0.2s, border-color 0.2s;
transition: box-shadow 0.2s, border-color 0.2s, background 0.2s;
}
.wrap:hover,
@@ -20,6 +27,7 @@
inset-inline-start: 12px;
color: var(--text-secondary);
pointer-events: none;
z-index: 1;
}
.chevron {
@@ -27,27 +35,37 @@
inset-inline-end: 10px;
color: var(--text-muted);
pointer-events: none;
z-index: 1;
}
.select {
/* Beat global `select` + `html[data-theme='dark'] select` so the pill stays one surface */
.wrap .select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
height: 100%;
min-width: 72px;
min-height: 0;
min-width: 76px;
padding-block: 0;
padding-inline: 34px 28px;
border: none;
border-radius: 50px;
background: transparent;
background-color: transparent;
background-image: none;
box-shadow: none;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
font-family: var(--font-ui);
line-height: 1;
cursor: pointer;
outline: none;
transition: none;
}
.select:focus {
.wrap .select:focus {
border: none;
box-shadow: none;
outline: none;
}
@@ -1,6 +1,7 @@
.card {
display: flex;
flex-direction: column;
height: 100%;
padding: 28px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
@@ -19,7 +20,8 @@
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-light) 0%, rgba(219, 234, 254, 0.5) 100%);
background: linear-gradient(135deg, var(--icon-bg) 0%, var(--icon-bg-end) 100%);
border: 1px solid rgba(var(--primary-rgb) / 0.22);
border-radius: var(--radius-sm);
color: var(--primary);
margin-bottom: 20px;
@@ -50,6 +52,9 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
margin-top: auto;
}
.link {
@@ -58,28 +63,74 @@
color: var(--primary);
}
.countMeta {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
height: 36px;
max-width: calc(100% - 48px);
padding: 0 14px;
box-sizing: border-box;
border-radius: 999px;
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
transition:
background 0.35s ease,
color 0.35s ease;
}
.countValue {
font-family: var(--font-en), var(--font-ui), sans-serif;
font-size: 16px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
line-height: 1;
color: inherit;
}
.countLabel {
font-size: 12px;
font-weight: 500;
line-height: 1;
color: inherit;
opacity: 0.85;
}
.card:hover .countMeta {
background: var(--primary);
color: white;
}
.card:hover .countLabel {
opacity: 0.95;
}
.arrowBtn {
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
transition: background 0.2s, transform 0.2s;
transition:
background 0.35s ease,
color 0.35s ease;
}
.arrowIcon {
display: block;
}
.card:hover .arrowBtn {
background: var(--primary);
color: white;
transform: translateX(2px);
}
:global([dir='rtl']) .arrowBtn {
transform: scaleX(-1);
}
:global([dir='rtl']) .card:hover .arrowBtn {
transform: scaleX(-1) translateX(2px);
}
@@ -6,8 +6,12 @@ interface SectionCardProps {
icon: LucideIcon
title: string
description: string
linkText: string
/** Legacy footer link text; prefer count + countLabel when available. */
linkText?: string
href: string
/** Entity total shown beside the arrow; omit for sections without a count. */
count?: number | null
countLabel?: string
}
export function SectionCard({
@@ -16,7 +20,14 @@ export function SectionCard({
description,
linkText,
href,
count,
countLabel,
}: SectionCardProps) {
const showCount = typeof count === 'number' && Number.isFinite(count)
const formattedCount = showCount
? new Intl.NumberFormat('en-US').format(count)
: null
return (
<Link to={href} className={styles.card} data-card-hover>
<div className={styles.iconWrap}>
@@ -27,9 +38,20 @@ export function SectionCard({
<p className={styles.description}>{description}</p>
<div className={styles.footer}>
{formattedCount !== null && countLabel ? (
<div className={styles.countMeta}>
<span className={styles.countValue} lang="en" dir="ltr">
{formattedCount}
</span>
<span className={styles.countLabel}>{countLabel}</span>
</div>
) : linkText ? (
<span className={styles.link}>{linkText}</span>
) : (
<span />
)}
<span className={styles.arrowBtn} aria-hidden="true">
<ArrowRight size={18} />
<ArrowRight size={18} className={styles.arrowIcon} />
</span>
</div>
</Link>
@@ -19,7 +19,7 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.55);
background: var(--elevated-surface);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);