feat: add promo code management and payment settings

Add default-web promo code CRUD, user/channel concurrency controls, and domestic Alipay/Wxpay billing settings. Harden promo code validation and preserve explicit zero concurrency limits with backend regression coverage.
This commit is contained in:
zizi 2026-05-20 21:45:28 +08:00
parent 36b742ad3d
commit 0ff2fff94d
35 changed files with 2549 additions and 25 deletions

View File

@ -19,6 +19,7 @@ import (
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -876,8 +877,16 @@ type PatchChannel struct {
} }
func UpdateChannel(c *gin.Context) { func UpdateChannel(c *gin.Context) {
var rawPayload map[string]json.RawMessage
err := c.ShouldBindBodyWith(&rawPayload, binding.JSON)
if err != nil {
common.ApiError(c, err)
return
}
_, hasConcurrencyLimit := rawPayload["concurrency_limit"]
channel := PatchChannel{} channel := PatchChannel{}
err := c.ShouldBindJSON(&channel) err = c.ShouldBindBodyWith(&channel, binding.JSON)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return
@ -990,7 +999,11 @@ func UpdateChannel(c *gin.Context) {
} }
} }
model.InitChannelNextResetTime(&channel.Channel) model.InitChannelNextResetTime(&channel.Channel)
err = channel.Update() forceUpdateZeroColumns := make([]string, 0, 1)
if hasConcurrencyLimit && channel.ConcurrencyLimit == 0 {
forceUpdateZeroColumns = append(forceUpdateZeroColumns, "concurrency_limit")
}
err = channel.Update(forceUpdateZeroColumns...)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
return return

View File

@ -13,6 +13,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const promoCodeMaxLength = 32
type createPromoCodeRequest struct { type createPromoCodeRequest struct {
Code string `json:"code"` Code string `json:"code"`
BonusAmount int `json:"bonus_amount"` BonusAmount int `json:"bonus_amount"`
@ -66,10 +68,14 @@ func CreatePromoCode(c *gin.Context) {
common.ApiErrorMsg(c, "优惠码不能为空") common.ApiErrorMsg(c, "优惠码不能为空")
return return
} }
if len(code) > 32 { if len(code) > promoCodeMaxLength {
common.ApiErrorMsg(c, "优惠码长度不能超过 32 个字符") common.ApiErrorMsg(c, "优惠码长度不能超过 32 个字符")
return return
} }
if !isPromoCodeSyntaxValid(code) {
common.ApiErrorMsg(c, "优惠码只能包含大写字母、数字、下划线和连字符")
return
}
if req.BonusAmount <= 0 { if req.BonusAmount <= 0 {
common.ApiErrorMsg(c, "赠送额度必须大于 0") common.ApiErrorMsg(c, "赠送额度必须大于 0")
return return
@ -228,6 +234,19 @@ func normalizePromoCode(code string) string {
return strings.ToUpper(strings.TrimSpace(code)) return strings.ToUpper(strings.TrimSpace(code))
} }
func isPromoCodeSyntaxValid(code string) bool {
if code == "" || len(code) > promoCodeMaxLength {
return false
}
for _, r := range code {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
return true
}
func parsePromoCodeExpiresAt(value string) (*time.Time, error) { func parsePromoCodeExpiresAt(value string) (*time.Time, error) {
expiresAt, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) expiresAt, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
if err != nil { if err != nil {

View File

@ -2,7 +2,6 @@ package controller
import ( import (
"errors" "errors"
"strings"
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
@ -12,6 +11,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const invalidPromoCodeReason = "invalid"
type validatePromoCodeRequest struct { type validatePromoCodeRequest struct {
Code string `json:"code"` Code string `json:"code"`
Amount int `json:"amount"` Amount int `json:"amount"`
@ -32,16 +33,16 @@ func ValidatePromoCode(c *gin.Context) {
return return
} }
code := normalizeUserPromoCode(req.Code) code := normalizePromoCode(req.Code)
if code == "" { if !isPromoCodeSyntaxValid(code) {
common.ApiErrorMsg(c, "优惠码不能为空") common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
return return
} }
promoCode, err := model.GetPromoCodeByCode(code) promoCode, err := model.GetPromoCodeByCode(code)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "not_found")) common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
return return
} }
common.ApiError(c, err) common.ApiError(c, err)
@ -49,25 +50,25 @@ func ValidatePromoCode(c *gin.Context) {
} }
if promoCode.Status != 1 { if promoCode.Status != 1 {
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "disabled")) common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
return return
} }
if promoCode.ExpiresAt != nil && promoCode.ExpiresAt.Before(time.Now()) { if promoCode.ExpiresAt != nil && promoCode.ExpiresAt.Before(time.Now()) {
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "expired")) common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
return return
} }
if promoCode.MaxUses > 0 && promoCode.UsedCount >= promoCode.MaxUses { if promoCode.MaxUses > 0 && promoCode.UsedCount >= promoCode.MaxUses {
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "max_uses_reached")) common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
return return
} }
if req.Amount < promoCode.MinRechargeAmount { if req.Amount < promoCode.MinRechargeAmount {
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "below_min_recharge")) common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
return return
} }
userId := c.GetInt("id") userId := c.GetInt("id")
if _, err := model.GetPromoCodeUsageByCodeAndUser(promoCode.Id, userId); err == nil { if _, err := model.GetPromoCodeUsageByCodeAndUser(promoCode.Id, userId); err == nil {
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "already_used")) common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
return return
} else if !errors.Is(err, gorm.ErrRecordNotFound) { } else if !errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiError(c, err) common.ApiError(c, err)
@ -83,14 +84,10 @@ func ValidatePromoCode(c *gin.Context) {
}) })
} }
func normalizeUserPromoCode(code string) string { func newInvalidPromoCodeResponse(code string) validatePromoCodeResponse {
return strings.ToUpper(strings.TrimSpace(code))
}
func newInvalidPromoCodeResponse(code string, reason string) validatePromoCodeResponse {
return validatePromoCodeResponse{ return validatePromoCodeResponse{
Valid: false, Valid: false,
Reason: reason, Reason: invalidPromoCodeReason,
Code: code, Code: code,
} }
} }

View File

@ -0,0 +1,97 @@
package controller
import (
"net/http"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/stretchr/testify/require"
)
type promoCodeValidateAPIResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data validatePromoCodeResponse `json:"data"`
}
func setupPromoCodeControllerTestDB(t *testing.T) {
t.Helper()
db := openTokenControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.PromoCode{}, &model.PromoCodeUsage{}))
t.Cleanup(func() {
db.Exec("DELETE FROM promo_code_usages")
db.Exec("DELETE FROM promo_codes")
})
}
func validatePromoCodeForTest(t *testing.T, code string, amount int, userID int) promoCodeValidateAPIResponse {
t.Helper()
ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/user/promo-codes/validate", validatePromoCodeRequest{
Code: code,
Amount: amount,
}, userID)
ValidatePromoCode(ctx)
var response promoCodeValidateAPIResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
return response
}
func TestValidatePromoCodeReturnsGenericInvalidReason(t *testing.T) {
setupPromoCodeControllerTestDB(t)
promoCode := &model.PromoCode{
Code: "PROMO_MIN",
BonusAmount: 100,
MaxUses: 0,
MinRechargeAmount: 50,
Status: 1,
}
require.NoError(t, promoCode.Insert())
notFound := validatePromoCodeForTest(t, "missing", 100, 1001)
require.True(t, notFound.Success)
require.False(t, notFound.Data.Valid)
require.Equal(t, invalidPromoCodeReason, notFound.Data.Reason)
belowMinimum := validatePromoCodeForTest(t, "PROMO_MIN", 10, 1001)
require.True(t, belowMinimum.Success)
require.False(t, belowMinimum.Data.Valid)
require.Equal(t, invalidPromoCodeReason, belowMinimum.Data.Reason)
}
func TestValidatePromoCodeRejectsInvalidSyntaxWithoutDetailedReason(t *testing.T) {
setupPromoCodeControllerTestDB(t)
response := validatePromoCodeForTest(t, "invalid code!", 100, 1001)
require.True(t, response.Success)
require.False(t, response.Data.Valid)
require.Equal(t, invalidPromoCodeReason, response.Data.Reason)
require.Zero(t, response.Data.PromoCodeId)
}
func TestValidatePromoCodeReturnsValidPromoCodeDetails(t *testing.T) {
setupPromoCodeControllerTestDB(t)
promoCode := &model.PromoCode{
Code: "PROMO_OK",
BonusAmount: 100,
MaxUses: 0,
MinRechargeAmount: 50,
Status: 1,
}
require.NoError(t, promoCode.Insert())
response := validatePromoCodeForTest(t, "promo_ok", 50, 1001)
require.True(t, response.Success)
require.True(t, response.Data.Valid)
require.Empty(t, response.Data.Reason)
require.Equal(t, promoCode.Id, response.Data.PromoCodeId)
require.Equal(t, 100, response.Data.BonusAmount)
require.Equal(t, "PROMO_OK", response.Data.Code)
}

