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:
parent
36b742ad3d
commit
0ff2fff94d
@ -19,6 +19,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@ -876,8 +877,16 @@ type PatchChannel struct {
|
||||
}
|
||||
|
||||
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{}
|
||||
err := c.ShouldBindJSON(&channel)
|
||||
err = c.ShouldBindBodyWith(&channel, binding.JSON)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@ -990,7 +999,11 @@ func UpdateChannel(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
|
||||
@ -13,6 +13,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const promoCodeMaxLength = 32
|
||||
|
||||
type createPromoCodeRequest struct {
|
||||
Code string `json:"code"`
|
||||
BonusAmount int `json:"bonus_amount"`
|
||||
@ -66,10 +68,14 @@ func CreatePromoCode(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "优惠码不能为空")
|
||||
return
|
||||
}
|
||||
if len(code) > 32 {
|
||||
if len(code) > promoCodeMaxLength {
|
||||
common.ApiErrorMsg(c, "优惠码长度不能超过 32 个字符")
|
||||
return
|
||||
}
|
||||
if !isPromoCodeSyntaxValid(code) {
|
||||
common.ApiErrorMsg(c, "优惠码只能包含大写字母、数字、下划线和连字符")
|
||||
return
|
||||
}
|
||||
if req.BonusAmount <= 0 {
|
||||
common.ApiErrorMsg(c, "赠送额度必须大于 0")
|
||||
return
|
||||
@ -228,6 +234,19 @@ func normalizePromoCode(code string) string {
|
||||
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) {
|
||||
expiresAt, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
|
||||
@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@ -12,6 +11,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const invalidPromoCodeReason = "invalid"
|
||||
|
||||
type validatePromoCodeRequest struct {
|
||||
Code string `json:"code"`
|
||||
Amount int `json:"amount"`
|
||||
@ -32,16 +33,16 @@ func ValidatePromoCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
code := normalizeUserPromoCode(req.Code)
|
||||
if code == "" {
|
||||
common.ApiErrorMsg(c, "优惠码不能为空")
|
||||
code := normalizePromoCode(req.Code)
|
||||
if !isPromoCodeSyntaxValid(code) {
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||
return
|
||||
}
|
||||
|
||||
promoCode, err := model.GetPromoCodeByCode(code)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "not_found"))
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||
return
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
@ -49,25 +50,25 @@ func ValidatePromoCode(c *gin.Context) {
|
||||
}
|
||||
|
||||
if promoCode.Status != 1 {
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "disabled"))
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||
return
|
||||
}
|
||||
if promoCode.ExpiresAt != nil && promoCode.ExpiresAt.Before(time.Now()) {
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "expired"))
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||
return
|
||||
}
|
||||
if promoCode.MaxUses > 0 && promoCode.UsedCount >= promoCode.MaxUses {
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "max_uses_reached"))
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||
return
|
||||
}
|
||||
if req.Amount < promoCode.MinRechargeAmount {
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "below_min_recharge"))
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
if _, err := model.GetPromoCodeUsageByCodeAndUser(promoCode.Id, userId); err == nil {
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "already_used"))
|
||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||
return
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ApiError(c, err)
|
||||
@ -83,14 +84,10 @@ func ValidatePromoCode(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeUserPromoCode(code string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
func newInvalidPromoCodeResponse(code string, reason string) validatePromoCodeResponse {
|
||||
func newInvalidPromoCodeResponse(code string) validatePromoCodeResponse {
|
||||
return validatePromoCodeResponse{
|
||||
Valid: false,
|
||||
Reason: reason,
|
||||
Reason: invalidPromoCodeReason,
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
||||
97
controller/promo_code_user_test.go
Normal file
97
controller/promo_code_user_test.go
Normal 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)
|
||||
}
|
||||
@ -529,7 +529,7 @@ func (channel *Channel) Insert() error {
|
||||
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 channel.ChannelInfo.IsMultiKey {
|
||||
var keyStr string
|
||||
@ -573,6 +573,13 @@ func (channel *Channel) Update() error {
|
||||
if err != nil {
|
||||
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)
|
||||
err = channel.UpdateAbilities(nil)
|
||||
return err
|
||||
|
||||
66
model/channel_update_test.go
Normal file
66
model/channel_update_test.go
Normal 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)
|
||||
}
|
||||
@ -652,10 +652,12 @@ func (user *User) Edit(updatePassword bool) error {
|
||||
|
||||
newUser := *user
|
||||
updates := map[string]interface{}{
|
||||
"username": newUser.Username,
|
||||
"display_name": newUser.DisplayName,
|
||||
"group": newUser.Group,
|
||||
"remark": newUser.Remark,
|
||||
"username": newUser.Username,
|
||||
"display_name": newUser.DisplayName,
|
||||
"group": newUser.Group,
|
||||
"remark": newUser.Remark,
|
||||
"concurrency_limit": newUser.ConcurrencyLimit,
|
||||
"rpm_limit": newUser.RpmLimit,
|
||||
}
|
||||
if updatePassword {
|
||||
updates["password"] = newUser.Password
|
||||
|
||||
@ -100,7 +100,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
selfRoute.GET("/topup/info", controller.GetTopUpInfo)
|
||||
selfRoute.GET("/topup/self", controller.GetUserTopUps)
|
||||
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("/amount", controller.RequestAmount)
|
||||
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
|
||||
|
||||
@ -2510,6 +2510,34 @@ export function ChannelMutateDrawer({
|
||||
</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>
|
||||
|
||||
<FormField
|
||||
|
||||
@ -37,6 +37,7 @@ export const channelFormSchema = z.object({
|
||||
weight: z.number().optional(),
|
||||
test_model: z.string().optional(),
|
||||
auto_ban: z.number().optional(),
|
||||
concurrency_limit: z.number().int().min(0).optional(),
|
||||
status: z.number(),
|
||||
status_code_mapping: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
@ -99,6 +100,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
|
||||
weight: 0,
|
||||
test_model: '',
|
||||
auto_ban: 1,
|
||||
concurrency_limit: 0,
|
||||
status: CHANNEL_STATUS.ENABLED,
|
||||
status_code_mapping: '',
|
||||
tag: '',
|
||||
@ -232,6 +234,7 @@ export function transformChannelToFormDefaults(
|
||||
weight: channel.weight || 0,
|
||||
test_model: channel.test_model || '',
|
||||
auto_ban: channel.auto_ban ?? 1,
|
||||
concurrency_limit: channel.concurrency_limit || 0,
|
||||
status: channel.status,
|
||||
status_code_mapping: channel.status_code_mapping || '',
|
||||
tag: channel.tag || '',
|
||||
@ -413,6 +416,7 @@ export function transformFormDataToCreatePayload(formData: ChannelFormValues): {
|
||||
weight: formData.weight || null,
|
||||
test_model: formData.test_model || null,
|
||||
auto_ban: formData.auto_ban ?? 1,
|
||||
concurrency_limit: formData.concurrency_limit || 0,
|
||||
status: formData.status,
|
||||
status_code_mapping: formData.status_code_mapping || null,
|
||||
tag: formData.tag || null,
|
||||
@ -461,6 +465,7 @@ export function transformFormDataToUpdatePayload(
|
||||
weight: formData.weight ?? 0,
|
||||
test_model: formData.test_model || null,
|
||||
auto_ban: formData.auto_ban ?? 1,
|
||||
concurrency_limit: formData.concurrency_limit || 0,
|
||||
status: formData.status,
|
||||
status_code_mapping: formData.status_code_mapping || null,
|
||||
tag: formData.tag || null,
|
||||
|
||||
2
web/default/src/features/channels/types.ts
vendored
2
web/default/src/features/channels/types.ts
vendored
@ -64,6 +64,7 @@ export const channelSchema = z.object({
|
||||
header_override: z.string().nullish(),
|
||||
remark: z.string().default(''),
|
||||
max_input_tokens: z.number().default(0),
|
||||
concurrency_limit: z.number().optional(),
|
||||
channel_info: channelInfoSchema.default({
|
||||
is_multi_key: false,
|
||||
multi_key_size: 0,
|
||||
@ -318,6 +319,7 @@ export interface ChannelFormData {
|
||||
header_override?: string
|
||||
settings?: string
|
||||
other?: string
|
||||
concurrency_limit?: number
|
||||
// Multi-key specific
|
||||
multi_key_mode?: 'single' | 'batch' | 'multi_to_single'
|
||||
multi_key_type?: 'random' | 'polling'
|
||||
|
||||
80
web/default/src/features/promo-codes/api.ts
vendored
Normal file
80
web/default/src/features/promo-codes/api.ts
vendored
Normal 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
|
||||
}
|
||||
173
web/default/src/features/promo-codes/components/promo-codes-columns.tsx
vendored
Normal file
173
web/default/src/features/promo-codes/components/promo-codes-columns.tsx
vendored
Normal 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} />,
|
||||
},
|
||||
]
|
||||
}
|
||||
92
web/default/src/features/promo-codes/components/promo-codes-delete-dialog.tsx
vendored
Normal file
92
web/default/src/features/promo-codes/components/promo-codes-delete-dialog.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
29
web/default/src/features/promo-codes/components/promo-codes-dialogs.tsx
vendored
Normal file
29
web/default/src/features/promo-codes/components/promo-codes-dialogs.tsx
vendored
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
323
web/default/src/features/promo-codes/components/promo-codes-mutate-drawer.tsx
vendored
Normal file
323
web/default/src/features/promo-codes/components/promo-codes-mutate-drawer.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
34
web/default/src/features/promo-codes/components/promo-codes-primary-buttons.tsx
vendored
Normal file
34
web/default/src/features/promo-codes/components/promo-codes-primary-buttons.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
86
web/default/src/features/promo-codes/components/promo-codes-provider.tsx
vendored
Normal file
86
web/default/src/features/promo-codes/components/promo-codes-provider.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
69
web/default/src/features/promo-codes/components/promo-codes-row-actions.tsx
vendored
Normal file
69
web/default/src/features/promo-codes/components/promo-codes-row-actions.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
195
web/default/src/features/promo-codes/components/promo-codes-table.tsx
vendored
Normal file
195
web/default/src/features/promo-codes/components/promo-codes-table.tsx
vendored
Normal 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
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
93
web/default/src/features/promo-codes/constants.ts
vendored
Normal file
93
web/default/src/features/promo-codes/constants.ts
vendored
Normal 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
|
||||
48
web/default/src/features/promo-codes/index.tsx
vendored
Normal file
48
web/default/src/features/promo-codes/index.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
121
web/default/src/features/promo-codes/lib/index.ts
vendored
Normal file
121
web/default/src/features/promo-codes/lib/index.ts
vendored
Normal 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 || '',
|
||||
}
|
||||
}
|
||||
81
web/default/src/features/promo-codes/types.ts
vendored
Normal file
81
web/default/src/features/promo-codes/types.ts
vendored
Normal 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'
|
||||
@ -108,6 +108,27 @@ const defaultBillingSettings: BillingSettings = {
|
||||
WaffoPancakeCurrency: 'USD',
|
||||
WaffoPancakeUnitPrice: 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.min_quota': 1000,
|
||||
'checkin_setting.max_quota': 10000,
|
||||
|
||||
@ -20,7 +20,9 @@ import { parseCurrencyDisplayType } from '@/lib/currency'
|
||||
import { CheckinSettingsSection } from '../general/checkin-settings-section'
|
||||
import { PricingSection } from '../general/pricing-section'
|
||||
import { QuotaSettingsSection } from '../general/quota-settings-section'
|
||||
import { AlipaySettingsSection } from '../integrations/alipay-settings-section'
|
||||
import { PaymentSettingsSection } from '../integrations/payment-settings-section'
|
||||
import { WxpaySettingsSection } from '../integrations/wxpay-settings-section'
|
||||
import { RatioSettingsCard } from '../models/ratio-settings-card'
|
||||
import type { BillingSettings } from '../types'
|
||||
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',
|
||||
titleKey: 'Check-in Rewards',
|
||||
|
||||
248
web/default/src/features/system-settings/integrations/alipay-settings-section.tsx
vendored
Normal file
248
web/default/src/features/system-settings/integrations/alipay-settings-section.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
366
web/default/src/features/system-settings/integrations/wxpay-settings-section.tsx
vendored
Normal file
366
web/default/src/features/system-settings/integrations/wxpay-settings-section.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -268,6 +268,27 @@ export type BillingSettings = {
|
||||
WaffoPancakeCurrency: string
|
||||
WaffoPancakeUnitPrice: 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.min_quota': number
|
||||
'checkin_setting.max_quota': number
|
||||
|
||||
@ -74,6 +74,10 @@ type UsersMutateDrawerProps = {
|
||||
currentRow?: User
|
||||
}
|
||||
|
||||
function parseOptionalNumberInput(value: string) {
|
||||
return value === '' ? undefined : Number(value)
|
||||
}
|
||||
|
||||
export function UsersMutateDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
@ -397,6 +401,84 @@ export function UsersMutateDrawer({
|
||||
</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) */}
|
||||
{isUpdate && (
|
||||
<div className='space-y-4'>
|
||||
|
||||
@ -33,6 +33,8 @@ export const userFormSchema = z.object({
|
||||
quota_dollars: z.number().min(0).optional(),
|
||||
group: 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>
|
||||
@ -49,6 +51,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = {
|
||||
quota_dollars: 0,
|
||||
group: DEFAULT_GROUP,
|
||||
remark: '',
|
||||
concurrency_limit: 0,
|
||||
rpm_limit: 0,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@ -76,6 +80,8 @@ export function transformFormDataToPayload(
|
||||
payload.group = data.group
|
||||
payload.remark = data.remark || undefined
|
||||
payload.id = userId
|
||||
payload.concurrency_limit = data.concurrency_limit ?? 0
|
||||
payload.rpm_limit = data.rpm_limit ?? 0
|
||||
}
|
||||
|
||||
return payload
|
||||
@ -93,5 +99,7 @@ export function transformUserToFormDefaults(user: User): UserFormValues {
|
||||
quota_dollars: quotaUnitsToDollars(user.quota),
|
||||
group: user.group || DEFAULT_GROUP,
|
||||
remark: user.remark || '',
|
||||
concurrency_limit: user.concurrency_limit || 0,
|
||||
rpm_limit: user.rpm_limit || 0,
|
||||
}
|
||||
}
|
||||
|
||||
4
web/default/src/features/users/types.ts
vendored
4
web/default/src/features/users/types.ts
vendored
@ -57,6 +57,8 @@ export const userSchema = z.object({
|
||||
last_login_at: z.number().optional(),
|
||||
DeletedAt: z.any().nullable().optional(),
|
||||
remark: z.string().optional(),
|
||||
concurrency_limit: z.number().optional(),
|
||||
rpm_limit: z.number().optional(),
|
||||
})
|
||||
export type User = z.infer<typeof userSchema>
|
||||
|
||||
@ -104,6 +106,8 @@ export interface UserFormData {
|
||||
quota?: number // Only used when updating user
|
||||
group?: string // Only used when updating user
|
||||
remark?: string // Only used when updating user
|
||||
concurrency_limit?: number
|
||||
rpm_limit?: number
|
||||
}
|
||||
|
||||
export type ManageUserAction =
|
||||
|
||||
6
web/default/src/hooks/use-sidebar-data.ts
vendored
6
web/default/src/hooks/use-sidebar-data.ts
vendored
@ -25,6 +25,7 @@ import {
|
||||
Box,
|
||||
Users,
|
||||
Ticket,
|
||||
Tags,
|
||||
User,
|
||||
Command,
|
||||
Radio,
|
||||
@ -140,6 +141,11 @@ export function useSidebarData(): SidebarData {
|
||||
url: '/redemption-codes',
|
||||
icon: Ticket,
|
||||
},
|
||||
{
|
||||
title: t('Promo Codes'),
|
||||
url: '/promo-codes',
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
title: t('Subscription Management'),
|
||||
url: '/subscriptions',
|
||||
|
||||
22
web/default/src/routeTree.gen.ts
vendored
22
web/default/src/routeTree.gen.ts
vendored
@ -41,6 +41,7 @@ import { Route as AuthenticatedUsageLogsIndexRouteImport } from './routes/_authe
|
||||
import { Route as AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index'
|
||||
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/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 AuthenticatedPlaygroundIndexRouteImport } from './routes/_authenticated/playground/index'
|
||||
import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenticated/models/index'
|
||||
@ -232,6 +233,12 @@ const AuthenticatedRedemptionCodesIndexRoute =
|
||||
path: '/redemption-codes/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedPromoCodesIndexRoute =
|
||||
AuthenticatedPromoCodesIndexRouteImport.update({
|
||||
id: '/promo-codes/',
|
||||
path: '/promo-codes/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedProfileIndexRoute =
|
||||
AuthenticatedProfileIndexRouteImport.update({
|
||||
id: '/profile/',
|
||||
@ -422,6 +429,7 @@ export interface FileRoutesByFullPath {
|
||||
'/models/': typeof AuthenticatedModelsIndexRoute
|
||||
'/playground/': typeof AuthenticatedPlaygroundIndexRoute
|
||||
'/profile/': typeof AuthenticatedProfileIndexRoute
|
||||
'/promo-codes/': typeof AuthenticatedPromoCodesIndexRoute
|
||||
'/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
|
||||
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
||||
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
||||
@ -479,6 +487,7 @@ export interface FileRoutesByTo {
|
||||
'/models': typeof AuthenticatedModelsIndexRoute
|
||||
'/playground': typeof AuthenticatedPlaygroundIndexRoute
|
||||
'/profile': typeof AuthenticatedProfileIndexRoute
|
||||
'/promo-codes': typeof AuthenticatedPromoCodesIndexRoute
|
||||
'/redemption-codes': typeof AuthenticatedRedemptionCodesIndexRoute
|
||||
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
|
||||
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
|
||||
@ -540,6 +549,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated/models/': typeof AuthenticatedModelsIndexRoute
|
||||
'/_authenticated/playground/': typeof AuthenticatedPlaygroundIndexRoute
|
||||
'/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute
|
||||
'/_authenticated/promo-codes/': typeof AuthenticatedPromoCodesIndexRoute
|
||||
'/_authenticated/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
|
||||
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
||||
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
||||
@ -600,6 +610,7 @@ export interface FileRouteTypes {
|
||||
| '/models/'
|
||||
| '/playground/'
|
||||
| '/profile/'
|
||||
| '/promo-codes/'
|
||||
| '/redemption-codes/'
|
||||
| '/subscriptions/'
|
||||
| '/system-settings/'
|
||||
@ -657,6 +668,7 @@ export interface FileRouteTypes {
|
||||
| '/models'
|
||||
| '/playground'
|
||||
| '/profile'
|
||||
| '/promo-codes'
|
||||
| '/redemption-codes'
|
||||
| '/subscriptions'
|
||||
| '/system-settings'
|
||||
@ -717,6 +729,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/models/'
|
||||
| '/_authenticated/playground/'
|
||||
| '/_authenticated/profile/'
|
||||
| '/_authenticated/promo-codes/'
|
||||
| '/_authenticated/redemption-codes/'
|
||||
| '/_authenticated/subscriptions/'
|
||||
| '/_authenticated/system-settings/'
|
||||
@ -987,6 +1000,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedRedemptionCodesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/promo-codes/': {
|
||||
id: '/_authenticated/promo-codes/'
|
||||
path: '/promo-codes'
|
||||
fullPath: '/promo-codes/'
|
||||
preLoaderRoute: typeof AuthenticatedPromoCodesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRouteRoute
|
||||
}
|
||||
'/_authenticated/profile/': {
|
||||
id: '/_authenticated/profile/'
|
||||
path: '/profile'
|
||||
@ -1267,6 +1287,7 @@ interface AuthenticatedRouteRouteChildren {
|
||||
AuthenticatedModelsIndexRoute: typeof AuthenticatedModelsIndexRoute
|
||||
AuthenticatedPlaygroundIndexRoute: typeof AuthenticatedPlaygroundIndexRoute
|
||||
AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute
|
||||
AuthenticatedPromoCodesIndexRoute: typeof AuthenticatedPromoCodesIndexRoute
|
||||
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
|
||||
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
|
||||
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
|
||||
@ -1289,6 +1310,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||
AuthenticatedModelsIndexRoute: AuthenticatedModelsIndexRoute,
|
||||
AuthenticatedPlaygroundIndexRoute: AuthenticatedPlaygroundIndexRoute,
|
||||
AuthenticatedProfileIndexRoute: AuthenticatedProfileIndexRoute,
|
||||
AuthenticatedPromoCodesIndexRoute: AuthenticatedPromoCodesIndexRoute,
|
||||
AuthenticatedRedemptionCodesIndexRoute:
|
||||
AuthenticatedRedemptionCodesIndexRoute,
|
||||
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
|
||||
|
||||
41
web/default/src/routes/_authenticated/promo-codes/index.tsx
vendored
Normal file
41
web/default/src/routes/_authenticated/promo-codes/index.tsx
vendored
Normal 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,
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user