1488 lines
52 KiB
TypeScript
Vendored
1488 lines
52 KiB
TypeScript
Vendored
/*
|
||
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 * as React from 'react'
|
||
import * as z from 'zod'
|
||
import { useForm } from 'react-hook-form'
|
||
import { zodResolver } from '@hookform/resolvers/zod'
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { Code2, Eye, ShieldAlert } from 'lucide-react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { toast } from 'sonner'
|
||
import { cn } from '@/lib/utils'
|
||
import {
|
||
Alert,
|
||
AlertAction,
|
||
AlertDescription,
|
||
AlertTitle,
|
||
} from '@/components/ui/alert'
|
||
import { Button } from '@/components/ui/button'
|
||
import {
|
||
Form,
|
||
FormControl,
|
||
FormDescription,
|
||
FormField,
|
||
FormItem,
|
||
FormLabel,
|
||
FormMessage,
|
||
} from '@/components/ui/form'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { RiskAcknowledgementDialog } from '@/components/risk-acknowledgement-dialog'
|
||
import { confirmPaymentCompliance } from '../api'
|
||
import { SettingsSection } from '../components/settings-section'
|
||
import { useUpdateOption } from '../hooks/use-update-option'
|
||
import { AmountDiscountVisualEditor } from './amount-discount-visual-editor'
|
||
import { AmountOptionsVisualEditor } from './amount-options-visual-editor'
|
||
import { CreemProductsVisualEditor } from './creem-products-visual-editor'
|
||
import { PaymentMethodsVisualEditor } from './payment-methods-visual-editor'
|
||
import {
|
||
formatJsonForEditor,
|
||
getJsonError,
|
||
normalizeJsonForComparison,
|
||
removeTrailingSlash,
|
||
} from './utils'
|
||
import {
|
||
WaffoPancakeSettingsSection,
|
||
type WaffoPancakeSettingsValues,
|
||
} from './waffo-pancake-settings-section'
|
||
import {
|
||
WaffoSettingsSection,
|
||
type WaffoSettingsValues,
|
||
} from './waffo-settings-section'
|
||
|
||
const paymentSchema = z.object({
|
||
PayAddress: z.string().refine((value) => {
|
||
const trimmed = value.trim()
|
||
if (!trimmed) return true
|
||
return /^https?:\/\//.test(trimmed)
|
||
}, 'Provide a valid callback URL starting with http:// or https://'),
|
||
EpayId: z.string(),
|
||
EpayKey: z.string(),
|
||
Price: z.coerce.number().min(0),
|
||
MinTopUp: z.coerce.number().min(0),
|
||
CustomCallbackAddress: z.string().refine((value) => {
|
||
const trimmed = value.trim()
|
||
if (!trimmed) return true
|
||
return /^https?:\/\//.test(trimmed)
|
||
}, 'Provide a valid URL starting with http:// or https://'),
|
||
PayMethods: z.string().superRefine((value, ctx) => {
|
||
const error = getJsonError(value)
|
||
if (error) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: error,
|
||
})
|
||
}
|
||
}),
|
||
AmountOptions: z.string().superRefine((value, ctx) => {
|
||
const error = getJsonError(value, (parsed) => Array.isArray(parsed))
|
||
if (error) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: error,
|
||
})
|
||
}
|
||
}),
|
||
AmountDiscount: z.string().superRefine((value, ctx) => {
|
||
const error = getJsonError(
|
||
value,
|
||
(parsed) =>
|
||
!!parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||
)
|
||
if (error) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: error,
|
||
})
|
||
}
|
||
}),
|
||
StripeApiSecret: z.string(),
|
||
StripeWebhookSecret: z.string(),
|
||
StripePriceId: z.string(),
|
||
StripeUnitPrice: z.coerce.number().min(0),
|
||
StripeMinTopUp: z.coerce.number().min(0),
|
||
StripePromotionCodesEnabled: z.boolean(),
|
||
CreemApiKey: z.string(),
|
||
CreemWebhookSecret: z.string(),
|
||
CreemTestMode: z.boolean(),
|
||
CreemProducts: z.string().superRefine((value, ctx) => {
|
||
const error = getJsonError(value, (parsed) => Array.isArray(parsed))
|
||
if (error) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: error,
|
||
})
|
||
}
|
||
}),
|
||
})
|
||
|
||
type PaymentFormValues = z.infer<typeof paymentSchema>
|
||
|
||
const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1'
|
||
|
||
type PaymentComplianceDefaults = {
|
||
confirmed: boolean
|
||
termsVersion: string
|
||
confirmedAt: number
|
||
confirmedBy: number
|
||
}
|
||
|
||
type PaymentSettingsSectionProps = {
|
||
defaultValues: PaymentFormValues
|
||
waffoDefaultValues: WaffoSettingsValues
|
||
waffoPancakeDefaultValues: WaffoPancakeSettingsValues
|
||
waffoPancakeProvisionedStoreID?: string
|
||
waffoPancakeProvisionedProductID?: string
|
||
complianceDefaults: PaymentComplianceDefaults
|
||
}
|
||
|
||
export function PaymentSettingsSection({
|
||
defaultValues,
|
||
waffoDefaultValues,
|
||
waffoPancakeDefaultValues,
|
||
waffoPancakeProvisionedStoreID,
|
||
waffoPancakeProvisionedProductID,
|
||
complianceDefaults,
|
||
}: PaymentSettingsSectionProps) {
|
||
const { t } = useTranslation()
|
||
const queryClient = useQueryClient()
|
||
const updateOption = useUpdateOption()
|
||
const initialRef = React.useRef(defaultValues)
|
||
const defaultsSignature = React.useMemo(
|
||
() => JSON.stringify(defaultValues),
|
||
[defaultValues]
|
||
)
|
||
|
||
const [payMethodsVisualMode, setPayMethodsVisualMode] = React.useState(true)
|
||
const [amountOptionsVisualMode, setAmountOptionsVisualMode] =
|
||
React.useState(true)
|
||
const [amountDiscountVisualMode, setAmountDiscountVisualMode] =
|
||
React.useState(true)
|
||
const [creemProductsVisualMode, setCreemProductsVisualMode] =
|
||
React.useState(true)
|
||
const [showComplianceDialog, setShowComplianceDialog] = React.useState(false)
|
||
|
||
const complianceStatements = React.useMemo(
|
||
() => [
|
||
t(
|
||
'You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.'
|
||
),
|
||
t(
|
||
'You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.'
|
||
),
|
||
t(
|
||
'If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.'
|
||
),
|
||
t(
|
||
'You commit not to use this system to implement, assist with, or indirectly implement acts that violate applicable laws and regulations, regulatory requirements, platform rules, public interests, or the lawful rights and interests of third parties.'
|
||
),
|
||
t(
|
||
'You understand and independently bear legal responsibility arising from deployment, operation, and charging behavior.'
|
||
),
|
||
t(
|
||
'You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.'
|
||
),
|
||
],
|
||
[t]
|
||
)
|
||
|
||
const complianceRequiredText = t(
|
||
'I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.'
|
||
)
|
||
const complianceRequiredTextParts = React.useMemo(
|
||
() => [
|
||
{
|
||
type: 'input' as const,
|
||
text: t('I have read and understood the above compliance reminder'),
|
||
},
|
||
{ type: 'static' as const, text: t(',') },
|
||
{
|
||
type: 'input' as const,
|
||
text: t('acknowledge the related legal risks'),
|
||
},
|
||
{ type: 'static' as const, text: t(',and ') },
|
||
{
|
||
type: 'input' as const,
|
||
text: t(
|
||
'confirm that I bear legal responsibility arising from deployment'
|
||
),
|
||
},
|
||
{ type: 'static' as const, text: t('、') },
|
||
{
|
||
type: 'input' as const,
|
||
text: t('operation and charging behavior'),
|
||
},
|
||
],
|
||
[t]
|
||
)
|
||
|
||
const complianceConfirmed =
|
||
complianceDefaults.confirmed &&
|
||
complianceDefaults.termsVersion === CURRENT_COMPLIANCE_TERMS_VERSION
|
||
|
||
const confirmComplianceMutation = useMutation({
|
||
mutationFn: confirmPaymentCompliance,
|
||
onSuccess: (data) => {
|
||
if (data.success) {
|
||
toast.success(t('Compliance confirmed successfully'))
|
||
setShowComplianceDialog(false)
|
||
queryClient.invalidateQueries({ queryKey: ['system-options'] })
|
||
} else {
|
||
toast.error(data.message || t('Failed to confirm compliance'))
|
||
}
|
||
},
|
||
onError: (error: Error) => {
|
||
toast.error(error.message || t('Failed to confirm compliance'))
|
||
},
|
||
})
|
||
|
||
const form = useForm({
|
||
resolver: zodResolver(paymentSchema),
|
||
mode: 'onChange', // Enable real-time validation
|
||
defaultValues: {
|
||
...defaultValues,
|
||
PayMethods: formatJsonForEditor(defaultValues.PayMethods),
|
||
AmountOptions: formatJsonForEditor(defaultValues.AmountOptions),
|
||
AmountDiscount: formatJsonForEditor(defaultValues.AmountDiscount),
|
||
CreemProducts: formatJsonForEditor(defaultValues.CreemProducts),
|
||
},
|
||
})
|
||
|
||
React.useEffect(() => {
|
||
const parsedDefaults = JSON.parse(defaultsSignature) as PaymentFormValues
|
||
initialRef.current = parsedDefaults
|
||
form.reset({
|
||
...parsedDefaults,
|
||
PayMethods: formatJsonForEditor(parsedDefaults.PayMethods),
|
||
AmountOptions: formatJsonForEditor(parsedDefaults.AmountOptions),
|
||
AmountDiscount: formatJsonForEditor(parsedDefaults.AmountDiscount),
|
||
CreemProducts: formatJsonForEditor(parsedDefaults.CreemProducts),
|
||
})
|
||
}, [defaultsSignature, form])
|
||
|
||
const saveGeneralSettings = async () => {
|
||
const values = form.getValues()
|
||
const sanitized = {
|
||
Price: values.Price as number,
|
||
MinTopUp: values.MinTopUp as number,
|
||
PayMethods: values.PayMethods.trim(),
|
||
AmountOptions: values.AmountOptions.trim(),
|
||
AmountDiscount: values.AmountDiscount.trim(),
|
||
}
|
||
|
||
const initial = {
|
||
Price: initialRef.current.Price,
|
||
MinTopUp: initialRef.current.MinTopUp,
|
||
PayMethods: initialRef.current.PayMethods.trim(),
|
||
AmountOptions: initialRef.current.AmountOptions.trim(),
|
||
AmountDiscount: initialRef.current.AmountDiscount.trim(),
|
||
}
|
||
|
||
const updates: Array<{ key: string; value: string | number }> = []
|
||
|
||
if (sanitized.Price !== initial.Price) {
|
||
updates.push({ key: 'Price', value: sanitized.Price })
|
||
}
|
||
|
||
if (sanitized.MinTopUp !== initial.MinTopUp) {
|
||
updates.push({ key: 'MinTopUp', value: sanitized.MinTopUp })
|
||
}
|
||
|
||
if (
|
||
normalizeJsonForComparison(sanitized.PayMethods) !==
|
||
normalizeJsonForComparison(initial.PayMethods)
|
||
) {
|
||
updates.push({ key: 'PayMethods', value: sanitized.PayMethods })
|
||
}
|
||
|
||
if (
|
||
normalizeJsonForComparison(sanitized.AmountOptions) !==
|
||
normalizeJsonForComparison(initial.AmountOptions)
|
||
) {
|
||
updates.push({
|
||
key: 'payment_setting.amount_options',
|
||
value: sanitized.AmountOptions,
|
||
})
|
||
}
|
||
|
||
if (
|
||
normalizeJsonForComparison(sanitized.AmountDiscount) !==
|
||
normalizeJsonForComparison(initial.AmountDiscount)
|
||
) {
|
||
updates.push({
|
||
key: 'payment_setting.amount_discount',
|
||
value: sanitized.AmountDiscount,
|
||
})
|
||
}
|
||
|
||
if (updates.length === 0) {
|
||
return
|
||
}
|
||
|
||
for (const update of updates) {
|
||
await updateOption.mutateAsync(update)
|
||
}
|
||
}
|
||
|
||
const saveEpaySettings = async () => {
|
||
const values = form.getValues()
|
||
const sanitized = {
|
||
PayAddress: removeTrailingSlash(values.PayAddress),
|
||
EpayId: values.EpayId.trim(),
|
||
EpayKey: values.EpayKey.trim(),
|
||
CustomCallbackAddress: removeTrailingSlash(values.CustomCallbackAddress),
|
||
}
|
||
|
||
const initial = {
|
||
PayAddress: removeTrailingSlash(initialRef.current.PayAddress),
|
||
EpayId: initialRef.current.EpayId.trim(),
|
||
EpayKey: initialRef.current.EpayKey.trim(),
|
||
CustomCallbackAddress: removeTrailingSlash(
|
||
initialRef.current.CustomCallbackAddress
|
||
),
|
||
}
|
||
|
||
const updates: Array<{ key: string; value: string }> = []
|
||
|
||
if (sanitized.PayAddress !== initial.PayAddress) {
|
||
updates.push({ key: 'PayAddress', value: sanitized.PayAddress })
|
||
}
|
||
|
||
if (sanitized.EpayId !== initial.EpayId) {
|
||
updates.push({ key: 'EpayId', value: sanitized.EpayId })
|
||
}
|
||
|
||
if (sanitized.EpayKey && sanitized.EpayKey !== initial.EpayKey) {
|
||
updates.push({ key: 'EpayKey', value: sanitized.EpayKey })
|
||
}
|
||
|
||
if (sanitized.CustomCallbackAddress !== initial.CustomCallbackAddress) {
|
||
updates.push({
|
||
key: 'CustomCallbackAddress',
|
||
value: sanitized.CustomCallbackAddress,
|
||
})
|
||
}
|
||
|
||
if (updates.length === 0) {
|
||
return
|
||
}
|
||
|
||
for (const update of updates) {
|
||
await updateOption.mutateAsync(update)
|
||
}
|
||
}
|
||
|
||
const saveStripeSettings = async () => {
|
||
const values = form.getValues()
|
||
const sanitized = {
|
||
StripeApiSecret: values.StripeApiSecret.trim(),
|
||
StripeWebhookSecret: values.StripeWebhookSecret.trim(),
|
||
StripePriceId: values.StripePriceId.trim(),
|
||
StripeUnitPrice: values.StripeUnitPrice as number,
|
||
StripeMinTopUp: values.StripeMinTopUp as number,
|
||
StripePromotionCodesEnabled:
|
||
values.StripePromotionCodesEnabled as boolean,
|
||
}
|
||
|
||
const initial = {
|
||
StripeApiSecret: initialRef.current.StripeApiSecret.trim(),
|
||
StripeWebhookSecret: initialRef.current.StripeWebhookSecret.trim(),
|
||
StripePriceId: initialRef.current.StripePriceId.trim(),
|
||
StripeUnitPrice: initialRef.current.StripeUnitPrice,
|
||
StripeMinTopUp: initialRef.current.StripeMinTopUp,
|
||
StripePromotionCodesEnabled:
|
||
initialRef.current.StripePromotionCodesEnabled,
|
||
}
|
||
|
||
const updates: Array<{ key: string; value: string | number | boolean }> = []
|
||
|
||
if (
|
||
sanitized.StripeApiSecret &&
|
||
sanitized.StripeApiSecret !== initial.StripeApiSecret
|
||
) {
|
||
updates.push({ key: 'StripeApiSecret', value: sanitized.StripeApiSecret })
|
||
}
|
||
|
||
if (
|
||
sanitized.StripeWebhookSecret &&
|
||
sanitized.StripeWebhookSecret !== initial.StripeWebhookSecret
|
||
) {
|
||
updates.push({
|
||
key: 'StripeWebhookSecret',
|
||
value: sanitized.StripeWebhookSecret,
|
||
})
|
||
}
|
||
|
||
if (sanitized.StripePriceId !== initial.StripePriceId) {
|
||
updates.push({ key: 'StripePriceId', value: sanitized.StripePriceId })
|
||
}
|
||
|
||
if (sanitized.StripeUnitPrice !== initial.StripeUnitPrice) {
|
||
updates.push({ key: 'StripeUnitPrice', value: sanitized.StripeUnitPrice })
|
||
}
|
||
|
||
if (sanitized.StripeMinTopUp !== initial.StripeMinTopUp) {
|
||
updates.push({ key: 'StripeMinTopUp', value: sanitized.StripeMinTopUp })
|
||
}
|
||
|
||
if (
|
||
sanitized.StripePromotionCodesEnabled !==
|
||
initial.StripePromotionCodesEnabled
|
||
) {
|
||
updates.push({
|
||
key: 'StripePromotionCodesEnabled',
|
||
value: sanitized.StripePromotionCodesEnabled,
|
||
})
|
||
}
|
||
|
||
if (updates.length === 0) {
|
||
return
|
||
}
|
||
|
||
for (const update of updates) {
|
||
await updateOption.mutateAsync(update)
|
||
}
|
||
}
|
||
|
||
const saveCreemSettings = async () => {
|
||
const values = form.getValues()
|
||
const sanitized = {
|
||
CreemApiKey: values.CreemApiKey.trim(),
|
||
CreemWebhookSecret: values.CreemWebhookSecret.trim(),
|
||
CreemTestMode: values.CreemTestMode as boolean,
|
||
CreemProducts: values.CreemProducts.trim(),
|
||
}
|
||
|
||
const initial = {
|
||
CreemApiKey: initialRef.current.CreemApiKey.trim(),
|
||
CreemWebhookSecret: initialRef.current.CreemWebhookSecret.trim(),
|
||
CreemTestMode: initialRef.current.CreemTestMode,
|
||
CreemProducts: initialRef.current.CreemProducts.trim(),
|
||
}
|
||
|
||
const updates: Array<{ key: string; value: string | boolean }> = []
|
||
|
||
if (
|
||
sanitized.CreemApiKey &&
|
||
sanitized.CreemApiKey !== initial.CreemApiKey
|
||
) {
|
||
updates.push({ key: 'CreemApiKey', value: sanitized.CreemApiKey })
|
||
}
|
||
|
||
if (
|
||
sanitized.CreemWebhookSecret &&
|
||
sanitized.CreemWebhookSecret !== initial.CreemWebhookSecret
|
||
) {
|
||
updates.push({
|
||
key: 'CreemWebhookSecret',
|
||
value: sanitized.CreemWebhookSecret,
|
||
})
|
||
}
|
||
|
||
if (sanitized.CreemTestMode !== initial.CreemTestMode) {
|
||
updates.push({ key: 'CreemTestMode', value: sanitized.CreemTestMode })
|
||
}
|
||
|
||
if (
|
||
normalizeJsonForComparison(sanitized.CreemProducts) !==
|
||
normalizeJsonForComparison(initial.CreemProducts)
|
||
) {
|
||
updates.push({ key: 'CreemProducts', value: sanitized.CreemProducts })
|
||
}
|
||
|
||
if (updates.length === 0) {
|
||
return
|
||
}
|
||
|
||
for (const update of updates) {
|
||
await updateOption.mutateAsync(update)
|
||
}
|
||
}
|
||
|
||
const onSubmit = async (values: PaymentFormValues) => {
|
||
const sanitized = {
|
||
PayAddress: removeTrailingSlash(values.PayAddress),
|
||
EpayId: values.EpayId.trim(),
|
||
EpayKey: values.EpayKey.trim(),
|
||
Price: values.Price,
|
||
MinTopUp: values.MinTopUp,
|
||
CustomCallbackAddress: removeTrailingSlash(values.CustomCallbackAddress),
|
||
PayMethods: values.PayMethods.trim(),
|
||
AmountOptions: values.AmountOptions.trim(),
|
||
AmountDiscount: values.AmountDiscount.trim(),
|
||
StripeApiSecret: values.StripeApiSecret.trim(),
|
||
StripeWebhookSecret: values.StripeWebhookSecret.trim(),
|
||
StripePriceId: values.StripePriceId.trim(),
|
||
StripeUnitPrice: values.StripeUnitPrice,
|
||
StripeMinTopUp: values.StripeMinTopUp,
|
||
StripePromotionCodesEnabled: values.StripePromotionCodesEnabled,
|
||
}
|
||
|
||
const initial = {
|
||
PayAddress: removeTrailingSlash(initialRef.current.PayAddress),
|
||
EpayId: initialRef.current.EpayId.trim(),
|
||
EpayKey: initialRef.current.EpayKey.trim(),
|
||
Price: initialRef.current.Price,
|
||
MinTopUp: initialRef.current.MinTopUp,
|
||
CustomCallbackAddress: removeTrailingSlash(
|
||
initialRef.current.CustomCallbackAddress
|
||
),
|
||
PayMethods: initialRef.current.PayMethods.trim(),
|
||
AmountOptions: initialRef.current.AmountOptions.trim(),
|
||
AmountDiscount: initialRef.current.AmountDiscount.trim(),
|
||
StripeApiSecret: initialRef.current.StripeApiSecret.trim(),
|
||
StripeWebhookSecret: initialRef.current.StripeWebhookSecret.trim(),
|
||
StripePriceId: initialRef.current.StripePriceId.trim(),
|
||
StripeUnitPrice: initialRef.current.StripeUnitPrice,
|
||
StripeMinTopUp: initialRef.current.StripeMinTopUp,
|
||
StripePromotionCodesEnabled:
|
||
initialRef.current.StripePromotionCodesEnabled,
|
||
}
|
||
|
||
const updates: Array<{ key: string; value: string | number | boolean }> = []
|
||
|
||
if (sanitized.PayAddress !== initial.PayAddress) {
|
||
updates.push({ key: 'PayAddress', value: sanitized.PayAddress })
|
||
}
|
||
|
||
if (sanitized.EpayId !== initial.EpayId) {
|
||
updates.push({ key: 'EpayId', value: sanitized.EpayId })
|
||
}
|
||
|
||
if (sanitized.EpayKey && sanitized.EpayKey !== initial.EpayKey) {
|
||
updates.push({ key: 'EpayKey', value: sanitized.EpayKey })
|
||
}
|
||
|
||
if (sanitized.Price !== initial.Price) {
|
||
updates.push({ key: 'Price', value: sanitized.Price })
|
||
}
|
||
|
||
if (sanitized.MinTopUp !== initial.MinTopUp) {
|
||
updates.push({ key: 'MinTopUp', value: sanitized.MinTopUp })
|
||
}
|
||
|
||
if (sanitized.CustomCallbackAddress !== initial.CustomCallbackAddress) {
|
||
updates.push({
|
||
key: 'CustomCallbackAddress',
|
||
value: sanitized.CustomCallbackAddress,
|
||
})
|
||
}
|
||
|
||
if (
|
||
normalizeJsonForComparison(sanitized.PayMethods) !==
|
||
normalizeJsonForComparison(initial.PayMethods)
|
||
) {
|
||
updates.push({ key: 'PayMethods', value: sanitized.PayMethods })
|
||
}
|
||
|
||
if (
|
||
normalizeJsonForComparison(sanitized.AmountOptions) !==
|
||
normalizeJsonForComparison(initial.AmountOptions)
|
||
) {
|
||
updates.push({
|
||
key: 'payment_setting.amount_options',
|
||
value: sanitized.AmountOptions,
|
||
})
|
||
}
|
||
|
||
if (
|
||
normalizeJsonForComparison(sanitized.AmountDiscount) !==
|
||
normalizeJsonForComparison(initial.AmountDiscount)
|
||
) {
|
||
updates.push({
|
||
key: 'payment_setting.amount_discount',
|
||
value: sanitized.AmountDiscount,
|
||
})
|
||
}
|
||
|
||
if (
|
||
sanitized.StripeApiSecret &&
|
||
sanitized.StripeApiSecret !== initial.StripeApiSecret
|
||
) {
|
||
updates.push({ key: 'StripeApiSecret', value: sanitized.StripeApiSecret })
|
||
}
|
||
|
||
if (
|
||
sanitized.StripeWebhookSecret &&
|
||
sanitized.StripeWebhookSecret !== initial.StripeWebhookSecret
|
||
) {
|
||
updates.push({
|
||
key: 'StripeWebhookSecret',
|
||
value: sanitized.StripeWebhookSecret,
|
||
})
|
||
}
|
||
|
||
if (sanitized.StripePriceId !== initial.StripePriceId) {
|
||
updates.push({ key: 'StripePriceId', value: sanitized.StripePriceId })
|
||
}
|
||
|
||
if (sanitized.StripeUnitPrice !== initial.StripeUnitPrice) {
|
||
updates.push({ key: 'StripeUnitPrice', value: sanitized.StripeUnitPrice })
|
||
}
|
||
|
||
if (sanitized.StripeMinTopUp !== initial.StripeMinTopUp) {
|
||
updates.push({ key: 'StripeMinTopUp', value: sanitized.StripeMinTopUp })
|
||
}
|
||
|
||
if (
|
||
sanitized.StripePromotionCodesEnabled !==
|
||
initial.StripePromotionCodesEnabled
|
||
) {
|
||
updates.push({
|
||
key: 'StripePromotionCodesEnabled',
|
||
value: sanitized.StripePromotionCodesEnabled,
|
||
})
|
||
}
|
||
|
||
for (const update of updates) {
|
||
await updateOption.mutateAsync(update)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<SettingsSection
|
||
title={t('Payment Gateway')}
|
||
description={t(
|
||
'Configure recharge pricing and payment gateway integrations'
|
||
)}
|
||
>
|
||
{!complianceConfirmed ? (
|
||
<Alert variant='destructive' className='mb-6'>
|
||
<ShieldAlert className='h-4 w-4' />
|
||
<AlertTitle>{t('Compliance confirmation required')}</AlertTitle>
|
||
<AlertDescription>
|
||
<div className='space-y-3'>
|
||
<p>
|
||
{t(
|
||
'Payment, redemption codes, subscription plans, and invitation rewards are locked until the root administrator confirms the compliance terms.'
|
||
)}
|
||
</p>
|
||
<ol className='list-decimal space-y-1 pl-5'>
|
||
{complianceStatements.map((statement) => (
|
||
<li key={statement}>{statement}</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
</AlertDescription>
|
||
<AlertAction>
|
||
<Button
|
||
type='button'
|
||
size='sm'
|
||
variant='destructive'
|
||
onClick={() => setShowComplianceDialog(true)}
|
||
>
|
||
{t('Confirm compliance')}
|
||
</Button>
|
||
</AlertAction>
|
||
</Alert>
|
||
) : (
|
||
<Alert className='mb-6'>
|
||
<AlertTitle>{t('Compliance confirmed')}</AlertTitle>
|
||
<AlertDescription>
|
||
{t('Confirmed at {{time}} by user #{{userId}}', {
|
||
time: complianceDefaults.confirmedAt
|
||
? new Date(
|
||
complianceDefaults.confirmedAt * 1000
|
||
).toLocaleString()
|
||
: '-',
|
||
userId: complianceDefaults.confirmedBy || '-',
|
||
})}
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<RiskAcknowledgementDialog
|
||
open={showComplianceDialog}
|
||
onOpenChange={setShowComplianceDialog}
|
||
title={t('Confirm compliance terms')}
|
||
description={t(
|
||
'This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.'
|
||
)}
|
||
items={complianceStatements}
|
||
requiredText={complianceRequiredText}
|
||
requiredTextParts={complianceRequiredTextParts}
|
||
inputPrompt={t('Please type the following text to confirm:')}
|
||
inputPlaceholder={t('Type the confirmation text here')}
|
||
mismatchHint={t('The entered text does not match the required text.')}
|
||
confirmText={t('Confirm and enable')}
|
||
isLoading={confirmComplianceMutation.isPending}
|
||
onConfirm={() => confirmComplianceMutation.mutate()}
|
||
/>
|
||
|
||
{/* eslint-disable react-hooks/refs */}
|
||
<Form {...form}>
|
||
<form
|
||
onSubmit={form.handleSubmit(onSubmit)}
|
||
className={cn(
|
||
'space-y-8',
|
||
!complianceConfirmed && 'pointer-events-none opacity-40'
|
||
)}
|
||
data-no-autosubmit='true'
|
||
>
|
||
<div className='space-y-4'>
|
||
<div>
|
||
<h3 className='text-lg font-medium'>{t('General Settings')}</h3>
|
||
<p className='text-muted-foreground text-sm'>
|
||
{t('Shared configuration for all payment gateways')}
|
||
</p>
|
||
</div>
|
||
|
||
<div className='grid gap-6 md:grid-cols-2'>
|
||
<FormField
|
||
control={form.control}
|
||
name='Price'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('Price (local currency / USD)')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type='number'
|
||
step='0.01'
|
||
min={0}
|
||
value={(field.value ?? 0) as number}
|
||
onChange={(event) =>
|
||
field.onChange(event.target.valueAsNumber)
|
||
}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t(
|
||
'How much to charge for each US dollar of balance (Epay)'
|
||
)}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='MinTopUp'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('Minimum top-up (USD)')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type='number'
|
||
step='0.01'
|
||
min={0}
|
||
value={(field.value ?? 0) as number}
|
||
onChange={(event) =>
|
||
field.onChange(event.target.valueAsNumber)
|
||
}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('Smallest USD amount users can recharge (Epay)')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='PayMethods'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<div className='mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||
<FormLabel>{t('Payment methods')}</FormLabel>
|
||
<Button
|
||
type='button'
|
||
variant='outline'
|
||
size='sm'
|
||
onClick={() =>
|
||
setPayMethodsVisualMode(!payMethodsVisualMode)
|
||
}
|
||
className='w-full sm:w-auto'
|
||
>
|
||
{payMethodsVisualMode ? (
|
||
<>
|
||
<Code2 className='mr-2 h-3 w-3' />
|
||
{t('JSON Editor')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Eye className='mr-2 h-3 w-3' />
|
||
{t('Visual Editor')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
<FormControl>
|
||
{payMethodsVisualMode ? (
|
||
<PaymentMethodsVisualEditor
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
/>
|
||
) : (
|
||
<Textarea
|
||
rows={4}
|
||
placeholder={t(
|
||
'[{"name":"支付宝","type":"alipay","color":"#1677FF"}]'
|
||
)}
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
)}
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t(
|
||
'Configure available payment methods. Provide a JSON array.'
|
||
)}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<div className='grid gap-6 md:grid-cols-2 md:items-start'>
|
||
<FormField
|
||
control={form.control}
|
||
name='AmountOptions'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<div className='mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||
<FormLabel>{t('Top-up amount options')}</FormLabel>
|
||
<Button
|
||
type='button'
|
||
variant='outline'
|
||
size='sm'
|
||
onClick={() =>
|
||
setAmountOptionsVisualMode(!amountOptionsVisualMode)
|
||
}
|
||
className='w-full sm:w-auto'
|
||
>
|
||
{amountOptionsVisualMode ? (
|
||
<>
|
||
<Code2 className='mr-2 h-3 w-3' />
|
||
{t('JSON Editor')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Eye className='mr-2 h-3 w-3' />
|
||
{t('Visual Editor')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
<FormControl>
|
||
{amountOptionsVisualMode ? (
|
||
<AmountOptionsVisualEditor
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
/>
|
||
) : (
|
||
<Textarea
|
||
rows={4}
|
||
placeholder='[10, 20, 50, 100]'
|
||
{...field}
|
||
onChange={(event) =>
|
||
field.onChange(event.target.value)
|
||
}
|
||
/>
|
||
)}
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('Preset recharge amounts (JSON array)')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='AmountDiscount'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<div className='mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||
<FormLabel>{t('Amount discount')}</FormLabel>
|
||
<Button
|
||
type='button'
|
||
variant='outline'
|
||
size='sm'
|
||
onClick={() =>
|
||
setAmountDiscountVisualMode(!amountDiscountVisualMode)
|
||
}
|
||
className='w-full sm:w-auto'
|
||
>
|
||
{amountDiscountVisualMode ? (
|
||
<>
|
||
<Code2 className='mr-2 h-3 w-3' />
|
||
{t('JSON Editor')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Eye className='mr-2 h-3 w-3' />
|
||
{t('Visual Editor')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
<FormControl>
|
||
{amountDiscountVisualMode ? (
|
||
<AmountDiscountVisualEditor
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
/>
|
||
) : (
|
||
<Textarea
|
||
rows={4}
|
||
placeholder='{"100":0.95,"200":0.9}'
|
||
{...field}
|
||
onChange={(event) =>
|
||
field.onChange(event.target.value)
|
||
}
|
||
/>
|
||
)}
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('Discount map by recharge amount (JSON object)')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<Button
|
||
type='button'
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
saveGeneralSettings()
|
||
}}
|
||
disabled={updateOption.isPending}
|
||
>
|
||
{updateOption.isPending
|
||
? t('Saving...')
|
||
: t('Save general settings')}
|
||
</Button>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className='space-y-4'>
|
||
<div>
|
||
<h3 className='text-lg font-medium'>{t('Epay Gateway')}</h3>
|
||
<p className='text-muted-foreground text-sm'>
|
||
{t('Configuration for Epay payment integration')}
|
||
</p>
|
||
</div>
|
||
|
||
<div className='grid gap-6 md:grid-cols-2'>
|
||
<FormField
|
||
control={form.control}
|
||
name='PayAddress'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('Epay endpoint')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
placeholder={t('https://pay.example.com')}
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('Base address provided by your Epay service')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='CustomCallbackAddress'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('Callback address')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
placeholder={t('https://gateway.example.com')}
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t(
|
||
'Optional callback override. Leave blank to use server address'
|
||
)}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<div className='grid gap-6 md:grid-cols-2'>
|
||
<FormField
|
||
control={form.control}
|
||
name='EpayId'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('Epay merchant ID')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
placeholder='10001'
|
||
autoComplete='off'
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='EpayKey'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('Epay secret key')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type='password'
|
||
placeholder={t('Enter new key to update')}
|
||
autoComplete='new-password'
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('Leave blank unless rotating the secret')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<Button
|
||
type='button'
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
saveEpaySettings()
|
||
}}
|
||
disabled={updateOption.isPending}
|
||
>
|
||
{updateOption.isPending
|
||
? t('Saving...')
|
||
: t('Save Epay settings')}
|
||
</Button>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className='space-y-4'>
|
||
<div>
|
||
<h3 className='text-lg font-medium'>{t('Stripe Gateway')}</h3>
|
||
<p className='text-muted-foreground text-sm'>
|
||
{t('Configuration for Stripe payment integration')}
|
||
</p>
|
||
</div>
|
||
|
||
<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:')}{' '}
|
||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||
{'<ServerAddress>/api/stripe/webhook'}
|
||
</code>
|
||
</li>
|
||
<li>
|
||
{t('Required events:')}{' '}
|
||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||
{t('checkout.session.completed')}
|
||
</code>{' '}
|
||
{t('and')}{' '}
|
||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||
{t('checkout.session.expired')}
|
||
</code>
|
||
</li>
|
||
<li>
|
||
{t('Configure at:')}{' '}
|
||
<a
|
||
href='https://dashboard.stripe.com/developers'
|
||
target='_blank'
|
||
rel='noreferrer'
|
||
className='underline hover:no-underline'
|
||
>
|
||
{t('Stripe Dashboard')}
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div className='grid gap-6 md:grid-cols-3'>
|
||
<FormField
|
||
control={form.control}
|
||
name='StripeApiSecret'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('API secret')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type='password'
|
||
placeholder={t('sk_xxx or rk_xxx')}
|
||
autoComplete='new-password'
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('Stripe API key (leave blank unless updating)')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='StripeWebhookSecret'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('Webhook secret')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type='password'
|
||
placeholder={t('whsec_xxx')}
|
||
autoComplete='new-password'
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t(
|
||
'Webhook signing secret (leave blank unless updating)'
|
||
)}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='StripePriceId'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('Price ID')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
placeholder={t('price_xxx')}
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('Stripe product price ID')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<div className='grid gap-6 md:grid-cols-3'>
|
||
<FormField
|
||
control={form.control}
|
||
name='StripeUnitPrice'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>
|
||
{t('Unit price (local currency / USD)')}
|
||
</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type='number'
|
||
step='0.01'
|
||
min={0}
|
||
value={(field.value ?? 0) as number}
|
||
onChange={(event) =>
|
||
field.onChange(event.target.valueAsNumber)
|
||
}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('e.g., 8 means 8 local currency per USD')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='StripeMinTopUp'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('Minimum top-up (USD)')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type='number'
|
||
step='0.01'
|
||
min={0}
|
||
value={(field.value ?? 0) as number}
|
||
onChange={(event) =>
|
||
field.onChange(event.target.valueAsNumber)
|
||
}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('Minimum recharge amount in USD')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='StripePromotionCodesEnabled'
|
||
render={({ field }) => (
|
||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||
<div className='space-y-0.5'>
|
||
<FormLabel className='text-base'>
|
||
{t('Promotion codes')}
|
||
</FormLabel>
|
||
<FormDescription>
|
||
{t('Allow users to enter promo codes')}
|
||
</FormDescription>
|
||
</div>
|
||
<FormControl>
|
||
<Switch
|
||
checked={field.value}
|
||
onCheckedChange={field.onChange}
|
||
/>
|
||
</FormControl>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<Button
|
||
type='button'
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
saveStripeSettings()
|
||
}}
|
||
disabled={updateOption.isPending}
|
||
>
|
||
{updateOption.isPending
|
||
? t('Saving...')
|
||
: t('Save Stripe settings')}
|
||
</Button>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
<div className='space-y-4'>
|
||
<div>
|
||
<h3 className='text-lg font-medium'>{t('Creem Gateway')}</h3>
|
||
<p className='text-muted-foreground text-sm'>
|
||
{t('Configuration for Creem payment integration')}
|
||
</p>
|
||
</div>
|
||
|
||
<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:')}{' '}
|
||
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
|
||
{'<ServerAddress>/api/creem/webhook'}
|
||
</code>
|
||
</li>
|
||
<li>{t('Configure in your Creem dashboard')}</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div className='grid gap-6 md:grid-cols-2'>
|
||
<FormField
|
||
control={form.control}
|
||
name='CreemApiKey'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('API Key')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type='password'
|
||
placeholder={t('Enter Creem API key')}
|
||
autoComplete='new-password'
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('Creem API key (leave blank unless updating)')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='CreemWebhookSecret'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t('Webhook Secret')}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type='password'
|
||
placeholder={t('Enter webhook secret')}
|
||
autoComplete='new-password'
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t(
|
||
'Webhook signing secret (leave blank unless updating)'
|
||
)}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='CreemTestMode'
|
||
render={({ field }) => (
|
||
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
|
||
<div className='space-y-0.5'>
|
||
<FormLabel className='text-base'>
|
||
{t('Test Mode')}
|
||
</FormLabel>
|
||
<FormDescription>
|
||
{t('Enable test mode for Creem payments')}
|
||
</FormDescription>
|
||
</div>
|
||
<FormControl>
|
||
<Switch
|
||
checked={field.value}
|
||
onCheckedChange={field.onChange}
|
||
/>
|
||
</FormControl>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name='CreemProducts'
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<div className='mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||
<FormLabel>{t('Products')}</FormLabel>
|
||
<Button
|
||
type='button'
|
||
variant='outline'
|
||
size='sm'
|
||
onClick={() =>
|
||
setCreemProductsVisualMode(!creemProductsVisualMode)
|
||
}
|
||
className='w-full sm:w-auto'
|
||
>
|
||
{creemProductsVisualMode ? (
|
||
<>
|
||
<Code2 className='mr-2 h-3 w-3' />
|
||
{t('JSON Editor')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Eye className='mr-2 h-3 w-3' />
|
||
{t('Visual Editor')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
<FormControl>
|
||
{creemProductsVisualMode ? (
|
||
<CreemProductsVisualEditor
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
/>
|
||
) : (
|
||
<Textarea
|
||
rows={4}
|
||
placeholder='[{"name":"Basic","productId":"prod_xxx","price":10,"quota":500000,"currency":"USD"}]'
|
||
{...field}
|
||
onChange={(event) => field.onChange(event.target.value)}
|
||
/>
|
||
)}
|
||
</FormControl>
|
||
<FormDescription>
|
||
{t('Configure Creem products. Provide a JSON array.')}
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<Button
|
||
type='button'
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
saveCreemSettings()
|
||
}}
|
||
disabled={updateOption.isPending}
|
||
>
|
||
{updateOption.isPending
|
||
? t('Saving...')
|
||
: t('Save Creem settings')}
|
||
</Button>
|
||
</div>
|
||
|
||
<Button type='submit' disabled={updateOption.isPending}>
|
||
{updateOption.isPending ? t('Saving...') : t('Save all settings')}
|
||
</Button>
|
||
</form>
|
||
</Form>
|
||
|
||
<Separator />
|
||
|
||
<WaffoPancakeSettingsSection
|
||
defaultValues={waffoPancakeDefaultValues}
|
||
provisionedStoreID={waffoPancakeProvisionedStoreID}
|
||
provisionedProductID={waffoPancakeProvisionedProductID}
|
||
/>
|
||
|
||
<Separator />
|
||
|
||
<WaffoSettingsSection defaultValues={waffoDefaultValues} />
|
||
{/* eslint-enable react-hooks/refs */}
|
||
</SettingsSection>
|
||
)
|
||
}
|