mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Add customer and business user-product flows across dashboards.
Ship my-products / customer-products UI with gallery uploads, status controls, module gating, and related shared UI polish. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
c5bdaad16b
commit
1271d96539
@@ -0,0 +1,605 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.iconRail {
|
||||
width: 128px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 12px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.4);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--glass-bg) 70%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.iconRailInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.iconRailGlyph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.iconRailLabelFa {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.iconRailLabelEn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: -6px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-en);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.1em;
|
||||
/* Compensate letter-spacing so centered Latin text doesn’t drift */
|
||||
padding-inline-start: 0.1em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.iconRail {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 22px 18px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.stepGroup {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stepGroup:last-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.stepUnit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-muted);
|
||||
border: 2px solid transparent;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.stepLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.stepActive .stepDot {
|
||||
background: rgba(var(--primary-rgb) / 0.15);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.stepActive .stepLabel {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stepDone .stepDot {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.stepDone .stepLabel {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.connector {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
min-width: 24px;
|
||||
margin: 0 8px 13px;
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
border-radius: 1px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.connectorDone {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stepDesc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.checkRow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: end;
|
||||
gap: 8px;
|
||||
height: var(--field-height);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.checkRow input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fieldRowTriple {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.locationRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.col2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.col3 {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.col4 {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.col6 {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.optional {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field input::placeholder,
|
||||
.field textarea::placeholder {
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.field select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding-inline-end: var(--select-padding-end);
|
||||
background-color: var(--surface);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--select-arrow-offset) center;
|
||||
background-size: var(--select-arrow-size);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .field select {
|
||||
background-position: left var(--select-arrow-offset) center;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.sectionDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sectionDivider::before,
|
||||
.sectionDivider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.thumbnailBlock {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.thumbnailBlock > .field {
|
||||
grid-column: 5 / span 4;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.thumbnailBlock > .field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.chipGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 50px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.chipSelected {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.conditionFieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.conditionFieldset legend {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.radioGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.radioCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) 8px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.radioCard:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.radioCard:has(input:checked) {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.16);
|
||||
}
|
||||
|
||||
.radioCard input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.radioCard span {
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.radioGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.inlineStatus {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
margin-top: 8px;
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #fca5a5;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.28);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.ghostBtn,
|
||||
.secondaryBtn,
|
||||
.primaryBtn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s, background 0.2s, color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ghostBtn {
|
||||
padding: 10px 4px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ghostBtn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
padding: 10px 18px;
|
||||
color: var(--text-primary);
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
background: rgba(148, 163, 184, 0.24);
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
margin-inline-start: auto;
|
||||
padding: 10px 22px;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fieldRow,
|
||||
.fieldRowTriple,
|
||||
.priceRow,
|
||||
.locationRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.col2,
|
||||
.col3,
|
||||
.col4,
|
||||
.col6 {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.stepLabel {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.connector {
|
||||
min-width: 12px;
|
||||
margin: 0 4px 13px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn,
|
||||
.ghostBtn {
|
||||
width: 100%;
|
||||
margin-inline-start: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Check, ClipboardList, Images, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
getLocationOptionLabel,
|
||||
useLocale,
|
||||
type CityOption,
|
||||
} from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import { CitySearchSelect } from '../components/CitySearchSelect'
|
||||
import { CategorySearchSelect } from '../components/CategorySearchSelect'
|
||||
import { ImageCropper } from '../components/ImageCropper'
|
||||
import { ImageUploader } from '../components/ImageUploader'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { translate } from '../i18n/messages'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
listCitiesByCountrySlug,
|
||||
listCountries,
|
||||
} from '../services/citiesService'
|
||||
import {
|
||||
buildTechnicalValuesPayload,
|
||||
createUserProduct,
|
||||
getUserProduct,
|
||||
getUserProductCategoryTechnicalForm,
|
||||
listUserProductCategories,
|
||||
updateUserProduct,
|
||||
type TechnicalFormField,
|
||||
type TechnicalFormValues,
|
||||
type UserProductCategoryOption,
|
||||
type UserProductCondition,
|
||||
type UserProductPriceCurrency,
|
||||
type UserProductTechnicalValueInput,
|
||||
} from '../services/userProductsService'
|
||||
import { resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
|
||||
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
|
||||
import styles from './AddCustomerProductPage.module.css'
|
||||
|
||||
type StepId = 1 | 2 | 3
|
||||
|
||||
const PRICE_UNITS: UserProductPriceCurrency[] = ['IRT', 'USD', 'EUR', 'AED']
|
||||
|
||||
const CONDITIONS: UserProductCondition[] = [
|
||||
'new',
|
||||
'stock',
|
||||
'needs_repair',
|
||||
'scrap',
|
||||
]
|
||||
|
||||
function fieldLabel(field: TechnicalFormField) {
|
||||
return field.label
|
||||
}
|
||||
|
||||
function optionLabel(option: TechnicalFormField['options'][number]) {
|
||||
return option.label
|
||||
}
|
||||
|
||||
function mapTechnicalValues(
|
||||
items: UserProductTechnicalValueInput[],
|
||||
): TechnicalFormValues {
|
||||
const values: TechnicalFormValues = {}
|
||||
for (const item of items) {
|
||||
if (item.textValue != null) {
|
||||
values[item.fieldId] = item.textValue
|
||||
continue
|
||||
}
|
||||
if (item.optionId) {
|
||||
values[item.fieldId] = item.optionId
|
||||
continue
|
||||
}
|
||||
if (item.optionIds?.length) {
|
||||
values[item.fieldId] = item.optionIds
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function isCondition(value: string | null | undefined): value is UserProductCondition {
|
||||
return (
|
||||
value === 'new' ||
|
||||
value === 'stock' ||
|
||||
value === 'needs_repair' ||
|
||||
value === 'scrap'
|
||||
)
|
||||
}
|
||||
|
||||
function isPriceCurrency(
|
||||
value: string | null | undefined,
|
||||
): value is UserProductPriceCurrency {
|
||||
return (
|
||||
value === 'IRT' || value === 'USD' || value === 'EUR' || value === 'AED'
|
||||
)
|
||||
}
|
||||
|
||||
export function AddCustomerProductPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams<{ id?: string }>()
|
||||
const isEdit = Boolean(id)
|
||||
const { showToast } = useToast()
|
||||
const [loaded, setLoaded] = useState(!isEdit)
|
||||
const [step, setStep] = useState<StepId>(1)
|
||||
const [stepError, setStepError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const pendingTechnicalValuesRef = useRef<TechnicalFormValues | null>(null)
|
||||
|
||||
const [categories, setCategories] = useState<UserProductCategoryOption[]>([])
|
||||
const [loadingCategories, setLoadingCategories] = useState(false)
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [titleFa, setTitleFa] = useState('')
|
||||
const [titleEn, setTitleEn] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [priceInput, setPriceInput] = useState('')
|
||||
const [priceUnit, setPriceUnit] = useState<UserProductPriceCurrency>('IRT')
|
||||
const [priceByExpert, setPriceByExpert] = useState(false)
|
||||
|
||||
const [countries, setCountries] = useState<CityOption[]>([])
|
||||
const [cities, setCities] = useState<CityOption[]>([])
|
||||
const [countrySlug, setCountrySlug] = useState('')
|
||||
const [countryId, setCountryId] = useState('')
|
||||
const [cityId, setCityId] = useState('')
|
||||
const [deliveryNote, setDeliveryNote] = useState('')
|
||||
const [loadingLocations, setLoadingLocations] = useState(false)
|
||||
|
||||
const [thumbnail, setThumbnail] = useState<string | null>(null)
|
||||
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
|
||||
const [gallery, setGallery] = useState<string[]>([])
|
||||
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
|
||||
|
||||
const [condition, setCondition] = useState<UserProductCondition>('new')
|
||||
const [technicalNotes, setTechnicalNotes] = useState('')
|
||||
const [technicalFields, setTechnicalFields] = useState<TechnicalFormField[]>([])
|
||||
const [technicalValues, setTechnicalValues] = useState<TechnicalFormValues>({})
|
||||
const [loadingTechnicalForm, setLoadingTechnicalForm] = useState(false)
|
||||
|
||||
const steps: { id: StepId; label: string }[] = [
|
||||
{ id: 1, label: t('customerProducts.form.step.basics') },
|
||||
{ id: 2, label: t('customerProducts.form.step.images') },
|
||||
{ id: 3, label: t('customerProducts.form.step.technical') },
|
||||
]
|
||||
|
||||
const stepIcon =
|
||||
step === 1 ? (
|
||||
<ClipboardList size={40} strokeWidth={1.4} />
|
||||
) : step === 2 ? (
|
||||
<Images size={40} strokeWidth={1.4} />
|
||||
) : (
|
||||
<SlidersHorizontal size={40} strokeWidth={1.4} />
|
||||
)
|
||||
|
||||
const stepLabelKey =
|
||||
step === 1
|
||||
? 'customerProducts.form.step.basics'
|
||||
: step === 2
|
||||
? 'customerProducts.form.step.images'
|
||||
: 'customerProducts.form.step.technical'
|
||||
const stepNameFa = translate('fa', stepLabelKey)
|
||||
const stepNameEn = translate('en', stepLabelKey)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setLoadingLocations(true)
|
||||
void listCountries(controller.signal)
|
||||
.then((items) => {
|
||||
if (!controller.signal.aborted) setCountries(items)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!isAbortError(err)) setStepError(t('customerProducts.form.error.loadLocations'))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoadingLocations(false)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setLoadingCategories(true)
|
||||
void listUserProductCategories(controller.signal)
|
||||
.then((response) => {
|
||||
if (!controller.signal.aborted) setCategories(response.items)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!isAbortError(err)) setStepError(t('customerProducts.form.error.loadCategories'))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoadingCategories(false)
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit || !id) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadProduct() {
|
||||
setStepError('')
|
||||
try {
|
||||
const response = await getUserProduct(id!, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
const product = response.product
|
||||
pendingTechnicalValuesRef.current = mapTechnicalValues(
|
||||
product.technicalValues ?? [],
|
||||
)
|
||||
setCategoryId(product.categoryId ?? '')
|
||||
setTitleFa(product.titleFa ?? product.title ?? '')
|
||||
setTitleEn(product.titleEn ?? '')
|
||||
setDescription(product.description ?? '')
|
||||
setPriceInput(
|
||||
product.price != null ? formatIrtInput(String(product.price)) : '',
|
||||
)
|
||||
setPriceUnit(
|
||||
isPriceCurrency(product.priceCurrency)
|
||||
? product.priceCurrency
|
||||
: 'IRT',
|
||||
)
|
||||
setPriceByExpert(product.priceByExpert === true)
|
||||
setCountrySlug(product.countrySlug)
|
||||
setCountryId(product.countryId)
|
||||
setCityId(product.cityId)
|
||||
setDeliveryNote(product.deliveryNote ?? '')
|
||||
setThumbnail(product.imageUrl)
|
||||
setFeaturedMediaId(product.featuredMediaId)
|
||||
setGallery((product.images ?? []).map((item) => item.url))
|
||||
setGalleryMediaIds(product.galleryMediaIds ?? [])
|
||||
setCondition(isCondition(product.condition) ? product.condition : 'new')
|
||||
setTechnicalNotes(product.technicalNotes ?? '')
|
||||
|
||||
if (product.countrySlug) {
|
||||
setCities(await listCitiesByCountrySlug(product.countrySlug))
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted) setLoaded(true)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
showToast(
|
||||
err instanceof ApiError ? err.message : t('customerProducts.form.error.load'),
|
||||
'error',
|
||||
)
|
||||
navigate('/customer-products', { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
void loadProduct()
|
||||
return () => controller.abort()
|
||||
}, [isEdit, id, navigate, showToast, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTechnicalValuesRef.current) {
|
||||
setTechnicalValues({})
|
||||
}
|
||||
setTechnicalFields([])
|
||||
|
||||
if (!categoryId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
setLoadingTechnicalForm(true)
|
||||
void getUserProductCategoryTechnicalForm(categoryId, controller.signal)
|
||||
.then((response) => {
|
||||
if (controller.signal.aborted) return
|
||||
setTechnicalFields(response.form?.fields ?? [])
|
||||
if (pendingTechnicalValuesRef.current) {
|
||||
setTechnicalValues(pendingTechnicalValuesRef.current)
|
||||
pendingTechnicalValuesRef.current = null
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!isAbortError(err)) {
|
||||
setTechnicalFields([])
|
||||
setStepError(t('customerProducts.form.error.loadTechnicalForm'))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoadingTechnicalForm(false)
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [categoryId, t])
|
||||
|
||||
async function handleCountryChange(nextSlug: string) {
|
||||
setCountrySlug(nextSlug)
|
||||
setCountryId(countries.find((item) => item.slug === nextSlug)?.id ?? '')
|
||||
setCityId('')
|
||||
setCities([])
|
||||
if (!nextSlug) return
|
||||
|
||||
setLoadingLocations(true)
|
||||
try {
|
||||
setCities(await listCitiesByCountrySlug(nextSlug))
|
||||
} catch {
|
||||
setStepError(t('customerProducts.form.error.loadLocations'))
|
||||
} finally {
|
||||
setLoadingLocations(false)
|
||||
}
|
||||
}
|
||||
|
||||
function validateStep1() {
|
||||
if (!categoryId) return t('customerProducts.form.error.categoryRequired')
|
||||
if (!titleFa.trim()) return t('customerProducts.form.error.titleFaRequired')
|
||||
if (!countryId || !cityId) {
|
||||
return t('customerProducts.form.error.locationRequired')
|
||||
}
|
||||
const price = parseIrtInput(priceInput)
|
||||
if (priceInput.trim() && (price === null || price < 0)) {
|
||||
return t('customerProducts.form.error.priceInvalid')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function validateStep3() {
|
||||
if (!condition) return t('customerProducts.form.error.conditionRequired')
|
||||
|
||||
for (const field of technicalFields) {
|
||||
if (!field.isRequired) continue
|
||||
const value = technicalValues[field.id]
|
||||
const ok =
|
||||
field.type === 'multi_select'
|
||||
? Array.isArray(value) && value.length > 0
|
||||
: typeof value === 'string' && value.trim().length > 0
|
||||
if (!ok) {
|
||||
return t('customerProducts.form.error.technicalRequired')
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
setStepError('')
|
||||
if (step === 1) {
|
||||
const error = validateStep1()
|
||||
if (error) {
|
||||
setStepError(error)
|
||||
return
|
||||
}
|
||||
setStep(2)
|
||||
return
|
||||
}
|
||||
if (step === 2) setStep(3)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
setStepError('')
|
||||
if (step === 2) setStep(1)
|
||||
if (step === 3) setStep(2)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setStepError('')
|
||||
const validationError = validateStep3()
|
||||
if (validationError) {
|
||||
setStepError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
const price = parseIrtInput(priceInput)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
let nextFeaturedMediaId = featuredMediaId
|
||||
if (thumbnail?.startsWith('data:')) {
|
||||
nextFeaturedMediaId = await resolveDataUrlToMediaId(
|
||||
thumbnail,
|
||||
'user-product-thumbnail.jpg',
|
||||
featuredMediaId,
|
||||
)
|
||||
} else if (!thumbnail) {
|
||||
nextFeaturedMediaId = null
|
||||
}
|
||||
|
||||
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(
|
||||
gallery,
|
||||
galleryMediaIds,
|
||||
)
|
||||
|
||||
const payload = {
|
||||
titleFa: titleFa.trim(),
|
||||
titleEn: titleEn.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
categoryId,
|
||||
price: price ?? undefined,
|
||||
priceCurrency: priceUnit,
|
||||
priceByExpert,
|
||||
countryId,
|
||||
cityId,
|
||||
deliveryNote: deliveryNote.trim() || undefined,
|
||||
condition,
|
||||
technicalNotes: technicalNotes.trim() || undefined,
|
||||
technicalValues: buildTechnicalValuesPayload(technicalFields, technicalValues),
|
||||
featuredMediaId: nextFeaturedMediaId || undefined,
|
||||
galleryMediaIds: nextGalleryMediaIds,
|
||||
}
|
||||
|
||||
if (isEdit && id) {
|
||||
await updateUserProduct(id, payload)
|
||||
showToast(t('customerProducts.form.updateSuccess'), 'success')
|
||||
} else {
|
||||
await createUserProduct(payload)
|
||||
showToast(t('customerProducts.form.submitSuccess'), 'success')
|
||||
}
|
||||
navigate('/customer-products')
|
||||
} catch (err) {
|
||||
setStepError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: isEdit
|
||||
? t('customerProducts.form.error.update')
|
||||
: t('customerProducts.form.error.submit'),
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function setTechnicalField(fieldId: string, value: string | string[]) {
|
||||
setTechnicalValues((prev) => ({ ...prev, [fieldId]: value }))
|
||||
}
|
||||
|
||||
function toggleMultiOption(fieldId: string, optionId: string) {
|
||||
setTechnicalValues((prev) => {
|
||||
const current = prev[fieldId]
|
||||
const selected = Array.isArray(current) ? current : []
|
||||
const next = selected.includes(optionId)
|
||||
? selected.filter((item) => item !== optionId)
|
||||
: [...selected, optionId]
|
||||
return { ...prev, [fieldId]: next }
|
||||
})
|
||||
}
|
||||
|
||||
function renderTechnicalField(field: TechnicalFormField) {
|
||||
const value = technicalValues[field.id]
|
||||
const id = `tech-${field.id}`
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setTechnicalField(field.id, e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setTechnicalField(field.id, e.target.value)}
|
||||
>
|
||||
<option value="">{t('customerProducts.form.technical.select')}</option>
|
||||
{field.options.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{optionLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'multi_select') {
|
||||
const selected = Array.isArray(value) ? value : []
|
||||
return (
|
||||
<div className={styles.chipGrid}>
|
||||
{field.options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={`${styles.chip} ${selected.includes(option.id) ? styles.chipSelected : ''}`}
|
||||
onClick={() => toggleMultiOption(field.id, option.id)}
|
||||
>
|
||||
{optionLabel(option)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setTechnicalField(field.id, e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.inlineStatus}>{t('customerProducts.form.loading')}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('nav.home'), href: '/' },
|
||||
{ label: t('customerProducts.title'), href: '/customer-products' },
|
||||
{
|
||||
label: isEdit
|
||||
? t('customerProducts.edit')
|
||||
: t('customerProducts.add'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className={styles.shell}>
|
||||
<aside className={styles.iconRail} aria-hidden>
|
||||
<div className={styles.iconRailInner}>
|
||||
<span className={styles.iconRailGlyph}>{stepIcon}</span>
|
||||
<span className={styles.iconRailLabelFa}>{stepNameFa}</span>
|
||||
<span className={styles.iconRailLabelEn}>{stepNameEn}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={styles.card}>
|
||||
<div className={styles.root}>
|
||||
<nav className={styles.stepper} aria-label={t('customerProducts.form.stepperLabel')}>
|
||||
{steps.map((item, index) => {
|
||||
const isDone = step > item.id
|
||||
const isActive = step === item.id
|
||||
const connectorDone = step > item.id
|
||||
|
||||
return (
|
||||
<div key={item.id} className={styles.stepGroup}>
|
||||
<div
|
||||
className={[
|
||||
styles.stepUnit,
|
||||
isActive ? styles.stepActive : '',
|
||||
isDone ? styles.stepDone : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className={styles.stepLabel}>{item.label}</span>
|
||||
<span className={styles.stepDot} aria-hidden>
|
||||
{isDone ? <Check size={14} /> : index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={[
|
||||
styles.connector,
|
||||
connectorDone ? styles.connectorDone : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={styles.body} key={step}>
|
||||
{step === 1 && (
|
||||
<>
|
||||
<h2 className={styles.stepTitle}>
|
||||
{isEdit ? t('customerProducts.form.editTitle') : t('customerProducts.form.step.basics')}
|
||||
</h2>
|
||||
<p className={styles.stepDesc}>
|
||||
{isEdit
|
||||
? t('customerProducts.form.editSubtitle')
|
||||
: t('customerProducts.form.step.basicsHint')}
|
||||
</p>
|
||||
|
||||
<div className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="add-category">{t('customerProducts.form.fields.category')}</label>
|
||||
<CategorySearchSelect
|
||||
id="add-category"
|
||||
options={categories}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
disabled={loadingCategories}
|
||||
placeholder={t('customerProducts.form.fields.searchCategory')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="add-title-fa">{t('customerProducts.form.fields.titleFa')}</label>
|
||||
<input
|
||||
id="add-title-fa"
|
||||
type="text"
|
||||
value={titleFa}
|
||||
onChange={(e) => setTitleFa(e.target.value)}
|
||||
placeholder={t('customerProducts.form.fields.titleFaPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="add-title-en">{t('customerProducts.form.fields.titleEn')}</label>
|
||||
<input
|
||||
id="add-title-en"
|
||||
type="text"
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.target.value)}
|
||||
placeholder={t('customerProducts.form.fields.titleEnPlaceholder')}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="add-description">{t('customerProducts.form.fields.description')}</label>
|
||||
<textarea
|
||||
id="add-description"
|
||||
rows={4}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('customerProducts.form.fields.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.priceRow}>
|
||||
<label
|
||||
className={`${styles.checkRow} ${styles.col6}`}
|
||||
htmlFor="add-price-by-expert"
|
||||
>
|
||||
<input
|
||||
id="add-price-by-expert"
|
||||
type="checkbox"
|
||||
checked={priceByExpert}
|
||||
onChange={(e) => setPriceByExpert(e.target.checked)}
|
||||
/>
|
||||
<span>{t('customerProducts.form.fields.priceByExpert')}</span>
|
||||
</label>
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
<label htmlFor="add-price">
|
||||
{priceByExpert
|
||||
? t('customerProducts.form.fields.priceSuggested')
|
||||
: t('customerProducts.form.fields.price')}
|
||||
</label>
|
||||
<input
|
||||
id="add-price"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={priceInput}
|
||||
onChange={(e) => setPriceInput(formatIrtInput(e.target.value))}
|
||||
placeholder={t('customerProducts.form.fields.pricePlaceholder')}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col2}`}>
|
||||
<label htmlFor="add-price-unit">{t('customerProducts.form.fields.priceUnit')}</label>
|
||||
<select
|
||||
id="add-price-unit"
|
||||
value={priceUnit}
|
||||
onChange={(e) =>
|
||||
setPriceUnit(e.target.value as UserProductPriceCurrency)
|
||||
}
|
||||
>
|
||||
{PRICE_UNITS.map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{t(`customerProducts.form.fields.priceUnit.${unit}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sectionDivider}>
|
||||
<span>{t('customerProducts.form.fields.location')}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.locationRow}>
|
||||
<div className={`${styles.field} ${styles.col2}`}>
|
||||
<label htmlFor="add-country">{t('customerProducts.form.fields.country')}</label>
|
||||
<select
|
||||
id="add-country"
|
||||
value={countrySlug}
|
||||
disabled={loadingLocations}
|
||||
onChange={(e) => void handleCountryChange(e.target.value)}
|
||||
>
|
||||
<option value="">{t('customerProducts.form.fields.selectCountry')}</option>
|
||||
{countries.map((country) => (
|
||||
<option key={country.id} value={country.slug}>
|
||||
{getLocationOptionLabel(country, locale)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
<label htmlFor="add-city">{t('customerProducts.form.fields.city')}</label>
|
||||
<CitySearchSelect
|
||||
id="add-city"
|
||||
options={cities}
|
||||
value={cityId}
|
||||
onChange={setCityId}
|
||||
disabled={!countrySlug || loadingLocations}
|
||||
placeholder={t('customerProducts.form.fields.searchCity')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col6}`}>
|
||||
<label htmlFor="add-delivery-note">
|
||||
{t('customerProducts.form.fields.deliveryNote')}
|
||||
<span className={styles.optional}> {t('customerProducts.form.optional')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="add-delivery-note"
|
||||
type="text"
|
||||
value={deliveryNote}
|
||||
onChange={(e) => setDeliveryNote(e.target.value)}
|
||||
placeholder={t('customerProducts.form.fields.deliveryNotePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<h2 className={styles.stepTitle}>{t('customerProducts.form.step.images')}</h2>
|
||||
<p className={styles.stepDesc}>{t('customerProducts.form.step.imagesHint')}</p>
|
||||
|
||||
<div className={styles.form}>
|
||||
<div className={styles.thumbnailBlock}>
|
||||
<div className={styles.field}>
|
||||
<label>{t('customerProducts.form.images.thumbnail')}</label>
|
||||
<ImageCropper
|
||||
value={thumbnail}
|
||||
onChange={(value) => {
|
||||
setThumbnail(value)
|
||||
if (!value || value.startsWith('data:')) {
|
||||
setFeaturedMediaId(null)
|
||||
}
|
||||
}}
|
||||
aspect={1}
|
||||
hint={t('customerProducts.form.images.thumbnailHint')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sectionDivider}>
|
||||
<span>{t('customerProducts.form.images.gallery')}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<ImageUploader
|
||||
images={gallery}
|
||||
onChange={(next) => {
|
||||
const prevUrlToId = new Map(
|
||||
gallery.map((url, index) => [
|
||||
url,
|
||||
galleryMediaIds[index] ?? '',
|
||||
]),
|
||||
)
|
||||
setGallery(next)
|
||||
setGalleryMediaIds(
|
||||
next.map((url) =>
|
||||
url.startsWith('data:')
|
||||
? ''
|
||||
: prevUrlToId.get(url) ?? '',
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<>
|
||||
<h2 className={styles.stepTitle}>{t('customerProducts.form.step.technical')}</h2>
|
||||
<p className={styles.stepDesc}>{t('customerProducts.form.step.technicalHint')}</p>
|
||||
|
||||
<div className={styles.form}>
|
||||
<fieldset className={styles.conditionFieldset}>
|
||||
<legend>{t('customerProducts.form.fields.condition')}</legend>
|
||||
<div className={styles.radioGrid} role="radiogroup">
|
||||
{CONDITIONS.map((value) => (
|
||||
<label key={value} className={styles.radioCard}>
|
||||
<input
|
||||
type="radio"
|
||||
name="product-condition"
|
||||
value={value}
|
||||
checked={condition === value}
|
||||
onChange={() => setCondition(value)}
|
||||
/>
|
||||
<span>{t(`customerProducts.form.condition.${value}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="add-technical-notes">
|
||||
{t('customerProducts.form.fields.technicalNotes')}
|
||||
<span className={styles.optional}> {t('customerProducts.form.optional')}</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="add-technical-notes"
|
||||
rows={4}
|
||||
value={technicalNotes}
|
||||
onChange={(e) => setTechnicalNotes(e.target.value)}
|
||||
placeholder={t('customerProducts.form.fields.technicalNotesPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.sectionDivider}>
|
||||
<span>{t('customerProducts.form.technical.categoryForm')}</span>
|
||||
</div>
|
||||
|
||||
{!categoryId ? (
|
||||
<div className={styles.placeholder}>
|
||||
{t('customerProducts.form.technical.needCategory')}
|
||||
</div>
|
||||
) : loadingTechnicalForm ? (
|
||||
<p className={styles.inlineStatus}>{t('customerProducts.form.technical.loading')}</p>
|
||||
) : technicalFields.length === 0 ? (
|
||||
<div className={styles.placeholder}>{t('customerProducts.form.technical.empty')}</div>
|
||||
) : (
|
||||
technicalFields.map((field) => (
|
||||
<div key={field.id} className={styles.field}>
|
||||
<label htmlFor={`tech-${field.id}`}>
|
||||
{fieldLabel(field)}
|
||||
{field.isRequired ? (
|
||||
' *'
|
||||
) : (
|
||||
<span className={styles.optional}> {t('customerProducts.form.optional')}</span>
|
||||
)}
|
||||
</label>
|
||||
{renderTechnicalField(field)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stepError ? (
|
||||
<div className={styles.error} role="alert">
|
||||
{stepError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{step === 1 ? (
|
||||
<Link to="/customer-products" className={styles.ghostBtn}>
|
||||
{t('customerProducts.form.cancel')}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
onClick={goBack}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t('customerProducts.form.back')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
<button type="button" className={styles.primaryBtn} onClick={goNext}>
|
||||
{t('customerProducts.form.next')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting || loadingTechnicalForm}
|
||||
>
|
||||
{submitting
|
||||
? t('customerProducts.form.submitting')
|
||||
: isEdit
|
||||
? t('customerProducts.form.save')
|
||||
: t('customerProducts.form.submit')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 20px;
|
||||
aspect-ratio: 3 / 2;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
@@ -15,15 +14,14 @@
|
||||
}
|
||||
|
||||
.heroImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.heroPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(148, 163, 184, 0.12) 0%,
|
||||
|
||||
@@ -528,6 +528,7 @@ export function CategoriesPage() {
|
||||
isGenerating={technicalGenerating}
|
||||
error={technicalError}
|
||||
onClose={() => !technicalSaving && !technicalGenerating && setTechnicalTarget(null)}
|
||||
escapeDisabled={techAiPromptOpen}
|
||||
onChange={(fields) => {
|
||||
setCategoryTechnicalFields((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
.headerRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editBtn,
|
||||
.secondaryBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--field-font-size);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-ui);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editBtn {
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.editBtn:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
color: var(--text-primary);
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
background: rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 320px) 1fr;
|
||||
gap: 28px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.mainImage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.galleryThumbs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.galleryThumb {
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.galleryThumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.imagePlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(148, 163, 184, 0.75);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
inset-inline-start: 10px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 50px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
backdrop-filter: blur(14px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.badge[data-status='draft'] {
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(251, 191, 36, 0.55) 0%,
|
||||
rgba(245, 158, 11, 0.32) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.badge[data-status='published'] {
|
||||
color: #047857;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(167, 243, 208, 0.55) 0%,
|
||||
rgba(52, 211, 153, 0.28) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.badge[data-status='archived'] {
|
||||
color: #e2e8f0;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(148, 163, 184, 0.45) 0%,
|
||||
rgba(100, 116, 139, 0.28) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.badge[data-status='rejected'] {
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(239, 68, 68, 0.55) 0%,
|
||||
rgba(220, 38, 38, 0.32) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.categoryChip {
|
||||
align-self: flex-start;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.summaryRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px 16px;
|
||||
flex-wrap: wrap;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.price {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.summaryMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
margin-inline-start: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-ui);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px 16px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.metaGrid dt {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metaGrid dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.location {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.prose {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.techGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.techGrid dt {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.techGrid dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 24px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 24px 0;
|
||||
font-size: 14px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.headerRow {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { ImageOff, MapPin, Pencil, User } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { UserProductStatusModal } from '../components/UserProductStatusModal'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
getUserProduct,
|
||||
getUserProductCategoryTechnicalForm,
|
||||
updateUserProductStatus,
|
||||
type TechnicalFormField,
|
||||
type UserProductDetail,
|
||||
type UserProductTechnicalValueInput,
|
||||
} from '../services/userProductsService'
|
||||
import type { UserProductStatus } from '../types/userProduct'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CustomerProductDetailsPage.module.css'
|
||||
|
||||
function formatTechnicalValue(
|
||||
field: TechnicalFormField,
|
||||
value: UserProductTechnicalValueInput | undefined,
|
||||
): string {
|
||||
if (!value) return '—'
|
||||
if (value.textValue != null && value.textValue.trim()) return value.textValue
|
||||
if (value.optionId) {
|
||||
return field.options.find((option) => option.id === value.optionId)?.label ?? value.optionId
|
||||
}
|
||||
if (value.optionIds?.length) {
|
||||
return value.optionIds
|
||||
.map(
|
||||
(optionId) =>
|
||||
field.options.find((option) => option.id === optionId)?.label ?? optionId,
|
||||
)
|
||||
.join(', ')
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
export function CustomerProductDetailsPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const isFa = locale === 'fa'
|
||||
|
||||
const [product, setProduct] = useState<UserProductDetail | null>(null)
|
||||
const [technicalFields, setTechnicalFields] = useState<TechnicalFormField[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [statusOpen, setStatusOpen] = useState(false)
|
||||
const [statusSaving, setStatusSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const response = await getUserProduct(id!, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
setProduct(response.product)
|
||||
|
||||
if (response.product.categoryId) {
|
||||
const formResponse = await getUserProductCategoryTechnicalForm(
|
||||
response.product.categoryId,
|
||||
controller.signal,
|
||||
)
|
||||
if (controller.signal.aborted) return
|
||||
setTechnicalFields(formResponse.form?.fields ?? [])
|
||||
} else {
|
||||
setTechnicalFields([])
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t('customerProducts.error.loadDetail')
|
||||
setError(message)
|
||||
setProduct(null)
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [id, t])
|
||||
|
||||
const technicalRows = useMemo(() => {
|
||||
if (!product) return []
|
||||
const byField = new Map(
|
||||
(product.technicalValues ?? []).map((item) => [item.fieldId, item]),
|
||||
)
|
||||
return technicalFields.map((field) => ({
|
||||
id: field.id,
|
||||
label: field.label,
|
||||
value: formatTechnicalValue(field, byField.get(field.id)),
|
||||
}))
|
||||
}, [product, technicalFields])
|
||||
|
||||
async function handleStatusSave(status: UserProductStatus) {
|
||||
if (!product) return
|
||||
setStatusSaving(true)
|
||||
try {
|
||||
const response = await updateUserProductStatus(product.id, status)
|
||||
setProduct((prev) => (prev ? { ...prev, ...response.product } : prev))
|
||||
showToast(t('customerProducts.statusSuccess'), 'success')
|
||||
setStatusOpen(false)
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t('customerProducts.error.status'),
|
||||
'error',
|
||||
)
|
||||
} finally {
|
||||
setStatusSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.status}>{t('customerProducts.loading')}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.error}>{error || t('customerProducts.error.loadDetail')}</p>
|
||||
<Link to="/customer-products" className={styles.backLink}>
|
||||
{t('customerProducts.backToList')}
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const title = isFa ? product.titleFa || product.title : product.titleEn || product.title
|
||||
const secondary = isFa ? product.titleEn || '' : product.titleFa || ''
|
||||
const city = isFa ? product.cityNameFa || product.cityName : product.cityName
|
||||
const country = isFa
|
||||
? product.countryNameFa || product.countryName
|
||||
: product.countryName
|
||||
const category = isFa
|
||||
? product.categoryNameFa || product.categoryName
|
||||
: product.categoryName
|
||||
const owner = isFa ? product.ownerNameFa || product.ownerName : product.ownerName
|
||||
const imageSrc = product.imageUrl?.trim() || ''
|
||||
const galleryImages = (product.images ?? [])
|
||||
.map((item) => item.url?.trim())
|
||||
.filter((url): url is string => Boolean(url))
|
||||
const statusLabel =
|
||||
product.status === 'published'
|
||||
? t('customerProducts.status.published')
|
||||
: product.status === 'archived'
|
||||
? t('customerProducts.status.archived')
|
||||
: product.status === 'rejected'
|
||||
? t('customerProducts.status.rejected')
|
||||
: t('customerProducts.status.pending')
|
||||
const currency = (product.priceCurrency || 'IRT').toUpperCase()
|
||||
const priceLabel =
|
||||
product.price == null
|
||||
? t('customerProducts.priceUnavailable')
|
||||
: currency === 'IRT'
|
||||
? formatIrtPrice(product.price)
|
||||
: `${Number(product.price).toLocaleString('en-US')} ${currency}`
|
||||
const conditionKey = product.condition
|
||||
? `customerProducts.form.condition.${product.condition}`
|
||||
: ''
|
||||
const conditionLabel = conditionKey ? t(conditionKey) : '—'
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('nav.home'), href: '/' },
|
||||
{ label: t('customerProducts.title'), href: '/customer-products' },
|
||||
{ label: title },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={styles.headerRow}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>{t('customerProducts.detailsTitle')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('customerProducts.detailsSubtitle')}</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
onClick={() => setStatusOpen(true)}
|
||||
>
|
||||
{t('customerProducts.statusChange')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.editBtn}
|
||||
onClick={() => navigate(`/customer-products/${product.id}/edit`)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
{t('customerProducts.edit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.layout} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
<div className={styles.gallery}>
|
||||
<div className={styles.mainImage}>
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={title} className={styles.image} />
|
||||
) : (
|
||||
<div className={styles.imagePlaceholder}>
|
||||
<ImageOff size={36} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
<span className={styles.badge} data-status={product.status}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
{galleryImages.length > 0 ? (
|
||||
<div className={styles.galleryThumbs}>
|
||||
{galleryImages.map((url) => (
|
||||
<div key={url} className={styles.galleryThumb}>
|
||||
<img src={url} alt="" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
{category ? <span className={styles.categoryChip}>{category}</span> : null}
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{secondary ? <p className={styles.secondary}>{secondary}</p> : null}
|
||||
|
||||
<div className={styles.summaryRow}>
|
||||
<p className={styles.price} dir="ltr">
|
||||
{priceLabel}
|
||||
</p>
|
||||
<div className={styles.summaryMeta} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
{owner ? (
|
||||
<span className={styles.metaItem}>
|
||||
<User size={14} aria-hidden />
|
||||
{owner}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={styles.metaItem}>
|
||||
<MapPin size={14} aria-hidden />
|
||||
{[city, country].filter(Boolean).join(isFa ? '، ' : ', ') || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className={styles.metaGrid}>
|
||||
<div>
|
||||
<dt>{t('customerProducts.form.fields.country')}</dt>
|
||||
<dd>{country || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('customerProducts.form.fields.city')}</dt>
|
||||
<dd>{city || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('customerProducts.form.fields.condition')}</dt>
|
||||
<dd>{conditionLabel}</dd>
|
||||
</div>
|
||||
{product.priceByExpert ? (
|
||||
<div>
|
||||
<dt>{t('customerProducts.form.fields.priceByExpert')}</dt>
|
||||
<dd>{t('customerProducts.yes')}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{product.description ? (
|
||||
<section className={styles.section}>
|
||||
<h3>{t('customerProducts.form.fields.description')}</h3>
|
||||
<p className={styles.prose}>{product.description}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{product.deliveryNote ? (
|
||||
<section className={styles.section}>
|
||||
<h3>{t('customerProducts.form.fields.deliveryNote')}</h3>
|
||||
<p className={styles.prose}>{product.deliveryNote}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{product.technicalNotes ? (
|
||||
<section className={styles.section}>
|
||||
<h3>{t('customerProducts.form.fields.technicalNotes')}</h3>
|
||||
<p className={styles.prose}>{product.technicalNotes}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{technicalRows.length > 0 ? (
|
||||
<section className={styles.section}>
|
||||
<h3>{t('customerProducts.form.technical.categoryForm')}</h3>
|
||||
<dl className={styles.techGrid}>
|
||||
{technicalRows.map((row) => (
|
||||
<div key={row.id}>
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserProductStatusModal
|
||||
open={statusOpen}
|
||||
currentStatus={product.status}
|
||||
saving={statusSaving}
|
||||
onClose={() => setStatusOpen(false)}
|
||||
onSave={(status) => void handleStatusSave(status)}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.emptyLink {
|
||||
margin-top: 4px;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.emptyLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status,
|
||||
.error {
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fabDock {
|
||||
position: fixed;
|
||||
inset-inline-end: 32px;
|
||||
bottom: 32px;
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
.addFab {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.addFab:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Package, Plus } from 'lucide-react'
|
||||
import { Pagination } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { UserProductCard } from '../components/UserProductCard'
|
||||
import { UserProductStatusModal } from '../components/UserProductStatusModal'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
deleteUserProduct,
|
||||
listUserProducts,
|
||||
promoteUserProduct,
|
||||
updateUserProductStatus,
|
||||
type UserProductsListResponse,
|
||||
} from '../services/userProductsService'
|
||||
import type { UserProductStatus } from '../types/userProduct'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CustomerProductsPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
|
||||
export function CustomerProductsPage() {
|
||||
const t = useT()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [data, setData] = useState<UserProductsListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [busyAction, setBusyAction] = useState<
|
||||
'remove' | 'promote' | 'status' | null
|
||||
>(null)
|
||||
const [statusProductId, setStatusProductId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const response = await listUserProducts(
|
||||
{ page, pageSize: PAGE_SIZE },
|
||||
controller.signal,
|
||||
)
|
||||
if (controller.signal.aborted) return
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : t('customerProducts.error.load'),
|
||||
)
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [page, t])
|
||||
|
||||
const products = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
const statusProduct = products.find((item) => item.id === statusProductId)
|
||||
|
||||
function handleEdit(id: string) {
|
||||
navigate(`/customer-products/${id}/edit`)
|
||||
}
|
||||
|
||||
async function handleRemove(id: string) {
|
||||
if (!window.confirm(t('customerProducts.removeConfirm'))) return
|
||||
|
||||
setBusyId(id)
|
||||
setBusyAction('remove')
|
||||
try {
|
||||
await deleteUserProduct(id)
|
||||
setData((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
items: prev.items.filter((item) => item.id !== id),
|
||||
total: Math.max(0, prev.total - 1),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
showToast(t('customerProducts.removeSuccess'), 'success')
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t('customerProducts.error.remove'),
|
||||
'error',
|
||||
)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
setBusyAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePromote(id: string) {
|
||||
setBusyId(id)
|
||||
setBusyAction('promote')
|
||||
try {
|
||||
const response = await promoteUserProduct(id)
|
||||
setData((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === id ? { ...item, ...response.product } : item,
|
||||
),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
showToast(t('customerProducts.promoteSuccess'), 'success')
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t('customerProducts.error.promote'),
|
||||
'error',
|
||||
)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
setBusyAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusSave(status: UserProductStatus) {
|
||||
if (!statusProductId) return
|
||||
setBusyId(statusProductId)
|
||||
setBusyAction('status')
|
||||
try {
|
||||
const response = await updateUserProductStatus(statusProductId, status)
|
||||
setData((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === statusProductId
|
||||
? { ...item, ...response.product }
|
||||
: item,
|
||||
),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
showToast(t('customerProducts.statusSuccess'), 'success')
|
||||
setStatusProductId(null)
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t('customerProducts.error.status'),
|
||||
'error',
|
||||
)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
setBusyAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('nav.home'), href: '/' },
|
||||
{ label: t('customerProducts.title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>{t('customerProducts.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{t('customerProducts.subtitle', { count: total })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? <p className={styles.status}>{t('customerProducts.loading')}</p> : null}
|
||||
|
||||
{!loading && !error && products.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Package size={32} />
|
||||
<p>{t('customerProducts.empty')}</p>
|
||||
<Link to="/customer-products/new" className={styles.emptyLink}>
|
||||
{t('customerProducts.add')}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && products.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{products.map((product) => (
|
||||
<UserProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
to={`/customer-products/${product.id}`}
|
||||
onEdit={handleEdit}
|
||||
onPromote={handlePromote}
|
||||
onChangeStatus={setStatusProductId}
|
||||
onRemove={handleRemove}
|
||||
busyAction={busyId === product.id ? busyAction : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 ? (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
disabled={loading}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className={styles.fabDock}>
|
||||
<Link
|
||||
to="/customer-products/new"
|
||||
className={styles.addFab}
|
||||
aria-label={t('customerProducts.add')}
|
||||
>
|
||||
<Plus size={22} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<UserProductStatusModal
|
||||
open={Boolean(statusProduct)}
|
||||
currentStatus={statusProduct?.status ?? 'draft'}
|
||||
saving={busyAction === 'status'}
|
||||
onClose={() => setStatusProductId(null)}
|
||||
onSave={(status) => void handleStatusSave(status)}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user