Add customer and business user-product flows across dashboards.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-10 00:23:03 +03:30
co-authored by Cursor
parent c5bdaad16b
commit 1271d96539
96 changed files with 10532 additions and 345 deletions
+1
View File
@@ -15,6 +15,7 @@
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.2.3",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
+10
View File
@@ -8,11 +8,15 @@ import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
import { ProtectedRoute } from './components/ProtectedRoute'
import { GuestRoute } from './components/GuestRoute'
import { PageLayout } from './components/PageLayout'
import { AddMyProductLayout } from './components/AddMyProductLayout'
import { HomePage } from './pages/HomePage'
import { ProfilePage } from './pages/ProfilePage'
import { OrdersPage } from './pages/OrdersPage'
import { AddressesPage } from './pages/AddressesPage'
import { FavoritesPage } from './pages/FavoritesPage'
import { MyProductsPage } from './pages/MyProductsPage'
import { MyProductDetailsPage } from './pages/MyProductDetailsPage'
import { AddMyProductPage } from './pages/AddMyProductPage'
import { LoginPage } from './pages/LoginPage'
import { CheckoutLayout } from './components/checkout/CheckoutLayout'
import { CheckoutFlow } from './pages/checkout/CheckoutFlow'
@@ -51,12 +55,18 @@ function App() {
</Route>
<Route element={<ProtectedRoute />}>
<Route element={<AddMyProductLayout />}>
<Route path="my-products/new" element={<AddMyProductPage />} />
<Route path="my-products/:id/edit" element={<AddMyProductPage />} />
</Route>
<Route element={<PageLayout />}>
<Route index element={<HomePage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="addresses" element={<AddressesPage />} />
<Route path="orders" element={<OrdersPage />} />
<Route path="favorites" element={<FavoritesPage />} />
<Route path="my-products" element={<MyProductsPage />} />
<Route path="my-products/:id" element={<MyProductDetailsPage />} />
</Route>
</Route>
</Routes>
@@ -0,0 +1,42 @@
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
position: relative;
background-color: var(--bg-gradient-mid);
background-image: linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-mid) 50%,
var(--bg-gradient-end) 100%
);
background-attachment: fixed;
background-repeat: no-repeat;
background-size: cover;
}
.main {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 32px 24px 48px;
}
.container {
width: 60%;
max-width: none;
min-width: 320px;
}
@media (max-width: 900px) {
.container {
width: 90%;
}
}
@media (max-width: 480px) {
.main {
padding: 20px 16px 32px;
}
}
@@ -0,0 +1,16 @@
import { Outlet } from 'react-router-dom'
import { Header } from './Header'
import styles from './AddMyProductLayout.module.css'
export function AddMyProductLayout() {
return (
<div className={styles.page}>
<Header hideMenu showBrand />
<main className={styles.main}>
<div className={styles.container}>
<Outlet />
</div>
</main>
</div>
)
}
@@ -0,0 +1,159 @@
.wrapper {
position: relative;
}
.inputWrap {
display: flex;
align-items: center;
gap: 6px;
min-height: var(--field-height);
padding: 0 var(--field-padding-x);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.inputWrapOpen,
.inputWrap:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.inputWrapDisabled {
opacity: 0.55;
cursor: not-allowed;
}
.searchIcon {
flex-shrink: 0;
color: var(--text-muted);
}
.input {
flex: 1;
min-width: 0;
padding: var(--field-padding-y) 0;
font-size: var(--field-font-size);
font-family: var(--font-ui);
line-height: 1.4;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
}
.input::placeholder {
font-family: var(--font-ui);
color: var(--text-muted);
opacity: 1;
}
.input:disabled {
cursor: not-allowed;
}
.clearBtn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.clearBtn:hover {
background: rgba(148, 163, 184, 0.2);
color: var(--text-secondary);
}
.chevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
inset-inline: 0;
max-height: 260px;
overflow-y: auto;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
z-index: 30;
list-style: none;
padding: 6px;
margin: 0;
}
.option {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
width: 100%;
padding: 8px 10px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
text-align: start;
color: var(--text-primary);
border-radius: 8px;
transition: background 0.15s;
box-sizing: border-box;
}
.optionChild {
border-inline-start: 2px solid rgba(var(--primary-rgb) / 0.35);
border-start-start-radius: 0;
border-end-start-radius: 0;
background: rgba(var(--primary-rgb) / 0.03);
padding-inline-start: 14px;
}
.option:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
.optionChild:hover {
background: rgba(var(--primary-rgb) / 0.1);
}
.optionSelected {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
font-weight: 500;
}
.optionChild.optionSelected {
border-inline-start-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.14);
}
.optionLabel {
font-weight: 500;
}
.optionSecondary {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
}
.noResults {
padding: 12px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
@@ -0,0 +1,246 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { UserProductCategoryOption } from '../services/userProductsService'
import styles from './CategorySearchSelect.module.css'
export interface FlatCategoryOption extends UserProductCategoryOption {
depth: number
}
interface CategorySearchSelectProps {
options: UserProductCategoryOption[]
value: string
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
id?: string
}
function categoryLabel(category: UserProductCategoryOption, isFa: boolean) {
if (isFa) {
return category.nameFa?.trim() || category.name
}
return category.name || category.nameFa?.trim() || ''
}
export function flattenCategoryTree(
categories: UserProductCategoryOption[],
): FlatCategoryOption[] {
const byParent = new Map<string | null, UserProductCategoryOption[]>()
for (const category of categories) {
const parentKey = category.parentId
const list = byParent.get(parentKey) ?? []
list.push(category)
byParent.set(parentKey, list)
}
for (const list of byParent.values()) {
list.sort((a, b) => {
const aLabel = (a.nameFa || a.name).localeCompare(b.nameFa || b.name, 'fa')
return aLabel
})
}
const result: FlatCategoryOption[] = []
const ids = new Set(categories.map((item) => item.id))
function walk(parentId: string | null, depth: number) {
const children = byParent.get(parentId) ?? []
for (const child of children) {
result.push({ ...child, depth })
walk(child.id, depth + 1)
}
}
walk(null, 0)
// Orphans whose parent is missing from the list
for (const category of categories) {
if (category.parentId && !ids.has(category.parentId)) {
if (!result.some((item) => item.id === category.id)) {
result.push({ ...category, depth: 0 })
walk(category.id, 1)
}
}
}
return result
}
function filterCategoryTree(
flat: FlatCategoryOption[],
query: string,
): FlatCategoryOption[] {
const q = query.trim().toLowerCase()
if (!q) return flat
const byId = new Map(flat.map((item) => [item.id, item]))
const matchedIds = new Set<string>()
for (const item of flat) {
const nameEn = item.name.toLowerCase()
const nameFa = (item.nameFa || '').toLowerCase()
if (nameEn.includes(q) || nameFa.includes(q) || (item.nameFa || '').includes(query.trim())) {
matchedIds.add(item.id)
let parentId = item.parentId
while (parentId) {
matchedIds.add(parentId)
parentId = byId.get(parentId)?.parentId ?? null
}
}
}
return flat.filter((item) => matchedIds.has(item.id))
}
export function CategorySearchSelect({
options,
value,
onChange,
disabled = false,
placeholder,
id,
}: CategorySearchSelectProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const flat = useMemo(() => flattenCategoryTree(options), [options])
const filtered = useMemo(() => filterCategoryTree(flat, query), [flat, query])
const selected = flat.find((option) => option.id === value)
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
const searchPlaceholder = placeholder ?? t('myProducts.fields.searchCategory')
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
useEffect(() => {
if (disabled) {
setOpen(false)
setQuery('')
}
}, [disabled])
function selectOption(nextId: string) {
onChange(nextId)
setOpen(false)
setQuery('')
}
return (
<div
className={styles.wrapper}
ref={containerRef}
dir={isFa ? 'rtl' : 'ltr'}
>
<div
className={[
styles.inputWrap,
open ? styles.inputWrapOpen : '',
disabled ? styles.inputWrapDisabled : '',
]
.filter(Boolean)
.join(' ')}
>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={styles.input}
disabled={disabled}
placeholder={selected ? selectedLabel : searchPlaceholder}
value={open ? query : selectedLabel}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('myProducts.fields.clearCategory')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<ul className={styles.dropdown} role="listbox">
{filtered.length === 0 ? (
<li className={styles.noResults}>{t('myProducts.fields.noCategories')}</li>
) : (
filtered.map((option) => (
<li key={option.id}>
<button
type="button"
className={[
styles.option,
option.depth > 0 ? styles.optionChild : '',
value === option.id ? styles.optionSelected : '',
]
.filter(Boolean)
.join(' ')}
style={
option.depth > 0
? {
marginInlineStart: `${option.depth * 22}px`,
}
: undefined
}
onClick={() => selectOption(option.id)}
role="option"
aria-selected={value === option.id}
>
<span className={styles.optionLabel}>
{categoryLabel(option, isFa)}
</span>
{!isFa && option.nameFa ? (
<span className={styles.optionSecondary}>{option.nameFa}</span>
) : null}
{isFa && option.name && option.name !== option.nameFa ? (
<span className={styles.optionSecondary} dir="ltr">
{option.name}
</span>
) : null}
</button>
</li>
))
)}
</ul>
) : null}
</div>
)
}
@@ -0,0 +1,129 @@
.wrapper {
position: relative;
}
.inputWrap {
display: flex;
align-items: center;
gap: 6px;
min-height: var(--field-height);
padding: 0 var(--field-padding-x);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.inputWrapOpen,
.inputWrap:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.inputWrapDisabled {
opacity: 0.55;
cursor: not-allowed;
}
.searchIcon {
flex-shrink: 0;
color: var(--text-muted);
}
.input {
flex: 1;
min-width: 0;
padding: var(--field-padding-y) 0;
font-size: var(--field-font-size);
font-family: var(--font-ui);
line-height: 1.4;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
}
.input::placeholder {
font-family: var(--font-ui);
color: var(--text-muted);
opacity: 1;
}
.input:disabled {
cursor: not-allowed;
}
.clearBtn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.clearBtn:hover {
background: rgba(148, 163, 184, 0.2);
color: var(--text-secondary);
}
.chevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
inset-inline: 0;
max-height: 220px;
overflow-y: auto;
background: var(--elevated-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
z-index: 20;
list-style: none;
padding: 6px;
margin: 0;
}
.option {
display: flex;
align-items: center;
width: 100%;
padding: 7px 10px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
text-align: start;
color: var(--text-primary);
border-radius: 8px;
transition: background 0.15s;
}
.option:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
.optionSelected {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
font-weight: 500;
}
.noResults {
padding: 12px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
@@ -0,0 +1,146 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import {
getLocationOptionLabel,
useLocale,
type CityOption,
} from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import styles from './CitySearchSelect.module.css'
interface CitySearchSelectProps {
options: CityOption[]
value: string
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
id?: string
}
export function CitySearchSelect({
options,
value,
onChange,
disabled = false,
placeholder,
id,
}: CitySearchSelectProps) {
const t = useT()
const { locale } = useLocale()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const selected = options.find((option) => option.id === value)
const selectedLabel = selected ? getLocationOptionLabel(selected, locale) : ''
const searchPlaceholder = placeholder ?? t('myProducts.fields.searchCity')
const filtered = options.filter((option) => {
const q = query.trim().toLowerCase()
if (!q) return true
return (
option.nameEn.toLowerCase().includes(q) ||
option.nameFa.includes(query.trim()) ||
option.slug.toLowerCase().includes(q)
)
})
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
useEffect(() => {
if (disabled) {
setOpen(false)
setQuery('')
}
}, [disabled])
function selectOption(nextId: string) {
onChange(nextId)
setOpen(false)
setQuery('')
}
return (
<div className={styles.wrapper} ref={containerRef}>
<div
className={[
styles.inputWrap,
open ? styles.inputWrapOpen : '',
disabled ? styles.inputWrapDisabled : '',
]
.filter(Boolean)
.join(' ')}
>
<Search size={16} className={styles.searchIcon} aria-hidden />
<input
id={id}
type="text"
className={styles.input}
disabled={disabled}
placeholder={selected ? selectedLabel : searchPlaceholder}
value={open ? query : selectedLabel}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
}}
onFocus={() => {
if (!disabled) setOpen(true)
}}
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{value && !open && !disabled ? (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label={t('myProducts.fields.clearCity')}
>
<X size={14} />
</button>
) : null}
<ChevronDown
size={16}
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
aria-hidden
/>
</div>
{open && !disabled ? (
<ul className={styles.dropdown} role="listbox">
{filtered.length === 0 ? (
<li className={styles.noResults}>{t('myProducts.fields.noCities')}</li>
) : (
filtered.map((option) => (
<li key={option.id}>
<button
type="button"
className={`${styles.option} ${value === option.id ? styles.optionSelected : ''}`}
onClick={() => selectOption(option.id)}
role="option"
aria-selected={value === option.id}
>
{getLocationOptionLabel(option, locale)}
</button>
</li>
))
)}
</ul>
) : null}
</div>
)
}
@@ -27,7 +27,7 @@
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
background: #ffffff;
background: var(--card-media-bg);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
@@ -54,10 +54,10 @@
letter-spacing: 0.03em;
border-radius: 50px;
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.82);
background: var(--elevated-surface);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12);
box-shadow: var(--glass-shadow);
z-index: 1;
}
+46 -3
View File
@@ -16,6 +16,49 @@
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.brandLogo {
width: 36px;
height: 36px;
object-fit: contain;
flex-shrink: 0;
border-radius: 8px;
background: transparent;
}
.brandText {
display: flex;
flex-direction: column;
min-width: 0;
}
.brandTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.brandSubtitle {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menuBtn {
@@ -87,7 +130,7 @@
padding-block: 8px;
padding-inline: 14px 12px;
border-radius: 50px;
background: rgba(255, 255, 255, 0.5);
background: var(--surface);
border: 1px solid var(--glass-border);
cursor: pointer;
transition: box-shadow 0.2s, border-color 0.2s;
@@ -131,12 +174,12 @@
inset-inline-end: 0;
min-width: 180px;
padding: 6px;
background: rgba(255, 255, 255, 0.95);
background: var(--elevated-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: 0 12px 32px rgba(31, 38, 135, 0.12);
box-shadow: var(--glass-shadow);
z-index: 60;
animation: dropdownIn 0.15s ease;
}
+36 -4
View File
@@ -3,8 +3,10 @@ import { Link, useNavigate } from 'react-router-dom'
import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut } from 'lucide-react'
import { LanguageSelect, PasswordResetModal, useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { useT } from '../i18n/useT'
import { changePassword } from '../services/authService'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Header.module.css'
function displayUserName(
@@ -30,16 +32,30 @@ function displayUserName(
return localized || other || user.cellNumber || fallback
}
export function Header() {
interface HeaderProps {
/** Hide the mobile sidebar hamburger (e.g. full-width flows without a sidebar). */
hideMenu?: boolean
/** Show website logo + name on the start side (visual right in RTL). */
showBrand?: boolean
}
export function Header({ hideMenu = false, showBrand = false }: HeaderProps) {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
const t = useT()
const [menuOpen, setMenuOpen] = useState(false)
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const displayName = displayUserName(user, locale, t('app.role.customer'))
const brandTitle = locale === 'en'
? businessNameEn || businessName
: businessName || businessNameEn
const brandSubtitle = locale === 'en'
? (businessName && businessName !== brandTitle ? businessName : '')
: (businessNameEn && businessNameEn !== brandTitle ? businessNameEn : '')
useEffect(() => {
if (!menuOpen) return
@@ -77,9 +93,25 @@ export function Header() {
<>
<header className={styles.header}>
<div className={styles.left}>
<button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
<Menu size={22} />
</button>
{showBrand ? (
<div className={styles.brand}>
<img
src={logoUrl || meshkeeLogo}
alt=""
className={styles.brandLogo}
/>
<div className={styles.brandText}>
<span className={styles.brandTitle}>{brandTitle || t('app.storeFallback')}</span>
{brandSubtitle ? (
<span className={styles.brandSubtitle}>{brandSubtitle}</span>
) : null}
</div>
</div>
) : !hideMenu ? (
<button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
<Menu size={22} />
</button>
) : null}
</div>
<div className={styles.right}>
@@ -0,0 +1,170 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.uploadZone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 0;
border: 2px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 70%, transparent);
color: var(--text-secondary);
font-size: 14px;
font-family: var(--font-ui);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
}
.uploadZone:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.06);
color: var(--primary);
}
.hint {
font-size: 12px;
color: var(--text-muted);
}
.preview {
position: relative;
width: 100%;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--glass-border);
background: var(--card-media-bg);
}
.previewImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.removeBtn {
position: absolute;
top: 8px;
inset-inline-end: 8px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(15, 23, 42, 0.6);
color: white;
transition: background 0.2s;
}
.removeBtn:hover {
background: #ef4444;
}
.cropPanel {
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
overflow: hidden;
background: color-mix(in srgb, var(--surface) 80%, transparent);
}
.cropArea {
position: relative;
width: 100%;
min-height: 160px;
max-height: 420px;
background: #1e293b;
}
.cropAreaPortrait {
max-height: none;
min-height: 0;
}
.cropControls {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.zoomLabel {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.zoomLabel input {
flex: 1;
accent-color: var(--primary);
}
.cropActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.cancelBtn {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-secondary);
border-radius: 8px;
transition: background 0.2s;
}
.cancelBtn:hover {
background: rgba(148, 163, 184, 0.15);
}
.applyBtn {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-ui);
color: white;
background: var(--primary);
border-radius: 8px;
transition: background 0.2s;
}
.applyBtn:hover {
background: var(--primary-dark);
}
.applyBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.changeBtn {
display: inline-flex;
align-self: flex-start;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--primary);
border: 1px solid rgba(var(--primary-rgb) / 0.3);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.changeBtn:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
@@ -0,0 +1,175 @@
import { useCallback, useEffect, useState } from 'react'
import Cropper, { type Area } from 'react-easy-crop'
import { ImagePlus, X } from 'lucide-react'
import { getCroppedImage } from '../utils/cropImage'
import { useT } from '../i18n/useT'
import styles from './ImageCropper.module.css'
interface ImageCropperProps {
value: string | null
onChange: (value: string | null) => void
aspect?: number
outputFormat?: 'jpeg' | 'png'
accept?: string
uploadLabel?: string
hint?: string
changeLabel?: string
}
export function ImageCropper({
value,
onChange,
aspect = 1,
outputFormat = 'jpeg',
accept = 'image/*',
uploadLabel,
hint,
changeLabel,
}: ImageCropperProps) {
const t = useT()
const resolvedUploadLabel = uploadLabel ?? t('myProducts.images.thumbnailUpload')
const resolvedHint = hint ?? t('myProducts.images.thumbnailHint')
const resolvedChangeLabel = changeLabel ?? t('myProducts.images.thumbnailChange')
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
useEffect(() => {
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedArea(null)
}, [aspect])
const onCropComplete = useCallback((_: Area, pixels: Area) => {
setCroppedArea(pixels)
}, [])
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
setImageSrc(reader.result as string)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedArea(null)
}
reader.readAsDataURL(file)
e.target.value = ''
}
async function applyCrop() {
if (!imageSrc || !croppedArea) return
const cropped = await getCroppedImage(imageSrc, croppedArea, outputFormat)
onChange(cropped)
setImageSrc(null)
setZoom(1)
setCrop({ x: 0, y: 0 })
}
function cancelCrop() {
setImageSrc(null)
setZoom(1)
setCrop({ x: 0, y: 0 })
}
function removeThumbnail() {
onChange(null)
}
const isPortrait = aspect < 1
const frameStyle: React.CSSProperties = isPortrait
? {
aspectRatio: `${aspect}`,
height: 'min(480px, 65vh)',
width: 'auto',
maxWidth: '100%',
marginInline: 'auto',
}
: {
aspectRatio: String(aspect),
width: '100%',
height: 'auto',
}
return (
<div className={styles.wrapper}>
{value && !imageSrc && (
<div className={styles.preview} style={{ aspectRatio: String(aspect) }}>
<img src={value} alt={resolvedUploadLabel} className={styles.previewImg} />
<button
type="button"
className={styles.removeBtn}
onClick={removeThumbnail}
aria-label={t('myProducts.images.thumbnailRemove')}
>
<X size={16} />
</button>
</div>
)}
{!value && !imageSrc && (
<label className={styles.uploadZone} style={frameStyle}>
<ImagePlus size={28} />
<span>{resolvedUploadLabel}</span>
<span className={styles.hint}>{resolvedHint}</span>
<input type="file" accept={accept} onChange={handleFile} hidden />
</label>
)}
{imageSrc && (
<div className={styles.cropPanel}>
<div
className={`${styles.cropArea} ${isPortrait ? styles.cropAreaPortrait : ''}`}
style={frameStyle}
>
<Cropper
key={String(aspect)}
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={aspect}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
<div className={styles.cropControls}>
<label className={styles.zoomLabel}>
{t('myProducts.images.zoom')}
<input
type="range"
min={1}
max={3}
step={0.05}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
/>
</label>
<div className={styles.cropActions}>
<button type="button" className={styles.cancelBtn} onClick={cancelCrop}>
{t('myProducts.cancel')}
</button>
<button
type="button"
className={styles.applyBtn}
onClick={() => void applyCrop()}
disabled={!croppedArea}
>
{t('myProducts.images.applyCrop')}
</button>
</div>
</div>
</div>
)}
{value && !imageSrc && (
<label className={styles.changeBtn}>
{resolvedChangeLabel}
<input type="file" accept={accept} onChange={handleFile} hidden />
</label>
)}
</div>
)
}
@@ -0,0 +1,83 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.grid {
--thumb-height: 140px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
}
.item {
position: relative;
height: var(--thumb-height);
width: fit-content;
max-width: 100%;
flex: 0 0 auto;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--glass-border);
background: var(--card-media-bg);
display: flex;
align-items: center;
justify-content: center;
}
.item img {
height: var(--thumb-height);
width: auto;
display: block;
object-fit: contain;
}
.removeBtn {
position: absolute;
top: 6px;
inset-inline-end: 6px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(15, 23, 42, 0.65);
color: white;
transition: background 0.2s;
}
.removeBtn:hover {
background: #ef4444;
}
.addBtn {
height: var(--thumb-height);
width: var(--thumb-height);
flex: 0 0 var(--thumb-height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border: 2px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 70%, transparent);
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-ui);
transition: border-color 0.2s, color 0.2s, background 0.2s;
}
.addBtn:hover {
border-color: var(--primary);
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.06);
}
.hint {
font-size: 12px;
color: var(--text-muted);
}
@@ -0,0 +1,77 @@
import { useRef } from 'react'
import { ImagePlus, X } from 'lucide-react'
import { useT } from '../i18n/useT'
import styles from './ImageUploader.module.css'
interface ImageUploaderProps {
images: string[]
onChange: (images: string[]) => void
}
export function ImageUploader({ images, onChange }: ImageUploaderProps) {
const t = useT()
const inputRef = useRef<HTMLInputElement>(null)
function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const readers = files.map(
(file) =>
new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(file)
}),
)
Promise.all(readers).then((results) => {
onChange([...images, ...results])
})
e.target.value = ''
}
function removeImage(index: number) {
onChange(images.filter((_, i) => i !== index))
}
return (
<div className={styles.wrapper}>
<div className={styles.grid}>
{images.map((src, index) => (
<div key={`${src.slice(0, 32)}-${index}`} className={styles.item}>
<img src={src} alt={t('myProducts.images.gallery')} />
<button
type="button"
className={styles.removeBtn}
onClick={() => removeImage(index)}
aria-label={t('myProducts.images.galleryRemove', { index: index + 1 })}
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
className={styles.addBtn}
onClick={() => inputRef.current?.click()}
>
<ImagePlus size={24} />
<span>{t('myProducts.images.galleryAdd')}</span>
</button>
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={handleFiles}
/>
<p className={styles.hint}>{t('myProducts.images.galleryHint')}</p>
</div>
)
}
@@ -48,7 +48,7 @@
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.55);
background: var(--surface);
}
.itemThumb {
@@ -57,7 +57,7 @@
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
background: #fff;
background: var(--card-media-bg);
border: 1px solid rgba(148, 163, 184, 0.15);
}
@@ -62,11 +62,32 @@
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 24px;
margin-top: 24px;
}
.col6 {
grid-column: span 6;
display: flex;
min-height: 0;
}
.col6 > *,
.col4 > *,
.col3 > * {
flex: 1;
width: 100%;
min-height: 100%;
}
.col4 {
grid-column: span 4;
display: flex;
min-height: 0;
}
.col3 {
grid-column: span 3;
display: flex;
min-height: 0;
}
@media (max-width: 1400px) {
@@ -80,6 +101,12 @@
.grid {
grid-template-columns: repeat(2, 1fr);
}
.col6,
.col4,
.col3 {
grid-column: span 12;
}
}
@media (max-width: 768px) {
@@ -107,7 +134,9 @@
gap: 16px;
}
.col6 {
.col6,
.col4,
.col3 {
grid-column: span 12;
}
}
+8 -1
View File
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { Home, User, MapPin, ShoppingBag, Heart, HelpCircle, LogOut } from 'lucide-react'
import { Home, User, MapPin, ShoppingBag, Heart, Package, HelpCircle, LogOut } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { useT } from '../i18n/useT'
import { getActiveBusinessDomain } from '../lib/businessContext'
import { isAbortError } from '../lib/api'
import { getWebsiteBusinessInfo } from '../services/websiteService'
import { hasBusinessModule } from '../utils/businessModules'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Sidebar.module.css'
@@ -14,6 +16,7 @@ export function Sidebar() {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
const { enabledModules } = useTenantBranding()
const t = useT()
const [brandName, setBrandName] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null)
@@ -21,6 +24,7 @@ export function Sidebar() {
const businessDomain = getActiveBusinessDomain()
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? t('app.storeFallback')
const displayName = brandName || fallbackBusinessName
const showMyProducts = hasBusinessModule(enabledModules, 'customer_products')
const navItems = [
{ icon: Home, label: t('nav.home'), to: '/' },
@@ -28,6 +32,9 @@ export function Sidebar() {
{ icon: MapPin, label: t('nav.addresses'), to: '/addresses' },
{ icon: ShoppingBag, label: t('nav.orders'), to: '/orders' },
{ icon: Heart, label: t('nav.favorites'), to: '/favorites' },
...(showMyProducts
? [{ icon: Package, label: t('nav.myProducts'), to: '/my-products' }]
: []),
]
useEffect(() => {
@@ -19,7 +19,7 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.55);
background: var(--elevated-surface);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
+12 -15
View File
@@ -4,10 +4,9 @@
}
.tip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(4px);
position: fixed;
z-index: 10000;
transform: translate(-50%, calc(-100% + 4px));
padding: 6px 10px;
font-size: 12px;
font-weight: 500;
@@ -18,13 +17,18 @@
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
z-index: 50;
background: rgba(255, 255, 255, 0.75);
background: var(--elevated-surface);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.12);
box-shadow: var(--glass-shadow);
}
.tipVisible {
opacity: 1;
visibility: visible;
transform: translate(-50%, -100%);
}
.tip::after {
@@ -34,12 +38,5 @@
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: rgba(255, 255, 255, 0.75);
}
.wrap:hover .tip,
.wrap:focus-within .tip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
border-top-color: var(--elevated-surface);
}
+64 -5
View File
@@ -1,4 +1,5 @@
import type { ReactElement } from 'react'
import { useEffect, useRef, useState, type ReactElement } from 'react'
import { createPortal } from 'react-dom'
import styles from './Tooltip.module.css'
interface TooltipProps {
@@ -7,12 +8,70 @@ interface TooltipProps {
}
export function Tooltip({ label, children }: TooltipProps) {
const wrapRef = useRef<HTMLSpanElement>(null)
const [visible, setVisible] = useState(false)
const [coords, setCoords] = useState({ top: 0, left: 0 })
function updatePosition() {
const el = wrapRef.current
if (!el) return
const rect = el.getBoundingClientRect()
setCoords({
top: rect.top - 8,
left: rect.left + rect.width / 2,
})
}
function show() {
updatePosition()
setVisible(true)
}
function hide() {
setVisible(false)
}
useEffect(() => {
if (!visible) return
function onReposition() {
updatePosition()
}
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
return () => {
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
}
}, [visible])
return (
<span className={styles.wrap}>
<span
ref={wrapRef}
className={styles.wrap}
onMouseEnter={show}
onMouseLeave={hide}
onFocusCapture={show}
onBlurCapture={(e) => {
if (!wrapRef.current?.contains(e.relatedTarget as Node | null)) {
hide()
}
}}
>
{children}
<span className={styles.tip} role="tooltip">
{label}
</span>
{visible
? createPortal(
<span
className={`${styles.tip} ${styles.tipVisible}`}
role="tooltip"
style={{ top: coords.top, left: coords.left }}
>
{label}
</span>,
document.body,
)
: null}
</span>
)
}
@@ -0,0 +1,236 @@
.card {
display: flex;
flex-direction: column;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
transition: var(--card-hover-transition, transform 0.2s, box-shadow 0.2s);
overflow: hidden;
}
.mainLink {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
color: inherit;
text-decoration: none;
}
.imageWrap {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
background: var(--card-media-bg);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.imagePlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(148, 163, 184, 0.75);
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.14) 0%,
rgba(148, 163, 184, 0.05) 100%
);
}
.badge {
position: absolute;
top: 8px;
inset-inline-start: 8px;
padding: 3px 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.03em;
border-radius: 50px;
border: 1px solid rgba(255, 255, 255, 0.22);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
z-index: 1;
}
.badge[data-status='draft'] {
color: #fff;
background: linear-gradient(
145deg,
rgba(251, 191, 36, 0.55) 0%,
rgba(245, 158, 11, 0.32) 100%
);
border-color: rgba(251, 191, 36, 0.35);
}
.badge[data-status='published'] {
color: #047857;
background: linear-gradient(
145deg,
rgba(167, 243, 208, 0.55) 0%,
rgba(52, 211, 153, 0.28) 100%
);
border-color: rgba(110, 231, 183, 0.4);
}
.badge[data-status='archived'] {
color: #e2e8f0;
background: linear-gradient(
145deg,
rgba(148, 163, 184, 0.45) 0%,
rgba(100, 116, 139, 0.28) 100%
);
border-color: rgba(148, 163, 184, 0.35);
}
.badge[data-status='rejected'] {
color: #fff;
background: linear-gradient(
145deg,
rgba(239, 68, 68, 0.55) 0%,
rgba(220, 38, 38, 0.32) 100%
);
border-color: rgba(239, 68, 68, 0.35);
}
.body {
padding: 10px 10px 8px;
flex: 1;
}
.title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
margin: 0 0 3px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.secondary {
font-family: var(--font-ui);
font-size: 12px;
color: var(--text-secondary);
margin: 0 0 6px;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.categoryChip {
display: inline-block;
margin-bottom: 6px;
padding: 2px 8px;
font-size: 10px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.12);
border-radius: 50px;
}
.metaRow {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
/* Physical LTR: price left, location right */
direction: ltr;
}
.location {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 0 auto;
font-size: 11px;
color: var(--text-muted);
min-width: 0;
}
.location span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.price {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
flex-shrink: 0;
}
.promotedBadge {
position: absolute;
top: 8px;
inset-inline-end: 8px;
padding: 3px 8px;
font-size: 10px;
font-weight: 600;
color: #fff;
border-radius: 50px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: linear-gradient(
145deg,
rgba(99, 102, 241, 0.7) 0%,
rgba(168, 85, 247, 0.45) 100%
);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
z-index: 1;
}
.controls {
display: flex;
align-items: center;
justify-content: space-around;
padding: 8px 6px 10px;
border-top: 1px solid rgba(148, 163, 184, 0.15);
overflow: visible;
}
.controls button {
position: relative;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.controls button:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.controls button:disabled {
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
.controls button.danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ImageOff, MapPin, Megaphone, Pencil, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { UserProductListItem } from '../types/userProduct'
import { formatIrtPrice } from '../utils/irtPrice'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
import styles from './UserProductCard.module.css'
interface UserProductCardProps {
product: UserProductListItem
to?: string
onEdit?: (id: string) => void
onRemove?: (id: string) => void
onPromote?: (id: string) => void
busyAction?: 'remove' | 'promote' | null
}
export function UserProductCard({
product,
to,
onEdit,
onRemove,
onPromote,
busyAction = null,
}: UserProductCardProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const titleFa = product.titleFa || product.title
const titleEn = product.titleEn?.trim() || ''
const title = isFa ? titleFa : titleEn || titleFa
const secondary = isFa ? titleEn : titleEn ? titleFa : ''
const city = isFa ? product.cityNameFa || product.cityName : product.cityName
const category = isFa
? product.categoryNameFa || product.categoryName
: product.categoryName
const imageSrc = product.imageUrl?.trim() || ''
const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed
const currency = (product.priceCurrency || 'IRT').toUpperCase()
const priceLabel =
product.price == null
? t('myProducts.priceUnavailable')
: currency === 'IRT'
? formatIrtPrice(product.price)
: `${product.price.toLocaleString('en-US')} ${currency}`
const showControls = Boolean(onEdit || onRemove || onPromote)
const isBusy = busyAction != null
useEffect(() => {
setImageFailed(false)
}, [imageSrc])
const statusLabel =
product.status === 'published'
? t('myProducts.status.published')
: product.status === 'archived'
? t('myProducts.status.archived')
: product.status === 'rejected'
? t('myProducts.status.rejected')
: t('myProducts.status.pending')
const mediaAndBody = (
<>
<div className={styles.imageWrap}>
{showImage ? (
<img
src={imageSrc}
alt={title}
className={styles.image}
loading="lazy"
onError={() => setImageFailed(true)}
/>
) : (
<div className={styles.imagePlaceholder} aria-hidden="true">
<ImageOff size={28} strokeWidth={1.5} />
</div>
)}
<span className={styles.badge} data-status={product.status}>
{statusLabel}
</span>
{product.promoted ? (
<span className={styles.promotedBadge}>{t('myProducts.promoted')}</span>
) : null}
</div>
<div className={styles.body} dir={isFa ? 'rtl' : 'ltr'}>
<h3 className={styles.title}>{title}</h3>
{secondary ? <p className={styles.secondary}>{secondary}</p> : null}
{category ? <span className={styles.categoryChip}>{category}</span> : null}
<div className={styles.metaRow}>
<p className={styles.price}>{priceLabel}</p>
<p className={styles.location} dir={isFa ? 'rtl' : 'ltr'}>
<MapPin size={12} aria-hidden="true" />
<span>{city}</span>
</p>
</div>
</div>
</>
)
return (
<article className={styles.card} data-card-hover>
{to ? (
<Link to={to} className={styles.mainLink}>
{mediaAndBody}
</Link>
) : (
mediaAndBody
)}
{showControls ? (
<div className={styles.controls}>
{onEdit ? (
<Tooltip label={t('myProducts.edit')}>
<button
type="button"
onClick={() => onEdit(product.id)}
aria-label={t('myProducts.edit')}
disabled={isBusy}
>
<Pencil size={16} />
</button>
</Tooltip>
) : null}
{onPromote ? (
<Tooltip
label={
product.promoted ? t('myProducts.promoted') : t('myProducts.promote')
}
>
<button
type="button"
onClick={() => onPromote(product.id)}
aria-label={t('myProducts.promote')}
disabled={isBusy || product.promoted}
>
<Megaphone size={16} />
</button>
</Tooltip>
) : null}
{onRemove ? (
<Tooltip label={t('myProducts.remove')}>
<button
type="button"
className={styles.danger}
onClick={() => onRemove(product.id)}
aria-label={t('myProducts.remove')}
disabled={isBusy}
>
<Trash2 size={16} />
</button>
</Tooltip>
) : null}
</div>
) : null}
</article>
)
}
@@ -20,7 +20,7 @@
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
background: rgba(255, 255, 255, 0.92);
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
@@ -100,7 +100,7 @@
align-items: center;
gap: 12px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.6);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: var(--radius-sm);
}
@@ -185,7 +185,7 @@
line-height: 1.4;
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.7);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
@@ -213,7 +213,7 @@
line-height: 1.5;
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.7);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
@@ -233,7 +233,7 @@
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.7);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 50px;
transition: all 0.15s;
@@ -5,7 +5,7 @@
padding: 14px;
border: 2px solid rgba(var(--primary-rgb) / 0.2);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.7);
background: var(--surface);
}
.formInModal {
@@ -60,7 +60,7 @@
font-size: var(--field-font-size);
font-family: var(--font-fa);
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.85);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
@@ -141,7 +141,7 @@
padding: 14px;
border: 2px dashed rgba(148, 163, 184, 0.45);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.45);
background: var(--surface);
color: var(--primary);
font-size: 13px;
font-weight: 600;
@@ -4,19 +4,14 @@
flex-direction: column;
position: relative;
direction: rtl;
font-family: var(--font-fa);
/* Soften brand color in page wash — keep primary accents, less saturated bg */
background-color: #f8f6f6;
background-image:
radial-gradient(ellipse 520px 520px at calc(100% - 40px) -60px, rgba(var(--primary-rgb) / 0.1), transparent 72%),
radial-gradient(ellipse 420px 420px at 18% calc(100% + 20px), rgba(var(--primary-rgb) / 0.07), transparent 72%),
radial-gradient(ellipse 320px 320px at -40px 42%, rgba(var(--primary-rgb) / 0.05), transparent 72%),
linear-gradient(
135deg,
color-mix(in srgb, var(--primary-light) 22%, #ffffff) 0%,
color-mix(in srgb, var(--primary-light) 10%, #ffffff) 50%,
#fafafa 100%
);
font-family: var(--font-ui);
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;
@@ -119,12 +114,12 @@
}
.card {
background: rgba(255, 255, 255, 0.75);
background: var(--glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
box-shadow: var(--glass-shadow);
padding: 28px 28px 24px;
}
@@ -5,6 +5,13 @@ import { resolveTenantByDomain } from '../services/tenantService'
import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme'
import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
function applyThemeMode(mode: 'light' | 'dark' | undefined) {
document.documentElement.setAttribute(
'data-theme',
mode === 'dark' ? 'dark' : 'light',
)
}
export function CustomerThemeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const controller = new AbortController()
@@ -13,12 +20,15 @@ export function CustomerThemeProvider({ children }: { children: ReactNode }) {
async function loadTheme() {
try {
const tenant = await resolveTenantByDomain(domain, controller.signal)
if (controller.signal.aborted) return
applyBusinessPrimaryColor(
normalizeBusinessPrimaryColorId(tenant.primaryColor),
)
applyThemeMode(tenant.themeMode)
} catch (err) {
if (isAbortError(err)) return
applyBusinessPrimaryColor(undefined)
applyThemeMode('light')
}
}
@@ -13,12 +13,18 @@ import { isAbortError } from '../lib/api'
import { getTenantDomain } from '../lib/config'
import { getWebsiteBusinessInfo } from '../services/websiteService'
import { resolveTenantByDomain } from '../services/tenantService'
import {
DEFAULT_ENABLED_BUSINESS_MODULES,
normalizeEnabledBusinessModules,
type BusinessModuleId,
} from '../utils/businessModules'
interface TenantBrandingContextValue {
businessName: string
businessNameEn: string
logoUrl: string | null
faviconUrl: string | null
enabledModules: BusinessModuleId[]
}
const TenantBrandingContext = createContext<TenantBrandingContextValue | null>(null)
@@ -39,6 +45,9 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
const [businessNameEn, setBusinessNameEn] = useState('')
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
const [enabledModules, setEnabledModules] = useState<BusinessModuleId[]>(
DEFAULT_ENABLED_BUSINESS_MODULES,
)
const defaultLocaleAppliedRef = useRef(false)
const domain = getTenantDomain()
@@ -75,6 +84,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
setBusinessNameEn(nameEn || domain)
setLogoUrl(nextLogo)
setFaviconUrl(nextFavicon)
setEnabledModules(normalizeEnabledBusinessModules(tenant.enabledModules))
applyDocumentFavicon(nextFavicon)
if (!defaultLocaleAppliedRef.current) {
@@ -91,6 +101,7 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
setBusinessNameEn(domain)
setLogoUrl(null)
setFaviconUrl(null)
setEnabledModules([...DEFAULT_ENABLED_BUSINESS_MODULES])
applyDocumentFavicon(null)
}
}
@@ -103,8 +114,8 @@ export function TenantBrandingProvider({ children }: { children: ReactNode }) {
}, [domain, setLocale])
const value = useMemo(
() => ({ businessName, businessNameEn, logoUrl, faviconUrl }),
[businessName, businessNameEn, logoUrl, faviconUrl],
() => ({ businessName, businessNameEn, logoUrl, faviconUrl, enabledModules }),
[businessName, businessNameEn, logoUrl, faviconUrl, enabledModules],
)
return (
+264
View File
@@ -12,6 +12,7 @@ const en = {
'nav.addresses': 'My Addresses',
'nav.orders': 'My Orders',
'nav.favorites': 'My Favorites',
'nav.myProducts': 'My Products',
'nav.help': 'Help Center',
'nav.logout': 'Logout',
@@ -30,12 +31,19 @@ const en = {
'home.card.addresses.title': 'My Addresses',
'home.card.addresses.desc': 'Manage your shipping addresses for checkout and deliveries.',
'home.card.addresses.link': 'View addresses',
'home.card.addresses.count': 'addresses',
'home.card.orders.title': 'My Orders',
'home.card.orders.desc': 'Track your orders, view order history and order details.',
'home.card.orders.link': 'View orders',
'home.card.orders.count': 'orders',
'home.card.favorites.title': 'My Favorites',
'home.card.favorites.desc': 'Browse and manage your saved favorite products.',
'home.card.favorites.link': 'View favorites',
'home.card.favorites.count': 'favorites',
'home.card.myProducts.title': 'My Products',
'home.card.myProducts.desc': 'List and manage products you have submitted.',
'home.card.myProducts.link': 'View my products',
'home.card.myProducts.count': 'products',
'profile.title': 'My Profile',
'profile.subtitle': 'Update your personal information and contact details.',
@@ -154,6 +162,128 @@ const en = {
'favorites.inStock': '{count} in stock',
'favorites.variantOne': '1 variant',
'favorites.variantMany': '{count} variants',
'myProducts.title': 'My Products',
'myProducts.subtitle':
'The products below are yours and will be sold on our website after supervisor approval.',
'myProducts.empty': 'You have not added any products yet.',
'myProducts.add': 'Add product',
'myProducts.addTitle': 'Add product',
'myProducts.addSubtitle': 'Submit a new product for review.',
'myProducts.addComingSoon': 'The add form will be designed next.',
'myProducts.backToList': 'Back to my products',
'myProducts.editTitle': 'Edit product',
'myProducts.editSubtitle': 'Update your listing details, then save.',
'myProducts.detailsTitle': 'Product details',
'myProducts.detailsSubtitle': 'Review your submitted listing.',
'myProducts.edit': 'Edit product',
'myProducts.yes': 'Yes',
'myProducts.editSoon': 'Edit product will be available soon.',
'myProducts.remove': 'Remove product',
'myProducts.removeConfirm': 'Remove this product from your stock?',
'myProducts.removeSuccess': 'Product removed.',
'myProducts.promote': 'Promote product',
'myProducts.promoted': 'Promoted',
'myProducts.promoteSuccess': 'Product promoted.',
'myProducts.priceUnavailable': 'Price on request',
'myProducts.status.pending': 'Pending',
'myProducts.status.published': 'Approved',
'myProducts.status.rejected': 'Rejected',
'myProducts.status.archived': 'Archived',
'myProducts.stepperLabel': 'Add product steps',
'myProducts.step.basics': 'Basics',
'myProducts.step.basicsHint': 'Category, names, description, price, and location.',
'myProducts.step.basicsPlaceholder': 'Basic fields will go here.',
'myProducts.step.images': 'Images',
'myProducts.step.imagesHint': 'Add a cropped thumbnail and gallery photos.',
'myProducts.step.details': 'Details',
'myProducts.step.detailsHint': 'Description and technical information for the selected category.',
'myProducts.step.detailsPlaceholder': 'Details and technical fields will go here.',
'myProducts.step.technical': 'Technical data',
'myProducts.step.technicalHint':
'Choose condition, add optional notes, then fill the category technical form.',
'myProducts.optional': '(optional)',
'myProducts.loading': 'Loading products…',
'myProducts.fields.category': 'Category',
'myProducts.fields.selectCategory': 'Select category',
'myProducts.fields.searchCategory': 'Search category…',
'myProducts.fields.clearCategory': 'Clear category',
'myProducts.fields.noCategories': 'No categories found',
'myProducts.fields.titleFa': 'Name (FA)',
'myProducts.fields.titleFaPlaceholder': 'Product name in Farsi',
'myProducts.fields.titleEn': 'Name (EN)',
'myProducts.fields.titleEnPlaceholder': 'Product name in English',
'myProducts.fields.description': 'Description',
'myProducts.fields.descriptionPlaceholder': 'Short description of your product',
'myProducts.fields.price': 'Desired price',
'myProducts.fields.priceSuggested': 'Your suggested price',
'myProducts.fields.priceByExpert': 'I want an expert to set the price',
'myProducts.fields.pricePlaceholder': 'e.g. 1,500,000',
'myProducts.fields.priceUnit': 'Unit',
'myProducts.fields.priceUnit.IRT': 'IRT',
'myProducts.fields.priceUnit.USD': 'Dollar',
'myProducts.fields.priceUnit.EUR': 'EURO',
'myProducts.fields.priceUnit.AED': 'AED',
'myProducts.fields.location': 'Location',
'myProducts.fields.country': 'Country',
'myProducts.fields.selectCountry': 'Select country',
'myProducts.fields.province': 'Province',
'myProducts.fields.selectProvince': 'Select province',
'myProducts.fields.city': 'City',
'myProducts.fields.selectCity': 'Select city',
'myProducts.fields.searchCity': 'Search city…',
'myProducts.fields.clearCity': 'Clear city',
'myProducts.fields.noCities': 'No cities found',
'myProducts.fields.deliveryNote': 'Pickup / delivery note',
'myProducts.fields.deliveryNotePlaceholder': 'e.g. pickup only, evening delivery…',
'myProducts.fields.district': 'District',
'myProducts.fields.selectDistrict': 'Select district',
'myProducts.fields.condition': 'Condition',
'myProducts.fields.technicalNotes': 'Technical notes',
'myProducts.fields.technicalNotesPlaceholder': 'Optional extra technical details…',
'myProducts.condition.new': 'New',
'myProducts.condition.stock': 'Stock',
'myProducts.condition.needs_repair': 'Needs repair',
'myProducts.condition.scrap': 'Scrap',
'myProducts.images.thumbnail': 'Thumbnail',
'myProducts.images.thumbnailUpload': 'Upload thumbnail',
'myProducts.images.thumbnailHint': '3:2 landscape works best',
'myProducts.images.thumbnailChange': 'Change thumbnail',
'myProducts.images.thumbnailRemove': 'Remove thumbnail',
'myProducts.images.zoom': 'Zoom',
'myProducts.images.applyCrop': 'Apply crop',
'myProducts.images.gallery': 'Gallery',
'myProducts.images.galleryAdd': 'Add photos',
'myProducts.images.galleryHint': 'You can select multiple images.',
'myProducts.images.galleryRemove': 'Remove image {index}',
'myProducts.technical.select': 'Select…',
'myProducts.technical.needCategory': 'Choose a category in step 1 to load technical fields.',
'myProducts.technical.empty': 'This category has no technical fields yet.',
'myProducts.technical.categoryForm': 'Category technical form',
'myProducts.technical.loading': 'Loading technical fields…',
'myProducts.cancel': 'Cancel',
'myProducts.next': 'Next',
'myProducts.back': 'Back',
'myProducts.submit': 'Submit',
'myProducts.save': 'Save changes',
'myProducts.submitting': 'Submitting…',
'myProducts.submitSuccess': 'Product submitted for review.',
'myProducts.updateSuccess': 'Product updated.',
'myProducts.error.load': 'Unable to load your products.',
'myProducts.error.loadDetail': 'Unable to load this product.',
'myProducts.error.loadLocations': 'Unable to load locations.',
'myProducts.error.loadCategories': 'Unable to load categories.',
'myProducts.error.loadTechnicalForm': 'Unable to load the category technical form.',
'myProducts.error.categoryRequired': 'Please select a category.',
'myProducts.error.titleFaRequired': 'Please enter the Farsi name.',
'myProducts.error.locationRequired': 'Please select country and city.',
'myProducts.error.priceInvalid': 'Please enter a valid price.',
'myProducts.error.conditionRequired': 'Please select a condition.',
'myProducts.error.technicalRequired': 'Please fill required technical fields.',
'myProducts.error.submit': 'Unable to submit the product.',
'myProducts.error.update': 'Unable to update the product.',
'myProducts.error.remove': 'Unable to remove the product.',
'myProducts.error.promote': 'Unable to promote the product.',
'storeItems.price.contact': 'Contact for price',
'title.signIn': 'Sign in',
@@ -261,6 +391,7 @@ const fa: Record<MessageKey, string> = {
'nav.addresses': 'آدرس‌های من',
'nav.orders': 'سفارش‌های من',
'nav.favorites': 'علاقه‌مندی‌ها',
'nav.myProducts': 'محصولات من',
'nav.help': 'مرکز راهنما',
'nav.logout': 'خروج',
@@ -279,12 +410,19 @@ const fa: Record<MessageKey, string> = {
'home.card.addresses.title': 'آدرس‌های من',
'home.card.addresses.desc': 'آدرس‌های ارسال برای تسویه‌حساب و تحویل را مدیریت کنید.',
'home.card.addresses.link': 'مشاهده آدرس‌ها',
'home.card.addresses.count': 'آدرس',
'home.card.orders.title': 'سفارش‌های من',
'home.card.orders.desc': 'سفارش‌ها را پیگیری کنید و تاریخچه و جزئیات را ببینید.',
'home.card.orders.link': 'مشاهده سفارش‌ها',
'home.card.orders.count': 'سفارش',
'home.card.favorites.title': 'علاقه‌مندی‌ها',
'home.card.favorites.desc': 'محصولات ذخیره‌شده مورد علاقه‌تان را ببینید و مدیریت کنید.',
'home.card.favorites.link': 'مشاهده علاقه‌مندی‌ها',
'home.card.favorites.count': 'علاقه‌مندی',
'home.card.myProducts.title': 'محصولات من',
'home.card.myProducts.desc': 'محصولاتی که ثبت کرده‌اید را ببینید و مدیریت کنید.',
'home.card.myProducts.link': 'مشاهده محصولات من',
'home.card.myProducts.count': 'محصول',
'profile.title': 'پروفایل من',
'profile.subtitle': 'اطلاعات شخصی و راه‌های ارتباطی خود را به‌روزرسانی کنید.',
@@ -403,6 +541,128 @@ const fa: Record<MessageKey, string> = {
'favorites.inStock': '{count} موجود',
'favorites.variantOne': '۱ تنوع',
'favorites.variantMany': '{count} تنوع',
'myProducts.title': 'محصولات من',
'myProducts.subtitle':
'محصولات زیر، محصولات شما هستند که توسط وبسایت ما بعد از تایید ناظر به فروش خواهد رسید.',
'myProducts.empty': 'هنوز محصولی ثبت نکرده‌اید.',
'myProducts.add': 'افزودن محصول',
'myProducts.addTitle': 'افزودن محصول',
'myProducts.addSubtitle': 'محصول جدید را برای بررسی ارسال کنید.',
'myProducts.addComingSoon': 'فرم افزودن در مرحله بعد طراحی می‌شود.',
'myProducts.backToList': 'بازگشت به محصولات من',
'myProducts.editTitle': 'ویرایش محصول',
'myProducts.editSubtitle': 'جزئیات آگهی را به‌روز کنید و ذخیره کنید.',
'myProducts.detailsTitle': 'جزئیات محصول',
'myProducts.detailsSubtitle': 'جزئیات آگهی ثبت‌شده را ببینید.',
'myProducts.edit': 'ویرایش محصول',
'myProducts.yes': 'بله',
'myProducts.editSoon': 'ویرایش محصول به‌زودی در دسترس خواهد بود.',
'myProducts.remove': 'حذف محصول',
'myProducts.removeConfirm': 'این محصول از موجودی شما حذف شود؟',
'myProducts.removeSuccess': 'محصول حذف شد.',
'myProducts.promote': 'پروموت محصول',
'myProducts.promoted': 'پروموت شده',
'myProducts.promoteSuccess': 'محصول پروموت شد.',
'myProducts.priceUnavailable': 'قیمت اعلام نشده',
'myProducts.status.pending': 'در انتظار تأیید',
'myProducts.status.published': 'تأیید شده',
'myProducts.status.rejected': 'رد شده',
'myProducts.status.archived': 'بایگانی',
'myProducts.stepperLabel': 'مراحل افزودن محصول',
'myProducts.step.basics': 'اطلاعات پایه',
'myProducts.step.basicsHint': 'دسته‌بندی، نام، توضیحات، قیمت و موقعیت.',
'myProducts.step.basicsPlaceholder': 'فیلدهای پایه اینجا قرار می‌گیرند.',
'myProducts.step.images': 'تصاویر',
'myProducts.step.imagesHint': 'تصویر شاخص با برش و گالری تصاویر را اضافه کنید.',
'myProducts.step.details': 'جزئیات',
'myProducts.step.detailsHint': 'توضیحات و اطلاعات فنی بر اساس دسته‌بندی انتخاب‌شده.',
'myProducts.step.detailsPlaceholder': 'جزئیات و فیلدهای فنی اینجا قرار می‌گیرند.',
'myProducts.step.technical': 'اطلاعات فنی',
'myProducts.step.technicalHint':
'وضعیت را انتخاب کنید، یادداشت اختیاری بنویسید و فرم فنی دسته‌بندی را تکمیل کنید.',
'myProducts.optional': '(اختیاری)',
'myProducts.loading': 'در حال بارگذاری محصولات…',
'myProducts.fields.category': 'دسته‌بندی',
'myProducts.fields.selectCategory': 'انتخاب دسته‌بندی',
'myProducts.fields.searchCategory': 'جستجوی دسته‌بندی…',
'myProducts.fields.clearCategory': 'پاک کردن دسته‌بندی',
'myProducts.fields.noCategories': 'دسته‌بندی‌ای پیدا نشد',
'myProducts.fields.titleFa': 'نام (فارسی)',
'myProducts.fields.titleFaPlaceholder': 'نام محصول به فارسی',
'myProducts.fields.titleEn': 'نام (انگلیسی)',
'myProducts.fields.titleEnPlaceholder': 'نام محصول به انگلیسی',
'myProducts.fields.description': 'توضیحات',
'myProducts.fields.descriptionPlaceholder': 'توضیح کوتاه درباره محصول',
'myProducts.fields.price': 'قیمت پیشنهادی',
'myProducts.fields.priceSuggested': 'قیمت پیشنهادی شما',
'myProducts.fields.priceByExpert': 'می‌خواهم قیمت توسط کارشناس مشخص شود',
'myProducts.fields.pricePlaceholder': 'مثلاً ۱٬۵۰۰٬۰۰۰',
'myProducts.fields.priceUnit': 'واحد',
'myProducts.fields.priceUnit.IRT': 'IRT',
'myProducts.fields.priceUnit.USD': 'Dollar',
'myProducts.fields.priceUnit.EUR': 'EURO',
'myProducts.fields.priceUnit.AED': 'AED',
'myProducts.fields.location': 'موقعیت',
'myProducts.fields.country': 'کشور',
'myProducts.fields.selectCountry': 'انتخاب کشور',
'myProducts.fields.province': 'استان',
'myProducts.fields.selectProvince': 'انتخاب استان',
'myProducts.fields.city': 'شهر',
'myProducts.fields.selectCity': 'انتخاب شهر',
'myProducts.fields.searchCity': 'جستجوی شهر…',
'myProducts.fields.clearCity': 'پاک کردن شهر',
'myProducts.fields.noCities': 'شهری پیدا نشد',
'myProducts.fields.deliveryNote': 'یادداشت تحویل / دریافت',
'myProducts.fields.deliveryNotePlaceholder': 'مثلاً فقط حضوری، تحویل عصر…',
'myProducts.fields.district': 'منطقه',
'myProducts.fields.selectDistrict': 'انتخاب منطقه',
'myProducts.fields.condition': 'وضعیت',
'myProducts.fields.technicalNotes': 'توضیحات فنی',
'myProducts.fields.technicalNotesPlaceholder': 'جزئیات فنی اختیاری…',
'myProducts.condition.new': 'نو',
'myProducts.condition.stock': 'استوک',
'myProducts.condition.needs_repair': 'نیاز به تعمیر',
'myProducts.condition.scrap': 'اوراق',
'myProducts.images.thumbnail': 'تصویر شاخص',
'myProducts.images.thumbnailUpload': 'آپلود تصویر شاخص',
'myProducts.images.thumbnailHint': 'نسبت ۳:۲ افقی بهتر است',
'myProducts.images.thumbnailChange': 'تغییر تصویر شاخص',
'myProducts.images.thumbnailRemove': 'حذف تصویر شاخص',
'myProducts.images.zoom': 'بزرگنمایی',
'myProducts.images.applyCrop': 'اعمال برش',
'myProducts.images.gallery': 'گالری',
'myProducts.images.galleryAdd': 'افزودن عکس',
'myProducts.images.galleryHint': 'می‌توانید چند تصویر انتخاب کنید.',
'myProducts.images.galleryRemove': 'حذف تصویر {index}',
'myProducts.technical.select': 'انتخاب کنید…',
'myProducts.technical.needCategory': 'برای نمایش فیلدهای فنی، در مرحله ۱ دسته‌بندی را انتخاب کنید.',
'myProducts.technical.empty': 'برای این دسته‌بندی هنوز فیلد فنی تعریف نشده است.',
'myProducts.technical.categoryForm': 'فرم فنی دسته‌بندی',
'myProducts.technical.loading': 'در حال بارگذاری فیلدهای فنی…',
'myProducts.cancel': 'انصراف',
'myProducts.next': 'بعدی',
'myProducts.back': 'قبلی',
'myProducts.submit': 'ارسال',
'myProducts.save': 'ذخیره تغییرات',
'myProducts.submitting': 'در حال ارسال…',
'myProducts.submitSuccess': 'محصول برای بررسی ارسال شد.',
'myProducts.updateSuccess': 'محصول به‌روز شد.',
'myProducts.error.load': 'بارگذاری محصولات ممکن نشد.',
'myProducts.error.loadDetail': 'بارگذاری این محصول ممکن نشد.',
'myProducts.error.loadLocations': 'بارگذاری موقعیت‌ها ممکن نشد.',
'myProducts.error.loadCategories': 'بارگذاری دسته‌بندی‌ها ممکن نشد.',
'myProducts.error.loadTechnicalForm': 'بارگذاری فرم فنی دسته‌بندی ممکن نشد.',
'myProducts.error.categoryRequired': 'لطفاً دسته‌بندی را انتخاب کنید.',
'myProducts.error.titleFaRequired': 'لطفاً نام فارسی را وارد کنید.',
'myProducts.error.locationRequired': 'لطفاً کشور و شهر را انتخاب کنید.',
'myProducts.error.priceInvalid': 'لطفاً قیمت معتبر وارد کنید.',
'myProducts.error.conditionRequired': 'لطفاً وضعیت را انتخاب کنید.',
'myProducts.error.technicalRequired': 'لطفاً فیلدهای فنی الزامی را تکمیل کنید.',
'myProducts.error.submit': 'ارسال محصول ممکن نشد.',
'myProducts.error.update': 'به‌روزرسانی محصول ممکن نشد.',
'myProducts.error.remove': 'حذف محصول ممکن نشد.',
'myProducts.error.promote': 'پروموت محصول ممکن نشد.',
'storeItems.price.contact': 'برای قیمت، تماس بگیرید',
'title.signIn': 'ورود',
@@ -533,6 +793,10 @@ export function getCustomerRouteTitleRules(locale: DashboardLocale) {
{ match: '/addresses', labels: [t('nav.addresses')] },
{ match: '/orders', labels: [t('nav.orders')] },
{ match: '/favorites', labels: [t('nav.favorites')] },
{ match: '/my-products/new', labels: [t('nav.myProducts'), t('myProducts.addTitle')] },
{ match: /^\/my-products\/[^/]+\/edit$/, labels: [t('nav.myProducts'), t('myProducts.editTitle')] },
{ match: /^\/my-products\/[^/]+$/, labels: [t('nav.myProducts'), t('myProducts.detailsTitle')] },
{ match: '/my-products', labels: [t('nav.myProducts')] },
{ match: '/', labels: [t('nav.home')] },
]
}
+65
View File
@@ -5,4 +5,69 @@
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'YekanBakh', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
/* Neutral light surfaces (theme color stays on accents only) */
--bg-gradient-start: #f4f5f7;
--bg-gradient-mid: #eef0f3;
--bg-gradient-end: #e8eaee;
--glass-bg: rgba(255, 255, 255, 0.72);
--glass-border: rgba(148, 163, 184, 0.28);
--glass-shadow: 0 8px 32px rgba(15, 23, 42, 0.08);
--surface: rgba(255, 255, 255, 0.82);
--card-media-bg: #ffffff;
--elevated-surface: rgba(255, 255, 255, 0.96);
--icon-bg: color-mix(in srgb, var(--primary) 14%, #ffffff);
--icon-bg-end: color-mix(in srgb, var(--primary) 8%, #f1f5f9);
}
/*
* Dark mode: neutral dark gray backgrounds.
* Brand/theme color (--primary) is for accents only not page/card backgrounds.
*/
html[data-theme='dark'] {
color-scheme: dark;
--bg-gradient-start: #2a2d34;
--bg-gradient-mid: #22252b;
--bg-gradient-end: #1a1d23;
--glass-bg: rgba(45, 49, 57, 0.9);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
--modal-overlay-bg: rgba(0, 0, 0, 0.6);
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-muted: #94a3b8;
--border-color: rgba(255, 255, 255, 0.12);
--surface: rgba(55, 59, 68, 0.95);
--card-media-bg: #32363f;
--elevated-surface: #3a3e48;
--card-hover-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
/* Icon chip: clear primary tint on dark gray (not muddy page wash) */
--icon-bg: rgba(var(--primary-rgb) / 0.22);
--icon-bg-end: rgba(var(--primary-rgb) / 0.12);
}
html[data-theme='dark'] select {
background-color: var(--surface);
color: var(--text-primary);
border-color: var(--border-color);
}
html[data-theme='dark']
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden']),
html[data-theme='dark'] textarea {
background-color: var(--surface);
color: var(--text-primary);
border-color: var(--border-color);
}
/* Soft neutral aura — no theme-color wash on the page */
html[data-theme='dark'] body::before {
background:
radial-gradient(ellipse 520px 520px at 72% 18%, rgba(255, 255, 255, 0.04), transparent 72%),
radial-gradient(ellipse 420px 420px at 22% 82%, rgba(255, 255, 255, 0.03), transparent 72%);
}
+4
View File
@@ -15,5 +15,9 @@ export const customerRouteTitleRules: RouteTitleRule[] = [
{ match: '/addresses', labels: ['My Addresses'] },
{ match: '/orders', labels: ['My Orders'] },
{ match: '/favorites', labels: ['My Favorites'] },
{ match: '/my-products/new', labels: ['My Products', 'Add product'] },
{ match: /^\/my-products\/[^/]+\/edit$/, labels: ['My Products', 'Edit product'] },
{ match: /^\/my-products\/[^/]+$/, labels: ['My Products', 'Product details'] },
{ match: '/my-products', labels: ['My Products'] },
{ match: '/', labels: ['Home'] },
]
@@ -0,0 +1,605 @@
.root {
display: flex;
flex-direction: column;
gap: 0;
font-family: var(--font-ui);
}
.shell {
display: flex;
align-items: stretch;
gap: 12px;
}
.iconRail {
width: 128px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 16px 12px;
border: 1px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius);
background: color-mix(in srgb, var(--glass-bg) 70%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-sizing: border-box;
}
.iconRailInner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
text-align: center;
}
.iconRailGlyph {
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
margin-bottom: 2px;
}
.iconRailLabelFa {
display: block;
width: 100%;
font-size: 12px;
font-weight: 700;
font-family: var(--font-ui);
color: var(--text-primary);
line-height: 1.2;
text-align: center;
}
.iconRailLabelEn {
display: block;
width: 100%;
margin-top: -6px;
font-size: 9px;
font-weight: 700;
font-family: var(--font-en);
color: var(--text-muted);
line-height: 1.25;
letter-spacing: 0.1em;
/* Compensate letter-spacing so centered Latin text doesnt drift */
padding-inline-start: 0.1em;
text-transform: uppercase;
text-align: center;
direction: ltr;
unicode-bidi: isolate;
}
.card {
flex: 1;
min-width: 0;
background: var(--glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
padding: 28px 28px 24px;
}
@media (max-width: 640px) {
.shell {
flex-direction: column;
}
.iconRail {
width: 100%;
min-height: 88px;
}
.card {
padding: 22px 18px 20px;
}
}
.stepper {
display: flex;
align-items: flex-end;
width: 100%;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
}
.stepGroup {
display: flex;
align-items: flex-end;
flex: 1;
min-width: 0;
}
.stepGroup:last-child {
flex: 0 0 auto;
}
.stepUnit {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.stepDot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
background: rgba(148, 163, 184, 0.2);
color: var(--text-muted);
border: 2px solid transparent;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.stepLabel {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
transition: color 0.2s;
}
.stepActive .stepDot {
background: rgba(var(--primary-rgb) / 0.15);
color: var(--primary);
border-color: var(--primary);
}
.stepActive .stepLabel {
color: var(--primary);
}
.stepDone .stepDot {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.stepDone .stepLabel {
color: var(--text-secondary);
}
.connector {
flex: 1;
height: 2px;
min-width: 24px;
margin: 0 8px 13px;
background: rgba(148, 163, 184, 0.3);
border-radius: 1px;
transition: background 0.2s;
}
.connectorDone {
background: var(--primary);
}
.body {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
}
.stepTitle {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.stepDesc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
.form {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 8px;
}
.fieldRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.priceRow {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px;
align-items: end;
}
.checkRow {
display: inline-flex;
align-items: center;
align-self: end;
gap: 8px;
height: var(--field-height);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
box-sizing: border-box;
}
.checkRow input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
flex-shrink: 0;
margin: 0;
}
.fieldRowTriple {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.locationRow {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px;
align-items: end;
}
.col2 {
grid-column: span 2;
}
.col3 {
grid-column: span 3;
}
.col4 {
grid-column: span 4;
}
.col6 {
grid-column: span 6;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.optional {
font-weight: 400;
color: var(--text-muted);
}
.field input,
.field select,
.field textarea {
width: 100%;
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.field input,
.field select {
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
}
.field textarea {
padding: var(--field-padding-y) var(--field-padding-x);
resize: vertical;
min-height: 96px;
line-height: 1.5;
}
.field input::placeholder,
.field textarea::placeholder {
font-family: var(--font-ui);
color: var(--text-muted);
opacity: 1;
}
.field select {
appearance: none;
-webkit-appearance: none;
padding-inline-end: var(--select-padding-end);
background-color: var(--surface);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--select-arrow-offset) center;
background-size: var(--select-arrow-size);
cursor: pointer;
}
:global([dir='rtl']) .field select {
background-position: left var(--select-arrow-offset) center;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.sectionDivider {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sectionDivider::before,
.sectionDivider::after {
content: '';
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.25);
}
.thumbnailBlock {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.thumbnailBlock > .field {
grid-column: 5 / span 4;
}
@media (max-width: 720px) {
.thumbnailBlock > .field {
grid-column: 1 / -1;
}
}
.chipGrid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
padding: 7px 14px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-secondary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 50px;
transition: all 0.15s;
}
.chip:hover {
border-color: var(--primary);
color: var(--primary);
}
.chipSelected {
background: rgba(var(--primary-rgb) / 0.12);
border-color: var(--primary);
color: var(--primary);
}
.conditionFieldset {
margin: 0;
padding: 0;
border: none;
}
.conditionFieldset legend {
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.radioGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.radioCard {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: var(--field-height);
padding: var(--field-padding-y) 8px;
font-size: 12px;
font-family: var(--font-ui);
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
text-align: center;
}
.radioCard:hover {
border-color: var(--primary);
}
.radioCard:has(input:checked) {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.16);
}
.radioCard input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary);
flex-shrink: 0;
}
.radioCard span {
min-width: 0;
line-height: 1.25;
}
@media (max-width: 720px) {
.radioGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.inlineStatus {
margin: 4px 0 0;
font-size: 13px;
color: var(--text-muted);
}
.placeholder {
margin-top: 8px;
padding: 28px 20px;
text-align: center;
font-size: 13px;
color: var(--text-muted);
background: color-mix(in srgb, var(--surface) 70%, transparent);
border: 1px dashed var(--glass-border);
border-radius: var(--radius-sm);
}
.error {
margin-top: 8px;
font-size: 13px;
color: #fca5a5;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.28);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.ghostBtn,
.secondaryBtn,
.primaryBtn {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 600;
line-height: 1.4;
border-radius: var(--radius-sm);
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s, background 0.2s, color 0.2s;
white-space: nowrap;
}
.ghostBtn {
padding: 10px 4px;
color: var(--text-secondary);
text-decoration: none;
background: transparent;
}
.ghostBtn:hover {
color: var(--primary);
}
.secondaryBtn {
padding: 10px 18px;
color: var(--text-primary);
background: rgba(148, 163, 184, 0.16);
border: 1px solid var(--glass-border);
}
.secondaryBtn:hover {
background: rgba(148, 163, 184, 0.24);
}
.primaryBtn {
margin-inline-start: auto;
padding: 10px 22px;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
}
.primaryBtn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
}
@media (max-width: 720px) {
.fieldRow,
.fieldRowTriple,
.priceRow,
.locationRow {
grid-template-columns: 1fr;
}
.col2,
.col3,
.col4,
.col6 {
grid-column: auto;
}
}
@media (max-width: 520px) {
.stepLabel {
font-size: 11px;
}
.connector {
min-width: 12px;
margin: 0 4px 13px;
}
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.primaryBtn,
.secondaryBtn,
.ghostBtn {
width: 100%;
margin-inline-start: 0;
text-align: center;
}
}
@@ -0,0 +1,872 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { Check, ClipboardList, Images, SlidersHorizontal } from 'lucide-react'
import {
getLocationOptionLabel,
useLocale,
useToast,
type CityOption,
} from '@meshkee/dashboard-ui'
import { CitySearchSelect } from '../components/CitySearchSelect'
import { CategorySearchSelect } from '../components/CategorySearchSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { useT } from '../i18n/useT'
import { translate } from '../i18n/messages'
import { ApiError, isAbortError } from '../lib/api'
import {
listCitiesByCountrySlug,
listCountries,
} from '../services/citiesService'
import {
buildTechnicalValuesPayload,
createMyUserProduct,
getMyUserProduct,
getMyUserProductCategoryTechnicalForm,
listMyUserProductCategories,
updateMyUserProduct,
type TechnicalFormField,
type TechnicalFormValues,
type UserProductCategoryOption,
type UserProductCondition,
type UserProductPriceCurrency,
type UserProductTechnicalValueInput,
} from '../services/userProductsService'
import { resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import styles from './AddMyProductPage.module.css'
type StepId = 1 | 2 | 3
const PRICE_UNITS: UserProductPriceCurrency[] = ['IRT', 'USD', 'EUR', 'AED']
const CONDITIONS: UserProductCondition[] = [
'new',
'stock',
'needs_repair',
'scrap',
]
function fieldLabel(field: TechnicalFormField) {
return field.label
}
function optionLabel(option: TechnicalFormField['options'][number]) {
return option.label
}
function mapTechnicalValues(
items: UserProductTechnicalValueInput[],
): TechnicalFormValues {
const values: TechnicalFormValues = {}
for (const item of items) {
if (item.textValue != null) {
values[item.fieldId] = item.textValue
continue
}
if (item.optionId) {
values[item.fieldId] = item.optionId
continue
}
if (item.optionIds?.length) {
values[item.fieldId] = item.optionIds
}
}
return values
}
function isCondition(value: string | null | undefined): value is UserProductCondition {
return (
value === 'new' ||
value === 'stock' ||
value === 'needs_repair' ||
value === 'scrap'
)
}
function isPriceCurrency(
value: string | null | undefined,
): value is UserProductPriceCurrency {
return (
value === 'IRT' || value === 'USD' || value === 'EUR' || value === 'AED'
)
}
export function AddMyProductPage() {
const t = useT()
const { locale } = useLocale()
const navigate = useNavigate()
const { id } = useParams<{ id?: string }>()
const isEdit = Boolean(id)
const { showToast } = useToast()
const [loaded, setLoaded] = useState(!isEdit)
const [step, setStep] = useState<StepId>(1)
const [stepError, setStepError] = useState('')
const [submitting, setSubmitting] = useState(false)
const pendingTechnicalValuesRef = useRef<TechnicalFormValues | null>(null)
const [categories, setCategories] = useState<UserProductCategoryOption[]>([])
const [loadingCategories, setLoadingCategories] = useState(false)
const [categoryId, setCategoryId] = useState('')
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [description, setDescription] = useState('')
const [priceInput, setPriceInput] = useState('')
const [priceUnit, setPriceUnit] = useState<UserProductPriceCurrency>('IRT')
const [priceByExpert, setPriceByExpert] = useState(false)
const [countries, setCountries] = useState<CityOption[]>([])
const [cities, setCities] = useState<CityOption[]>([])
const [countrySlug, setCountrySlug] = useState('')
const [countryId, setCountryId] = useState('')
const [cityId, setCityId] = useState('')
const [deliveryNote, setDeliveryNote] = useState('')
const [loadingLocations, setLoadingLocations] = useState(false)
const [thumbnail, setThumbnail] = useState<string | null>(null)
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [gallery, setGallery] = useState<string[]>([])
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [condition, setCondition] = useState<UserProductCondition>('new')
const [technicalNotes, setTechnicalNotes] = useState('')
const [technicalFields, setTechnicalFields] = useState<TechnicalFormField[]>([])
const [technicalValues, setTechnicalValues] = useState<TechnicalFormValues>({})
const [loadingTechnicalForm, setLoadingTechnicalForm] = useState(false)
const steps: { id: StepId; label: string }[] = [
{ id: 1, label: t('myProducts.step.basics') },
{ id: 2, label: t('myProducts.step.images') },
{ id: 3, label: t('myProducts.step.technical') },
]
const stepIcon =
step === 1 ? (
<ClipboardList size={40} strokeWidth={1.4} />
) : step === 2 ? (
<Images size={40} strokeWidth={1.4} />
) : (
<SlidersHorizontal size={40} strokeWidth={1.4} />
)
const stepLabelKey =
step === 1
? 'myProducts.step.basics'
: step === 2
? 'myProducts.step.images'
: 'myProducts.step.technical'
const stepNameFa = translate('fa', stepLabelKey)
const stepNameEn = translate('en', stepLabelKey)
useEffect(() => {
const controller = new AbortController()
setLoadingLocations(true)
void listCountries(controller.signal)
.then((items) => {
if (!controller.signal.aborted) setCountries(items)
})
.catch((err) => {
if (!isAbortError(err)) setStepError(t('myProducts.error.loadLocations'))
})
.finally(() => {
if (!controller.signal.aborted) setLoadingLocations(false)
})
return () => controller.abort()
}, [t])
useEffect(() => {
const controller = new AbortController()
setLoadingCategories(true)
void listMyUserProductCategories(controller.signal)
.then((response) => {
if (!controller.signal.aborted) setCategories(response.items)
})
.catch((err) => {
if (!isAbortError(err)) setStepError(t('myProducts.error.loadCategories'))
})
.finally(() => {
if (!controller.signal.aborted) setLoadingCategories(false)
})
return () => controller.abort()
}, [t])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadProduct() {
setStepError('')
try {
const response = await getMyUserProduct(id!, controller.signal)
if (controller.signal.aborted) return
const product = response.product
pendingTechnicalValuesRef.current = mapTechnicalValues(
product.technicalValues ?? [],
)
setCategoryId(product.categoryId ?? '')
setTitleFa(product.titleFa ?? product.title ?? '')
setTitleEn(product.titleEn ?? '')
setDescription(product.description ?? '')
setPriceInput(
product.price != null ? formatIrtInput(String(product.price)) : '',
)
setPriceUnit(
isPriceCurrency(product.priceCurrency)
? product.priceCurrency
: 'IRT',
)
setPriceByExpert(product.priceByExpert === true)
setCountrySlug(product.countrySlug)
setCountryId(product.countryId)
setCityId(product.cityId)
setDeliveryNote(product.deliveryNote ?? '')
setThumbnail(product.imageUrl)
setFeaturedMediaId(product.featuredMediaId)
setGallery((product.images ?? []).map((item) => item.url))
setGalleryMediaIds(product.galleryMediaIds ?? [])
setCondition(isCondition(product.condition) ? product.condition : 'new')
setTechnicalNotes(product.technicalNotes ?? '')
if (product.countrySlug) {
setCities(await listCitiesByCountrySlug(product.countrySlug))
}
if (!controller.signal.aborted) setLoaded(true)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
showToast(
err instanceof ApiError ? err.message : t('myProducts.error.load'),
'error',
)
navigate('/my-products', { replace: true })
}
}
void loadProduct()
return () => controller.abort()
}, [isEdit, id, navigate, showToast, t])
useEffect(() => {
if (!pendingTechnicalValuesRef.current) {
setTechnicalValues({})
}
setTechnicalFields([])
if (!categoryId) return
const controller = new AbortController()
setLoadingTechnicalForm(true)
void getMyUserProductCategoryTechnicalForm(categoryId, controller.signal)
.then((response) => {
if (controller.signal.aborted) return
setTechnicalFields(response.form?.fields ?? [])
if (pendingTechnicalValuesRef.current) {
setTechnicalValues(pendingTechnicalValuesRef.current)
pendingTechnicalValuesRef.current = null
}
})
.catch((err) => {
if (!isAbortError(err)) {
setTechnicalFields([])
setStepError(t('myProducts.error.loadTechnicalForm'))
}
})
.finally(() => {
if (!controller.signal.aborted) setLoadingTechnicalForm(false)
})
return () => controller.abort()
}, [categoryId, t])
async function handleCountryChange(nextSlug: string) {
setCountrySlug(nextSlug)
setCountryId(countries.find((item) => item.slug === nextSlug)?.id ?? '')
setCityId('')
setCities([])
if (!nextSlug) return
setLoadingLocations(true)
try {
setCities(await listCitiesByCountrySlug(nextSlug))
} catch {
setStepError(t('myProducts.error.loadLocations'))
} finally {
setLoadingLocations(false)
}
}
function validateStep1() {
if (!categoryId) return t('myProducts.error.categoryRequired')
if (!titleFa.trim()) return t('myProducts.error.titleFaRequired')
if (!countryId || !cityId) {
return t('myProducts.error.locationRequired')
}
const price = parseIrtInput(priceInput)
if (priceInput.trim() && (price === null || price < 0)) {
return t('myProducts.error.priceInvalid')
}
return ''
}
function validateStep3() {
if (!condition) return t('myProducts.error.conditionRequired')
for (const field of technicalFields) {
if (!field.isRequired) continue
const value = technicalValues[field.id]
const ok =
field.type === 'multi_select'
? Array.isArray(value) && value.length > 0
: typeof value === 'string' && value.trim().length > 0
if (!ok) {
return t('myProducts.error.technicalRequired')
}
}
return ''
}
function goNext() {
setStepError('')
if (step === 1) {
const error = validateStep1()
if (error) {
setStepError(error)
return
}
setStep(2)
return
}
if (step === 2) setStep(3)
}
function goBack() {
setStepError('')
if (step === 2) setStep(1)
if (step === 3) setStep(2)
}
async function handleSubmit() {
setStepError('')
const validationError = validateStep3()
if (validationError) {
setStepError(validationError)
return
}
const price = parseIrtInput(priceInput)
setSubmitting(true)
try {
let nextFeaturedMediaId = featuredMediaId
if (thumbnail?.startsWith('data:')) {
nextFeaturedMediaId = await resolveDataUrlToMediaId(
thumbnail,
'user-product-thumbnail.jpg',
featuredMediaId,
)
} else if (!thumbnail) {
nextFeaturedMediaId = null
}
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(
gallery,
galleryMediaIds,
)
const payload = {
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
description: description.trim() || undefined,
categoryId,
price: price ?? undefined,
priceCurrency: priceUnit,
priceByExpert,
countryId,
cityId,
deliveryNote: deliveryNote.trim() || undefined,
condition,
technicalNotes: technicalNotes.trim() || undefined,
technicalValues: buildTechnicalValuesPayload(technicalFields, technicalValues),
featuredMediaId: nextFeaturedMediaId || undefined,
galleryMediaIds: nextGalleryMediaIds,
}
if (isEdit && id) {
await updateMyUserProduct(id, payload)
showToast(t('myProducts.updateSuccess'), 'success')
} else {
await createMyUserProduct(payload)
showToast(t('myProducts.submitSuccess'), 'success')
}
navigate('/my-products')
} catch (err) {
setStepError(
err instanceof ApiError
? err.message
: isEdit
? t('myProducts.error.update')
: t('myProducts.error.submit'),
)
} finally {
setSubmitting(false)
}
}
function setTechnicalField(fieldId: string, value: string | string[]) {
setTechnicalValues((prev) => ({ ...prev, [fieldId]: value }))
}
function toggleMultiOption(fieldId: string, optionId: string) {
setTechnicalValues((prev) => {
const current = prev[fieldId]
const selected = Array.isArray(current) ? current : []
const next = selected.includes(optionId)
? selected.filter((item) => item !== optionId)
: [...selected, optionId]
return { ...prev, [fieldId]: next }
})
}
function renderTechnicalField(field: TechnicalFormField) {
const value = technicalValues[field.id]
const id = `tech-${field.id}`
if (field.type === 'textarea') {
return (
<textarea
id={id}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
rows={4}
/>
)
}
if (field.type === 'select') {
return (
<select
id={id}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
>
<option value="">{t('myProducts.technical.select')}</option>
{field.options.map((option) => (
<option key={option.id} value={option.id}>
{optionLabel(option)}
</option>
))}
</select>
)
}
if (field.type === 'multi_select') {
const selected = Array.isArray(value) ? value : []
return (
<div className={styles.chipGrid}>
{field.options.map((option) => (
<button
key={option.id}
type="button"
className={`${styles.chip} ${selected.includes(option.id) ? styles.chipSelected : ''}`}
onClick={() => toggleMultiOption(field.id, option.id)}
>
{optionLabel(option)}
</button>
))}
</div>
)
}
return (
<input
id={id}
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => setTechnicalField(field.id, e.target.value)}
/>
)
}
if (!loaded) {
return <p className={styles.inlineStatus}>{t('myProducts.loading')}</p>
}
return (
<div className={styles.shell}>
<aside className={styles.iconRail} aria-hidden>
<div className={styles.iconRailInner}>
<span className={styles.iconRailGlyph}>{stepIcon}</span>
<span className={styles.iconRailLabelFa}>{stepNameFa}</span>
<span className={styles.iconRailLabelEn}>{stepNameEn}</span>
</div>
</aside>
<div className={styles.card}>
<div className={styles.root}>
<nav className={styles.stepper} aria-label={t('myProducts.stepperLabel')}>
{steps.map((item, index) => {
const isDone = step > item.id
const isActive = step === item.id
const connectorDone = step > item.id
return (
<div key={item.id} className={styles.stepGroup}>
<div
className={[
styles.stepUnit,
isActive ? styles.stepActive : '',
isDone ? styles.stepDone : '',
]
.filter(Boolean)
.join(' ')}
>
<span className={styles.stepLabel}>{item.label}</span>
<span className={styles.stepDot} aria-hidden>
{isDone ? <Check size={14} /> : index + 1}
</span>
</div>
{index < steps.length - 1 && (
<div
className={[
styles.connector,
connectorDone ? styles.connectorDone : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden
/>
)}
</div>
)
})}
</nav>
<div className={styles.body} key={step}>
{step === 1 && (
<>
<h2 className={styles.stepTitle}>
{isEdit ? t('myProducts.editTitle') : t('myProducts.step.basics')}
</h2>
<p className={styles.stepDesc}>
{isEdit
? t('myProducts.editSubtitle')
: t('myProducts.step.basicsHint')}
</p>
<div className={styles.form}>
<div className={styles.field}>
<label htmlFor="add-category">{t('myProducts.fields.category')}</label>
<CategorySearchSelect
id="add-category"
options={categories}
value={categoryId}
onChange={setCategoryId}
disabled={loadingCategories}
placeholder={t('myProducts.fields.searchCategory')}
/>
</div>
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="add-title-fa">{t('myProducts.fields.titleFa')}</label>
<input
id="add-title-fa"
type="text"
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
placeholder={t('myProducts.fields.titleFaPlaceholder')}
/>
</div>
<div className={styles.field}>
<label htmlFor="add-title-en">{t('myProducts.fields.titleEn')}</label>
<input
id="add-title-en"
type="text"
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
placeholder={t('myProducts.fields.titleEnPlaceholder')}
dir="ltr"
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="add-description">{t('myProducts.fields.description')}</label>
<textarea
id="add-description"
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('myProducts.fields.descriptionPlaceholder')}
/>
</div>
<div className={styles.priceRow}>
<label
className={`${styles.checkRow} ${styles.col6}`}
htmlFor="add-price-by-expert"
>
<input
id="add-price-by-expert"
type="checkbox"
checked={priceByExpert}
onChange={(e) => setPriceByExpert(e.target.checked)}
/>
<span>{t('myProducts.fields.priceByExpert')}</span>
</label>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="add-price">
{priceByExpert
? t('myProducts.fields.priceSuggested')
: t('myProducts.fields.price')}
</label>
<input
id="add-price"
type="text"
inputMode="numeric"
value={priceInput}
onChange={(e) => setPriceInput(formatIrtInput(e.target.value))}
placeholder={t('myProducts.fields.pricePlaceholder')}
dir="ltr"
/>
</div>
<div className={`${styles.field} ${styles.col2}`}>
<label htmlFor="add-price-unit">{t('myProducts.fields.priceUnit')}</label>
<select
id="add-price-unit"
value={priceUnit}
onChange={(e) =>
setPriceUnit(e.target.value as UserProductPriceCurrency)
}
>
{PRICE_UNITS.map((unit) => (
<option key={unit} value={unit}>
{t(`myProducts.fields.priceUnit.${unit}`)}
</option>
))}
</select>
</div>
</div>
<div className={styles.sectionDivider}>
<span>{t('myProducts.fields.location')}</span>
</div>
<div className={styles.locationRow}>
<div className={`${styles.field} ${styles.col2}`}>
<label htmlFor="add-country">{t('myProducts.fields.country')}</label>
<select
id="add-country"
value={countrySlug}
disabled={loadingLocations}
onChange={(e) => void handleCountryChange(e.target.value)}
>
<option value="">{t('myProducts.fields.selectCountry')}</option>
{countries.map((country) => (
<option key={country.id} value={country.slug}>
{getLocationOptionLabel(country, locale)}
</option>
))}
</select>
</div>
<div className={`${styles.field} ${styles.col4}`}>
<label htmlFor="add-city">{t('myProducts.fields.city')}</label>
<CitySearchSelect
id="add-city"
options={cities}
value={cityId}
onChange={setCityId}
disabled={!countrySlug || loadingLocations}
placeholder={t('myProducts.fields.searchCity')}
/>
</div>
<div className={`${styles.field} ${styles.col6}`}>
<label htmlFor="add-delivery-note">
{t('myProducts.fields.deliveryNote')}
<span className={styles.optional}> {t('myProducts.optional')}</span>
</label>
<input
id="add-delivery-note"
type="text"
value={deliveryNote}
onChange={(e) => setDeliveryNote(e.target.value)}
placeholder={t('myProducts.fields.deliveryNotePlaceholder')}
/>
</div>
</div>
</div>
</>
)}
{step === 2 && (
<>
<h2 className={styles.stepTitle}>{t('myProducts.step.images')}</h2>
<p className={styles.stepDesc}>{t('myProducts.step.imagesHint')}</p>
<div className={styles.form}>
<div className={styles.thumbnailBlock}>
<div className={styles.field}>
<label>{t('myProducts.images.thumbnail')}</label>
<ImageCropper
value={thumbnail}
onChange={(value) => {
setThumbnail(value)
if (!value || value.startsWith('data:')) {
setFeaturedMediaId(null)
}
}}
aspect={1}
hint={t('myProducts.images.thumbnailHint')}
/>
</div>
</div>
<div className={styles.sectionDivider}>
<span>{t('myProducts.images.gallery')}</span>
</div>
<div className={styles.field}>
<ImageUploader
images={gallery}
onChange={(next) => {
const prevUrlToId = new Map(
gallery.map((url, index) => [
url,
galleryMediaIds[index] ?? '',
]),
)
setGallery(next)
setGalleryMediaIds(
next.map((url) =>
url.startsWith('data:')
? ''
: prevUrlToId.get(url) ?? '',
),
)
}}
/>
</div>
</div>
</>
)}
{step === 3 && (
<>
<h2 className={styles.stepTitle}>{t('myProducts.step.technical')}</h2>
<p className={styles.stepDesc}>{t('myProducts.step.technicalHint')}</p>
<div className={styles.form}>
<fieldset className={styles.conditionFieldset}>
<legend>{t('myProducts.fields.condition')}</legend>
<div className={styles.radioGrid} role="radiogroup">
{CONDITIONS.map((value) => (
<label key={value} className={styles.radioCard}>
<input
type="radio"
name="product-condition"
value={value}
checked={condition === value}
onChange={() => setCondition(value)}
/>
<span>{t(`myProducts.condition.${value}`)}</span>
</label>
))}
</div>
</fieldset>
<div className={styles.field}>
<label htmlFor="add-technical-notes">
{t('myProducts.fields.technicalNotes')}
<span className={styles.optional}> {t('myProducts.optional')}</span>
</label>
<textarea
id="add-technical-notes"
rows={4}
value={technicalNotes}
onChange={(e) => setTechnicalNotes(e.target.value)}
placeholder={t('myProducts.fields.technicalNotesPlaceholder')}
/>
</div>
<div className={styles.sectionDivider}>
<span>{t('myProducts.technical.categoryForm')}</span>
</div>
{!categoryId ? (
<div className={styles.placeholder}>
{t('myProducts.technical.needCategory')}
</div>
) : loadingTechnicalForm ? (
<p className={styles.inlineStatus}>{t('myProducts.technical.loading')}</p>
) : technicalFields.length === 0 ? (
<div className={styles.placeholder}>{t('myProducts.technical.empty')}</div>
) : (
technicalFields.map((field) => (
<div key={field.id} className={styles.field}>
<label htmlFor={`tech-${field.id}`}>
{fieldLabel(field)}
{field.isRequired ? (
' *'
) : (
<span className={styles.optional}> {t('myProducts.optional')}</span>
)}
</label>
{renderTechnicalField(field)}
</div>
))
)}
</div>
</>
)}
{stepError ? (
<div className={styles.error} role="alert">
{stepError}
</div>
) : null}
</div>
<div className={styles.actions}>
{step === 1 ? (
<Link to="/my-products" className={styles.ghostBtn}>
{t('myProducts.cancel')}
</Link>
) : (
<button
type="button"
className={styles.secondaryBtn}
onClick={goBack}
disabled={submitting}
>
{t('myProducts.back')}
</button>
)}
{step < 3 ? (
<button type="button" className={styles.primaryBtn} onClick={goNext}>
{t('myProducts.next')}
</button>
) : (
<button
type="button"
className={styles.primaryBtn}
onClick={() => void handleSubmit()}
disabled={submitting || loadingTechnicalForm}
>
{submitting
? t('myProducts.submitting')
: isEdit
? t('myProducts.save')
: t('myProducts.submit')}
</button>
)}
</div>
</div>
</div>
</div>
)
}
@@ -54,7 +54,7 @@
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.55);
background: var(--elevated-surface);
}
.rowLine {
+124 -33
View File
@@ -1,49 +1,129 @@
import { CalendarDays, User, MapPin, ShoppingBag, Heart } from 'lucide-react'
import { useEffect, useState } from 'react'
import { CalendarDays, User, MapPin, ShoppingBag, Heart, Package } from 'lucide-react'
import { SectionCard, useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { useT } from '../i18n/useT'
import type { CustomerMessageKey } from '../i18n/messages'
import { listAddresses } from '../services/addressService'
import { listFavorites } from '../services/favoritesService'
import { listOrders } from '../services/orderService'
import { listMyUserProducts } from '../services/userProductsService'
import { hasBusinessModule } from '../utils/businessModules'
import styles from '../components/PageContent.module.css'
type CountKey = 'myProducts' | 'addresses' | 'orders' | 'favorites'
type HomeSection = {
icon: typeof User
titleKey: CustomerMessageKey
descKey: CustomerMessageKey
countLabelKey?: CustomerMessageKey
href: string
countKey?: CountKey
colClass: string
moduleGated?: boolean
}
const baseSections: HomeSection[] = [
{
icon: Package,
titleKey: 'home.card.myProducts.title',
descKey: 'home.card.myProducts.desc',
countLabelKey: 'home.card.myProducts.count',
href: '/my-products',
countKey: 'myProducts',
colClass: styles.col6,
moduleGated: true,
},
{
icon: User,
titleKey: 'home.card.profile.title',
descKey: 'home.card.profile.desc',
href: '/profile',
colClass: styles.col3,
},
{
icon: MapPin,
titleKey: 'home.card.addresses.title',
descKey: 'home.card.addresses.desc',
countLabelKey: 'home.card.addresses.count',
href: '/addresses',
countKey: 'addresses',
colClass: styles.col3,
},
{
icon: ShoppingBag,
titleKey: 'home.card.orders.title',
descKey: 'home.card.orders.desc',
countLabelKey: 'home.card.orders.count',
href: '/orders',
countKey: 'orders',
colClass: styles.col3,
},
{
icon: Heart,
titleKey: 'home.card.favorites.title',
descKey: 'home.card.favorites.desc',
countLabelKey: 'home.card.favorites.count',
href: '/favorites',
countKey: 'favorites',
colClass: styles.col3,
},
]
type SectionCounts = Partial<Record<CountKey, number>>
async function loadSectionCounts(signal: AbortSignal): Promise<SectionCounts> {
const [myProducts, addresses, orders, favorites] = await Promise.all([
listMyUserProducts({ page: 1, pageSize: 1 }, signal)
.then((r) => r.total)
.catch(() => null),
listAddresses(signal)
.then((r) => r.items.length)
.catch(() => null),
listOrders({ page: 1, pageSize: 1 }, signal)
.then((r) => r.total)
.catch(() => null),
listFavorites({ page: 1, pageSize: 1 }, signal)
.then((r) => r.total)
.catch(() => null),
])
const counts: SectionCounts = {}
if (myProducts !== null) counts.myProducts = myProducts
if (addresses !== null) counts.addresses = addresses
if (orders !== null) counts.orders = orders
if (favorites !== null) counts.favorites = favorites
return counts
}
export function HomePage() {
const { user } = useAuth()
const { locale } = useLocale()
const { enabledModules } = useTenantBranding()
const t = useT()
const [counts, setCounts] = useState<SectionCounts>({})
const showMyProducts = hasBusinessModule(enabledModules, 'customer_products')
useEffect(() => {
const controller = new AbortController()
void loadSectionCounts(controller.signal).then((next) => {
if (!controller.signal.aborted) setCounts(next)
})
return () => controller.abort()
}, [])
const firstName =
(locale === 'en' ? user?.firstNameEn : user?.firstName) ||
user?.firstName ||
user?.firstNameEn ||
t('home.welcomeFallback')
const sections = [
{
icon: User,
title: t('home.card.profile.title'),
description: t('home.card.profile.desc'),
linkText: t('home.card.profile.link'),
href: '/profile',
},
{
icon: MapPin,
title: t('home.card.addresses.title'),
description: t('home.card.addresses.desc'),
linkText: t('home.card.addresses.link'),
href: '/addresses',
},
{
icon: ShoppingBag,
title: t('home.card.orders.title'),
description: t('home.card.orders.desc'),
linkText: t('home.card.orders.link'),
href: '/orders',
},
{
icon: Heart,
title: t('home.card.favorites.title'),
description: t('home.card.favorites.desc'),
linkText: t('home.card.favorites.link'),
href: '/favorites',
},
]
const sections = baseSections.filter(
(section) => !section.moduleGated || showMyProducts,
)
const formattedDate = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
month: 'long',
@@ -68,9 +148,20 @@ export function HomePage() {
</div>
</div>
<div className={styles.gridHome}>
<div className={styles.grid12}>
{sections.map((section) => (
<SectionCard key={section.href} {...section} />
<div key={section.href} className={section.colClass}>
<SectionCard
icon={section.icon}
title={t(section.titleKey)}
description={t(section.descKey)}
href={section.href}
count={section.countKey ? counts[section.countKey] : undefined}
countLabel={
section.countLabelKey ? t(section.countLabelKey) : undefined
}
/>
</div>
))}
</div>
</main>
+4 -4
View File
@@ -15,12 +15,12 @@
width: 100%;
max-width: 420px;
padding: 36px 32px 32px;
background: rgba(255, 255, 255, 0.75);
background: var(--glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
box-shadow: var(--glass-shadow);
}
.brand {
@@ -148,8 +148,8 @@
line-height: 1.4;
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(148, 163, 184, 0.35);
background: var(--surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
@@ -0,0 +1,327 @@
.headerRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}
.editBtn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--field-height);
padding: var(--field-padding-y) 14px;
border-radius: var(--radius-sm);
font-size: var(--field-font-size);
font-weight: 600;
font-family: var(--font-ui);
color: #fff;
background: var(--primary);
white-space: nowrap;
flex-shrink: 0;
}
.editBtn:hover {
filter: brightness(1.05);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 320px) 1fr;
gap: 28px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
padding: 24px;
}
.gallery {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 320px;
}
.mainImage {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.15);
overflow: hidden;
background: var(--card-media-bg, rgba(148, 163, 184, 0.12));
}
.galleryThumbs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.galleryThumb {
aspect-ratio: 1 / 1;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.15);
overflow: hidden;
background: var(--card-media-bg, rgba(148, 163, 184, 0.12));
}
.galleryThumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.imagePlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(148, 163, 184, 0.75);
}
.badge {
position: absolute;
top: 10px;
inset-inline-start: 10px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 50px;
border: 1px solid rgba(255, 255, 255, 0.22);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
z-index: 1;
}
.badge[data-status='draft'] {
color: #fff;
background: linear-gradient(
145deg,
rgba(251, 191, 36, 0.55) 0%,
rgba(245, 158, 11, 0.32) 100%
);
}
.badge[data-status='published'] {
color: #047857;
background: linear-gradient(
145deg,
rgba(167, 243, 208, 0.55) 0%,
rgba(52, 211, 153, 0.28) 100%
);
}
.badge[data-status='archived'] {
color: #e2e8f0;
background: linear-gradient(
145deg,
rgba(148, 163, 184, 0.45) 0%,
rgba(100, 116, 139, 0.28) 100%
);
}
.promotedBadge {
position: absolute;
top: 10px;
inset-inline-end: 10px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
color: #fff;
border-radius: 50px;
background: linear-gradient(
145deg,
rgba(99, 102, 241, 0.7) 0%,
rgba(168, 85, 247, 0.45) 100%
);
z-index: 1;
}
.details {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.categoryChip {
align-self: flex-start;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.15);
border-radius: 50px;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
font-family: var(--font-ui);
}
.secondary {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.summaryRow {
display: flex;
align-items: center;
gap: 12px 16px;
flex-wrap: wrap;
direction: ltr;
}
.price {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.summaryMeta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 14px;
margin-inline-start: auto;
min-width: 0;
}
.metaItem {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-ui);
min-width: 0;
}
.metaGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px 16px;
margin: 4px 0 0;
}
.metaGrid dt {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 4px;
}
.metaGrid dd {
margin: 0;
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-ui);
}
.location {
display: inline-flex;
align-items: center;
gap: 6px;
}
.section {
padding-top: 8px;
border-top: 1px solid rgba(148, 163, 184, 0.15);
}
.section h3 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.prose {
margin: 0;
font-size: 13px;
line-height: 1.65;
color: var(--text-primary);
white-space: pre-wrap;
font-family: var(--font-ui);
}
.techGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin: 0;
}
.techGrid dt {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 4px;
}
.techGrid dd {
margin: 0;
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-ui);
}
.status,
.error {
margin: 24px 0;
font-size: 14px;
color: var(--text-secondary);
}
.error {
color: #ef4444;
}
.backLink {
color: var(--primary);
font-weight: 600;
text-decoration: none;
}
.backLink:hover {
text-decoration: underline;
}
@media (max-width: 800px) {
.headerRow {
flex-direction: column;
}
.layout {
grid-template-columns: 1fr;
padding: 16px;
}
.gallery {
max-width: none;
}
}
@@ -0,0 +1,285 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ImageOff, MapPin, Pencil } from 'lucide-react'
import { Breadcrumbs, useLocale, useToast } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
getMyUserProduct,
getMyUserProductCategoryTechnicalForm,
type TechnicalFormField,
type UserProductDetail,
type UserProductTechnicalValueInput,
} from '../services/userProductsService'
import { formatIrtPrice } from '../utils/irtPrice'
import pageStyles from '../components/PageContent.module.css'
import styles from './MyProductDetailsPage.module.css'
function formatTechnicalValue(
field: TechnicalFormField,
value: UserProductTechnicalValueInput | undefined,
): string {
if (!value) return '—'
if (value.textValue != null && value.textValue.trim()) return value.textValue
if (value.optionId) {
return field.options.find((option) => option.id === value.optionId)?.label ?? value.optionId
}
if (value.optionIds?.length) {
return value.optionIds
.map(
(optionId) =>
field.options.find((option) => option.id === optionId)?.label ?? optionId,
)
.join(', ')
}
return '—'
}
export function MyProductDetailsPage() {
const { id } = useParams<{ id: string }>()
const t = useT()
const { locale } = useLocale()
const navigate = useNavigate()
const { showToast } = useToast()
const isFa = locale === 'fa'
const [product, setProduct] = useState<UserProductDetail | null>(null)
const [technicalFields, setTechnicalFields] = useState<TechnicalFormField[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
if (!id) return
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const response = await getMyUserProduct(id!, controller.signal)
if (controller.signal.aborted) return
setProduct(response.product)
if (response.product.categoryId) {
const formResponse = await getMyUserProductCategoryTechnicalForm(
response.product.categoryId,
controller.signal,
)
if (controller.signal.aborted) return
setTechnicalFields(formResponse.form?.fields ?? [])
} else {
setTechnicalFields([])
}
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
const message =
err instanceof ApiError ? err.message : t('myProducts.error.loadDetail')
setError(message)
setProduct(null)
showToast(message, 'error')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => controller.abort()
}, [id, showToast, t])
const technicalRows = useMemo(() => {
if (!product) return []
const byField = new Map(
(product.technicalValues ?? []).map((item) => [item.fieldId, item]),
)
return technicalFields.map((field) => ({
id: field.id,
label: field.label,
value: formatTechnicalValue(field, byField.get(field.id)),
}))
}, [product, technicalFields])
if (loading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>{t('myProducts.loading')}</p>
</main>
)
}
if (error || !product) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || t('myProducts.error.loadDetail')}</p>
<Link to="/my-products" className={styles.backLink}>
{t('myProducts.backToList')}
</Link>
</main>
)
}
const titleFa = product.titleFa || product.title
const titleEn = product.titleEn?.trim() || ''
const title = isFa ? titleFa : titleEn || titleFa
const secondary = isFa ? titleEn : titleEn ? titleFa : ''
const city = isFa ? product.cityNameFa || product.cityName : product.cityName
const country = isFa
? product.countryNameFa || product.countryName
: product.countryName
const category = isFa
? product.categoryNameFa || product.categoryName
: product.categoryName
const location = [city, country].filter(Boolean).join(isFa ? '، ' : ', ')
const statusLabel =
product.status === 'published'
? t('myProducts.status.published')
: product.status === 'archived'
? t('myProducts.status.archived')
: product.status === 'rejected'
? t('myProducts.status.rejected')
: t('myProducts.status.pending')
const conditionLabel = product.condition
? t(`myProducts.condition.${product.condition}`)
: '—'
const imageSrc = product.imageUrl?.trim() || ''
const galleryImages = (product.images ?? [])
.map((item) => item.url?.trim())
.filter((url): url is string => Boolean(url))
const currency = (product.priceCurrency || 'IRT').toUpperCase()
const priceLabel =
product.price == null
? t('myProducts.priceUnavailable')
: currency === 'IRT'
? formatIrtPrice(product.price)
: `${Number(product.price).toLocaleString('en-US')} ${currency}`
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('myProducts.title'), href: '/my-products' },
{ label: title },
]}
/>
<div className={styles.headerRow}>
<div>
<h2 className={pageStyles.pageTitle}>{t('myProducts.detailsTitle')}</h2>
<p className={pageStyles.pageSubtitle}>{t('myProducts.detailsSubtitle')}</p>
</div>
<button
type="button"
className={styles.editBtn}
onClick={() => navigate(`/my-products/${product.id}/edit`)}
>
<Pencil size={16} />
{t('myProducts.edit')}
</button>
</div>
<div className={styles.layout} dir={isFa ? 'rtl' : 'ltr'}>
<div className={styles.gallery}>
<div className={styles.mainImage}>
{imageSrc ? (
<img src={imageSrc} alt={title} className={styles.image} />
) : (
<div className={styles.imagePlaceholder}>
<ImageOff size={36} strokeWidth={1.5} />
</div>
)}
<span className={styles.badge} data-status={product.status}>
{statusLabel}
</span>
{product.promoted ? (
<span className={styles.promotedBadge}>{t('myProducts.promoted')}</span>
) : null}
</div>
{galleryImages.length > 0 ? (
<div className={styles.galleryThumbs}>
{galleryImages.map((url) => (
<div key={url} className={styles.galleryThumb}>
<img src={url} alt="" />
</div>
))}
</div>
) : null}
</div>
<div className={styles.details}>
{category ? <span className={styles.categoryChip}>{category}</span> : null}
<h1 className={styles.title}>{title}</h1>
{secondary ? <p className={styles.secondary}>{secondary}</p> : null}
<div className={styles.summaryRow}>
<p className={styles.price} dir="ltr">
{priceLabel}
</p>
<div className={styles.summaryMeta} dir={isFa ? 'rtl' : 'ltr'}>
<span className={styles.metaItem}>
<MapPin size={14} aria-hidden />
{location || '—'}
</span>
</div>
</div>
<dl className={styles.metaGrid}>
<div>
<dt>{t('myProducts.fields.country')}</dt>
<dd>{country || '—'}</dd>
</div>
<div>
<dt>{t('myProducts.fields.city')}</dt>
<dd>{city || '—'}</dd>
</div>
<div>
<dt>{t('myProducts.fields.condition')}</dt>
<dd>{conditionLabel}</dd>
</div>
{product.priceByExpert ? (
<div>
<dt>{t('myProducts.fields.priceByExpert')}</dt>
<dd>{t('myProducts.yes')}</dd>
</div>
) : null}
</dl>
{product.description ? (
<section className={styles.section}>
<h3>{t('myProducts.fields.description')}</h3>
<p className={styles.prose}>{product.description}</p>
</section>
) : null}
{product.deliveryNote ? (
<section className={styles.section}>
<h3>{t('myProducts.fields.deliveryNote')}</h3>
<p className={styles.prose}>{product.deliveryNote}</p>
</section>
) : null}
{product.technicalNotes ? (
<section className={styles.section}>
<h3>{t('myProducts.fields.technicalNotes')}</h3>
<p className={styles.prose}>{product.technicalNotes}</p>
</section>
) : null}
{technicalRows.length > 0 ? (
<section className={styles.section}>
<h3>{t('myProducts.technical.categoryForm')}</h3>
<dl className={styles.techGrid}>
{technicalRows.map((row) => (
<div key={row.id}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
</section>
) : null}
</div>
</div>
</main>
)
}
@@ -0,0 +1,103 @@
.error {
margin-bottom: 16px;
padding: 12px 14px;
font-size: 13px;
color: #b91c1c;
background: rgba(254, 226, 226, 0.85);
border: 1px solid rgba(248, 113, 113, 0.45);
border-radius: var(--radius-sm);
}
.status {
margin: 8px 0 16px;
font-size: 13px;
color: var(--text-muted);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 48px 24px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
}
.emptyLink {
margin-top: 4px;
font-size: 13px;
font-weight: 600;
color: var(--primary);
text-decoration: none;
}
.emptyLink:hover {
text-decoration: underline;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.fabDock {
position: fixed;
inset-inline-end: 32px;
inset-inline-start: auto;
bottom: 32px;
z-index: 110;
}
.addFab {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.addFab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1280px) {
.grid {
grid-template-columns: repeat(6, 1fr);
}
}
@media (max-width: 768px) {
.fabDock {
inset-inline-end: 20px;
bottom: 20px;
}
.addFab {
width: 52px;
height: 52px;
}
}
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Package, Plus } from 'lucide-react'
import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
import { UserProductCard } from '../components/UserProductCard'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api'
import {
deleteMyUserProduct,
listMyUserProducts,
promoteMyUserProduct,
type MyUserProductsListResponse,
} from '../services/userProductsService'
import pageStyles from '../components/PageContent.module.css'
import styles from './MyProductsPage.module.css'
const PAGE_SIZE = 24
export function MyProductsPage() {
const t = useT()
const navigate = useNavigate()
const { showToast } = useToast()
const [data, setData] = useState<MyUserProductsListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [page, setPage] = useState(1)
const [busyId, setBusyId] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<'remove' | 'promote' | null>(null)
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const response = await listMyUserProducts(
{ page, pageSize: PAGE_SIZE },
controller.signal,
)
if (controller.signal.aborted) return
setData(response)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : t('myProducts.error.load'))
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => controller.abort()
}, [page, t])
const products = data?.items ?? []
const total = data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
function handleEdit(id: string) {
navigate(`/my-products/${id}/edit`)
}
async function handleRemove(id: string) {
if (!window.confirm(t('myProducts.removeConfirm'))) return
setBusyId(id)
setBusyAction('remove')
try {
await deleteMyUserProduct(id)
setData((prev) =>
prev
? {
...prev,
items: prev.items.filter((item) => item.id !== id),
total: Math.max(0, prev.total - 1),
}
: prev,
)
showToast(t('myProducts.removeSuccess'), 'success')
} catch (err) {
showToast(
err instanceof ApiError ? err.message : t('myProducts.error.remove'),
'error',
)
} finally {
setBusyId(null)
setBusyAction(null)
}
}
async function handlePromote(id: string) {
setBusyId(id)
setBusyAction('promote')
try {
const response = await promoteMyUserProduct(id)
setData((prev) =>
prev
? {
...prev,
items: prev.items.map((item) =>
item.id === id ? { ...item, ...response.product } : item,
),
}
: prev,
)
showToast(t('myProducts.promoteSuccess'), 'success')
} catch (err) {
showToast(
err instanceof ApiError ? err.message : t('myProducts.error.promote'),
'error',
)
} finally {
setBusyId(null)
setBusyAction(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: t('nav.home'), href: '/' },
{ label: t('myProducts.title') },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>{t('myProducts.title')}</h2>
<p className={pageStyles.pageSubtitle}>{t('myProducts.subtitle')}</p>
</div>
</div>
{error ? (
<div className={styles.error} role="alert">
{error}
</div>
) : null}
{loading ? <p className={styles.status}>{t('myProducts.loading')}</p> : null}
{!loading && !error && products.length === 0 ? (
<div className={styles.empty}>
<Package size={32} />
<p>{t('myProducts.empty')}</p>
<Link to="/my-products/new" className={styles.emptyLink}>
{t('myProducts.add')}
</Link>
</div>
) : null}
{!loading && products.length > 0 ? (
<>
<div className={styles.grid}>
{products.map((product) => (
<UserProductCard
key={product.id}
product={product}
to={`/my-products/${product.id}`}
onEdit={handleEdit}
onRemove={handleRemove}
onPromote={handlePromote}
busyAction={busyId === product.id ? busyAction : null}
/>
))}
</div>
{totalPages > 1 ? (
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
/>
) : null}
</>
) : null}
<div className={styles.fabDock}>
<Link
to="/my-products/new"
className={styles.addFab}
aria-label={t('myProducts.add')}
title={t('myProducts.add')}
>
<Plus size={24} />
</Link>
</div>
</main>
)
}
@@ -80,7 +80,7 @@
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.35);
background: var(--elevated-surface);
}
.td {
@@ -282,7 +282,7 @@
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.35);
background: var(--elevated-surface);
color: var(--text-secondary);
font-weight: 700;
font-size: 12px;
@@ -125,7 +125,7 @@
align-items: center;
gap: 14px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.6);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: var(--radius-sm);
}
@@ -299,7 +299,7 @@
padding: 16px 12px;
border: 2px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.5);
background: var(--surface);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
text-align: center;
@@ -345,7 +345,7 @@
padding: 12px 14px;
border: 2px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.5);
background: var(--surface);
cursor: pointer;
text-align: left;
transition: border-color 0.2s, background 0.2s;
@@ -435,7 +435,7 @@
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.8);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
@@ -482,7 +482,7 @@
padding: 14px;
border: 2px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.5);
background: var(--surface);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
text-align: right;
@@ -538,7 +538,7 @@
padding: 12px 14px;
border: 1.5px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.65);
background: var(--surface);
cursor: pointer;
text-align: right;
font-size: 13px;
@@ -765,7 +765,7 @@
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.8);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
+21 -2
View File
@@ -3,14 +3,33 @@ import type { CityOption } from '@meshkee/dashboard-ui'
export type { CityOption }
export async function listIranProvinces(signal?: AbortSignal) {
export async function listCountries(signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>('/cities?level=country', { signal })
return data.items
}
/** Cities under a country (direct + via provinces). Province is optional in the tree. */
export async function listCitiesByCountrySlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
'/cities?level=province&parentSlug=iran',
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
{ signal },
)
return data.items
}
export async function listProvincesByCountrySlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
`/cities?level=province&parentSlug=${encodeURIComponent(parentSlug)}`,
{ signal },
)
return data.items
}
/** @deprecated Prefer listProvincesByCountrySlug('iran') */
export async function listIranProvinces(signal?: AbortSignal) {
return listProvincesByCountrySlug('iran', signal)
}
export async function listCitiesByProvinceSlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
+155
View File
@@ -0,0 +1,155 @@
import {
clearTokens,
getAccessToken,
setTokens,
} from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import { ensureJpegUploadFile, ensureUploadFile } from '../utils/imageUpload'
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
export interface MediaItem {
id: string
publicUrl: string
fileName: string
originalFileName: string
mimeType: string
width: number | null
height: number | null
}
function isDataUrl(value: string) {
return value.startsWith('data:')
}
function businessMediaPath() {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/my-user-products/media`
}
async function refreshAccessToken() {
const refreshToken = localStorage.getItem('meshkee_customer_refresh_token')
if (!refreshToken) return false
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
})
if (!response.ok) {
clearTokens()
return false
}
const data = await response.json()
setTokens(data.accessToken, data.refreshToken)
return true
}
function parseUploadError(payload: unknown, status: number) {
if (payload && typeof payload === 'object') {
const message = (payload as { message?: string | string[] }).message
if (Array.isArray(message)) {
return message.join(', ')
}
if (typeof message === 'string' && message) {
return message
}
}
return `Upload failed with status ${status}`
}
export async function uploadMyUserProductMedia(
files: File[],
signal?: AbortSignal,
): Promise<MediaItem[]> {
if (!files.length) return []
const send = async () => {
const formData = new FormData()
files.forEach((file) => formData.append('files', file))
const headers = new Headers()
const accessToken = getAccessToken()
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
return fetch(`${API_BASE_URL}${businessMediaPath()}`, {
method: 'POST',
headers,
body: formData,
signal,
})
}
let response = await send()
if (response.status === 401) {
const refreshed = await refreshAccessToken()
if (refreshed) {
response = await send()
}
}
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(parseUploadError(payload, response.status))
}
return (payload.items as Array<Record<string, unknown>>).map((item) => ({
id: String(item.id),
publicUrl: String(item.publicUrl),
fileName: String(item.fileName),
originalFileName: String(item.originalFileName),
mimeType: String(item.mimeType),
width: typeof item.width === 'number' ? item.width : null,
height: typeof item.height === 'number' ? item.height : null,
}))
}
export async function resolveDataUrlToMediaId(
value: string | null,
filename: string,
existingMediaId?: string | null,
): Promise<string | null> {
if (!value) return null
if (!isDataUrl(value)) {
return existingMediaId ?? null
}
const file = filename.toLowerCase().endsWith('.png')
? await ensureUploadFile(value, filename)
: await ensureJpegUploadFile(value, filename)
const uploaded = await uploadMyUserProductMedia([file])
return uploaded[0]?.id ?? null
}
export async function resolveDataUrlsToMediaIds(
values: string[],
existingMediaIds: string[],
): Promise<string[]> {
const resolved: string[] = []
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (isDataUrl(value)) {
const file = await ensureJpegUploadFile(
value,
`user-product-image-${index + 1}.jpg`,
)
const uploaded = await uploadMyUserProductMedia([file])
if (uploaded[0]) resolved.push(uploaded[0].id)
} else if (existingMediaIds[index]) {
resolved.push(existingMediaIds[index])
}
}
return resolved
}
@@ -1,6 +1,9 @@
import { apiRequest } from '../lib/api'
import type { DashboardLocale } from '@meshkee/dashboard-core'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
import type { BusinessModuleId } from '../utils/businessModules'
export type DashboardThemeMode = 'light' | 'dark'
export interface ResolvedTenant {
id: string
@@ -10,6 +13,8 @@ export interface ResolvedTenant {
domain: string
primaryColor: BusinessPrimaryColorId
defaultLocale?: DashboardLocale
themeMode?: DashboardThemeMode
enabledModules?: BusinessModuleId[]
logoUrl?: string | null
faviconUrl?: string | null
}
@@ -0,0 +1,213 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { UserProductListItem } from '../types/userProduct'
export type UserProductCondition = 'new' | 'stock' | 'needs_repair' | 'scrap'
export type UserProductPriceCurrency = 'IRT' | 'USD' | 'EUR' | 'AED'
export type TechnicalFieldType = 'text' | 'textarea' | 'select' | 'multi_select'
export interface UserProductCategoryOption {
id: string
name: string
nameFa: string | null
parentId: string | null
}
export interface TechnicalFormFieldOption {
id: string
label: string
value: string
sortOrder: number
}
export interface TechnicalFormField {
id: string
label: string
key: string
type: TechnicalFieldType
isRequired: boolean
sortOrder: number
options: TechnicalFormFieldOption[]
}
export interface CategoryTechnicalForm {
id: string
categoryId: string
fields: TechnicalFormField[]
}
export type TechnicalFormValues = Record<string, string | string[]>
export interface UserProductTechnicalValueInput {
fieldId: string
textValue?: string
optionId?: string
optionIds?: string[]
}
export interface CreateUserProductInput {
titleFa: string
titleEn?: string
description?: string
categoryId: string
price?: number
priceCurrency?: UserProductPriceCurrency
priceByExpert?: boolean
countryId: string
cityId: string
deliveryNote?: string
condition: UserProductCondition
technicalNotes?: string
technicalValues?: UserProductTechnicalValueInput[]
featuredMediaId?: string
galleryMediaIds?: string[]
}
export type UpdateUserProductInput = CreateUserProductInput
export interface UserProductDetail extends UserProductListItem {
countryId: string
cityId: string
countrySlug: string
featuredMediaId: string | null
galleryMediaIds: string[]
images: Array<{ mediaId: string; url: string }>
technicalValues: UserProductTechnicalValueInput[]
deliveryNote?: string | null
technicalNotes?: string | null
}
export interface ListMyUserProductsParams {
page?: number
pageSize?: number
}
export interface MyUserProductsListResponse {
items: UserProductListItem[]
total: number
page: number
pageSize: number
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/my-user-products${suffix}`
}
export function buildTechnicalValuesPayload(
fields: TechnicalFormField[],
values: TechnicalFormValues,
): UserProductTechnicalValueInput[] {
const payload: UserProductTechnicalValueInput[] = []
for (const field of fields) {
const value = values[field.id]
if (field.type === 'text' || field.type === 'textarea') {
const text = typeof value === 'string' ? value.trim() : ''
if (!text) continue
payload.push({ fieldId: field.id, textValue: text })
continue
}
if (field.type === 'select') {
const optionId = typeof value === 'string' ? value.trim() : ''
if (!optionId) continue
payload.push({ fieldId: field.id, optionId })
continue
}
const optionIds = Array.isArray(value) ? value.filter(Boolean) : []
if (!optionIds.length) continue
payload.push({ fieldId: field.id, optionIds })
}
return payload
}
export async function listMyUserProducts(
params: ListMyUserProductsParams = {},
signal?: AbortSignal,
) {
const q = new URLSearchParams()
if (params.page !== undefined) q.set('page', String(params.page))
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
const query = q.toString()
return apiRequest<MyUserProductsListResponse>(
`${businessPath()}${query ? `?${query}` : ''}`,
{ auth: true, signal },
)
}
export async function createMyUserProduct(input: CreateUserProductInput) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(),
{
method: 'POST',
auth: true,
body: input,
},
)
}
export async function getMyUserProduct(
productId: string,
signal?: AbortSignal,
) {
return apiRequest<{ product: UserProductDetail }>(
businessPath(`/${productId}`),
{ auth: true, signal },
)
}
export async function updateMyUserProduct(
productId: string,
input: UpdateUserProductInput,
) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}`),
{
method: 'PATCH',
auth: true,
body: input,
},
)
}
export async function deleteMyUserProduct(productId: string) {
return apiRequest<{ message: string }>(businessPath(`/${productId}`), {
method: 'DELETE',
auth: true,
})
}
export async function promoteMyUserProduct(productId: string) {
return apiRequest<{ message: string; product: UserProductListItem }>(
businessPath(`/${productId}/promote`),
{
method: 'POST',
auth: true,
},
)
}
export async function listMyUserProductCategories(signal?: AbortSignal) {
return apiRequest<{ items: UserProductCategoryOption[] }>(
businessPath('/categories'),
{ auth: true, signal },
)
}
export async function getMyUserProductCategoryTechnicalForm(
categoryId: string,
signal?: AbortSignal,
) {
return apiRequest<{ form: CategoryTechnicalForm | null }>(
businessPath(`/categories/${categoryId}/technical-form`),
{ auth: true, signal },
)
}
+26
View File
@@ -0,0 +1,26 @@
export type UserProductStatus = 'draft' | 'published' | 'archived' | 'rejected'
export type UserProductPriceCurrency = 'IRT' | 'USD' | 'EUR' | 'AED'
export interface UserProductListItem {
id: string
title: string
titleFa?: string | null
titleEn?: string | null
description?: string | null
price: number | null
priceCurrency?: UserProductPriceCurrency | string | null
priceByExpert?: boolean
promoted?: boolean
status: UserProductStatus
condition?: string | null
cityName: string
cityNameFa?: string | null
countryName?: string | null
countryNameFa?: string | null
imageUrl: string | null
categoryId?: string | null
categoryName?: string | null
categoryNameFa?: string | null
createdAt?: string
}
@@ -0,0 +1,53 @@
/** Optional modules that can gate customer-dashboard sections. */
export const CUSTOMER_MODULE_IDS = ['customer_products'] as const
export type CustomerModuleId = (typeof CUSTOMER_MODULE_IDS)[number]
/** All optional module ids returned on tenant resolve (business + customer). */
export const BUSINESS_MODULE_IDS = [
'products',
'store',
'portfolio',
'blog',
'warehouse',
'videos',
...CUSTOMER_MODULE_IDS,
] as const
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number]
/** Legacy tenants without modules: business modules on, customer modules opt-in. */
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
'products',
'store',
'portfolio',
'blog',
'warehouse',
'videos',
]
const MODULE_ID_SET = new Set<string>(BUSINESS_MODULE_IDS)
export function isBusinessModuleId(value: unknown): value is BusinessModuleId {
return typeof value === 'string' && MODULE_ID_SET.has(value)
}
export function normalizeEnabledBusinessModules(value: unknown): BusinessModuleId[] {
if (!Array.isArray(value)) {
return [...DEFAULT_ENABLED_BUSINESS_MODULES]
}
const selected = new Set<BusinessModuleId>()
for (const item of value) {
if (isBusinessModuleId(item)) selected.add(item)
}
return BUSINESS_MODULE_IDS.filter((id) => selected.has(id))
}
export function hasBusinessModule(
enabledModules: readonly BusinessModuleId[] | null | undefined,
moduleId: BusinessModuleId,
): boolean {
return normalizeEnabledBusinessModules(enabledModules).includes(moduleId)
}
+46
View File
@@ -0,0 +1,46 @@
import type { Area } from 'react-easy-crop'
function createImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', reject)
image.src = url
})
}
export async function getCroppedImage(
imageSrc: string,
pixelCrop: Area,
format: 'jpeg' | 'png' = 'jpeg',
): Promise<string> {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
if (format === 'png') {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height,
)
if (format === 'png') {
return canvas.toDataURL('image/png')
}
return canvas.toDataURL('image/jpeg', 0.92)
}
+84
View File
@@ -0,0 +1,84 @@
const ALLOWED_IMAGE_TYPES = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'image/gif',
])
function parseDataUrl(dataUrl: string): { mime: string; bytes: Uint8Array } {
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/)
if (!match) {
throw new Error('Invalid image data')
}
const mime = match[1] === 'image/jpg' ? 'image/jpeg' : match[1]
const binary = atob(match[2])
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return { mime, bytes }
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', () => reject(new Error('Could not load image')))
image.src = src
})
}
export function dataUrlToFile(dataUrl: string, filename: string): File {
const { mime, bytes } = parseDataUrl(dataUrl)
const copy = new Uint8Array(bytes)
return new File([copy], filename, { type: mime })
}
export async function ensureUploadFile(dataUrl: string, filename: string): Promise<File> {
if (dataUrl.startsWith('data:')) {
const mime = dataUrl.slice(5, dataUrl.indexOf(';'))
const normalized = mime === 'image/jpg' ? 'image/jpeg' : mime
if (ALLOWED_IMAGE_TYPES.has(normalized)) {
const file = dataUrlToFile(dataUrl, filename)
if (file.size > 0) {
return file
}
}
}
const image = await loadImage(dataUrl)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('Could not prepare image for upload')
}
const wantsPng = filename.toLowerCase().endsWith('.png')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
if (wantsPng) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
ctx.drawImage(image, 0, 0)
const outputDataUrl = wantsPng
? canvas.toDataURL('image/png')
: canvas.toDataURL('image/jpeg', 0.92)
const safeName = filename.replace(/\.[^.]+$/, '') || 'image'
const extension = wantsPng ? 'png' : 'jpg'
return dataUrlToFile(outputDataUrl, `${safeName}.${extension}`)
}
export async function ensureJpegUploadFile(
dataUrl: string,
filename: string,
): Promise<File> {
const safeName = filename.replace(/\.[^.]+$/, '') || 'image'
return ensureUploadFile(dataUrl, `${safeName}.jpg`)
}