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
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)}
|
|
>
|
|
×
|
|
</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"
|
|
>
|
|
×
|
|
</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} •{' '}
|
|
{(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"
|
|
>
|
|
×
|
|
</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}
|
|
/>
|
|
</>
|
|
)
|
|
}
|