[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
}