fix: 修复兑换码额度精度损失 (#6685)
* fix: 修复兑换码额度精度损失(#6680) * fix(redemption): guard update data integrity
This commit is contained in:
+439
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
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)
|
||||
})
|
||||
@@ -51,7 +51,12 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
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 { createRedemption, updateRedemption, getRedemption } from '../api'
|
||||
@@ -63,7 +68,7 @@ import {
|
||||
transformFormDataToPayload,
|
||||
transformRedemptionToFormDefaults,
|
||||
} from '../lib'
|
||||
import { type Redemption } from '../types'
|
||||
import type { Redemption } from '../types'
|
||||
import { useRedemptions } from './redemptions-provider'
|
||||
|
||||
type RedemptionsMutateDrawerProps = {
|
||||
@@ -79,8 +84,15 @@ export function RedemptionsMutateDrawer({
|
||||
}: RedemptionsMutateDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const isUpdate = !!currentRow
|
||||
const redemptionId = currentRow?.id
|
||||
const { triggerRefresh } = useRedemptions()
|
||||
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>({
|
||||
resolver: zodResolver(getRedemptionFormSchema(t)),
|
||||
@@ -89,27 +101,76 @@ export function RedemptionsMutateDrawer({
|
||||
|
||||
// Load existing data when updating
|
||||
useEffect(() => {
|
||||
if (open && isUpdate && currentRow) {
|
||||
// For update, fetch fresh data
|
||||
getRedemption(currentRow.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
form.reset(transformRedemptionToFormDefaults(result.data))
|
||||
}
|
||||
})
|
||||
} else if (open && !isUpdate) {
|
||||
// For create, reset to defaults
|
||||
form.reset(REDEMPTION_FORM_DEFAULT_VALUES)
|
||||
if (!open) {
|
||||
setRedemptionLoadState('idle')
|
||||
setLoadedRedemption(null)
|
||||
return
|
||||
}
|
||||
}, [open, isUpdate, currentRow, form])
|
||||
|
||||
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')
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (ignoreResult) return
|
||||
|
||||
setRedemptionLoadState('error')
|
||||
handleServerError(error)
|
||||
})
|
||||
|
||||
return () => {
|
||||
ignoreResult = true
|
||||
}
|
||||
}, [open, isUpdate, redemptionId, form, t])
|
||||
|
||||
const isUpdateReady =
|
||||
!isUpdate ||
|
||||
(redemptionLoadState === 'ready' && loadedRedemption?.id === redemptionId)
|
||||
const isLoadingRedemption = redemptionLoadState === 'loading'
|
||||
|
||||
const onSubmit = async (data: RedemptionFormValues) => {
|
||||
if (isUpdate && (!currentRow || !loadedRedemption || !isUpdateReady)) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
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({
|
||||
...basePayload,
|
||||
quota,
|
||||
id: currentRow.id,
|
||||
})
|
||||
if (result.success) {
|
||||
@@ -158,10 +219,17 @@ export function RedemptionsMutateDrawer({
|
||||
const { meta: currencyMeta } = getCurrencyDisplay()
|
||||
const currencyLabel = getCurrencyLabel()
|
||||
const tokensOnly = currencyMeta.kind === 'tokens'
|
||||
const quotaStep = getEditableQuotaStep()
|
||||
const quotaLabel = t('Quota ({{currency}})', { currency: currencyLabel })
|
||||
const quotaPlaceholder = tokensOnly
|
||||
? t('Enter quota in tokens')
|
||||
: t('Enter quota in {{currency}}', { currency: currencyLabel })
|
||||
let submitButtonLabel = t('Save changes')
|
||||
if (isLoadingRedemption) {
|
||||
submitButtonLabel = t('Loading...')
|
||||
} else if (isSubmitting) {
|
||||
submitButtonLabel = t('Saving...')
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
@@ -194,147 +262,163 @@ export function RedemptionsMutateDrawer({
|
||||
id='redemption-form'
|
||||
onSubmit={handleSubmit}
|
||||
className={sideDrawerFormClassName()}
|
||||
aria-busy={isLoadingRedemption}
|
||||
>
|
||||
<SideDrawerSection>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t('Enter a name')} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{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>
|
||||
<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 && (
|
||||
<fieldset
|
||||
disabled={!isUpdateReady || isSubmitting}
|
||||
className='contents'
|
||||
>
|
||||
<SideDrawerSection>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='count'
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Quantity')}</FormLabel>
|
||||
<FormLabel>{t('Name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type='number'
|
||||
min='1'
|
||||
max='100'
|
||||
placeholder={t('Number of codes to create')}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
/>
|
||||
<Input {...field} placeholder={t('Enter a name')} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('Create multiple redemption codes at once (1-100)')}
|
||||
{t('Name for this redemption code (1-20 characters)')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SideDrawerSection>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='quota_dollars'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{quotaLabel}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type='number'
|
||||
step={quotaStep}
|
||||
placeholder={quotaPlaceholder}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
Number.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>
|
||||
<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>
|
||||
<SheetFooter className={sideDrawerFooterClassName()}>
|
||||
<SheetClose render={<Button variant='outline' />}>
|
||||
{t('Close')}
|
||||
</SheetClose>
|
||||
<Button form='redemption-form' type='submit' disabled={isSubmitting}>
|
||||
{isSubmitting ? t('Saving...') : t('Save changes')}
|
||||
<Button
|
||||
form='redemption-form'
|
||||
type='submit'
|
||||
disabled={isSubmitting || !isUpdateReady}
|
||||
>
|
||||
{submitButtonLabel}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
@@ -19,13 +19,16 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import type { TFunction } from 'i18next'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
|
||||
import {
|
||||
parseQuotaFromDollars,
|
||||
quotaUnitsToEditableAmount,
|
||||
} from '@/lib/format'
|
||||
|
||||
import {
|
||||
REDEMPTION_VALIDATION,
|
||||
getRedemptionFormErrorMessages,
|
||||
} 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)
|
||||
@@ -94,7 +97,7 @@ export function transformRedemptionToFormDefaults(
|
||||
): RedemptionFormValues {
|
||||
return {
|
||||
name: redemption.name,
|
||||
quota_dollars: quotaUnitsToDollars(redemption.quota),
|
||||
quota_dollars: quotaUnitsToEditableAmount(redemption.quota),
|
||||
expired_time:
|
||||
redemption.expired_time > 0
|
||||
? new Date(redemption.expired_time * 1000)
|
||||
|
||||
+23
-3
@@ -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 {
|
||||
if (!str.includes('.')) return str
|
||||
return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '')
|
||||
@@ -261,7 +278,7 @@ function formatNumberWithSuffix(
|
||||
return `${removeTrailingZeros(result.toFixed(1))}k`
|
||||
}
|
||||
|
||||
const digits = abs >= 1 ? digitsLarge : digitsSmall
|
||||
const digits = getFractionDigits(value, digitsLarge, digitsSmall)
|
||||
return removeTrailingZeros(value.toFixed(digits))
|
||||
}
|
||||
|
||||
@@ -300,8 +317,11 @@ function formatCurrencyValue(
|
||||
)
|
||||
}
|
||||
|
||||
const digits =
|
||||
Math.abs(value) >= 1 ? options.digitsLarge : options.digitsSmall
|
||||
const digits = getFractionDigits(
|
||||
value,
|
||||
options.digitsLarge,
|
||||
options.digitsSmall
|
||||
)
|
||||
const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero)
|
||||
|
||||
if (meta.kind === 'currency') {
|
||||
|
||||
+33
-4
@@ -22,6 +22,7 @@ import {
|
||||
formatCurrencyFromUSD,
|
||||
formatQuotaWithCurrency,
|
||||
getCurrencyDisplay,
|
||||
getCurrencyFractionDigits,
|
||||
} from './currency'
|
||||
|
||||
// ============================================================================
|
||||
@@ -104,16 +105,44 @@ export function parseQuotaFromDollars(amount: number): number {
|
||||
*/
|
||||
export function quotaUnitsToDollars(units: number): number {
|
||||
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') {
|
||||
return units
|
||||
}
|
||||
|
||||
const usdAmount = units / config.quotaPerUnit
|
||||
const exchangeRate =
|
||||
meta.kind === 'currency' || meta.kind === 'custom' ? meta.exchangeRate : 1
|
||||
return (units / quotaPerUnit) * meta.exchangeRate
|
||||
}
|
||||
|
||||
return usdAmount * exchangeRate
|
||||
/**
|
||||
* 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 10 ** -getCurrencyFractionDigits(0)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user