Enhance user plan management and update related endpoints
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s

- Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`.
- Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes.
- Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users.
- Removed deprecated role request approval endpoints from `AdminSupportEndpoints`.
- Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications.
- Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings.
- Enhanced billing request handling to accommodate plan changes instead of role changes.
- Updated various interfaces and command handlers to support new plan management features.
This commit is contained in:
Leonid Pershin
2026-07-23 22:52:20 +03:00
parent 2c5b730500
commit fad03c2834
152 changed files with 4060 additions and 2240 deletions
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { HttpError } from '@/shared/api/client'
import type { AdminPlanDto } from '@/shared/api/types'
import { createPlan, updatePlan } from './api'
export function PlanFormDialog({
plan,
open,
onOpenChange,
}: {
plan?: AdminPlanDto
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [internalOpen, setInternalOpen] = useState(false)
const [name, setName] = useState(plan?.name ?? '')
const [configCount, setConfigCount] = useState(String(plan?.configCount ?? 3))
const [sortOrder, setSortOrder] = useState(String(plan?.sortOrder ?? 0))
const [isEnabled, setIsEnabled] = useState(plan?.isEnabled ?? true)
const isControlled = open !== undefined
const dialogOpen = isControlled ? open : internalOpen
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
const mutation = useMutation({
mutationFn: () =>
plan
? updatePlan(plan.id, name.trim(), Number(configCount), Number(sortOrder), isEnabled)
: createPlan(name.trim(), Number(configCount), Number(sortOrder)),
onSuccess: async () => {
toast.success(plan ? t('admin.plans.updated') : t('admin.plans.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-plans'] })
setDialogOpen(false)
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const canSubmit = name.trim() && Number(configCount) >= 1
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{!isControlled && (
<DialogTrigger asChild>
<Button size="sm">{t('admin.plans.create')}</Button>
</DialogTrigger>
)}
<DialogContent>
<DialogHeader>
<DialogTitle>{plan ? plan.name : t('admin.plans.create')}</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="planName">{t('admin.plans.name')}</Label>
<Input id="planName" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="configCount">{t('admin.plans.configCount')}</Label>
<Input
id="configCount"
type="number"
min={1}
value={configCount}
onChange={(e) => setConfigCount(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="planSortOrder">{t('admin.plans.sortOrder')}</Label>
<Input id="planSortOrder" type="number" min={0} value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
</div>
{plan && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
{t('admin.plans.enabled')}
</label>
)}
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
{plan ? t('admin.plans.save') : t('admin.plans.create')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { apiRequest } from '@/shared/api/client'
import type { AdminPlanDto } from '@/shared/api/types'
export function listAdminPlans() {
return apiRequest<AdminPlanDto[]>('/admin/plans')
}
export function createPlan(name: string, configCount: number, sortOrder: number) {
return apiRequest<AdminPlanDto>('/admin/plans', {
method: 'POST',
body: { name, configCount, sortOrder },
})
}
export function updatePlan(id: string, name: string, configCount: number, sortOrder: number, isEnabled: boolean) {
return apiRequest<AdminPlanDto>(`/admin/plans/${id}`, {
method: 'PUT',
body: { name, configCount, sortOrder, isEnabled },
})
}
export function deletePlan(id: string) {
return apiRequest<void>(`/admin/plans/${id}`, { method: 'DELETE' })
}
@@ -24,7 +24,6 @@ export function RoleFormDialog({
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState(role?.name ?? '')
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2))
const [billingEnabled, setBillingEnabled] = useState(role?.billingEnabled ?? false)
const [internalOpen, setInternalOpen] = useState(false)
@@ -52,14 +51,13 @@ export function RoleFormDialog({
const mutation = useMutation({
mutationFn: () =>
role
? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit), billingEnabled)
: createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit), billingEnabled),
? updateRole(role.id, Number(maxIpLimit), billingEnabled)
: createRole(name.trim(), Number(maxIpLimit), billingEnabled),
onSuccess: async () => {
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
setDialogOpen(false)
setName('')
setMaxConfigs('3')
setMaxIpLimit('2')
setBillingEnabled(false)
},
@@ -90,11 +88,6 @@ export function RoleFormDialog({
<Input id="roleName" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxConfigs">{t('admin.roles.maxConfigs')}</Label>
<Input id="maxConfigs" type="number" value={maxConfigs} onChange={(e) => setMaxConfigs(e.target.value)} />
<p className="text-xs text-muted-foreground">{t('admin.roles.maxConfigsHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="maxIpLimit">{t('admin.roles.maxIpLimit')}</Label>
<Input id="maxIpLimit" type="number" value={maxIpLimit} onChange={(e) => setMaxIpLimit(e.target.value)} />
+4 -4
View File
@@ -5,17 +5,17 @@ export function listRoles() {
return apiRequest<RoleDto[]>('/admin/roles')
}
export function createRole(name: string, maxConfigs: number, maxIpLimit: number, billingEnabled: boolean) {
export function createRole(name: string, maxIpLimit: number, billingEnabled: boolean) {
return apiRequest<RoleDto>('/admin/roles', {
method: 'POST',
body: { name, maxConfigs, maxIpLimit, billingEnabled },
body: { name, maxIpLimit, billingEnabled },
})
}
export function updateRole(id: string, maxConfigs: number, maxIpLimit: number, billingEnabled: boolean) {
export function updateRole(id: string, maxIpLimit: number, billingEnabled: boolean) {
return apiRequest<RoleDto>(`/admin/roles/${id}`, {
method: 'PUT',
body: { maxConfigs, maxIpLimit, billingEnabled },
body: { maxIpLimit, billingEnabled },
})
}
@@ -12,11 +12,9 @@ import { TicketAttachmentImage } from '@/features/support/TicketAttachmentImage'
import {
addAdminComment,
approveExtensionRequest,
approveRoleRequest,
closeTicket,
getAdminTicket,
rejectExtensionRequest,
rejectRoleRequest,
resolveTicket,
} from './api'
@@ -66,8 +64,7 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
})
const approveMutation = useMutation({
mutationFn: () =>
data?.type === 'ExtensionRequest' ? approveExtensionRequest(ticketId) : approveRoleRequest(ticketId),
mutationFn: () => approveExtensionRequest(ticketId),
onSuccess: async () => {
toast.success(t('admin.support.approved'))
await invalidate()
@@ -76,8 +73,7 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
})
const rejectMutation = useMutation({
mutationFn: () =>
data?.type === 'ExtensionRequest' ? rejectExtensionRequest(ticketId) : rejectRoleRequest(ticketId),
mutationFn: () => rejectExtensionRequest(ticketId),
onSuccess: async () => {
toast.success(t('admin.support.rejected'))
await invalidate()
@@ -104,18 +100,6 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
{data && (
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto">
{data.type === 'RoleRequest' && (
<div className="rounded-md border border-border p-3 text-sm">
{data.requestedRoleName
? t('support.requestedExistingRole', { role: data.requestedRoleName })
: t('support.requestedNewRole', {
name: data.proposedRoleName,
configs: data.proposedMaxConfigs,
ip: data.proposedMaxIpLimit,
})}
</div>
)}
{data.type === 'ExtensionRequest' && (
<div className="rounded-md border border-border p-3 text-sm">
{t('support.requestedExtension', { days: data.requestedDays })}
@@ -143,7 +127,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.type === 'ExtensionRequest') && data.status === 'Open' ? (
{data.type === 'ExtensionRequest' && data.status === 'Open' ? (
<>
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate()}>
{t('admin.support.approve')}
@@ -34,14 +34,6 @@ export function closeTicket(ticketId: string) {
return apiRequest<void>(`/admin/support/tickets/${ticketId}/close`, { method: 'POST' })
}
export function approveRoleRequest(ticketId: string) {
return apiRequest<void>(`/admin/support/tickets/${ticketId}/approve`, { method: 'POST' })
}
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' })
}
@@ -103,11 +103,11 @@ export function PaymentRequestPanel({ status }: { status: BillingStatusDto }) {
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-sm">
{request.kind === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t(`billing.periods.${request.period}`)} {' '}
{request.kind === 'PlanChangeTopUp' ? t('billing.planChangeTopUp') : t(`billing.periods.${request.period}`)} {' '}
<span className="font-medium">{request.amountSnapshot} </span>
</p>
{request.kind === 'RoleChangeTopUp' && (
<p className="text-xs text-muted-foreground">{t('billing.roleChangeTopUpHint')}</p>
{request.kind === 'PlanChangeTopUp' && (
<p className="text-xs text-muted-foreground">{t('billing.planChangeTopUpHint')}</p>
)}
{!isAwaitingConfirmation && (
+21
View File
@@ -0,0 +1,21 @@
import { apiRequest } from '@/shared/api/client'
import type { ChangePlanResultDto, MyPlanStatusDto, PlanDto } from '@/shared/api/types'
export function listPlans() {
return apiRequest<PlanDto[]>('/plans')
}
export function getMyPlanStatus() {
return apiRequest<MyPlanStatusDto>('/plans/status')
}
export function changePlan(planId: string | undefined, customConfigCount: number | undefined, configIdsToRevoke: string[]) {
return apiRequest<ChangePlanResultDto>('/plans/change', {
method: 'POST',
body: {
planId: planId ?? null,
customConfigCount: customConfigCount ?? null,
configIdsToRevoke,
},
})
}
@@ -1,198 +0,0 @@
import { useState } from 'react'
import { useMutation, useQuery, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { HttpError } from '@/shared/api/client'
import { applyDiscount, resolveDiscountPercent } from '@/shared/lib/pricing'
import { createRoleRequestTicket, getSupportPricing, listSelectableRoles } from './api'
type Mode = 'existing' | 'new'
export function CreateRoleRequestDialog() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<Mode>('existing')
const [roleId, setRoleId] = useState('')
const [newRoleName, setNewRoleName] = useState('')
const [newRoleMaxConfigs, setNewRoleMaxConfigs] = useState('')
const [newRoleMaxIpLimit, setNewRoleMaxIpLimit] = useState('')
const [justification, setJustification] = useState('')
const rolesQuery = useQuery({ queryKey: ['selectable-roles'], queryFn: listSelectableRoles, enabled: open })
const pricingQuery = useQuery({ queryKey: ['support-pricing'], queryFn: getSupportPricing, enabled: open })
const resetForm = () => {
setMode('existing')
setRoleId('')
setNewRoleName('')
setNewRoleMaxConfigs('')
setNewRoleMaxIpLimit('')
setJustification('')
}
const mutation = useMutation({
mutationFn: () =>
createRoleRequestTicket(
mode === 'existing'
? { existingRoleId: roleId, justification: justification.trim() }
: {
newRoleName: newRoleName.trim(),
newRoleMaxConfigs: Number(newRoleMaxConfigs),
newRoleMaxIpLimit: Number(newRoleMaxIpLimit),
justification: 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.roleRequestPending')
: error instanceof HttpError
? error.detail
: t('auth.genericError')
toast.error(message)
},
})
const canSubmit =
justification.trim().length > 0 &&
(mode === 'existing'
? roleId.length > 0
: newRoleName.trim().length > 0 && newRoleMaxConfigs !== '' && newRoleMaxIpLimit !== '')
// Квота, для которой считаем ориентировочную стоимость: у существующей роли — её maxConfigs,
// у новой — то, что пользователь ввёл (пока не введено или отрицательное кроме -1 — не считаем).
const maxConfigsForPricing =
mode === 'existing'
? rolesQuery.data?.find((role) => role.id === roleId)?.maxConfigs
: newRoleMaxConfigs !== '' && Number.isFinite(Number(newRoleMaxConfigs))
? Number(newRoleMaxConfigs)
: undefined
// Ставки — цена за конфиг В МЕСЯЦ; итог за период = ставка × месяцев × квота, затем скидка по
// лесенке (см. shared/lib/pricing) — за роль с большей квотой конфигов та же лесенка, что в
// admin/roles.tsx и на реальной оплате (CreatePaymentRequestCommandHandler).
const totalPrice = (monthlyRate: number | null | undefined, months: number) => {
if (monthlyRate == null || maxConfigsForPricing == null || maxConfigsForPricing < 0) return t('admin.roles.noPrice')
const original = monthlyRate * months * maxConfigsForPricing
const percent = resolveDiscountPercent(pricingQuery.data?.discountTiers ?? [], maxConfigsForPricing)
if (percent <= 0) return `${original}`
const discounted = applyDiscount(original, percent)
return t('support.pricingDiscounted', { price: discounted, original, percent })
}
const showPricing = maxConfigsForPricing != null && maxConfigsForPricing >= 0
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('support.requestRole')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('support.requestRole')}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) mutation.mutate()
}}
>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-2">
<input type="radio" checked={mode === 'existing'} onChange={() => setMode('existing')} />
{t('support.existingRole')}
</label>
<label className="flex items-center gap-2">
<input type="radio" checked={mode === 'new'} onChange={() => setMode('new')} />
{t('support.newRole')}
</label>
</div>
{mode === 'existing' ? (
<div className="flex flex-col gap-1.5">
<Label>{t('support.selectRole')}</Label>
<Select value={roleId} onValueChange={setRoleId}>
<SelectTrigger>
<SelectValue placeholder={t('support.selectRole')} />
</SelectTrigger>
<SelectContent>
{rolesQuery.data?.map((role) => (
<SelectItem key={role.id} value={role.id}>
{t('support.roleOption', {
name: role.name,
configs: role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs,
ip: role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<>
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-role-name">{t('support.newRoleName')}</Label>
<Input id="new-role-name" value={newRoleName} onChange={(e) => setNewRoleName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-role-configs">{t('support.newRoleMaxConfigs')}</Label>
<Input
id="new-role-configs"
type="number"
min={-1}
value={newRoleMaxConfigs}
onChange={(e) => setNewRoleMaxConfigs(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-role-ip">{t('support.newRoleMaxIpLimit')}</Label>
<Input
id="new-role-ip"
type="number"
min={-1}
value={newRoleMaxIpLimit}
onChange={(e) => setNewRoleMaxIpLimit(e.target.value)}
/>
</div>
</>
)}
{showPricing && (
<div className="flex flex-col gap-1 rounded-md border border-border px-3 py-2 text-sm">
<p className="font-medium">{t('support.pricingTitle')}</p>
<p>{t('support.pricingQuarter', { price: totalPrice(pricingQuery.data?.pricePerConfigPerQuarter, 3) })}</p>
<p>{t('support.pricingHalfYear', { price: totalPrice(pricingQuery.data?.pricePerConfigPerHalfYear, 6) })}</p>
<p>{t('support.pricingYear', { price: totalPrice(pricingQuery.data?.pricePerConfigPerYear, 12) })}</p>
<p className="text-xs text-muted-foreground">{t('support.pricingDisclaimer')}</p>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="justification">{t('support.justification')}</Label>
<Textarea id="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>
)
}
@@ -7,7 +7,6 @@ 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'
import { listMyTickets } from './api'
@@ -31,7 +30,6 @@ export function SupportTicketList() {
<div className="flex flex-col gap-6">
<div className="flex flex-wrap gap-2">
<CreateBugReportDialog />
<CreateRoleRequestDialog />
{billingStatusQuery.data?.billingEnabled && <CreateExtensionRequestDialog />}
</div>
@@ -63,18 +63,6 @@ export function TicketDetailDialog({ ticketId, onOpenChange }: { ticketId: strin
{data && (
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto">
{data.type === 'RoleRequest' && (
<div className="rounded-md border border-border p-3 text-sm">
{data.requestedRoleName
? t('support.requestedExistingRole', { role: data.requestedRoleName })
: t('support.requestedNewRole', {
name: data.proposedRoleName,
configs: data.proposedMaxConfigs,
ip: data.proposedMaxIpLimit,
})}
</div>
)}
{data.type === 'ExtensionRequest' && (
<div className="rounded-md border border-border p-3 text-sm">
{t('support.requestedExtension', { days: data.requestedDays })}
-15
View File
@@ -2,7 +2,6 @@ import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client'
import type {
PagedList,
PricingSettingsDto,
RoleDto,
TicketCommentDto,
TicketDetailDto,
TicketStatus,
@@ -25,10 +24,6 @@ export function getTicket(id: string) {
return apiRequest<TicketDetailDto>(`/support/tickets/${id}`)
}
export function listSelectableRoles() {
return apiRequest<RoleDto[]>('/support/roles')
}
export function getSupportPricing() {
return apiRequest<PricingSettingsDto>('/support/pricing')
}
@@ -40,16 +35,6 @@ export function createBugReportTicket(message: string, files: File[]) {
return apiUpload<TicketDetailDto>('/support/tickets/bug-reports', formData)
}
export function createRoleRequestTicket(payload: {
existingRoleId?: string
newRoleName?: string
newRoleMaxConfigs?: number
newRoleMaxIpLimit?: number
justification: string
}) {
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',
+42
View File
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as SupportRouteImport } from './routes/support'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as RegisterRouteImport } from './routes/register'
import { Route as PlanRouteImport } from './routes/plan'
import { Route as NewsRouteImport } from './routes/news'
import { Route as LoginRouteImport } from './routes/login'
import { Route as InstructionsRouteImport } from './routes/instructions'
@@ -24,6 +25,7 @@ import { Route as AdminUsersRouteImport } from './routes/admin/users'
import { Route as AdminSupportRouteImport } from './routes/admin/support'
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
import { Route as AdminPricingRouteImport } from './routes/admin/pricing'
import { Route as AdminPlansRouteImport } from './routes/admin/plans'
import { Route as AdminNodesRouteImport } from './routes/admin/nodes'
import { Route as AdminNewsRouteImport } from './routes/admin/news'
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
@@ -49,6 +51,11 @@ const RegisterRoute = RegisterRouteImport.update({
path: '/register',
getParentRoute: () => rootRouteImport,
} as any)
const PlanRoute = PlanRouteImport.update({
id: '/plan',
path: '/plan',
getParentRoute: () => rootRouteImport,
} as any)
const NewsRoute = NewsRouteImport.update({
id: '/news',
path: '/news',
@@ -109,6 +116,11 @@ const AdminPricingRoute = AdminPricingRouteImport.update({
path: '/pricing',
getParentRoute: () => AdminRoute,
} as any)
const AdminPlansRoute = AdminPlansRouteImport.update({
id: '/plans',
path: '/plans',
getParentRoute: () => AdminRoute,
} as any)
const AdminNodesRoute = AdminNodesRouteImport.update({
id: '/nodes',
path: '/nodes',
@@ -163,6 +175,7 @@ export interface FileRoutesByFullPath {
'/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute
'/news': typeof NewsRoute
'/plan': typeof PlanRoute
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/support': typeof SupportRoute
@@ -175,6 +188,7 @@ export interface FileRoutesByFullPath {
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/news': typeof AdminNewsRoute
'/admin/nodes': typeof AdminNodesRoute
'/admin/plans': typeof AdminPlansRoute
'/admin/pricing': typeof AdminPricingRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/support': typeof AdminSupportRoute
@@ -188,6 +202,7 @@ export interface FileRoutesByTo {
'/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute
'/news': typeof NewsRoute
'/plan': typeof PlanRoute
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/support': typeof SupportRoute
@@ -200,6 +215,7 @@ export interface FileRoutesByTo {
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/news': typeof AdminNewsRoute
'/admin/nodes': typeof AdminNodesRoute
'/admin/plans': typeof AdminPlansRoute
'/admin/pricing': typeof AdminPricingRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/support': typeof AdminSupportRoute
@@ -215,6 +231,7 @@ export interface FileRoutesById {
'/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute
'/news': typeof NewsRoute
'/plan': typeof PlanRoute
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/support': typeof SupportRoute
@@ -227,6 +244,7 @@ export interface FileRoutesById {
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/news': typeof AdminNewsRoute
'/admin/nodes': typeof AdminNodesRoute
'/admin/plans': typeof AdminPlansRoute
'/admin/pricing': typeof AdminPricingRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/support': typeof AdminSupportRoute
@@ -243,6 +261,7 @@ export interface FileRouteTypes {
| '/instructions'
| '/login'
| '/news'
| '/plan'
| '/register'
| '/settings'
| '/support'
@@ -255,6 +274,7 @@ export interface FileRouteTypes {
| '/admin/maintenance'
| '/admin/news'
| '/admin/nodes'
| '/admin/plans'
| '/admin/pricing'
| '/admin/roles'
| '/admin/support'
@@ -268,6 +288,7 @@ export interface FileRouteTypes {
| '/instructions'
| '/login'
| '/news'
| '/plan'
| '/register'
| '/settings'
| '/support'
@@ -280,6 +301,7 @@ export interface FileRouteTypes {
| '/admin/maintenance'
| '/admin/news'
| '/admin/nodes'
| '/admin/plans'
| '/admin/pricing'
| '/admin/roles'
| '/admin/support'
@@ -294,6 +316,7 @@ export interface FileRouteTypes {
| '/instructions'
| '/login'
| '/news'
| '/plan'
| '/register'
| '/settings'
| '/support'
@@ -306,6 +329,7 @@ export interface FileRouteTypes {
| '/admin/maintenance'
| '/admin/news'
| '/admin/nodes'
| '/admin/plans'
| '/admin/pricing'
| '/admin/roles'
| '/admin/support'
@@ -321,6 +345,7 @@ export interface RootRouteChildren {
InstructionsRoute: typeof InstructionsRoute
LoginRoute: typeof LoginRoute
NewsRoute: typeof NewsRoute
PlanRoute: typeof PlanRoute
RegisterRoute: typeof RegisterRoute
SettingsRoute: typeof SettingsRoute
SupportRoute: typeof SupportRoute
@@ -349,6 +374,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RegisterRouteImport
parentRoute: typeof rootRouteImport
}
'/plan': {
id: '/plan'
path: '/plan'
fullPath: '/plan'
preLoaderRoute: typeof PlanRouteImport
parentRoute: typeof rootRouteImport
}
'/news': {
id: '/news'
path: '/news'
@@ -433,6 +465,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminPricingRouteImport
parentRoute: typeof AdminRoute
}
'/admin/plans': {
id: '/admin/plans'
path: '/plans'
fullPath: '/admin/plans'
preLoaderRoute: typeof AdminPlansRouteImport
parentRoute: typeof AdminRoute
}
'/admin/nodes': {
id: '/admin/nodes'
path: '/nodes'
@@ -509,6 +548,7 @@ interface AdminRouteChildren {
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
AdminNewsRoute: typeof AdminNewsRoute
AdminNodesRoute: typeof AdminNodesRoute
AdminPlansRoute: typeof AdminPlansRoute
AdminPricingRoute: typeof AdminPricingRoute
AdminRolesRoute: typeof AdminRolesRoute
AdminSupportRoute: typeof AdminSupportRoute
@@ -526,6 +566,7 @@ const AdminRouteChildren: AdminRouteChildren = {
AdminMaintenanceRoute: AdminMaintenanceRoute,
AdminNewsRoute: AdminNewsRoute,
AdminNodesRoute: AdminNodesRoute,
AdminPlansRoute: AdminPlansRoute,
AdminPricingRoute: AdminPricingRoute,
AdminRolesRoute: AdminRolesRoute,
AdminSupportRoute: AdminSupportRoute,
@@ -543,6 +584,7 @@ const rootRouteChildren: RootRouteChildren = {
InstructionsRoute: InstructionsRoute,
LoginRoute: LoginRoute,
NewsRoute: NewsRoute,
PlanRoute: PlanRoute,
RegisterRoute: RegisterRoute,
SettingsRoute: SettingsRoute,
SupportRoute: SupportRoute,
+1
View File
@@ -11,6 +11,7 @@ const TABS = [
{ to: '/admin/users', key: 'users' },
{ to: '/admin/configs', key: 'configs' },
{ to: '/admin/roles', key: 'roles' },
{ to: '/admin/plans', key: 'plans' },
{ to: '/admin/pricing', key: 'pricing' },
{ to: '/admin/billing', key: 'billing' },
{ to: '/admin/nodes', key: 'nodes' },
+3 -3
View File
@@ -20,7 +20,7 @@ import {
export const Route = createFileRoute('/admin/billing')({ component: AdminBillingPage })
const KIND_FILTERS: (PaymentRequestKind | 'All')[] = ['Subscription', 'RoleChangeTopUp', 'All']
const KIND_FILTERS: (PaymentRequestKind | 'All')[] = ['Subscription', 'PlanChangeTopUp', 'All']
const STATUS_FILTERS: (PaymentRequestStatus | 'All')[] = [
'AwaitingConfirmation',
@@ -162,7 +162,7 @@ function RequestsSection() {
<SelectContent>
{KIND_FILTERS.map((k) => (
<SelectItem key={k} value={k}>
{k === 'All' ? t('admin.billing.allKinds') : k === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t('billing.subscription')}
{k === 'All' ? t('admin.billing.allKinds') : k === 'PlanChangeTopUp' ? t('billing.planChangeTopUp') : t('billing.subscription')}
</SelectItem>
))}
</SelectContent>
@@ -200,7 +200,7 @@ function RequestsSection() {
<tr key={request.id} className="border-b border-border">
<td className="py-2">{request.userName}</td>
<td className="py-2">
{request.kind === 'RoleChangeTopUp' ? t('billing.roleChangeTopUp') : t(`billing.periods.${request.period}`)}
{request.kind === 'PlanChangeTopUp' ? t('billing.planChangeTopUp') : t(`billing.periods.${request.period}`)}
</td>
<td className="py-2">{request.amountSnapshot} </td>
<td className="py-2">
+122
View File
@@ -0,0 +1,122 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { listAdminPlans, deletePlan } from '@/features/admin/plans/api'
import { PlanFormDialog } from '@/features/admin/plans/PlanFormDialog'
import { getPricingSettings } from '@/features/admin/pricing/api'
import { applyDiscount, resolveDiscountPercent } from '@/shared/lib/pricing'
import type { AdminPlanDto } from '@/shared/api/types'
export const Route = createFileRoute('/admin/plans')({ component: AdminPlansPage })
function AdminPlansPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [editing, setEditing] = useState<AdminPlanDto | null>(null)
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-plans'], queryFn: listAdminPlans })
const { data: pricing } = useQuery({ queryKey: ['admin-pricing'], queryFn: getPricingSettings })
const priceCell = (monthlyRate: number | null | undefined, configCount: number, months: number) => {
if (monthlyRate == null) return <span>{t('admin.plans.noPrice')}</span>
const original = monthlyRate * months * configCount
const percent = resolveDiscountPercent(pricing?.discountTiers ?? [], configCount)
if (percent <= 0) return <span>{original} </span>
const discounted = applyDiscount(original, percent)
return (
<span className="flex flex-col">
<span className="text-xs text-muted-foreground line-through">{original} </span>
<span className="flex items-center gap-1">
{discounted}
<Badge variant="success">-{percent}%</Badge>
</span>
</span>
)
}
const deleteMutation = useMutation({
mutationFn: deletePlan,
onSuccess: async () => {
toast.success(t('admin.plans.deleted'))
await queryClient.invalidateQueries({ queryKey: ['admin-plans'] })
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<PlanFormDialog />
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data?.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.plans.empty')}</p>}
{data && data.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="py-2 font-medium">{t('admin.plans.name')}</th>
<th className="py-2 font-medium">{t('admin.plans.configCount')}</th>
<th className="py-2 font-medium">{t('admin.plans.totalPerQuarter')}</th>
<th className="py-2 font-medium">{t('admin.plans.totalHalfYear')}</th>
<th className="py-2 font-medium">{t('admin.plans.totalPerYear')}</th>
<th className="py-2" />
<th className="py-2" />
</tr>
</thead>
<tbody>
{data.map((plan) => (
<tr key={plan.id} className="border-b border-border">
<td className="py-2">
{plan.name} {!plan.isEnabled && <Badge variant="outline">{t('admin.plans.disabled')}</Badge>}
</td>
<td className="py-2">{plan.configCount}</td>
<td className="py-2">{priceCell(pricing?.pricePerConfigPerQuarter, plan.configCount, 3)}</td>
<td className="py-2">{priceCell(pricing?.pricePerConfigPerHalfYear, plan.configCount, 6)}</td>
<td className="py-2">{priceCell(pricing?.pricePerConfigPerYear, plan.configCount, 12)}</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setEditing(plan)}>
{t('admin.plans.edit')}
</Button>
</td>
<td className="py-2 text-right">
<Button
size="sm"
variant="ghost"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm(t('admin.plans.confirmDelete'))) deleteMutation.mutate(plan.id)
}}
>
{t('admin.plans.delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editing && <PlanFormDialog plan={editing} open={!!editing} onOpenChange={(open) => !open && setEditing(null)} />}
</div>
)
}
-32
View File
@@ -7,8 +7,6 @@ import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
import { listRoles, deleteRole } from '@/features/admin/roles/api'
import { RoleFormDialog } from '@/features/admin/roles/RoleFormDialog'
import { getPricingSettings } from '@/features/admin/pricing/api'
import { applyDiscount, resolveDiscountPercent } from '@/shared/lib/pricing'
import type { RoleDto } from '@/shared/api/types'
export const Route = createFileRoute('/admin/roles')({ component: AdminRolesPage })
@@ -19,28 +17,6 @@ function AdminRolesPage() {
const [editing, setEditing] = useState<RoleDto | null>(null)
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles })
const { data: pricing } = useQuery({ queryKey: ['admin-pricing'], queryFn: getPricingSettings })
// Ставки — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцев × квота,
// затем скидка по лесенке (см. shared/lib/pricing) — за роль с большей квотой конфигов.
const priceCell = (monthlyRate: number | null | undefined, maxConfigs: number, months: number) => {
if (monthlyRate == null || maxConfigs < 0) return <span>{t('admin.roles.noPrice')}</span>
const original = monthlyRate * months * maxConfigs
const percent = resolveDiscountPercent(pricing?.discountTiers ?? [], maxConfigs)
if (percent <= 0) return <span>{original} </span>
const discounted = applyDiscount(original, percent)
return (
<span className="flex flex-col">
<span className="text-xs text-muted-foreground line-through">{original} </span>
<span className="flex items-center gap-1">
{discounted}
<Badge variant="success">-{percent}%</Badge>
</span>
</span>
)
}
const deleteMutation = useMutation({
mutationFn: deleteRole,
@@ -74,11 +50,7 @@ function AdminRolesPage() {
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="py-2 font-medium">{t('admin.roles.name')}</th>
<th className="py-2 font-medium">{t('admin.roles.maxConfigs')}</th>
<th className="py-2 font-medium">{t('admin.roles.maxIpLimit')}</th>
<th className="py-2 font-medium">{t('admin.roles.totalPerQuarter')}</th>
<th className="py-2 font-medium">{t('admin.roles.totalHalfYear')}</th>
<th className="py-2 font-medium">{t('admin.roles.totalPerYear')}</th>
<th className="py-2" />
<th className="py-2" />
</tr>
@@ -90,11 +62,7 @@ function AdminRolesPage() {
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}{' '}
{role.billingEnabled && <Badge variant="success">{t('admin.roles.billingBadge')}</Badge>}
</td>
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
<td className="py-2">{priceCell(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)}</td>
<td className="py-2">{priceCell(pricing?.pricePerConfigPerHalfYear, role.maxConfigs, 6)}</td>
<td className="py-2">{priceCell(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)}</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
{t('admin.roles.edit')}
+1 -1
View File
@@ -10,7 +10,7 @@ import { AdminTicketDetailDialog } from '@/features/admin/support/AdminTicketDet
import { listAllTickets } from '@/features/admin/support/api'
import type { TicketStatus, TicketType } from '@/shared/api/types'
const TYPES: TicketType[] = ['BugReport', 'RoleRequest', 'ExtensionRequest']
const TYPES: TicketType[] = ['BugReport', 'ExtensionRequest']
const STATUSES: TicketStatus[] = ['Open', 'Resolved', 'Closed']
export const Route = createFileRoute('/admin/support')({
+6 -2
View File
@@ -43,9 +43,13 @@ function ConfigsList() {
<h1 className="text-2xl font-semibold tracking-tight">{t('configs.title')}</h1>
{data && (
<p className="text-sm text-muted-foreground">
{data.maxConfigs < 0
{data.configQuota < 0
? t('configs.quotaUnlimited', { used: data.configs.length })
: t('configs.quota', { used: data.configs.length, max: data.maxConfigs })}
: t('configs.quota', { used: data.configs.length, max: data.configQuota })}
{' · '}
<Link to="/plan" className="underline underline-offset-2 hover:text-foreground">
{t('plan.changePlan')}
</Link>
</p>
)}
{billingStatusQuery.data?.billingEnabled && (
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useRequireAuth } from '@/features/auth/guards'
import { ActivationGate } from '@/features/activation/ActivationGate'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { HttpError } from '@/shared/api/client'
import { applyDiscount, resolveDiscountPercent } from '@/shared/lib/pricing'
import { getMyConfigs } from '@/features/configs/api'
import { getSupportPricing } from '@/features/support/api'
import { changePlan, getMyPlanStatus, listPlans } from '@/features/plans/api'
export const Route = createFileRoute('/plan')({ component: PlanPage })
function PlanPage() {
const { isReady } = useRequireAuth()
if (!isReady) return null
return (
<ActivationGate>
<PlanContent />
</ActivationGate>
)
}
function PlanContent() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const plansQuery = useQuery({ queryKey: ['plans'], queryFn: listPlans })
const statusQuery = useQuery({ queryKey: ['my-plan-status'], queryFn: getMyPlanStatus })
const pricingQuery = useQuery({ queryKey: ['support-pricing'], queryFn: getSupportPricing })
const configsQuery = useQuery({ queryKey: ['my-configs'], queryFn: getMyConfigs })
const [selection, setSelection] = useState<string>('')
const [customCount, setCustomCount] = useState('3')
const [configIdsToRevoke, setConfigIdsToRevoke] = useState<string[]>([])
useEffect(() => {
if (!selection && statusQuery.data) {
setSelection(statusQuery.data.planId ?? 'custom')
if (statusQuery.data.planId == null) setCustomCount(String(statusQuery.data.configQuota))
}
}, [selection, statusQuery.data])
const targetCount = selection === 'custom' ? Number(customCount) || 0 : (plansQuery.data?.find((p) => p.id === selection)?.configCount ?? 0)
// Expired (приостановленные за неуплату) тоже занимают квоту — при следующей оплате они
// вернутся в Active целиком, так что не выбрать их здесь на понижении означало бы превысить
// новую квоту после оплаты (см. ChangePlanCommandHandler).
const liveConfigs = useMemo(
() => configsQuery.data?.configs.filter((c) => c.status === 'Active' || c.status === 'Expired') ?? [],
[configsQuery.data],
)
const excess = Math.max(0, liveConfigs.length - targetCount)
const needsRevokePicker = excess > 0
const mutation = useMutation({
mutationFn: () =>
changePlan(
selection === 'custom' ? undefined : selection,
selection === 'custom' ? Number(customCount) : undefined,
configIdsToRevoke,
),
onSuccess: async (result) => {
toast.success(t('plan.changed'))
if (result.topUpAmount != null) toast.success(t('plan.topUpCreated'))
setConfigIdsToRevoke([])
await queryClient.invalidateQueries({ queryKey: ['my-plan-status'] })
await queryClient.invalidateQueries({ queryKey: ['my-configs'] })
await queryClient.invalidateQueries({ queryKey: ['my-billing-status'] })
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const priceFor = (monthlyRate: number | null | undefined, count: number, months: number) => {
if (monthlyRate == null || count <= 0) return t('plan.noPrice')
const original = monthlyRate * months * count
const percent = resolveDiscountPercent(pricingQuery.data?.discountTiers ?? [], count)
return percent > 0 ? `${applyDiscount(original, percent)}` : `${original}`
}
const toggleConfig = (id: string) => {
setConfigIdsToRevoke((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
}
const canSubmit = targetCount >= 1 && (!needsRevokePicker || configIdsToRevoke.length === excess)
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('plan.title')}</h1>
{statusQuery.data && (
<p className="text-sm text-muted-foreground">
{statusQuery.data.configQuota < 0
? t('plan.currentUnlimited')
: t('plan.current', { count: statusQuery.data.configQuota })}
</p>
)}
</div>
<div className="flex flex-col gap-3">
{plansQuery.data?.map((p) => (
<label
key={p.id}
className="flex cursor-pointer items-center justify-between rounded-md border border-border p-3 text-sm has-[:checked]:border-foreground"
>
<span className="flex items-center gap-2">
<input type="radio" name="plan" checked={selection === p.id} onChange={() => setSelection(p.id)} />
<span className="font-medium">{p.name}</span>
<span className="text-muted-foreground"> {p.configCount}</span>
</span>
<span className="flex flex-col items-end text-xs text-muted-foreground">
<span>{t('plan.perQuarter', { price: priceFor(pricingQuery.data?.pricePerConfigPerQuarter, p.configCount, 3) })}</span>
<span>{t('plan.perHalfYear', { price: priceFor(pricingQuery.data?.pricePerConfigPerHalfYear, p.configCount, 6) })}</span>
<span>{t('plan.perYear', { price: priceFor(pricingQuery.data?.pricePerConfigPerYear, p.configCount, 12) })}</span>
</span>
</label>
))}
<label className="flex cursor-pointer items-center gap-3 rounded-md border border-border p-3 text-sm has-[:checked]:border-foreground">
<input type="radio" name="plan" checked={selection === 'custom'} onChange={() => setSelection('custom')} />
<span className="font-medium">{t('plan.custom')}</span>
<Input
type="number"
min={3}
className="w-24"
value={customCount}
onChange={(e) => {
setCustomCount(e.target.value)
setSelection('custom')
}}
/>
</label>
</div>
{needsRevokePicker && (
<div className="flex flex-col gap-2 rounded-md border border-border p-3">
<p className="text-sm font-medium">{t('plan.downgradeNotice')}</p>
<p className="text-xs text-muted-foreground">{t('plan.selectExactly', { count: excess })}</p>
<div className="flex flex-col gap-1">
{liveConfigs.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={configIdsToRevoke.includes(c.id)}
onChange={() => toggleConfig(c.id)}
/>
{c.label || c.clientEmail} <span className="text-xs text-muted-foreground">({c.location})</span>
{c.status === 'Expired' && (
<span className="text-xs text-muted-foreground"> {t('configs.status.Expired')}</span>
)}
</label>
))}
</div>
</div>
)}
<Button disabled={!canSubmit || mutation.isPending} onClick={() => mutation.mutate()}>
{t('plan.submit')}
</Button>
{mutation.data?.topUpAmount != null && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('plan.topUpCreated')}</CardTitle>
</CardHeader>
<CardContent>
<Link to="/billing" className="font-medium text-foreground underline underline-offset-2">
{t('plan.goToBilling')}
</Link>
</CardContent>
</Card>
)}
</div>
)
}
+35 -12
View File
@@ -93,7 +93,8 @@ export type MySubscriptionDto = {
export type GetMyConfigsResult = {
configs: VpnConfigDto[]
maxConfigs: number
configQuota: number
planId: string | null
}
export type ClientAppDto = {
@@ -175,13 +176,40 @@ export type UserSummaryDto = {
export type RoleDto = {
id: string
name: string
maxConfigs: number
maxIpLimit: number
isSystem: boolean
billingEnabled: boolean
}
/** Одна ступень скидочной лесенки за объём: роль с квотой maxConfigs >= minConfigs получает скидку
export type PlanDto = {
id: string
name: string
configCount: number
}
export type AdminPlanDto = {
id: string
name: string
configCount: number
sortOrder: number
isEnabled: boolean
}
export type MyPlanStatusDto = {
configQuota: number
planId: string | null
activeConfigCount: number
billingEnabled: boolean
billingPaidUntil: string | null
}
export type ChangePlanResultDto = {
configQuota: number
planId: string | null
topUpAmount: number | null
}
/** Одна ступень скидочной лесенки за объём: тариф с количеством конфигов >= minConfigs получает скидку
* discountPercent от итоговой цены периода. Действует наивысший подходящий порог (не суммируется). */
export type DiscountTierDto = {
minConfigs: number
@@ -205,9 +233,9 @@ export type PaymentRequestStatus =
| 'Confirmed'
| 'Rejected'
| 'Cancelled'
/** Subscription оплата за период (period задан). RoleChangeTopUp доплата разницы в цене при
* апгрейде роли с активным оплаченным периодом (period null, не продлевает paidUntil). */
export type PaymentRequestKind = 'Subscription' | 'RoleChangeTopUp'
/** Subscription оплата за период (period задан). PlanChangeTopUp доплата разницы в цене при
* увеличении тарифа с активным оплаченным периодом (period null, не продлевает paidUntil). */
export type PaymentRequestKind = 'Subscription' | 'PlanChangeTopUp'
export type PaymentRequestDto = {
id: string
@@ -328,7 +356,7 @@ export type AuditLogDto = {
createdAt: string
}
export type TicketType = 'BugReport' | 'RoleRequest' | 'ExtensionRequest'
export type TicketType = 'BugReport' | 'ExtensionRequest'
export type TicketStatus = 'Open' | 'Resolved' | 'Closed'
export type TicketAttachmentDto = {
@@ -369,11 +397,6 @@ export type TicketDetailDto = {
userName: string
type: TicketType
status: TicketStatus
requestedRoleId: string | null
requestedRoleName: string | null
proposedRoleName: string | null
proposedMaxConfigs: number | null
proposedMaxIpLimit: number | null
requestedDays: number | null
createdAt: string
comments: TicketCommentDto[]
+90 -56
View File
@@ -81,7 +81,7 @@ const resources = {
billingRequiredNotice: 'Оплата подписки истекла — создание новых конфигов недоступно, пока не продлите доступ.',
goToBilling: 'Перейти к оплате',
created: 'Конфиг создан.',
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
quotaExceeded: 'Достигнут лимит конфигов для вашего тарифа.',
showLink: 'Ссылка / QR',
rotate: 'Перевыпустить',
rotated: 'Конфиг перевыпущен.',
@@ -103,6 +103,26 @@ const resources = {
},
},
plan: {
title: 'Тариф',
changePlan: 'Изменить тариф',
current: 'Текущая квота: {{count}} конфигов',
currentUnlimited: 'Текущая квота: без лимита',
custom: 'Своё количество',
customConfigCount: 'Количество конфигов',
submit: 'Применить',
changed: 'Тариф изменён.',
downgradeNotice: 'Новая квота меньше текущего числа активных конфигов — выберите, какие отозвать:',
selectExactly: 'Нужно выбрать ровно {{count}} конфиг(ов) для отзыва.',
topUpCreated: 'Тариф увеличен. Создана доплата за оставшийся оплаченный период.',
goToBilling: 'Перейти к оплате',
mustSelectConfigs: 'Выберите конфиги для отзыва.',
perQuarter: '3 мес: {{price}}',
perHalfYear: 'Полгода: {{price}}',
perYear: 'Год: {{price}}',
noPrice: '—',
},
billing: {
title: 'Оплата подписки',
notApplicable: 'Биллинг не применяется к вашей роли.',
@@ -147,8 +167,8 @@ const resources = {
confirmCancel: 'Отменить заявку на оплату?',
requestCancelled: 'Заявка отменена.',
awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.',
roleChangeTopUp: 'Доплата за смену роли',
roleChangeTopUpHint: 'Новая роль дороже прежней — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.',
planChangeTopUp: 'Доплата за смену тарифа',
planChangeTopUpHint: 'Новый тариф дороже прежнего — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.',
subscription: 'Подписка',
},
@@ -175,7 +195,6 @@ const resources = {
title: 'Поддержка',
empty: 'У вас пока нет обращений.',
reportBug: 'Сообщить об ошибке',
requestRole: 'Запросить роль',
requestExtension: 'Попросить о продлении',
requestedDaysLabel: 'Сколько дней нужно',
requestedExtension: 'Запрошено продление на {{days}} дн.',
@@ -184,32 +203,15 @@ const resources = {
messageLabel: 'Опишите проблему или предложение',
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
filesSelected: '{{count}} файл(ов) выбрано',
existingRole: 'Существующая роль',
newRole: 'Новая роль',
selectRole: 'Выберите роль',
roleOption: '{{name}} — конфигов: {{configs}}, IP: {{ip}}',
newRoleName: 'Название роли',
newRoleMaxConfigs: 'Количество конфигов (-1 — без лимита)',
newRoleMaxIpLimit: 'Количество IP (-1 — без лимита)',
pricingTitle: 'Ориентировочная стоимость',
pricingQuarter: '3 месяца: {{price}}',
pricingHalfYear: 'Полгода: {{price}}',
pricingYear: 'Год: {{price}}',
pricingDisclaimer: 'Цены на данный момент ознакомительные.',
pricingDiscounted: '{{price}} ₽ (вместо {{original}} ₽, скидка {{percent}}%)',
justification: 'Обоснование',
ticketCreated: 'Обращение отправлено.',
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
reply: 'Ответить',
replyPlaceholder: 'Написать комментарий…',
reopen: 'Переоткрыть',
reopened: 'Тикет переоткрыт.',
lastActivity: 'Последняя активность: {{date}}',
requestedExistingRole: 'Запрошена роль: {{role}}',
requestedNewRole: 'Запрошена новая роль «{{name}}» (конфигов: {{configs}}, IP: {{ip}})',
type: {
BugReport: 'Ошибка/предложение',
RoleRequest: 'Заявка на роль',
ExtensionRequest: 'Заявка на продление',
},
status: {
@@ -254,6 +256,7 @@ const resources = {
users: 'Пользователи',
configs: 'Все конфиги',
roles: 'Роли',
plans: 'Тарифы',
pricing: 'Цены',
billing: 'Оплата',
nodes: 'Ноды',
@@ -331,25 +334,39 @@ const resources = {
roles: {
create: 'Создать роль',
name: 'Название',
maxConfigs: 'Квота конфигов',
maxConfigsHint: '1 = без лимита.',
maxIpLimit: 'Лимит IP на конфиг',
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
totalPerQuarter: 'Итого / 3 мес',
totalHalfYear: 'Итого / полгода',
totalPerYear: 'Итого / год',
noPrice: '—',
system: 'системная',
edit: 'Изменить',
save: 'Сохранить',
delete: 'Удалить',
confirmDelete: 'Удалить роль? Это действие необратимо.',
created: 'Роль создана.',
updated: 'Квота обновлена.',
updated: 'Роль обновлена.',
deleted: 'Роль удалена.',
billingEnabledLabel: 'Включить биллинг для этой роли',
billingBadge: 'биллинг',
},
plans: {
create: 'Создать тариф',
name: 'Название',
configCount: 'Количество конфигов',
sortOrder: 'Порядок сортировки',
enabled: 'Включён',
totalPerQuarter: 'Итого / 3 мес',
totalHalfYear: 'Итого / полгода',
totalPerYear: 'Итого / год',
noPrice: '—',
edit: 'Изменить',
save: 'Сохранить',
delete: 'Удалить',
confirmDelete: 'Удалить тариф? Это действие необратимо.',
created: 'Тариф создан.',
updated: 'Тариф обновлён.',
deleted: 'Тариф удалён.',
empty: 'Тарифы пока не созданы.',
disabled: 'выключен',
},
pricing: {
title: 'Справочная цена конфига',
pricePerConfigPerQuarter: 'Цена за конфиг в месяц / оплата раз в 3 мес',
@@ -651,7 +668,7 @@ const resources = {
billingRequiredNotice: 'Your subscription has expired — creating new configs is unavailable until you renew.',
goToBilling: 'Go to payment',
created: 'Config created.',
quotaExceeded: 'Config quota reached for your role.',
quotaExceeded: 'Config quota reached for your plan.',
showLink: 'Link / QR',
rotate: 'Rotate',
rotated: 'Config rotated.',
@@ -673,6 +690,26 @@ const resources = {
},
},
plan: {
title: 'Plan',
changePlan: 'Change plan',
current: 'Current quota: {{count}} configs',
currentUnlimited: 'Current quota: unlimited',
custom: 'Custom amount',
customConfigCount: 'Number of configs',
submit: 'Apply',
changed: 'Plan changed.',
downgradeNotice: 'The new quota is lower than your current number of active configs — choose which to revoke:',
selectExactly: 'Select exactly {{count}} config(s) to revoke.',
topUpCreated: 'Plan increased. A top-up was created for the remaining paid period.',
goToBilling: 'Go to payment',
mustSelectConfigs: 'Select configs to revoke.',
perQuarter: '3 months: {{price}}',
perHalfYear: '6 months: {{price}}',
perYear: 'Year: {{price}}',
noPrice: '—',
},
billing: {
title: 'Subscription billing',
notApplicable: 'Billing does not apply to your role.',
@@ -717,8 +754,8 @@ const resources = {
confirmCancel: 'Cancel this payment request?',
requestCancelled: 'Request cancelled.',
awaitingAdminHint: 'The administrator has been notified and will verify the payment. Configs stay active until the request is decided.',
roleChangeTopUp: 'Role change top-up',
roleChangeTopUpHint: "Your new role costs more than the old one — this amount covers the price difference for the remaining part of your already-paid period; your subscription end date doesn't change.",
planChangeTopUp: 'Plan change top-up',
planChangeTopUpHint: "Your new plan costs more than the old one — this amount covers the price difference for the remaining part of your already-paid period; your subscription end date doesn't change.",
subscription: 'Subscription',
},
@@ -745,7 +782,6 @@ const resources = {
title: 'Support',
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',
@@ -754,32 +790,15 @@ const resources = {
messageLabel: 'Describe the issue or suggestion',
attachmentsLabel: 'Screenshots (optional, up to 5)',
filesSelected: '{{count}} file(s) selected',
existingRole: 'Existing role',
newRole: 'New role',
selectRole: 'Select a role',
roleOption: '{{name}} — configs: {{configs}}, IP: {{ip}}',
newRoleName: 'Role name',
newRoleMaxConfigs: 'Max configs (-1 = unlimited)',
newRoleMaxIpLimit: 'Max IPs (-1 = unlimited)',
pricingTitle: 'Estimated cost',
pricingQuarter: '3 months: {{price}}',
pricingHalfYear: '6 months: {{price}}',
pricingYear: 'Year: {{price}}',
pricingDisclaimer: 'Prices are indicative only at this time.',
pricingDiscounted: '{{price}} ₽ (instead of {{original}} ₽, {{percent}}% off)',
justification: 'Justification',
ticketCreated: 'Ticket submitted.',
roleRequestPending: 'You already have a pending role request.',
reply: 'Reply',
replyPlaceholder: 'Write a comment…',
reopen: 'Reopen',
reopened: 'Ticket reopened.',
lastActivity: 'Last activity: {{date}}',
requestedExistingRole: 'Requested role: {{role}}',
requestedNewRole: 'Requested new role "{{name}}" (configs: {{configs}}, IPs: {{ip}})',
type: {
BugReport: 'Bug/suggestion',
RoleRequest: 'Role request',
ExtensionRequest: 'Extension request',
},
status: {
@@ -824,6 +843,7 @@ const resources = {
users: 'Users',
configs: 'All configs',
roles: 'Roles',
plans: 'Plans',
pricing: 'Pricing',
billing: 'Billing',
nodes: 'Nodes',
@@ -901,25 +921,39 @@ const resources = {
roles: {
create: 'Create role',
name: 'Name',
maxConfigs: 'Config quota',
maxConfigsHint: '1 = unlimited.',
maxIpLimit: 'IP limit per config',
maxIpLimitHint: '1 = unlimited. Applies to new configs only.',
totalPerQuarter: 'Total / 3 months',
totalHalfYear: 'Total / 6 months',
totalPerYear: 'Total / year',
noPrice: '—',
system: 'system',
edit: 'Edit',
save: 'Save',
delete: 'Delete',
confirmDelete: 'Delete this role? This cannot be undone.',
created: 'Role created.',
updated: 'Quota updated.',
updated: 'Role updated.',
deleted: 'Role deleted.',
billingEnabledLabel: 'Enable billing for this role',
billingBadge: 'billing',
},
plans: {
create: 'Create plan',
name: 'Name',
configCount: 'Number of configs',
sortOrder: 'Sort order',
enabled: 'Enabled',
totalPerQuarter: 'Total / 3 months',
totalHalfYear: 'Total / 6 months',
totalPerYear: 'Total / year',
noPrice: '—',
edit: 'Edit',
save: 'Save',
delete: 'Delete',
confirmDelete: 'Delete this plan? This cannot be undone.',
created: 'Plan created.',
updated: 'Plan updated.',
deleted: 'Plan deleted.',
empty: 'No plans created yet.',
disabled: 'disabled',
},
pricing: {
title: 'Reference config price',
pricePerConfigPerQuarter: 'Price per config per month / billed every 3 months',