From 3c0098ceab6e3b78c2204cd3186946ac4414e4af Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Fri, 3 Jul 2026 17:22:57 +0330 Subject: [PATCH] feat:add purchesa --- app/admin/dashboard/page.tsx | 9 +- app/admin/purchases/page.tsx | 294 +++++++++++++++++++++++++++++++++++ lib/api/purchases.ts | 22 +++ types/purchase.ts | 91 +++++++++++ 4 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 app/admin/purchases/page.tsx create mode 100644 lib/api/purchases.ts create mode 100644 types/purchase.ts diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index 4f1d26e..a74df3f 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -2,7 +2,7 @@ import { useAuth } from '@/contexts/AuthContext'; import Link from 'next/link'; -import { UsersIcon, CubeIcon, TagIcon , BellIcon, GiftIcon, PowerIcon } from '@heroicons/react/24/outline'; +import { UsersIcon, CubeIcon, TagIcon , BellIcon, GiftIcon, PowerIcon, ShoppingBagIcon } from '@heroicons/react/24/outline'; import { MegaphoneIcon } from 'lucide-react'; import ThemeToggle from '@/components/ThemeToggle'; // import { BellIcon } from 'lucide-react'; @@ -18,6 +18,13 @@ export default function Dashboard() { Icon: UsersIcon, iconWrap: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-400', }, + { + href: '/admin/purchases', + title: 'خریدهای اشتراک', + desc: 'مشاهده و فیلتر خریدها بر اساس ایمیل، موبایل، پکیج و منبع پرداخت', + Icon: ShoppingBagIcon, + iconWrap: 'bg-orange-100 text-orange-600 dark:bg-orange-500/15 dark:text-orange-400', + }, { href: '/admin/packages', title: 'مدیریت پکیج‌ها', diff --git a/app/admin/purchases/page.tsx b/app/admin/purchases/page.tsx new file mode 100644 index 0000000..9eb05a6 --- /dev/null +++ b/app/admin/purchases/page.tsx @@ -0,0 +1,294 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import Link from 'next/link'; +import { useAuth } from '@/contexts/AuthContext'; +import { purchasesApi } from '@/lib/api/purchases'; +import { packagesApi } from '@/lib/api/packages'; +import { + Purchase, + Paginated, + PurchaseFilters, + PAYMENT_GATEWAYS, + PURCHASE_STATUSES, + getGatewayLabel, + getPurchaseStatusLabel, +} from '@/types/purchase'; +import { PackageName } from '@/types/package'; +import { formatPrice, formatDate, getProductTypeLabel } from '@/lib/utils'; +import { ArrowRightIcon, MagnifyingGlassIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; + +const statusBadgeClass = (status: number): string => { + switch (status) { + case 2: // موفق + return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; + case 3: // مصرف‌شده + return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'; + default: // در انتظار + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'; + } +}; + +const emptyFilters: PurchaseFilters = { + email: '', + mobile: '', + package_name: '', + gateway: '', + status: undefined, +}; + +export default function PurchasesPage() { + const { token } = useAuth(); + const [packages, setPackages] = useState([]); + const [filters, setFilters] = useState(emptyFilters); + const [result, setResult] = useState | null>(null); + const [page, setPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const loadPurchases = useCallback(async (targetPage: number, activeFilters: PurchaseFilters) => { + if (!token) return; + setIsLoading(true); + setError(null); + try { + const data = await purchasesApi.getPurchases( + { ...activeFilters, page: targetPage, per_page: 30 }, + token + ); + setResult(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'خطا در دریافت لیست خریدها'); + } finally { + setIsLoading(false); + } + }, [token]); + + // Load package list (for the filter dropdown) and the first page + useEffect(() => { + if (!token) return; + packagesApi.getAllPackages(token).then(setPackages).catch(() => setPackages([])); + loadPurchases(1, emptyFilters); + }, [token, loadPurchases]); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFilters((prev) => ({ + ...prev, + [name]: name === 'status' ? (value === '' ? undefined : parseInt(value)) : value, + })); + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setPage(1); + loadPurchases(1, filters); + }; + + const handleReset = () => { + setFilters(emptyFilters); + setPage(1); + loadPurchases(1, emptyFilters); + }; + + const goToPage = (targetPage: number) => { + setPage(targetPage); + loadPurchases(targetPage, filters); + }; + + const inputClass = + 'block w-full px-3 py-2 text-slate-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm dark:bg-gray-800 dark:placeholder-gray-500 transition-colors'; + const labelClass = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'; + + return ( +
+
+
+

+ خریدهای اشتراک +

+ + + بازگشت به داشبورد + +
+ + {/* Filters */} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + {error && ( +
+

{error}

+
+ )} + + {/* Results */} +
+
+

+ {result ? `${formatPrice(result.total)} خرید یافت شد` : 'در حال بارگذاری...'} +

