feat: add subscription redemption support to redemption codes
Redemption codes can now redeem either quota (existing) or a subscription plan (new). The Redemption model gains Type (1=quota, 2=subscription) and PlanId fields. When Type=2, Redeem() calls CreateUserSubscriptionFromPlanTx to activate the plan, reusing all existing subscription logic (purchase limits, group upgrade, quota reset, expiry). Old codes default to Type=0 which behaves identically to Type=1 (quota).
This commit is contained in:
parent
e6bced5dc2
commit
0a0616a370
@ -248,6 +248,11 @@ const (
|
|||||||
RedemptionCodeStatusUsed = 3 // also don't use 0
|
RedemptionCodeStatusUsed = 3 // also don't use 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RedemptionTypeQuota = 1 // 兑换额度
|
||||||
|
RedemptionTypeSubscription = 2 // 兑换订阅
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ChannelStatusUnknown = 0
|
ChannelStatusUnknown = 0
|
||||||
ChannelStatusEnabled = 1 // don't use 0, 0 is the default value!
|
ChannelStatusEnabled = 1 // don't use 0, 0 is the default value!
|
||||||
|
|||||||
@ -83,6 +83,22 @@ func AddRedemption(c *gin.Context) {
|
|||||||
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
|
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 校验兑换类型
|
||||||
|
if redemption.Type != 0 && redemption.Type != common.RedemptionTypeQuota && redemption.Type != common.RedemptionTypeSubscription {
|
||||||
|
common.ApiErrorI18n(c, i18n.MsgRedemptionTypeInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if redemption.Type == common.RedemptionTypeSubscription {
|
||||||
|
if redemption.PlanId <= 0 {
|
||||||
|
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
|
||||||
|
if err != nil || plan == nil {
|
||||||
|
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
|
||||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||||
return
|
return
|
||||||
@ -94,8 +110,10 @@ func AddRedemption(c *gin.Context) {
|
|||||||
UserId: c.GetInt("id"),
|
UserId: c.GetInt("id"),
|
||||||
Name: redemption.Name,
|
Name: redemption.Name,
|
||||||
Key: key,
|
Key: key,
|
||||||
CreatedTime: common.GetTimestamp(),
|
Type: redemption.Type,
|
||||||
Quota: redemption.Quota,
|
Quota: redemption.Quota,
|
||||||
|
PlanId: redemption.PlanId,
|
||||||
|
CreatedTime: common.GetTimestamp(),
|
||||||
ExpiredTime: redemption.ExpiredTime,
|
ExpiredTime: redemption.ExpiredTime,
|
||||||
}
|
}
|
||||||
err = cleanRedemption.Insert()
|
err = cleanRedemption.Insert()
|
||||||
@ -150,10 +168,28 @@ func UpdateRedemption(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 校验兑换类型
|
||||||
|
if redemption.Type != 0 && redemption.Type != common.RedemptionTypeQuota && redemption.Type != common.RedemptionTypeSubscription {
|
||||||
|
common.ApiErrorI18n(c, i18n.MsgRedemptionTypeInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if redemption.Type == common.RedemptionTypeSubscription {
|
||||||
|
if redemption.PlanId <= 0 {
|
||||||
|
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
|
||||||
|
if err != nil || plan == nil {
|
||||||
|
common.ApiErrorI18n(c, i18n.MsgRedemptionPlanNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// If you add more fields, please also update redemption.Update()
|
// 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.ExpiredTime = redemption.ExpiredTime
|
cleanRedemption.ExpiredTime = redemption.ExpiredTime
|
||||||
|
cleanRedemption.Type = redemption.Type
|
||||||
|
cleanRedemption.PlanId = redemption.PlanId
|
||||||
}
|
}
|
||||||
if statusOnly != "" {
|
if statusOnly != "" {
|
||||||
cleanRedemption.Status = redemption.Status
|
cleanRedemption.Status = redemption.Status
|
||||||
|
|||||||
@ -1104,7 +1104,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)
|
||||||
@ -1116,7 +1116,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,6 +69,9 @@ const (
|
|||||||
MsgRedemptionFailed = "redemption.failed"
|
MsgRedemptionFailed = "redemption.failed"
|
||||||
MsgRedemptionNotProvided = "redemption.not_provided"
|
MsgRedemptionNotProvided = "redemption.not_provided"
|
||||||
MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid"
|
MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid"
|
||||||
|
MsgRedemptionTypeInvalid = "redemption.type_invalid"
|
||||||
|
MsgRedemptionPlanRequired = "redemption.plan_required"
|
||||||
|
MsgRedemptionPlanNotFound = "redemption.plan_not_found"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User related messages
|
// User related messages
|
||||||
|
|||||||
@ -59,6 +59,9 @@ redemption.expired: "This redemption code has expired"
|
|||||||
redemption.failed: "Redemption failed, please try again later"
|
redemption.failed: "Redemption failed, please try again later"
|
||||||
redemption.not_provided: "Redemption code not provided"
|
redemption.not_provided: "Redemption code not provided"
|
||||||
redemption.expire_time_invalid: "Expiration time cannot be earlier than current time"
|
redemption.expire_time_invalid: "Expiration time cannot be earlier than current time"
|
||||||
|
redemption.type_invalid: "Invalid redemption code type"
|
||||||
|
redemption.plan_required: "Subscription redemption code must specify a plan"
|
||||||
|
redemption.plan_not_found: "The subscription plan associated with this redemption code does not exist"
|
||||||
|
|
||||||
# User messages
|
# User messages
|
||||||
user.password_login_disabled: "Password login has been disabled by administrator"
|
user.password_login_disabled: "Password login has been disabled by administrator"
|
||||||
|
|||||||
@ -60,6 +60,9 @@ redemption.expired: "该兑换码已过期"
|
|||||||
redemption.failed: "兑换失败,请稍后重试"
|
redemption.failed: "兑换失败,请稍后重试"
|
||||||
redemption.not_provided: "未提供兑换码"
|
redemption.not_provided: "未提供兑换码"
|
||||||
redemption.expire_time_invalid: "过期时间不能早于当前时间"
|
redemption.expire_time_invalid: "过期时间不能早于当前时间"
|
||||||
|
redemption.type_invalid: "无效的兑换码类型"
|
||||||
|
redemption.plan_required: "订阅类型兑换码必须指定套餐"
|
||||||
|
redemption.plan_not_found: "兑换码关联的订阅套餐不存在"
|
||||||
|
|
||||||
# User messages
|
# User messages
|
||||||
user.password_login_disabled: "管理员关闭了密码登录"
|
user.password_login_disabled: "管理员关闭了密码登录"
|
||||||
|
|||||||
@ -60,6 +60,9 @@ redemption.expired: "該兌換碼已過期"
|
|||||||
redemption.failed: "兌換失敗,請稍後重試"
|
redemption.failed: "兌換失敗,請稍後重試"
|
||||||
redemption.not_provided: "未提供兌換碼"
|
redemption.not_provided: "未提供兌換碼"
|
||||||
redemption.expire_time_invalid: "過期時間不能早於當前時間"
|
redemption.expire_time_invalid: "過期時間不能早於當前時間"
|
||||||
|
redemption.type_invalid: "無效的兌換碼類型"
|
||||||
|
redemption.plan_required: "訂閱類型兌換碼必須指定套餐"
|
||||||
|
redemption.plan_not_found: "兌換碼關聯的訂閱套餐不存在"
|
||||||
|
|
||||||
# User messages
|
# User messages
|
||||||
user.password_login_disabled: "管理員關閉了密碼登錄"
|
user.password_login_disabled: "管理員關閉了密碼登錄"
|
||||||
|
|||||||
@ -17,7 +17,9 @@ type Redemption struct {
|
|||||||
Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
|
Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
|
||||||
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"`
|
||||||
|
Type int `json:"type" gorm:"default:1"` // 1=额度 2=订阅
|
||||||
Quota int `json:"quota" gorm:"default:100"`
|
Quota int `json:"quota" gorm:"default:100"`
|
||||||
|
PlanId int `json:"plan_id" gorm:"default:0"` // Type=2 时关联的订阅套餐
|
||||||
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
|
||||||
@ -112,12 +114,19 @@ func GetRedemptionById(id int) (*Redemption, error) {
|
|||||||
return &redemption, err
|
return &redemption, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func Redeem(key string, userId int) (quota int, err error) {
|
// RedeemResult 兑换结果
|
||||||
|
type RedeemResult struct {
|
||||||
|
Type int // 1=额度 2=订阅
|
||||||
|
Quota int // Type=1 时返回兑换的额度
|
||||||
|
PlanId int // Type=2 时返回套餐ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func Redeem(key string, userId int) (*RedeemResult, error) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return 0, errors.New("未提供兑换码")
|
return nil, errors.New("未提供兑换码")
|
||||||
}
|
}
|
||||||
if userId == 0 {
|
if userId == 0 {
|
||||||
return 0, errors.New("无效的 user id")
|
return nil, errors.New("无效的 user id")
|
||||||
}
|
}
|
||||||
redemption := &Redemption{}
|
redemption := &Redemption{}
|
||||||
|
|
||||||
@ -126,7 +135,7 @@ func Redeem(key string, userId int) (quota int, err error) {
|
|||||||
keyCol = `"key"`
|
keyCol = `"key"`
|
||||||
}
|
}
|
||||||
common.RandomSleep()
|
common.RandomSleep()
|
||||||
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("无效的兑换码")
|
||||||
@ -137,10 +146,29 @@ func Redeem(key string, userId int) (quota int, err error) {
|
|||||||
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
|
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
|
||||||
return errors.New("该兑换码已过期")
|
return errors.New("该兑换码已过期")
|
||||||
}
|
}
|
||||||
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
|
|
||||||
if err != nil {
|
switch redemption.Type {
|
||||||
return err
|
case common.RedemptionTypeQuota, 0:
|
||||||
|
err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case common.RedemptionTypeSubscription:
|
||||||
|
plan, planErr := GetSubscriptionPlanById(redemption.PlanId)
|
||||||
|
if planErr != nil {
|
||||||
|
return errors.New("兑换码关联的订阅套餐不存在")
|
||||||
|
}
|
||||||
|
if !plan.Enabled {
|
||||||
|
return errors.New("兑换码关联的订阅套餐已禁用")
|
||||||
|
}
|
||||||
|
_, subErr := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "redemption")
|
||||||
|
if subErr != nil {
|
||||||
|
return subErr
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("无效的兑换码类型")
|
||||||
}
|
}
|
||||||
|
|
||||||
redemption.RedeemedTime = common.GetTimestamp()
|
redemption.RedeemedTime = common.GetTimestamp()
|
||||||
redemption.Status = common.RedemptionCodeStatusUsed
|
redemption.Status = common.RedemptionCodeStatusUsed
|
||||||
redemption.UsedUserId = userId
|
redemption.UsedUserId = userId
|
||||||
@ -149,10 +177,19 @@ 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
|
result := &RedeemResult{Type: redemption.Type}
|
||||||
|
switch redemption.Type {
|
||||||
|
case common.RedemptionTypeSubscription:
|
||||||
|
result.PlanId = redemption.PlanId
|
||||||
|
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码兑换订阅套餐,套餐ID %d,兑换码ID %d", redemption.PlanId, redemption.Id))
|
||||||
|
default:
|
||||||
|
result.Quota = redemption.Quota
|
||||||
|
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (redemption *Redemption) Insert() error {
|
func (redemption *Redemption) Insert() error {
|
||||||
@ -169,7 +206,7 @@ func (redemption *Redemption) SelectUpdate() 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
|
var err error
|
||||||
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
|
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time", "type", "plan_id").Updates(redemption).Error
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ import {
|
|||||||
import { DataTableColumnHeader } from '@/components/data-table'
|
import { DataTableColumnHeader } from '@/components/data-table'
|
||||||
import { MaskedValueDisplay } from '@/components/masked-value-display'
|
import { MaskedValueDisplay } from '@/components/masked-value-display'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
|
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES, REDEMPTION_TYPE } from '../constants'
|
||||||
import { isRedemptionExpired, isTimestampExpired } from '../lib'
|
import { isRedemptionExpired, isTimestampExpired } from '../lib'
|
||||||
import { type Redemption } from '../types'
|
import { type Redemption } from '../types'
|
||||||
import { DataTableRowActions } from './data-table-row-actions'
|
import { DataTableRowActions } from './data-table-row-actions'
|
||||||
@ -135,6 +135,36 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
|||||||
return value.includes(String(statusValue))
|
return value.includes(String(statusValue))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'type',
|
||||||
|
meta: { label: t('Type') },
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title={t('Type')} />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const type = row.getValue('type') as number
|
||||||
|
if (type === REDEMPTION_TYPE.SUBSCRIPTION) {
|
||||||
|
return (
|
||||||
|
<StatusBadge
|
||||||
|
label={t('Subscription')}
|
||||||
|
variant='info'
|
||||||
|
copyable={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<StatusBadge
|
||||||
|
label={t('Quota')}
|
||||||
|
variant='neutral'
|
||||||
|
copyable={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
filterFn: (row, id, value) => {
|
||||||
|
const typeValue = row.getValue(id) as number
|
||||||
|
return value.includes(String(typeValue || 1))
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'code',
|
id: 'code',
|
||||||
accessorKey: 'key',
|
accessorKey: 'key',
|
||||||
|
|||||||
@ -34,6 +34,13 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetClose,
|
SheetClose,
|
||||||
@ -44,8 +51,9 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { DateTimePicker } from '@/components/datetime-picker'
|
import { DateTimePicker } from '@/components/datetime-picker'
|
||||||
|
import { getAdminPlans } from '@/features/subscriptions/api'
|
||||||
import { createRedemption, updateRedemption, getRedemption } from '../api'
|
import { createRedemption, updateRedemption, getRedemption } from '../api'
|
||||||
import { SUCCESS_MESSAGES } from '../constants'
|
import { SUCCESS_MESSAGES, REDEMPTION_TYPE } from '../constants'
|
||||||
import {
|
import {
|
||||||
getRedemptionFormSchema,
|
getRedemptionFormSchema,
|
||||||
type RedemptionFormValues,
|
type RedemptionFormValues,
|
||||||
@ -71,12 +79,27 @@ export function RedemptionsMutateDrawer({
|
|||||||
const isUpdate = !!currentRow
|
const isUpdate = !!currentRow
|
||||||
const { triggerRefresh } = useRedemptions()
|
const { triggerRefresh } = useRedemptions()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [plans, setPlans] = useState<{ id: number; title: string }[]>([])
|
||||||
|
|
||||||
const form = useForm<RedemptionFormValues>({
|
const form = useForm<RedemptionFormValues>({
|
||||||
resolver: zodResolver(getRedemptionFormSchema(t)),
|
resolver: zodResolver(getRedemptionFormSchema(t)),
|
||||||
defaultValues: REDEMPTION_FORM_DEFAULT_VALUES,
|
defaultValues: REDEMPTION_FORM_DEFAULT_VALUES,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const watchType = form.watch('type')
|
||||||
|
const isSubscription = watchType === REDEMPTION_TYPE.SUBSCRIPTION
|
||||||
|
|
||||||
|
// Load subscription plans when drawer opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
getAdminPlans().then((res) => {
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setPlans(res.data.map((p) => ({ id: p.id, title: p.title })))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
// Load existing data when updating
|
// Load existing data when updating
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && isUpdate && currentRow) {
|
if (open && isUpdate && currentRow) {
|
||||||
@ -192,33 +215,102 @@ export function RedemptionsMutateDrawer({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='quota_dollars'
|
name='type'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{quotaLabel}</FormLabel>
|
<FormLabel>{t('Redemption Type')}</FormLabel>
|
||||||
<FormControl>
|
<Select
|
||||||
<Input
|
value={String(field.value)}
|
||||||
{...field}
|
onValueChange={(v) => field.onChange(Number(v))}
|
||||||
type='number'
|
>
|
||||||
step={tokensOnly ? 1 : 0.01}
|
<FormControl>
|
||||||
placeholder={quotaPlaceholder}
|
<SelectTrigger>
|
||||||
onChange={(e) =>
|
<SelectValue placeholder={t('Select type')} />
|
||||||
field.onChange(parseFloat(e.target.value) || 0)
|
</SelectTrigger>
|
||||||
}
|
</FormControl>
|
||||||
/>
|
<SelectContent>
|
||||||
</FormControl>
|
<SelectItem value={String(REDEMPTION_TYPE.QUOTA)}>
|
||||||
|
{t('Quota')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={String(REDEMPTION_TYPE.SUBSCRIPTION)}>
|
||||||
|
{t('Subscription')}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{tokensOnly
|
{isSubscription
|
||||||
? t('Enter the quota amount in tokens')
|
? t('Redeem to activate a subscription plan')
|
||||||
: t('Enter the quota amount in {{currency}}', {
|
: t('Redeem to add quota to wallet')}
|
||||||
currency: currencyLabel,
|
|
||||||
})}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!isSubscription && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='quota_dollars'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{quotaLabel}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type='number'
|
||||||
|
step={tokensOnly ? 1 : 0.01}
|
||||||
|
placeholder={quotaPlaceholder}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{tokensOnly
|
||||||
|
? t('Enter the quota amount in tokens')
|
||||||
|
: t('Enter the quota amount in {{currency}}', {
|
||||||
|
currency: currencyLabel,
|
||||||
|
})}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSubscription && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='plan_id'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t('Subscription Plan')}</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={String(field.value)}
|
||||||
|
onValueChange={(v) => field.onChange(Number(v))}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('Select a plan')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<SelectItem key={plan.id} value={String(plan.id)}>
|
||||||
|
{plan.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
{t('The subscription plan to activate when redeemed')}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='expired_time'
|
name='expired_time'
|
||||||
|
|||||||
@ -29,6 +29,11 @@ export const REDEMPTION_STATUS = {
|
|||||||
USED: 3,
|
USED: 3,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
export const REDEMPTION_TYPE = {
|
||||||
|
QUOTA: 1,
|
||||||
|
SUBSCRIPTION: 2,
|
||||||
|
} as const
|
||||||
|
|
||||||
export const REDEMPTION_STATUS_VALUES = Object.values(REDEMPTION_STATUS).map(
|
export const REDEMPTION_STATUS_VALUES = Object.values(REDEMPTION_STATUS).map(
|
||||||
(value) => String(value)
|
(value) => String(value)
|
||||||
) as `${number}`[]
|
) as `${number}`[]
|
||||||
|
|||||||
@ -36,7 +36,9 @@ export function getRedemptionFormSchema(t: TFunction) {
|
|||||||
.string()
|
.string()
|
||||||
.min(REDEMPTION_VALIDATION.NAME_MIN_LENGTH, msg.NAME_LENGTH_INVALID)
|
.min(REDEMPTION_VALIDATION.NAME_MIN_LENGTH, msg.NAME_LENGTH_INVALID)
|
||||||
.max(REDEMPTION_VALIDATION.NAME_MAX_LENGTH, msg.NAME_LENGTH_INVALID),
|
.max(REDEMPTION_VALIDATION.NAME_MAX_LENGTH, msg.NAME_LENGTH_INVALID),
|
||||||
|
type: z.number().default(1),
|
||||||
quota_dollars: z.number().min(0, t('Quota must be a positive number')),
|
quota_dollars: z.number().min(0, t('Quota must be a positive number')),
|
||||||
|
plan_id: z.number().default(0),
|
||||||
expired_time: z.date().optional(),
|
expired_time: z.date().optional(),
|
||||||
count: z
|
count: z
|
||||||
.number()
|
.number()
|
||||||
@ -48,7 +50,9 @@ export function getRedemptionFormSchema(t: TFunction) {
|
|||||||
|
|
||||||
export type RedemptionFormValues = {
|
export type RedemptionFormValues = {
|
||||||
name: string
|
name: string
|
||||||
|
type: number
|
||||||
quota_dollars: number
|
quota_dollars: number
|
||||||
|
plan_id: number
|
||||||
expired_time?: Date
|
expired_time?: Date
|
||||||
count?: number
|
count?: number
|
||||||
}
|
}
|
||||||
@ -59,7 +63,9 @@ export type RedemptionFormValues = {
|
|||||||
|
|
||||||
export const REDEMPTION_FORM_DEFAULT_VALUES: RedemptionFormValues = {
|
export const REDEMPTION_FORM_DEFAULT_VALUES: RedemptionFormValues = {
|
||||||
name: '',
|
name: '',
|
||||||
|
type: 1,
|
||||||
quota_dollars: 10,
|
quota_dollars: 10,
|
||||||
|
plan_id: 0,
|
||||||
expired_time: undefined,
|
expired_time: undefined,
|
||||||
count: 1,
|
count: 1,
|
||||||
}
|
}
|
||||||
@ -76,7 +82,9 @@ export function transformFormDataToPayload(
|
|||||||
): RedemptionFormData {
|
): RedemptionFormData {
|
||||||
return {
|
return {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
type: data.type,
|
||||||
quota: parseQuotaFromDollars(data.quota_dollars),
|
quota: parseQuotaFromDollars(data.quota_dollars),
|
||||||
|
plan_id: data.plan_id,
|
||||||
expired_time: data.expired_time
|
expired_time: data.expired_time
|
||||||
? Math.floor(data.expired_time.getTime() / 1000)
|
? Math.floor(data.expired_time.getTime() / 1000)
|
||||||
: 0,
|
: 0,
|
||||||
@ -92,7 +100,9 @@ export function transformRedemptionToFormDefaults(
|
|||||||
): RedemptionFormValues {
|
): RedemptionFormValues {
|
||||||
return {
|
return {
|
||||||
name: redemption.name,
|
name: redemption.name,
|
||||||
|
type: redemption.type || 1,
|
||||||
quota_dollars: quotaUnitsToDollars(redemption.quota),
|
quota_dollars: quotaUnitsToDollars(redemption.quota),
|
||||||
|
plan_id: redemption.plan_id || 0,
|
||||||
expired_time:
|
expired_time:
|
||||||
redemption.expired_time > 0
|
redemption.expired_time > 0
|
||||||
? new Date(redemption.expired_time * 1000)
|
? new Date(redemption.expired_time * 1000)
|
||||||
|
|||||||
@ -28,7 +28,9 @@ export const redemptionSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
status: z.number(), // 1: enabled, 2: disabled, 3: used
|
status: z.number(), // 1: enabled, 2: disabled, 3: used
|
||||||
|
type: z.number(), // 1: quota, 2: subscription
|
||||||
quota: z.number(),
|
quota: z.number(),
|
||||||
|
plan_id: z.number(), // only when type=2
|
||||||
created_time: z.number(),
|
created_time: z.number(),
|
||||||
redeemed_time: z.number(),
|
redeemed_time: z.number(),
|
||||||
expired_time: z.number(), // 0 for never expires
|
expired_time: z.number(), // 0 for never expires
|
||||||
@ -72,7 +74,9 @@ export interface SearchRedemptionsParams {
|
|||||||
export interface RedemptionFormData {
|
export interface RedemptionFormData {
|
||||||
id?: number
|
id?: number
|
||||||
name: string
|
name: string
|
||||||
|
type: number // 1: quota, 2: subscription
|
||||||
quota: number
|
quota: number
|
||||||
|
plan_id: number // only when type=2
|
||||||
expired_time: number
|
expired_time: number
|
||||||
count?: number // Only for create
|
count?: number // Only for create
|
||||||
status?: number // Only for status update
|
status?: number // Only for status update
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import { getSelf } from '@/lib/api'
|
|||||||
import { formatQuota } from '@/lib/format'
|
import { formatQuota } from '@/lib/format'
|
||||||
import { redeemTopupCode } from '../api'
|
import { redeemTopupCode } from '../api'
|
||||||
|
|
||||||
|
const REDEMPTION_TYPE_SUBSCRIPTION = 2
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Redemption Hook
|
// Redemption Hook
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -41,12 +43,18 @@ export function useRedemption() {
|
|||||||
const response = await redeemTopupCode({ key: code })
|
const response = await redeemTopupCode({ key: code })
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const quotaAdded = response.data
|
const result = response.data
|
||||||
toast.success(
|
if (result.type === REDEMPTION_TYPE_SUBSCRIPTION) {
|
||||||
i18next.t('Redemption successful! Added: {{quota}}', {
|
toast.success(
|
||||||
quota: formatQuota(quotaAdded),
|
i18next.t('Redemption successful! Subscription plan activated')
|
||||||
})
|
)
|
||||||
)
|
} else {
|
||||||
|
toast.success(
|
||||||
|
i18next.t('Redemption successful! Added: {{quota}}', {
|
||||||
|
quota: formatQuota(result.quota),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
await getSelf()
|
await getSelf()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
6
web/default/src/features/wallet/types.ts
vendored
6
web/default/src/features/wallet/types.ts
vendored
@ -33,7 +33,11 @@ export interface ApiResponse<T = unknown> {
|
|||||||
* Standard API response types
|
* Standard API response types
|
||||||
*/
|
*/
|
||||||
export type TopupInfoResponse = ApiResponse<TopupInfo>
|
export type TopupInfoResponse = ApiResponse<TopupInfo>
|
||||||
export type RedemptionResponse = ApiResponse<number>
|
export type RedemptionResponse = ApiResponse<{
|
||||||
|
type: number // 1: quota, 2: subscription
|
||||||
|
quota: number // type=1
|
||||||
|
plan_id: number // type=2
|
||||||
|
}>
|
||||||
export type AmountResponse = ApiResponse<string>
|
export type AmountResponse = ApiResponse<string>
|
||||||
export type PaymentResponse = ApiResponse<Record<string, unknown>> & {
|
export type PaymentResponse = ApiResponse<Record<string, unknown>> & {
|
||||||
url?: string
|
url?: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user