fix: consolidate Waffo payment settings save flow (#5110)

This commit is contained in:
Seefs
2026-05-26 12:32:05 +08:00
committed by GitHub
parent f223db9330
commit c91ba0c4eb
3 changed files with 719 additions and 514 deletions
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Code2, Eye, ShieldAlert } from 'lucide-react'
@@ -65,11 +65,14 @@ import {
normalizeJsonForComparison,
removeTrailingSlash,
} from './utils'
import { saveWaffoPancakeConfig } from './waffo-pancake-api'
import {
WaffoPancakeSettingsSection,
type WaffoPancakeBinding,
type WaffoPancakeSettingsValues,
} from './waffo-pancake-settings-section'
import {
type PayMethod,
WaffoSettingsSection,
type WaffoSettingsValues,
} from './waffo-settings-section'
@@ -138,9 +141,31 @@ const paymentSchema = z.object({
})
}
}),
WaffoEnabled: z.boolean(),
WaffoApiKey: z.string(),
WaffoPrivateKey: z.string(),
WaffoPublicCert: z.string(),
WaffoSandboxPublicCert: z.string(),
WaffoSandboxApiKey: z.string(),
WaffoSandboxPrivateKey: z.string(),
WaffoSandbox: z.boolean(),
WaffoMerchantId: z.string(),
WaffoCurrency: z.string(),
WaffoUnitPrice: z.coerce.number().min(0),
WaffoMinTopUp: z.coerce.number().min(1),
WaffoNotifyUrl: z.string(),
WaffoReturnUrl: z.string(),
WaffoPancakeMerchantID: z.string(),
WaffoPancakePrivateKey: z.string(),
WaffoPancakeReturnURL: z.string(),
})
type PaymentFormValues = z.infer<typeof paymentSchema>
type WaffoFormFieldValues = Omit<WaffoSettingsValues, 'WaffoPayMethods'>
type PaymentBaseFormValues = Omit<
PaymentFormValues,
keyof WaffoFormFieldValues | keyof WaffoPancakeSettingsValues
>
const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1'
@@ -152,7 +177,7 @@ type PaymentComplianceDefaults = {
}
type PaymentSettingsSectionProps = {
defaultValues: PaymentFormValues
defaultValues: PaymentBaseFormValues
waffoDefaultValues: WaffoSettingsValues
waffoPancakeDefaultValues: WaffoPancakeSettingsValues
waffoPancakeProvisionedStoreID?: string
@@ -160,6 +185,15 @@ type PaymentSettingsSectionProps = {
complianceDefaults: PaymentComplianceDefaults
}
function parseWaffoPayMethods(value: string): PayMethod[] {
try {
const parsed = JSON.parse(value || '[]')
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
export function PaymentSettingsSection({
defaultValues,
waffoDefaultValues,
@@ -171,10 +205,18 @@ export function PaymentSettingsSection({
const { t } = useTranslation()
const queryClient = useQueryClient()
const updateOption = useUpdateOption()
const initialRef = React.useRef(defaultValues)
const initialFormValues = React.useMemo<PaymentFormValues>(
() => ({
...defaultValues,
...waffoDefaultValues,
...waffoPancakeDefaultValues,
}),
[defaultValues, waffoDefaultValues, waffoPancakeDefaultValues]
)
const initialRef = React.useRef(initialFormValues)
const defaultsSignature = React.useMemo(
() => JSON.stringify(defaultValues),
[defaultValues]
() => JSON.stringify(initialFormValues),
[initialFormValues]
)
const [payMethodsVisualMode, setPayMethodsVisualMode] = React.useState(true)
@@ -185,6 +227,32 @@ export function PaymentSettingsSection({
const [creemProductsVisualMode, setCreemProductsVisualMode] =
React.useState(true)
const [showComplianceDialog, setShowComplianceDialog] = React.useState(false)
const [waffoPayMethods, setWaffoPayMethods] = React.useState<PayMethod[]>(
() => parseWaffoPayMethods(waffoDefaultValues.WaffoPayMethods)
)
const [waffoPancakeSelection, setWaffoPancakeSelection] =
React.useState<WaffoPancakeBinding>({
storeID: waffoPancakeProvisionedStoreID ?? '',
productID: waffoPancakeProvisionedProductID ?? '',
})
const [waffoPancakeSavedBinding, setWaffoPancakeSavedBinding] =
React.useState<WaffoPancakeBinding>({
storeID: waffoPancakeProvisionedStoreID ?? '',
productID: waffoPancakeProvisionedProductID ?? '',
})
React.useEffect(() => {
setWaffoPayMethods(parseWaffoPayMethods(waffoDefaultValues.WaffoPayMethods))
}, [waffoDefaultValues.WaffoPayMethods])
React.useEffect(() => {
const nextBinding = {
storeID: waffoPancakeProvisionedStoreID ?? '',
productID: waffoPancakeProvisionedProductID ?? '',
}
setWaffoPancakeSelection(nextBinding)
setWaffoPancakeSavedBinding(nextBinding)
}, [waffoPancakeProvisionedProductID, waffoPancakeProvisionedStoreID])
const complianceStatements = React.useMemo(
() => [
@@ -260,18 +328,63 @@ export function PaymentSettingsSection({
},
})
const form = useForm({
resolver: zodResolver(paymentSchema),
const form = useForm<PaymentFormValues>({
resolver: zodResolver(paymentSchema) as Resolver<PaymentFormValues>,
mode: 'onChange', // Enable real-time validation
defaultValues: {
...defaultValues,
PayMethods: formatJsonForEditor(defaultValues.PayMethods),
AmountOptions: formatJsonForEditor(defaultValues.AmountOptions),
AmountDiscount: formatJsonForEditor(defaultValues.AmountDiscount),
CreemProducts: formatJsonForEditor(defaultValues.CreemProducts),
...initialFormValues,
PayMethods: formatJsonForEditor(initialFormValues.PayMethods),
AmountOptions: formatJsonForEditor(initialFormValues.AmountOptions),
AmountDiscount: formatJsonForEditor(initialFormValues.AmountDiscount),
CreemProducts: formatJsonForEditor(initialFormValues.CreemProducts),
},
})
const { isSubmitting } = form.formState
const setPaymentValue = React.useCallback(
(
key: keyof PaymentFormValues,
value: PaymentFormValues[keyof PaymentFormValues]
) => {
form.setValue(
key as Parameters<typeof form.setValue>[0],
value as Parameters<typeof form.setValue>[1],
{
shouldDirty: true,
shouldValidate: true,
}
)
},
[form]
)
const setWaffoValue = React.useCallback(
<K extends keyof WaffoFormFieldValues>(
key: K,
value: WaffoFormFieldValues[K]
) => {
setPaymentValue(
key as keyof PaymentFormValues,
value as PaymentFormValues[keyof PaymentFormValues]
)
},
[setPaymentValue]
)
const setWaffoPancakeValue = React.useCallback(
<K extends keyof WaffoPancakeSettingsValues>(
key: K,
value: WaffoPancakeSettingsValues[K]
) => {
setPaymentValue(
key as keyof PaymentFormValues,
value as PaymentFormValues[keyof PaymentFormValues]
)
},
[setPaymentValue]
)
React.useEffect(() => {
const parsedDefaults = JSON.parse(defaultsSignature) as PaymentFormValues
initialRef.current = parsedDefaults
@@ -305,6 +418,26 @@ export function PaymentSettingsSection({
CreemWebhookSecret: values.CreemWebhookSecret.trim(),
CreemTestMode: values.CreemTestMode,
CreemProducts: values.CreemProducts.trim(),
WaffoEnabled: values.WaffoEnabled,
WaffoSandbox: values.WaffoSandbox,
WaffoMerchantId: values.WaffoMerchantId.trim(),
WaffoCurrency: values.WaffoCurrency.trim() || 'USD',
WaffoUnitPrice: values.WaffoUnitPrice,
WaffoMinTopUp: values.WaffoMinTopUp,
WaffoNotifyUrl: values.WaffoNotifyUrl.trim(),
WaffoReturnUrl: values.WaffoReturnUrl.trim(),
WaffoPublicCert: values.WaffoPublicCert.trim(),
WaffoSandboxPublicCert: values.WaffoSandboxPublicCert.trim(),
WaffoApiKey: values.WaffoApiKey.trim(),
WaffoPrivateKey: values.WaffoPrivateKey.trim(),
WaffoSandboxApiKey: values.WaffoSandboxApiKey.trim(),
WaffoSandboxPrivateKey: values.WaffoSandboxPrivateKey.trim(),
WaffoPayMethods: JSON.stringify(waffoPayMethods),
WaffoPancakeMerchantID: values.WaffoPancakeMerchantID.trim(),
WaffoPancakePrivateKey: values.WaffoPancakePrivateKey.trim(),
WaffoPancakeReturnURL: removeTrailingSlash(
values.WaffoPancakeReturnURL.trim()
),
}
const initial = {
@@ -330,6 +463,28 @@ export function PaymentSettingsSection({
CreemWebhookSecret: initialRef.current.CreemWebhookSecret.trim(),
CreemTestMode: initialRef.current.CreemTestMode,
CreemProducts: initialRef.current.CreemProducts.trim(),
WaffoEnabled: initialRef.current.WaffoEnabled,
WaffoSandbox: initialRef.current.WaffoSandbox,
WaffoMerchantId: initialRef.current.WaffoMerchantId.trim(),
WaffoCurrency: initialRef.current.WaffoCurrency.trim() || 'USD',
WaffoUnitPrice: initialRef.current.WaffoUnitPrice,
WaffoMinTopUp: initialRef.current.WaffoMinTopUp,
WaffoNotifyUrl: initialRef.current.WaffoNotifyUrl.trim(),
WaffoReturnUrl: initialRef.current.WaffoReturnUrl.trim(),
WaffoPublicCert: initialRef.current.WaffoPublicCert.trim(),
WaffoSandboxPublicCert: initialRef.current.WaffoSandboxPublicCert.trim(),
WaffoApiKey: initialRef.current.WaffoApiKey.trim(),
WaffoPrivateKey: initialRef.current.WaffoPrivateKey.trim(),
WaffoSandboxApiKey: initialRef.current.WaffoSandboxApiKey.trim(),
WaffoSandboxPrivateKey: initialRef.current.WaffoSandboxPrivateKey.trim(),
WaffoPayMethods: JSON.stringify(
parseWaffoPayMethods(waffoDefaultValues.WaffoPayMethods)
),
WaffoPancakeMerchantID: initialRef.current.WaffoPancakeMerchantID.trim(),
WaffoPancakePrivateKey: initialRef.current.WaffoPancakePrivateKey.trim(),
WaffoPancakeReturnURL: removeTrailingSlash(
initialRef.current.WaffoPancakeReturnURL.trim()
),
}
const updates: Array<{ key: string; value: string | number | boolean }> = []
@@ -455,9 +610,171 @@ export function PaymentSettingsSection({
updates.push({ key: 'CreemProducts', value: sanitized.CreemProducts })
}
if (sanitized.WaffoEnabled !== initial.WaffoEnabled) {
updates.push({ key: 'WaffoEnabled', value: sanitized.WaffoEnabled })
}
if (sanitized.WaffoSandbox !== initial.WaffoSandbox) {
updates.push({ key: 'WaffoSandbox', value: sanitized.WaffoSandbox })
}
if (sanitized.WaffoMerchantId !== initial.WaffoMerchantId) {
updates.push({ key: 'WaffoMerchantId', value: sanitized.WaffoMerchantId })
}
if (sanitized.WaffoCurrency !== initial.WaffoCurrency) {
updates.push({ key: 'WaffoCurrency', value: sanitized.WaffoCurrency })
}
if (sanitized.WaffoUnitPrice !== initial.WaffoUnitPrice) {
updates.push({ key: 'WaffoUnitPrice', value: sanitized.WaffoUnitPrice })
}
if (sanitized.WaffoMinTopUp !== initial.WaffoMinTopUp) {
updates.push({ key: 'WaffoMinTopUp', value: sanitized.WaffoMinTopUp })
}
if (sanitized.WaffoNotifyUrl !== initial.WaffoNotifyUrl) {
updates.push({ key: 'WaffoNotifyUrl', value: sanitized.WaffoNotifyUrl })
}
if (sanitized.WaffoReturnUrl !== initial.WaffoReturnUrl) {
updates.push({ key: 'WaffoReturnUrl', value: sanitized.WaffoReturnUrl })
}
if (sanitized.WaffoPublicCert !== initial.WaffoPublicCert) {
updates.push({ key: 'WaffoPublicCert', value: sanitized.WaffoPublicCert })
}
if (sanitized.WaffoSandboxPublicCert !== initial.WaffoSandboxPublicCert) {
updates.push({
key: 'WaffoSandboxPublicCert',
value: sanitized.WaffoSandboxPublicCert,
})
}
if (sanitized.WaffoApiKey) {
updates.push({ key: 'WaffoApiKey', value: sanitized.WaffoApiKey })
}
if (sanitized.WaffoPrivateKey) {
updates.push({ key: 'WaffoPrivateKey', value: sanitized.WaffoPrivateKey })
}
if (sanitized.WaffoSandboxApiKey) {
updates.push({
key: 'WaffoSandboxApiKey',
value: sanitized.WaffoSandboxApiKey,
})
}
if (sanitized.WaffoSandboxPrivateKey) {
updates.push({
key: 'WaffoSandboxPrivateKey',
value: sanitized.WaffoSandboxPrivateKey,
})
}
if (
normalizeJsonForComparison(sanitized.WaffoPayMethods) !==
normalizeJsonForComparison(initial.WaffoPayMethods)
) {
updates.push({ key: 'WaffoPayMethods', value: sanitized.WaffoPayMethods })
}
const hasWaffoPancakeChanges =
sanitized.WaffoPancakeMerchantID !== initial.WaffoPancakeMerchantID ||
sanitized.WaffoPancakePrivateKey.length > 0 ||
sanitized.WaffoPancakeReturnURL !== initial.WaffoPancakeReturnURL ||
waffoPancakeSelection.storeID !== waffoPancakeSavedBinding.storeID ||
waffoPancakeSelection.productID !== waffoPancakeSavedBinding.productID
if (updates.length === 0 && !hasWaffoPancakeChanges) {
toast.info(t('No changes to save'))
return
}
for (const update of updates) {
await updateOption.mutateAsync(update)
}
if (!hasWaffoPancakeChanges) {
return
}
if (!sanitized.WaffoPancakeMerchantID) {
toast.error(t('Merchant ID is required'))
return
}
if (!waffoPancakeSelection.storeID || !waffoPancakeSelection.productID) {
toast.error(t('Pick or create both a store and a product before saving.'))
return
}
try {
const body = await saveWaffoPancakeConfig({
merchantID: sanitized.WaffoPancakeMerchantID,
privateKey: sanitized.WaffoPancakePrivateKey,
returnURL: sanitized.WaffoPancakeReturnURL,
storeID: waffoPancakeSelection.storeID,
productID: waffoPancakeSelection.productID,
})
if (
body?.message === 'success' &&
typeof body.data === 'object' &&
body.data
) {
const saved = body.data as { product_id: string; store_id: string }
const savedBinding = {
storeID: saved.store_id,
productID: saved.product_id,
}
setWaffoPancakeSavedBinding(savedBinding)
setWaffoPancakeSelection(savedBinding)
queryClient.invalidateQueries({ queryKey: ['system-options'] })
toast.success(t('Waffo Pancake settings saved'))
return
}
const reason = typeof body?.data === 'string' ? body.data : undefined
toast.error(
reason
? `${t('Waffo Pancake save failed')}: ${reason}`
: t('Waffo Pancake save failed')
)
} catch (error) {
toast.error(
`${t('Waffo Pancake save failed')}: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
const currentFormValues = form.watch()
const waffoValues: WaffoSettingsValues = {
WaffoEnabled: currentFormValues.WaffoEnabled,
WaffoApiKey: currentFormValues.WaffoApiKey,
WaffoPrivateKey: currentFormValues.WaffoPrivateKey,
WaffoPublicCert: currentFormValues.WaffoPublicCert,
WaffoSandboxPublicCert: currentFormValues.WaffoSandboxPublicCert,
WaffoSandboxApiKey: currentFormValues.WaffoSandboxApiKey,
WaffoSandboxPrivateKey: currentFormValues.WaffoSandboxPrivateKey,
WaffoSandbox: currentFormValues.WaffoSandbox,
WaffoMerchantId: currentFormValues.WaffoMerchantId,
WaffoCurrency: currentFormValues.WaffoCurrency,
WaffoUnitPrice: currentFormValues.WaffoUnitPrice,
WaffoMinTopUp: currentFormValues.WaffoMinTopUp,
WaffoNotifyUrl: currentFormValues.WaffoNotifyUrl,
WaffoReturnUrl: currentFormValues.WaffoReturnUrl,
WaffoPayMethods: JSON.stringify(waffoPayMethods),
}
const waffoPancakeValues: WaffoPancakeSettingsValues = {
WaffoPancakeMerchantID: currentFormValues.WaffoPancakeMerchantID,
WaffoPancakePrivateKey: currentFormValues.WaffoPancakePrivateKey,
WaffoPancakeReturnURL: currentFormValues.WaffoPancakeReturnURL,
}
return (
@@ -525,7 +842,6 @@ export function PaymentSettingsSection({
onConfirm={() => confirmComplianceMutation.mutate()}
/>
{/* eslint-disable react-hooks/refs */}
<Form {...form}>
<SettingsForm
onSubmit={form.handleSubmit(onSubmit)}
@@ -537,7 +853,7 @@ export function PaymentSettingsSection({
>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
isSaving={updateOption.isPending || isSubmitting}
saveLabel='Save all settings'
/>
<div className='space-y-4'>
@@ -1206,21 +1522,28 @@ export function PaymentSettingsSection({
)}
/>
</div>
<Separator />
<WaffoPancakeSettingsSection
defaultValues={waffoPancakeDefaultValues}
values={waffoPancakeValues}
onValueChange={setWaffoPancakeValue}
selectedBinding={waffoPancakeSelection}
savedBinding={waffoPancakeSavedBinding}
onSelectedBindingChange={setWaffoPancakeSelection}
/>
<Separator />
<WaffoSettingsSection
values={waffoValues}
onValueChange={setWaffoValue}
payMethods={waffoPayMethods}
onPayMethodsChange={setWaffoPayMethods}
/>
</SettingsForm>
</Form>
<Separator />
<WaffoPancakeSettingsSection
defaultValues={waffoPancakeDefaultValues}
provisionedStoreID={waffoPancakeProvisionedStoreID}
provisionedProductID={waffoPancakeProvisionedProductID}
/>
<Separator />
<WaffoSettingsSection defaultValues={waffoDefaultValues} />
{/* eslint-enable react-hooks/refs */}
</SettingsSection>
)
}
@@ -17,20 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { SetStateAction } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -41,8 +31,6 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { SettingsForm } from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { removeTrailingSlash } from './utils'
import {
type CatalogStore,
@@ -50,23 +38,29 @@ import {
type PairResult,
createWaffoPancakePair,
listWaffoPancakeCatalog,
saveWaffoPancakeConfig,
} from './waffo-pancake-api'
// Only operator-typed fields. Nothing else lands in OptionMap until Save.
const waffoPancakeSchema = z.object({
WaffoPancakeMerchantID: z.string(),
WaffoPancakePrivateKey: z.string(),
})
export type WaffoPancakeSettingsValues = z.infer<typeof waffoPancakeSchema> & {
export type WaffoPancakeSettingsValues = {
WaffoPancakeMerchantID: string
WaffoPancakePrivateKey: string
WaffoPancakeReturnURL: string
}
export interface WaffoPancakeBinding {
storeID: string
productID: string
}
interface Props {
defaultValues: WaffoPancakeSettingsValues
provisionedStoreID?: string
provisionedProductID?: string
values: WaffoPancakeSettingsValues
onValueChange: <K extends keyof WaffoPancakeSettingsValues>(
key: K,
value: WaffoPancakeSettingsValues[K]
) => void
selectedBinding: WaffoPancakeBinding
savedBinding: WaffoPancakeBinding
onSelectedBindingChange: (value: SetStateAction<WaffoPancakeBinding>) => void
}
const PANCAKE_DASHBOARD_URL = 'https://pancake.waffo.ai/merchant/dashboard'
@@ -74,35 +68,29 @@ const DEFAULT_NEW_STORE_NAME = 'new-api-store'
const DEFAULT_NEW_PRODUCT_NAME = 'new-api-charge-product'
const DEFAULT_NEW_PAIR_NAME = `${DEFAULT_NEW_STORE_NAME} + ${DEFAULT_NEW_PRODUCT_NAME}`
export function WaffoPancakeSettingsSection(props: Props) {
export function WaffoPancakeSettingsSection({
defaultValues,
values,
onValueChange,
selectedBinding,
savedBinding,
onSelectedBindingChange,
}: Props) {
const { t } = useTranslation()
const [storeID, setStoreID] = React.useState(props.provisionedStoreID ?? '')
const [productID, setProductID] = React.useState(
props.provisionedProductID ?? ''
)
const [phase, setPhase] = React.useState<'idle' | 'verifying' | 'saving'>(
'idle'
)
const [phase, setPhase] = React.useState<'idle' | 'verifying'>('idle')
const [catalog, setCatalog] = React.useState<CatalogStore[]>([])
// Seed dropdowns from saved bindings so they render on first paint instead
// of waiting for the async catalog fetch to confirm them.
const [chosenStoreID, setChosenStoreID] = React.useState<string>(
props.provisionedStoreID ?? ''
)
const [chosenProductID, setChosenProductID] = React.useState<string>(
props.provisionedProductID ?? ''
)
const [returnURL, setReturnURL] = React.useState(
props.defaultValues.WaffoPancakeReturnURL ?? ''
)
const [creatingPair, setCreatingPair] = React.useState(false)
const chosenStoreID = selectedBinding.storeID
const chosenProductID = selectedBinding.productID
const storeID = savedBinding.storeID
const productID = savedBinding.productID
const returnURL = values.WaffoPancakeReturnURL
const initialRef = React.useRef(props.defaultValues)
const initialRef = React.useRef(defaultValues)
const defaultsSignature = React.useMemo(
() => JSON.stringify(props.defaultValues),
[props.defaultValues]
() => JSON.stringify(defaultValues),
[defaultValues]
)
// "merchantID|privateKey" of the last verified pair; debounced verify
@@ -110,15 +98,6 @@ export function WaffoPancakeSettingsSection(props: Props) {
const lastVerifiedSignature = React.useRef('')
const fetchSerialRef = React.useRef(0)
const form = useForm({
resolver: zodResolver(waffoPancakeSchema),
mode: 'onChange',
defaultValues: {
WaffoPancakeMerchantID: props.defaultValues.WaffoPancakeMerchantID,
WaffoPancakePrivateKey: props.defaultValues.WaffoPancakePrivateKey,
},
})
// Mount-only — never re-sync from props after the first render. The
// backend strips PrivateKey from GET /api/option/, so a re-sync would
// wipe whatever the operator just typed.
@@ -128,21 +107,8 @@ export function WaffoPancakeSettingsSection(props: Props) {
initialRef.current = parsed
if (didMountRef.current) return
didMountRef.current = true
form.reset({
WaffoPancakeMerchantID: parsed.WaffoPancakeMerchantID,
WaffoPancakePrivateKey: parsed.WaffoPancakePrivateKey,
})
setReturnURL(parsed.WaffoPancakeReturnURL ?? '')
lastVerifiedSignature.current = `${parsed.WaffoPancakeMerchantID.trim()}|${parsed.WaffoPancakePrivateKey.trim()}`
}, [defaultsSignature, form])
React.useEffect(() => {
setStoreID(props.provisionedStoreID ?? '')
}, [props.provisionedStoreID])
React.useEffect(() => {
setProductID(props.provisionedProductID ?? '')
}, [props.provisionedProductID])
}, [defaultsSignature])
const productsForChosenStore = React.useMemo(() => {
if (!chosenStoreID) return []
@@ -185,7 +151,7 @@ export function WaffoPancakeSettingsSection(props: Props) {
preselect?: { storeID?: string; productID?: string }
) => {
const serial = ++fetchSerialRef.current
let stores: CatalogStore[] = []
let stores: CatalogStore[]
try {
const body = await listWaffoPancakeCatalog(merchantID, privateKey)
if (serial !== fetchSerialRef.current) return
@@ -221,8 +187,10 @@ export function WaffoPancakeSettingsSection(props: Props) {
setCatalog(stores)
if (preselect) {
setChosenStoreID(preselect.storeID ?? '')
setChosenProductID(preselect.productID ?? '')
onSelectedBindingChange({
storeID: preselect.storeID ?? '',
productID: preselect.productID ?? '',
})
} else {
// Default anchor: bound product if found, else first product of
// the first store with any — saves a click for new operators.
@@ -230,28 +198,31 @@ export function WaffoPancakeSettingsSection(props: Props) {
s.onetimeProducts.some((p) => p.id === productID)
)
if (boundStore && productID) {
setChosenStoreID(boundStore.id)
setChosenProductID(productID)
onSelectedBindingChange({
storeID: boundStore.id,
productID,
})
} else {
const storeWithProducts = stores.find(
(s) => s.onetimeProducts.length > 0
)
if (storeWithProducts) {
setChosenStoreID(storeWithProducts.id)
setChosenProductID(storeWithProducts.onetimeProducts[0].id)
onSelectedBindingChange({
storeID: storeWithProducts.id,
productID: storeWithProducts.onetimeProducts[0].id,
})
} else {
setChosenStoreID('')
setChosenProductID('')
onSelectedBindingChange({ storeID: '', productID: '' })
}
}
}
setPhase('idle')
},
[productID, t]
[onSelectedBindingChange, productID, t]
)
const watchedMerchantID = form.watch('WaffoPancakeMerchantID') || ''
const watchedPrivateKey = form.watch('WaffoPancakePrivateKey') || ''
const watchedMerchantID = values.WaffoPancakeMerchantID || ''
const watchedPrivateKey = values.WaffoPancakePrivateKey || ''
React.useEffect(() => {
const m = watchedMerchantID.trim()
const k = watchedPrivateKey.trim()
@@ -272,20 +243,23 @@ export function WaffoPancakeSettingsSection(props: Props) {
const initialLoadRef = React.useRef(false)
React.useEffect(() => {
if (initialLoadRef.current) return
if (!props.defaultValues.WaffoPancakeMerchantID.trim()) return
if (!defaultValues.WaffoPancakeMerchantID.trim()) return
initialLoadRef.current = true
setPhase('verifying')
void verifyAndFetchCatalog('', '')
}, [props.defaultValues.WaffoPancakeMerchantID, verifyAndFetchCatalog])
const timer = window.setTimeout(() => {
setPhase('verifying')
void verifyAndFetchCatalog('', '')
}, 0)
return () => window.clearTimeout(timer)
}, [defaultValues.WaffoPancakeMerchantID, verifyAndFetchCatalog])
// Returns typed creds when the operator edited either field; otherwise
// blanks so the backend falls back to persisted creds. Without this,
// returning admins (saved merchant ID but empty key field) would send
// a mixed-state body that the backend rejects.
const readCreds = () => {
const formMerchant = (form.getValues('WaffoPancakeMerchantID') || '').trim()
const formKey = (form.getValues('WaffoPancakePrivateKey') || '').trim()
const saved = (props.defaultValues.WaffoPancakeMerchantID || '').trim()
const formMerchant = (values.WaffoPancakeMerchantID || '').trim()
const formKey = (values.WaffoPancakePrivateKey || '').trim()
const saved = (defaultValues.WaffoPancakeMerchantID || '').trim()
const edited = formMerchant !== saved || formKey.length > 0
if (!edited) return { merchantID: '', privateKey: '' }
return { merchantID: formMerchant, privateKey: formKey }
@@ -364,66 +338,12 @@ export function WaffoPancakeSettingsSection(props: Props) {
}
}
const handleSave = async () => {
// Sends raw form values (not readCreds): SaveWaffoPancakeConfig already
// treats a blank PrivateKey as "keep existing", and MerchantID stays
// populated from props for returning admins.
const merchantID = (form.getValues('WaffoPancakeMerchantID') || '').trim()
const privateKey = (form.getValues('WaffoPancakePrivateKey') || '').trim()
if (!merchantID) {
toast.error(t('Merchant ID is required'))
return
}
if (!chosenStoreID || !chosenProductID) {
toast.error(t('Pick or create both a store and a product before saving.'))
return
}
setPhase('saving')
try {
const body = await saveWaffoPancakeConfig({
merchantID,
privateKey,
returnURL: removeTrailingSlash(returnURL.trim()),
storeID: chosenStoreID,
productID: chosenProductID,
})
if (
body?.message === 'success' &&
typeof body.data === 'object' &&
body.data
) {
const saved = body.data as { product_id: string; store_id: string }
setStoreID(saved.store_id)
setProductID(saved.product_id)
toast.success(t('Waffo Pancake settings saved'))
} else {
const reason = typeof body?.data === 'string' ? body.data : undefined
toast.error(
reason
? `${t('Waffo Pancake save failed')}: ${reason}`
: t('Waffo Pancake save failed')
)
}
} catch (err) {
toast.error(
`${t('Waffo Pancake save failed')}: ${
err instanceof Error ? err.message : String(err)
}`
)
} finally {
setPhase('idle')
}
}
const verifying = phase === 'verifying'
const saving = phase === 'saving'
// "Not edited" = MerchantID unchanged AND PrivateKey field blank, in
// which case the backend falls back to persisted creds. Otherwise we
// require both fields filled (mixed states would fail signature check).
const savedMerchantID = (
props.defaultValues.WaffoPancakeMerchantID || ''
).trim()
const savedMerchantID = (defaultValues.WaffoPancakeMerchantID || '').trim()
const formMerchantID = watchedMerchantID.trim()
const formPrivateKey = watchedPrivateKey.trim()
const credsEdited =
@@ -453,16 +373,6 @@ export function WaffoPancakeSettingsSection(props: Props) {
return (
<div className='space-y-4 pt-4'>
<SettingsPageActionsPortal>
<Button
type='button'
size='sm'
onClick={handleSave}
disabled={saving || !chosenStoreID || !chosenProductID}
>
{saving ? t('Saving...') : t('Save Waffo Pancake settings')}
</Button>
</SettingsPageActionsPortal>
<div>
<h3 className='text-lg font-medium'>{t('Waffo Pancake MoR')}</h3>
<p className='text-muted-foreground text-sm'>
@@ -471,93 +381,74 @@ export function WaffoPancakeSettingsSection(props: Props) {
)}
</p>
</div>
<Form {...form}>
<SettingsForm
onSubmit={(e) => e.preventDefault()}
className='gap-y-4'
data-no-autosubmit='true'
>
{/* Blue box — webhook configuration only. */}
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 dark:bg-blue-950 dark:text-blue-100'>
<p className='mb-2 font-medium'>{t('Webhook Configuration:')}</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t('Webhook URL (Test):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
{'<ServerAddress>/api/waffo-pancake/webhook/test'}
</code>
</li>
<li>
{t('Webhook URL (Production):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
{'<ServerAddress>/api/waffo-pancake/webhook/prod'}
</code>
</li>
<li>
{t(
'Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.'
)}
</li>
<li>
{t('Configure at:')}{' '}
<a
href={PANCAKE_DASHBOARD_URL}
target='_blank'
rel='noreferrer'
className='underline hover:no-underline'
>
{t('Waffo Pancake Dashboard')}
</a>
</li>
</ul>
</div>
<div className='grid min-w-0 gap-x-5 gap-y-4 lg:grid-cols-2'>
{/* Blue box — webhook configuration only. */}
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 lg:col-span-2 dark:bg-blue-950 dark:text-blue-100'>
<p className='mb-2 font-medium'>{t('Webhook Configuration:')}</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t('Webhook URL (Test):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
{'<ServerAddress>/api/waffo-pancake/webhook/test'}
</code>
</li>
<li>
{t('Webhook URL (Production):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
{'<ServerAddress>/api/waffo-pancake/webhook/prod'}
</code>
</li>
<li>
{t(
'Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.'
)}
</li>
<li>
{t('Configure at:')}{' '}
<a
href={PANCAKE_DASHBOARD_URL}
target='_blank'
rel='noreferrer'
className='underline hover:no-underline'
>
{t('Waffo Pancake Dashboard')}
</a>
</li>
</ul>
</div>
<FormField
control={form.control}
name='WaffoPancakeMerchantID'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Merchant ID')}</FormLabel>
<FormControl>
<Input
placeholder='MER_xxx'
autoComplete='off'
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
<div className='grid gap-1.5'>
<Label>{t('Merchant ID')}</Label>
<Input
placeholder='MER_xxx'
autoComplete='off'
value={values.WaffoPancakeMerchantID}
onChange={(event) =>
onValueChange('WaffoPancakeMerchantID', event.target.value)
}
/>
</div>
<FormField
control={form.control}
name='WaffoPancakePrivateKey'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API Private Key')}</FormLabel>
<FormControl>
<Textarea
rows={4}
placeholder={t('Leave blank to keep the existing key')}
autoComplete='new-password'
{...field}
onChange={(event) => field.onChange(event.target.value)}
className='font-mono text-xs'
/>
</FormControl>
<p className='text-muted-foreground text-xs'>
{t(
'The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.'
)}
</p>
<FormMessage />
</FormItem>
)}
<div className='grid gap-1.5'>
<Label>{t('API Private Key')}</Label>
<Textarea
rows={4}
placeholder={t('Leave blank to keep the existing key')}
autoComplete='new-password'
value={values.WaffoPancakePrivateKey}
onChange={(event) =>
onValueChange('WaffoPancakePrivateKey', event.target.value)
}
className='font-mono text-xs'
/>
<p className='text-muted-foreground text-xs'>
{t(
'The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.'
)}
</p>
</div>
{/*
{/*
Binding section — split into two visually distinct paths:
(A) "Use existing" pair from the loaded catalog — only rendered when
the merchant actually has stores, so first-time setup isn't
@@ -567,160 +458,164 @@ export function WaffoPancakeSettingsSection(props: Props) {
The two paths are split by an "or" divider so the operator never has
to wonder which field belongs to which intent.
*/}
<div className='space-y-4 pt-2'>
<div>
<h4 className='font-medium'>
{t('Bind a Pancake store + product')}
</h4>
<p className='text-muted-foreground text-xs'>
{bindStatusMessage}
</p>
</div>
<div className='space-y-4 pt-2 lg:col-span-2'>
<div>
<h4 className='font-medium'>
{t('Bind a Pancake store + product')}
</h4>
<p className='text-muted-foreground text-xs'>{bindStatusMessage}</p>
</div>
{/*
{/*
Operator-facing explainer: why only ONE store + product needs
to be bound at the gateway level, and what each piece is used
for. Subscriptions reuse the same Store but get their own
per-plan product, configured in the Subscriptions admin.
*/}
<div className='rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-900 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100'>
<p className='mb-1 font-medium'>
{t('Why only one store + product?')}
</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t(
'The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.'
)}
</li>
<li>
{t(
'The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.'
)}
</li>
<li>
{t(
'Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the "+ Create" button there).'
)}
</li>
</ul>
</div>
{/* Create section — first, since creating auto-fills the pick-existing dropdowns below. */}
<div className='space-y-1.5'>
<Label>{t('Payment return URL')}</Label>
<div className='flex gap-2'>
<Input
placeholder='https://example.com/console/topup'
value={returnURL}
onChange={(event) => setReturnURL(event.target.value)}
className='flex-1'
/>
<Button
type='button'
variant='outline'
onClick={handleCreatePair}
disabled={creatingPair || verifying || !credsReady}
className='shrink-0'
>
{creatingPair
? t('Creating...')
: `+ ${t('Create')} ${DEFAULT_NEW_PAIR_NAME}`}
</Button>
</div>
<p className='text-muted-foreground text-xs'>
<div className='rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-900 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100'>
<p className='mb-1 font-medium'>
{t('Why only one store + product?')}
</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t(
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank."
'The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.'
)}
</p>
</div>
{hasCatalog ? (
<>
<div className='relative flex items-center py-1'>
<div className='flex-1 border-t' />
<span className='text-muted-foreground px-3 text-[10px] font-medium tracking-[0.2em] uppercase'>
{t('or pick existing')}
</span>
<div className='flex-1 border-t' />
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='grid gap-1.5'>
<Label>{t('Store')}</Label>
<Select
items={storeSelectItems}
value={chosenStoreID}
onValueChange={(value) => {
// Base UI Select can deliver null on deselect.
setChosenStoreID(value ?? '')
setChosenProductID('')
}}
>
<SelectTrigger className='w-full'>
<SelectValue placeholder={t('Select a store')} />
</SelectTrigger>
<SelectContent>
{storeSelectItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label>{t('Product')}</Label>
<Select
items={productSelectItems}
value={chosenProductID}
onValueChange={(value) => setChosenProductID(value ?? '')}
disabled={
!chosenStoreID || productSelectItems.length === 0
}
>
<SelectTrigger className='w-full'>
<SelectValue placeholder={t('Select a product')} />
</SelectTrigger>
<SelectContent>
{productSelectItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
) : null}
<div className='flex items-center gap-3'>
{storeID || productID ? (
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
{storeID ? (
<span>
{t('Bound store:')}{' '}
<code className='bg-muted rounded px-1 py-0.5'>
{storeID}
</code>
</span>
) : null}
{productID ? (
<span>
{t('Bound product:')}{' '}
<code className='bg-muted rounded px-1 py-0.5'>
{productID}
</code>
</span>
) : null}
</div>
) : null}
</div>
</li>
<li>
{t(
'The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.'
)}
</li>
<li>
{t(
'Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the "+ Create" button there).'
)}
</li>
</ul>
</div>
</SettingsForm>
</Form>
{/* Create section — first, since creating auto-fills the pick-existing dropdowns below. */}
<div className='space-y-1.5'>
<Label>{t('Payment return URL')}</Label>
<div className='flex gap-2'>
<Input
placeholder='https://example.com/console/topup'
value={returnURL}
onChange={(event) =>
onValueChange('WaffoPancakeReturnURL', event.target.value)
}
className='flex-1'
/>
<Button
type='button'
variant='outline'
onClick={handleCreatePair}
disabled={creatingPair || verifying || !credsReady}
className='shrink-0'
>
{creatingPair
? t('Creating...')
: `+ ${t('Create')} ${DEFAULT_NEW_PAIR_NAME}`}
</Button>
</div>
<p className='text-muted-foreground text-xs'>
{t(
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank."
)}
</p>
</div>
{hasCatalog ? (
<>
<div className='relative flex items-center py-1'>
<div className='flex-1 border-t' />
<span className='text-muted-foreground px-3 text-[10px] font-medium tracking-[0.2em] uppercase'>
{t('or pick existing')}
</span>
<div className='flex-1 border-t' />
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='grid gap-1.5'>
<Label>{t('Store')}</Label>
<Select
items={storeSelectItems}
value={chosenStoreID}
onValueChange={(value) => {
// Base UI Select can deliver null on deselect.
onSelectedBindingChange({
storeID: value ?? '',
productID: '',
})
}}
>
<SelectTrigger className='w-full'>
<SelectValue placeholder={t('Select a store')} />
</SelectTrigger>
<SelectContent>
{storeSelectItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label>{t('Product')}</Label>
<Select
items={productSelectItems}
value={chosenProductID}
onValueChange={(value) =>
onSelectedBindingChange((previous) => ({
...previous,
productID: value ?? '',
}))
}
disabled={!chosenStoreID || productSelectItems.length === 0}
>
<SelectTrigger className='w-full'>
<SelectValue placeholder={t('Select a product')} />
</SelectTrigger>
<SelectContent>
{productSelectItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
) : null}
<div className='flex items-center gap-3'>
{storeID || productID ? (
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
{storeID ? (
<span>
{t('Bound store:')}{' '}
<code className='bg-muted rounded px-1 py-0.5'>
{storeID}
</code>
</span>
) : null}
{productID ? (
<span>
{t('Bound product:')}{' '}
<code className='bg-muted rounded px-1 py-0.5'>
{productID}
</code>
</span>
) : null}
</div>
) : null}
</div>
</div>
</div>
</div>
)
}
@@ -16,8 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ChangeEvent, useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { type ChangeEvent, useRef, type SetStateAction, useState } from 'react'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -43,8 +42,6 @@ import {
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { useUpdateOption } from '../hooks/use-update-option'
export interface WaffoSettingsValues {
WaffoEnabled: boolean
@@ -64,34 +61,33 @@ export interface WaffoSettingsValues {
WaffoPayMethods: string
}
interface PayMethod {
export interface PayMethod {
name: string
icon: string
payMethodType: string
payMethodName: string
}
type WaffoFieldValues = Omit<WaffoSettingsValues, 'WaffoPayMethods'>
interface Props {
defaultValues: WaffoSettingsValues
values: WaffoSettingsValues
onValueChange: <K extends keyof WaffoFieldValues>(
key: K,
value: WaffoFieldValues[K]
) => void
payMethods: PayMethod[]
onPayMethodsChange: (value: SetStateAction<PayMethod[]>) => void
}
export function WaffoSettingsSection(props: Props) {
export function WaffoSettingsSection({
values,
onValueChange,
payMethods,
onPayMethodsChange,
}: Props) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [loading, setLoading] = useState(false)
const iconFileInputRef = useRef<HTMLInputElement | null>(null)
const form = useForm<Omit<WaffoSettingsValues, 'WaffoPayMethods'>>({
defaultValues: props.defaultValues,
})
const [payMethods, setPayMethods] = useState<PayMethod[]>(() => {
try {
return JSON.parse(props.defaultValues.WaffoPayMethods || '[]')
} catch {
return []
}
})
const [methodDialogOpen, setMethodDialogOpen] = useState(false)
const [editingIdx, setEditingIdx] = useState(-1)
const [methodForm, setMethodForm] = useState<PayMethod>({
@@ -101,61 +97,6 @@ export function WaffoSettingsSection(props: Props) {
payMethodName: '',
})
useEffect(() => {
form.reset(props.defaultValues)
try {
setPayMethods(JSON.parse(props.defaultValues.WaffoPayMethods || '[]'))
} catch {
setPayMethods([])
}
}, [props.defaultValues, form])
const handleSave = async () => {
setLoading(true)
try {
const values = form.getValues()
const options: { key: string; value: string }[] = [
{ key: 'WaffoEnabled', value: String(values.WaffoEnabled) },
{ key: 'WaffoSandbox', value: String(values.WaffoSandbox) },
{ key: 'WaffoMerchantId', value: values.WaffoMerchantId || '' },
{ key: 'WaffoCurrency', value: values.WaffoCurrency || 'USD' },
{ key: 'WaffoUnitPrice', value: String(values.WaffoUnitPrice || 1) },
{ key: 'WaffoMinTopUp', value: String(values.WaffoMinTopUp || 1) },
{ key: 'WaffoNotifyUrl', value: values.WaffoNotifyUrl || '' },
{ key: 'WaffoReturnUrl', value: values.WaffoReturnUrl || '' },
{ key: 'WaffoPublicCert', value: values.WaffoPublicCert || '' },
{
key: 'WaffoSandboxPublicCert',
value: values.WaffoSandboxPublicCert || '',
},
{ key: 'WaffoPayMethods', value: JSON.stringify(payMethods) },
]
if (values.WaffoApiKey)
options.push({ key: 'WaffoApiKey', value: values.WaffoApiKey })
if (values.WaffoPrivateKey)
options.push({ key: 'WaffoPrivateKey', value: values.WaffoPrivateKey })
if (values.WaffoSandboxApiKey)
options.push({
key: 'WaffoSandboxApiKey',
value: values.WaffoSandboxApiKey,
})
if (values.WaffoSandboxPrivateKey)
options.push({
key: 'WaffoSandboxPrivateKey',
value: values.WaffoSandboxPrivateKey,
})
for (const opt of options) {
await updateOption.mutateAsync(opt)
}
toast.success(t('Updated successfully'))
} catch {
toast.error(t('Update failed'))
} finally {
setLoading(false)
}
}
const openAdd = () => {
setEditingIdx(-1)
setMethodForm({ name: '', icon: '', payMethodType: '', payMethodName: '' })
@@ -172,9 +113,9 @@ export function WaffoSettingsSection(props: Props) {
if (!methodForm.name.trim())
return toast.error(t('Payment method name is required'))
if (editingIdx === -1) {
setPayMethods((prev) => [...prev, methodForm])
onPayMethodsChange((prev) => [...prev, methodForm])
} else {
setPayMethods((prev) =>
onPayMethodsChange((prev) =>
prev.map((m, i) => (i === editingIdx ? methodForm : m))
)
}
@@ -213,16 +154,6 @@ export function WaffoSettingsSection(props: Props) {
return (
<>
<div className='space-y-4 pt-4'>
<SettingsPageActionsPortal>
<Button
type='button'
size='sm'
onClick={handleSave}
disabled={loading}
>
{loading ? t('Saving...') : t('Save Waffo settings')}
</Button>
</SettingsPageActionsPortal>
<div>
<h3 className='text-lg font-medium'>
{t('Waffo Aggregator Gateway')}
@@ -243,14 +174,14 @@ export function WaffoSettingsSection(props: Props) {
<div className='grid gap-4 sm:grid-cols-2'>
<SettingsSwitchField
checked={form.watch('WaffoEnabled')}
onCheckedChange={(v) => form.setValue('WaffoEnabled', v)}
checked={values.WaffoEnabled}
onCheckedChange={(v) => onValueChange('WaffoEnabled', v)}
label={t('Enable Waffo')}
className='border-b-0 py-0'
/>
<SettingsSwitchField
checked={form.watch('WaffoSandbox')}
onCheckedChange={(v) => form.setValue('WaffoSandbox', v)}
checked={values.WaffoSandbox}
onCheckedChange={(v) => onValueChange('WaffoSandbox', v)}
label={t('Sandbox mode')}
className='border-b-0 py-0'
/>
@@ -259,17 +190,34 @@ export function WaffoSettingsSection(props: Props) {
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-1.5'>
<Label>{t('API Key (Production)')}</Label>
<Input type='password' {...form.register('WaffoApiKey')} />
<Input
type='password'
value={values.WaffoApiKey}
onChange={(event) =>
onValueChange('WaffoApiKey', event.target.value)
}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('API Key (Sandbox)')}</Label>
<Input type='password' {...form.register('WaffoSandboxApiKey')} />
<Input
type='password'
value={values.WaffoSandboxApiKey}
onChange={(event) =>
onValueChange('WaffoSandboxApiKey', event.target.value)
}
/>
</div>
</div>
<div className='grid gap-1.5'>
<Label>{t('Merchant ID')}</Label>
<Input {...form.register('WaffoMerchantId')} />
<Input
value={values.WaffoMerchantId}
onChange={(event) =>
onValueChange('WaffoMerchantId', event.target.value)
}
/>
</div>
<div className='grid grid-cols-2 gap-4'>
@@ -277,7 +225,10 @@ export function WaffoSettingsSection(props: Props) {
<Label>{t('RSA Private Key (Production)')}</Label>
<Textarea
rows={3}
{...form.register('WaffoPrivateKey')}
value={values.WaffoPrivateKey}
onChange={(event) =>
onValueChange('WaffoPrivateKey', event.target.value)
}
className='font-mono text-xs'
/>
</div>
@@ -285,7 +236,10 @@ export function WaffoSettingsSection(props: Props) {
<Label>{t('RSA Private Key (Sandbox)')}</Label>
<Textarea
rows={3}
{...form.register('WaffoSandboxPrivateKey')}
value={values.WaffoSandboxPrivateKey}
onChange={(event) =>
onValueChange('WaffoSandboxPrivateKey', event.target.value)
}
className='font-mono text-xs'
/>
</div>
@@ -296,7 +250,10 @@ export function WaffoSettingsSection(props: Props) {
<Label>{t('Waffo Public Key (Production)')}</Label>
<Textarea
rows={3}
{...form.register('WaffoPublicCert')}
value={values.WaffoPublicCert}
onChange={(event) =>
onValueChange('WaffoPublicCert', event.target.value)
}
className='font-mono text-xs'
/>
</div>
@@ -304,7 +261,10 @@ export function WaffoSettingsSection(props: Props) {
<Label>{t('Waffo Public Key (Sandbox)')}</Label>
<Textarea
rows={3}
{...form.register('WaffoSandboxPublicCert')}
value={values.WaffoSandboxPublicCert}
onChange={(event) =>
onValueChange('WaffoSandboxPublicCert', event.target.value)
}
className='font-mono text-xs'
/>
</div>
@@ -313,7 +273,7 @@ export function WaffoSettingsSection(props: Props) {
<div className='grid grid-cols-3 gap-4'>
<div className='grid gap-1.5'>
<Label>{t('Currency')}</Label>
<Input {...form.register('WaffoCurrency')} disabled />
<Input value={values.WaffoCurrency} disabled />
</div>
<div className='grid gap-1.5'>
<Label>{t('Unit price (USD)')}</Label>
@@ -321,12 +281,28 @@ export function WaffoSettingsSection(props: Props) {
type='number'
step={0.1}
min={0}
{...form.register('WaffoUnitPrice')}
value={values.WaffoUnitPrice}
onChange={(event) =>
onValueChange(
'WaffoUnitPrice',
event.target.value === '' ? 0 : event.target.valueAsNumber
)
}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Minimum top-up quantity')}</Label>
<Input type='number' min={1} {...form.register('WaffoMinTopUp')} />
<Input
type='number'
min={1}
value={values.WaffoMinTopUp}
onChange={(event) =>
onValueChange(
'WaffoMinTopUp',
event.target.value === '' ? 1 : event.target.valueAsNumber
)
}
/>
</div>
</div>
@@ -335,14 +311,20 @@ export function WaffoSettingsSection(props: Props) {
<Label>{t('Callback notification URL')}</Label>
<Input
placeholder='https://example.com/api/waffo/webhook'
{...form.register('WaffoNotifyUrl')}
value={values.WaffoNotifyUrl}
onChange={(event) =>
onValueChange('WaffoNotifyUrl', event.target.value)
}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment return URL')}</Label>
<Input
placeholder='https://example.com/console/topup'
{...form.register('WaffoReturnUrl')}
value={values.WaffoReturnUrl}
onChange={(event) =>
onValueChange('WaffoReturnUrl', event.target.value)
}
/>
</div>
</div>
@@ -351,7 +333,7 @@ export function WaffoSettingsSection(props: Props) {
<div className='flex items-center justify-between'>
<h4 className='font-medium'>{t('Payment Methods')}</h4>
<Button variant='outline' size='sm' onClick={openAdd}>
<Button type='button' variant='outline' size='sm' onClick={openAdd}>
<Plus className='mr-1 h-3 w-3' />
{t('Add payment method')}
</Button>
@@ -398,6 +380,7 @@ export function WaffoSettingsSection(props: Props) {
<TableCell className='text-right'>
<div className='flex justify-end gap-1'>
<Button
type='button'
variant='ghost'
size='icon'
className='h-7 w-7'
@@ -406,11 +389,12 @@ export function WaffoSettingsSection(props: Props) {
<Pencil className='h-3 w-3' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() =>
setPayMethods((prev) =>
onPayMethodsChange((prev) =>
prev.filter((_, i) => i !== idx)
)
}
@@ -523,12 +507,15 @@ export function WaffoSettingsSection(props: Props) {
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setMethodDialogOpen(false)}
>
{t('Cancel')}
</Button>
<Button onClick={saveMethod}>{t('Confirm')}</Button>
<Button type='button' onClick={saveMethod}>
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>