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.
 
 
 

2048 lines
94 KiB

import React, { useState, useEffect, useMemo, useRef } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Ic } from '@/icons'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import {
TourDetailModalShimmer,
TourItineraryShimmer,
TourPassengersShimmer,
TourCommentsShimmer,
} from './TourDetailShimmer'
import {
fetchTourDetail,
fetchTourItineraries,
fetchTourPassengers,
fetchTourComments,
fetchTourTranslations,
saveTourTranslations,
fetchTourItineraryTranslations,
saveTourItineraryTranslationsBatch,
createTourItineraryStep,
deleteTourItineraryStep,
updateTour,
deleteTour,
toggleTourVisibility,
uploadTourImage,
} from '../services/tour-api'
import type {
TourDetailItem,
TourItineraryItem,
TourPassengerGroupItem,
TourCommentItem,
TourTranslationsResponse,
TourItineraryTranslationsResponseItem,
TourTranslationData,
} from '../types'
import { TourStatusBadge } from './TourStatusBadge'
import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog'
interface TourDetailDialogProps {
tourId: number | null
isOpen: boolean
onClose: () => void
onUpdated?: () => 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']
interface LangDraft {
title: string
description: string
features: string[]
tips: { title: string; desc: string }[]
}
type TranslationsDraftMap = Record<LangCode, LangDraft>
type ItinDraftMap = Record<number, Record<LangCode, { title: string; summary: string }>>
interface CoreTourFields {
started_at: string
ended_at: string
capacity: number | string
status: string
trip_status: string
slug: string
price: string
price_child: string
price_infant: string
percent_off: number | string
visibility: boolean
}
interface TourImageDraftItem {
id?: number // TourImage ID (if existing on server)
filer_id?: number // FilerImage ID (for newly uploaded images)
url: string
isNew?: boolean
isUploading?: boolean
localId?: string
}
const createEmptyLangDraft = (): LangDraft => ({
title: '',
description: '',
features: [],
tips: [],
})
const createEmptyTranslationsMap = (): TranslationsDraftMap => ({
fa: createEmptyLangDraft(),
ar: createEmptyLangDraft(),
en: createEmptyLangDraft(),
ru: createEmptyLangDraft(),
id: createEmptyLangDraft(),
})
const createEmptyCoreFields = (): CoreTourFields => ({
started_at: '',
ended_at: '',
capacity: 0,
status: 'AVAILABLE',
trip_status: 'PENDING',
slug: '',
price: '0',
price_child: '0',
price_infant: '0',
percent_off: 0,
visibility: true,
})
export function TourDetailDialog({
tourId,
isOpen,
onClose,
onUpdated,
}: TourDetailDialogProps) {
const [activeTab, setActiveTab] = useState<'overview' | 'itinerary' | 'features' | 'passengers' | 'comments'>('overview')
const [selectedLang, setSelectedLang] = useState<LangCode>('ar')
// Edit mode toggle (Default: false / Read-Only)
const [isEditMode, setIsEditMode] = useState<boolean>(false)
// Main tour data from server
const [tour, setTour] = useState<TourDetailItem | null>(null)
const [itineraries, setItineraries] = useState<TourItineraryItem[]>([])
const [passengersGroup, setPassengersGroup] = useState<TourPassengerGroupItem[]>([])
const [comments, setComments] = useState<TourCommentItem[]>([])
const [transData, setTransData] = useState<TourTranslationsResponse | null>(null)
const [itinTransList, setItinTransList] = useState<TourItineraryTranslationsResponseItem[]>([])
// Unified Multi-Language Form States (Holds in-memory edits across all 5 languages)
const [allTranslations, setAllTranslations] = useState<TranslationsDraftMap>(createEmptyTranslationsMap)
const [initialTranslations, setInitialTranslations] = useState<TranslationsDraftMap>(createEmptyTranslationsMap)
// Itineraries drafts across all languages: { [itId]: { fa: { title, summary }, ar: ... } }
const [allItinDrafts, setAllItinDrafts] = useState<ItinDraftMap>({})
const [initialItinDrafts, setInitialItinDrafts] = useState<ItinDraftMap>({})
// Core tour properties state
const [coreFields, setCoreFields] = useState<CoreTourFields>(createEmptyCoreFields)
const [initialCoreFields, setInitialCoreFields] = useState<CoreTourFields>(createEmptyCoreFields)
// Image management states
const [tourImages, setTourImages] = useState<TourImageDraftItem[]>([])
const [initialTourImages, setInitialTourImages] = useState<TourImageDraftItem[]>([])
const [deletedTourImageIds, setDeletedTourImageIds] = useState<number[]>([])
const [newFilerImageIds, setNewFilerImageIds] = useState<number[]>([])
const [isUploadingImage, setIsUploadingImage] = useState<boolean>(false)
const [imageUploadError, setImageUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
// Temporary local inputs for adding feature / tip
const [newFeatureInput, setNewFeatureInput] = useState<string>('')
const [newTipTitle, setNewTipTitle] = useState<string>('')
const [newTipDesc, setNewTipDesc] = useState<string>('')
// Add new itinerary step state
const [showAddItinForm, setShowAddItinForm] = useState<boolean>(false)
const [newItinTitle, setNewItinTitle] = useState<string>('')
const [newItinSummary, setNewItinSummary] = useState<string>('')
const [isAddingItin, setIsAddingItin] = useState<boolean>(false)
// Unsaved changes confirmation dialog state
const [showUnsavedDialog, setShowUnsavedDialog] = useState<boolean>(false)
const [pendingAction, setPendingAction] = useState<'close' | 'toggle_edit' | null>(null)
// Delete Tour confirmation dialog state
const [showDeleteDialog, setShowDeleteDialog] = useState<boolean>(false)
const [isDeletingTour, setIsDeletingTour] = useState<boolean>(false)
// Loading and feedback states
const [isLoading, setIsLoading] = useState(false)
const [isLoadingSub, setIsLoadingSub] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isTogglingVisibility, setIsTogglingVisibility] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
// Load tour detail and all translations on modal open
useEffect(() => {
if (!tourId || !isOpen) {
setTour(null)
setTransData(null)
setIsEditMode(false)
setShowAddItinForm(false)
setShowUnsavedDialog(false)
setShowDeleteDialog(false)
setPendingAction(null)
setTourImages([])
setInitialTourImages([])
setDeletedTourImageIds([])
setNewFilerImageIds([])
setImageUploadError(null)
return
}
let isMounted = true
setIsLoading(true)
setError(null)
setSuccessMessage(null)
setActiveTab('overview')
setIsEditMode(false)
setShowAddItinForm(false)
Promise.all([
fetchTourDetail(tourId),
fetchTourTranslations(tourId).catch(() => null),
fetchTourItineraryTranslations(tourId).catch(() => []),
])
.then(([detail, trans, itinTrans]) => {
if (!isMounted) return
setTour(detail)
setTransData(trans)
setItinTransList(itinTrans || [])
// Initialize Core Fields
const initialCore: CoreTourFields = {
started_at: detail.started_at || '',
ended_at: detail.ended_at || '',
capacity: detail.capacity || 0,
status: detail.status || 'AVAILABLE',
trip_status: detail.trip_status || 'PENDING',
slug: detail.slug || '',
price: detail.price ? String(parseFloat(detail.price)) : '0',
price_child: detail.price_child ? String(parseFloat(detail.price_child)) : '0',
price_infant: detail.price_infant ? String(parseFloat(detail.price_infant)) : '0',
percent_off: detail.percent_off || 0,
visibility: detail.visibility !== false,
}
setCoreFields(initialCore)
setInitialCoreFields(initialCore)
// Initialize Images
const initialImgs: TourImageDraftItem[] = (detail.images || []).map((img) => ({
id: img.id,
url:
img.image_url?.md ||
img.image_url?.medium ||
img.image_url?.lg ||
img.image_url?.large ||
img.image_url?.original ||
'/placeholder.jpg',
}))
setTourImages(initialImgs)
setInitialTourImages(JSON.parse(JSON.stringify(initialImgs)))
setDeletedTourImageIds([])
setNewFilerImageIds([])
setImageUploadError(null)
// Initialize 5 Languages Unified Drafts
const initialDrafts = createEmptyTranslationsMap()
const primaryCode = (trans?.primary_language || 'ar') as LangCode
SUPPORTED_LANGUAGES.forEach((lang) => {
const isPrimary = primaryCode === lang.code
const langTrans = trans?.translations?.[lang.code]
if (langTrans) {
initialDrafts[lang.code] = {
title: langTrans.title ?? '',
description: langTrans.description ?? '',
features: (langTrans.tour_feature || []).map((f: any) => (typeof f === 'string' ? f : f.title)),
tips: Object.entries(langTrans.travel_tips || {}).map(([tTitle, tDesc]) => ({
title: tTitle,
desc: String(tDesc),
})),
}
} else if (isPrimary && trans?.primary_data) {
initialDrafts[lang.code] = {
title: trans.primary_data.title ?? '',
description: trans.primary_data.description ?? '',
features: (trans.primary_data.tour_feature || []).map((f: any) => (typeof f === 'string' ? f : f.title)),
tips: Object.entries(trans.primary_data.travel_tips || {}).map(([tTitle, tDesc]) => ({
title: tTitle,
desc: String(tDesc),
})),
}
} else if (isPrimary && detail) {
initialDrafts[lang.code] = {
title: detail.title ?? '',
description: detail.description ?? '',
features: (detail.tour_features || []).map((f) => f.title),
tips: (detail.travel_tips || []).map((t) => ({
title: t.title,
desc: t.description,
})),
}
} else {
initialDrafts[lang.code] = createEmptyLangDraft()
}
})
setAllTranslations(initialDrafts)
setInitialTranslations(JSON.parse(JSON.stringify(initialDrafts)))
setSelectedLang(primaryCode)
// Initialize Itinerary Drafts for all languages
const initialItins: ItinDraftMap = {}
const itinList = itinTrans || []
itinList.forEach((it) => {
initialItins[it.id] = {} as Record<LangCode, { title: string; summary: string }>
SUPPORTED_LANGUAGES.forEach((lang) => {
const itTrans = it.translations[lang.code]
initialItins[it.id][lang.code] = {
title: itTrans?.title ?? (lang.code === primaryCode ? it.title : ''),
summary: itTrans?.summary ?? (lang.code === primaryCode ? it.summary : ''),
}
})
})
setAllItinDrafts(initialItins)
setInitialItinDrafts(JSON.parse(JSON.stringify(initialItins)))
setIsLoading(false)
})
.catch((err: any) => {
if (isMounted) {
setError(err?.message || 'خطا در بارگذاری اطلاعات کامل تور')
setIsLoading(false)
}
})
return () => {
isMounted = false
}
}, [tourId, isOpen])
// Load sub-tabs (passengers, comments, itineraries) on demand
useEffect(() => {
if (!tourId || !isOpen || !tour) return
let isMounted = true
if (activeTab === 'itinerary' && itineraries.length === 0) {
setIsLoadingSub(true)
fetchTourItineraries(tourId)
.then((res) => {
if (isMounted) {
setItineraries(res.results || [])
setIsLoadingSub(false)
}
})
.catch(() => isMounted && setIsLoadingSub(false))
} else if (activeTab === 'passengers' && passengersGroup.length === 0) {
setIsLoadingSub(true)
fetchTourPassengers(tourId)
.then((res) => {
if (isMounted) {
setPassengersGroup(res.results || [])
setIsLoadingSub(false)
}
})
.catch(() => isMounted && setIsLoadingSub(false))
} else if (activeTab === 'comments' && comments.length === 0 && tour.slug) {
setIsLoadingSub(true)
fetchTourComments(tour.slug)
.then((res) => {
if (isMounted) {
setComments(res.results || [])
setIsLoadingSub(false)
}
})
.catch(() => isMounted && setIsLoadingSub(false))
}
return () => {
isMounted = false
}
}, [activeTab, tourId, isOpen, tour, itineraries.length, passengersGroup.length, comments.length])
// Check if a specific language has unsaved changes
const isLanguageDirty = (langCode: LangCode): boolean => {
const cur = allTranslations[langCode]
const init = initialTranslations[langCode]
if (!cur || !init) return false
if (cur.title !== init.title || cur.description !== init.description) return true
if (JSON.stringify(cur.features) !== JSON.stringify(init.features)) return true
if (JSON.stringify(cur.tips) !== JSON.stringify(init.tips)) return true
// Check itineraries for this language
for (const itId of Object.keys(allItinDrafts).map(Number)) {
const curIt = allItinDrafts[itId]?.[langCode]
const initIt = initialItinDrafts[itId]?.[langCode]
if (curIt && initIt) {
if (curIt.title !== initIt.title || curIt.summary !== initIt.summary) return true
} else if (curIt && (curIt.title || curIt.summary)) {
return true
}
}
return false
}
// Global isDirty check across core fields, images, all 5 languages, and itineraries
const isDirty = useMemo(() => {
if (!tour) return false
// Core fields check
const coreChanged =
coreFields.started_at !== initialCoreFields.started_at ||
coreFields.ended_at !== initialCoreFields.ended_at ||
String(coreFields.capacity) !== String(initialCoreFields.capacity) ||
coreFields.status !== initialCoreFields.status ||
coreFields.trip_status !== initialCoreFields.trip_status ||
coreFields.slug !== initialCoreFields.slug ||
String(coreFields.price) !== String(initialCoreFields.price) ||
String(coreFields.price_child) !== String(initialCoreFields.price_child) ||
String(coreFields.price_infant) !== String(initialCoreFields.price_infant) ||
String(coreFields.percent_off) !== String(initialCoreFields.percent_off) ||
coreFields.visibility !== initialCoreFields.visibility
if (coreChanged) return true
// Images check
const imagesChanged =
deletedTourImageIds.length > 0 ||
newFilerImageIds.length > 0 ||
tourImages.length !== initialTourImages.length
if (imagesChanged) return true
// Check all 5 languages
for (const lang of SUPPORTED_LANGUAGES) {
if (isLanguageDirty(lang.code)) return true
}
return false
}, [
coreFields,
initialCoreFields,
tourImages,
initialTourImages,
deletedTourImageIds,
newFilerImageIds,
allTranslations,
initialTranslations,
allItinDrafts,
initialItinDrafts,
tour,
])
// Validation: No tour can be without an image
const hasNoImages = tourImages.length === 0
// Switching language preserves all typed values seamlessly
const handleLanguageSwitch = (lang: LangCode) => {
setSelectedLang(lang)
setSuccessMessage(null)
setNewFeatureInput('')
setNewTipTitle('')
setNewTipDesc('')
}
// Current active language draft
const currentLangDraft = allTranslations[selectedLang] || createEmptyLangDraft()
const handleTitleChange = (val: string) => {
setAllTranslations((prev) => ({
...prev,
[selectedLang]: { ...prev[selectedLang], title: val },
}))
}
const handleDescChange = (val: string) => {
setAllTranslations((prev) => ({
...prev,
[selectedLang]: { ...prev[selectedLang], description: val },
}))
}
const handleAddFeature = () => {
if (!newFeatureInput.trim()) return
setAllTranslations((prev) => ({
...prev,
[selectedLang]: {
...prev[selectedLang],
features: [...prev[selectedLang].features, newFeatureInput.trim()],
},
}))
setNewFeatureInput('')
}
const handleRemoveFeature = (idx: number) => {
setAllTranslations((prev) => ({
...prev,
[selectedLang]: {
...prev[selectedLang],
features: prev[selectedLang].features.filter((_, i) => i !== idx),
},
}))
}
const handleAddTip = () => {
if (!newTipTitle.trim() || !newTipDesc.trim()) return
setAllTranslations((prev) => ({
...prev,
[selectedLang]: {
...prev[selectedLang],
tips: [...prev[selectedLang].tips, { title: newTipTitle.trim(), desc: newTipDesc.trim() }],
},
}))
setNewTipTitle('')
setNewTipDesc('')
}
const handleRemoveTip = (idx: number) => {
setAllTranslations((prev) => ({
...prev,
[selectedLang]: {
...prev[selectedLang],
tips: prev[selectedLang].tips.filter((_, i) => i !== idx),
},
}))
}
const handleItinDraftChange = (itId: number, field: 'title' | 'summary', val: string) => {
setAllItinDrafts((prev) => {
const itEntry = prev[itId] || ({} as Record<LangCode, { title: string; summary: string }>)
const langEntry = itEntry[selectedLang] || { title: '', summary: '' }
return {
...prev,
[itId]: {
...itEntry,
[selectedLang]: {
...langEntry,
[field]: val,
},
},
}
})
}
// Fast interactive toggle for Tour Visibility (Active / Inactive)
const handleToggleVisibility = async (newVal: boolean) => {
if (!tourId) return
// If currently in edit mode, update draft coreFields state
if (isEditMode) {
setCoreFields((prev) => ({ ...prev, visibility: newVal }))
return
}
// If in view mode, perform instant update via toggleTourVisibility API
setIsTogglingVisibility(true)
setError(null)
try {
await toggleTourVisibility(tourId, newVal)
setCoreFields((prev) => ({ ...prev, visibility: newVal }))
setInitialCoreFields((prev) => ({ ...prev, visibility: newVal }))
setTour((prev) => (prev ? { ...prev, visibility: newVal } : prev))
setSuccessMessage(
newVal
? 'تور با موفقیت در اپلیکیشن و وب‌سایت فعال (روشن) گردید.'
: 'تور با موفقیت غیرفعال (خاموش) و در اپلیکیشن و وب‌سایت مخفی گردید.'
)
if (onUpdated) onUpdated()
} catch (err: any) {
setError(err?.message || 'خطا در تغییر وضعیت فعال/غیرفعال بودن تور')
} finally {
setIsTogglingVisibility(false)
}
}
// Upload image handler with instant previews and parallel processing
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
const validFiles: File[] = []
for (let i = 0; i < files.length; i++) {
const file = files[i]
if (file.type.startsWith('image/')) {
validFiles.push(file)
}
}
if (validFiles.length === 0) {
setImageUploadError('لطفاً فقط فایل‌های تصویری معتبر (JPG، PNG، WEBP) انتخاب نمایید.')
if (fileInputRef.current) fileInputRef.current.value = ''
return
}
setIsUploadingImage(true)
setImageUploadError(null)
// 1. Create immediate local preview placeholders
const pendingItems: TourImageDraftItem[] = validFiles.map((file) => ({
localId: `pending-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
url: URL.createObjectURL(file),
isNew: true,
isUploading: true,
}))
setTourImages((prev) => [...prev, ...pendingItems])
// 2. Upload files in parallel
await Promise.all(
validFiles.map(async (file, idx) => {
const pendingItem = pendingItems[idx]
try {
const res = await uploadTourImage(file)
setTourImages((prev) =>
prev.map((item) =>
item.localId === pendingItem.localId
? { filer_id: res.id, url: res.url, isNew: true, isUploading: false }
: item
)
)
setNewFilerImageIds((prev) => [...prev, res.id])
} catch (err: any) {
// If upload fails, remove the pending placeholder
setTourImages((prev) => prev.filter((item) => item.localId !== pendingItem.localId))
setImageUploadError(err?.message || `خطا در آپلود تصویر «${file.name}». لطفاً مجدداً تلاش کنید.`)
}
})
)
setIsUploadingImage(false)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// Delete image from tour draft
const handleDeleteImage = (index: number) => {
const target = tourImages[index]
if (!target) return
if (target.id) {
setDeletedTourImageIds((prev) => [...prev, target.id!])
}
if (target.isNew && target.filer_id) {
setNewFilerImageIds((prev) => prev.filter((id) => id !== target.filer_id))
}
setTourImages((prev) => prev.filter((_, i) => i !== index))
setImageUploadError(null)
}
// Create a new itinerary step
const handleCreateItineraryStep = async () => {
if (!tourId || !newItinTitle.trim()) return
setIsAddingItin(true)
setError(null)
setSuccessMessage(null)
try {
const res = await createTourItineraryStep(tourId, {
title: newItinTitle.trim(),
summary: newItinSummary.trim(),
lang_code: selectedLang,
})
const updatedItinTrans = await fetchTourItineraryTranslations(tourId)
setItinTransList(updatedItinTrans)
const updatedItins = await fetchTourItineraries(tourId)
setItineraries(updatedItins.results || [])
setAllItinDrafts((prev) => {
const itEntry = prev[res.itinerary_id] || ({} as Record<LangCode, { title: string; summary: string }>)
return {
...prev,
[res.itinerary_id]: {
...itEntry,
[selectedLang]: {
title: newItinTitle.trim(),
summary: newItinSummary.trim(),
},
},
}
})
setNewItinTitle('')
setNewItinSummary('')
setShowAddItinForm(false)
setSuccessMessage(`مرحله جدید با موفقیت به برنامه سفر (${activeLangConfig.label}) افزوده شد.`)
if (onUpdated) onUpdated()
} catch (err: any) {
setError(err?.message || 'خطا در افزودن مرحله جدید به برنامه سفر')
} finally {
setIsAddingItin(false)
}
}
// Delete an itinerary step
const handleDeleteItineraryStep = async (itId: number) => {
if (!tourId) return
if (!confirm('آیا از حذف این مرحله از برنامه سفر اطمینان دارید؟')) return
setIsSaving(true)
setError(null)
try {
await deleteTourItineraryStep(tourId, itId)
const updatedItinTrans = await fetchTourItineraryTranslations(tourId)
setItinTransList(updatedItinTrans)
const updatedItins = await fetchTourItineraries(tourId)
setItineraries(updatedItins.results || [])
setAllItinDrafts((prev) => {
const next = { ...prev }
delete next[itId]
return next
})
setInitialItinDrafts((prev) => {
const next = { ...prev }
delete next[itId]
return next
})
setSuccessMessage('مرحله مورد نظر از برنامه سفر حذف شد.')
if (onUpdated) onUpdated()
} catch (err: any) {
setError(err?.message || 'خطا در حذف مرحله برنامه سفر')
} finally {
setIsSaving(false)
}
}
// Global One-Click Save: Saves all 5 languages, core fields, images, and itinerary steps in batch
const handleSaveAllChanges = async () => {
if (!tourId) return
if (hasNoImages) {
setError('وجود حداقل یک تصویر برای تور الزامی است. لطفاً حداقل یک تصویر آپلود نمایید.')
return
}
setIsSaving(true)
setError(null)
setSuccessMessage(null)
try {
// 1. Prepare multi-language translations payload
const translationsPayload: Record<string, TourTranslationData> = {}
SUPPORTED_LANGUAGES.forEach((lang) => {
const draft = allTranslations[lang.code]
if (draft) {
const travel_tips_obj: Record<string, string> = {}
draft.tips.forEach((t) => {
if (t.title.trim()) {
travel_tips_obj[t.title.trim()] = t.desc.trim()
}
})
translationsPayload[lang.code] = {
title: draft.title.trim(),
description: draft.description.trim(),
tour_feature: draft.features.map((f) => ({ title: f.trim() })).filter((f) => f.title),
travel_tips: travel_tips_obj,
}
}
})
// 2. Prepare core details payload including image updates
const corePayload: any = {
started_at: coreFields.started_at,
ended_at: coreFields.ended_at,
capacity: Number(coreFields.capacity) || 0,
status: coreFields.status,
trip_status: coreFields.trip_status,
slug: coreFields.slug.trim(),
price: coreFields.price,
price_child: coreFields.price_child,
price_infant: coreFields.price_infant,
percent_off: Number(coreFields.percent_off) || 0,
visibility: coreFields.visibility,
}
if (deletedTourImageIds.length > 0) {
corePayload.deleted_image_ids = deletedTourImageIds
}
if (newFilerImageIds.length > 0) {
corePayload.image_ids = newFilerImageIds
}
// 3. Prepare itinerary batch payload
const itineraryBatch: Array<{
itinerary_id: number
lang_code: string
title: string
summary: string
}> = []
const itIds = Object.keys(allItinDrafts).map(Number)
itIds.forEach((itId) => {
SUPPORTED_LANGUAGES.forEach((lang) => {
const item = allItinDrafts[itId]?.[lang.code]
if (item) {
itineraryBatch.push({
itinerary_id: itId,
lang_code: lang.code,
title: item.title.trim(),
summary: item.summary.trim(),
})
}
})
})
// Execute all updates concurrently
const promises: Promise<any>[] = [
updateTour(tourId, corePayload),
saveTourTranslations(tourId, { translations: translationsPayload }),
]
if (itineraryBatch.length > 0) {
promises.push(saveTourItineraryTranslationsBatch(tourId, itineraryBatch))
}
await Promise.all(promises)
// Refresh full tour details, translations, and itinerary data from backend
const [freshDetail, freshTrans, freshItinTrans] = await Promise.all([
fetchTourDetail(tourId),
fetchTourTranslations(tourId).catch(() => null),
fetchTourItineraryTranslations(tourId).catch(() => []),
])
setTour(freshDetail)
setTransData(freshTrans)
setItinTransList(freshItinTrans || [])
// Re-initialize images state from server response
const updatedImgs: TourImageDraftItem[] = (freshDetail.images || []).map((img) => ({
id: img.id,
url:
img.image_url?.md ||
img.image_url?.medium ||
img.image_url?.lg ||
img.image_url?.large ||
img.image_url?.original ||
'/placeholder.jpg',
}))
setTourImages(updatedImgs)
setInitialTourImages(JSON.parse(JSON.stringify(updatedImgs)))
setDeletedTourImageIds([])
setNewFilerImageIds([])
// Update baseline states to reset isDirty
setInitialCoreFields({ ...coreFields })
setInitialTranslations(JSON.parse(JSON.stringify(allTranslations)))
setInitialItinDrafts(JSON.parse(JSON.stringify(allItinDrafts)))
setSuccessMessage('تمامی تغییرات مشخصات تور، تصاویر، ترجمه‌های 5 زبانه و برنامه سفر با موفقیت ذخیره شدند.')
if (onUpdated) onUpdated()
} catch (err: any) {
setError(err?.message || 'خطا در ذخیره یکپارچه اطلاعات تور')
} finally {
setIsSaving(false)
}
}
// Delete Tour Handler
const handleConfirmDeleteTour = async () => {
if (!tourId) return
setIsDeletingTour(true)
setError(null)
try {
await deleteTour(tourId)
setShowDeleteDialog(false)
onClose()
if (onUpdated) onUpdated()
} catch (err: any) {
setError(err?.message || 'خطا در حذف تور')
setShowDeleteDialog(false)
} finally {
setIsDeletingTour(false)
}
}
// Guard exit / close against unsaved changes
const handleRequestClose = () => {
if (isDirty) {
setPendingAction('close')
setShowUnsavedDialog(true)
} else {
onClose()
}
}
const handleToggleEditMode = () => {
if (isEditMode) {
if (isDirty) {
setPendingAction('toggle_edit')
setShowUnsavedDialog(true)
} else {
setIsEditMode(false)
setShowAddItinForm(false)
}
} else {
setIsEditMode(true)
}
}
const handleDiscardAndProceed = () => {
// Revert form state back to initial
setCoreFields({ ...initialCoreFields })
setAllTranslations(JSON.parse(JSON.stringify(initialTranslations)))
setAllItinDrafts(JSON.parse(JSON.stringify(initialItinDrafts)))
setTourImages(JSON.parse(JSON.stringify(initialTourImages)))
setDeletedTourImageIds([])
setNewFilerImageIds([])
setImageUploadError(null)
setShowUnsavedDialog(false)
if (pendingAction === 'close') {
onClose()
} else if (pendingAction === 'toggle_edit') {
setIsEditMode(false)
setShowAddItinForm(false)
}
setPendingAction(null)
}
const handleSaveAndProceed = async () => {
if (hasNoImages) {
setError('وجود حداقل یک تصویر برای تور الزامی است. لطفاً حداقل یک تصویر آپلود نمایید.')
setShowUnsavedDialog(false)
return
}
await handleSaveAllChanges()
setShowUnsavedDialog(false)
if (pendingAction === 'close') {
onClose()
} else if (pendingAction === 'toggle_edit') {
setIsEditMode(false)
setShowAddItinForm(false)
}
setPendingAction(null)
}
const activeLangConfig = SUPPORTED_LANGUAGES.find((l) => l.code === selectedLang) || SUPPORTED_LANGUAGES[0]
// Calculated discounted adult price for live preview
const adultNum = parseFloat(coreFields.price) || 0
const discNum = parseFloat(String(coreFields.percent_off)) || 0
const discountedAdult = Math.max(0, adultNum * (1 - discNum / 100))
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && handleRequestClose()}>
<DialogContent className="max-w-6xl xl:max-w-7xl w-[96vw] p-0 overflow-hidden h-[92vh] min-h-[640px] max-h-[92vh] flex flex-col border border-border-soft bg-background rounded-2xl shadow-2xl">
{/* Header Modal */}
<DialogHeader className="px-6 py-4 border-b border-border-soft bg-card/40 backdrop-blur-md shrink-0 space-y-3">
<div className="flex items-center justify-between gap-4">
{/* بخش راست: آیکون، عنوان تور، وضعیت سفر و تاگل روشن/خاموش */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
<Ic name="map" className="size-5" />
</div>
<DialogTitle className="text-base font-extrabold text-foreground truncate max-w-sm shrink-0">
{isLoading && !tour ? (
<ShimmerLine width={160} height={20} rounded="sm" />
) : (
tour?.title || 'جزئیات و مشخصات تور'
)}
</DialogTitle>
{tour && (
<div className="flex items-center gap-2.5 shrink-0">
{/* بج وضعیت برگزاری سفر */}
<TourStatusBadge status={coreFields.trip_status || tour.trip_status} type="trip" />
{/* تاگل تعاملی روشن/خاموش وضعیت فعال/غیرفعال بودن تور */}
<div
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2 rounded-xl border border-border-soft bg-card/60 px-3 py-1.5 shadow-2xs transition"
>
<span className="text-[11px] font-bold text-grey-2">نمایش در اپ و وب:</span>
<Switch
checked={coreFields.visibility}
disabled={isTogglingVisibility || isSaving}
isLoading={isTogglingVisibility}
onCheckedChange={handleToggleVisibility}
aria-label="تغییر وضعیت فعال یا غیرفعال بودن تور"
/>
<span
className={`text-[11px] font-bold transition-colors ${
coreFields.visibility
? 'text-emerald-600 dark:text-emerald-400'
: 'text-rose-500'
}`}
>
{coreFields.visibility ? 'روشن (فعال)' : 'خاموش (مخفی)'}
</span>
</div>
</div>
)}
</div>
{/* دکمه‌های عملیاتی ثابت و بدون شکستن خط (Action Buttons) */}
<div className="flex items-center gap-2 shrink-0">
{isLoading && !tour ? (
<div className="flex items-center gap-1.5">
<ShimmerBox width={80} height={32} rounded="lg" />
</div>
) : tour ? (
<>
{/* دکمه حذف تور در حالت ویرایش */}
{isEditMode && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowDeleteDialog(true)}
className="h-8.5 px-3 text-xs font-bold rounded-xl gap-1.5 border-rose-500/30 bg-rose-500/5 text-rose-600 dark:text-rose-400 hover:bg-rose-500/15 hover:border-rose-500 transition cursor-pointer shrink-0"
>
<Ic name="trash" className="size-3.5" />
<span>حذف تور</span>
</Button>
)}
{/* دکمه سراسری ثبت نهایی تغییرات (در حالت ویرایش) */}
{isEditMode && (
<Button
type="button"
variant="default"
size="sm"
isLoading={isSaving}
loadingText="در حال ذخیره..."
disabled={isSaving || !isDirty || hasNoImages}
onClick={handleSaveAllChanges}
className="h-8.5 px-3.5 text-xs font-bold rounded-xl gap-1.5 bg-primary text-white hover:bg-primary/90 shadow-xs cursor-pointer disabled:opacity-50 shrink-0"
>
<Ic name="save" className="size-3.5" />
<span>ذخیره نهایی تغییرات</span>
</Button>
)}
{/* دکمه سوییچ حالت فقط‌خواندنی / ویرایش */}
<Button
type="button"
variant={isEditMode ? 'outline' : 'default'}
size="sm"
onClick={handleToggleEditMode}
className={`h-8.5 px-3 text-xs font-bold rounded-xl gap-1.5 transition cursor-pointer shrink-0 ${
isEditMode
? 'border-border-soft bg-card/60 text-grey-2 hover:text-foreground hover:bg-card'
: 'bg-surface-base border border-border-soft text-foreground hover:border-primary/40 shadow-xs'
}`}
>
<Ic name={isEditMode ? 'x' : 'edit'} className="size-3.5" />
<span>{isEditMode ? 'خروج از ویرایش' : 'ویرایش تور'}</span>
</Button>
</>
) : null}
{/* دکمه بستن پنجره */}
<button
type="button"
onClick={handleRequestClose}
aria-label="بستن پنجره"
title="بستن"
className="flex size-8.5 items-center justify-center rounded-xl text-grey-3 hover:text-foreground hover:bg-muted/60 transition-colors shrink-0 cursor-pointer border border-transparent hover:border-border-soft"
>
<Ic name="x" className="size-4.5" />
</button>
</div>
</div>
{/* Integrated 5-Language Selector Bar with dirty indicators */}
<div className="mt-4 flex items-center justify-between gap-2 overflow-x-auto pt-2 border-t border-border-soft/60">
<div className="flex items-center gap-1.5 shrink-0 text-xs text-grey-3">
<Ic name="globe" className="size-3.5 text-primary" />
<span className="font-bold">زبان فعال محتوا:</span>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar pb-1">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang.code === selectedLang
const isPrimary = transData?.primary_language === lang.code
const hasDraftData = Boolean(
allTranslations[lang.code]?.title ||
allTranslations[lang.code]?.description ||
allTranslations[lang.code]?.features.length > 0 ||
allTranslations[lang.code]?.tips.length > 0
)
const dirty = isLanguageDirty(lang.code)
return (
<button
key={lang.code}
type="button"
onClick={() => handleLanguageSwitch(lang.code)}
className={`relative flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer border ${
isSelected
? 'bg-primary text-white border-primary shadow-xs'
: hasDraftData
? 'bg-card/70 border-border-soft text-foreground hover:border-primary/40'
: 'bg-surface-base border-dashed border-border-soft text-grey-3 hover:text-foreground'
}`}
>
<span>{lang.flag}</span>
<span>{lang.label}</span>
{dirty && (
<span
title="دارای تغییرات ذخیره‌نشده"
className={`size-2 rounded-full ${isSelected ? 'bg-white animate-pulse' : 'bg-amber-500 animate-pulse'}`}
/>
)}
{isPrimary && (
<span className={`text-[9px] px-1 py-0.2 rounded-full ${isSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
پایه
</span>
)}
</button>
)
})}
</div>
</div>
{/* Main Navigation Tabs */}
<Tabs
value={activeTab}
onValueChange={(val: any) => setActiveTab(val)}
className="mt-3 w-full"
>
<TabsList className="grid w-full grid-cols-5 bg-surface-base border border-border-soft p-1">
<TabsTrigger value="overview" className="text-xs gap-1.5">
<Ic name="file" className="size-3.5" />
<span>مشخصات و متون</span>
</TabsTrigger>
<TabsTrigger value="itinerary" className="text-xs gap-1.5">
<Ic name="calendar" className="size-3.5" />
<span>برنامه سفر</span>
</TabsTrigger>
<TabsTrigger value="features" className="text-xs gap-1.5">
<Ic name="star" className="size-3.5" />
<span>امکانات و نکات</span>
</TabsTrigger>
<TabsTrigger value="passengers" className="text-xs gap-1.5">
<Ic name="users" className="size-3.5" />
<span>مسافران</span>
</TabsTrigger>
<TabsTrigger value="comments" className="text-xs gap-1.5">
<Ic name="message" className="size-3.5" />
<span>نظرات</span>
</TabsTrigger>
</TabsList>
</Tabs>
</DialogHeader>
{/* Modal Scrollable Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-6 min-h-0">
{successMessage && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-3 text-xs font-bold text-emerald-600 dark:text-emerald-400 flex items-center justify-between">
<div className="flex items-center gap-2">
<Ic name="check" className="size-4" />
<span>{successMessage}</span>
</div>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 text-emerald-600"
onClick={() => setSuccessMessage(null)}
>
&times;
</Button>
</div>
)}
{isLoading ? (
<TourDetailModalShimmer activeTab={activeTab} />
) : error ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-8 text-center text-rose-500">
<Ic name="alert" className="mx-auto size-8 mb-2" />
<p className="font-bold text-sm">{error}</p>
<Button
variant="outline"
size="sm"
className="mt-4 border-rose-500/30 text-rose-500"
onClick={() => tourId && fetchTourDetail(tourId).then(setTour)}
>
تلاش مجدد
</Button>
</div>
) : tour ? (
<>
{/* تب ۱: مشخصات و متون تور */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* بخش ترجمه پویا برای عنوان و توضیحات تور */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-extrabold text-foreground">
<span>{activeLangConfig.flag}</span>
<span>
محتوای متنی تور به زبان {activeLangConfig.label} ({activeLangConfig.code})
</span>
</div>
<span className="text-[11px] text-grey-3">
جهت متن: <span className="font-bold font-mono">{activeLangConfig.dir.toUpperCase()}</span>
</span>
</div>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">
عنوان تور ({activeLangConfig.label}):
</label>
<Input
value={currentLangDraft.title}
disabled={!isEditMode || isSaving}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={`عنوان تور به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="bg-card font-medium text-xs rounded-xl border-border-soft disabled:opacity-85 disabled:cursor-default"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">
توضیحات کامل تور ({activeLangConfig.label}):
</label>
<textarea
rows={4}
value={currentLangDraft.description}
disabled={!isEditMode || isSaving}
onChange={(e) => handleDescChange(e.target.value)}
placeholder={`توضیحات تفصیلی تور به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="w-full rounded-xl border border-border-soft bg-card p-3 text-xs leading-relaxed text-foreground focus:outline-hidden focus:ring-1 focus:ring-primary disabled:opacity-85 disabled:cursor-default"
/>
</div>
</div>
</div>
{/* بخش مشخصات اصلی، زمان‌بندی و وضعیت تور */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-border-soft text-xs font-extrabold text-foreground">
<Ic name="calendar" className="size-4 text-primary" />
<span>مشخصات عمومی و زمان‌بندی تور</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3.5">
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">تاریخ شروع:</label>
<Input
type="date"
value={coreFields.started_at}
disabled={!isEditMode || isSaving}
onChange={(e) => setCoreFields((prev) => ({ ...prev, started_at: e.target.value }))}
className="bg-card text-xs font-mono rounded-xl border-border-soft disabled:opacity-85 disabled:cursor-default"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">تاریخ پایان:</label>
<Input
type="date"
value={coreFields.ended_at}
disabled={!isEditMode || isSaving}
onChange={(e) => setCoreFields((prev) => ({ ...prev, ended_at: e.target.value }))}
className="bg-card text-xs font-mono rounded-xl border-border-soft disabled:opacity-85 disabled:cursor-default"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">ظرفیت کل (نفر):</label>
<Input
type="number"
min="1"
value={coreFields.capacity}
disabled={!isEditMode || isSaving}
onChange={(e) => setCoreFields((prev) => ({ ...prev, capacity: e.target.value }))}
className="bg-card text-xs font-mono rounded-xl border-border-soft disabled:opacity-85 disabled:cursor-default"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">اسلاگ سیستمی (Slug):</label>
<Input
value={coreFields.slug}
disabled={!isEditMode || isSaving}
onChange={(e) => setCoreFields((prev) => ({ ...prev, slug: e.target.value }))}
placeholder="tour-slug"
dir="ltr"
className="bg-card text-xs font-mono rounded-xl border-border-soft disabled:opacity-85 disabled:cursor-default"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">نمایش در اپ و وب:</label>
<div className="flex items-center justify-between h-9 rounded-xl border border-border-soft bg-card px-3 py-1.5">
<span className={`text-xs font-bold ${coreFields.visibility ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-500'}`}>
{coreFields.visibility ? 'روشن (فعال)' : 'خاموش (مخفی)'}
</span>
<Switch
checked={coreFields.visibility}
disabled={!isEditMode || isSaving}
onCheckedChange={(checked) => setCoreFields((prev) => ({ ...prev, visibility: checked }))}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-grey-2">وضعیت برگزاری سفر:</label>
<select
value={coreFields.trip_status}
disabled={!isEditMode || isSaving}
onChange={(e) => setCoreFields((prev) => ({ ...prev, trip_status: e.target.value }))}
className="w-full h-9 rounded-xl border border-border-soft bg-card px-3 py-1.5 text-xs text-foreground focus:outline-hidden focus:ring-1 focus:ring-primary disabled:opacity-85 disabled:cursor-default"
>
<option value="PENDING">در انتظار اجرا (PENDING)</option>
<option value="TRAVELING">در حال برگزاری (TRAVELING)</option>
<option value="FINISHED">پایان‌یافته (FINISHED)</option>
</select>
</div>
</div>
</div>
{/* بخش جدول ۳ ردیف نرخ‌های مصوب و تخفیف */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-border-soft">
<div className="flex items-center gap-2 text-xs font-extrabold text-foreground">
<Ic name="dollar" className="size-4 text-primary" />
<span>جدول 3 ردیف نرخ‌های مصوب و تخفیف تور</span>
</div>
{discNum > 0 && (
<Badge
variant="outline"
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[11px] font-mono"
>
{discNum}% تخفیف فعال
</Badge>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1.5">
<label className="text-[11px] font-bold text-grey-3 block">بزرگسال (7+ سال / Adult):</label>
<div className="relative">
<Input
type="number"
min="0"
step="any"
value={coreFields.price}
disabled={!isEditMode || isSaving}
onChange={(e) => setCoreFields((prev) => ({ ...prev, price: e.target.value }))}
dir="ltr"
className="bg-card text-xs font-mono font-bold rounded-xl border-border-soft pl-7 disabled:opacity-85 disabled:cursor-default"
/>
<span className="absolute left-2.5 top-2 text-xs text-grey-3 font-mono">$</span>
</div>
{discNum > 0 && (
<div className="text-[11px] text-emerald-600 dark:text-emerald-400 font-bold font-mono">
نهایی: ${discountedAdult.toLocaleString()}
</div>
)}
<span className="text-[10px] text-grey-3 block">خدمات کامل (7 سال به بالا)</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1.5">
<label className="text-[11px] font-bold text-grey-3 block">کودک (2 تا 7 سال / Child):</label>
<div className="relative">
<Input
type="number"
min="0"
step="any"
value={coreFields.price_child}
disabled={!isEditMode || isSaving}
onChange={(e) => setCoreFields((prev) => ({ ...prev, price_child: e.target.value }))}
dir="ltr"
className="bg-card text-xs font-mono font-bold rounded-xl border-border-soft pl-7 disabled:opacity-85 disabled:cursor-default"
/>
<span className="absolute left-2.5 top-2 text-xs text-grey-3 font-mono">$</span>
</div>
<span className="text-[10px] text-grey-3 block">صندلی و خدمات کودک (2 تا 7 سال کامل)</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1.5">
<label className="text-[11px] font-bold text-grey-3 block">نوزاد (0 تا 2 سال / Infant):</label>
<div className="relative">
<Input
type="number"
min="0"
step="any"
value={coreFields.price_infant}
disabled={!isEditMode || isSaving}
onChange={(e) => setCoreFields((prev) => ({ ...prev, price_infant: e.target.value }))}
dir="ltr"
className="bg-card text-xs font-mono font-bold rounded-xl border-border-soft pl-7 disabled:opacity-85 disabled:cursor-default"
/>
<span className="absolute left-2.5 top-2 text-xs text-grey-3 font-mono">$</span>
</div>
<span className="text-[10px] text-grey-3 block">بیمه و ترانسفر نوزاد (0 تا 2 سال کامل)</span>
</div>
<div className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1.5">
<label className="text-[11px] font-bold text-grey-3 block">درصد تخفیف (Discount %):</label>
<div className="relative">
<Input
type="number"
min="0"
max="100"
value={coreFields.percent_off}
disabled={!isEditMode || isSaving}
onChange={(e) => setCoreFields((prev) => ({ ...prev, percent_off: e.target.value }))}
dir="ltr"
className="bg-card text-xs font-mono font-bold rounded-xl border-border-soft pl-7 disabled:opacity-85 disabled:cursor-default"
/>
<span className="absolute left-2.5 top-2 text-xs text-grey-3 font-mono">%</span>
</div>
<span className="text-[10px] text-grey-3 block">اعمال تخفیف روی نرخ بزرگسال</span>
</div>
</div>
</div>
{/* وضعیت تکمیل ظرفیت */}
{(() => {
const soldCount = Number(tour.number_sold) || 0
const capCount = Number(coreFields.capacity) || 0
const percentSold = capCount > 0 ? Math.min(100, Math.round((soldCount / capCount) * 100)) : 0
const remainingCap = Math.max(0, capCount - soldCount)
return (
<div className="rounded-xl border border-border-soft bg-card/20 p-4 space-y-2">
<div className="flex justify-between text-xs font-bold">
<span>وضعیت تکمیل ظرفیت</span>
<span className="text-primary font-mono">{percentSold}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-border-soft">
<div
className="h-full rounded-full bg-gradient-to-r from-primary to-amber-500 transition-all duration-300"
style={{ width: `${percentSold}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-grey-3">
<span>ظرفیت باقیمانده: {remainingCap} صندلی</span>
<span>فروخته شده: {soldCount} نفر | ظرفیت کل: {capCount} نفر</span>
</div>
</div>
)
})()}
{/* بخش مدیریت و گالری تصاویر تور */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between pb-2 border-b border-border-soft">
<div className="flex items-center gap-2 text-xs font-extrabold text-foreground">
<Ic name="image" className="size-4 text-primary" />
<span>گالری تصاویر تور ({tourImages.length} تصویر)</span>
</div>
{hasNoImages && (
<Badge variant="outline" className="border-rose-500/30 bg-rose-500/10 text-rose-500 text-[11px]">
حداقل 1 تصویر الزامی است
</Badge>
)}
</div>
{/* هشدار در صورت عدم وجود تصویر */}
{hasNoImages && (
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3.5 text-xs text-rose-500 font-bold flex items-center gap-2.5">
<Ic name="alert" className="size-4 shrink-0" />
<span>
توجه: هیچ توری نمی‌تواند بدون تصویر باشد. شما تمامی تصاویر را حذف کرده‌اید؛ لطفاً حداقل یک تصویر جدید آپلود نمایید.
</span>
</div>
)}
{imageUploadError && (
<div className="rounded-xl border border-rose-500/20 bg-rose-500/10 p-2.5 text-xs text-rose-500 flex items-center justify-between">
<span>{imageUploadError}</span>
<button
type="button"
onClick={() => setImageUploadError(null)}
className="text-rose-500 hover:text-rose-700 font-bold text-sm px-1 cursor-pointer"
>
&times;
</button>
</div>
)}
{/* شبکه تصاویر */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3.5 pt-1">
{tourImages.map((img, idx) => (
<div
key={img.localId || img.id || img.filer_id || idx}
className="group relative aspect-4/3 overflow-hidden rounded-xl border border-border-soft bg-surface-base shadow-2xs transition hover:border-primary/40"
>
<img
src={img.url}
alt={`Tour Image ${idx + 1}`}
className={`h-full w-full object-cover transition-opacity ${img.isUploading ? 'opacity-50 blur-[1px]' : 'opacity-100'}`}
onError={(e) => {
;(e.target as HTMLImageElement).src =
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150" fill="%2318181b"><rect width="200" height="150"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="12" font-family="sans-serif">Image</text></svg>'
}}
/>
{/* انیمیشن بارگذاری مجزای تصویر در حال آپلود */}
{img.isUploading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 text-white gap-1 backdrop-blur-xs">
<span className="size-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span className="text-[9px] font-bold">در حال آپلود...</span>
</div>
)}
{/* تگ تصویر اصلی/کاور */}
{idx === 0 && !img.isUploading && (
<div className="absolute top-1.5 right-1.5 bg-black/70 backdrop-blur-xs text-white text-[9px] font-bold px-1.5 py-0.5 rounded-md border border-white/20">
کاور اصلی
</div>
)}
{/* دکمه حذف تصویر در حالت ویرایش */}
{isEditMode && !img.isUploading && (
<button
type="button"
title="حذف این تصویر"
onClick={() => handleDeleteImage(idx)}
className="absolute top-1.5 left-1.5 flex size-6.5 items-center justify-center rounded-lg bg-rose-600/90 text-white hover:bg-rose-600 shadow-md transition-all cursor-pointer opacity-90 group-hover:opacity-100"
>
<Ic name="trash" className="size-3.5" />
</button>
)}
</div>
))}
{/* کارت افزودن تصویر جدید در حالت ویرایش */}
{isEditMode && (
<div
onClick={() => !isUploadingImage && fileInputRef.current?.click()}
className={`flex flex-col items-center justify-center aspect-4/3 rounded-xl border-2 border-dashed border-primary/40 bg-primary/5 p-3 text-center transition cursor-pointer hover:border-primary hover:bg-primary/10 ${
isUploadingImage ? 'opacity-60 pointer-events-none' : ''
}`}
>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/jpg"
multiple
onClick={(e) => e.stopPropagation()}
onChange={handleImageUpload}
className="hidden"
/>
{isUploadingImage ? (
<div className="flex flex-col items-center gap-1 text-primary">
<span className="size-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-[10px] font-bold">در حال آپلود...</span>
</div>
) : (
<div className="flex flex-col items-center gap-1 text-primary">
<div className="flex size-7 items-center justify-center rounded-lg bg-primary/15">
<Ic name="plus" className="size-4" />
</div>
<span className="text-[11px] font-bold">افزودن تصویر</span>
<span className="text-[9px] text-grey-3">JPG, PNG, WEBP</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* تب ۲: برنامه سفر روزانه و افزودن/ویرایش مراحل */}
{activeTab === 'itinerary' && (
<div className="space-y-4">
{/* نوار وضعیت و دکمه افزودن مرحله در حالت ویرایش */}
<div className="flex items-center justify-between bg-surface-base p-3 rounded-xl border border-border-soft text-xs">
<div className="flex items-center gap-2">
<span>{activeLangConfig.flag}</span>
<span className="font-bold text-foreground">
برنامه سفر به زبان {activeLangConfig.label} ({activeLangConfig.code})
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-grey-3 text-[11px]">
تعداد مراحل: {itinTransList.length || itineraries.length}
</span>
{isEditMode && !showAddItinForm && (
<Button
size="sm"
onClick={() => setShowAddItinForm(true)}
className="h-7 px-3 text-[11px] font-bold rounded-xl gap-1.5 bg-primary text-white hover:bg-primary/90 shadow-xs cursor-pointer"
>
<Ic name="plus" className="size-3.5" />
<span>افزودن مرحله جدید</span>
</Button>
)}
</div>
</div>
{/* فرم افزودن مرحله جدید در حالت ویرایش */}
{isEditMode && showAddItinForm && (
<div className="rounded-2xl border-2 border-primary/30 bg-primary/5 p-4 space-y-3 transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-extrabold text-foreground">
<Ic name="plus" className="size-4 text-primary" />
<span>افزودن مرحله جدید به برنامه سفر ({activeLangConfig.label})</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-grey-3 hover:text-foreground"
onClick={() => setShowAddItinForm(false)}
>
انصراف
</Button>
</div>
<div className="space-y-2.5">
<div className="space-y-1">
<label className="text-[11px] font-bold text-grey-2">
عنوان مرحله (مثال: روز 1: ورود به نجف اشرف و استقرار):
</label>
<Input
value={newItinTitle}
onChange={(e) => setNewItinTitle(e.target.value)}
placeholder={`عنوان مرحله سفر به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="bg-card text-xs font-bold rounded-xl border-border-soft"
/>
</div>
<div className="space-y-1">
<label className="text-[11px] font-bold text-grey-2">
شرح و برنامه‌های این مرحله:
</label>
<textarea
rows={3}
value={newItinSummary}
onChange={(e) => setNewItinSummary(e.target.value)}
placeholder={`توضیحات و برنامه‌های روزانه به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="w-full rounded-xl border border-border-soft bg-card p-2.5 text-xs leading-relaxed text-foreground focus:outline-hidden focus:ring-1 focus:ring-primary"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button
variant="outline"
size="sm"
onClick={() => setShowAddItinForm(false)}
className="text-xs rounded-xl"
>
انصراف
</Button>
<Button
size="sm"
disabled={!newItinTitle.trim()}
isLoading={isAddingItin}
loadingText="در حال ثبت مرحله..."
onClick={handleCreateItineraryStep}
className="gap-1.5 text-xs font-bold rounded-xl shadow-xs"
>
<Ic name="check" className="size-3.5" />
<span>ثبت مرحله در برنامه سفر</span>
</Button>
</div>
</div>
</div>
)}
{isLoadingSub ? (
<TourItineraryShimmer />
) : itinTransList.length === 0 && itineraries.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-12 text-center text-grey-3 flex flex-col items-center justify-center min-h-[280px]">
<Ic name="calendar" className="size-10 mb-3 opacity-40 text-primary" />
<p className="text-xs font-bold text-foreground mb-1">
برنامه زمان‌بندی روزانه‌ای برای این تور ثبت نشده است.
</p>
<p className="text-[11px] text-grey-3 max-w-sm mb-4">
{isEditMode
? 'شما در حالت ویرایش هستید. با کلیک بر روی دکمه زیر اولین مرحله سفر را اضافه کنید.'
: 'جهت اضافه کردن مراحل و زمان‌بندی سفر به این تور، روی دکمه «ویرایش تور» در بالای پنجره کلیک نمایید.'}
</p>
{isEditMode && !showAddItinForm && (
<Button
size="sm"
onClick={() => setShowAddItinForm(true)}
className="text-xs font-bold rounded-xl gap-1.5 shadow-xs"
>
<Ic name="plus" className="size-3.5" />
<span>افزودن اولین مرحله سفر</span>
</Button>
)}
</div>
) : (
<div className="space-y-4">
{(itinTransList.length > 0 ? itinTransList : itineraries).map((item, idx) => {
const itId = item.id
const itDraft = allItinDrafts[itId]?.[selectedLang] || {
title: (item as any).translations?.[selectedLang]?.title || (selectedLang === transData?.primary_language ? item.title : ''),
summary: (item as any).translations?.[selectedLang]?.summary || (selectedLang === transData?.primary_language ? (item as any).summary : ''),
}
return (
<div
key={itId || idx}
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3 transition hover:border-primary/40"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary font-mono">
{idx + 1}
</span>
<span className="text-xs font-bold text-grey-3">
مرحله #{itId} &bull;{' '}
{(item as any).started_at
? new Date((item as any).started_at).toLocaleDateString('fa-IR')
: `روز ${idx + 1}`}
</span>
</div>
{isEditMode && (
<button
type="button"
title="حذف مرحله"
onClick={() => handleDeleteItineraryStep(itId)}
className="size-7 flex items-center justify-center rounded-lg text-grey-3 hover:text-rose-500 hover:bg-rose-500/10 transition cursor-pointer"
>
<Ic name="trash" className="size-3.5" />
</button>
)}
</div>
<div className="space-y-2">
<Input
value={itDraft.title}
disabled={!isEditMode || isSaving}
onChange={(e) => handleItinDraftChange(itId, 'title', e.target.value)}
placeholder={`عنوان مرحله ${idx + 1} به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="bg-card text-xs font-bold rounded-xl border-border-soft disabled:opacity-85 disabled:cursor-default"
/>
<textarea
rows={2}
value={itDraft.summary}
disabled={!isEditMode || isSaving}
onChange={(e) => handleItinDraftChange(itId, 'summary', e.target.value)}
placeholder={`خلاصه برنامه این روز به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="w-full rounded-xl border border-border-soft bg-card p-2.5 text-xs leading-relaxed text-foreground focus:outline-hidden focus:ring-1 focus:ring-primary disabled:opacity-85 disabled:cursor-default"
/>
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* تب ۳: امکانات و نکات راهنما */}
{activeTab === 'features' && (
<div className="space-y-6">
<div className="flex items-center justify-between bg-surface-base p-3 rounded-xl border border-border-soft text-xs">
<div className="flex items-center gap-2">
<span>{activeLangConfig.flag}</span>
<span className="font-bold text-foreground">
امکانات و راهنمای سفر به زبان {activeLangConfig.label} ({activeLangConfig.code})
</span>
</div>
</div>
{/* ویژگی‌ها و امکانات (Features) */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<h4 className="text-xs font-extrabold text-foreground">
امکانات و خدمات تور ({activeLangConfig.label})
</h4>
<div className="flex flex-wrap gap-2">
{currentLangDraft.features.length === 0 ? (
<span className="text-xs text-grey-3">هیچ امکانی ثبت نشده است.</span>
) : (
currentLangDraft.features.map((feat, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 rounded-xl border border-border-soft bg-surface-base px-3 py-1.5 text-xs font-bold text-foreground"
dir={activeLangConfig.dir}
>
<span>{feat}</span>
{isEditMode && (
<button
type="button"
onClick={() => handleRemoveFeature(idx)}
className="text-grey-3 hover:text-rose-500 cursor-pointer"
>
&times;
</button>
)}
</div>
))
)}
</div>
{isEditMode && (
<div className="flex items-center gap-2 pt-2">
<Input
value={newFeatureInput}
onChange={(e) => setNewFeatureInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddFeature()
}
}}
placeholder={`افزودن امکان جدید به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="bg-card text-xs rounded-xl border-border-soft"
/>
<Button
size="sm"
variant="outline"
onClick={handleAddFeature}
className="shrink-0 text-xs rounded-xl cursor-pointer"
>
+ افزودن
</Button>
</div>
)}
</div>
{/* راهنمای سفر و نکات (Travel Tips) */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3">
<h4 className="text-xs font-extrabold text-foreground">
نکات و راهنمای سفر ({activeLangConfig.label})
</h4>
<div className="space-y-2">
{currentLangDraft.tips.length === 0 ? (
<span className="text-xs text-grey-3">هیچ نکته‌ای ثبت نشده است.</span>
) : (
currentLangDraft.tips.map((tip, idx) => (
<div
key={idx}
className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1"
dir={activeLangConfig.dir}
>
<div className="flex items-center justify-between">
<span className="font-bold text-xs text-foreground">{tip.title}</span>
{isEditMode && (
<button
type="button"
onClick={() => handleRemoveTip(idx)}
className="text-grey-3 hover:text-rose-500 cursor-pointer text-xs"
>
حذف
</button>
)}
</div>
<p className="text-xs text-grey-2 leading-relaxed">{tip.desc}</p>
</div>
))
)}
</div>
{isEditMode && (
<div className="rounded-xl border border-dashed border-border-soft bg-card/20 p-3 space-y-2 pt-3">
<span className="text-[11px] font-bold text-grey-3">افزودن نکته جدید:</span>
<Input
value={newTipTitle}
onChange={(e) => setNewTipTitle(e.target.value)}
placeholder={`عنوان نکته به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="bg-card text-xs rounded-xl border-border-soft"
/>
<textarea
rows={2}
value={newTipDesc}
onChange={(e) => setNewTipDesc(e.target.value)}
placeholder={`شرح راهنما به زبان ${activeLangConfig.label}...`}
dir={activeLangConfig.dir}
className="w-full rounded-xl border border-border-soft bg-card p-2 text-xs text-foreground focus:outline-hidden"
/>
<Button
size="sm"
variant="outline"
onClick={handleAddTip}
className="text-xs rounded-xl cursor-pointer"
>
+ ثبت نکته
</Button>
</div>
)}
</div>
</div>
)}
{/* تب ۴: مسافران (Passengers) */}
{activeTab === 'passengers' && (
<div className="space-y-4">
{isLoadingSub ? (
<TourPassengersShimmer />
) : passengersGroup.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-12 text-center text-grey-3 flex flex-col items-center justify-center min-h-[280px]">
<Ic name="users" className="size-10 mb-3 opacity-40 text-primary" />
<p className="text-xs font-bold text-foreground">هنوز مسافری برای این تور به ثبت نرسیده است.</p>
</div>
) : (
<div className="space-y-3">
{passengersGroup.map((group, idx) => (
<div
key={group.user_id || idx}
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3"
>
<div className="flex items-center justify-between border-b border-border-soft pb-2 text-xs">
<span className="font-bold text-foreground">
سرگروه رزرو: {group.user}
</span>
<span className="text-grey-3 font-mono">
{group.passengers?.length || 0} مسافر
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{group.passengers?.map((p) => (
<div
key={p.id}
className="rounded-xl border border-border-soft bg-surface-base p-3 space-y-1 text-xs"
>
<div className="font-bold text-foreground">{p.fullname}</div>
<div className="flex justify-between text-grey-3 font-mono text-[11px]" dir="ltr">
<span>Passport: {p.passport_number || '-'}</span>
<span>DOB: {p.birthdate || '-'}</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* تب ۵: نظرات کاربران (Comments) */}
{activeTab === 'comments' && (
<div className="space-y-4">
{isLoadingSub ? (
<TourCommentsShimmer />
) : comments.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-12 text-center text-grey-3 flex flex-col items-center justify-center min-h-[280px]">
<Ic name="message" className="size-10 mb-3 opacity-40 text-primary" />
<p className="text-xs font-bold text-foreground">تاکنون نظری برای این تور ثبت نشده است.</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<div
key={comment.id}
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-2"
>
<div className="flex items-center justify-between text-xs">
<span className="font-bold text-foreground">{comment.user}</span>
<div className="flex items-center gap-1 text-amber-500 font-mono font-bold">
<span>★</span>
<span>{comment.score}</span>
</div>
</div>
<p className="text-xs text-grey-2 leading-relaxed">{comment.text}</p>
</div>
))}
</div>
)}
</div>
)}
</>
) : null}
</div>
</DialogContent>
</Dialog>
{/* مودال هشدار تغییرات ذخیره‌نشده (Unsaved Changes Warning Dialog) */}
<Dialog open={showUnsavedDialog} onOpenChange={(open) => !open && setShowUnsavedDialog(false)}>
<DialogContent className="max-w-md w-full p-6 border border-border-soft bg-card rounded-2xl shadow-2xl space-y-4">
<div className="flex items-start gap-3.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-amber-500/15 text-amber-500 shrink-0">
<Ic name="alert" className="size-5" />
</div>
<div className="space-y-1 text-start">
<DialogTitle className="text-sm font-black text-foreground">
تغییرات ذخیره‌نشده دارید
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 leading-relaxed">
شما در مشخصات، تصاویر، متون زبان‌ها یا برنامه سفر تغییراتی ایجاد کرده‌اید که هنوز ذخیره نشده‌اند. در صورت خروج بدون ذخیره، تمامی این تغییرات از بین خواهند رفت.
</DialogDescription>
</div>
</div>
<div className="flex flex-col-reverse sm:flex-row sm:items-center justify-end gap-2 pt-2 border-t border-border-soft">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUnsavedDialog(false)}
className="text-xs font-bold rounded-xl cursor-pointer"
>
انصراف و ادامه ویرایش
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDiscardAndProceed}
className="text-xs font-bold rounded-xl border-rose-500/30 text-rose-500 hover:bg-rose-500/10 cursor-pointer"
>
خروج بدون ذخیره
</Button>
<Button
type="button"
variant="default"
size="sm"
isLoading={isSaving}
loadingText="در حال ذخیره..."
disabled={hasNoImages}
onClick={handleSaveAndProceed}
className="text-xs font-bold rounded-xl bg-primary text-white hover:bg-primary/90 shadow-xs cursor-pointer disabled:opacity-50"
>
<Ic name="save" className="size-3.5" />
<span>ذخیره تغییرات و خروج</span>
</Button>
</div>
</DialogContent>
</Dialog>
{/* دیالوگ تایید حذف کامل تور */}
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
title={`حذف تور «${tour?.title || `#${tourId}`}»`}
description="آیا از حذف کامل این تور از سامانه اطمینان دارید؟ تمامی اطلاعات، برنامه‌های سفر و تصاویر ثبت‌شده برای این تور به صورت دائمی حذف خواهند شد."
confirmText="بله، حذف کامل تور"
cancelText="انصراف"
isLoading={isDeletingTour}
onConfirm={handleConfirmDeleteTour}
/>
</>
)
}