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,
|
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 {
|
|||||||
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({
|
|||||||
}: 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({
|
|||||||
|
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else if (open && !isUpdate) {
|
|
||||||
// For create, reset to defaults
|
|
||||||
form.reset(REDEMPTION_FORM_DEFAULT_VALUES)
|
|
||||||
}
|
}
|
||||||
}, [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) => {
|
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({
|
|||||||
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({
|
|||||||
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>
|
|
||||||
<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 && (
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='count'
|
name='name'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('Quantity')}</FormLabel>
|
<FormLabel>{t('Name')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} placeholder={t('Enter a name')} />
|
||||||
{...field}
|
|
||||||
type='number'
|
|
||||||
min='1'
|
|
||||||
max='100'
|
|
||||||
placeholder={t('Number of codes to create')}
|
|
||||||
onChange={(e) =>
|
|
||||||
field.onChange(parseInt(e.target.value, 10) || 1)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t('Create multiple redemption codes at once (1-100)')}
|
{t('Name for this redemption code (1-20 characters)')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</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>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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(
|
|||||||
): RedemptionFormValues {
|
): RedemptionFormValues {
|
||||||
return {
|
return {
|
||||||
name: redemption.name,
|
name: redemption.name,
|
||||||
quota_dollars: quotaUnitsToDollars(redemption.quota),
|
quota_dollars: quotaUnitsToEditableAmount(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)
|
||||||
|
|||||||
+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 {
|
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(
|
|||||||
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(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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') {
|
||||||
|
|||||||
+33
-4
@@ -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 {
|
|||||||
*/
|
*/
|
||||||
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
|
|
||||||
|
|
||||||
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