[Feature Request] Waffo Pancake gateway — full integration with subscription support + admin catalog binding flow (#4935)
This commit is contained in:
+36
@@ -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 }> {
|
||||
|
||||
+39
-2
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
+162
-1
@@ -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 plan’s 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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user