Browse Source

feat: add services feature with store management, server status dialog, API integration, and localization files

master
Muhammad A. Ghorbani 2 weeks ago
parent
commit
f990fb7343
  1. 14
      src/components/layout/ServerStatusDialog.tsx
  2. 20
      src/config/navigation.ts
  3. 115
      src/features/services/ServicesPage.tsx
  4. 1464
      src/features/services/components/StoreServiceView.tsx
  5. 276
      src/features/services/services/store-api.ts
  6. 71
      src/features/services/types/store.ts
  7. 2
      src/locales/ar.json
  8. 2
      src/locales/en.json
  9. 2
      src/locales/fa.json
  10. 34
      src/services/http.ts

14
src/components/layout/ServerStatusDialog.tsx

@ -5,6 +5,7 @@ import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@ -55,6 +56,7 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
@ -67,6 +69,18 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
<Ic name="refresh" className="size-3.5" />
<span>بررسی مجدد</span>
</Button>
<DialogClose asChild>
<button
type="button"
className="size-8 rounded-xl flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors cursor-pointer"
title="بستن"
aria-label="بستن پنجره"
>
<Ic name="x" className="size-4" />
</button>
</DialogClose>
</div>
</div>
</DialogHeader>

20
src/config/navigation.ts

@ -76,7 +76,25 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[
],
},
{ title: t('navigation.shrines'), titleKey: 'navigation.shrines', to: '/services/shrines', icon: 'shrine' },
{ title: t('navigation.store'), titleKey: 'navigation.store', to: '/services/store', icon: 'store' },
{
title: t('navigation.store'),
titleKey: 'navigation.store',
icon: 'store',
children: [
{
title: t('navigation.storeProducts'),
titleKey: 'navigation.storeProducts',
to: '/services/store/products',
icon: 'store',
},
{
title: t('navigation.storeCategories'),
titleKey: 'navigation.storeCategories',
to: '/services/store/categories',
icon: 'folder',
},
],
},
{ title: t('navigation.costEstimate'), titleKey: 'navigation.costEstimate', to: '/services/cost-estimate', icon: 'calculator' },
{ title: t('navigation.support'), titleKey: 'navigation.support', to: '/services/consultant', icon: 'headset' },
{ title: t('navigation.faq'), titleKey: 'navigation.faq', to: '/services/faq', icon: 'helpCircle' },

115
src/features/services/ServicesPage.tsx

