Browse Source

feat: add comprehensive billing page and supporting UI components, services, and localizations

master
parent
commit
2278b07519
  1. 8
      scripts/generate-locales.js
  2. 8
      src/app/providers.tsx
  3. 4
      src/components/layout/PanelSlot.tsx
  4. 163
      src/components/shared/ServiceTranslation/ServiceTranslation.tsx
  5. 2
      src/components/shared/ServiceTranslation/index.ts
  6. 74
      src/components/shared/ServiceTranslation/types.ts
  7. 2
      src/components/ui/card.tsx
  8. 11
      src/components/ui/dialog.tsx
  9. 162
      src/components/ui/sidebar.tsx
  10. 2
      src/components/ui/table.tsx
  11. 3
      src/features/billing/BillingPage.tsx
  12. 155
      src/features/billing/components/BillingDetailModal.tsx
  13. 9
      src/features/billing/components/BillingStatusBadge.tsx
  14. 11
      src/features/billing/services/billing-api.ts
  15. 1
      src/features/billing/types.ts
  16. 2
      src/features/services/ServicesPage.tsx
  17. 157
      src/features/services/components/CategoriesServiceView.tsx
  18. 50
      src/features/services/components/CreateLocationDialog.tsx
  19. 10
      src/features/services/components/DuasServiceView.tsx
  20. 91
      src/features/services/components/FaqServiceView.tsx
  21. 2
      src/features/services/components/ShrinesServiceView.tsx
  22. 66
      src/features/services/components/SimcardFormDialog.tsx
  23. 808
      src/features/services/components/StoreServiceView.tsx
  24. 81
      src/features/services/services/store-api.ts
  25. 21
      src/features/services/types/store.ts
  26. 2
      src/features/tours/TourOrdersPage.tsx
  27. 53
      src/features/tours/components/CreateTourDialog.tsx
  28. 88
      src/features/tours/components/TourDetailDialog.tsx
  29. 50
      src/features/tours/components/TourTranslationsDialog.tsx
  30. 7
      src/features/users/components/UserFactorsTab.tsx
  31. 2
      src/features/users/types.ts
  32. 11
      src/index.css
  33. 12
      src/locales/ar.json
  34. 12
      src/locales/en.json
  35. 12
      src/locales/fa.json

8
scripts/generate-locales.js

