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 React from 'react'
import * as z from 'zod' 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 { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Code2, Eye, ShieldAlert } from 'lucide-react' import { Code2, Eye, ShieldAlert } from 'lucide-react'
@@ -65,11 +65,14 @@ import {
normalizeJsonForComparison, normalizeJsonForComparison,
removeTrailingSlash, removeTrailingSlash,
} from './utils' } from './utils'
import { saveWaffoPancakeConfig } from './waffo-pancake-api'
import { import {
WaffoPancakeSettingsSection, WaffoPancakeSettingsSection,
type WaffoPancakeBinding,
type WaffoPancakeSettingsValues, type WaffoPancakeSettingsValues,
} from './waffo-pancake-settings-section' } from './waffo-pancake-settings-section'
import { import {
type PayMethod,
WaffoSettingsSection, WaffoSettingsSection,
type WaffoSettingsValues, type WaffoSettingsValues,
} from './waffo-settings-section' } 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 PaymentFormValues = z.infer<typeof paymentSchema>
type WaffoFormFieldValues = Omit<WaffoSettingsValues, 'WaffoPayMethods'>
type PaymentBaseFormValues = Omit<
PaymentFormValues,
keyof WaffoFormFieldValues | keyof WaffoPancakeSettingsValues
>
const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1' const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1'
@@ -152,7 +177,7 @@ type PaymentComplianceDefaults = {
} }
type PaymentSettingsSectionProps = { type PaymentSettingsSectionProps = {
defaultValues: PaymentFormValues defaultValues: PaymentBaseFormValues
waffoDefaultValues: WaffoSettingsValues waffoDefaultValues: WaffoSettingsValues
waffoPancakeDefaultValues: WaffoPancakeSettingsValues waffoPancakeDefaultValues: WaffoPancakeSettingsValues
waffoPancakeProvisionedStoreID?: string waffoPancakeProvisionedStoreID?: string
@@ -160,6 +185,15 @@ type PaymentSettingsSectionProps = {
complianceDefaults: PaymentComplianceDefaults complianceDefaults: PaymentComplianceDefaults
} }
function parseWaffoPayMethods(value: string): PayMethod[] {
try {
const parsed = JSON.parse(value || '[]')
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
export function PaymentSettingsSection({ export function PaymentSettingsSection({
defaultValues, defaultValues,
waffoDefaultValues, waffoDefaultValues,
@@ -171,10 +205,18 @@ export function PaymentSettingsSection({
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const updateOption = useUpdateOption() 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( const defaultsSignature = React.useMemo(
() => JSON.stringify(defaultValues), () => JSON.stringify(initialFormValues),
[defaultValues] [initialFormValues]
) )
const [payMethodsVisualMode, setPayMethodsVisualMode] = React.useState(true) const [payMethodsVisualMode, setPayMethodsVisualMode] = React.useState(true)
@@ -185,6 +227,32 @@ export function PaymentSettingsSection({
const [creemProductsVisualMode, setCreemProductsVisualMode] = const [creemProductsVisualMode, setCreemProductsVisualMode] =
React.useState(true) React.useState(true)
const [showComplianceDialog, setShowComplianceDialog] = React.useState(false) 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( const complianceStatements = React.useMemo(
() => [ () => [
@@ -260,18 +328,63 @@ export function PaymentSettingsSection({
}, },
}) })
const form = useForm({ const form = useForm<PaymentFormValues>({
resolver: zodResolver(paymentSchema), resolver: zodResolver(paymentSchema) as Resolver<PaymentFormValues>,
mode: 'onChange', // Enable real-time validation mode: 'onChange', // Enable real-time validation
defaultValues: { defaultValues: {
...defaultValues, ...initialFormValues,
PayMethods: formatJsonForEditor(defaultValues.PayMethods), PayMethods: formatJsonForEditor(initialFormValues.PayMethods),
AmountOptions: formatJsonForEditor(defaultValues.AmountOptions), AmountOptions: formatJsonForEditor(initialFormValues.AmountOptions),
AmountDiscount: formatJsonForEditor(defaultValues.AmountDiscount), AmountDiscount: formatJsonForEditor(initialFormValues.AmountDiscount),
CreemProducts: formatJsonForEditor(defaultValues.CreemProducts), 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(() => { React.useEffect(() => {
const parsedDefaults = JSON.parse(defaultsSignature) as PaymentFormValues const parsedDefaults = JSON.parse(defaultsSignature) as PaymentFormValues
initialRef.current = parsedDefaults initialRef.current = parsedDefaults
@@ -305,6 +418,26 @@ export function PaymentSettingsSection({
CreemWebhookSecret: values.CreemWebhookSecret.trim(), CreemWebhookSecret: values.CreemWebhookSecret.trim(),
CreemTestMode: values.CreemTestMode, CreemTestMode: values.CreemTestMode,
CreemProducts: values.CreemProducts.trim(), 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 = { const initial = {
@@ -330,6 +463,28 @@ export function PaymentSettingsSection({
CreemWebhookSecret: initialRef.current.CreemWebhookSecret.trim(), CreemWebhookSecret: initialRef.current.CreemWebhookSecret.trim(),
CreemTestMode: initialRef.current.CreemTestMode, CreemTestMode: initialRef.current.CreemTestMode,
CreemProducts: initialRef.current.CreemProducts.trim(), 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 }> = [] const updates: Array<{ key: string; value: string | number | boolean }> = []
@@ -455,9 +610,171 @@ export function PaymentSettingsSection({
updates.push({ key: 'CreemProducts', value: sanitized.CreemProducts }) 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) { for (const update of updates) {
await updateOption.mutateAsync(update) 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 ( return (
@@ -525,7 +842,6 @@ export function PaymentSettingsSection({
onConfirm={() => confirmComplianceMutation.mutate()} onConfirm={() => confirmComplianceMutation.mutate()}
/> />
{/* eslint-disable react-hooks/refs */}
<Form {...form}> <Form {...form}>
<SettingsForm <SettingsForm
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
@@ -537,7 +853,7 @@ export function PaymentSettingsSection({
> >
<SettingsPageFormActions <SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)} onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending} isSaving={updateOption.isPending || isSubmitting}
saveLabel='Save all settings' saveLabel='Save all settings'
/> />
<div className='space-y-4'> <div className='space-y-4'>
@@ -1206,21 +1522,28 @@ export function PaymentSettingsSection({
)} )}
/> />
</div> </div>
</SettingsForm>
</Form>
<Separator /> <Separator />
<WaffoPancakeSettingsSection <WaffoPancakeSettingsSection
defaultValues={waffoPancakeDefaultValues} defaultValues={waffoPancakeDefaultValues}
provisionedStoreID={waffoPancakeProvisionedStoreID} values={waffoPancakeValues}
provisionedProductID={waffoPancakeProvisionedProductID} onValueChange={setWaffoPancakeValue}
selectedBinding={waffoPancakeSelection}
savedBinding={waffoPancakeSavedBinding}
onSelectedBindingChange={setWaffoPancakeSelection}
/> />
<Separator /> <Separator />
<WaffoSettingsSection defaultValues={waffoDefaultValues} /> <WaffoSettingsSection
{/* eslint-enable react-hooks/refs */} values={waffoValues}
onValueChange={setWaffoValue}
payMethods={waffoPayMethods}
onPayMethodsChange={setWaffoPayMethods}
/>
</SettingsForm>
</Form>
</SettingsSection> </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 For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' import * as React from 'react'
import * as z from 'zod' import type { SetStateAction } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import {
@@ -41,8 +31,6 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' 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 { removeTrailingSlash } from './utils'
import { import {
type CatalogStore, type CatalogStore,
@@ -50,23 +38,29 @@ import {
type PairResult, type PairResult,
createWaffoPancakePair, createWaffoPancakePair,
listWaffoPancakeCatalog, listWaffoPancakeCatalog,
saveWaffoPancakeConfig,
} from './waffo-pancake-api' } from './waffo-pancake-api'
// Only operator-typed fields. Nothing else lands in OptionMap until Save. export type WaffoPancakeSettingsValues = {
const waffoPancakeSchema = z.object({ WaffoPancakeMerchantID: string
WaffoPancakeMerchantID: z.string(), WaffoPancakePrivateKey: string
WaffoPancakePrivateKey: z.string(),
})
export type WaffoPancakeSettingsValues = z.infer<typeof waffoPancakeSchema> & {
WaffoPancakeReturnURL: string WaffoPancakeReturnURL: string
} }
export interface WaffoPancakeBinding {
storeID: string
productID: string
}
interface Props { interface Props {
defaultValues: WaffoPancakeSettingsValues defaultValues: WaffoPancakeSettingsValues
provisionedStoreID?: string values: WaffoPancakeSettingsValues
provisionedProductID?: string 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' 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_PRODUCT_NAME = 'new-api-charge-product'
const DEFAULT_NEW_PAIR_NAME = `${DEFAULT_NEW_STORE_NAME} + ${DEFAULT_NEW_PRODUCT_NAME}` 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 { t } = useTranslation()
const [storeID, setStoreID] = React.useState(props.provisionedStoreID ?? '') const [phase, setPhase] = React.useState<'idle' | 'verifying'>('idle')
const [productID, setProductID] = React.useState(
props.provisionedProductID ?? ''
)
const [phase, setPhase] = React.useState<'idle' | 'verifying' | 'saving'>(
'idle'
)
const [catalog, setCatalog] = React.useState<CatalogStore[]>([]) 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 [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( const defaultsSignature = React.useMemo(
() => JSON.stringify(props.defaultValues), () => JSON.stringify(defaultValues),
[props.defaultValues] [defaultValues]
) )
// "merchantID|privateKey" of the last verified pair; debounced verify // "merchantID|privateKey" of the last verified pair; debounced verify
@@ -110,15 +98,6 @@ export function WaffoPancakeSettingsSection(props: Props) {
const lastVerifiedSignature = React.useRef('') const lastVerifiedSignature = React.useRef('')
const fetchSerialRef = React.useRef(0) 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 // Mount-only — never re-sync from props after the first render. The
// backend strips PrivateKey from GET /api/option/, so a re-sync would // backend strips PrivateKey from GET /api/option/, so a re-sync would
// wipe whatever the operator just typed. // wipe whatever the operator just typed.
@@ -128,21 +107,8 @@ export function WaffoPancakeSettingsSection(props: Props) {
initialRef.current = parsed initialRef.current = parsed
if (didMountRef.current) return if (didMountRef.current) return
didMountRef.current = true didMountRef.current = true
form.reset({
WaffoPancakeMerchantID: parsed.WaffoPancakeMerchantID,
WaffoPancakePrivateKey: parsed.WaffoPancakePrivateKey,
})
setReturnURL(parsed.WaffoPancakeReturnURL ?? '')
lastVerifiedSignature.current = `${parsed.WaffoPancakeMerchantID.trim()}|${parsed.WaffoPancakePrivateKey.trim()}` lastVerifiedSignature.current = `${parsed.WaffoPancakeMerchantID.trim()}|${parsed.WaffoPancakePrivateKey.trim()}`
}, [defaultsSignature, form]) }, [defaultsSignature])
React.useEffect(() => {
setStoreID(props.provisionedStoreID ?? '')
}, [props.provisionedStoreID])
React.useEffect(() => {
setProductID(props.provisionedProductID ?? '')
}, [props.provisionedProductID])
const productsForChosenStore = React.useMemo(() => { const productsForChosenStore = React.useMemo(() => {
if (!chosenStoreID) return [] if (!chosenStoreID) return []
@@ -185,7 +151,7 @@ export function WaffoPancakeSettingsSection(props: Props) {
preselect?: { storeID?: string; productID?: string } preselect?: { storeID?: string; productID?: string }
) => { ) => {
const serial = ++fetchSerialRef.current const serial = ++fetchSerialRef.current
let stores: CatalogStore[] = [] let stores: CatalogStore[]
try { try {
const body = await listWaffoPancakeCatalog(merchantID, privateKey) const body = await listWaffoPancakeCatalog(merchantID, privateKey)
if (serial !== fetchSerialRef.current) return if (serial !== fetchSerialRef.current) return
@@ -221,8 +187,10 @@ export function WaffoPancakeSettingsSection(props: Props) {
setCatalog(stores) setCatalog(stores)
if (preselect) { if (preselect) {
setChosenStoreID(preselect.storeID ?? '') onSelectedBindingChange({
setChosenProductID(preselect.productID ?? '') storeID: preselect.storeID ?? '',
productID: preselect.productID ?? '',
})
} else { } else {
// Default anchor: bound product if found, else first product of // Default anchor: bound product if found, else first product of
// the first store with any — saves a click for new operators. // 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) s.onetimeProducts.some((p) => p.id === productID)
) )
if (boundStore && productID) { if (boundStore && productID) {
setChosenStoreID(boundStore.id) onSelectedBindingChange({
setChosenProductID(productID) storeID: boundStore.id,
productID,
})
} else { } else {
const storeWithProducts = stores.find( const storeWithProducts = stores.find(
(s) => s.onetimeProducts.length > 0 (s) => s.onetimeProducts.length > 0
) )
if (storeWithProducts) { if (storeWithProducts) {
setChosenStoreID(storeWithProducts.id) onSelectedBindingChange({
setChosenProductID(storeWithProducts.onetimeProducts[0].id) storeID: storeWithProducts.id,
productID: storeWithProducts.onetimeProducts[0].id,
})
} else { } else {
setChosenStoreID('') onSelectedBindingChange({ storeID: '', productID: '' })
setChosenProductID('')
} }
} }
} }
setPhase('idle') setPhase('idle')
}, },
[productID, t] [onSelectedBindingChange, productID, t]
) )
const watchedMerchantID = form.watch('WaffoPancakeMerchantID') || '' const watchedMerchantID = values.WaffoPancakeMerchantID || ''
const watchedPrivateKey = form.watch('WaffoPancakePrivateKey') || '' const watchedPrivateKey = values.WaffoPancakePrivateKey || ''
React.useEffect(() => { React.useEffect(() => {
const m = watchedMerchantID.trim() const m = watchedMerchantID.trim()
const k = watchedPrivateKey.trim() const k = watchedPrivateKey.trim()
@@ -272,20 +243,23 @@ export function WaffoPancakeSettingsSection(props: Props) {
const initialLoadRef = React.useRef(false) const initialLoadRef = React.useRef(false)
React.useEffect(() => { React.useEffect(() => {
if (initialLoadRef.current) return if (initialLoadRef.current) return
if (!props.defaultValues.WaffoPancakeMerchantID.trim()) return if (!defaultValues.WaffoPancakeMerchantID.trim()) return
initialLoadRef.current = true initialLoadRef.current = true
const timer = window.setTimeout(() => {
setPhase('verifying') setPhase('verifying')
void verifyAndFetchCatalog('', '') void verifyAndFetchCatalog('', '')
}, [props.defaultValues.WaffoPancakeMerchantID, verifyAndFetchCatalog]) }, 0)
return () => window.clearTimeout(timer)
}, [defaultValues.WaffoPancakeMerchantID, verifyAndFetchCatalog])
// Returns typed creds when the operator edited either field; otherwise // Returns typed creds when the operator edited either field; otherwise
// blanks so the backend falls back to persisted creds. Without this, // blanks so the backend falls back to persisted creds. Without this,
// returning admins (saved merchant ID but empty key field) would send // returning admins (saved merchant ID but empty key field) would send
// a mixed-state body that the backend rejects. // a mixed-state body that the backend rejects.
const readCreds = () => { const readCreds = () => {
const formMerchant = (form.getValues('WaffoPancakeMerchantID') || '').trim() const formMerchant = (values.WaffoPancakeMerchantID || '').trim()
const formKey = (form.getValues('WaffoPancakePrivateKey') || '').trim() const formKey = (values.WaffoPancakePrivateKey || '').trim()
const saved = (props.defaultValues.WaffoPancakeMerchantID || '').trim() const saved = (defaultValues.WaffoPancakeMerchantID || '').trim()
const edited = formMerchant !== saved || formKey.length > 0 const edited = formMerchant !== saved || formKey.length > 0
if (!edited) return { merchantID: '', privateKey: '' } if (!edited) return { merchantID: '', privateKey: '' }
return { merchantID: formMerchant, privateKey: formKey } 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 verifying = phase === 'verifying'
const saving = phase === 'saving'
// "Not edited" = MerchantID unchanged AND PrivateKey field blank, in // "Not edited" = MerchantID unchanged AND PrivateKey field blank, in
// which case the backend falls back to persisted creds. Otherwise we // which case the backend falls back to persisted creds. Otherwise we
// require both fields filled (mixed states would fail signature check). // require both fields filled (mixed states would fail signature check).
const savedMerchantID = ( const savedMerchantID = (defaultValues.WaffoPancakeMerchantID || '').trim()
props.defaultValues.WaffoPancakeMerchantID || ''
).trim()
const formMerchantID = watchedMerchantID.trim() const formMerchantID = watchedMerchantID.trim()
const formPrivateKey = watchedPrivateKey.trim() const formPrivateKey = watchedPrivateKey.trim()
const credsEdited = const credsEdited =
@@ -453,16 +373,6 @@ export function WaffoPancakeSettingsSection(props: Props) {
return ( return (
<div className='space-y-4 pt-4'> <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> <div>
<h3 className='text-lg font-medium'>{t('Waffo Pancake MoR')}</h3> <h3 className='text-lg font-medium'>{t('Waffo Pancake MoR')}</h3>
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
@@ -471,14 +381,9 @@ export function WaffoPancakeSettingsSection(props: Props) {
)} )}
</p> </p>
</div> </div>
<Form {...form}> <div className='grid min-w-0 gap-x-5 gap-y-4 lg:grid-cols-2'>
<SettingsForm
onSubmit={(e) => e.preventDefault()}
className='gap-y-4'
data-no-autosubmit='true'
>
{/* Blue box — webhook configuration only. */} {/* 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'> <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> <p className='mb-2 font-medium'>{t('Webhook Configuration:')}</p>
<ul className='list-inside list-disc space-y-1'> <ul className='list-inside list-disc space-y-1'>
<li> <li>
@@ -512,50 +417,36 @@ export function WaffoPancakeSettingsSection(props: Props) {
</ul> </ul>
</div> </div>
<FormField <div className='grid gap-1.5'>
control={form.control} <Label>{t('Merchant ID')}</Label>
name='WaffoPancakeMerchantID'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Merchant ID')}</FormLabel>
<FormControl>
<Input <Input
placeholder='MER_xxx' placeholder='MER_xxx'
autoComplete='off' autoComplete='off'
{...field} value={values.WaffoPancakeMerchantID}
onChange={(event) => field.onChange(event.target.value)} onChange={(event) =>
/> onValueChange('WaffoPancakeMerchantID', event.target.value)
</FormControl> }
<FormMessage />
</FormItem>
)}
/> />
</div>
<FormField <div className='grid gap-1.5'>
control={form.control} <Label>{t('API Private Key')}</Label>
name='WaffoPancakePrivateKey'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API Private Key')}</FormLabel>
<FormControl>
<Textarea <Textarea
rows={4} rows={4}
placeholder={t('Leave blank to keep the existing key')} placeholder={t('Leave blank to keep the existing key')}
autoComplete='new-password' autoComplete='new-password'
{...field} value={values.WaffoPancakePrivateKey}
onChange={(event) => field.onChange(event.target.value)} onChange={(event) =>
onValueChange('WaffoPancakePrivateKey', event.target.value)
}
className='font-mono text-xs' className='font-mono text-xs'
/> />
</FormControl>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{t( {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.' '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> </p>
<FormMessage /> </div>
</FormItem>
)}
/>
{/* {/*
Binding section — split into two visually distinct paths: Binding section — split into two visually distinct paths:
@@ -567,14 +458,12 @@ export function WaffoPancakeSettingsSection(props: Props) {
The two paths are split by an "or" divider so the operator never has The two paths are split by an "or" divider so the operator never has
to wonder which field belongs to which intent. to wonder which field belongs to which intent.
*/} */}
<div className='space-y-4 pt-2'> <div className='space-y-4 pt-2 lg:col-span-2'>
<div> <div>
<h4 className='font-medium'> <h4 className='font-medium'>
{t('Bind a Pancake store + product')} {t('Bind a Pancake store + product')}
</h4> </h4>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>{bindStatusMessage}</p>
{bindStatusMessage}
</p>
</div> </div>
{/* {/*
@@ -613,7 +502,9 @@ export function WaffoPancakeSettingsSection(props: Props) {
<Input <Input
placeholder='https://example.com/console/topup' placeholder='https://example.com/console/topup'
value={returnURL} value={returnURL}
onChange={(event) => setReturnURL(event.target.value)} onChange={(event) =>
onValueChange('WaffoPancakeReturnURL', event.target.value)
}
className='flex-1' className='flex-1'
/> />
<Button <Button
@@ -653,8 +544,10 @@ export function WaffoPancakeSettingsSection(props: Props) {
value={chosenStoreID} value={chosenStoreID}
onValueChange={(value) => { onValueChange={(value) => {
// Base UI Select can deliver null on deselect. // Base UI Select can deliver null on deselect.
setChosenStoreID(value ?? '') onSelectedBindingChange({
setChosenProductID('') storeID: value ?? '',
productID: '',
})
}} }}
> >
<SelectTrigger className='w-full'> <SelectTrigger className='w-full'>
@@ -675,10 +568,13 @@ export function WaffoPancakeSettingsSection(props: Props) {
<Select <Select
items={productSelectItems} items={productSelectItems}
value={chosenProductID} value={chosenProductID}
onValueChange={(value) => setChosenProductID(value ?? '')} onValueChange={(value) =>
disabled={ onSelectedBindingChange((previous) => ({
!chosenStoreID || productSelectItems.length === 0 ...previous,
productID: value ?? '',
}))
} }
disabled={!chosenStoreID || productSelectItems.length === 0}
> >
<SelectTrigger className='w-full'> <SelectTrigger className='w-full'>
<SelectValue placeholder={t('Select a product')} /> <SelectValue placeholder={t('Select a product')} />
@@ -719,8 +615,7 @@ export function WaffoPancakeSettingsSection(props: Props) {
) : null} ) : null}
</div> </div>
</div> </div>
</SettingsForm> </div>
</Form>
</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 For commercial licensing, please contact support@quantumnous.com
*/ */
import { type ChangeEvent, useEffect, useRef, useState } from 'react' import { type ChangeEvent, useRef, type SetStateAction, useState } from 'react'
import { useForm } from 'react-hook-form'
import { Plus, Pencil, Trash2 } from 'lucide-react' import { Plus, Pencil, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -43,8 +42,6 @@ import {
} from '@/components/ui/table' } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { SettingsSwitchField } from '../components/settings-form-layout' import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../components/settings-page-context'
import { useUpdateOption } from '../hooks/use-update-option'
export interface WaffoSettingsValues { export interface WaffoSettingsValues {
WaffoEnabled: boolean WaffoEnabled: boolean
@@ -64,34 +61,33 @@ export interface WaffoSettingsValues {
WaffoPayMethods: string WaffoPayMethods: string
} }
interface PayMethod { export interface PayMethod {
name: string name: string
icon: string icon: string
payMethodType: string payMethodType: string
payMethodName: string payMethodName: string
} }
type WaffoFieldValues = Omit<WaffoSettingsValues, 'WaffoPayMethods'>
interface Props { 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 { t } = useTranslation()
const updateOption = useUpdateOption()
const [loading, setLoading] = useState(false)
const iconFileInputRef = useRef<HTMLInputElement | null>(null) 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 [methodDialogOpen, setMethodDialogOpen] = useState(false)
const [editingIdx, setEditingIdx] = useState(-1) const [editingIdx, setEditingIdx] = useState(-1)
const [methodForm, setMethodForm] = useState<PayMethod>({ const [methodForm, setMethodForm] = useState<PayMethod>({
@@ -101,61 +97,6 @@ export function WaffoSettingsSection(props: Props) {
payMethodName: '', 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 = () => { const openAdd = () => {
setEditingIdx(-1) setEditingIdx(-1)
setMethodForm({ name: '', icon: '', payMethodType: '', payMethodName: '' }) setMethodForm({ name: '', icon: '', payMethodType: '', payMethodName: '' })
@@ -172,9 +113,9 @@ export function WaffoSettingsSection(props: Props) {
if (!methodForm.name.trim()) if (!methodForm.name.trim())
return toast.error(t('Payment method name is required')) return toast.error(t('Payment method name is required'))
if (editingIdx === -1) { if (editingIdx === -1) {
setPayMethods((prev) => [...prev, methodForm]) onPayMethodsChange((prev) => [...prev, methodForm])
} else { } else {
setPayMethods((prev) => onPayMethodsChange((prev) =>
prev.map((m, i) => (i === editingIdx ? methodForm : m)) prev.map((m, i) => (i === editingIdx ? methodForm : m))
) )
} }
@@ -213,16 +154,6 @@ export function WaffoSettingsSection(props: Props) {
return ( return (
<> <>
<div className='space-y-4 pt-4'> <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> <div>
<h3 className='text-lg font-medium'> <h3 className='text-lg font-medium'>
{t('Waffo Aggregator Gateway')} {t('Waffo Aggregator Gateway')}
@@ -243,14 +174,14 @@ export function WaffoSettingsSection(props: Props) {
<div className='grid gap-4 sm:grid-cols-2'> <div className='grid gap-4 sm:grid-cols-2'>
<SettingsSwitchField <SettingsSwitchField
checked={form.watch('WaffoEnabled')} checked={values.WaffoEnabled}
onCheckedChange={(v) => form.setValue('WaffoEnabled', v)} onCheckedChange={(v) => onValueChange('WaffoEnabled', v)}
label={t('Enable Waffo')} label={t('Enable Waffo')}
className='border-b-0 py-0' className='border-b-0 py-0'
/> />
<SettingsSwitchField <SettingsSwitchField
checked={form.watch('WaffoSandbox')} checked={values.WaffoSandbox}
onCheckedChange={(v) => form.setValue('WaffoSandbox', v)} onCheckedChange={(v) => onValueChange('WaffoSandbox', v)}
label={t('Sandbox mode')} label={t('Sandbox mode')}
className='border-b-0 py-0' 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 grid-cols-2 gap-4'>
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label>{t('API Key (Production)')}</Label> <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>
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label>{t('API Key (Sandbox)')}</Label> <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> </div>
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label>{t('Merchant ID')}</Label> <Label>{t('Merchant ID')}</Label>
<Input {...form.register('WaffoMerchantId')} /> <Input
value={values.WaffoMerchantId}
onChange={(event) =>
onValueChange('WaffoMerchantId', event.target.value)
}
/>
</div> </div>
<div className='grid grid-cols-2 gap-4'> <div className='grid grid-cols-2 gap-4'>
@@ -277,7 +225,10 @@ export function WaffoSettingsSection(props: Props) {
<Label>{t('RSA Private Key (Production)')}</Label> <Label>{t('RSA Private Key (Production)')}</Label>
<Textarea <Textarea
rows={3} rows={3}
{...form.register('WaffoPrivateKey')} value={values.WaffoPrivateKey}
onChange={(event) =>
onValueChange('WaffoPrivateKey', event.target.value)
}
className='font-mono text-xs' className='font-mono text-xs'
/> />
</div> </div>
@@ -285,7 +236,10 @@ export function WaffoSettingsSection(props: Props) {
<Label>{t('RSA Private Key (Sandbox)')}</Label> <Label>{t('RSA Private Key (Sandbox)')}</Label>
<Textarea <Textarea
rows={3} rows={3}
{...form.register('WaffoSandboxPrivateKey')} value={values.WaffoSandboxPrivateKey}
onChange={(event) =>
onValueChange('WaffoSandboxPrivateKey', event.target.value)
}
className='font-mono text-xs' className='font-mono text-xs'
/> />
</div> </div>
@@ -296,7 +250,10 @@ export function WaffoSettingsSection(props: Props) {
<Label>{t('Waffo Public Key (Production)')}</Label> <Label>{t('Waffo Public Key (Production)')}</Label>
<Textarea <Textarea
rows={3} rows={3}
{...form.register('WaffoPublicCert')} value={values.WaffoPublicCert}
onChange={(event) =>
onValueChange('WaffoPublicCert', event.target.value)
}
className='font-mono text-xs' className='font-mono text-xs'
/> />
</div> </div>
@@ -304,7 +261,10 @@ export function WaffoSettingsSection(props: Props) {
<Label>{t('Waffo Public Key (Sandbox)')}</Label> <Label>{t('Waffo Public Key (Sandbox)')}</Label>
<Textarea <Textarea
rows={3} rows={3}
{...form.register('WaffoSandboxPublicCert')} value={values.WaffoSandboxPublicCert}
onChange={(event) =>
onValueChange('WaffoSandboxPublicCert', event.target.value)
}
className='font-mono text-xs' className='font-mono text-xs'
/> />
</div> </div>
@@ -313,7 +273,7 @@ export function WaffoSettingsSection(props: Props) {
<div className='grid grid-cols-3 gap-4'> <div className='grid grid-cols-3 gap-4'>
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label>{t('Currency')}</Label> <Label>{t('Currency')}</Label>
<Input {...form.register('WaffoCurrency')} disabled /> <Input value={values.WaffoCurrency} disabled />
</div> </div>
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label>{t('Unit price (USD)')}</Label> <Label>{t('Unit price (USD)')}</Label>
@@ -321,12 +281,28 @@ export function WaffoSettingsSection(props: Props) {
type='number' type='number'
step={0.1} step={0.1}
min={0} min={0}
{...form.register('WaffoUnitPrice')} value={values.WaffoUnitPrice}
onChange={(event) =>
onValueChange(
'WaffoUnitPrice',
event.target.value === '' ? 0 : event.target.valueAsNumber
)
}
/> />
</div> </div>
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label>{t('Minimum top-up quantity')}</Label> <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>
</div> </div>
@@ -335,14 +311,20 @@ export function WaffoSettingsSection(props: Props) {
<Label>{t('Callback notification URL')}</Label> <Label>{t('Callback notification URL')}</Label>
<Input <Input
placeholder='https://example.com/api/waffo/webhook' placeholder='https://example.com/api/waffo/webhook'
{...form.register('WaffoNotifyUrl')} value={values.WaffoNotifyUrl}
onChange={(event) =>
onValueChange('WaffoNotifyUrl', event.target.value)
}
/> />
</div> </div>
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label>{t('Payment return URL')}</Label> <Label>{t('Payment return URL')}</Label>
<Input <Input
placeholder='https://example.com/console/topup' placeholder='https://example.com/console/topup'
{...form.register('WaffoReturnUrl')} value={values.WaffoReturnUrl}
onChange={(event) =>
onValueChange('WaffoReturnUrl', event.target.value)
}
/> />
</div> </div>
</div> </div>
@@ -351,7 +333,7 @@ export function WaffoSettingsSection(props: Props) {
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<h4 className='font-medium'>{t('Payment Methods')}</h4> <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' /> <Plus className='mr-1 h-3 w-3' />
{t('Add payment method')} {t('Add payment method')}
</Button> </Button>
@@ -398,6 +380,7 @@ export function WaffoSettingsSection(props: Props) {
<TableCell className='text-right'> <TableCell className='text-right'>
<div className='flex justify-end gap-1'> <div className='flex justify-end gap-1'>
<Button <Button
type='button'
variant='ghost' variant='ghost'
size='icon' size='icon'
className='h-7 w-7' className='h-7 w-7'
@@ -406,11 +389,12 @@ export function WaffoSettingsSection(props: Props) {
<Pencil className='h-3 w-3' /> <Pencil className='h-3 w-3' />
</Button> </Button>
<Button <Button
type='button'
variant='ghost' variant='ghost'
size='icon' size='icon'
className='h-7 w-7' className='h-7 w-7'
onClick={() => onClick={() =>
setPayMethods((prev) => onPayMethodsChange((prev) =>
prev.filter((_, i) => i !== idx) prev.filter((_, i) => i !== idx)
) )
} }
@@ -523,12 +507,15 @@ export function WaffoSettingsSection(props: Props) {
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
type='button'
variant='outline' variant='outline'
onClick={() => setMethodDialogOpen(false)} onClick={() => setMethodDialogOpen(false)}
> >
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button onClick={saveMethod}>{t('Confirm')}</Button> <Button type='button' onClick={saveMethod}>
{t('Confirm')}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>