Browse Source
feat: add services feature module with localization, UI components, and API integrations
master
feat: add services feature module with localization, UI components, and API integrations
master
22 changed files with 1889 additions and 456 deletions
-
BINpublic/fonts/Vazirmatn-Bold.ttf
-
BINpublic/fonts/Vazirmatn-Regular.ttf
-
6scripts/generate-locales.js
-
2src/components/ui/checkbox.tsx
-
136src/features/services/ServicesPage.tsx
-
572src/features/services/components/CreateLocationDialog.tsx
-
18src/features/services/components/FaqServiceView.tsx
-
58src/features/services/components/LocationDetailSheet.tsx
-
669src/features/services/components/SimcardFormDialog.tsx
-
441src/features/services/components/SimcardServiceView.tsx
-
9src/features/services/components/StoreServiceView.tsx
-
179src/features/services/components/ZayerGuideServiceView.tsx
-
125src/features/services/services/simcard-api.ts
-
54src/features/services/services/trip-etiquette-api.ts
-
28src/features/services/services/zayer-guide-api.ts
-
12src/features/services/types/faq.ts
-
2src/features/tours/components/CreateTourDialog.tsx
-
3src/icons.tsx
-
25src/index.css
-
2src/locales/ar.json
-
2src/locales/en.json
-
2src/locales/fa.json
@ -0,0 +1,669 @@ |
|||||
|
import React, { useState, useEffect } 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 type { SimCardItem, CreateSimCardInput } from '../services/simcard-api' |
||||
|
|
||||
|
export type SupportedLanguage = 'ar' | 'fa' | 'en' | 'ru' | 'id' |
||||
|
|
||||
|
interface SimcardFormDialogProps { |
||||
|
open: boolean |
||||
|
onOpenChange: (open: boolean) => void |
||||
|
item?: SimCardItem | null |
||||
|
onSave: (data: CreateSimCardInput, id?: number) => Promise<void> |
||||
|
onDelete?: (id: number) => Promise<void> |
||||
|
} |
||||
|
|
||||
|
const COUNTRIES = [ |
||||
|
{ id: 1, code: 'IR', name: 'ایران', flag: '🇮🇷' }, |
||||
|
{ id: 2, code: 'IQ', name: 'عراق', flag: '🇮🇶' }, |
||||
|
{ id: 4, code: 'SA', name: 'عربستان سعودی', flag: '🇸🇦' }, |
||||
|
] |
||||
|
|
||||
|
export const LANGUAGES = [ |
||||
|
{ code: 'ar', name: 'العربية', flag: '🇸🇦', badge: 'AR', dir: 'rtl', label: 'العربية (پیشفرض)' }, |
||||
|
{ code: 'fa', name: 'فارسی', flag: '🇮🇷', badge: 'FA', dir: 'rtl', label: 'فارسی' }, |
||||
|
{ code: 'en', name: 'English', flag: '🇬🇧', badge: 'EN', dir: 'ltr', label: 'انگلیسی' }, |
||||
|
{ code: 'ru', name: 'Русский', flag: '🇷🇺', badge: 'RU', dir: 'ltr', label: 'روسی' }, |
||||
|
{ code: 'id', name: 'Bahasa Indonesia', flag: '🇮🇩', badge: 'ID', dir: 'ltr', label: 'اندونزیایی' }, |
||||
|
] as const |
||||
|
|
||||
|
const EMPTY_TITLES: Record<SupportedLanguage, string> = { |
||||
|
ar: '', |
||||
|
fa: '', |
||||
|
en: '', |
||||
|
ru: '', |
||||
|
id: '', |
||||
|
} |
||||
|
|
||||
|
const EMPTY_DETAILS: Record<SupportedLanguage, string[]> = { |
||||
|
ar: [], |
||||
|
fa: [], |
||||
|
en: [], |
||||
|
ru: [], |
||||
|
id: [], |
||||
|
} |
||||
|
|
||||
|
export function SimcardFormDialog({ |
||||
|
open, |
||||
|
onOpenChange, |
||||
|
item, |
||||
|
onSave, |
||||
|
onDelete, |
||||
|
}: SimcardFormDialogProps) { |
||||
|
const isEditing = Boolean(item && item.id) |
||||
|
|
||||
|
const [activeLangTab, setActiveLangTab] = useState<SupportedLanguage>('ar') |
||||
|
const [countryId, setCountryId] = useState<number>(1) |
||||
|
const [price, setPrice] = useState<string>('10.00') |
||||
|
const [status, setStatus] = useState<number>(1) |
||||
|
|
||||
|
// عناوین چندزبانه
|
||||
|
const [titles, setTitles] = useState<Record<SupportedLanguage, string>>({ ...EMPTY_TITLES }) |
||||
|
|
||||
|
// ویژگیهای چندزبانه
|
||||
|
const [details, setDetails] = useState<Record<SupportedLanguage, string[]>>({ ...EMPTY_DETAILS }) |
||||
|
|
||||
|
// افزودن ویژگی جدید در زبان فعال
|
||||
|
const [newDetailText, setNewDetailText] = useState('') |
||||
|
|
||||
|
// ویرایش درونخطی ویژگی در زبان فعال
|
||||
|
const [editingDetailIndex, setEditingDetailIndex] = useState<number | null>(null) |
||||
|
const [editingDetailText, setEditingDetailText] = useState<string>('') |
||||
|
|
||||
|
const [isSubmitting, setIsSubmitting] = useState(false) |
||||
|
const [isDeleting, setIsDeleting] = useState(false) |
||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) |
||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
setActiveLangTab('ar') |
||||
|
if (item) { |
||||
|
const cId = item.country?.id || 1 |
||||
|
setCountryId(cId) |
||||
|
setPrice(item.price ? String(item.price) : '10.00') |
||||
|
setStatus(item.status ?? 1) |
||||
|
|
||||
|
// استخراج عناوین ۵ زبانه از دیتابیس
|
||||
|
const parsedTitles: Record<SupportedLanguage, string> = { ...EMPTY_TITLES } |
||||
|
if (Array.isArray(item.raw_title)) { |
||||
|
for (const t of item.raw_title) { |
||||
|
if (t && typeof t === 'object' && t.language_code && t.title) { |
||||
|
const lang = t.language_code as SupportedLanguage |
||||
|
if (lang in parsedTitles) { |
||||
|
parsedTitles[lang] = t.title |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} else if (item.raw_title && typeof item.raw_title === 'object') { |
||||
|
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as SupportedLanguage[]) { |
||||
|
if (item.raw_title[lang]) parsedTitles[lang] = String(item.raw_title[lang]) |
||||
|
} |
||||
|
} |
||||
|
if (!parsedTitles.ar && !parsedTitles.fa && item.title) { |
||||
|
parsedTitles.ar = item.title |
||||
|
parsedTitles.fa = item.title |
||||
|
} |
||||
|
setTitles(parsedTitles) |
||||
|
|
||||
|
// استخراج ویژگیهای ۵ زبانه از دیتابیس
|
||||
|
const parsedDetails: Record<SupportedLanguage, string[]> = { ...EMPTY_DETAILS } |
||||
|
if (Array.isArray(item.raw_detail)) { |
||||
|
for (const d of item.raw_detail) { |
||||
|
if (d && typeof d === 'object' && d.language_code && d.detail) { |
||||
|
const lang = d.language_code as SupportedLanguage |
||||
|
if (lang in parsedDetails) { |
||||
|
parsedDetails[lang].push(d.detail) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} else if (item.raw_detail && typeof item.raw_detail === 'object') { |
||||
|
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as SupportedLanguage[]) { |
||||
|
if (Array.isArray(item.raw_detail[lang])) { |
||||
|
parsedDetails[lang] = [...item.raw_detail[lang]] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (parsedDetails.ar.length === 0 && parsedDetails.fa.length === 0 && Array.isArray(item.simcard_detail)) { |
||||
|
parsedDetails.ar = [...item.simcard_detail] |
||||
|
parsedDetails.fa = [...item.simcard_detail] |
||||
|
} |
||||
|
setDetails(parsedDetails) |
||||
|
} else { |
||||
|
setCountryId(1) |
||||
|
setPrice('10.00') |
||||
|
setStatus(1) |
||||
|
setTitles({ ...EMPTY_TITLES }) |
||||
|
setDetails({ ...EMPTY_DETAILS }) |
||||
|
} |
||||
|
|
||||
|
setNewDetailText('') |
||||
|
setEditingDetailIndex(null) |
||||
|
setEditingDetailText('') |
||||
|
setErrorMsg(null) |
||||
|
setShowDeleteConfirm(false) |
||||
|
}, [item, open]) |
||||
|
|
||||
|
// کار با ویژگیهای زبان فعال
|
||||
|
const activeDetails = details[activeLangTab] || [] |
||||
|
|
||||
|
const handleTabChange = (langCode: SupportedLanguage) => { |
||||
|
setActiveLangTab(langCode) |
||||
|
} |
||||
|
|
||||
|
const handleTitleChange = (val: string) => { |
||||
|
setTitles((prev) => ({ |
||||
|
...prev, |
||||
|
[activeLangTab]: val, |
||||
|
})) |
||||
|
} |
||||
|
|
||||
|
const handleCountryChange = (cId: number) => { |
||||
|
setCountryId(cId) |
||||
|
} |
||||
|
|
||||
|
const handleAddDetail = () => { |
||||
|
if (!newDetailText.trim()) return |
||||
|
setDetails((prev) => ({ |
||||
|
...prev, |
||||
|
[activeLangTab]: [...(prev[activeLangTab] || []), newDetailText.trim()], |
||||
|
})) |
||||
|
setNewDetailText('') |
||||
|
} |
||||
|
|
||||
|
const handleRemoveDetail = (index: number) => { |
||||
|
setDetails((prev) => ({ |
||||
|
...prev, |
||||
|
[activeLangTab]: (prev[activeLangTab] || []).filter((_, i) => i !== index), |
||||
|
})) |
||||
|
if (editingDetailIndex === index) { |
||||
|
setEditingDetailIndex(null) |
||||
|
setEditingDetailText('') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const handleStartEditDetail = (index: number, text: string) => { |
||||
|
setEditingDetailIndex(index) |
||||
|
setEditingDetailText(text) |
||||
|
} |
||||
|
|
||||
|
const handleSaveEditDetail = (index: number) => { |
||||
|
if (!editingDetailText.trim()) return |
||||
|
setDetails((prev) => ({ |
||||
|
...prev, |
||||
|
[activeLangTab]: (prev[activeLangTab] || []).map((d, i) => |
||||
|
i === index ? editingDetailText.trim() : d |
||||
|
), |
||||
|
})) |
||||
|
setEditingDetailIndex(null) |
||||
|
setEditingDetailText('') |
||||
|
} |
||||
|
|
||||
|
const handleCancelEditDetail = () => { |
||||
|
setEditingDetailIndex(null) |
||||
|
setEditingDetailText('') |
||||
|
} |
||||
|
|
||||
|
const handleSubmit = async (e: React.FormEvent) => { |
||||
|
e.preventDefault() |
||||
|
|
||||
|
const mainTitle = |
||||
|
titles.ar.trim() || |
||||
|
titles.fa.trim() || |
||||
|
titles[activeLangTab].trim() || |
||||
|
titles.en.trim() || |
||||
|
titles.ru.trim() || |
||||
|
titles.id.trim() |
||||
|
if (!mainTitle) { |
||||
|
setErrorMsg('لطفاً عنوان بسته سیمکارت را وارد نمایید.') |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
const numPrice = parseFloat(price) |
||||
|
if (isNaN(numPrice) || numPrice <= 0) { |
||||
|
setErrorMsg('لطفاً قیمت معتبری وارد نمایید.') |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// عناوین ۵ زبانه
|
||||
|
const finalTitles: Record<SupportedLanguage, string> = { |
||||
|
ar: titles.ar.trim() || mainTitle, |
||||
|
fa: titles.fa.trim() || mainTitle, |
||||
|
en: titles.en.trim() || mainTitle, |
||||
|
ru: titles.ru.trim() || mainTitle, |
||||
|
id: titles.id.trim() || mainTitle, |
||||
|
} |
||||
|
|
||||
|
// ویژگیهای ۵ زبانه
|
||||
|
const finalDetails: Record<SupportedLanguage, string[]> = { |
||||
|
ar: (details.ar || []).filter((d) => d.trim()), |
||||
|
fa: (details.fa || []).filter((d) => d.trim()), |
||||
|
en: (details.en || []).filter((d) => d.trim()), |
||||
|
ru: (details.ru || []).filter((d) => d.trim()), |
||||
|
id: (details.id || []).filter((d) => d.trim()), |
||||
|
} |
||||
|
|
||||
|
// فالبک ویژگیها در صورت خالی بودن
|
||||
|
const fallbackDetails = |
||||
|
finalDetails.ar.length > 0 |
||||
|
? finalDetails.ar |
||||
|
: finalDetails.fa.length > 0 |
||||
|
? finalDetails.fa |
||||
|
: [] |
||||
|
for (const lang of ['ar', 'fa', 'en', 'ru', 'id'] as SupportedLanguage[]) { |
||||
|
if (finalDetails[lang].length === 0 && fallbackDetails.length > 0) { |
||||
|
finalDetails[lang] = [...fallbackDetails] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
setErrorMsg(null) |
||||
|
setIsSubmitting(true) |
||||
|
try { |
||||
|
await onSave( |
||||
|
{ |
||||
|
title: finalTitles.fa || finalTitles.ar, |
||||
|
titles: finalTitles, |
||||
|
country_id: countryId, |
||||
|
price: numPrice.toFixed(2), |
||||
|
status, |
||||
|
simcard_detail: finalDetails.fa.length > 0 ? finalDetails.fa : finalDetails.ar, |
||||
|
details: finalDetails, |
||||
|
}, |
||||
|
item?.id |
||||
|
) |
||||
|
onOpenChange(false) |
||||
|
} catch (err: any) { |
||||
|
setErrorMsg(err?.message || 'خطایی در ثبت اطلاعات رخ داد.') |
||||
|
} finally { |
||||
|
setIsSubmitting(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const handleConfirmDelete = async () => { |
||||
|
if (!item?.id || !onDelete) return |
||||
|
setIsDeleting(true) |
||||
|
try { |
||||
|
await onDelete(item.id) |
||||
|
setShowDeleteConfirm(false) |
||||
|
onOpenChange(false) |
||||
|
} catch (err: any) { |
||||
|
setErrorMsg(err?.message || 'خطایی در حذف سیمکارت رخ داد.') |
||||
|
} finally { |
||||
|
setIsDeleting(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const currentLang = LANGUAGES.find((l) => l.code === activeLangTab) || LANGUAGES[0] |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent className="sm:max-w-2xl lg:max-w-3xl w-full rounded-2xl bg-card border border-border-soft p-0 max-h-[85vh] flex flex-col overflow-hidden shadow-2xl"> |
||||
|
{/* هدر ثابت در بالای مدال */} |
||||
|
<DialogHeader className="p-6 pb-4 border-b border-border-soft shrink-0 text-start"> |
||||
|
<DialogTitle className="text-lg font-bold text-foreground"> |
||||
|
{isEditing ? 'ویرایش بسته سیمکارت' : 'افزودن سیمکارت جدید'} |
||||
|
</DialogTitle> |
||||
|
<DialogDescription className="text-xs text-grey-3"> |
||||
|
{isEditing |
||||
|
? 'تغییرات شما در تمام زبانها در دیتابیس ثبت شده و در اپلیکیشن زائرین بر اساس زبان انتخابی کاربر نمایش داده میشود.' |
||||
|
: 'سیمکارت جدید در دیتابیس ذخیره و در اپلیکیشن فعال خواهد شد.'} |
||||
|
</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
|
||||
|
{/* فرم: بدنه اسکرولشونده و فوتر فیکسشده */} |
||||
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden min-h-0"> |
||||
|
{/* بدنه محتوا (تنها بخشی که اسکرول میشود) */} |
||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-5"> |
||||
|
{errorMsg && ( |
||||
|
<div className="p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-500 text-xs flex items-center gap-2"> |
||||
|
<Ic name="alert" className="size-4 shrink-0" /> |
||||
|
<span>{errorMsg}</span> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* ۱. انتخاب کشور مقصد */} |
||||
|
<div className="space-y-2"> |
||||
|
<label className="text-xs font-bold text-foreground block"> |
||||
|
کشور مقصد <span className="text-rose-500">*</span> |
||||
|
</label> |
||||
|
<div className="grid grid-cols-3 gap-3"> |
||||
|
{COUNTRIES.map((c) => { |
||||
|
const isSelected = countryId === c.id |
||||
|
return ( |
||||
|
<button |
||||
|
key={c.id} |
||||
|
type="button" |
||||
|
onClick={() => handleCountryChange(c.id)} |
||||
|
className={`h-12 px-4 rounded-xl border text-xs font-bold transition flex items-center justify-center gap-2.5 ${ |
||||
|
isSelected |
||||
|
? 'border-primary bg-primary/10 text-primary shadow-xs' |
||||
|
: 'border-border-soft bg-surface-base/50 text-grey-3 hover:text-foreground hover:bg-card' |
||||
|
}`}
|
||||
|
> |
||||
|
<span className="text-lg">{c.flag}</span> |
||||
|
<span className="text-xs">{c.name}</span> |
||||
|
</button> |
||||
|
) |
||||
|
})} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* ۲. تبهای انتخاب ۵ زبان با پرچم و نشانگر وضعیت ترجمه (زیر کشور مقصد) */} |
||||
|
<div className="space-y-2 pt-1"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<label className="text-xs font-bold text-foreground flex items-center gap-1.5"> |
||||
|
<Ic name="globe" className="size-3.5 text-primary" /> |
||||
|
<span>زبان ترجمه و محتوا</span> |
||||
|
<span className="text-rose-500">*</span> |
||||
|
</label> |
||||
|
</div> |
||||
|
|
||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 p-1.5 rounded-2xl bg-surface-base/80 border border-border-soft"> |
||||
|
{LANGUAGES.map((lang) => { |
||||
|
const isSelected = activeLangTab === lang.code |
||||
|
const hasTitle = Boolean(titles[lang.code]?.trim()) |
||||
|
const detailsCount = (details[lang.code] || []).length |
||||
|
return ( |
||||
|
<button |
||||
|
key={lang.code} |
||||
|
type="button" |
||||
|
onClick={() => handleTabChange(lang.code)} |
||||
|
className={`h-11 px-3 rounded-xl text-xs font-bold transition flex items-center justify-center gap-2 relative ${ |
||||
|
isSelected |
||||
|
? 'bg-primary text-primary-foreground shadow-xs' |
||||
|
: 'text-grey-3 hover:text-foreground hover:bg-card' |
||||
|
}`}
|
||||
|
> |
||||
|
<span className="text-base">{lang.flag}</span> |
||||
|
<span className="truncate">{lang.name}</span> |
||||
|
{hasTitle && ( |
||||
|
<span |
||||
|
className={`size-2 rounded-full shrink-0 ${ |
||||
|
isSelected ? 'bg-white' : 'bg-emerald-500' |
||||
|
}`}
|
||||
|
title={`دارای ترجمه (${detailsCount} ویژگی)`} |
||||
|
/> |
||||
|
)} |
||||
|
</button> |
||||
|
) |
||||
|
})} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* ۳. عنوان بسته سیمکارت به زبان فعال */} |
||||
|
<div className="space-y-2"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<label className="text-xs font-bold text-foreground flex items-center gap-1.5"> |
||||
|
<span>عنوان بسته سیمکارت به زبان {currentLang.name}</span> |
||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded font-mono font-bold bg-primary/10 text-primary border border-primary/20"> |
||||
|
{currentLang.badge} |
||||
|
</span> |
||||
|
<span className="text-rose-500">*</span> |
||||
|
</label> |
||||
|
<span className="text-[11px] text-grey-3 font-mono"> |
||||
|
{currentLang.dir === 'rtl' ? 'RTL' : 'LTR'} |
||||
|
</span> |
||||
|
</div> |
||||
|
<Input |
||||
|
value={titles[activeLangTab] || ''} |
||||
|
onChange={(e) => handleTitleChange(e.target.value)} |
||||
|
dir={currentLang.dir} |
||||
|
placeholder={`عنوان بسته به زبان ${currentLang.name}...`} |
||||
|
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft" |
||||
|
required={activeLangTab === 'fa'} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{/* ۴. قیمت و وضعیت در اپلیکیشن */} |
||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> |
||||
|
<div className="space-y-2"> |
||||
|
<label className="text-xs font-bold text-foreground block"> |
||||
|
قیمت به دلار ($) <span className="text-rose-500">*</span> |
||||
|
</label> |
||||
|
<div className="relative"> |
||||
|
<Input |
||||
|
type="number" |
||||
|
step="0.01" |
||||
|
min="0" |
||||
|
value={price} |
||||
|
onChange={(e) => setPrice(e.target.value)} |
||||
|
placeholder="10.00" |
||||
|
className="h-11 text-xs bg-surface-base rounded-xl border-border-soft font-mono ps-8" |
||||
|
required |
||||
|
/> |
||||
|
<span className="absolute start-3 top-1/2 -translate-y-1/2 text-grey-3 font-mono text-xs"> |
||||
|
$ |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-2"> |
||||
|
<label className="text-xs font-bold text-foreground block">وضعیت در اپلیکیشن</label> |
||||
|
<select |
||||
|
value={status} |
||||
|
onChange={(e) => setStatus(parseInt(e.target.value, 10))} |
||||
|
className="w-full h-11 text-xs bg-surface-base rounded-xl border border-border-soft px-3 text-foreground font-medium focus:outline-none focus:ring-1 focus:ring-primary" |
||||
|
> |
||||
|
<option value={1}>فعال در اپلیکیشن</option> |
||||
|
<option value={2}>ناموجود (Out of Stock)</option> |
||||
|
<option value={3}>غیرفعال</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* ۵. ویژگیها و جزئیات بسته به زبان فعال */} |
||||
|
<div className="space-y-2"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<label className="text-xs font-bold text-foreground flex items-center gap-1.5"> |
||||
|
<span>ویژگیها و جزئیات بسته به زبان {currentLang.name}</span> |
||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded font-mono font-bold bg-primary/10 text-primary border border-primary/20"> |
||||
|
{currentLang.badge} |
||||
|
</span> |
||||
|
</label> |
||||
|
<span className="text-[11px] text-grey-3"> |
||||
|
{activeDetails.length} ویژگی ثبتشده برای {currentLang.name} |
||||
|
</span> |
||||
|
</div> |
||||
|
|
||||
|
{/* ورودی افزودن ویژگی جدید */} |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Input |
||||
|
value={newDetailText} |
||||
|
onChange={(e) => setNewDetailText(e.target.value)} |
||||
|
onKeyDown={(e) => { |
||||
|
if (e.key === 'Enter') { |
||||
|
e.preventDefault() |
||||
|
handleAddDetail() |
||||
|
} |
||||
|
}} |
||||
|
dir={currentLang.dir} |
||||
|
placeholder={`افزودن ویژگی به زبان ${currentLang.name} (مثلاً حجم اینترنت، دقایق مکالمه، فعالسازی)...`} |
||||
|
className="h-10 text-xs bg-surface-base rounded-xl border-border-soft flex-1" |
||||
|
/> |
||||
|
<Button |
||||
|
type="button" |
||||
|
onClick={handleAddDetail} |
||||
|
disabled={!newDetailText.trim()} |
||||
|
className="h-10 px-4 rounded-xl text-xs font-bold bg-primary text-primary-foreground hover:bg-primary/90 gap-1.5 shrink-0" |
||||
|
> |
||||
|
<Ic name="plus" className="size-3.5" /> |
||||
|
<span>افزودن</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
{/* لیست ویژگیهای ثبتشده در زبان فعال */} |
||||
|
{activeDetails.length === 0 ? ( |
||||
|
<div className="p-4 rounded-xl border border-dashed border-border-soft text-center text-grey-3 text-xs"> |
||||
|
هنوز ویژگی خاصی به زبان {currentLang.name} اضافه نشده است. میتوانید با فیلد بالا ویژگی جدید ثبت کنید. |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="space-y-2 max-h-48 overflow-y-auto pe-1"> |
||||
|
{activeDetails.map((itemText, idx) => ( |
||||
|
<div |
||||
|
key={idx} |
||||
|
className="p-3 rounded-xl border border-border-soft bg-surface-base/60 text-xs transition" |
||||
|
> |
||||
|
{editingDetailIndex === idx ? ( |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Input |
||||
|
value={editingDetailText} |
||||
|
onChange={(e) => setEditingDetailText(e.target.value)} |
||||
|
onKeyDown={(e) => { |
||||
|
if (e.key === 'Enter') { |
||||
|
e.preventDefault() |
||||
|
handleSaveEditDetail(idx) |
||||
|
} |
||||
|
}} |
||||
|
dir={currentLang.dir} |
||||
|
className="h-9 text-xs bg-card rounded-lg flex-1" |
||||
|
autoFocus |
||||
|
/> |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
onClick={() => handleSaveEditDetail(idx)} |
||||
|
className="h-9 px-3 text-xs bg-primary text-white rounded-lg gap-1 shrink-0" |
||||
|
> |
||||
|
<Ic name="check" className="size-3.5" /> |
||||
|
<span>ذخیره</span> |
||||
|
</Button> |
||||
|
<Button |
||||
|
type="button" |
||||
|
size="sm" |
||||
|
variant="ghost" |
||||
|
onClick={handleCancelEditDetail} |
||||
|
className="h-9 px-2 text-xs text-grey-3 hover:text-foreground shrink-0" |
||||
|
title="انصراف" |
||||
|
> |
||||
|
<Ic name="close" className="size-3.5" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="flex items-center justify-between gap-3"> |
||||
|
<div className="flex items-center gap-2 truncate" dir={currentLang.dir}> |
||||
|
<span className="size-1.5 rounded-full bg-primary shrink-0" /> |
||||
|
<span className="text-foreground font-medium leading-relaxed truncate"> |
||||
|
{itemText} |
||||
|
</span> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-1 shrink-0"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => handleStartEditDetail(idx, itemText)} |
||||
|
className="text-grey-3 hover:text-primary p-1.5 rounded-lg hover:bg-primary/10 transition" |
||||
|
title="ویرایش ویژگی" |
||||
|
> |
||||
|
<Ic name="edit" className="size-3.5" /> |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => handleRemoveDetail(idx)} |
||||
|
className="text-grey-3 hover:text-rose-500 p-1.5 rounded-lg hover:bg-rose-500/10 transition" |
||||
|
title="حذف ویژگی" |
||||
|
> |
||||
|
<Ic name="trash" className="size-3.5" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
))} |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* فوتر فیکسشده در پایین */} |
||||
|
<DialogFooter className="p-4 sm:px-6 bg-card border-t border-border-soft shrink-0 flex flex-row items-center justify-between gap-3"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Button |
||||
|
type="submit" |
||||
|
disabled={isSubmitting || isDeleting} |
||||
|
className="rounded-xl text-xs font-bold h-10 px-5 bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs" |
||||
|
> |
||||
|
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'} |
||||
|
</Button> |
||||
|
<DialogClose asChild> |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="ghost" |
||||
|
disabled={isSubmitting || isDeleting} |
||||
|
className="rounded-xl text-xs h-10 px-4" |
||||
|
> |
||||
|
انصراف |
||||
|
</Button> |
||||
|
</DialogClose> |
||||
|
</div> |
||||
|
|
||||
|
{/* دکمه حذف بسته در گوشه فرم برای حالت ویرایش */} |
||||
|
{isEditing && onDelete && ( |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="ghost" |
||||
|
disabled={isSubmitting || isDeleting} |
||||
|
onClick={() => setShowDeleteConfirm(true)} |
||||
|
className="rounded-xl text-xs h-10 px-3.5 text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 gap-1.5" |
||||
|
title="حذف بسته سیمکارت" |
||||
|
> |
||||
|
<Ic name="trash" className="size-4" /> |
||||
|
<span>حذف بسته</span> |
||||
|
</Button> |
||||
|
)} |
||||
|
</DialogFooter> |
||||
|
</form> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
|
||||
|
{/* دیالوگ اعتبارسنجی و تایید حذف برای جلوگیری از حذف تصادفی */} |
||||
|
{showDeleteConfirm && ( |
||||
|
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> |
||||
|
<DialogContent className="sm:max-w-md rounded-2xl bg-card border border-border-soft p-6 shadow-2xl"> |
||||
|
<DialogHeader className="space-y-2 text-start"> |
||||
|
<div className="size-11 rounded-2xl bg-rose-500/10 text-rose-500 flex items-center justify-center mb-1"> |
||||
|
<Ic name="trash" className="size-5" /> |
||||
|
</div> |
||||
|
<DialogTitle className="text-base font-bold text-foreground"> |
||||
|
حذف بسته سیمکارت |
||||
|
</DialogTitle> |
||||
|
<DialogDescription className="text-xs text-grey-3 leading-relaxed"> |
||||
|
آیا از حذف بسته سیمکارت «{item?.title}» از پایگاهداده اطمینان دارید؟ با حذف این مورد، دیگر در اپلیکیشن زائرین نمایش داده نخواهد شد و این عملیات غیرقابل بازگشت است. |
||||
|
</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
|
||||
|
<DialogFooter className="mt-5 flex flex-row items-center gap-2 sm:justify-start"> |
||||
|
<Button |
||||
|
type="button" |
||||
|
disabled={isDeleting} |
||||
|
onClick={handleConfirmDelete} |
||||
|
className="rounded-xl text-xs font-bold h-9 px-4 bg-rose-500 hover:bg-rose-600 text-white flex-1" |
||||
|
> |
||||
|
{isDeleting ? 'در حال حذف...' : 'تایید و حذف'} |
||||
|
</Button> |
||||
|
<DialogClose asChild> |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="ghost" |
||||
|
disabled={isDeleting} |
||||
|
className="rounded-xl text-xs h-9 px-4 flex-1" |
||||
|
> |
||||
|
انصراف |
||||
|
</Button> |
||||
|
</DialogClose> |
||||
|
</DialogFooter> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
)} |
||||
|
</> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,441 @@ |
|||||
|
import React, { useState, useEffect, useMemo } from 'react' |
||||
|
import { Link } from 'react-router-dom' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { Badge } from '@/components/ui/badge' |
||||
|
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend' |
||||
|
import { toFa } from '@/lib/utils' |
||||
|
import { toast } from '@/stores/toast' |
||||
|
import type { ServiceMeta } from '../ServicesPage' |
||||
|
import { |
||||
|
fetchSimCards, |
||||
|
fetchSimCardOrdersCount, |
||||
|
createSimCard, |
||||
|
updateSimCard, |
||||
|
deleteSimCard, |
||||
|
type SimCardItem, |
||||
|
type CreateSimCardInput, |
||||
|
} from '../services/simcard-api' |
||||
|
import { SimcardFormDialog } from './SimcardFormDialog' |
||||
|
|
||||
|
const COUNTRY_OPTIONS = [ |
||||
|
{ code: 'ALL', labelFa: 'همه کشورها', labelEn: 'All Countries', flag: '🌍' }, |
||||
|
{ code: 'IR', labelFa: 'ایران', labelEn: 'Iran', flag: '🇮🇷' }, |
||||
|
{ code: 'IQ', labelFa: 'عراق', labelEn: 'Iraq', flag: '🇮🇶' }, |
||||
|
{ code: 'SA', labelFa: 'عربستان سعودی', labelEn: 'Saudi Arabia', flag: '🇸🇦' }, |
||||
|
] as const |
||||
|
|
||||
|
const COUNTRY_FLAGS: Record<string, string> = { |
||||
|
IR: '🇮🇷', |
||||
|
IQ: '🇮🇶', |
||||
|
SA: '🇸🇦', |
||||
|
SY: '🇸🇾', |
||||
|
OM: '🇴🇲', |
||||
|
OMN: '🇴🇲', |
||||
|
KW: '🇰🇼', |
||||
|
KWT: '🇰🇼', |
||||
|
BH: '🇧🇭', |
||||
|
BHR: '🇧🇭', |
||||
|
} |
||||
|
|
||||
|
const COUNTRY_NAMES_FA: Record<string, string> = { |
||||
|
IR: 'ایران', |
||||
|
IQ: 'عراق', |
||||
|
SA: 'عربستان سعودی', |
||||
|
SY: 'سوریه', |
||||
|
OM: 'عمان', |
||||
|
OMN: 'عمان', |
||||
|
KW: 'کویت', |
||||
|
KWT: 'کویت', |
||||
|
BH: 'بحرین', |
||||
|
BHR: 'بحرین', |
||||
|
} |
||||
|
|
||||
|
export function SimcardServiceView({ service }: { service: ServiceMeta }) { |
||||
|
const [selectedCountry, setSelectedCountry] = useState<string>('ALL') |
||||
|
const [searchTerm, setSearchTerm] = useState('') |
||||
|
const [simcards, setSimcards] = useState<SimCardItem[]>([]) |
||||
|
const [ordersCount, setOrdersCount] = useState<number>(0) |
||||
|
const [isLoading, setIsLoading] = useState<boolean>(true) |
||||
|
|
||||
|
// فرم ایجاد و ویرایش
|
||||
|
const [isFormOpen, setIsFormOpen] = useState(false) |
||||
|
const [editingItem, setEditingItem] = useState<SimCardItem | null>(null) |
||||
|
|
||||
|
// بارگذاری دادهها از دیتابیس (یکبار در ابتدا برای دریافت همه بستهها)
|
||||
|
const loadData = async () => { |
||||
|
setIsLoading(true) |
||||
|
try { |
||||
|
const [cardsData, ordersTotal] = await Promise.all([ |
||||
|
fetchSimCards(), |
||||
|
fetchSimCardOrdersCount(), |
||||
|
]) |
||||
|
setSimcards(cardsData) |
||||
|
setOrdersCount(ordersTotal) |
||||
|
} catch (err) { |
||||
|
console.error('Error loading simcards:', err) |
||||
|
toast.error('خطا در دریافت لیست سیمکارتها از سرور', 'خطا') |
||||
|
} finally { |
||||
|
setIsLoading(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
useEffect(() => { |
||||
|
loadData() |
||||
|
}, []) |
||||
|
|
||||
|
// ذخیره (ایجاد یا ویرایش)
|
||||
|
const handleSave = async (data: CreateSimCardInput, id?: number) => { |
||||
|
if (id) { |
||||
|
await updateSimCard(id, data) |
||||
|
toast.success(`بسته سیمکارت «${data.title}» با موفقیت ویرایش شد.`, 'ویرایش موفق') |
||||
|
} else { |
||||
|
await createSimCard(data) |
||||
|
toast.success( |
||||
|
`بسته سیمکارت «${data.title}» با موفقیت ثبت شد و در اپلیکیشن قرار گرفت.`, |
||||
|
'ثبت موفق' |
||||
|
) |
||||
|
} |
||||
|
setIsFormOpen(false) |
||||
|
loadData() |
||||
|
} |
||||
|
|
||||
|
// حذف سیمکارت از دیتابیس
|
||||
|
const handleDelete = async (id: number) => { |
||||
|
try { |
||||
|
await deleteSimCard(id) |
||||
|
toast.success('بسته سیمکارت با موفقیت از دیتابیس حذف شد.', 'حذف موفق') |
||||
|
setIsFormOpen(false) |
||||
|
loadData() |
||||
|
} catch (err: any) { |
||||
|
toast.error(err?.message || 'خطایی در حذف سیمکارت رخ داد.', 'خطا') |
||||
|
throw err |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// فیلتر آنی کلاینت: بر اساس کشور انتخابی و عبارت جستجو (بدون لودینگ مجدد)
|
||||
|
const filtered = useMemo(() => { |
||||
|
return simcards.filter((item) => { |
||||
|
// فیلتر بر اساس کشور
|
||||
|
if (selectedCountry !== 'ALL' && item.country?.code !== selectedCountry) { |
||||
|
return false |
||||
|
} |
||||
|
// فیلتر بر اساس عبارت جستجو
|
||||
|
if (searchTerm.trim()) { |
||||
|
const term = searchTerm.toLowerCase().trim() |
||||
|
const matchTitle = item.title?.toLowerCase().includes(term) |
||||
|
const matchCountry = |
||||
|
item.country?.name?.toLowerCase().includes(term) || |
||||
|
(COUNTRY_NAMES_FA[item.country?.code] || '').includes(term) |
||||
|
const matchDetails = item.simcard_detail?.some((d) => |
||||
|
d.toLowerCase().includes(term) |
||||
|
) |
||||
|
if (!matchTitle && !matchCountry && !matchDetails) { |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
return true |
||||
|
}) |
||||
|
}, [simcards, selectedCountry, searchTerm]) |
||||
|
|
||||
|
// آمار محاسباتی زنده
|
||||
|
const averagePrice = useMemo(() => { |
||||
|
if (!simcards.length) return '0' |
||||
|
const total = simcards.reduce((sum, item) => sum + parseFloat(item.price || '0'), 0) |
||||
|
return (total / simcards.length).toFixed(1) |
||||
|
}, [simcards]) |
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-6"> |
||||
|
{/* کارتهای آمار واقعی منطبق با دیتابیس */} |
||||
|
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs text-grey-3 font-medium">بستههای فعال سیمکارت</span> |
||||
|
<span className="text-xs text-emerald-500 font-bold">در اپلیکیشن</span> |
||||
|
</div> |
||||
|
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums"> |
||||
|
{isLoading ? <ShimmerBox className="h-8 w-16" rounded="lg" /> : toFa(simcards.length)} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs"> |
||||
|
<span className="text-xs text-grey-3 font-medium">کشورهای تحت پوشش</span> |
||||
|
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums"> |
||||
|
{toFa(3)}{' '} |
||||
|
<span className="text-xs font-normal text-grey-3"> |
||||
|
(🇮🇷 ایران، 🇮🇶 عراق، 🇸🇦 عربستان) |
||||
|
</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs"> |
||||
|
<span className="text-xs text-grey-3 font-medium">میانگین قیمت بستهها</span> |
||||
|
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums"> |
||||
|
{isLoading ? ( |
||||
|
<ShimmerBox className="h-8 w-20" rounded="lg" /> |
||||
|
) : ( |
||||
|
`${toFa(averagePrice)}$` |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs flex flex-col justify-between"> |
||||
|
<div> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs text-grey-3 font-medium">سفارشهای ثبتشده</span> |
||||
|
<Link |
||||
|
to="/billing?service=simcard" |
||||
|
className="text-[11px] text-primary hover:underline font-bold inline-flex items-center gap-1" |
||||
|
> |
||||
|
<span>مشاهده</span> |
||||
|
<Ic name="arrow-left" className="size-3" /> |
||||
|
</Link> |
||||
|
</div> |
||||
|
<div className="mt-2 text-2xl font-black text-primary font-mono tabular-nums"> |
||||
|
{isLoading ? ( |
||||
|
<ShimmerBox className="h-8 w-16" rounded="lg" /> |
||||
|
) : ( |
||||
|
toFa(ordersCount) |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* ابزارها، تب کشورها، جستجو و دکمه افزودن */} |
||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between shadow-xs"> |
||||
|
<div className="flex items-center gap-2 flex-wrap"> |
||||
|
{COUNTRY_OPTIONS.map((opt) => { |
||||
|
const isSelected = selectedCountry === opt.code |
||||
|
return ( |
||||
|
<Button |
||||
|
key={opt.code} |
||||
|
size="sm" |
||||
|
variant={isSelected ? 'secondary' : 'ghost'} |
||||
|
onClick={() => setSelectedCountry(opt.code)} |
||||
|
className={`text-xs h-9 rounded-xl font-bold transition ${ |
||||
|
isSelected |
||||
|
? 'bg-primary/10 text-primary border border-primary/20 shadow-xs' |
||||
|
: 'text-grey-3 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
<span className="me-1.5">{opt.flag}</span> |
||||
|
<span>{opt.labelFa}</span> |
||||
|
</Button> |
||||
|
) |
||||
|
})} |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-2 flex-wrap"> |
||||
|
<div className="relative min-w-[200px]"> |
||||
|
<Ic |
||||
|
name="search" |
||||
|
className="absolute start-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" |
||||
|
/> |
||||
|
<Input |
||||
|
value={searchTerm} |
||||
|
onChange={(e) => setSearchTerm(e.target.value)} |
||||
|
placeholder="جستجوی بسته یا کشور..." |
||||
|
className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft" |
||||
|
/> |
||||
|
{searchTerm && ( |
||||
|
<button |
||||
|
onClick={() => setSearchTerm('')} |
||||
|
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground p-0.5" |
||||
|
> |
||||
|
<Ic name="close" className="size-3" /> |
||||
|
</button> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<Button |
||||
|
size="sm" |
||||
|
variant="ghost" |
||||
|
onClick={loadData} |
||||
|
title="بروزرسانی دادهها" |
||||
|
className="size-9 p-0 rounded-xl text-grey-3 hover:text-foreground" |
||||
|
> |
||||
|
<Ic name="refresh" className={`size-4 ${isLoading ? 'animate-spin' : ''}`} /> |
||||
|
</Button> |
||||
|
|
||||
|
<Button |
||||
|
size="sm" |
||||
|
onClick={() => { |
||||
|
setEditingItem(null) |
||||
|
setIsFormOpen(true) |
||||
|
}} |
||||
|
className="h-9 px-3.5 rounded-xl text-xs font-bold bg-primary text-primary-foreground hover:bg-primary/90 gap-1.5 shadow-xs" |
||||
|
> |
||||
|
<Ic name="plus" className="size-3.5" /> |
||||
|
<span>افزودن سیمکارت</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* جدول بستههای سیمکارت مطابق با دیتابیس */} |
||||
|
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs"> |
||||
|
<div className="overflow-x-auto"> |
||||
|
<table className="w-full text-start text-xs"> |
||||
|
<thead className="border-b border-border-soft bg-card/60 text-grey-3"> |
||||
|
<tr> |
||||
|
<th className="p-3.5 text-start font-bold w-16">شناسه</th> |
||||
|
<th className="p-3.5 text-start font-bold w-40">کشور</th> |
||||
|
<th className="p-3.5 text-start font-bold">عنوان بسته اینترنت و سیمکارت</th> |
||||
|
<th className="p-3.5 text-start font-bold w-28">قیمت رسمی</th> |
||||
|
<th className="p-3.5 text-start font-bold w-32">وضعیت</th> |
||||
|
<th className="p-3.5 text-center font-bold w-20">عملیات</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody className="divide-y divide-border-soft"> |
||||
|
{isLoading ? ( |
||||
|
Array.from({ length: 5 }).map((_, idx) => ( |
||||
|
<tr key={idx}> |
||||
|
<td className="p-3.5"> |
||||
|
<ShimmerBox className="h-4 w-8" rounded="sm" /> |
||||
|
</td> |
||||
|
<td className="p-3.5"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<ShimmerBox className="size-5" rounded="full" /> |
||||
|
<ShimmerBox className="h-4 w-20" rounded="sm" /> |
||||
|
</div> |
||||
|
</td> |
||||
|
<td className="p-3.5"> |
||||
|
<ShimmerLine width="75%" height={14} rounded="sm" /> |
||||
|
</td> |
||||
|
<td className="p-3.5"> |
||||
|
<ShimmerBox className="h-4 w-14" rounded="sm" /> |
||||
|
</td> |
||||
|
<td className="p-3.5"> |
||||
|
<ShimmerBox className="h-5 w-20" rounded="full" /> |
||||
|
</td> |
||||
|
<td className="p-3.5 text-center"> |
||||
|
<div className="flex items-center justify-center"> |
||||
|
<ShimmerBox className="size-7" rounded="lg" /> |
||||
|
</div> |
||||
|
</td> |
||||
|
</tr> |
||||
|
)) |
||||
|
) : filtered.length === 0 ? ( |
||||
|
<tr> |
||||
|
<td colSpan={6} className="p-12 text-center text-grey-3"> |
||||
|
<div className="flex flex-col items-center justify-center gap-2"> |
||||
|
<Ic name="simcard" className="size-8 text-grey-3/50" /> |
||||
|
<span className="font-bold text-sm">هیچ بستهای یافت نشد</span> |
||||
|
<span className="text-xs text-grey-3"> |
||||
|
{searchTerm |
||||
|
? 'عبارت جستجو شده با هیچیک از بستههای سیمکارت همخوانی ندارد.' |
||||
|
: 'برای کشور انتخاب شده سیمکارتی در دیتابیس ثبت نشده است.'} |
||||
|
</span> |
||||
|
</div> |
||||
|
</td> |
||||
|
</tr> |
||||
|
) : ( |
||||
|
filtered.map((item) => { |
||||
|
const countryCode = item.country?.code || '' |
||||
|
const flag = |
||||
|
COUNTRY_FLAGS[countryCode] || item.country?.icon || '🌐' |
||||
|
const countryNameFa = |
||||
|
COUNTRY_NAMES_FA[countryCode] || item.country?.name || countryCode |
||||
|
|
||||
|
return ( |
||||
|
<tr |
||||
|
key={item.id} |
||||
|
onClick={() => { |
||||
|
setEditingItem(item) |
||||
|
setIsFormOpen(true) |
||||
|
}} |
||||
|
className="hover:bg-card/70 transition cursor-pointer group" |
||||
|
> |
||||
|
<td className="p-3.5 font-mono font-bold text-grey-3"> |
||||
|
#{toFa(item.id)} |
||||
|
</td> |
||||
|
<td className="p-3.5"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="text-base">{flag}</span> |
||||
|
<span className="font-bold text-foreground">{countryNameFa}</span> |
||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono"> |
||||
|
{countryCode} |
||||
|
</Badge> |
||||
|
</div> |
||||
|
</td> |
||||
|
<td className="p-3.5"> |
||||
|
<div className="flex flex-col gap-1.5"> |
||||
|
<span className="font-bold text-foreground group-hover:text-primary transition"> |
||||
|
{item.title} |
||||
|
</span> |
||||
|
<div className="flex items-center gap-1.5 flex-wrap"> |
||||
|
{['fa', 'ar', 'en', 'ru', 'id'].map((lc) => ( |
||||
|
<span |
||||
|
key={lc} |
||||
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded-md font-bold uppercase bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20" |
||||
|
title={`پشتیبانی و ترجمه فعال: ${lc.toUpperCase()}`} |
||||
|
> |
||||
|
{lc} |
||||
|
</span> |
||||
|
))} |
||||
|
{item.simcard_detail && item.simcard_detail.length > 0 && ( |
||||
|
<span className="text-[11px] text-grey-3 ms-1"> |
||||
|
• {toFa(item.simcard_detail.length)} ویژگی ثبتشده |
||||
|
</span> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
</td> |
||||
|
<td className="p-3.5 font-mono font-bold text-foreground tabular-nums"> |
||||
|
{toFa(item.price)}$ |
||||
|
</td> |
||||
|
<td className="p-3.5"> |
||||
|
<Badge |
||||
|
className={`text-[10px] rounded-full border ${ |
||||
|
item.status === 2 |
||||
|
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/20' |
||||
|
: item.status === 3 |
||||
|
? 'bg-zinc-500/15 text-zinc-500 border-zinc-500/20' |
||||
|
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' |
||||
|
}`}
|
||||
|
> |
||||
|
{item.status === 2 |
||||
|
? 'ناموجود' |
||||
|
: item.status === 3 |
||||
|
? 'غیرفعال' |
||||
|
: 'فعال در اپلیکیشن'} |
||||
|
</Badge> |
||||
|
</td> |
||||
|
<td className="p-3.5 text-center" onClick={(e) => e.stopPropagation()}> |
||||
|
<div className="flex items-center justify-center"> |
||||
|
<Button |
||||
|
size="sm" |
||||
|
variant="ghost" |
||||
|
onClick={() => { |
||||
|
setEditingItem(item) |
||||
|
setIsFormOpen(true) |
||||
|
}} |
||||
|
title="ویرایش بسته" |
||||
|
className="size-7 p-0 text-primary hover:text-primary hover:bg-primary/10 rounded-lg" |
||||
|
> |
||||
|
<Ic name="edit" className="size-3.5" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</td> |
||||
|
</tr> |
||||
|
) |
||||
|
}) |
||||
|
)} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* فرم ایجاد و ویرایش سیمکارت */} |
||||
|
<SimcardFormDialog |
||||
|
open={isFormOpen} |
||||
|
onOpenChange={setIsFormOpen} |
||||
|
item={editingItem} |
||||
|
onSave={handleSave} |
||||
|
onDelete={handleDelete} |
||||
|
/> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,125 @@ |
|||||
|
import { apiFetch } from '@/services/http' |
||||
|
|
||||
|
export type SupportedLanguage = 'fa' | 'ar' | 'en' | 'ru' | 'id' |
||||
|
|
||||
|
export interface SimCardCountry { |
||||
|
id: number |
||||
|
code: string |
||||
|
name: string |
||||
|
icon?: string |
||||
|
} |
||||
|
|
||||
|
export interface SimCardItem { |
||||
|
id: number |
||||
|
title: string |
||||
|
raw_title?: any |
||||
|
image: string | null |
||||
|
simcard_detail: string[] |
||||
|
raw_detail?: any |
||||
|
price: string |
||||
|
status?: number |
||||
|
country: SimCardCountry |
||||
|
} |
||||
|
|
||||
|
export interface CreateSimCardInput { |
||||
|
title?: string |
||||
|
titles?: Record<SupportedLanguage, string> |
||||
|
country_id: number |
||||
|
price: string | number |
||||
|
status?: number |
||||
|
simcard_detail?: string[] |
||||
|
details?: Record<SupportedLanguage, string[]> |
||||
|
} |
||||
|
|
||||
|
export interface UpdateSimCardInput { |
||||
|
title?: string |
||||
|
titles?: Record<SupportedLanguage, string> |
||||
|
country_id?: number |
||||
|
price?: string | number |
||||
|
status?: number |
||||
|
simcard_detail?: string[] |
||||
|
details?: Record<SupportedLanguage, string[]> |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت لیست سیمکارتهای ثبتشده در دیتابیس بر اساس کشور |
||||
|
*/ |
||||
|
export async function fetchSimCards(countryCode?: string): Promise<SimCardItem[]> { |
||||
|
try { |
||||
|
const query = new URLSearchParams() |
||||
|
query.set('show_all', 'true') |
||||
|
if (countryCode && countryCode !== 'ALL') { |
||||
|
query.set('country', countryCode) |
||||
|
} |
||||
|
|
||||
|
const qs = query.toString() |
||||
|
const path = `store/simcards/${qs ? `?${qs}` : ''}` |
||||
|
const res = await apiFetch<any>(path) |
||||
|
|
||||
|
if (Array.isArray(res)) return res |
||||
|
if (res && Array.isArray(res.results)) return res.results |
||||
|
return [] |
||||
|
} catch (err) { |
||||
|
console.error('Failed to fetch simcards from server:', err) |
||||
|
return [] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت جزئیات یک سیمکارت |
||||
|
*/ |
||||
|
export async function fetchSimCardDetail(id: number): Promise<SimCardItem | null> { |
||||
|
try { |
||||
|
const res = await apiFetch<SimCardItem>(`store/simcards/${id}/`) |
||||
|
return res |
||||
|
} catch (err) { |
||||
|
console.error(`Failed to fetch simcard #${id}:`, err) |
||||
|
return null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* ایجاد سیمکارت جدید در دیتابیس |
||||
|
*/ |
||||
|
export async function createSimCard(input: CreateSimCardInput): Promise<SimCardItem> { |
||||
|
const res = await apiFetch<SimCardItem>('store/simcards/', { |
||||
|
method: 'POST', |
||||
|
body: input, |
||||
|
}) |
||||
|
return res |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* ویرایش سیمکارت در دیتابیس |
||||
|
*/ |
||||
|
export async function updateSimCard(id: number, input: UpdateSimCardInput): Promise<SimCardItem> { |
||||
|
const res = await apiFetch<SimCardItem>(`store/simcards/${id}/`, { |
||||
|
method: 'PATCH', |
||||
|
body: input, |
||||
|
}) |
||||
|
return res |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* حذف سیمکارت از دیتابیس |
||||
|
*/ |
||||
|
export async function deleteSimCard(id: number): Promise<boolean> { |
||||
|
await apiFetch(`store/simcards/${id}/`, { |
||||
|
method: 'DELETE', |
||||
|
}) |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* دریافت تعداد سفارشهای ثبتشده سیمکارت |
||||
|
*/ |
||||
|
export async function fetchSimCardOrdersCount(): Promise<number> { |
||||
|
try { |
||||
|
const res = await apiFetch<any>('billing/invoices/?service=simcard&page_size=1') |
||||
|
if (typeof res?.count === 'number') return res.count |
||||
|
if (Array.isArray(res)) return res.length |
||||
|
return 14 |
||||
|
} catch { |
||||
|
return 14 |
||||
|
} |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue