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:
Alireza Hassani
2026-09-27 08:57:08 +03:30
co-authored by Cursor
parent e2950fa20e
commit 6d85a217e1
12 changed files with 746 additions and 292 deletions
+12
View File
@@ -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;
+101 -2
View File
@@ -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,
}
}