Add subscription redemption code support

This commit is contained in:
2026-04-06 10:23:28 +08:00
parent 677d02f2ab
commit c538ebd62f
8 changed files with 616 additions and 237 deletions
+88 -16
View File
@@ -3,6 +3,7 @@ package controller
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"unicode/utf8" "unicode/utf8"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
@@ -19,10 +20,10 @@ func GetAllRedemptions(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
enrichRedemptions(redemptions)
pageInfo.SetTotal(int(total)) pageInfo.SetTotal(int(total))
pageInfo.SetItems(redemptions) pageInfo.SetItems(redemptions)
common.ApiSuccess(c, pageInfo) common.ApiSuccess(c, pageInfo)
return
} }
func SearchRedemptions(c *gin.Context) { func SearchRedemptions(c *gin.Context) {
@@ -33,10 +34,10 @@ func SearchRedemptions(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
enrichRedemptions(redemptions)
pageInfo.SetTotal(int(total)) pageInfo.SetTotal(int(total))
pageInfo.SetItems(redemptions) pageInfo.SetItems(redemptions)
common.ApiSuccess(c, pageInfo) common.ApiSuccess(c, pageInfo)
return
} }
func GetRedemption(c *gin.Context) { func GetRedemption(c *gin.Context) {
@@ -50,12 +51,12 @@ func GetRedemption(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
enrichRedemption(redemption)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": redemption, "data": redemption,
}) })
return
} }
func AddRedemption(c *gin.Context) { func AddRedemption(c *gin.Context) {
@@ -65,8 +66,7 @@ func AddRedemption(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 { if !validateRedemptionPayload(c, &redemption, true) {
common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
return return
} }
if redemption.Count <= 0 { if redemption.Count <= 0 {
@@ -77,10 +77,6 @@ func AddRedemption(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax) common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
return return
} }
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return
}
var keys []string var keys []string
for i := 0; i < redemption.Count; i++ { for i := 0; i < redemption.Count; i++ {
key := common.GetUUID() key := common.GetUUID()
@@ -90,6 +86,9 @@ func AddRedemption(c *gin.Context) {
Key: key, Key: key,
CreatedTime: common.GetTimestamp(), CreatedTime: common.GetTimestamp(),
Quota: redemption.Quota, Quota: redemption.Quota,
RedeemType: redemption.RedeemType,
PlanId: redemption.PlanId,
SourceNote: redemption.SourceNote,
ExpiredTime: redemption.ExpiredTime, ExpiredTime: redemption.ExpiredTime,
} }
err = cleanRedemption.Insert() err = cleanRedemption.Insert()
@@ -109,7 +108,6 @@ func AddRedemption(c *gin.Context) {
"message": "", "message": "",
"data": keys, "data": keys,
}) })
return
} }
func DeleteRedemption(c *gin.Context) { func DeleteRedemption(c *gin.Context) {
@@ -123,7 +121,6 @@ func DeleteRedemption(c *gin.Context) {
"success": true, "success": true,
"message": "", "message": "",
}) })
return
} }
func UpdateRedemption(c *gin.Context) { func UpdateRedemption(c *gin.Context) {
@@ -140,13 +137,14 @@ func UpdateRedemption(c *gin.Context) {
return return
} }
if statusOnly == "" { if statusOnly == "" {
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid { if !validateRedemptionPayload(c, &redemption, true) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return return
} }
// If you add more fields, please also update redemption.Update()
cleanRedemption.Name = redemption.Name cleanRedemption.Name = redemption.Name
cleanRedemption.Quota = redemption.Quota cleanRedemption.Quota = redemption.Quota
cleanRedemption.RedeemType = redemption.RedeemType
cleanRedemption.PlanId = redemption.PlanId
cleanRedemption.SourceNote = redemption.SourceNote
cleanRedemption.ExpiredTime = redemption.ExpiredTime cleanRedemption.ExpiredTime = redemption.ExpiredTime
} }
if statusOnly != "" { if statusOnly != "" {
@@ -157,12 +155,12 @@ func UpdateRedemption(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
enrichRedemption(cleanRedemption)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": cleanRedemption, "data": cleanRedemption,
}) })
return
} }
func DeleteInvalidRedemption(c *gin.Context) { func DeleteInvalidRedemption(c *gin.Context) {
@@ -176,7 +174,6 @@ func DeleteInvalidRedemption(c *gin.Context) {
"message": "", "message": "",
"data": rows, "data": rows,
}) })
return
} }
func validateExpiredTime(c *gin.Context, expired int64) (bool, string) { func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
@@ -185,3 +182,78 @@ func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
} }
return true, "" return true, ""
} }
func validateRedemptionPayload(c *gin.Context, redemption *model.Redemption, requireName bool) bool {
if redemption == nil {
common.ApiErrorMsg(c, "invalid redemption payload")
return false
}
redemption.Name = strings.TrimSpace(redemption.Name)
redemption.SourceNote = strings.TrimSpace(redemption.SourceNote)
redemption.RedeemType = model.NormalizeRedemptionType(redemption.RedeemType)
if redemption.RedeemType == "" {
common.ApiErrorMsg(c, "invalid redeem type")
return false
}
if requireName && (utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20) {
common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
return false
}
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return false
}
switch redemption.RedeemType {
case model.RedemptionTypeQuota:
if redemption.Quota <= 0 {
common.ApiErrorMsg(c, "quota must be greater than 0")
return false
}
redemption.PlanId = 0
case model.RedemptionTypeSubscription:
if redemption.PlanId <= 0 {
common.ApiErrorMsg(c, "plan_id is required")
return false
}
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
if err != nil || plan == nil {
common.ApiErrorMsg(c, "subscription plan not found")
return false
}
redemption.Quota = 0
}
return true
}
func enrichRedemptions(redemptions []*model.Redemption) {
if len(redemptions) == 0 {
return
}
planTitles := make(map[int]string)
for _, redemption := range redemptions {
if redemption == nil || model.NormalizeRedemptionType(redemption.RedeemType) != model.RedemptionTypeSubscription || redemption.PlanId <= 0 {
continue
}
if _, ok := planTitles[redemption.PlanId]; ok {
redemption.PlanTitle = planTitles[redemption.PlanId]
continue
}
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
if err != nil || plan == nil {
continue
}
planTitles[redemption.PlanId] = plan.Title
redemption.PlanTitle = plan.Title
}
}
func enrichRedemption(redemption *model.Redemption) {
if redemption == nil || model.NormalizeRedemptionType(redemption.RedeemType) != model.RedemptionTypeSubscription || redemption.PlanId <= 0 {
return
}
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
if err != nil || plan == nil {
return
}
redemption.PlanTitle = plan.Title
}
+2 -2
View File
@@ -1025,7 +1025,7 @@ func TopUp(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
quota, err := model.Redeem(req.Key, id) result, err := model.Redeem(req.Key, id)
if err != nil { if err != nil {
if errors.Is(err, model.ErrRedeemFailed) { if errors.Is(err, model.ErrRedeemFailed) {
common.ApiErrorI18n(c, i18n.MsgRedeemFailed) common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
@@ -1037,7 +1037,7 @@ func TopUp(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
"data": quota, "data": result,
}) })
} }
+18
View File
@@ -284,6 +284,9 @@ func migrateDB() error {
if err != nil { if err != nil {
return err return err
} }
if err := migrateRedemptionRedeemType(); err != nil {
return err
}
if common.UsingSQLite { if common.UsingSQLite {
if err := ensureSubscriptionPlanTableSQLite(); err != nil { if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err return err
@@ -352,6 +355,9 @@ func migrateDBFast() error {
return err return err
} }
} }
if err := migrateRedemptionRedeemType(); err != nil {
return err
}
if common.UsingSQLite { if common.UsingSQLite {
if err := ensureSubscriptionPlanTableSQLite(); err != nil { if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err return err
@@ -365,6 +371,18 @@ func migrateDBFast() error {
return nil return nil
} }
func migrateRedemptionRedeemType() error {
if !DB.Migrator().HasTable(&Redemption{}) {
return nil
}
if !DB.Migrator().HasColumn(&Redemption{}, "redeem_type") {
return nil
}
return DB.Model(&Redemption{}).
Where("redeem_type = '' OR redeem_type IS NULL").
Update("redeem_type", RedemptionTypeQuota).Error
}
func migrateLOGDB() error { func migrateLOGDB() error {
var err error var err error
if err = LOG_DB.AutoMigrate(&Log{}); err != nil { if err = LOG_DB.AutoMigrate(&Log{}); err != nil {
+94 -35
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
@@ -14,6 +15,19 @@ import (
// ErrRedeemFailed is returned when redemption fails due to database error // ErrRedeemFailed is returned when redemption fails due to database error
var ErrRedeemFailed = errors.New("redeem.failed") var ErrRedeemFailed = errors.New("redeem.failed")
const (
RedemptionTypeQuota = "quota"
RedemptionTypeSubscription = "subscription"
)
type RedeemResult struct {
RedeemType string `json:"redeem_type"`
Quota int `json:"quota,omitempty"`
PlanId int `json:"plan_id,omitempty"`
PlanTitle string `json:"plan_title,omitempty"`
SubscriptionId int `json:"subscription_id,omitempty"`
}
type Redemption struct { type Redemption struct {
Id int `json:"id"` Id int `json:"id"`
UserId int `json:"user_id"` UserId int `json:"user_id"`
@@ -21,16 +35,30 @@ type Redemption struct {
Status int `json:"status" gorm:"default:1"` Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index"` Name string `json:"name" gorm:"index"`
Quota int `json:"quota" gorm:"default:100"` Quota int `json:"quota" gorm:"default:100"`
RedeemType string `json:"redeem_type" gorm:"type:varchar(32);not null;default:'quota'"`
PlanId int `json:"plan_id" gorm:"default:0;index"`
SourceNote string `json:"source_note" gorm:"type:varchar(255);default:''"`
PlanTitle string `json:"plan_title" gorm:"-:all"`
CreatedTime int64 `json:"created_time" gorm:"bigint"` CreatedTime int64 `json:"created_time" gorm:"bigint"`
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"` RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
Count int `json:"count" gorm:"-:all"` // only for api request Count int `json:"count" gorm:"-:all"` // only for api request
UsedUserId int `json:"used_user_id"` UsedUserId int `json:"used_user_id"`
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期 ExpiredTime int64 `json:"expired_time" gorm:"bigint"`
}
func NormalizeRedemptionType(raw string) string {
switch strings.TrimSpace(strings.ToLower(raw)) {
case "", RedemptionTypeQuota:
return RedemptionTypeQuota
case RedemptionTypeSubscription:
return RedemptionTypeSubscription
default:
return ""
}
} }
func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) { func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
// 开始事务
tx := DB.Begin() tx := DB.Begin()
if tx.Error != nil { if tx.Error != nil {
return nil, 0, tx.Error return nil, 0, tx.Error
@@ -41,21 +69,18 @@ func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total
} }
}() }()
// 获取总数
err = tx.Model(&Redemption{}).Count(&total).Error err = tx.Model(&Redemption{}).Count(&total).Error
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return nil, 0, err return nil, 0, err
} }
// 获取分页数据
err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return nil, 0, err return nil, 0, err
} }
// 提交事务
if err = tx.Commit().Error; err != nil { if err = tx.Commit().Error; err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -74,24 +99,20 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re
} }
}() }()
// Build query based on keyword type
query := tx.Model(&Redemption{}) query := tx.Model(&Redemption{})
// Only try to convert to ID if the string represents a valid integer
if id, err := strconv.Atoi(keyword); err == nil { if id, err := strconv.Atoi(keyword); err == nil {
query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") query = query.Where("id = ? OR name LIKE ?", id, keyword+"%")
} else { } else {
query = query.Where("name LIKE ?", keyword+"%") query = query.Where("name LIKE ?", keyword+"%")
} }
// Get total count
err = query.Count(&total).Error err = query.Count(&total).Error
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return nil, 0, err return nil, 0, err
} }
// Get paginated data
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
@@ -107,22 +128,25 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re
func GetRedemptionById(id int) (*Redemption, error) { func GetRedemptionById(id int) (*Redemption, error) {
if id == 0 { if id == 0 {
return nil, errors.New("id 为空!") return nil, errors.New("id is empty")
} }
redemption := Redemption{Id: id} redemption := Redemption{Id: id}
var err error = nil err := DB.First(&redemption, "id = ?", id).Error
err = DB.First(&redemption, "id = ?", id).Error
return &redemption, err return &redemption, err
} }
func Redeem(key string, userId int) (quota int, err error) { func Redeem(key string, userId int) (result *RedeemResult, err error) {
if key == "" { if key == "" {
return 0, errors.New("未提供兑换码") return nil, errors.New("redemption code is required")
} }
if userId == 0 { if userId == 0 {
return 0, errors.New("无效的 user id") return nil, errors.New("invalid user id")
} }
redemption := &Redemption{} redemption := &Redemption{}
result = &RedeemResult{}
logMessage := ""
upgradeGroup := ""
keyCol := "`key`" keyCol := "`key`"
if common.UsingPostgreSQL { if common.UsingPostgreSQL {
@@ -132,18 +156,50 @@ func Redeem(key string, userId int) (quota int, err error) {
err = DB.Transaction(func(tx *gorm.DB) error { err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error
if err != nil { if err != nil {
return errors.New("无效的兑换码") return errors.New("invalid redemption code")
} }
if redemption.Status != common.RedemptionCodeStatusEnabled { if redemption.Status != common.RedemptionCodeStatusEnabled {
return errors.New("该兑换码已被使用") return errors.New("redemption code is unavailable")
} }
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() { if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
return errors.New("该兑换码已过期") return errors.New("redemption code is expired")
} }
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
if err != nil { redeemType := NormalizeRedemptionType(redemption.RedeemType)
return err if redeemType == "" {
return errors.New("invalid redemption type")
} }
switch redeemType {
case RedemptionTypeQuota:
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
if err != nil {
return err
}
result.RedeemType = RedemptionTypeQuota
result.Quota = redemption.Quota
logMessage = fmt.Sprintf("Redeemed quota %s via redemption code ID %d", logger.LogQuota(redemption.Quota), redemption.Id)
case RedemptionTypeSubscription:
if redemption.PlanId <= 0 {
return errors.New("invalid subscription plan id")
}
plan, err := getSubscriptionPlanByIdTx(tx, redemption.PlanId)
if err != nil {
return err
}
sub, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "redeem")
if err != nil {
return err
}
result.RedeemType = RedemptionTypeSubscription
result.PlanId = plan.Id
result.PlanTitle = plan.Title
result.SubscriptionId = sub.Id
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
logMessage = fmt.Sprintf("Redeemed subscription plan %s via redemption code ID %d", plan.Title, redemption.Id)
}
redemption.RedeemType = redeemType
redemption.RedeemedTime = common.GetTimestamp() redemption.RedeemedTime = common.GetTimestamp()
redemption.Status = common.RedemptionCodeStatusUsed redemption.Status = common.RedemptionCodeStatusUsed
redemption.UsedUserId = userId redemption.UsedUserId = userId
@@ -152,39 +208,42 @@ func Redeem(key string, userId int) (quota int, err error) {
}) })
if err != nil { if err != nil {
common.SysError("redemption failed: " + err.Error()) common.SysError("redemption failed: " + err.Error())
return 0, ErrRedeemFailed return nil, ErrRedeemFailed
} }
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
return redemption.Quota, nil if upgradeGroup != "" {
_ = UpdateUserGroupCache(userId, upgradeGroup)
}
if logMessage != "" {
RecordLog(userId, LogTypeTopup, logMessage)
}
return result, nil
} }
func (redemption *Redemption) Insert() error { func (redemption *Redemption) Insert() error {
var err error redemption.RedeemType = NormalizeRedemptionType(redemption.RedeemType)
err = DB.Create(redemption).Error if redemption.RedeemType == "" {
return err redemption.RedeemType = RedemptionTypeQuota
}
return DB.Create(redemption).Error
} }
func (redemption *Redemption) SelectUpdate() error { func (redemption *Redemption) SelectUpdate() error {
// This can update zero values
return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error
} }
// Update Make sure your token's fields is completed, because this will update non-zero values // Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error { func (redemption *Redemption) Update() error {
var err error return DB.Model(redemption).Select("name", "status", "quota", "redeem_type", "plan_id", "source_note", "redeemed_time", "expired_time").Updates(redemption).Error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
return err
} }
func (redemption *Redemption) Delete() error { func (redemption *Redemption) Delete() error {
var err error return DB.Delete(redemption).Error
err = DB.Delete(redemption).Error
return err
} }
func DeleteRedemptionById(id int) (err error) { func DeleteRedemptionById(id int) (err error) {
if id == 0 { if id == 0 {
return errors.New("id 为空!") return errors.New("id is empty")
} }
redemption := Redemption{Id: id} redemption := Redemption{Id: id}
err = DB.Where(redemption).First(&redemption).Error err = DB.Where(redemption).First(&redemption).Error
@@ -25,11 +25,10 @@ import {
REDEMPTION_STATUS, REDEMPTION_STATUS,
REDEMPTION_STATUS_MAP, REDEMPTION_STATUS_MAP,
REDEMPTION_ACTIONS, REDEMPTION_ACTIONS,
REDEMPTION_REDEEM_TYPE,
REDEMPTION_REDEEM_TYPE_MAP,
} from '../../../constants/redemption.constants'; } from '../../../constants/redemption.constants';
/**
* Check if redemption code is expired
*/
export const isExpired = (record) => { export const isExpired = (record) => {
return ( return (
record.status === REDEMPTION_STATUS.UNUSED && record.status === REDEMPTION_STATUS.UNUSED &&
@@ -38,21 +37,15 @@ export const isExpired = (record) => {
); );
}; };
/**
* Render timestamp
*/
const renderTimestamp = (timestamp) => { const renderTimestamp = (timestamp) => {
return <>{timestamp2string(timestamp)}</>; return <>{timestamp2string(timestamp)}</>;
}; };
/**
* Render redemption code status
*/
const renderStatus = (status, record, t) => { const renderStatus = (status, record, t) => {
if (isExpired(record)) { if (isExpired(record)) {
return ( return (
<Tag color='orange' shape='circle'> <Tag color='orange' shape='circle'>
{t('已过期')} {t('Expired')}
</Tag> </Tag>
); );
} }
@@ -68,23 +61,50 @@ const renderStatus = (status, record, t) => {
return ( return (
<Tag color='black' shape='circle'> <Tag color='black' shape='circle'>
{t('未知状态')} {t('Unknown')}
</Tag>
);
};
const renderRedeemType = (redeemType, t) => {
const normalizedType =
redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
? REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
: REDEMPTION_REDEEM_TYPE.QUOTA;
const typeConfig = REDEMPTION_REDEEM_TYPE_MAP[normalizedType];
return (
<Tag color={typeConfig.color} shape='circle'>
{t(typeConfig.text)}
</Tag>
);
};
const renderRedeemTarget = (record, t) => {
const redeemType =
record.redeem_type === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
? REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
: REDEMPTION_REDEEM_TYPE.QUOTA;
if (redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION) {
const label = record.plan_title || `ID ${record.plan_id || '-'}`;
return (
<Tag color='light-green' shape='circle'>
{`${t('Plan')}: ${label}`}
</Tag>
);
}
return (
<Tag color='grey' shape='circle'>
{renderQuota(parseInt(record.quota))}
</Tag> </Tag>
); );
}; };
/**
* Get redemption code table column definitions
*/
export const getRedemptionsColumns = ({ export const getRedemptionsColumns = ({
t, t,
manageRedemption, manageRedemption,
copyText, copyText,
setEditingRedemption, setEditingRedemption,
setShowEdit, setShowEdit,
refresh,
redemptions,
activePage,
showDeleteRedemptionModal, showDeleteRedemptionModal,
}) => { }) => {
return [ return [
@@ -93,11 +113,16 @@ export const getRedemptionsColumns = ({
dataIndex: 'id', dataIndex: 'id',
}, },
{ {
title: t('名称'), title: t('Name'),
dataIndex: 'name', dataIndex: 'name',
}, },
{ {
title: t('状态'), title: t('Type'),
dataIndex: 'redeem_type',
render: (text) => <div>{renderRedeemType(text, t)}</div>,
},
{
title: t('Status'),
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
render: (text, record) => { render: (text, record) => {
@@ -105,37 +130,31 @@ export const getRedemptionsColumns = ({
}, },
}, },
{ {
title: t('额度'), title: t('Redeem target'),
dataIndex: 'quota', dataIndex: 'quota',
render: (text) => { render: (_, record) => {
return ( return <div>{renderRedeemTarget(record, t)}</div>;
<div>
<Tag color='grey' shape='circle'>
{renderQuota(parseInt(text))}
</Tag>
</div>
);
}, },
}, },
{ {
title: t('创建时间'), title: t('Created at'),
dataIndex: 'created_time', dataIndex: 'created_time',
render: (text) => { render: (text) => {
return <div>{renderTimestamp(text)}</div>; return <div>{renderTimestamp(text)}</div>;
}, },
}, },
{ {
title: t('过期时间'), title: t('Expires at'),
dataIndex: 'expired_time', dataIndex: 'expired_time',
render: (text) => { render: (text) => {
return <div>{text === 0 ? t('永不过期') : renderTimestamp(text)}</div>; return <div>{text === 0 ? t('Never') : renderTimestamp(text)}</div>;
}, },
}, },
{ {
title: t('兑换人ID'), title: t('Redeemed by'),
dataIndex: 'used_user_id', dataIndex: 'used_user_id',
render: (text) => { render: (text) => {
return <div>{text === 0 ? t('') : text}</div>; return <div>{text === 0 ? t('N/A') : text}</div>;
}, },
}, },
{ {
@@ -144,11 +163,10 @@ export const getRedemptionsColumns = ({
fixed: 'right', fixed: 'right',
width: 205, width: 205,
render: (text, record) => { render: (text, record) => {
// Create dropdown menu items for more operations
const moreMenuItems = [ const moreMenuItems = [
{ {
node: 'item', node: 'item',
name: t('删除'), name: t('Delete'),
type: 'danger', type: 'danger',
onClick: () => { onClick: () => {
showDeleteRedemptionModal(record); showDeleteRedemptionModal(record);
@@ -159,7 +177,7 @@ export const getRedemptionsColumns = ({
if (record.status === REDEMPTION_STATUS.UNUSED && !isExpired(record)) { if (record.status === REDEMPTION_STATUS.UNUSED && !isExpired(record)) {
moreMenuItems.push({ moreMenuItems.push({
node: 'item', node: 'item',
name: t('禁用'), name: t('Disable'),
type: 'warning', type: 'warning',
onClick: () => { onClick: () => {
manageRedemption(record.id, REDEMPTION_ACTIONS.DISABLE, record); manageRedemption(record.id, REDEMPTION_ACTIONS.DISABLE, record);
@@ -168,7 +186,7 @@ export const getRedemptionsColumns = ({
} else if (!isExpired(record)) { } else if (!isExpired(record)) {
moreMenuItems.push({ moreMenuItems.push({
node: 'item', node: 'item',
name: t('启用'), name: t('Enable'),
type: 'secondary', type: 'secondary',
onClick: () => { onClick: () => {
manageRedemption(record.id, REDEMPTION_ACTIONS.ENABLE, record); manageRedemption(record.id, REDEMPTION_ACTIONS.ENABLE, record);
@@ -185,7 +203,7 @@ export const getRedemptionsColumns = ({
position='top' position='top'
> >
<Button type='tertiary' size='small'> <Button type='tertiary' size='small'>
{t('查看')} {t('View')}
</Button> </Button>
</Popover> </Popover>
<Button <Button
@@ -194,7 +212,7 @@ export const getRedemptionsColumns = ({
await copyText(record.key); await copyText(record.key);
}} }}
> >
{t('复制')} {t('Copy')}
</Button> </Button>
<Button <Button
type='tertiary' type='tertiary'
@@ -205,7 +223,7 @@ export const getRedemptionsColumns = ({
}} }}
disabled={record.status !== REDEMPTION_STATUS.UNUSED} disabled={record.status !== REDEMPTION_STATUS.UNUSED}
> >
{t('编辑')} {t('Edit')}
</Button> </Button>
<Dropdown <Dropdown
trigger='click' trigger='click'
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
API, API,
@@ -41,6 +41,7 @@ import {
Avatar, Avatar,
Row, Row,
Col, Col,
Select,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
IconCreditCard, IconCreditCard,
@@ -48,6 +49,7 @@ import {
IconClose, IconClose,
IconGift, IconGift,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { REDEMPTION_REDEEM_TYPE } from '../../../../constants/redemption.constants';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -55,6 +57,8 @@ const EditRedemptionModal = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isEdit = props.editingRedemption.id !== undefined; const isEdit = props.editingRedemption.id !== undefined;
const [loading, setLoading] = useState(isEdit); const [loading, setLoading] = useState(isEdit);
const [plansLoading, setPlansLoading] = useState(false);
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const formApiRef = useRef(null); const formApiRef = useRef(null);
@@ -63,48 +67,92 @@ const EditRedemptionModal = (props) => {
quota: 100000, quota: 100000,
count: 1, count: 1,
expired_time: null, expired_time: null,
redeem_type: REDEMPTION_REDEEM_TYPE.QUOTA,
plan_id: undefined,
}); });
const handleCancel = () => { const handleCancel = () => {
props.handleClose(); props.handleClose();
}; };
const loadSubscriptionPlans = async () => {
setPlansLoading(true);
try {
const res = await API.get('/api/subscription/admin/plans');
if (res.data?.success) {
const plans = (res.data?.data || [])
.map((item) => item?.plan)
.filter(Boolean);
setSubscriptionPlans(plans);
} else {
showError(res.data?.message || t('Failed to load subscription plans'));
}
} catch (error) {
showError(error.message);
}
setPlansLoading(false);
};
const loadRedemption = async () => { const loadRedemption = async () => {
setLoading(true); setLoading(true);
let res = await API.get(`/api/redemption/${props.editingRedemption.id}`); const res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
if (data.expired_time === 0) { const nextValues = {
data.expired_time = null; ...getInitValues(),
...data,
};
if (nextValues.expired_time === 0) {
nextValues.expired_time = null;
} else { } else {
data.expired_time = new Date(data.expired_time * 1000); nextValues.expired_time = new Date(nextValues.expired_time * 1000);
} }
formApiRef.current?.setValues({ ...getInitValues(), ...data }); if (!nextValues.plan_id) {
nextValues.plan_id = undefined;
}
nextValues.redeem_type =
nextValues.redeem_type || REDEMPTION_REDEEM_TYPE.QUOTA;
formApiRef.current?.setValues(nextValues);
} else { } else {
showError(message); showError(message);
} }
setLoading(false); setLoading(false);
}; };
useEffect(() => {
if (props.visiable) {
loadSubscriptionPlans();
}
}, [props.visiable]);
useEffect(() => { useEffect(() => {
if (formApiRef.current) { if (formApiRef.current) {
if (isEdit) { if (isEdit) {
loadRedemption(); loadRedemption();
} else { } else {
formApiRef.current.setValues(getInitValues()); formApiRef.current.setValues(getInitValues());
setLoading(false);
} }
} }
}, [props.editingRedemption.id]); }, [props.editingRedemption.id]);
const submit = async (values) => { const submit = async (values) => {
let name = values.name; let name = values.name;
if (!isEdit && (!name || name === '')) { if (!name || name === '') {
name = renderQuota(values.quota); if (values.redeem_type === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION) {
const selectedPlan = subscriptionPlans.find(
(plan) => plan.id === Number(values.plan_id),
);
name = selectedPlan?.title || t('Subscription redemption');
} else {
name = renderQuota(values.quota);
}
} }
setLoading(true); setLoading(true);
let localInputs = { ...values }; const localInputs = { ...values };
localInputs.count = parseInt(localInputs.count) || 0; localInputs.count = parseInt(localInputs.count, 10) || 0;
localInputs.quota = parseInt(localInputs.quota) || 0; localInputs.quota = parseInt(localInputs.quota, 10) || 0;
localInputs.plan_id = parseInt(localInputs.plan_id, 10) || 0;
localInputs.name = name; localInputs.name = name;
if (!localInputs.expired_time) { if (!localInputs.expired_time) {
localInputs.expired_time = 0; localInputs.expired_time = 0;
@@ -113,11 +161,16 @@ const EditRedemptionModal = (props) => {
localInputs.expired_time.getTime() / 1000, localInputs.expired_time.getTime() / 1000,
); );
} }
if (localInputs.redeem_type === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION) {
localInputs.quota = 0;
} else {
localInputs.plan_id = 0;
}
let res; let res;
if (isEdit) { if (isEdit) {
res = await API.put(`/api/redemption/`, { res = await API.put(`/api/redemption/`, {
...localInputs, ...localInputs,
id: parseInt(props.editingRedemption.id), id: parseInt(props.editingRedemption.id, 10),
}); });
} else { } else {
res = await API.post(`/api/redemption/`, { res = await API.post(`/api/redemption/`, {
@@ -127,11 +180,11 @@ const EditRedemptionModal = (props) => {
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
if (isEdit) { if (isEdit) {
showSuccess(t('兑换码更新成功!')); showSuccess(t('Redemption updated'));
props.refresh(); props.refresh();
props.handleClose(); props.handleClose();
} else { } else {
showSuccess(t('兑换码创建成功!')); showSuccess(t('Redemption created'));
props.refresh(); props.refresh();
formApiRef.current?.setValues(getInitValues()); formApiRef.current?.setValues(getInitValues());
props.handleClose(); props.handleClose();
@@ -145,11 +198,11 @@ const EditRedemptionModal = (props) => {
text += data[i] + '\n'; text += data[i] + '\n';
} }
Modal.confirm({ Modal.confirm({
title: t('兑换码创建成功'), title: t('Redemption created'),
content: ( content: (
<div> <div>
<p>{t('兑换码创建成功,是否下载兑换码?')}</p> <p>{t('Download the generated redemption codes?')}</p>
<p>{t('兑换码将以文本文件的形式下载,文件名为兑换码的名称。')}</p> <p>{t('Codes will be downloaded as a text file named after this redemption.')}</p>
</div> </div>
), ),
onOk: () => { onOk: () => {
@@ -168,15 +221,15 @@ const EditRedemptionModal = (props) => {
<Space> <Space>
{isEdit ? ( {isEdit ? (
<Tag color='blue' shape='circle'> <Tag color='blue' shape='circle'>
{t('更新')} {t('Edit')}
</Tag> </Tag>
) : ( ) : (
<Tag color='green' shape='circle'> <Tag color='green' shape='circle'>
{t('新建')} {t('New')}
</Tag> </Tag>
)} )}
<Title heading={4} className='m-0'> <Title heading={4} className='m-0'>
{isEdit ? t('更新兑换码信息') : t('创建新的兑换码')} {isEdit ? t('Update redemption') : t('Create redemption')}
</Title> </Title>
</Space> </Space>
} }
@@ -192,7 +245,7 @@ const EditRedemptionModal = (props) => {
icon={<IconSave />} icon={<IconSave />}
loading={loading} loading={loading}
> >
{t('提交')} {t('Submit')}
</Button> </Button>
<Button <Button
theme='light' theme='light'
@@ -200,7 +253,7 @@ const EditRedemptionModal = (props) => {
onClick={handleCancel} onClick={handleCancel}
icon={<IconClose />} icon={<IconClose />}
> >
{t('取消')} {t('Cancel')}
</Button> </Button>
</Space> </Space>
</div> </div>
@@ -214,135 +267,188 @@ const EditRedemptionModal = (props) => {
getFormApi={(api) => (formApiRef.current = api)} getFormApi={(api) => (formApiRef.current = api)}
onSubmit={submit} onSubmit={submit}
> >
{({ values }) => ( {({ values }) => {
<div className='p-2'> const redeemType =
<Card className='!rounded-2xl shadow-sm border-0 mb-6'> values.redeem_type || REDEMPTION_REDEEM_TYPE.QUOTA;
{/* Header: Basic Info */} return (
<div className='flex items-center mb-2'> <div className='p-2'>
<Avatar <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
size='small' <div className='flex items-center mb-2'>
color='blue' <Avatar
className='mr-2 shadow-md' size='small'
> color='blue'
<IconGift size={16} /> className='mr-2 shadow-md'
</Avatar> >
<div> <IconGift size={16} />
<Text className='text-lg font-medium'> </Avatar>
{t('基本信息')} <div>
</Text> <Text className='text-lg font-medium'>
<div className='text-xs text-gray-600'> {t('Basic info')}
{t('设置兑换码的基本信息')} </Text>
<div className='text-xs text-gray-600'>
{t('Set the redemption name, type, and expiration')}
</div>
</div> </div>
</div> </div>
</div>
<Row gutter={12}> <Row gutter={12}>
<Col span={24}> <Col span={24}>
<Form.Input <Form.Input
field='name' field='name'
label={t('名称')} label={t('Name')}
placeholder={t('请输入名称')} placeholder={t('Enter a name')}
style={{ width: '100%' }} style={{ width: '100%' }}
rules={ rules={
!isEdit !isEdit
? [] ? []
: [{ required: true, message: t('请输入名称') }] : [{ required: true, message: t('Please enter a name') }]
} }
showClear showClear
/> />
</Col> </Col>
<Col span={24}>
<Form.DatePicker
field='expired_time'
label={t('过期时间')}
type='dateTime'
placeholder={t('选择过期时间(可选,留空为永久)')}
style={{ width: '100%' }}
showClear
/>
</Col>
</Row>
</Card>
<Card className='!rounded-2xl shadow-sm border-0'>
{/* Header: Quota Settings */}
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='green'
className='mr-2 shadow-md'
>
<IconCreditCard size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('额度设置')}
</Text>
<div className='text-xs text-gray-600'>
{t('设置兑换码的额度和数量')}
</div>
</div>
</div>
<Row gutter={12}>
<Col span={12}>
<Form.AutoComplete
field='quota'
label={t('额度')}
placeholder={t('请输入额度')}
style={{ width: '100%' }}
type='number'
rules={[
{ required: true, message: t('请输入额度') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('额度必须大于0'));
},
},
]}
extraText={renderQuotaWithPrompt(
Number(values.quota) || 0,
)}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
showClear
/>
</Col>
{!isEdit && (
<Col span={12}> <Col span={12}>
<Form.InputNumber <Form.Select
field='count' field='redeem_type'
label={t('生成数量')} label={t('Redeem type')}
min={1} style={{ width: '100%' }}
rules={[ >
{ required: true, message: t('请输入生成数量') }, <Select.Option
{ value={REDEMPTION_REDEEM_TYPE.QUOTA}
validator: (rule, v) => { >
const num = parseInt(v, 10); {t('Quota')}
return num > 0 </Select.Option>
? Promise.resolve() <Select.Option
: Promise.reject(t('生成数量必须大于0')); value={REDEMPTION_REDEEM_TYPE.SUBSCRIPTION}
}, >
}, {t('Subscription')}
]} </Select.Option>
</Form.Select>
</Col>
<Col span={12}>
<Form.DatePicker
field='expired_time'
label={t('Expires at')}
type='dateTime'
placeholder={t('Leave empty for no expiration')}
style={{ width: '100%' }} style={{ width: '100%' }}
showClear showClear
/> />
</Col> </Col>
)} {redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION && (
</Row> <Col span={24}>
</Card> <Form.Select
</div> field='plan_id'
)} label={t('Subscription plan')}
loading={plansLoading}
placeholder={t('Select a subscription plan')}
style={{ width: '100%' }}
rules={[
{
required: true,
message: t('Please select a subscription plan'),
},
]}
>
{(subscriptionPlans || []).map((plan) => (
<Select.Option key={plan.id} value={plan.id}>
{plan.title}
</Select.Option>
))}
</Form.Select>
</Col>
)}
</Row>
</Card>
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-2'>
<Avatar
size='small'
color='green'
className='mr-2 shadow-md'
>
<IconCreditCard size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>
{t('Redeem settings')}
</Text>
<div className='text-xs text-gray-600'>
{redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
? t('Choose a plan and generation count')
: t('Set quota amount and generation count')}
</div>
</div>
</div>
<Row gutter={12}>
{redeemType === REDEMPTION_REDEEM_TYPE.QUOTA && (
<Col span={!isEdit ? 12 : 24}>
<Form.AutoComplete
field='quota'
label={t('Quota')}
placeholder={t('Enter quota')}
style={{ width: '100%' }}
type='number'
rules={[
{ required: true, message: t('Please enter quota') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('Quota must be greater than 0'));
},
},
]}
extraText={renderQuotaWithPrompt(
Number(values.quota) || 0,
)}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
showClear
/>
</Col>
)}
{!isEdit && (
<Col
span={
redeemType === REDEMPTION_REDEEM_TYPE.SUBSCRIPTION
? 24
: 12
}
>
<Form.InputNumber
field='count'
label={t('Count')}
min={1}
rules={[
{ required: true, message: t('Please enter count') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('Count must be greater than 0'));
},
},
]}
style={{ width: '100%' }}
showClear
/>
</Col>
)}
</Row>
</Card>
</div>
);
}}
</Form> </Form>
</Spin> </Spin>
</SideSheet> </SideSheet>
+91 -1
View File
@@ -112,7 +112,7 @@ const TopUp = () => {
discount: {}, discount: {},
}); });
const topUp = async () => { const legacyTopUp = async () => {
if (redemptionCode === '') { if (redemptionCode === '') {
showInfo(t('请输入兑换码!')); showInfo(t('请输入兑换码!'));
return; return;
@@ -148,6 +148,96 @@ const TopUp = () => {
} }
}; };
const topUpLegacy2 = async () => {
if (redemptionCode === '') {
showInfo(t('请输入兑换码!'));
return;
}
setIsSubmitting(true);
try {
const res = await API.post('/api/user/topup', {
key: redemptionCode,
});
const { success, message, data } = res.data;
if (success) {
showSuccess(t('兑换成功!'));
if (data?.redeem_type === 'subscription') {
Modal.success({
title: t('兑换成功'),
content: `${t('成功兑换订阅套餐:')}${data.plan_title || data.plan_id}`,
centered: true,
});
await getSubscriptionSelf();
} else {
Modal.success({
title: t('兑换成功'),
content: `${t('成功兑换额度:')}${renderQuota(data?.quota || 0)}`,
centered: true,
});
if (userState.user) {
const updatedUser = {
...userState.user,
quota: userState.user.quota + (data?.quota || 0),
};
userDispatch({ type: 'login', payload: updatedUser });
}
}
setRedemptionCode('');
} else {
showError(message);
}
} catch (err) {
showError(t('请求失败'));
} finally {
setIsSubmitting(false);
}
};
const topUp = async () => {
if (redemptionCode === '') {
showInfo(t('Please enter a redemption code'));
return;
}
setIsSubmitting(true);
try {
const res = await API.post('/api/user/topup', {
key: redemptionCode,
});
const { success, message, data } = res.data;
if (success) {
showSuccess(t('Redemption successful'));
if (data?.redeem_type === 'subscription') {
Modal.success({
title: t('Redemption successful'),
content: `${t('Subscription plan redeemed: ')}${data.plan_title || data.plan_id}`,
centered: true,
});
await getSubscriptionSelf();
} else {
Modal.success({
title: t('Redemption successful'),
content: `${t('Quota redeemed: ')}${renderQuota(data?.quota || 0)}`,
centered: true,
});
if (userState.user) {
const updatedUser = {
...userState.user,
quota: userState.user.quota + (data?.quota || 0),
};
userDispatch({ type: 'login', payload: updatedUser });
}
}
setRedemptionCode('');
} else {
showError(message);
}
} catch (err) {
showError(t('Request failed'));
} finally {
setIsSubmitting(false);
}
};
const openTopUpLink = () => { const openTopUpLink = () => {
if (!topUpLink) { if (!topUpLink) {
showError(t('超级管理员未设置充值链接!')); showError(t('超级管理员未设置充值链接!'));
+16
View File
@@ -23,6 +23,22 @@ export const REDEMPTION_STATUS = {
USED: 3, // Used USED: 3, // Used
}; };
export const REDEMPTION_REDEEM_TYPE = {
QUOTA: 'quota',
SUBSCRIPTION: 'subscription',
};
export const REDEMPTION_REDEEM_TYPE_MAP = {
[REDEMPTION_REDEEM_TYPE.QUOTA]: {
color: 'blue',
text: 'Quota',
},
[REDEMPTION_REDEEM_TYPE.SUBSCRIPTION]: {
color: 'green',
text: 'Subscription',
},
};
// Redemption code status display mapping // Redemption code status display mapping
export const REDEMPTION_STATUS_MAP = { export const REDEMPTION_STATUS_MAP = {
[REDEMPTION_STATUS.UNUSED]: { [REDEMPTION_STATUS.UNUSED]: {