Browse Source
feat: implement tours and billing features with supporting pages, components, and services
master
feat: implement tours and billing features with supporting pages, components, and services
master
21 changed files with 3120 additions and 212 deletions
-
87src/components/shared/Shimmer.tsx
-
2src/components/shared/TablePagination.tsx
-
331src/components/shared/UniversalDatePicker.tsx
-
17src/components/ui/dialog.tsx
-
133src/features/billing/BillingPage.tsx
-
5src/features/billing/components/BillingDetailModal.tsx
-
26src/features/tours/OngoingToursPage.tsx
-
5src/features/tours/TourOrdersPage.tsx
-
182src/features/tours/ToursPage.tsx
-
957src/features/tours/components/CreateTourDialog.tsx
-
948src/features/tours/components/TourDetailDialog.tsx
-
26src/features/tours/components/TourDetailSheet.tsx
-
3src/features/tours/components/TourStatusBadge.tsx
-
37src/features/tours/components/TourTranslationsDialog.tsx
-
31src/features/tours/services/tour-api.ts
-
1src/features/tours/types.ts
-
4src/icons.tsx
-
2src/index.css
-
486src/lib/date-utils.ts
-
42src/lib/jalali.ts
-
7src/services/http.ts
@ -0,0 +1,331 @@ |
|||||
|
import React, { useState, useEffect, useRef, useMemo } from 'react' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { useTranslation } from '@/i18n' |
||||
|
import { |
||||
|
parseSafeDate, |
||||
|
formatDateByLanguage, |
||||
|
gregorianToJalali, |
||||
|
jalaliToGregorian, |
||||
|
gregorianToHijri, |
||||
|
hijriToGregorian, |
||||
|
getCalendarMonthData, |
||||
|
JALALI_MONTH_NAMES, |
||||
|
HIJRI_MONTH_NAMES, |
||||
|
GREGORIAN_MONTH_NAMES, |
||||
|
} from '@/lib/date-utils' |
||||
|
import { cn } from '@/lib/utils' |
||||
|
|
||||
|
export interface UniversalDatePickerProps { |
||||
|
value?: string | null |
||||
|
onChange: (isoDate: string) => void |
||||
|
placeholder?: string |
||||
|
disabled?: boolean |
||||
|
label?: string |
||||
|
className?: string |
||||
|
minDate?: string |
||||
|
maxDate?: string |
||||
|
required?: boolean |
||||
|
size?: 'sm' | 'default' | 'lg' |
||||
|
clearable?: boolean |
||||
|
} |
||||
|
|
||||
|
export function UniversalDatePicker({ |
||||
|
value, |
||||
|
onChange, |
||||
|
placeholder, |
||||
|
disabled = false, |
||||
|
label, |
||||
|
className, |
||||
|
minDate, |
||||
|
maxDate, |
||||
|
required = false, |
||||
|
size = 'default', |
||||
|
clearable = true, |
||||
|
}: UniversalDatePickerProps) { |
||||
|
const { language, dir } = useTranslation() |
||||
|
const [isOpen, setIsOpen] = useState(false) |
||||
|
const containerRef = useRef<HTMLDivElement>(null) |
||||
|
|
||||
|
// تعیین نوع تقویم بر اساس زبان فعال
|
||||
|
const calendarType: 'persian' | 'islamic' | 'gregorian' = |
||||
|
language === 'fa' ? 'persian' : language === 'ar' ? 'islamic' : 'gregorian' |
||||
|
|
||||
|
// مقدار فعلی به صورت Date
|
||||
|
const selectedDate = useMemo(() => parseSafeDate(value), [value]) |
||||
|
|
||||
|
// وضعیت ماه و سال در حال مشاهده در تقویم
|
||||
|
const [viewYear, setViewYear] = useState<number>(() => { |
||||
|
const d = selectedDate || new Date() |
||||
|
if (calendarType === 'persian') return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()).year |
||||
|
if (calendarType === 'islamic') return gregorianToHijri(d).year |
||||
|
return d.getFullYear() |
||||
|
}) |
||||
|
|
||||
|
const [viewMonth, setViewMonth] = useState<number>(() => { |
||||
|
const d = selectedDate || new Date() |
||||
|
if (calendarType === 'persian') return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()).month |
||||
|
if (calendarType === 'islamic') return gregorianToHijri(d).month |
||||
|
return d.getMonth() + 1 |
||||
|
}) |
||||
|
|
||||
|
// وقتی مقدار تغییر کند یا باز شود، تقویم روی تاریخ انتخابی یا امروز تنظیم شود
|
||||
|
useEffect(() => { |
||||
|
if (isOpen) { |
||||
|
const d = selectedDate || new Date() |
||||
|
if (calendarType === 'persian') { |
||||
|
const j = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()) |
||||
|
setViewYear(j.year) |
||||
|
setViewMonth(j.month) |
||||
|
} else if (calendarType === 'islamic') { |
||||
|
const h = gregorianToHijri(d) |
||||
|
setViewYear(h.year) |
||||
|
setViewMonth(h.month) |
||||
|
} else { |
||||
|
setViewYear(d.getFullYear()) |
||||
|
setViewMonth(d.getMonth() + 1) |
||||
|
} |
||||
|
} |
||||
|
}, [isOpen, selectedDate, calendarType]) |
||||
|
|
||||
|
// بستن پاپاور هنگام کلیک به بیرون یا فشردن Escape
|
||||
|
useEffect(() => { |
||||
|
if (!isOpen) return |
||||
|
const handleClickOutside = (e: MouseEvent) => { |
||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { |
||||
|
setIsOpen(false) |
||||
|
} |
||||
|
} |
||||
|
const handleKeyDown = (e: KeyboardEvent) => { |
||||
|
if (e.key === 'Escape') setIsOpen(false) |
||||
|
} |
||||
|
document.addEventListener('mousedown', handleClickOutside) |
||||
|
document.addEventListener('keydown', handleKeyDown) |
||||
|
return () => { |
||||
|
document.removeEventListener('mousedown', handleClickOutside) |
||||
|
document.removeEventListener('keydown', handleKeyDown) |
||||
|
} |
||||
|
}, [isOpen]) |
||||
|
|
||||
|
// رفتن به ماه قبل
|
||||
|
const handlePrevMonth = (e: React.MouseEvent) => { |
||||
|
e.stopPropagation() |
||||
|
if (viewMonth === 1) { |
||||
|
setViewMonth(12) |
||||
|
setViewYear((y) => y - 1) |
||||
|
} else { |
||||
|
setViewMonth((m) => m - 1) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// رفتن به ماه بعد
|
||||
|
const handleNextMonth = (e: React.MouseEvent) => { |
||||
|
e.stopPropagation() |
||||
|
if (viewMonth === 12) { |
||||
|
setViewMonth(1) |
||||
|
setViewYear((y) => y + 1) |
||||
|
} else { |
||||
|
setViewMonth((m) => m + 1) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// دادههای ماه و ماتریس روزها
|
||||
|
const monthData = useMemo(() => { |
||||
|
return getCalendarMonthData(calendarType, viewYear, viewMonth) |
||||
|
}, [calendarType, viewYear, viewMonth]) |
||||
|
|
||||
|
// بررسی وضعیت امروز و انتخاب
|
||||
|
const todayIso = useMemo(() => { |
||||
|
const n = new Date() |
||||
|
return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}` |
||||
|
}, []) |
||||
|
|
||||
|
const selectedIso = useMemo(() => { |
||||
|
if (!selectedDate) return '' |
||||
|
return `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}` |
||||
|
}, [selectedDate]) |
||||
|
|
||||
|
// انتخاب یک روز
|
||||
|
const handleSelectDay = (dayNumber: number) => { |
||||
|
const isoStr = monthData.toIso(dayNumber) |
||||
|
onChange(isoStr) |
||||
|
setIsOpen(false) |
||||
|
} |
||||
|
|
||||
|
// انتخاب تاریخ امروز
|
||||
|
const handleSelectToday = () => { |
||||
|
onChange(todayIso) |
||||
|
setIsOpen(false) |
||||
|
} |
||||
|
|
||||
|
// پاک کردن تاریخ
|
||||
|
const handleClear = (e?: React.MouseEvent) => { |
||||
|
if (e) e.stopPropagation() |
||||
|
onChange('') |
||||
|
setIsOpen(false) |
||||
|
} |
||||
|
|
||||
|
const formattedDisplayValue = value ? formatDateByLanguage(value, language, { style: 'medium' }) : '' |
||||
|
const defaultPlaceholder = |
||||
|
language === 'fa' ? 'انتخاب تاریخ...' : language === 'ar' ? 'اختر التاريخ...' : 'Select date...' |
||||
|
|
||||
|
return ( |
||||
|
<div className={cn('relative inline-flex flex-col', className)} ref={containerRef}> |
||||
|
{label && ( |
||||
|
<label className="mb-1.5 text-xs font-bold text-foreground"> |
||||
|
{label} {required && <span className="text-rose-500">*</span>} |
||||
|
</label> |
||||
|
)} |
||||
|
|
||||
|
{/* دکمه اینپوت تریگر انتخاب تاریخ */} |
||||
|
<div |
||||
|
onClick={() => !disabled && setIsOpen((prev) => !prev)} |
||||
|
className={cn( |
||||
|
'flex items-center justify-between gap-2 rounded-xl border border-border-soft bg-card/60 px-3 text-xs transition cursor-pointer select-none', |
||||
|
size === 'sm' && 'h-8 text-[11px]', |
||||
|
size === 'default' && 'h-9', |
||||
|
size === 'lg' && 'h-11 text-sm', |
||||
|
disabled && 'opacity-50 cursor-not-allowed pointer-events-none', |
||||
|
isOpen ? 'border-primary ring-1 ring-primary/40' : 'hover:border-primary/40', |
||||
|
!formattedDisplayValue && 'text-muted-foreground' |
||||
|
)} |
||||
|
> |
||||
|
<div className="flex items-center gap-2 truncate min-w-0"> |
||||
|
<Ic name="calendar" className="size-4 shrink-0 text-primary" /> |
||||
|
<span className="truncate font-medium"> |
||||
|
{formattedDisplayValue || placeholder || defaultPlaceholder} |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-1 shrink-0"> |
||||
|
{clearable && Boolean(value) && !disabled && ( |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={handleClear} |
||||
|
className="size-5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40 flex items-center justify-center transition" |
||||
|
title={language === 'fa' ? 'پاک کردن' : language === 'ar' ? 'مسح' : 'Clear'} |
||||
|
> |
||||
|
<Ic name="x" className="size-3.5" /> |
||||
|
</button> |
||||
|
)} |
||||
|
<Ic name="chevronDown" className={cn('size-3.5 text-muted-foreground transition-transform', isOpen && 'rotate-180')} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* پاپاور تقویم ماهانه */} |
||||
|
{isOpen && ( |
||||
|
<div |
||||
|
dir={dir} |
||||
|
className="absolute start-0 top-full z-50 mt-1.5 w-[280px] sm:w-[300px] rounded-2xl border border-border-soft bg-[#1e2128] p-3 shadow-xl backdrop-blur-md anim-pop" |
||||
|
> |
||||
|
{/* هدر کنترل ماه و سال */} |
||||
|
<div className="flex items-center justify-between gap-1 pb-2.5 border-b border-border-soft"> |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={dir === 'rtl' ? handleNextMonth : handlePrevMonth} |
||||
|
className="size-7 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-card" |
||||
|
> |
||||
|
<Ic name={dir === 'rtl' ? 'chevronRight' : 'chevronLeft'} className="size-4" /> |
||||
|
</Button> |
||||
|
|
||||
|
<div className="flex items-center gap-1 font-bold text-xs text-foreground"> |
||||
|
<span>{monthData.monthName}</span> |
||||
|
<span className="font-mono">{monthData.yearFormatted}</span> |
||||
|
</div> |
||||
|
|
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={dir === 'rtl' ? handlePrevMonth : handleNextMonth} |
||||
|
className="size-7 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-card" |
||||
|
> |
||||
|
<Ic name={dir === 'rtl' ? 'chevronLeft' : 'chevronRight'} className="size-4" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
{/* ردیف روزهای هفته */} |
||||
|
<div className="grid grid-cols-7 gap-1 pt-2 text-center text-[10px] font-bold text-muted-foreground"> |
||||
|
{monthData.weekdays.map((w, idx) => ( |
||||
|
<div key={idx} className="py-1"> |
||||
|
{w} |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
|
||||
|
{/* ماتریس روزهای ماه */} |
||||
|
<div className="grid grid-cols-7 gap-1 pt-1"> |
||||
|
{/* خانههای خالی ابتدای ماه */} |
||||
|
{Array.from({ length: monthData.startOffset }).map((_, idx) => ( |
||||
|
<div key={`empty-${idx}`} className="size-8" /> |
||||
|
))} |
||||
|
|
||||
|
{/* روزهای ماه */} |
||||
|
{Array.from({ length: monthData.daysInMonth }).map((_, idx) => { |
||||
|
const dayNum = idx + 1 |
||||
|
const dayIso = monthData.toIso(dayNum) |
||||
|
const isSelected = selectedIso === dayIso |
||||
|
const isToday = todayIso === dayIso |
||||
|
|
||||
|
// بررسی minDate و maxDate
|
||||
|
const isDisabled = |
||||
|
(minDate && dayIso < minDate) || |
||||
|
(maxDate && dayIso > maxDate) |
||||
|
|
||||
|
return ( |
||||
|
<button |
||||
|
key={dayNum} |
||||
|
type="button" |
||||
|
disabled={Boolean(isDisabled)} |
||||
|
onClick={() => handleSelectDay(dayNum)} |
||||
|
className={cn( |
||||
|
'size-8 rounded-xl text-xs font-bold transition flex items-center justify-center', |
||||
|
isSelected |
||||
|
? 'bg-primary text-primary-foreground shadow-sm' |
||||
|
: isToday |
||||
|
? 'border border-primary/60 text-primary hover:bg-primary/10' |
||||
|
: 'text-foreground hover:bg-card/70', |
||||
|
isDisabled && 'opacity-30 cursor-not-allowed pointer-events-none' |
||||
|
)} |
||||
|
> |
||||
|
{monthData.formatDayNumber(dayNum)} |
||||
|
</button> |
||||
|
) |
||||
|
})} |
||||
|
</div> |
||||
|
|
||||
|
{/* فوتر تقویم با دکمههای سریع */} |
||||
|
<div className="mt-3 pt-2.5 border-t border-border-soft flex items-center justify-between gap-1 text-[11px]"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={handleSelectToday} |
||||
|
className="text-primary hover:underline font-bold px-2 py-0.5 rounded" |
||||
|
> |
||||
|
{language === 'fa' ? 'امروز' : language === 'ar' ? 'اليوم' : 'Today'} |
||||
|
</button> |
||||
|
|
||||
|
{value && ( |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={handleClear} |
||||
|
className="text-muted-foreground hover:text-rose-400 px-2 py-0.5 rounded" |
||||
|
> |
||||
|
{language === 'fa' ? 'پاک کردن' : language === 'ar' ? 'مسح' : 'Clear'} |
||||
|
</button> |
||||
|
)} |
||||
|
|
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setIsOpen(false)} |
||||
|
className="text-muted-foreground hover:text-foreground px-2 py-0.5 rounded" |
||||
|
> |
||||
|
{language === 'fa' ? 'بستن' : language === 'ar' ? 'إغلاق' : 'Close'} |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,957 @@ |
|||||
|
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 { createTour, type CreateTourPayload } from '../services/tour-api' |
||||
|
import type { TourTranslationData } from '../types' |
||||
|
|
||||
|
interface CreateTourDialogProps { |
||||
|
open: boolean |
||||
|
onOpenChange: (open: boolean) => void |
||||
|
onSuccess?: () => void |
||||
|
} |
||||
|
|
||||
|
const SUPPORTED_LANGUAGES = [ |
||||
|
{ code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' }, |
||||
|
{ code: 'ar', 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 |
||||
|
|
||||
|
const DESTINATION_COUNTRIES = [ |
||||
|
{ code: 'IQ', name: 'عراق (Iraq)', flag: '🇮🇶' }, |
||||
|
{ code: 'SA', name: 'عربستان (Saudi Arabia)', flag: '🇸🇦' }, |
||||
|
{ code: 'IR', name: 'ایران (Iran)', flag: '🇮🇷' }, |
||||
|
{ code: 'SY', name: 'سوریه (Syria)', flag: '🇸🇾' }, |
||||
|
{ code: 'OM', name: 'عمان (Oman)', flag: '🇴🇲' }, |
||||
|
] as const |
||||
|
|
||||
|
type LangCode = (typeof SUPPORTED_LANGUAGES)[number]['code'] |
||||
|
|
||||
|
export function CreateTourDialog({ |
||||
|
open, |
||||
|
onOpenChange, |
||||
|
onSuccess, |
||||
|
}: CreateTourDialogProps) { |
||||
|
const [activeTab, setActiveTab] = React.useState<'basic' | 'features_tips' | 'translations'>('basic') |
||||
|
const [selectedTransLang, setSelectedTransLang] = React.useState<LangCode>('en') |
||||
|
const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false) |
||||
|
const [errorMessage, setErrorMessage] = React.useState<string | null>(null) |
||||
|
const [successMessage, setSuccessMessage] = React.useState<string | null>(null) |
||||
|
|
||||
|
// Basic info states
|
||||
|
const [title, setTitle] = React.useState('') |
||||
|
const [description, setDescription] = React.useState('') |
||||
|
const [startedAt, setStartedAt] = React.useState('') |
||||
|
const [endedAt, setEndedAt] = React.useState('') |
||||
|
const [capacity, setCapacity] = React.useState('40') |
||||
|
const [destinationCountry, setDestinationCountry] = React.useState('IQ') |
||||
|
const [primaryLang, setPrimaryLang] = React.useState<LangCode>('fa') |
||||
|
const [status, setStatus] = React.useState('AVAILABLE') |
||||
|
|
||||
|
// Pricing states
|
||||
|
const [price, setPrice] = React.useState('') |
||||
|
const [priceChild, setPriceChild] = React.useState('') |
||||
|
const [priceInfant, setPriceInfant] = React.useState('') |
||||
|
const [percentOff, setPercentOff] = React.useState('0') |
||||
|
|
||||
|
// Base features & tips
|
||||
|
const [features, setFeatures] = React.useState<string[]>([]) |
||||
|
const [newFeature, setNewFeature] = React.useState('') |
||||
|
const [tips, setTips] = React.useState<{ title: string; desc: string }[]>([]) |
||||
|
const [newTipTitle, setNewTipTitle] = React.useState('') |
||||
|
const [newTipDesc, setNewTipDesc] = React.useState('') |
||||
|
|
||||
|
// Multi-language translation state
|
||||
|
const [translations, setTranslations] = React.useState<Record<string, TourTranslationData>>({ |
||||
|
fa: { title: '', description: '', tour_feature: [], travel_tips: {} }, |
||||
|
ar: { title: '', description: '', tour_feature: [], travel_tips: {} }, |
||||
|
en: { title: '', description: '', tour_feature: [], travel_tips: {} }, |
||||
|
ru: { title: '', description: '', tour_feature: [], travel_tips: {} }, |
||||
|
id: { title: '', description: '', tour_feature: [], travel_tips: {} }, |
||||
|
}) |
||||
|
|
||||
|
// Reset form when opened
|
||||
|
React.useEffect(() => { |
||||
|
if (open) { |
||||
|
setErrorMessage(null) |
||||
|
setSuccessMessage(null) |
||||
|
setActiveTab('basic') |
||||
|
} |
||||
|
}, [open]) |
||||
|
|
||||
|
const handleAddFeature = () => { |
||||
|
if (!newFeature.trim()) return |
||||
|
setFeatures((prev) => [...prev, newFeature.trim()]) |
||||
|
setNewFeature('') |
||||
|
} |
||||
|
|
||||
|
const handleRemoveFeature = (idx: number) => { |
||||
|
setFeatures((prev) => prev.filter((_, i) => i !== idx)) |
||||
|
} |
||||
|
|
||||
|
const handleAddTip = () => { |
||||
|
if (!newTipTitle.trim() || !newTipDesc.trim()) return |
||||
|
setTips((prev) => [...prev, { title: newTipTitle.trim(), desc: newTipDesc.trim() }]) |
||||
|
setNewTipTitle('') |
||||
|
setNewTipDesc('') |
||||
|
} |
||||
|
|
||||
|
const handleRemoveTip = (idx: number) => { |
||||
|
setTips((prev) => prev.filter((_, i) => i !== idx)) |
||||
|
} |
||||
|
|
||||
|
// Helper for updating translation fields for selectedTransLang
|
||||
|
const handleTransFieldChange = (field: 'title' | 'description', val: string) => { |
||||
|
setTranslations((prev) => ({ |
||||
|
...prev, |
||||
|
[selectedTransLang]: { |
||||
|
...prev[selectedTransLang], |
||||
|
[field]: val, |
||||
|
}, |
||||
|
})) |
||||
|
} |
||||
|
|
||||
|
const handleAddTransFeature = (text: string) => { |
||||
|
if (!text.trim()) return |
||||
|
setTranslations((prev) => { |
||||
|
const current = prev[selectedTransLang] || { tour_feature: [] } |
||||
|
const currFeats = current.tour_feature || [] |
||||
|
return { |
||||
|
...prev, |
||||
|
[selectedTransLang]: { |
||||
|
...current, |
||||
|
tour_feature: [...currFeats, { title: text.trim() }], |
||||
|
}, |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
const handleRemoveTransFeature = (idx: number) => { |
||||
|
setTranslations((prev) => { |
||||
|
const current = prev[selectedTransLang] || { tour_feature: [] } |
||||
|
const currFeats = current.tour_feature || [] |
||||
|
return { |
||||
|
...prev, |
||||
|
[selectedTransLang]: { |
||||
|
...current, |
||||
|
tour_feature: currFeats.filter((_, i) => i !== idx), |
||||
|
}, |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
const handleAddTransTip = (tipTitle: string, tipDesc: string) => { |
||||
|
if (!tipTitle.trim() || !tipDesc.trim()) return |
||||
|
setTranslations((prev) => { |
||||
|
const current = prev[selectedTransLang] || { travel_tips: {} } |
||||
|
const currTips = current.travel_tips || {} |
||||
|
return { |
||||
|
...prev, |
||||
|
[selectedTransLang]: { |
||||
|
...current, |
||||
|
travel_tips: { |
||||
|
...currTips, |
||||
|
[tipTitle.trim()]: tipDesc.trim(), |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
const handleRemoveTransTip = (tipTitle: string) => { |
||||
|
setTranslations((prev) => { |
||||
|
const current = prev[selectedTransLang] || { travel_tips: {} } |
||||
|
const currTips = { ...(current.travel_tips || {}) } |
||||
|
delete currTips[tipTitle] |
||||
|
return { |
||||
|
...prev, |
||||
|
[selectedTransLang]: { |
||||
|
...current, |
||||
|
travel_tips: currTips, |
||||
|
}, |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
const [newTransFeatText, setNewTransFeatText] = React.useState('') |
||||
|
const [newTransTipTitle, setNewTransTipTitle] = React.useState('') |
||||
|
const [newTransTipDesc, setNewTransTipDesc] = React.useState('') |
||||
|
|
||||
|
const handleSubmit = async (e: React.FormEvent) => { |
||||
|
e.preventDefault() |
||||
|
setErrorMessage(null) |
||||
|
setSuccessMessage(null) |
||||
|
|
||||
|
// Validation
|
||||
|
if (!title.trim()) { |
||||
|
setErrorMessage('لطفاً عنوان اصلی تور را وارد نمایید.') |
||||
|
setActiveTab('basic') |
||||
|
return |
||||
|
} |
||||
|
if (!startedAt) { |
||||
|
setErrorMessage('لطفاً تاریخ شروع تور را مشخص نمایید.') |
||||
|
setActiveTab('basic') |
||||
|
return |
||||
|
} |
||||
|
if (!endedAt) { |
||||
|
setErrorMessage('لطفاً تاریخ پایان تور را مشخص نمایید.') |
||||
|
setActiveTab('basic') |
||||
|
return |
||||
|
} |
||||
|
if (new Date(endedAt) < new Date(startedAt)) { |
||||
|
setErrorMessage('تاریخ پایان تور نمیتواند قبل از تاریخ شروع باشد.') |
||||
|
setActiveTab('basic') |
||||
|
return |
||||
|
} |
||||
|
if (!price || isNaN(Number(price)) || Number(price) <= 0) { |
||||
|
setErrorMessage('لطفاً قیمت معتبر برای بزرگسال وارد نمایید.') |
||||
|
setActiveTab('basic') |
||||
|
return |
||||
|
} |
||||
|
if (!capacity || isNaN(Number(capacity)) || Number(capacity) <= 0) { |
||||
|
setErrorMessage('لطفاً ظرفیت معتبر برای تور وارد نمایید.') |
||||
|
setActiveTab('basic') |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
setIsSubmitting(true) |
||||
|
|
||||
|
try { |
||||
|
const tour_feature = features.map((f) => ({ title: f })) |
||||
|
const travel_tips: Record<string, string> = {} |
||||
|
tips.forEach((t) => { |
||||
|
travel_tips[t.title] = t.desc |
||||
|
}) |
||||
|
|
||||
|
// Clean translations payload: include any language that has data
|
||||
|
const cleanedTranslations: Record<string, TourTranslationData> = {} |
||||
|
Object.entries(translations).forEach(([code, data]) => { |
||||
|
const hasTitle = Boolean(data.title?.trim()) |
||||
|
const hasDesc = Boolean(data.description?.trim()) |
||||
|
const hasFeats = Boolean(data.tour_feature && data.tour_feature.length > 0) |
||||
|
const hasTips = Boolean(data.travel_tips && Object.keys(data.travel_tips).length > 0) |
||||
|
|
||||
|
if (hasTitle || hasDesc || hasFeats || hasTips) { |
||||
|
cleanedTranslations[code] = { |
||||
|
title: data.title?.trim() || (code === primaryLang ? title.trim() : ''), |
||||
|
description: data.description?.trim() || '', |
||||
|
tour_feature: data.tour_feature || [], |
||||
|
travel_tips: data.travel_tips || {}, |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
// Ensure primary language translation is also present if not defined
|
||||
|
if (!cleanedTranslations[primaryLang]) { |
||||
|
cleanedTranslations[primaryLang] = { |
||||
|
title: title.trim(), |
||||
|
description: description.trim(), |
||||
|
tour_feature, |
||||
|
travel_tips, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const payload: CreateTourPayload = { |
||||
|
title: title.trim(), |
||||
|
description: description.trim(), |
||||
|
started_at: startedAt, |
||||
|
ended_at: endedAt, |
||||
|
capacity: Number(capacity), |
||||
|
price: String(price), |
||||
|
price_child: priceChild ? String(priceChild) : '0', |
||||
|
price_infant: priceInfant ? String(priceInfant) : '0', |
||||
|
percent_off: percentOff ? Number(percentOff) : 0, |
||||
|
destination_country_code: destinationCountry, |
||||
|
language_code: primaryLang, |
||||
|
status, |
||||
|
tour_feature, |
||||
|
travel_tips, |
||||
|
visibility: true, |
||||
|
translations: cleanedTranslations, |
||||
|
} |
||||
|
|
||||
|
const createdTour = await createTour(payload) |
||||
|
setSuccessMessage(`تور "${createdTour.title}" با موفقیت ایجاد و ترجمهها در دیتابیس ثبت گردید.`) |
||||
|
|
||||
|
setTimeout(() => { |
||||
|
if (onSuccess) onSuccess() |
||||
|
onOpenChange(false) |
||||
|
}, 900) |
||||
|
} catch (err: any) { |
||||
|
setErrorMessage(err?.message || 'خطا در ثبت تور جدید در سرور.') |
||||
|
} finally { |
||||
|
setIsSubmitting(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const activeTransConfig = SUPPORTED_LANGUAGES.find((l) => l.code === selectedTransLang) |
||||
|
const currentTransData = translations[selectedTransLang] || { |
||||
|
title: '', |
||||
|
description: '', |
||||
|
tour_feature: [], |
||||
|
travel_tips: {}, |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent |
||||
|
onPointerDownOutside={(e) => e.preventDefault()} |
||||
|
onInteractOutside={(e) => e.preventDefault()} |
||||
|
className="max-w-[840px] p-0 overflow-hidden max-h-[92vh] flex flex-col" |
||||
|
> |
||||
|
{/* Header */} |
||||
|
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15"> |
||||
|
<div className="flex items-center justify-between gap-3"> |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0"> |
||||
|
<Ic name="plus" className="size-5" /> |
||||
|
</div> |
||||
|
<div> |
||||
|
<DialogTitle className="text-base font-extrabold text-foreground"> |
||||
|
افزودن تور زیارتی و گردشگری جدید |
||||
|
</DialogTitle> |
||||
|
<DialogDescription className="text-xs text-grey-3 mt-0.5"> |
||||
|
ثبت مشخصات پایه، هزینهها، ظرفیت و ورودیهای ترجمه ۵ زبانه همزمان |
||||
|
</DialogDescription> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-2"> |
||||
|
{/* Step/Tab buttons */} |
||||
|
<div className="flex items-center gap-1 rounded-xl bg-surface-2 p-1 border border-border/40"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setActiveTab('basic')} |
||||
|
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all cursor-pointer ${ |
||||
|
activeTab === 'basic' |
||||
|
? 'bg-primary text-white shadow-xs' |
||||
|
: 'text-grey-3 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
۱. مشخصات و قیمت |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setActiveTab('features_tips')} |
||||
|
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all cursor-pointer ${ |
||||
|
activeTab === 'features_tips' |
||||
|
? 'bg-primary text-white shadow-xs' |
||||
|
: 'text-grey-3 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
۲. ویژگیها و نکات |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setActiveTab('translations')} |
||||
|
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all flex items-center gap-1.5 cursor-pointer ${ |
||||
|
activeTab === 'translations' |
||||
|
? 'bg-primary text-white shadow-xs' |
||||
|
: 'text-grey-3 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
<Ic name="globe" className="size-3.5" /> |
||||
|
<span>۳. ترجمههای ۵ زبانه</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
{/* دکمه علامت ضربدر بالای مودال */} |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => onOpenChange(false)} |
||||
|
aria-label="بستن پنجره" |
||||
|
title="بستن" |
||||
|
className="flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer" |
||||
|
> |
||||
|
<Ic name="x" className="size-4.5" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</DialogHeader> |
||||
|
|
||||
|
{/* Form Body */} |
||||
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto px-6 py-5 space-y-5 flex flex-col justify-between"> |
||||
|
<div className="space-y-4"> |
||||
|
{errorMessage && ( |
||||
|
<div className="flex items-center gap-2 rounded-xl bg-rose-500/10 border border-rose-500/20 px-4 py-3 text-xs text-rose-400"> |
||||
|
<Ic name="alert" className="size-4 shrink-0" /> |
||||
|
<span>{errorMessage}</span> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{successMessage && ( |
||||
|
<div className="flex items-center gap-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 px-4 py-3 text-xs text-emerald-400"> |
||||
|
<Ic name="check" className="size-4 shrink-0" /> |
||||
|
<span>{successMessage}</span> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* TAB 1: Basic Info & Pricing */} |
||||
|
{activeTab === 'basic' && ( |
||||
|
<div className="space-y-4"> |
||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
||||
|
<div className="sm:col-span-2"> |
||||
|
<label className="block text-xs font-bold text-foreground mb-1.5"> |
||||
|
عنوان اصلی تور <span className="text-rose-500">*</span>: |
||||
|
</label> |
||||
|
<Input |
||||
|
value={title} |
||||
|
onChange={(e) => setTitle(e.target.value)} |
||||
|
placeholder="مثال: تور زیارتی عتبات عالیات نجف و کربلا (ویژه اربعین)..." |
||||
|
className="bg-surface-2 border-border/50 text-sm font-medium" |
||||
|
required |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-foreground mb-1.5"> |
||||
|
کشور مقصد <span className="text-rose-500">*</span>: |
||||
|
</label> |
||||
|
<select |
||||
|
value={destinationCountry} |
||||
|
onChange={(e) => setDestinationCountry(e.target.value)} |
||||
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none" |
||||
|
> |
||||
|
{DESTINATION_COUNTRIES.map((c) => ( |
||||
|
<option key={c.code} value={c.code}> |
||||
|
{c.flag} {c.name} |
||||
|
</option> |
||||
|
))} |
||||
|
</select> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-foreground mb-1.5"> |
||||
|
زبان اصلی ثبت داده: |
||||
|
</label> |
||||
|
<select |
||||
|
value={primaryLang} |
||||
|
onChange={(e) => setPrimaryLang(e.target.value as LangCode)} |
||||
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none" |
||||
|
> |
||||
|
{SUPPORTED_LANGUAGES.map((l) => ( |
||||
|
<option key={l.code} value={l.code}> |
||||
|
{l.flag} {l.label} ({l.code}) |
||||
|
</option> |
||||
|
))} |
||||
|
</select> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-foreground mb-1.5"> |
||||
|
تاریخ شروع تور <span className="text-rose-500">*</span>: |
||||
|
</label> |
||||
|
<Input |
||||
|
type="date" |
||||
|
value={startedAt} |
||||
|
onChange={(e) => setStartedAt(e.target.value)} |
||||
|
className="bg-surface-2 border-border/50 text-sm font-mono" |
||||
|
required |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-foreground mb-1.5"> |
||||
|
تاریخ پایان تور <span className="text-rose-500">*</span>: |
||||
|
</label> |
||||
|
<Input |
||||
|
type="date" |
||||
|
value={endedAt} |
||||
|
onChange={(e) => setEndedAt(e.target.value)} |
||||
|
className="bg-surface-2 border-border/50 text-sm font-mono" |
||||
|
required |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-foreground mb-1.5"> |
||||
|
ظرفیت کل مسافران <span className="text-rose-500">*</span>: |
||||
|
</label> |
||||
|
<Input |
||||
|
type="number" |
||||
|
min="1" |
||||
|
value={capacity} |
||||
|
onChange={(e) => setCapacity(e.target.value)} |
||||
|
placeholder="۴۰" |
||||
|
className="bg-surface-2 border-border/50 text-sm font-mono" |
||||
|
required |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-foreground mb-1.5"> |
||||
|
وضعیت فروش اولیه: |
||||
|
</label> |
||||
|
<select |
||||
|
value={status} |
||||
|
onChange={(e) => setStatus(e.target.value)} |
||||
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground focus:border-primary focus:outline-none" |
||||
|
> |
||||
|
<option value="AVAILABLE">در دسترس (Available)</option> |
||||
|
<option value="SOLD_OUT">تکمیل ظرفیت (Sold Out)</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Pricing Sub-card */} |
||||
|
<div className="rounded-2xl bg-surface-2/70 border border-border/50 p-4 space-y-3"> |
||||
|
<h4 className="text-xs font-extrabold text-foreground flex items-center gap-1.5"> |
||||
|
<Ic name="dollar" className="size-4 text-emerald-500" /> |
||||
|
<span>قیمتگذاری و تخفیفات (دلار)</span> |
||||
|
</h4> |
||||
|
|
||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> |
||||
|
<div> |
||||
|
<label className="block text-[11px] font-bold text-grey-2 mb-1"> |
||||
|
بزرگسال ($) <span className="text-rose-500">*</span>: |
||||
|
</label> |
||||
|
<Input |
||||
|
type="number" |
||||
|
step="0.01" |
||||
|
value={price} |
||||
|
onChange={(e) => setPrice(e.target.value)} |
||||
|
placeholder="520" |
||||
|
className="bg-surface-3 text-xs font-mono" |
||||
|
required |
||||
|
/> |
||||
|
</div> |
||||
|
<div> |
||||
|
<label className="block text-[11px] font-bold text-grey-2 mb-1"> |
||||
|
کودک (۲ تا ۷ سال): |
||||
|
</label> |
||||
|
<Input |
||||
|
type="number" |
||||
|
step="0.01" |
||||
|
value={priceChild} |
||||
|
onChange={(e) => setPriceChild(e.target.value)} |
||||
|
placeholder="280" |
||||
|
className="bg-surface-3 text-xs font-mono" |
||||
|
/> |
||||
|
</div> |
||||
|
<div> |
||||
|
<label className="block text-[11px] font-bold text-grey-2 mb-1"> |
||||
|
نوزاد (زیر ۲ سال): |
||||
|
</label> |
||||
|
<Input |
||||
|
type="number" |
||||
|
step="0.01" |
||||
|
value={priceInfant} |
||||
|
onChange={(e) => setPriceInfant(e.target.value)} |
||||
|
placeholder="80" |
||||
|
className="bg-surface-3 text-xs font-mono" |
||||
|
/> |
||||
|
</div> |
||||
|
<div> |
||||
|
<label className="block text-[11px] font-bold text-grey-2 mb-1"> |
||||
|
درصد تخفیف (%): |
||||
|
</label> |
||||
|
<Input |
||||
|
type="number" |
||||
|
min="0" |
||||
|
max="100" |
||||
|
value={percentOff} |
||||
|
onChange={(e) => setPercentOff(e.target.value)} |
||||
|
placeholder="0" |
||||
|
className="bg-surface-3 text-xs font-mono" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Description */} |
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-foreground mb-1.5"> |
||||
|
شرح و توضیحات تور: |
||||
|
</label> |
||||
|
<textarea |
||||
|
rows={3} |
||||
|
value={description} |
||||
|
onChange={(e) => setDescription(e.target.value)} |
||||
|
placeholder="شرح کامل خدمات، برنامههای زیارتی و تسهیلات اقامتی تور..." |
||||
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2.5 text-sm text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* TAB 2: Features and Tips */} |
||||
|
{activeTab === 'features_tips' && ( |
||||
|
<div className="space-y-5"> |
||||
|
{/* Features Section */} |
||||
|
<div className="space-y-3"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<label className="text-xs font-bold text-foreground flex items-center gap-1.5"> |
||||
|
<Ic name="check" className="size-4 text-primary" /> |
||||
|
<span>ویژگیها و خدمات شاخص تور ({features.length})</span> |
||||
|
</label> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Input |
||||
|
value={newFeature} |
||||
|
onChange={(e) => setNewFeature(e.target.value)} |
||||
|
placeholder="مثال: اقامت در هتل ۵ ستاره نزدیک حرم..." |
||||
|
className="bg-surface-2 text-xs" |
||||
|
onKeyDown={(e) => { |
||||
|
if (e.key === 'Enter') { |
||||
|
e.preventDefault() |
||||
|
handleAddFeature() |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
onClick={handleAddFeature} |
||||
|
className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-9 rounded-xl" |
||||
|
> |
||||
|
<Ic name="plus" className="size-4" /> |
||||
|
<span>افزودن</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2"> |
||||
|
{features.length === 0 ? ( |
||||
|
<p className="text-xs text-grey-4 col-span-2 text-center py-4 bg-surface-2/30 rounded-xl"> |
||||
|
هنوز ویژگی برای این تور اضافه نشده است. |
||||
|
</p> |
||||
|
) : ( |
||||
|
features.map((feat, idx) => ( |
||||
|
<div |
||||
|
key={idx} |
||||
|
className="flex items-center justify-between gap-2 rounded-xl bg-surface-2 border border-border/40 px-3 py-2 text-xs" |
||||
|
> |
||||
|
<span className="font-medium text-foreground truncate">{feat}</span> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => handleRemoveFeature(idx)} |
||||
|
className="text-grey-4 hover:text-rose-400 p-1 shrink-0" |
||||
|
> |
||||
|
<Ic name="x" className="size-3.5" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
)) |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Travel Tips Section */} |
||||
|
<div className="space-y-3 pt-3 border-t border-border/40"> |
||||
|
<label className="text-xs font-bold text-foreground flex items-center gap-1.5"> |
||||
|
<Ic name="info" className="size-4 text-amber-500" /> |
||||
|
<span>نکات و راهنمای سفر ({tips.length})</span> |
||||
|
</label> |
||||
|
|
||||
|
<div className="rounded-xl bg-surface-2/60 border border-border/40 p-3 space-y-2"> |
||||
|
<Input |
||||
|
value={newTipTitle} |
||||
|
onChange={(e) => setNewTipTitle(e.target.value)} |
||||
|
placeholder="عنوان نکته (مثال: مدارک لازم / ارز مسافرتی)..." |
||||
|
className="bg-surface-2 text-xs" |
||||
|
/> |
||||
|
<textarea |
||||
|
rows={2} |
||||
|
value={newTipDesc} |
||||
|
onChange={(e) => setNewTipDesc(e.target.value)} |
||||
|
placeholder="توضیحات تکمیلی نکته سفر..." |
||||
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none" |
||||
|
/> |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
onClick={handleAddTip} |
||||
|
className="w-full bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-8 rounded-xl" |
||||
|
> |
||||
|
<Ic name="plus" className="size-3.5 ml-1" /> |
||||
|
<span>افزودن نکته سفر</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-2 mt-2"> |
||||
|
{tips.length === 0 ? ( |
||||
|
<p className="text-xs text-grey-4 text-center py-4 bg-surface-2/30 rounded-xl"> |
||||
|
نکته سفری ثبت نشده است. |
||||
|
</p> |
||||
|
) : ( |
||||
|
tips.map((tip, idx) => ( |
||||
|
<div |
||||
|
key={idx} |
||||
|
className="rounded-xl bg-surface-2 border border-border/40 p-3 text-xs space-y-1 relative" |
||||
|
> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="font-bold text-foreground">{tip.title}</span> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => handleRemoveTip(idx)} |
||||
|
className="text-grey-4 hover:text-rose-400 p-0.5" |
||||
|
> |
||||
|
<Ic name="x" className="size-3.5" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
<p className="text-grey-3 leading-relaxed">{tip.desc}</p> |
||||
|
</div> |
||||
|
)) |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* TAB 3: Multi-Language Translations */} |
||||
|
{activeTab === 'translations' && ( |
||||
|
<div className="space-y-4"> |
||||
|
{/* 5-Language Selector */} |
||||
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 border-b border-border/40"> |
||||
|
{SUPPORTED_LANGUAGES.map((lang) => { |
||||
|
const isSelected = lang.code === selectedTransLang |
||||
|
const transObj = translations[lang.code] |
||||
|
const hasData = Boolean( |
||||
|
transObj?.title?.trim() || |
||||
|
transObj?.description?.trim() || |
||||
|
(transObj?.tour_feature && transObj.tour_feature.length > 0) |
||||
|
) |
||||
|
return ( |
||||
|
<button |
||||
|
key={lang.code} |
||||
|
type="button" |
||||
|
onClick={() => setSelectedTransLang(lang.code)} |
||||
|
className={`flex items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition-all ${ |
||||
|
isSelected |
||||
|
? 'bg-primary text-white shadow-md' |
||||
|
: 'bg-surface-2 text-grey-2 hover:bg-surface-3 hover:text-foreground border border-border/30' |
||||
|
}`}
|
||||
|
> |
||||
|
<span className="text-sm">{lang.flag}</span> |
||||
|
<span>{lang.label}</span> |
||||
|
{hasData && !isSelected && ( |
||||
|
<span className="size-1.5 rounded-full bg-emerald-500" /> |
||||
|
)} |
||||
|
{lang.code === primaryLang && ( |
||||
|
<span className="text-[10px] opacity-80">(اصلی)</span> |
||||
|
)} |
||||
|
</button> |
||||
|
) |
||||
|
})} |
||||
|
</div> |
||||
|
|
||||
|
<div dir={activeTransConfig?.dir || 'rtl'} className="space-y-4"> |
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-foreground mb-1.5"> |
||||
|
عنوان تور به زبان {activeTransConfig?.label} ({selectedTransLang}): |
||||
|
</label> |
||||
|
<Input |
||||
|
value={currentTransData.title || ''} |
||||
|
onChange={(e) => handleTransFieldChange('title', e.target.value)} |
||||
|
placeholder={`عنوان تور به زبان ${activeTransConfig?.label}...`} |
||||
|
className="bg-surface-2 border-border/50 text-sm" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<label className="block text-xs font-bold text-foreground mb-1.5"> |
||||
|
شرح و توضیحات به زبان {activeTransConfig?.label}: |
||||
|
</label> |
||||
|
<textarea |
||||
|
rows={3} |
||||
|
value={currentTransData.description || ''} |
||||
|
onChange={(e) => handleTransFieldChange('description', e.target.value)} |
||||
|
placeholder={`شرح تور به زبان ${activeTransConfig?.label}...`} |
||||
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3.5 py-2 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{/* Features for this language */} |
||||
|
<div className="space-y-2"> |
||||
|
<label className="block text-xs font-bold text-foreground"> |
||||
|
ویژگیها به زبان {activeTransConfig?.label} ({(currentTransData.tour_feature || []).length}): |
||||
|
</label> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Input |
||||
|
value={newTransFeatText} |
||||
|
onChange={(e) => setNewTransFeatText(e.target.value)} |
||||
|
placeholder={`افزودن ویژگی به ${activeTransConfig?.label}...`} |
||||
|
className="bg-surface-2 text-xs" |
||||
|
onKeyDown={(e) => { |
||||
|
if (e.key === 'Enter') { |
||||
|
e.preventDefault() |
||||
|
handleAddTransFeature(newTransFeatText) |
||||
|
setNewTransFeatText('') |
||||
|
} |
||||
|
}} |
||||
|
/> |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
onClick={() => { |
||||
|
handleAddTransFeature(newTransFeatText) |
||||
|
setNewTransFeatText('') |
||||
|
}} |
||||
|
className="shrink-0 bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-9 rounded-xl" |
||||
|
> |
||||
|
<Ic name="plus" className="size-4" /> |
||||
|
<span>افزودن</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
{(currentTransData.tour_feature || []).map((feat, fIdx) => ( |
||||
|
<div |
||||
|
key={fIdx} |
||||
|
className="flex items-center justify-between rounded-xl bg-surface-2 border border-border/40 px-3 py-1.5 text-xs" |
||||
|
> |
||||
|
<span className="font-medium text-foreground">{feat.title}</span> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => handleRemoveTransFeature(fIdx)} |
||||
|
className="text-grey-4 hover:text-rose-400 p-0.5" |
||||
|
> |
||||
|
<Ic name="x" className="size-3.5" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Travel Tips for this language */} |
||||
|
<div className="space-y-2 pt-2 border-t border-border/40"> |
||||
|
<label className="block text-xs font-bold text-foreground"> |
||||
|
نکات سفر به زبان {activeTransConfig?.label} ({Object.keys(currentTransData.travel_tips || {}).length}): |
||||
|
</label> |
||||
|
<div className="rounded-xl bg-surface-2/60 border border-border/40 p-2.5 space-y-2"> |
||||
|
<Input |
||||
|
value={newTransTipTitle} |
||||
|
onChange={(e) => setNewTransTipTitle(e.target.value)} |
||||
|
placeholder={`عنوان نکته به ${activeTransConfig?.label}...`} |
||||
|
className="bg-surface-2 text-xs" |
||||
|
/> |
||||
|
<textarea |
||||
|
rows={2} |
||||
|
value={newTransTipDesc} |
||||
|
onChange={(e) => setNewTransTipDesc(e.target.value)} |
||||
|
placeholder={`شرح نکته به ${activeTransConfig?.label}...`} |
||||
|
className="w-full rounded-xl border border-border/50 bg-surface-2 px-3 py-1.5 text-xs text-foreground placeholder:text-grey-4 focus:border-primary focus:outline-none" |
||||
|
/> |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
onClick={() => { |
||||
|
handleAddTransTip(newTransTipTitle, newTransTipDesc) |
||||
|
setNewTransTipTitle('') |
||||
|
setNewTransTipDesc('') |
||||
|
}} |
||||
|
className="w-full bg-primary/20 text-primary hover:bg-primary hover:text-white text-xs h-8 rounded-xl" |
||||
|
> |
||||
|
<Ic name="plus" className="size-3.5 ml-1" /> |
||||
|
<span>افزودن نکته به زبان {activeTransConfig?.label}</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1.5"> |
||||
|
{Object.entries(currentTransData.travel_tips || {}).map(([tTitle, tDesc], tIdx) => ( |
||||
|
<div |
||||
|
key={tIdx} |
||||
|
className="rounded-xl bg-surface-2 border border-border/40 p-2.5 text-xs space-y-1" |
||||
|
> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="font-bold text-foreground">{tTitle}</span> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => handleRemoveTransTip(tTitle)} |
||||
|
className="text-grey-4 hover:text-rose-400 p-0.5" |
||||
|
> |
||||
|
<Ic name="x" className="size-3.5" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
<p className="text-grey-3 leading-relaxed">{tDesc}</p> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{/* Footer */} |
||||
|
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between -mx-6 -mb-5 mt-6"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="text-xs text-grey-3"> |
||||
|
{activeTab === 'basic' && 'مرحله ۱ از ۳: اطلاعات پایه و قیمت'} |
||||
|
{activeTab === 'features_tips' && 'مرحله ۲ از ۳: ویژگیها و نکات'} |
||||
|
{activeTab === 'translations' && 'مرحله ۳ از ۳: ترجمههای چندزبانه'} |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-2"> |
||||
|
<DialogClose asChild> |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={() => onOpenChange(false)} |
||||
|
className="rounded-xl text-xs cursor-pointer" |
||||
|
> |
||||
|
انصراف |
||||
|
</Button> |
||||
|
</DialogClose> |
||||
|
|
||||
|
{activeTab === 'basic' && ( |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
onClick={() => setActiveTab('features_tips')} |
||||
|
className="rounded-xl bg-primary text-white hover:bg-primary/90 text-xs gap-1.5" |
||||
|
> |
||||
|
<span>مرحله بعد: ویژگیها</span> |
||||
|
<Ic name="arrow-left" className="size-3.5" /> |
||||
|
</Button> |
||||
|
)} |
||||
|
|
||||
|
{activeTab === 'features_tips' && ( |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
onClick={() => setActiveTab('translations')} |
||||
|
className="rounded-xl bg-primary text-white hover:bg-primary/90 text-xs gap-1.5" |
||||
|
> |
||||
|
<span>مرحله بعد: ترجمهها</span> |
||||
|
<Ic name="arrow-left" className="size-3.5" /> |
||||
|
</Button> |
||||
|
)} |
||||
|
|
||||
|
<Button |
||||
|
type="submit" |
||||
|
size="sm" |
||||
|
disabled={isSubmitting} |
||||
|
className="rounded-xl bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold gap-1.5 shadow-sm" |
||||
|
> |
||||
|
{isSubmitting ? ( |
||||
|
<> |
||||
|
<div className="size-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" /> |
||||
|
<span>در حال ثبت تور...</span> |
||||
|
</> |
||||
|
) : ( |
||||
|
<> |
||||
|
<Ic name="check" className="size-3.5" /> |
||||
|
<span>ایجاد و ثبت نهایی تور</span> |
||||
|
</> |
||||
|
)} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</DialogFooter> |
||||
|
</form> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,948 @@ |
|||||
|
import React, { useState, useEffect } from 'react' |
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogContent, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
DialogDescription, |
||||
|
} from '@/components/ui/dialog' |
||||
|
import { Tabs, TabsContent, 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 { Ic } from '@/icons' |
||||
|
import { Shimmer } from '@/components/shared/Shimmer' |
||||
|
import { |
||||
|
fetchTourDetail, |
||||
|
fetchTourItineraries, |
||||
|
fetchTourPassengers, |
||||
|
fetchTourComments, |
||||
|
fetchTourTranslations, |
||||
|
saveTourTranslations, |
||||
|
fetchTourItineraryTranslations, |
||||
|
saveTourItineraryTranslation, |
||||
|
} from '../services/tour-api' |
||||
|
import type { |
||||
|
TourDetailItem, |
||||
|
TourItineraryItem, |
||||
|
TourPassengerGroupItem, |
||||
|
TourCommentItem, |
||||
|
TourTranslationsResponse, |
||||
|
TourItineraryTranslationsResponseItem, |
||||
|
} from '../types' |
||||
|
import { TourStatusBadge, CountryFlagBadge } from './TourStatusBadge' |
||||
|
|
||||
|
interface TourDetailDialogProps { |
||||
|
tourId: number | null |
||||
|
isOpen: boolean |
||||
|
onClose: () => void |
||||
|
onUpdated?: () => void |
||||
|
} |
||||
|
|
||||
|
const SUPPORTED_LANGUAGES = [ |
||||
|
{ code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' }, |
||||
|
{ code: 'ar', 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 TourDetailDialog({ |
||||
|
tourId, |
||||
|
isOpen, |
||||
|
onClose, |
||||
|
onUpdated, |
||||
|
}: TourDetailDialogProps) { |
||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'itinerary' | 'features' | 'passengers' | 'comments'>('overview') |
||||
|
const [selectedLang, setSelectedLang] = useState<LangCode>('fa') |
||||
|
|
||||
|
// Main tour data
|
||||
|
const [tour, setTour] = useState<TourDetailItem | null>(null) |
||||
|
const [itineraries, setItineraries] = useState<TourItineraryItem[]>([]) |
||||
|
const [passengersGroup, setPassengersGroup] = useState<TourPassengerGroupItem[]>([]) |
||||
|
const [comments, setComments] = useState<TourCommentItem[]>([]) |
||||
|
|
||||
|
// Translation data from server
|
||||
|
const [transData, setTransData] = useState<TourTranslationsResponse | null>(null) |
||||
|
const [itinTransList, setItinTransList] = useState<TourItineraryTranslationsResponseItem[]>([]) |
||||
|
|
||||
|
// Translation local form drafts
|
||||
|
const [draftTitle, setDraftTitle] = useState<string>('') |
||||
|
const [draftDesc, setDraftDesc] = useState<string>('') |
||||
|
const [draftFeatures, setDraftFeatures] = useState<string[]>([]) |
||||
|
const [newFeatureInput, setNewFeatureInput] = useState<string>('') |
||||
|
const [draftTips, setDraftTips] = useState<{ title: string; desc: string }[]>([]) |
||||
|
const [newTipTitle, setNewTipTitle] = useState<string>('') |
||||
|
const [newTipDesc, setNewTipDesc] = useState<string>('') |
||||
|
const [itinDrafts, setItinDrafts] = useState<Record<number, { title: string; summary: string }>>({}) |
||||
|
|
||||
|
// Statuses
|
||||
|
const [isLoading, setIsLoading] = useState(false) |
||||
|
const [isLoadingSub, setIsLoadingSub] = useState(false) |
||||
|
const [isSaving, setIsSaving] = useState(false) |
||||
|
const [error, setError] = useState<string | null>(null) |
||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null) |
||||
|
const [savedItinId, setSavedItinId] = useState<number | null>(null) |
||||
|
|
||||
|
// Load tour detail and all translations on open
|
||||
|
useEffect(() => { |
||||
|
if (!tourId || !isOpen) { |
||||
|
setTour(null) |
||||
|
setTransData(null) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
let isMounted = true |
||||
|
setIsLoading(true) |
||||
|
setError(null) |
||||
|
setSuccessMessage(null) |
||||
|
setActiveTab('overview') |
||||
|
|
||||
|
Promise.all([ |
||||
|
fetchTourDetail(tourId), |
||||
|
fetchTourTranslations(tourId).catch(() => null), |
||||
|
fetchTourItineraryTranslations(tourId).catch(() => []), |
||||
|
]) |
||||
|
.then(([detail, trans, itinTrans]) => { |
||||
|
if (!isMounted) return |
||||
|
setTour(detail) |
||||
|
setTransData(trans) |
||||
|
setItinTransList(itinTrans || []) |
||||
|
|
||||
|
const primaryLang = (trans?.primary_language || 'fa') as LangCode |
||||
|
setSelectedLang(primaryLang) |
||||
|
initDraftForLanguage(primaryLang, trans, itinTrans || [], detail) |
||||
|
setIsLoading(false) |
||||
|
}) |
||||
|
.catch((err: any) => { |
||||
|
if (isMounted) { |
||||
|
setError(err?.message || 'خطا در بارگذاری اطلاعات کامل تور') |
||||
|
setIsLoading(false) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
return () => { |
||||
|
isMounted = false |
||||
|
} |
||||
|
}, [tourId, isOpen]) |
||||
|
|
||||
|
// Sync draft fields when language changes
|
||||
|
const initDraftForLanguage = ( |
||||
|
lang: LangCode, |
||||
|
translations: TourTranslationsResponse | null, |
||||
|
itinList: TourItineraryTranslationsResponseItem[], |
||||
|
tourDetail: TourDetailItem | null |
||||
|
) => { |
||||
|
if (translations) { |
||||
|
const isPrimary = translations.primary_language === lang |
||||
|
const existing = isPrimary |
||||
|
? { |
||||
|
title: translations.primary_data.title || tourDetail?.title || '', |
||||
|
description: translations.primary_data.description || tourDetail?.description || '', |
||||
|
tour_feature: translations.primary_data.tour_feature || [], |
||||
|
travel_tips: translations.primary_data.travel_tips || {}, |
||||
|
} |
||||
|
: translations.translations[lang] || { |
||||
|
title: '', |
||||
|
description: '', |
||||
|
tour_feature: [], |
||||
|
travel_tips: {}, |
||||
|
} |
||||
|
|
||||
|
setDraftTitle(existing.title || '') |
||||
|
setDraftDesc(existing.description || '') |
||||
|
setDraftFeatures((existing.tour_feature || []).map((f) => f.title)) |
||||
|
setDraftTips( |
||||
|
Object.entries(existing.travel_tips || {}).map(([title, desc]) => ({ |
||||
|
title, |
||||
|
desc: String(desc), |
||||
|
})) |
||||
|
) |
||||
|
} else if (tourDetail) { |
||||
|
setDraftTitle(tourDetail.title || '') |
||||
|
setDraftDesc(tourDetail.description || '') |
||||
|
setDraftFeatures((tourDetail.tour_features || []).map((f) => f.title)) |
||||
|
setDraftTips( |
||||
|
(tourDetail.travel_tips || []).map((t) => ({ |
||||
|
title: t.title, |
||||
|
desc: t.description, |
||||
|
})) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// 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 || (lang === translations?.primary_language ? it.title : ''), |
||||
|
summary: itTrans?.summary || (lang === translations?.primary_language ? it.summary : ''), |
||||
|
} |
||||
|
}) |
||||
|
setItinDrafts(drafts) |
||||
|
} |
||||
|
|
||||
|
const handleLanguageSwitch = (lang: LangCode) => { |
||||
|
setSelectedLang(lang) |
||||
|
setSuccessMessage(null) |
||||
|
initDraftForLanguage(lang, transData, itinTransList, tour) |
||||
|
} |
||||
|
|
||||
|
// 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]) |
||||
|
|
||||
|
// Save tour translation for base overview
|
||||
|
const handleSaveBaseTranslation = async () => { |
||||
|
if (!tourId) return |
||||
|
setIsSaving(true) |
||||
|
setSuccessMessage(null) |
||||
|
|
||||
|
try { |
||||
|
await saveTourTranslations(tourId, { |
||||
|
lang_code: selectedLang, |
||||
|
title: draftTitle.trim(), |
||||
|
description: draftDesc.trim(), |
||||
|
}) |
||||
|
|
||||
|
const updatedTrans = await fetchTourTranslations(tourId) |
||||
|
setTransData(updatedTrans) |
||||
|
setSuccessMessage(`ترجمه مشخصات تور برای زبان ${activeLangConfig.label} با موفقیت ذخیره شد.`) |
||||
|
if (onUpdated) onUpdated() |
||||
|
} catch (err: any) { |
||||
|
setError(err?.message || 'خطا در ذخیره ترجمه مشخصات تور') |
||||
|
} finally { |
||||
|
setIsSaving(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Save specific itinerary step translation
|
||||
|
const handleSaveItineraryStep = async (itineraryId: number) => { |
||||
|
if (!tourId) return |
||||
|
const draft = itinDrafts[itineraryId] |
||||
|
if (!draft) return |
||||
|
|
||||
|
setIsSaving(true) |
||||
|
setSavedItinId(itineraryId) |
||||
|
|
||||
|
try { |
||||
|
await saveTourItineraryTranslation(tourId, { |
||||
|
itinerary_id: itineraryId, |
||||
|
lang_code: selectedLang, |
||||
|
title: draft.title.trim(), |
||||
|
summary: draft.summary.trim(), |
||||
|
}) |
||||
|
|
||||
|
const updatedItinTrans = await fetchTourItineraryTranslations(tourId) |
||||
|
setItinTransList(updatedItinTrans) |
||||
|
setSuccessMessage(`ترجمه مرحله سفر (${draft.title || itineraryId}) برای زبان ${activeLangConfig.label} ذخیره شد.`) |
||||
|
if (onUpdated) onUpdated() |
||||
|
} catch (err: any) { |
||||
|
setError(err?.message || 'خطا در ذخیره ترجمه مرحله سفر') |
||||
|
} finally { |
||||
|
setIsSaving(false) |
||||
|
setSavedItinId(null) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Save features & tips translation
|
||||
|
const handleSaveFeaturesAndTips = async () => { |
||||
|
if (!tourId) return |
||||
|
setIsSaving(true) |
||||
|
setSuccessMessage(null) |
||||
|
|
||||
|
try { |
||||
|
const tour_feature = draftFeatures.map((title) => ({ title })) |
||||
|
const travel_tips: Record<string, string> = {} |
||||
|
draftTips.forEach((t) => { |
||||
|
travel_tips[t.title] = t.desc |
||||
|
}) |
||||
|
|
||||
|
await saveTourTranslations(tourId, { |
||||
|
lang_code: selectedLang, |
||||
|
tour_feature, |
||||
|
travel_tips, |
||||
|
}) |
||||
|
|
||||
|
const updatedTrans = await fetchTourTranslations(tourId) |
||||
|
setTransData(updatedTrans) |
||||
|
setSuccessMessage(`امکانات و نکات راهنما برای زبان ${activeLangConfig.label} با موفقیت ذخیره شد.`) |
||||
|
if (onUpdated) onUpdated() |
||||
|
} catch (err: any) { |
||||
|
setError(err?.message || 'خطا در ذخیره امکانات و نکات راهنما') |
||||
|
} finally { |
||||
|
setIsSaving(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const activeLangConfig = SUPPORTED_LANGUAGES.find((l) => l.code === selectedLang) || SUPPORTED_LANGUAGES[0] |
||||
|
|
||||
|
return ( |
||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> |
||||
|
<DialogContent className="max-w-4xl w-full p-0 overflow-hidden max-h-[90vh] flex flex-col border border-border-soft bg-background rounded-2xl shadow-xl"> |
||||
|
{/* Header Modal */} |
||||
|
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border-soft bg-card/40 backdrop-blur-sm shrink-0"> |
||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> |
||||
|
<div className="flex items-center gap-3"> |
||||
|
<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> |
||||
|
<div className="space-y-0.5 text-start"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<DialogTitle className="text-base font-extrabold text-foreground"> |
||||
|
{tour?.title || 'جزئیات و ترجمههای تور'} |
||||
|
</DialogTitle> |
||||
|
</div> |
||||
|
<DialogDescription className="text-xs text-grey-3 font-mono" dir="ltr"> |
||||
|
#{tourId} • {tour?.slug} |
||||
|
</DialogDescription> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Badges & Close */} |
||||
|
<div className="flex items-center gap-2 self-start sm:self-auto"> |
||||
|
{tour && ( |
||||
|
<> |
||||
|
<CountryFlagBadge countryCode={tour.destination_country} /> |
||||
|
<TourStatusBadge status={tour.status} type="sale" /> |
||||
|
<TourStatusBadge status={tour.trip_status} type="trip" /> |
||||
|
</> |
||||
|
)} |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={onClose} |
||||
|
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> |
||||
|
|
||||
|
{/* Integrated 5-Language Selector Bar */} |
||||
|
<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 hasTrans = Boolean( |
||||
|
isPrimary || |
||||
|
transData?.translations[lang.code]?.title || |
||||
|
transData?.translations[lang.code]?.description |
||||
|
) |
||||
|
|
||||
|
return ( |
||||
|
<button |
||||
|
key={lang.code} |
||||
|
type="button" |
||||
|
onClick={() => handleLanguageSwitch(lang.code)} |
||||
|
className={`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' |
||||
|
: hasTrans |
||||
|
? '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> |
||||
|
{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"> |
||||
|
{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 ? ( |
||||
|
<Shimmer rows={6} withCard={false} /> |
||||
|
) : 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-primary/20 bg-primary/5 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={draftTitle} |
||||
|
onChange={(e) => setDraftTitle(e.target.value)} |
||||
|
placeholder={`عنوان تور را به زبان ${activeLangConfig.label} وارد نمایید...`} |
||||
|
dir={activeLangConfig.dir} |
||||
|
className="bg-card font-medium text-xs rounded-xl border-border-soft" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1"> |
||||
|
<label className="text-xs font-bold text-grey-2">توضیحات کامل تور ({activeLangConfig.label}):</label> |
||||
|
<textarea |
||||
|
rows={4} |
||||
|
value={draftDesc} |
||||
|
onChange={(e) => setDraftDesc(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" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex justify-end pt-1"> |
||||
|
<Button |
||||
|
size="sm" |
||||
|
disabled={isSaving} |
||||
|
onClick={handleSaveBaseTranslation} |
||||
|
className="gap-1.5 text-xs font-bold rounded-xl" |
||||
|
> |
||||
|
<Ic name="save" className={`size-3.5 ${isSaving ? 'animate-spin' : ''}`} /> |
||||
|
<span>ذخیره تغییرات متنی ({activeLangConfig.label})</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* گالری تصاویر تور */} |
||||
|
{tour.images && tour.images.length > 0 && ( |
||||
|
<div className="space-y-2"> |
||||
|
<h4 className="text-xs font-bold text-grey-2">گالری تصاویر تور</h4> |
||||
|
<div className="flex gap-2.5 overflow-x-auto pb-2 no-scrollbar"> |
||||
|
{tour.images.map((img, i) => ( |
||||
|
<div |
||||
|
key={img.id || i} |
||||
|
className="relative size-24 shrink-0 overflow-hidden rounded-xl border border-border-soft bg-surface-base shadow-2xs" |
||||
|
> |
||||
|
<img |
||||
|
src={img.image_url?.md || img.image_url?.medium || img.image_url?.lg || img.image_url?.large || img.image_url?.original || '/placeholder.jpg'} |
||||
|
alt="" |
||||
|
className="h-full w-full object-cover" |
||||
|
/> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* بازه تاریخ و ظرفیت */} |
||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> |
||||
|
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center"> |
||||
|
<div className="text-[11px] text-grey-3">تاریخ شروع</div> |
||||
|
<div className="mt-1 font-bold text-xs text-foreground font-mono" dir="ltr">{tour.started_at}</div> |
||||
|
</div> |
||||
|
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center"> |
||||
|
<div className="text-[11px] text-grey-3">تاریخ پایان</div> |
||||
|
<div className="mt-1 font-bold text-xs text-foreground font-mono" dir="ltr">{tour.ended_at}</div> |
||||
|
</div> |
||||
|
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center"> |
||||
|
<div className="text-[11px] text-grey-3">ظرفیت کل</div> |
||||
|
<div className="mt-1 font-extrabold text-sm text-foreground">{tour.capacity} نفر</div> |
||||
|
</div> |
||||
|
<div className="rounded-xl border border-border-soft bg-card/30 p-3 text-center"> |
||||
|
<div className="text-[11px] text-grey-3">فروخته شده</div> |
||||
|
<div className="mt-1 font-extrabold text-sm text-primary">{tour.number_sold} نفر</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* نوار تکمیل ظرفیت */} |
||||
|
<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"> |
||||
|
{Math.round((tour.number_sold / (tour.capacity || 1)) * 100)}% |
||||
|
</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" |
||||
|
style={{ |
||||
|
width: `${Math.min(100, Math.round((tour.number_sold / (tour.capacity || 1)) * 100))}%`, |
||||
|
}} |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="flex justify-between text-[11px] text-grey-3"> |
||||
|
<span>ظرفیت باقیمانده: {Math.max(0, tour.capacity - tour.number_sold)} صندلی</span> |
||||
|
<span>ظرفیت کل: {tour.capacity}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* جدول ۳ ردیف نرخهای مصوب */} |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3"> |
||||
|
<h4 className="text-xs font-extrabold text-foreground">جدول نرخهای مصوب تور</h4> |
||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> |
||||
|
<div className="rounded-xl border border-border-soft bg-surface-base p-3"> |
||||
|
<span className="text-[11px] text-grey-3">بزرگسال (۷+ سال / Adult 7+)</span> |
||||
|
<div className="mt-1 text-sm font-black text-foreground font-mono"> |
||||
|
${parseFloat(tour.price || '0').toLocaleString()} |
||||
|
</div> |
||||
|
{tour.percent_off > 0 && ( |
||||
|
<div className="mt-1 flex items-center gap-1.5 text-[11px]"> |
||||
|
<span className="rounded bg-rose-500/10 px-1.5 py-0.2 font-bold text-rose-500"> |
||||
|
{tour.percent_off}% تخفیف |
||||
|
</span> |
||||
|
<span className="font-bold text-emerald-600 dark:text-emerald-400 font-mono"> |
||||
|
نهایی: ${parseFloat(tour.final_price || '0').toLocaleString()} |
||||
|
</span> |
||||
|
</div> |
||||
|
)} |
||||
|
<span className="text-[10px] text-grey-3 block mt-1">خدمات کامل بزرگسال (۷ سال به بالا)</span> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-xl border border-border-soft bg-surface-base p-3"> |
||||
|
<span className="text-[11px] text-grey-3">کودک (۲ تا ۷ سال / Child 2-7)</span> |
||||
|
<div className="mt-1 text-sm font-black text-foreground font-mono"> |
||||
|
${parseFloat(tour.price_child || '0').toLocaleString()} |
||||
|
</div> |
||||
|
<span className="text-[10px] text-grey-3 block mt-1">صندلی و خدمات کودک (۲ تا ۷ سال کامل)</span> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-xl border border-border-soft bg-surface-base p-3"> |
||||
|
<span className="text-[11px] text-grey-3">نوزاد (۰ تا ۲ سال / Infant 0-2)</span> |
||||
|
<div className="mt-1 text-sm font-black text-foreground font-mono"> |
||||
|
${parseFloat(tour.price_infant || '0').toLocaleString()} |
||||
|
</div> |
||||
|
<span className="text-[10px] text-grey-3 block mt-1">بیمه و ترانسفر نوزاد (۰ تا ۲ سال کامل)</span> |
||||
|
</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> |
||||
|
<span className="text-grey-3 text-[11px]"> |
||||
|
تعداد مراحل: {itinTransList.length || itineraries.length} |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
{isLoadingSub ? ( |
||||
|
<Shimmer rows={3} withCard={false} /> |
||||
|
) : (itinTransList.length === 0 && itineraries.length === 0) ? ( |
||||
|
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3"> |
||||
|
<Ic name="calendar" className="mx-auto size-8 mb-2 opacity-50" /> |
||||
|
<p className="text-xs">برنامه زمانبندی روزانهای برای این تور ثبت نشده است.</p> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="space-y-4"> |
||||
|
{(itinTransList.length > 0 ? itinTransList : itineraries).map((item, idx) => { |
||||
|
const itId = item.id |
||||
|
const draft = itinDrafts[itId] || { title: item.title || '', summary: (item as any).summary || '' } |
||||
|
const isThisSaving = isSaving && savedItinId === itId |
||||
|
|
||||
|
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> |
||||
|
<Button |
||||
|
size="sm" |
||||
|
disabled={isThisSaving} |
||||
|
onClick={() => handleSaveItineraryStep(itId)} |
||||
|
className="h-7 px-3 text-[11px] font-bold rounded-xl gap-1" |
||||
|
> |
||||
|
<Ic name="save" className={`size-3 ${isThisSaving ? 'animate-spin' : ''}`} /> |
||||
|
<span>ذخیره مرحله</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-2"> |
||||
|
<Input |
||||
|
value={draft.title} |
||||
|
onChange={(e) => { |
||||
|
const val = e.target.value |
||||
|
setItinDrafts((prev) => ({ |
||||
|
...prev, |
||||
|
[itId]: { ...prev[itId], title: val, summary: prev[itId]?.summary || '' }, |
||||
|
})) |
||||
|
}} |
||||
|
placeholder={`عنوان مرحله ${idx + 1} به زبان ${activeLangConfig.label}...`} |
||||
|
dir={activeLangConfig.dir} |
||||
|
className="bg-card text-xs font-bold rounded-xl border-border-soft" |
||||
|
/> |
||||
|
|
||||
|
<textarea |
||||
|
rows={2} |
||||
|
value={draft.summary} |
||||
|
onChange={(e) => { |
||||
|
const val = e.target.value |
||||
|
setItinDrafts((prev) => ({ |
||||
|
...prev, |
||||
|
[itId]: { ...prev[itId], title: prev[itId]?.title || '', summary: val }, |
||||
|
})) |
||||
|
}} |
||||
|
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> |
||||
|
) |
||||
|
})} |
||||
|
</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> |
||||
|
<Button |
||||
|
size="sm" |
||||
|
disabled={isSaving} |
||||
|
onClick={handleSaveFeaturesAndTips} |
||||
|
className="gap-1.5 text-xs font-bold rounded-xl h-7 px-3" |
||||
|
> |
||||
|
<Ic name="save" className={`size-3.5 ${isSaving ? 'animate-spin' : ''}`} /> |
||||
|
<span>ذخیره امکانات و نکات</span> |
||||
|
</Button> |
||||
|
</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"> |
||||
|
{draftFeatures.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> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setDraftFeatures((prev) => prev.filter((_, i) => i !== idx))} |
||||
|
className="text-grey-3 hover:text-rose-500 cursor-pointer" |
||||
|
> |
||||
|
× |
||||
|
</button> |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
|
||||
|
<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() |
||||
|
if (newFeatureInput.trim()) { |
||||
|
setDraftFeatures((prev) => [...prev, newFeatureInput.trim()]) |
||||
|
setNewFeatureInput('') |
||||
|
} |
||||
|
} |
||||
|
}} |
||||
|
placeholder={`افزودن امکان جدید به زبان ${activeLangConfig.label}...`} |
||||
|
dir={activeLangConfig.dir} |
||||
|
className="bg-card text-xs rounded-xl border-border-soft" |
||||
|
/> |
||||
|
<Button |
||||
|
size="sm" |
||||
|
variant="outline" |
||||
|
onClick={() => { |
||||
|
if (newFeatureInput.trim()) { |
||||
|
setDraftFeatures((prev) => [...prev, newFeatureInput.trim()]) |
||||
|
setNewFeatureInput('') |
||||
|
} |
||||
|
}} |
||||
|
className="shrink-0 text-xs rounded-xl" |
||||
|
> |
||||
|
+ افزودن |
||||
|
</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"> |
||||
|
{draftTips.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> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setDraftTips((prev) => prev.filter((_, i) => i !== 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> |
||||
|
|
||||
|
<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={() => { |
||||
|
if (newTipTitle.trim() && newTipDesc.trim()) { |
||||
|
setDraftTips((prev) => [...prev, { title: newTipTitle.trim(), desc: newTipDesc.trim() }]) |
||||
|
setNewTipTitle('') |
||||
|
setNewTipDesc('') |
||||
|
} |
||||
|
}} |
||||
|
className="text-xs rounded-xl" |
||||
|
> |
||||
|
+ ثبت نکته |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* تب ۴: مسافران (Passengers) */} |
||||
|
{activeTab === 'passengers' && ( |
||||
|
<div className="space-y-4"> |
||||
|
{isLoadingSub ? ( |
||||
|
<Shimmer rows={2} withCard={false} /> |
||||
|
) : passengersGroup.length === 0 ? ( |
||||
|
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3"> |
||||
|
<Ic name="users" className="mx-auto size-8 mb-2 opacity-50" /> |
||||
|
<p className="text-xs">هنوز مسافری برای این تور به ثبت نرسیده است.</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 ? ( |
||||
|
<Shimmer rows={2} withCard={false} /> |
||||
|
) : comments.length === 0 ? ( |
||||
|
<div className="rounded-2xl border border-dashed border-border-soft p-8 text-center text-grey-3"> |
||||
|
<Ic name="message" className="mx-auto size-8 mb-2 opacity-50" /> |
||||
|
<p className="text-xs">تاکنون نظری برای این تور ثبت نشده است.</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> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,486 @@ |
|||||
|
import { useLanguageStore, type SupportedLanguage } from '@/i18n' |
||||
|
import { toFa } from './utils' |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* سیستم جامع تبدیل و نمایش تاریخهای چندزبانه در پنل مدیریت عقیله |
||||
|
* |
||||
|
* منطق تبدیل تقویمها: |
||||
|
* ۱. زبان فارسی ('fa') → هجری شمسی (جلالی / Solar Hijri) |
||||
|
* ۲. زبان عربی ('ar') → هجری قمری (تقویم اسلامی أم القری / Islamic Hijri) |
||||
|
* ۳. انگلیسی ('en') و سایر → میلادی (گریگوری / Gregorian) |
||||
|
* |
||||
|
* دادههای پایه بکاند و مقادیر فرمها همواره در استاندارد ISO YYYY-MM-DD ذخیره میشوند. |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
export type DateFormatStyle = 'short' | 'medium' | 'long' | 'numeric' |
||||
|
|
||||
|
export interface FormatDateOptions { |
||||
|
style?: DateFormatStyle |
||||
|
withTime?: boolean |
||||
|
withSeconds?: boolean |
||||
|
showEra?: boolean |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* پارس امن تاریخ بدون مشکل شیفت منطقه زمانی (Timezone Offset) |
||||
|
*/ |
||||
|
export function parseSafeDate(input: string | number | Date | null | undefined): Date | null { |
||||
|
if (!input) return null |
||||
|
if (input instanceof Date) return isNaN(input.getTime()) ? null : input |
||||
|
|
||||
|
const str = String(input).trim() |
||||
|
if (!str) return null |
||||
|
|
||||
|
// الگوی YYYY-MM-DD یا YYYY/MM/DD
|
||||
|
const matchOnlyDate = str.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/) |
||||
|
if (matchOnlyDate) { |
||||
|
const y = parseInt(matchOnlyDate[1], 10) |
||||
|
const m = parseInt(matchOnlyDate[2], 10) - 1 |
||||
|
const d = parseInt(matchOnlyDate[3], 10) |
||||
|
// استفاده از ساعت ۱۲ ظهر برای جلوگیری از تغییر روز در شیفتهای تابستانی/زمستانی
|
||||
|
return new Date(y, m, d, 12, 0, 0) |
||||
|
} |
||||
|
|
||||
|
const parsed = new Date(str) |
||||
|
return isNaN(parsed.getTime()) ? null : parsed |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* قالببندی یک تاریخ مشخص بر اساس زبان انتخابی (یا پیشفرض سامانه) |
||||
|
*/ |
||||
|
export function formatDateByLanguage( |
||||
|
dateInput: string | number | Date | null | undefined, |
||||
|
lang: SupportedLanguage = 'fa', |
||||
|
options: FormatDateOptions = {} |
||||
|
): string { |
||||
|
const d = parseSafeDate(dateInput) |
||||
|
if (!d) return '—' |
||||
|
|
||||
|
const style = options.style || 'medium' |
||||
|
|
||||
|
try { |
||||
|
if (lang === 'fa') { |
||||
|
if (style === 'numeric') { |
||||
|
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { |
||||
|
year: 'numeric', |
||||
|
month: '2-digit', |
||||
|
day: '2-digit', |
||||
|
}).format(d) |
||||
|
} |
||||
|
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { |
||||
|
year: 'numeric', |
||||
|
month: style === 'short' ? 'short' : 'long', |
||||
|
day: 'numeric', |
||||
|
}).format(d) |
||||
|
} |
||||
|
|
||||
|
if (lang === 'ar') { |
||||
|
if (style === 'numeric') { |
||||
|
return new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura', { |
||||
|
year: 'numeric', |
||||
|
month: '2-digit', |
||||
|
day: '2-digit', |
||||
|
}).format(d) |
||||
|
} |
||||
|
return new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura', { |
||||
|
year: 'numeric', |
||||
|
month: style === 'short' ? 'short' : 'long', |
||||
|
day: 'numeric', |
||||
|
}).format(d) |
||||
|
} |
||||
|
|
||||
|
// انگلیسی و سایر زبانها (میلادی)
|
||||
|
if (style === 'numeric') { |
||||
|
const year = d.getFullYear() |
||||
|
const month = String(d.getMonth() + 1).padStart(2, '0') |
||||
|
const day = String(d.getDate()).padStart(2, '0') |
||||
|
return `${year}-${month}-${day}` |
||||
|
} |
||||
|
|
||||
|
return new Intl.DateTimeFormat('en-US', { |
||||
|
year: 'numeric', |
||||
|
month: style === 'short' ? 'short' : 'long', |
||||
|
day: 'numeric', |
||||
|
}).format(d) |
||||
|
} catch { |
||||
|
return d.toISOString().split('T')[0] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* قالببندی بازه زمانی تاریخ (شروع تا پایان) بدون باگ جهت متن (BiDi) |
||||
|
*/ |
||||
|
export function formatDateRangeByLanguage( |
||||
|
startDateInput: string | number | Date | null | undefined, |
||||
|
endDateInput: string | number | Date | null | undefined, |
||||
|
lang: SupportedLanguage = 'fa', |
||||
|
options: FormatDateOptions = {} |
||||
|
): string { |
||||
|
const s = parseSafeDate(startDateInput) |
||||
|
const e = parseSafeDate(endDateInput) |
||||
|
|
||||
|
if (!s && !e) return '—' |
||||
|
if (s && !e) return formatDateByLanguage(s, lang, options) |
||||
|
if (!s && e) return formatDateByLanguage(e, lang, options) |
||||
|
|
||||
|
const startFormatted = formatDateByLanguage(s, lang, options) |
||||
|
const endFormatted = formatDateByLanguage(e, lang, options) |
||||
|
|
||||
|
if (lang === 'fa') { |
||||
|
return `${startFormatted} تا ${endFormatted}` |
||||
|
} |
||||
|
if (lang === 'ar') { |
||||
|
return `${startFormatted} إلى ${endFormatted}` |
||||
|
} |
||||
|
return `${startFormatted} – ${endFormatted}` |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* قالببندی تاریخ و ساعت به صورت همزمان |
||||
|
*/ |
||||
|
export function formatDateTimeByLanguage( |
||||
|
dateInput: string | number | Date | null | undefined, |
||||
|
lang: SupportedLanguage = 'fa', |
||||
|
options: FormatDateOptions = {} |
||||
|
): string { |
||||
|
const d = parseSafeDate(dateInput) |
||||
|
if (!d) return '—' |
||||
|
|
||||
|
const dateStr = formatDateByLanguage(d, lang, { ...options, style: options.style || 'numeric' }) |
||||
|
|
||||
|
const hours = String(d.getHours()).padStart(2, '0') |
||||
|
const minutes = String(d.getMinutes()).padStart(2, '0') |
||||
|
const timeStr = `${hours}:${minutes}` |
||||
|
|
||||
|
if (lang === 'fa') { |
||||
|
return `${dateStr}، ${toFa(timeStr)}` |
||||
|
} |
||||
|
if (lang === 'ar') { |
||||
|
return `${dateStr}، ${timeStr}` |
||||
|
} |
||||
|
return `${dateStr} ${timeStr}` |
||||
|
} |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* React Hooks برای اشتراک خودکار با زبان فعال سیستم (Reactive Localization) |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
/** |
||||
|
* هوک نمایش تاریخ منطبق بر زبان فعال پنل |
||||
|
*/ |
||||
|
export function useFormattedDate( |
||||
|
dateInput: string | number | Date | null | undefined, |
||||
|
options: FormatDateOptions = {} |
||||
|
): string { |
||||
|
const currentLang = useLanguageStore((s) => s.language) |
||||
|
return formatDateByLanguage(dateInput, currentLang, options) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* هوک نمایش بازه تاریخ (شروع و پایان) منطبق بر زبان فعال پنل |
||||
|
*/ |
||||
|
export function useFormattedDateRange( |
||||
|
startDateInput: string | number | Date | null | undefined, |
||||
|
endDateInput: string | number | Date | null | undefined, |
||||
|
options: FormatDateOptions = {} |
||||
|
): string { |
||||
|
const currentLang = useLanguageStore((s) => s.language) |
||||
|
return formatDateRangeByLanguage(startDateInput, endDateInput, currentLang, options) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* هوک نمایش تاریخ + ساعت منطبق بر زبان فعال پنل |
||||
|
*/ |
||||
|
export function useFormattedDateTime( |
||||
|
dateInput: string | number | Date | null | undefined, |
||||
|
options: FormatDateOptions = {} |
||||
|
): string { |
||||
|
const currentLang = useLanguageStore((s) => s.language) |
||||
|
return formatDateTimeByLanguage(dateInput, currentLang, options) |
||||
|
} |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* الگوریتمهای دقیق تبدیل تقویمها جهت استفاده در کامپوننت انتخابگر تاریخ (Calendar Picker) |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
// ۱. بررسی سال کبیسه جلالی
|
||||
|
export function isJalaliLeapYear(jy: number): boolean { |
||||
|
const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178] |
||||
|
const bl = breaks.length |
||||
|
let jp = breaks[0] |
||||
|
if (jy < jp || jy >= breaks[bl - 1]) return false |
||||
|
let jump = 0 |
||||
|
for (let i = 1; i < bl; i++) { |
||||
|
const jm = breaks[i] |
||||
|
jump = jm - jp |
||||
|
if (jy < jm) break |
||||
|
jp = jm |
||||
|
} |
||||
|
let n = jy - jp |
||||
|
if (jump - n < 6) n = n - jump + Math.floor((jump + 4) / 33) * 33 |
||||
|
let leap = (((n + 1) % 33) - 1) % 4 |
||||
|
if (leap === -1) leap = 4 |
||||
|
return leap === 0 |
||||
|
} |
||||
|
|
||||
|
export function getJalaliDaysInMonth(jy: number, jm: number): number { |
||||
|
if (jm <= 6) return 31 |
||||
|
if (jm <= 11) return 30 |
||||
|
return isJalaliLeapYear(jy) ? 30 : 29 |
||||
|
} |
||||
|
|
||||
|
export function jalaliToGregorian(jy: number, jm: number, jd: number): { year: number; month: number; day: number } { |
||||
|
let gy = jy <= 979 ? 621 : 1600 |
||||
|
jy -= jy <= 979 ? 0 : 979 |
||||
|
let days = |
||||
|
365 * jy + |
||||
|
Math.floor(jy / 33) * 8 + |
||||
|
Math.floor(((jy % 33) + 3) / 4) + |
||||
|
78 + |
||||
|
jd + |
||||
|
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186) |
||||
|
gy += 400 * Math.floor(days / 146097) |
||||
|
days %= 146097 |
||||
|
if (days > 36524) { |
||||
|
gy += 100 * Math.floor(--days / 36524) |
||||
|
days %= 36524 |
||||
|
if (days >= 365) days++ |
||||
|
} |
||||
|
gy += 4 * Math.floor(days / 1461) |
||||
|
days %= 1461 |
||||
|
if (days > 365) { |
||||
|
gy += Math.floor((days - 1) / 365) |
||||
|
days = (days - 1) % 365 |
||||
|
} |
||||
|
const sal_a = [0, 31, (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] |
||||
|
let gm = 0 |
||||
|
while (gm < 13 && days >= sal_a[gm]) { |
||||
|
days -= sal_a[gm] |
||||
|
gm++ |
||||
|
} |
||||
|
const gd = days + 1 |
||||
|
return { year: gy, month: gm, day: gd } |
||||
|
} |
||||
|
|
||||
|
export function gregorianToJalali(gy: number, gm: number, gd: number): { year: number; month: number; day: number } { |
||||
|
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334] |
||||
|
let jy = gy <= 1600 ? 0 : 979 |
||||
|
gy -= gy <= 1600 ? 621 : 1600 |
||||
|
const gy2 = gm > 2 ? gy + 1 : gy |
||||
|
let days = |
||||
|
365 * gy + |
||||
|
Math.floor((gy2 + 3) / 4) - |
||||
|
Math.floor((gy2 + 99) / 100) + |
||||
|
Math.floor((gy2 + 399) / 400) - |
||||
|
80 + |
||||
|
gd + |
||||
|
g_d_m[gm - 1] |
||||
|
jy += 33 * Math.floor(days / 12053) |
||||
|
days %= 12053 |
||||
|
jy += 4 * Math.floor(days / 1461) |
||||
|
days %= 1461 |
||||
|
if (days > 365) { |
||||
|
jy += Math.floor((days - 1) / 365) |
||||
|
days = (days - 1) % 365 |
||||
|
} |
||||
|
const jm = days < 186 ? 1 + Math.floor(days / 31) : 7 + Math.floor((days - 186) / 30) |
||||
|
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30) |
||||
|
return { year: jy, month: jm, day: jd } |
||||
|
} |
||||
|
|
||||
|
// ۲. تبدیل هجری قمری به میلادی و برعکس
|
||||
|
export function gregorianToHijri(date: Date): { year: number; month: number; day: number } { |
||||
|
try { |
||||
|
const fmt = new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura-nu-latn', { |
||||
|
timeZone: 'UTC', |
||||
|
year: 'numeric', |
||||
|
month: 'numeric', |
||||
|
day: 'numeric', |
||||
|
}) |
||||
|
const parts = fmt.formatToParts(date) |
||||
|
const m: Record<string, string> = {} |
||||
|
for (const p of parts) m[p.type] = p.value |
||||
|
return { |
||||
|
year: parseInt(m.year, 10) || 1448, |
||||
|
month: parseInt(m.month, 10) || 1, |
||||
|
day: parseInt(m.day, 10) || 1, |
||||
|
} |
||||
|
} catch { |
||||
|
return { year: 1448, month: 1, day: 1 } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function hijriToGregorian(hy: number, hm: number, hd: number): { year: number; month: number; day: number } { |
||||
|
const fmt = new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura-nu-latn', { |
||||
|
timeZone: 'UTC', |
||||
|
year: 'numeric', |
||||
|
month: 'numeric', |
||||
|
day: 'numeric', |
||||
|
}) |
||||
|
|
||||
|
const estJulianDay = Math.floor((hy - 1) * 354.367056) + Math.floor((hm - 1) * 29.5) + hd + 1948439 |
||||
|
const estGregMs = (estJulianDay - 2440587.5) * 86400000 |
||||
|
const candidate = new Date(estGregMs) |
||||
|
|
||||
|
for (let offset = -10; offset <= 10; offset++) { |
||||
|
const testDate = new Date(candidate.getTime() + offset * 86400000) |
||||
|
const parts = fmt.formatToParts(testDate) |
||||
|
const m: Record<string, string> = {} |
||||
|
for (const p of parts) m[p.type] = p.value |
||||
|
if (parseInt(m.year, 10) === hy && parseInt(m.month, 10) === hm && parseInt(m.day, 10) === hd) { |
||||
|
return { |
||||
|
year: testDate.getUTCFullYear(), |
||||
|
month: testDate.getUTCMonth() + 1, |
||||
|
day: testDate.getUTCDate(), |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
year: candidate.getUTCFullYear(), |
||||
|
month: candidate.getUTCMonth() + 1, |
||||
|
day: candidate.getUTCDate(), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* ────────────────────────────────────────────────────────────────────────── |
||||
|
* اطلاعات متای تقویمها برای رندر در ادیتورها (Calendar Meta & Names) |
||||
|
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
|
export const JALALI_MONTH_NAMES = [ |
||||
|
'فروردین', |
||||
|
'اردیبهشت', |
||||
|
'خرداد', |
||||
|
'تیر', |
||||
|
'مرداد', |
||||
|
'شهریور', |
||||
|
'مهر', |
||||
|
'آبان', |
||||
|
'آذر', |
||||
|
'دی', |
||||
|
'بهمن', |
||||
|
'اسفند', |
||||
|
] |
||||
|
|
||||
|
export const HIJRI_MONTH_NAMES = [ |
||||
|
'محرم', |
||||
|
'صفر', |
||||
|
'ربيع الأول', |
||||
|
'ربيع الآخر', |
||||
|
'جمادى الأولى', |
||||
|
'جمادى الآخرة', |
||||
|
'رجب', |
||||
|
'شعبان', |
||||
|
'رمضان', |
||||
|
'شوال', |
||||
|
'ذو القعدة', |
||||
|
'ذو الحجة', |
||||
|
] |
||||
|
|
||||
|
export const GREGORIAN_MONTH_NAMES = [ |
||||
|
'January', |
||||
|
'February', |
||||
|
'March', |
||||
|
'April', |
||||
|
'May', |
||||
|
'June', |
||||
|
'July', |
||||
|
'August', |
||||
|
'September', |
||||
|
'October', |
||||
|
'November', |
||||
|
'December', |
||||
|
] |
||||
|
|
||||
|
export const WEEKDAY_NAMES_FA = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] |
||||
|
export const WEEKDAY_NAMES_AR = ['س', 'ح', 'ن', 'ث', 'ر', 'خ', 'ج'] |
||||
|
export const WEEKDAY_NAMES_EN = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] |
||||
|
|
||||
|
/** |
||||
|
* دریافت اطلاعات و روزهای ماه جهت ساخت ماتریس گرید تقویم در ادیتور |
||||
|
*/ |
||||
|
export function getCalendarMonthData( |
||||
|
calendar: 'persian' | 'islamic' | 'gregorian', |
||||
|
year: number, |
||||
|
month: number // 1-based (1..12)
|
||||
|
) { |
||||
|
if (calendar === 'persian') { |
||||
|
const daysInMonth = getJalaliDaysInMonth(year, month) |
||||
|
const gFirst = jalaliToGregorian(year, month, 1) |
||||
|
const dFirst = new Date(gFirst.year, gFirst.month - 1, gFirst.day) |
||||
|
// 0 = Sunday, 6 = Saturday in JS
|
||||
|
const jsDay = dFirst.getDay() |
||||
|
const startOffset = (jsDay + 1) % 7 // 0 = شنبه
|
||||
|
|
||||
|
return { |
||||
|
daysInMonth, |
||||
|
startOffset, |
||||
|
monthName: JALALI_MONTH_NAMES[month - 1] || '', |
||||
|
weekdays: WEEKDAY_NAMES_FA, |
||||
|
yearFormatted: toFa(year), |
||||
|
toIso: (d: number) => { |
||||
|
const g = jalaliToGregorian(year, month, d) |
||||
|
return `${g.year}-${String(g.month).padStart(2, '0')}-${String(g.day).padStart(2, '0')}` |
||||
|
}, |
||||
|
formatDayNumber: (d: number) => toFa(d), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (calendar === 'islamic') { |
||||
|
const gFirst = hijriToGregorian(year, month, 1) |
||||
|
const dFirst = new Date(Date.UTC(gFirst.year, gFirst.month - 1, gFirst.day)) |
||||
|
const jsDay = dFirst.getUTCDay() |
||||
|
const startOffset = (jsDay + 1) % 7 // 0 = السبت
|
||||
|
|
||||
|
// محاسبه تعداد روزهای ماه هجری (۲۹ یا ۳۰ روز)
|
||||
|
const fmt = new Intl.DateTimeFormat('ar-SA-u-ca-islamic-umalqura-nu-latn', { |
||||
|
timeZone: 'UTC', |
||||
|
year: 'numeric', |
||||
|
month: 'numeric', |
||||
|
day: 'numeric', |
||||
|
}) |
||||
|
|
||||
|
let daysInMonth = 29 |
||||
|
for (let d = 1; d <= 31; d++) { |
||||
|
const testDate = new Date(dFirst.getTime() + (d - 1) * 86400000) |
||||
|
const parts = fmt.formatToParts(testDate) |
||||
|
const m: Record<string, string> = {} |
||||
|
for (const p of parts) m[p.type] = p.value |
||||
|
if (parseInt(m.month, 10) === month && parseInt(m.year, 10) === year) { |
||||
|
daysInMonth = parseInt(m.day, 10) |
||||
|
} else { |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
daysInMonth, |
||||
|
startOffset, |
||||
|
monthName: HIJRI_MONTH_NAMES[month - 1] || '', |
||||
|
weekdays: WEEKDAY_NAMES_AR, |
||||
|
yearFormatted: `${year} هـ`, |
||||
|
toIso: (d: number) => { |
||||
|
const g = hijriToGregorian(year, month, d) |
||||
|
return `${g.year}-${String(g.month).padStart(2, '0')}-${String(g.day).padStart(2, '0')}` |
||||
|
}, |
||||
|
formatDayNumber: (d: number) => String(d), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// میلادی (Gregorian)
|
||||
|
const dFirst = new Date(year, month - 1, 1) |
||||
|
const daysInMonth = new Date(year, month, 0).getDate() |
||||
|
const startOffset = dFirst.getDay() // 0 = Sunday
|
||||
|
|
||||
|
return { |
||||
|
daysInMonth, |
||||
|
startOffset, |
||||
|
monthName: GREGORIAN_MONTH_NAMES[month - 1] || '', |
||||
|
weekdays: WEEKDAY_NAMES_EN, |
||||
|
yearFormatted: String(year), |
||||
|
toIso: (d: number) => { |
||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}` |
||||
|
}, |
||||
|
formatDayNumber: (d: number) => String(d), |
||||
|
} |
||||
|
} |
||||
@ -1,37 +1,25 @@ |
|||||
/* ────────────────────────────────────────────────────────────────────────── |
/* ────────────────────────────────────────────────────────────────────────── |
||||
* تبدیل تاریخهای میلادیِ بکاند (ISO datetime) به نمایشِ جلالی (هجری شمسی). |
|
||||
* |
|
||||
* بکاند فیلدهای updated_at/created_at را بهصورت ISO (UTC) میفرستد؛ کاربر باید |
|
||||
* تاریخ را به تقویم جلالی ببیند. از `Intl.DateTimeFormat` با تقویم `persian` |
|
||||
* استفاده میکنیم (بدون وابستگیِ خارجی) و ارقام را فارسی نمایش میدهیم. |
|
||||
|
* تبدیل تاریخهای میلادیِ بکاند به نمایش تقویمی متناسب با زبان. |
||||
|
* این ماژول جهت حفظ سازگاری کدهای قبلی حفظ شده و از موتور اصلی date-utils ارثبری میکند. |
||||
* ────────────────────────────────────────────────────────────────────────── */ |
* ────────────────────────────────────────────────────────────────────────── */ |
||||
|
|
||||
const DATE_FMT = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { |
|
||||
year: 'numeric', |
|
||||
month: '2-digit', |
|
||||
day: '2-digit', |
|
||||
}) |
|
||||
|
import { formatDateByLanguage, formatDateTimeByLanguage } from './date-utils' |
||||
|
|
||||
const DATE_TIME_FMT = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { |
|
||||
year: 'numeric', |
|
||||
month: '2-digit', |
|
||||
day: '2-digit', |
|
||||
hour: '2-digit', |
|
||||
minute: '2-digit', |
|
||||
}) |
|
||||
|
export { |
||||
|
formatDateByLanguage, |
||||
|
formatDateRangeByLanguage, |
||||
|
formatDateTimeByLanguage, |
||||
|
useFormattedDate, |
||||
|
useFormattedDateRange, |
||||
|
useFormattedDateTime, |
||||
|
} from './date-utils' |
||||
|
|
||||
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵» (جلالی). ورودی خالی/نامعتبر → '—'. */ |
|
||||
|
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵» (جلالی در فارسی، هجری در عربی، میلادی در انگلیسی). */ |
||||
export function toJalali(iso: string | null | undefined): string { |
export function toJalali(iso: string | null | undefined): string { |
||||
if (!iso) return '—' |
|
||||
const d = new Date(iso) |
|
||||
if (Number.isNaN(d.getTime())) return '—' |
|
||||
return DATE_FMT.format(d) |
|
||||
|
return formatDateByLanguage(iso, 'fa', { style: 'numeric' }) |
||||
} |
} |
||||
|
|
||||
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵، ۱۴:۳۰» (جلالی + ساعت). ورودی نامعتبر → '—'. */ |
|
||||
|
/** تاریخ ISO → «۱۴۰۳/۰۱/۲۵، ۱۴:۳۰» (جلالی + ساعت). */ |
||||
export function toJalaliDateTime(iso: string | null | undefined): string { |
export function toJalaliDateTime(iso: string | null | undefined): string { |
||||
if (!iso) return '—' |
|
||||
const d = new Date(iso) |
|
||||
if (Number.isNaN(d.getTime())) return '—' |
|
||||
return DATE_TIME_FMT.format(d) |
|
||||
|
return formatDateTimeByLanguage(iso, 'fa') |
||||
} |
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue