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