Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
f6f072cb
Commit
f6f072cb
authored
Jan 10, 2026
by
Edric Li
Browse files
Merge branch 'main' into feat/api-key-ip-restriction
parents
5265b12c
ff087586
Changes
108
Show whitespace changes
Inline
Side-by-side
frontend/src/components/layout/AppSidebar.vue
View file @
f6f072cb
...
...
@@ -448,6 +448,7 @@ const adminNavItems = computed(() => {
{
path
:
'
/admin/accounts
'
,
label
:
t
(
'
nav.accounts
'
),
icon
:
GlobeIcon
},
{
path
:
'
/admin/proxies
'
,
label
:
t
(
'
nav.proxies
'
),
icon
:
ServerIcon
},
{
path
:
'
/admin/redeem
'
,
label
:
t
(
'
nav.redeemCodes
'
),
icon
:
TicketIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/promo-codes
'
,
label
:
t
(
'
nav.promoCodes
'
),
icon
:
GiftIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/usage
'
,
label
:
t
(
'
nav.usage
'
),
icon
:
ChartIcon
},
]
...
...
frontend/src/i18n/locales/en.ts
View file @
f6f072cb
...
...
@@ -145,6 +145,7 @@ export default {
copiedToClipboard
:
'
Copied to clipboard
'
,
copyFailed
:
'
Failed to copy
'
,
contactSupport
:
'
Contact Support
'
,
optional
:
'
optional
'
,
selectOption
:
'
Select an option
'
,
searchPlaceholder
:
'
Search...
'
,
noOptionsFound
:
'
No options found
'
,
...
...
@@ -177,6 +178,7 @@ export default {
accounts
:
'
Accounts
'
,
proxies
:
'
Proxies
'
,
redeemCodes
:
'
Redeem Codes
'
,
promoCodes
:
'
Promo Codes
'
,
settings
:
'
Settings
'
,
myAccount
:
'
My Account
'
,
lightMode
:
'
Light Mode
'
,
...
...
@@ -229,6 +231,17 @@ export default {
sendingCode
:
'
Sending...
'
,
clickToResend
:
'
Click to resend code
'
,
resendCode
:
'
Resend verification code
'
,
promoCodeLabel
:
'
Promo Code
'
,
promoCodePlaceholder
:
'
Enter promo code (optional)
'
,
promoCodeValid
:
'
Valid! You will receive ${amount} bonus balance
'
,
promoCodeInvalid
:
'
Invalid promo code
'
,
promoCodeNotFound
:
'
Promo code not found
'
,
promoCodeExpired
:
'
This promo code has expired
'
,
promoCodeDisabled
:
'
This promo code is disabled
'
,
promoCodeMaxUsed
:
'
This promo code has reached its usage limit
'
,
promoCodeAlreadyUsed
:
'
You have already used this promo code
'
,
promoCodeValidating
:
'
Promo code is being validated, please wait
'
,
promoCodeInvalidCannotRegister
:
'
Invalid promo code. Please check and try again or clear the promo code field
'
,
linuxdo
:
{
signIn
:
'
Continue with Linux.do
'
,
orContinue
:
'
or continue with email
'
,
...
...
@@ -1722,6 +1735,65 @@ export default {
}
},
// Promo Codes
promo
:
{
title
:
'
Promo Code Management
'
,
description
:
'
Create and manage registration promo codes
'
,
createCode
:
'
Create Promo Code
'
,
editCode
:
'
Edit Promo Code
'
,
deleteCode
:
'
Delete Promo Code
'
,
searchCodes
:
'
Search codes...
'
,
allStatus
:
'
All Status
'
,
columns
:
{
code
:
'
Code
'
,
bonusAmount
:
'
Bonus Amount
'
,
maxUses
:
'
Max Uses
'
,
usedCount
:
'
Used
'
,
usage
:
'
Usage
'
,
status
:
'
Status
'
,
expiresAt
:
'
Expires At
'
,
createdAt
:
'
Created At
'
,
actions
:
'
Actions
'
},
// Form labels (flat structure for template usage)
code
:
'
Promo Code
'
,
autoGenerate
:
'
auto-generate if empty
'
,
codePlaceholder
:
'
Enter promo code or leave empty
'
,
bonusAmount
:
'
Bonus Amount ($)
'
,
maxUses
:
'
Max Uses
'
,
zeroUnlimited
:
'
0 = unlimited
'
,
expiresAt
:
'
Expires At
'
,
notes
:
'
Notes
'
,
notesPlaceholder
:
'
Optional notes for this code
'
,
status
:
'
Status
'
,
neverExpires
:
'
Never expires
'
,
// Status labels
statusActive
:
'
Active
'
,
statusDisabled
:
'
Disabled
'
,
statusExpired
:
'
Expired
'
,
statusMaxUsed
:
'
Used Up
'
,
// Usage records
usageRecords
:
'
Usage Records
'
,
viewUsages
:
'
View Usages
'
,
noUsages
:
'
No usage records yet
'
,
userPrefix
:
'
User #{id}
'
,
copied
:
'
Copied!
'
,
// Messages
noCodesYet
:
'
No promo codes yet
'
,
createFirstCode
:
'
Create your first promo code to offer registration bonuses.
'
,
codeCreated
:
'
Promo code created successfully
'
,
codeUpdated
:
'
Promo code updated successfully
'
,
codeDeleted
:
'
Promo code deleted successfully
'
,
deleteCodeConfirm
:
'
Are you sure you want to delete this promo code? This action cannot be undone.
'
,
copyRegisterLink
:
'
Copy register link
'
,
registerLinkCopied
:
'
Register link copied to clipboard
'
,
failedToLoad
:
'
Failed to load promo codes
'
,
failedToCreate
:
'
Failed to create promo code
'
,
failedToUpdate
:
'
Failed to update promo code
'
,
failedToDelete
:
'
Failed to delete promo code
'
,
failedToLoadUsages
:
'
Failed to load usage records
'
},
// Usage Records
usage
:
{
title
:
'
Usage Records
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
f6f072cb
...
...
@@ -142,6 +142,7 @@ export default {
copiedToClipboard
:
'
已复制到剪贴板
'
,
copyFailed
:
'
复制失败
'
,
contactSupport
:
'
联系客服
'
,
optional
:
'
可选
'
,
selectOption
:
'
请选择
'
,
searchPlaceholder
:
'
搜索...
'
,
noOptionsFound
:
'
无匹配选项
'
,
...
...
@@ -175,6 +176,7 @@ export default {
accounts
:
'
账号管理
'
,
proxies
:
'
IP管理
'
,
redeemCodes
:
'
兑换码
'
,
promoCodes
:
'
优惠码
'
,
settings
:
'
系统设置
'
,
myAccount
:
'
我的账户
'
,
lightMode
:
'
浅色模式
'
,
...
...
@@ -227,6 +229,17 @@ export default {
sendingCode
:
'
发送中...
'
,
clickToResend
:
'
点击重新发送验证码
'
,
resendCode
:
'
重新发送验证码
'
,
promoCodeLabel
:
'
优惠码
'
,
promoCodePlaceholder
:
'
输入优惠码(可选)
'
,
promoCodeValid
:
'
有效!注册后将获得 ${amount} 赠送余额
'
,
promoCodeInvalid
:
'
无效的优惠码
'
,
promoCodeNotFound
:
'
优惠码不存在
'
,
promoCodeExpired
:
'
此优惠码已过期
'
,
promoCodeDisabled
:
'
此优惠码已被禁用
'
,
promoCodeMaxUsed
:
'
此优惠码已达到使用上限
'
,
promoCodeAlreadyUsed
:
'
您已使用过此优惠码
'
,
promoCodeValidating
:
'
优惠码正在验证中,请稍候
'
,
promoCodeInvalidCannotRegister
:
'
优惠码无效,请检查后重试或清空优惠码
'
,
linuxdo
:
{
signIn
:
'
使用 Linux.do 登录
'
,
orContinue
:
'
或使用邮箱密码继续
'
,
...
...
@@ -1867,6 +1880,65 @@ export default {
failedToDelete
:
'
删除兑换码失败
'
},
// Promo Codes
promo
:
{
title
:
'
优惠码管理
'
,
description
:
'
创建和管理注册优惠码
'
,
createCode
:
'
创建优惠码
'
,
editCode
:
'
编辑优惠码
'
,
deleteCode
:
'
删除优惠码
'
,
searchCodes
:
'
搜索优惠码...
'
,
allStatus
:
'
全部状态
'
,
columns
:
{
code
:
'
优惠码
'
,
bonusAmount
:
'
赠送金额
'
,
maxUses
:
'
最大使用次数
'
,
usedCount
:
'
已使用
'
,
usage
:
'
使用量
'
,
status
:
'
状态
'
,
expiresAt
:
'
过期时间
'
,
createdAt
:
'
创建时间
'
,
actions
:
'
操作
'
},
// 表单标签(扁平结构便于模板使用)
code
:
'
优惠码
'
,
autoGenerate
:
'
留空自动生成
'
,
codePlaceholder
:
'
输入优惠码或留空
'
,
bonusAmount
:
'
赠送金额 ($)
'
,
maxUses
:
'
最大使用次数
'
,
zeroUnlimited
:
'
0 = 无限制
'
,
expiresAt
:
'
过期时间
'
,
notes
:
'
备注
'
,
notesPlaceholder
:
'
可选备注信息
'
,
status
:
'
状态
'
,
neverExpires
:
'
永不过期
'
,
// 状态标签
statusActive
:
'
启用
'
,
statusDisabled
:
'
禁用
'
,
statusExpired
:
'
已过期
'
,
statusMaxUsed
:
'
已用完
'
,
// 使用记录
usageRecords
:
'
使用记录
'
,
viewUsages
:
'
查看使用记录
'
,
noUsages
:
'
暂无使用记录
'
,
userPrefix
:
'
用户 #{id}
'
,
copied
:
'
已复制!
'
,
// 消息
noCodesYet
:
'
暂无优惠码
'
,
createFirstCode
:
'
创建您的第一个优惠码,为新用户提供注册奖励。
'
,
codeCreated
:
'
优惠码创建成功
'
,
codeUpdated
:
'
优惠码更新成功
'
,
codeDeleted
:
'
优惠码删除成功
'
,
deleteCodeConfirm
:
'
确定要删除此优惠码吗?此操作无法撤销。
'
,
copyRegisterLink
:
'
复制注册链接
'
,
registerLinkCopied
:
'
注册链接已复制到剪贴板
'
,
failedToLoad
:
'
加载优惠码失败
'
,
failedToCreate
:
'
创建优惠码失败
'
,
failedToUpdate
:
'
更新优惠码失败
'
,
failedToDelete
:
'
删除优惠码失败
'
,
failedToLoadUsages
:
'
加载使用记录失败
'
},
// Usage Records
usage
:
{
title
:
'
使用记录
'
,
...
...
frontend/src/router/index.ts
View file @
f6f072cb
...
...
@@ -245,6 +245,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey
:
'
admin.redeem.description
'
}
},
{
path
:
'
/admin/promo-codes
'
,
name
:
'
AdminPromoCodes
'
,
component
:
()
=>
import
(
'
@/views/admin/PromoCodesView.vue
'
),
meta
:
{
requiresAuth
:
true
,
requiresAdmin
:
true
,
title
:
'
Promo Code Management
'
,
titleKey
:
'
admin.promo.title
'
,
descriptionKey
:
'
admin.promo.description
'
}
},
{
path
:
'
/admin/settings
'
,
name
:
'
AdminSettings
'
,
...
...
frontend/src/types/index.ts
View file @
f6f072cb
...
...
@@ -50,6 +50,7 @@ export interface RegisterRequest {
password
:
string
verify_code
?:
string
turnstile_token
?:
string
promo_code
?:
string
}
export
interface
SendVerifyCodeRequest
{
...
...
@@ -961,3 +962,44 @@ export interface UpdateUserAttributeRequest {
export
interface
UserAttributeValuesMap
{
[
attributeId
:
number
]:
string
}
// ==================== Promo Code Types ====================
export
interface
PromoCode
{
id
:
number
code
:
string
bonus_amount
:
number
max_uses
:
number
used_count
:
number
status
:
'
active
'
|
'
disabled
'
expires_at
:
string
|
null
notes
:
string
|
null
created_at
:
string
updated_at
:
string
}
export
interface
PromoCodeUsage
{
id
:
number
promo_code_id
:
number
user_id
:
number
bonus_amount
:
number
used_at
:
string
user
?:
User
}
export
interface
CreatePromoCodeRequest
{
code
?:
string
bonus_amount
:
number
max_uses
?:
number
expires_at
?:
number
|
null
notes
?:
string
}
export
interface
UpdatePromoCodeRequest
{
code
?:
string
bonus_amount
?:
number
max_uses
?:
number
status
?:
'
active
'
|
'
disabled
'
expires_at
?:
number
|
null
notes
?:
string
}
frontend/src/views/admin/PromoCodesView.vue
0 → 100644
View file @
f6f072cb
<
template
>
<AppLayout>
<TablePageLayout>
<template
#actions
>
<div
class=
"flex justify-end gap-3"
>
<button
@
click=
"loadCodes"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
<button
@
click=
"showCreateDialog = true"
class=
"btn btn-primary"
>
<Icon
name=
"plus"
size=
"md"
class=
"mr-1"
/>
{{
t
(
'
admin.promo.createCode
'
)
}}
</button>
</div>
</
template
>
<
template
#filters
>
<div
class=
"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div
class=
"max-w-md flex-1"
>
<input
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('admin.promo.searchCodes')"
class=
"input"
@
input=
"handleSearch"
/>
</div>
<div
class=
"flex gap-2"
>
<Select
v-model=
"filters.status"
:options=
"filterStatusOptions"
class=
"w-36"
@
change=
"loadCodes"
/>
</div>
</div>
</
template
>
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"codes"
:loading=
"loading"
>
<template
#cell-code
="
{ value }">
<div
class=
"flex items-center space-x-2"
>
<code
class=
"font-mono text-sm text-gray-900 dark:text-gray-100"
>
{{
value
}}
</code>
<button
@
click=
"copyToClipboard(value)"
:class=
"[
'flex items-center transition-colors',
copiedCode === value
? 'text-green-500'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
]"
:title=
"copiedCode === value ? t('admin.promo.copied') : t('keys.copyToClipboard')"
>
<Icon
v-if=
"copiedCode !== value"
name=
"copy"
size=
"sm"
:stroke-width=
"2"
/>
<svg
v-else
class=
"h-4 w-4"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M5 13l4 4L19 7"
/>
</svg>
</button>
</div>
</
template
>
<
template
#cell-bonus_amount=
"{ value }"
>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{
value
.
toFixed
(
2
)
}}
</span>
</
template
>
<
template
#cell-usage=
"{ row }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-300"
>
{{
row
.
used_count
}}
/
{{
row
.
max_uses
===
0
?
'
∞
'
:
row
.
max_uses
}}
</span>
</
template
>
<
template
#cell-status=
"{ value, row }"
>
<span
:class=
"[
'badge',
getStatusClass(value, row)
]"
>
{{
getStatusLabel
(
value
,
row
)
}}
</span>
</
template
>
<
template
#cell-expires_at=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
value
?
formatDateTime
(
value
)
:
t
(
'
admin.promo.neverExpires
'
)
}}
</span>
</
template
>
<
template
#cell-created_at=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
formatDateTime
(
value
)
}}
</span>
</
template
>
<
template
#cell-actions=
"{ row }"
>
<div
class=
"flex items-center space-x-1"
>
<button
@
click=
"copyRegisterLink(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
:title=
"t('admin.promo.copyRegisterLink')"
>
<Icon
name=
"link"
size=
"sm"
/>
</button>
<button
@
click=
"handleViewUsages(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
:title=
"t('admin.promo.viewUsages')"
>
<Icon
name=
"eye"
size=
"sm"
/>
</button>
<button
@
click=
"handleEdit(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title=
"t('common.edit')"
>
<Icon
name=
"edit"
size=
"sm"
/>
</button>
<button
@
click=
"handleDelete(row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title=
"t('common.delete')"
>
<Icon
name=
"trash"
size=
"sm"
/>
</button>
</div>
</
template
>
</DataTable>
</template>
<
template
#pagination
>
<Pagination
v-if=
"pagination.total > 0"
:page=
"pagination.page"
:total=
"pagination.total"
:page-size=
"pagination.page_size"
@
update:page=
"handlePageChange"
@
update:pageSize=
"handlePageSizeChange"
/>
</
template
>
</TablePageLayout>
<!-- Create Dialog -->
<BaseDialog
:show=
"showCreateDialog"
:title=
"t('admin.promo.createCode')"
width=
"normal"
@
close=
"showCreateDialog = false"
>
<form
id=
"create-promo-form"
@
submit.prevent=
"handleCreate"
class=
"space-y-4"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.code') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('admin.promo.autoGenerate') }})
</span>
</label>
<input
v-model=
"createForm.code"
type=
"text"
class=
"input font-mono uppercase"
:placeholder=
"t('admin.promo.codePlaceholder')"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.bonusAmount') }}
</label>
<input
v-model.number=
"createForm.bonus_amount"
type=
"number"
step=
"0.01"
min=
"0"
required
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.maxUses') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('admin.promo.zeroUnlimited') }})
</span>
</label>
<input
v-model.number=
"createForm.max_uses"
type=
"number"
min=
"0"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.expiresAt') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('common.optional') }})
</span>
</label>
<input
v-model=
"createForm.expires_at_str"
type=
"datetime-local"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.notes') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('common.optional') }})
</span>
</label>
<textarea
v-model=
"createForm.notes"
rows=
"2"
class=
"input"
:placeholder=
"t('admin.promo.notesPlaceholder')"
></textarea>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
@
click=
"showCreateDialog = false"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"create-promo-form"
:disabled=
"creating"
class=
"btn btn-primary"
>
{{
creating
?
t
(
'
common.creating
'
)
:
t
(
'
common.create
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
:show=
"showEditDialog"
:title=
"t('admin.promo.editCode')"
width=
"normal"
@
close=
"closeEditDialog"
>
<form
id=
"edit-promo-form"
@
submit.prevent=
"handleUpdate"
class=
"space-y-4"
>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.code') }}
</label>
<input
v-model=
"editForm.code"
type=
"text"
class=
"input font-mono uppercase"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.bonusAmount') }}
</label>
<input
v-model.number=
"editForm.bonus_amount"
type=
"number"
step=
"0.01"
min=
"0"
required
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.maxUses') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('admin.promo.zeroUnlimited') }})
</span>
</label>
<input
v-model.number=
"editForm.max_uses"
type=
"number"
min=
"0"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.status') }}
</label>
<Select
v-model=
"editForm.status"
:options=
"statusOptions"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.expiresAt') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('common.optional') }})
</span>
</label>
<input
v-model=
"editForm.expires_at_str"
type=
"datetime-local"
class=
"input"
/>
</div>
<div>
<label
class=
"input-label"
>
{{ t('admin.promo.notes') }}
<span
class=
"ml-1 text-xs font-normal text-gray-400"
>
({{ t('common.optional') }})
</span>
</label>
<textarea
v-model=
"editForm.notes"
rows=
"2"
class=
"input"
></textarea>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
@
click=
"closeEditDialog"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"edit-promo-form"
:disabled=
"updating"
class=
"btn btn-primary"
>
{{
updating
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Usages Dialog -->
<BaseDialog
:show=
"showUsagesDialog"
:title=
"t('admin.promo.usageRecords')"
width=
"wide"
@
close=
"showUsagesDialog = false"
>
<div
v-if=
"usagesLoading"
class=
"flex items-center justify-center py-8"
>
<Icon
name=
"refresh"
size=
"lg"
class=
"animate-spin text-gray-400"
/>
</div>
<div
v-else-if=
"usages.length === 0"
class=
"py-8 text-center text-gray-500 dark:text-gray-400"
>
{{ t('admin.promo.noUsages') }}
</div>
<div
v-else
class=
"space-y-3"
>
<div
v-for=
"usage in usages"
:key=
"usage.id"
class=
"flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30"
>
<Icon
name=
"user"
size=
"sm"
class=
"text-green-600 dark:text-green-400"
/>
</div>
<div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ usage.user?.email || t('admin.promo.userPrefix', { id: usage.user_id }) }}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{ formatDateTime(usage.used_at) }}
</p>
</div>
</div>
<div
class=
"text-right"
>
<span
class=
"text-sm font-medium text-green-600 dark:text-green-400"
>
+${{ usage.bonus_amount.toFixed(2) }}
</span>
</div>
</div>
<!-- Usages Pagination -->
<div
v-if=
"usagesTotal > usagesPageSize"
class=
"mt-4"
>
<Pagination
:page=
"usagesPage"
:total=
"usagesTotal"
:page-size=
"usagesPageSize"
:page-size-options=
"[10, 20, 50]"
@
update:page=
"handleUsagesPageChange"
@
update:page-size=
"(size: number) => { usagesPageSize = size; usagesPage = 1; loadUsages() }"
/>
</div>
</div>
<
template
#footer
>
<div
class=
"flex justify-end"
>
<button
type=
"button"
@
click=
"showUsagesDialog = false"
class=
"btn btn-secondary"
>
{{
t
(
'
common.close
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show=
"showDeleteDialog"
:title=
"t('admin.promo.deleteCode')"
:message=
"t('admin.promo.deleteCodeConfirm')"
:confirm-text=
"t('common.delete')"
:cancel-text=
"t('common.cancel')"
danger
@
confirm=
"confirmDelete"
@
cancel=
"showDeleteDialog = false"
/>
</AppLayout>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useClipboard
}
from
'
@/composables/useClipboard
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
PromoCode
,
PromoCodeUsage
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
TablePageLayout
from
'
@/components/layout/TablePageLayout.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
ConfirmDialog
from
'
@/components/common/ConfirmDialog.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
{
copyToClipboard
:
clipboardCopy
}
=
useClipboard
()
// State
const
codes
=
ref
<
PromoCode
[]
>
([])
const
loading
=
ref
(
false
)
const
creating
=
ref
(
false
)
const
updating
=
ref
(
false
)
const
searchQuery
=
ref
(
''
)
const
copiedCode
=
ref
<
string
|
null
>
(
null
)
const
filters
=
reactive
({
status
:
''
})
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
total
:
0
})
// Dialogs
const
showCreateDialog
=
ref
(
false
)
const
showEditDialog
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showUsagesDialog
=
ref
(
false
)
const
editingCode
=
ref
<
PromoCode
|
null
>
(
null
)
const
deletingCode
=
ref
<
PromoCode
|
null
>
(
null
)
// Usages
const
usages
=
ref
<
PromoCodeUsage
[]
>
([])
const
usagesLoading
=
ref
(
false
)
const
currentViewingCode
=
ref
<
PromoCode
|
null
>
(
null
)
const
usagesPage
=
ref
(
1
)
const
usagesPageSize
=
ref
(
20
)
const
usagesTotal
=
ref
(
0
)
// Forms
const
createForm
=
reactive
({
code
:
''
,
bonus_amount
:
1
,
max_uses
:
0
,
expires_at_str
:
''
,
notes
:
''
})
const
editForm
=
reactive
({
code
:
''
,
bonus_amount
:
0
,
max_uses
:
0
,
status
:
'
active
'
as
'
active
'
|
'
disabled
'
,
expires_at_str
:
''
,
notes
:
''
})
// Options
const
filterStatusOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.promo.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.promo.statusActive
'
)
},
{
value
:
'
disabled
'
,
label
:
t
(
'
admin.promo.statusDisabled
'
)
}
])
const
statusOptions
=
computed
(()
=>
[
{
value
:
'
active
'
,
label
:
t
(
'
admin.promo.statusActive
'
)
},
{
value
:
'
disabled
'
,
label
:
t
(
'
admin.promo.statusDisabled
'
)
}
])
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
code
'
,
label
:
t
(
'
admin.promo.columns.code
'
)
},
{
key
:
'
bonus_amount
'
,
label
:
t
(
'
admin.promo.columns.bonusAmount
'
),
sortable
:
true
},
{
key
:
'
usage
'
,
label
:
t
(
'
admin.promo.columns.usage
'
)
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.promo.columns.status
'
),
sortable
:
true
},
{
key
:
'
expires_at
'
,
label
:
t
(
'
admin.promo.columns.expiresAt
'
),
sortable
:
true
},
{
key
:
'
created_at
'
,
label
:
t
(
'
admin.promo.columns.createdAt
'
),
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
admin.promo.columns.actions
'
)
}
])
// Helpers
const
getStatusClass
=
(
status
:
string
,
row
:
PromoCode
)
=>
{
if
(
row
.
expires_at
&&
new
Date
(
row
.
expires_at
)
<
new
Date
())
{
return
'
badge-danger
'
}
if
(
row
.
max_uses
>
0
&&
row
.
used_count
>=
row
.
max_uses
)
{
return
'
badge-gray
'
}
return
status
===
'
active
'
?
'
badge-success
'
:
'
badge-gray
'
}
const
getStatusLabel
=
(
status
:
string
,
row
:
PromoCode
)
=>
{
if
(
row
.
expires_at
&&
new
Date
(
row
.
expires_at
)
<
new
Date
())
{
return
t
(
'
admin.promo.statusExpired
'
)
}
if
(
row
.
max_uses
>
0
&&
row
.
used_count
>=
row
.
max_uses
)
{
return
t
(
'
admin.promo.statusMaxUsed
'
)
}
return
status
===
'
active
'
?
t
(
'
admin.promo.statusActive
'
)
:
t
(
'
admin.promo.statusDisabled
'
)
}
// API calls
let
abortController
:
AbortController
|
null
=
null
const
loadCodes
=
async
()
=>
{
if
(
abortController
)
{
abortController
.
abort
()
}
const
currentController
=
new
AbortController
()
abortController
=
currentController
loading
.
value
=
true
try
{
const
response
=
await
adminAPI
.
promo
.
list
(
pagination
.
page
,
pagination
.
page_size
,
{
status
:
filters
.
status
||
undefined
,
search
:
searchQuery
.
value
||
undefined
}
)
if
(
currentController
.
signal
.
aborted
)
return
codes
.
value
=
response
.
items
pagination
.
total
=
response
.
total
}
catch
(
error
:
any
)
{
if
(
currentController
.
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
)
return
appStore
.
showError
(
t
(
'
admin.promo.failedToLoad
'
))
console
.
error
(
'
Error loading promo codes:
'
,
error
)
}
finally
{
if
(
abortController
===
currentController
&&
!
currentController
.
signal
.
aborted
)
{
loading
.
value
=
false
abortController
=
null
}
}
}
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
const
handleSearch
=
()
=>
{
clearTimeout
(
searchTimeout
)
searchTimeout
=
setTimeout
(()
=>
{
pagination
.
page
=
1
loadCodes
()
},
300
)
}
const
handlePageChange
=
(
page
:
number
)
=>
{
pagination
.
page
=
page
loadCodes
()
}
const
handlePageSizeChange
=
(
pageSize
:
number
)
=>
{
pagination
.
page_size
=
pageSize
pagination
.
page
=
1
loadCodes
()
}
const
copyToClipboard
=
async
(
text
:
string
)
=>
{
const
success
=
await
clipboardCopy
(
text
,
t
(
'
admin.promo.copied
'
))
if
(
success
)
{
copiedCode
.
value
=
text
setTimeout
(()
=>
{
copiedCode
.
value
=
null
},
2000
)
}
}
// Create
const
handleCreate
=
async
()
=>
{
creating
.
value
=
true
try
{
await
adminAPI
.
promo
.
create
({
code
:
createForm
.
code
||
undefined
,
bonus_amount
:
createForm
.
bonus_amount
,
max_uses
:
createForm
.
max_uses
,
expires_at
:
createForm
.
expires_at_str
?
Math
.
floor
(
new
Date
(
createForm
.
expires_at_str
).
getTime
()
/
1000
)
:
undefined
,
notes
:
createForm
.
notes
||
undefined
})
appStore
.
showSuccess
(
t
(
'
admin.promo.codeCreated
'
))
showCreateDialog
.
value
=
false
resetCreateForm
()
loadCodes
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.promo.failedToCreate
'
))
}
finally
{
creating
.
value
=
false
}
}
const
resetCreateForm
=
()
=>
{
createForm
.
code
=
''
createForm
.
bonus_amount
=
1
createForm
.
max_uses
=
0
createForm
.
expires_at_str
=
''
createForm
.
notes
=
''
}
// Edit
const
handleEdit
=
(
code
:
PromoCode
)
=>
{
editingCode
.
value
=
code
editForm
.
code
=
code
.
code
editForm
.
bonus_amount
=
code
.
bonus_amount
editForm
.
max_uses
=
code
.
max_uses
editForm
.
status
=
code
.
status
editForm
.
expires_at_str
=
code
.
expires_at
?
new
Date
(
code
.
expires_at
).
toISOString
().
slice
(
0
,
16
)
:
''
editForm
.
notes
=
code
.
notes
||
''
showEditDialog
.
value
=
true
}
const
closeEditDialog
=
()
=>
{
showEditDialog
.
value
=
false
editingCode
.
value
=
null
}
const
handleUpdate
=
async
()
=>
{
if
(
!
editingCode
.
value
)
return
updating
.
value
=
true
try
{
await
adminAPI
.
promo
.
update
(
editingCode
.
value
.
id
,
{
code
:
editForm
.
code
,
bonus_amount
:
editForm
.
bonus_amount
,
max_uses
:
editForm
.
max_uses
,
status
:
editForm
.
status
,
expires_at
:
editForm
.
expires_at_str
?
Math
.
floor
(
new
Date
(
editForm
.
expires_at_str
).
getTime
()
/
1000
)
:
0
,
notes
:
editForm
.
notes
})
appStore
.
showSuccess
(
t
(
'
admin.promo.codeUpdated
'
))
closeEditDialog
()
loadCodes
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.promo.failedToUpdate
'
))
}
finally
{
updating
.
value
=
false
}
}
// Copy Register Link
const
copyRegisterLink
=
async
(
code
:
PromoCode
)
=>
{
const
baseUrl
=
window
.
location
.
origin
const
registerLink
=
`
${
baseUrl
}
/register?promo=
${
encodeURIComponent
(
code
.
code
)}
`
try
{
await
navigator
.
clipboard
.
writeText
(
registerLink
)
appStore
.
showSuccess
(
t
(
'
admin.promo.registerLinkCopied
'
))
}
catch
(
error
)
{
// Fallback for older browsers
const
textArea
=
document
.
createElement
(
'
textarea
'
)
textArea
.
value
=
registerLink
document
.
body
.
appendChild
(
textArea
)
textArea
.
select
()
document
.
execCommand
(
'
copy
'
)
document
.
body
.
removeChild
(
textArea
)
appStore
.
showSuccess
(
t
(
'
admin.promo.registerLinkCopied
'
))
}
}
// Delete
const
handleDelete
=
(
code
:
PromoCode
)
=>
{
deletingCode
.
value
=
code
showDeleteDialog
.
value
=
true
}
const
confirmDelete
=
async
()
=>
{
if
(
!
deletingCode
.
value
)
return
try
{
await
adminAPI
.
promo
.
delete
(
deletingCode
.
value
.
id
)
appStore
.
showSuccess
(
t
(
'
admin.promo.codeDeleted
'
))
showDeleteDialog
.
value
=
false
deletingCode
.
value
=
null
loadCodes
()
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.promo.failedToDelete
'
))
}
}
// View Usages
const
handleViewUsages
=
async
(
code
:
PromoCode
)
=>
{
currentViewingCode
.
value
=
code
showUsagesDialog
.
value
=
true
usagesPage
.
value
=
1
await
loadUsages
()
}
const
loadUsages
=
async
()
=>
{
if
(
!
currentViewingCode
.
value
)
return
usagesLoading
.
value
=
true
usages
.
value
=
[]
try
{
const
response
=
await
adminAPI
.
promo
.
getUsages
(
currentViewingCode
.
value
.
id
,
usagesPage
.
value
,
usagesPageSize
.
value
)
usages
.
value
=
response
.
items
usagesTotal
.
value
=
response
.
total
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.promo.failedToLoadUsages
'
))
}
finally
{
usagesLoading
.
value
=
false
}
}
const
handleUsagesPageChange
=
(
page
:
number
)
=>
{
usagesPage
.
value
=
page
loadUsages
()
}
onMounted
(()
=>
{
loadCodes
()
})
onUnmounted
(()
=>
{
clearTimeout
(
searchTimeout
)
abortController
?.
abort
()
})
</
script
>
frontend/src/views/auth/EmailVerifyView.vue
View file @
f6f072cb
...
...
@@ -200,6 +200,7 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null
const
email
=
ref
<
string
>
(
''
)
const
password
=
ref
<
string
>
(
''
)
const
initialTurnstileToken
=
ref
<
string
>
(
''
)
const
promoCode
=
ref
<
string
>
(
''
)
const
hasRegisterData
=
ref
<
boolean
>
(
false
)
// Public settings
...
...
@@ -228,6 +229,7 @@ onMounted(async () => {
email
.
value
=
registerData
.
email
||
''
password
.
value
=
registerData
.
password
||
''
initialTurnstileToken
.
value
=
registerData
.
turnstile_token
||
''
promoCode
.
value
=
registerData
.
promo_code
||
''
hasRegisterData
.
value
=
!!
(
email
.
value
&&
password
.
value
)
}
catch
{
hasRegisterData
.
value
=
false
...
...
@@ -381,7 +383,8 @@ async function handleVerify(): Promise<void> {
email
:
email
.
value
,
password
:
password
.
value
,
verify_code
:
verifyCode
.
value
.
trim
(),
turnstile_token
:
initialTurnstileToken
.
value
||
undefined
turnstile_token
:
initialTurnstileToken
.
value
||
undefined
,
promo_code
:
promoCode
.
value
||
undefined
})
// Clear session data
...
...
frontend/src/views/auth/RegisterView.vue
View file @
f6f072cb
...
...
@@ -95,6 +95,57 @@
<
/p
>
<
/div
>
<!--
Promo
Code
Input
(
Optional
)
-->
<
div
>
<
label
for
=
"
promo_code
"
class
=
"
input-label
"
>
{{
t
(
'
auth.promoCodeLabel
'
)
}}
<
span
class
=
"
ml-1 text-xs font-normal text-gray-400 dark:text-dark-500
"
>
({{
t
(
'
common.optional
'
)
}}
)
<
/span
>
<
/label
>
<
div
class
=
"
relative
"
>
<
div
class
=
"
pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5
"
>
<
Icon
name
=
"
gift
"
size
=
"
md
"
:
class
=
"
promoValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'
"
/>
<
/div
>
<
input
id
=
"
promo_code
"
v
-
model
=
"
formData.promo_code
"
type
=
"
text
"
:
disabled
=
"
isLoading
"
class
=
"
input pl-11 pr-10
"
:
class
=
"
{
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
'border-red-500 focus:border-red-500 focus:ring-red-500': promoValidation.invalid
}
"
:
placeholder
=
"
t('auth.promoCodePlaceholder')
"
@
input
=
"
handlePromoCodeInput
"
/>
<!--
Validation
indicator
-->
<
div
v
-
if
=
"
promoValidating
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-3.5
"
>
<
svg
class
=
"
h-4 w-4 animate-spin text-gray-400
"
fill
=
"
none
"
viewBox
=
"
0 0 24 24
"
>
<
circle
class
=
"
opacity-25
"
cx
=
"
12
"
cy
=
"
12
"
r
=
"
10
"
stroke
=
"
currentColor
"
stroke
-
width
=
"
4
"
><
/circle
>
<
path
class
=
"
opacity-75
"
fill
=
"
currentColor
"
d
=
"
M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z
"
><
/path
>
<
/svg
>
<
/div
>
<
div
v
-
else
-
if
=
"
promoValidation.valid
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-3.5
"
>
<
Icon
name
=
"
checkCircle
"
size
=
"
md
"
class
=
"
text-green-500
"
/>
<
/div
>
<
div
v
-
else
-
if
=
"
promoValidation.invalid
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-3.5
"
>
<
Icon
name
=
"
exclamationCircle
"
size
=
"
md
"
class
=
"
text-red-500
"
/>
<
/div
>
<
/div
>
<!--
Promo
code
validation
result
-->
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
promoValidation.valid
"
class
=
"
mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20
"
>
<
Icon
name
=
"
gift
"
size
=
"
sm
"
class
=
"
text-green-600 dark:text-green-400
"
/>
<
span
class
=
"
text-sm text-green-700 dark:text-green-400
"
>
{{
t
(
'
auth.promoCodeValid
'
,
{
amount
:
promoValidation
.
bonusAmount
?.
toFixed
(
2
)
}
)
}}
<
/span
>
<
/div
>
<
p
v
-
else
-
if
=
"
promoValidation.invalid
"
class
=
"
input-error-text
"
>
{{
promoValidation
.
message
}}
<
/p
>
<
/transition
>
<
/div
>
<!--
Turnstile
Widget
-->
<
div
v
-
if
=
"
turnstileEnabled && turnstileSiteKey
"
>
<
TurnstileWidget
...
...
@@ -180,21 +231,22 @@
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
reactive
,
onMounted
}
from
'
vue
'
import
{
useRouter
}
from
'
vue-router
'
import
{
ref
,
reactive
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useRouter
,
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
getPublicSettings
}
from
'
@/api/auth
'
import
{
getPublicSettings
,
validatePromoCode
}
from
'
@/api/auth
'
const
{
t
}
=
useI18n
()
// ==================== Router & Stores ====================
const
router
=
useRouter
()
const
route
=
useRoute
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
...
...
@@ -217,9 +269,20 @@ const linuxdoOAuthEnabled = ref<boolean>(false)
const
turnstileRef
=
ref
<
InstanceType
<
typeof
TurnstileWidget
>
|
null
>
(
null
)
const
turnstileToken
=
ref
<
string
>
(
''
)
// Promo code validation
const
promoValidating
=
ref
<
boolean
>
(
false
)
const
promoValidation
=
reactive
({
valid
:
false
,
invalid
:
false
,
bonusAmount
:
null
as
number
|
null
,
message
:
''
}
)
let
promoValidateTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
formData
=
reactive
({
email
:
''
,
password
:
''
password
:
''
,
promo_code
:
''
}
)
const
errors
=
reactive
({
...
...
@@ -231,6 +294,14 @@ const errors = reactive({
// ==================== Lifecycle ====================
onMounted
(
async
()
=>
{
// Read promo code from URL parameter
const
promoParam
=
route
.
query
.
promo
as
string
if
(
promoParam
)
{
formData
.
promo_code
=
promoParam
// Validate the promo code from URL
await
validatePromoCodeDebounced
(
promoParam
)
}
try
{
const
settings
=
await
getPublicSettings
()
registrationEnabled
.
value
=
settings
.
registration_enabled
...
...
@@ -246,6 +317,85 @@ onMounted(async () => {
}
}
)
onUnmounted
(()
=>
{
if
(
promoValidateTimeout
)
{
clearTimeout
(
promoValidateTimeout
)
}
}
)
// ==================== Promo Code Validation ====================
function
handlePromoCodeInput
():
void
{
const
code
=
formData
.
promo_code
.
trim
()
// Clear previous validation
promoValidation
.
valid
=
false
promoValidation
.
invalid
=
false
promoValidation
.
bonusAmount
=
null
promoValidation
.
message
=
''
if
(
!
code
)
{
promoValidating
.
value
=
false
return
}
// Debounce validation
if
(
promoValidateTimeout
)
{
clearTimeout
(
promoValidateTimeout
)
}
promoValidateTimeout
=
setTimeout
(()
=>
{
validatePromoCodeDebounced
(
code
)
}
,
500
)
}
async
function
validatePromoCodeDebounced
(
code
:
string
):
Promise
<
void
>
{
if
(
!
code
.
trim
())
return
promoValidating
.
value
=
true
try
{
const
result
=
await
validatePromoCode
(
code
)
if
(
result
.
valid
)
{
promoValidation
.
valid
=
true
promoValidation
.
invalid
=
false
promoValidation
.
bonusAmount
=
result
.
bonus_amount
||
0
promoValidation
.
message
=
''
}
else
{
promoValidation
.
valid
=
false
promoValidation
.
invalid
=
true
promoValidation
.
bonusAmount
=
null
// 根据错误码显示对应的翻译
promoValidation
.
message
=
getPromoErrorMessage
(
result
.
error_code
)
}
}
catch
(
error
)
{
console
.
error
(
'
Failed to validate promo code:
'
,
error
)
promoValidation
.
valid
=
false
promoValidation
.
invalid
=
true
promoValidation
.
message
=
t
(
'
auth.promoCodeInvalid
'
)
}
finally
{
promoValidating
.
value
=
false
}
}
function
getPromoErrorMessage
(
errorCode
?:
string
):
string
{
switch
(
errorCode
)
{
case
'
PROMO_CODE_NOT_FOUND
'
:
return
t
(
'
auth.promoCodeNotFound
'
)
case
'
PROMO_CODE_EXPIRED
'
:
return
t
(
'
auth.promoCodeExpired
'
)
case
'
PROMO_CODE_DISABLED
'
:
return
t
(
'
auth.promoCodeDisabled
'
)
case
'
PROMO_CODE_MAX_USED
'
:
return
t
(
'
auth.promoCodeMaxUsed
'
)
case
'
PROMO_CODE_ALREADY_USED
'
:
return
t
(
'
auth.promoCodeAlreadyUsed
'
)
default
:
return
t
(
'
auth.promoCodeInvalid
'
)
}
}
// ==================== Turnstile Handlers ====================
function
onTurnstileVerify
(
token
:
string
):
void
{
...
...
@@ -316,6 +466,20 @@ async function handleRegister(): Promise<void> {
return
}
// Check promo code validation status
if
(
formData
.
promo_code
.
trim
())
{
// If promo code is being validated, wait
if
(
promoValidating
.
value
)
{
errorMessage
.
value
=
t
(
'
auth.promoCodeValidating
'
)
return
}
// If promo code is invalid, block submission
if
(
promoValidation
.
invalid
)
{
errorMessage
.
value
=
t
(
'
auth.promoCodeInvalidCannotRegister
'
)
return
}
}
isLoading
.
value
=
true
try
{
...
...
@@ -327,7 +491,8 @@ async function handleRegister(): Promise<void> {
JSON
.
stringify
({
email
:
formData
.
email
,
password
:
formData
.
password
,
turnstile_token
:
turnstileToken
.
value
turnstile_token
:
turnstileToken
.
value
,
promo_code
:
formData
.
promo_code
||
undefined
}
)
)
...
...
@@ -340,7 +505,8 @@ async function handleRegister(): Promise<void> {
await
authStore
.
register
({
email
:
formData
.
email
,
password
:
formData
.
password
,
turnstile_token
:
turnstileEnabled
.
value
?
turnstileToken
.
value
:
undefined
turnstile_token
:
turnstileEnabled
.
value
?
turnstileToken
.
value
:
undefined
,
promo_code
:
formData
.
promo_code
||
undefined
}
)
// Show success toast
...
...
Prev
1
2
3
4
5
6
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment