You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1113 lines
49 KiB
1113 lines
49 KiB
import * as React from 'react'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogClose,
|
|
} from '@/components/ui/dialog'
|
|
import { Button, ButtonSpinner } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Ic } from '@/icons'
|
|
import { createTour, uploadTourImage, type CreateTourPayload } from '../services/tour-api'
|
|
import type { TourTranslationData } from '../types'
|
|
|
|
interface CreateTourDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onSuccess?: () => void
|
|
}
|
|
|
|
const SUPPORTED_LANGUAGES = [
|
|
{ code: 'ar', label: 'العربية', flag: '🇸🇦', dir: 'rtl' },
|
|
{ code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' },
|
|
{ code: 'en', label: 'English', flag: '🇬🇧', dir: 'ltr' },
|
|
{ code: 'ru', label: 'Русский', flag: '🇷🇺', dir: 'ltr' },
|
|
{ code: 'id', label: 'Indonesia', flag: '🇮🇩', dir: 'ltr' },
|
|
] as const
|
|
|
|
type LangCode = (typeof SUPPORTED_LANGUAGES)[number]['code']
|
|
|
|
export function CreateTourDialog({
|
|
open,
|
|
onOpenChange,
|
|
onSuccess,
|
|
}: CreateTourDialogProps) {
|
|
const [activeTab, setActiveTab] = React.useState<'basic' | 'features_tips' | 'translations'>('basic')
|
|
const [selectedTransLang, setSelectedTransLang] = React.useState<LangCode>('en')
|
|
const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false)
|
|
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
|
|
const [successMessage, setSuccessMessage] = React.useState<string | null>(null)
|
|
|
|
// Basic info states
|
|
const [title, setTitle] = React.useState('')
|
|
const [description, setDescription] = React.useState('')
|
|
const [startedAt, setStartedAt] = React.useState('')
|
|
const [endedAt, setEndedAt] = React.useState('')
|
|
const [capacity, setCapacity] = React.useState('40')
|
|
const [primaryLang, setPrimaryLang] = React.useState<LangCode>('ar')
|
|
const [status, setStatus] = React.useState('AVAILABLE')
|
|
const [visibility, setVisibility] = React.useState<boolean>(true)
|
|
|
|
// Image states (mandatory cover photo)
|
|
const [imageId, setImageId] = React.useState<number | null>(null)
|
|
const [imageUrl, setImageUrl] = React.useState<string | null>(null)
|
|
const [imageName, setImageName] = React.useState<string | null>(null)
|
|
const [isUploadingImage, setIsUploadingImage] = React.useState<boolean>(false)
|
|
const [imageUploadError, setImageUploadError] = React.useState<string | null>(null)
|
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
|
|
|
|
// Pricing states
|
|
const [price, setPrice] = React.useState('')
|
|
const [priceChild, setPriceChild] = React.useState('')
|
|
const [priceInfant, setPriceInfant] = React.useState('')
|
|
const [percentOff, setPercentOff] = React.useState('0')
|
|
|
|
// Base features & tips
|
|
const [features, setFeatures] = React.useState<string[]>([])
|
|
const [newFeature, setNewFeature] = React.useState('')
|
|
const [tips, setTips] = React.useState<{ title: string; desc: string }[]>([])
|
|
const [newTipTitle, setNewTipTitle] = React.useState('')
|
|
const [newTipDesc, setNewTipDesc] = React.useState('')
|
|
|
|
// Multi-language translation state
|
|
const [translations, setTranslations] = React.useState<Record<string, TourTranslationData>>({
|
|
fa: { title: '', description: '', tour_feature: [], travel_tips: {} },
|
|
ar: { title: '', description: '', tour_feature: [], travel_tips: {} },
|
|
en: { title: '', description: '', tour_feature: [], travel_tips: {} },
|
|
ru: { title: '', description: '', tour_feature: [], travel_tips: {} },
|
|
id: { title: '', description: '', tour_feature: [], travel_tips: {} },
|
|
})
|
|
|
|
// Reset form when opened
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
setErrorMessage(null)
|
|
setSuccessMessage(null)
|
|
setActiveTab('basic')
|
|
setImageId(null)
|
|
setImageUrl(null)
|
|
setImageName(null)
|
|
setImageUploadError(null)
|
|
}
|
|
}, [open])
|
|
|
|
const processImageUpload = async (file: File) => {
|
|
if (!file.type.startsWith('image/')) {
|
|
setImageUploadError('لطفاً فقط فایل تصویری (JPG، PNG، WEBP) انتخاب نمایید.')
|
|
return
|
|
}
|
|
if (file.size > 15 * 1024 * 1024) {
|
|
setImageUploadError('حجم تصویر نباید بیشتر از 15 مگابایت باشد.')
|
|
return
|
|
}
|
|
|
|
setImageUploadError(null)
|
|
setIsUploadingImage(true)
|
|
try {
|
|
const res = await uploadTourImage(file)
|
|
setImageId(res.id)
|
|
setImageUrl(res.url || URL.createObjectURL(file))
|
|
setImageName(file.name)
|
|
} catch (err: any) {
|
|
setImageUploadError(err?.message || 'خطا در آپلود تصویر در سرور.')
|
|
} finally {
|
|
setIsUploadingImage(false)
|
|
}
|
|
}
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
await processImageUpload(file)
|
|
}
|
|
|
|
const handleRemoveImage = () => {
|
|
setImageId(null)
|
|
setImageUrl(null)
|
|
setImageName(null)
|
|
setImageUploadError(null)
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = ''
|
|
}
|
|
}
|
|
|
|
const handleAddFeature = () => {
|
|
if (!newFeature.trim()) return
|
|
setFeatures((prev) => [...prev, newFeature.trim()])
|
|
setNewFeature('')
|
|
}
|
|
|
|
const handleRemoveFeature = (idx: number) => {
|
|
setFeatures((prev) => prev.filter((_, i) => i !== idx))
|
|
}
|
|
|
|
const handleAddTip = () => {
|
|
if (!newTipTitle.trim() || !newTipDesc.trim()) return
|
|
setTips((prev) => [...prev, { title: newTipTitle.trim(), desc: newTipDesc.trim() }])
|
|
setNewTipTitle('')
|
|
setNewTipDesc('')
|
|
}
|
|
|
|
const handleRemoveTip = (idx: number) => {
|
|
setTips((prev) => prev.filter((_, i) => i !== idx))
|
|
}
|
|
|
|
// Helper for updating translation fields for selectedTransLang
|
|
const handleTransFieldChange = (field: 'title' | 'description', val: string) => {
|
|
setTranslations((prev) => ({
|
|
...prev,
|
|
[selectedTransLang]: {
|
|
...prev[selectedTransLang],
|
|
[field]: val,
|
|
},
|
|
}))
|
|
}
|
|
|
|
const handleAddTransFeature = (text: string) => {
|
|
if (!text.trim()) return
|
|
setTranslations((prev) => {
|
|
const current = prev[selectedTransLang] || { tour_feature: [] }
|
|
const currFeats = current.tour_feature || []
|
|
return {
|
|
...prev,
|
|
[selectedTransLang]: {
|
|
...current,
|
|
tour_feature: [...currFeats, { title: text.trim() }],
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleRemoveTransFeature = (idx: number) => {
|
|
setTranslations((prev) => {
|
|
const current = prev[selectedTransLang] || { tour_feature: [] }
|
|
const currFeats = current.tour_feature || []
|
|
return {
|
|
...prev,
|
|
[selectedTransLang]: {
|
|
...current,
|
|
tour_feature: currFeats.filter((_, i) => i !== idx),
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleAddTransTip = (tipTitle: string, tipDesc: string) => {
|
|
if (!tipTitle.trim() || !tipDesc.trim()) return
|
|
setTranslations((prev) => {
|
|
const current = prev[selectedTransLang] || { travel_tips: {} }
|
|
const currTips = current.travel_tips || {}
|
|
return {
|
|
...prev,
|
|
[selectedTransLang]: {
|
|
...current,
|
|
travel_tips: {
|
|
...currTips,
|
|
[tipTitle.trim()]: tipDesc.trim(),
|
|
},
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleRemoveTransTip = (tipTitle: string) => {
|
|
setTranslations((prev) => {
|
|
const current = prev[selectedTransLang] || { travel_tips: {} }
|
|
const currTips = { ...(current.travel_tips || {}) }
|
|
delete currTips[tipTitle]
|
|
return {
|
|
...prev,
|
|
[selectedTransLang]: {
|
|
...current,
|
|
travel_tips: currTips,
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
const [newTransFeatText, setNewTransFeatText] = React.useState('')
|
|
const [newTransTipTitle, setNewTransTipTitle] = React.useState('')
|
|
const [newTransTipDesc, setNewTransTipDesc] = React.useState('')
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setErrorMessage(null)
|
|
setSuccessMessage(null)
|
|
|
|
// Validation
|
|
if (!title.trim()) {
|
|
setErrorMessage('لطفاً عنوان اصلی تور را وارد نمایید.')
|
|
setActiveTab('basic')
|
|
return
|
|
}
|
|
if (!imageId) {
|
|
setErrorMessage('بارگذاری تصویر شاخص برای ایجاد تور الزامی است.')
|
|
setActiveTab('basic')
|
|
return
|
|
}
|
|
if (!startedAt) {
|
|
setErrorMessage('لطفاً تاریخ شروع تور را مشخص نمایید.')
|
|
setActiveTab('basic')
|
|
return
|
|
}
|
|
if (!endedAt) {
|
|
setErrorMessage('لطفاً تاریخ پایان تور را مشخص نمایید.')
|
|
setActiveTab('basic')
|
|
return
|
|
}
|
|
if (new Date(endedAt) < new Date(startedAt)) {
|
|
setErrorMessage('تاریخ پایان تور نمیتواند قبل از تاریخ شروع باشد.')
|
|
setActiveTab('basic')
|
|
return
|
|
}
|
|
if (!price || isNaN(Number(price)) || Number(price) <= 0) {
|
|
setErrorMessage('لطفاً قیمت معتبر برای بزرگسال وارد نمایید.')
|
|
setActiveTab('basic')
|
|
return
|
|
}
|
|
if (!capacity || isNaN(Number(capacity)) || Number(capacity) <= 0) {
|
|
setErrorMessage('لطفاً ظرفیت معتبر برای تور وارد نمایید.')
|
|
setActiveTab('basic')
|
|
return
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
|
|
try {
|
|
const tour_feature = features.map((f) => ({ title: f }))
|
|
const travel_tips: Record<string, string> = {}
|
|
tips.forEach((t) => {
|
|
travel_tips[t.title] = t.desc
|
|
})
|
|
|
|
// Clean translations payload: include any language that has data
|
|
const cleanedTranslations: Record<string, TourTranslationData> = {}
|
|
Object.entries(translations).forEach(([code, data]) => {
|
|
const hasTitle = Boolean(data.title?.trim())
|
|
const hasDesc = Boolean(data.description?.trim())
|
|
const hasFeats = Boolean(data.tour_feature && data.tour_feature.length > 0)
|
|
const hasTips = Boolean(data.travel_tips && Object.keys(data.travel_tips).length > 0)
|
|
|
|
if (hasTitle || hasDesc || hasFeats || hasTips) {
|
|
cleanedTranslations[code] = {
|
|
title: data.title?.trim() || (code === primaryLang ? title.trim() : ''),
|
|
description: data.description?.trim() || '',
|
|
tour_feature: data.tour_feature || [],
|
|
travel_tips: data.travel_tips || {},
|
|
}
|
|
}
|
|
})
|
|
|
|
// Ensure primary language translation is also present if not defined
|
|
if (!cleanedTranslations[primaryLang]) {
|
|
cleanedTranslations[primaryLang] = {
|
|
title: title.trim(),
|
|
description: description.trim(),
|
|
tour_feature,
|
|
travel_tips,
|
|
}
|
|
}
|
|
|
|
const payload: CreateTourPayload = {
|
|
title: title.trim(),
|
|
description: description.trim(),
|
|
started_at: startedAt,
|
|
ended_at: endedAt,
|
|
capacity: Number(capacity),
|
|
price: String(price),
|
|
price_child: priceChild ? String(priceChild) : '0',
|
|
price_infant: priceInfant ? String(priceInfant) : '0',
|
|
percent_off: percentOff ? Number(percentOff) : 0,
|
|
language_code: primaryLang,
|
|
status,
|
|
tour_feature,
|
|
travel_tips,
|
|
visibility,
|
|
translations: cleanedTranslations,
|
|
image_id: imageId,
|
|
image_ids: [imageId],
|
|
}
|
|
|
|
const createdTour = await createTour(payload)
|
|
setSuccessMessage(`تور "${createdTour.title}" با موفقیت ایجاد و ترجمهها در دیتابیس ثبت گردید.`)
|
|
|
|
setTimeout(() => {
|
|
if (onSuccess) onSuccess()
|
|
onOpenChange(false)
|
|
}, 900)
|
|
} catch (err: any) {
|
|
setErrorMessage(err?.message || 'خطا در ثبت تور جدید در سرور.')
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const activeTransConfig = SUPPORTED_LANGUAGES.find((l) => l.code === selectedTransLang)
|
|
const currentTransData = translations[selectedTransLang] || {
|
|
title: '',
|
|
description: '',
|
|
tour_feature: [],
|
|
travel_tips: {},
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
onPointerDownOutside={(e) => e.preventDefault()}
|
|
onInteractOutside={(e) => e.preventDefault()}
|
|
className="max-w-5xl xl:max-w-6xl w-[95vw] p-0 overflow-hidden h-[90vh] min-h-[600px] max-h-[92vh] flex flex-col border border-border-soft bg-background rounded-2xl shadow-2xl"
|
|
>
|
|
{/* Header */}
|
|
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
|
|
<Ic name="plus" className="size-5" />
|
|
</div>
|
|
<div>
|
|
<DialogTitle className="text-base font-extrabold text-foreground">
|
|
افزودن تور زیارتی و گردشگری جدید
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-grey-3 mt-0.5">
|
|
ثبت مشخصات پایه، هزینهها، ظرفیت، تصویر شاخص و ورودیهای ترجمه 5 زبانه
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Step/Tab buttons */}
|
|
<div className="flex items-center gap-1 rounded-xl bg-surface-2 p-1 border border-border/40">
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('basic')}
|
|
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all cursor-pointer ${
|
|
activeTab === 'basic'
|
|
? 'bg-primary text-white shadow-xs'
|
|
: 'text-grey-3 hover:text-foreground'
|
|
}`}
|
|
>
|
|
1. مشخصات و قیمت
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('features_tips')}
|
|
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all cursor-pointer ${
|
|
activeTab === 'features_tips'
|
|
? 'bg-primary text-white shadow-xs'
|
|
: 'text-grey-3 hover:text-foreground'
|
|
}`}
|
|
>
|
|
2. ویژگیها و نکات
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('translations')}
|
|
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all flex items-center gap-1.5 cursor-pointer ${
|
|
activeTab === 'translations'
|
|
? 'bg-primary text-white shadow-xs'
|
|
: 'text-grey-3 hover:text-foreground'
|
|
}`}
|
|
>
|
|
<Ic name="globe" className="size-3.5" />
|
|
<span>3. ترجمههای 5 زبانه</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* دکمه علامت ضربدر بالای مودال */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenChange(false)}
|
|
aria-label="بستن پنجره"
|
|
title="بستن"
|
|
className="flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer"
|
|
>
|
|
<Ic name="x" className="size-4.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{/* Form Body */}
|
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto px-6 py-5 space-y-5 flex flex-col justify-between">
|
|
<div className="space-y-4">
|
|
{errorMessage && (
|
|
<div className="flex items-center gap-2 rounded-xl bg-rose-500/10 border border-rose-500/20 px-4 py-3 text-xs text-rose-400">
|
|
<Ic name="alert" className="size-4 shrink-0" />
|
|
<span>{errorMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{successMessage && (
|
|
<div className="flex items-center gap-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 text-xs text-emerald-400">
|
|
<Ic name="check" className="size-4 shrink-0" />
|
|
<span>{successMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* TAB 1: Basic Info & Pricing */}
|
|
{activeTab === 'basic' && (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div className="sm:col-span-2">
|
|
<label className="block text-xs font-bold text-foreground mb-1.5">
|
|
عنوان اصلی تور <span className="text-rose-500">*</span>:
|
|
</label>
|
|
<Input
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="مثال: تور زیارتی عتبات عالیات نجف و کربلا (ویژه اربعین)..."
|
|
className="bg-surface-2 border-border/50 text-sm font-medium"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* بخش آپلود تصویر شاخص تور (الزامی) */}
|
|
<div className="sm:col-span-2">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
|
<span>تصویر شاخص تور (کاور)</span>
|
|
<span className="text-rose-500 font-black">*</span>
|
|
<span className="text-[11px] font-normal text-grey-3">(الزامی - JPG، PNG، WEBP)</span>
|
|
</label>
|
|
{imageId && (
|
|
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md border border-emerald-500/20">
|
|
<Ic name="check" className="size-3" />
|
|
تصویر بارگذاری شد (کد: #{imageId})
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/jpg"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
|
|
{imageUrl ? (
|
|
<div className="relative overflow-hidden rounded-2xl border border-border-soft bg-surface-2 p-3 flex items-center gap-4">
|
|
<div className="relative size-20 sm:size-24 rounded-xl overflow-hidden bg-black/40 border border-border-soft shrink-0">
|
|
<img
|
|
src={imageUrl}
|
|
alt="پیشنمایش کاور تور"
|
|
className="size-full object-cover"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-bold text-foreground truncate">
|
|
{imageName || 'تصویر تور'}
|
|
</p>
|
|
<span className="text-[10px] font-mono text-grey-3 bg-card px-1.5 py-0.5 rounded border border-border/40 shrink-0">
|
|
#{imageId}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-grey-3 mt-1">
|
|
تصویر با موفقیت در سرور ذخیره شد و به عنوان تصویر شاخص تور نمایش داده میشود.
|
|
</p>
|
|
<div className="mt-2.5 flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isUploadingImage}
|
|
className="h-7 text-xs border-border/60 hover:bg-surface-3 cursor-pointer"
|
|
>
|
|
<Ic name="pencil" className="size-3 ml-1" />
|
|
تغییر تصویر
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleRemoveImage}
|
|
disabled={isUploadingImage}
|
|
className="h-7 text-xs text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 cursor-pointer"
|
|
>
|
|
<Ic name="trash" className="size-3 ml-1" />
|
|
حذف تصویر
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onClick={() => !isUploadingImage && fileInputRef.current?.click()}
|
|
onDragOver={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const file = e.dataTransfer.files?.[0]
|
|
if (file) processImageUpload(file)
|
|
}}
|
|
className={`relative flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-6 text-center transition-all cursor-pointer ${
|
|
imageUploadError
|
|
? 'border-rose-500/40 bg-rose-500/5'
|
|
: 'border-border-soft hover:border-primary/50 bg-surface-2/60 hover:bg-surface-2'
|
|
}`}
|
|
>
|
|
{isUploadingImage ? (
|
|
<div className="flex flex-col items-center gap-2 py-2">
|
|
<ButtonSpinner size="large" className="text-primary size-8" />
|
|
<p className="text-xs font-bold text-foreground">در حال بارگذاری و بهینهسازی تصویر در سرور...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary mb-3">
|
|
<Ic name="upload" className="size-6" />
|
|
</div>
|
|
<p className="text-xs font-bold text-foreground">
|
|
برای انتخاب تصویر شاخص کلیک کنید یا فایل را اینجا رها کنید (Drag & Drop)
|
|
</p>
|
|
<p className="text-[11px] text-grey-3 mt-1">
|
|
فرمتهای پشتیبانیشده: JPG، PNG، WEBP — حداکثر حجم: 15 مگابایت
|
|
</p>
|
|
{imageUploadError && (
|
|
<p className="text-xs text-rose-400 font-medium mt-2">
|
|
{imageUploadError}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="sm:col-span-2">
|
|
<label className="block text-xs font-bold text-foreground mb-1.5">
|
|
زبان اصلی ثبت داده:
|
|
</label>
|
|
<select
|
|
value={primaryLang}
|
|
onChange={(e) => setPrimaryLang(e.target.value as LangCode)}
|
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none"
|
|
>
|
|
{SUPPORTED_LANGUAGES.map((l) => (
|
|
<option key={l.code} value={l.code}>
|
|
{l.flag} {l.label} ({l.code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-foreground mb-1.5">
|
|
تاریخ شروع تور <span className="text-rose-500">*</span>:
|
|
</label>
|
|
<Input
|
|
type="date"
|
|
value={startedAt}
|
|
onChange={(e) => setStartedAt(e.target.value)}
|
|
className="bg-surface-2 border-border/50 text-sm font-mono"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-foreground mb-1.5">
|
|
تاریخ پایان تور <span className="text-rose-500">*</span>:
|
|
</label>
|
|
<Input
|
|
type="date"
|
|
value={endedAt}
|
|
onChange={(e) => setEndedAt(e.target.value)}
|
|
className="bg-surface-2 border-border/50 text-sm font-mono"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-foreground mb-1.5">
|
|
ظرفیت کل مسافران <span className="text-rose-500">*</span>:
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
value={capacity}
|
|
onChange={(e) => setCapacity(e.target.value)}
|
|
placeholder="40"
|
|
className="bg-surface-2 border-border/50 text-sm font-mono"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-foreground mb-1.5">
|
|
وضعیت فروش اولیه:
|
|
</label>
|
|
<select
|
|
value={status}
|
|
onChange={(e) => setStatus(e.target.value)}
|
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none"
|
|
>
|
|
<option value="AVAILABLE">در دسترس (Available)</option>
|
|
<option value="SOLD_OUT">تکمیل ظرفیت (Sold Out)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-foreground mb-1.5">
|
|
نمایش در اپ و وب:
|
|
</label>
|
|
<select
|
|
value={visibility ? 'true' : 'false'}
|
|
onChange={(e) => setVisibility(e.target.value === 'true')}
|
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none font-bold"
|
|
>
|
|
<option value="true">فعال (قابل مشاهده در اپ و وب)</option>
|
|
<option value="false">غیرفعال (مخفی در اپ و وب)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pricing Sub-card */}
|
|
<div className="rounded-2xl bg-surface-2/70 border border-border/50 p-4 space-y-3">
|
|
<h4 className="text-xs font-extrabold text-foreground flex items-center gap-1.5">
|
|
<Ic name="dollar" className="size-4 text-emerald-500" />
|
|
<span>قیمتگذاری و تخفیفات (دلار)</span>
|
|
</h4>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
<div>
|
|
<label className="block text-[11px] font-bold text-grey-2 mb-1">
|
|
بزرگسال ($) <span className="text-rose-500">*</span>:
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
value={price}
|
|
onChange={(e) => setPrice(e.target.value)}
|
|
placeholder="520"
|
|
className="bg-surface-3 text-xs font-mono"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[11px] font-bold text-grey-2 mb-1">
|
|
کودک (2 تا 7 سال):
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
value={priceChild}
|
|
onChange={(e) => setPriceChild(e.target.value)}
|
|
placeholder="280"
|
|
className="bg-surface-3 text-xs font-mono"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[11px] font-bold text-grey-2 mb-1">
|
|
نوزاد (زیر 2 سال):
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
value={priceInfant}
|
|
onChange={(e) => setPriceInfant(e.target.value)}
|
|
placeholder="80"
|
|
className="bg-surface-3 text-xs font-mono"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[11px] font-bold text-grey-2 mb-1">
|
|
درصد تخفیف (%):
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
value={percentOff}
|
|
onChange={(e) => setPercentOff(e.target.value)}
|
|
placeholder="0"
|
|
className="bg-surface-3 text-xs font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="block text-xs font-bold text-foreground mb-1.5">
|
|
شرح و توضیحات تور:
|
|
</label>
|
|
<textarea
|
|
rows={3}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="شرح کامل خدمات، برنامههای زیارتی و تسهیلات اقامتی تور..."
|
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* TAB 2: Features and Tips */}
|
|
{activeTab === 'features_tips' && (
|
|
<div className="space-y-5">
|
|
{/* Features Section */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
|
<Ic name="check" className="size-4 text-primary" />
|
|
<span>ویژگیها و خدمات شاخص تور ({features.length})</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={newFeature}
|
|
onChange={(e) => setNewFeature(e.target.value)}
|
|
placeholder="مثال: اقامت در هتل 5 ستاره نزدیک حرم..."
|
|
className="bg-surface-2 text-xs"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
handleAddFeature()
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={handleAddFeature}
|
|
className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-9 rounded-xl"
|
|
>
|
|
<Ic name="plus" className="size-4" />
|
|
<span>افزودن</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
|
{features.length === 0 ? (
|
|
<p className="text-xs text-grey-4 col-span-2 text-center py-4 bg-surface-2/30 rounded-xl">
|
|
هنوز ویژگی برای این تور اضافه نشده است.
|
|
</p>
|
|
) : (
|
|
features.map((feat, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="flex items-center justify-between gap-2 rounded-xl bg-surface-2 border border-border/40 px-3 py-2 text-xs"
|
|
>
|
|
<span className="font-medium text-foreground truncate">{feat}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveFeature(idx)}
|
|
className="text-grey-4 hover:text-rose-400 p-1 shrink-0"
|
|
>
|
|
<Ic name="x" className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Travel Tips Section */}
|
|
<div className="space-y-3 pt-3 border-t border-border/40">
|
|
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
|
<Ic name="info" className="size-4 text-amber-500" />
|
|
<span>نکات و راهنمای سفر ({tips.length})</span>
|
|
</label>
|
|
|
|
<div className="rounded-xl bg-surface-2/60 border border-border/40 p-3 space-y-2">
|
|
<Input
|
|
value={newTipTitle}
|
|
onChange={(e) => setNewTipTitle(e.target.value)}
|
|
placeholder="عنوان نکته (مثال: مدارک لازم / ارز مسافرتی)..."
|
|
className="bg-surface-2 text-xs"
|
|
/>
|
|
<textarea
|
|
rows={2}
|
|
value={newTipDesc}
|
|
onChange={(e) => setNewTipDesc(e.target.value)}
|
|
placeholder="توضیحات تکمیلی نکته سفر..."
|
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={handleAddTip}
|
|
className="w-full bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-8 rounded-xl"
|
|
>
|
|
<Ic name="plus" className="size-3.5 ml-1" />
|
|
<span>افزودن نکته سفر</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2 mt-2">
|
|
{tips.length === 0 ? (
|
|
<p className="text-xs text-grey-4 text-center py-4 bg-surface-2/30 rounded-xl">
|
|
نکته سفری ثبت نشده است.
|
|
</p>
|
|
) : (
|
|
tips.map((tip, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="rounded-xl bg-surface-2 border border-border/40 p-3 text-xs space-y-1 relative"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-bold text-foreground">{tip.title}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveTip(idx)}
|
|
className="text-grey-4 hover:text-rose-400 p-0.5"
|
|
>
|
|
<Ic name="x" className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<p className="text-grey-3 leading-relaxed">{tip.desc}</p>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* TAB 3: Multi-Language Translations */}
|
|
{activeTab === 'translations' && (
|
|
<div className="space-y-4">
|
|
{/* 5-Language Selector */}
|
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 border-b border-border/40">
|
|
{SUPPORTED_LANGUAGES.map((lang) => {
|
|
const isSelected = lang.code === selectedTransLang
|
|
const transObj = translations[lang.code]
|
|
const hasData = Boolean(
|
|
transObj?.title?.trim() ||
|
|
transObj?.description?.trim() ||
|
|
(transObj?.tour_feature && transObj.tour_feature.length > 0)
|
|
)
|
|
return (
|
|
<button
|
|
key={lang.code}
|
|
type="button"
|
|
onClick={() => setSelectedTransLang(lang.code)}
|
|
className={`flex items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition-all ${
|
|
isSelected
|
|
? 'bg-primary text-white shadow-md'
|
|
: 'bg-surface-2 text-grey-2 hover:bg-surface-3 hover:text-foreground border border-border/30'
|
|
}`}
|
|
>
|
|
<span className="text-sm">{lang.flag}</span>
|
|
<span>{lang.label}</span>
|
|
{hasData && !isSelected && (
|
|
<span className="size-1.5 rounded-full bg-emerald-500" />
|
|
)}
|
|
{lang.code === primaryLang && (
|
|
<span className="text-[10px] opacity-80">(اصلی)</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div dir={activeTransConfig?.dir || 'rtl'} className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-foreground mb-1.5">
|
|
عنوان تور به زبان {activeTransConfig?.label} ({selectedTransLang}):
|
|
</label>
|
|
<Input
|
|
value={currentTransData.title || ''}
|
|
onChange={(e) => handleTransFieldChange('title', e.target.value)}
|
|
placeholder={`عنوان تور به زبان ${activeTransConfig?.label}...`}
|
|
className="bg-surface-2 border-border/50 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-bold text-foreground mb-1.5">
|
|
شرح و توضیحات به زبان {activeTransConfig?.label}:
|
|
</label>
|
|
<textarea
|
|
rows={3}
|
|
value={currentTransData.description || ''}
|
|
onChange={(e) => handleTransFieldChange('description', e.target.value)}
|
|
placeholder={`شرح تور به زبان ${activeTransConfig?.label}...`}
|
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Features for this language */}
|
|
<div className="space-y-2">
|
|
<label className="block text-xs font-bold text-foreground">
|
|
ویژگیها به زبان {activeTransConfig?.label} ({(currentTransData.tour_feature || []).length}):
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={newTransFeatText}
|
|
onChange={(e) => setNewTransFeatText(e.target.value)}
|
|
placeholder={`افزودن ویژگی به ${activeTransConfig?.label}...`}
|
|
className="bg-surface-2 text-xs"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
handleAddTransFeature(newTransFeatText)
|
|
setNewTransFeatText('')
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => {
|
|
handleAddTransFeature(newTransFeatText)
|
|
setNewTransFeatText('')
|
|
}}
|
|
className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-9 rounded-xl"
|
|
>
|
|
<Ic name="plus" className="size-4" />
|
|
<span>افزودن</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
{(currentTransData.tour_feature || []).map((feat, fIdx) => (
|
|
<div
|
|
key={fIdx}
|
|
className="flex items-center justify-between rounded-xl bg-surface-2 border border-border/40 px-3 py-1.5 text-xs"
|
|
>
|
|
<span className="font-medium text-foreground">{feat.title}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveTransFeature(fIdx)}
|
|
className="text-grey-4 hover:text-rose-400 p-0.5"
|
|
>
|
|
<Ic name="x" className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Travel Tips for this language */}
|
|
<div className="space-y-2 pt-2 border-t border-border/40">
|
|
<label className="block text-xs font-bold text-foreground">
|
|
نکات سفر به زبان {activeTransConfig?.label} ({Object.keys(currentTransData.travel_tips || {}).length}):
|
|
</label>
|
|
<div className="rounded-xl bg-surface-2/60 border border-border/40 p-2.5 space-y-2">
|
|
<Input
|
|
value={newTransTipTitle}
|
|
onChange={(e) => setNewTransTipTitle(e.target.value)}
|
|
placeholder={`عنوان نکته به ${activeTransConfig?.label}...`}
|
|
className="bg-surface-2 text-xs"
|
|
/>
|
|
<textarea
|
|
rows={2}
|
|
value={newTransTipDesc}
|
|
onChange={(e) => setNewTransTipDesc(e.target.value)}
|
|
placeholder={`شرح نکته به ${activeTransConfig?.label}...`}
|
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3 py-1.5 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => {
|
|
handleAddTransTip(newTransTipTitle, newTransTipDesc)
|
|
setNewTransTipTitle('')
|
|
setNewTransTipDesc('')
|
|
}}
|
|
className="w-full bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-8 rounded-xl"
|
|
>
|
|
<Ic name="plus" className="size-3.5 ml-1" />
|
|
<span>افزودن نکته به زبان {activeTransConfig?.label}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
{Object.entries(currentTransData.travel_tips || {}).map(([tTitle, tDesc], tIdx) => (
|
|
<div
|
|
key={tIdx}
|
|
className="rounded-xl bg-surface-2 border border-border/40 p-2.5 text-xs space-y-1"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-bold text-foreground">{tTitle}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveTransTip(tTitle)}
|
|
className="text-grey-4 hover:text-rose-400 p-0.5"
|
|
>
|
|
<Ic name="x" className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<p className="text-grey-3 leading-relaxed">{tDesc}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between -mx-6 -mb-5 mt-6">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-grey-3">
|
|
{activeTab === 'basic' && 'مرحله 1 از 3: اطلاعات پایه و قیمت'}
|
|
{activeTab === 'features_tips' && 'مرحله 2 از 3: ویژگیها و نکات'}
|
|
{activeTab === 'translations' && 'مرحله 3 از 3: ترجمههای چندزبانه'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<DialogClose asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onOpenChange(false)}
|
|
className="rounded-xl text-xs cursor-pointer"
|
|
>
|
|
انصراف
|
|
</Button>
|
|
</DialogClose>
|
|
|
|
{activeTab === 'basic' && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => setActiveTab('features_tips')}
|
|
className="rounded-xl bg-primary text-white hover:bg-primary/90 text-xs gap-1.5"
|
|
>
|
|
<span>مرحله بعد: ویژگیها</span>
|
|
<Ic name="arrow-left" className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
|
|
{activeTab === 'features_tips' && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => setActiveTab('translations')}
|
|
className="rounded-xl bg-primary text-white hover:bg-primary/90 text-xs gap-1.5"
|
|
>
|
|
<span>مرحله بعد: ترجمهها</span>
|
|
<Ic name="arrow-left" className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
isLoading={isSubmitting}
|
|
loadingText="در حال ثبت تور..."
|
|
className="rounded-xl bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold gap-1.5 shadow-sm"
|
|
>
|
|
<Ic name="check" className="size-3.5" />
|
|
<span>ایجاد و ثبت نهایی تور</span>
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|