View File

@ -529,7 +529,7 @@ func (channel *Channel) Insert() error {
return err return err
} }
func (channel *Channel) Update() error { func (channel *Channel) Update(forceUpdateZeroColumns ...string) error {
// If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys // If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys
if channel.ChannelInfo.IsMultiKey { if channel.ChannelInfo.IsMultiKey {
var keyStr string var keyStr string
@ -573,6 +573,13 @@ func (channel *Channel) Update() error {
if err != nil { if err != nil {
return err return err
} }
for _, column := range forceUpdateZeroColumns {
if column == "concurrency_limit" && channel.ConcurrencyLimit == 0 {
if err = DB.Model(channel).Update(column, 0).Error; err != nil {
return err
}
}
}
DB.Model(channel).First(channel, "id = ?", channel.Id) DB.Model(channel).First(channel, "id = ?", channel.Id)
err = channel.UpdateAbilities(nil) err = channel.UpdateAbilities(nil)
return err return err

View File

@ -0,0 +1,66 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestChannelUpdatePersistsExplicitZeroConcurrencyLimit(t *testing.T) {
truncateTables(t)
require.NoError(t, DB.AutoMigrate(&Ability{}))
t.Cleanup(func() {
DB.Exec("DELETE FROM abilities")
})
channel := &Channel{
Type: 1,
Key: "test-key",
Status: 1,
Name: "test-channel",
Models: "gpt-test",
Group: "default",
ConcurrencyLimit: 3,
}
require.NoError(t, DB.Create(channel).Error)
channel.ConcurrencyLimit = 0
require.NoError(t, channel.Update("concurrency_limit"))
var reloaded Channel
require.NoError(t, DB.First(&reloaded, channel.Id).Error)
require.Equal(t, 0, reloaded.ConcurrencyLimit)
}
func TestChannelUpdateKeepsZeroConcurrencyLimitImplicitByDefault(t *testing.T) {
truncateTables(t)
require.NoError(t, DB.AutoMigrate(&Ability{}))
t.Cleanup(func() {
DB.Exec("DELETE FROM abilities")
})
channel := &Channel{
Type: 1,
Key: "test-key",
Status: 1,
Name: "test-channel",
Models: "gpt-test",
Group: "default",
ConcurrencyLimit: 3,
}
require.NoError(t, DB.Create(channel).Error)
update := &Channel{
Id: channel.Id,
Name: "renamed-channel",
Models: channel.Models,
Group: channel.Group,
ConcurrencyLimit: 0,
}
require.NoError(t, update.Update())
var reloaded Channel
require.NoError(t, DB.First(&reloaded, channel.Id).Error)
require.Equal(t, "renamed-channel", reloaded.Name)
require.Equal(t, 3, reloaded.ConcurrencyLimit)
}

View File

@ -652,10 +652,12 @@ func (user *User) Edit(updatePassword bool) error {
newUser := *user newUser := *user
updates := map[string]interface{}{ updates := map[string]interface{}{
"username": newUser.Username, "username": newUser.Username,
"display_name": newUser.DisplayName, "display_name": newUser.DisplayName,
"group": newUser.Group, "group": newUser.Group,
"remark": newUser.Remark, "remark": newUser.Remark,
"concurrency_limit": newUser.ConcurrencyLimit,
"rpm_limit": newUser.RpmLimit,
} }
if updatePassword { if updatePassword {
updates["password"] = newUser.Password updates["password"] = newUser.Password

View File

@ -100,7 +100,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.GET("/topup/info", controller.GetTopUpInfo)
selfRoute.GET("/topup/self", controller.GetUserTopUps) selfRoute.GET("/topup/self", controller.GetUserTopUps)
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
selfRoute.POST("/promo-codes/validate", controller.ValidatePromoCode) selfRoute.POST("/promo-codes/validate", middleware.SearchRateLimit(), controller.ValidatePromoCode)
selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay) selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay)
selfRoute.POST("/amount", controller.RequestAmount) selfRoute.POST("/amount", controller.RequestAmount)
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)

View File

@ -2510,6 +2510,34 @@ export function ChannelMutateDrawer({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name='concurrency_limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Concurrency Limit')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
placeholder='0 = unlimited'
value={field.value ?? 0}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormDescription>
{t(
'Maximum concurrent requests for this channel. 0 = unlimited.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<FormField <FormField

View File

@ -37,6 +37,7 @@ export const channelFormSchema = z.object({
weight: z.number().optional(), weight: z.number().optional(),
test_model: z.string().optional(), test_model: z.string().optional(),
auto_ban: z.number().optional(), auto_ban: z.number().optional(),
concurrency_limit: z.number().int().min(0).optional(),
status: z.number(), status: z.number(),
status_code_mapping: z.string().optional(), status_code_mapping: z.string().optional(),
tag: z.string().optional(), tag: z.string().optional(),
@ -99,6 +100,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
weight: 0, weight: 0,
test_model: '', test_model: '',
auto_ban: 1, auto_ban: 1,
concurrency_limit: 0,
status: CHANNEL_STATUS.ENABLED, status: CHANNEL_STATUS.ENABLED,
status_code_mapping: '', status_code_mapping: '',
tag: '', tag: '',
@ -232,6 +234,7 @@ export function transformChannelToFormDefaults(
weight: channel.weight || 0, weight: channel.weight || 0,
test_model: channel.test_model || '', test_model: channel.test_model || '',
auto_ban: channel.auto_ban ?? 1, auto_ban: channel.auto_ban ?? 1,
concurrency_limit: channel.concurrency_limit || 0,
status: channel.status, status: channel.status,
status_code_mapping: channel.status_code_mapping || '', status_code_mapping: channel.status_code_mapping || '',
tag: channel.tag || '', tag: channel.tag || '',
@ -413,6 +416,7 @@ export function transformFormDataToCreatePayload(formData: ChannelFormValues): {
weight: formData.weight || null, weight: formData.weight || null,
test_model: formData.test_model || null, test_model: formData.test_model || null,
auto_ban: formData.auto_ban ?? 1, auto_ban: formData.auto_ban ?? 1,
concurrency_limit: formData.concurrency_limit || 0,
status: formData.status, status: formData.status,
status_code_mapping: formData.status_code_mapping || null, status_code_mapping: formData.status_code_mapping || null,
tag: formData.tag || null, tag: formData.tag || null,
@ -461,6 +465,7 @@ export function transformFormDataToUpdatePayload(
weight: formData.weight ?? 0, weight: formData.weight ?? 0,
test_model: formData.test_model || null, test_model: formData.test_model || null,
auto_ban: formData.auto_ban ?? 1, auto_ban: formData.auto_ban ?? 1,
concurrency_limit: formData.concurrency_limit || 0,
status: formData.status, status: formData.status,
status_code_mapping: formData.status_code_mapping || null, status_code_mapping: formData.status_code_mapping || null,
tag: formData.tag || null, tag: formData.tag || null,

View File

@ -64,6 +64,7 @@ export const channelSchema = z.object({
header_override: z.string().nullish(), header_override: z.string().nullish(),
remark: z.string().default(''), remark: z.string().default(''),
max_input_tokens: z.number().default(0), max_input_tokens: z.number().default(0),
concurrency_limit: z.number().optional(),
channel_info: channelInfoSchema.default({ channel_info: channelInfoSchema.default({
is_multi_key: false, is_multi_key: false,
multi_key_size: 0, multi_key_size: 0,
@ -318,6 +319,7 @@ export interface ChannelFormData {
header_override?: string header_override?: string
settings?: string settings?: string
other?: string other?: string
concurrency_limit?: number
// Multi-key specific // Multi-key specific
multi_key_mode?: 'single' | 'batch' | 'multi_to_single' multi_key_mode?: 'single' | 'batch' | 'multi_to_single'
multi_key_type?: 'random' | 'polling' multi_key_type?: 'random' | 'polling'

View File

@ -0,0 +1,80 @@
/*
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 {
ApiResponse,
PaginatedData,
PromoCode,
GetPromoCodesParams,
PromoCodeFormData,
PromoCodeUpdateData,
PromoCodeUsage,
} from './types'
export async function getPromoCodes(
params: GetPromoCodesParams = {}
): Promise<ApiResponse<PaginatedData<PromoCode>>> {
const searchParams = new URLSearchParams()
if (params.p) searchParams.set('p', String(params.p))
if (params.page_size) searchParams.set('page_size', String(params.page_size))
if (params.status !== undefined) searchParams.set('status', String(params.status))
if (params.keyword) searchParams.set('keyword', params.keyword)
const res = await api.get(`/api/promo-codes?${searchParams.toString()}`)
return res.data
}
export async function getPromoCode(
id: number
): Promise<ApiResponse<PromoCode>> {
const res = await api.get(`/api/promo-codes/${id}`)
return res.data
}
export async function createPromoCode(
data: PromoCodeFormData
): Promise<ApiResponse<PromoCode>> {
const res = await api.post('/api/promo-codes', data)
return res.data
}
export async function updatePromoCode(
id: number,
data: PromoCodeUpdateData
): Promise<ApiResponse<PromoCode>> {
const res = await api.put(`/api/promo-codes/${id}`, data)
return res.data
}
export async function deletePromoCode(
id: number
): Promise<ApiResponse> {
const res = await api.delete(`/api/promo-codes/${id}`)
return res.data
}
export async function getPromoCodeUsages(
id: number,
p = 1,
pageSize = 10
): Promise<ApiResponse<PaginatedData<PromoCodeUsage>>> {
const res = await api.get(
`/api/promo-codes/${id}/usages?p=${p}&page_size=${pageSize}`
)
return res.data
}

View File

@ -0,0 +1,173 @@
/*
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 { Checkbox } from '@/components/ui/checkbox'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { PROMO_CODE_STATUSES } from '../constants'
import type { PromoCode } from '../types'
import { PromoCodesRowActions } from './promo-codes-row-actions'
export function usePromoCodesColumns(): ColumnDef<PromoCode>[] {
const { t } = useTranslation()
return [
{
id: 'select',
meta: { label: t('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,
},
{
accessorKey: 'id',
meta: { label: t('ID'), mobileHidden: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('ID')} />
),
cell: ({ row }) => <div className='w-[50px] text-sm'>{row.getValue('id')}</div>,
},
{
accessorKey: 'code',
meta: { label: t('Code'), mobileTitle: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Code')} />
),
cell: ({ row }) => (
<div className='max-w-[180px] truncate font-mono font-medium'>
{row.getValue('code')}
</div>
),
},
{
accessorKey: 'status',
meta: { label: t('Status'), mobileBadge: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Status')} />
),
cell: ({ row }) => {
const statusValue = row.getValue('status') as number
const config = PROMO_CODE_STATUSES[statusValue]
if (!config) return null
return (
<StatusBadge
label={t(config.labelKey)}
variant={config.variant}
showDot={config.showDot}
copyable={false}
/>
)
},
filterFn: (row, id, value) => {
return value.includes(String(row.getValue(id)))
},
},
{
accessorKey: 'bonus_amount',
meta: { label: t('Bonus') },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Bonus')} />
),
cell: ({ row }) => {
const amount = row.getValue('bonus_amount') as number
return (
<StatusBadge
label={String(amount)}
variant='neutral'
copyable={false}
/>
)
},
},
{
accessorKey: 'used_count',
meta: { label: t('Used'), mobileHidden: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Used')} />
),
cell: ({ row }) => {
const used = row.getValue('used_count') as number
const maxUses = row.original.max_uses
const label = maxUses > 0 ? `${used}/${maxUses}` : String(used)
return <span className='text-sm'>{label}</span>
},
},
{
accessorKey: 'min_recharge_amount',
meta: { label: t('Min Recharge'), mobileHidden: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Min Recharge')} />
),
cell: ({ row }) => {
const amount = row.getValue('min_recharge_amount') as number
return (
<span className='text-sm'>
{amount > 0 ? String(amount) : '-'}
</span>
)
},
},
{
accessorKey: 'notes',
meta: { label: t('Notes'), mobileHidden: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Notes')} />
),
cell: ({ row }) => {
const notes = row.getValue('notes') as string
return (
<div className='max-w-[150px] truncate text-sm text-muted-foreground'>
{notes || '-'}
</div>
)
},
},
{
accessorKey: 'created_at',
meta: { label: t('Created'), mobileHidden: true },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Created')} />
),
cell: ({ row }) => {
const val = row.getValue('created_at') as string
return <div className='min-w-[140px] font-mono text-sm'>{val ? new Date(val).toLocaleString() : '-'}</div>
},
},
{
id: 'actions',
cell: ({ row }) => <PromoCodesRowActions row={row} />,
},
]
}

View File

@ -0,0 +1,92 @@
/*
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 { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { deletePromoCode } from '../api'
import { SUCCESS_MESSAGES } from '../constants'
import { usePromoCodes } from './promo-codes-provider'
export function PromoCodesDeleteDialog() {
const { t } = useTranslation()
const { dialogType, selectedPromoCode, closeDialog, triggerRefresh } =
usePromoCodes()
const isOpen = dialogType === 'delete' && selectedPromoCode !== null
const deleteMutation = useMutation({
mutationFn: deletePromoCode,
onSuccess: (data) => {
if (data.success) {
toast.success(t(SUCCESS_MESSAGES.PROMO_CODE_DISABLED))
closeDialog()
triggerRefresh()
} else {
toast.error(data.message || t('Failed to disable promo code'))
}
},
onError: (error: Error) => {
toast.error(error.message || t('Failed to disable promo code'))
},
})
const handleDelete = () => {
if (selectedPromoCode) {
deleteMutation.mutate(selectedPromoCode.id)
}
}
return (
<AlertDialog open={isOpen} onOpenChange={(open) => { if (!open) closeDialog() }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Disable Promo Code')}</AlertDialogTitle>
<AlertDialogDescription>
{t(
'Are you sure you want to disable promo code "{{code}}"? It will become unavailable to users.',
{ code: selectedPromoCode?.code || '' }
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleteMutation.isPending}
className='bg-destructive hover:bg-destructive/90'
>
{deleteMutation.isPending ? t('Disabling...') : t('Disable')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -0,0 +1,29 @@
/*
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 { PromoCodesMutateDrawer } from './promo-codes-mutate-drawer'
import { PromoCodesDeleteDialog } from './promo-codes-delete-dialog'
export function PromoCodesDialogs() {
return (
<>
<PromoCodesMutateDrawer />
<PromoCodesDeleteDialog />
</>
)
}

View File

@ -0,0 +1,323 @@
/*
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 { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { createPromoCode, updatePromoCode } from '../api'
import { SUCCESS_MESSAGES } from '../constants'
import {
getPromoCodeFormSchema,
PROMO_CODE_FORM_DEFAULTS,
transformFormDataToCreatePayload,
transformFormDataToUpdatePayload,
transformPromoCodeToFormDefaults,
type PromoCodeFormValues,
} from '../lib'
import { usePromoCodes } from './promo-codes-provider'
import type { PromoCode } from '../types'
function computeDefaults(promo: PromoCode | null): PromoCodeFormValues {
if (!promo) return PROMO_CODE_FORM_DEFAULTS
return transformPromoCodeToFormDefaults(promo)
}
function parseOptionalNumberInput(value: string) {
return value === '' ? undefined : Number(value)
}
export function PromoCodesMutateDrawer() {
const { t } = useTranslation()
const {
dialogType,
selectedPromoCode,
closeDialog,
triggerRefresh,
} = usePromoCodes()
const isUpdate = dialogType === 'update'
const isOpen = dialogType === 'create' || dialogType === 'update'
const schema = getPromoCodeFormSchema(t)
const form = useForm<PromoCodeFormValues>({
resolver: zodResolver(schema),
defaultValues: {
code: '',
bonus_amount: 0,
max_uses: 0,
min_recharge_amount: 0,
expires_at: '',
notes: '',
},
})
useEffect(() => {
if (isOpen) {
form.reset(computeDefaults(selectedPromoCode))
}
}, [isOpen, selectedPromoCode, form])
const createMutation = useMutation({
mutationFn: createPromoCode,
onSuccess: (data) => {
if (data.success) {
toast.success(t(SUCCESS_MESSAGES.PROMO_CODE_CREATED))
form.reset()
closeDialog()
triggerRefresh()
} else {
toast.error(data.message || t('Failed to create promo code'))
}
},
onError: (error: Error) => {
toast.error(error.message || t('Failed to create promo code'))
},
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: ReturnType<typeof transformFormDataToUpdatePayload> }) =>
updatePromoCode(id, data),
onSuccess: (data) => {
if (data.success) {
toast.success(t(SUCCESS_MESSAGES.PROMO_CODE_UPDATED))
closeDialog()
triggerRefresh()
} else {
toast.error(data.message || t('Failed to update promo code'))
}
},
onError: (error: Error) => {
toast.error(error.message || t('Failed to update promo code'))
},
})
const onSubmit = (values: PromoCodeFormValues) => {
if (isUpdate && selectedPromoCode) {
updateMutation.mutate({
id: selectedPromoCode.id,
data: transformFormDataToUpdatePayload(values),
})
} else {
createMutation.mutate(transformFormDataToCreatePayload(values))
}
}
const isPending = createMutation.isPending || updateMutation.isPending
return (
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) closeDialog() }}>
<SheetContent side='right' className='w-full max-w-md sm:max-w-lg'>
<SheetHeader>
<SheetTitle>
{isUpdate ? t('Edit Promo Code') : t('Create Promo Code')}
</SheetTitle>
<SheetDescription>
{isUpdate
? t('Update promo code settings. Code and bonus amount cannot be changed.')
: t('Create a new promo code for users to redeem.')}
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id='promo-code-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 overflow-y-auto px-1 py-4 space-y-4'
>
<FormField
control={form.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Code')}</FormLabel>
<FormControl>
<Input
placeholder='SUMMER2024'
{...field}
disabled={isUpdate}
onChange={(e) => field.onChange(e.target.value.toUpperCase())}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='bonus_amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Bonus Amount')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
step={1}
disabled={isUpdate}
value={field.value ?? ''}
onBlur={(event) => {
field.onBlur()
if (event.target.value === '') field.onChange(0)
}}
onChange={(e) =>
field.onChange(parseOptionalNumberInput(e.target.value))
}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_uses'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Max Uses')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
placeholder='0 = unlimited'
value={field.value ?? ''}
onBlur={(event) => {
field.onBlur()
if (event.target.value === '') field.onChange(0)
}}
onChange={(e) =>
field.onChange(parseOptionalNumberInput(e.target.value))
}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='min_recharge_amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Min Recharge Amount')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
placeholder='0 = no minimum'
value={field.value ?? ''}
onBlur={(event) => {
field.onBlur()
if (event.target.value === '') field.onChange(0)
}}
onChange={(e) =>
field.onChange(parseOptionalNumberInput(e.target.value))
}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expires_at'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expires At')}</FormLabel>
<FormControl>
<Input
type='datetime-local'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='notes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Notes')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Optional notes...')}
{...field}
rows={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<SheetFooter className='pt-4'>
<SheetClose render={<Button variant='outline' />}>
{t('Cancel')}
</SheetClose>
<Button
type='submit'
form='promo-code-form'
disabled={isPending}
>
{isPending ? t('Saving...') : isUpdate ? t('Save') : t('Create')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@ -0,0 +1,34 @@
/*
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 { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import { usePromoCodes } from './promo-codes-provider'
export function PromoCodesPrimaryButtons() {
const { t } = useTranslation()
const { openCreateDialog } = usePromoCodes()
return (
<Button onClick={openCreateDialog} size='sm'>
<Plus className='mr-2 h-4 w-4' />
{t('Create Promo Code')}
</Button>
)
}

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 { createContext, useContext, useState, useCallback } from 'react'
import type { PromoCode, PromoCodesDialogType } from '../types'
interface PromoCodesContextValue {
dialogType: PromoCodesDialogType | null
selectedPromoCode: PromoCode | null
refreshTrigger: number
openCreateDialog: () => void
openUpdateDialog: (promo: PromoCode) => void
openDeleteDialog: (promo: PromoCode) => void
closeDialog: () => void
triggerRefresh: () => void
}
const PromoCodesContext = createContext<PromoCodesContextValue | null>(null)
export function usePromoCodes() {
const ctx = useContext(PromoCodesContext)
if (!ctx) throw new Error('usePromoCodes must be used within PromoCodesProvider')
return ctx
}
export function PromoCodesProvider({ children }: { children: React.ReactNode }) {
const [dialogType, setDialogType] = useState<PromoCodesDialogType | null>(null)
const [selectedPromoCode, setSelectedPromoCode] = useState<PromoCode | null>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const closeDialog = useCallback(() => {
setDialogType(null)
setSelectedPromoCode(null)
}, [])
const openCreateDialog = useCallback(() => {
setSelectedPromoCode(null)
setDialogType('create')
}, [])
const openUpdateDialog = useCallback((promo: PromoCode) => {
setSelectedPromoCode(promo)
setDialogType('update')
}, [])
const openDeleteDialog = useCallback((promo: PromoCode) => {
setSelectedPromoCode(promo)
setDialogType('delete')
}, [])
const triggerRefresh = useCallback(() => {
setRefreshTrigger((prev) => prev + 1)
}, [])
return (
<PromoCodesContext.Provider
value={{
dialogType,
selectedPromoCode,
refreshTrigger,
openCreateDialog,
openUpdateDialog,
openDeleteDialog,
closeDialog,
triggerRefresh,
}}
>
{children}
</PromoCodesContext.Provider>
)
}

View File

@ -0,0 +1,69 @@
/*
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 { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreHorizontal } from 'lucide-react'
import { type Row } from '@tanstack/react-table'
import type { PromoCode } from '../types'
import { usePromoCodes } from './promo-codes-provider'
interface PromoCodesRowActionsProps {
row: Row<PromoCode>
}
export function PromoCodesRowActions({ row }: PromoCodesRowActionsProps) {
const { t } = useTranslation()
const { openUpdateDialog, openDeleteDialog } = usePromoCodes()
const promo = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
/>
}
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem onClick={() => openUpdateDialog(promo)}>
{t('Edit')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className='text-destructive'
onClick={() => openDeleteDialog(promo)}
>
{t('Disable')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,195 @@
/*
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 { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
type SortingState,
type VisibilityState,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
} from '@/components/data-table'
import { getPromoCodes } from '../api'
import { PROMO_CODE_STATUS, getPromoCodeStatusOptions } from '../constants'
import type { PromoCode } from '../types'
import { usePromoCodesColumns } from './promo-codes-columns'
import { usePromoCodes } from './promo-codes-provider'
const route = getRouteApi('/_authenticated/promo-codes/')
function isDisabledPromoCodeRow(promo: PromoCode) {
return promo.status !== PROMO_CODE_STATUS.ACTIVE
}
function getStatusFilterValue(columnFilters: { id: string; value: unknown }[]) {
const value = columnFilters.find((filter) => filter.id === 'status')?.value
const status = Array.isArray(value) ? value[0] : value
if (status === undefined || status === null || status === '') return undefined
const parsed = Number(status)
return Number.isFinite(parsed) ? parsed : undefined
}
export function PromoCodesTable() {
const { t } = useTranslation()
const columns = usePromoCodesColumns()
const { refreshTrigger } = usePromoCodes()
const isMobile = useMediaQuery('(max-width: 640px)')
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
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 = useMemo(
() => getStatusFilterValue(columnFilters),
[columnFilters]
)
const { data, isLoading, isFetching } = useQuery({
queryKey: [
'promo-codes',
pagination.pageIndex + 1,
pagination.pageSize,
globalFilter,
statusFilter,
refreshTrigger,
],
queryFn: async () => {
const params: Record<string, unknown> = {
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
}
if (globalFilter?.trim()) params.keyword = globalFilter
if (statusFilter !== undefined) params.status = statusFilter
const result = await getPromoCodes(params)
return {
items: result.data?.items || [],
total: result.data?.total || 0,
}
},
placeholderData: (previousData) => previousData,
})
const promos = data?.items || []
const table = useReactTable({
data: promos,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
globalFilter,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
globalFilterFn: (row, _columnId, filterValue) => {
const code = String(row.getValue('code')).toLowerCase()
const id = String(row.getValue('id'))
const searchValue = String(filterValue).toLowerCase()
return code.includes(searchValue) || id.includes(searchValue)
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
onPaginationChange,
onGlobalFilterChange,
onColumnFiltersChange,
manualFiltering: true,
manualPagination: !globalFilter,
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
})
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
const statusOptions = useMemo(
() => getPromoCodeStatusOptions(t),
[t]
)
return (
<DataTablePage
table={table}
columns={columns}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Promo Codes Found')}
emptyDescription={t(
'No promo codes available. Create your first promo code to get started.'
)}
skeletonKeyPrefix='promo-codes-skeleton'
toolbarProps={{
searchPlaceholder: t('Filter by code or ID...'),
filters: [
{
columnId: 'status',
title: t('Status'),
options: statusOptions,
singleSelect: true,
},
],
}}
getRowClassName={(row, { isMobile }) =>
isDisabledPromoCodeRow(row.original)
? isMobile
? DISABLED_ROW_MOBILE
: DISABLED_ROW_DESKTOP
: undefined
}
/>
)
}

View File

@ -0,0 +1,93 @@
/*
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'
export const PROMO_CODE_STATUS = {
DISABLED: 0,
ACTIVE: 1,
} as const
export const PROMO_CODE_STATUS_VALUES = Object.values(PROMO_CODE_STATUS).map(
(value) => String(value)
) as `${number}`[]
export const PROMO_CODE_STATUSES: Record<
number,
Pick<StatusBadgeProps, 'variant' | 'showDot'> & {
labelKey: string
value: number
}
> = {
[PROMO_CODE_STATUS.DISABLED]: {
labelKey: 'Disabled',
variant: 'neutral',
value: PROMO_CODE_STATUS.DISABLED,
showDot: true,
},
[PROMO_CODE_STATUS.ACTIVE]: {
labelKey: 'Active',
variant: 'success',
value: PROMO_CODE_STATUS.ACTIVE,
showDot: true,
},
} as const
export function getPromoCodeStatusOptions(t: TFunction) {
return Object.values(PROMO_CODE_STATUSES).map((config) => ({
label: t(config.labelKey),
value: String(config.value),
}))
}
export const PROMO_CODE_VALIDATION = {
CODE_MIN_LENGTH: 1,
CODE_MAX_LENGTH: 32,
BONUS_MIN: 1,
} as const
export const ERROR_MESSAGES = {
UNEXPECTED: 'An unexpected error occurred',
LOAD_FAILED: 'Failed to load promo codes',
CREATE_FAILED: 'Failed to create promo code',
UPDATE_FAILED: 'Failed to update promo code',
DELETE_FAILED: 'Failed to delete promo code',
STATUS_UPDATE_FAILED: 'Failed to update promo code status',
CODE_REQUIRED: 'Code is required',
CODE_MAX_LENGTH: 'Code must be no more than {{max}} characters',
BONUS_REQUIRED: 'Bonus amount must be greater than 0',
} as const
export function getPromoCodeFormErrorMessages(t: TFunction) {
return {
CODE_REQUIRED: t(ERROR_MESSAGES.CODE_REQUIRED),
CODE_MAX_LENGTH: t(ERROR_MESSAGES.CODE_MAX_LENGTH, {
max: PROMO_CODE_VALIDATION.CODE_MAX_LENGTH,
}),
BONUS_REQUIRED: t(ERROR_MESSAGES.BONUS_REQUIRED),
} as const
}
export const SUCCESS_MESSAGES = {
PROMO_CODE_CREATED: 'Promo code created successfully',
PROMO_CODE_UPDATED: 'Promo code updated successfully',
PROMO_CODE_DELETED: 'Promo code deleted successfully',
PROMO_CODE_ENABLED: 'Promo code enabled successfully',
PROMO_CODE_DISABLED: 'Promo code disabled successfully',
} as const

View File

@ -0,0 +1,48 @@
/*
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 { PromoCodesProvider } from './components/promo-codes-provider'
import { PromoCodesTable } from './components/promo-codes-table'
import { PromoCodesPrimaryButtons } from './components/promo-codes-primary-buttons'
import { PromoCodesDialogs } from './components/promo-codes-dialogs'
export function PromoCodes() {
const { t } = useTranslation()
return (
<PromoCodesProvider>
<SectionPageLayout>
<SectionPageLayout.Title>
{t('Promo Codes')}
</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t('Manage promo codes with fixed bonus amounts for top-up rewards')}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
<PromoCodesPrimaryButtons />
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<PromoCodesTable />
</SectionPageLayout.Content>
</SectionPageLayout>
<PromoCodesDialogs />
</PromoCodesProvider>
)
}

View File

@ -0,0 +1,121 @@
/*
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'
import { PROMO_CODE_VALIDATION } from '../constants'
import type { PromoCode, PromoCodeFormData, PromoCodeUpdateData } from '../types'
function optionalNumberWithDefault(defaultValue: number) {
return z
.number()
.int()
.optional()
.transform((value) => value ?? defaultValue)
}
function toDatetimeLocalValue(isoString: string): string {
const d = new Date(isoString)
if (isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function fromDatetimeLocalValue(localString: string): string {
if (!localString) return ''
return new Date(localString).toISOString()
}
export function getPromoCodeFormSchema(t: (key: string, opts?: Record<string, unknown>) => string) {
return z.object({
code: z
.string()
.min(PROMO_CODE_VALIDATION.CODE_MIN_LENGTH, t('Code is required'))
.max(
PROMO_CODE_VALIDATION.CODE_MAX_LENGTH,
t('Code must be no more than {{max}} characters', {
max: PROMO_CODE_VALIDATION.CODE_MAX_LENGTH,
})
),
bonus_amount: optionalNumberWithDefault(0).pipe(
z
.number()
.int()
.min(PROMO_CODE_VALIDATION.BONUS_MIN, t('Bonus amount must be greater than 0'))
),
max_uses: optionalNumberWithDefault(0).pipe(z.number().int().min(0)),
min_recharge_amount: optionalNumberWithDefault(0).pipe(z.number().int().min(0)),
expires_at: z.string().optional(),
notes: z.string().optional(),
})
}
export type PromoCodeFormValues = z.input<
ReturnType<typeof getPromoCodeFormSchema>
>
export const PROMO_CODE_FORM_DEFAULTS: PromoCodeFormValues = {
code: '',
bonus_amount: 0,
max_uses: 0,
min_recharge_amount: 0,
expires_at: '',
notes: '',
}
export function transformPromoCodeToFormDefaults(
promo: PromoCode
): PromoCodeFormValues {
return {
code: promo.code,
bonus_amount: promo.bonus_amount,
max_uses: promo.max_uses,
min_recharge_amount: promo.min_recharge_amount,
expires_at: promo.expires_at
? toDatetimeLocalValue(promo.expires_at)
: '',
notes: promo.notes || '',
}
}
export function transformFormDataToCreatePayload(
values: PromoCodeFormValues
): PromoCodeFormData {
return {
code: values.code.toUpperCase(),
bonus_amount: values.bonus_amount ?? 0,
max_uses: values.max_uses ?? 0,
min_recharge_amount: values.min_recharge_amount ?? 0,
expires_at: values.expires_at
? fromDatetimeLocalValue(values.expires_at)
: '',
notes: values.notes || '',
}
}
export function transformFormDataToUpdatePayload(
values: PromoCodeFormValues
): PromoCodeUpdateData {
return {
max_uses: values.max_uses ?? 0,
min_recharge_amount: values.min_recharge_amount ?? 0,
expires_at: values.expires_at
? fromDatetimeLocalValue(values.expires_at)
: '',
notes: values.notes || '',
}
}

View File

@ -0,0 +1,81 @@
/*
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'
export const promoCodeSchema = z.object({
id: z.number(),
code: z.string(),
bonus_amount: z.number(),
max_uses: z.number(),
min_recharge_amount: z.number(),
expires_at: z.string().nullable(),
notes: z.string(),
status: z.number(),
used_count: z.number(),
created_at: z.string(),
updated_at: z.string(),
})
export type PromoCode = z.infer<typeof promoCodeSchema>
export interface ApiResponse<T = unknown> {
success: boolean
message?: string
data?: T
}
export interface PaginatedData<T> {
items: T[]
total: number
page: number
page_size: number
}
export interface GetPromoCodesParams {
p?: number
page_size?: number
status?: number
keyword?: string
}
export interface PromoCodeFormData {
code: string
bonus_amount: number
max_uses: number
min_recharge_amount: number
expires_at: string
notes: string
}
export interface PromoCodeUpdateData {
status?: number
max_uses?: number
min_recharge_amount?: number
expires_at?: string
notes?: string
}
export interface PromoCodeUsage {
id: number
promo_code_id: number
user_id: number
used_at: string
}
export type PromoCodesDialogType = 'create' | 'update' | 'delete'

View File

@ -108,6 +108,27 @@ const defaultBillingSettings: BillingSettings = {
WaffoPancakeCurrency: 'USD', WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1, WaffoPancakeUnitPrice: 1,
WaffoPancakeMinTopUp: 1, WaffoPancakeMinTopUp: 1,
AlipayEnabled: false,
AlipayAppID: '',
AlipayPrivateKey: '',
AlipayPublicKey: '',
AlipayGatewayUrl: 'https://openapi.alipay.com/gateway.do',
AlipayNotifyURL: '',
AlipayReturnURL: '',
WxpayEnabled: false,
WxpayAppID: '',
WxpayMchID: '',
WxpayPrivateKey: '',
WxpayMerchantSerialNo: '',
WxpayAPIv3Key: '',
WxpayPlatformPublicKey: '',
WxpayPlatformCertificate: '',
WxpayPlatformSerialNo: '',
WxpayGatewayURL: 'https://api.mch.weixin.qq.com',
WxpayNotifyURL: '',
WxpayReturnURL: '',
WxpayH5AppName: '',
WxpayH5AppURL: '',
'checkin_setting.enabled': false, 'checkin_setting.enabled': false,
'checkin_setting.min_quota': 1000, 'checkin_setting.min_quota': 1000,
'checkin_setting.max_quota': 10000, 'checkin_setting.max_quota': 10000,

View File

@ -20,7 +20,9 @@ import { parseCurrencyDisplayType } from '@/lib/currency'
import { CheckinSettingsSection } from '../general/checkin-settings-section' import { CheckinSettingsSection } from '../general/checkin-settings-section'
import { PricingSection } from '../general/pricing-section' import { PricingSection } from '../general/pricing-section'
import { QuotaSettingsSection } from '../general/quota-settings-section' import { QuotaSettingsSection } from '../general/quota-settings-section'
import { AlipaySettingsSection } from '../integrations/alipay-settings-section'
import { PaymentSettingsSection } from '../integrations/payment-settings-section' import { PaymentSettingsSection } from '../integrations/payment-settings-section'
import { WxpaySettingsSection } from '../integrations/wxpay-settings-section'
import { RatioSettingsCard } from '../models/ratio-settings-card' import { RatioSettingsCard } from '../models/ratio-settings-card'
import type { BillingSettings } from '../types' import type { BillingSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry' import { createSectionRegistry } from '../utils/section-registry'
@ -201,6 +203,49 @@ const BILLING_SECTIONS = [
/> />
), ),
}, },
{
id: 'alipay',
titleKey: 'Alipay',
descriptionKey: 'Configure Alipay for domestic payments',
build: (settings: BillingSettings) => (
<AlipaySettingsSection
defaultValues={{
AlipayEnabled: settings.AlipayEnabled ?? false,
AlipayAppID: settings.AlipayAppID ?? '',
AlipayPrivateKey: settings.AlipayPrivateKey ?? '',
AlipayPublicKey: settings.AlipayPublicKey ?? '',
AlipayGatewayUrl: settings.AlipayGatewayUrl ?? '',
AlipayNotifyURL: settings.AlipayNotifyURL ?? '',
AlipayReturnURL: settings.AlipayReturnURL ?? '',
}}
/>
),
},
{
id: 'wxpay',
titleKey: 'WeChat Pay',
descriptionKey: 'Configure WeChat Pay for domestic payments',
build: (settings: BillingSettings) => (
<WxpaySettingsSection
defaultValues={{
WxpayEnabled: settings.WxpayEnabled ?? false,
WxpayAppID: settings.WxpayAppID ?? '',
WxpayMchID: settings.WxpayMchID ?? '',
WxpayPrivateKey: settings.WxpayPrivateKey ?? '',
WxpayMerchantSerialNo: settings.WxpayMerchantSerialNo ?? '',
WxpayAPIv3Key: settings.WxpayAPIv3Key ?? '',
WxpayPlatformPublicKey: settings.WxpayPlatformPublicKey ?? '',
WxpayPlatformCertificate: settings.WxpayPlatformCertificate ?? '',
WxpayPlatformSerialNo: settings.WxpayPlatformSerialNo ?? '',
WxpayGatewayURL: settings.WxpayGatewayURL ?? '',
WxpayNotifyURL: settings.WxpayNotifyURL ?? '',
WxpayReturnURL: settings.WxpayReturnURL ?? '',
WxpayH5AppName: settings.WxpayH5AppName ?? '',
WxpayH5AppURL: settings.WxpayH5AppURL ?? '',
}}
/>
),
},
{ {
id: 'checkin', id: 'checkin',
titleKey: 'Check-in Rewards', titleKey: 'Check-in Rewards',

View File

@ -0,0 +1,248 @@
/*
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 * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
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 { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
const schema = z.object({
enabled: z.boolean(),
app_id: z.string(),
private_key: z.string(),
public_key: z.string(),
gateway_url: z.string(),
notify_url: z.string(),
return_url: z.string(),
})
type FormValues = z.output<typeof schema>
type FormInput = z.input<typeof schema>
type Props = {
defaultValues: {
AlipayEnabled: boolean
AlipayAppID: string
AlipayPrivateKey: string
AlipayPublicKey: string
AlipayGatewayUrl: string
AlipayNotifyURL: string
AlipayReturnURL: string
}
}
function normalizeDefaults(defaults: Props['defaultValues']) {
return {
enabled: defaults.AlipayEnabled ?? false,
app_id: defaults.AlipayAppID ?? '',
private_key: defaults.AlipayPrivateKey ?? '',
public_key: defaults.AlipayPublicKey ?? '',
gateway_url: defaults.AlipayGatewayUrl ?? 'https://openapi.alipay.com/gateway.do',
notify_url: defaults.AlipayNotifyURL ?? '',
return_url: defaults.AlipayReturnURL ?? '',
}
}
export function AlipaySettingsSection({ defaultValues }: Props) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [baseline, setBaseline] = useState(() => normalizeDefaults(defaultValues))
const form = useForm<FormInput, unknown, FormValues>({
resolver: zodResolver(schema),
defaultValues: normalizeDefaults(defaultValues),
})
const onSubmit = async (values: FormValues) => {
const optionMap: Record<keyof FormValues, string> = {
enabled: 'AlipayEnabled',
app_id: 'AlipayAppID',
private_key: 'AlipayPrivateKey',
public_key: 'AlipayPublicKey',
gateway_url: 'AlipayGatewayUrl',
notify_url: 'AlipayNotifyURL',
return_url: 'AlipayReturnURL',
}
const normalized = values
for (const [field, optionKey] of Object.entries(optionMap)) {
const key = field as keyof FormValues
const val = normalized[key]
const base = baseline[key]
const strVal = typeof val === 'boolean' ? String(val) : (val || '')
const strBase = typeof base === 'boolean' ? String(base) : (base || '')
if (strVal !== strBase) {
const result = await updateOption.mutateAsync({ key: optionKey, value: strVal })
if (!result.success) return
}
}
setBaseline(normalized)
}
return (
<SettingsSection
title={t('Alipay Settings')}
description={t('Configure Alipay payment integration for domestic payments')}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>{t('Enable Alipay')}</FormLabel>
<FormDescription>
{t('Enable Alipay as a payment method')}
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='app_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('App ID')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Alipay application ID')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='private_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Private Key')}</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t('Alipay application private key (PEM)')}
rows={4}
className='font-mono text-xs'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='public_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Alipay Public Key')}</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t('Alipay public key (PEM)')}
rows={4}
className='font-mono text-xs'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='gateway_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Gateway URL')}</FormLabel>
<FormControl>
<Input {...field} placeholder='https://openapi.alipay.com/gateway.do' />
</FormControl>
<FormDescription>
{t('Leave default unless using sandbox or custom gateway')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='notify_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Notify URL')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Leave empty for auto-detection')} />
</FormControl>
<FormDescription>
{t('Webhook callback URL. Leave empty to use default.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='return_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Return URL')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Leave empty for default')} />
</FormControl>
<FormDescription>
{t('Page URL after payment completion')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save Alipay Settings')}
</Button>
</form>
</Form>
</SettingsSection>
)
}

View File

@ -0,0 +1,366 @@
/*
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 * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
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 { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
const schema = z.object({
enabled: z.boolean(),
app_id: z.string(),
mch_id: z.string(),
private_key: z.string(),
merchant_serial_no: z.string(),
api_v3_key: z.string(),
platform_public_key: z.string(),
platform_certificate: z.string(),
platform_serial_no: z.string(),
gateway_url: z.string(),
notify_url: z.string(),
return_url: z.string(),
h5_app_name: z.string(),
h5_app_url: z.string(),
})
type FormValues = z.output<typeof schema>
type FormInput = z.input<typeof schema>
type Props = {
defaultValues: {
WxpayEnabled: boolean
WxpayAppID: string
WxpayMchID: string
WxpayPrivateKey: string
WxpayMerchantSerialNo: string
WxpayAPIv3Key: string
WxpayPlatformPublicKey: string
WxpayPlatformCertificate: string
WxpayPlatformSerialNo: string
WxpayGatewayURL: string
WxpayNotifyURL: string
WxpayReturnURL: string
WxpayH5AppName: string
WxpayH5AppURL: string
}
}
function normalizeDefaults(defaults: Props['defaultValues']) {
return {
enabled: defaults.WxpayEnabled ?? false,
app_id: defaults.WxpayAppID ?? '',
mch_id: defaults.WxpayMchID ?? '',
private_key: defaults.WxpayPrivateKey ?? '',
merchant_serial_no: defaults.WxpayMerchantSerialNo ?? '',
api_v3_key: defaults.WxpayAPIv3Key ?? '',
platform_public_key: defaults.WxpayPlatformPublicKey ?? '',
platform_certificate: defaults.WxpayPlatformCertificate ?? '',
platform_serial_no: defaults.WxpayPlatformSerialNo ?? '',
gateway_url: defaults.WxpayGatewayURL ?? 'https://api.mch.weixin.qq.com',
notify_url: defaults.WxpayNotifyURL ?? '',
return_url: defaults.WxpayReturnURL ?? '',
h5_app_name: defaults.WxpayH5AppName ?? '',
h5_app_url: defaults.WxpayH5AppURL ?? '',
}
}
export function WxpaySettingsSection({ defaultValues }: Props) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [baseline, setBaseline] = useState(() => normalizeDefaults(defaultValues))
const form = useForm<FormInput, unknown, FormValues>({
resolver: zodResolver(schema),
defaultValues: normalizeDefaults(defaultValues),
})
const onSubmit = async (values: FormValues) => {
const optionMap: Record<keyof FormValues, string> = {
enabled: 'WxpayEnabled',
app_id: 'WxpayAppID',
mch_id: 'WxpayMchID',
private_key: 'WxpayPrivateKey',
merchant_serial_no: 'WxpayMerchantSerialNo',
api_v3_key: 'WxpayAPIv3Key',
platform_public_key: 'WxpayPlatformPublicKey',
platform_certificate: 'WxpayPlatformCertificate',
platform_serial_no: 'WxpayPlatformSerialNo',
gateway_url: 'WxpayGatewayURL',
notify_url: 'WxpayNotifyURL',
return_url: 'WxpayReturnURL',
h5_app_name: 'WxpayH5AppName',
h5_app_url: 'WxpayH5AppURL',
}
const normalized = values
for (const [field, optionKey] of Object.entries(optionMap)) {
const key = field as keyof FormValues
const val = normalized[key]
const base = baseline[key]
const strVal = typeof val === 'boolean' ? String(val) : (val || '')
const strBase = typeof base === 'boolean' ? String(base) : (base || '')
if (strVal !== strBase) {
const result = await updateOption.mutateAsync({ key: optionKey, value: strVal })
if (!result.success) return
}
}
setBaseline(normalized)
}
return (
<SettingsSection
title={t('WeChat Pay Settings')}
description={t('Configure WeChat Pay (JSAPI/Native/H5) integration for domestic payments')}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>{t('Enable WeChat Pay')}</FormLabel>
<FormDescription>
{t('Enable WeChat Pay as a payment method')}
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='app_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('App ID')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('WeChat Pay application ID')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='mch_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Merchant ID')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('WeChat Pay merchant ID')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='private_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Private Key')}</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t('Merchant private key (PEM)')}
rows={4}
className='font-mono text-xs'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='merchant_serial_no'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Merchant Serial Number')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Certificate serial number')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api_v3_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API v3 Key')}</FormLabel>
<FormControl>
<Input {...field} type='password' placeholder={t('WeChat Pay API v3 key')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='platform_certificate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Platform Certificate')}</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t('WeChat Pay platform certificate (PEM)')}
rows={3}
className='font-mono text-xs'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='platform_serial_no'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Platform Serial Number')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Platform certificate serial number')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='gateway_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Gateway URL')}</FormLabel>
<FormControl>
<Input {...field} placeholder='https://api.mch.weixin.qq.com' />
</FormControl>
<FormDescription>
{t('Leave default for production, change for sandbox')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='notify_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Notify URL')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Leave empty for auto-detection')} />
</FormControl>
<FormDescription>
{t('Payment result notification URL')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='return_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Return URL')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Leave empty for default')} />
</FormControl>
<FormDescription>
{t('Page URL after payment completion')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='h5_app_name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('H5 App Name')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('App name for H5 payments')} />
</FormControl>
<FormDescription>
{t('Required for H5 payment scenarios')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='h5_app_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('H5 App URL')}</FormLabel>
<FormControl>
<Input {...field} placeholder='https://example.com' />
</FormControl>
<FormDescription>
{t('Website URL for H5 payment referrer')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit' disabled={updateOption.isPending}>
{updateOption.isPending ? t('Saving...') : t('Save WeChat Pay Settings')}
</Button>
</form>
</Form>
</SettingsSection>
)
}

View File

@ -268,6 +268,27 @@ export type BillingSettings = {
WaffoPancakeCurrency: string WaffoPancakeCurrency: string
WaffoPancakeUnitPrice: number WaffoPancakeUnitPrice: number
WaffoPancakeMinTopUp: number WaffoPancakeMinTopUp: number
AlipayEnabled: boolean
AlipayAppID: string
AlipayPrivateKey: string
AlipayPublicKey: string
AlipayGatewayUrl: string
AlipayNotifyURL: string
AlipayReturnURL: string
WxpayEnabled: boolean
WxpayAppID: string
WxpayMchID: string
WxpayPrivateKey: string
WxpayMerchantSerialNo: string
WxpayAPIv3Key: string
WxpayPlatformPublicKey: string
WxpayPlatformCertificate: string
WxpayPlatformSerialNo: string
WxpayGatewayURL: string
WxpayNotifyURL: string
WxpayReturnURL: string
WxpayH5AppName: string
WxpayH5AppURL: string
'checkin_setting.enabled': boolean 'checkin_setting.enabled': boolean
'checkin_setting.min_quota': number 'checkin_setting.min_quota': number
'checkin_setting.max_quota': number 'checkin_setting.max_quota': number

View File

@ -74,6 +74,10 @@ type UsersMutateDrawerProps = {
currentRow?: User currentRow?: User
} }
function parseOptionalNumberInput(value: string) {
return value === '' ? undefined : Number(value)
}
export function UsersMutateDrawer({ export function UsersMutateDrawer({
open, open,
onOpenChange, onOpenChange,
@ -397,6 +401,84 @@ export function UsersMutateDrawer({
</div> </div>
)} )}
{/* Concurrency Control (Update only) */}
{isUpdate && (
<div className='space-y-4'>
<h3 className='text-sm font-medium'>
{t('Concurrency Control')}
</h3>
<p className='text-muted-foreground text-xs'>
{t(
'Set limits per user. 0 means unlimited (use global defaults).'
)}
</p>
<FormField
control={form.control}
name='concurrency_limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Concurrency Limit')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
placeholder='0 = unlimited'
value={field.value ?? ''}
onBlur={(event) => {
field.onBlur()
if (event.target.value === '') field.onChange(0)
}}
onChange={(e) =>
field.onChange(parseOptionalNumberInput(e.target.value))
}
/>
</FormControl>
<FormDescription>
{t(
'Maximum concurrent API requests for this user. 0 = unlimited.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='rpm_limit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('RPM Limit')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
placeholder='0 = unlimited'
value={field.value ?? ''}
onBlur={(event) => {
field.onBlur()
if (event.target.value === '') field.onChange(0)
}}
onChange={(e) =>
field.onChange(parseOptionalNumberInput(e.target.value))
}
/>
</FormControl>
<FormDescription>
{t(
'Maximum requests per minute for this user. 0 = unlimited.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* Binding Information (Read-only) */} {/* Binding Information (Read-only) */}
{isUpdate && ( {isUpdate && (
<div className='space-y-4'> <div className='space-y-4'>

View File

@ -33,6 +33,8 @@ export const userFormSchema = z.object({
quota_dollars: z.number().min(0).optional(), quota_dollars: z.number().min(0).optional(),
group: z.string().optional(), group: z.string().optional(),
remark: z.string().optional(), remark: z.string().optional(),
concurrency_limit: z.number().int().min(0).optional(),
rpm_limit: z.number().int().min(0).optional(),
}) })
export type UserFormValues = z.infer<typeof userFormSchema> export type UserFormValues = z.infer<typeof userFormSchema>
@ -49,6 +51,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = {
quota_dollars: 0, quota_dollars: 0,
group: DEFAULT_GROUP, group: DEFAULT_GROUP,
remark: '', remark: '',
concurrency_limit: 0,
rpm_limit: 0,
} }
// ============================================================================ // ============================================================================
@ -76,6 +80,8 @@ export function transformFormDataToPayload(
payload.group = data.group payload.group = data.group
payload.remark = data.remark || undefined payload.remark = data.remark || undefined
payload.id = userId payload.id = userId
payload.concurrency_limit = data.concurrency_limit ?? 0
payload.rpm_limit = data.rpm_limit ?? 0
} }
return payload return payload
@ -93,5 +99,7 @@ export function transformUserToFormDefaults(user: User): UserFormValues {
quota_dollars: quotaUnitsToDollars(user.quota), quota_dollars: quotaUnitsToDollars(user.quota),
group: user.group || DEFAULT_GROUP, group: user.group || DEFAULT_GROUP,
remark: user.remark || '', remark: user.remark || '',
concurrency_limit: user.concurrency_limit || 0,
rpm_limit: user.rpm_limit || 0,
} }
} }

View File

@ -57,6 +57,8 @@ export const userSchema = z.object({
last_login_at: z.number().optional(), last_login_at: z.number().optional(),
DeletedAt: z.any().nullable().optional(), DeletedAt: z.any().nullable().optional(),
remark: z.string().optional(), remark: z.string().optional(),
concurrency_limit: z.number().optional(),
rpm_limit: z.number().optional(),
}) })
export type User = z.infer<typeof userSchema> export type User = z.infer<typeof userSchema>
@ -104,6 +106,8 @@ export interface UserFormData {
quota?: number // Only used when updating user quota?: number // Only used when updating user
group?: string // Only used when updating user group?: string // Only used when updating user
remark?: string // Only used when updating user remark?: string // Only used when updating user
concurrency_limit?: number
rpm_limit?: number
} }
export type ManageUserAction = export type ManageUserAction =

View File

@ -25,6 +25,7 @@ import {
Box, Box,
Users, Users,
Ticket, Ticket,
Tags,
User, User,
Command, Command,
Radio, Radio,
@ -140,6 +141,11 @@ export function useSidebarData(): SidebarData {
url: '/redemption-codes', url: '/redemption-codes',
icon: Ticket, icon: Ticket,
}, },
{
title: t('Promo Codes'),
url: '/promo-codes',
icon: Tags,
},
{ {
title: t('Subscription Management'), title: t('Subscription Management'),
url: '/subscriptions', url: '/subscriptions',

View File

@ -41,6 +41,7 @@ import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authe
import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index' import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index'
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index' import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/_authenticated/redemption-codes/index' import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/_authenticated/redemption-codes/index'
import { Route as AuthenticatedPromoCodesIndexRouteImport } from './routes/_authenticated/promo-codes/index'
import { Route as AuthenticatedProfileIndexRouteImport } from './routes/_authenticated/profile/index' import { Route as AuthenticatedProfileIndexRouteImport } from './routes/_authenticated/profile/index'
import { Route as AuthenticatedPlaygroundIndexRouteImport } from './routes/_authenticated/playground/index' import { Route as AuthenticatedPlaygroundIndexRouteImport } from './routes/_authenticated/playground/index'
import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenticated/models/index' import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenticated/models/index'
@ -232,6 +233,12 @@ const AuthenticatedRedemptionCodesIndexRoute =
path: '/redemption-codes/', path: '/redemption-codes/',
getParentRoute: () => AuthenticatedRouteRoute, getParentRoute: () => AuthenticatedRouteRoute,
} as any) } as any)
const AuthenticatedPromoCodesIndexRoute =
AuthenticatedPromoCodesIndexRouteImport.update({
id: '/promo-codes/',
path: '/promo-codes/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedProfileIndexRoute = const AuthenticatedProfileIndexRoute =
AuthenticatedProfileIndexRouteImport.update({ AuthenticatedProfileIndexRouteImport.update({
id: '/profile/', id: '/profile/',
@ -422,6 +429,7 @@ export interface FileRoutesByFullPath {
'/models/': typeof AuthenticatedModelsIndexRoute '/models/': typeof AuthenticatedModelsIndexRoute
'/playground/': typeof AuthenticatedPlaygroundIndexRoute '/playground/': typeof AuthenticatedPlaygroundIndexRoute
'/profile/': typeof AuthenticatedProfileIndexRoute '/profile/': typeof AuthenticatedProfileIndexRoute
'/promo-codes/': typeof AuthenticatedPromoCodesIndexRoute
'/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute '/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute '/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute '/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
@ -479,6 +487,7 @@ export interface FileRoutesByTo {
'/models': typeof AuthenticatedModelsIndexRoute '/models': typeof AuthenticatedModelsIndexRoute
'/playground': typeof AuthenticatedPlaygroundIndexRoute '/playground': typeof AuthenticatedPlaygroundIndexRoute
'/profile': typeof AuthenticatedProfileIndexRoute '/profile': typeof AuthenticatedProfileIndexRoute
'/promo-codes': typeof AuthenticatedPromoCodesIndexRoute
'/redemption-codes': typeof AuthenticatedRedemptionCodesIndexRoute '/redemption-codes': typeof AuthenticatedRedemptionCodesIndexRoute
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute '/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute '/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
@ -540,6 +549,7 @@ export interface FileRoutesById {
'/_authenticated/models/': typeof AuthenticatedModelsIndexRoute '/_authenticated/models/': typeof AuthenticatedModelsIndexRoute
'/_authenticated/playground/': typeof AuthenticatedPlaygroundIndexRoute '/_authenticated/playground/': typeof AuthenticatedPlaygroundIndexRoute
'/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute '/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute
'/_authenticated/promo-codes/': typeof AuthenticatedPromoCodesIndexRoute
'/_authenticated/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute '/_authenticated/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute '/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute '/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
@ -600,6 +610,7 @@ export interface FileRouteTypes {
| '/models/' | '/models/'
| '/playground/' | '/playground/'
| '/profile/' | '/profile/'
| '/promo-codes/'
| '/redemption-codes/' | '/redemption-codes/'
| '/subscriptions/' | '/subscriptions/'
| '/system-settings/' | '/system-settings/'
@ -657,6 +668,7 @@ export interface FileRouteTypes {
| '/models' | '/models'
| '/playground' | '/playground'
| '/profile' | '/profile'
| '/promo-codes'
| '/redemption-codes' | '/redemption-codes'
| '/subscriptions' | '/subscriptions'
| '/system-settings' | '/system-settings'
@ -717,6 +729,7 @@ export interface FileRouteTypes {
| '/_authenticated/models/' | '/_authenticated/models/'
| '/_authenticated/playground/' | '/_authenticated/playground/'
| '/_authenticated/profile/' | '/_authenticated/profile/'
| '/_authenticated/promo-codes/'
| '/_authenticated/redemption-codes/' | '/_authenticated/redemption-codes/'
| '/_authenticated/subscriptions/' | '/_authenticated/subscriptions/'
| '/_authenticated/system-settings/' | '/_authenticated/system-settings/'
@ -987,6 +1000,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedRedemptionCodesIndexRouteImport preLoaderRoute: typeof AuthenticatedRedemptionCodesIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute parentRoute: typeof AuthenticatedRouteRoute
} }
'/_authenticated/promo-codes/': {
id: '/_authenticated/promo-codes/'
path: '/promo-codes'
fullPath: '/promo-codes/'
preLoaderRoute: typeof AuthenticatedPromoCodesIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/profile/': { '/_authenticated/profile/': {
id: '/_authenticated/profile/' id: '/_authenticated/profile/'
path: '/profile' path: '/profile'
@ -1267,6 +1287,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedModelsIndexRoute: typeof AuthenticatedModelsIndexRoute AuthenticatedModelsIndexRoute: typeof AuthenticatedModelsIndexRoute
AuthenticatedPlaygroundIndexRoute: typeof AuthenticatedPlaygroundIndexRoute AuthenticatedPlaygroundIndexRoute: typeof AuthenticatedPlaygroundIndexRoute
AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute
AuthenticatedPromoCodesIndexRoute: typeof AuthenticatedPromoCodesIndexRoute
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
@ -1289,6 +1310,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedModelsIndexRoute: AuthenticatedModelsIndexRoute, AuthenticatedModelsIndexRoute: AuthenticatedModelsIndexRoute,
AuthenticatedPlaygroundIndexRoute: AuthenticatedPlaygroundIndexRoute, AuthenticatedPlaygroundIndexRoute: AuthenticatedPlaygroundIndexRoute,
AuthenticatedProfileIndexRoute: AuthenticatedProfileIndexRoute, AuthenticatedProfileIndexRoute: AuthenticatedProfileIndexRoute,
AuthenticatedPromoCodesIndexRoute: AuthenticatedPromoCodesIndexRoute,
AuthenticatedRedemptionCodesIndexRoute: AuthenticatedRedemptionCodesIndexRoute:
AuthenticatedRedemptionCodesIndexRoute, AuthenticatedRedemptionCodesIndexRoute,
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute, AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,

View File

@ -0,0 +1,41 @@
/*
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'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth-store'
import { ROLE } from '@/lib/roles'
import { PromoCodes } from '@/features/promo-codes'
const promoCodesSearchSchema = z.object({
page: z.number().optional().catch(1),
pageSize: z.number().optional().catch(10),
filter: z.string().optional().catch(''),
status: z.array(z.string()).optional().catch([]),
})
export const Route = createFileRoute('/_authenticated/promo-codes/')({
beforeLoad: () => {
const { auth } = useAuthStore.getState()
if (!auth.user || auth.user.role < ROLE.ADMIN) {
throw redirect({ to: '/403' })
}
},
validateSearch: promoCodesSearchSchema,
component: PromoCodes,
})