@ -583,7 +583,7 @@ export const arLocale = {
duasList: 'الأدعية والزيارات',
categories: 'التصنيفات',
cities: 'المدن المقدسة',
store: 'متجر معصومة (Store)',
store: 'متجر نصرة العقيلة (Store)',
storeProducts: 'المنتجات',
storeCategories: 'التصنيفات',
costEstimate: 'تقدير التكلفة (Cost Estimate)',
@ -767,8 +767,8 @@ export const arLocale = {
categoriesDesc: 'إدارة التصنيفات الموضوعية للخدمات والأماكن والمتجر ومحتويات التطبيق',
citiesTitle: 'المدن المقدسة',
citiesDesc: 'المدن المقدسة (النجف، كربلاء، الكاظمية، سامراء، مشهد، قم، مكة، المدينة)',
storeTitle: 'متجر معصومة',
storeDesc: 'منتجات متجر معصومة الإلكتروني وهدايا الزيارة وإدارة المخزون وطلبات الشراء',
storeTitle: 'متجر نصرة العقيلة',
storeDesc: 'منتجات متجر نصرة العقيلة الإلكتروني وهدايا الزيارة وإدارة المخزون وطلبات الشراء',
costEstimateTitle: 'تقدير التكلفة',
costEstimateDesc: 'حساب تكاليف الرحلات الفردية والعائلية حسب الموسم والخدمات ومستوى الفندق',
supportTitle: 'الدعم الفني',
@ -817,7 +817,7 @@ export const arLocale = {
noReceiptUploaded: 'لم يتم رفع إيصال',
serviceTour: 'رحلة زيارة',
serviceSimcard: 'شريحة اتصال',
serviceShop: 'متجر معصومة',
serviceShop: 'متجر نصرة العقيلة',
serviceExchange: 'صرافة (تصريف)',
serviceCustomTrip: 'رحلة مخصصة',
allStatuses: 'جميع الحالات',

8
src/app/providers.tsx

@ -6,12 +6,16 @@ import { queryClient } from '@/lib/query-client'
import { TooltipProvider } from '@/components/ui/tooltip'
import { ToastContainer } from '@/components/ui/toast-container'
import { useLanguageStore } from '@/i18n'
/* پروایدرهای سراسری: React Query + RTL + Tooltips + Router + Toasts */
/* پروایدرهای سراسری: React Query + RTL/LTR چندزبانه + Tooltips + Router + Toasts */
export default function Providers({ children }: { children: React.ReactNode }) {
const language = useLanguageStore((s) => s.language)
const dir = language === 'en' ? 'ltr' : 'rtl'
return (
<QueryClientProvider client={queryClient}>
<DirectionProvider dir="rtl">
<DirectionProvider dir={dir}>
<TooltipProvider delayDuration={200}>
<BrowserRouter>{children}</BrowserRouter>
<ToastContainer />

4
src/components/layout/PanelSlot.tsx

@ -97,6 +97,8 @@ export default function PanelSlot() {
isMobile: false,
open: isPanelOpen,
state: isPanelOpen ? 'expanded' : 'collapsed',
handleSidebarMouseEnter: () => {},
handleSidebarMouseLeave: () => {},
} as typeof sidebar
return (
@ -105,7 +107,7 @@ export default function PanelSlot() {
className="contents"
style={{ '--sidebar-width': 'var(--panel-width)', '--sidebar': '#26272c' } as CSSProperties}
>
<Sidebar side="left" collapsible="offcanvas">
<Sidebar side="left" collapsible="offcanvas" autoCloseOnLeave={false}>
{content}
</Sidebar>
</div>

163
src/components/shared/ServiceTranslation/ServiceTranslation.tsx

@ -0,0 +1,163 @@
import React from 'react'
import { Ic } from '@/icons'
import {
SERVICE_LANGUAGES,
type ServiceLanguageCode,
type ServiceLanguageMeta,
type ServiceTranslationProps,
} from './types'
export function ServiceTranslation({
activeLang,
onLangChange,
completedLangs,
dirtyLangs,
primaryLang,
title = 'زبان ترجمه و محتوا',
showDirectionBadge = true,
showGlobeIcon = true,
required = false,
className = '',
variant = 'grid',
children,
}: ServiceTranslationProps) {
const currentLang =
SERVICE_LANGUAGES.find((l) => l.code === activeLang) || SERVICE_LANGUAGES[0]
const isCompleted = (code: ServiceLanguageCode): boolean => {
if (!completedLangs) return false
if (Array.isArray(completedLangs)) {
return completedLangs.includes(code)
}
return Boolean(completedLangs[code])
}
const isDirty = (code: ServiceLanguageCode): boolean => {
if (!dirtyLangs) return false
if (Array.isArray(dirtyLangs)) {
return dirtyLangs.includes(code)
}
return Boolean(dirtyLangs[code])
}
return (
<div className={`space-y-3 ${className}`}>
{/* هدر انتخاب زبان و نمایش دایرکشن */}
<div className="flex items-center justify-between flex-wrap gap-2">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
{showGlobeIcon && <Ic name="globe" className="size-3.5 text-primary" />}
<span>{title}</span>
{required && <span className="text-rose-500">*</span>}
</label>
{showDirectionBadge && (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground font-mono">
<span>جهت متن:</span>
<span
className={`px-1.5 py-0.5 rounded-md font-bold text-[10px] uppercase border ${
currentLang.dir === 'rtl'
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'
: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20'
}`}
>
{currentLang.dir.toUpperCase()}
</span>
</div>
)}
</div>
{/* تب‌های انتخاب زبان */}
{variant === 'pills' ? (
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar p-1 rounded-xl bg-surface-base border border-border-soft">
{SERVICE_LANGUAGES.map((lang) => {
const isSelected = activeLang === lang.code
const hasData = isCompleted(lang.code)
const dirty = isDirty(lang.code)
const isBase = primaryLang ? primaryLang === lang.code : lang.code === 'ar'
return (
<button
key={lang.code}
type="button"
onClick={() => onLangChange(lang.code)}
className={`relative px-3 py-1.5 text-xs rounded-lg transition-colors font-medium flex items-center gap-1.5 shrink-0 cursor-pointer ${
isSelected
? 'bg-primary text-white shadow-xs font-bold'
: hasData
? 'bg-card text-foreground hover:bg-card/80 border border-border-soft'
: 'text-muted-foreground hover:text-foreground hover:bg-card/50'
}`}
>
<span className="text-sm leading-none">{lang.flag}</span>
<span>{lang.name}</span>
{isBase && !isSelected && (
<span className="text-[9px] px-1 rounded bg-muted text-muted-foreground font-normal">
پیش‌فرض
</span>
)}
{hasData && (
<span
className={`size-1.5 rounded-full shrink-0 ${
isSelected ? 'bg-white' : 'bg-emerald-500'
}`}
/>
)}
{dirty && (
<span className="size-1.5 rounded-full bg-amber-400 animate-pulse shrink-0" />
)}
</button>
)
})}
</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">
{SERVICE_LANGUAGES.map((lang) => {
const isSelected = activeLang === lang.code
const hasData = isCompleted(lang.code)
const dirty = isDirty(lang.code)
const isBase = primaryLang ? primaryLang === lang.code : lang.code === 'ar'
return (
<button
key={lang.code}
type="button"
onClick={() => onLangChange(lang.code)}
className={`h-11 px-3 rounded-xl text-xs font-bold transition flex items-center justify-center gap-2 relative cursor-pointer ${
isSelected
? 'bg-primary text-primary-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground hover:bg-card border border-transparent hover:border-border-soft'
}`}
>
<span className="text-base">{lang.flag}</span>
<span className="truncate">{lang.name}</span>
{isBase && (
<span
className={`text-[9px] px-1 py-0.2 rounded font-normal shrink-0 ${
isSelected ? 'bg-white/20 text-white' : 'bg-muted text-muted-foreground'
}`}
>
پایه
</span>
)}
{hasData && (
<span
className={`size-2 rounded-full shrink-0 ${
isSelected ? 'bg-white' : 'bg-emerald-500'
}`}
title="دارای محتوای ترجمه‌شده"
/>
)}
{dirty && (
<span className="size-2 rounded-full bg-amber-400 animate-pulse shrink-0" />
)}
</button>
)
})}
</div>
)}
{/* اگر کامپوننت حاوی فرزند بود، اینجا قرار می‌گیرد */}
{children}
</div>
)
}

2
src/components/shared/ServiceTranslation/index.ts

@ -0,0 +1,2 @@
export * from './types'
export * from './ServiceTranslation'

74
src/components/shared/ServiceTranslation/types.ts

@ -0,0 +1,74 @@
export type ServiceLanguageCode = 'ar' | 'fa' | 'en' | 'ru' | 'id'
export interface ServiceLanguageMeta {
code: ServiceLanguageCode
name: string
nativeName: string
flag: string
badge: string
dir: 'rtl' | 'ltr'
label: string
}
export const SERVICE_LANGUAGES: readonly ServiceLanguageMeta[] = [
{
code: 'ar',
name: 'العربية',
nativeName: 'العربية',
flag: '🇸🇦',
badge: 'AR',
dir: 'rtl',
label: 'العربية (پیش‌فرض)',
},
{
code: 'fa',
name: 'فارسی',
nativeName: 'فارسی',
flag: '🇮🇷',
badge: 'FA',
dir: 'rtl',
label: 'فارسی',
},
{
code: 'en',
name: 'English',
nativeName: 'English',
flag: '🇬🇧',
badge: 'EN',
dir: 'ltr',
label: 'انگلیسی',
},
{
code: 'ru',
name: 'Русский',
nativeName: 'Русский',
flag: '🇷🇺',
badge: 'RU',
dir: 'ltr',
label: 'روسی',
},
{
code: 'id',
name: 'Bahasa Indonesia',
nativeName: 'Indonesia',
flag: '🇮🇩',
badge: 'ID',
dir: 'ltr',
label: 'اندونزیایی',
},
] as const
export interface ServiceTranslationProps {
activeLang: ServiceLanguageCode
onLangChange: (lang: ServiceLanguageCode) => void
completedLangs?: Partial<Record<ServiceLanguageCode, boolean>> | ServiceLanguageCode[]
dirtyLangs?: Partial<Record<ServiceLanguageCode, boolean>> | ServiceLanguageCode[]
primaryLang?: ServiceLanguageCode
title?: string
showDirectionBadge?: boolean
showGlobeIcon?: boolean
required?: boolean
className?: string
variant?: 'grid' | 'pills'
children?: React.ReactNode
}

2
src/components/ui/card.tsx

@ -34,7 +34,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-base font-bold leading-none tracking-tight', className)}
className={cn('text-base font-bold leading-none', className)}
{...props}
/>
))

11
src/components/ui/dialog.tsx

@ -19,12 +19,14 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
export interface DialogContentProps extends React.ComponentProps<typeof DialogPrimitive.Content> {
showCloseButton?: boolean
closeButtonClassName?: string
}
function DialogContent({
className,
children,
showCloseButton = true,
closeButtonClassName,
onPointerDownOutside,
onInteractOutside,
...props
@ -52,9 +54,12 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
aria-label="بستن"
className="absolute end-4 top-4 z-20 flex size-7 items-center justify-center rounded-xl text-grey-3 transition-colors hover:bg-muted/80 hover:text-foreground focus:outline-hidden disabled:pointer-events-none cursor-pointer"
className={cn(
'absolute end-3.5 top-3.5 sm:end-4 sm:top-4 z-20 flex size-8 items-center justify-center rounded-xl bg-muted/60 hover:bg-muted text-muted-foreground hover:text-foreground border border-border-soft/80 shadow-2xs transition-all duration-150 active:scale-95 focus:outline-hidden disabled:pointer-events-none cursor-pointer',
closeButtonClassName,
)}
>
<Ic name="x" className="size-4" />
<Ic name="x" className="size-4 shrink-0 transition-transform duration-150" />
<span className="sr-only">بستن</span>
</DialogPrimitive.Close>
)}
@ -69,7 +74,7 @@ const DialogDescription = DialogPrimitive.Description
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col space-y-1.5 text-start pb-3 pe-8 border-b border-border-soft shrink-0', className)}
className={cn('flex flex-col space-y-1.5 text-start pb-3 pe-10 sm:pe-12 border-b border-border-soft shrink-0', className)}
{...props}
/>
)

162
src/components/ui/sidebar.tsx

@ -30,7 +30,13 @@ type SidebarContextProps = {
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
isPinned: boolean
setIsPinned: (pinned: boolean | ((prev: boolean) => boolean)) => void
toggleSidebar: () => void
handleTriggerMouseEnter: () => void
handleTriggerMouseLeave: () => void
handleSidebarMouseEnter: () => void
handleSidebarMouseLeave: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
@ -42,7 +48,7 @@ function useSidebar() {
}
function SidebarProvider({
defaultOpen = true,
defaultOpen = false,
open: openProp,
onOpenChange: setOpenProp,
className,
@ -58,6 +64,9 @@ function SidebarProvider({
const [openMobile, setOpenMobile] = React.useState(false)
const [_open, _setOpen] = React.useState(defaultOpen)
const [isPinned, setIsPinnedState] = React.useState(defaultOpen)
const isPinnedRef = React.useRef(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
@ -69,9 +78,72 @@ function SidebarProvider({
[setOpenProp, open],
)
const setIsPinned = React.useCallback((value: boolean | ((prev: boolean) => boolean)) => {
setIsPinnedState((prev) => {
const next = typeof value === 'function' ? value(prev) : value
isPinnedRef.current = next
return next
})
}, [])
const hoverTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const cancelCloseTimer = React.useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current)
hoverTimeoutRef.current = null
}
}, [])
const scheduleCloseTimer = React.useCallback(() => {
cancelCloseTimer()
hoverTimeoutRef.current = setTimeout(() => {
if (!isPinnedRef.current) {
setOpen(false)
}
}, 300)
}, [cancelCloseTimer, setOpen])
const handleTriggerMouseEnter = React.useCallback(() => {
if (isMobile) return
cancelCloseTimer()
setOpen(true)
}, [isMobile, cancelCloseTimer, setOpen])
const handleTriggerMouseLeave = React.useCallback(() => {
if (isMobile || isPinnedRef.current) return
scheduleCloseTimer()
}, [isMobile, scheduleCloseTimer])
const handleSidebarMouseEnter = React.useCallback(() => {
if (isMobile) return
cancelCloseTimer()
}, [isMobile, cancelCloseTimer])
const handleSidebarMouseLeave = React.useCallback(() => {
if (isMobile || isPinnedRef.current) return
scheduleCloseTimer()
}, [isMobile, scheduleCloseTimer])
React.useEffect(() => {
return () => {
cancelCloseTimer()
}
}, [cancelCloseTimer])
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((o) => !o) : setOpen((o) => !o)
}, [isMobile, setOpen, setOpenMobile])
cancelCloseTimer()
if (isMobile) {
return setOpenMobile((o) => !o)
}
// اگر در وضعیت پین/فیکس بود، با کلیک مجدد بسته شده و به رفتار اولیه (هاور موقت) بازمی‌گردد.
// اگر فیکس نبود (حتی اگر با هاور باز شده باشد)، قفل/فیکس شده و باز می‌ماند.
const nextPinned = !isPinnedRef.current
isPinnedRef.current = nextPinned
setIsPinnedState(nextPinned)
setOpen(nextPinned)
}, [cancelCloseTimer, isMobile, setOpen, setOpenMobile])
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@ -87,8 +159,36 @@ function SidebarProvider({
const state = open ? 'expanded' : 'collapsed'
const contextValue = React.useMemo<SidebarContextProps>(
() => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
() => ({
state,
open,
setOpen,
openMobile,
setOpenMobile,
isMobile,
isPinned,
setIsPinned,
toggleSidebar,
handleTriggerMouseEnter,
handleTriggerMouseLeave,
handleSidebarMouseEnter,
handleSidebarMouseLeave,
}),
[
state,
open,
setOpen,
openMobile,
setOpenMobile,
isMobile,
isPinned,
setIsPinned,
toggleSidebar,
handleTriggerMouseEnter,
handleTriggerMouseLeave,
handleSidebarMouseEnter,
handleSidebarMouseLeave,
],
)
return (
@ -116,13 +216,25 @@ function Sidebar({
collapsible = 'offcanvas',
className,
children,
onMouseEnter,
onMouseLeave,
autoCloseOnLeave = true,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
autoCloseOnLeave?: boolean
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const {
isMobile,
state,
openMobile,
setOpenMobile,
handleSidebarMouseEnter,
handleSidebarMouseLeave,
isPinned,
} = useSidebar()
if (collapsible === 'none') {
return (
@ -165,6 +277,7 @@ function Sidebar({
data-variant={variant}
data-side={side}
data-slot="sidebar"
data-pinned={isPinned}
>
{/* spacer که فاصلهٔ سایدبار را در flow رزرو می‌کند */}
<div
@ -190,6 +303,18 @@ function Sidebar({
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
onMouseEnter={(event) => {
onMouseEnter?.(event)
if (autoCloseOnLeave) {
handleSidebarMouseEnter?.()
}
}}
onMouseLeave={(event) => {
onMouseLeave?.(event)
if (autoCloseOnLeave) {
handleSidebarMouseLeave?.()
}
}}
{...props}
>
<div
@ -204,19 +329,38 @@ function Sidebar({
)
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
function SidebarTrigger({
className,
onClick,
onMouseEnter,
onMouseLeave,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar, handleTriggerMouseEnter, handleTriggerMouseLeave, isPinned } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
data-pinned={isPinned}
variant="ghost"
size="icon"
className={cn('size-9 [&_svg]:size-[18px]', className)}
className={cn(
'size-9 [&_svg]:size-[18px] transition-colors',
isPinned && 'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
className,
)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
onMouseEnter={(event) => {
onMouseEnter?.(event)
handleTriggerMouseEnter?.()
}}
onMouseLeave={(event) => {
onMouseLeave?.(event)
handleTriggerMouseLeave?.()
}}
{...props}
>
<Ic name="list" />

2
src/components/ui/table.tsx

@ -21,7 +21,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
className={cn('h-12 px-4 text-right align-middle text-[12px] font-semibold text-grey-3', className)}
className={cn('h-12 px-4 text-start align-middle text-[12px] font-bold text-grey-3', className)}
{...props}
/>
)

3
src/features/billing/BillingPage.tsx

@ -177,7 +177,7 @@ export default function BillingPage() {
else if (f.status === 'approved') {
approved++
totalApprovedAmount += numVal
} else if (f.status === 'rejected' || f.status === 'expired') {
} else if (f.status === 'rejected' || f.status === 'expired' || f.status === 'cancelled') {
rejectedOrExpired++
}
})
@ -500,6 +500,7 @@ export default function BillingPage() {
{ id: 'approved', label: t('billing.statusApproved') },
{ id: 'rejected', label: t('billing.statusRejected') },
{ id: 'expired', label: t('billing.statusExpired') },
{ id: 'cancelled', label: t('billing.statusCancelled') },
].map((st) => {
const isSelected = selectedStatus === st.id
return (

155
src/features/billing/components/BillingDetailModal.tsx

@ -21,7 +21,7 @@ import {
import { Ic } from '@/icons'
import type { FactorItem, FactorStatus } from '../types'
import { BillingServiceBadge } from './BillingServiceBadge'
import { fetchFactorDetail, reviewFactor } from '../services/billing-api'
import { fetchFactorDetail, reviewFactor, cancelFactor } from '../services/billing-api'
import { ShimmerLine, ShimmerBox } from '@/components/shared/shimmer_frontend'
import { toast } from '@/stores/toast'
@ -45,31 +45,43 @@ const STATUS_OPTIONS: {
value: FactorStatus
label: string
dotClass: string
activeClass: string
}[] = [
{
value: 'awaiting_payment',
label: 'در انتظار پرداخت',
dotClass: 'bg-amber-500',
activeClass: 'bg-amber-500/15 border-amber-500/60 text-amber-700 dark:text-amber-300 ring-amber-500/30',
},
{
value: 'pending',
label: 'در انتظار بررسی فیش',
dotClass: 'bg-blue-500',
activeClass: 'bg-blue-500/15 border-blue-500/60 text-blue-700 dark:text-blue-300 ring-blue-500/30',
},
{
value: 'approved',
label: 'تایید شده / پرداخت شده',
dotClass: 'bg-emerald-500',
activeClass: 'bg-emerald-500/15 border-emerald-500/60 text-emerald-700 dark:text-emerald-300 ring-emerald-500/30',
},
{
value: 'rejected',
label: 'رد شده',
dotClass: 'bg-rose-500',
activeClass: 'bg-rose-500/15 border-rose-500/60 text-rose-700 dark:text-rose-300 ring-rose-500/30',
},
{
value: 'expired',
label: 'منقضی شده',
dotClass: 'bg-slate-400',
activeClass: 'bg-slate-500/15 border-slate-500/60 text-slate-700 dark:text-slate-300 ring-slate-500/30',
},
{
value: 'cancelled',
label: 'کنسل‌شده',
dotClass: 'bg-red-500',
activeClass: 'bg-red-500/15 border-red-500/60 text-red-700 dark:text-red-300 ring-red-500/30',
},
]
@ -169,6 +181,28 @@ export function BillingDetailModal({
}
}
const handleCancelFactor = async () => {
if (!activeFactor) return
if (!window.confirm(t('billing.cancelInvoiceConfirm'))) return
setIsSaving(true)
try {
const updated = await cancelFactor(activeFactor.id)
setCurrentFactor(updated)
setSelectedStatus(updated.status)
setRejectReason(updated.rejected_description || '')
setHasChanges(false)
if (onFactorUpdated) {
onFactorUpdated(updated)
}
toast.success(t('billing.invoiceCancelledSuccess'), 'کنسل کردن فاکتور')
onClose()
} catch (err: any) {
toast.error(err?.message || 'خطا در کنسل کردن صورت‌حساب.', 'خطا در عملیات')
} finally {
setIsSaving(false)
}
}
const detail = activeFactor.detail_service
const user = activeFactor.user_info
@ -192,12 +226,40 @@ export function BillingDetailModal({
</div>
</div>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-2.5 me-8">
<BillingServiceBadge service={activeFactor.service} />
</div>
</div>
</DialogHeader>
{/* دراپ‌داون تعاملی انتخاب وضعیت صورت‌حساب و دکمه ذخیره سریع در هدر */}
<div className="flex items-center gap-2">
<div className="relative">
{/* بدنه محتوا با اسکرول داخلی مجزا */}
<div className="p-5 space-y-4 flex-1 overflow-y-auto">
{/* کادر مهلت پرداخت (تاریخ انقضا) */}
{activeFactor.expiration_date && (
<div className="bg-muted/40 border border-border-soft rounded-xl p-3 flex items-center justify-between text-xs">
<span className="text-foreground/80 font-medium flex items-center gap-1.5">
<Ic name="clock" className="size-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span>مهلت پرداخت (تاریخ انقضا):</span>
</span>
<span className="font-bold text-foreground font-mono">
{formatDateByLanguage(activeFactor.expiration_date, language as any)}
</span>
</div>
)}
{/* بخش استاتوس پرداخت: به صورت دراپ‌داون بولد و تعاملی زیر مهلت پرداخت */}
<div className="bg-card border border-border/80 rounded-2xl p-4 shadow-2xs flex flex-col sm:flex-row sm:items-center justify-between gap-3.5">
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-xl bg-primary/15 text-primary flex items-center justify-center shrink-0">
<Ic name="receipt" className="size-4.5" />
</div>
<div>
<h4 className="text-xs font-black text-foreground">وضعیت پرداخت صورت‌حساب:</h4>
<p className="text-[11px] text-muted-foreground">جهت تغییر وضعیت فاکتور، وضعیت مورد نظر را از دراپ‌داون انتخاب نمایید:</p>
</div>
</div>
<div className="w-full sm:w-64">
<Select
value={selectedStatus}
onValueChange={(val) => {
@ -206,61 +268,41 @@ export function BillingDetailModal({
setHasChanges(newStatus !== activeFactor.status)
}}
>
<SelectTrigger className="h-8.5 px-3 min-w-[175px] text-xs font-bold rounded-xl border-border-soft bg-card shadow-2xs hover:bg-muted/40 transition-colors">
<SelectTrigger className="h-10 px-3.5 text-xs font-black rounded-xl border-2 border-border/80 bg-background shadow-xs hover:border-primary/50 transition-all">
<SelectValue placeholder="انتخاب وضعیت...">
{(() => {
const curOpt = STATUS_OPTIONS.find((o) => o.value === selectedStatus)
if (!curOpt) return selectedStatus
return (
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full shrink-0', curOpt.dotClass)} />
<span className="truncate">{curOpt.label}</span>
<span className={cn('size-2.5 rounded-full shrink-0', curOpt.dotClass)} />
<span className="truncate font-black">{curOpt.label}</span>
</div>
)
})()}
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-xl border-border-soft bg-card shadow-xl p-1 z-50">
<SelectContent className="rounded-xl border-border-soft bg-card shadow-2xl p-1 z-50">
{STATUS_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
className="rounded-lg text-xs font-medium py-2 gap-2 cursor-pointer"
className="rounded-lg text-xs font-bold py-2.5 px-3 gap-2 cursor-pointer hover:bg-muted/70"
>
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full shrink-0', opt.dotClass)} />
<span>{opt.label}</span>
<div className="flex items-center gap-2.5">
<span className={cn('size-2.5 rounded-full shrink-0', opt.dotClass)} />
<span className="font-bold">{opt.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* دکمه ذخیره وضعیت سریع در کنار منو برای جلوگیری از سردرگمی کاربر */}
{hasChanges && (
<Button
size="sm"
disabled={isSaving || isUpdating || (selectedStatus === 'rejected' && !rejectReason.trim())}
isLoading={isSaving || isUpdating}
onClick={handleSaveStatus}
className="h-8.5 px-3.5 text-xs font-bold gap-1 rounded-xl bg-primary text-primary-foreground shadow-xs hover:brightness-110 animate-in fade-in zoom-in-95 duration-150 shrink-0"
title="ذخیره وضعیت انتخاب‌شده"
>
<Ic name="check" className="size-3.5" />
<span>ذخیره وضعیت</span>
</Button>
)}
</div>
</div>
</div>
</DialogHeader>
{/* بدنه محتوا با اسکرول داخلی مجزا */}
<div className="p-5 space-y-5 flex-1 overflow-y-auto">
{/* کادر ویرایش/مشاهده دلیل رد صورت‌حساب در صورت انتخاب وضعیت رد شده */}
{selectedStatus === 'rejected' && (
<div className="bg-rose-500/10 border border-rose-500/25 rounded-2xl p-4 space-y-2.5 text-foreground">
<div className="bg-rose-500/10 border border-rose-500/25 rounded-2xl p-4 space-y-2.5 text-foreground animate-in fade-in slide-in-from-top-1 duration-150">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="size-6 rounded-lg bg-rose-500/20 text-rose-600 dark:text-rose-400 flex items-center justify-center shrink-0">
@ -313,8 +355,23 @@ export function BillingDetailModal({
</div>
)}
{/* اطلاع‌رسانی تمدید خودکار مهلت 3 روزه در صورت فعال‌سازی مجدد فاکتور منقضی شده یا رد شده */}
{(activeFactor.status === 'expired' || activeFactor.status === 'rejected') && (selectedStatus === 'awaiting_payment' || selectedStatus === 'pending') && (
{/* کارت وضعیت کنسل‌شدن صورت‌حساب */}
{activeFactor.status === 'cancelled' && selectedStatus === 'cancelled' && (
<div className="bg-red-50/80 dark:bg-red-950/40 border border-red-200 dark:border-red-800/60 rounded-2xl p-4 flex items-start gap-3.5">
<div className="size-8 rounded-xl bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400 flex items-center justify-center shrink-0 mt-0.5">
<Ic name="alert" className="size-4" />
</div>
<div className="space-y-1 flex-1">
<h4 className="font-bold text-xs text-red-900 dark:text-red-200">وضعیت صورت‌حساب: کنسل‌شده</h4>
<p className="text-xs text-red-700 dark:text-red-300 leading-relaxed font-medium">
{activeFactor.rejected_description || 'این صورت‌حساب کنسل شده است.'}
</p>
</div>
</div>
)}
{/* اطلاع‌رسانی تمدید خودکار مهلت 3 روزه در صورت فعال‌سازی مجدد فاکتور منقضی شده یا رد شده یا کنسل‌شده */}
{(activeFactor.status === 'expired' || activeFactor.status === 'rejected' || activeFactor.status === 'cancelled') && (selectedStatus === 'awaiting_payment' || selectedStatus === 'pending') && (
<div className="bg-slate-800 border border-amber-500/40 rounded-2xl p-4 flex items-start gap-3.5 shadow-md">
<div className="size-8 rounded-xl bg-amber-500/20 text-amber-400 flex items-center justify-center shrink-0 mt-0.5">
<Ic name="clock" className="size-4" />
@ -335,18 +392,6 @@ export function BillingDetailModal({
</div>
)}
{activeFactor.expiration_date && (
<div className="bg-muted/50 border border-border-soft rounded-xl p-3 flex items-center justify-between text-xs">
<span className="text-foreground/80 font-medium flex items-center gap-1.5">
<Ic name="clock" className="size-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span>مهلت پرداخت (تاریخ انقضا):</span>
</span>
<span className="font-bold text-foreground font-mono">
{formatDateByLanguage(activeFactor.expiration_date, language as any)}
</span>
</div>
)}
{/* اطلاعات خریدار و اطلاعات مالی در ۲ ستون */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* کارت مشخصات خریدار */}
@ -758,6 +803,20 @@ export function BillingDetailModal({
</Button>
<div className="flex items-center gap-2">
{/* دکمه لغو فاکتور: منحصراً در وضعیت در انتظار پرداخت و پیش از بارگذاری هرگونه فیش یا پرداخت */}
{activeFactor.status === 'awaiting_payment' && !activeFactor.transaction_receipt && (
<Button
variant="outline"
size="sm"
disabled={isSaving || isUpdating}
className="rounded-xl h-9 px-3.5 text-xs font-bold gap-1.5 border-rose-500/40 text-rose-600 dark:text-rose-400 hover:bg-rose-500/10 hover:border-rose-500/60 transition-colors"
onClick={handleCancelFactor}
>
<Ic name="x" className="size-3.5" />
<span>{t('billing.cancelInvoice')}</span>
</Button>
)}
<Button
variant="default"
size="sm"

9
src/features/billing/components/BillingStatusBadge.tsx

@ -56,6 +56,15 @@ export function BillingStatusBadge({ status, className }: Props) {
<span>{t('billing.statusExpired')}</span>
</span>
)
case 'cancelled':
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-bold border border-red-200 bg-red-50/90 text-red-950 dark:border-red-900/60 dark:bg-red-950/60 dark:text-red-200 shrink-0 ${className || ''}`}
>
<span className="size-1.5 rounded-full bg-red-600 dark:bg-red-400 shrink-0" />
<span>{t('billing.statusCancelled')}</span>
</span>
)
default:
return (
<span

11
src/features/billing/services/billing-api.ts

@ -69,6 +69,7 @@ export interface BillingCounts {
approved: number
rejected: number
expired: number
cancelled: number
}
export async function fetchBillingCounts(): Promise<BillingCounts> {
@ -85,3 +86,13 @@ export async function clearFactorReceipt(
method: 'POST',
})
}
/**
* کنسل کردن فاکتور در وضعیت در انتظار پرداخت
*/
export async function cancelFactor(factorId: number): Promise<FactorItem> {
return apiFetch<FactorItem>(`factors/${factorId}/cancel/`, {
method: 'POST',
})
}

1
src/features/billing/types.ts

@ -4,6 +4,7 @@ export type FactorStatus =
| 'approved'
| 'rejected'
| 'expired'
| 'cancelled'
export type FactorService =
| 'tour'

2
src/features/services/ServicesPage.tsx

@ -403,7 +403,7 @@ function ExchangeServiceView({ service }: { service: ServiceMeta }) {
{/* ۲. کارت‌های لحظه‌ای نرخ برابری ارزها */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-grey-3 uppercase tracking-wider">تابلوی زنده نرخ برابری ارزهای زیارتی</h3>
<h3 className="text-xs font-bold text-grey-3">تابلوی زنده نرخ برابری ارزهای زیارتی</h3>
<span className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">همگام با بازار نجف و صرافی مرکزی</span>
</div>
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">

157
src/features/services/components/CategoriesServiceView.tsx

@ -21,6 +21,11 @@ import {
deleteCategory,
fetchDuas,
} from '../services/trip-etiquette-api'
import {
ServiceTranslation,
SERVICE_LANGUAGES,
type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation'
import { toast } from '@/stores/toast'
export const PILGRIMAGE_CITIES = [
{ id: 5, name: 'Karbala', nameFa: 'کربلای معلی', flag: '🇮🇶' },
@ -83,6 +88,7 @@ export function CategoriesServiceView() {
const [isSaving, setIsSaving] = useState(false)
// فرم ایجاد/ویرایش (۵ زبانه + انتخاب چندگانه شهرها)
const [formActiveLang, setFormActiveLang] = useState<ServiceLanguageCode>('ar')
const [formNameAr, setFormNameAr] = useState('')
const [formNameFa, setFormNameFa] = useState('')
const [formNameEn, setFormNameEn] = useState('')
@ -182,6 +188,7 @@ export function CategoriesServiceView() {
// باز کردن فرم ایجاد دسته‌بندی جدید
const handleOpenCreateDialog = () => {
setEditingCategory(null)
setFormActiveLang('ar')
setFormNameAr('')
setFormNameFa('')
setFormNameEn('')
@ -223,6 +230,7 @@ export function CategoriesServiceView() {
arVal = c.name_display
}
setFormActiveLang('ar')
setFormNameAr(arVal)
setFormNameFa(faVal)
setFormNameEn(enVal)
@ -344,7 +352,7 @@ export function CategoriesServiceView() {
}, [allDuas, duaFilterSearch])
return (
<div className="space-y-6" dir="rtl">
<div className="space-y-6">
{/* پیام خطا در صورت بروز مشکل در اتصال */}
{errorMessage && (
<div className="rounded-2xl border border-destructive/20 bg-destructive/10 p-4 text-xs font-bold text-destructive flex items-center justify-between">
@ -368,7 +376,7 @@ export function CategoriesServiceView() {
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 flex items-center justify-between shadow-xs">
<div>
<span className="text-xs text-grey-3 font-medium">کل دسته‌بندی‌های فعال</span>
<div className="mt-1 text-2xl font-bold font-mono tracking-tight text-foreground">
<div className="mt-1 text-2xl font-bold font-mono tabular-nums text-foreground">
{isLoading ? <ShimmerBox width={48} height={28} rounded="lg" /> : toFa(totalCategoriesCount)}
</div>
</div>
@ -380,7 +388,7 @@ export function CategoriesServiceView() {
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 flex items-center justify-between shadow-xs">
<div>
<span className="text-xs text-grey-3 font-medium">شهرهای تحت پوشش</span>
<div className="mt-1 text-2xl font-bold font-mono tracking-tight text-foreground">
<div className="mt-1 text-2xl font-bold font-mono tabular-nums text-foreground">
{isLoading ? <ShimmerBox width={40} height={28} rounded="lg" /> : toFa(coveredCitiesCount)}
</div>
</div>
@ -392,7 +400,7 @@ export function CategoriesServiceView() {
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 flex items-center justify-between shadow-xs">
<div>
<span className="text-xs text-grey-3 font-medium">پیوندهای ادعیه به پوشه‌ها</span>
<div className="mt-1 text-2xl font-bold font-mono tracking-tight text-foreground">
<div className="mt-1 text-2xl font-bold font-mono tabular-nums text-foreground">
{isLoading ? <ShimmerBox width={48} height={28} rounded="lg" /> : toFa(totalAssignedDuas)}
</div>
</div>
@ -818,94 +826,77 @@ export function CategoriesServiceView() {
</p>
</div>
{/* ۱. عنوان دسته‌بندی به عربی (زبان پیش‌فرض و الزامی) */}
<div className="space-y-1.5 rounded-xl border border-primary/20 bg-primary/5 p-3">
<div className="flex items-center justify-between">
<label className="block text-xs font-bold text-foreground">
عنوان دسته‌بندی (عربی) <span className="text-destructive">*</span>
</label>
<span className="text-[10px] text-primary bg-primary/15 px-2 py-0.5 rounded-full font-bold">
پیش‌فرض و الزامی
</span>
</div>
<Input
type="text"
placeholder="مثلاً: الأعمال والزيارات أو أدعية الأيام"
value={formNameAr}
onChange={(e) => setFormNameAr(e.target.value)}
required
dir="rtl"
className="text-xs bg-card border-border-soft rounded-xl focus:border-primary"
/>
</div>
{/* ۲. سایر زبان‌ها: فارسی، انگلیسی، روسی، اندونزیایی */}
<div className="space-y-2 pt-1">
<label className="block text-[11px] font-bold text-grey-3">
ترجمه عنوان به سایر زبان‌ها (اختیاری):
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{/* فارسی */}
<div>
<label className="block text-[11px] text-foreground mb-1 font-medium">
فارسی (FA)
</label>
<Input
type="text"
placeholder="مثلاً: ادعیه و زیارات مشهوره"
value={formNameFa}
onChange={(e) => setFormNameFa(e.target.value)}
dir="rtl"
className="text-xs bg-card/60 border-border-soft rounded-xl"
{/* تب‌های انتخاب زبان با کامپوننت مشترک ServiceTranslation */}
<ServiceTranslation
activeLang={formActiveLang}
onLangChange={setFormActiveLang}
completedLangs={{
ar: Boolean(formNameAr.trim()),
fa: Boolean(formNameFa.trim()),
en: Boolean(formNameEn.trim()),
ru: Boolean(formNameRu.trim()),
id: Boolean(formNameId.trim()),
}}
primaryLang="ar"
title="عنوان چندزبانه دسته‌بندی"
variant="pills"
/>
</div>
{/* انگلیسی */}
<div>
<label className="block text-[11px] text-foreground mb-1 font-medium">
انگلیسی (EN)
</label>
<Input
type="text"
placeholder="e.g. Famous Prayers and Ziyarats"
value={formNameEn}
onChange={(e) => setFormNameEn(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl font-mono"
dir="ltr"
/>
</div>
{/* ورودی عنوان بر اساس زبان فعال */}
{(() => {
const currentMeta =
SERVICE_LANGUAGES.find((l) => l.code === formActiveLang) || SERVICE_LANGUAGES[0]
const valueMap: Record<ServiceLanguageCode, string> = {
ar: formNameAr,
fa: formNameFa,
en: formNameEn,
ru: formNameRu,
id: formNameId,
}
const setterMap: Record<ServiceLanguageCode, (val: string) => void> = {
ar: setFormNameAr,
fa: setFormNameFa,
en: setFormNameEn,
ru: setFormNameRu,
id: setFormNameId,
}
const placeholderMap: Record<ServiceLanguageCode, string> = {
ar: 'مثال: الأعمال والزيارات أو أدعية الأيام...',
fa: 'مثال: ادعیه و زیارات مشهوره، اعمال روزانه...',
en: 'e.g. Famous Prayers, Daily Ziyarats...',
ru: 'напр. Молитвы и Зияраты...',
id: 'misal: Doa dan Ziarah...',
}
{/* روسی */}
<div>
<label className="block text-[11px] text-foreground mb-1 font-medium">
روسی (RU - Русский)
return (
<div
key={formActiveLang}
className="space-y-2 rounded-2xl bg-surface-base/80 p-4 border border-border-soft"
dir={currentMeta.dir}
>
<div className="flex items-center justify-between">
<label className="block text-xs font-semibold text-foreground">
عنوان دسته‌بندی به زبان {currentMeta.name} ({currentMeta.nativeName}){' '}
{formActiveLang === 'ar' && <span className="text-destructive">*</span>}
</label>
<Input
type="text"
placeholder="напр. Молитвы и Зияраты"
value={formNameRu}
onChange={(e) => setFormNameRu(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl"
dir="ltr"
/>
{formActiveLang === 'ar' && (
<span className="text-[10px] text-primary bg-primary/10 border border-primary/20 px-2 py-0.5 rounded-full font-bold">
پیش‌فرض و الزامی
</span>
)}
</div>
{/* اندونزیایی */}
<div>
<label className="block text-[11px] text-foreground mb-1 font-medium">
اندونزیایی (ID - Bahasa)
</label>
<Input
type="text"
placeholder="misal: Doa dan Ziarah"
value={formNameId}
onChange={(e) => setFormNameId(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl"
dir="ltr"
placeholder={placeholderMap[formActiveLang]}
value={valueMap[formActiveLang] || ''}
onChange={(e) => setterMap[formActiveLang](e.target.value)}
required={formActiveLang === 'ar'}
dir={currentMeta.dir}
className="text-xs bg-card border-border-soft rounded-xl focus:border-primary h-10 px-3"
/>
</div>
</div>
</div>
)
})()}
{/* تخصیص اولیه ادعیه به این دسته‌بندی */}
<div className="space-y-2 pt-2 border-t border-border-soft">

50
src/features/services/components/CreateLocationDialog.tsx

@ -18,6 +18,11 @@ import {
CreateCityGuideInput,
} from '../types/zayer-guide'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import {
ServiceTranslation,
SERVICE_LANGUAGES,
type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation'
import { fetchCityGuideDetail } from '../services/zayer-guide-api'
interface CreateLocationDialogProps {
@ -355,38 +360,29 @@ export function CreateLocationDialog({
</div>
)}
{/* تب‌های انتخاب زبان برای محتوای متنی */}
{/* تب‌های انتخاب زبان برای محتوای متنی با کامپوننت مشترک ServiceTranslation */}
<div className="space-y-3 rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="flex items-center justify-between border-b border-border-soft pb-2.5 flex-wrap gap-2">
<div className="flex items-center gap-2">
<div className="text-xs font-bold text-foreground">اطلاعات متنی مکان</div>
<div className="border-b border-border-soft pb-2.5">
<ServiceTranslation
activeLang={activeLangTab}
onLangChange={(lang) => setActiveLangTab(lang)}
completedLangs={{
ar: Boolean(titleAr.trim()),
fa: Boolean(titleFa.trim()),
en: Boolean(titleEn.trim()),
ru: Boolean(titleRu.trim()),
id: Boolean(titleId.trim()),
}}
primaryLang="ar"
title="اطلاعات متنی مکان"
variant="pills"
/>
{isLoadingDetail && (
<span className="text-[11px] text-primary flex items-center gap-1 font-medium animate-pulse">
<div className="text-[11px] text-primary flex items-center gap-1 font-medium animate-pulse mt-2">
<Ic name="loader" className="size-3 animate-spin" />
<span>در حال دریافت ترجمه‌ها از دیتابیس...</span>
</span>
)}
</div>
<div className="flex items-center gap-1 rounded-xl bg-surface-base p-1 border border-border-soft flex-wrap">
{LOCATION_LANGUAGES.map((lang) => {
const isSelected = activeLangTab === lang.code
return (
<button
key={lang.code}
type="button"
onClick={() => setActiveLangTab(lang.code)}
className={`px-2.5 py-1 text-xs rounded-lg transition-colors font-medium flex items-center gap-1.5 ${
isSelected
? 'bg-primary text-white shadow-xs font-bold'
: 'text-grey-2 hover:text-foreground hover:bg-card/60'
}`}
>
<span className="text-sm leading-none">{lang.flag}</span>
<span>{lang.name}</span>
</button>
)
})}
</div>
)}
</div>
{/* لودینگ اسکلتون شیمر خنثی در صورت خالی بودن داده‌ها هنگام دریافت ترجمه‌ها */}

10
src/features/services/components/DuasServiceView.tsx

@ -754,7 +754,7 @@ export function DuasServiceView() {
}, [duas])
return (
<div className="space-y-6" dir="rtl">
<div className="space-y-6">
{/* ۰. کارت‌های آماری کلان ادعیه و زیارات */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
{/* کل ادعیه */}
@ -941,12 +941,12 @@ export function DuasServiceView() {
<Table>
<TableHeader className="bg-card/70">
<TableRow>
<TableHead className="w-20 text-center font-mono">شناسه</TableHead>
<TableHead className="w-20 text-center">شناسه</TableHead>
<TableHead className="min-w-[240px]">نام زیارت / دعا</TableHead>
<TableHead className="min-w-[140px]">شهر(ها)</TableHead>
<TableHead className="min-w-[160px]">دسته‌بندی اعمال</TableHead>
<TableHead className="min-w-[160px]">مکان متبرک / حرم</TableHead>
<TableHead className="w-24 text-center font-mono">تعداد فراز</TableHead>
<TableHead className="w-24 text-center">تعداد فراز</TableHead>
<TableHead className="w-24 text-center">وضعیت</TableHead>
<TableHead className="w-36 text-center">عملیات</TableHead>
</TableRow>
@ -1103,9 +1103,9 @@ export function DuasServiceView() {
<TableCell className="text-center">
<Badge
variant="secondary"
className="bg-card border border-border-soft font-mono text-[11px] text-primary px-2.5 py-0.5 rounded-full"
className="bg-card border border-border-soft text-[11px] text-primary px-2.5 py-0.5 rounded-full"
>
{toFa(dua.verses_count)} فراز
<span className="font-mono tabular-nums">{toFa(dua.verses_count)}</span> فراز
</Badge>
</TableCell>

91
src/features/services/components/FaqServiceView.tsx

@ -18,6 +18,10 @@ import {
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import {
ServiceTranslation,
type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation'
import {
SUPPORTED_LANGUAGES,
type SupportedLanguage,
@ -1345,37 +1349,20 @@ export function FaqServiceView() {
</div>
</div>
{/* تب‌های انتخاب زبان */}
<div className="flex items-center gap-1.5 p-1 rounded-xl bg-muted/40 border border-border/40 overflow-x-auto">
{SUPPORTED_LANGUAGES.map((lang) => {
const isCurrent = catActiveLang === lang.code
const isFilled = Boolean(catTranslations[lang.code]?.title.trim())
return (
<button
key={lang.code}
type="button"
onClick={() => setCatActiveLang(lang.code)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-xs font-bold transition-all shrink-0',
isCurrent
? 'bg-primary text-primary-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
>
<span className="text-sm leading-none">{lang.flag}</span>
<span>{lang.nativeName}</span>
{isFilled && (
<span
className={cn(
'size-1.5 rounded-full',
isCurrent ? 'bg-white' : 'bg-emerald-500'
{/* تب‌های انتخاب زبان با ServiceTranslation */}
<ServiceTranslation
activeLang={catActiveLang as ServiceLanguageCode}
onLangChange={(lang) => setCatActiveLang(lang as SupportedLanguage)}
completedLangs={Object.fromEntries(
(['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]).map((code) => [
code,
Boolean(catTranslations[code]?.title.trim()),
])
)}
primaryLang="ar"
title="زبان ترجمه و محتوا"
variant="pills"
/>
)}
</button>
)
})}
</div>
{/* فیلدهای ورودی زبان انتخاب‌شده */}
<div
@ -1557,39 +1544,23 @@ export function FaqServiceView() {
</div>
</div>
{/* تب‌های انتخاب زبان */}
<div className="flex items-center gap-1.5 p-1 rounded-xl bg-muted/40 border border-border/40 overflow-x-auto">
{SUPPORTED_LANGUAGES.map((lang) => {
const isCurrent = itemActiveLang === lang.code
const isFilled =
Boolean(itemTranslations[lang.code]?.question.trim()) &&
Boolean(itemTranslations[lang.code]?.answer.trim())
return (
<button
key={lang.code}
type="button"
onClick={() => setItemActiveLang(lang.code)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-xs font-bold transition-all shrink-0',
isCurrent
? 'bg-primary text-primary-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
>
<span className="text-sm leading-none">{lang.flag}</span>
<span>{lang.nativeName}</span>
{isFilled && (
<span
className={cn(
'size-1.5 rounded-full',
isCurrent ? 'bg-white' : 'bg-emerald-500'
{/* تب‌های انتخاب زبان با ServiceTranslation */}
<ServiceTranslation
activeLang={itemActiveLang as ServiceLanguageCode}
onLangChange={(lang) => setItemActiveLang(lang as SupportedLanguage)}
completedLangs={Object.fromEntries(
(['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]).map((code) => [
code,
Boolean(
itemTranslations[code]?.question.trim() &&
itemTranslations[code]?.answer.trim()
),
])
)}
primaryLang="ar"
title="زبان ترجمه و محتوا"
variant="pills"
/>
)}
</button>
)
})}
</div>
{/* فیلدهای ورودی زبان انتخاب‌شده */}
<div

2
src/features/services/components/ShrinesServiceView.tsx

@ -210,7 +210,7 @@ export function ShrinesServiceView() {
}
return (
<div className="space-y-6" dir="rtl">
<div className="space-y-6">
{/* ردیف آمار خلاصه (طراحی خنثی مطابق سند) */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-4">

66
src/features/services/components/SimcardFormDialog.tsx

@ -11,9 +11,14 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Ic } from '@/icons'
import {
ServiceTranslation,
SERVICE_LANGUAGES,
type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation'
import type { SimCardItem, CreateSimCardInput } from '../services/simcard-api'
export type SupportedLanguage = 'ar' | 'fa' | 'en' | 'ru' | 'id'
export type SupportedLanguage = ServiceLanguageCode
interface SimcardFormDialogProps {
open: boolean
@ -29,13 +34,7 @@ const COUNTRIES = [
{ 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
export const LANGUAGES = SERVICE_LANGUAGES
const createEmptyTitles = (): Record<SupportedLanguage, string> => ({
ar: '',
@ -379,47 +378,18 @@ export function SimcardFormDialog({
</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} ویژگی)`}
/>
{/* ۲. تب‌های انتخاب ۵ زبان با کامپوننت مشترک ServiceTranslation */}
<ServiceTranslation
activeLang={activeLangTab}
onLangChange={(lang) => handleTabChange(lang)}
completedLangs={Object.fromEntries(
Object.entries(titles).map(([k, v]) => [k, Boolean(v?.trim())])
)}
</button>
)
})}
</div>
</div>
primaryLang="ar"
title="زبان ترجمه و محتوا"
required
variant="grid"
/>
{/* ۳. عنوان بسته سیم‌کارت به زبان فعال */}
<div className="space-y-2">

808
src/features/services/components/StoreServiceView.tsx
File diff suppressed because it is too large
View File

81
src/features/services/services/store-api.ts

@ -253,6 +253,13 @@ export async function createStoreProduct(input: CreateStoreProductInput): Promis
})
}
/**
* دریافت اطلاعات کامل و به‌روز یک محصول با اسلاگ از سرور
*/
export async function fetchStoreProductDetail(slug: string): Promise<StoreProductItem> {
return apiFetch<StoreProductItem>(`store/products/${slug}/`)
}
/**
* ویرایش محصول
*/
@ -274,3 +281,77 @@ export async function deleteStoreProduct(slug: string): Promise<void> {
method: 'DELETE',
})
}
/**
* آپلود تصویر محصول فروشگاه
*/
export async function uploadStoreProductImage(
file: File,
productSlug?: string
): Promise<{
id: number
filer_id: number
product_image_id?: number | null
url: string
original_filename?: string
}> {
const form = new FormData()
form.append('file', file)
if (productSlug) {
form.append('product_slug', productSlug)
}
return apiFetch<{
id: number
filer_id: number
product_image_id?: number | null
url: string
original_filename?: string
}>('store/products/upload_image/', {
method: 'POST',
body: form,
})
}
/**
* حذف تصویر محصول از طریق شناسه تصویر
*/
export async function deleteStoreProductImage(imageId: number): Promise<void> {
return apiFetch<void>(`store/products/images/${imageId}/`, {
method: 'DELETE',
})
}
export interface StoreCountryOption {
id: number
name: string
code: string
}
/**
* دریافت لیست کشورهای در دسترس برای فروشگاه
*/
export async function fetchStoreAvailableCountries(): Promise<StoreCountryOption[]> {
try {
const res = await apiFetch<any>('cityguide/countries/?service=store')
const list = Array.isArray(res) ? res : (res && Array.isArray(res.results) ? res.results : [])
if (list.length > 0) {
return list.map((c: any) => ({
id: c.id,
name: c.name,
code: c.code,
}))
}
} catch (err) {
console.warn('Failed to fetch countries from cityguide/countries/?service=store:', err)
}
// Fallback defaults
return [
{ id: 1, name: 'Iran', code: 'IR' },
{ id: 2, name: 'Iraq', code: 'IQ' },
{ id: 3, name: 'Syria', code: 'SY' },
{ id: 4, name: 'Saudi Arabia', code: 'SA' },
{ id: 5, name: 'Oman', code: 'OMN' },
{ id: 6, name: 'Kuwait', code: 'KWT' },
{ id: 7, name: 'Bahrain', code: 'BHR' },
]
}

21
src/features/services/types/store.ts

@ -16,14 +16,23 @@ export interface StoreProductItem {
slug: string
price: string
stock: number
detail?: any
raw_title?: any
raw_detail?: any
category?: StoreCategoryItem | null
country?: {
id: number
name: string
code: string
} | null
countries?: Array<{
id: number
name: string
code: string
}>
images?: Array<{
id: number
url?: string
image_url?: {
original?: string
large?: string
@ -53,19 +62,29 @@ export interface UpdateStoreCategoryInput {
}
export interface CreateStoreProductInput {
title_text: string
title_text?: string
title?: any
price: number | string
stock: number
category_id?: number | null
country_id?: number | null
country_ids?: number[]
uploaded_image_ids?: number[]
delete_image_ids?: number[]
detail_text?: string
detail?: any
}
export interface UpdateStoreProductInput {
title_text?: string
title?: any
price?: number | string
stock?: number
category_id?: number | null
country_id?: number | null
country_ids?: number[]
uploaded_image_ids?: number[]
delete_image_ids?: number[]
detail_text?: string
detail?: any
}

2
src/features/tours/TourOrdersPage.tsx

@ -121,7 +121,7 @@ export default function TourOrdersPage() {
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-foreground tracking-tight">
<h1 className="text-2xl font-black text-foreground">
سفارشات و رزروهای تور
</h1>
<p className="text-xs text-grey-3 mt-1">

53
src/features/tours/components/CreateTourDialog.tsx

@ -12,6 +12,10 @@ import { Button, ButtonSpinner } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Ic } from '@/icons'
import { createTour, uploadTourImage, type CreateTourPayload } from '../services/tour-api'
import {
ServiceTranslation,
type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation'
import type { TourTranslationData } from '../types'
interface CreateTourDialogProps {
@ -875,39 +879,24 @@ export function CreateTourDialog({
{/* TAB 3: Multi-Language Translations */}
{activeTab === 'translations' && (
<div className="space-y-4">
{/* 5-Language Selector */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 border-b border-border/40">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang.code === selectedTransLang
const transObj = translations[lang.code]
const hasData = Boolean(
transObj?.title?.trim() ||
transObj?.description?.trim() ||
(transObj?.tour_feature && transObj.tour_feature.length > 0)
)
return (
<button
key={lang.code}
type="button"
onClick={() => setSelectedTransLang(lang.code)}
className={`flex items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition-all ${
isSelected
? 'bg-primary text-white shadow-md'
: 'bg-surface-2 text-grey-2 hover:bg-surface-3 hover:text-foreground border border-border/30'
}`}
>
<span className="text-sm">{lang.flag}</span>
<span>{lang.label}</span>
{hasData && !isSelected && (
<span className="size-1.5 rounded-full bg-emerald-500" />
)}
{lang.code === primaryLang && (
<span className="text-[10px] opacity-80">(اصلی)</span>
{/* 5-Language Selector with ServiceTranslation */}
<ServiceTranslation
activeLang={selectedTransLang as ServiceLanguageCode}
onLangChange={(lang) => setSelectedTransLang(lang as LangCode)}
completedLangs={Object.fromEntries(
(['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]).map((code) => [
code,
Boolean(
translations[code]?.title?.trim() ||
translations[code]?.description?.trim() ||
(translations[code]?.tour_feature && translations[code].tour_feature.length > 0)
),
])
)}
</button>
)
})}
</div>
primaryLang={primaryLang as ServiceLanguageCode}
title="زبان ترجمه و محتوا"
variant="pills"
/>
<div dir={activeTransConfig?.dir || 'rtl'} className="space-y-4">
<div>

88
src/features/tours/components/TourDetailDialog.tsx

@ -12,6 +12,10 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Ic } from '@/icons'
import {
ServiceTranslation,
type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import {
TourDetailModalShimmer,
@ -1005,7 +1009,7 @@ export function TourDetailDialog({
</div>
{/* دکمه‌های عملیاتی ثابت و بدون شکستن خط (Action Buttons) */}
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-2 shrink-0 pe-8">
{isLoading && !tour ? (
<div className="flex items-center gap-1.5">
<ShimmerBox width={80} height={32} rounded="lg" />
@ -1060,69 +1064,35 @@ export function TourDetailDialog({
</Button>
</>
) : null}
{/* دکمه بستن پنجره */}
<button
type="button"
onClick={handleRequestClose}
aria-label="بستن پنجره"
title="بستن"
className="flex size-8.5 items-center justify-center rounded-xl text-grey-3 hover:text-foreground hover:bg-muted/60 transition-colors shrink-0 cursor-pointer border border-transparent hover:border-border-soft"
>
<Ic name="x" className="size-4.5" />
</button>
</div>
</div>
{/* Integrated 5-Language Selector Bar with dirty indicators */}
<div className="mt-4 flex items-center justify-between gap-2 overflow-x-auto pt-2 border-t border-border-soft/60">
<div className="flex items-center gap-1.5 shrink-0 text-xs text-grey-3">
<Ic name="globe" className="size-3.5 text-primary" />
<span className="font-bold">زبان فعال محتوا:</span>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar pb-1">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang.code === selectedLang
const isPrimary = transData?.primary_language === lang.code
const hasDraftData = Boolean(
allTranslations[lang.code]?.title ||
allTranslations[lang.code]?.description ||
allTranslations[lang.code]?.features.length > 0 ||
allTranslations[lang.code]?.tips.length > 0
)
const dirty = isLanguageDirty(lang.code)
return (
<button
key={lang.code}
type="button"
onClick={() => handleLanguageSwitch(lang.code)}
className={`relative flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer border ${
isSelected
? 'bg-primary text-white border-primary shadow-xs'
: hasDraftData
? 'bg-card/70 border-border-soft text-foreground hover:border-primary/40'
: 'bg-surface-base border-dashed border-border-soft text-grey-3 hover:text-foreground'
}`}
>
<span>{lang.flag}</span>
<span>{lang.label}</span>
{dirty && (
<span
title="دارای تغییرات ذخیره‌نشده"
className={`size-2 rounded-full ${isSelected ? 'bg-white animate-pulse' : 'bg-amber-500 animate-pulse'}`}
/>
{/* Integrated 5-Language Selector Bar with dirty indicators via ServiceTranslation */}
<div className="mt-4 pt-2 border-t border-border-soft/60">
<ServiceTranslation
activeLang={selectedLang as ServiceLanguageCode}
onLangChange={(lang) => handleLanguageSwitch(lang)}
completedLangs={Object.fromEntries(
(['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]).map((code) => [
code,
Boolean(
allTranslations[code]?.title ||
allTranslations[code]?.description ||
allTranslations[code]?.features?.length > 0 ||
allTranslations[code]?.tips?.length > 0
),
])
)}
{isPrimary && (
<span className={`text-[9px] px-1 py-0.2 rounded-full ${isSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
پایه
</span>
dirtyLangs={Object.fromEntries(
(['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]).map((code) => [
code,
isLanguageDirty(code),
])
)}
</button>
)
})}
</div>
primaryLang={transData?.primary_language as ServiceLanguageCode}
title="زبان فعال محتوا"
variant="pills"
/>
</div>
{/* Main Navigation Tabs */}

50
src/features/tours/components/TourTranslationsDialog.tsx

@ -11,6 +11,10 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Ic } from '@/icons'
import {
ServiceTranslation,
type ServiceLanguageCode,
} from '@/components/shared/ServiceTranslation'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { TourDetailOverviewShimmer } from './TourDetailShimmer'
import {
@ -303,36 +307,24 @@ export function TourTranslationsDialog({
</div>
</div>
{/* 5-Language Selector Bar */}
<div className="flex items-center gap-1.5 mt-4 overflow-x-auto pb-1">
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = lang.code === selectedLang
const hasTrans = Boolean(
transData?.primary_language === lang.code ||
transData?.translations[lang.code]?.title
)
return (
<button
key={lang.code}
type="button"
onClick={() => handleLanguageChange(lang.code)}
className={`flex items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition-all ${
isSelected
? 'bg-primary text-white shadow-md'
: 'bg-surface-2 text-grey-2 hover:bg-surface-3 hover:text-foreground border border-border/30'
}`}
>
<span className="text-sm">{lang.flag}</span>
<span>{lang.label}</span>
{hasTrans && !isSelected && (
<span className="size-1.5 rounded-full bg-emerald-500" />
)}
{transData?.primary_language === lang.code && (
<span className="text-[10px] opacity-80">(اصلی)</span>
{/* 5-Language Selector Bar via ServiceTranslation */}
<div className="mt-4">
<ServiceTranslation
activeLang={selectedLang as ServiceLanguageCode}
onLangChange={(lang) => handleLanguageChange(lang as LangCode)}
completedLangs={Object.fromEntries(
(['ar', 'fa', 'en', 'ru', 'id'] as ServiceLanguageCode[]).map((code) => [
code,
Boolean(
transData?.primary_language === code ||
transData?.translations[code]?.title
),
])
)}
</button>
)
})}
primaryLang={transData?.primary_language as ServiceLanguageCode}
title="زبان ترجمه و محتوا"
variant="pills"
/>
</div>
</DialogHeader>

7
src/features/users/components/UserFactorsTab.tsx

@ -103,6 +103,13 @@ export function UserFactorsTab({ userId }: UserFactorsTabProps) {
{t('billing.statusExpired')}
</span>
)
case 'cancelled':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
{t('billing.statusCancelled')}
</span>
)
default:
return (
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground">

2
src/features/users/types.ts

@ -77,7 +77,7 @@ export interface UserTourItem {
receipt_verified?: boolean
}
export type FactorStatus = 'awaiting_payment' | 'pending' | 'rejected' | 'approved' | 'expired'
export type FactorStatus = 'awaiting_payment' | 'pending' | 'rejected' | 'approved' | 'expired' | 'cancelled'
export type FactorService = 'shop' | 'simcard' | 'tour' | 'tasrif' | 'custom_trip'
export interface UserFactorItem {

11
src/index.css

@ -104,7 +104,7 @@ html.light {
@theme inline {
--font-sans: 'Inter', 'Vazirmatn', system-ui, -apple-system, sans-serif;
--font-mono: 'Inter', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-mono: 'Inter', 'Vazirmatn', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@ -160,11 +160,16 @@ html.light {
font-size: 14px;
font-feature-settings: 'tnum' 1, 'cv05' 1;
}
table, thead, tbody, tfoot, tr, th, td {
font-family: 'Inter', 'Vazirmatn', system-ui, -apple-system, sans-serif !important;
}
input, button, select, optgroup, textarea, option {
font-family: 'Inter', 'Vazirmatn', system-ui, -apple-system, sans-serif !important;
}
.font-mono, .tabular-nums, table td, table th, [class*="tabular-nums"] {
font-family: 'Inter', ui-monospace, SFMono-Regular, monospace;
.font-mono {
font-family: 'Inter', 'Vazirmatn', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
}
.tabular-nums, table td, table th, [class*="tabular-nums"] {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1, 'cv05' 1;
}

12
src/locales/ar.json

@ -17,7 +17,7 @@
"duasList": "الأدعية والزيارات",
"categories": "التصنيفات",
"cities": "المدن المقدسة",
"store": "متجر معصومة (Store)",
"store": "متجر نصرة العقيلة (Store)",
"storeProducts": "المنتجات",
"storeCategories": "التصنيفات",
"costEstimate": "تقدير التكلفة (Cost Estimate)",
@ -200,8 +200,8 @@
"categoriesDesc": "إدارة التصنيفات الموضوعية للخدمات والأماكن والمتجر ومحتويات التطبيق",
"citiesTitle": "المدن المقدسة",
"citiesDesc": "المدن المقدسة (النجف، كربلاء، الكاظمية، سامراء، مشهد، قم، مكة، المدينة)",
"storeTitle": "متجر معصومة",
"storeDesc": "منتجات متجر معصومة الإلكتروني وهدايا الزيارة وإدارة المخزون وطلبات الشراء",
"storeTitle": "متجر نصرة العقيلة",
"storeDesc": "منتجات متجر نصرة العقيلة الإلكتروني وهدايا الزيارة وإدارة المخزون وطلبات الشراء",
"costEstimateTitle": "تقدير التكلفة",
"costEstimateDesc": "حساب تكاليف الرحلات الفردية والعائلية حسب الموسم والخدمات ومستوى الفندق",
"supportTitle": "الدعم الفني",
@ -242,6 +242,10 @@
"statusApproved": "تم اعتماد الدفع",
"statusRejected": "مرفوض",
"statusExpired": "منتهي الصلاحية",
"statusCancelled": "ملغاة",
"cancelInvoice": "إلغاء الفاتورة",
"cancelInvoiceConfirm": "هل أنت متأكد من رغبتك في إلغاء هذه الفاتورة؟ هذا الإجراء لا يمكن التراجع عنه.",
"invoiceCancelledSuccess": "تم إلغاء الفاتورة بنجاح.",
"invoice": "فاتورة",
"invoices": "الفواتير",
"id": "المعرف",
@ -250,7 +254,7 @@
"noReceiptUploaded": "لم يتم رفع إيصال",
"serviceTour": "رحلة زيارة",
"serviceSimcard": "شريحة اتصال",
"serviceShop": "متجر معصومة",
"serviceShop": "متجر نصرة العقيلة",
"serviceExchange": "صرافة (تصريف)",
"serviceCustomTrip": "رحلة مخصصة",
"allStatuses": "جميع الحالات",

12
src/locales/en.json

@ -17,7 +17,7 @@
"duasList": "Duas & Ziyarat",
"categories": "Categories",
"cities": "Holy Cities",
"store": "Massoumah Store",
"store": "Nosrat Al-Aqila Store",
"storeProducts": "Products",
"storeCategories": "Categories",
"costEstimate": "Cost Estimate",
@ -200,8 +200,8 @@
"categoriesDesc": "Management of religious, shrine, store, and app content categories",
"citiesTitle": "Holy Cities",
"citiesDesc": "Pilgrimage cities (Najaf, Karbala, Kadhimiya, Samarra, Mashhad, Qom, Mecca, Medina)",
"storeTitle": "Massoumah Store",
"storeDesc": "Massoumah online store products, souvenirs, inventory, and order fulfillment",
"storeTitle": "Nosrat Al-Aqila Store",
"storeDesc": "Nosrat Al-Aqila online store products, souvenirs, inventory, and order fulfillment",
"costEstimateTitle": "Cost Estimate",
"costEstimateDesc": "Estimation of total pilgrimage trip costs according to season, services, and hotel class",
"supportTitle": "Support",
@ -242,6 +242,10 @@
"statusApproved": "Payment Approved",
"statusRejected": "Rejected",
"statusExpired": "Expired",
"statusCancelled": "Cancelled",
"cancelInvoice": "Cancel Invoice",
"cancelInvoiceConfirm": "Are you sure you want to cancel this invoice? This action cannot be undone.",
"invoiceCancelledSuccess": "Invoice was cancelled successfully.",
"invoice": "Invoice",
"invoices": "Invoices",
"id": "ID",
@ -250,7 +254,7 @@
"noReceiptUploaded": "No receipt uploaded",
"serviceTour": "Pilgrimage Tours",
"serviceSimcard": "SIM Card",
"serviceShop": "Massoumah Store",
"serviceShop": "Nosrat Al-Aqila Store",
"serviceExchange": "Exchange (Tasreef)",
"serviceCustomTrip": "Custom Trip",
"allStatuses": "All Statuses",

12
src/locales/fa.json

@ -17,7 +17,7 @@
"duasList": "ادعیه و زیارات",
"categories": "دسته‌بندی‌ها",
"cities": "شهرهای زیارتی",
"store": "فروشگاه معصومه (Store)",
"store": "فروشگاه نصرت عقیله (Store)",
"storeProducts": "محصولات",
"storeCategories": "دسته‌بندی‌ها",
"costEstimate": "تخمین هزینه (Cost Estimate)",
@ -200,8 +200,8 @@
"categoriesDesc": "مدیریت دسته‌بندی‌های موضوعی خدمات، اماکن، فروشگاه و محتواهای اپلیکیشن",
"citiesTitle": "شهرهای زیارتی",
"citiesDesc": "شهرهای زیارتی (نجف، کربلا، کاظمین، سامرا، مشهد، قم، مکه، مدینه)",
"storeTitle": "فروشگاه معصومه",
"storeDesc": "محصولات فروشگاه آنلاین معصومه، سوغات زیارتی، مدیریت انبار و سفارشات خرید",
"storeTitle": "فروشگاه نصرت عقیله",
"storeDesc": "محصولات فروشگاه آنلاین نصرت عقیله، سوغات زیارتی، مدیریت انبار و سفارشات خرید",
"costEstimateTitle": "تخمین هزینه",
"costEstimateDesc": "محاسبه آنلاین هزینه‌های سفر انفرادی و خانوادگی به تفکیک فصل، خدمات و سطح هتل",
"supportTitle": "پشتیبانی",
@ -242,6 +242,10 @@
"statusApproved": "پرداخت تایید شده",
"statusRejected": "رد شده",
"statusExpired": "منقضی شده",
"statusCancelled": "کنسل‌شده",
"cancelInvoice": "کنسل کردن فاکتور",
"cancelInvoiceConfirm": "آیا از کنسل کردن این فاکتور اطمینان دارید؟ این عملیات غیرقابل بازگشت است.",
"invoiceCancelledSuccess": "صورت‌حساب با موفقیت کنسل شد.",
"invoice": "صورت‌حساب",
"invoices": "صورت‌حساب‌ها",
"id": "شناسه",
@ -250,7 +254,7 @@
"noReceiptUploaded": "فیشی بارگذاری نشده است",
"serviceTour": "تور زیارتی",
"serviceSimcard": "سیم‌کارت",
"serviceShop": "فروشگاه معصومه",
"serviceShop": "فروشگاه نصرت عقیله",
"serviceExchange": "صرافی (تصریف)",
"serviceCustomTrip": "سفر اختصاصی",
"allStatuses": "همه وضعیت‌ها",

Loading…
Cancel
Save