+
+ + {isLoading ? ( +
+
+
+ ) : !result || result.data.length === 0 ? ( +
+

هیچ خریدی با این فیلترها یافت نشد

+
+ ) : ( +
+ + + + + + + + + + + + + + {result.data.map((purchase) => ( + + + + + + + + + + ))} + +
کاربرپکیجمحصولمبلغمنبع پرداختوضعیتتاریخ
+
+ {purchase.user?.full_name?.trim() || 'بدون نام'} +
+
+ {purchase.user?.email || '—'} +
+
+ {purchase.user?.mobile || '—'} +
+
+ {purchase.product?.package_name?.title || purchase.product?.package_name?.name || '—'} + +
+ {purchase.product?.title || '—'} +
+
+ {purchase.product ? getProductTypeLabel(purchase.product.type) : ''} +
+
+ {formatPrice(purchase.amount)} تومان + + + {getGatewayLabel(purchase.gateway)} + + + + {getPurchaseStatusLabel(purchase.status)} + + + {formatDate(purchase.created_at)} +
+
+ )} + + {/* Pagination */} + {result && result.last_page > 1 && ( +
+ + + صفحه {result.current_page} از {result.last_page} + + +
+ )} +
+
+
+ ); +} diff --git a/lib/api/purchases.ts b/lib/api/purchases.ts new file mode 100644 index 0000000..1d41dc3 --- /dev/null +++ b/lib/api/purchases.ts @@ -0,0 +1,22 @@ +import { apiClient } from './client'; +import { Purchase, Paginated, PurchaseFilters } from '@/types/purchase'; + +export const purchasesApi = { + // List all subscription purchases with optional filters + getPurchases: async (filters: PurchaseFilters, token: string): Promise> => { + const params = new URLSearchParams(); + + if (filters.email) params.append('email', filters.email); + if (filters.mobile) params.append('mobile', filters.mobile); + if (filters.package_name) params.append('package_name', filters.package_name); + if (filters.gateway) params.append('gateway', filters.gateway); + if (filters.status !== undefined && filters.status !== null) { + params.append('status', String(filters.status)); + } + if (filters.per_page) params.append('per_page', String(filters.per_page)); + if (filters.page) params.append('page', String(filters.page)); + + const qs = params.toString(); + return apiClient.get>(`/admin/purchases${qs ? `?${qs}` : ''}`, token); + }, +}; diff --git a/types/purchase.ts b/types/purchase.ts new file mode 100644 index 0000000..851a0f7 --- /dev/null +++ b/types/purchase.ts @@ -0,0 +1,91 @@ +import { PackageName } from './user'; + +// Nested user on a purchase (subset of the full User model) +export interface PurchaseUser { + id: number; + uuid: string; + first_name: string | null; + last_name: string | null; + full_name: string | null; + email: string | null; + mobile: string | null; +} + +// Nested product on a purchase, with its package +export interface PurchaseProduct { + id: number; + title: string | null; + price: number | null; + type: number | null; + package_name_id: number; + package_name: PackageName | null; +} + +// A purchase = a subscription transaction +export interface Purchase { + id: number; + user_id: number; + product_id: number | null; + amount: number; + uuid: string; + status: number; + authority: string | null; + ref_id: string | null; + gateway: number; + created_at: string; + updated_at: string; + user: PurchaseUser | null; + product: PurchaseProduct | null; +} + +// Laravel length-aware paginator envelope +export interface Paginated { + current_page: number; + data: T[]; + last_page: number; + per_page: number; + total: number; + from: number | null; + to: number | null; + next_page_url: string | null; + prev_page_url: string | null; +} + +// Payment source (gateway) — matches backend Transaction::GATEWAYS +export const PAYMENT_GATEWAYS = { + asanpardakht: { code: 1, label: 'آسان‌پرداخت' }, + zarinpal: { code: 2, label: 'زرین‌پال' }, + digipay: { code: 3, label: 'دیجی‌پی' }, + cafe: { code: 4, label: 'کافه بازار' }, + myket: { code: 5, label: 'مایکت' }, +} as const; + +export type PaymentGatewayKey = keyof typeof PAYMENT_GATEWAYS; + +export const getGatewayLabel = (gateway: number | null): string => { + const found = Object.values(PAYMENT_GATEWAYS).find((g) => g.code === gateway); + return found ? found.label : 'نامشخص'; +}; + +// Purchase status — matches backend Transaction::STATUSES +export const PURCHASE_STATUSES: Record = { + 1: 'در انتظار پرداخت', + 2: 'موفق', + 3: 'مصرف‌شده', +}; + +export const getPurchaseStatusLabel = (status: number | null): string => { + if (status === null || status === undefined) return 'نامشخص'; + return PURCHASE_STATUSES[status] || 'نامشخص'; +}; + +// Filters sent to the purchases endpoint +export interface PurchaseFilters { + email?: string; + mobile?: string; + package_name?: string; + gateway?: string; // gateway name key (e.g. "zarinpal") + status?: number; + per_page?: number; + page?: number; +}