fix: consolidate Waffo payment settings save flow (#5110)
This commit is contained in:
+350
-27
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+275
-380
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+94
-107
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user