refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+100
View File
@@ -0,0 +1,100 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
import type {
Redemption,
ApiResponse,
GetRedemptionsParams,
GetRedemptionsResponse,
SearchRedemptionsParams,
RedemptionFormData,
} from './types'
// ============================================================================
// Redemption Code Management
// ============================================================================
// Get paginated redemption codes list
export async function getRedemptions(
params: GetRedemptionsParams = {}
): Promise<GetRedemptionsResponse> {
const { p = 1, page_size = 10 } = params
const res = await api.get(`/api/redemption/?p=${p}&page_size=${page_size}`)
return res.data
}
// Search redemption codes by keyword
export async function searchRedemptions(
params: SearchRedemptionsParams
): Promise<GetRedemptionsResponse> {
const { keyword = '', status = '', p = 1, page_size = 10 } = params
const queryParams = new URLSearchParams()
queryParams.set('keyword', keyword)
if (status) queryParams.set('status', status)
queryParams.set('p', String(p))
queryParams.set('page_size', String(page_size))
const res = await api.get(`/api/redemption/search?${queryParams.toString()}`)
return res.data
}
// Get single redemption code by ID
export async function getRedemption(
id: number
): Promise<ApiResponse<Redemption>> {
const res = await api.get(`/api/redemption/${id}`)
return res.data
}
// Create redemption code(s)
export async function createRedemption(
data: RedemptionFormData
): Promise<ApiResponse<string[]>> {
const res = await api.post('/api/redemption/', data)
return res.data
}
// Update redemption code
export async function updateRedemption(
data: RedemptionFormData & { id: number }
): Promise<ApiResponse<Redemption>> {
const res = await api.put('/api/redemption/', data)
return res.data
}
// Update redemption code status (enable/disable)
export async function updateRedemptionStatus(
id: number,
status: number
): Promise<ApiResponse<Redemption>> {
const res = await api.put('/api/redemption/?status_only=true', { id, status })
return res.data
}
// Delete a single redemption code
export async function deleteRedemption(id: number): Promise<ApiResponse> {
const res = await api.delete(`/api/redemption/${id}/`)
return res.data
}
// Delete invalid redemption codes (used, disabled, expired)
export async function deleteInvalidRedemptions(): Promise<ApiResponse<number>> {
const res = await api.delete('/api/redemption/invalid')
return res.data
}
@@ -0,0 +1,59 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { Table } from '@tanstack/react-table'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import type { Redemption } from '../types'
type DataTableBulkActionsProps<TData> = {
table: Table<TData>
}
export function DataTableBulkActions<TData>({
table,
}: DataTableBulkActionsProps<TData>) {
const { t } = useTranslation()
const selectedRows = table.getSelectedRowModel().rows
const contentToCopy = useMemo(() => {
const selectedCodes = selectedRows.map((row) => {
const redemption = row.original as Redemption
return `${redemption.name}\t${redemption.key}`
})
return selectedCodes.join('\n')
}, [selectedRows])
return (
<BulkActionsToolbar table={table} entityName={t('redemption code')}>
<CopyButton
value={contentToCopy}
variant='outline'
size='icon'
className='size-8'
tooltip={t('Copy selected codes')}
successTooltip={t('Codes copied!')}
aria-label={t('Copy selected codes')}
/>
</BulkActionsToolbar>
)
}
@@ -0,0 +1,136 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { Row } from '@tanstack/react-table'
import { Trash2, Edit, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { updateRedemptionStatus } from '../api'
import { REDEMPTION_STATUS, SUCCESS_MESSAGES } from '../constants'
import { isRedemptionExpired } from '../lib'
import { redemptionSchema } from '../types'
import { useRedemptions } from './redemptions-provider'
interface DataTableRowActionsProps<TData> {
row: Row<TData>
}
export function DataTableRowActions<TData>({
row,
}: DataTableRowActionsProps<TData>) {
const { t } = useTranslation()
const redemption = redemptionSchema.parse(row.original)
const { setOpen, setCurrentRow, triggerRefresh } = useRedemptions()
const isEnabled = redemption.status === REDEMPTION_STATUS.ENABLED
const isUsed = redemption.status === REDEMPTION_STATUS.USED
const isExpired = isRedemptionExpired(
redemption.expired_time,
redemption.status
)
const handleToggleStatus = async () => {
const newStatus = isEnabled
? REDEMPTION_STATUS.DISABLED
: REDEMPTION_STATUS.ENABLED
const result = await updateRedemptionStatus(redemption.id, newStatus)
if (result.success) {
const message = isEnabled
? t(SUCCESS_MESSAGES.REDEMPTION_DISABLED)
: t(SUCCESS_MESSAGES.REDEMPTION_ENABLED)
toast.success(message)
triggerRefresh()
}
}
const canEdit = isEnabled && !isExpired
const canToggle = !isUsed && !isExpired
return (
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={() => {
setCurrentRow(redemption)
setOpen('update')
}}
disabled={!canEdit}
aria-label={t('Edit')}
/>
}
>
<Edit />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<DataTableRowActionMenu ariaLabel={t('Open menu')} modal={false}>
{canToggle && (
<DropdownMenuItem onClick={handleToggleStatus}>
{isEnabled ? (
<>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</>
) : (
<>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</>
)}
</DropdownMenuItem>
)}
{canToggle && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => {
setCurrentRow(redemption)
setOpen('delete')
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
</div>
)
}
@@ -0,0 +1,262 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { MaskedValueDisplay } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { formatQuota, formatTimestampToDate } from '@/lib/format'
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
import { isRedemptionExpired, isTimestampExpired } from '../lib'
import { type Redemption } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
const { t } = useTranslation()
return [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label={t('Select all')}
className='translate-y-[2px]'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t('Select row')}
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
},
{
accessorKey: 'id',
header: t('ID'),
meta: { mobileHidden: true },
cell: ({ row }) => {
return (
<TableId value={row.getValue('id') as number} className='w-[60px]' />
)
},
size: 80,
},
{
accessorKey: 'name',
header: t('Name'),
meta: { mobileTitle: true },
cell: ({ row }) => (
<span className='font-medium'>{row.getValue('name')}</span>
),
size: 180,
},
{
accessorKey: 'status',
header: t('Status'),
meta: { mobileBadge: true },
cell: ({ row }) => {
const redemption = row.original
const statusValue = row.getValue('status') as number
// Check if expired
if (isRedemptionExpired(redemption.expired_time, statusValue)) {
return (
<StatusBadge
label={t('Expired')}
variant='warning'
copyable={false}
className='-ml-1.5'
/>
)
}
const statusConfig = REDEMPTION_STATUSES[statusValue]
if (!statusConfig) {
return null
}
return (
<StatusBadge
label={t(statusConfig.labelKey)}
variant={statusConfig.variant}
copyable={false}
className='-ml-1.5'
/>
)
},
filterFn: (row, id, value) => {
const redemption = row.original
const statusValue = row.getValue(id) as number
// Check if expired status is being filtered
if (value.includes(REDEMPTION_FILTER_EXPIRED)) {
if (isRedemptionExpired(redemption.expired_time, statusValue)) {
return true
}
}
// Check regular status
return value.includes(String(statusValue))
},
size: 120,
},
{
id: 'code',
accessorKey: 'key',
header: t('Code'),
cell: function CodeCell({ row }) {
const redemption = row.original
const key = redemption.key
const maskedKey = `${key.slice(0, 8)}${'*'.repeat(16)}${key.slice(-8)}`
return (
<MaskedValueDisplay
label={t('Full Code')}
fullValue={key}
maskedValue={maskedKey}
copyTooltip={t('Copy code')}
copyAriaLabel={t('Copy redemption code')}
/>
)
},
enableSorting: false,
size: 320,
},
{
accessorKey: 'quota',
header: t('Quota'),
cell: ({ row }) => {
const quota = row.getValue('quota') as number
return (
<StatusBadge
label={formatQuota(quota)}
variant='neutral'
copyable={false}
className='-ml-1.5'
/>
)
},
size: 120,
},
{
accessorKey: 'created_time',
header: t('Created'),
meta: { mobileHidden: true },
cell: ({ row }) => {
return (
<div className='min-w-[160px] font-mono text-sm'>
{formatTimestampToDate(row.getValue('created_time'))}
</div>
)
},
size: 180,
},
{
accessorKey: 'expired_time',
header: t('Expires'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const expiredTime = row.getValue('expired_time') as number
if (expiredTime === 0) {
return (
<StatusBadge
label={t('Never')}
variant='neutral'
copyable={false}
className='-ml-1.5'
/>
)
}
const isExpired = isTimestampExpired(expiredTime)
return (
<div
className={`min-w-[160px] font-mono text-sm ${isExpired ? 'text-destructive' : ''}`}
>
{formatTimestampToDate(expiredTime)}
</div>
)
},
size: 180,
},
{
accessorKey: 'used_user_id',
header: t('Redeemed By'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const userId = row.getValue('used_user_id') as number
const redemption = row.original
if (userId === 0) {
return <span className='text-muted-foreground text-sm'>-</span>
}
return (
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={t('User {{id}}', { id: userId })}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
></TooltipTrigger>
<TooltipContent>
<div className='space-y-1 text-xs'>
<div>
{t('User ID:')} {userId}
</div>
{redemption.redeemed_time > 0 && (
<div>
{t('Redeemed:')}{' '}
{formatTimestampToDate(redemption.redeemed_time)}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
)
},
size: 140,
},
{
id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { pinned: 'right' as const },
},
]
}
@@ -0,0 +1,88 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { deleteRedemption } from '../api'
import { SUCCESS_MESSAGES } from '../constants'
import { useRedemptions } from './redemptions-provider'
export function RedemptionsDeleteDialog() {
const { t } = useTranslation()
const { open, setOpen, currentRow, triggerRefresh } = useRedemptions()
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async () => {
if (!currentRow) return
setIsDeleting(true)
try {
const result = await deleteRedemption(currentRow.id)
if (result.success) {
toast.success(t(SUCCESS_MESSAGES.REDEMPTION_DELETED))
setOpen(null)
triggerRefresh()
}
} finally {
setIsDeleting(false)
}
}
return (
<AlertDialog
open={open === 'delete'}
onOpenChange={(open) => !open && setOpen(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('This will permanently delete redemption code')}{' '}
<span className='font-semibold'>{currentRow?.name}</span>
{t('. This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
variant='destructive'
>
{isDeleting ? t('Deleting...') : t('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,37 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { RedemptionsDeleteDialog } from './redemptions-delete-dialog'
import { RedemptionsMutateDrawer } from './redemptions-mutate-drawer'
import { useRedemptions } from './redemptions-provider'
export function RedemptionsDialogs() {
const { open, setOpen, currentRow } = useRedemptions()
const isUpdate = open === 'update'
return (
<>
<RedemptionsMutateDrawer
open={open === 'create' || isUpdate}
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
currentRow={isUpdate ? currentRow || undefined : undefined}
/>
<RedemptionsDeleteDialog />
</>
)
}
@@ -0,0 +1,175 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { Table as TanstackTable } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DISABLED_ROW_MOBILE } from '@/components/data-table'
import { MaskedValueDisplay } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { REDEMPTION_STATUS, REDEMPTION_STATUSES } from '../constants'
import { isRedemptionExpired } from '../lib'
import type { Redemption } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
const MOBILE_SKELETON_KEYS = [
'redemption-mobile-skeleton-1',
'redemption-mobile-skeleton-2',
'redemption-mobile-skeleton-3',
'redemption-mobile-skeleton-4',
'redemption-mobile-skeleton-5',
]
function RedemptionsMobileSkeleton() {
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{MOBILE_SKELETON_KEYS.map((key) => (
<div
key={key}
className='space-y-2 border-b px-3 py-2.5 last:border-b-0'
>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-5 w-16 rounded-md' />
</div>
<div className='flex items-center justify-between gap-3'>
<Skeleton className='h-7 w-44' />
<Skeleton className='h-8 w-16' />
</div>
<Skeleton className='h-3 w-28' />
</div>
))}
</div>
)
}
interface RedemptionsMobileListProps {
table: TanstackTable<Redemption>
isLoading: boolean
}
export function RedemptionsMobileList(props: RedemptionsMobileListProps) {
const { t } = useTranslation()
const rows = props.table.getRowModel().rows
if (props.isLoading) return <RedemptionsMobileSkeleton />
if (!rows.length) {
return (
<div className='rounded-lg border p-8'>
<Empty className='border-none p-0'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Database className='size-6' />
</EmptyMedia>
<EmptyTitle>{t('No Redemption Codes Found')}</EmptyTitle>
<EmptyDescription>
{t(
'No redemption codes available. Create your first redemption code to get started.'
)}
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)
}
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{rows.map((row) => {
const redemption = row.original
const expired = isRedemptionExpired(
redemption.expired_time,
redemption.status
)
const statusConfig = REDEMPTION_STATUSES[redemption.status]
const maskedKey = `${redemption.key.slice(0, 8)}******${redemption.key.slice(-8)}`
return (
<div
key={row.id}
className={cn(
'bg-card space-y-2.5 border-b px-3 py-2.5 last:border-b-0',
expired || redemption.status !== REDEMPTION_STATUS.ENABLED
? DISABLED_ROW_MOBILE
: undefined
)}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<div className='truncate text-sm font-semibold'>
{redemption.name}
</div>
<div className='text-muted-foreground text-[11px]'>
{t('Redemption Code')}
</div>
</div>
{expired ? (
<StatusBadge
label={t('Expired')}
variant='warning'
copyable={false}
/>
) : (
statusConfig && (
<StatusBadge
label={t(statusConfig.labelKey)}
variant={statusConfig.variant}
copyable={false}
/>
)
)}
</div>
<div className='flex min-w-0 items-center justify-between gap-2'>
<div className='min-w-0 flex-1 [&_button:first-child]:max-w-full [&_button:first-child]:truncate [&_button:first-child]:px-0'>
<MaskedValueDisplay
label={t('Full Code')}
fullValue={redemption.key}
maskedValue={maskedKey}
copyTooltip={t('Copy code')}
copyAriaLabel={t('Copy redemption code')}
/>
</div>
<DataTableRowActions row={row} />
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Quota')}</span>
<span className='font-medium tabular-nums'>
{formatQuota(redemption.quota)}
</span>
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,343 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { zodResolver } from '@hookform/resolvers/zod'
import { type FormEvent, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DateTimePicker } from '@/components/datetime-picker'
import {
SideDrawerSection,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
import { addTimeToDate } from '@/lib/time'
import { createRedemption, updateRedemption, getRedemption } from '../api'
import { SUCCESS_MESSAGES } from '../constants'
import {
getRedemptionFormSchema,
type RedemptionFormValues,
REDEMPTION_FORM_DEFAULT_VALUES,
transformFormDataToPayload,
transformRedemptionToFormDefaults,
} from '../lib'
import { type Redemption } from '../types'
import { useRedemptions } from './redemptions-provider'
type RedemptionsMutateDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
currentRow?: Redemption
}
export function RedemptionsMutateDrawer({
open,
onOpenChange,
currentRow,
}: RedemptionsMutateDrawerProps) {
const { t } = useTranslation()
const isUpdate = !!currentRow
const { triggerRefresh } = useRedemptions()
const [isSubmitting, setIsSubmitting] = useState(false)
const form = useForm<RedemptionFormValues>({
resolver: zodResolver(getRedemptionFormSchema(t)),
defaultValues: REDEMPTION_FORM_DEFAULT_VALUES,
})
// Load existing data when updating
useEffect(() => {
if (open && isUpdate && currentRow) {
// For update, fetch fresh data
getRedemption(currentRow.id).then((result) => {
if (result.success && result.data) {
form.reset(transformRedemptionToFormDefaults(result.data))
}
})
} else if (open && !isUpdate) {
// For create, reset to defaults
form.reset(REDEMPTION_FORM_DEFAULT_VALUES)
}
}, [open, isUpdate, currentRow, form])
const onSubmit = async (data: RedemptionFormValues) => {
setIsSubmitting(true)
try {
const basePayload = transformFormDataToPayload(data)
if (isUpdate && currentRow) {
const result = await updateRedemption({
...basePayload,
id: currentRow.id,
})
if (result.success) {
toast.success(t(SUCCESS_MESSAGES.REDEMPTION_UPDATED))
onOpenChange(false)
triggerRefresh()
}
} else {
// Create mode
const result = await createRedemption(basePayload)
if (result.success) {
const count = result.data?.length || 0
toast.success(
count > 1
? t('Successfully created {{count}} redemption codes', {
count,
})
: t(SUCCESS_MESSAGES.REDEMPTION_CREATED)
)
onOpenChange(false)
triggerRefresh()
}
}
} finally {
setIsSubmitting(false)
}
}
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
if (!isUpdate) {
const name = form.getValues('name')
if (!name?.trim()) {
const quota = parseQuotaFromDollars(form.getValues('quota_dollars'))
form.setValue('name', formatQuota(quota), { shouldValidate: true })
}
}
void form.handleSubmit(onSubmit)(event)
}
const handleSetExpiry = (months: number, days: number, hours: number) => {
const newDate = addTimeToDate(months, days, hours)
form.setValue('expired_time', newDate)
}
const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel()
const tokensOnly = currencyMeta.kind === 'tokens'
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', { currency: currencyLabel })
return (
<Sheet
open={open}
onOpenChange={(v) => {
onOpenChange(v)
if (!v) {
form.reset()
}
}}
>
<SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isUpdate
? t('Update Redemption Code')
: t('Create Redemption Code')}
</SheetTitle>
<SheetDescription>
{isUpdate
? t('Update the redemption code by providing necessary info.')
: t(
'Add new redemption code(s) by providing necessary info.'
)}{' '}
{t('Click save when you&apos;re done.')}
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id='redemption-form'
onSubmit={handleSubmit}
className={sideDrawerFormClassName()}
>
<SideDrawerSection>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Enter a name')} />
</FormControl>
<FormDescription>
{t('Name for this redemption code (1-20 characters)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='flex flex-col gap-2'>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
/>
</FormControl>
<div className='grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1M')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 7, 0)}
>
{t('1W')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
</div>
</div>
<FormDescription>
{t('Leave empty for never expires')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<FormField
control={form.control}
name='count'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quantity')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min='1'
max='100'
placeholder={t('Number of codes to create')}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1)
}
/>
</FormControl>
<FormDescription>
{t('Create multiple redemption codes at once (1-100)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</SideDrawerSection>
</form>
</Form>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}>
{t('Close')}
</SheetClose>
<Button form='redemption-form' type='submit' disabled={isSubmitting}>
{isSubmitting ? t('Saving...') : t('Save changes')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,98 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import { deleteInvalidRedemptions } from '../api'
import { ERROR_MESSAGES } from '../constants'
import { useRedemptions } from './redemptions-provider'
export function RedemptionsPrimaryButtons() {
const { t } = useTranslation()
const { setOpen, triggerRefresh } = useRedemptions()
const [showDeleteInvalidConfirm, setShowDeleteInvalidConfirm] =
useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const handleDeleteInvalid = async () => {
setIsDeleting(true)
try {
const result = await deleteInvalidRedemptions()
if (result.success) {
const count = result.data || 0
toast.success(
t('Successfully deleted {{count}} invalid redemption codes', {
count,
})
)
triggerRefresh()
setShowDeleteInvalidConfirm(false)
} else {
toast.error(result.message || t(ERROR_MESSAGES.DELETE_INVALID_FAILED))
}
} finally {
setIsDeleting(false)
}
}
return (
<>
<div className='flex flex-wrap gap-2'>
<Button
size='sm'
variant='outline'
onClick={() => setShowDeleteInvalidConfirm(true)}
>
<Trash2 className='text-destructive h-4 w-4' />
{t('Delete Invalid')}
</Button>
<Button size='sm' onClick={() => setOpen('create')}>
<Plus className='h-4 w-4' />
{t('Create Code')}
</Button>
</div>
<ConfirmDialog
destructive
open={showDeleteInvalidConfirm}
onOpenChange={setShowDeleteInvalidConfirm}
handleConfirm={handleDeleteInvalid}
isLoading={isDeleting}
className='max-w-md'
title={t('Delete Invalid Redemption Codes?')}
desc={
<>
{t('This will delete all')} <strong>{t('used')}</strong>,{' '}
<strong>{t('disabled')}</strong>
{t(', and')} <strong>{t('expired')}</strong>{' '}
{t('redemption codes.')}
<br />
{t('This action cannot be undone.')}
</>
}
confirmText={t('Delete Invalid')}
/>
</>
)
}
@@ -0,0 +1,76 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState } from 'react'
import useDialogState from '@/hooks/use-dialog'
import { type Redemption, type RedemptionsDialogType } from '../types'
type RedemptionsContextType = {
open: RedemptionsDialogType | null
setOpen: (str: RedemptionsDialogType | null) => void
currentRow: Redemption | null
setCurrentRow: React.Dispatch<React.SetStateAction<Redemption | null>>
refreshTrigger: number
triggerRefresh: () => void
}
const RedemptionsContext = React.createContext<RedemptionsContextType | null>(
null
)
export function RedemptionsProvider({
children,
}: {
children: React.ReactNode
}) {
const [open, setOpen] = useDialogState<RedemptionsDialogType>(null)
const [currentRow, setCurrentRow] = useState<Redemption | null>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const triggerRefresh = () => setRefreshTrigger((prev) => prev + 1)
return (
<RedemptionsContext
value={{
open,
setOpen,
currentRow,
setCurrentRow,
refreshTrigger,
triggerRefresh,
}}
>
{children}
</RedemptionsContext>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useRedemptions = () => {
const redemptionsContext = React.useContext(RedemptionsContext)
if (!redemptionsContext) {
throw new Error(
'useRedemptions has to be used within <RedemptionsProvider>'
)
}
return redemptionsContext
}
@@ -0,0 +1,191 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
useDataTable,
} from '@/components/data-table'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { getRedemptions, searchRedemptions } from '../api'
import {
ERROR_MESSAGES,
REDEMPTION_STATUS,
getRedemptionStatusOptions,
} from '../constants'
import { isRedemptionExpired } from '../lib'
import type { Redemption } from '../types'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { useRedemptionsColumns } from './redemptions-columns'
import { RedemptionsMobileList } from './redemptions-mobile-list'
import { useRedemptions } from './redemptions-provider'
const route = getRouteApi('/_authenticated/redemption-codes/')
function isDisabledRedemptionRow(redemption: Redemption) {
return (
redemption.status !== REDEMPTION_STATUS.ENABLED ||
isRedemptionExpired(redemption.expired_time, redemption.status)
)
}
export function RedemptionsTable() {
const { t } = useTranslation()
const columns = useRedemptionsColumns()
const { refreshTrigger } = useRedemptions()
const isMobile = useMediaQuery('(max-width: 640px)')
const {
globalFilter,
onGlobalFilterChange,
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: isMobile ? 10 : 20 },
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [{ columnId: 'status', searchKey: 'status', type: 'array' }],
})
const statusFilter =
(columnFilters.find((filter) => filter.id === 'status')?.value as
| string[]
| undefined) ?? []
const statusFilterValue = statusFilter[0] ?? ''
// Fetch data with React Query
const { data, isLoading, isFetching } = useQuery({
queryKey: [
'redemptions',
pagination.pageIndex + 1,
pagination.pageSize,
globalFilter,
statusFilterValue,
refreshTrigger,
],
queryFn: async () => {
const hasFilter = globalFilter?.trim()
const hasStatusFilter = statusFilterValue !== ''
const params = {
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
}
const result =
hasFilter || hasStatusFilter
? await searchRedemptions({
...params,
keyword: globalFilter,
status: statusFilterValue,
})
: await getRedemptions(params)
if (!result.success) {
toast.error(
result.message ||
t(
hasFilter || hasStatusFilter
? ERROR_MESSAGES.SEARCH_FAILED
: ERROR_MESSAGES.LOAD_FAILED
)
)
return { items: [], total: 0 }
}
return {
items: result.data?.items || [],
total: result.data?.total || 0,
}
},
placeholderData: (previousData) => previousData,
})
const redemptions = data?.items || []
const { table } = useDataTable({
data: redemptions,
columns,
enableRowSelection: true,
columnFilters,
globalFilter,
pagination,
globalFilterFn: (row, _columnId, filterValue) => {
const name = String(row.getValue('name')).toLowerCase()
const id = String(row.getValue('id'))
const searchValue = String(filterValue).toLowerCase()
return name.includes(searchValue) || id.includes(searchValue)
},
onPaginationChange,
onGlobalFilterChange,
onColumnFiltersChange,
manualPagination: true,
manualFiltering: true,
totalCount: data?.total || 0,
ensurePageInRange,
})
const redemptionStatusOptions = useMemo(
() => getRedemptionStatusOptions(t),
[t]
)
return (
<DataTablePage
table={table}
columns={columns}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Redemption Codes Found')}
emptyDescription={t(
'No redemption codes available. Create your first redemption code to get started.'
)}
skeletonKeyPrefix='redemptions-skeleton'
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name or ID...'),
filters: [
{
columnId: 'status',
title: t('Status'),
options: redemptionStatusOptions,
singleSelect: true,
},
],
}}
mobile={<RedemptionsMobileList table={table} isLoading={isLoading} />}
getRowClassName={(row, { isMobile }) => {
if (!isDisabledRedemptionRow(row.original)) return undefined
return isMobile ? DISABLED_ROW_MOBILE : DISABLED_ROW_DESKTOP
}}
bulkActions={<DataTableBulkActions table={table} />}
/>
)
}
+142
View File
@@ -0,0 +1,142 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { TFunction } from 'i18next'
import type { StatusBadgeProps } from '@/components/status-badge'
// ============================================================================
// Redemption Status Configuration
// ============================================================================
export const REDEMPTION_STATUS = {
ENABLED: 1,
DISABLED: 2,
USED: 3,
} as const
export const REDEMPTION_STATUS_VALUES = Object.values(REDEMPTION_STATUS).map(
(value) => String(value)
) as `${number}`[]
// labelKey values are i18n keys; use t(config.labelKey) in components
export const REDEMPTION_STATUSES: Record<
number,
Pick<StatusBadgeProps, 'variant'> & {
labelKey: string
value: number
}
> = {
[REDEMPTION_STATUS.ENABLED]: {
labelKey: 'Unused',
variant: 'success',
value: REDEMPTION_STATUS.ENABLED,
},
[REDEMPTION_STATUS.DISABLED]: {
labelKey: 'Disabled',
variant: 'neutral',
value: REDEMPTION_STATUS.DISABLED,
},
[REDEMPTION_STATUS.USED]: {
labelKey: 'Used',
variant: 'neutral',
value: REDEMPTION_STATUS.USED,
},
} as const
// Virtual status filter value for expired redemption codes
// Note: "Expired" is not a real DB status, it's computed from expired_time
export const REDEMPTION_FILTER_EXPIRED = 'expired'
export const REDEMPTION_FILTER_VALUES = [
String(REDEMPTION_STATUS.ENABLED),
String(REDEMPTION_STATUS.DISABLED),
String(REDEMPTION_STATUS.USED),
REDEMPTION_FILTER_EXPIRED,
] as const
export function getRedemptionStatusOptions(t: TFunction) {
return [
...Object.values(REDEMPTION_STATUSES).map((config) => ({
label: t(config.labelKey),
value: String(config.value),
})),
{
label: t('Expired'),
value: REDEMPTION_FILTER_EXPIRED,
},
]
}
// ============================================================================
// Validation Constants
// ============================================================================
export const REDEMPTION_VALIDATION = {
NAME_MIN_LENGTH: 1,
NAME_MAX_LENGTH: 20,
COUNT_MIN: 1,
COUNT_MAX: 100,
} as const
// ============================================================================
// Error Messages
// ============================================================================
// i18n keys; use t(ERROR_MESSAGES.xxx) when displaying. For form schema with interpolation use getRedemptionFormErrorMessages(t).
export const ERROR_MESSAGES = {
UNEXPECTED: 'An unexpected error occurred',
LOAD_FAILED: 'Failed to load redemption codes',
SEARCH_FAILED: 'Failed to search redemption codes',
CREATE_FAILED: 'Failed to create redemption code',
UPDATE_FAILED: 'Failed to update redemption code',
DELETE_FAILED: 'Failed to delete redemption code',
DELETE_INVALID_FAILED: 'Failed to delete invalid redemption codes',
STATUS_UPDATE_FAILED: 'Failed to update redemption code status',
NAME_LENGTH_INVALID: 'Name must be between {{min}} and {{max}} characters',
COUNT_INVALID: 'Count must be between {{min}} and {{max}}',
EXPIRED_TIME_INVALID: 'Expired time cannot be earlier than current time',
} as const
/** For form schema only: returns translated messages with interpolation. */
export function getRedemptionFormErrorMessages(t: TFunction) {
return {
NAME_LENGTH_INVALID: t(ERROR_MESSAGES.NAME_LENGTH_INVALID, {
min: REDEMPTION_VALIDATION.NAME_MIN_LENGTH,
max: REDEMPTION_VALIDATION.NAME_MAX_LENGTH,
}),
COUNT_INVALID: t(ERROR_MESSAGES.COUNT_INVALID, {
min: REDEMPTION_VALIDATION.COUNT_MIN,
max: REDEMPTION_VALIDATION.COUNT_MAX,
}),
EXPIRED_TIME_INVALID: t(ERROR_MESSAGES.EXPIRED_TIME_INVALID),
} as const
}
// ============================================================================
// Success Messages (i18n keys; use t(SUCCESS_MESSAGES.xxx) when displaying)
// ============================================================================
export const SUCCESS_MESSAGES = {
REDEMPTION_CREATED: 'Redemption code(s) created successfully',
REDEMPTION_UPDATED: 'Redemption code updated successfully',
REDEMPTION_DELETED: 'Redemption code deleted successfully',
REDEMPTION_ENABLED: 'Redemption code enabled successfully',
REDEMPTION_DISABLED: 'Redemption code disabled successfully',
COPY_SUCCESS: 'Copied to clipboard',
} as const
+47
View File
@@ -0,0 +1,47 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { RedemptionsDialogs } from './components/redemptions-dialogs'
import { RedemptionsPrimaryButtons } from './components/redemptions-primary-buttons'
import { RedemptionsProvider } from './components/redemptions-provider'
import { RedemptionsTable } from './components/redemptions-table'
export function Redemptions() {
const { t } = useTranslation()
return (
<RedemptionsProvider>
<SectionPageLayout fixedContent>
<SectionPageLayout.Title>
{t('Redemption Codes')}
</SectionPageLayout.Title>
<SectionPageLayout.Actions>
<RedemptionsPrimaryButtons />
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<RedemptionsTable />
</SectionPageLayout.Content>
</SectionPageLayout>
<RedemptionsDialogs />
</RedemptionsProvider>
)
}
+33
View File
@@ -0,0 +1,33 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
// ============================================================================
// Utility Functions
// ============================================================================
export { isRedemptionExpired, isTimestampExpired } from './utils'
// ============================================================================
// Form Utilities
// ============================================================================
export {
getRedemptionFormSchema,
type RedemptionFormValues,
REDEMPTION_FORM_DEFAULT_VALUES,
transformFormDataToPayload,
transformRedemptionToFormDefaults,
} from './redemption-form'
+104
View File
@@ -0,0 +1,104 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { TFunction } from 'i18next'
import { z } from 'zod'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
import {
REDEMPTION_VALIDATION,
getRedemptionFormErrorMessages,
} from '../constants'
import { type RedemptionFormData, type Redemption } from '../types'
// ============================================================================
// Form Schema (use getRedemptionFormSchema(t) in components for i18n messages)
// ============================================================================
export function getRedemptionFormSchema(t: TFunction) {
const msg = getRedemptionFormErrorMessages(t)
return z.object({
name: z
.string()
.min(REDEMPTION_VALIDATION.NAME_MIN_LENGTH, msg.NAME_LENGTH_INVALID)
.max(REDEMPTION_VALIDATION.NAME_MAX_LENGTH, msg.NAME_LENGTH_INVALID),
quota_dollars: z.number().min(0, t('Quota must be a positive number')),
expired_time: z.date().optional(),
count: z
.number()
.min(REDEMPTION_VALIDATION.COUNT_MIN, msg.COUNT_INVALID)
.max(REDEMPTION_VALIDATION.COUNT_MAX, msg.COUNT_INVALID)
.optional(),
})
}
export type RedemptionFormValues = {
name: string
quota_dollars: number
expired_time?: Date
count?: number
}
// ============================================================================
// Form Defaults
// ============================================================================
export const REDEMPTION_FORM_DEFAULT_VALUES: RedemptionFormValues = {
name: '',
quota_dollars: 10,
expired_time: undefined,
count: 1,
}
// ============================================================================
// Form Data Transformation
// ============================================================================
/**
* Transform form data to API payload
*/
export function transformFormDataToPayload(
data: RedemptionFormValues
): RedemptionFormData {
return {
name: data.name,
quota: parseQuotaFromDollars(data.quota_dollars),
expired_time: data.expired_time
? Math.floor(data.expired_time.getTime() / 1000)
: 0,
count: data.count || 1,
}
}
/**
* Transform redemption data to form defaults
*/
export function transformRedemptionToFormDefaults(
redemption: Redemption
): RedemptionFormValues {
return {
name: redemption.name,
quota_dollars: quotaUnitsToDollars(redemption.quota),
expired_time:
redemption.expired_time > 0
? new Date(redemption.expired_time * 1000)
: undefined,
count: 1,
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/**
* Utility functions for redemption codes
*/
/**
* Check if a Unix timestamp (in seconds) is expired
* @param timestamp - Unix timestamp in seconds (0 means never expires)
* @returns true if the timestamp is in the past
*/
export function isTimestampExpired(timestamp: number): boolean {
if (timestamp === 0) return false
return timestamp < Date.now() / 1000
}
/**
* Check if redemption code is expired based on business logic
* Only enabled redemption codes (status === 1) can be considered expired
* @param expired_time - Unix timestamp in seconds (0 means never expires)
* @param status - Redemption status (1: enabled, 2: disabled, 3: used)
* @returns true if the code is expired
*/
export function isRedemptionExpired(
expired_time: number,
status: number
): boolean {
return status === 1 && isTimestampExpired(expired_time)
}
+86
View File
@@ -0,0 +1,86 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
// ============================================================================
// Redemption Schema & Types
// ============================================================================
export const redemptionSchema = z.object({
id: z.number(),
user_id: z.number(),
name: z.string(),
key: z.string(),
status: z.number(), // 1: enabled, 2: disabled, 3: used
quota: z.number(),
created_time: z.number(),
redeemed_time: z.number(),
expired_time: z.number(), // 0 for never expires
used_user_id: z.number(),
})
export type Redemption = z.infer<typeof redemptionSchema>
// ============================================================================
// API Request/Response Types
// ============================================================================
export interface ApiResponse<T = unknown> {
success: boolean
message?: string
data?: T
}
export interface GetRedemptionsParams {
p?: number
page_size?: number
}
export interface GetRedemptionsResponse {
success: boolean
message?: string
data?: {
items: Redemption[]
total: number
page: number
page_size: number
}
}
export interface SearchRedemptionsParams {
keyword?: string
status?: string
p?: number
page_size?: number
}
export interface RedemptionFormData {
id?: number
name: string
quota: number
expired_time: number
count?: number // Only for create
status?: number // Only for status update
}
// ============================================================================
// Dialog Types
// ============================================================================
export type RedemptionsDialogType = 'create' | 'update' | 'delete' | 'view'