Implement extension request and gift functionalities in billing system
- Added new endpoints for creating and managing extension requests, allowing users to request billing period extensions. - Implemented admin approval processes for extension requests via Telegram, including inline buttons for approval and rejection. - Introduced a gifting feature for admins to grant additional billing days directly to users without a request. - Updated the support ticket model to accommodate extension requests and their associated properties. - Enhanced the Telegram notifier to inform admins of new extension requests and notify users of approval or rejection. - Updated frontend components to support the new extension request and gifting functionalities, including user interfaces for managing these features. - Revised API documentation to reflect the new endpoints and their usage in the billing context.
This commit is contained in:
@@ -37,3 +37,7 @@ export function rejectPaymentRequest(id: string, reason?: string) {
|
||||
body: { reason: reason ?? null },
|
||||
})
|
||||
}
|
||||
|
||||
export function grantBillingGift(userId: string, days: number) {
|
||||
return apiRequest<void>('/admin/billing/gift', { method: 'POST', body: { userId, days } })
|
||||
}
|
||||
|
||||
@@ -9,7 +9,16 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/di
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
|
||||
import { TicketAttachmentImage } from '@/features/support/TicketAttachmentImage'
|
||||
import { addAdminComment, approveRoleRequest, closeTicket, getAdminTicket, rejectRoleRequest, resolveTicket } from './api'
|
||||
import {
|
||||
addAdminComment,
|
||||
approveExtensionRequest,
|
||||
approveRoleRequest,
|
||||
closeTicket,
|
||||
getAdminTicket,
|
||||
rejectExtensionRequest,
|
||||
rejectRoleRequest,
|
||||
resolveTicket,
|
||||
} from './api'
|
||||
|
||||
const MAX_FILES = 5
|
||||
|
||||
@@ -57,7 +66,8 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
|
||||
})
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: () => approveRoleRequest(ticketId),
|
||||
mutationFn: () =>
|
||||
data?.type === 'ExtensionRequest' ? approveExtensionRequest(ticketId) : approveRoleRequest(ticketId),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.support.approved'))
|
||||
await invalidate()
|
||||
@@ -66,7 +76,8 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
|
||||
})
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: () => rejectRoleRequest(ticketId),
|
||||
mutationFn: () =>
|
||||
data?.type === 'ExtensionRequest' ? rejectExtensionRequest(ticketId) : rejectRoleRequest(ticketId),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.support.rejected'))
|
||||
await invalidate()
|
||||
@@ -105,6 +116,12 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.type === 'ExtensionRequest' && (
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
{t('support.requestedExtension', { days: data.requestedDays })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.comments.map((comment) => (
|
||||
<div key={comment.id} className="rounded-md border border-border p-3">
|
||||
@@ -126,7 +143,7 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
|
||||
|
||||
{data.status !== 'Closed' && (
|
||||
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
|
||||
{data.type === 'RoleRequest' && data.status === 'Open' ? (
|
||||
{(data.type === 'RoleRequest' || data.type === 'ExtensionRequest') && data.status === 'Open' ? (
|
||||
<>
|
||||
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate()}>
|
||||
{t('admin.support.approve')}
|
||||
|
||||
@@ -41,3 +41,14 @@ export function approveRoleRequest(ticketId: string) {
|
||||
export function rejectRoleRequest(ticketId: string, reason?: string) {
|
||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/reject`, { method: 'POST', body: { reason } })
|
||||
}
|
||||
|
||||
export function approveExtensionRequest(ticketId: string) {
|
||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/approve-extension`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function rejectExtensionRequest(ticketId: string, reason?: string) {
|
||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/reject-extension`, {
|
||||
method: 'POST',
|
||||
body: { reason },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Label } from '@/shared/ui/label'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { listRoles } from '@/features/admin/roles/api'
|
||||
import { grantBillingGift } from '@/features/admin/billing/api'
|
||||
import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge'
|
||||
import { useAuthStore } from '@/features/auth/store'
|
||||
import type { UserSummaryDto } from '@/shared/api/types'
|
||||
import {
|
||||
@@ -25,6 +27,7 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [giftDays, setGiftDays] = useState('')
|
||||
const currentUserId = useAuthStore((state) => state.user?.id)
|
||||
|
||||
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
|
||||
@@ -59,6 +62,16 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const giftMutation = useMutation({
|
||||
mutationFn: () => grantBillingGift(user.id, Number(giftDays)),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.users.giftGranted'))
|
||||
setGiftDays('')
|
||||
await invalidateUsers()
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (configId: string) => forceRevokeConfig(configId),
|
||||
onSuccess: async () => {
|
||||
@@ -139,6 +152,30 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.billingEnabled && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.users.billingLabel')}</Label>
|
||||
<PaidUntilBadge paidUntil={user.billingPaidUntil} />
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={giftDays}
|
||||
onChange={(e) => setGiftDays(e.target.value)}
|
||||
placeholder={t('admin.users.giftDaysPlaceholder')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!(Number(giftDays) > 0) || giftMutation.isPending}
|
||||
onClick={() => giftMutation.mutate()}
|
||||
>
|
||||
{t('admin.users.giftGrant')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{t('admin.users.configs')}</Label>
|
||||
{configsQuery.data?.length === 0 && <p className="text-sm text-muted-foreground">{t('configs.empty')}</p>}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { createExtensionRequestTicket } from './api'
|
||||
|
||||
export function CreateExtensionRequestDialog() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [requestedDays, setRequestedDays] = useState('')
|
||||
const [justification, setJustification] = useState('')
|
||||
|
||||
const resetForm = () => {
|
||||
setRequestedDays('')
|
||||
setJustification('')
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => createExtensionRequestTicket(Number(requestedDays), justification.trim()),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('support.ticketCreated'))
|
||||
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof HttpError && error.status === 409
|
||||
? t('support.extensionRequestPending')
|
||||
: error instanceof HttpError
|
||||
? error.detail
|
||||
: t('auth.genericError')
|
||||
toast.error(message)
|
||||
},
|
||||
})
|
||||
|
||||
const canSubmit = Number(requestedDays) > 0 && justification.trim().length > 0
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{t('support.requestExtension')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('support.requestExtension')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (canSubmit) mutation.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="requested-days">{t('support.requestedDaysLabel')}</Label>
|
||||
<Input
|
||||
id="requested-days"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={requestedDays}
|
||||
onChange={(e) => setRequestedDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="extension-justification">{t('support.justification')}</Label>
|
||||
<Textarea
|
||||
id="extension-justification"
|
||||
value={justification}
|
||||
onChange={(e) => setJustification(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
|
||||
{t('support.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { getMyBillingStatus } from '@/features/billing/api'
|
||||
import { CreateBugReportDialog } from './CreateBugReportDialog'
|
||||
import { CreateExtensionRequestDialog } from './CreateExtensionRequestDialog'
|
||||
import { CreateRoleRequestDialog } from './CreateRoleRequestDialog'
|
||||
import { TicketDetailDialog } from './TicketDetailDialog'
|
||||
import { TicketStatusBadge } from './TicketStatusBadge'
|
||||
@@ -23,12 +25,14 @@ export function SupportTicketList() {
|
||||
queryKey: ['my-tickets', page],
|
||||
queryFn: () => listMyTickets(undefined, undefined, page, PAGE_SIZE),
|
||||
})
|
||||
const billingStatusQuery = useQuery({ queryKey: ['my-billing-status'], queryFn: getMyBillingStatus })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CreateBugReportDialog />
|
||||
<CreateRoleRequestDialog />
|
||||
{billingStatusQuery.data?.billingEnabled && <CreateExtensionRequestDialog />}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||
|
||||
@@ -75,6 +75,12 @@ export function TicketDetailDialog({ ticketId, onOpenChange }: { ticketId: strin
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.type === 'ExtensionRequest' && (
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
{t('support.requestedExtension', { days: data.requestedDays })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.comments.map((comment) => (
|
||||
<div key={comment.id} className="rounded-md border border-border p-3">
|
||||
|
||||
@@ -50,6 +50,13 @@ export function createRoleRequestTicket(payload: {
|
||||
return apiRequest<TicketDetailDto>('/support/tickets/role-requests', { method: 'POST', body: payload })
|
||||
}
|
||||
|
||||
export function createExtensionRequestTicket(requestedDays: number, justification: string) {
|
||||
return apiRequest<TicketDetailDto>('/support/tickets/extension-requests', {
|
||||
method: 'POST',
|
||||
body: { requestedDays, justification },
|
||||
})
|
||||
}
|
||||
|
||||
export function addTicketComment(ticketId: string, body: string, files: File[]) {
|
||||
const formData = new FormData()
|
||||
formData.set('body', body)
|
||||
|
||||
@@ -310,7 +310,7 @@ export type AuditLogDto = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type TicketType = 'BugReport' | 'RoleRequest'
|
||||
export type TicketType = 'BugReport' | 'RoleRequest' | 'ExtensionRequest'
|
||||
export type TicketStatus = 'Open' | 'Resolved' | 'Closed'
|
||||
|
||||
export type TicketAttachmentDto = {
|
||||
@@ -356,6 +356,7 @@ export type TicketDetailDto = {
|
||||
proposedRoleName: string | null
|
||||
proposedMaxConfigs: number | null
|
||||
proposedMaxIpLimit: number | null
|
||||
requestedDays: number | null
|
||||
createdAt: string
|
||||
comments: TicketCommentDto[]
|
||||
}
|
||||
|
||||
@@ -165,6 +165,10 @@ const resources = {
|
||||
empty: 'У вас пока нет обращений.',
|
||||
reportBug: 'Сообщить об ошибке',
|
||||
requestRole: 'Запросить роль',
|
||||
requestExtension: 'Попросить о продлении',
|
||||
requestedDaysLabel: 'Сколько дней нужно',
|
||||
requestedExtension: 'Запрошено продление на {{days}} дн.',
|
||||
extensionRequestPending: 'У вас уже есть необработанная заявка на продление.',
|
||||
submit: 'Отправить',
|
||||
messageLabel: 'Опишите проблему или предложение',
|
||||
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
|
||||
@@ -194,6 +198,7 @@ const resources = {
|
||||
type: {
|
||||
BugReport: 'Ошибка/предложение',
|
||||
RoleRequest: 'Заявка на роль',
|
||||
ExtensionRequest: 'Заявка на продление',
|
||||
},
|
||||
status: {
|
||||
Open: 'Открыт',
|
||||
@@ -274,6 +279,9 @@ const resources = {
|
||||
delete: 'Удалить пользователя',
|
||||
confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.',
|
||||
deleted: 'Пользователь удалён.',
|
||||
giftDaysPlaceholder: 'Дней',
|
||||
giftGrant: 'Подарить',
|
||||
giftGranted: 'Дни подписки подарены пользователю.',
|
||||
},
|
||||
configs: {
|
||||
searchPlaceholder: 'Поиск по email в панели или метке',
|
||||
@@ -681,6 +689,10 @@ const resources = {
|
||||
empty: 'You have no tickets yet.',
|
||||
reportBug: 'Report a bug',
|
||||
requestRole: 'Request a role',
|
||||
requestExtension: 'Request an extension',
|
||||
requestedDaysLabel: 'How many days you need',
|
||||
requestedExtension: 'Requested a {{days}}-day extension',
|
||||
extensionRequestPending: 'You already have a pending extension request.',
|
||||
submit: 'Submit',
|
||||
messageLabel: 'Describe the issue or suggestion',
|
||||
attachmentsLabel: 'Screenshots (optional, up to 5)',
|
||||
@@ -710,6 +722,7 @@ const resources = {
|
||||
type: {
|
||||
BugReport: 'Bug/suggestion',
|
||||
RoleRequest: 'Role request',
|
||||
ExtensionRequest: 'Extension request',
|
||||
},
|
||||
status: {
|
||||
Open: 'Open',
|
||||
@@ -790,6 +803,9 @@ const resources = {
|
||||
delete: 'Delete user',
|
||||
confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.',
|
||||
deleted: 'User deleted.',
|
||||
giftDaysPlaceholder: 'Days',
|
||||
giftGrant: 'Grant',
|
||||
giftGranted: 'Subscription days granted to the user.',
|
||||
},
|
||||
configs: {
|
||||
searchPlaceholder: 'Search by panel email or label',
|
||||
|
||||
Reference in New Issue
Block a user