feat(web): add Agent Profile, KYC, Subscription & Payment dashboard pages
Implement four new dashboard pages with full UI: - /dashboard/profile: view/edit profile, agent details, KYC status - /dashboard/kyc: multi-step KYC document submission flow - /dashboard/subscription: plan comparison, quota usage, billing history - /dashboard/payments: transaction history with filters and pagination Also adds API client modules (profile-api, subscription-api, payment-api) and updates dashboard navigation with new page links. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
315
apps/web/app/(dashboard)/dashboard/kyc/page.tsx
Normal file
315
apps/web/app/(dashboard)/dashboard/kyc/page.tsx
Normal file
@@ -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<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; description: string }> = {
|
||||||
|
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<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const [documentType, setDocumentType] = useState('CCCD');
|
||||||
|
const [documentNumber, setDocumentNumber] = useState('');
|
||||||
|
const [frontImage, setFrontImage] = useState<File | null>(null);
|
||||||
|
const [backImage, setBackImage] = useState<File | null>(null);
|
||||||
|
const [selfieImage, setSelfieImage] = useState<File | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Xác minh danh tính (KYC)</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Xác minh danh tính để sử dụng đầy đủ tính năng của GoodGo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KYC Status */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">Trạng thái xác minh</CardTitle>
|
||||||
|
<Badge variant={kycInfo.variant}>{kycInfo.label}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">{kycInfo.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} className="ml-2 font-medium underline">
|
||||||
|
Đóng
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-700">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* KYC Form */}
|
||||||
|
{canSubmit && !success && (
|
||||||
|
<>
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{KYC_STEPS.map((s, i) => (
|
||||||
|
<div key={s.step} className="flex items-center">
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold ${
|
||||||
|
currentStep >= s.step
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.step}
|
||||||
|
</div>
|
||||||
|
<span className="ml-2 hidden text-sm sm:inline">{s.title}</span>
|
||||||
|
{i < KYC_STEPS.length - 1 && (
|
||||||
|
<div className="mx-3 h-px w-8 bg-border sm:w-16" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{KYC_STEPS[currentStep - 1]?.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{KYC_STEPS[currentStep - 1]?.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Step 1: Document type */}
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="docType">Loại giấy tờ</Label>
|
||||||
|
<Select
|
||||||
|
id="docType"
|
||||||
|
value={documentType}
|
||||||
|
onChange={(e) => setDocumentType(e.target.value)}
|
||||||
|
>
|
||||||
|
{DOCUMENT_TYPES.map((dt) => (
|
||||||
|
<option key={dt.value} value={dt.value}>
|
||||||
|
{dt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="docNumber">Số giấy tờ</Label>
|
||||||
|
<Input
|
||||||
|
id="docNumber"
|
||||||
|
value={documentNumber}
|
||||||
|
onChange={(e) => setDocumentNumber(e.target.value)}
|
||||||
|
placeholder="Nhập số CCCD/CMND/Hộ chiếu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Upload images */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="frontImg">Ảnh mặt trước *</Label>
|
||||||
|
<Input
|
||||||
|
id="frontImg"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setFrontImage(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{frontImage && (
|
||||||
|
<p className="text-xs text-muted-foreground">{frontImage.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="backImg">Ảnh mặt sau</Label>
|
||||||
|
<Input
|
||||||
|
id="backImg"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setBackImage(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{backImage && (
|
||||||
|
<p className="text-xs text-muted-foreground">{backImage.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="selfieImg">Ảnh selfie cầm giấy tờ</Label>
|
||||||
|
<Input
|
||||||
|
id="selfieImg"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setSelfieImage(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{selfieImage && (
|
||||||
|
<p className="text-xs text-muted-foreground">{selfieImage.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Confirm */}
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<div className="space-y-3 rounded-lg border bg-muted/50 p-4">
|
||||||
|
<h3 className="font-semibold">Kiểm tra thông tin</h3>
|
||||||
|
<div className="grid gap-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Loại giấy tờ</span>
|
||||||
|
<span>{DOCUMENT_TYPES.find((d) => d.value === documentType)?.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Số giấy tờ</span>
|
||||||
|
<span>{documentNumber}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Ảnh mặt trước</span>
|
||||||
|
<span>{frontImage ? frontImage.name : 'Chưa tải'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Ảnh mặt sau</span>
|
||||||
|
<span>{backImage ? backImage.name : 'Không có'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Ảnh selfie</span>
|
||||||
|
<span>{selfieImage ? selfieImage.name : 'Không có'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation buttons */}
|
||||||
|
<div className="flex justify-between pt-2">
|
||||||
|
{currentStep > 1 ? (
|
||||||
|
<Button variant="outline" onClick={() => setCurrentStep((s) => s - 1)}>
|
||||||
|
Quay lại
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
{currentStep < 3 ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (currentStep === 1 && !documentNumber.trim()) {
|
||||||
|
setError('Vui lòng nhập số giấy tờ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setCurrentStep((s) => s + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tiếp tục
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleSubmit} disabled={submitting}>
|
||||||
|
{submitting ? 'Đang gửi...' : 'Gửi hồ sơ xác minh'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Already verified */}
|
||||||
|
{kycStatus === 'VERIFIED' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 text-3xl">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold">Danh tính đã được xác minh</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending status */}
|
||||||
|
{kycStatus === 'PENDING' && !success && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-yellow-100 text-3xl">
|
||||||
|
⏳
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold">Đang xem xét hồ sơ</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||||
|
Độ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.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
apps/web/app/(dashboard)/dashboard/payments/page.tsx
Normal file
248
apps/web/app/(dashboard)/dashboard/payments/page.tsx
Normal file
@@ -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<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
VNPAY: 'VNPay',
|
||||||
|
MOMO: 'MoMo',
|
||||||
|
ZALOPAY: 'ZaloPay',
|
||||||
|
BANK_TRANSFER: 'Chuyển khoản',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PaymentsPage() {
|
||||||
|
const [transactions, setTransactions] = useState<TransactionListDto | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Thanh toán</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Lịch sử giao dịch và quản lý thanh toán
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Tổng giao dịch</CardDescription>
|
||||||
|
<CardTitle className="text-2xl">
|
||||||
|
{loading ? '...' : (transactions?.total ?? 0)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Đã thanh toán</CardDescription>
|
||||||
|
<CardTitle className="text-2xl">
|
||||||
|
{loading ? '...' : formatVND(completedTotal)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>Đang chờ</CardDescription>
|
||||||
|
<CardTitle className="text-2xl">
|
||||||
|
{loading
|
||||||
|
? '...'
|
||||||
|
: (transactions?.items.filter((t) => t.status === 'PENDING' || t.status === 'PROCESSING').length ?? 0)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transactions table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">Lịch sử giao dịch</CardTitle>
|
||||||
|
<CardDescription>Tất cả giao dịch thanh toán của bạn</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Tất cả</option>
|
||||||
|
<option value="PENDING">Chờ xử lý</option>
|
||||||
|
<option value="PROCESSING">Đang xử lý</option>
|
||||||
|
<option value="COMPLETED">Thành công</option>
|
||||||
|
<option value="FAILED">Thất bại</option>
|
||||||
|
<option value="REFUNDED">Hoàn tiền</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||||
|
Đang tải...
|
||||||
|
</div>
|
||||||
|
) : !transactions || transactions.items.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||||
|
Chưa có giao dịch nào
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Ngày</TableHead>
|
||||||
|
<TableHead>Loại</TableHead>
|
||||||
|
<TableHead>Nhà cung cấp</TableHead>
|
||||||
|
<TableHead className="text-right">Số tiền</TableHead>
|
||||||
|
<TableHead>Trạng thái</TableHead>
|
||||||
|
<TableHead>Mã GD</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{transactions.items.map((tx) => {
|
||||||
|
const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const };
|
||||||
|
return (
|
||||||
|
<TableRow key={tx.id}>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{new Date(tx.createdAt).toLocaleDateString('vi-VN')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{TYPE_LABELS[tx.type] ?? tx.type}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{PROVIDER_LABELS[tx.provider] ?? tx.provider}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-semibold">
|
||||||
|
{formatVND(tx.amountVND)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{tx.providerTxId ? tx.providerTxId.slice(0, 12) + '...' : '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile cards */}
|
||||||
|
<div className="space-y-3 sm:hidden">
|
||||||
|
{transactions.items.map((tx) => {
|
||||||
|
const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const };
|
||||||
|
return (
|
||||||
|
<div key={tx.id} className="rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{TYPE_LABELS[tx.type] ?? tx.type}
|
||||||
|
</span>
|
||||||
|
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{new Date(tx.createdAt).toLocaleDateString('vi-VN')} —{' '}
|
||||||
|
{PROVIDER_LABELS[tx.provider] ?? tx.provider}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{formatVND(tx.amountVND)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Trang {page + 1}/{totalPages} ({transactions.total} giao dịch)
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
Trước
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page + 1 >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Sau
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
apps/web/app/(dashboard)/dashboard/profile/page.tsx
Normal file
283
apps/web/app/(dashboard)/dashboard/profile/page.tsx
Normal file
@@ -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<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||||
|
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<AgentProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Hồ sơ cá nhân</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">Quản lý thông tin tài khoản của bạn</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} className="ml-2 font-medium underline">
|
||||||
|
Đóng
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-700">
|
||||||
|
{success}
|
||||||
|
<button onClick={() => setSuccess(null)} className="ml-2 font-medium underline">
|
||||||
|
Đóng
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Profile info */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">Thông tin cá nhân</CardTitle>
|
||||||
|
<CardDescription>Thông tin cơ bản trên hồ sơ của bạn</CardDescription>
|
||||||
|
</div>
|
||||||
|
{!editing && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||||
|
Chỉnh sửa
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||||
|
Đang tải...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fullName">Họ và tên</Label>
|
||||||
|
{editing ? (
|
||||||
|
<Input
|
||||||
|
id="fullName"
|
||||||
|
value={formData.fullName}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, fullName: e.target.value }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{user?.fullName ?? '—'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">Số điện thoại</Label>
|
||||||
|
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{user?.phone ?? '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Số điện thoại không thể thay đổi
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
{editing ? (
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData((p) => ({ ...p, email: e.target.value }))}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{user?.email ?? 'Chưa cập nhật'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Vai trò</Label>
|
||||||
|
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{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'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Đang lưu...' : 'Lưu thay đổi'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(false);
|
||||||
|
if (user) {
|
||||||
|
setFormData({
|
||||||
|
fullName: user.fullName,
|
||||||
|
email: user.email ?? '',
|
||||||
|
phone: user.phone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Status sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Trạng thái tài khoản</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Tài khoản</span>
|
||||||
|
<Badge variant={user?.isActive ? 'default' : 'destructive'}>
|
||||||
|
{user?.isActive ? 'Hoạt động' : 'Bị khóa'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Xác minh KYC</span>
|
||||||
|
<Badge variant={kycInfo.variant}>{kycInfo.label}</Badge>
|
||||||
|
</div>
|
||||||
|
{user?.kycStatus !== 'VERIFIED' && (
|
||||||
|
<a href="/dashboard/kyc">
|
||||||
|
<Button variant="outline" size="sm" className="mt-2 w-full">
|
||||||
|
{user?.kycStatus === 'NONE' ? 'Bắt đầu xác minh' : 'Xem trạng thái KYC'}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Tham gia</span>
|
||||||
|
<span className="text-sm">
|
||||||
|
{user?.createdAt
|
||||||
|
? new Date(user.createdAt).toLocaleDateString('vi-VN')
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Agent details */}
|
||||||
|
{agentProfile && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Thông tin môi giới</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Mã chứng chỉ</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{agentProfile.licenseNumber ?? 'Chưa có'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Công ty</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{agentProfile.agency ?? 'Độc lập'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{agentProfile.qualityScore != null && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Điểm chất lượng</span>
|
||||||
|
<span className="text-sm font-semibold text-primary">
|
||||||
|
{agentProfile.qualityScore}/100
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Xác minh</span>
|
||||||
|
<Badge variant={agentProfile.isVerified ? 'default' : 'outline'}>
|
||||||
|
{agentProfile.isVerified ? 'Đã xác minh' : 'Chưa xác minh'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{agentProfile.serviceAreas.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-muted-foreground">Khu vực hoạt động</span>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{agentProfile.serviceAreas.map((area) => (
|
||||||
|
<Badge key={area} variant="secondary">
|
||||||
|
{area}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
381
apps/web/app/(dashboard)/dashboard/subscription/page.tsx
Normal file
381
apps/web/app/(dashboard)/dashboard/subscription/page.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
FREE: 'Miễn phí',
|
||||||
|
AGENT_PRO: 'Môi giới Pro',
|
||||||
|
INVESTOR: 'Nhà đầu tư',
|
||||||
|
ENTERPRISE: 'Doanh nghiệp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||||
|
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<PlanDto[]>([]);
|
||||||
|
const [billing, setBilling] = useState<BillingHistoryDto | null>(null);
|
||||||
|
const [quotas, setQuotas] = useState<QuotaCheckResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [upgradeTarget, setUpgradeTarget] = useState<PlanDto | null>(null);
|
||||||
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Gói dịch vụ</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Quản lý gói đăng ký và theo dõi hạn mức sử dụng
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} className="ml-2 font-medium underline">
|
||||||
|
Đóng
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
|
Đang tải...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="plan">Gói hiện tại</TabsTrigger>
|
||||||
|
<TabsTrigger value="plans">So sánh gói</TabsTrigger>
|
||||||
|
<TabsTrigger value="history">Lịch sử thanh toán</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Current plan tab */}
|
||||||
|
<TabsContent value="plan" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
Gói {PLAN_TIER_LABELS[currentTier] ?? currentTier}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{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í'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{subStatus && <Badge variant={subStatus.variant}>{subStatus.label}</Badge>}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Quota usage */}
|
||||||
|
{quotas.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold">Hạn mức sử dụng</h3>
|
||||||
|
{quotas.map((q) => {
|
||||||
|
const pct = q.limit > 0 ? (q.used / q.limit) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={q.metric} className="space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{q.metric === 'listings' ? 'Tin đăng' : q.metric === 'saved_searches' ? 'Tìm kiếm đã lưu' : q.metric}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{q.used}/{q.limit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-primary'}`}
|
||||||
|
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Plan comparison tab */}
|
||||||
|
<TabsContent value="plans" className="space-y-6">
|
||||||
|
{/* Billing cycle toggle */}
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant={billingCycle === 'monthly' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setBillingCycle('monthly')}
|
||||||
|
>
|
||||||
|
Theo tháng
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={billingCycle === 'yearly' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setBillingCycle('yearly')}
|
||||||
|
>
|
||||||
|
Theo năm
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
-17%
|
||||||
|
</Badge>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{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 (
|
||||||
|
<Card
|
||||||
|
key={plan.id}
|
||||||
|
className={isCurrent ? 'border-primary ring-1 ring-primary' : ''}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{PLAN_TIER_LABELS[plan.tier] ?? plan.name}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<span className="text-2xl font-bold text-foreground">
|
||||||
|
{formatVND(price)}
|
||||||
|
</span>
|
||||||
|
{Number(price) > 0 && (
|
||||||
|
<span className="text-sm">
|
||||||
|
/{billingCycle === 'monthly' ? 'tháng' : 'năm'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Tin đăng</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{plan.maxListings === -1 ? 'Không giới hạn' : plan.maxListings}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Tìm kiếm lưu</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{plan.maxSavedSearches === -1
|
||||||
|
? 'Không giới hạn'
|
||||||
|
: plan.maxSavedSearches}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{plan.features &&
|
||||||
|
Object.entries(plan.features).map(([key, val]) => (
|
||||||
|
<div key={key} className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">{key}</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{typeof val === 'boolean' ? (val ? '✓' : '✗') : String(val)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCurrent ? (
|
||||||
|
<Button variant="outline" className="w-full" disabled>
|
||||||
|
Gói hiện tại
|
||||||
|
</Button>
|
||||||
|
) : isUpgrade ? (
|
||||||
|
<Button className="w-full" onClick={() => setUpgradeTarget(plan)}>
|
||||||
|
Nâng cấp
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" className="w-full" disabled>
|
||||||
|
—
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Payment history tab */}
|
||||||
|
<TabsContent value="history">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Lịch sử thanh toán</CardTitle>
|
||||||
|
<CardDescription>Các giao dịch liên quan đến gói dịch vụ</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!billing || billing.payments.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
||||||
|
Chưa có giao dịch nào
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{billing.payments.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{p.type}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(p.createdAt).toLocaleDateString('vi-VN')} — {p.provider}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-semibold">{formatVND(p.amountVND)}</p>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
p.status === 'COMPLETED'
|
||||||
|
? 'default'
|
||||||
|
: p.status === 'FAILED'
|
||||||
|
? 'destructive'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.status === 'COMPLETED'
|
||||||
|
? 'Thành công'
|
||||||
|
: p.status === 'FAILED'
|
||||||
|
? 'Thất bại'
|
||||||
|
: p.status === 'PENDING'
|
||||||
|
? 'Chờ xử lý'
|
||||||
|
: p.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upgrade dialog */}
|
||||||
|
<Dialog open={!!upgradeTarget} onOpenChange={(o) => !o && setUpgradeTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Nâng cấp lên {PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
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.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2 rounded-lg border bg-muted/50 p-4 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Gói</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Chu kỳ</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{billingCycle === 'monthly' ? 'Hàng tháng' : 'Hàng năm'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Giá</span>
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{upgradeTarget &&
|
||||||
|
formatVND(
|
||||||
|
billingCycle === 'monthly'
|
||||||
|
? upgradeTarget.priceMonthlyVND
|
||||||
|
: upgradeTarget.priceYearlyVND,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setUpgradeTarget(null)}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUpgrade} disabled={processing}>
|
||||||
|
{processing ? 'Đang xử lý...' : 'Xác nhận nâng cấp'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ const navItems = [
|
|||||||
{ href: '/listings', label: 'Tin đăng', icon: '📋' },
|
{ href: '/listings', label: 'Tin đăng', icon: '📋' },
|
||||||
{ href: '/listings/new', label: 'Đăng tin', icon: '➕' },
|
{ href: '/listings/new', label: 'Đăng tin', icon: '➕' },
|
||||||
{ href: '/analytics', label: 'Phân tích', 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 }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -33,7 +36,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md px-2 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground sm:px-3',
|
'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'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-muted-foreground',
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
|
|||||||
59
apps/web/lib/payment-api.ts
Normal file
59
apps/web/lib/payment-api.ts
Normal file
@@ -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<CreatePaymentResult>('/payments', data),
|
||||||
|
|
||||||
|
getPaymentStatus: (id: string) =>
|
||||||
|
apiClient.get<PaymentStatusDto>(`/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<TransactionListDto>(`/payments${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
34
apps/web/lib/profile-api.ts
Normal file
34
apps/web/lib/profile-api.ts
Normal file
@@ -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<UserProfile>('/auth/profile'),
|
||||||
|
|
||||||
|
getAgentProfile: () => apiClient.get<AgentProfile | null>('/auth/profile/agent'),
|
||||||
|
|
||||||
|
updateProfile: (data: UpdateProfilePayload) =>
|
||||||
|
apiClient.patch<{ message: string }>('/auth/profile', data),
|
||||||
|
};
|
||||||
80
apps/web/lib/subscription-api.ts
Normal file
80
apps/web/lib/subscription-api.ts
Normal file
@@ -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<string, boolean | number | string>;
|
||||||
|
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<PlanDto[]>('/subscriptions/plans'),
|
||||||
|
|
||||||
|
getPlanByTier: (tier: string) =>
|
||||||
|
apiClient.get<PlanDto>(`/subscriptions/plans/${tier}`),
|
||||||
|
|
||||||
|
getBillingHistory: (limit = 20, offset = 0) =>
|
||||||
|
apiClient.get<BillingHistoryDto>(
|
||||||
|
`/subscriptions/billing?limit=${limit}&offset=${offset}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
checkQuota: (metric: string) =>
|
||||||
|
apiClient.get<QuotaCheckResult>(`/subscriptions/quota/${metric}`),
|
||||||
|
|
||||||
|
createSubscription: (planTier: string, billingCycle: 'monthly' | 'yearly') =>
|
||||||
|
apiClient.post<CreateSubscriptionResult>('/subscriptions', {
|
||||||
|
planTier,
|
||||||
|
billingCycle,
|
||||||
|
}),
|
||||||
|
|
||||||
|
upgradeSubscription: (newPlanTier: string) =>
|
||||||
|
apiClient.post<{ message: string }>('/subscriptions/upgrade', {
|
||||||
|
newPlanTier,
|
||||||
|
}),
|
||||||
|
|
||||||
|
cancelSubscription: (_reason: string) =>
|
||||||
|
apiClient.delete<{ message: string }>('/subscriptions'),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user