From 0ff2fff94d0a7db4419598e96b84421c80a297f1 Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 20 May 2026 21:45:28 +0800 Subject: [PATCH] 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. --- controller/channel.go | 17 +- controller/promo_code.go | 21 +- controller/promo_code_user.go | 29 +- controller/promo_code_user_test.go | 97 +++++ model/channel.go | 9 +- model/channel_update_test.go | 66 ++++ model/user.go | 10 +- router/api-router.go | 2 +- .../drawers/channel-mutate-drawer.tsx | 28 ++ .../src/features/channels/lib/channel-form.ts | 5 + web/default/src/features/channels/types.ts | 2 + web/default/src/features/promo-codes/api.ts | 80 ++++ .../components/promo-codes-columns.tsx | 173 +++++++++ .../components/promo-codes-delete-dialog.tsx | 92 +++++ .../components/promo-codes-dialogs.tsx | 29 ++ .../components/promo-codes-mutate-drawer.tsx | 323 ++++++++++++++++ .../promo-codes-primary-buttons.tsx | 34 ++ .../components/promo-codes-provider.tsx | 86 ++++ .../components/promo-codes-row-actions.tsx | 69 ++++ .../components/promo-codes-table.tsx | 195 ++++++++++ .../src/features/promo-codes/constants.ts | 93 +++++ .../src/features/promo-codes/index.tsx | 48 +++ .../src/features/promo-codes/lib/index.ts | 121 ++++++ web/default/src/features/promo-codes/types.ts | 81 ++++ .../system-settings/billing/index.tsx | 21 + .../billing/section-registry.tsx | 45 +++ .../integrations/alipay-settings-section.tsx | 248 ++++++++++++ .../integrations/wxpay-settings-section.tsx | 366 ++++++++++++++++++ .../src/features/system-settings/types.ts | 21 + .../users/components/users-mutate-drawer.tsx | 82 ++++ .../src/features/users/lib/user-form.ts | 8 + web/default/src/features/users/types.ts | 4 + web/default/src/hooks/use-sidebar-data.ts | 6 + web/default/src/routeTree.gen.ts | 22 ++ .../_authenticated/promo-codes/index.tsx | 41 ++ 35 files changed, 2549 insertions(+), 25 deletions(-) create mode 100644 controller/promo_code_user_test.go create mode 100644 model/channel_update_test.go create mode 100644 web/default/src/features/promo-codes/api.ts create mode 100644 web/default/src/features/promo-codes/components/promo-codes-columns.tsx create mode 100644 web/default/src/features/promo-codes/components/promo-codes-delete-dialog.tsx create mode 100644 web/default/src/features/promo-codes/components/promo-codes-dialogs.tsx create mode 100644 web/default/src/features/promo-codes/components/promo-codes-mutate-drawer.tsx create mode 100644 web/default/src/features/promo-codes/components/promo-codes-primary-buttons.tsx create mode 100644 web/default/src/features/promo-codes/components/promo-codes-provider.tsx create mode 100644 web/default/src/features/promo-codes/components/promo-codes-row-actions.tsx create mode 100644 web/default/src/features/promo-codes/components/promo-codes-table.tsx create mode 100644 web/default/src/features/promo-codes/constants.ts create mode 100644 web/default/src/features/promo-codes/index.tsx create mode 100644 web/default/src/features/promo-codes/lib/index.ts create mode 100644 web/default/src/features/promo-codes/types.ts create mode 100644 web/default/src/features/system-settings/integrations/alipay-settings-section.tsx create mode 100644 web/default/src/features/system-settings/integrations/wxpay-settings-section.tsx create mode 100644 web/default/src/routes/_authenticated/promo-codes/index.tsx diff --git a/controller/channel.go b/controller/channel.go index d951a20a..be2c1f60 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -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 diff --git a/controller/promo_code.go b/controller/promo_code.go index 3977fb57..d59c8f67 100644 --- a/controller/promo_code.go +++ b/controller/promo_code.go @@ -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 { diff --git a/controller/promo_code_user.go b/controller/promo_code_user.go index d19aebc3..b20828fa 100644 --- a/controller/promo_code_user.go +++ b/controller/promo_code_user.go @@ -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, } } diff --git a/controller/promo_code_user_test.go b/controller/promo_code_user_test.go new file mode 100644 index 00000000..fb7e7728 --- /dev/null +++ b/controller/promo_code_user_test.go @@ -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) +} diff --git a/model/channel.go b/model/channel.go index 1984c272..f3929964 100644 --- a/model/channel.go +++ b/model/channel.go @@ -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 diff --git a/model/channel_update_test.go b/model/channel_update_test.go new file mode 100644 index 00000000..062ead1a --- /dev/null +++ b/model/channel_update_test.go @@ -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) +} diff --git a/model/user.go b/model/user.go index c17542c3..902f95bc 100644 --- a/model/user.go +++ b/model/user.go @@ -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 diff --git a/router/api-router.go b/router/api-router.go index ea6baf04..530c5c41 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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) diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 39a6e152..58f7e8a7 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -2510,6 +2510,34 @@ export function ChannelMutateDrawer({ )} /> + + ( + + {t('Concurrency Limit')} + + + field.onChange(Number(e.target.value)) + } + /> + + + {t( + 'Maximum concurrent requests for this channel. 0 = unlimited.' + )} + + + + )} + /> . + +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>> { + 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> { + const res = await api.get(`/api/promo-codes/${id}`) + return res.data +} + +export async function createPromoCode( + data: PromoCodeFormData +): Promise> { + const res = await api.post('/api/promo-codes', data) + return res.data +} + +export async function updatePromoCode( + id: number, + data: PromoCodeUpdateData +): Promise> { + const res = await api.put(`/api/promo-codes/${id}`, data) + return res.data +} + +export async function deletePromoCode( + id: number +): Promise { + const res = await api.delete(`/api/promo-codes/${id}`) + return res.data +} + +export async function getPromoCodeUsages( + id: number, + p = 1, + pageSize = 10 +): Promise>> { + const res = await api.get( + `/api/promo-codes/${id}/usages?p=${p}&page_size=${pageSize}` + ) + return res.data +} \ No newline at end of file diff --git a/web/default/src/features/promo-codes/components/promo-codes-columns.tsx b/web/default/src/features/promo-codes/components/promo-codes-columns.tsx new file mode 100644 index 00000000..19bcbb01 --- /dev/null +++ b/web/default/src/features/promo-codes/components/promo-codes-columns.tsx @@ -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 . + +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[] { + const { t } = useTranslation() + return [ + { + id: 'select', + meta: { label: t('Select') }, + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={t('Select all')} + className='translate-y-[2px]' + /> + ), + cell: ({ row }) => ( + 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 }) => ( + + ), + cell: ({ row }) =>
{row.getValue('id')}
, + }, + { + accessorKey: 'code', + meta: { label: t('Code'), mobileTitle: true }, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.getValue('code')} +
+ ), + }, + { + accessorKey: 'status', + meta: { label: t('Status'), mobileBadge: true }, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const statusValue = row.getValue('status') as number + const config = PROMO_CODE_STATUSES[statusValue] + if (!config) return null + return ( + + ) + }, + filterFn: (row, id, value) => { + return value.includes(String(row.getValue(id))) + }, + }, + { + accessorKey: 'bonus_amount', + meta: { label: t('Bonus') }, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const amount = row.getValue('bonus_amount') as number + return ( + + ) + }, + }, + { + accessorKey: 'used_count', + meta: { label: t('Used'), mobileHidden: true }, + header: ({ column }) => ( + + ), + 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 {label} + }, + }, + { + accessorKey: 'min_recharge_amount', + meta: { label: t('Min Recharge'), mobileHidden: true }, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const amount = row.getValue('min_recharge_amount') as number + return ( + + {amount > 0 ? String(amount) : '-'} + + ) + }, + }, + { + accessorKey: 'notes', + meta: { label: t('Notes'), mobileHidden: true }, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const notes = row.getValue('notes') as string + return ( +
+ {notes || '-'} +
+ ) + }, + }, + { + accessorKey: 'created_at', + meta: { label: t('Created'), mobileHidden: true }, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const val = row.getValue('created_at') as string + return
{val ? new Date(val).toLocaleString() : '-'}
+ }, + }, + { + id: 'actions', + cell: ({ row }) => , + }, + ] +} \ No newline at end of file diff --git a/web/default/src/features/promo-codes/components/promo-codes-delete-dialog.tsx b/web/default/src/features/promo-codes/components/promo-codes-delete-dialog.tsx new file mode 100644 index 00000000..b630a32d --- /dev/null +++ b/web/default/src/features/promo-codes/components/promo-codes-delete-dialog.tsx @@ -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 . + +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 ( + { if (!open) closeDialog() }}> + + + {t('Disable Promo Code')} + + {t( + 'Are you sure you want to disable promo code "{{code}}"? It will become unavailable to users.', + { code: selectedPromoCode?.code || '' } + )} + + + + + {t('Cancel')} + + + {deleteMutation.isPending ? t('Disabling...') : t('Disable')} + + + + + ) +} diff --git a/web/default/src/features/promo-codes/components/promo-codes-dialogs.tsx b/web/default/src/features/promo-codes/components/promo-codes-dialogs.tsx new file mode 100644 index 00000000..81effc45 --- /dev/null +++ b/web/default/src/features/promo-codes/components/promo-codes-dialogs.tsx @@ -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 . + +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 ( + <> + + + + ) +} \ No newline at end of file diff --git a/web/default/src/features/promo-codes/components/promo-codes-mutate-drawer.tsx b/web/default/src/features/promo-codes/components/promo-codes-mutate-drawer.tsx new file mode 100644 index 00000000..be01d9dc --- /dev/null +++ b/web/default/src/features/promo-codes/components/promo-codes-mutate-drawer.tsx @@ -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 . + +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({ + 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 }) => + 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 ( + { if (!open) closeDialog() }}> + + + + {isUpdate ? t('Edit Promo Code') : t('Create Promo Code')} + + + {isUpdate + ? t('Update promo code settings. Code and bonus amount cannot be changed.') + : t('Create a new promo code for users to redeem.')} + + + +
+ + ( + + {t('Code')} + + field.onChange(e.target.value.toUpperCase())} + /> + + + + )} + /> + + ( + + {t('Bonus Amount')} + + { + field.onBlur() + if (event.target.value === '') field.onChange(0) + }} + onChange={(e) => + field.onChange(parseOptionalNumberInput(e.target.value)) + } + name={field.name} + ref={field.ref} + /> + + + + )} + /> + + ( + + {t('Max Uses')} + + { + field.onBlur() + if (event.target.value === '') field.onChange(0) + }} + onChange={(e) => + field.onChange(parseOptionalNumberInput(e.target.value)) + } + name={field.name} + ref={field.ref} + /> + + + + )} + /> + + ( + + {t('Min Recharge Amount')} + + { + field.onBlur() + if (event.target.value === '') field.onChange(0) + }} + onChange={(e) => + field.onChange(parseOptionalNumberInput(e.target.value)) + } + name={field.name} + ref={field.ref} + /> + + + + )} + /> + + ( + + {t('Expires At')} + + + + + + )} + /> + + ( + + {t('Notes')} + +