Browse Source

feat: add social media settings tab and related API services

master
parent
commit
18461f6bb1
  1. 7
      src/features/custom-trip/CustomTripDestinationDetailPage.tsx
  2. 27
      src/features/custom-trip/components/CustomTripDestinationsTab.tsx
  3. 65
      src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx
  4. 14
      src/features/custom-trip/components/CustomTripOrdersTab.tsx
  5. 2709
      src/features/custom-trip/services/custom-trip-api.ts
  6. 11
      src/features/custom-trip/types.ts
  7. 32
      src/features/settings/SettingsPage.tsx
  8. 714
      src/features/settings/components/SocialMediaSettingsTab.tsx
  9. 25
      src/features/settings/services/settings-api.ts
  10. 34
      src/features/settings/types.ts
  11. 7
      src/icons.tsx
  12. 35
      src/locales/ar.json
  13. 35
      src/locales/en.json
  14. 35
      src/locales/fa.json
  15. 9
      src/services/http.ts

7
src/features/custom-trip/CustomTripDestinationDetailPage.tsx

@ -390,7 +390,12 @@ export default function CustomTripDestinationDetailPage() {
e.preventDefault()
if (!destination || isSavingDestination) return
const primaryName = names.fa?.trim() || names.ar?.trim() || names.en?.trim() || destName.trim()
const primaryName =
names[activeLangTab]?.trim() ||
names.fa?.trim() ||
names.ar?.trim() ||
names.en?.trim() ||
destName.trim()
if (!primaryName) return
setIsSavingDestination(true)

27
src/features/custom-trip/components/CustomTripDestinationsTab.tsx

@ -277,28 +277,17 @@ export function CustomTripDestinationsTab({
const [selectedCountry, setSelectedCountry] = useState<string>('all')
const getTransportCount = (item: CustomTripDestinationItem) => {
if (item.transportsCount !== undefined) return item.transportsCount
if (relations && relations.length > 0) {
const dName = (item.name || '').trim()
const count = relations.filter(
return relations.filter(
(r) =>
r.toCity.trim() === dName ||
dName.includes(r.toCity.trim()) ||
r.toCity.trim().includes(dName)
).length
if (count > 0) return count
}
if (item.transportsCount !== undefined) return item.transportsCount
const defaults: Record<string, number> = {
karbala: 7,
najaf: 5,
kadhimiya: 4,
samarra: 3,
mashhad: 6,
qom: 4,
medina: 4,
mecca: 5,
}
return defaults[String(item.id)] || 4
return 0
}
// Modals state
@ -444,9 +433,9 @@ export function CustomTripDestinationsTab({
if (e) e.preventDefault()
const primaryName =
names.ar.trim() ||
names.fa.trim() ||
names[activeLangTab].trim() ||
names.fa.trim() ||
names.ar.trim() ||
names.en.trim() ||
names.ru.trim() ||
names.id.trim()
@ -479,7 +468,7 @@ export function CustomTripDestinationsTab({
if (editingItem && onUpdateDestination) {
const updated: CustomTripDestinationItem = {
...editingItem,
name: names.fa.trim() || names.ar.trim() || primaryName,
name: primaryName,
nameEn: names.en.trim() || undefined,
country: formCountry.trim(),
flag: formFlag,
@ -491,8 +480,8 @@ export function CustomTripDestinationsTab({
onUpdateDestination(updated)
} else {
const newDest: CustomTripDestinationItem = {
id: `dest_${Date.now()}`,
name: names.fa.trim() || names.ar.trim() || primaryName,
id: selectedCityPresetId && selectedCityPresetId !== 'custom' ? selectedCityPresetId : `dest_${Date.now()}`,
name: primaryName,
nameEn: names.en.trim() || undefined,
country: formCountry.trim() || 'عراق',
flag: formFlag,

65
src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx

@ -161,6 +161,70 @@ export function CustomTripOrderReviewDialog({
</div>
</div>
{/* Passengers & Passport Images */}
{order.passengers && order.passengers.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
{t('customTrip.passengersList')}:
</h4>
<div className="space-y-2">
{order.passengers.map((pax, idx) => (
<div
key={idx}
className="p-3 rounded-xl border border-border/70 bg-card flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs sm:text-sm"
>
<div className="flex items-center gap-2.5">
<span className="size-6 rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center text-xs shrink-0">
{toFa(idx + 1)}
</span>
<div>
<div className="font-bold text-foreground">
{`${pax.firstName} ${pax.lastName}`.trim() || '—'}
<span className="ms-2 text-[11px] font-medium px-2 py-0.5 rounded-md bg-muted text-muted-foreground">
{pax.passengerType === 'INFANT'
? t('customTrip.infants')
: pax.passengerType === 'CHILD'
? t('customTrip.children')
: t('customTrip.adults')}
</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{pax.passportNumber && (
<span>
{t('customTrip.passportNumber')}:{' '}
<span className="font-mono text-foreground">{pax.passportNumber}</span>
</span>
)}
{pax.phoneNumber && (
<span className="font-mono dir-ltr inline-block">{pax.phoneNumber}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{pax.passportImage ? (
<a
href={pax.passportImage}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 text-xs font-semibold transition-colors"
>
<Ic name="eye" className="size-3.5" />
{t('customTrip.viewPassportImage')}
</a>
) : (
<span className="text-xs text-muted-foreground bg-muted/50 px-2.5 py-1 rounded-lg">
{t('customTrip.noPassportImage')}
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* User note */}
{order.detail && (
<div className="p-3 rounded-xl bg-blue-500/5 border border-blue-500/20 text-xs">
@ -187,6 +251,7 @@ export function CustomTripOrderReviewDialog({
<option value="APPROVED">{t('customTrip.statusApproved')}</option>
<option value="AWAITING_PAYMENT">{t('customTrip.statusAwaitingPayment')}</option>
<option value="REJECTED">{t('customTrip.statusRejected')}</option>
<option value="EXPIRED">{t('customTrip.statusExpired')}</option>
<option value="CANCELLED">{t('customTrip.statusCancelled')}</option>
</select>
</div>

14
src/features/custom-trip/components/CustomTripOrdersTab.tsx

@ -54,6 +54,20 @@ export function CustomTripOrdersTab({ orders, onOrderUpdated }: CustomTripOrders
{t('customTrip.statusRejected')}
</span>
)
case 'EXPIRED':
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-slate-500/10 text-slate-500 dark:text-slate-400 border border-slate-500/20">
<Ic name="history" className="size-3.5" />
{t('customTrip.statusExpired')}
</span>
)
case 'CANCELLED':
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/20">
<Ic name="xCircle" className="size-3.5" />
{t('customTrip.statusCancelled')}
</span>
)
default:
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-muted text-muted-foreground border border-border">

2709
src/features/custom-trip/services/custom-trip-api.ts
File diff suppressed because it is too large
View File

11
src/features/custom-trip/types.ts

@ -23,12 +23,23 @@ export interface CustomTripLeg {
mealPlan?: string
}
export interface CustomTripPassenger {
firstName: string
lastName: string
passportNumber?: string
phoneNumber?: string
birthDate?: string
passengerType: 'ADULT' | 'CHILD' | 'INFANT'
passportImage?: string | null
}
export interface CustomTripOrderItem {
id: number
user: CustomTripUser
originCity: string
destinations: string[]
legs: CustomTripLeg[]
passengers?: CustomTripPassenger[]
adultsCount: number
childrenCount: number
infantsCount: number

32
src/features/settings/SettingsPage.tsx

@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge'
import { Ic, IconName } from '@/icons'
import { AdminManagementTab } from './components/AdminManagementTab'
import { NotificationsTab } from './components/NotificationsTab'
import { SocialMediaSettingsTab } from './components/SocialMediaSettingsTab'
interface SettingsTabConfig {
id: string
@ -15,6 +16,7 @@ interface SettingsTabConfig {
const SETTINGS_TABS: SettingsTabConfig[] = [
{ id: 'admins', labelKey: 'settings.tabAdmins', icon: 'users', isCore: true },
{ id: 'social', labelKey: 'settings.tabSocial', icon: 'share' },
{ id: 'notifications', labelKey: 'settings.tabNotifications', icon: 'bell' },
]
@ -24,24 +26,24 @@ export default function SettingsPage() {
return (
<div className="space-y-6 pt-2 pb-14">
{/* ۱. سربرگ اصلی صفحه تنظیمات (مطابق الگوی طراحی جامع عقیله) */}
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1">
{/* ۱. سربرگ اصلی صفحه تنظیمات (مطابق الگوی طراحی جامع عقیله - کالیبره دارک) */}
<div className="flex flex-col gap-4 rounded-2xl border border-white/[0.08] bg-[#1e2129] p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1.5">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/15 text-primary border border-primary/20">
<Ic name="settings" className="size-5" />
</div>
<h1 className="text-xl font-black text-foreground">
<h1 className="text-xl font-black text-white">
{t('settings.pageTitle')}
</h1>
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold font-mono text-xs min-w-[65px] justify-center"
className="border-primary/30 bg-primary/10 text-primary font-bold text-xs px-2.5 py-0.5 justify-center"
>
{t('settings.badgeRoleControl')}
</Badge>
</div>
<p className="text-xs text-grey-3">
<p className="text-xs text-slate-400">
{t('settings.pageSubtitle')}
</p>
</div>
@ -56,10 +58,10 @@ export default function SettingsPage() {
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap border shrink-0 ${
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap border shrink-0 ${
isActive
? 'bg-primary text-white border-primary shadow-xs'
: 'bg-card/60 text-grey-2 border-border-soft hover:bg-card hover:text-foreground'
? 'bg-gradient-to-r from-[#d09460] to-[#b37745] text-white border-primary shadow-md shadow-[#d09460]/20'
: 'bg-[#1e2129] text-slate-300 border-white/[0.08] hover:bg-[#252833] hover:text-white'
}`}
>
<Ic name={tab.icon} className={`size-4 ${isActive ? 'text-white' : 'text-primary'}`} />
@ -69,7 +71,7 @@ export default function SettingsPage() {
className={`text-[10px] px-1.5 py-0.2 rounded-md font-bold ${
isActive
? 'bg-white/20 text-white'
: 'bg-primary/15 text-primary'
: 'bg-primary/20 text-primary'
}`}
>
{t('billing.coreTourBadge')}
@ -81,7 +83,13 @@ export default function SettingsPage() {
</div>
{/* ۳. محتوای تب فعال */}
{activeTab === 'admins' ? <AdminManagementTab /> : <NotificationsTab />}
{activeTab === 'admins' ? (
<AdminManagementTab />
) : activeTab === 'social' ? (
<SocialMediaSettingsTab />
) : (
<NotificationsTab />
)}
</div>
)
}

714
src/features/settings/components/SocialMediaSettingsTab.tsx

@ -0,0 +1,714 @@
import * as React from 'react'
import { useTranslation } from '@/i18n'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Ic, type IconName } from '@/icons'
import { toast } from '@/stores/toast'
import {
fetchSocialMediaSettings,
saveSocialMediaSettings,
} from '../services/settings-api'
import type {
SocialMediaItem,
SocialMediaContactUs,
} from '../types'
interface PlatformMeta {
key: string
name: string
nameEn: string
icon: IconName
brandColor: string
defaultUrl: string
placeholder: string
}
const PRESET_PLATFORMS: Record<string, PlatformMeta> = {
instagram: {
key: 'instagram',
name: 'اینستاگرام',
nameEn: 'Instagram',
icon: 'instagram',
brandColor: '#E1306C',
defaultUrl: 'https://instagram.com/',
placeholder: 'https://instagram.com/aqila_group',
},
telegram: {
key: 'telegram',
name: 'تلگرام',
nameEn: 'Telegram',
icon: 'telegram',
brandColor: '#229ED9',
defaultUrl: 'https://t.me/',
placeholder: 'https://t.me/aqila_official',
},
whatsapp: {
key: 'whatsapp',
name: 'واتساپ',
nameEn: 'WhatsApp',
icon: 'whatsapp',
brandColor: '#25D366',
defaultUrl: 'https://wa.me/',
placeholder: 'https://wa.me/989123456789',
},
youtube: {
key: 'youtube',
name: 'یوتیوب',
nameEn: 'YouTube',
icon: 'youtube',
brandColor: '#FF0000',
defaultUrl: 'https://youtube.com/@',
placeholder: 'https://youtube.com/@aqila_tours',
},
x: {
key: 'x',
name: 'ایکس / توییتر',
nameEn: 'X (Twitter)',
icon: 'twitter',
brandColor: '#E2E8F0',
defaultUrl: 'https://x.com/',
placeholder: 'https://x.com/aqila',
},
linkedin: {
key: 'linkedin',
name: 'لینکدین',
nameEn: 'LinkedIn',
icon: 'linkedin',
brandColor: '#0A66C2',
defaultUrl: 'https://linkedin.com/company/',
placeholder: 'https://linkedin.com/company/aqila',
},
facebook: {
key: 'facebook',
name: 'فیس‌بوک',
nameEn: 'Facebook',
icon: 'facebook',
brandColor: '#1877F2',
defaultUrl: 'https://facebook.com/',
placeholder: 'https://facebook.com/aqila',
},
tiktok: {
key: 'tiktok',
name: 'تیک‌تاک',
nameEn: 'TikTok',
icon: 'tiktok',
brandColor: '#F43F5E',
defaultUrl: 'https://tiktok.com/@',
placeholder: 'https://tiktok.com/@aqila',
},
aparat: {
key: 'aparat',
name: 'آپارات',
nameEn: 'Aparat',
icon: 'video',
brandColor: '#EA1D5D',
defaultUrl: 'https://aparat.com/',
placeholder: 'https://aparat.com/aqila',
},
bale: {
key: 'bale',
name: 'پیام‌رسان بله',
nameEn: 'Bale',
icon: 'message',
brandColor: '#10B981',
defaultUrl: 'https://ble.ir/',
placeholder: 'https://ble.ir/aqila_group',
},
eitaa: {
key: 'eitaa',
name: 'پیام‌رسان ایتا',
nameEn: 'Eitaa',
icon: 'message',
brandColor: '#F97316',
defaultUrl: 'https://eitaa.com/',
placeholder: 'https://eitaa.com/aqila_official',
},
threads: {
key: 'threads',
name: 'تردز',
nameEn: 'Threads',
icon: 'share2',
brandColor: '#F1F5F9',
defaultUrl: 'https://threads.net/@',
placeholder: 'https://threads.net/@aqila',
},
website: {
key: 'website',
name: 'وب‌سایت رسمی',
nameEn: 'Official Website',
icon: 'globe',
brandColor: '#38BDF8',
defaultUrl: 'https://',
placeholder: 'https://aqila.ir',
},
}
export function SocialMediaSettingsTab() {
const { t } = useTranslation()
const [loading, setLoading] = React.useState(true)
const [saving, setSaving] = React.useState(false)
const [items, setItems] = React.useState<SocialMediaItem[]>([])
const [contactus, setContactus] = React.useState<SocialMediaContactUs>({
phone_number: '',
whatsapp_number: '',
address: '',
})
// Modal State for adding new platform
const [addModalOpen, setAddModalOpen] = React.useState(false)
const [selectedPlatformKey, setSelectedPlatformKey] = React.useState('telegram')
const [newChannelName, setNewChannelName] = React.useState('')
const [newChannelLink, setNewChannelLink] = React.useState('')
const loadData = React.useCallback(async () => {
setLoading(true)
try {
const res = await fetchSocialMediaSettings()
setItems(res.items || [])
setContactus(res.contactus || {})
} catch (err: any) {
console.error('Error fetching social media settings:', err)
toast.error(err?.message || t('settings.socialFetchError'))
} finally {
setLoading(false)
}
}, [t])
React.useEffect(() => {
loadData()
}, [loadData])
const handleOpenAddModal = (platformKey?: string) => {
const pKey = platformKey || 'telegram'
const preset = PRESET_PLATFORMS[pKey] || PRESET_PLATFORMS.telegram
setSelectedPlatformKey(pKey)
setNewChannelName(preset.name)
setNewChannelLink(preset.defaultUrl)
setAddModalOpen(true)
}
const handleSelectPreset = (key: string) => {
const preset = PRESET_PLATFORMS[key]
setSelectedPlatformKey(key)
if (preset) {
setNewChannelName(preset.name)
setNewChannelLink(preset.defaultUrl)
}
}
const handleAddChannel = () => {
if (!newChannelLink.trim()) {
toast.error('لطفاً آدرس پیوند (URL) را وارد نمایید.')
return
}
const newItem: SocialMediaItem = {
id: `social-${selectedPlatformKey}-${Date.now()}`,
platform: selectedPlatformKey,
name: newChannelName.trim() || selectedPlatformKey,
link: newChannelLink.trim(),
is_active: true,
}
setItems((prev) => [...prev, newItem])
setAddModalOpen(false)
toast.success(t('settings.channelAdded'))
}
const handleItemChange = (index: number, field: keyof SocialMediaItem, value: any) => {
setItems((prev) => {
const next = [...prev]
next[index] = { ...next[index], [field]: value }
return next
})
}
const handleDeleteItem = (index: number) => {
setItems((prev) => prev.filter((_, idx) => idx !== index))
}
const handleCopyLink = (link: string) => {
if (!link) return
navigator.clipboard.writeText(link)
toast.success(t('settings.linkCopied'))
}
const handleSaveToDatabase = async () => {
setSaving(true)
try {
const res = await saveSocialMediaSettings({
items,
contactus,
})
if (res.items) {
setItems(res.items)
}
if (res.contactus) {
setContactus(res.contactus)
}
toast.success(res.message || t('settings.socialSaveSuccess'))
} catch (err: any) {
console.error('Error saving social media settings:', err)
toast.error(err?.message || t('settings.socialSaveError'))
} finally {
setSaving(false)
}
}
const activeCount = items.filter((i) => i.is_active).length
return (
<div className="space-y-6">
{/* ۱. کارت سربرگ و راهنمای تنظیمات شبکه‌های اجتماعی (کالیبره حالت دارک) */}
<Card className="border border-white/[0.08] bg-[#1e2129] shadow-md">
<CardContent className="p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary/15 text-primary border border-primary/25 shadow-inner">
<Ic name="share" className="size-6" />
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2.5 flex-wrap">
<h2 className="text-base sm:text-lg font-black text-white">
{t('settings.socialTabTitle')}
</h2>
<Badge variant="outline" className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400 font-bold text-xs">
{activeCount} {t('settings.activeStatus')}
</Badge>
<Badge variant="outline" className="border-white/10 bg-white/[0.04] text-slate-300 font-mono text-[11px]">
PostgreSQL Live Sync
</Badge>
</div>
<p className="text-xs text-slate-400 leading-relaxed max-w-2xl">
{t('settings.socialTabSubtitle')}
</p>
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0 flex-wrap sm:flex-nowrap">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleOpenAddModal()}
className="gap-2 rounded-xl text-xs font-bold border-white/15 bg-white/[0.04] hover:bg-white/[0.08] text-slate-100 hover:text-white"
>
<Ic name="plus" className="size-4 text-primary" />
<span>{t('settings.addSocialBtn')}</span>
</Button>
<Button
type="button"
size="sm"
disabled={saving || loading}
onClick={handleSaveToDatabase}
className="gap-2 rounded-xl text-xs font-black bg-gradient-to-r from-[#d09460] to-[#b37745] hover:from-[#dd9e69] hover:to-[#be804c] text-white shadow-md shadow-[#d09460]/20 transition-all active:scale-[0.98]"
>
<Ic name="save" className={`size-4 ${saving ? 'animate-spin' : ''}`} />
<span>{saving ? t('settings.saving') : t('settings.saveSocialBtn')}</span>
</Button>
</div>
</div>
{/* نوار انتخاب سریع پلتفرم‌های مطرح (کالیبره و منسجم در تم تیره) */}
<div className="mt-5 pt-4 border-t border-white/[0.07]">
<div className="text-[11px] font-bold text-slate-400 mb-2.5 flex items-center gap-1.5">
<Ic name="sparkles" className="size-3.5 text-primary" />
<span>پلتفرم‌های پیشنهادی جهت افزودن فوری:</span>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-1.5 scrollbar-none">
{Object.values(PRESET_PLATFORMS).map((preset) => {
const isAlreadyAdded = items.some((it) => it.platform.toLowerCase() === preset.key.toLowerCase())
return (
<button
key={preset.key}
type="button"
onClick={() => handleOpenAddModal(preset.key)}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-xl border text-xs font-medium shrink-0 transition-all ${
isAlreadyAdded
? 'border-emerald-500/35 bg-emerald-500/10 text-emerald-300'
: 'border-white/[0.08] bg-[#16181f] hover:bg-[#252833] hover:border-white/[0.2] text-slate-200'
}`}
>
<span
className="size-2 rounded-full shrink-0"
style={{ backgroundColor: preset.brandColor }}
/>
<Ic
name={preset.icon}
className="size-3.5 shrink-0"
style={{ color: preset.brandColor }}
/>
<span>{preset.name}</span>
{isAlreadyAdded ? (
<span className="text-[10px] size-4 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center font-bold">
✓
</span>
) : (
<span className="text-[10px] text-slate-500 group-hover:text-white">+</span>
)}
</button>
)
})}
</div>
</div>
</CardContent>
</Card>
{/* ۲. فهرست شبکه‌های اجتماعی ثبت‌شده */}
{loading ? (
<Card className="border border-white/[0.08] bg-[#1e2129] p-12 text-center shadow-xs">
<div className="flex flex-col items-center justify-center gap-3">
<span className="size-8 rounded-full border-3 border-primary/20 border-t-primary animate-spin" />
<p className="text-xs text-slate-300 font-bold">در حال دریافت شبکه‌های اجتماعی از دیتابیس...</p>
</div>
</Card>
) : items.length === 0 ? (
<Card className="border-dashed border-2 border-white/[0.1] bg-[#1e2129]/60 p-12 text-center shadow-xs">
<div className="max-w-md mx-auto space-y-4">
<div className="size-16 rounded-3xl bg-primary/10 text-primary border border-primary/20 flex items-center justify-center mx-auto shadow-inner">
<Ic name="share" className="size-8" />
</div>
<div className="space-y-1">
<h3 className="text-base font-black text-white">
{t('settings.noSocialChannels')}
</h3>
<p className="text-xs text-slate-400">
{t('settings.noSocialChannelsDesc')}
</p>
</div>
<Button
type="button"
onClick={() => handleOpenAddModal('instagram')}
className="gap-2 rounded-xl text-xs font-bold bg-primary text-white hover:bg-primary/90"
>
<Ic name="plus" className="size-4" />
<span>{t('settings.addSocialBtn')}</span>
</Button>
</div>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{items.map((item, index) => {
const preset = PRESET_PLATFORMS[item.platform.toLowerCase()] || {
key: item.platform,
name: item.name || item.platform,
nameEn: item.platform,
icon: 'link' as IconName,
brandColor: '#38BDF8',
defaultUrl: '',
placeholder: 'https://...',
}
return (
<Card
key={item.id || index}
className={`border transition-all duration-200 ${
item.is_active
? 'border-white/[0.1] bg-[#1e2129] shadow-sm hover:border-white/[0.18]'
: 'border-white/[0.05] bg-[#171920] opacity-65'
}`}
>
<CardContent className="p-4 sm:p-5 space-y-4">
{/* ردیف بالا: آیکون برند، عنوان پلتفرم، بج وضعیت، سوییچ و دکمه حذف */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div
className="size-11 rounded-2xl flex items-center justify-center shrink-0 border border-white/[0.08] bg-[#15171e] shadow-inner"
style={{ color: preset.brandColor }}
>
<Ic name={preset.icon} className="size-5.5" />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-black text-white">
{preset.name}
</span>
<span className="text-[10px] text-slate-400 uppercase font-mono px-1.5 py-0.5 rounded bg-white/[0.05] border border-white/[0.05]">
{preset.nameEn || item.platform}
</span>
</div>
<div>
{item.is_active ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[10.5px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/25">
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
<span>{t('settings.activeStatus')}</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-[10.5px] font-medium bg-slate-800 text-slate-400 border border-white/[0.06]">
<span>{t('settings.inactiveStatus')}</span>
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<Switch
checked={item.is_active}
onCheckedChange={(checked) => handleItemChange(index, 'is_active', checked)}
/>
<button
type="button"
onClick={() => handleDeleteItem(index)}
title={t('settings.deleteChannel')}
className="size-8.5 rounded-xl text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 flex items-center justify-center transition-colors"
>
<Ic name="trash" className="size-4" />
</button>
</div>
</div>
{/* فیلدهای ورودی عنوان نمایشی و آدرس اینترنتی (با اصلاح کامل مشکل همپوشانی) */}
<div className="space-y-3 pt-1">
<div className="space-y-1">
<label className="text-xs font-semibold text-slate-300">
{t('settings.channelName')}
</label>
<input
type="text"
value={item.name}
onChange={(e) => handleItemChange(index, 'name', e.target.value)}
placeholder="مثال: کانال رسمی اطلاع‌رسانی عقیله"
className="w-full h-9.5 rounded-xl bg-[#14161c] border border-white/[0.1] px-3.5 text-xs font-medium text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/40"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-slate-300">
{t('settings.channelLink')}
</label>
{/* کانتینر LTR اختصاصی جهت جلوگیری از هرگونه تداخل آیکون‌ها با متن */}
<div className="relative flex items-center" dir="ltr">
<input
type="text"
dir="ltr"
value={item.link}
onChange={(e) => handleItemChange(index, 'link', e.target.value)}
placeholder={preset.placeholder}
className="w-full h-9.5 rounded-xl bg-[#14161c] border border-white/[0.1] pl-3.5 pr-20 text-xs font-mono text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/40 text-left"
/>
{/* دکمه‌های عملیات ثابت در سمت راست فیلد LTR */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
onClick={() => handleCopyLink(item.link)}
title={t('settings.copyLinkTooltip')}
className="size-7 rounded-lg bg-white/[0.04] hover:bg-white/[0.1] border border-white/[0.06] text-slate-300 hover:text-white flex items-center justify-center transition-colors"
>
<Ic name="copy" className="size-3.5" />
</button>
{item.link ? (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
title={t('settings.testLinkTooltip')}
className="size-7 rounded-lg bg-primary/10 hover:bg-primary/20 border border-primary/20 text-primary flex items-center justify-center transition-colors"
>
<Ic name="external" className="size-3.5" />
</a>
) : null}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* ۳. کارت اطلاعات تماس تکمیلی (Contact Us - کالیبره در تم تیره) */}
<Card className="border border-white/[0.08] bg-[#1e2129] shadow-md">
<CardContent className="p-6 space-y-5">
<div className="flex items-center gap-3.5">
<div className="flex size-11 items-center justify-center rounded-2xl bg-amber-500/15 text-amber-400 border border-amber-500/25 shadow-inner">
<Ic name="phone" className="size-5.5" />
</div>
<div>
<h3 className="text-sm sm:text-base font-black text-white">
{t('settings.contactInfoCardTitle')}
</h3>
<p className="text-xs text-slate-400 mt-0.5">
{t('settings.contactInfoCardDesc')}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-1">
{/* شماره‌های پشتیبانی */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
<Ic name="phone" className="size-3.5 text-primary" />
<span>{t('settings.phoneNumberLabel')}</span>
</label>
<div className="relative flex items-center" dir="ltr">
<input
type="text"
dir="ltr"
value={contactus.phone_number || ''}
onChange={(e) => setContactus((prev) => ({ ...prev, phone_number: e.target.value }))}
placeholder="+982188888888 | +9647700000000"
className="w-full h-10 rounded-xl bg-[#14161c] border border-white/[0.1] px-3.5 text-xs font-mono text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/40 text-left"
/>
</div>
</div>
{/* شماره اختصاصی واتساپ */}
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
<Ic name="whatsapp" className="size-3.5 text-emerald-400" />
<span>{t('settings.whatsappNumberLabel')}</span>
</label>
<div className="relative flex items-center" dir="ltr">
<input
type="text"
dir="ltr"
value={contactus.whatsapp_number || ''}
onChange={(e) => setContactus((prev) => ({ ...prev, whatsapp_number: e.target.value }))}
placeholder="+989120000000"
className="w-full h-10 rounded-xl bg-[#14161c] border border-white/[0.1] px-3.5 text-xs font-mono text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/40 text-left"
/>
</div>
</div>
{/* آدرس دفتر مرکزی */}
<div className="space-y-1.5 md:col-span-2">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
<Ic name="mapPin" className="size-3.5 text-rose-400" />
<span>{t('settings.addressLabel')}</span>
</label>
<textarea
rows={2}
value={contactus.address || ''}
onChange={(e) => setContactus((prev) => ({ ...prev, address: e.target.value }))}
placeholder="آدرس دقیق دفتر مرکزی عقیله جهت نمایش در بخش ارتباط با ما..."
className="w-full rounded-xl bg-[#14161c] border border-white/[0.1] p-3 text-xs font-medium text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/40 resize-none"
/>
</div>
</div>
<div className="flex justify-end pt-2 border-t border-white/[0.07]">
<Button
type="button"
disabled={saving || loading}
onClick={handleSaveToDatabase}
className="gap-2 rounded-xl text-xs font-black bg-gradient-to-r from-[#d09460] to-[#b37745] hover:from-[#dd9e69] hover:to-[#be804c] text-white shadow-md shadow-[#d09460]/20 transition-all active:scale-[0.98]"
>
<Ic name="save" className={`size-4 ${saving ? 'animate-spin' : ''}`} />
<span>{saving ? t('settings.saving') : t('settings.saveSocialBtn')}</span>
</Button>
</div>
</CardContent>
</Card>
{/* مدال افزودن پلتفرم جدید (کالیبره تم تیره) */}
<Dialog open={addModalOpen} onOpenChange={setAddModalOpen}>
<DialogContent className="max-w-md bg-[#1e2129] border border-white/[0.12] text-white">
<DialogHeader className="p-5 pb-0">
<DialogTitle className="text-sm font-black text-white flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-primary/15 text-primary">
<Ic name="share" className="size-4" />
</div>
<span>{t('settings.addSocialBtn')}</span>
</DialogTitle>
</DialogHeader>
<div className="p-5 space-y-4">
<div className="space-y-2">
<label className="text-xs font-semibold text-slate-300">
{t('settings.selectPlatform')}
</label>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 max-h-52 overflow-y-auto p-1.5 border rounded-xl border-white/[0.08] bg-[#14161c] scrollbar-thin">
{Object.values(PRESET_PLATFORMS).map((preset) => {
const isSelected = selectedPlatformKey === preset.key
return (
<button
key={preset.key}
type="button"
onClick={() => handleSelectPreset(preset.key)}
className={`flex flex-col items-center justify-center p-2 rounded-xl border text-center transition-all ${
isSelected
? 'border-primary bg-primary/20 text-primary shadow-sm'
: 'border-white/[0.06] bg-[#191b22] hover:bg-[#22252f] text-slate-300'
}`}
>
<div
className="size-8 rounded-lg flex items-center justify-center mb-1.5 bg-white/[0.05] border border-white/[0.05]"
style={{ color: preset.brandColor }}
>
<Ic name={preset.icon} className="size-4.5" />
</div>
<span className="text-[11px] font-bold truncate max-w-full">
{preset.name}
</span>
</button>
)
})}
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300">
{t('settings.channelName')}
</label>
<input
type="text"
value={newChannelName}
onChange={(e) => setNewChannelName(e.target.value)}
placeholder="عنوان نمایشی کانال یا حساب کاربری"
className="w-full h-10 rounded-xl bg-[#14161c] border border-white/[0.1] px-3.5 text-xs text-slate-100 placeholder:text-slate-500 outline-none focus:border-primary"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-300">
{t('settings.channelLink')}
</label>
<input
type="text"
dir="ltr"
value={newChannelLink}
onChange={(e) => setNewChannelLink(e.target.value)}
placeholder={PRESET_PLATFORMS[selectedPlatformKey]?.placeholder || 'https://...'}
className="w-full h-10 rounded-xl bg-[#14161c] border border-white/[0.1] px-3.5 text-xs font-mono text-slate-100 placeholder:text-slate-500 outline-none focus:border-primary text-left"
/>
</div>
</div>
<DialogFooter className="px-5 py-4 border-t border-white/[0.08] flex items-center justify-end gap-2 bg-[#171920]">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAddModalOpen(false)}
className="rounded-xl text-xs border-white/10 hover:bg-white/[0.06] text-slate-300"
>
انصراف
</Button>
<Button
type="button"
size="sm"
onClick={handleAddChannel}
className="rounded-xl text-xs font-black bg-gradient-to-r from-[#d09460] to-[#b37745] hover:from-[#dd9e69] hover:to-[#be804c] text-white shadow-sm"
>
افزودن به لیست
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

25
src/features/settings/services/settings-api.ts

@ -106,3 +106,28 @@ export async function updateAdminUser(
body: payload,
})
}
/**
* دریافت تنظیمات شبکه‌های اجتماعی و پیام‌رسان‌ها
*/
export async function fetchSocialMediaSettings(): Promise<import('../types').SocialMediaSettingsResponse> {
return apiFetch<import('../types').SocialMediaSettingsResponse>('home/settings/social/')
}
/**
* ذخیره و همگام‌سازی تنظیمات شبکه‌های اجتماعی در پایگاه داده
*/
export async function saveSocialMediaSettings(
payload: import('../types').SaveSocialMediaPayload
): Promise<{
success: boolean
message: string
items: import('../types').SocialMediaItem[]
contactus?: import('../types').SocialMediaContactUs
}> {
return apiFetch('home/settings/social/', {
method: 'POST',
body: payload,
})
}

34
src/features/settings/types.ts

@ -34,3 +34,37 @@ export interface UpdateAdminPayload {
is_staff?: boolean
is_superuser?: boolean
}
export interface SocialPlatformConfig {
key: string
name: string
name_en?: string
color?: string
placeholder?: string
}
export interface SocialMediaItem {
id: string
platform: string
name: string
link: string
is_active: boolean
}
export interface SocialMediaContactUs {
phone_number?: string
whatsapp_number?: string
address?: string
}
export interface SocialMediaSettingsResponse {
items: SocialMediaItem[]
contactus: SocialMediaContactUs
available_platforms: SocialPlatformConfig[]
}
export interface SaveSocialMediaPayload {
items: SocialMediaItem[]
contactus?: SocialMediaContactUs
}

7
src/icons.tsx

@ -115,6 +115,13 @@ const PATHS: Record<string, string> = {
building: '<rect width="16" height="20" x="4" y="2" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01"/><path d="M16 6h.01"/><path d="M12 6h.01"/><path d="M12 10h.01"/><path d="M12 14h.01"/><path d="M16 10h.01"/><path d="M16 14h.01"/><path d="M8 10h.01"/><path d="M8 14h.01"/>',
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
whatsapp: '<path d="M3 21l1.65-3.8a9 9 0 1 1 3.4 2.9L3 21"/><path d="M9 10a.5.5 0 0 0 1 0V9a.5.5 0 0 0-1 0v1a5 5 0 0 0 5 5h1a.5.5 0 0 0 0-1h-1a.5.5 0 0 0 0 1"/>',
instagram: '<rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/>',
telegram: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
twitter: '<path d="M4 4l11.733 16h4.267l-11.733 -16z"/><path d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772"/>',
facebook: '<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>',
linkedin: '<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect width="4" height="12" x="2" y="9"/><circle cx="4" cy="4" r="2"/>',
tiktok: '<path d="M9 12a4 4 0 1 0 4 4V4a5 5 0 0 0 5 5"/>',
share2: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
}
export type IconName = keyof typeof PATHS

35
src/locales/ar.json

@ -441,8 +441,37 @@
"settings": {
"pageTitle": "إعدادات النظام",
"pageDescription": "إدارة المشرفين وتعيين وتغيير كلمات المرور وتهيئة وحدات النظام",
"pageSubtitle": "إدارة المشرفين وشبكات التواصل الاجتماعي والإعدادات الشاملة لمنظومة عقيلة",
"badgeRoleControl": "الصلاحيات والإعدادات",
"tabAdmins": "إدارة المشرفين والصلاحيات",
"tabNotifications": "نظام الإشعارات",
"tabSocial": "وسائل التواصل الاجتماعي",
"socialTabTitle": "شبكات التواصل الاجتماعي والمراسلة",
"socialTabSubtitle": "إدارة قنوات البث الرسمية وروابط التواصل الاجتماعي المحفوظة في قاعدة البيانات",
"addSocialBtn": "إضافة شبكة تواصل",
"saveSocialBtn": "حفظ التغييرات في قاعدة البيانات",
"saving": "جارٍ الحفظ...",
"noSocialChannels": "لا توجد شبكات تواصل مسجلة حالياً",
"noSocialChannelsDesc": "أضف قناة تواصل واحدة على الأقل لعرض روابط الاتصال في تطبيق الهاتف والموقع الإلكتروني.",
"selectPlatform": "اختيار المنصة",
"channelName": "الاسم الظاهر",
"channelLink": "الرابط (URL)",
"activeStatus": "نشط في التطبيق والويب",
"inactiveStatus": "غير نشط (مخفي)",
"deleteChannel": "حذف",
"deleteChannelConfirm": "هل أنت متأكد من حذف شبكة التواصل هذه؟",
"channelAdded": "تمت إضافة شبكة التواصل بنجاح.",
"socialSaveSuccess": "تم حفظ إعدادات شبكات التواصل الاجتماعي بنجاح في قاعدة البيانات.",
"socialSaveError": "خطأ أثناء حفظ إعدادات شبكات التواصل الاجتماعي.",
"socialFetchError": "خطأ في جلب إعدادات شبكات التواصل الاجتماعي من الخادم.",
"contactInfoCardTitle": "معلومات الاتصال الإضافية (اتصل بنا)",
"contactInfoCardDesc": "يتم إرسال هذه المعلومات مع روابط شبكات التواصل في واجهات التطبيق والموقع الرسمية لعقيلة.",
"phoneNumberLabel": "أرقام هواتف الدعم الفني",
"whatsappNumberLabel": "رقم الدعم المخصص عبر واتساب",
"addressLabel": "عنوان المكتب الرئيسي لعقيلة",
"testLinkTooltip": "تجربة وفتح الرابط في نافذة جديدة",
"copyLinkTooltip": "نسخ الرابط إلى الحافظة",
"linkCopied": "تم نسخ الرابط بنجاح.",
"kpiTotalAdmins": "إجمالي المشرفين",
"kpiActiveAdmins": "المشرفون النشطون",
"kpiSuperusers": "المشرفون المتميزون",
@ -627,7 +656,11 @@
"specialRequest": "ملاحظات وطلبات المسافر الخاصة",
"currencyUsd": "دولار $",
"pricePlaceholder": "المبلغ التقديري...",
"submitting": "جارٍ الحفظ..."
"submitting": "جارٍ الحفظ...",
"passengersList": "بيانات المسافرين وصور جوازات السفر",
"passportNumber": "رقم الجواز",
"viewPassportImage": "عرض الجواز",
"noPassportImage": "لا توجد صورة جواز"
},
"common": {
"deleteConfirmTitle": "تأكيد الحذف",

35
src/locales/en.json

@ -441,8 +441,37 @@
"settings": {
"pageTitle": "System Settings",
"pageDescription": "Management of administrators, setting and updating passwords, and system configuration",
"pageSubtitle": "Manage administrators, social networks, and comprehensive system settings",
"badgeRoleControl": "Access & Settings",
"tabAdmins": "Admin & Access Management",
"tabNotifications": "Notification System",
"tabSocial": "Social Media & Networks",
"socialTabTitle": "Social Media & Messaging Channels",
"socialTabSubtitle": "Configure and manage official broadcast channels, social profiles, and messaging links saved in database",
"addSocialBtn": "Add Social Channel",
"saveSocialBtn": "Save Changes to Database",
"saving": "Saving changes...",
"noSocialChannels": "No active social channels configured",
"noSocialChannelsDesc": "Add at least one social media channel to display contact links in the mobile app and website.",
"selectPlatform": "Select Platform",
"channelName": "Display Name",
"channelLink": "Link (URL)",
"activeStatus": "Active in App & Web",
"inactiveStatus": "Inactive (Hidden)",
"deleteChannel": "Delete",
"deleteChannelConfirm": "Are you sure you want to delete this social channel?",
"channelAdded": "New social channel added.",
"socialSaveSuccess": "Social media settings saved successfully to the database.",
"socialSaveError": "Error saving social media settings.",
"socialFetchError": "Error fetching social media settings from server.",
"contactInfoCardTitle": "Additional Contact Details (Contact Us)",
"contactInfoCardDesc": "This info is returned alongside social links in Aqila's public mobile & web APIs.",
"phoneNumberLabel": "Support Phone Numbers",
"whatsappNumberLabel": "Dedicated WhatsApp Support Number",
"addressLabel": "Aqila Headquarters Address",
"testLinkTooltip": "Test and open link in new tab",
"copyLinkTooltip": "Copy link to clipboard",
"linkCopied": "Link copied to clipboard successfully.",
"kpiTotalAdmins": "Total Administrators",
"kpiActiveAdmins": "Active Administrators",
"kpiSuperusers": "Superusers",
@ -627,7 +656,11 @@
"specialRequest": "Traveler Special Notes & Requests",
"currencyUsd": "USD ($)",
"pricePlaceholder": "Estimated amount...",
"submitting": "Saving..."
"submitting": "Saving...",
"passengersList": "Passengers & Passport Images",
"passportNumber": "Passport No.",
"viewPassportImage": "View Passport",
"noPassportImage": "No Passport Image"
},
"common": {
"deleteConfirmTitle": "Confirm Deletion",

35
src/locales/fa.json

@ -441,8 +441,37 @@
"settings": {
"pageTitle": "تنظیمات سامانه",
"pageDescription": "مدیریت مدیران، تعیین و تغییر رمزهای عبور و پیکربندی ماژول‌های سامانه",
"pageSubtitle": "مدیریت مدیران، شبکه‌های اجتماعی و تنظیمات جامع سامانه عقیله",
"badgeRoleControl": "کنترل دسترسی و تنظیمات",
"tabAdmins": "مدیریت مدیران و دسترسی‌ها",
"tabNotifications": "سامانه اعلان‌ها",
"tabSocial": "سوشال مدیا و فضای مجازی",
"socialTabTitle": "شبکه‌های اجتماعی و پیام‌رسان‌ها",
"socialTabSubtitle": "پیکربندی و مدیریت کانال‌های اطلاع‌رسانی، حساب‌های رسمی و پیام‌رسان‌های متصل به دیتابیس",
"addSocialBtn": "افزودن شبکه اجتماعی",
"saveSocialBtn": "ذخیره تغییرات در دیتابیس",
"saving": "در حال ذخیره‌سازی...",
"noSocialChannels": "هیچ شبکه اجتماعی فعالی ثبت نشده است",
"noSocialChannelsDesc": "برای نمایش لینک‌های اجتماعی در اپلیکیشن و وب‌سایت، حداقل یک کانال اضافه کنید.",
"selectPlatform": "انتخاب شبکه اجتماعی",
"channelName": "عنوان نمایشی",
"channelLink": "پیوند (URL)",
"activeStatus": "فعال در اپلیکیشن و وب",
"inactiveStatus": "غیرفعال (مخفی)",
"deleteChannel": "حذف",
"deleteChannelConfirm": "آیا از حذف این شبکه اجتماعی اطمینان دارید؟",
"channelAdded": "شبکه اجتماعی جدید اضافه شد.",
"socialSaveSuccess": "تنظیمات شبکه‌های اجتماعی با موفقیت در دیتابیس ذخیره شد.",
"socialSaveError": "خطا در ذخیره‌سازی تنظیمات شبکه‌های اجتماعی.",
"socialFetchError": "خطا در بارگذاری تنظیمات شبکه‌های اجتماعی از سرور.",
"contactInfoCardTitle": "اطلاعات تماس تکمیلی (Contact Us)",
"contactInfoCardDesc": "این اطلاعات در کنار شبکه‌های اجتماعی در APIهای عمومی اپ و وب عقیله ارسال می‌شود.",
"phoneNumberLabel": "شماره تلفن‌های پشتیبانی",
"whatsappNumberLabel": "شماره اختصاصی پشتیبانی واتساپ",
"addressLabel": "آدرس دفتر مرکزی عقیله",
"testLinkTooltip": "تست و بازکردن لینک در تب جدید",
"copyLinkTooltip": "کپی کردن لینک",
"linkCopied": "لینک با موفقیت کپی شد.",
"kpiTotalAdmins": "کل مدیران سیستم",
"kpiActiveAdmins": "مدیران فعال",
"kpiSuperusers": "مدیران ارشد",
@ -627,7 +656,11 @@
"specialRequest": "یادداشت و درخواست ویژه مسافر",
"currencyUsd": "دلار $",
"pricePlaceholder": "مبلغ تخمینی...",
"submitting": "در حال ثبت..."
"submitting": "در حال ثبت...",
"passengersList": "مشخصات مسافران و تصویر پاسپورت",
"passportNumber": "شماره پاسپورت",
"viewPassportImage": "مشاهده پاسپورت",
"noPassportImage": "بدون تصویر پاسپورت"
},
"common": {
"deleteConfirmTitle": "تأیید حذف",

9
src/services/http.ts

@ -126,7 +126,14 @@ export async function apiFetch<T = unknown>(path: string, options: ApiFetchOptio
res = await fetch(buildUrl(path), {
...rest,
headers: finalHeaders,
body: body === undefined ? undefined : isFormData ? (body as FormData) : JSON.stringify(body),
body:
body === undefined
? undefined
: isFormData
? (body as FormData)
: typeof body === 'string'
? body
: JSON.stringify(body),
})
} catch (err) {
/* لغو درخواست (AbortController) باید همان AbortError بماند تا React Query

Loading…
Cancel
Save