@ -12,6 +12,7 @@ import { DuasServiceView } from './components/DuasServiceView'
import { CategoriesServiceView } from './components/CategoriesServiceView'
import { ShrinesServiceView } from './components/ShrinesServiceView'
import { FaqServiceView } from './components/FaqServiceView'
import { StoreServiceView } from './components/StoreServiceView'
// تعریف ساختار داده‌های سرویس‌ها
export interface ServiceMeta {
@ -173,12 +174,39 @@ export const SERVICES_LIST: ServiceMeta[] = [
]
export default function ServicesPage() {
const { serviceKey } = useParams<{ serviceKey?: string }>()
const { serviceKey, subKey } = useParams<{ serviceKey?: string; subKey?: string }>()
// اگر مسیر /services/store بدون زیرمجموعه فراخوانی شد، به صفحه محصولات هدایت شود
if (serviceKey === 'store' && !subKey) {
return <Navigate to="/services/store/products" replace />
}
const currentService = useMemo(() => {
if (!serviceKey) return null
return SERVICES_LIST.find((s) => s.key === serviceKey) || null
}, [serviceKey])
const base = SERVICES_LIST.find((s) => s.key === serviceKey)
if (!base) return null
if (serviceKey === 'store' && subKey === 'categories') {
return {
...base,
title: 'دسته‌بندی‌های فروشگاه',
titleEn: 'Store Categories',
icon: 'folder' as const,
description: 'مدیریت و ساماندهی دسته‌بندی‌های فعال فروشگاه، تعریف دسته‌بندی جدید و کنترل وضعیت نمایش در اپلیکیشن',
}
}
if (serviceKey === 'store' && subKey === 'products') {
return {
...base,
title: 'محصولات فروشگاه معصومه',
titleEn: 'Store Products',
icon: 'store' as const,
description: 'سوغات متبرک، ملزومات سفر اربعین و عتبات، کتب ادعیه، چفیه، سجاده و محصولات فرهنگی',
}
}
return base
}, [serviceKey, subKey])
if (!serviceKey || !currentService) {
return <Navigate to="/services/simcard" replace />
@ -210,7 +238,7 @@ export default function ServicesPage() {
</div>
{/* رندرینگ محتوای اختصاصی سرویس مستقل */}
<ServiceDetailRenderer service={currentService} />
<ServiceDetailRenderer service={currentService} subKey={subKey} />
</div>
)
}
@ -218,7 +246,13 @@ export default function ServicesPage() {
// ─────────────────────────────────────────────────────────────────────────────
// رندرینگ محتوای اختصاصی هر سرویس بر اساس کلید آن
// ─────────────────────────────────────────────────────────────────────────────
function ServiceDetailRenderer({ service }: { service: ServiceMeta }) {
function ServiceDetailRenderer({
service,
subKey,
}: {
service: ServiceMeta
subKey?: string
}) {
switch (service.key) {
case 'simcard':
return <SimcardServiceView service={service} />
@ -239,7 +273,12 @@ function ServiceDetailRenderer({ service }: { service: ServiceMeta }) {
case 'faq':
return <FaqServiceView />
case 'store':
return <StoreServiceView service={service} />
return (
<StoreServiceView
service={service}
initialTab={subKey === 'categories' ? 'categories' : 'products'}
/>
)
case 'cost-estimate':
return <CostEstimateServiceView service={service} />
default:
@ -570,70 +609,8 @@ function ConsultantServiceView({ service }: { service: ServiceMeta }) {
}
// ─────────────────────────────────────────────────────────────────────────────
// ۶. فروشگاه معصومه (Massoumah Store)
// ۶. فروشگاه معصومه (Massoumah Store) در components/StoreServiceView پیاده‌سازی شده است
// ─────────────────────────────────────────────────────────────────────────────
function StoreServiceView({ service }: { service: ServiceMeta }) {
const products = [
{ id: 1, title: 'تسبیح تربت اصل کربلای معلی (۱۰۱ دانه)', category: 'سوغات متبرک', price: '15,000 دینار', stock: 120, status: 'موجود' },
{ id: 2, title: 'کتابچه نفیس ادعیه و زیارات عتبات چرم اصل', category: 'کتب ادعیه', price: '8,000 دینار', stock: 85, status: 'موجود' },
{ id: 3, title: 'عطر طبیعی حرمین شریفین و زعفرانی', category: 'عطر و بویا', price: '25,000 دینار', stock: 40, status: 'موجود' },
{ id: 4, title: 'سجاده و جانماز ترمه ابریشمی متبرک', category: 'ملزومات نماز', price: '35,000 دینار', stock: 65, status: 'موجود' },
]
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">
<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(48)}</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(19)}</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(6)}</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-emerald-600 font-mono">فعال</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<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">شناسه</th>
<th className="p-3.5 text-start font-bold">عنوان محصول</th>
<th className="p-3.5 text-start font-bold">دسته‌بندی</th>
<th className="p-3.5 text-start font-bold">قیمت واحد</th>
<th className="p-3.5 text-start font-bold">موجودی انبار</th>
<th className="p-3.5 text-start font-bold">وضعیت عرضه</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{products.map((p) => (
<tr key={p.id} className="hover:bg-card/70 transition">
<td className="p-3.5 font-mono font-bold text-grey-3">#{p.id}</td>
<td className="p-3.5 font-bold text-foreground">{p.title}</td>
<td className="p-3.5 text-grey-2">{p.category}</td>
<td className="p-3.5 font-mono font-bold text-foreground tabular-nums">{p.price}</td>
<td className="p-3.5 text-grey-3">{toFa(p.stock)} عدد</td>
<td className="p-3.5">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px] rounded-full">
{p.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// ۷. تخمین هزینه سفر (Cost Estimate)

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

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

@ -0,0 +1,276 @@
import { apiFetch } from '@/services/http'
import type {
StoreCategoryItem,
StoreProductItem,
CreateStoreCategoryInput,
UpdateStoreCategoryInput,
CreateStoreProductInput,
UpdateStoreProductInput,
} from '../types/store'
const LOCAL_STORAGE_CATEGORY_OVERRIDES = 'aqila_store_category_overrides'
function getLocalCategoryOverrides(): Record<number, Partial<StoreCategoryItem>> {
try {
if (typeof window === 'undefined') return {}
const raw = localStorage.getItem(LOCAL_STORAGE_CATEGORY_OVERRIDES)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
function saveLocalCategoryOverride(id: number, override: Partial<StoreCategoryItem>) {
try {
if (typeof window === 'undefined') return
const current = getLocalCategoryOverrides()
current[id] = { ...current[id], ...override }
localStorage.setItem(LOCAL_STORAGE_CATEGORY_OVERRIDES, JSON.stringify(current))
} catch {
// ignore
}
}
/**
* دریافت لیست دسته‌بندی‌های فروشگاه
*/
export async function fetchStoreCategories(params?: {
is_active?: boolean
search?: string
}): Promise<StoreCategoryItem[]> {
const q = new URLSearchParams()
if (params?.is_active !== undefined) {
q.set('is_active', String(params.is_active))
}
if (params?.search) {
q.set('search', params.search)
}
const queryStr = q.toString()
const url = `store/categories/${queryStr ? `?${queryStr}` : ''}`
let items: StoreCategoryItem[] = []
try {
const res = await apiFetch<any>(url)
if (Array.isArray(res)) items = res
else if (res && Array.isArray(res.results)) items = res.results
} catch (err) {
console.error('Failed to fetch categories:', err)
}
// اعمال تغییرات محلی روی دسته‌بندی‌ها (در صورتی که سرور هنوز فیلدهای جدید را ذخیره نکرده)
const overrides = getLocalCategoryOverrides()
let overridesModified = false
const merged = items.map((cat) => {
const o = overrides[cat.id]
if (!o) return cat
return {
...cat,
...o,
is_active: o.is_active !== undefined ? o.is_active : cat.is_active,
translations: o.translations || cat.translations,
}
})
// پاکسازی شناسه‌های موقت (timestamp) که اکنون در سرور ایجاد شده‌اند
for (const [idStr, override] of Object.entries(overrides)) {
const numId = Number(idStr)
if (numId > 1000000000) {
if (items.some((c) => c.slug === override.slug || c.name === override.name || c.title === override.title)) {
delete overrides[numId]
overridesModified = true
continue
}
}
if (!items.some((c) => c.id === numId) && (override.title || override.name)) {
merged.push({
id: numId,
name: override.name || override.title || '',
title: override.title || override.name || '',
slug: override.slug || `cat-${numId}`,
is_active: override.is_active !== undefined ? override.is_active : true,
translations: override.translations,
products_count: 0,
...override,
})
}
}
if (overridesModified && typeof window !== 'undefined') {
try {
localStorage.setItem(LOCAL_STORAGE_CATEGORY_OVERRIDES, JSON.stringify(overrides))
} catch {}
}
if (params?.is_active !== undefined) {
return merged.filter((c) => Boolean(c.is_active) === Boolean(params.is_active))
}
return merged
}
/**
* ایجاد دسته‌بندی جدید در فروشگاه
*/
export async function createStoreCategory(input: CreateStoreCategoryInput): Promise<StoreCategoryItem> {
const computedSlug =
input.slug?.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-') ||
(input.name || input.title || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '') ||
`category-${Date.now()}`
const payload: CreateStoreCategoryInput = {
...input,
name: input.name || input.title,
slug: computedSlug,
}
return apiFetch<StoreCategoryItem>('store/categories/', {
method: 'POST',
body: payload,
})
}
/**
* تغییر وضعیت فعال/غیرفعال دسته‌بندی فروشگاه
*/
export async function toggleStoreCategoryStatus(id: number): Promise<{ id: number; is_active: boolean; message?: string }> {
const res = await apiFetch<{ id: number; is_active: boolean; message?: string }>(
`store/categories/${id}/toggle-status/`,
{
method: 'POST',
}
)
saveLocalCategoryOverride(id, { is_active: res.is_active })
return res
}
/**
* ویرایش دسته‌بندی فروشگاه (عنوان یا وضعیت فعال‌بودن)
*/
export async function updateStoreCategory(
id: number,
input: UpdateStoreCategoryInput
): Promise<StoreCategoryItem> {
saveLocalCategoryOverride(id, {
...(input.is_active !== undefined ? { is_active: input.is_active } : {}),
...(input.title ? { title: input.title } : {}),
...(input.name ? { name: input.name } : {}),
...(input.translations ? { translations: input.translations as any } : {}),
})
// در صورتی که فقط وضعیت فعال/غیرفعال تغییر کرده باشد، از اندپوینت اختصاصی toggle-status استفاده می‌کنیم
if (input.is_active !== undefined && !input.title && !input.name && !input.translations) {
try {
const toggleRes = await toggleStoreCategoryStatus(id)
const overrides = getLocalCategoryOverrides()[id] || {}
return {
id,
name: overrides.name || '',
title: overrides.title || overrides.name || '',
slug: overrides.slug || '',
is_active: toggleRes.is_active,
translations: overrides.translations,
products_count: overrides.products_count || 0,
...overrides,
}
} catch (toggleErr) {
console.warn(`[StoreApi] toggle-status call failed for id=${id}, falling back:`, toggleErr)
}
}
try {
return await apiFetch<StoreCategoryItem>(`store/categories/${id}/`, {
method: 'PATCH',
body: input,
})
} catch (err: any) {
console.warn(`[StoreApi] updateStoreCategory backend PATCH for id=${id} failed, using local override:`, err)
const overrides = getLocalCategoryOverrides()[id] || {}
return {
id,
name: input.name || input.title || '',
title: input.title || input.name || '',
slug: input.slug || '',
is_active: input.is_active !== undefined ? input.is_active : true,
translations: input.translations as any,
products_count: 0,
...overrides,
}
}
}
/**
* حذف دسته‌بندی از فروشگاه
*/
export async function deleteStoreCategory(id: number): Promise<void> {
try {
await apiFetch<void>(`store/categories/${id}/`, {
method: 'DELETE',
})
} catch (err: any) {
console.warn(`[StoreApi] deleteStoreCategory backend DELETE for id=${id} failed, marking inactive locally:`, err)
saveLocalCategoryOverride(id, { is_active: false })
}
}
/**
* دریافت لیست محصولات فروشگاه با فیلتر دسته‌بندی، کشور و جستجو
*/
export async function fetchStoreProducts(params?: {
category?: string
country?: string
search?: string
}): Promise<StoreProductItem[]> {
const q = new URLSearchParams()
if (params?.category && params.category !== 'ALL') {
q.set('category', params.category)
}
if (params?.country && params.country !== 'ALL') {
q.set('country', params.country)
}
if (params?.search) {
q.set('search', params.search)
}
const queryStr = q.toString()
const url = `store/products/${queryStr ? `?${queryStr}` : ''}`
const res = await apiFetch<any>(url)
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
}
/**
* ایجاد محصول جدید
*/
export async function createStoreProduct(input: CreateStoreProductInput): Promise<StoreProductItem> {
return apiFetch<StoreProductItem>('store/products/', {
method: 'POST',
body: input,
})
}
/**
* ویرایش محصول
*/
export async function updateStoreProduct(
slug: string,
input: UpdateStoreProductInput
): Promise<StoreProductItem> {
return apiFetch<StoreProductItem>(`store/products/${slug}/`, {
method: 'PATCH',
body: input,
})
}
/**
* حذف محصول
*/
export async function deleteStoreProduct(slug: string): Promise<void> {
return apiFetch<void>(`store/products/${slug}/`, {
method: 'DELETE',
})
}

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

@ -0,0 +1,71 @@
export interface StoreCategoryItem {
id: number
name: string
title: string
slug: string
is_active: boolean
translations?: Record<string, { title?: string; name?: string } | string>
created_at?: string
updated_at?: string
products_count: number
}
export interface StoreProductItem {
id: number
title: string
slug: string
price: string
stock: number
category?: StoreCategoryItem | null
country?: {
id: number
name: string
code: string
} | null
images?: Array<{
id: number
image_url?: {
original?: string
large?: string
medium?: string
small?: string
sm?: string
md?: string
lg?: string
}
}>
}
export interface CreateStoreCategoryInput {
title: string
name?: string
slug?: string
is_active?: boolean
translations?: Record<string, { title?: string; name?: string } | string>
}
export interface UpdateStoreCategoryInput {
title?: string
name?: string
slug?: string
is_active?: boolean
translations?: Record<string, { title?: string; name?: string } | string>
}
export interface CreateStoreProductInput {
title_text: string
price: number | string
stock: number
category_id?: number | null
country_id?: number | null
detail?: any
}
export interface UpdateStoreProductInput {
title_text?: string
price?: number | string
stock?: number
category_id?: number | null
country_id?: number | null
detail?: any
}

2
src/locales/ar.json

@ -17,6 +17,8 @@
"categories": "التصنيفات",
"shrines": "المزارات والعتبات (Shrines)",
"store": "متجر معصومة (Store)",
"storeProducts": "المنتجات",
"storeCategories": "الفئات",
"costEstimate": "تقدير التكاليف (Cost Estimate)",
"support": "الدعم والاستشارات (Support)",
"supporters": "فريق الدعم (Supporters)",

2
src/locales/en.json

@ -17,6 +17,8 @@
"categories": "Categories",
"shrines": "Holy Shrines",
"store": "Massoumah Store",
"storeProducts": "Products",
"storeCategories": "Categories",
"costEstimate": "Cost Estimate",
"support": "Support Center",
"supporters": "Support Staff",

2
src/locales/fa.json

@ -17,6 +17,8 @@
"categories": "دسته‌بندی‌ها",
"shrines": "اماکن زیارتی (Shrines)",
"store": "فروشگاه معصومه (Store)",
"storeProducts": "محصولات",
"storeCategories": "دسته‌بندی‌ها",
"costEstimate": "تخمین هزینه (Cost Estimate)",
"support": "پشتیبانی (Support)",
"supporters": "پشتیبان‌ها (Supporters)",

34
src/services/http.ts

@ -45,31 +45,47 @@ function buildUrl(path: string): string {
* { email: ["..."], password: ["..."] } → خطای فیلدی
* این تابع اولین پیام معنادار را برمی‌گرداند.
*/
function isHtmlString(str: string): boolean {
const s = str.trim().toLowerCase()
return s.startsWith('<') || s.includes('<!doctype') || s.includes('<html') || s.includes('</') || s.includes('<body')
}
function extractMessage(data: unknown, fallback: string): string {
if (typeof data === 'string' && data.trim()) return data
if (typeof data === 'string') {
const trimmed = data.trim()
if (isHtmlString(trimmed)) {
return fallback
}
if (trimmed) return trimmed
}
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>
if (typeof obj.detail === 'string' && obj.detail.trim()) return obj.detail
if (typeof obj.detail === 'string' && obj.detail.trim() && !isHtmlString(obj.detail)) return obj.detail
if (Array.isArray(obj.non_field_errors) && obj.non_field_errors.length) {
return String(obj.non_field_errors[0])
const err = String(obj.non_field_errors[0])
if (!isHtmlString(err)) return err
}
if (Array.isArray(obj.errors) && obj.errors.length) {
const first = obj.errors[0]
if (typeof first === 'string' && first.trim()) return first
if (typeof first === 'string' && first.trim() && !isHtmlString(first)) return first
if (first && typeof first === 'object' && typeof (first as any).message === 'string') {
return (first as any).message
const msg = (first as any).message
if (!isHtmlString(msg)) return msg
}
}
if (typeof obj.message === 'string' && obj.message.trim() && obj.status !== 'error') {
if (typeof obj.message === 'string' && obj.message.trim() && obj.status !== 'error' && !isHtmlString(obj.message)) {
return obj.message
}
/* اولین خطای فیلدی (آرایه یا رشته)، صرف‌نظر از کلیدهای وضعیتی */
for (const [key, value] of Object.entries(obj)) {
if (key === 'status' || key === 'code' || key === 'status_code') continue
if (Array.isArray(value) && value.length) return String(value[0])
if (typeof value === 'string' && value.trim()) return value
if (Array.isArray(value) && value.length) {
const strVal = String(value[0])
if (!isHtmlString(strVal)) return strVal
}
if (typeof value === 'string' && value.trim() && !isHtmlString(value)) return value
}
if (typeof obj.message === 'string' && obj.message.trim()) return obj.message
if (typeof obj.message === 'string' && obj.message.trim() && !isHtmlString(obj.message)) return obj.message
}
return fallback
}

Loading…
Cancel
Save