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
-
422src/features/services/components/CreateLocationDialog.tsx
-
14src/features/services/components/FaqServiceView.tsx
-
48src/features/services/components/LocationDetailSheet.tsx
-
669src/features/services/components/SimcardFormDialog.tsx
-
441src/features/services/components/SimcardServiceView.tsx
-
9src/features/services/components/StoreServiceView.tsx
-
159src/features/services/components/ZayerGuideServiceView.tsx
-
125src/features/services/services/simcard-api.ts
-
28src/features/services/services/trip-etiquette-api.ts
-
18src/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