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

This commit is contained in:
Seefs
2026-05-26 12:32:05 +08:00
committed by GitHub
parent f223db9330
commit c91ba0c4eb
3 changed files with 719 additions and 514 deletions
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' import * as React from 'react'
import * as z from 'zod' import * as z from 'zod'
import { useForm } from 'react-hook-form' import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Code2, Eye, ShieldAlert } from 'lucide-react' import { Code2, Eye, ShieldAlert } from 'lucide-react'
@@ -65,11 +65,14 @@ import {
normalizeJsonForComparison, normalizeJsonForComparison,
removeTrailingSlash, removeTrailingSlash,
} from './utils' } from './utils'
import { saveWaffoPancakeConfig } from './waffo-pancake-api'
import { import {
WaffoPancakeSettingsSection, WaffoPancakeSettingsSection,
type WaffoPancakeBinding,
type WaffoPancakeSettingsValues, type WaffoPancakeSettingsValues,
} from './waffo-pancake-settings-section' } from './waffo-pancake-settings-section'
import { import {
type PayMethod,
WaffoSettingsSection, WaffoSettingsSection,
type WaffoSettingsValues, type WaffoSettingsValues,
} from './waffo-settings-section' } from './waffo-settings-section'
@@ -138,9 +141,31 @@ const paymentSchema = z.object({
}) })
} }
}), }),
WaffoEnabled: z.boolean(),
WaffoApiKey: z.string(),
WaffoPrivateKey: z.string(),
WaffoPublicCert: z.string(),
WaffoSandboxPublicCert: z.string(),
WaffoSandboxApiKey: z.string(),
WaffoSandboxPrivateKey: z.string(),
WaffoSandbox: z.boolean(),
WaffoMerchantId: z.string(),
WaffoCurrency: z.string(),
WaffoUnitPrice: z.coerce.number().min(0),
WaffoMinTopUp: z.coerce.number().min(1),
WaffoNotifyUrl: z.string(),
WaffoReturnUrl: z.string(),
WaffoPancakeMerchantID: z.string(),
WaffoPancakePrivateKey: z.string(),
WaffoPancakeReturnURL: z.string(),
}) })
type PaymentFormValues = z.infer<typeof paymentSchema> type PaymentFormValues = z.infer<typeof paymentSchema>
type WaffoFormFieldValues = Omit<WaffoSettingsValues, 'WaffoPayMethods'>
type PaymentBaseFormValues = Omit<
PaymentFormValues,
keyof WaffoFormFieldValues | keyof WaffoPancakeSettingsValues
>
const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1' const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1'
@@ -152,7 +177,7 @@ type PaymentComplianceDefaults = {
} }
type PaymentSettingsSectionProps = { type PaymentSettingsSectionProps = {
defaultValues: PaymentFormValues defaultValues: PaymentBaseFormValues
waffoDefaultValues: WaffoSettingsValues waffoDefaultValues: WaffoSettingsValues
waffoPancakeDefaultValues: WaffoPancakeSettingsValues waffoPancakeDefaultValues: WaffoPancakeSettingsValues
waffoPancakeProvisionedStoreID?: string waffoPancakeProvisionedStoreID?: string
@@ -160,6 +185,15 @@ type PaymentSettingsSectionProps = {
complianceDefaults: PaymentComplianceDefaults complianceDefaults: PaymentComplianceDefaults
} }
function parseWaffoPayMethods(value: string): PayMethod[] {
try {
const parsed = JSON.parse(value || '[]')
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
export function PaymentSettingsSection({ export function PaymentSettingsSection({
defaultValues, defaultValues,
waffoDefaultValues, waffoDefaultValues,
@@ -171,10 +205,18 @@ export function PaymentSettingsSection({
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const updateOption = useUpdateOption() const updateOption = useUpdateOption()
const initialRef = React.useRef(defaultValues) const initialFormValues = React.useMemo<PaymentFormValues>(
() => ({
...defaultValues,
...waffoDefaultValues,
...waffoPancakeDefaultValues,
}),
[defaultValues, waffoDefaultValues, waffoPancakeDefaultValues]
)
const initialRef = React.useRef(initialFormValues)
const defaultsSignature = React.useMemo( const defaultsSignature = React.useMemo(
() => JSON.stringify(defaultValues), () => JSON.stringify(initialFormValues),
[defaultValues] [initialFormValues]
) )
const [payMethodsVisualMode, setPayMethodsVisualMode] = React.useState(true) const [payMethodsVisualMode, setPayMethodsVisualMode] = React.useState(true)
@@ -185,6 +227,32 @@ export function PaymentSettingsSection({
const [creemProductsVisualMode, setCreemProductsVisualMode] = const [creemProductsVisualMode, setCreemProductsVisualMode] =
React.useState(true) React.useState(true)
const [showComplianceDialog, setShowComplianceDialog] = React.useState(false) const [showComplianceDialog, setShowComplianceDialog] = React.useState(false)
const [waffoPayMethods, setWaffoPayMethods] = React.useState<PayMethod[]>(
() => parseWaffoPayMethods(waffoDefaultValues.WaffoPayMethods)
)
const [waffoPancakeSelection, setWaffoPancakeSelection] =
React.useState<WaffoPancakeBinding>({
storeID: waffoPancakeProvisionedStoreID ?? '',
productID: waffoPancakeProvisionedProductID ?? '',
})
const [waffoPancakeSavedBinding, setWaffoPancakeSavedBinding] =
React.useState<WaffoPancakeBinding>({
storeID: waffoPancakeProvisionedStoreID ?? '',
productID: waffoPancakeProvisionedProductID ?? '',
})
React.useEffect(() => {
setWaffoPayMethods(parseWaffoPayMethods(waffoDefaultValues.WaffoPayMethods))
}, [waffoDefaultValues.WaffoPayMethods])
React.useEffect(() => {
const nextBinding = {
storeID: waffoPancakeProvisionedStoreID ?? '',
productID: waffoPancakeProvisionedProductID ?? '',
}
setWaffoPancakeSelection(nextBinding)
setWaffoPancakeSavedBinding(nextBinding)
}, [waffoPancakeProvisionedProductID, waffoPancakeProvisionedStoreID])
const complianceStatements = React.useMemo( const complianceStatements = React.useMemo(
() => [ () => [
@@ -260,18 +328,63 @@ export function PaymentSettingsSection({
}, },
}) })
const form = useForm({ const form = useForm<PaymentFormValues>({
resolver: zodResolver(paymentSchema), resolver: zodResolver(paymentSchema) as Resolver<PaymentFormValues>,
mode: 'onChange', // Enable real-time validation mode: 'onChange', // Enable real-time validation
defaultValues: { defaultValues: {
...defaultValues, ...initialFormValues,
PayMethods: formatJsonForEditor(defaultValues.PayMethods), PayMethods: formatJsonForEditor(initialFormValues.PayMethods),
AmountOptions: formatJsonForEditor(defaultValues.AmountOptions), AmountOptions: formatJsonForEditor(initialFormValues.AmountOptions),
AmountDiscount: formatJsonForEditor(defaultValues.AmountDiscount), AmountDiscount: formatJsonForEditor(initialFormValues.AmountDiscount),
CreemProducts: formatJsonForEditor(defaultValues.CreemProducts), CreemProducts: formatJsonForEditor(initialFormValues.CreemProducts),
}, },
}) })
const { isSubmitting } = form.formState
const setPaymentValue = React.useCallback(
(
key: keyof PaymentFormValues,
value: PaymentFormValues[keyof PaymentFormValues]
) => {
form.setValue(
key as Parameters<typeof form.setValue>[0],
value as Parameters<typeof form.setValue>[1],
{
shouldDirty: true,
shouldValidate: true,
}
)
},
[form]
)
const setWaffoValue = React.useCallback(
<K extends keyof WaffoFormFieldValues>(
key: K,
value: WaffoFormFieldValues[K]
) => {
setPaymentValue(
key as keyof PaymentFormValues,
value as PaymentFormValues[keyof PaymentFormValues]
)
},
[setPaymentValue]
)
const setWaffoPancakeValue = React.useCallback(
<K extends keyof WaffoPancakeSettingsValues>(
key: K,
value: WaffoPancakeSettingsValues[K]
) => {
setPaymentValue(
key as keyof PaymentFormValues,
value as PaymentFormValues[keyof PaymentFormValues]
)
},
[setPaymentValue]
)
React.useEffect(() => { React.useEffect(() => {
const parsedDefaults = JSON.parse(defaultsSignature) as PaymentFormValues const parsedDefaults = JSON.parse(defaultsSignature) as PaymentFormValues
initialRef.current = parsedDefaults initialRef.current = parsedDefaults
@@ -305,6 +418,26 @@ export function PaymentSettingsSection({
CreemWebhookSecret: values.CreemWebhookSecret.trim(), CreemWebhookSecret: values.CreemWebhookSecret.trim(),
CreemTestMode: values.CreemTestMode, CreemTestMode: values.CreemTestMode,
CreemProducts: values.CreemProducts.trim(), CreemProducts: values.CreemProducts.trim(),
WaffoEnabled: values.WaffoEnabled,
WaffoSandbox: values.WaffoSandbox,
WaffoMerchantId: values.WaffoMerchantId.trim(),
WaffoCurrency: values.WaffoCurrency.trim() || 'USD',
WaffoUnitPrice: values.WaffoUnitPrice,
WaffoMinTopUp: values.WaffoMinTopUp,
WaffoNotifyUrl: values.WaffoNotifyUrl.trim(),
WaffoReturnUrl: values.WaffoReturnUrl.trim(),
WaffoPublicCert: values.WaffoPublicCert.trim(),
WaffoSandboxPublicCert: values.WaffoSandboxPublicCert.trim(),
WaffoApiKey: values.WaffoApiKey.trim(),
WaffoPrivateKey: values.WaffoPrivateKey.trim(),
WaffoSandboxApiKey: values.WaffoSandboxApiKey.trim(),
WaffoSandboxPrivateKey: values.WaffoSandboxPrivateKey.trim(),
WaffoPayMethods: JSON.stringify(waffoPayMethods),
WaffoPancakeMerchantID: values.WaffoPancakeMerchantID.trim(),
WaffoPancakePrivateKey: values.WaffoPancakePrivateKey.trim(),
WaffoPancakeReturnURL: removeTrailingSlash(
values.WaffoPancakeReturnURL.trim()
),
} }
const initial = { const initial = {
@@ -330,6 +463,28 @@ export function PaymentSettingsSection({
CreemWebhookSecret: initialRef.current.CreemWebhookSecret.trim(), CreemWebhookSecret: initialRef.current.CreemWebhookSecret.trim(),
CreemTestMode: initialRef.current.CreemTestMode, CreemTestMode: initialRef.current.CreemTestMode,
CreemProducts: initialRef.current.CreemProducts.trim(), CreemProducts: initialRef.current.CreemProducts.trim(),
WaffoEnabled: initialRef.current.WaffoEnabled,
WaffoSandbox: initialRef.current.WaffoSandbox,
WaffoMerchantId: initialRef.current.WaffoMerchantId.trim(),
WaffoCurrency: initialRef.current.WaffoCurrency.trim() || 'USD',
WaffoUnitPrice: initialRef.current.WaffoUnitPrice,
WaffoMinTopUp: initialRef.current.WaffoMinTopUp,
WaffoNotifyUrl: initialRef.current.WaffoNotifyUrl.trim(),
WaffoReturnUrl: initialRef.current.WaffoReturnUrl.trim(),
WaffoPublicCert: initialRef.current.WaffoPublicCert.trim(),
WaffoSandboxPublicCert: initialRef.current.WaffoSandboxPublicCert.trim(),
WaffoApiKey: initialRef.current.WaffoApiKey.trim(),
WaffoPrivateKey: initialRef.current.WaffoPrivateKey.trim(),
WaffoSandboxApiKey: initialRef.current.WaffoSandboxApiKey.trim(),
WaffoSandboxPrivateKey: initialRef.current.WaffoSandboxPrivateKey.trim(),
WaffoPayMethods: JSON.stringify(
parseWaffoPayMethods(waffoDefaultValues.WaffoPayMethods)
),
WaffoPancakeMerchantID: initialRef.current.WaffoPancakeMerchantID.trim(),
WaffoPancakePrivateKey: initialRef.current.WaffoPancakePrivateKey.trim(),
WaffoPancakeReturnURL: removeTrailingSlash(
initialRef.current.WaffoPancakeReturnURL.trim()
),
} }
const updates: Array<{ key: string; value: string | number | boolean }> = [] const updates: Array<{ key: string; value: string | number | boolean }> = []
@@ -455,9 +610,171 @@ export function PaymentSettingsSection({
updates.push({ key: 'CreemProducts', value: sanitized.CreemProducts }) updates.push({ key: 'CreemProducts', value: sanitized.CreemProducts })
} }
if (sanitized.WaffoEnabled !== initial.WaffoEnabled) {
updates.push({ key: 'WaffoEnabled', value: sanitized.WaffoEnabled })
}
if (sanitized.WaffoSandbox !== initial.WaffoSandbox) {
updates.push({ key: 'WaffoSandbox', value: sanitized.WaffoSandbox })
}
if (sanitized.WaffoMerchantId !== initial.WaffoMerchantId) {
updates.push({ key: 'WaffoMerchantId', value: sanitized.WaffoMerchantId })
}
if (sanitized.WaffoCurrency !== initial.WaffoCurrency) {
updates.push({ key: 'WaffoCurrency', value: sanitized.WaffoCurrency })
}
if (sanitized.WaffoUnitPrice !== initial.WaffoUnitPrice) {
updates.push({ key: 'WaffoUnitPrice', value: sanitized.WaffoUnitPrice })
}
if (sanitized.WaffoMinTopUp !== initial.WaffoMinTopUp) {
updates.push({ key: 'WaffoMinTopUp', value: sanitized.WaffoMinTopUp })
}
if (sanitized.WaffoNotifyUrl !== initial.WaffoNotifyUrl) {
updates.push({ key: 'WaffoNotifyUrl', value: sanitized.WaffoNotifyUrl })
}
if (sanitized.WaffoReturnUrl !== initial.WaffoReturnUrl) {
updates.push({ key: 'WaffoReturnUrl', value: sanitized.WaffoReturnUrl })
}
if (sanitized.WaffoPublicCert !== initial.WaffoPublicCert) {
updates.push({ key: 'WaffoPublicCert', value: sanitized.WaffoPublicCert })
}
if (sanitized.WaffoSandboxPublicCert !== initial.WaffoSandboxPublicCert) {
updates.push({
key: 'WaffoSandboxPublicCert',
value: sanitized.WaffoSandboxPublicCert,
})
}
if (sanitized.WaffoApiKey) {
updates.push({ key: 'WaffoApiKey', value: sanitized.WaffoApiKey })
}
if (sanitized.WaffoPrivateKey) {
updates.push({ key: 'WaffoPrivateKey', value: sanitized.WaffoPrivateKey })
}
if (sanitized.WaffoSandboxApiKey) {
updates.push({
key: 'WaffoSandboxApiKey',
value: sanitized.WaffoSandboxApiKey,
})
}
if (sanitized.WaffoSandboxPrivateKey) {
updates.push({
key: 'WaffoSandboxPrivateKey',
value: sanitized.WaffoSandboxPrivateKey,
})
}
if (
normalizeJsonForComparison(sanitized.WaffoPayMethods) !==
normalizeJsonForComparison(initial.WaffoPayMethods)
) {
updates.push({ key: 'WaffoPayMethods', value: sanitized.WaffoPayMethods })
}
const hasWaffoPancakeChanges =
sanitized.WaffoPancakeMerchantID !== initial.WaffoPancakeMerchantID ||
sanitized.WaffoPancakePrivateKey.length > 0 ||
sanitized.WaffoPancakeReturnURL !== initial.WaffoPancakeReturnURL ||
waffoPancakeSelection.storeID !== waffoPancakeSavedBinding.storeID ||
waffoPancakeSelection.productID !== waffoPancakeSavedBinding.productID
if (updates.length === 0 && !hasWaffoPancakeChanges) {
toast.info(t('No changes to save'))
return
}
for (const update of updates) { for (const update of updates) {
await updateOption.mutateAsync(update) await updateOption.mutateAsync(update)
} }
if (!hasWaffoPancakeChanges) {
return
}
if (!sanitized.WaffoPancakeMerchantID) {
toast.error(t('Merchant ID is required'))
return
}
if (!waffoPancakeSelection.storeID || !waffoPancakeSelection.productID) {
toast.error(t('Pick or create both a store and a product before saving.'))
return
}
try {
const body = await saveWaffoPancakeConfig({
merchantID: sanitized.WaffoPancakeMerchantID,
privateKey: sanitized.WaffoPancakePrivateKey,
returnURL: sanitized.WaffoPancakeReturnURL,
storeID: waffoPancakeSelection.storeID,
productID: waffoPancakeSelection.productID,
})
if (
body?.message === 'success' &&
typeof body.data === 'object' &&
body.data
) {
const saved = body.data as { product_id: string; store_id: string }
const savedBinding = {
storeID: saved.store_id,
productID: saved.product_id,
}
setWaffoPancakeSavedBinding(savedBinding)
setWaffoPancakeSelection(savedBinding)
queryClient.invalidateQueries({ queryKey: ['system-options'] })
toast.success(t('Waffo Pancake settings saved'))
return
}
const reason = typeof body?.data === 'string' ? body.data : undefined
toast.error(
reason
? `${t('Waffo Pancake save failed')}: ${reason}`
: t('Waffo Pancake save failed')
)
} catch (error) {
toast.error(
`${t('Waffo Pancake save failed')}: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
const currentFormValues = form.watch()
const waffoValues: WaffoSettingsValues = {
WaffoEnabled: currentFormValues.WaffoEnabled,
WaffoApiKey: currentFormValues.WaffoApiKey,
WaffoPrivateKey: currentFormValues.WaffoPrivateKey,
WaffoPublicCert: currentFormValues.WaffoPublicCert,
WaffoSandboxPublicCert: currentFormValues.WaffoSandboxPublicCert,
WaffoSandboxApiKey: currentFormValues.WaffoSandboxApiKey,
WaffoSandboxPrivateKey: currentFormValues.WaffoSandboxPrivateKey,
WaffoSandbox: currentFormValues.WaffoSandbox,
WaffoMerchantId: currentFormValues.WaffoMerchantId,
WaffoCurrency: currentFormValues.WaffoCurrency,
WaffoUnitPrice: currentFormValues.WaffoUnitPrice,
WaffoMinTopUp: currentFormValues.WaffoMinTopUp,
WaffoNotifyUrl: currentFormValues.WaffoNotifyUrl,
WaffoReturnUrl: currentFormValues.WaffoReturnUrl,
WaffoPayMethods: JSON.stringify(waffoPayMethods),
}
const waffoPancakeValues: WaffoPancakeSettingsValues = {
WaffoPancakeMerchantID: currentFormValues.WaffoPancakeMerchantID,
WaffoPancakePrivateKey: currentFormValues.WaffoPancakePrivateKey,
WaffoPancakeReturnURL: currentFormValues.WaffoPancakeReturnURL,
} }
return ( return (
@@ -525,7 +842,6 @@ export function PaymentSettingsSection({
onConfirm={() => confirmComplianceMutation.mutate()} onConfirm={() => confirmComplianceMutation.mutate()}
/> />
{/* eslint-disable react-hooks/refs */}
<Form {...form}> <Form {...form}>
<SettingsForm <SettingsForm
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
@@ -537,7 +853,7 @@ export function PaymentSettingsSection({
> >
<SettingsPageFormActions <SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)} onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending} isSaving={updateOption.isPending || isSubmitting}
saveLabel='Save all settings' saveLabel='Save all settings'
/> />
<div className='space-y-4'> <div className='space-y-4'>
@@ -1206,21 +1522,28 @@ export function PaymentSettingsSection({
)} )}
/> />
</div> </div>
<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>
) )
} }
@@ -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>
) )
} }
@@ -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>