mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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:
co-authored by
Cursor
parent
c5bdaad16b
commit
1271d96539
@@ -23,6 +23,9 @@ import { StorePage } from './pages/StorePage'
|
|||||||
import { StoreItemsPage } from './pages/StoreItemsPage'
|
import { StoreItemsPage } from './pages/StoreItemsPage'
|
||||||
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
|
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
|
||||||
import { CustomersPage } from './pages/CustomersPage'
|
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 { OrdersPage } from './pages/OrdersPage'
|
||||||
import { LoginPage } from './pages/LoginPage'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
import { ShoppingCardsPage } from './pages/ShoppingCardsPage'
|
import { ShoppingCardsPage } from './pages/ShoppingCardsPage'
|
||||||
@@ -83,6 +86,10 @@ function App() {
|
|||||||
<Route path="store/cards" element={<ShoppingCardsPage />} />
|
<Route path="store/cards" element={<ShoppingCardsPage />} />
|
||||||
<Route path="store/settings" element={<StoreSettingsPage />} />
|
<Route path="store/settings" element={<StoreSettingsPage />} />
|
||||||
<Route path="customers" element={<CustomersPage />} />
|
<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" element={<BlogPage />} />
|
||||||
<Route path="blog/list" element={<BlogListPage />} />
|
<Route path="blog/list" element={<BlogListPage />} />
|
||||||
<Route path="blog/detail/:id" element={<BlogDetailsPage />} />
|
<Route path="blog/detail/:id" element={<BlogDetailsPage />} />
|
||||||
|
|||||||
@@ -32,21 +32,20 @@
|
|||||||
.imageWrap {
|
.imageWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 3 / 2;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(148, 163, 184, 0.08);
|
background: rgba(148, 163, 184, 0.08);
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.imagePlaceholder {
|
.imagePlaceholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
aspect-ratio: 3 / 2;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(148, 163, 184, 0.12) 0%,
|
rgba(148, 163, 184, 0.12) 0%,
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Sparkles, X } from 'lucide-react'
|
import { Sparkles, X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
import modalStyles from './CategoryModal.module.css'
|
import modalStyles from './CategoryModal.module.css'
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
import styles from './CategoryAiPromptModal.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.
|
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.`
|
Use 3-5 main categories with relevant subcategories where it helps shoppers browse.`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CATEGORY_AI_PROMPT = buildCategoryAiPrompt('en')
|
||||||
|
|
||||||
interface CategoryAiPromptModalProps {
|
interface CategoryAiPromptModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -26,16 +39,19 @@ export function CategoryAiPromptModal({
|
|||||||
onRun,
|
onRun,
|
||||||
isRunning,
|
isRunning,
|
||||||
}: CategoryAiPromptModalProps) {
|
}: CategoryAiPromptModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
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('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
setClosing(false)
|
setClosing(false)
|
||||||
setPrompt(DEFAULT_CATEGORY_AI_PROMPT)
|
setPrompt(buildCategoryAiPrompt(isFa ? 'fa' : 'en'))
|
||||||
setError('')
|
setError('')
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
setClosing(true)
|
setClosing(true)
|
||||||
@@ -45,7 +61,7 @@ export function CategoryAiPromptModal({
|
|||||||
}, ANIMATION_MS)
|
}, ANIMATION_MS)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [open, mounted])
|
}, [open, mounted, isFa])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || closing) return
|
if (!mounted || closing) return
|
||||||
@@ -60,7 +76,7 @@ export function CategoryAiPromptModal({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const trimmed = prompt.trim()
|
const trimmed = prompt.trim()
|
||||||
if (trimmed.length < 10) {
|
if (trimmed.length < 10) {
|
||||||
setError('Prompt must be at least 10 characters.')
|
setError(t('products.form.aiPromptTooShort'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +89,7 @@ export function CategoryAiPromptModal({
|
|||||||
} else if (err instanceof Error) {
|
} else if (err instanceof Error) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError('Unable to generate categories with AI.')
|
setError(t('categories.ai.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,22 +107,22 @@ export function CategoryAiPromptModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="category-ai-prompt-title"
|
aria-labelledby="category-ai-prompt-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
>
|
>
|
||||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||||
<div className={styles.headerBlock}>
|
<div className={styles.headerBlock}>
|
||||||
<h2 id="category-ai-prompt-title" className={modalStyles.title}>
|
<h2 id="category-ai-prompt-title" className={modalStyles.title}>
|
||||||
Fill Categories with AI
|
{t('categories.ai.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>{t('categories.ai.subtitle')}</p>
|
||||||
Edit the prompt below, then run it to generate and save a category tree.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={modalStyles.closeBtn}
|
className={modalStyles.closeBtn}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
aria-label="Close"
|
aria-label={t('common.close')}
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -114,7 +130,7 @@ export function CategoryAiPromptModal({
|
|||||||
|
|
||||||
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||||
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
|
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
|
||||||
<label htmlFor="category-ai-prompt">Prompt</label>
|
<label htmlFor="category-ai-prompt">{t('products.form.aiPrompt')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="category-ai-prompt"
|
id="category-ai-prompt"
|
||||||
className={aiStyles.aiPromptTextarea}
|
className={aiStyles.aiPromptTextarea}
|
||||||
@@ -123,6 +139,8 @@ export function CategoryAiPromptModal({
|
|||||||
rows={8}
|
rows={8}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
required
|
required
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,30 +153,20 @@ export function CategoryAiPromptModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('categories.modal.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={aiStyles.aiBtn}
|
className={aiStyles.aiBtn}
|
||||||
disabled={isRunning || prompt.trim().length < 10}
|
disabled={isRunning || prompt.trim().length < 10}
|
||||||
>
|
>
|
||||||
{isRunning ? (
|
<Sparkles size={16} />
|
||||||
<>
|
{isRunning ? t('products.form.aiGenerating') : t('products.form.aiRun')}
|
||||||
<Sparkles size={16} />
|
|
||||||
Running...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles size={16} />
|
|
||||||
Run
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
,
|
document.body,
|
||||||
document.body,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stack above VariationsModal / TechnicalFormModal (z-index 1000–1010) */
|
||||||
|
.overlayStacked {
|
||||||
|
z-index: 1200;
|
||||||
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Building2,
|
Building2,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Package,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
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: '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',
|
type: 'group',
|
||||||
id: 'blog',
|
id: 'blog',
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Sparkles, X } from 'lucide-react'
|
import { Sparkles, X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
import modalStyles from './CategoryModal.module.css'
|
import modalStyles from './CategoryModal.module.css'
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
import styles from './CategoryAiPromptModal.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.
|
return `Suggest technical data form fields for the "${categoryName}" product category.
|
||||||
|
|
||||||
For each field include:
|
For each field include:
|
||||||
@@ -34,16 +51,21 @@ export function TechnicalFormAiPromptModal({
|
|||||||
onRun,
|
onRun,
|
||||||
isRunning,
|
isRunning,
|
||||||
}: TechnicalFormAiPromptModalProps) {
|
}: TechnicalFormAiPromptModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const [prompt, setPrompt] = useState(() => buildTechnicalFormAiPrompt(categoryName))
|
const [prompt, setPrompt] = useState(() =>
|
||||||
|
buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'),
|
||||||
|
)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
setClosing(false)
|
setClosing(false)
|
||||||
setPrompt(buildTechnicalFormAiPrompt(categoryName))
|
setPrompt(buildTechnicalFormAiPrompt(categoryName, isFa ? 'fa' : 'en'))
|
||||||
setError('')
|
setError('')
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
setClosing(true)
|
setClosing(true)
|
||||||
@@ -53,7 +75,7 @@ export function TechnicalFormAiPromptModal({
|
|||||||
}, ANIMATION_MS)
|
}, ANIMATION_MS)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [open, mounted, categoryName])
|
}, [open, mounted, categoryName, isFa])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || closing) return
|
if (!mounted || closing) return
|
||||||
@@ -68,7 +90,7 @@ export function TechnicalFormAiPromptModal({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const trimmed = prompt.trim()
|
const trimmed = prompt.trim()
|
||||||
if (trimmed.length < 10) {
|
if (trimmed.length < 10) {
|
||||||
setError('Prompt must be at least 10 characters.')
|
setError(t('products.form.aiPromptTooShort'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
@@ -78,7 +100,7 @@ export function TechnicalFormAiPromptModal({
|
|||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} else {
|
} else {
|
||||||
setError('Unable to generate technical form with AI.')
|
setError(t('categories.technical.ai.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +109,7 @@ export function TechnicalFormAiPromptModal({
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<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()}
|
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
@@ -96,22 +118,22 @@ export function TechnicalFormAiPromptModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="tech-form-ai-prompt-title"
|
aria-labelledby="tech-form-ai-prompt-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
>
|
>
|
||||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||||
<div className={styles.headerBlock}>
|
<div className={styles.headerBlock}>
|
||||||
<h2 id="tech-form-ai-prompt-title" className={modalStyles.title}>
|
<h2 id="tech-form-ai-prompt-title" className={modalStyles.title}>
|
||||||
Generate Technical Form with AI
|
{t('categories.technical.ai.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>{t('categories.technical.ai.subtitle')}</p>
|
||||||
Edit the prompt below, then run it to fill the form fields.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={modalStyles.closeBtn}
|
className={modalStyles.closeBtn}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
aria-label="Close"
|
aria-label={t('common.close')}
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -119,7 +141,7 @@ export function TechnicalFormAiPromptModal({
|
|||||||
|
|
||||||
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||||
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
|
<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
|
<textarea
|
||||||
id="tech-form-ai-prompt"
|
id="tech-form-ai-prompt"
|
||||||
className={aiStyles.aiPromptTextarea}
|
className={aiStyles.aiPromptTextarea}
|
||||||
@@ -128,10 +150,12 @@ export function TechnicalFormAiPromptModal({
|
|||||||
rows={8}
|
rows={8}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
required
|
required
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
{error ? <p className={styles.error}>{error}</p> : null}
|
||||||
|
|
||||||
<div className={modalStyles.actions}>
|
<div className={modalStyles.actions}>
|
||||||
<button
|
<button
|
||||||
@@ -140,7 +164,7 @@ export function TechnicalFormAiPromptModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('categories.modal.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -148,13 +172,12 @@ export function TechnicalFormAiPromptModal({
|
|||||||
disabled={isRunning || prompt.trim().length < 10}
|
disabled={isRunning || prompt.trim().length < 10}
|
||||||
>
|
>
|
||||||
<Sparkles size={16} />
|
<Sparkles size={16} />
|
||||||
{isRunning ? 'Generating…' : 'Run'}
|
{isRunning ? t('products.form.aiGenerating') : t('products.form.aiRun')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
,
|
document.body,
|
||||||
document.body,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,5 +156,23 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsRow {
|
||||||
justify-content: space-between;
|
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 { useEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Plus, Sparkles, Trash2, X } from 'lucide-react'
|
import { Plus, Sparkles, Trash2, X } from 'lucide-react'
|
||||||
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
|
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
|
||||||
|
import { useT } from '../i18n/useT'
|
||||||
|
import type { BusinessMessageKey } from '../i18n/messages'
|
||||||
import { createId } from '../utils/id'
|
import { createId } from '../utils/id'
|
||||||
|
import { Tooltip } from './Tooltip'
|
||||||
import styles from './VariationsModal.module.css'
|
import styles from './VariationsModal.module.css'
|
||||||
import fieldStyles from './TechnicalFormModal.module.css'
|
import fieldStyles from './TechnicalFormModal.module.css'
|
||||||
import aiStyles from '../styles/ai.module.css'
|
import aiStyles from '../styles/ai.module.css'
|
||||||
@@ -14,6 +18,7 @@ interface TechnicalFormModalProps {
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
isSaving?: boolean
|
isSaving?: boolean
|
||||||
isGenerating?: boolean
|
isGenerating?: boolean
|
||||||
|
escapeDisabled?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onChange: (fields: TechnicalFormFieldDraft[]) => void
|
onChange: (fields: TechnicalFormFieldDraft[]) => void
|
||||||
@@ -24,11 +29,11 @@ interface TechnicalFormModalProps {
|
|||||||
|
|
||||||
const ANIMATION_MS = 220
|
const ANIMATION_MS = 220
|
||||||
|
|
||||||
const FIELD_TYPE_LABELS: Record<TechnicalFieldType, string> = {
|
const FIELD_TYPE_KEYS: Record<TechnicalFieldType, BusinessMessageKey> = {
|
||||||
text: 'Text',
|
text: 'categories.technical.type.text',
|
||||||
textarea: 'Textarea',
|
textarea: 'categories.technical.type.textarea',
|
||||||
select: 'Select',
|
select: 'categories.technical.type.select',
|
||||||
multi_select: 'Multi',
|
multi_select: 'categories.technical.type.multi_select',
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyField(): TechnicalFormFieldDraft {
|
function createEmptyField(): TechnicalFormFieldDraft {
|
||||||
@@ -48,6 +53,7 @@ export function TechnicalFormModal({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
isGenerating = false,
|
isGenerating = false,
|
||||||
|
escapeDisabled = false,
|
||||||
error = '',
|
error = '',
|
||||||
onClose,
|
onClose,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -55,6 +61,9 @@ export function TechnicalFormModal({
|
|||||||
onGenerate,
|
onGenerate,
|
||||||
onGenerateClick,
|
onGenerateClick,
|
||||||
}: TechnicalFormModalProps) {
|
}: TechnicalFormModalProps) {
|
||||||
|
const t = useT()
|
||||||
|
const { locale } = useLocale()
|
||||||
|
const isFa = locale === 'fa'
|
||||||
const [mounted, setMounted] = useState(open)
|
const [mounted, setMounted] = useState(open)
|
||||||
const [closing, setClosing] = useState(false)
|
const [closing, setClosing] = useState(false)
|
||||||
const optionInputRefs = useRef<Record<string, (HTMLInputElement | null)[]>>({})
|
const optionInputRefs = useRef<Record<string, (HTMLInputElement | null)[]>>({})
|
||||||
@@ -82,13 +91,13 @@ export function TechnicalFormModal({
|
|||||||
}, [fields])
|
}, [fields])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted || closing) return
|
if (!mounted || closing || escapeDisabled) return
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [mounted, closing, onClose])
|
}, [mounted, closing, escapeDisabled, onClose])
|
||||||
|
|
||||||
if (!mounted) return null
|
if (!mounted) return null
|
||||||
|
|
||||||
@@ -180,22 +189,29 @@ export function TechnicalFormModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="technical-form-title"
|
aria-labelledby="technical-form-title"
|
||||||
|
lang={isFa ? 'fa' : 'en'}
|
||||||
|
dir={isFa ? 'rtl' : 'ltr'}
|
||||||
>
|
>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div>
|
<div>
|
||||||
<h3 id="technical-form-title" className={styles.title}>
|
<h3 id="technical-form-title" className={styles.title}>
|
||||||
Technical Data Form
|
{t('categories.technical.title')}
|
||||||
</h3>
|
</h3>
|
||||||
{categoryName && <p className={styles.subtitle}>{categoryName}</p>}
|
{categoryName ? <p className={styles.subtitle}>{categoryName}</p> : null}
|
||||||
</div>
|
</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} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className={styles.emptyText}>Loading form...</p>
|
<p className={styles.emptyText}>{t('categories.technical.loading')}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={fieldStyles.fieldList}>
|
<div className={fieldStyles.fieldList}>
|
||||||
@@ -206,16 +222,16 @@ export function TechnicalFormModal({
|
|||||||
id={`label-${field.id}`}
|
id={`label-${field.id}`}
|
||||||
type="text"
|
type="text"
|
||||||
className={fieldStyles.compactInput}
|
className={fieldStyles.compactInput}
|
||||||
placeholder="Field label"
|
placeholder={t('categories.technical.fieldLabel')}
|
||||||
value={field.label}
|
value={field.label}
|
||||||
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
||||||
aria-label="Field label"
|
aria-label={t('categories.technical.fieldLabel')}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
id={`type-${field.id}`}
|
id={`type-${field.id}`}
|
||||||
className={fieldStyles.compactInput}
|
className={fieldStyles.compactInput}
|
||||||
value={field.type}
|
value={field.type}
|
||||||
aria-label="Input type"
|
aria-label={t('categories.technical.inputType')}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const type = e.target.value as TechnicalFieldType
|
const type = e.target.value as TechnicalFieldType
|
||||||
updateField(field.id, {
|
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}>
|
<option key={value} value={value}>
|
||||||
{label}
|
{t(FIELD_TYPE_KEYS[value])}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<label className={fieldStyles.requiredCell} title="Required">
|
<label
|
||||||
|
className={fieldStyles.requiredCell}
|
||||||
|
title={t('categories.technical.required')}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.isRequired}
|
checked={field.isRequired}
|
||||||
@@ -243,23 +262,27 @@ export function TechnicalFormModal({
|
|||||||
updateField(field.id, { isRequired: e.target.checked })
|
updateField(field.id, { isRequired: e.target.checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>Required</span>
|
<span>{t('categories.technical.required')}</span>
|
||||||
</label>
|
</label>
|
||||||
<div className={fieldStyles.removeCell}>
|
<div className={fieldStyles.removeCell}>
|
||||||
<button
|
<Tooltip label={t('categories.technical.removeField')}>
|
||||||
type="button"
|
<button
|
||||||
className={styles.removeRowBtn}
|
type="button"
|
||||||
onClick={() => removeField(field.id)}
|
className={styles.removeRowBtn}
|
||||||
aria-label="Remove field"
|
onClick={() => removeField(field.id)}
|
||||||
>
|
aria-label={t('categories.technical.removeField')}
|
||||||
<Trash2 size={15} />
|
>
|
||||||
</button>
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(field.type === 'select' || field.type === 'multi_select') && (
|
{(field.type === 'select' || field.type === 'multi_select') && (
|
||||||
<div className={fieldStyles.optionsBlock}>
|
<div className={fieldStyles.optionsBlock}>
|
||||||
<span className={fieldStyles.optionsLabel}>Options</span>
|
<span className={fieldStyles.optionsLabel}>
|
||||||
|
{t('categories.technical.options')}
|
||||||
|
</span>
|
||||||
<div className={fieldStyles.customValues}>
|
<div className={fieldStyles.customValues}>
|
||||||
{field.options.map((option, optionIndex) => (
|
{field.options.map((option, optionIndex) => (
|
||||||
<div key={optionIndex} className={fieldStyles.customRow}>
|
<div key={optionIndex} className={fieldStyles.customRow}>
|
||||||
@@ -271,7 +294,9 @@ export function TechnicalFormModal({
|
|||||||
optionInputRefs.current[field.id][optionIndex] = el
|
optionInputRefs.current[field.id][optionIndex] = el
|
||||||
}}
|
}}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={`Option ${optionIndex + 1}`}
|
placeholder={t('categories.technical.optionN', {
|
||||||
|
n: optionIndex + 1,
|
||||||
|
})}
|
||||||
value={option}
|
value={option}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateOption(field.id, optionIndex, e.target.value)
|
updateOption(field.id, optionIndex, e.target.value)
|
||||||
@@ -282,14 +307,16 @@ export function TechnicalFormModal({
|
|||||||
/>
|
/>
|
||||||
{field.options.length > 1 && (
|
{field.options.length > 1 && (
|
||||||
<div className={fieldStyles.removeCell}>
|
<div className={fieldStyles.removeCell}>
|
||||||
<button
|
<Tooltip label={t('categories.technical.removeOption')}>
|
||||||
type="button"
|
<button
|
||||||
className={styles.removeRowBtn}
|
type="button"
|
||||||
onClick={() => removeOption(field.id, optionIndex)}
|
className={styles.removeRowBtn}
|
||||||
aria-label="Remove option"
|
onClick={() => removeOption(field.id, optionIndex)}
|
||||||
>
|
aria-label={t('categories.technical.removeOption')}
|
||||||
<X size={16} />
|
>
|
||||||
</button>
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{field.options.length <= 1 && <div />}
|
{field.options.length <= 1 && <div />}
|
||||||
@@ -301,7 +328,7 @@ export function TechnicalFormModal({
|
|||||||
onClick={() => addOption(field.id)}
|
onClick={() => addOption(field.id)}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
Add option
|
{t('categories.technical.addOption')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,56 +337,59 @@ export function TechnicalFormModal({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{!fields.length && (
|
{!fields.length && (
|
||||||
<p className={styles.emptyText}>
|
<p className={styles.emptyText}>{t('categories.technical.empty')}</p>
|
||||||
No fields yet. Add text, textarea, select, or multi-select inputs.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={fieldStyles.toolbar}>
|
<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}>
|
<button type="button" className={styles.addBtn} onClick={addField}>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
Add field
|
{t('categories.technical.addField')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className={styles.errorText}>{error}</p>}
|
{error ? <p className={styles.errorText}>{error}</p> : null}
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={`${styles.actions} ${fieldStyles.actionsRow}`}>
|
||||||
<button
|
<div className={fieldStyles.actionsMain}>
|
||||||
type="button"
|
<button
|
||||||
className={styles.cancelBtn}
|
type="button"
|
||||||
onClick={onClose}
|
className={styles.submitBtn}
|
||||||
disabled={isSaving || isGenerating}
|
onClick={onSave}
|
||||||
>
|
disabled={!canSave}
|
||||||
Cancel
|
>
|
||||||
</button>
|
{isSaving
|
||||||
<button
|
? t('categories.technical.saving')
|
||||||
type="button"
|
: t('categories.technical.save')}
|
||||||
className={styles.submitBtn}
|
</button>
|
||||||
onClick={onSave}
|
<button
|
||||||
disabled={!canSave}
|
type="button"
|
||||||
>
|
className={styles.cancelBtn}
|
||||||
{isSaving ? 'Saving…' : 'Save form'}
|
onClick={onClose}
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
,
|
document.body,
|
||||||
document.body,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tip {
|
.tip {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
bottom: calc(100% + 8px);
|
z-index: 10000;
|
||||||
left: 50%;
|
transform: translate(-50%, calc(-100% + 4px));
|
||||||
transform: translateX(-50%) translateY(4px);
|
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -19,8 +18,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
||||||
z-index: 50;
|
background: rgba(255, 255, 255, 0.95);
|
||||||
background: rgba(255, 255, 255, 0.75);
|
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
@@ -28,6 +26,12 @@
|
|||||||
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.12);
|
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tipVisible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
}
|
||||||
|
|
||||||
.tip::after {
|
.tip::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -35,12 +39,5 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
border: 5px solid transparent;
|
border: 5px solid transparent;
|
||||||
border-top-color: rgba(255, 255, 255, 0.75);
|
border-top-color: rgba(255, 255, 255, 0.95);
|
||||||
}
|
|
||||||
|
|
||||||
.wrap:hover .tip,
|
|
||||||
.wrap:focus-within .tip {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
import styles from './Tooltip.module.css'
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
@@ -7,12 +8,70 @@ interface TooltipProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Tooltip({ label, children }: 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 (
|
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}
|
{children}
|
||||||
<span className={styles.tip} role="tooltip">
|
{visible
|
||||||
{label}
|
? createPortal(
|
||||||
</span>
|
<span
|
||||||
|
className={`${styles.tip} ${styles.tipVisible}`}
|
||||||
|
role="tooltip"
|
||||||
|
style={{ top: coords.top, left: coords.left }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
</span>
|
</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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ const en = {
|
|||||||
'nav.store.cards': 'Shopping Cards',
|
'nav.store.cards': 'Shopping Cards',
|
||||||
'nav.store.settings': 'Settings',
|
'nav.store.settings': 'Settings',
|
||||||
'nav.customers': 'Customers',
|
'nav.customers': 'Customers',
|
||||||
|
'nav.customerProducts': 'Customer products',
|
||||||
'nav.settings': 'Settings',
|
'nav.settings': 'Settings',
|
||||||
'nav.blog': 'Blog',
|
'nav.blog': 'Blog',
|
||||||
'nav.blog.overview': 'Overview',
|
'nav.blog.overview': 'Overview',
|
||||||
@@ -241,6 +242,163 @@ const en = {
|
|||||||
'Are you sure you want to delete "{name}"? This action cannot be undone.',
|
'Are you sure you want to delete "{name}"? This action cannot be undone.',
|
||||||
'products.list.addByAi': 'Add product by AI',
|
'products.list.addByAi': 'Add product by AI',
|
||||||
'products.list.addNew': 'Add new product',
|
'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.edit': 'Edit product',
|
||||||
'products.card.quickInfo': 'Quick info',
|
'products.card.quickInfo': 'Quick info',
|
||||||
'products.card.variations': 'Manage variations',
|
'products.card.variations': 'Manage variations',
|
||||||
@@ -301,6 +459,10 @@ const en = {
|
|||||||
'categories.addSub': 'Add Sub Category',
|
'categories.addSub': 'Add Sub Category',
|
||||||
'categories.edit': 'Edit Category',
|
'categories.edit': 'Edit Category',
|
||||||
'categories.addByAi': 'Fill categories by AI',
|
'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.deleteTitle': 'Delete Category',
|
||||||
'categories.deleteMessage':
|
'categories.deleteMessage':
|
||||||
'Are you sure you want to delete "{name}"? Subcategories will also be removed.',
|
'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.empty': 'No categories found',
|
||||||
'categories.select.clear': 'Clear selection',
|
'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.page.subtitle': 'Manage product brands for your store catalog.',
|
||||||
'brands.loading': 'Loading brands...',
|
'brands.loading': 'Loading brands...',
|
||||||
'brands.empty': 'No brands yet. Click + to add one.',
|
'brands.empty': 'No brands yet. Click + to add one.',
|
||||||
@@ -1009,6 +1196,7 @@ const en = {
|
|||||||
'title.orders': 'My Orders',
|
'title.orders': 'My Orders',
|
||||||
'title.shoppingCards': 'Shopping Cards',
|
'title.shoppingCards': 'Shopping Cards',
|
||||||
'title.customers': 'Customers',
|
'title.customers': 'Customers',
|
||||||
|
'title.customerProducts': 'Customer products',
|
||||||
'title.blog': 'Blog',
|
'title.blog': 'Blog',
|
||||||
'title.myBlogs': 'My Blogs',
|
'title.myBlogs': 'My Blogs',
|
||||||
'title.addBlog': 'Add New Blog',
|
'title.addBlog': 'Add New Blog',
|
||||||
@@ -1143,6 +1331,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'nav.store.cards': 'کارتهای خرید',
|
'nav.store.cards': 'کارتهای خرید',
|
||||||
'nav.store.settings': 'تنظیمات',
|
'nav.store.settings': 'تنظیمات',
|
||||||
'nav.customers': 'مشتریان',
|
'nav.customers': 'مشتریان',
|
||||||
|
'nav.customerProducts': 'محصولات مشتریان',
|
||||||
'nav.settings': 'تنظیمات',
|
'nav.settings': 'تنظیمات',
|
||||||
'nav.blog': 'بلاگ',
|
'nav.blog': 'بلاگ',
|
||||||
'nav.blog.overview': 'نمای کلی',
|
'nav.blog.overview': 'نمای کلی',
|
||||||
@@ -1356,6 +1545,163 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
|
'آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.',
|
||||||
'products.list.addByAi': 'افزودن محصول با هوش مصنوعی',
|
'products.list.addByAi': 'افزودن محصول با هوش مصنوعی',
|
||||||
'products.list.addNew': 'افزودن محصول جدید',
|
'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.edit': 'ویرایش محصول',
|
||||||
'products.card.quickInfo': 'اطلاعات سریع',
|
'products.card.quickInfo': 'اطلاعات سریع',
|
||||||
'products.card.variations': 'مدیریت تنوعها',
|
'products.card.variations': 'مدیریت تنوعها',
|
||||||
@@ -1416,6 +1762,10 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'categories.addSub': 'افزودن زیردسته',
|
'categories.addSub': 'افزودن زیردسته',
|
||||||
'categories.edit': 'ویرایش دستهبندی',
|
'categories.edit': 'ویرایش دستهبندی',
|
||||||
'categories.addByAi': 'پر کردن دستهها با هوش مصنوعی',
|
'categories.addByAi': 'پر کردن دستهها با هوش مصنوعی',
|
||||||
|
'categories.ai.title': 'پر کردن دستهها با هوش مصنوعی',
|
||||||
|
'categories.ai.subtitle':
|
||||||
|
'پرامپت زیر را ویرایش کنید، سپس اجرا کنید تا درخت دستهبندی ساخته و ذخیره شود.',
|
||||||
|
'categories.ai.error': 'تولید دستهبندی با هوش مصنوعی ممکن نشد.',
|
||||||
'categories.deleteTitle': 'حذف دستهبندی',
|
'categories.deleteTitle': 'حذف دستهبندی',
|
||||||
'categories.deleteMessage':
|
'categories.deleteMessage':
|
||||||
'آیا از حذف «{name}» مطمئن هستید؟ زیردستهها نیز حذف میشوند.',
|
'آیا از حذف «{name}» مطمئن هستید؟ زیردستهها نیز حذف میشوند.',
|
||||||
@@ -1440,6 +1790,31 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'categories.select.empty': 'دستهبندیای یافت نشد',
|
'categories.select.empty': 'دستهبندیای یافت نشد',
|
||||||
'categories.select.clear': 'پاک کردن انتخاب',
|
'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.page.subtitle': 'برندهای محصول فروشگاه خود را مدیریت کنید.',
|
||||||
'brands.loading': 'در حال بارگذاری برندها...',
|
'brands.loading': 'در حال بارگذاری برندها...',
|
||||||
'brands.empty': 'هنوز برندی نیست. برای افزودن روی + کلیک کنید.',
|
'brands.empty': 'هنوز برندی نیست. برای افزودن روی + کلیک کنید.',
|
||||||
@@ -2123,6 +2498,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'title.orders': 'سفارشهای من',
|
'title.orders': 'سفارشهای من',
|
||||||
'title.shoppingCards': 'کارتهای خرید',
|
'title.shoppingCards': 'کارتهای خرید',
|
||||||
'title.customers': 'مشتریان',
|
'title.customers': 'مشتریان',
|
||||||
|
'title.customerProducts': 'محصولات مشتریان',
|
||||||
'title.blog': 'بلاگ',
|
'title.blog': 'بلاگ',
|
||||||
'title.myBlogs': 'بلاگهای من',
|
'title.myBlogs': 'بلاگهای من',
|
||||||
'title.addBlog': 'افزودن بلاگ',
|
'title.addBlog': 'افزودن بلاگ',
|
||||||
@@ -2255,6 +2631,7 @@ const BREADCRUMB_LABEL_KEYS: Record<string, MessageKey> = {
|
|||||||
'Shipping Fees': 'nav.store.shipping',
|
'Shipping Fees': 'nav.store.shipping',
|
||||||
'Shopping Cards': 'nav.store.cards',
|
'Shopping Cards': 'nav.store.cards',
|
||||||
Customers: 'nav.customers',
|
Customers: 'nav.customers',
|
||||||
|
'Customer products': 'nav.customerProducts',
|
||||||
Blog: 'nav.blog',
|
Blog: 'nav.blog',
|
||||||
'My Blogs': 'nav.blog.list',
|
'My Blogs': 'nav.blog.list',
|
||||||
'Add New Blog': 'nav.blog.new',
|
'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/settings', labels: [t('title.store'), t('title.settings')] },
|
||||||
{ match: '/store', labels: [t('title.store')] },
|
{ match: '/store', labels: [t('title.store')] },
|
||||||
{ match: '/customers', labels: [t('title.customers')] },
|
{ 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/list', labels: [t('title.blog'), t('title.myBlogs')] },
|
||||||
{ match: /^\/blog\/detail\/[^/]+$/, labels: [t('title.blog'), t('title.blogDetails')] },
|
{ match: /^\/blog\/detail\/[^/]+$/, labels: [t('title.blog'), t('title.blogDetails')] },
|
||||||
{ match: '/blog/new', labels: [t('title.blog'), t('title.addBlog')] },
|
{ 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 doesn’t 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%;
|
width: 100%;
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0 auto 20px;
|
margin: 0 auto 20px;
|
||||||
aspect-ratio: 3 / 2;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: rgba(148, 163, 184, 0.08);
|
background: rgba(148, 163, 184, 0.08);
|
||||||
@@ -15,15 +14,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heroImage {
|
.heroImage {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroPlaceholder {
|
.heroPlaceholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
aspect-ratio: 3 / 2;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(148, 163, 184, 0.12) 0%,
|
rgba(148, 163, 184, 0.12) 0%,
|
||||||
|
|||||||
@@ -528,6 +528,7 @@ export function CategoriesPage() {
|
|||||||
isGenerating={technicalGenerating}
|
isGenerating={technicalGenerating}
|
||||||
error={technicalError}
|
error={technicalError}
|
||||||
onClose={() => !technicalSaving && !technicalGenerating && setTechnicalTarget(null)}
|
onClose={() => !technicalSaving && !technicalGenerating && setTechnicalTarget(null)}
|
||||||
|
escapeDisabled={techAiPromptOpen}
|
||||||
onChange={(fields) => {
|
onChange={(fields) => {
|
||||||
setCategoryTechnicalFields((prev) => ({
|
setCategoryTechnicalFields((prev) => ({
|
||||||
...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
|
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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/** Optional CMS modules a business can have. Always-on areas are not listed. */
|
/** Optional business-dashboard CMS modules. Always-on areas are not listed. */
|
||||||
export const BUSINESS_MODULE_IDS = [
|
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||||
'products',
|
'products',
|
||||||
'store',
|
'store',
|
||||||
'portfolio',
|
'portfolio',
|
||||||
@@ -8,6 +8,18 @@ export const BUSINESS_MODULE_IDS = [
|
|||||||
'videos',
|
'videos',
|
||||||
] as const
|
] 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 type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number]
|
||||||
|
|
||||||
/** Home dashboard chart slots (super-admin selectable). */
|
/** Home dashboard chart slots (super-admin selectable). */
|
||||||
@@ -21,9 +33,12 @@ export const HOME_CHART_IDS = [
|
|||||||
|
|
||||||
export type HomeChartId = (typeof HOME_CHART_IDS)[number]
|
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[] = [
|
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||||
...BUSINESS_MODULE_IDS,
|
...BUSINESS_DASHBOARD_MODULE_IDS,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-easy-crop": "^6.2.3",
|
||||||
"react-router-dom": "^7.18.1"
|
"react-router-dom": "^7.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
|
|||||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||||
import { GuestRoute } from './components/GuestRoute'
|
import { GuestRoute } from './components/GuestRoute'
|
||||||
import { PageLayout } from './components/PageLayout'
|
import { PageLayout } from './components/PageLayout'
|
||||||
|
import { AddMyProductLayout } from './components/AddMyProductLayout'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { ProfilePage } from './pages/ProfilePage'
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
import { OrdersPage } from './pages/OrdersPage'
|
import { OrdersPage } from './pages/OrdersPage'
|
||||||
import { AddressesPage } from './pages/AddressesPage'
|
import { AddressesPage } from './pages/AddressesPage'
|
||||||
import { FavoritesPage } from './pages/FavoritesPage'
|
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 { LoginPage } from './pages/LoginPage'
|
||||||
import { CheckoutLayout } from './components/checkout/CheckoutLayout'
|
import { CheckoutLayout } from './components/checkout/CheckoutLayout'
|
||||||
import { CheckoutFlow } from './pages/checkout/CheckoutFlow'
|
import { CheckoutFlow } from './pages/checkout/CheckoutFlow'
|
||||||
@@ -51,12 +55,18 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route element={<ProtectedRoute />}>
|
<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 element={<PageLayout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="addresses" element={<AddressesPage />} />
|
<Route path="addresses" element={<AddressesPage />} />
|
||||||
<Route path="orders" element={<OrdersPage />} />
|
<Route path="orders" element={<OrdersPage />} />
|
||||||
<Route path="favorites" element={<FavoritesPage />} />
|
<Route path="favorites" element={<FavoritesPage />} />
|
||||||
|
<Route path="my-products" element={<MyProductsPage />} />
|
||||||
|
<Route path="my-products/:id" element={<MyProductDetailsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</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%;
|
width: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #ffffff;
|
background: var(--card-media-bg);
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +54,10 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
background: rgba(255, 255, 255, 0.82);
|
background: var(--elevated-surface);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-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;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,49 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
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 {
|
.menuBtn {
|
||||||
@@ -87,7 +130,7 @@
|
|||||||
padding-block: 8px;
|
padding-block: 8px;
|
||||||
padding-inline: 14px 12px;
|
padding-inline: 14px 12px;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: var(--surface);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow 0.2s, border-color 0.2s;
|
transition: box-shadow 0.2s, border-color 0.2s;
|
||||||
@@ -131,12 +174,12 @@
|
|||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: var(--elevated-surface);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: 0 12px 32px rgba(31, 38, 135, 0.12);
|
box-shadow: var(--glass-shadow);
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
animation: dropdownIn 0.15s ease;
|
animation: dropdownIn 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { Link, useNavigate } from 'react-router-dom'
|
|||||||
import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut } from 'lucide-react'
|
import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut } from 'lucide-react'
|
||||||
import { LanguageSelect, PasswordResetModal, useLocale } from '@meshkee/dashboard-ui'
|
import { LanguageSelect, PasswordResetModal, useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { changePassword } from '../services/authService'
|
import { changePassword } from '../services/authService'
|
||||||
|
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||||
import styles from './Header.module.css'
|
import styles from './Header.module.css'
|
||||||
|
|
||||||
function displayUserName(
|
function displayUserName(
|
||||||
@@ -30,16 +32,30 @@ function displayUserName(
|
|||||||
return localized || other || user.cellNumber || fallback
|
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 navigate = useNavigate()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
|
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
|
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const displayName = displayUserName(user, locale, t('app.role.customer'))
|
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(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return
|
if (!menuOpen) return
|
||||||
@@ -77,9 +93,25 @@ export function Header() {
|
|||||||
<>
|
<>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.left}>
|
<div className={styles.left}>
|
||||||
<button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
|
{showBrand ? (
|
||||||
<Menu size={22} />
|
<div className={styles.brand}>
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div className={styles.right}>
|
<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;
|
padding: 10px 12px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
background: rgba(255, 255, 255, 0.55);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemThumb {
|
.itemThumb {
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: var(--card-media-bg);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,11 +62,32 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(12, 1fr);
|
grid-template-columns: repeat(12, 1fr);
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-top: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.col6 {
|
.col6 {
|
||||||
grid-column: span 6;
|
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) {
|
@media (max-width: 1400px) {
|
||||||
@@ -80,6 +101,12 @@
|
|||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.col6,
|
||||||
|
.col4,
|
||||||
|
.col3 {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -107,7 +134,9 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col6 {
|
.col6,
|
||||||
|
.col4,
|
||||||
|
.col3 {
|
||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { NavLink, useNavigate } from 'react-router-dom'
|
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 { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { getActiveBusinessDomain } from '../lib/businessContext'
|
import { getActiveBusinessDomain } from '../lib/businessContext'
|
||||||
import { isAbortError } from '../lib/api'
|
import { isAbortError } from '../lib/api'
|
||||||
import { getWebsiteBusinessInfo } from '../services/websiteService'
|
import { getWebsiteBusinessInfo } from '../services/websiteService'
|
||||||
|
import { hasBusinessModule } from '../utils/businessModules'
|
||||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ export function Sidebar() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
|
const { enabledModules } = useTenantBranding()
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const [brandName, setBrandName] = useState('')
|
const [brandName, setBrandName] = useState('')
|
||||||
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
||||||
@@ -21,6 +24,7 @@ export function Sidebar() {
|
|||||||
const businessDomain = getActiveBusinessDomain()
|
const businessDomain = getActiveBusinessDomain()
|
||||||
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? t('app.storeFallback')
|
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? t('app.storeFallback')
|
||||||
const displayName = brandName || fallbackBusinessName
|
const displayName = brandName || fallbackBusinessName
|
||||||
|
const showMyProducts = hasBusinessModule(enabledModules, 'customer_products')
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: Home, label: t('nav.home'), to: '/' },
|
{ icon: Home, label: t('nav.home'), to: '/' },
|
||||||
@@ -28,6 +32,9 @@ export function Sidebar() {
|
|||||||
{ icon: MapPin, label: t('nav.addresses'), to: '/addresses' },
|
{ icon: MapPin, label: t('nav.addresses'), to: '/addresses' },
|
||||||
{ icon: ShoppingBag, label: t('nav.orders'), to: '/orders' },
|
{ icon: ShoppingBag, label: t('nav.orders'), to: '/orders' },
|
||||||
{ icon: Heart, label: t('nav.favorites'), to: '/favorites' },
|
{ icon: Heart, label: t('nav.favorites'), to: '/favorites' },
|
||||||
|
...(showMyProducts
|
||||||
|
? [{ icon: Package, label: t('nav.myProducts'), to: '/my-products' }]
|
||||||
|
: []),
|
||||||
]
|
]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: rgba(255, 255, 255, 0.55);
|
background: var(--elevated-surface);
|
||||||
backdrop-filter: blur(var(--blur-glass));
|
backdrop-filter: blur(var(--blur-glass));
|
||||||
-webkit-backdrop-filter: blur(var(--blur-glass));
|
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tip {
|
.tip {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
bottom: calc(100% + 8px);
|
z-index: 10000;
|
||||||
left: 50%;
|
transform: translate(-50%, calc(-100% + 4px));
|
||||||
transform: translateX(-50%) translateY(4px);
|
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -18,13 +17,18 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
||||||
z-index: 50;
|
background: var(--elevated-surface);
|
||||||
background: rgba(255, 255, 255, 0.75);
|
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 8px;
|
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 {
|
.tip::after {
|
||||||
@@ -34,12 +38,5 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
border: 5px solid transparent;
|
border: 5px solid transparent;
|
||||||
border-top-color: rgba(255, 255, 255, 0.75);
|
border-top-color: var(--elevated-surface);
|
||||||
}
|
|
||||||
|
|
||||||
.wrap:hover .tip,
|
|
||||||
.wrap:focus-within .tip {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
import styles from './Tooltip.module.css'
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
@@ -7,12 +8,70 @@ interface TooltipProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Tooltip({ label, children }: 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 (
|
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}
|
{children}
|
||||||
<span className={styles.tip} role="tooltip">
|
{visible
|
||||||
{label}
|
? createPortal(
|
||||||
</span>
|
<span
|
||||||
|
className={`${styles.tip} ${styles.tipVisible}`}
|
||||||
|
role="tooltip"
|
||||||
|
style={{ top: coords.top, left: coords.left }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
</span>
|
</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-width: 480px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: var(--surface);
|
||||||
backdrop-filter: blur(24px);
|
backdrop-filter: blur(24px);
|
||||||
-webkit-backdrop-filter: blur(24px);
|
-webkit-backdrop-filter: blur(24px);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
background: rgba(255, 255, 255, 0.6);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 2px solid rgba(var(--primary-rgb) / 0.2);
|
border: 2px solid rgba(var(--primary-rgb) / 0.2);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.formInModal {
|
.formInModal {
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
font-size: var(--field-font-size);
|
font-size: var(--field-font-size);
|
||||||
font-family: var(--font-fa);
|
font-family: var(--font-fa);
|
||||||
color: var(--text-primary);
|
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: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 2px dashed rgba(148, 163, 184, 0.45);
|
border: 2px dashed rgba(148, 163, 184, 0.45);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(255, 255, 255, 0.45);
|
background: var(--surface);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -4,19 +4,14 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
font-family: var(--font-fa);
|
font-family: var(--font-ui);
|
||||||
/* Soften brand color in page wash — keep primary accents, less saturated bg */
|
background-color: var(--bg-gradient-mid);
|
||||||
background-color: #f8f6f6;
|
background-image: linear-gradient(
|
||||||
background-image:
|
135deg,
|
||||||
radial-gradient(ellipse 520px 520px at calc(100% - 40px) -60px, rgba(var(--primary-rgb) / 0.1), transparent 72%),
|
var(--bg-gradient-start) 0%,
|
||||||
radial-gradient(ellipse 420px 420px at 18% calc(100% + 20px), rgba(var(--primary-rgb) / 0.07), transparent 72%),
|
var(--bg-gradient-mid) 50%,
|
||||||
radial-gradient(ellipse 320px 320px at -40px 42%, rgba(var(--primary-rgb) / 0.05), transparent 72%),
|
var(--bg-gradient-end) 100%
|
||||||
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%
|
|
||||||
);
|
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
@@ -119,12 +114,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: rgba(255, 255, 255, 0.75);
|
background: var(--glass-bg);
|
||||||
backdrop-filter: blur(24px);
|
backdrop-filter: blur(24px);
|
||||||
-webkit-backdrop-filter: blur(24px);
|
-webkit-backdrop-filter: blur(24px);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
|
box-shadow: var(--glass-shadow);
|
||||||
padding: 28px 28px 24px;
|
padding: 28px 28px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import { resolveTenantByDomain } from '../services/tenantService'
|
|||||||
import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme'
|
import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme'
|
||||||
import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
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 }) {
|
export function CustomerThemeProvider({ children }: { children: ReactNode }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -13,12 +20,15 @@ export function CustomerThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
async function loadTheme() {
|
async function loadTheme() {
|
||||||
try {
|
try {
|
||||||
const tenant = await resolveTenantByDomain(domain, controller.signal)
|
const tenant = await resolveTenantByDomain(domain, controller.signal)
|
||||||
|
if (controller.signal.aborted) return
|
||||||
applyBusinessPrimaryColor(
|
applyBusinessPrimaryColor(
|
||||||
normalizeBusinessPrimaryColorId(tenant.primaryColor),
|
normalizeBusinessPrimaryColorId(tenant.primaryColor),
|
||||||
)
|
)
|
||||||
|
applyThemeMode(tenant.themeMode)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAbortError(err)) return
|
if (isAbortError(err)) return
|
||||||
applyBusinessPrimaryColor(undefined)
|
applyBusinessPrimaryColor(undefined)
|
||||||
|
applyThemeMode('light')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,18 @@ import { isAbortError } from '../lib/api'
|
|||||||
import { getTenantDomain } from '../lib/config'
|
import { getTenantDomain } from '../lib/config'
|
||||||
import { getWebsiteBusinessInfo } from '../services/websiteService'
|
import { getWebsiteBusinessInfo } from '../services/websiteService'
|
||||||
import { resolveTenantByDomain } from '../services/tenantService'
|
import { resolveTenantByDomain } from '../services/tenantService'
|
||||||
|
import {
|
||||||
|
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
|
normalizeEnabledBusinessModules,
|
||||||
|
type BusinessModuleId,
|
||||||
|
} from '../utils/businessModules'
|
||||||
|
|
||||||
interface TenantBrandingContextValue {
|
interface TenantBrandingContextValue {
|
||||||
businessName: string
|
businessName: string
|
||||||
businessNameEn: string
|
businessNameEn: string
|
||||||
logoUrl: string | null
|
logoUrl: string | null
|
||||||
faviconUrl: string | null
|
faviconUrl: string | null
|
||||||
|
enabledModules: BusinessModuleId[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const TenantBrandingContext = createContext<TenantBrandingContextValue | null>(null)
|
const TenantBrandingContext = createContext<TenantBrandingContextValue | null>(null)
|
||||||
@@ -39,6 +45,9 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
const [businessNameEn, setBusinessNameEn] = useState('')
|
const [businessNameEn, setBusinessNameEn] = useState('')
|
||||||
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
||||||
const [faviconUrl, setFaviconUrl] = 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 defaultLocaleAppliedRef = useRef(false)
|
||||||
const domain = getTenantDomain()
|
const domain = getTenantDomain()
|
||||||
|
|
||||||
@@ -75,6 +84,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
setBusinessNameEn(nameEn || domain)
|
setBusinessNameEn(nameEn || domain)
|
||||||
setLogoUrl(nextLogo)
|
setLogoUrl(nextLogo)
|
||||||
setFaviconUrl(nextFavicon)
|
setFaviconUrl(nextFavicon)
|
||||||
|
setEnabledModules(normalizeEnabledBusinessModules(tenant.enabledModules))
|
||||||
applyDocumentFavicon(nextFavicon)
|
applyDocumentFavicon(nextFavicon)
|
||||||
|
|
||||||
if (!defaultLocaleAppliedRef.current) {
|
if (!defaultLocaleAppliedRef.current) {
|
||||||
@@ -91,6 +101,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
setBusinessNameEn(domain)
|
setBusinessNameEn(domain)
|
||||||
setLogoUrl(null)
|
setLogoUrl(null)
|
||||||
setFaviconUrl(null)
|
setFaviconUrl(null)
|
||||||
|
setEnabledModules([...DEFAULT_ENABLED_BUSINESS_MODULES])
|
||||||
applyDocumentFavicon(null)
|
applyDocumentFavicon(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,8 +114,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [domain, setLocale])
|
}, [domain, setLocale])
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ businessName, businessNameEn, logoUrl, faviconUrl }),
|
() => ({ businessName, businessNameEn, logoUrl, faviconUrl, enabledModules }),
|
||||||
[businessName, businessNameEn, logoUrl, faviconUrl],
|
[businessName, businessNameEn, logoUrl, faviconUrl, enabledModules],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const en = {
|
|||||||
'nav.addresses': 'My Addresses',
|
'nav.addresses': 'My Addresses',
|
||||||
'nav.orders': 'My Orders',
|
'nav.orders': 'My Orders',
|
||||||
'nav.favorites': 'My Favorites',
|
'nav.favorites': 'My Favorites',
|
||||||
|
'nav.myProducts': 'My Products',
|
||||||
'nav.help': 'Help Center',
|
'nav.help': 'Help Center',
|
||||||
'nav.logout': 'Logout',
|
'nav.logout': 'Logout',
|
||||||
|
|
||||||
@@ -30,12 +31,19 @@ const en = {
|
|||||||
'home.card.addresses.title': 'My Addresses',
|
'home.card.addresses.title': 'My Addresses',
|
||||||
'home.card.addresses.desc': 'Manage your shipping addresses for checkout and deliveries.',
|
'home.card.addresses.desc': 'Manage your shipping addresses for checkout and deliveries.',
|
||||||
'home.card.addresses.link': 'View addresses',
|
'home.card.addresses.link': 'View addresses',
|
||||||
|
'home.card.addresses.count': 'addresses',
|
||||||
'home.card.orders.title': 'My Orders',
|
'home.card.orders.title': 'My Orders',
|
||||||
'home.card.orders.desc': 'Track your orders, view order history and order details.',
|
'home.card.orders.desc': 'Track your orders, view order history and order details.',
|
||||||
'home.card.orders.link': 'View orders',
|
'home.card.orders.link': 'View orders',
|
||||||
|
'home.card.orders.count': 'orders',
|
||||||
'home.card.favorites.title': 'My Favorites',
|
'home.card.favorites.title': 'My Favorites',
|
||||||
'home.card.favorites.desc': 'Browse and manage your saved favorite products.',
|
'home.card.favorites.desc': 'Browse and manage your saved favorite products.',
|
||||||
'home.card.favorites.link': 'View favorites',
|
'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.title': 'My Profile',
|
||||||
'profile.subtitle': 'Update your personal information and contact details.',
|
'profile.subtitle': 'Update your personal information and contact details.',
|
||||||
@@ -154,6 +162,128 @@ const en = {
|
|||||||
'favorites.inStock': '{count} in stock',
|
'favorites.inStock': '{count} in stock',
|
||||||
'favorites.variantOne': '1 variant',
|
'favorites.variantOne': '1 variant',
|
||||||
'favorites.variantMany': '{count} variants',
|
'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',
|
'storeItems.price.contact': 'Contact for price',
|
||||||
|
|
||||||
'title.signIn': 'Sign in',
|
'title.signIn': 'Sign in',
|
||||||
@@ -261,6 +391,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'nav.addresses': 'آدرسهای من',
|
'nav.addresses': 'آدرسهای من',
|
||||||
'nav.orders': 'سفارشهای من',
|
'nav.orders': 'سفارشهای من',
|
||||||
'nav.favorites': 'علاقهمندیها',
|
'nav.favorites': 'علاقهمندیها',
|
||||||
|
'nav.myProducts': 'محصولات من',
|
||||||
'nav.help': 'مرکز راهنما',
|
'nav.help': 'مرکز راهنما',
|
||||||
'nav.logout': 'خروج',
|
'nav.logout': 'خروج',
|
||||||
|
|
||||||
@@ -279,12 +410,19 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'home.card.addresses.title': 'آدرسهای من',
|
'home.card.addresses.title': 'آدرسهای من',
|
||||||
'home.card.addresses.desc': 'آدرسهای ارسال برای تسویهحساب و تحویل را مدیریت کنید.',
|
'home.card.addresses.desc': 'آدرسهای ارسال برای تسویهحساب و تحویل را مدیریت کنید.',
|
||||||
'home.card.addresses.link': 'مشاهده آدرسها',
|
'home.card.addresses.link': 'مشاهده آدرسها',
|
||||||
|
'home.card.addresses.count': 'آدرس',
|
||||||
'home.card.orders.title': 'سفارشهای من',
|
'home.card.orders.title': 'سفارشهای من',
|
||||||
'home.card.orders.desc': 'سفارشها را پیگیری کنید و تاریخچه و جزئیات را ببینید.',
|
'home.card.orders.desc': 'سفارشها را پیگیری کنید و تاریخچه و جزئیات را ببینید.',
|
||||||
'home.card.orders.link': 'مشاهده سفارشها',
|
'home.card.orders.link': 'مشاهده سفارشها',
|
||||||
|
'home.card.orders.count': 'سفارش',
|
||||||
'home.card.favorites.title': 'علاقهمندیها',
|
'home.card.favorites.title': 'علاقهمندیها',
|
||||||
'home.card.favorites.desc': 'محصولات ذخیرهشده مورد علاقهتان را ببینید و مدیریت کنید.',
|
'home.card.favorites.desc': 'محصولات ذخیرهشده مورد علاقهتان را ببینید و مدیریت کنید.',
|
||||||
'home.card.favorites.link': 'مشاهده علاقهمندیها',
|
'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.title': 'پروفایل من',
|
||||||
'profile.subtitle': 'اطلاعات شخصی و راههای ارتباطی خود را بهروزرسانی کنید.',
|
'profile.subtitle': 'اطلاعات شخصی و راههای ارتباطی خود را بهروزرسانی کنید.',
|
||||||
@@ -403,6 +541,128 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'favorites.inStock': '{count} موجود',
|
'favorites.inStock': '{count} موجود',
|
||||||
'favorites.variantOne': '۱ تنوع',
|
'favorites.variantOne': '۱ تنوع',
|
||||||
'favorites.variantMany': '{count} تنوع',
|
'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': 'برای قیمت، تماس بگیرید',
|
'storeItems.price.contact': 'برای قیمت، تماس بگیرید',
|
||||||
|
|
||||||
'title.signIn': 'ورود',
|
'title.signIn': 'ورود',
|
||||||
@@ -533,6 +793,10 @@ export function getCustomerRouteTitleRules(locale: DashboardLocale) {
|
|||||||
{ match: '/addresses', labels: [t('nav.addresses')] },
|
{ match: '/addresses', labels: [t('nav.addresses')] },
|
||||||
{ match: '/orders', labels: [t('nav.orders')] },
|
{ match: '/orders', labels: [t('nav.orders')] },
|
||||||
{ match: '/favorites', labels: [t('nav.favorites')] },
|
{ 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')] },
|
{ match: '/', labels: [t('nav.home')] },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,69 @@
|
|||||||
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
--font-fa: 'YekanBakh', Tahoma, sans-serif;
|
--font-fa: 'YekanBakh', Tahoma, sans-serif;
|
||||||
--font-ui: var(--font-en), var(--font-fa);
|
--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%);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,9 @@ export const customerRouteTitleRules: RouteTitleRule[] = [
|
|||||||
{ match: '/addresses', labels: ['My Addresses'] },
|
{ match: '/addresses', labels: ['My Addresses'] },
|
||||||
{ match: '/orders', labels: ['My Orders'] },
|
{ match: '/orders', labels: ['My Orders'] },
|
||||||
{ match: '/favorites', labels: ['My Favorites'] },
|
{ 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'] },
|
{ 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 doesn’t 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;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(255, 255, 255, 0.55);
|
background: var(--elevated-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowLine {
|
.rowLine {
|
||||||
|
|||||||
@@ -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 { SectionCard, useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||||
import { useT } from '../i18n/useT'
|
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'
|
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() {
|
export function HomePage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
|
const { enabledModules } = useTenantBranding()
|
||||||
const t = useT()
|
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 =
|
const firstName =
|
||||||
(locale === 'en' ? user?.firstNameEn : user?.firstName) ||
|
(locale === 'en' ? user?.firstNameEn : user?.firstName) ||
|
||||||
user?.firstName ||
|
user?.firstName ||
|
||||||
user?.firstNameEn ||
|
user?.firstNameEn ||
|
||||||
t('home.welcomeFallback')
|
t('home.welcomeFallback')
|
||||||
|
|
||||||
const sections = [
|
const sections = baseSections.filter(
|
||||||
{
|
(section) => !section.moduleGated || showMyProducts,
|
||||||
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 formattedDate = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
const formattedDate = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||||
month: 'long',
|
month: 'long',
|
||||||
@@ -68,9 +148,20 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.gridHome}>
|
<div className={styles.grid12}>
|
||||||
{sections.map((section) => (
|
{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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -15,12 +15,12 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
padding: 36px 32px 32px;
|
padding: 36px 32px 32px;
|
||||||
background: rgba(255, 255, 255, 0.75);
|
background: var(--glass-bg);
|
||||||
backdrop-filter: blur(24px);
|
backdrop-filter: blur(24px);
|
||||||
-webkit-backdrop-filter: blur(24px);
|
-webkit-backdrop-filter: blur(24px);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
|
box-shadow: var(--glass-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -148,8 +148,8 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(255, 255, 255, 0.35);
|
background: var(--elevated-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.td {
|
.td {
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
background: rgba(255, 255, 255, 0.35);
|
background: var(--elevated-surface);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
background: rgba(255, 255, 255, 0.6);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
border: 2px solid rgba(148, 163, 184, 0.3);
|
border: 2px solid rgba(148, 163, 184, 0.3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s, background 0.2s;
|
transition: border-color 0.2s, background 0.2s;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -345,7 +345,7 @@
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 2px solid rgba(148, 163, 184, 0.3);
|
border: 2px solid rgba(148, 163, 184, 0.3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: border-color 0.2s, background 0.2s;
|
transition: border-color 0.2s, background 0.2s;
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
font-size: var(--field-font-size);
|
font-size: var(--field-font-size);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -482,7 +482,7 @@
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
border: 2px solid rgba(148, 163, 184, 0.3);
|
border: 2px solid rgba(148, 163, 184, 0.3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s, background 0.2s;
|
transition: border-color 0.2s, background 0.2s;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -538,7 +538,7 @@
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1.5px solid rgba(148, 163, 184, 0.3);
|
border: 1.5px solid rgba(148, 163, 184, 0.3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(255, 255, 255, 0.65);
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -765,7 +765,7 @@
|
|||||||
font-size: var(--field-font-size);
|
font-size: var(--field-font-size);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -3,14 +3,33 @@ import type { CityOption } from '@meshkee/dashboard-ui'
|
|||||||
|
|
||||||
export type { CityOption }
|
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[] }>(
|
const data = await apiRequest<{ items: CityOption[] }>(
|
||||||
'/cities?level=province&parentSlug=iran',
|
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
)
|
)
|
||||||
return data.items
|
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) {
|
export async function listCitiesByProvinceSlug(parentSlug: string, signal?: AbortSignal) {
|
||||||
const data = await apiRequest<{ items: CityOption[] }>(
|
const data = await apiRequest<{ items: CityOption[] }>(
|
||||||
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
|
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
|
||||||
|
|||||||
@@ -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 { apiRequest } from '../lib/api'
|
||||||
import type { DashboardLocale } from '@meshkee/dashboard-core'
|
import type { DashboardLocale } from '@meshkee/dashboard-core'
|
||||||
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
||||||
|
import type { BusinessModuleId } from '../utils/businessModules'
|
||||||
|
|
||||||
|
export type DashboardThemeMode = 'light' | 'dark'
|
||||||
|
|
||||||
export interface ResolvedTenant {
|
export interface ResolvedTenant {
|
||||||
id: string
|
id: string
|
||||||
@@ -10,6 +13,8 @@ export interface ResolvedTenant {
|
|||||||
domain: string
|
domain: string
|
||||||
primaryColor: BusinessPrimaryColorId
|
primaryColor: BusinessPrimaryColorId
|
||||||
defaultLocale?: DashboardLocale
|
defaultLocale?: DashboardLocale
|
||||||
|
themeMode?: DashboardThemeMode
|
||||||
|
enabledModules?: BusinessModuleId[]
|
||||||
logoUrl?: string | null
|
logoUrl?: string | null
|
||||||
faviconUrl?: 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
z-index: 1200;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 10px;
|
||||||
justify-content: center;
|
width: max-content;
|
||||||
gap: 6px;
|
min-width: 196px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
padding: 8px 10px;
|
padding: 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.85);
|
border: 1px solid rgba(255, 255, 255, 0.85);
|
||||||
background: rgba(255, 255, 255, 0.78);
|
background: rgba(255, 255, 255, 0.78);
|
||||||
@@ -57,6 +57,63 @@
|
|||||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.16);
|
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 {
|
.option {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Moon, Sun } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
BUSINESS_PRIMARY_COLOR_IDS,
|
BUSINESS_PRIMARY_COLOR_IDS,
|
||||||
BUSINESS_PRIMARY_COLOR_PALETTE,
|
BUSINESS_PRIMARY_COLOR_PALETTE,
|
||||||
type BusinessPrimaryColorId,
|
type BusinessPrimaryColorId,
|
||||||
} from '../utils/businessPrimaryColors'
|
} from '../utils/businessPrimaryColors'
|
||||||
|
import type { DashboardThemeMode } from '../services/businessSettingsService'
|
||||||
import styles from './PrimaryColorSwatchControl.module.css'
|
import styles from './PrimaryColorSwatchControl.module.css'
|
||||||
|
|
||||||
interface PrimaryColorSwatchControlProps {
|
interface PrimaryColorSwatchControlProps {
|
||||||
value: BusinessPrimaryColorId
|
value: BusinessPrimaryColorId
|
||||||
|
themeMode: DashboardThemeMode
|
||||||
onChange: (value: BusinessPrimaryColorId) => void
|
onChange: (value: BusinessPrimaryColorId) => void
|
||||||
|
onThemeModeChange: (value: DashboardThemeMode) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +24,9 @@ type PopoverPosition = {
|
|||||||
|
|
||||||
export function PrimaryColorSwatchControl({
|
export function PrimaryColorSwatchControl({
|
||||||
value,
|
value,
|
||||||
|
themeMode,
|
||||||
onChange,
|
onChange,
|
||||||
|
onThemeModeChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: PrimaryColorSwatchControlProps) {
|
}: PrimaryColorSwatchControlProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -60,6 +66,12 @@ export function PrimaryColorSwatchControl({
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectThemeMode(mode: DashboardThemeMode) {
|
||||||
|
if (mode !== themeMode) {
|
||||||
|
onThemeModeChange(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
@@ -99,10 +111,10 @@ export function PrimaryColorSwatchControl({
|
|||||||
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
|
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
|
||||||
onClick={toggleOpen}
|
onClick={toggleOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={`Dashboard primary color: ${tokens.label}`}
|
aria-label={`Dashboard theme: ${themeMode}, color: ${tokens.label}`}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="dialog"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
title={`Theme: ${tokens.label}`}
|
title={`Theme: ${themeMode} · ${tokens.label}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={styles.swatchDot}
|
className={styles.swatchDot}
|
||||||
@@ -117,35 +129,66 @@ export function PrimaryColorSwatchControl({
|
|||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className={styles.popover}
|
className={styles.popover}
|
||||||
role="listbox"
|
role="dialog"
|
||||||
aria-label="Choose primary color"
|
aria-label="Choose dashboard theme and primary color"
|
||||||
style={{
|
style={{
|
||||||
top: popoverPos.top,
|
top: popoverPos.top,
|
||||||
left: popoverPos.left,
|
left: popoverPos.left,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{BUSINESS_PRIMARY_COLOR_IDS.map((colorId) => {
|
<div className={styles.section}>
|
||||||
const option = BUSINESS_PRIMARY_COLOR_PALETTE[colorId]
|
<p className={styles.sectionLabel}>Theme</p>
|
||||||
const selected = colorId === value
|
<div className={styles.themeRow} role="radiogroup" aria-label="Theme mode">
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={colorId}
|
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="radio"
|
||||||
aria-selected={selected}
|
aria-checked={themeMode === 'light'}
|
||||||
aria-label={option.label}
|
className={`${styles.themeOption} ${themeMode === 'light' ? styles.themeOptionSelected : ''}`}
|
||||||
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
|
onClick={() => selectThemeMode('light')}
|
||||||
onClick={() => selectColor(colorId)}
|
|
||||||
>
|
>
|
||||||
<span
|
<Sun size={14} aria-hidden="true" />
|
||||||
className={styles.optionDot}
|
<span>Light</span>
|
||||||
style={{ backgroundColor: option.primary }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</button>
|
</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
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={colorId}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
|
||||||
|
onClick={() => selectColor(colorId)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={styles.optionDot}
|
||||||
|
style={{ backgroundColor: option.primary }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -459,13 +459,27 @@
|
|||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modulesSection {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modulesSection .entityGroupLabel {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.modulesChecks {
|
.modulesChecks {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px 16px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.modulesChecks {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.modulesCharts {
|
.modulesCharts {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -53,11 +53,16 @@ import {
|
|||||||
updateBusinessDefaultLocale,
|
updateBusinessDefaultLocale,
|
||||||
updateBusinessModules,
|
updateBusinessModules,
|
||||||
updateBusinessPrimaryColor,
|
updateBusinessPrimaryColor,
|
||||||
|
updateBusinessThemeMode,
|
||||||
type DashboardLocale,
|
type DashboardLocale,
|
||||||
|
type DashboardThemeMode,
|
||||||
} from '../services/businessSettingsService'
|
} from '../services/businessSettingsService'
|
||||||
import {
|
import {
|
||||||
|
BUSINESS_DASHBOARD_MODULE_IDS,
|
||||||
|
BUSINESS_DASHBOARD_MODULE_LABELS,
|
||||||
BUSINESS_MODULE_IDS,
|
BUSINESS_MODULE_IDS,
|
||||||
BUSINESS_MODULE_LABELS,
|
CUSTOMER_MODULE_IDS,
|
||||||
|
CUSTOMER_MODULE_LABELS,
|
||||||
DEFAULT_ENABLED_BUSINESS_MODULES,
|
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
DEFAULT_HOME_CHARTS,
|
DEFAULT_HOME_CHARTS,
|
||||||
HOME_CHART_IDS,
|
HOME_CHART_IDS,
|
||||||
@@ -74,11 +79,16 @@ import styles from './BusinessesPage.module.css'
|
|||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 10
|
const DEFAULT_PAGE_SIZE = 10
|
||||||
const DEFAULT_LOCALE: DashboardLocale = 'fa'
|
const DEFAULT_LOCALE: DashboardLocale = 'fa'
|
||||||
|
const DEFAULT_THEME_MODE: DashboardThemeMode = 'light'
|
||||||
|
|
||||||
function normalizeDefaultLocale(value: unknown): DashboardLocale {
|
function normalizeDefaultLocale(value: unknown): DashboardLocale {
|
||||||
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
|
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeThemeMode(value: unknown): DashboardThemeMode {
|
||||||
|
return value === 'dark' || value === 'light' ? value : DEFAULT_THEME_MODE
|
||||||
|
}
|
||||||
|
|
||||||
function emptyModuleSelection(
|
function emptyModuleSelection(
|
||||||
enabled: BusinessModuleId[] = DEFAULT_ENABLED_BUSINESS_MODULES,
|
enabled: BusinessModuleId[] = DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
): Record<BusinessModuleId, boolean> {
|
): Record<BusinessModuleId, boolean> {
|
||||||
@@ -400,7 +410,7 @@ export function BusinessesPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await updateBusinessPrimaryColor(b.id, primaryColor)
|
await updateBusinessPrimaryColor(b.id, primaryColor)
|
||||||
showToast(`Theme updated for "${b.name}".`, 'success')
|
showToast(`Theme color updated for "${b.name}".`, 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
@@ -413,7 +423,53 @@ export function BusinessesPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
showToast(
|
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',
|
'error',
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1103,10 +1159,14 @@ export function BusinessesPage() {
|
|||||||
<td className={`${styles.td} ${styles.tdTheme}`}>
|
<td className={`${styles.td} ${styles.tdTheme}`}>
|
||||||
<PrimaryColorSwatchControl
|
<PrimaryColorSwatchControl
|
||||||
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
|
value={normalizeBusinessPrimaryColorId(b.primaryColor)}
|
||||||
|
themeMode={normalizeThemeMode(b.themeMode)}
|
||||||
disabled={savingColorId === b.id}
|
disabled={savingColorId === b.id}
|
||||||
onChange={(primaryColor) =>
|
onChange={(primaryColor) =>
|
||||||
void handlePrimaryColorChange(b, primaryColor)
|
void handlePrimaryColorChange(b, primaryColor)
|
||||||
}
|
}
|
||||||
|
onThemeModeChange={(themeMode) =>
|
||||||
|
void handleThemeModeChange(b, themeMode)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className={`${styles.td} ${styles.tdLocale}`}>
|
<td className={`${styles.td} ${styles.tdLocale}`}>
|
||||||
@@ -1384,26 +1444,50 @@ export function BusinessesPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className={styles.helperText}>
|
<p className={styles.helperText}>
|
||||||
Choose which optional modules this business can use. Customers, website,
|
Choose which optional modules this business can use. Home, profile,
|
||||||
and other general sections stay available for every business.
|
website, and other general sections stay available for every business.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.modulesChecks}>
|
<div className={styles.modulesSection}>
|
||||||
{BUSINESS_MODULE_IDS.map((id) => (
|
<p className={styles.entityGroupLabel}>Business modules</p>
|
||||||
<label key={id} className={styles.entityCheck}>
|
<div className={styles.modulesChecks}>
|
||||||
<input
|
{BUSINESS_DASHBOARD_MODULE_IDS.map((id) => (
|
||||||
type="checkbox"
|
<label key={id} className={styles.entityCheck}>
|
||||||
checked={modulesSelected[id]}
|
<input
|
||||||
disabled={modulesSubmitting}
|
type="checkbox"
|
||||||
onChange={(e) =>
|
checked={modulesSelected[id]}
|
||||||
setModulesSelected((prev) => ({
|
disabled={modulesSubmitting}
|
||||||
...prev,
|
onChange={(e) =>
|
||||||
[id]: e.target.checked,
|
setModulesSelected((prev) => ({
|
||||||
}))
|
...prev,
|
||||||
}
|
[id]: e.target.checked,
|
||||||
/>
|
}))
|
||||||
<span>{BUSINESS_MODULE_LABELS[id]}</span>
|
}
|
||||||
</label>
|
/>
|
||||||
))}
|
<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>
|
||||||
<div className={styles.modulesCharts}>
|
<div className={styles.modulesCharts}>
|
||||||
<p className={styles.entityGroupLabel}>Home charts</p>
|
<p className={styles.entityGroupLabel}>Home charts</p>
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
|
|||||||
|
|
||||||
export type DashboardLocale = 'en' | 'fa'
|
export type DashboardLocale = 'en' | 'fa'
|
||||||
|
|
||||||
|
export type DashboardThemeMode = 'light' | 'dark'
|
||||||
|
|
||||||
export interface BrandingSettings {
|
export interface BrandingSettings {
|
||||||
primaryColor: BusinessPrimaryColorId
|
primaryColor: BusinessPrimaryColorId
|
||||||
defaultLocale: DashboardLocale
|
defaultLocale: DashboardLocale
|
||||||
|
themeMode: DashboardThemeMode
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModulesSettings {
|
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(
|
export async function updateBusinessDefaultLocale(
|
||||||
businessId: string,
|
businessId: string,
|
||||||
defaultLocale: DashboardLocale,
|
defaultLocale: DashboardLocale,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
||||||
import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
|
import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
|
||||||
import type { DashboardLocale } from '../services/businessSettingsService'
|
import type {
|
||||||
|
DashboardLocale,
|
||||||
|
DashboardThemeMode,
|
||||||
|
} from '../services/businessSettingsService'
|
||||||
|
|
||||||
export interface BusinessOwnerInfo {
|
export interface BusinessOwnerInfo {
|
||||||
name: string | null
|
name: string | null
|
||||||
@@ -28,6 +31,7 @@ export interface BusinessListItem {
|
|||||||
ownerCellNumber: string | null
|
ownerCellNumber: string | null
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
primaryColor: BusinessPrimaryColorId
|
primaryColor: BusinessPrimaryColorId
|
||||||
|
themeMode: DashboardThemeMode
|
||||||
defaultLocale: DashboardLocale
|
defaultLocale: DashboardLocale
|
||||||
enabledModules: BusinessModuleId[]
|
enabledModules: BusinessModuleId[]
|
||||||
moduleCount: number
|
moduleCount: number
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** Optional CMS modules a business can have. Always-on areas are not listed. */
|
/** Optional business-dashboard CMS modules. Always-on areas are not listed. */
|
||||||
export const BUSINESS_MODULE_IDS = [
|
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||||
'products',
|
'products',
|
||||||
'store',
|
'store',
|
||||||
'portfolio',
|
'portfolio',
|
||||||
@@ -8,9 +8,24 @@ export const BUSINESS_MODULE_IDS = [
|
|||||||
'videos',
|
'videos',
|
||||||
] as const
|
] 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 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',
|
products: 'Products',
|
||||||
store: 'Store',
|
store: 'Store',
|
||||||
portfolio: 'Portfolio',
|
portfolio: 'Portfolio',
|
||||||
@@ -19,6 +34,15 @@ export const BUSINESS_MODULE_LABELS: Record<BusinessModuleId, string> = {
|
|||||||
videos: 'Videos',
|
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). */
|
/** Home dashboard chart slots (super-admin selectable). */
|
||||||
export const HOME_CHART_IDS = [
|
export const HOME_CHART_IDS = [
|
||||||
'none',
|
'none',
|
||||||
@@ -38,9 +62,12 @@ export const HOME_CHART_LABELS: Record<HomeChartId, string> = {
|
|||||||
products_added_1y: 'Added product in last year',
|
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[] = [
|
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||||
...BUSINESS_MODULE_IDS,
|
...BUSINESS_DASHBOARD_MODULE_IDS,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||||
|
|||||||
+16
-4
@@ -3,7 +3,7 @@
|
|||||||
> **For AI agents:** Read this file at the start of a new chat before making changes.
|
> **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.
|
> 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 |
|
| `/addresses` | Addresses |
|
||||||
| `/orders` | Orders |
|
| `/orders` | Orders |
|
||||||
| `/favorites` | Favorites |
|
| `/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`)
|
### Business (`apps/business`)
|
||||||
|
|
||||||
@@ -184,6 +188,10 @@ Add to `/etc/hosts` (one line per tenant):
|
|||||||
| `/store/items` | Store items (product variants) | Yes |
|
| `/store/items` | Store items (product variants) | Yes |
|
||||||
| `/store/settings` | Online sell + order process steps | Yes |
|
| `/store/settings` | Online sell + order process steps | Yes |
|
||||||
| `/customers` | Business customers list | 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 |
|
| `/store/orders` | Orders list + filters | Yes |
|
||||||
| `/blog` | Blog hub | Yes |
|
| `/blog` | Blog hub | Yes |
|
||||||
| `/blog/list` | My Blogs grid | Yes |
|
| `/blog/list` | My Blogs grid | Yes |
|
||||||
@@ -257,9 +265,9 @@ Products accept optional `brandId` on create/update.
|
|||||||
|
|
||||||
### Business settings
|
### Business settings
|
||||||
- `GET/PATCH /businesses/:businessId/settings` — `branding`, `dashboard`, `store`, `modules`
|
- `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.
|
- `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`
|
- Tenant public: `GET /tenants/:host` includes `enabledModules` + `homeCharts`
|
||||||
|
|
||||||
### Portfolios
|
### Portfolios
|
||||||
@@ -301,6 +309,10 @@ Products accept optional `brandId` on create/update.
|
|||||||
### Media
|
### Media
|
||||||
- `POST /businesses/:businessId/media` — upload to S3
|
- `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)
|
## 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`
|
- **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 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 app’s `--font-fa`
|
- **RTL:** Farsi inputs use `dir="rtl"` and the app’s `--font-fa`
|
||||||
- **IRT prices:** comma-separated thousands + `IRT` suffix — see `.cursor/rules/ui-irt-price.mdc`
|
- **IRT prices:** comma-separated thousands + `IRT` suffix — see `.cursor/rules/ui-irt-price.mdc`
|
||||||
- **Toasts:** see `.cursor/rules/ui-toasts.mdc`
|
- **Toasts:** see `.cursor/rules/ui-toasts.mdc`
|
||||||
|
|||||||
Generated
+4
-3
@@ -40,6 +40,7 @@
|
|||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-easy-crop": "^6.2.3",
|
||||||
"react-router-dom": "^7.18.1"
|
"react-router-dom": "^7.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1287,9 +1288,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-easy-crop": {
|
"node_modules/react-easy-crop": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.3.tgz",
|
||||||
"integrity": "sha512-b0HOicSvLYoNk1yvZTwjH8sNjF5uD9xLtWrSDnv+fx1dXTbwd8bNLQaP6gjXw//A+tni9Mw5KDmPNOEjKF55NQ==",
|
"integrity": "sha512-ebimG3OGlzizjxEZ77Cj9CVLcg5vDZ6QVz8ud1rx4WmsCDWHIROEhgMi0GpJ/jKAuwHrH0M+QZUK4hyvxbYhOA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"normalize-wheel": "^1.0.1"
|
"normalize-wheel": "^1.0.1"
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
--card-hover-lift: -4px;
|
--card-hover-lift: -4px;
|
||||||
--card-hover-shadow: 0 16px 48px rgba(var(--primary-rgb) / 0.14);
|
--card-hover-shadow: 0 16px 48px rgba(var(--primary-rgb) / 0.14);
|
||||||
--card-hover-transition: transform 0.25s ease, box-shadow 0.25s ease;
|
--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 {
|
html {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import styles from './AddressListEditor.module.css'
|
|||||||
export type CityOption = {
|
export type CityOption = {
|
||||||
id: string
|
id: string
|
||||||
parentId: string | null
|
parentId: string | null
|
||||||
level: 'country' | 'province' | 'city'
|
level: 'country' | 'province' | 'city' | 'district'
|
||||||
nameFa: string
|
nameFa: string
|
||||||
nameEn: string
|
nameEn: string
|
||||||
landlineCode: string | null
|
landlineCode: string | null
|
||||||
|
|||||||
@@ -4,9 +4,16 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
border-radius: 50px;
|
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);
|
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,
|
.wrap:hover,
|
||||||
@@ -20,6 +27,7 @@
|
|||||||
inset-inline-start: 12px;
|
inset-inline-start: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
@@ -27,27 +35,37 @@
|
|||||||
inset-inline-end: 10px;
|
inset-inline-end: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
pointer-events: none;
|
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;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 72px;
|
min-height: 0;
|
||||||
|
min-width: 76px;
|
||||||
padding-block: 0;
|
padding-block: 0;
|
||||||
padding-inline: 34px 28px;
|
padding-inline: 34px 28px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
background: transparent;
|
background-color: transparent;
|
||||||
|
background-image: none;
|
||||||
|
box-shadow: none;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select:focus {
|
.wrap .select:focus {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.card {
|
.card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -50,6 +52,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 36px;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
@@ -58,28 +63,74 @@
|
|||||||
color: var(--primary);
|
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 {
|
.arrowBtn {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(var(--primary-rgb) / 0.1);
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
color: var(--primary);
|
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 {
|
.card:hover .arrowBtn {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([dir='rtl']) .arrowBtn {
|
:global([dir='rtl']) .arrowBtn {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([dir='rtl']) .card:hover .arrowBtn {
|
|
||||||
transform: scaleX(-1) translateX(2px);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ interface SectionCardProps {
|
|||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
linkText: string
|
/** Legacy footer link text; prefer count + countLabel when available. */
|
||||||
|
linkText?: string
|
||||||
href: string
|
href: string
|
||||||
|
/** Entity total shown beside the arrow; omit for sections without a count. */
|
||||||
|
count?: number | null
|
||||||
|
countLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SectionCard({
|
export function SectionCard({
|
||||||
@@ -16,7 +20,14 @@ export function SectionCard({
|
|||||||
description,
|
description,
|
||||||
linkText,
|
linkText,
|
||||||
href,
|
href,
|
||||||
|
count,
|
||||||
|
countLabel,
|
||||||
}: SectionCardProps) {
|
}: SectionCardProps) {
|
||||||
|
const showCount = typeof count === 'number' && Number.isFinite(count)
|
||||||
|
const formattedCount = showCount
|
||||||
|
? new Intl.NumberFormat('en-US').format(count)
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={href} className={styles.card} data-card-hover>
|
<Link to={href} className={styles.card} data-card-hover>
|
||||||
<div className={styles.iconWrap}>
|
<div className={styles.iconWrap}>
|
||||||
@@ -27,9 +38,20 @@ export function SectionCard({
|
|||||||
<p className={styles.description}>{description}</p>
|
<p className={styles.description}>{description}</p>
|
||||||
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<span className={styles.link}>{linkText}</span>
|
{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">
|
<span className={styles.arrowBtn} aria-hidden="true">
|
||||||
<ArrowRight size={18} />
|
<ArrowRight size={18} className={styles.arrowIcon} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: rgba(255, 255, 255, 0.55);
|
background: var(--elevated-surface);
|
||||||
backdrop-filter: blur(var(--blur-glass));
|
backdrop-filter: blur(var(--blur-glass));
|
||||||
-webkit-backdrop-filter: blur(var(--blur-glass));
|
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
|
|||||||
Reference in New Issue
Block a user