[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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
+10
-2
@@ -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
|
||||
}
|
||||
+660
-239
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+12
-8
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user