[Feature Request] Waffo Pancake gateway — full integration with subscription support + admin catalog binding flow (#4935)

This commit is contained in:
Hill-waffo
2026-05-22 11:00:58 +08:00
committed by GitHub
parent 8e5e89bb5b
commit 19f1821fc8
45 changed files with 2437 additions and 1091 deletions
+36
View File
@@ -122,6 +122,42 @@ export async function paySubscriptionCreem(
return res.data
}
export async function paySubscriptionWaffoPancake(
data: SubscriptionPayRequest
): Promise<SubscriptionPayResponse> {
const res = await api.post('/api/subscription/waffo-pancake/pay', data)
return res.data
}
// Mints a Pancake OnetimeProduct (see controller for the OnetimeProduct vs
// SubscriptionProduct rationale) using persisted creds + StoreID.
export async function createWaffoPancakeSubscriptionProduct(data: {
name: string
amount: string
}): Promise<
ApiResponse<{ product_id: string; product_name: string; store_id: string }>
> {
const res = await api.post(
'/api/option/waffo-pancake/subscription-product',
data
)
return res.data
}
// Returns the OnetimeProducts in the saved Pancake store; empty when the
// gateway isn't fully configured.
export async function listWaffoPancakeSubscriptionProductOptions(): Promise<
ApiResponse<{
store_id: string
products: { id: string; name: string; status: string }[]
}>
> {
const res = await api.post(
'/api/option/waffo-pancake/subscription-product-options'
)
return res.data
}
export async function paySubscriptionEpay(
data: SubscriptionPayRequest & { payment_method: string }
): Promise<SubscriptionPayResponse & { url?: string }> {
@@ -42,6 +42,7 @@ import {
paySubscriptionStripe,
paySubscriptionCreem,
paySubscriptionEpay,
paySubscriptionWaffoPancake,
} from '../../api'
import { formatDuration, formatResetPeriod } from '../../lib'
import type { PlanRecord } from '../../types'
@@ -57,6 +58,7 @@ interface Props {
plan: PlanRecord | null
enableStripe?: boolean
enableCreem?: boolean
enableWaffoPancake?: boolean
enableOnlineTopUp?: boolean
epayMethods?: PaymentMethod[]
purchaseLimit?: number
@@ -81,9 +83,11 @@ export function SubscriptionPurchaseDialog(props: Props) {
const hasStripe = props.enableStripe && !!plan.stripe_price_id
const hasCreem = props.enableCreem && !!plan.creem_product_id
const hasWaffoPancake =
props.enableWaffoPancake && !!plan.waffo_pancake_product_id
const hasEpay =
props.enableOnlineTopUp && (props.epayMethods || []).length > 0
const hasAnyPayment = hasStripe || hasCreem || hasEpay
const hasAnyPayment = hasStripe || hasCreem || hasWaffoPancake || hasEpay
const selectedEpayMethodLabel =
(props.epayMethods || []).find((m) => m.type === selectedEpayMethod)
?.name ||
@@ -139,6 +143,29 @@ export function SubscriptionPurchaseDialog(props: Props) {
}
}
// In-tab redirect (not window.open) — user-gesture context is lost
// across the await, so a popup would be blocked. Same as the wallet hook.
const handlePayWaffoPancake = async () => {
setPaying(true)
try {
const res = await paySubscriptionWaffoPancake({ plan_id: plan.id })
if (res.message === 'success' && res.data?.checkout_url) {
toast.success(t('Redirecting to payment page...'))
window.location.href = res.data.checkout_url
} else {
toast.error(
res.message && res.message !== 'success'
? res.message
: t('Payment request failed')
)
}
} catch {
toast.error(t('Payment request failed'))
} finally {
setPaying(false)
}
}
const isSafari =
typeof navigator !== 'undefined' &&
/^((?!chrome|android).)*safari/i.test(navigator.userAgent)
@@ -262,7 +289,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
<p className='text-muted-foreground text-xs'>
{t('Select payment method')}
</p>
{(hasStripe || hasCreem) && (
{(hasStripe || hasCreem || hasWaffoPancake) && (
<div className='grid grid-cols-2 gap-2 sm:flex'>
{hasStripe && (
<Button
@@ -284,6 +311,16 @@ export function SubscriptionPurchaseDialog(props: Props) {
Creem
</Button>
)}
{hasWaffoPancake && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayWaffoPancake}
disabled={paying || limitReached}
>
Waffo Pancake
</Button>
)}
</div>
)}
{hasEpay && (
@@ -162,6 +162,13 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
{plan.creem_product_id && (
<StatusBadge label='Creem' variant='neutral' copyable={false} />
)}
{plan.waffo_pancake_product_id && (
<StatusBadge
label='Waffo Pancake'
variant='neutral'
copyable={false}
/>
)}
</div>
)
},
@@ -51,7 +51,13 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { createPlan, updatePlan, getGroups } from '../api'
import {
createPlan,
updatePlan,
getGroups,
createWaffoPancakeSubscriptionProduct,
listWaffoPancakeSubscriptionProductOptions,
} from '../api'
import { getDurationUnitOptions, getResetPeriodOptions } from '../constants'
import {
getPlanFormSchema,
@@ -79,6 +85,10 @@ export function SubscriptionsMutateDrawer({
const { triggerRefresh } = useSubscriptions()
const [isSubmitting, setIsSubmitting] = useState(false)
const [groupOptions, setGroupOptions] = useState<string[]>([])
const [creatingPancakeProduct, setCreatingPancakeProduct] = useState(false)
const [pancakeProducts, setPancakeProducts] = useState<
{ id: string; name: string; status: string }[]
>([])
const schema = getPlanFormSchema(t)
const form = useForm<PlanFormValues>({
@@ -98,11 +108,35 @@ export function SubscriptionsMutateDrawer({
if (res.success) setGroupOptions(res.data || [])
})
.catch(() => {})
// Best-effort — empty list still lets the operator use "+ Create".
listWaffoPancakeSubscriptionProductOptions()
.then((res) => {
if (
res.message === 'success' &&
typeof res.data === 'object' &&
res.data &&
Array.isArray((res.data as { products?: unknown }).products)
) {
setPancakeProducts(
(res.data as { products: typeof pancakeProducts }).products
)
} else {
setPancakeProducts([])
}
})
.catch(() => setPancakeProducts([]))
}
}, [open, currentRow, form])
const durationUnit = form.watch('duration_unit')
const resetPeriod = form.watch('quota_reset_period')
// Gate "+ Create on Pancake" on the same checks the mint handler runs.
const watchedTitle = form.watch('title')
const watchedPrice = form.watch('price_amount')
const pancakeCreateReady =
typeof watchedTitle === 'string' &&
watchedTitle.trim().length > 0 &&
Number(watchedPrice ?? 0) > 0
const onSubmit = async (values: PlanFormValues) => {
setIsSubmitting(true)
@@ -130,6 +164,72 @@ export function SubscriptionsMutateDrawer({
}
}
// Mints a Pancake OnetimeProduct (not SubscriptionProduct — see
// controller) using persisted creds + the form's title/price, then
// pins the returned PROD_ ID into the form field.
const handleCreatePancakeProduct = async () => {
const title = form.getValues('title').trim()
const priceAmount = Number(form.getValues('price_amount') || 0)
if (!title) {
toast.error(t('Plan title is required'))
return
}
if (priceAmount <= 0) {
toast.error(t('Plan price must be greater than zero'))
return
}
setCreatingPancakeProduct(true)
try {
const res = await createWaffoPancakeSubscriptionProduct({
name: title,
amount: priceAmount.toFixed(2),
})
if (
res.message === 'success' &&
typeof res.data === 'object' &&
res.data
) {
const created = res.data as { product_id: string; product_name: string }
form.setValue('waffo_pancake_product_id', created.product_id, {
shouldDirty: true,
})
// Refetch from GraphQL so the dropdown reflects authoritative state.
try {
const refresh = await listWaffoPancakeSubscriptionProductOptions()
if (
refresh.message === 'success' &&
typeof refresh.data === 'object' &&
refresh.data &&
Array.isArray((refresh.data as { products?: unknown }).products)
) {
setPancakeProducts(
(refresh.data as { products: typeof pancakeProducts }).products
)
}
} catch {
// Best-effort — form value already points at the new product;
// raw-ID fallback covers the missing label.
}
toast.success(
`${t('Waffo Pancake product created')}: ${created.product_id}`
)
} else {
const reason = typeof res.data === 'string' ? res.data : undefined
toast.error(
reason
? `${t('Waffo Pancake product creation failed')}: ${reason}`
: t('Waffo Pancake product creation failed')
)
}
} catch (err) {
toast.error(
`${t('Waffo Pancake product creation failed')}: ${err instanceof Error ? err.message : String(err)}`
)
} finally {
setCreatingPancakeProduct(false)
}
}
const durationUnitOpts = getDurationUnitOptions(t)
const resetPeriodOpts = getResetPeriodOptions(t)
@@ -546,6 +646,67 @@ export function SubscriptionsMutateDrawer({
</FormItem>
)}
/>
<FormField
control={form.control}
name='waffo_pancake_product_id'
render={({ field }) => {
// Raw-ID fallback for IDs not yet in the catalog.
const items = pancakeProducts.map((p) => ({
value: p.id,
label: `${p.name} (${p.id})`,
}))
if (
field.value &&
!pancakeProducts.some((p) => p.id === field.value)
) {
items.push({ value: field.value, label: field.value })
}
return (
<FormItem>
<FormLabel>Waffo Pancake Product ID</FormLabel>
<div className='flex gap-2'>
<Select
items={items}
value={field.value || ''}
onValueChange={(v) => field.onChange(v)}
disabled={items.length === 0}
>
<SelectTrigger className='w-full flex-1'>
<SelectValue
placeholder={t('Select a product')}
/>
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
onClick={handleCreatePancakeProduct}
disabled={creatingPancakeProduct || !pancakeCreateReady}
className='shrink-0'
>
{creatingPancakeProduct
? t('Creating...')
: `+ ${t('Create')}`}
</Button>
</div>
<FormDescription>
{t(
'Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)
}}
/>
</div>
</form>
</Form>
@@ -43,6 +43,7 @@ export function getPlanFormSchema(t: TFunction) {
upgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(),
creem_product_id: z.string().optional(),
waffo_pancake_product_id: z.string().optional(),
})
}
@@ -64,6 +65,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
upgrade_group: '',
stripe_price_id: '',
creem_product_id: '',
waffo_pancake_product_id: '',
}
export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
@@ -83,6 +85,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
upgrade_group: plan.upgrade_group || '',
stripe_price_id: plan.stripe_price_id || '',
creem_product_id: plan.creem_product_id || '',
waffo_pancake_product_id: plan.waffo_pancake_product_id || '',
}
}
+10
View File
@@ -40,6 +40,7 @@ export const subscriptionPlanSchema = z.object({
upgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(),
creem_product_id: z.string().optional(),
waffo_pancake_product_id: z.string().optional(),
})
export type SubscriptionPlan = z.infer<typeof subscriptionPlanSchema>
@@ -94,8 +95,17 @@ export interface SubscriptionPayResponse {
success: boolean
message?: string
data?: {
// Stripe-style hosted checkout link.
pay_link?: string
// Waffo Pancake / Creem hosted checkout URL.
checkout_url?: string
// Pancake-only: order metadata + self-service buyer session token,
// surfaced for future flows (refund / cancel from new-api's own UI).
session_id?: string
expires_at?: number | string
order_id?: string
token?: string
token_expires_at?: number | string
}
url?: string
}
+1 -8
View File
@@ -96,18 +96,11 @@ const defaultBillingSettings: BillingSettings = {
WaffoNotifyUrl: '',
WaffoReturnUrl: '',
WaffoPayMethods: '[]',
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeWebhookPublicKey: '',
WaffoPancakeWebhookTestKey: '',
WaffoPancakeReturnURL: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1,
WaffoPancakeMinTopUp: 1,
'checkin_setting.enabled': false,
'checkin_setting.min_quota': 1000,
'checkin_setting.max_quota': 10000,
@@ -177,20 +177,12 @@ const BILLING_SECTIONS = [
WaffoPayMethods: settings.WaffoPayMethods ?? '[]',
}}
waffoPancakeDefaultValues={{
WaffoPancakeEnabled: settings.WaffoPancakeEnabled ?? false,
WaffoPancakeSandbox: settings.WaffoPancakeSandbox ?? false,
WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '',
WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '',
WaffoPancakeWebhookPublicKey:
settings.WaffoPancakeWebhookPublicKey ?? '',
WaffoPancakeWebhookTestKey: settings.WaffoPancakeWebhookTestKey ?? '',
WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '',
WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '',
WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '',
WaffoPancakeCurrency: settings.WaffoPancakeCurrency ?? 'USD',
WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1,
WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1,
}}
waffoPancakeProvisionedStoreID={settings.WaffoPancakeStoreID ?? ''}
waffoPancakeProvisionedProductID={settings.WaffoPancakeProductID ?? ''}
complianceDefaults={{
confirmed: settings['payment_setting.compliance_confirmed'] ?? false,
termsVersion:
@@ -149,6 +149,8 @@ type PaymentSettingsSectionProps = {
defaultValues: PaymentFormValues
waffoDefaultValues: WaffoSettingsValues
waffoPancakeDefaultValues: WaffoPancakeSettingsValues
waffoPancakeProvisionedStoreID?: string
waffoPancakeProvisionedProductID?: string
complianceDefaults: PaymentComplianceDefaults
}
@@ -156,6 +158,8 @@ export function PaymentSettingsSection({
defaultValues,
waffoDefaultValues,
waffoPancakeDefaultValues,
waffoPancakeProvisionedStoreID,
waffoPancakeProvisionedProductID,
complianceDefaults,
}: PaymentSettingsSectionProps) {
const { t } = useTranslation()
@@ -1468,11 +1472,15 @@ export function PaymentSettingsSection({
<Separator />
<WaffoSettingsSection defaultValues={waffoDefaultValues} />
<WaffoPancakeSettingsSection
defaultValues={waffoPancakeDefaultValues}
provisionedStoreID={waffoPancakeProvisionedStoreID}
provisionedProductID={waffoPancakeProvisionedProductID}
/>
<Separator />
<WaffoPancakeSettingsSection defaultValues={waffoPancakeDefaultValues} />
<WaffoSettingsSection defaultValues={waffoDefaultValues} />
{/* eslint-enable react-hooks/refs */}
</SettingsSection>
)
@@ -0,0 +1,102 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
// Catalog / pair / save admin endpoints. Match
// controller/topup_waffo_pancake.go: empty body creds make the backend
// fall back to persisted OptionMap values, so returning admins don't
// have to re-paste the private key (stripped from GET /api/option/).
export interface CatalogProduct {
id: string
name: string
status: string
}
export interface CatalogStore {
id: string
name: string
status: string
prodEnabled: boolean
onetimeProducts: CatalogProduct[]
}
export interface PairResult {
store_id: string
store_name: string
product_id: string
product_name: string
}
export interface PairOrphanError {
error?: string
orphan_store?: boolean
store_id?: string
store_name?: string
}
interface BackendBody<T> {
message?: string
data?: T | string
}
export type CatalogResponse = BackendBody<{ stores: CatalogStore[] }>
export type PairResponse = BackendBody<PairResult>
export type SaveResponse = BackendBody<{ product_id: string; store_id: string }>
export async function listWaffoPancakeCatalog(
merchantID: string,
privateKey: string
): Promise<CatalogResponse> {
const res = await api.post<CatalogResponse>(
'/api/option/waffo-pancake/catalog',
{ merchant_id: merchantID, private_key: privateKey }
)
return res.data
}
export async function createWaffoPancakePair(params: {
merchantID: string
privateKey: string
returnURL: string
}): Promise<PairResponse> {
const res = await api.post<PairResponse>('/api/option/waffo-pancake/pair', {
merchant_id: params.merchantID,
private_key: params.privateKey,
return_url: params.returnURL,
})
return res.data
}
export async function saveWaffoPancakeConfig(params: {
merchantID: string
privateKey: string
returnURL: string
storeID: string
productID: string
}): Promise<SaveResponse> {
const res = await api.post<SaveResponse>('/api/option/waffo-pancake/save', {
merchant_id: params.merchantID,
private_key: params.privateKey,
return_url: params.returnURL,
store_id: params.storeID,
product_id: params.productID,
})
return res.data
}
@@ -16,293 +16,714 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import * as React from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { removeTrailingSlash } from './utils'
import {
type CatalogStore,
type PairOrphanError,
type PairResult,
createWaffoPancakePair,
listWaffoPancakeCatalog,
saveWaffoPancakeConfig,
} from './waffo-pancake-api'
export interface WaffoPancakeSettingsValues {
WaffoPancakeEnabled: boolean
WaffoPancakeSandbox: boolean
WaffoPancakeMerchantID: string
WaffoPancakePrivateKey: string
WaffoPancakeWebhookPublicKey: string
WaffoPancakeWebhookTestKey: string
WaffoPancakeStoreID: string
WaffoPancakeProductID: string
// Only operator-typed fields. Nothing else lands in OptionMap until Save.
const waffoPancakeSchema = z.object({
WaffoPancakeMerchantID: z.string(),
WaffoPancakePrivateKey: z.string(),
})
export type WaffoPancakeSettingsValues = z.infer<typeof waffoPancakeSchema> & {
WaffoPancakeReturnURL: string
WaffoPancakeCurrency: string
WaffoPancakeUnitPrice: number
WaffoPancakeMinTopUp: number
}
interface Props {
defaultValues: WaffoPancakeSettingsValues
provisionedStoreID?: string
provisionedProductID?: string
}
const PANCAKE_DASHBOARD_URL = 'https://pancake.waffo.ai/merchant/dashboard'
const DEFAULT_NEW_STORE_NAME = 'new-api-store'
const DEFAULT_NEW_PRODUCT_NAME = 'new-api-charge-product'
const DEFAULT_NEW_PAIR_NAME = `${DEFAULT_NEW_STORE_NAME} + ${DEFAULT_NEW_PRODUCT_NAME}`
export function WaffoPancakeSettingsSection(props: Props) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [loading, setLoading] = useState(false)
const form = useForm<WaffoPancakeSettingsValues>({
defaultValues: props.defaultValues,
const [storeID, setStoreID] = React.useState(
props.provisionedStoreID ?? ''
)
const [productID, setProductID] = React.useState(
props.provisionedProductID ?? ''
)
const [phase, setPhase] = React.useState<'idle' | 'verifying' | 'saving'>(
'idle'
)
const [catalog, setCatalog] = React.useState<CatalogStore[]>([])
// Seed dropdowns from saved bindings so they render on first paint instead
// of waiting for the async catalog fetch to confirm them.
const [chosenStoreID, setChosenStoreID] = React.useState<string>(
props.provisionedStoreID ?? ''
)
const [chosenProductID, setChosenProductID] = React.useState<string>(
props.provisionedProductID ?? ''
)
const [returnURL, setReturnURL] = React.useState(
props.defaultValues.WaffoPancakeReturnURL ?? ''
)
const [creatingPair, setCreatingPair] = React.useState(false)
const initialRef = React.useRef(props.defaultValues)
const defaultsSignature = React.useMemo(
() => JSON.stringify(props.defaultValues),
[props.defaultValues]
)
// "merchantID|privateKey" of the last verified pair; debounced verify
// skips when nothing changed.
const lastVerifiedSignature = React.useRef('')
const fetchSerialRef = React.useRef(0)
const form = useForm({
resolver: zodResolver(waffoPancakeSchema),
mode: 'onChange',
defaultValues: {
WaffoPancakeMerchantID: props.defaultValues.WaffoPancakeMerchantID,
WaffoPancakePrivateKey: props.defaultValues.WaffoPancakePrivateKey,
},
})
useEffect(() => {
form.reset(props.defaultValues)
}, [props.defaultValues, form])
// Mount-only — never re-sync from props after the first render. The
// backend strips PrivateKey from GET /api/option/, so a re-sync would
// wipe whatever the operator just typed.
const didMountRef = React.useRef(false)
React.useEffect(() => {
const parsed = JSON.parse(defaultsSignature) as WaffoPancakeSettingsValues
initialRef.current = parsed
if (didMountRef.current) return
didMountRef.current = true
form.reset({
WaffoPancakeMerchantID: parsed.WaffoPancakeMerchantID,
WaffoPancakePrivateKey: parsed.WaffoPancakePrivateKey,
})
setReturnURL(parsed.WaffoPancakeReturnURL ?? '')
lastVerifiedSignature.current = `${parsed.WaffoPancakeMerchantID.trim()}|${parsed.WaffoPancakePrivateKey.trim()}`
}, [defaultsSignature, form])
const handleSave = async () => {
const values = form.getValues()
const enabled = !!values.WaffoPancakeEnabled
const sandbox = !!values.WaffoPancakeSandbox
React.useEffect(() => {
setStoreID(props.provisionedStoreID ?? '')
}, [props.provisionedStoreID])
if (enabled && !values.WaffoPancakeMerchantID.trim()) {
toast.error(t('Merchant ID is required'))
return
React.useEffect(() => {
setProductID(props.provisionedProductID ?? '')
}, [props.provisionedProductID])
const productsForChosenStore = React.useMemo(() => {
if (!chosenStoreID) return []
return catalog.find((s) => s.id === chosenStoreID)?.onetimeProducts ?? []
}, [catalog, chosenStoreID])
// Raw-ID fallback items render the trigger before the catalog loads or
// when the saved entity has been deleted upstream.
const storeSelectItems = React.useMemo(() => {
const items = catalog.map((s) => ({
value: s.id,
label: `${s.name} (${s.id})`,
}))
if (chosenStoreID && !catalog.some((s) => s.id === chosenStoreID)) {
items.push({ value: chosenStoreID, label: chosenStoreID })
}
if (enabled && !values.WaffoPancakeStoreID.trim()) {
toast.error(t('Store ID is required'))
return
return items
}, [catalog, chosenStoreID])
const productSelectItems = React.useMemo(() => {
const items = productsForChosenStore.map((p) => ({
value: p.id,
label: `${p.name} (${p.id})`,
}))
if (
chosenProductID &&
!productsForChosenStore.some((p) => p.id === chosenProductID)
) {
items.push({ value: chosenProductID, label: chosenProductID })
}
return items
}, [productsForChosenStore, chosenProductID])
if (enabled && !values.WaffoPancakeProductID.trim()) {
toast.error(t('Product ID is required'))
return
}
// Verifies typed creds against Pancake (via /catalog) and refreshes the
// dropdown options. `preselect` overrides the post-load anchor selection;
// omitting it defaults to: saved binding → first store with products.
const verifyAndFetchCatalog = React.useCallback(
async (
merchantID: string,
privateKey: string,
preselect?: { storeID?: string; productID?: string }
) => {
const serial = ++fetchSerialRef.current
let stores: CatalogStore[] = []
try {
const body = await listWaffoPancakeCatalog(merchantID, privateKey)
if (serial !== fetchSerialRef.current) return
if (
body?.message === 'success' &&
typeof body.data === 'object' &&
body.data
) {
stores = (body.data as { stores: CatalogStore[] }).stores ?? []
} else {
const reason = typeof body?.data === 'string' ? body.data : undefined
toast.error(
reason
? `${t('Credentials verification failed')}: ${reason}`
: t(
'Credentials verification failed — double-check Merchant ID and API private key.'
)
)
setPhase('idle')
return
}
} catch (err) {
if (serial !== fetchSerialRef.current) return
toast.error(
`${t('Credentials verification failed')}: ${
err instanceof Error ? err.message : String(err)
}`
)
setPhase('idle')
return
}
if (serial !== fetchSerialRef.current) return
const requiredWebhookKey = sandbox
? values.WaffoPancakeWebhookTestKey
: values.WaffoPancakeWebhookPublicKey
if (enabled && !String(requiredWebhookKey || '').trim()) {
setCatalog(stores)
if (preselect) {
setChosenStoreID(preselect.storeID ?? '')
setChosenProductID(preselect.productID ?? '')
} else {
// Default anchor: bound product if found, else first product of
// the first store with any — saves a click for new operators.
const boundStore = stores.find((s) =>
s.onetimeProducts.some((p) => p.id === productID)
)
if (boundStore && productID) {
setChosenStoreID(boundStore.id)
setChosenProductID(productID)
} else {
const storeWithProducts = stores.find(
(s) => s.onetimeProducts.length > 0
)
if (storeWithProducts) {
setChosenStoreID(storeWithProducts.id)
setChosenProductID(storeWithProducts.onetimeProducts[0].id)
} else {
setChosenStoreID('')
setChosenProductID('')
}
}
}
setPhase('idle')
},
[productID, t]
)
const watchedMerchantID = form.watch('WaffoPancakeMerchantID') || ''
const watchedPrivateKey = form.watch('WaffoPancakePrivateKey') || ''
React.useEffect(() => {
const m = watchedMerchantID.trim()
const k = watchedPrivateKey.trim()
if (!m || !k) return
const signature = `${m}|${k}`
if (signature === lastVerifiedSignature.current) return
const timer = setTimeout(() => {
lastVerifiedSignature.current = signature
setPhase('verifying')
void verifyAndFetchCatalog(m, k)
}, 800)
return () => clearTimeout(timer)
}, [watchedMerchantID, watchedPrivateKey, verifyAndFetchCatalog])
// Initial-load verify: GET /api/option/ strips PrivateKey so a returning
// admin opens the page with empty key. Send blank creds in the body —
// the catalog controller falls back to the persisted OptionMap creds.
const initialLoadRef = React.useRef(false)
React.useEffect(() => {
if (initialLoadRef.current) return
if (!props.defaultValues.WaffoPancakeMerchantID.trim()) return
initialLoadRef.current = true
setPhase('verifying')
void verifyAndFetchCatalog('', '')
}, [props.defaultValues.WaffoPancakeMerchantID, verifyAndFetchCatalog])
// Returns typed creds when the operator edited either field; otherwise
// blanks so the backend falls back to persisted creds. Without this,
// returning admins (saved merchant ID but empty key field) would send
// a mixed-state body that the backend rejects.
const readCreds = () => {
const formMerchant = (
form.getValues('WaffoPancakeMerchantID') || ''
).trim()
const formKey = (form.getValues('WaffoPancakePrivateKey') || '').trim()
const saved = (props.defaultValues.WaffoPancakeMerchantID || '').trim()
const edited = formMerchant !== saved || formKey.length > 0
if (!edited) return { merchantID: '', privateKey: '' }
return { merchantID: formMerchant, privateKey: formKey }
}
// The minted product's SuccessURL is pinned to the current Return URL
// field, so we prompt before creating when that field is empty.
const handleCreatePair = async () => {
if (!credsReady) {
toast.error(
sandbox
? t('Webhook public key (sandbox) is required')
: t('Webhook public key (production) is required')
t('Fill in both Merchant ID and API Private Key before creating.')
)
return
}
if (enabled && Number(values.WaffoPancakeUnitPrice) <= 0) {
toast.error(t('Unit price must be greater than 0'))
return
const { merchantID, privateKey } = readCreds()
const trimmedReturn = removeTrailingSlash(returnURL.trim())
if (!trimmedReturn) {
if (
!window.confirm(
t(
'Payment return URL is empty. Create the product without a SuccessURL redirect?'
)
)
) {
return
}
}
if (enabled && Number(values.WaffoPancakeMinTopUp) < 1) {
toast.error(t('Minimum top-up amount must be at least 1'))
return
}
setLoading(true)
setCreatingPair(true)
try {
const options: { key: string; value: string }[] = [
{ key: 'WaffoPancakeEnabled', value: enabled ? 'true' : 'false' },
{ key: 'WaffoPancakeSandbox', value: sandbox ? 'true' : 'false' },
{
key: 'WaffoPancakeMerchantID',
value: values.WaffoPancakeMerchantID || '',
},
{
key: 'WaffoPancakeStoreID',
value: values.WaffoPancakeStoreID || '',
},
{
key: 'WaffoPancakeProductID',
value: values.WaffoPancakeProductID || '',
},
{
key: 'WaffoPancakeReturnURL',
value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''),
},
{
key: 'WaffoPancakeCurrency',
value: values.WaffoPancakeCurrency || 'USD',
},
{
key: 'WaffoPancakeUnitPrice',
value: String(values.WaffoPancakeUnitPrice ?? 1),
},
{
key: 'WaffoPancakeMinTopUp',
value: String(values.WaffoPancakeMinTopUp ?? 1),
},
]
if ((values.WaffoPancakePrivateKey || '').trim()) {
options.push({
key: 'WaffoPancakePrivateKey',
value: values.WaffoPancakePrivateKey,
const body = await createWaffoPancakePair({
merchantID,
privateKey,
returnURL: trimmedReturn,
})
if (
body?.message === 'success' &&
typeof body.data === 'object' &&
body.data
) {
const created = body.data as PairResult
// Refetch from GraphQL rather than trusting the response body so the
// dropdowns reflect authoritative state, then anchor on minted IDs.
setPhase('verifying')
await verifyAndFetchCatalog(merchantID, privateKey, {
storeID: created.store_id,
productID: created.product_id,
})
toast.success(
`${t('Store + product created')}: ${created.store_id} / ${created.product_id}`
)
return
}
const errData =
body && typeof body.data === 'object' && body.data !== null
? (body.data as PairOrphanError)
: null
if (errData?.orphan_store && errData.store_id) {
setPhase('verifying')
await verifyAndFetchCatalog(merchantID, privateKey, {
storeID: errData.store_id,
productID: '',
})
}
if ((values.WaffoPancakeWebhookPublicKey || '').trim()) {
options.push({
key: 'WaffoPancakeWebhookPublicKey',
value: values.WaffoPancakeWebhookPublicKey,
})
}
if ((values.WaffoPancakeWebhookTestKey || '').trim()) {
options.push({
key: 'WaffoPancakeWebhookTestKey',
value: values.WaffoPancakeWebhookTestKey,
})
}
for (const option of options) {
await updateOption.mutateAsync(option)
}
toast.success(t('Updated successfully'))
} catch {
toast.error(t('Update failed'))
const reason =
errData?.error ??
(typeof body?.data === 'string' ? body.data : undefined)
toast.error(
reason ? `${t('Creation failed')}: ${reason}` : t('Creation failed')
)
} catch (err) {
toast.error(
`${t('Creation failed')}: ${err instanceof Error ? err.message : String(err)}`
)
} finally {
setLoading(false)
setCreatingPair(false)
}
}
const handleSave = async () => {
// Sends raw form values (not readCreds): SaveWaffoPancakeConfig already
// treats a blank PrivateKey as "keep existing", and MerchantID stays
// populated from props for returning admins.
const merchantID = (
form.getValues('WaffoPancakeMerchantID') || ''
).trim()
const privateKey = (
form.getValues('WaffoPancakePrivateKey') || ''
).trim()
if (!merchantID) {
toast.error(t('Merchant ID is required'))
return
}
if (!chosenStoreID || !chosenProductID) {
toast.error(t('Pick or create both a store and a product before saving.'))
return
}
setPhase('saving')
try {
const body = await saveWaffoPancakeConfig({
merchantID,
privateKey,
returnURL: removeTrailingSlash(returnURL.trim()),
storeID: chosenStoreID,
productID: chosenProductID,
})
if (
body?.message === 'success' &&
typeof body.data === 'object' &&
body.data
) {
const saved = body.data as { product_id: string; store_id: string }
setStoreID(saved.store_id)
setProductID(saved.product_id)
toast.success(t('Waffo Pancake settings saved'))
} else {
const reason = typeof body?.data === 'string' ? body.data : undefined
toast.error(
reason
? `${t('Waffo Pancake save failed')}: ${reason}`
: t('Waffo Pancake save failed')
)
}
} catch (err) {
toast.error(
`${t('Waffo Pancake save failed')}: ${
err instanceof Error ? err.message : String(err)
}`
)
} finally {
setPhase('idle')
}
}
const verifying = phase === 'verifying'
const saving = phase === 'saving'
// "Not edited" = MerchantID unchanged AND PrivateKey field blank, in
// which case the backend falls back to persisted creds. Otherwise we
// require both fields filled (mixed states would fail signature check).
const savedMerchantID = (
props.defaultValues.WaffoPancakeMerchantID || ''
).trim()
const formMerchantID = watchedMerchantID.trim()
const formPrivateKey = watchedPrivateKey.trim()
const credsEdited =
formMerchantID !== savedMerchantID || formPrivateKey.length > 0
const hasSavedCreds = savedMerchantID.length > 0
const credsReady = credsEdited
? formMerchantID.length > 0 && formPrivateKey.length > 0
: hasSavedCreds
const hasCatalog = catalog.length > 0
let bindStatusMessage: string
if (!credsReady) {
bindStatusMessage = t('Fill in the credentials above to begin.')
} else if (verifying) {
bindStatusMessage = t(
'Verifying credentials and pulling stores from your Pancake account...'
)
} else if (hasCatalog) {
bindStatusMessage = t(
'Mint a fresh pair below — or pick an existing one further down. Click Save when ready.'
)
} else {
bindStatusMessage = t(
'No stores on this merchant yet. Set a return URL and click Create to mint your first pair.'
)
}
return (
<SettingsSection
title={t('Waffo Pancake Payment Gateway')}
description={t(
'Configure Waffo Pancake hosted checkout integration for USD-priced top-ups'
)}
>
<Alert>
<AlertDescription className='text-xs'>
<div className='space-y-4 pt-4'>
<div>
<h3 className='text-lg font-medium'>{t('Waffo Pancake MoR')}</h3>
<p className='text-muted-foreground text-sm'>
{t(
'Obtain the merchant, store, product and signing keys from your Waffo dashboard. Webhook URL: <ServerAddress>/api/waffo-pancake/webhook'
'Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.'
)}
</AlertDescription>
</Alert>
<div className='grid grid-cols-3 gap-4'>
<div className='flex items-center gap-2'>
<Switch
checked={form.watch('WaffoPancakeEnabled')}
onCheckedChange={(value) =>
form.setValue('WaffoPancakeEnabled', value)
}
/>
<Label>{t('Enable Waffo Pancake')}</Label>
</div>
<div className='flex items-center gap-2'>
<Switch
checked={form.watch('WaffoPancakeSandbox')}
onCheckedChange={(value) =>
form.setValue('WaffoPancakeSandbox', value)
}
/>
<Label>{t('Sandbox mode')}</Label>
</div>
<div className='grid gap-1.5'>
<Label>{t('Currency')}</Label>
<Input placeholder='USD' {...form.register('WaffoPancakeCurrency')} />
</div>
</p>
</div>
<Form {...form}>
<form
onSubmit={(e) => e.preventDefault()}
className='space-y-4'
data-no-autosubmit='true'
>
{/* Blue box — webhook configuration only. */}
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 dark:bg-blue-950 dark:text-blue-100'>
<p className='mb-2 font-medium'>{t('Webhook Configuration:')}</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t('Webhook URL (Test):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
{'<ServerAddress>/api/waffo-pancake/webhook/test'}
</code>
</li>
<li>
{t('Webhook URL (Production):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
{'<ServerAddress>/api/waffo-pancake/webhook/prod'}
</code>
</li>
<li>
{t(
'Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.'
)}
</li>
<li>
{t('Configure at:')}{' '}
<a
href={PANCAKE_DASHBOARD_URL}
target='_blank'
rel='noreferrer'
className='underline hover:no-underline'
>
{t('Waffo Pancake Dashboard')}
</a>
</li>
</ul>
</div>
<div className='grid grid-cols-3 gap-4'>
<div className='grid gap-1.5'>
<Label>{t('Merchant ID')}</Label>
<Input
placeholder='MER_xxx'
{...form.register('WaffoPancakeMerchantID')}
<FormField
control={form.control}
name='WaffoPancakeMerchantID'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Merchant ID')}</FormLabel>
<FormControl>
<Input
placeholder='MER_xxx'
autoComplete='off'
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Store ID')}</Label>
<Input
placeholder='STO_xxx'
{...form.register('WaffoPancakeStoreID')}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Product ID')}</Label>
<Input
placeholder='PROD_xxx'
{...form.register('WaffoPancakeProductID')}
/>
</div>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-1.5'>
<Label>{t('API Private Key')}</Label>
<Textarea
rows={3}
placeholder={t('Leave blank to keep the existing key')}
{...form.register('WaffoPancakePrivateKey')}
className='font-mono text-xs'
<FormField
control={form.control}
name='WaffoPancakePrivateKey'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API Private Key')}</FormLabel>
<FormControl>
<Textarea
rows={4}
placeholder={t('Leave blank to keep the existing key')}
autoComplete='new-password'
{...field}
onChange={(event) => field.onChange(event.target.value)}
className='font-mono text-xs'
/>
</FormControl>
<p className='text-muted-foreground text-xs'>
{t(
'The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.'
)}
</p>
<FormMessage />
</FormItem>
)}
/>
<p className='text-muted-foreground text-xs'>
{t('Stored value is not echoed back for security')}
</p>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment return URL')}</Label>
<Input
placeholder='https://example.com/console/topup'
{...form.register('WaffoPancakeReturnURL')}
/>
<p className='text-muted-foreground text-xs'>
{t('Defaults to the wallet page when empty')}
</p>
</div>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-1.5'>
<Label>{t('Webhook public key (production)')}</Label>
<Textarea
rows={3}
placeholder={t('Leave blank to keep the existing key')}
{...form.register('WaffoPancakeWebhookPublicKey')}
className='font-mono text-xs'
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Webhook public key (sandbox)')}</Label>
<Textarea
rows={3}
placeholder={t('Leave blank to keep the existing key')}
{...form.register('WaffoPancakeWebhookTestKey')}
className='font-mono text-xs'
/>
</div>
</div>
{/*
Binding section — split into two visually distinct paths:
(A) "Use existing" pair from the loaded catalog — only rendered when
the merchant actually has stores, so first-time setup isn't
cluttered by dead dropdowns.
(B) "Create a fresh pair" — always available, paired with the
return URL field that's only meaningful here.
The two paths are split by an "or" divider so the operator never has
to wonder which field belongs to which intent.
*/}
<div className='space-y-4 pt-2'>
<div>
<h4 className='font-medium'>
{t('Bind a Pancake store + product')}
</h4>
<p className='text-muted-foreground text-xs'>
{bindStatusMessage}
</p>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-1.5'>
<Label>{t('Unit price (local currency / USD)')}</Label>
<Input
type='number'
step={0.01}
min={0}
{...form.register('WaffoPancakeUnitPrice', { valueAsNumber: true })}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Minimum top-up (USD)')}</Label>
<Input
type='number'
min={1}
{...form.register('WaffoPancakeMinTopUp', { valueAsNumber: true })}
/>
</div>
</div>
{/*
Operator-facing explainer: why only ONE store + product needs
to be bound at the gateway level, and what each piece is used
for. Subscriptions reuse the same Store but get their own
per-plan product, configured in the Subscriptions admin.
*/}
<div className='rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-900 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100'>
<p className='mb-1 font-medium'>
{t('Why only one store + product?')}
</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t(
'The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.'
)}
</li>
<li>
{t(
'The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.'
)}
</li>
<li>
{t(
'Subscription plans do NOT use the bound Product — each plan has its own dedicated Pancake product, set in the Subscriptions admin (or auto-minted via the "+ Create" button there).'
)}
</li>
</ul>
</div>
<Button onClick={handleSave} disabled={loading}>
{loading ? t('Saving...') : t('Save Waffo Pancake settings')}
</Button>
</SettingsSection>
{/* 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(
"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.
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'>
<Button
type='button'
onClick={handleSave}
disabled={saving || !chosenStoreID || !chosenProductID}
>
{saving ? t('Saving...') : t('Save Waffo Pancake settings')}
</Button>
{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>
</form>
</Form>
</div>
)
}
@@ -43,7 +43,6 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
export interface WaffoSettingsValues {
@@ -212,12 +211,17 @@ export function WaffoSettingsSection(props: Props) {
return (
<>
<SettingsSection
title={t('Waffo Payment Gateway')}
description={t(
'Configure Waffo payment aggregation platform integration'
)}
>
<div className='space-y-4 pt-4'>
<div>
<h3 className='text-lg font-medium'>
{t('Waffo Aggregator Gateway')}
</h3>
<p className='text-muted-foreground text-sm'>
{t(
'Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.'
)}
</p>
</div>
<Alert>
<AlertDescription className='text-xs'>
{t(
@@ -416,7 +420,7 @@ export function WaffoSettingsSection(props: Props) {
<Button onClick={handleSave} disabled={loading}>
{loading ? t('Saving...') : t('Save Changes')}
</Button>
</SettingsSection>
</div>
<Dialog open={methodDialogOpen} onOpenChange={setMethodDialogOpen}>
<DialogContent>
+3 -8
View File
@@ -256,18 +256,13 @@ export type BillingSettings = {
WaffoNotifyUrl: string
WaffoReturnUrl: string
WaffoPayMethods: string
WaffoPancakeEnabled: boolean
WaffoPancakeSandbox: boolean
WaffoPancakeMerchantID: string
WaffoPancakePrivateKey: string
WaffoPancakeWebhookPublicKey: string
WaffoPancakeWebhookTestKey: string
WaffoPancakeReturnURL: string
// Bound by the operator through the catalog flow in the admin Pancake
// section (saved via /api/option/waffo-pancake/save).
WaffoPancakeStoreID: string
WaffoPancakeProductID: string
WaffoPancakeReturnURL: string
WaffoPancakeCurrency: string
WaffoPancakeUnitPrice: number
WaffoPancakeMinTopUp: number
'checkin_setting.enabled': boolean
'checkin_setting.min_quota': number
'checkin_setting.max_quota': number
@@ -111,6 +111,7 @@ export function SubscriptionPlansCard({
const enableStripe = !!topupInfo?.enable_stripe_topup
const enableCreem = !!topupInfo?.enable_creem_topup
const enableWaffoPancake = !!topupInfo?.enable_waffo_pancake_topup
const enableOnlineTopUp = !!topupInfo?.enable_online_topup
const epayMethods = useMemo(
() => getEpayMethods(topupInfo?.pay_methods),
@@ -629,6 +630,7 @@ export function SubscriptionPlansCard({
plan={selectedPlan}
enableStripe={enableStripe}
enableCreem={enableCreem}
enableWaffoPancake={enableWaffoPancake}
enableOnlineTopUp={enableOnlineTopUp}
epayMethods={epayMethods}
purchaseLimit={
@@ -59,11 +59,10 @@ function getErrorMessage(message: string | undefined, data: unknown): string {
}
/**
* Hook for handling Waffo Pancake payment processing
* Hook for the Waffo Pancake hosted-checkout flow.
*
* Pancake uses a hosted checkout URL flow rather than the generic epay form
* submission, so we open the returned URL in a new tab once the backend
* returns a successful response.
* Same-tab redirect (window.location.href) rather than window.open: the
* user-gesture context is lost across the await, so popups get blocked.
*/
export function useWaffoPancakePayment() {
const [processing, setProcessing] = useState(false)
@@ -85,8 +84,8 @@ export function useWaffoPancakePayment() {
toast.error(i18next.t('Invalid payment redirect URL'))
return false
}
window.open(checkoutUrl, '_blank', 'noopener,noreferrer')
toast.success(i18next.t('Redirecting to payment page...'))
window.location.href = checkoutUrl
return true
}
}
+19 -4
View File
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { type ReactNode } from 'react'
import { CreditCard, Landmark } from 'lucide-react'
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'
import i18next from 'i18next'
import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants'
// ============================================================================
@@ -121,11 +122,25 @@ export function getPaymentIcon(
/>
)
case PAYMENT_TYPES.WAFFO_PANCAKE:
// The W glyph fills only ~40% of its viewBox vertically (wide and
// short letterform); scale(2) brings its rendered height in line
// with Stripe's S and Creem's Landmark.
return (
<CreditCard
className={className}
style={{ color: PAYMENT_ICON_COLORS[PAYMENT_TYPES.WAFFO_PANCAKE] }}
/>
<span
className={`inline-flex items-center justify-center leading-none ${className}`}
style={{ transform: 'scale(2)' }}
>
<img
src='/waffo-logo-light.svg'
alt={i18next.t('Waffo')}
className='block h-full w-full object-contain dark:hidden'
/>
<img
src='/waffo-logo-dark.svg'
alt={i18next.t('Waffo')}
className='hidden h-full w-full object-contain dark:block'
/>
</span>
)
default:
return <CreditCard className={className} />
+5
View File
@@ -51,6 +51,11 @@ export type WaffoPancakePaymentResponse = ApiResponse<
session_id?: string
expires_at?: number | string
order_id?: string
// Self-service session token + expiry — surfaced by the backend so
// future flows (refund / cancel from new-api's own UI) can use them
// without re-issuing checkout. Not consumed by the current handler.
token?: string
token_expires_at?: number | string
}
| string
>