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.
 
 
 

641 lines
26 KiB

import * as React from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Ic } from '@/icons'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { TourDetailOverviewShimmer } from './TourDetailShimmer'
import {
fetchTourTranslations,
saveTourTranslations,
fetchTourItineraryTranslations,
saveTourItineraryTranslation,
} from '../services/tour-api'
import type {
TourTranslationsResponse,
TourTranslationData,
TourItineraryTranslationsResponseItem,
} from '../types'
interface TourTranslationsDialogProps {
tourId: number | null
tourTitle?: string
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}
const SUPPORTED_LANGUAGES = [
{ code: 'ar', label: 'العربية', flag: '🇸🇦', dir: 'rtl' },
{ code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' },
{ code: 'en', label: 'English', flag: '🇬🇧', dir: 'ltr' },
{ code: 'ru', label: 'Русский', flag: '🇷🇺', dir: 'ltr' },
{ code: 'id', label: 'Indonesia', flag: '🇮🇩', dir: 'ltr' },
] as const
type LangCode = (typeof SUPPORTED_LANGUAGES)[number]['code']
export function TourTranslationsDialog({
tourId,
tourTitle,
open,
onOpenChange,
onSuccess,
}: TourTranslationsDialogProps) {
const [selectedLang, setSelectedLang] = React.useState<LangCode>('ar')
const [activeSection, setActiveSection] = React.useState<'base' | 'features' | 'tips' | 'itinerary'>('base')
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [isSaving, setIsSaving] = React.useState<boolean>(false)
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
const [successMessage, setSuccessMessage] = React.useState<string | null>(null)
// Translations data
const [transData, setTransData] = React.useState<TourTranslationsResponse | null>(null)
const [itineraries, setItineraries] = React.useState<TourItineraryTranslationsResponseItem[]>([])
// Local draft states for current selected language
const [formTitle, setFormTitle] = React.useState<string>('')
const [formDesc, setFormDesc] = React.useState<string>('')
const [formFeatures, setFormFeatures] = React.useState<string[]>([])
const [newFeatureText, setNewFeatureText] = React.useState<string>('')
const [formTips, setFormTips] = React.useState<{ title: string; desc: string }[]>([])
const [newTipTitle, setNewTipTitle] = React.useState<string>('')
const [newTipDesc, setNewTipDesc] = React.useState<string>('')
// Drafts for itineraries: { [itineraryId]: { title: string, summary: string } }
const [itinDrafts, setItinDrafts] = React.useState<Record<number, { title: string; summary: string }>>({})
// Load translations on open
React.useEffect(() => {
if (!open || !tourId) return
let isMounted = true
setIsLoading(true)
setErrorMessage(null)
setSuccessMessage(null)
Promise.all([
fetchTourTranslations(tourId),
fetchTourItineraryTranslations(tourId).catch(() => []),
])
.then(([tourTrans, itinTrans]) => {
if (!isMounted) return
setTransData(tourTrans)
setItineraries(itinTrans || [])
// Default to a language different from primary if primary is English/Persian
const defaultLang = tourTrans.primary_language === 'fa' ? 'ar' : 'fa'
setSelectedLang(defaultLang as LangCode)
initDraftForLanguage(defaultLang as LangCode, tourTrans, itinTrans || [])
})
.catch((err) => {
if (!isMounted) return
setErrorMessage(err?.message || 'خطا در بارگذاری ترجمه‌های تور از سرور.')
})
.finally(() => {
if (isMounted) setIsLoading(false)
})
return () => {
isMounted = false
}
}, [open, tourId])
// Sync draft whenever selectedLang changes
const initDraftForLanguage = (
lang: LangCode,
tourTrans: TourTranslationsResponse | null,
itinList: TourItineraryTranslationsResponseItem[]
) => {
if (!tourTrans) return
const isPrimary = tourTrans.primary_language === lang
const existingTour = isPrimary
? {
title: tourTrans.primary_data.title || '',
description: tourTrans.primary_data.description || '',
tour_feature: tourTrans.primary_data.tour_feature || [],
travel_tips: tourTrans.primary_data.travel_tips || {},
}
: tourTrans.translations[lang] || {
title: '',
description: '',
tour_feature: [],
travel_tips: {},
}
setFormTitle(existingTour.title || '')
setFormDesc(existingTour.description || '')
const featuresList = (existingTour.tour_feature || []).map((f) => f.title)
setFormFeatures(featuresList)
const tipsList = Object.entries(existingTour.travel_tips || {}).map(([title, desc]) => ({
title,
desc: String(desc),
}))
setFormTips(tipsList)
// Init itinerary drafts for this language
const drafts: Record<number, { title: string; summary: string }> = {}
itinList.forEach((it) => {
const itTrans = it.translations[lang]
drafts[it.id] = {
title: itTrans?.title || '',
summary: itTrans?.summary || '',
}
})
setItinDrafts(drafts)
}
const handleLanguageChange = (lang: LangCode) => {
setSelectedLang(lang)
setErrorMessage(null)
setSuccessMessage(null)
initDraftForLanguage(lang, transData, itineraries)
}
const handleAddFeature = () => {
if (!newFeatureText.trim()) return
setFormFeatures((prev) => [...prev, newFeatureText.trim()])
setNewFeatureText('')
}
const handleRemoveFeature = (idx: number) => {
setFormFeatures((prev) => prev.filter((_, i) => i !== idx))
}
const handleAddTip = () => {
if (!newTipTitle.trim() || !newTipDesc.trim()) return
setFormTips((prev) => [...prev, { title: newTipTitle.trim(), desc: newTipDesc.trim() }])
setNewTipTitle('')
setNewTipDesc('')
}
const handleRemoveTip = (idx: number) => {
setFormTips((prev) => prev.filter((_, i) => i !== idx))
}
const handleItineraryDraftChange = (itId: number, field: 'title' | 'summary', val: string) => {
setItinDrafts((prev) => ({
...prev,
[itId]: {
title: prev[itId]?.title || '',
summary: prev[itId]?.summary || '',
[field]: val,
},
}))
}
const handleSave = async () => {
if (!tourId || !transData) return
if (!formTitle.trim() && !isPrimary) {
setErrorMessage(`وارد کردن عنوان تور برای زبان ${activeLangConfig?.label || selectedLang} اجباری است.`)
return
}
setIsSaving(true)
setErrorMessage(null)
setSuccessMessage(null)
try {
// Build tour_feature array
const tour_feature = formFeatures.map((title) => ({ title }))
// Build travel_tips object
const travel_tips: Record<string, string> = {}
formTips.forEach((item) => {
travel_tips[item.title] = item.desc
})
// 1. Save Tour translation
await saveTourTranslations(tourId, {
lang_code: selectedLang,
title: formTitle.trim(),
description: formDesc.trim(),
tour_feature,
travel_tips,
})
// 2. Save Itinerary translations
const itinSavePromises = Object.entries(itinDrafts).map(([itIdStr, draft]) => {
const itId = Number(itIdStr)
if (!draft.title.trim() && !draft.summary.trim()) return Promise.resolve()
return saveTourItineraryTranslation(tourId, {
itinerary_id: itId,
lang_code: selectedLang,
title: draft.title.trim(),
summary: draft.summary.trim(),
})
})
await Promise.all(itinSavePromises)
// Refresh data
const [updatedTour, updatedItin] = await Promise.all([
fetchTourTranslations(tourId),
fetchTourItineraryTranslations(tourId).catch(() => []),
])
setTransData(updatedTour)
setItineraries(updatedItin || [])
setSuccessMessage(`ترجمه زبان ${SUPPORTED_LANGUAGES.find((l) => l.code === selectedLang)?.label} با موفقیت در دیتابیس ذخیره شد.`)
if (onSuccess) onSuccess()
} catch (err: any) {
setErrorMessage(err?.message || 'خطا در ذخیره ترجمه در سرور.')
} finally {
setIsSaving(false)
}
}
const activeLangConfig = SUPPORTED_LANGUAGES.find((l) => l.code === selectedLang)
const isPrimary = transData?.primary_language === selectedLang
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[760px] p-0 overflow-hidden max-h-[90vh] flex flex-col">
{/* Dialog Header */}
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name="globe" className="size-5" />
</div>
<div>
<DialogTitle className="text-base font-extrabold text-foreground">
مدیریت ترجمه‌های 5 زبانه تور
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
{tourTitle || `تور شناسه #${tourId}`} &bull; ذخیره‌سازی پویا در سرور و پایگاه‌داده
</DialogDescription>
</div>
</div>
<div className="flex items-center gap-2">
{/* Primary Language Indicator */}
{transData && (
<div className="flex items-center gap-1.5 rounded-full bg-surface-2 px-3 py-1 text-xs text-grey-2 border border-border/40">
<span className="text-grey-3">زبان پایه دیتابیس:</span>
<span className="font-bold text-foreground">
{SUPPORTED_LANGUAGES.find((l) => l.code === transData.primary_language)?.label ||
transData.primary_language}
</span>
</div>
)}
{/* دکمه ضربدر بالای مودال */}
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="بستن پنجره"
title="بستن"
className="flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer mr-1"
>
<Ic name="x" className="size-4.5" />
</button>
</div>
</div>
{/* 5-Language Selector Bar */}
<div className="flex items-center gap-1.5 mt-4 overflow-x-auto pb-1">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang.code === selectedLang
const hasTrans = Boolean(
transData?.primary_language === lang.code ||
transData?.translations[lang.code]?.title
)
return (
<button
key={lang.code}
type="button"
onClick={() => handleLanguageChange(lang.code)}
className={`flex items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition-all ${
isSelected
? 'bg-primary text-white shadow-md'
: 'bg-surface-2 text-grey-2 hover:bg-surface-3 hover:text-foreground border border-border/30'
}`}
>
<span className="text-sm">{lang.flag}</span>
<span>{lang.label}</span>
{hasTrans && !isSelected && (
<span className="size-1.5 rounded-full bg-emerald-500" />
)}
{transData?.primary_language === lang.code && (
<span className="text-[10px] opacity-80">(اصلی)</span>
)}
</button>
)
})}
</div>
</DialogHeader>
{/* Dialog Body */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{isLoading ? (
<TourDetailOverviewShimmer />
) : (
<>
{errorMessage && (
<div className="flex items-center gap-2 rounded-xl bg-rose-500/10 border border-rose-500/20 px-4 py-3 text-xs text-rose-400">
<Ic name="alert" className="size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
{successMessage && (
<div className="flex items-center gap-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 text-xs text-emerald-400">
<Ic name="check" className="size-4 shrink-0" />
<span>{successMessage}</span>
</div>
)}
{/* Section Tabs */}
<div className="flex items-center gap-2 border-b border-border/40 pb-2">
<button
type="button"
onClick={() => setActiveSection('base')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
activeSection === 'base'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
}`}
>
اطلاعات کلی (عنوان و شرح)
</button>
<button
type="button"
onClick={() => setActiveSection('features')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
activeSection === 'features'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
}`}
>
ویژگی‌های تور ({formFeatures.length})
</button>
<button
type="button"
onClick={() => setActiveSection('tips')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
activeSection === 'tips'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
}`}
>
نکات و راهنمای سفر ({formTips.length})
</button>
<button
type="button"
onClick={() => setActiveSection('itinerary')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-colors ${
activeSection === 'itinerary'
? 'bg-primary/15 text-primary'
: 'text-grey-3 hover:text-foreground'
}`}
>
مراحل سفر ({itineraries.length})
</button>
</div>
{/* Active Section Form */}
<div dir={activeLangConfig?.dir || 'rtl'} className="space-y-4">
{/* 1. Base Info */}
{activeSection === 'base' && (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-grey-2 mb-1.5">
عنوان تور به زبان {activeLangConfig?.label} ({selectedLang}):
</label>
<Input
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder={`عنوان تور به زبان ${activeLangConfig?.label}...`}
className="bg-surface-2 border-border/50 text-sm"
disabled={isPrimary}
/>
{isPrimary && (
<p className="text-[11px] text-amber-400/80 mt-1">
* این زبان، زبان اصلی تور است و از فرم اصلی مدیریت می‌شود.
</p>
)}
</div>
<div>
<label className="block text-xs font-bold text-grey-2 mb-1.5">
شرح و توضیحات تور:
</label>
<textarea
rows={4}
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
placeholder={`توضیحات کامل تور به زبان ${activeLangConfig?.label}...`}
disabled={isPrimary}
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-60"
/>
</div>
</div>
)}
{/* 2. Tour Features */}
{activeSection === 'features' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
value={newFeatureText}
onChange={(e) => setNewFeatureText(e.target.value)}
placeholder={`افزودن ویژگی جدید به ${activeLangConfig?.label}...`}
className="bg-surface-2 border-border/50 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddFeature()
}
}}
/>
<Button
type="button"
size="sm"
onClick={handleAddFeature}
className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white"
>
<Ic name="plus" className="size-4" />
<span>افزودن</span>
</Button>
</div>
<div className="space-y-2 mt-3">
{formFeatures.length === 0 ? (
<p className="text-xs text-grey-4 text-center py-6">
هنوز ویژگی برای این زبان اضافه نشده است.
</p>
) : (
formFeatures.map((feat, idx) => (
<div
key={idx}
className="flex items-center justify-between gap-3 rounded-xl bg-surface-2 border border-border/40 px-3.5 py-2 text-xs"
>
<span className="font-medium text-foreground">{feat}</span>
<button
type="button"
onClick={() => handleRemoveFeature(idx)}
className="text-grey-4 hover:text-rose-400 p-1"
>
<Ic name="x" className="size-3.5" />
</button>
</div>
))
)}
</div>
</div>
)}
{/* 3. Travel Tips */}
{activeSection === 'tips' && (
<div className="space-y-3">
<div className="rounded-xl bg-surface-2/60 border border-border/40 p-3 space-y-2">
<Input
value={newTipTitle}
onChange={(e) => setNewTipTitle(e.target.value)}
placeholder="عنوان نکته (مثلاً: لباس مناسب / Walking Shoes)..."
className="bg-surface-2 text-xs"
/>
<textarea
rows={2}
value={newTipDesc}
onChange={(e) => setNewTipDesc(e.target.value)}
placeholder="توضیح نکته سفر..."
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none"
/>
<Button
type="button"
size="sm"
onClick={handleAddTip}
className="w-full bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-8"
>
<Ic name="plus" className="size-3.5" />
<span>افزودن نکته سفر</span>
</Button>
</div>
<div className="space-y-2 mt-3">
{formTips.length === 0 ? (
<p className="text-xs text-grey-4 text-center py-6">
هنوز نکته سفری برای این زبان ثبت نشده است.
</p>
) : (
formTips.map((tip, idx) => (
<div
key={idx}
className="rounded-xl bg-surface-2 border border-border/40 p-3 text-xs space-y-1 relative"
>
<div className="flex items-center justify-between">
<span className="font-bold text-foreground">{tip.title}</span>
<button
type="button"
onClick={() => handleRemoveTip(idx)}
className="text-grey-4 hover:text-rose-400 p-0.5"
>
<Ic name="x" className="size-3.5" />
</button>
</div>
<p className="text-grey-3 leading-relaxed">{tip.desc}</p>
</div>
))
)}
</div>
</div>
)}
{/* 4. Itinerary Steps Translations */}
{activeSection === 'itinerary' && (
<div className="space-y-4">
{itineraries.length === 0 ? (
<p className="text-xs text-grey-4 text-center py-8">
هیچ مرحله سفری برای این تور ثبت نشده است.
</p>
) : (
itineraries.map((it, idx) => {
const draft = itinDrafts[it.id] || { title: '', summary: '' }
return (
<div
key={it.id}
className="rounded-xl bg-surface-2 border border-border/40 p-3.5 space-y-2.5"
>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary/20 text-primary text-[10px] font-bold">
{idx + 1}
</span>
<span className="font-bold text-foreground">
عنوان اصلی: {it.title}
</span>
</div>
<span className="text-[10px] text-grey-3">شناسه #{it.id}</span>
</div>
<Input
value={draft.title}
onChange={(e) =>
handleItineraryDraftChange(it.id, 'title', e.target.value)
}
placeholder={`عنوان مرحله به ${activeLangConfig?.label}...`}
className="bg-surface-3 border-border/50 text-xs"
/>
<textarea
rows={2}
value={draft.summary}
onChange={(e) =>
handleItineraryDraftChange(it.id, 'summary', e.target.value)
}
placeholder={`شرح مرحله به ${activeLangConfig?.label}...`}
className="w-full rounded-xl border border-border/50 bg-surface-3 px-3 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none"
/>
</div>
)
})
)}
</div>
)}
</div>
</>
)}
</div>
{/* Dialog Footer */}
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between">
<div className="text-xs text-grey-3">
زبان در حال ویرایش: <span className="font-bold text-primary">{activeLangConfig?.label}</span>
</div>
<div className="flex items-center gap-2">
<DialogClose asChild>
<Button type="button" variant="outline" size="sm" className="rounded-xl">
بستن
</Button>
</DialogClose>
<Button
type="button"
size="sm"
disabled={isPrimary || isLoading}
isLoading={isSaving}
loadingText="در حال ذخیره..."
onClick={handleSave}
className="rounded-xl bg-primary text-white hover:bg-primary/90 gap-1.5 shadow-sm"
>
<Ic name="check" className="size-3.5" />
<span>ذخیره ترجمه {activeLangConfig?.label}</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}