Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
e926e5ca
authored
Aug 07, 2026
by
lihu-001
Committed by
GitHub
Aug 07, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: 修复兑换码额度精度损失 (#6685)
* fix: 修复兑换码额度精度损失(#6680) * fix(redemption): guard update data integrity
parent
1da23d6b
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
710 additions
and
135 deletions
+710
-135
web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx
+439
-0
web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx
+209
-125
web/src/features/redemption-codes/lib/redemption-form.ts
+6
-3
web/src/lib/currency.ts
+23
-3
web/src/lib/format.ts
+33
-4
No files found.
web/src/features/redemption-codes/components/__tests__/redemptions-mutate-drawer.test.tsx
0 → 100644
View file @
e926e5ca
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import
assert
from
'node:assert/strict'
import
{
Window
}
from
'happy-dom'
import
type
{
Redemption
}
from
'../../types'
// Use Bun's runner at runtime while reusing the Node test types installed here.
const
bunTestModule
=
'bun:test'
const
{
afterAll
,
afterEach
,
test
}
=
(
await
import
(
bunTestModule
))
as
{
afterAll
:
typeof
import
(
'node:test'
).
after
afterEach
:
typeof
import
(
'node:test'
).
afterEach
test
:
typeof
import
(
'node:test'
).
test
}
const
domWindow
=
new
Window
()
const
domGlobals
=
[
'window'
,
'document'
,
'navigator'
,
'HTMLElement'
,
'HTMLButtonElement'
,
'HTMLInputElement'
,
'HTMLFormElement'
,
'HTMLLabelElement'
,
'HTMLFieldSetElement'
,
'SVGElement'
,
'Node'
,
'Element'
,
'Event'
,
'KeyboardEvent'
,
'PointerEvent'
,
'MouseEvent'
,
'FocusEvent'
,
'CustomEvent'
,
'MutationObserver'
,
'ResizeObserver'
,
'requestAnimationFrame'
,
'cancelAnimationFrame'
,
'getComputedStyle'
,
]
as
const
for
(
const
key
of
domGlobals
)
{
Object
.
defineProperty
(
globalThis
,
key
,
{
configurable
:
true
,
value
:
domWindow
[
key
],
})
}
const
{
act
}
=
await
import
(
'react'
)
const
{
createRoot
}
=
await
import
(
'react-dom/client'
)
const
i18n
=
(
await
import
(
'i18next'
)).
default
const
{
I18nextProvider
,
initReactI18next
}
=
await
import
(
'react-i18next'
)
const
{
Toaster
,
toast
}
=
await
import
(
'sonner'
)
const
{
api
}
=
await
import
(
'@/lib/api'
)
const
{
useSystemConfigStore
}
=
await
import
(
'@/stores/system-config-store'
)
const
{
RedemptionsProvider
}
=
await
import
(
'../redemptions-provider'
)
const
{
RedemptionsMutateDrawer
}
=
await
import
(
'../redemptions-mutate-drawer'
)
await
i18n
.
use
(
initReactI18next
).
init
({
lng
:
'en'
,
resources
:
{
en
:
{
translation
:
{
'Failed to load'
:
'Failed to load'
,
'Loading...'
:
'Loading...'
,
'Save changes'
:
'Save changes'
,
'Something went wrong!'
:
'Something went wrong!'
,
},
},
},
})
const
reactTestGlobals
=
globalThis
as
typeof
globalThis
&
{
IS_REACT_ACT_ENVIRONMENT
?:
boolean
}
reactTestGlobals
.
IS_REACT_ACT_ENVIRONMENT
=
true
type
ApiMethod
=
(
url
:
string
,
data
?:
unknown
)
=>
Promise
<
{
data
:
unknown
}
>
type
MockableApi
=
{
get
:
ApiMethod
put
:
ApiMethod
}
type
RenderedDrawer
=
{
host
:
HTMLDivElement
root
:
ReturnType
<
typeof
createRoot
>
}
type
CurrencyFixture
=
{
quotaDisplayType
:
'USD'
|
'CNY'
usdExchangeRate
:
number
}
const
apiClient
=
api
as
unknown
as
MockableApi
const
originalGet
=
apiClient
.
get
const
originalPut
=
apiClient
.
put
const
originalConsoleLog
=
Reflect
.
get
(
console
,
'log'
)
let
renderedDrawer
:
RenderedDrawer
|
null
=
null
function
redemption
(
id
:
number
,
quota
=
500001
):
Redemption
{
return
{
id
,
user_id
:
1
,
name
:
`code-
${
id
}
`
,
key
:
`key-
${
id
}
`
,
status
:
1
,
quota
,
created_time
:
1
,
redeemed_time
:
0
,
expired_time
:
0
,
used_user_id
:
0
,
}
}
function
deferred
<
T
>
()
{
let
resolve
!
:
(
value
:
T
)
=>
void
let
reject
!
:
(
error
:
unknown
)
=>
void
const
promise
=
new
Promise
<
T
>
((
promiseResolve
,
promiseReject
)
=>
{
resolve
=
promiseResolve
reject
=
promiseReject
})
return
{
promise
,
reject
,
resolve
}
}
function
drawerTree
(
currentRow
:
Redemption
)
{
return
(
<
I18nextProvider
i18n=
{
i18n
}
>
<
RedemptionsProvider
>
<
RedemptionsMutateDrawer
open
currentRow=
{
currentRow
}
onOpenChange=
{
()
=>
undefined
}
/>
</
RedemptionsProvider
>
<
Toaster
duration=
{
60
_000
}
/>
</
I18nextProvider
>
)
}
async
function
renderDrawer
(
currentRow
:
Redemption
,
currency
:
CurrencyFixture
=
{
quotaDisplayType
:
'USD'
,
usdExchangeRate
:
1
,
}
):
Promise
<
void
>
{
useSystemConfigStore
.
getState
().
setConfig
({
currency
:
{
displayInCurrency
:
true
,
quotaDisplayType
:
currency
.
quotaDisplayType
,
quotaPerUnit
:
500000
,
usdExchangeRate
:
currency
.
usdExchangeRate
,
customCurrencySymbol
:
'¤'
,
customCurrencyExchangeRate
:
1
,
},
})
const
host
=
document
.
createElement
(
'div'
)
document
.
body
.
append
(
host
)
const
root
=
createRoot
(
host
)
renderedDrawer
=
{
host
,
root
}
await
act
(
async
()
=>
root
.
render
(
drawerTree
(
currentRow
)))
}
async
function
rerenderDrawer
(
currentRow
:
Redemption
):
Promise
<
void
>
{
assert
.
ok
(
renderedDrawer
)
await
act
(
async
()
=>
renderedDrawer
?.
root
.
render
(
drawerTree
(
currentRow
)))
}
async
function
waitForCondition
(
condition
:
()
=>
boolean
,
failureMessage
:
string
):
Promise
<
void
>
{
if
(
condition
())
return
await
new
Promise
<
void
>
((
resolve
,
reject
)
=>
{
const
observer
=
new
MutationObserver
(()
=>
{
if
(
!
condition
())
return
clearTimeout
(
timeoutId
)
observer
.
disconnect
()
resolve
()
})
const
timeoutId
=
setTimeout
(()
=>
{
observer
.
disconnect
()
reject
(
new
Error
(
`
${
failureMessage
}
:
${
document
.
body
.
textContent
}
`
))
},
1500
)
observer
.
observe
(
document
,
{
attributes
:
true
,
childList
:
true
,
characterData
:
true
,
subtree
:
true
,
})
})
}
function
getSaveButton
():
HTMLButtonElement
{
const
button
=
document
.
querySelector
<
HTMLButtonElement
>
(
'button[form="redemption-form"][type="submit"]'
)
assert
.
ok
(
button
)
return
button
}
function
getControlByLabel
<
T
extends
HTMLElement
>
(
labelText
:
string
):
T
{
const
label
=
[...
document
.
querySelectorAll
<
HTMLLabelElement
>
(
'label'
)].
find
(
(
candidate
)
=>
candidate
.
textContent
?.
trim
()
===
labelText
)
assert
.
ok
(
label
,
`Expected label "
${
labelText
}
"`
)
assert
.
ok
(
label
.
htmlFor
)
const
control
=
label
.
control
??
label
.
closest
(
'[data-slot="form-item"]'
)
?.
querySelector
<
HTMLElement
>
(
'[data-slot="form-control"], input'
)
assert
.
ok
(
control
)
return
control
as
T
}
async
function
changeInput
(
input
:
HTMLInputElement
,
value
:
string
)
{
await
act
(
async
()
=>
{
const
valueSetter
=
Object
.
getOwnPropertyDescriptor
(
domWindow
.
HTMLInputElement
.
prototype
,
'value'
)?.
set
assert
.
ok
(
valueSetter
)
valueSetter
.
call
(
input
,
value
)
input
.
dispatchEvent
(
new
domWindow
.
Event
(
'input'
,
{
bubbles
:
true
})
as
unknown
as
Event
)
})
}
async
function
submitForm
():
Promise
<
void
>
{
const
form
=
document
.
querySelector
<
HTMLFormElement
>
(
'#redemption-form'
)
assert
.
ok
(
form
)
await
act
(
async
()
=>
form
.
dispatchEvent
(
new
domWindow
.
Event
(
'submit'
,
{
bubbles
:
true
,
cancelable
:
true
,
})
as
unknown
as
Event
)
)
}
async
function
waitForLoadedForm
():
Promise
<
void
>
{
await
act
(
async
()
=>
waitForCondition
(()
=>
{
const
saveButton
=
getSaveButton
()
return
(
saveButton
.
textContent
?.
includes
(
'Save changes'
)
===
true
&&
!
saveButton
.
disabled
)
},
'redemption drawer did not finish loading'
)
)
}
afterEach
(
async
()
=>
{
apiClient
.
get
=
originalGet
apiClient
.
put
=
originalPut
Reflect
.
set
(
console
,
'log'
,
originalConsoleLog
)
toast
.
dismiss
()
domWindow
.
localStorage
.
clear
()
if
(
renderedDrawer
)
{
await
act
(
async
()
=>
renderedDrawer
?.
root
.
unmount
())
renderedDrawer
.
host
.
remove
()
renderedDrawer
=
null
}
document
.
body
.
replaceChildren
()
})
afterAll
(()
=>
{
domWindow
.
close
()
})
test
(
'redemption drawer shows the reported CNY quota without floating-point noise'
,
async
()
=>
{
const
original
=
redemption
(
1
,
13888889
)
apiClient
.
get
=
async
()
=>
({
data
:
{
success
:
true
,
data
:
original
}
})
await
renderDrawer
(
original
,
{
quotaDisplayType
:
'CNY'
,
usdExchangeRate
:
7.2
,
})
await
waitForLoadedForm
()
assert
.
equal
(
getControlByLabel
<
HTMLInputElement
>
(
'Quota (CNY)'
).
value
,
'200'
)
})
test
(
'redemption drawer blocks updates and reports an error when loading rejects'
,
async
()
=>
{
const
updates
:
unknown
[]
=
[]
Reflect
.
set
(
console
,
'log'
,
()
=>
undefined
)
apiClient
.
get
=
async
()
=>
{
throw
new
Error
(
'network failure'
)
}
apiClient
.
put
=
async
(
_url
,
data
)
=>
{
updates
.
push
(
data
)
return
{
data
:
{
success
:
true
}
}
}
await
renderDrawer
(
redemption
(
1
))
await
act
(
async
()
=>
waitForCondition
(
()
=>
document
.
body
.
textContent
?.
includes
(
'Something went wrong!'
)
===
true
,
'load error toast was not shown'
)
)
assert
.
equal
(
getSaveButton
().
disabled
,
true
)
await
submitForm
()
assert
.
deepEqual
(
updates
,
[])
})
test
(
'redemption drawer blocks updates and uses localized feedback for unsuccessful responses'
,
async
()
=>
{
apiClient
.
get
=
async
()
=>
({
data
:
{
success
:
false
,
message
:
'raw server message'
},
})
await
renderDrawer
(
redemption
(
1
))
await
act
(
async
()
=>
waitForCondition
(
()
=>
document
.
body
.
textContent
?.
includes
(
'Failed to load'
)
===
true
,
'unsuccessful-load toast was not shown'
)
)
assert
.
equal
(
getSaveButton
().
disabled
,
true
)
assert
.
equal
(
document
.
body
.
textContent
?.
includes
(
'raw server message'
),
false
)
})
test
(
'redemption drawer keeps the original quota when another field changes'
,
async
()
=>
{
const
original
=
redemption
(
1
)
const
updates
:
Array
<
Record
<
string
,
unknown
>>
=
[]
apiClient
.
get
=
async
()
=>
({
data
:
{
success
:
true
,
data
:
original
}
})
apiClient
.
put
=
async
(
_url
,
data
)
=>
{
assert
.
ok
(
data
&&
typeof
data
===
'object'
)
updates
.
push
(
data
as
Record
<
string
,
unknown
>
)
return
{
data
:
{
success
:
true
,
data
:
original
}
}
}
await
renderDrawer
(
original
)
await
waitForLoadedForm
()
assert
.
equal
(
getControlByLabel
<
HTMLInputElement
>
(
'Quota (USD)'
).
value
,
'1'
)
await
changeInput
(
getControlByLabel
<
HTMLInputElement
>
(
'Name'
),
'renamed'
)
await
submitForm
()
await
act
(
async
()
=>
waitForCondition
(()
=>
updates
.
length
===
1
,
'update was not submitted'
)
)
assert
.
equal
(
updates
[
0
]?.
name
,
'renamed'
)
assert
.
equal
(
updates
[
0
]?.
quota
,
500001
)
})
test
(
'redemption drawer recalculates quota when the quota field changes'
,
async
()
=>
{
const
original
=
redemption
(
1
)
const
updates
:
Array
<
Record
<
string
,
unknown
>>
=
[]
apiClient
.
get
=
async
()
=>
({
data
:
{
success
:
true
,
data
:
original
}
})
apiClient
.
put
=
async
(
_url
,
data
)
=>
{
assert
.
ok
(
data
&&
typeof
data
===
'object'
)
updates
.
push
(
data
as
Record
<
string
,
unknown
>
)
return
{
data
:
{
success
:
true
,
data
:
original
}
}
}
await
renderDrawer
(
original
)
await
waitForLoadedForm
()
await
changeInput
(
getControlByLabel
<
HTMLInputElement
>
(
'Quota (USD)'
),
'2'
)
await
submitForm
()
await
act
(
async
()
=>
waitForCondition
(()
=>
updates
.
length
===
1
,
'update was not submitted'
)
)
assert
.
equal
(
updates
[
0
]?.
quota
,
1000000
)
})
test
(
'redemption drawer ignores an older response after switching records'
,
async
()
=>
{
const
first
=
redemption
(
1
,
500001
)
const
second
=
redemption
(
2
,
1000001
)
const
firstRequest
=
deferred
<
{
data
:
unknown
}
>
()
const
secondRequest
=
deferred
<
{
data
:
unknown
}
>
()
const
requestedUrls
:
string
[]
=
[]
const
updates
:
Array
<
Record
<
string
,
unknown
>>
=
[]
apiClient
.
get
=
(
url
)
=>
{
requestedUrls
.
push
(
url
)
if
(
url
===
'/api/redemption/1'
)
return
firstRequest
.
promise
if
(
url
===
'/api/redemption/2'
)
return
secondRequest
.
promise
throw
new
Error
(
`Unexpected GET
${
url
}
`
)
}
apiClient
.
put
=
async
(
_url
,
data
)
=>
{
assert
.
ok
(
data
&&
typeof
data
===
'object'
)
updates
.
push
(
data
as
Record
<
string
,
unknown
>
)
return
{
data
:
{
success
:
true
,
data
:
second
}
}
}
await
renderDrawer
(
first
)
await
rerenderDrawer
(
second
)
await
act
(
async
()
=>
waitForCondition
(
()
=>
requestedUrls
.
includes
(
'/api/redemption/2'
),
'second redemption was not requested'
)
)
await
act
(
async
()
=>
secondRequest
.
resolve
({
data
:
{
success
:
true
,
data
:
second
}
})
)
await
waitForLoadedForm
()
await
act
(
async
()
=>
firstRequest
.
resolve
({
data
:
{
success
:
true
,
data
:
first
}
})
)
assert
.
equal
(
getControlByLabel
<
HTMLInputElement
>
(
'Name'
).
value
,
'code-2'
)
await
changeInput
(
getControlByLabel
<
HTMLInputElement
>
(
'Name'
),
'second'
)
await
submitForm
()
await
act
(
async
()
=>
waitForCondition
(()
=>
updates
.
length
===
1
,
'update was not submitted'
)
)
assert
.
equal
(
updates
[
0
]?.
id
,
2
)
assert
.
equal
(
updates
[
0
]?.
quota
,
1000001
)
})
web/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx
View file @
e926e5ca
...
@@ -51,7 +51,12 @@ import {
...
@@ -51,7 +51,12 @@ import {
SheetTitle
,
SheetTitle
,
}
from
'@/components/ui/sheet'
}
from
'@/components/ui/sheet'
import
{
getCurrencyDisplay
,
getCurrencyLabel
}
from
'@/lib/currency'
import
{
getCurrencyDisplay
,
getCurrencyLabel
}
from
'@/lib/currency'
import
{
formatQuota
,
parseQuotaFromDollars
}
from
'@/lib/format'
import
{
formatQuota
,
getEditableQuotaStep
,
parseQuotaFromDollars
,
}
from
'@/lib/format'
import
{
handleServerError
}
from
'@/lib/handle-server-error'
import
{
addTimeToDate
}
from
'@/lib/time'
import
{
addTimeToDate
}
from
'@/lib/time'
import
{
createRedemption
,
updateRedemption
,
getRedemption
}
from
'../api'
import
{
createRedemption
,
updateRedemption
,
getRedemption
}
from
'../api'
...
@@ -63,7 +68,7 @@ import {
...
@@ -63,7 +68,7 @@ import {
transformFormDataToPayload
,
transformFormDataToPayload
,
transformRedemptionToFormDefaults
,
transformRedemptionToFormDefaults
,
}
from
'../lib'
}
from
'../lib'
import
{
type
Redemption
}
from
'../types'
import
type
{
Redemption
}
from
'../types'
import
{
useRedemptions
}
from
'./redemptions-provider'
import
{
useRedemptions
}
from
'./redemptions-provider'
type
RedemptionsMutateDrawerProps
=
{
type
RedemptionsMutateDrawerProps
=
{
...
@@ -79,8 +84,15 @@ export function RedemptionsMutateDrawer({
...
@@ -79,8 +84,15 @@ export function RedemptionsMutateDrawer({
}:
RedemptionsMutateDrawerProps
)
{
}:
RedemptionsMutateDrawerProps
)
{
const
{
t
}
=
useTranslation
()
const
{
t
}
=
useTranslation
()
const
isUpdate
=
!!
currentRow
const
isUpdate
=
!!
currentRow
const
redemptionId
=
currentRow
?.
id
const
{
triggerRefresh
}
=
useRedemptions
()
const
{
triggerRefresh
}
=
useRedemptions
()
const
[
isSubmitting
,
setIsSubmitting
]
=
useState
(
false
)
const
[
isSubmitting
,
setIsSubmitting
]
=
useState
(
false
)
const
[
redemptionLoadState
,
setRedemptionLoadState
]
=
useState
<
'idle'
|
'loading'
|
'ready'
|
'error'
>
(
'idle'
)
const
[
loadedRedemption
,
setLoadedRedemption
]
=
useState
<
Redemption
|
null
>
(
null
)
const
form
=
useForm
<
RedemptionFormValues
>
({
const
form
=
useForm
<
RedemptionFormValues
>
({
resolver
:
zodResolver
(
getRedemptionFormSchema
(
t
)),
resolver
:
zodResolver
(
getRedemptionFormSchema
(
t
)),
...
@@ -89,27 +101,76 @@ export function RedemptionsMutateDrawer({
...
@@ -89,27 +101,76 @@ export function RedemptionsMutateDrawer({
// Load existing data when updating
// Load existing data when updating
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
open
&&
isUpdate
&&
currentRow
)
{
if
(
!
open
)
{
// For update, fetch fresh data
setRedemptionLoadState
(
'idle'
)
getRedemption
(
currentRow
.
id
).
then
((
result
)
=>
{
setLoadedRedemption
(
null
)
if
(
result
.
success
&&
result
.
data
)
{
return
form
.
reset
(
transformRedemptionToFormDefaults
(
result
.
data
))
}
if
(
!
isUpdate
||
redemptionId
===
undefined
)
{
form
.
reset
(
REDEMPTION_FORM_DEFAULT_VALUES
)
setRedemptionLoadState
(
'ready'
)
setLoadedRedemption
(
null
)
return
}
let
ignoreResult
=
false
form
.
reset
(
REDEMPTION_FORM_DEFAULT_VALUES
)
setRedemptionLoadState
(
'loading'
)
setLoadedRedemption
(
null
)
void
getRedemption
(
redemptionId
)
.
then
((
result
)
=>
{
if
(
ignoreResult
)
return
if
(
!
result
.
success
||
!
result
.
data
||
result
.
data
.
id
!==
redemptionId
)
{
setRedemptionLoadState
(
'error'
)
toast
.
error
(
t
(
'Failed to load'
))
return
}
}
form
.
reset
(
transformRedemptionToFormDefaults
(
result
.
data
))
setLoadedRedemption
(
result
.
data
)
setRedemptionLoadState
(
'ready'
)
})
})
}
else
if
(
open
&&
!
isUpdate
)
{
.
catch
((
error
:
unknown
)
=>
{
// For create, reset to defaults
if
(
ignoreResult
)
return
form
.
reset
(
REDEMPTION_FORM_DEFAULT_VALUES
)
setRedemptionLoadState
(
'error'
)
handleServerError
(
error
)
})
return
()
=>
{
ignoreResult
=
true
}
}
},
[
open
,
isUpdate
,
currentRow
,
form
])
},
[
open
,
isUpdate
,
redemptionId
,
form
,
t
])
const
isUpdateReady
=
!
isUpdate
||
(
redemptionLoadState
===
'ready'
&&
loadedRedemption
?.
id
===
redemptionId
)
const
isLoadingRedemption
=
redemptionLoadState
===
'loading'
const
onSubmit
=
async
(
data
:
RedemptionFormValues
)
=>
{
const
onSubmit
=
async
(
data
:
RedemptionFormValues
)
=>
{
if
(
isUpdate
&&
(
!
currentRow
||
!
loadedRedemption
||
!
isUpdateReady
))
{
return
}
setIsSubmitting
(
true
)
setIsSubmitting
(
true
)
try
{
try
{
const
basePayload
=
transformFormDataToPayload
(
data
)
const
basePayload
=
transformFormDataToPayload
(
data
)
if
(
isUpdate
&&
currentRow
)
{
if
(
isUpdate
&&
currentRow
&&
loadedRedemption
)
{
const
quota
=
form
.
getFieldState
(
'quota_dollars'
).
isDirty
?
basePayload
.
quota
:
loadedRedemption
.
quota
const
result
=
await
updateRedemption
({
const
result
=
await
updateRedemption
({
...
basePayload
,
...
basePayload
,
quota
,
id
:
currentRow
.
id
,
id
:
currentRow
.
id
,
})
})
if
(
result
.
success
)
{
if
(
result
.
success
)
{
...
@@ -158,10 +219,17 @@ export function RedemptionsMutateDrawer({
...
@@ -158,10 +219,17 @@ export function RedemptionsMutateDrawer({
const
{
meta
:
currencyMeta
}
=
getCurrencyDisplay
()
const
{
meta
:
currencyMeta
}
=
getCurrencyDisplay
()
const
currencyLabel
=
getCurrencyLabel
()
const
currencyLabel
=
getCurrencyLabel
()
const
tokensOnly
=
currencyMeta
.
kind
===
'tokens'
const
tokensOnly
=
currencyMeta
.
kind
===
'tokens'
const
quotaStep
=
getEditableQuotaStep
()
const
quotaLabel
=
t
(
'Quota ({{currency}})'
,
{
currency
:
currencyLabel
})
const
quotaLabel
=
t
(
'Quota ({{currency}})'
,
{
currency
:
currencyLabel
})
const
quotaPlaceholder
=
tokensOnly
const
quotaPlaceholder
=
tokensOnly
?
t
(
'Enter quota in tokens'
)
?
t
(
'Enter quota in tokens'
)
:
t
(
'Enter quota in {{currency}}'
,
{
currency
:
currencyLabel
})
:
t
(
'Enter quota in {{currency}}'
,
{
currency
:
currencyLabel
})
let
submitButtonLabel
=
t
(
'Save changes'
)
if
(
isLoadingRedemption
)
{
submitButtonLabel
=
t
(
'Loading...'
)
}
else
if
(
isSubmitting
)
{
submitButtonLabel
=
t
(
'Saving...'
)
}
return
(
return
(
<
Sheet
<
Sheet
...
@@ -194,147 +262,163 @@ export function RedemptionsMutateDrawer({
...
@@ -194,147 +262,163 @@ export function RedemptionsMutateDrawer({
id=
'redemption-form'
id=
'redemption-form'
onSubmit=
{
handleSubmit
}
onSubmit=
{
handleSubmit
}
className=
{
sideDrawerFormClassName
()
}
className=
{
sideDrawerFormClassName
()
}
aria
-
busy=
{
isLoadingRedemption
}
>
>
<
SideDrawerSection
>
<
fieldset
<
FormField
disabled=
{
!
isUpdateReady
||
isSubmitting
}
control=
{
form
.
control
}
className=
'contents'
name=
'name'
>
render=
{
({
field
})
=>
(
<
SideDrawerSection
>
<
FormItem
>
<
FormField
<
FormLabel
>
{
t
(
'Name'
)
}
</
FormLabel
>
control=
{
form
.
control
}
<
FormControl
>
name=
'name'
<
Input
{
...
field
}
placeholder=
{
t
(
'Enter a name'
)
}
/>
render=
{
({
field
})
=>
(
</
FormControl
>
<
FormItem
>
<
FormDescription
>
<
FormLabel
>
{
t
(
'Name'
)
}
</
FormLabel
>
{
t
(
'Name for this redemption code (1-20 characters)'
)
}
</
FormDescription
>
<
FormMessage
/>
</
FormItem
>
)
}
/>
<
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
>
)
}
/>
<
FormField
control=
{
form
.
control
}
name=
'expired_time'
render=
{
({
field
})
=>
(
<
FormItem
>
<
FormLabel
>
{
t
(
'Expiration Time'
)
}
</
FormLabel
>
<
div
className=
'flex flex-col gap-2'
>
<
FormControl
>
<
FormControl
>
<
DateTimePicker
<
Input
{
...
field
}
placeholder=
{
t
(
'Enter a name'
)
}
/>
value=
{
field
.
value
}
onChange=
{
field
.
onChange
}
placeholder=
{
t
(
'Never expires'
)
}
/>
</
FormControl
>
</
FormControl
>
<
div
className=
'grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'
>
<
FormDescription
>
<
Button
{
t
(
'Name for this redemption code (1-20 characters)'
)
}
type=
'button'
</
FormDescription
>
variant=
'outline'
<
FormMessage
/>
size=
'sm'
</
FormItem
>
onClick=
{
()
=>
handleSetExpiry
(
0
,
0
,
0
)
}
)
}
>
/>
{
t
(
'Never'
)
}
</
Button
>
<
Button
type=
'button'
variant=
'outline'
size=
'sm'
onClick=
{
()
=>
handleSetExpiry
(
1
,
0
,
0
)
}
>
{
t
(
'1M'
)
}
</
Button
>
<
Button
type=
'button'
variant=
'outline'
size=
'sm'
onClick=
{
()
=>
handleSetExpiry
(
0
,
7
,
0
)
}
>
{
t
(
'1W'
)
}
</
Button
>
<
Button
type=
'button'
variant=
'outline'
size=
'sm'
onClick=
{
()
=>
handleSetExpiry
(
0
,
1
,
0
)
}
>
{
t
(
'1 Day'
)
}
</
Button
>
</
div
>
</
div
>
<
FormDescription
>
{
t
(
'Leave empty for never expires'
)
}
</
FormDescription
>
<
FormMessage
/>
</
FormItem
>
)
}
/>
{
!
isUpdate
&&
(
<
FormField
<
FormField
control=
{
form
.
control
}
control=
{
form
.
control
}
name=
'
count
'
name=
'
quota_dollars
'
render=
{
({
field
})
=>
(
render=
{
({
field
})
=>
(
<
FormItem
>
<
FormItem
>
<
FormLabel
>
{
t
(
'Quantity'
)
}
</
FormLabel
>
<
FormLabel
>
{
quotaLabel
}
</
FormLabel
>
<
FormControl
>
<
FormControl
>
<
Input
<
Input
{
...
field
}
{
...
field
}
type=
'number'
type=
'number'
min=
'1'
step=
{
quotaStep
}
max=
'100'
placeholder=
{
quotaPlaceholder
}
placeholder=
{
t
(
'Number of codes to create'
)
}
onChange=
{
(
e
)
=>
onChange=
{
(
e
)
=>
field
.
onChange
(
parseInt
(
e
.
target
.
value
,
10
)
||
1
)
field
.
onChange
(
Number
.
parseFloat
(
e
.
target
.
value
)
||
0
)
}
}
/>
/>
</
FormControl
>
</
FormControl
>
<
FormDescription
>
<
FormDescription
>
{
t
(
'Create multiple redemption codes at once (1-100)'
)
}
{
tokensOnly
?
t
(
'Enter the quota amount in tokens'
)
:
t
(
'
Enter
the
quota
amount
in
{{
currency
}}
'
,
{
currency
:
currencyLabel
,
})
}
</
FormDescription
>
</
FormDescription
>
<
FormMessage
/>
<
FormMessage
/>
</
FormItem
>
</
FormItem
>
)
}
)
}
/>
/>
)
}
</
SideDrawerSection
>
<
FormField
control=
{
form
.
control
}
name=
'expired_time'
render=
{
({
field
})
=>
(
<
FormItem
>
<
FormLabel
>
{
t
(
'Expiration Time'
)
}
</
FormLabel
>
<
div
className=
'flex flex-col gap-2'
>
<
FormControl
>
<
DateTimePicker
value=
{
field
.
value
}
onChange=
{
field
.
onChange
}
placeholder=
{
t
(
'Never expires'
)
}
/>
</
FormControl
>
<
div
className=
'grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'
>
<
Button
type=
'button'
variant=
'outline'
size=
'sm'
onClick=
{
()
=>
handleSetExpiry
(
0
,
0
,
0
)
}
>
{
t
(
'Never'
)
}
</
Button
>
<
Button
type=
'button'
variant=
'outline'
size=
'sm'
onClick=
{
()
=>
handleSetExpiry
(
1
,
0
,
0
)
}
>
{
t
(
'1M'
)
}
</
Button
>
<
Button
type=
'button'
variant=
'outline'
size=
'sm'
onClick=
{
()
=>
handleSetExpiry
(
0
,
7
,
0
)
}
>
{
t
(
'1W'
)
}
</
Button
>
<
Button
type=
'button'
variant=
'outline'
size=
'sm'
onClick=
{
()
=>
handleSetExpiry
(
0
,
1
,
0
)
}
>
{
t
(
'1 Day'
)
}
</
Button
>
</
div
>
</
div
>
<
FormDescription
>
{
t
(
'Leave empty for never expires'
)
}
</
FormDescription
>
<
FormMessage
/>
</
FormItem
>
)
}
/>
{
!
isUpdate
&&
(
<
FormField
control=
{
form
.
control
}
name=
'count'
render=
{
({
field
})
=>
(
<
FormItem
>
<
FormLabel
>
{
t
(
'Quantity'
)
}
</
FormLabel
>
<
FormControl
>
<
Input
{
...
field
}
type=
'number'
min=
'1'
max=
'100'
placeholder=
{
t
(
'Number of codes to create'
)
}
onChange=
{
(
e
)
=>
field
.
onChange
(
Number
.
parseInt
(
e
.
target
.
value
,
10
)
||
1
)
}
/>
</
FormControl
>
<
FormDescription
>
{
t
(
'Create multiple redemption codes at once (1-100)'
)
}
</
FormDescription
>
<
FormMessage
/>
</
FormItem
>
)
}
/>
)
}
</
SideDrawerSection
>
</
fieldset
>
</
form
>
</
form
>
</
Form
>
</
Form
>
<
SheetFooter
className=
{
sideDrawerFooterClassName
()
}
>
<
SheetFooter
className=
{
sideDrawerFooterClassName
()
}
>
<
SheetClose
render=
{
<
Button
variant=
'outline'
/>
}
>
<
SheetClose
render=
{
<
Button
variant=
'outline'
/>
}
>
{
t
(
'Close'
)
}
{
t
(
'Close'
)
}
</
SheetClose
>
</
SheetClose
>
<
Button
form=
'redemption-form'
type=
'submit'
disabled=
{
isSubmitting
}
>
<
Button
{
isSubmitting
?
t
(
'Saving...'
)
:
t
(
'Save changes'
)
}
form=
'redemption-form'
type=
'submit'
disabled=
{
isSubmitting
||
!
isUpdateReady
}
>
{
submitButtonLabel
}
</
Button
>
</
Button
>
</
SheetFooter
>
</
SheetFooter
>
</
SheetContent
>
</
SheetContent
>
...
...
web/src/features/redemption-codes/lib/redemption-form.ts
View file @
e926e5ca
...
@@ -19,13 +19,16 @@ For commercial licensing, please contact support@quantumnous.com
...
@@ -19,13 +19,16 @@ For commercial licensing, please contact support@quantumnous.com
import
type
{
TFunction
}
from
'i18next'
import
type
{
TFunction
}
from
'i18next'
import
{
z
}
from
'zod'
import
{
z
}
from
'zod'
import
{
parseQuotaFromDollars
,
quotaUnitsToDollars
}
from
'@/lib/format'
import
{
parseQuotaFromDollars
,
quotaUnitsToEditableAmount
,
}
from
'@/lib/format'
import
{
import
{
REDEMPTION_VALIDATION
,
REDEMPTION_VALIDATION
,
getRedemptionFormErrorMessages
,
getRedemptionFormErrorMessages
,
}
from
'../constants'
}
from
'../constants'
import
{
type
RedemptionFormData
,
type
Redemption
}
from
'../types'
import
type
{
RedemptionFormData
,
Redemption
}
from
'../types'
// ============================================================================
// ============================================================================
// Form Schema (use getRedemptionFormSchema(t) in components for i18n messages)
// Form Schema (use getRedemptionFormSchema(t) in components for i18n messages)
...
@@ -94,7 +97,7 @@ export function transformRedemptionToFormDefaults(
...
@@ -94,7 +97,7 @@ export function transformRedemptionToFormDefaults(
):
RedemptionFormValues
{
):
RedemptionFormValues
{
return
{
return
{
name
:
redemption
.
name
,
name
:
redemption
.
name
,
quota_dollars
:
quotaUnitsTo
Dollars
(
redemption
.
quota
),
quota_dollars
:
quotaUnitsTo
EditableAmount
(
redemption
.
quota
),
expired_time
:
expired_time
:
redemption
.
expired_time
>
0
redemption
.
expired_time
>
0
?
new
Date
(
redemption
.
expired_time
*
1000
)
?
new
Date
(
redemption
.
expired_time
*
1000
)
...
...
web/src/lib/currency.ts
View file @
e926e5ca
...
@@ -244,6 +244,23 @@ function mergeOptions(
...
@@ -244,6 +244,23 @@ function mergeOptions(
}
}
}
}
function
getFractionDigits
(
value
:
number
,
digitsLarge
:
number
,
digitsSmall
:
number
):
number
{
return
Math
.
abs
(
value
)
>=
1
?
digitsLarge
:
digitsSmall
}
/** Return the configured fraction digits for a plain currency value. */
export
function
getCurrencyFractionDigits
(
value
:
number
,
options
?:
CurrencyFormatOptions
):
number
{
const
merged
=
mergeOptions
(
options
)
return
getFractionDigits
(
value
,
merged
.
digitsLarge
,
merged
.
digitsSmall
)
}
function
removeTrailingZeros
(
str
:
string
):
string
{
function
removeTrailingZeros
(
str
:
string
):
string
{
if
(
!
str
.
includes
(
'.'
))
return
str
if
(
!
str
.
includes
(
'.'
))
return
str
return
str
.
replace
(
/
(\.[
0-9
]
*
?)
0+$/
,
'$1'
).
replace
(
/
\.
$/
,
''
)
return
str
.
replace
(
/
(\.[
0-9
]
*
?)
0+$/
,
'$1'
).
replace
(
/
\.
$/
,
''
)
...
@@ -261,7 +278,7 @@ function formatNumberWithSuffix(
...
@@ -261,7 +278,7 @@ function formatNumberWithSuffix(
return
`
${
removeTrailingZeros
(
result
.
toFixed
(
1
))}
k`
return
`
${
removeTrailingZeros
(
result
.
toFixed
(
1
))}
k`
}
}
const
digits
=
abs
>=
1
?
digitsLarge
:
digitsSmall
const
digits
=
getFractionDigits
(
value
,
digitsLarge
,
digitsSmall
)
return
removeTrailingZeros
(
value
.
toFixed
(
digits
))
return
removeTrailingZeros
(
value
.
toFixed
(
digits
))
}
}
...
@@ -300,8 +317,11 @@ function formatCurrencyValue(
...
@@ -300,8 +317,11 @@ function formatCurrencyValue(
)
)
}
}
const
digits
=
const
digits
=
getFractionDigits
(
Math
.
abs
(
value
)
>=
1
?
options
.
digitsLarge
:
options
.
digitsSmall
value
,
options
.
digitsLarge
,
options
.
digitsSmall
)
const
adjustedValue
=
adjustForMinimum
(
value
,
digits
,
options
.
minimumNonZero
)
const
adjustedValue
=
adjustForMinimum
(
value
,
digits
,
options
.
minimumNonZero
)
if
(
meta
.
kind
===
'currency'
)
{
if
(
meta
.
kind
===
'currency'
)
{
...
...
web/src/lib/format.ts
View file @
e926e5ca
...
@@ -22,6 +22,7 @@ import {
...
@@ -22,6 +22,7 @@ import {
formatCurrencyFromUSD
,
formatCurrencyFromUSD
,
formatQuotaWithCurrency
,
formatQuotaWithCurrency
,
getCurrencyDisplay
,
getCurrencyDisplay
,
getCurrencyFractionDigits
,
}
from
'./currency'
}
from
'./currency'
// ============================================================================
// ============================================================================
...
@@ -104,16 +105,44 @@ export function parseQuotaFromDollars(amount: number): number {
...
@@ -104,16 +105,44 @@ export function parseQuotaFromDollars(amount: number): number {
*/
*/
export
function
quotaUnitsToDollars
(
units
:
number
):
number
{
export
function
quotaUnitsToDollars
(
units
:
number
):
number
{
const
{
config
,
meta
}
=
getCurrencyDisplay
()
const
{
config
,
meta
}
=
getCurrencyDisplay
()
return
quotaUnitsToDisplayAmount
(
units
,
config
.
quotaPerUnit
,
meta
)
}
function
quotaUnitsToDisplayAmount
(
units
:
number
,
quotaPerUnit
:
number
,
meta
:
ReturnType
<
typeof
getCurrencyDisplay
>
[
'meta'
]
):
number
{
if
(
meta
.
kind
===
'tokens'
)
{
if
(
meta
.
kind
===
'tokens'
)
{
return
units
return
units
}
}
const
usdAmount
=
units
/
config
.
quotaPerUnit
return
(
units
/
quotaPerUnit
)
*
meta
.
exchangeRate
const
exchangeRate
=
}
meta
.
kind
===
'currency'
||
meta
.
kind
===
'custom'
?
meta
.
exchangeRate
:
1
/**
* Convert quota units to a plain number suitable for an editable input.
* Uses the same precision as quota list formatting without symbols or suffixes.
*/
export
function
quotaUnitsToEditableAmount
(
units
:
number
):
number
{
const
{
config
,
meta
}
=
getCurrencyDisplay
()
const
amount
=
quotaUnitsToDisplayAmount
(
units
,
config
.
quotaPerUnit
,
meta
)
if
(
meta
.
kind
===
'tokens'
)
{
return
Math
.
round
(
amount
)
}
return
Number
(
amount
.
toFixed
(
getCurrencyFractionDigits
(
amount
)))
}
/** Return the input step matching the configured editable quota precision. */
export
function
getEditableQuotaStep
():
number
{
const
{
meta
}
=
getCurrencyDisplay
()
if
(
meta
.
kind
===
'tokens'
)
{
return
1
}
return
usdAmount
*
exchangeRate
return
10
**
-
getCurrencyFractionDigits
(
0
)
}
}
// ============================================================================
// ============================================================================
...
...
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