Add OTP-first customer signup and product catalog PDF field.
Registration collects name and phone, verifies SMS, then asks for a password only for new Meshkee accounts. Product add/edit supports an optional catalog PDF. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e2950fa20e
commit
6d85a217e1
@@ -679,6 +679,12 @@ const en = {
|
||||
'products.form.imagesAdd': 'Add images',
|
||||
'products.form.imagesHint': 'Upload multiple product images. Click + to add more.',
|
||||
'products.form.imagesRemove': 'Remove image {index}',
|
||||
'products.form.catalog': 'Product catalog (PDF)',
|
||||
'products.form.catalogHint': 'Optional. PDF only, maximum 2MB.',
|
||||
'products.form.catalogView': 'View catalog',
|
||||
'products.form.catalogRemove': 'Remove catalog file',
|
||||
'products.form.catalog.errorType': 'Catalog file must be a PDF.',
|
||||
'products.form.catalog.errorSize': 'Catalog PDF must be 2MB or smaller.',
|
||||
'products.form.tags': 'Tags',
|
||||
'products.form.tagsPlaceholder': 'Type a tag and press Enter',
|
||||
'products.form.cancel': 'Cancel',
|
||||
@@ -3440,6 +3446,12 @@ const fa: Record<MessageKey, string> = {
|
||||
'products.form.imagesAdd': 'افزودن تصاویر',
|
||||
'products.form.imagesHint': 'چند تصویر محصول آپلود کنید. برای افزودن بیشتر روی + کلیک کنید.',
|
||||
'products.form.imagesRemove': 'حذف تصویر {index}',
|
||||
'products.form.catalog': 'کاتالوگ محصول (PDF)',
|
||||
'products.form.catalogHint': 'اختیاری. فقط PDF، حداکثر ۲ مگابایت.',
|
||||
'products.form.catalogView': 'مشاهده کاتالوگ',
|
||||
'products.form.catalogRemove': 'حذف فایل کاتالوگ',
|
||||
'products.form.catalog.errorType': 'فایل کاتالوگ باید PDF باشد.',
|
||||
'products.form.catalog.errorSize': 'حجم PDF کاتالوگ باید حداکثر ۲ مگابایت باشد.',
|
||||
'products.form.tags': 'برچسبها',
|
||||
'products.form.tagsPlaceholder': 'برچسب را بنویسید و Enter بزنید',
|
||||
'products.form.cancel': 'انصراف',
|
||||
|
||||
@@ -87,6 +87,64 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.catalogHint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.catalogRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.catalogInput {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.catalogSelected {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalogSelected span,
|
||||
.catalogLink {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.catalogLink {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.catalogClear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.catalogClear:hover {
|
||||
color: var(--text);
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.summaryField textarea {
|
||||
min-height: calc((var(--field-height) + 8px) * 2);
|
||||
padding-inline-end: 12px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { FileText, Sparkles, X } from 'lucide-react'
|
||||
import { CategoryTreeSelect } from '../components/CategoryTreeSelect'
|
||||
import { SearchableSelect } from '../components/SearchableSelect'
|
||||
import { ImageCropper } from '../components/ImageCropper'
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
galleryMediaIdsAfterChange,
|
||||
resolveDataUrlToMediaId,
|
||||
resolveDataUrlsToMediaIds,
|
||||
uploadCatalogPdf,
|
||||
} from '../services/mediaService'
|
||||
import {
|
||||
createProduct,
|
||||
@@ -34,12 +35,15 @@ import aiStyles from '../styles/ai.module.css'
|
||||
import styles from './AddNewProductPage.module.css'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
const CATALOG_PDF_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
export function AddNewProductPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const isEdit = Boolean(id)
|
||||
const catalogInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([])
|
||||
@@ -54,6 +58,11 @@ export function AddNewProductPage() {
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
|
||||
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
|
||||
const [catalogMediaId, setCatalogMediaId] = useState<string | null>(null)
|
||||
const [catalogUrl, setCatalogUrl] = useState<string | null>(null)
|
||||
const [catalogFileName, setCatalogFileName] = useState<string | null>(null)
|
||||
const [catalogFile, setCatalogFile] = useState<File | null>(null)
|
||||
const [catalogRemoved, setCatalogRemoved] = useState(false)
|
||||
const [loaded, setLoaded] = useState(!isEdit)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -172,6 +181,11 @@ export function AddNewProductPage() {
|
||||
setTags(form.tags)
|
||||
setThumbnailMediaId(form.thumbnailMediaId)
|
||||
setGalleryMediaIds(form.galleryMediaIds)
|
||||
setCatalogMediaId(form.catalogMediaId)
|
||||
setCatalogUrl(form.catalogUrl)
|
||||
setCatalogFileName(form.catalogFileName)
|
||||
setCatalogFile(null)
|
||||
setCatalogRemoved(false)
|
||||
setLoaded(true)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
@@ -185,6 +199,39 @@ export function AddNewProductPage() {
|
||||
|
||||
if (!loaded) return null
|
||||
|
||||
function clearCatalogFile() {
|
||||
setCatalogFile(null)
|
||||
setCatalogMediaId(null)
|
||||
setCatalogUrl(null)
|
||||
setCatalogFileName(null)
|
||||
setCatalogRemoved(true)
|
||||
if (catalogInputRef.current) {
|
||||
catalogInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleCatalogFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0] ?? null
|
||||
if (!file) return
|
||||
|
||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
setError(t('products.form.catalog.errorType'))
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
if (file.size > CATALOG_PDF_MAX_BYTES) {
|
||||
setError(t('products.form.catalog.errorSize'))
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
setCatalogFile(file)
|
||||
setCatalogFileName(file.name)
|
||||
setCatalogUrl(null)
|
||||
setCatalogRemoved(false)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
@@ -208,6 +255,14 @@ export function AddNewProductPage() {
|
||||
galleryMediaIds,
|
||||
)
|
||||
|
||||
let nextCatalogMediaId: string | null = catalogMediaId
|
||||
if (catalogFile) {
|
||||
const uploaded = await uploadCatalogPdf(catalogFile)
|
||||
nextCatalogMediaId = uploaded.id
|
||||
} else if (catalogRemoved) {
|
||||
nextCatalogMediaId = null
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: nameEn.trim(),
|
||||
nameFa: nameFa.trim(),
|
||||
@@ -217,6 +272,7 @@ export function AddNewProductPage() {
|
||||
brandId: brandId || undefined,
|
||||
featuredMediaId: featuredMediaId ?? undefined,
|
||||
galleryMediaIds: nextGalleryMediaIds,
|
||||
catalogMediaId: nextCatalogMediaId ?? undefined,
|
||||
tags,
|
||||
status: 'published' as const,
|
||||
}
|
||||
@@ -227,6 +283,7 @@ export function AddNewProductPage() {
|
||||
categoryId: categoryId || null,
|
||||
brandId: brandId || null,
|
||||
featuredMediaId,
|
||||
catalogMediaId: nextCatalogMediaId,
|
||||
})
|
||||
} else {
|
||||
await createProduct(payload)
|
||||
@@ -434,6 +491,48 @@ export function AddNewProductPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label htmlFor="product-catalog">{t('products.form.catalog')}</label>
|
||||
<p className={styles.catalogHint}>{t('products.form.catalogHint')}</p>
|
||||
<div className={styles.catalogRow}>
|
||||
<input
|
||||
ref={catalogInputRef}
|
||||
id="product-catalog"
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
onChange={handleCatalogFileChange}
|
||||
disabled={isSubmitting}
|
||||
className={styles.catalogInput}
|
||||
/>
|
||||
{(catalogFileName || catalogUrl) && (
|
||||
<div className={styles.catalogSelected}>
|
||||
<FileText size={16} aria-hidden />
|
||||
{catalogUrl && !catalogFile ? (
|
||||
<a
|
||||
href={catalogUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.catalogLink}
|
||||
>
|
||||
{catalogFileName || t('products.form.catalogView')}
|
||||
</a>
|
||||
) : (
|
||||
<span>{catalogFileName}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.catalogClear}
|
||||
onClick={clearCatalogFile}
|
||||
disabled={isSubmitting}
|
||||
aria-label={t('products.form.catalogRemove')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>{t('products.form.tags')}</label>
|
||||
<TagInput
|
||||
|
||||
@@ -108,6 +108,23 @@ export async function uploadMediaFiles(
|
||||
}))
|
||||
}
|
||||
|
||||
const CATALOG_PDF_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
export async function uploadCatalogPdf(file: File, signal?: AbortSignal) {
|
||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
throw new Error('Catalog file must be a PDF')
|
||||
}
|
||||
if (file.size > CATALOG_PDF_MAX_BYTES) {
|
||||
throw new Error('Catalog PDF must be 2MB or smaller')
|
||||
}
|
||||
const uploaded = await uploadMediaFiles([file], signal)
|
||||
const item = uploaded[0]
|
||||
if (!item) {
|
||||
throw new Error('Catalog upload failed')
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
export async function resolveDataUrlToMediaId(
|
||||
value: string | null,
|
||||
filename: string,
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface ProductApi {
|
||||
seoMetaDescription?: string | null
|
||||
thumbnailUrl: string | null
|
||||
thumbnailMediaId: string | null
|
||||
catalogMediaId: string | null
|
||||
catalogUrl: string | null
|
||||
catalogFileName: string | null
|
||||
image: string
|
||||
thumbnail: string
|
||||
images: { mediaId: string; url: string }[]
|
||||
@@ -58,6 +61,7 @@ export interface CreateProductPayload {
|
||||
brandId?: string
|
||||
status?: 'draft' | 'published' | 'archived'
|
||||
featuredMediaId?: string
|
||||
catalogMediaId?: string
|
||||
galleryMediaIds?: string[]
|
||||
tags?: string[]
|
||||
seoMetaTitle?: string | null
|
||||
@@ -73,6 +77,7 @@ export interface UpdateProductPayload {
|
||||
brandId?: string | null
|
||||
status?: 'draft' | 'published' | 'archived'
|
||||
featuredMediaId?: string | null
|
||||
catalogMediaId?: string | null
|
||||
galleryMediaIds?: string[]
|
||||
tags?: string[]
|
||||
seoMetaTitle?: string | null
|
||||
@@ -155,6 +160,9 @@ export function mapProductApiToFormState(product: ProductApi) {
|
||||
seoMetaDescription: product.seoMetaDescription ?? '',
|
||||
thumbnailMediaId: product.thumbnailMediaId,
|
||||
galleryMediaIds: product.galleryMediaIds,
|
||||
catalogMediaId: product.catalogMediaId,
|
||||
catalogUrl: product.catalogUrl,
|
||||
catalogFileName: product.catalogFileName,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user