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:
+100
@@ -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'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
@@ -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
@@ -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
@@ -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'
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
Reference in New Issue
Block a user