[Feature Request] Waffo Pancake gateway — full integration with subscription support + admin catalog binding flow (#4935)
This commit is contained in:
+80
-22
@@ -16,11 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
|
||||
interface FooterLink {
|
||||
text: string
|
||||
@@ -74,23 +75,75 @@ function FooterLinkItem(props: { link: FooterLink }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectAttribution(props: { currentYear: number }) {
|
||||
// Renders User Agreement / Privacy Policy links inline with the parent's
|
||||
// copyright row when either is configured in System Settings → Site. Emits
|
||||
// fragmented siblings so the parent flex container's gap controls spacing.
|
||||
function LegalLinks(props: { leadingSeparator?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
const items: { key: string; label: string; href: string }[] = []
|
||||
if (status?.user_agreement_enabled) {
|
||||
items.push({
|
||||
key: 'user-agreement',
|
||||
label: t('User Agreement'),
|
||||
href: '/user-agreement',
|
||||
})
|
||||
}
|
||||
if (status?.privacy_policy_enabled) {
|
||||
items.push({
|
||||
key: 'privacy-policy',
|
||||
label: t('Privacy Policy'),
|
||||
href: '/privacy-policy',
|
||||
})
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.key}>
|
||||
{(props.leadingSeparator || index > 0) && (
|
||||
<span aria-hidden='true' className='text-muted-foreground/30'>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={item.href}
|
||||
className='hover:text-foreground transition-colors duration-200'
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// inline=true returns just the inner span for composition in a parent flex
|
||||
// row. inline=false wraps in a centered/right-aligned div (default).
|
||||
function ProjectAttribution(props: { currentYear: number; inline?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const content = (
|
||||
<span className='text-muted-foreground/45'>
|
||||
© {props.currentYear}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
|
||||
>
|
||||
{t('New API')}
|
||||
</a>
|
||||
. {t(NEW_API_FOOTER_ATTRIBUTION_KEY)}
|
||||
</span>
|
||||
)
|
||||
if (props.inline) {
|
||||
return content
|
||||
}
|
||||
return (
|
||||
<div className='text-muted-foreground/45 text-center text-xs sm:text-right'>
|
||||
<span className='text-muted-foreground/45'>
|
||||
© {props.currentYear}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
|
||||
>
|
||||
{t('New API')}
|
||||
</a>
|
||||
. {t(NEW_API_FOOTER_ATTRIBUTION_KEY)}
|
||||
</span>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -182,8 +235,9 @@ export function Footer(props: FooterProps) {
|
||||
className='custom-footer text-muted-foreground min-w-0 text-center text-sm sm:text-left'
|
||||
dangerouslySetInnerHTML={{ __html: footerHtml }}
|
||||
/>
|
||||
<div className='border-border/60 w-full border-t pt-4 sm:w-auto sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
|
||||
<ProjectAttribution currentYear={currentYear} />
|
||||
<div className='border-border/60 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-muted-foreground/45 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
|
||||
<LegalLinks />
|
||||
<ProjectAttribution currentYear={currentYear} inline />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,12 +289,16 @@ export function Footer(props: FooterProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className='border-border/30 mt-12 flex flex-col items-center justify-between gap-3 border-t pt-6 sm:flex-row'>
|
||||
<p className='text-muted-foreground/40 text-xs'>
|
||||
© {currentYear} {displayName}.{' '}
|
||||
{props.copyright ?? t('footer.defaultCopyright')}
|
||||
</p>
|
||||
{/* Copyright + optional legal links inline on the left, project
|
||||
attribution on the right; wraps on narrow screens. */}
|
||||
<div className='border-border/30 mt-12 flex flex-col items-center justify-between gap-x-3 gap-y-2 border-t pt-6 sm:flex-row'>
|
||||
<div className='text-muted-foreground/40 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs sm:justify-start'>
|
||||
<span>
|
||||
© {currentYear} {displayName}.{' '}
|
||||
{props.copyright ?? t('footer.defaultCopyright')}
|
||||
</span>
|
||||
<LegalLinks leadingSeparator />
|
||||
</div>
|
||||
<ProjectAttribution currentYear={currentYear} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+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
|
||||
>
|
||||
|
||||
Vendored
+41
@@ -530,6 +530,7 @@
|
||||
"Billing Process": "Billing Process",
|
||||
"Billing Source": "Billing Source",
|
||||
"Bind": "Bind",
|
||||
"Bind a Pancake store + product": "Bind a Pancake store + product",
|
||||
"Bind an email address to your account.": "Bind an email address to your account.",
|
||||
"Bind Email": "Bind Email",
|
||||
"Bind Telegram Account": "Bind Telegram Account",
|
||||
@@ -556,6 +557,8 @@
|
||||
"Bound": "Bound",
|
||||
"Bound Channels": "Bound Channels",
|
||||
"Bound Only": "Bound Only",
|
||||
"Bound product:": "Bound product:",
|
||||
"Bound store:": "Bound store:",
|
||||
"Bring channels back online after successful checks": "Bring channels back online after successful checks",
|
||||
"Broadcast a global banner to users. Markdown is supported.": "Broadcast a global banner to users. Markdown is supported.",
|
||||
"Broadcast short system notices on the dashboard": "Broadcast short system notices on the dashboard",
|
||||
@@ -1022,9 +1025,13 @@
|
||||
"Create, revoke, and audit API tokens.": "Create, revoke, and audit API tokens.",
|
||||
"Created": "Created",
|
||||
"Created At": "Created At",
|
||||
"Creating...": "Creating...",
|
||||
"Creation failed": "Creation failed",
|
||||
"Credential generated": "Credential generated",
|
||||
"Credential refreshed": "Credential refreshed",
|
||||
"Credentials": "Credentials",
|
||||
"Credentials verification failed": "Credentials verification failed",
|
||||
"Credentials verification failed — double-check Merchant ID and API private key.": "Credentials verification failed — double-check Merchant ID and API private key.",
|
||||
"Credit remaining": "Credit remaining",
|
||||
"Creem API key (leave blank unless updating)": "Creem API key (leave blank unless updating)",
|
||||
"Creem Gateway": "Creem Gateway",
|
||||
@@ -1716,6 +1723,8 @@
|
||||
"Fill Codex CLI / Claude CLI Templates": "Fill Codex CLI / Claude CLI Templates",
|
||||
"Fill example (all channels)": "Fill example (all channels)",
|
||||
"Fill example (specific channels)": "Fill example (specific channels)",
|
||||
"Fill in both Merchant ID and API Private Key before creating.": "Fill in both Merchant ID and API Private Key before creating.",
|
||||
"Fill in the credentials above to begin.": "Fill in the credentials above to begin.",
|
||||
"Fill in the following info to create a new subscription plan": "Fill in the following info to create a new subscription plan",
|
||||
"Fill Related Models": "Fill Related Models",
|
||||
"Fill Template": "Fill Template",
|
||||
@@ -2323,6 +2332,8 @@
|
||||
"Minimum Trust Level": "Minimum Trust Level",
|
||||
"Minimum:": "Minimum:",
|
||||
"Minor blips in the last 30 days": "Minor blips in the last 30 days",
|
||||
"Mint a fresh pair below — or pick an existing one further down. Click Save when ready.": "Mint a fresh pair below — or pick an existing one further down. Click Save when ready.",
|
||||
"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.": "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.",
|
||||
"Minute": "Minute",
|
||||
"minutes": "minutes",
|
||||
"Missing code": "Missing code",
|
||||
@@ -2619,6 +2630,7 @@
|
||||
"No rules yet. Add a group below to get started.": "No rules yet. Add a group below to get started.",
|
||||
"No separate media pricing configured.": "No separate media pricing configured.",
|
||||
"No status code mappings configured.": "No status code mappings configured.",
|
||||
"No stores on this merchant yet. Set a return URL and click Create to mint your first pair.": "No stores on this merchant yet. Set a return URL and click Create to mint your first pair.",
|
||||
"No subscription plans yet": "No subscription plans yet",
|
||||
"No subscription records": "No subscription records",
|
||||
"No Sync": "No Sync",
|
||||
@@ -2762,6 +2774,7 @@
|
||||
"Opus Model": "Opus Model",
|
||||
"Or continue with": "Or continue with",
|
||||
"Or enter this key manually:": "Or enter this key manually:",
|
||||
"or pick existing": "or pick existing",
|
||||
"Order completed successfully": "Order completed successfully",
|
||||
"Order History": "Order History",
|
||||
"Order Payment Method": "Order Payment Method",
|
||||
@@ -2796,6 +2809,7 @@
|
||||
"Page {{current}} of {{total}}": "Page {{current}} of {{total}}",
|
||||
"PaLM": "PaLM",
|
||||
"Pan": "Pan",
|
||||
"Pancake": "Pancake",
|
||||
"Param Override": "Param Override",
|
||||
"Parameter": "Parameter",
|
||||
"Parameter configuration error": "Parameter configuration error",
|
||||
@@ -2859,6 +2873,7 @@
|
||||
"Pay": "Pay",
|
||||
"Pay-as-you-go with real-time usage monitoring": "Pay-as-you-go with real-time usage monitoring",
|
||||
"Payment": "Payment",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.",
|
||||
"Payment Channel": "Payment Channel",
|
||||
"Payment Gateway": "Payment Gateway",
|
||||
"Payment initiated": "Payment initiated",
|
||||
@@ -2872,6 +2887,7 @@
|
||||
"Payment page opened": "Payment page opened",
|
||||
"Payment request failed": "Payment request failed",
|
||||
"Payment return URL": "Payment return URL",
|
||||
"Payment return URL is empty. Create the product without a SuccessURL redirect?": "Payment return URL is empty. Create the product without a SuccessURL redirect?",
|
||||
"Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.": "Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.",
|
||||
"Peak": "Peak",
|
||||
"Peak throughput": "Peak throughput",
|
||||
@@ -2915,11 +2931,14 @@
|
||||
"Personal use": "Personal use",
|
||||
"Personal use mode": "Personal use mode",
|
||||
"Pick a date": "Pick a date",
|
||||
"Pick or create both a store and a product before saving.": "Pick or create both a store and a product before saving.",
|
||||
"Ping Interval (seconds)": "Ping Interval (seconds)",
|
||||
"Plan": "Plan",
|
||||
"Plan Name": "Plan Name",
|
||||
"Plan price must be greater than zero": "Plan price must be greater than zero",
|
||||
"Plan Subtitle": "Plan Subtitle",
|
||||
"Plan Title": "Plan Title",
|
||||
"Plan title is required": "Plan title is required",
|
||||
"Planned maintenance on Friday at 22:00 UTC...": "Planned maintenance on Friday at 22:00 UTC...",
|
||||
"Platform": "Platform",
|
||||
"Playground": "Playground",
|
||||
@@ -3225,6 +3244,7 @@
|
||||
"Regex": "Regex",
|
||||
"Regex Pattern": "Regex Pattern",
|
||||
"Regex Replace": "Regex Replace",
|
||||
"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.": "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.",
|
||||
"Register Passkey": "Register Passkey",
|
||||
"Registration Enabled": "Registration Enabled",
|
||||
"Registry (optional)": "Registry (optional)",
|
||||
@@ -3504,8 +3524,10 @@
|
||||
"Select a group type": "Select a group type",
|
||||
"Select a model to edit pricing": "Select a model to edit pricing",
|
||||
"Select a preset...": "Select a preset...",
|
||||
"Select a product": "Select a product",
|
||||
"Select a role": "Select a role",
|
||||
"Select a rule to edit.": "Select a rule to edit.",
|
||||
"Select a store": "Select a store",
|
||||
"Select a timestamp before clearing logs.": "Select a timestamp before clearing logs.",
|
||||
"Select a usage mode to continue": "Select a usage mode to continue",
|
||||
"Select a verification method first": "Select a verification method first",
|
||||
@@ -3700,6 +3722,7 @@
|
||||
"Standard price": "Standard price",
|
||||
"Start": "Start",
|
||||
"Start a conversation to see messages here": "Start a conversation to see messages here",
|
||||
"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.": "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.",
|
||||
"Start for free with generous limits. No credit card required.": "Start for free with generous limits. No credit card required.",
|
||||
"Start Time": "Start Time",
|
||||
"Static page describing the platform.": "Static page describing the platform.",
|
||||
@@ -3720,6 +3743,8 @@
|
||||
"Step": "Step",
|
||||
"Stop": "Stop",
|
||||
"Stop Retry": "Stop Retry",
|
||||
"Store": "Store",
|
||||
"Store + product created": "Store + product created",
|
||||
"Store ID": "Store ID",
|
||||
"Store ID is required": "Store ID is required",
|
||||
"Stored value is not echoed back for security": "Stored value is not echoed back for security",
|
||||
@@ -3755,6 +3780,7 @@
|
||||
"Subscription Only": "Subscription Only",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.",
|
||||
"Subscription Plans": "Subscription Plans",
|
||||
"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).": "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).",
|
||||
"Subtract": "Subtract",
|
||||
"Success": "Success",
|
||||
"Success rate": "Success rate",
|
||||
@@ -3879,8 +3905,11 @@
|
||||
"The administrator has not configured a user agreement yet.": "The administrator has not configured a user agreement yet.",
|
||||
"The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.": "The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.",
|
||||
"The binding will complete automatically after authorization": "The binding will complete automatically after authorization",
|
||||
"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.": "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.",
|
||||
"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.": "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.",
|
||||
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "The effective domain for Passkey registration. Must match the current domain or be its parent domain.",
|
||||
"The entered text does not match the required text.": "The entered text does not match the required text.",
|
||||
"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.": "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.",
|
||||
"The exact model identifier as used in API requests.": "The exact model identifier as used in API requests.",
|
||||
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.",
|
||||
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:",
|
||||
@@ -4239,6 +4268,7 @@
|
||||
"used": "used",
|
||||
"Used": "Used",
|
||||
"Used / Remaining": "Used / Remaining",
|
||||
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.",
|
||||
"Used for load balancing. Higher weight = more requests": "Used for load balancing. Higher weight = more requests",
|
||||
"Used in URLs and API routes": "Used in URLs and API routes",
|
||||
"Used Quota": "Used Quota",
|
||||
@@ -4313,6 +4343,7 @@
|
||||
"Verify routing with Playground or your client": "Verify routing with Playground or your client",
|
||||
"Verify Setup": "Verify Setup",
|
||||
"Verify your database connection": "Verify your database connection",
|
||||
"Verifying credentials and pulling stores from your Pancake account...": "Verifying credentials and pulling stores from your Pancake account...",
|
||||
"Version Overrides": "Version Overrides",
|
||||
"Vertex AI": "Vertex AI",
|
||||
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.",
|
||||
@@ -4362,7 +4393,14 @@
|
||||
"Visual Parameter Override": "Visual Parameter Override",
|
||||
"VolcEngine": "VolcEngine",
|
||||
"vs. previous": "vs. previous",
|
||||
"Waffo Aggregator Gateway": "Waffo Aggregator Gateway",
|
||||
"Waffo Pancake Dashboard": "Waffo Pancake Dashboard",
|
||||
"Waffo Pancake MoR": "Waffo Pancake MoR",
|
||||
"Waffo Pancake Payment Gateway": "Waffo Pancake payment gateway",
|
||||
"Waffo Pancake product created": "Waffo Pancake product created",
|
||||
"Waffo Pancake product creation failed": "Waffo Pancake product creation failed",
|
||||
"Waffo Pancake save failed": "Waffo Pancake save failed",
|
||||
"Waffo Pancake settings saved": "Waffo Pancake settings saved",
|
||||
"Waffo Payment": "Waffo Payment",
|
||||
"Waffo Payment Gateway": "Waffo Payment Gateway",
|
||||
"Waffo Public Key (Production)": "Waffo Public Key (Production)",
|
||||
@@ -4393,6 +4431,8 @@
|
||||
"Webhook Secret": "Webhook Secret",
|
||||
"Webhook signing secret (leave blank unless updating)": "Webhook signing secret (leave blank unless updating)",
|
||||
"Webhook URL": "Webhook URL",
|
||||
"Webhook URL (Production):": "Webhook URL (Production):",
|
||||
"Webhook URL (Test):": "Webhook URL (Test):",
|
||||
"Webhook URL:": "Webhook URL:",
|
||||
"Website is under maintenance!": "Website is under maintenance!",
|
||||
"WeChat": "WeChat",
|
||||
@@ -4432,6 +4472,7 @@
|
||||
"Whitelist (Only allow listed domains)": "Whitelist (Only allow listed domains)",
|
||||
"Whitelist (Only allow listed IPs)": "Whitelist (Only allow listed IPs)",
|
||||
"whsec_xxx": "whsec_xxx",
|
||||
"Why only one store + product?": "Why only one store + product?",
|
||||
"Window:": "Window:",
|
||||
"Wire encoding for the embedding vectors": "Wire encoding for the embedding vectors",
|
||||
"with conflicts": "with conflicts",
|
||||
|
||||
Vendored
+41
@@ -530,6 +530,7 @@
|
||||
"Billing Process": "计费过程",
|
||||
"Billing Source": "计费来源",
|
||||
"Bind": "绑定",
|
||||
"Bind a Pancake store + product": "绑定 Pancake 店铺 + 商品",
|
||||
"Bind an email address to your account.": "将邮箱地址绑定到您的账户。",
|
||||
"Bind Email": "绑定邮箱",
|
||||
"Bind Telegram Account": "绑定 Telegram 账户",
|
||||
@@ -556,6 +557,8 @@
|
||||
"Bound": "已绑定",
|
||||
"Bound Channels": "绑定渠道",
|
||||
"Bound Only": "仅已绑定",
|
||||
"Bound product:": "已绑定产品:",
|
||||
"Bound store:": "已绑定店铺:",
|
||||
"Bring channels back online after successful checks": "检查成功后使渠道恢复在线",
|
||||
"Broadcast a global banner to users. Markdown is supported.": "向用户广播全局横幅。支持 Markdown。",
|
||||
"Broadcast short system notices on the dashboard": "在仪表板上广播简短的系统通知",
|
||||
@@ -1022,9 +1025,13 @@
|
||||
"Create, revoke, and audit API tokens.": "创建、撤销和审计 API 令牌。",
|
||||
"Created": "创建时间",
|
||||
"Created At": "创建时间",
|
||||
"Creating...": "创建中...",
|
||||
"Creation failed": "创建失败",
|
||||
"Credential generated": "凭据已生成",
|
||||
"Credential refreshed": "凭据已刷新",
|
||||
"Credentials": "凭证",
|
||||
"Credentials verification failed": "凭证验证失败",
|
||||
"Credentials verification failed — double-check Merchant ID and API private key.": "凭证验证失败——请仔细核对商户 ID 和 API 私钥。",
|
||||
"Credit remaining": "剩余额度",
|
||||
"Creem API key (leave blank unless updating)": "Creem API 密钥(除非更新,否则留空)",
|
||||
"Creem Gateway": "Creem 网关",
|
||||
@@ -1716,6 +1723,8 @@
|
||||
"Fill Codex CLI / Claude CLI Templates": "填充 Codex CLI / Claude CLI 模板",
|
||||
"Fill example (all channels)": "填充示例(全部频道)",
|
||||
"Fill example (specific channels)": "填充示例(指定频道)",
|
||||
"Fill in both Merchant ID and API Private Key before creating.": "请先填写商户 ID 和 API 私钥再进行创建。",
|
||||
"Fill in the credentials above to begin.": "请先填写上方凭证。",
|
||||
"Fill in the following info to create a new subscription plan": "填写以下信息创建新的订阅套餐",
|
||||
"Fill Related Models": "填入相关模型",
|
||||
"Fill Template": "填入模板",
|
||||
@@ -2323,6 +2332,8 @@
|
||||
"Minimum Trust Level": "最低信任级别",
|
||||
"Minimum:": "最低:",
|
||||
"Minor blips in the last 30 days": "近 30 天内有轻微抖动",
|
||||
"Mint a fresh pair below — or pick an existing one further down. Click Save when ready.": "下方可新建一对——或继续下滑选择已有的。完成后点击保存。",
|
||||
"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.": "在已保存的店铺中用此套餐的标题和价格创建一个 Pancake 商品。需要先在支付设置中完成 Waffo Pancake 配置。",
|
||||
"Minute": "分钟",
|
||||
"minutes": "分钟",
|
||||
"Missing code": "缺少代码",
|
||||
@@ -2619,6 +2630,7 @@
|
||||
"No rules yet. Add a group below to get started.": "暂无规则。请在下方添加分组以开始。",
|
||||
"No separate media pricing configured.": "未单独配置多模态价格。",
|
||||
"No status code mappings configured.": "未配置状态码映射。",
|
||||
"No stores on this merchant yet. Set a return URL and click Create to mint your first pair.": "该商户暂无店铺。填写支付返回地址后点击「创建」生成第一对。",
|
||||
"No subscription plans yet": "暂无订阅套餐",
|
||||
"No subscription records": "暂无订阅记录",
|
||||
"No Sync": "不同步",
|
||||
@@ -2762,6 +2774,7 @@
|
||||
"Opus Model": "Opus 模型",
|
||||
"Or continue with": "或继续使用",
|
||||
"Or enter this key manually:": "或手动输入此密钥:",
|
||||
"or pick existing": "或选择已有",
|
||||
"Order completed successfully": "订单已成功完成",
|
||||
"Order History": "订单历史",
|
||||
"Order Payment Method": "订单支付方式",
|
||||
@@ -2796,6 +2809,7 @@
|
||||
"Page {{current}} of {{total}}": "第 {{current}} 页,共 {{total}} 页",
|
||||
"PaLM": "PaLM",
|
||||
"Pan": "平移",
|
||||
"Pancake": "煎饼",
|
||||
"Param Override": "参数覆盖",
|
||||
"Parameter": "参数",
|
||||
"Parameter configuration error": "参数配置有误",
|
||||
@@ -2859,6 +2873,7 @@
|
||||
"Pay": "支付",
|
||||
"Pay-as-you-go with real-time usage monitoring": "按量付费,实时监控使用情况",
|
||||
"Payment": "支付",
|
||||
"Payment aggregator mode — onboard with your own registered company (offshore entity). Built for Enterprise.": "支付聚合模式——使用你自己的注册公司(海外主体)来入驻,适合 Enterprise 企业",
|
||||
"Payment Channel": "支付渠道",
|
||||
"Payment Gateway": "支付网关",
|
||||
"Payment initiated": "已发起支付",
|
||||
@@ -2872,6 +2887,7 @@
|
||||
"Payment page opened": "已打开支付页面",
|
||||
"Payment request failed": "支付请求失败",
|
||||
"Payment return URL": "支付返回地址",
|
||||
"Payment return URL is empty. Create the product without a SuccessURL redirect?": "支付返回地址为空。是否在不绑定 SuccessURL 的情况下创建商品?",
|
||||
"Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.": "确认前,支付、兑换码、订阅计划和邀请返利功能将保持锁定。",
|
||||
"Peak": "峰值",
|
||||
"Peak throughput": "峰值吞吐",
|
||||
@@ -2915,11 +2931,14 @@
|
||||
"Personal use": "个人使用",
|
||||
"Personal use mode": "个人使用模式",
|
||||
"Pick a date": "选择日期",
|
||||
"Pick or create both a store and a product before saving.": "保存前请先选择或新建店铺和商品。",
|
||||
"Ping Interval (seconds)": "Ping 间隔(秒)",
|
||||
"Plan": "套餐",
|
||||
"Plan Name": "套餐名称",
|
||||
"Plan price must be greater than zero": "套餐价格必须大于 0",
|
||||
"Plan Subtitle": "套餐副标题",
|
||||
"Plan Title": "套餐标题",
|
||||
"Plan title is required": "请填写套餐标题",
|
||||
"Planned maintenance on Friday at 22:00 UTC...": "计划于周五 22:00 UTC 进行维护...",
|
||||
"Platform": "平台",
|
||||
"Playground": "游乐场",
|
||||
@@ -3225,6 +3244,7 @@
|
||||
"Regex": "正则",
|
||||
"Regex Pattern": "正则表达式",
|
||||
"Regex Replace": "正则替换",
|
||||
"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.": "将上述 URL 分别注册到 Pancake 控制台的测试模式和生产模式 Webhook 槽位中。独立的端点可以防止测试流量误充值到生产账户。",
|
||||
"Register Passkey": "注册 Passkey",
|
||||
"Registration Enabled": "注册已启用",
|
||||
"Registry (optional)": "注册表 (可选)",
|
||||
@@ -3504,8 +3524,10 @@
|
||||
"Select a group type": "选择分组类型",
|
||||
"Select a model to edit pricing": "选择一个模型编辑定价",
|
||||
"Select a preset...": "选择一个预设...",
|
||||
"Select a product": "选择商品",
|
||||
"Select a role": "选择角色",
|
||||
"Select a rule to edit.": "请选择一条规则进行编辑。",
|
||||
"Select a store": "选择店铺",
|
||||
"Select a timestamp before clearing logs.": "清除日志前请选择一个时间戳。",
|
||||
"Select a usage mode to continue": "选择使用模式以继续",
|
||||
"Select a verification method first": "请先选择验证方式",
|
||||
@@ -3700,6 +3722,7 @@
|
||||
"Standard price": "标准价格",
|
||||
"Start": "开始",
|
||||
"Start a conversation to see messages here": "开始对话以在此处查看消息",
|
||||
"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.": "无需注册公司即可开始全球收款,适合个人 / OPC 一人公司 / Startup。Waffo Pancake 作为你的 Merchant of Record,替你承担全球收款的合规责任:消费税、开票(Invoice)、订阅管理、退款与拒付处理。独立开发者可以直接上线,专注产品而非合规。极速入驻,一个 Prompt 完成集成。",
|
||||
"Start for free with generous limits. No credit card required.": "免费开始使用,额度充足,无需绑定信用卡。",
|
||||
"Start Time": "起始时间",
|
||||
"Static page describing the platform.": "描述平台的静态页面。",
|
||||
@@ -3720,6 +3743,8 @@
|
||||
"Step": "步骤",
|
||||
"Stop": "停止",
|
||||
"Stop Retry": "停止重试",
|
||||
"Store": "店铺",
|
||||
"Store + product created": "店铺 + 商品已创建",
|
||||
"Store ID": "商店 ID",
|
||||
"Store ID is required": "商店 ID 为必填项",
|
||||
"Stored value is not echoed back for security": "出于安全考虑,已存储的值不会回显",
|
||||
@@ -3755,6 +3780,7 @@
|
||||
"Subscription Only": "仅用订阅",
|
||||
"Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.": "订阅套餐创建和变更已锁定,管理员需先在支付设置中确认合规声明。",
|
||||
"Subscription Plans": "订阅套餐",
|
||||
"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).": "订阅套餐不使用这里绑定的商品 —— 每个套餐在「订阅」管理页有自己专属的 Pancake 商品,可以手动填写或点 \"+ 新建\" 一键创建。",
|
||||
"Subtract": "减少",
|
||||
"Success": "成功",
|
||||
"Success rate": "成功率",
|
||||
@@ -3879,8 +3905,11 @@
|
||||
"The administrator has not configured a user agreement yet.": "管理员尚未配置用户协议。",
|
||||
"The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.": "管理员尚未配置任何关于内容。您可以在设置页面中进行设置,支持 HTML 或 URL。",
|
||||
"The binding will complete automatically after authorization": "授权后绑定将自动完成",
|
||||
"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.": "绑定的商品用于钱包充值:用户输入任意金额时,new-api 用这一个 Pancake 商品发起结账并按用户输入动态设置价格 —— 不需要预先创建 $1 / $5 / $10 等一堆 SKU。",
|
||||
"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.": "绑定的店铺是 new-api 从此处创建的所有 Pancake 商品(钱包充值商品 + 各订阅套餐商品)的父容器。一个店铺足够使用;只有在确实需要使用独立的 Pancake 商品目录时,才需要绑定到不同的店铺。",
|
||||
"The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用于 Passkey 注册的有效域。必须与当前域匹配或为其父域。",
|
||||
"The entered text does not match the required text.": "输入内容与要求文案不一致。",
|
||||
"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.": "测试 / 生产环境由你粘进来的 API 私钥本身决定——集成阶段用 Test Key,正式上线时再换成 Production Key。",
|
||||
"The exact model identifier as used in API requests.": "API 请求中使用的确切模型标识符。",
|
||||
"The following models have billing type conflicts (fixed price vs ratio billing). Confirm to proceed with the changes.": "以下模型存在计费类型冲突(固定价格 vs 比例计费)。确认以继续更改。",
|
||||
"The following models in the model redirect have not been added to the \"Models\" list and may fail during invocation due to missing available models:": "模型重定向里的下列模型尚未添加到\"模型\"列表,调用时会因为缺少可用模型而失败:",
|
||||
@@ -4239,6 +4268,7 @@
|
||||
"used": "已使用",
|
||||
"Used": "已使用",
|
||||
"Used / Remaining": "已使用 / 剩余",
|
||||
"Used as SuccessURL on the new product. You'll be prompted to confirm if left blank.": "作为新商品的 SuccessURL。留空时会再次提示确认。",
|
||||
"Used for load balancing. Higher weight = more requests": "用于负载均衡。权重越高 = 请求越多",
|
||||
"Used in URLs and API routes": "用于URL和API路由",
|
||||
"Used Quota": "消耗额度",
|
||||
@@ -4313,6 +4343,7 @@
|
||||
"Verify routing with Playground or your client": "使用 Playground 或你的客户端验证路由",
|
||||
"Verify Setup": "验证设置",
|
||||
"Verify your database connection": "验证数据库连接",
|
||||
"Verifying credentials and pulling stores from your Pancake account...": "正在验证凭证并从你的 Pancake 账户拉取店铺...",
|
||||
"Version Overrides": "版本覆盖",
|
||||
"Vertex AI": "Vertex AI",
|
||||
"Vertex AI does not support functionResponse.id. Enable this to remove the field automatically.": "Vertex AI 不支持 functionResponse.id 字段,开启后将自动移除该字段",
|
||||
@@ -4362,7 +4393,14 @@
|
||||
"Visual Parameter Override": "可视化参数覆盖",
|
||||
"VolcEngine": "字节火山方舟、豆包通用",
|
||||
"vs. previous": "相较上期",
|
||||
"Waffo Aggregator Gateway": "Waffo 支付聚合网关",
|
||||
"Waffo Pancake Dashboard": "Waffo Pancake 控制台",
|
||||
"Waffo Pancake MoR": "Waffo Pancake MoR",
|
||||
"Waffo Pancake Payment Gateway": "Waffo Pancake 支付网关",
|
||||
"Waffo Pancake product created": "Waffo Pancake 产品已创建",
|
||||
"Waffo Pancake product creation failed": "Waffo Pancake 产品创建失败",
|
||||
"Waffo Pancake save failed": "Waffo Pancake 保存失败",
|
||||
"Waffo Pancake settings saved": "Waffo Pancake 设置已保存",
|
||||
"Waffo Payment": "Waffo 支付",
|
||||
"Waffo Payment Gateway": "Waffo 支付网关",
|
||||
"Waffo Public Key (Production)": "Waffo 公钥(生产)",
|
||||
@@ -4393,6 +4431,8 @@
|
||||
"Webhook Secret": "Webhook 密钥",
|
||||
"Webhook signing secret (leave blank unless updating)": "Webhook 签名密钥(除非更新,否则留空)",
|
||||
"Webhook URL": "Webhook 地址",
|
||||
"Webhook URL (Production):": "Webhook URL(生产):",
|
||||
"Webhook URL (Test):": "Webhook URL(测试):",
|
||||
"Webhook URL:": "Webhook URL:",
|
||||
"Website is under maintenance!": "网站正在维护中!",
|
||||
"WeChat": "微信",
|
||||
@@ -4432,6 +4472,7 @@
|
||||
"Whitelist (Only allow listed domains)": "白名单(仅允许列出的域)",
|
||||
"Whitelist (Only allow listed IPs)": "白名单(仅允许列出的 IP)",
|
||||
"whsec_xxx": "whsec_xxx",
|
||||
"Why only one store + product?": "为什么只绑定一个店铺 + 商品?",
|
||||
"Window:": "窗口:",
|
||||
"Wire encoding for the embedding vectors": "向量传输的编码格式",
|
||||
"with conflicts": "有冲突",
|
||||
|
||||
Reference in New Issue
Block a user