Browse Source
feat: add services feature with store management, server status dialog, API integration, and localization files
master
feat: add services feature with store management, server status dialog, API integration, and localization files
master
10 changed files with 1933 additions and 91 deletions
-
38src/components/layout/ServerStatusDialog.tsx
-
20src/config/navigation.ts
-
115src/features/services/ServicesPage.tsx
-
1464src/features/services/components/StoreServiceView.tsx
-
276src/features/services/services/store-api.ts
-
71src/features/services/types/store.ts
-
2src/locales/ar.json
-
2src/locales/en.json
-
2src/locales/fa.json
-
34src/services/http.ts
1464
src/features/services/components/StoreServiceView.tsx
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -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', |
||||
|
}) |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue