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/QuantumNous/new-api/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -876,8 +877,16 @@ type PatchChannel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func UpdateChannel(c *gin.Context) {
|
func UpdateChannel(c *gin.Context) {
|
||||||
|
var rawPayload map[string]json.RawMessage
|
||||||
|
err := c.ShouldBindBodyWith(&rawPayload, binding.JSON)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, hasConcurrencyLimit := rawPayload["concurrency_limit"]
|
||||||
|
|
||||||
channel := PatchChannel{}
|
channel := PatchChannel{}
|
||||||
err := c.ShouldBindJSON(&channel)
|
err = c.ShouldBindBodyWith(&channel, binding.JSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
@ -990,7 +999,11 @@ func UpdateChannel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
model.InitChannelNextResetTime(&channel.Channel)
|
model.InitChannelNextResetTime(&channel.Channel)
|
||||||
err = channel.Update()
|
forceUpdateZeroColumns := make([]string, 0, 1)
|
||||||
|
if hasConcurrencyLimit && channel.ConcurrencyLimit == 0 {
|
||||||
|
forceUpdateZeroColumns = append(forceUpdateZeroColumns, "concurrency_limit")
|
||||||
|
}
|
||||||
|
err = channel.Update(forceUpdateZeroColumns...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const promoCodeMaxLength = 32
|
||||||
|
|
||||||
type createPromoCodeRequest struct {
|
type createPromoCodeRequest struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
BonusAmount int `json:"bonus_amount"`
|
BonusAmount int `json:"bonus_amount"`
|
||||||
@ -66,10 +68,14 @@ func CreatePromoCode(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "优惠码不能为空")
|
common.ApiErrorMsg(c, "优惠码不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(code) > 32 {
|
if len(code) > promoCodeMaxLength {
|
||||||
common.ApiErrorMsg(c, "优惠码长度不能超过 32 个字符")
|
common.ApiErrorMsg(c, "优惠码长度不能超过 32 个字符")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !isPromoCodeSyntaxValid(code) {
|
||||||
|
common.ApiErrorMsg(c, "优惠码只能包含大写字母、数字、下划线和连字符")
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.BonusAmount <= 0 {
|
if req.BonusAmount <= 0 {
|
||||||
common.ApiErrorMsg(c, "赠送额度必须大于 0")
|
common.ApiErrorMsg(c, "赠送额度必须大于 0")
|
||||||
return
|
return
|
||||||
@ -228,6 +234,19 @@ func normalizePromoCode(code string) string {
|
|||||||
return strings.ToUpper(strings.TrimSpace(code))
|
return strings.ToUpper(strings.TrimSpace(code))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPromoCodeSyntaxValid(code string) bool {
|
||||||
|
if code == "" || len(code) > promoCodeMaxLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range code {
|
||||||
|
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func parsePromoCodeExpiresAt(value string) (*time.Time, error) {
|
func parsePromoCodeExpiresAt(value string) (*time.Time, error) {
|
||||||
expiresAt, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
|
expiresAt, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
@ -12,6 +11,8 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const invalidPromoCodeReason = "invalid"
|
||||||
|
|
||||||
type validatePromoCodeRequest struct {
|
type validatePromoCodeRequest struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Amount int `json:"amount"`
|
Amount int `json:"amount"`
|
||||||
@ -32,16 +33,16 @@ func ValidatePromoCode(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code := normalizeUserPromoCode(req.Code)
|
code := normalizePromoCode(req.Code)
|
||||||
if code == "" {
|
if !isPromoCodeSyntaxValid(code) {
|
||||||
common.ApiErrorMsg(c, "优惠码不能为空")
|
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
promoCode, err := model.GetPromoCodeByCode(code)
|
promoCode, err := model.GetPromoCodeByCode(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "not_found"))
|
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
@ -49,25 +50,25 @@ func ValidatePromoCode(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if promoCode.Status != 1 {
|
if promoCode.Status != 1 {
|
||||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "disabled"))
|
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if promoCode.ExpiresAt != nil && promoCode.ExpiresAt.Before(time.Now()) {
|
if promoCode.ExpiresAt != nil && promoCode.ExpiresAt.Before(time.Now()) {
|
||||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "expired"))
|
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if promoCode.MaxUses > 0 && promoCode.UsedCount >= promoCode.MaxUses {
|
if promoCode.MaxUses > 0 && promoCode.UsedCount >= promoCode.MaxUses {
|
||||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "max_uses_reached"))
|
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Amount < promoCode.MinRechargeAmount {
|
if req.Amount < promoCode.MinRechargeAmount {
|
||||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "below_min_recharge"))
|
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userId := c.GetInt("id")
|
userId := c.GetInt("id")
|
||||||
if _, err := model.GetPromoCodeUsageByCodeAndUser(promoCode.Id, userId); err == nil {
|
if _, err := model.GetPromoCodeUsageByCodeAndUser(promoCode.Id, userId); err == nil {
|
||||||
common.ApiSuccess(c, newInvalidPromoCodeResponse(code, "already_used"))
|
common.ApiSuccess(c, newInvalidPromoCodeResponse(code))
|
||||||
return
|
return
|
||||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
@ -83,14 +84,10 @@ func ValidatePromoCode(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeUserPromoCode(code string) string {
|
func newInvalidPromoCodeResponse(code string) validatePromoCodeResponse {
|
||||||
return strings.ToUpper(strings.TrimSpace(code))
|
|
||||||
}
|
|
||||||
|
|
||||||
func newInvalidPromoCodeResponse(code string, reason string) validatePromoCodeResponse {
|
|
||||||
return validatePromoCodeResponse{
|
return validatePromoCodeResponse{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Reason: reason,
|
Reason: invalidPromoCodeReason,
|
||||||
Code: code,
|
Code: code,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (channel *Channel) Update() error {
|
func (channel *Channel) Update(forceUpdateZeroColumns ...string) error {
|
||||||
// If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys
|
// If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys
|
||||||
if channel.ChannelInfo.IsMultiKey {
|
if channel.ChannelInfo.IsMultiKey {
|
||||||
var keyStr string
|
var keyStr string
|
||||||
@ -573,6 +573,13 @@ func (channel *Channel) Update() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for _, column := range forceUpdateZeroColumns {
|
||||||
|
if column == "concurrency_limit" && channel.ConcurrencyLimit == 0 {
|
||||||
|
if err = DB.Model(channel).Update(column, 0).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
DB.Model(channel).First(channel, "id = ?", channel.Id)
|
DB.Model(channel).First(channel, "id = ?", channel.Id)
|
||||||
err = channel.UpdateAbilities(nil)
|
err = channel.UpdateAbilities(nil)
|
||||||
return err
|
return err
|
||||||
|
|||||||
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
|
newUser := *user
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"username": newUser.Username,
|
"username": newUser.Username,
|
||||||
"display_name": newUser.DisplayName,
|
"display_name": newUser.DisplayName,
|
||||||
"group": newUser.Group,
|
"group": newUser.Group,
|
||||||
"remark": newUser.Remark,
|
"remark": newUser.Remark,
|
||||||
|
"concurrency_limit": newUser.ConcurrencyLimit,
|
||||||
|
"rpm_limit": newUser.RpmLimit,
|
||||||
}
|
}
|
||||||
if updatePassword {
|
if updatePassword {
|
||||||
updates["password"] = newUser.Password
|
updates["password"] = newUser.Password
|
||||||
|
|||||||
@ -100,7 +100,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
selfRoute.GET("/topup/info", controller.GetTopUpInfo)
|
selfRoute.GET("/topup/info", controller.GetTopUpInfo)
|
||||||
selfRoute.GET("/topup/self", controller.GetUserTopUps)
|
selfRoute.GET("/topup/self", controller.GetUserTopUps)
|
||||||
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
|
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
|
||||||
selfRoute.POST("/promo-codes/validate", controller.ValidatePromoCode)
|
selfRoute.POST("/promo-codes/validate", middleware.SearchRateLimit(), controller.ValidatePromoCode)
|
||||||
selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay)
|
selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay)
|
||||||
selfRoute.POST("/amount", controller.RequestAmount)
|
selfRoute.POST("/amount", controller.RequestAmount)
|
||||||
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
|
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
|
||||||
|
|||||||
@ -2510,6 +2510,34 @@ export function ChannelMutateDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='concurrency_limit'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('Concurrency Limit')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
placeholder='0 = unlimited'
|
||||||
|
value={field.value ?? 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(Number(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
'Maximum concurrent requests for this channel. 0 = unlimited.'
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export const channelFormSchema = z.object({
|
|||||||
weight: z.number().optional(),
|
weight: z.number().optional(),
|
||||||
test_model: z.string().optional(),
|
test_model: z.string().optional(),
|
||||||
auto_ban: z.number().optional(),
|
auto_ban: z.number().optional(),
|
||||||
|
concurrency_limit: z.number().int().min(0).optional(),
|
||||||
status: z.number(),
|
status: z.number(),
|
||||||
status_code_mapping: z.string().optional(),
|
status_code_mapping: z.string().optional(),
|
||||||
tag: z.string().optional(),
|
tag: z.string().optional(),
|
||||||
@ -99,6 +100,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
|
|||||||
weight: 0,
|
weight: 0,
|
||||||
test_model: '',
|
test_model: '',
|
||||||
auto_ban: 1,
|
auto_ban: 1,
|
||||||
|
concurrency_limit: 0,
|
||||||
status: CHANNEL_STATUS.ENABLED,
|
status: CHANNEL_STATUS.ENABLED,
|
||||||
status_code_mapping: '',
|
status_code_mapping: '',
|
||||||
tag: '',
|
tag: '',
|
||||||
@ -232,6 +234,7 @@ export function transformChannelToFormDefaults(
|
|||||||
weight: channel.weight || 0,
|
weight: channel.weight || 0,
|
||||||
test_model: channel.test_model || '',
|
test_model: channel.test_model || '',
|
||||||
auto_ban: channel.auto_ban ?? 1,
|
auto_ban: channel.auto_ban ?? 1,
|
||||||
|
concurrency_limit: channel.concurrency_limit || 0,
|
||||||
status: channel.status,
|
status: channel.status,
|
||||||
status_code_mapping: channel.status_code_mapping || '',
|
status_code_mapping: channel.status_code_mapping || '',
|
||||||
tag: channel.tag || '',
|
tag: channel.tag || '',
|
||||||
@ -413,6 +416,7 @@ export function transformFormDataToCreatePayload(formData: ChannelFormValues): {
|
|||||||
weight: formData.weight || null,
|
weight: formData.weight || null,
|
||||||
test_model: formData.test_model || null,
|
test_model: formData.test_model || null,
|
||||||
auto_ban: formData.auto_ban ?? 1,
|
auto_ban: formData.auto_ban ?? 1,
|
||||||
|
concurrency_limit: formData.concurrency_limit || 0,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
status_code_mapping: formData.status_code_mapping || null,
|
status_code_mapping: formData.status_code_mapping || null,
|
||||||
tag: formData.tag || null,
|
tag: formData.tag || null,
|
||||||
@ -461,6 +465,7 @@ export function transformFormDataToUpdatePayload(
|
|||||||
weight: formData.weight ?? 0,
|
weight: formData.weight ?? 0,
|
||||||
test_model: formData.test_model || null,
|
test_model: formData.test_model || null,
|
||||||
auto_ban: formData.auto_ban ?? 1,
|
auto_ban: formData.auto_ban ?? 1,
|
||||||
|
concurrency_limit: formData.concurrency_limit || 0,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
status_code_mapping: formData.status_code_mapping || null,
|
status_code_mapping: formData.status_code_mapping || null,
|
||||||
tag: formData.tag || null,
|
tag: formData.tag || null,
|
||||||
|
|||||||
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(),
|
header_override: z.string().nullish(),
|
||||||
remark: z.string().default(''),
|
remark: z.string().default(''),
|
||||||
max_input_tokens: z.number().default(0),
|
max_input_tokens: z.number().default(0),
|
||||||
|
concurrency_limit: z.number().optional(),
|
||||||
channel_info: channelInfoSchema.default({
|
channel_info: channelInfoSchema.default({
|
||||||
is_multi_key: false,
|
is_multi_key: false,
|
||||||
multi_key_size: 0,
|
multi_key_size: 0,
|
||||||
@ -318,6 +319,7 @@ export interface ChannelFormData {
|
|||||||
header_override?: string
|
header_override?: string
|
||||||
settings?: string
|
settings?: string
|
||||||
other?: string
|
other?: string
|
||||||
|
concurrency_limit?: number
|
||||||
// Multi-key specific
|
// Multi-key specific
|
||||||
multi_key_mode?: 'single' | 'batch' | 'multi_to_single'
|
multi_key_mode?: 'single' | 'batch' | 'multi_to_single'
|
||||||
multi_key_type?: 'random' | 'polling'
|
multi_key_type?: 'random' | 'polling'
|
||||||
|
|||||||
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',
|
WaffoPancakeCurrency: 'USD',
|
||||||
WaffoPancakeUnitPrice: 1,
|
WaffoPancakeUnitPrice: 1,
|
||||||
WaffoPancakeMinTopUp: 1,
|
WaffoPancakeMinTopUp: 1,
|
||||||
|
AlipayEnabled: false,
|
||||||
|
AlipayAppID: '',
|
||||||
|
AlipayPrivateKey: '',
|
||||||
|
AlipayPublicKey: '',
|
||||||
|
AlipayGatewayUrl: 'https://openapi.alipay.com/gateway.do',
|
||||||
|
AlipayNotifyURL: '',
|
||||||
|
AlipayReturnURL: '',
|
||||||
|
WxpayEnabled: false,
|
||||||
|
WxpayAppID: '',
|
||||||
|
WxpayMchID: '',
|
||||||
|
WxpayPrivateKey: '',
|
||||||
|
WxpayMerchantSerialNo: '',
|
||||||
|
WxpayAPIv3Key: '',
|
||||||
|
WxpayPlatformPublicKey: '',
|
||||||
|
WxpayPlatformCertificate: '',
|
||||||
|
WxpayPlatformSerialNo: '',
|
||||||
|
WxpayGatewayURL: 'https://api.mch.weixin.qq.com',
|
||||||
|
WxpayNotifyURL: '',
|
||||||
|
WxpayReturnURL: '',
|
||||||
|
WxpayH5AppName: '',
|
||||||
|
WxpayH5AppURL: '',
|
||||||
'checkin_setting.enabled': false,
|
'checkin_setting.enabled': false,
|
||||||
'checkin_setting.min_quota': 1000,
|
'checkin_setting.min_quota': 1000,
|
||||||
'checkin_setting.max_quota': 10000,
|
'checkin_setting.max_quota': 10000,
|
||||||
|
|||||||
@ -20,7 +20,9 @@ import { parseCurrencyDisplayType } from '@/lib/currency'
|
|||||||
import { CheckinSettingsSection } from '../general/checkin-settings-section'
|
import { CheckinSettingsSection } from '../general/checkin-settings-section'
|
||||||
import { PricingSection } from '../general/pricing-section'
|
import { PricingSection } from '../general/pricing-section'
|
||||||
import { QuotaSettingsSection } from '../general/quota-settings-section'
|
import { QuotaSettingsSection } from '../general/quota-settings-section'
|
||||||
|
import { AlipaySettingsSection } from '../integrations/alipay-settings-section'
|
||||||
import { PaymentSettingsSection } from '../integrations/payment-settings-section'
|
import { PaymentSettingsSection } from '../integrations/payment-settings-section'
|
||||||
|
import { WxpaySettingsSection } from '../integrations/wxpay-settings-section'
|
||||||
import { RatioSettingsCard } from '../models/ratio-settings-card'
|
import { RatioSettingsCard } from '../models/ratio-settings-card'
|
||||||
import type { BillingSettings } from '../types'
|
import type { BillingSettings } from '../types'
|
||||||
import { createSectionRegistry } from '../utils/section-registry'
|
import { createSectionRegistry } from '../utils/section-registry'
|
||||||
@ -201,6 +203,49 @@ const BILLING_SECTIONS = [
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'alipay',
|
||||||
|
titleKey: 'Alipay',
|
||||||
|
descriptionKey: 'Configure Alipay for domestic payments',
|
||||||
|
build: (settings: BillingSettings) => (
|
||||||
|
<AlipaySettingsSection
|
||||||
|
defaultValues={{
|
||||||
|
AlipayEnabled: settings.AlipayEnabled ?? false,
|
||||||
|
AlipayAppID: settings.AlipayAppID ?? '',
|
||||||
|
AlipayPrivateKey: settings.AlipayPrivateKey ?? '',
|
||||||
|
AlipayPublicKey: settings.AlipayPublicKey ?? '',
|
||||||
|
AlipayGatewayUrl: settings.AlipayGatewayUrl ?? '',
|
||||||
|
AlipayNotifyURL: settings.AlipayNotifyURL ?? '',
|
||||||
|
AlipayReturnURL: settings.AlipayReturnURL ?? '',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wxpay',
|
||||||
|
titleKey: 'WeChat Pay',
|
||||||
|
descriptionKey: 'Configure WeChat Pay for domestic payments',
|
||||||
|
build: (settings: BillingSettings) => (
|
||||||
|
<WxpaySettingsSection
|
||||||
|
defaultValues={{
|
||||||
|
WxpayEnabled: settings.WxpayEnabled ?? false,
|
||||||
|
WxpayAppID: settings.WxpayAppID ?? '',
|
||||||
|
WxpayMchID: settings.WxpayMchID ?? '',
|
||||||
|
WxpayPrivateKey: settings.WxpayPrivateKey ?? '',
|
||||||
|
WxpayMerchantSerialNo: settings.WxpayMerchantSerialNo ?? '',
|
||||||
|
WxpayAPIv3Key: settings.WxpayAPIv3Key ?? '',
|
||||||
|
WxpayPlatformPublicKey: settings.WxpayPlatformPublicKey ?? '',
|
||||||
|
WxpayPlatformCertificate: settings.WxpayPlatformCertificate ?? '',
|
||||||
|
WxpayPlatformSerialNo: settings.WxpayPlatformSerialNo ?? '',
|
||||||
|
WxpayGatewayURL: settings.WxpayGatewayURL ?? '',
|
||||||
|
WxpayNotifyURL: settings.WxpayNotifyURL ?? '',
|
||||||
|
WxpayReturnURL: settings.WxpayReturnURL ?? '',
|
||||||
|
WxpayH5AppName: settings.WxpayH5AppName ?? '',
|
||||||
|
WxpayH5AppURL: settings.WxpayH5AppURL ?? '',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'checkin',
|
id: 'checkin',
|
||||||
titleKey: 'Check-in Rewards',
|
titleKey: 'Check-in Rewards',
|
||||||
|
|||||||
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
|
WaffoPancakeCurrency: string
|
||||||
WaffoPancakeUnitPrice: number
|
WaffoPancakeUnitPrice: number
|
||||||
WaffoPancakeMinTopUp: number
|
WaffoPancakeMinTopUp: number
|
||||||
|
AlipayEnabled: boolean
|
||||||
|
AlipayAppID: string
|
||||||
|
AlipayPrivateKey: string
|
||||||
|
AlipayPublicKey: string
|
||||||
|
AlipayGatewayUrl: string
|
||||||
|
AlipayNotifyURL: string
|
||||||
|
AlipayReturnURL: string
|
||||||
|
WxpayEnabled: boolean
|
||||||
|
WxpayAppID: string
|
||||||
|
WxpayMchID: string
|
||||||
|
WxpayPrivateKey: string
|
||||||
|
WxpayMerchantSerialNo: string
|
||||||
|
WxpayAPIv3Key: string
|
||||||
|
WxpayPlatformPublicKey: string
|
||||||
|
WxpayPlatformCertificate: string
|
||||||
|
WxpayPlatformSerialNo: string
|
||||||
|
WxpayGatewayURL: string
|
||||||
|
WxpayNotifyURL: string
|
||||||
|
WxpayReturnURL: string
|
||||||
|
WxpayH5AppName: string
|
||||||
|
WxpayH5AppURL: string
|
||||||
'checkin_setting.enabled': boolean
|
'checkin_setting.enabled': boolean
|
||||||
'checkin_setting.min_quota': number
|
'checkin_setting.min_quota': number
|
||||||
'checkin_setting.max_quota': number
|
'checkin_setting.max_quota': number
|
||||||
|
|||||||
@ -74,6 +74,10 @@ type UsersMutateDrawerProps = {
|
|||||||
currentRow?: User
|
currentRow?: User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalNumberInput(value: string) {
|
||||||
|
return value === '' ? undefined : Number(value)
|
||||||
|
}
|
||||||
|
|
||||||
export function UsersMutateDrawer({
|
export function UsersMutateDrawer({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@ -397,6 +401,84 @@ export function UsersMutateDrawer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Concurrency Control (Update only) */}
|
||||||
|
{isUpdate && (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<h3 className='text-sm font-medium'>
|
||||||
|
{t('Concurrency Control')}
|
||||||
|
</h3>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
{t(
|
||||||
|
'Set limits per user. 0 means unlimited (use global defaults).'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='concurrency_limit'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('Concurrency Limit')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
placeholder='0 = unlimited'
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onBlur={(event) => {
|
||||||
|
field.onBlur()
|
||||||
|
if (event.target.value === '') field.onChange(0)
|
||||||
|
}}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseOptionalNumberInput(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
'Maximum concurrent API requests for this user. 0 = unlimited.'
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='rpm_limit'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('RPM Limit')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
placeholder='0 = unlimited'
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onBlur={(event) => {
|
||||||
|
field.onBlur()
|
||||||
|
if (event.target.value === '') field.onChange(0)
|
||||||
|
}}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseOptionalNumberInput(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
'Maximum requests per minute for this user. 0 = unlimited.'
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Binding Information (Read-only) */}
|
{/* Binding Information (Read-only) */}
|
||||||
{isUpdate && (
|
{isUpdate && (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
|
|||||||
@ -33,6 +33,8 @@ export const userFormSchema = z.object({
|
|||||||
quota_dollars: z.number().min(0).optional(),
|
quota_dollars: z.number().min(0).optional(),
|
||||||
group: z.string().optional(),
|
group: z.string().optional(),
|
||||||
remark: z.string().optional(),
|
remark: z.string().optional(),
|
||||||
|
concurrency_limit: z.number().int().min(0).optional(),
|
||||||
|
rpm_limit: z.number().int().min(0).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type UserFormValues = z.infer<typeof userFormSchema>
|
export type UserFormValues = z.infer<typeof userFormSchema>
|
||||||
@ -49,6 +51,8 @@ export const USER_FORM_DEFAULT_VALUES: UserFormValues = {
|
|||||||
quota_dollars: 0,
|
quota_dollars: 0,
|
||||||
group: DEFAULT_GROUP,
|
group: DEFAULT_GROUP,
|
||||||
remark: '',
|
remark: '',
|
||||||
|
concurrency_limit: 0,
|
||||||
|
rpm_limit: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -76,6 +80,8 @@ export function transformFormDataToPayload(
|
|||||||
payload.group = data.group
|
payload.group = data.group
|
||||||
payload.remark = data.remark || undefined
|
payload.remark = data.remark || undefined
|
||||||
payload.id = userId
|
payload.id = userId
|
||||||
|
payload.concurrency_limit = data.concurrency_limit ?? 0
|
||||||
|
payload.rpm_limit = data.rpm_limit ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
@ -93,5 +99,7 @@ export function transformUserToFormDefaults(user: User): UserFormValues {
|
|||||||
quota_dollars: quotaUnitsToDollars(user.quota),
|
quota_dollars: quotaUnitsToDollars(user.quota),
|
||||||
group: user.group || DEFAULT_GROUP,
|
group: user.group || DEFAULT_GROUP,
|
||||||
remark: user.remark || '',
|
remark: user.remark || '',
|
||||||
|
concurrency_limit: user.concurrency_limit || 0,
|
||||||
|
rpm_limit: user.rpm_limit || 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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(),
|
last_login_at: z.number().optional(),
|
||||||
DeletedAt: z.any().nullable().optional(),
|
DeletedAt: z.any().nullable().optional(),
|
||||||
remark: z.string().optional(),
|
remark: z.string().optional(),
|
||||||
|
concurrency_limit: z.number().optional(),
|
||||||
|
rpm_limit: z.number().optional(),
|
||||||
})
|
})
|
||||||
export type User = z.infer<typeof userSchema>
|
export type User = z.infer<typeof userSchema>
|
||||||
|
|
||||||
@ -104,6 +106,8 @@ export interface UserFormData {
|
|||||||
quota?: number // Only used when updating user
|
quota?: number // Only used when updating user
|
||||||
group?: string // Only used when updating user
|
group?: string // Only used when updating user
|
||||||
remark?: string // Only used when updating user
|
remark?: string // Only used when updating user
|
||||||
|
concurrency_limit?: number
|
||||||
|
rpm_limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManageUserAction =
|
export type ManageUserAction =
|
||||||
|
|||||||
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,
|
Box,
|
||||||
Users,
|
Users,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
Tags,
|
||||||
User,
|
User,
|
||||||
Command,
|
Command,
|
||||||
Radio,
|
Radio,
|
||||||
@ -140,6 +141,11 @@ export function useSidebarData(): SidebarData {
|
|||||||
url: '/redemption-codes',
|
url: '/redemption-codes',
|
||||||
icon: Ticket,
|
icon: Ticket,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('Promo Codes'),
|
||||||
|
url: '/promo-codes',
|
||||||
|
icon: Tags,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('Subscription Management'),
|
title: t('Subscription Management'),
|
||||||
url: '/subscriptions',
|
url: '/subscriptions',
|
||||||
|
|||||||
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 AuthenticatedSystemSettingsIndexRouteImport } from './routes/_authenticated/system-settings/index'
|
||||||
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
|
import { Route as AuthenticatedSubscriptionsIndexRouteImport } from './routes/_authenticated/subscriptions/index'
|
||||||
import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/_authenticated/redemption-codes/index'
|
import { Route as AuthenticatedRedemptionCodesIndexRouteImport } from './routes/_authenticated/redemption-codes/index'
|
||||||
|
import { Route as AuthenticatedPromoCodesIndexRouteImport } from './routes/_authenticated/promo-codes/index'
|
||||||
import { Route as AuthenticatedProfileIndexRouteImport } from './routes/_authenticated/profile/index'
|
import { Route as AuthenticatedProfileIndexRouteImport } from './routes/_authenticated/profile/index'
|
||||||
import { Route as AuthenticatedPlaygroundIndexRouteImport } from './routes/_authenticated/playground/index'
|
import { Route as AuthenticatedPlaygroundIndexRouteImport } from './routes/_authenticated/playground/index'
|
||||||
import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenticated/models/index'
|
import { Route as AuthenticatedModelsIndexRouteImport } from './routes/_authenticated/models/index'
|
||||||
@ -232,6 +233,12 @@ const AuthenticatedRedemptionCodesIndexRoute =
|
|||||||
path: '/redemption-codes/',
|
path: '/redemption-codes/',
|
||||||
getParentRoute: () => AuthenticatedRouteRoute,
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedPromoCodesIndexRoute =
|
||||||
|
AuthenticatedPromoCodesIndexRouteImport.update({
|
||||||
|
id: '/promo-codes/',
|
||||||
|
path: '/promo-codes/',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedProfileIndexRoute =
|
const AuthenticatedProfileIndexRoute =
|
||||||
AuthenticatedProfileIndexRouteImport.update({
|
AuthenticatedProfileIndexRouteImport.update({
|
||||||
id: '/profile/',
|
id: '/profile/',
|
||||||
@ -422,6 +429,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/models/': typeof AuthenticatedModelsIndexRoute
|
'/models/': typeof AuthenticatedModelsIndexRoute
|
||||||
'/playground/': typeof AuthenticatedPlaygroundIndexRoute
|
'/playground/': typeof AuthenticatedPlaygroundIndexRoute
|
||||||
'/profile/': typeof AuthenticatedProfileIndexRoute
|
'/profile/': typeof AuthenticatedProfileIndexRoute
|
||||||
|
'/promo-codes/': typeof AuthenticatedPromoCodesIndexRoute
|
||||||
'/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
|
'/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
|
||||||
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
'/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
||||||
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
'/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
||||||
@ -479,6 +487,7 @@ export interface FileRoutesByTo {
|
|||||||
'/models': typeof AuthenticatedModelsIndexRoute
|
'/models': typeof AuthenticatedModelsIndexRoute
|
||||||
'/playground': typeof AuthenticatedPlaygroundIndexRoute
|
'/playground': typeof AuthenticatedPlaygroundIndexRoute
|
||||||
'/profile': typeof AuthenticatedProfileIndexRoute
|
'/profile': typeof AuthenticatedProfileIndexRoute
|
||||||
|
'/promo-codes': typeof AuthenticatedPromoCodesIndexRoute
|
||||||
'/redemption-codes': typeof AuthenticatedRedemptionCodesIndexRoute
|
'/redemption-codes': typeof AuthenticatedRedemptionCodesIndexRoute
|
||||||
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
|
'/subscriptions': typeof AuthenticatedSubscriptionsIndexRoute
|
||||||
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
|
'/system-settings': typeof AuthenticatedSystemSettingsIndexRoute
|
||||||
@ -540,6 +549,7 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated/models/': typeof AuthenticatedModelsIndexRoute
|
'/_authenticated/models/': typeof AuthenticatedModelsIndexRoute
|
||||||
'/_authenticated/playground/': typeof AuthenticatedPlaygroundIndexRoute
|
'/_authenticated/playground/': typeof AuthenticatedPlaygroundIndexRoute
|
||||||
'/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute
|
'/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute
|
||||||
|
'/_authenticated/promo-codes/': typeof AuthenticatedPromoCodesIndexRoute
|
||||||
'/_authenticated/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
|
'/_authenticated/redemption-codes/': typeof AuthenticatedRedemptionCodesIndexRoute
|
||||||
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
'/_authenticated/subscriptions/': typeof AuthenticatedSubscriptionsIndexRoute
|
||||||
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
'/_authenticated/system-settings/': typeof AuthenticatedSystemSettingsIndexRoute
|
||||||
@ -600,6 +610,7 @@ export interface FileRouteTypes {
|
|||||||
| '/models/'
|
| '/models/'
|
||||||
| '/playground/'
|
| '/playground/'
|
||||||
| '/profile/'
|
| '/profile/'
|
||||||
|
| '/promo-codes/'
|
||||||
| '/redemption-codes/'
|
| '/redemption-codes/'
|
||||||
| '/subscriptions/'
|
| '/subscriptions/'
|
||||||
| '/system-settings/'
|
| '/system-settings/'
|
||||||
@ -657,6 +668,7 @@ export interface FileRouteTypes {
|
|||||||
| '/models'
|
| '/models'
|
||||||
| '/playground'
|
| '/playground'
|
||||||
| '/profile'
|
| '/profile'
|
||||||
|
| '/promo-codes'
|
||||||
| '/redemption-codes'
|
| '/redemption-codes'
|
||||||
| '/subscriptions'
|
| '/subscriptions'
|
||||||
| '/system-settings'
|
| '/system-settings'
|
||||||
@ -717,6 +729,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated/models/'
|
| '/_authenticated/models/'
|
||||||
| '/_authenticated/playground/'
|
| '/_authenticated/playground/'
|
||||||
| '/_authenticated/profile/'
|
| '/_authenticated/profile/'
|
||||||
|
| '/_authenticated/promo-codes/'
|
||||||
| '/_authenticated/redemption-codes/'
|
| '/_authenticated/redemption-codes/'
|
||||||
| '/_authenticated/subscriptions/'
|
| '/_authenticated/subscriptions/'
|
||||||
| '/_authenticated/system-settings/'
|
| '/_authenticated/system-settings/'
|
||||||
@ -987,6 +1000,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedRedemptionCodesIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedRedemptionCodesIndexRouteImport
|
||||||
parentRoute: typeof AuthenticatedRouteRoute
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/promo-codes/': {
|
||||||
|
id: '/_authenticated/promo-codes/'
|
||||||
|
path: '/promo-codes'
|
||||||
|
fullPath: '/promo-codes/'
|
||||||
|
preLoaderRoute: typeof AuthenticatedPromoCodesIndexRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
'/_authenticated/profile/': {
|
'/_authenticated/profile/': {
|
||||||
id: '/_authenticated/profile/'
|
id: '/_authenticated/profile/'
|
||||||
path: '/profile'
|
path: '/profile'
|
||||||
@ -1267,6 +1287,7 @@ interface AuthenticatedRouteRouteChildren {
|
|||||||
AuthenticatedModelsIndexRoute: typeof AuthenticatedModelsIndexRoute
|
AuthenticatedModelsIndexRoute: typeof AuthenticatedModelsIndexRoute
|
||||||
AuthenticatedPlaygroundIndexRoute: typeof AuthenticatedPlaygroundIndexRoute
|
AuthenticatedPlaygroundIndexRoute: typeof AuthenticatedPlaygroundIndexRoute
|
||||||
AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute
|
AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute
|
||||||
|
AuthenticatedPromoCodesIndexRoute: typeof AuthenticatedPromoCodesIndexRoute
|
||||||
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
|
AuthenticatedRedemptionCodesIndexRoute: typeof AuthenticatedRedemptionCodesIndexRoute
|
||||||
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
|
AuthenticatedSubscriptionsIndexRoute: typeof AuthenticatedSubscriptionsIndexRoute
|
||||||
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
|
AuthenticatedUsageLogsIndexRoute: typeof AuthenticatedUsageLogsIndexRoute
|
||||||
@ -1289,6 +1310,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
|||||||
AuthenticatedModelsIndexRoute: AuthenticatedModelsIndexRoute,
|
AuthenticatedModelsIndexRoute: AuthenticatedModelsIndexRoute,
|
||||||
AuthenticatedPlaygroundIndexRoute: AuthenticatedPlaygroundIndexRoute,
|
AuthenticatedPlaygroundIndexRoute: AuthenticatedPlaygroundIndexRoute,
|
||||||
AuthenticatedProfileIndexRoute: AuthenticatedProfileIndexRoute,
|
AuthenticatedProfileIndexRoute: AuthenticatedProfileIndexRoute,
|
||||||
|
AuthenticatedPromoCodesIndexRoute: AuthenticatedPromoCodesIndexRoute,
|
||||||
AuthenticatedRedemptionCodesIndexRoute:
|
AuthenticatedRedemptionCodesIndexRoute:
|
||||||
AuthenticatedRedemptionCodesIndexRoute,
|
AuthenticatedRedemptionCodesIndexRoute,
|
||||||
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
|
AuthenticatedSubscriptionsIndexRoute: AuthenticatedSubscriptionsIndexRoute,
|
||||||
|
|||||||
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