diff --git a/apps/web/app/(dashboard)/dashboard/kyc/page.tsx b/apps/web/app/(dashboard)/dashboard/kyc/page.tsx new file mode 100644 index 0000000..364021e --- /dev/null +++ b/apps/web/app/(dashboard)/dashboard/kyc/page.tsx @@ -0,0 +1,315 @@ +'use client'; + +import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; +import { apiClient } from '@/lib/api-client'; +import { useAuthStore } from '@/lib/auth-store'; + +const KYC_STATUS_MAP: Record = { + NONE: { label: 'Chưa xác minh', variant: 'outline', description: 'Bạn chưa gửi hồ sơ xác minh danh tính. Hoàn tất KYC để mở khóa đầy đủ tính năng.' }, + PENDING: { label: 'Đang chờ duyệt', variant: 'secondary', description: 'Hồ sơ của bạn đã được gửi và đang chờ đội ngũ quản trị xem xét. Vui lòng chờ 1-3 ngày làm việc.' }, + VERIFIED: { label: 'Đã xác minh', variant: 'default', description: 'Danh tính của bạn đã được xác minh thành công. Bạn có thể sử dụng đầy đủ tính năng.' }, + REJECTED: { label: 'Bị từ chối', variant: 'destructive', description: 'Hồ sơ xác minh bị từ chối. Vui lòng kiểm tra lại và gửi lại hồ sơ.' }, +}; + +const DOCUMENT_TYPES = [ + { value: 'CCCD', label: 'Căn cước công dân (CCCD)' }, + { value: 'CMND', label: 'Chứng minh nhân dân (CMND)' }, + { value: 'PASSPORT', label: 'Hộ chiếu' }, + { value: 'BUSINESS_LICENSE', label: 'Giấy phép kinh doanh' }, +]; + +const KYC_STEPS = [ + { step: 1, title: 'Loại giấy tờ', description: 'Chọn loại giấy tờ tùy thân' }, + { step: 2, title: 'Tải ảnh', description: 'Tải ảnh mặt trước, mặt sau và ảnh selfie' }, + { step: 3, title: 'Xác nhận', description: 'Kiểm tra và gửi hồ sơ' }, +]; + +export default function KycPage() { + const { user, fetchProfile } = useAuthStore(); + const [currentStep, setCurrentStep] = useState(1); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const [documentType, setDocumentType] = useState('CCCD'); + const [documentNumber, setDocumentNumber] = useState(''); + const [frontImage, setFrontImage] = useState(null); + const [backImage, setBackImage] = useState(null); + const [selfieImage, setSelfieImage] = useState(null); + + const kycStatus = user?.kycStatus ?? 'NONE'; + const kycInfo = KYC_STATUS_MAP[kycStatus] ?? { label: 'Chưa xác minh', variant: 'outline' as const, description: 'Bạn chưa gửi hồ sơ xác minh danh tính.' }; + const canSubmit = kycStatus === 'NONE' || kycStatus === 'REJECTED'; + + const handleSubmit = async () => { + if (!documentNumber.trim()) { + setError('Vui lòng nhập số giấy tờ'); + return; + } + if (!frontImage) { + setError('Vui lòng tải ảnh mặt trước'); + return; + } + + setSubmitting(true); + setError(null); + try { + await apiClient.patch('/auth/profile', { + kycData: { + documentType, + documentNumber: documentNumber.trim(), + submittedAt: new Date().toISOString(), + }, + }); + await fetchProfile(); + setSuccess(true); + } catch (e) { + setError(e instanceof Error ? e.message : 'Gửi hồ sơ thất bại'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

Xác minh danh tính (KYC)

+

+ Xác minh danh tính để sử dụng đầy đủ tính năng của GoodGo +

+
+ + {/* KYC Status */} + + +
+ Trạng thái xác minh + {kycInfo.label} +
+
+ +

{kycInfo.description}

+
+
+ + {error && ( +
+ {error} + +
+ )} + + {success && ( +
+ Hồ sơ KYC đã được gửi thành công. Vui lòng chờ 1-3 ngày làm việc để được xem xét. +
+ )} + + {/* KYC Form */} + {canSubmit && !success && ( + <> + {/* Step indicator */} +
+ {KYC_STEPS.map((s, i) => ( +
+
= s.step + ? 'bg-primary text-primary-foreground' + : 'bg-muted text-muted-foreground' + }`} + > + {s.step} +
+ {s.title} + {i < KYC_STEPS.length - 1 && ( +
+ )} +
+ ))} +
+ + + + + {KYC_STEPS[currentStep - 1]?.title} + + + {KYC_STEPS[currentStep - 1]?.description} + + + + {/* Step 1: Document type */} + {currentStep === 1 && ( + <> +
+ + +
+
+ + setDocumentNumber(e.target.value)} + placeholder="Nhập số CCCD/CMND/Hộ chiếu" + /> +
+ + )} + + {/* Step 2: Upload images */} + {currentStep === 2 && ( + <> +
+ + setFrontImage(e.target.files?.[0] ?? null)} + /> + {frontImage && ( +

{frontImage.name}

+ )} +
+
+ + setBackImage(e.target.files?.[0] ?? null)} + /> + {backImage && ( +

{backImage.name}

+ )} +
+
+ + setSelfieImage(e.target.files?.[0] ?? null)} + /> + {selfieImage && ( +

{selfieImage.name}

+ )} +
+ + )} + + {/* Step 3: Confirm */} + {currentStep === 3 && ( +
+

Kiểm tra thông tin

+
+
+ Loại giấy tờ + {DOCUMENT_TYPES.find((d) => d.value === documentType)?.label} +
+
+ Số giấy tờ + {documentNumber} +
+
+ Ảnh mặt trước + {frontImage ? frontImage.name : 'Chưa tải'} +
+
+ Ảnh mặt sau + {backImage ? backImage.name : 'Không có'} +
+
+ Ảnh selfie + {selfieImage ? selfieImage.name : 'Không có'} +
+
+
+ )} + + {/* Navigation buttons */} +
+ {currentStep > 1 ? ( + + ) : ( +
+ )} + {currentStep < 3 ? ( + + ) : ( + + )} +
+ + + + )} + + {/* Already verified */} + {kycStatus === 'VERIFIED' && ( + + +
+ ✓ +
+

Danh tính đã được xác minh

+

+ Tài khoản của bạn đã được xác minh đầy đủ. Bạn có thể sử dụng tất cả tính năng của + GoodGo. +

+
+
+ )} + + {/* Pending status */} + {kycStatus === 'PENDING' && !success && ( + + +
+ ⏳ +
+

Đang xem xét hồ sơ

+

+ Đội ngũ quản trị đang xem xét hồ sơ của bạn. Thời gian dự kiến: 1-3 ngày làm việc. +

+
+
+ )} +
+ ); +} diff --git a/apps/web/app/(dashboard)/dashboard/payments/page.tsx b/apps/web/app/(dashboard)/dashboard/payments/page.tsx new file mode 100644 index 0000000..b104f4e --- /dev/null +++ b/apps/web/app/(dashboard)/dashboard/payments/page.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Select } from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { paymentApi, type TransactionListDto } from '@/lib/payment-api'; + +function formatVND(amount: string | number): string { + const num = typeof amount === 'string' ? Number(amount) : amount; + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ đ`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu đ`; + return num.toLocaleString('vi-VN') + ' đ'; +} + +const STATUS_LABELS: Record = { + PENDING: { label: 'Chờ xử lý', variant: 'secondary' }, + PROCESSING: { label: 'Đang xử lý', variant: 'secondary' }, + COMPLETED: { label: 'Thành công', variant: 'default' }, + FAILED: { label: 'Thất bại', variant: 'destructive' }, + REFUNDED: { label: 'Hoàn tiền', variant: 'outline' }, +}; + +const TYPE_LABELS: Record = { + SUBSCRIPTION: 'Gói dịch vụ', + LISTING_FEE: 'Phí đăng tin', + DEPOSIT: 'Đặt cọc', + FEATURED_LISTING: 'Tin nổi bật', +}; + +const PROVIDER_LABELS: Record = { + VNPAY: 'VNPay', + MOMO: 'MoMo', + ZALOPAY: 'ZaloPay', + BANK_TRANSFER: 'Chuyển khoản', +}; + +export default function PaymentsPage() { + const [transactions, setTransactions] = useState(null); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState(''); + const [page, setPage] = useState(0); + const limit = 20; + + useEffect(() => { + setLoading(true); + paymentApi + .getTransactions({ + status: statusFilter || undefined, + limit, + offset: page * limit, + }) + .then((data) => setTransactions(data)) + .catch(() => setTransactions(null)) + .finally(() => setLoading(false)); + }, [statusFilter, page]); + + const totalPages = transactions ? Math.ceil(transactions.total / limit) : 0; + + // Summary stats + const completedTotal = + transactions?.items + .filter((t) => t.status === 'COMPLETED') + .reduce((sum, t) => sum + Number(t.amountVND), 0) ?? 0; + + return ( +
+
+

Thanh toán

+

+ Lịch sử giao dịch và quản lý thanh toán +

+
+ + {/* Summary cards */} +
+ + + Tổng giao dịch + + {loading ? '...' : (transactions?.total ?? 0)} + + + + + + Đã thanh toán + + {loading ? '...' : formatVND(completedTotal)} + + + + + + Đang chờ + + {loading + ? '...' + : (transactions?.items.filter((t) => t.status === 'PENDING' || t.status === 'PROCESSING').length ?? 0)} + + + +
+ + {/* Transactions table */} + + +
+ Lịch sử giao dịch + Tất cả giao dịch thanh toán của bạn +
+
+ +
+
+ + {loading ? ( +
+ Đang tải... +
+ ) : !transactions || transactions.items.length === 0 ? ( +
+ Chưa có giao dịch nào +
+ ) : ( + <> + {/* Desktop table */} +
+ + + + Ngày + Loại + Nhà cung cấp + Số tiền + Trạng thái + Mã GD + + + + {transactions.items.map((tx) => { + const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const }; + return ( + + + {new Date(tx.createdAt).toLocaleDateString('vi-VN')} + + + {TYPE_LABELS[tx.type] ?? tx.type} + + + {PROVIDER_LABELS[tx.provider] ?? tx.provider} + + + {formatVND(tx.amountVND)} + + + {statusInfo.label} + + + {tx.providerTxId ? tx.providerTxId.slice(0, 12) + '...' : '—'} + + + ); + })} + +
+
+ + {/* Mobile cards */} +
+ {transactions.items.map((tx) => { + const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const }; + return ( +
+
+ + {TYPE_LABELS[tx.type] ?? tx.type} + + {statusInfo.label} +
+
+ + {new Date(tx.createdAt).toLocaleDateString('vi-VN')} —{' '} + {PROVIDER_LABELS[tx.provider] ?? tx.provider} + + {formatVND(tx.amountVND)} +
+
+ ); + })} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Trang {page + 1}/{totalPages} ({transactions.total} giao dịch) +

+
+ + +
+
+ )} + + )} +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/dashboard/profile/page.tsx b/apps/web/app/(dashboard)/dashboard/profile/page.tsx new file mode 100644 index 0000000..f1227bc --- /dev/null +++ b/apps/web/app/(dashboard)/dashboard/profile/page.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAuthStore } from '@/lib/auth-store'; +import { profileApi, type AgentProfile } from '@/lib/profile-api'; + +const KYC_STATUS_MAP: Record = { + NONE: { label: 'Chưa xác minh', variant: 'outline' }, + PENDING: { label: 'Đang chờ duyệt', variant: 'secondary' }, + VERIFIED: { label: 'Đã xác minh', variant: 'default' }, + REJECTED: { label: 'Bị từ chối', variant: 'destructive' }, +}; + +export default function ProfilePage() { + const { user, fetchProfile } = useAuthStore(); + const [agentProfile, setAgentProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [formData, setFormData] = useState({ + fullName: '', + email: '', + phone: '', + }); + + useEffect(() => { + setLoading(true); + profileApi + .getAgentProfile() + .then((agent) => setAgentProfile(agent)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + if (user) { + setFormData({ + fullName: user.fullName, + email: user.email ?? '', + phone: user.phone, + }); + } + }, [user]); + + const handleSave = async () => { + setSaving(true); + setError(null); + setSuccess(null); + try { + await profileApi.updateProfile({ + fullName: formData.fullName, + email: formData.email || undefined, + }); + await fetchProfile(); + setSuccess('Cập nhật hồ sơ thành công'); + setEditing(false); + } catch (e) { + setError(e instanceof Error ? e.message : 'Cập nhật thất bại'); + } finally { + setSaving(false); + } + }; + + const kycInfo = KYC_STATUS_MAP[user?.kycStatus ?? 'NONE'] ?? { label: 'Chưa xác minh', variant: 'outline' as const }; + + return ( +
+
+

Hồ sơ cá nhân

+

Quản lý thông tin tài khoản của bạn

+
+ + {error && ( +
+ {error} + +
+ )} + + {success && ( +
+ {success} + +
+ )} + +
+ {/* Profile info */} + + +
+ Thông tin cá nhân + Thông tin cơ bản trên hồ sơ của bạn +
+ {!editing && ( + + )} +
+ + {loading ? ( +
+ Đang tải... +
+ ) : ( + <> +
+ + {editing ? ( + setFormData((p) => ({ ...p, fullName: e.target.value }))} + /> + ) : ( +

+ {user?.fullName ?? '—'} +

+ )} +
+ +
+ +

+ {user?.phone ?? '—'} +

+

+ Số điện thoại không thể thay đổi +

+
+ +
+ + {editing ? ( + setFormData((p) => ({ ...p, email: e.target.value }))} + placeholder="email@example.com" + /> + ) : ( +

+ {user?.email ?? 'Chưa cập nhật'} +

+ )} +
+ +
+ +

+ {user?.role === 'AGENT' ? 'Môi giới' : user?.role === 'ADMIN' ? 'Quản trị viên' : user?.role === 'SELLER' ? 'Người bán' : 'Người mua'} +

+
+ + {editing && ( +
+ + +
+ )} + + )} +
+
+ + {/* Status sidebar */} +
+ + + Trạng thái tài khoản + + +
+ Tài khoản + + {user?.isActive ? 'Hoạt động' : 'Bị khóa'} + +
+
+ Xác minh KYC + {kycInfo.label} +
+ {user?.kycStatus !== 'VERIFIED' && ( + + + + )} +
+ Tham gia + + {user?.createdAt + ? new Date(user.createdAt).toLocaleDateString('vi-VN') + : '—'} + +
+
+
+ + {/* Agent details */} + {agentProfile && ( + + + Thông tin môi giới + + +
+ Mã chứng chỉ + + {agentProfile.licenseNumber ?? 'Chưa có'} + +
+
+ Công ty + + {agentProfile.agency ?? 'Độc lập'} + +
+ {agentProfile.qualityScore != null && ( +
+ Điểm chất lượng + + {agentProfile.qualityScore}/100 + +
+ )} +
+ Xác minh + + {agentProfile.isVerified ? 'Đã xác minh' : 'Chưa xác minh'} + +
+ {agentProfile.serviceAreas.length > 0 && ( +
+ Khu vực hoạt động +
+ {agentProfile.serviceAreas.map((area) => ( + + {area} + + ))} +
+
+ )} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/dashboard/subscription/page.tsx b/apps/web/app/(dashboard)/dashboard/subscription/page.tsx new file mode 100644 index 0000000..2b9ebb1 --- /dev/null +++ b/apps/web/app/(dashboard)/dashboard/subscription/page.tsx @@ -0,0 +1,381 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + subscriptionApi, + type BillingHistoryDto, + type PlanDto, + type QuotaCheckResult, +} from '@/lib/subscription-api'; + +function formatVND(amount: string | number): string { + const num = typeof amount === 'string' ? Number(amount) : amount; + if (num === 0) return 'Miễn phí'; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu đ`; + return num.toLocaleString('vi-VN') + ' đ'; +} + +const PLAN_TIER_ORDER = ['FREE', 'AGENT_PRO', 'INVESTOR', 'ENTERPRISE']; +const PLAN_TIER_LABELS: Record = { + FREE: 'Miễn phí', + AGENT_PRO: 'Môi giới Pro', + INVESTOR: 'Nhà đầu tư', + ENTERPRISE: 'Doanh nghiệp', +}; + +const STATUS_MAP: Record = { + ACTIVE: { label: 'Đang hoạt động', variant: 'default' }, + PAST_DUE: { label: 'Quá hạn', variant: 'destructive' }, + CANCELLED: { label: 'Đã hủy', variant: 'outline' }, + EXPIRED: { label: 'Hết hạn', variant: 'secondary' }, +}; + +export default function SubscriptionPage() { + const [plans, setPlans] = useState([]); + const [billing, setBilling] = useState(null); + const [quotas, setQuotas] = useState([]); + const [loading, setLoading] = useState(true); + const [upgradeTarget, setUpgradeTarget] = useState(null); + const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly'); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('plan'); + + useEffect(() => { + setLoading(true); + Promise.all([ + subscriptionApi.getPlans().catch(() => []), + subscriptionApi.getBillingHistory().catch(() => null), + Promise.all([ + subscriptionApi.checkQuota('listings').catch(() => null), + subscriptionApi.checkQuota('saved_searches').catch(() => null), + ]), + ]) + .then(([plansData, billingData, quotaResults]) => { + setPlans(plansData.sort((a, b) => PLAN_TIER_ORDER.indexOf(a.tier) - PLAN_TIER_ORDER.indexOf(b.tier))); + setBilling(billingData); + setQuotas(quotaResults.filter((q): q is QuotaCheckResult => q !== null)); + }) + .finally(() => setLoading(false)); + }, []); + + const currentTier = billing?.subscription?.planTier ?? 'FREE'; + const currentTierIndex = PLAN_TIER_ORDER.indexOf(currentTier); + const subStatus = billing?.subscription?.status + ? STATUS_MAP[billing.subscription.status] ?? { label: 'Đang hoạt động', variant: 'default' as const } + : null; + + const handleUpgrade = async () => { + if (!upgradeTarget) return; + setProcessing(true); + setError(null); + try { + if (billing?.subscription) { + await subscriptionApi.upgradeSubscription(upgradeTarget.tier); + } else { + await subscriptionApi.createSubscription(upgradeTarget.tier, billingCycle); + } + // Reload billing data + const newBilling = await subscriptionApi.getBillingHistory().catch(() => null); + setBilling(newBilling); + setUpgradeTarget(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Nâng cấp thất bại'); + } finally { + setProcessing(false); + } + }; + + return ( +
+
+

Gói dịch vụ

+

+ Quản lý gói đăng ký và theo dõi hạn mức sử dụng +

+
+ + {error && ( +
+ {error} + +
+ )} + + {loading ? ( +
+ Đang tải... +
+ ) : ( + + + Gói hiện tại + So sánh gói + Lịch sử thanh toán + + + {/* Current plan tab */} + + + +
+
+ + Gói {PLAN_TIER_LABELS[currentTier] ?? currentTier} + + + {billing?.subscription + ? `Kỳ hiện tại: ${new Date(billing.subscription.currentPeriodStart).toLocaleDateString('vi-VN')} — ${new Date(billing.subscription.currentPeriodEnd).toLocaleDateString('vi-VN')}` + : 'Bạn đang sử dụng gói miễn phí'} + +
+ {subStatus && {subStatus.label}} +
+
+ + {/* Quota usage */} + {quotas.length > 0 && ( +
+

Hạn mức sử dụng

+ {quotas.map((q) => { + const pct = q.limit > 0 ? (q.used / q.limit) * 100 : 0; + return ( +
+
+ + {q.metric === 'listings' ? 'Tin đăng' : q.metric === 'saved_searches' ? 'Tìm kiếm đã lưu' : q.metric} + + + {q.used}/{q.limit} + +
+
+
90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-primary'}`} + style={{ width: `${Math.min(pct, 100)}%` }} + /> +
+
+ ); + })} +
+ )} + + + + + {/* Plan comparison tab */} + + {/* Billing cycle toggle */} +
+ + +
+ +
+ {plans.map((plan) => { + const tierIndex = PLAN_TIER_ORDER.indexOf(plan.tier); + const isCurrent = plan.tier === currentTier; + const isUpgrade = tierIndex > currentTierIndex; + const price = billingCycle === 'monthly' ? plan.priceMonthlyVND : plan.priceYearlyVND; + + return ( + + + + {PLAN_TIER_LABELS[plan.tier] ?? plan.name} + + + + {formatVND(price)} + + {Number(price) > 0 && ( + + /{billingCycle === 'monthly' ? 'tháng' : 'năm'} + + )} + + + +
+
+ Tin đăng + + {plan.maxListings === -1 ? 'Không giới hạn' : plan.maxListings} + +
+
+ Tìm kiếm lưu + + {plan.maxSavedSearches === -1 + ? 'Không giới hạn' + : plan.maxSavedSearches} + +
+ {plan.features && + Object.entries(plan.features).map(([key, val]) => ( +
+ {key} + + {typeof val === 'boolean' ? (val ? '✓' : '✗') : String(val)} + +
+ ))} +
+ + {isCurrent ? ( + + ) : isUpgrade ? ( + + ) : ( + + )} +
+
+ ); + })} +
+
+ + {/* Payment history tab */} + + + + Lịch sử thanh toán + Các giao dịch liên quan đến gói dịch vụ + + + {!billing || billing.payments.length === 0 ? ( +
+ Chưa có giao dịch nào +
+ ) : ( +
+ {billing.payments.map((p) => ( +
+
+

{p.type}

+

+ {new Date(p.createdAt).toLocaleDateString('vi-VN')} — {p.provider} +

+
+
+

{formatVND(p.amountVND)}

+ + {p.status === 'COMPLETED' + ? 'Thành công' + : p.status === 'FAILED' + ? 'Thất bại' + : p.status === 'PENDING' + ? 'Chờ xử lý' + : p.status} + +
+
+ ))} +
+ )} +
+
+
+ + )} + + {/* Upgrade dialog */} + !o && setUpgradeTarget(null)}> + + + + Nâng cấp lên {PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name} + + + Xác nhận nâng cấp gói dịch vụ. Bạn sẽ được chuyển hướng đến trang thanh toán. + + +
+
+ Gói + + {PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name} + +
+
+ Chu kỳ + + {billingCycle === 'monthly' ? 'Hàng tháng' : 'Hàng năm'} + +
+
+ Giá + + {upgradeTarget && + formatVND( + billingCycle === 'monthly' + ? upgradeTarget.priceMonthlyVND + : upgradeTarget.priceYearlyVND, + )} + +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index 548cc19..a6b8512 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -11,6 +11,9 @@ const navItems = [ { href: '/listings', label: 'Tin đăng', icon: '📋' }, { href: '/listings/new', label: 'Đăng tin', icon: '➕' }, { href: '/analytics', label: 'Phân tích', icon: '📊' }, + { href: '/dashboard/profile', label: 'Hồ sơ', icon: '👤' }, + { href: '/dashboard/subscription', label: 'Gói dịch vụ', icon: '💎' }, + { href: '/dashboard/payments', label: 'Thanh toán', icon: '💳' }, ]; export default function DashboardLayout({ children }: { children: React.ReactNode }) { @@ -33,7 +36,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod aria-label={item.label} className={cn( 'rounded-md px-2 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground sm:px-3', - pathname === item.href + pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href)) ? 'bg-accent text-accent-foreground' : 'text-muted-foreground', )} diff --git a/apps/web/lib/payment-api.ts b/apps/web/lib/payment-api.ts new file mode 100644 index 0000000..75097eb --- /dev/null +++ b/apps/web/lib/payment-api.ts @@ -0,0 +1,59 @@ +import { apiClient } from './api-client'; + +export interface CreatePaymentPayload { + provider: 'VNPAY' | 'MOMO' | 'ZALOPAY' | 'BANK_TRANSFER'; + type: 'SUBSCRIPTION' | 'LISTING_FEE' | 'DEPOSIT' | 'FEATURED_LISTING'; + amountVND: number; + description: string; + returnUrl: string; + idempotencyKey?: string; +} + +export interface CreatePaymentResult { + paymentId: string; + paymentUrl: string; + providerTxId: string; +} + +export interface PaymentStatusDto { + id: string; + provider: string; + type: string; + amountVND: string; + status: string; + providerTxId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface TransactionListDto { + items: Array<{ + id: string; + provider: string; + type: string; + amountVND: string; + status: string; + providerTxId: string | null; + createdAt: string; + }>; + total: number; + limit: number; + offset: number; +} + +export const paymentApi = { + createPayment: (data: CreatePaymentPayload) => + apiClient.post('/payments', data), + + getPaymentStatus: (id: string) => + apiClient.get(`/payments/${id}`), + + getTransactions: (params: { status?: string; limit?: number; offset?: number } = {}) => { + const query = new URLSearchParams(); + if (params.status) query.set('status', params.status); + if (params.limit) query.set('limit', String(params.limit)); + if (params.offset) query.set('offset', String(params.offset)); + const qs = query.toString(); + return apiClient.get(`/payments${qs ? `?${qs}` : ''}`); + }, +}; diff --git a/apps/web/lib/profile-api.ts b/apps/web/lib/profile-api.ts new file mode 100644 index 0000000..6ac4fc8 --- /dev/null +++ b/apps/web/lib/profile-api.ts @@ -0,0 +1,34 @@ +import { apiClient } from './api-client'; +import type { UserProfile } from './auth-api'; + +export interface AgentProfile { + id: string; + email: string | null; + phone: string; + fullName: string; + avatarUrl: string | null; + role: string; + kycStatus: string; + isActive: boolean; + createdAt: string; + licenseNumber: string | null; + agency: string | null; + qualityScore: number | null; + serviceAreas: string[]; + isVerified: boolean; +} + +export interface UpdateProfilePayload { + fullName?: string; + email?: string; + phone?: string; +} + +export const profileApi = { + getProfile: () => apiClient.get('/auth/profile'), + + getAgentProfile: () => apiClient.get('/auth/profile/agent'), + + updateProfile: (data: UpdateProfilePayload) => + apiClient.patch<{ message: string }>('/auth/profile', data), +}; diff --git a/apps/web/lib/subscription-api.ts b/apps/web/lib/subscription-api.ts new file mode 100644 index 0000000..115ed78 --- /dev/null +++ b/apps/web/lib/subscription-api.ts @@ -0,0 +1,80 @@ +import { apiClient } from './api-client'; + +export interface PlanDto { + id: string; + tier: string; + name: string; + priceMonthlyVND: string; + priceYearlyVND: string; + maxListings: number; + maxSavedSearches: number; + features: Record; + isActive: boolean; +} + +export interface SubscriptionInfo { + id: string; + planTier: string; + status: string; + currentPeriodStart: string; + currentPeriodEnd: string; + cancelledAt: string | null; + createdAt: string; +} + +export interface BillingHistoryDto { + subscription: SubscriptionInfo | null; + payments: Array<{ + id: string; + provider: string; + type: string; + amountVND: string; + status: string; + createdAt: string; + }>; + total: number; +} + +export interface QuotaCheckResult { + metric: string; + used: number; + limit: number; + remaining: number; +} + +export interface CreateSubscriptionResult { + subscriptionId: string; + planTier: string; + status: string; + currentPeriodStart: string; + currentPeriodEnd: string; +} + +export const subscriptionApi = { + getPlans: () => apiClient.get('/subscriptions/plans'), + + getPlanByTier: (tier: string) => + apiClient.get(`/subscriptions/plans/${tier}`), + + getBillingHistory: (limit = 20, offset = 0) => + apiClient.get( + `/subscriptions/billing?limit=${limit}&offset=${offset}`, + ), + + checkQuota: (metric: string) => + apiClient.get(`/subscriptions/quota/${metric}`), + + createSubscription: (planTier: string, billingCycle: 'monthly' | 'yearly') => + apiClient.post('/subscriptions', { + planTier, + billingCycle, + }), + + upgradeSubscription: (newPlanTier: string) => + apiClient.post<{ message: string }>('/subscriptions/upgrade', { + newPlanTier, + }), + + cancelSubscription: (_reason: string) => + apiClient.delete<{ message: string }>('/subscriptions'), +};