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 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>
|
||||||
|
|
||||||
|
<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>
|
</SettingsForm>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<WaffoPancakeSettingsSection
|
|
||||||
defaultValues={waffoPancakeDefaultValues}
|
|
||||||
provisionedStoreID={waffoPancakeProvisionedStoreID}
|
|
||||||
provisionedProductID={waffoPancakeProvisionedProductID}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<WaffoSettingsSection defaultValues={waffoDefaultValues} />
|
|
||||||
{/* eslint-enable react-hooks/refs */}
|
|
||||||
</SettingsSection>
|
</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
|
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
|
||||||
setPhase('verifying')
|
const timer = window.setTimeout(() => {
|
||||||
void verifyAndFetchCatalog('', '')
|
setPhase('verifying')
|
||||||
}, [props.defaultValues.WaffoPancakeMerchantID, verifyAndFetchCatalog])
|
void 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,93 +381,74 @@ 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
|
{/* Blue box — webhook configuration only. */}
|
||||||
onSubmit={(e) => e.preventDefault()}
|
<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'>
|
||||||
className='gap-y-4'
|
<p className='mb-2 font-medium'>{t('Webhook Configuration:')}</p>
|
||||||
data-no-autosubmit='true'
|
<ul className='list-inside list-disc space-y-1'>
|
||||||
>
|
<li>
|
||||||
{/* Blue box — webhook configuration only. */}
|
{t('Webhook URL (Test):')}{' '}
|
||||||
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 dark:bg-blue-950 dark:text-blue-100'>
|
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||||||
<p className='mb-2 font-medium'>{t('Webhook Configuration:')}</p>
|
{'<ServerAddress>/api/waffo-pancake/webhook/test'}
|
||||||
<ul className='list-inside list-disc space-y-1'>
|
</code>
|
||||||
<li>
|
</li>
|
||||||
{t('Webhook URL (Test):')}{' '}
|
<li>
|
||||||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
{t('Webhook URL (Production):')}{' '}
|
||||||
{'<ServerAddress>/api/waffo-pancake/webhook/test'}
|
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||||||
</code>
|
{'<ServerAddress>/api/waffo-pancake/webhook/prod'}
|
||||||
</li>
|
</code>
|
||||||
<li>
|
</li>
|
||||||
{t('Webhook URL (Production):')}{' '}
|
<li>
|
||||||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
{t(
|
||||||
{'<ServerAddress>/api/waffo-pancake/webhook/prod'}
|
'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.'
|
||||||
</code>
|
)}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{t(
|
{t('Configure at:')}{' '}
|
||||||
'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.'
|
<a
|
||||||
)}
|
href={PANCAKE_DASHBOARD_URL}
|
||||||
</li>
|
target='_blank'
|
||||||
<li>
|
rel='noreferrer'
|
||||||
{t('Configure at:')}{' '}
|
className='underline hover:no-underline'
|
||||||
<a
|
>
|
||||||
href={PANCAKE_DASHBOARD_URL}
|
{t('Waffo Pancake Dashboard')}
|
||||||
target='_blank'
|
</a>
|
||||||
rel='noreferrer'
|
</li>
|
||||||
className='underline hover:no-underline'
|
</ul>
|
||||||
>
|
</div>
|
||||||
{t('Waffo Pancake Dashboard')}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
<div className='grid gap-1.5'>
|
||||||
control={form.control}
|
<Label>{t('Merchant ID')}</Label>
|
||||||
name='WaffoPancakeMerchantID'
|
<Input
|
||||||
render={({ field }) => (
|
placeholder='MER_xxx'
|
||||||
<FormItem>
|
autoComplete='off'
|
||||||
<FormLabel>{t('Merchant ID')}</FormLabel>
|
value={values.WaffoPancakeMerchantID}
|
||||||
<FormControl>
|
onChange={(event) =>
|
||||||
<Input
|
onValueChange('WaffoPancakeMerchantID', event.target.value)
|
||||||
placeholder='MER_xxx'
|
}
|
||||||
autoComplete='off'
|
|
||||||
{...field}
|
|
||||||
onChange={(event) => field.onChange(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'
|
<Textarea
|
||||||
render={({ field }) => (
|
rows={4}
|
||||||
<FormItem>
|
placeholder={t('Leave blank to keep the existing key')}
|
||||||
<FormLabel>{t('API Private Key')}</FormLabel>
|
autoComplete='new-password'
|
||||||
<FormControl>
|
value={values.WaffoPancakePrivateKey}
|
||||||
<Textarea
|
onChange={(event) =>
|
||||||
rows={4}
|
onValueChange('WaffoPancakePrivateKey', event.target.value)
|
||||||
placeholder={t('Leave blank to keep the existing key')}
|
}
|
||||||
autoComplete='new-password'
|
className='font-mono text-xs'
|
||||||
{...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>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
<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:
|
Binding section — split into two visually distinct paths:
|
||||||
(A) "Use existing" pair from the loaded catalog — only rendered when
|
(A) "Use existing" pair from the loaded catalog — only rendered when
|
||||||
the merchant actually has stores, so first-time setup isn't
|
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
|
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}
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Operator-facing explainer: why only ONE store + product needs
|
Operator-facing explainer: why only ONE store + product needs
|
||||||
to be bound at the gateway level, and what each piece is used
|
to be bound at the gateway level, and what each piece is used
|
||||||
for. Subscriptions reuse the same Store but get their own
|
for. Subscriptions reuse the same Store but get their own
|
||||||
per-plan product, configured in the Subscriptions admin.
|
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'>
|
<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'>
|
<p className='mb-1 font-medium'>
|
||||||
{t('Why only one store + product?')}
|
{t('Why only one store + product?')}
|
||||||
</p>
|
</p>
|
||||||
<ul className='list-inside list-disc space-y-1'>
|
<ul className='list-inside list-disc space-y-1'>
|
||||||
<li>
|
<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'>
|
|
||||||
{t(
|
{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>
|
</li>
|
||||||
</div>
|
<li>
|
||||||
|
{t(
|
||||||
{hasCatalog ? (
|
'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.'
|
||||||
<>
|
)}
|
||||||
<div className='relative flex items-center py-1'>
|
</li>
|
||||||
<div className='flex-1 border-t' />
|
<li>
|
||||||
<span className='text-muted-foreground px-3 text-[10px] font-medium tracking-[0.2em] uppercase'>
|
{t(
|
||||||
{t('or pick existing')}
|
'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).'
|
||||||
</span>
|
)}
|
||||||
<div className='flex-1 border-t' />
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</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
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user