feat: require compliance confirmation for paid features
Gate payment, redemption, subscription, and invitation reward flows behind an audited compliance acknowledgement.
This commit is contained in:
+3
-1
@@ -35,7 +35,7 @@ interface DataTableRowActionsProps {
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen, setCurrentRow } = useSubscriptions()
|
||||
const { setOpen, setCurrentRow, complianceConfirmed } = useSubscriptions()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -46,6 +46,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
disabled={!complianceConfirmed}
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('update')
|
||||
@@ -55,6 +56,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
{t('Edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!complianceConfirmed}
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('toggle-status')
|
||||
|
||||
+6
-2
@@ -23,10 +23,14 @@ import { useSubscriptions } from './subscriptions-provider'
|
||||
|
||||
export function SubscriptionsPrimaryButtons() {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen } = useSubscriptions()
|
||||
const { setOpen, complianceConfirmed } = useSubscriptions()
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' onClick={() => setOpen('create')}>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => setOpen('create')}
|
||||
disabled={!complianceConfirmed}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
{t('Create Plan')}
|
||||
</Button>
|
||||
|
||||
@@ -18,8 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import useDialogState from '@/hooks/use-dialog'
|
||||
import {
|
||||
getOptionValue,
|
||||
useSystemOptions,
|
||||
} from '@/features/system-settings/hooks/use-system-options'
|
||||
import { type PlanRecord, type SubscriptionsDialogType } from '../types'
|
||||
|
||||
const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1'
|
||||
|
||||
type SubscriptionsContextType = {
|
||||
open: SubscriptionsDialogType | null
|
||||
setOpen: (str: SubscriptionsDialogType | null) => void
|
||||
@@ -27,6 +33,7 @@ type SubscriptionsContextType = {
|
||||
setCurrentRow: React.Dispatch<React.SetStateAction<PlanRecord | null>>
|
||||
refreshTrigger: number
|
||||
triggerRefresh: () => void
|
||||
complianceConfirmed: boolean
|
||||
}
|
||||
|
||||
const SubscriptionsContext =
|
||||
@@ -40,6 +47,15 @@ export function SubscriptionsProvider({
|
||||
const [open, setOpen] = useDialogState<SubscriptionsDialogType>(null)
|
||||
const [currentRow, setCurrentRow] = useState<PlanRecord | null>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const { data } = useSystemOptions()
|
||||
const complianceOptions = getOptionValue(data?.data, {
|
||||
'payment_setting.compliance_confirmed': false,
|
||||
'payment_setting.compliance_terms_version': '',
|
||||
})
|
||||
const complianceConfirmed =
|
||||
complianceOptions['payment_setting.compliance_confirmed'] &&
|
||||
complianceOptions['payment_setting.compliance_terms_version'] ===
|
||||
CURRENT_COMPLIANCE_TERMS_VERSION
|
||||
|
||||
const triggerRefresh = () => setRefreshTrigger((prev) => prev + 1)
|
||||
|
||||
@@ -52,6 +68,7 @@ export function SubscriptionsProvider({
|
||||
setCurrentRow,
|
||||
refreshTrigger,
|
||||
triggerRefresh,
|
||||
complianceConfirmed,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
+25
-3
@@ -22,13 +22,18 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { SubscriptionsDialogs } from './components/subscriptions-dialogs'
|
||||
import { SubscriptionsPrimaryButtons } from './components/subscriptions-primary-buttons'
|
||||
import { SubscriptionsProvider } from './components/subscriptions-provider'
|
||||
import {
|
||||
SubscriptionsProvider,
|
||||
useSubscriptions,
|
||||
} from './components/subscriptions-provider'
|
||||
import { SubscriptionsTable } from './components/subscriptions-table'
|
||||
|
||||
export function Subscriptions() {
|
||||
function SubscriptionsContent() {
|
||||
const { t } = useTranslation()
|
||||
const { complianceConfirmed } = useSubscriptions()
|
||||
|
||||
return (
|
||||
<SubscriptionsProvider>
|
||||
<>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout.Title>
|
||||
{t('Subscription Management')}
|
||||
@@ -50,11 +55,28 @@ export function Subscriptions() {
|
||||
</div>
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
{!complianceConfirmed ? (
|
||||
<Alert variant='destructive' className='mb-4'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Subscription plan creation and changes are locked until the administrator confirms compliance terms in Payment Gateway settings.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<SubscriptionsTable />
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
<SubscriptionsDialogs />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Subscriptions() {
|
||||
return (
|
||||
<SubscriptionsProvider>
|
||||
<SubscriptionsContent />
|
||||
</SubscriptionsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
ConfirmPaymentComplianceResponse,
|
||||
DeleteLogsResponse,
|
||||
FetchUpstreamRatiosRequest,
|
||||
SystemOptionsResponse,
|
||||
@@ -37,6 +38,14 @@ export async function updateSystemOption(request: UpdateOptionRequest) {
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function confirmPaymentCompliance() {
|
||||
const res = await api.post<ConfirmPaymentComplianceResponse>(
|
||||
'/api/option/payment_compliance',
|
||||
{ confirmed: true }
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteLogsBefore(targetTimestamp: number) {
|
||||
const res = await api.delete<DeleteLogsResponse>('/api/log/', {
|
||||
params: { target_timestamp: targetTimestamp },
|
||||
|
||||
@@ -66,6 +66,11 @@ const defaultBillingSettings: BillingSettings = {
|
||||
PayMethods: '',
|
||||
'payment_setting.amount_options': '',
|
||||
'payment_setting.amount_discount': '',
|
||||
'payment_setting.compliance_confirmed': false,
|
||||
'payment_setting.compliance_terms_version': '',
|
||||
'payment_setting.compliance_confirmed_at': 0,
|
||||
'payment_setting.compliance_confirmed_by': 0,
|
||||
'payment_setting.compliance_confirmed_ip': '',
|
||||
StripeApiSecret: '',
|
||||
StripeWebhookSecret: '',
|
||||
StripePriceId: '',
|
||||
|
||||
@@ -71,6 +71,10 @@ const BILLING_SECTIONS = [
|
||||
settings['quota_setting.enable_free_model_pre_consume'],
|
||||
},
|
||||
}}
|
||||
complianceConfirmed={
|
||||
(settings['payment_setting.compliance_confirmed'] ?? false) &&
|
||||
settings['payment_setting.compliance_terms_version'] === 'v1'
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -187,6 +191,13 @@ const BILLING_SECTIONS = [
|
||||
WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1,
|
||||
WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1,
|
||||
}}
|
||||
complianceDefaults={{
|
||||
confirmed: settings['payment_setting.compliance_confirmed'] ?? false,
|
||||
termsVersion:
|
||||
settings['payment_setting.compliance_terms_version'] ?? '',
|
||||
confirmedAt: settings['payment_setting.compliance_confirmed_at'] ?? 0,
|
||||
confirmedBy: settings['payment_setting.compliance_confirmed_by'] ?? 0,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import * as z from 'zod'
|
||||
import type { Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
@@ -57,10 +58,12 @@ type QuotaFormValues = z.infer<typeof quotaSchema>
|
||||
|
||||
type QuotaSettingsSectionProps = {
|
||||
defaultValues: QuotaFormValues
|
||||
complianceConfirmed?: boolean
|
||||
}
|
||||
|
||||
export function QuotaSettingsSection({
|
||||
defaultValues,
|
||||
complianceConfirmed = true,
|
||||
}: QuotaSettingsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateOption = useUpdateOption()
|
||||
@@ -97,6 +100,16 @@ export function QuotaSettingsSection({
|
||||
>
|
||||
<FormNavigationGuard when={isDirty} />
|
||||
|
||||
{!complianceConfirmed ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit} className='space-y-6'>
|
||||
<FormDirtyIndicator isDirty={isDirty} />
|
||||
|
||||
+166
-2
@@ -20,8 +20,17 @@ import * as React from 'react'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Code2, Eye } from 'lucide-react'
|
||||
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,
|
||||
@@ -36,6 +45,8 @@ 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'
|
||||
@@ -125,18 +136,30 @@ const paymentSchema = z.object({
|
||||
|
||||
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
|
||||
complianceDefaults: PaymentComplianceDefaults
|
||||
}
|
||||
|
||||
export function PaymentSettingsSection({
|
||||
defaultValues,
|
||||
waffoDefaultValues,
|
||||
waffoPancakeDefaultValues,
|
||||
complianceDefaults,
|
||||
}: PaymentSettingsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const updateOption = useUpdateOption()
|
||||
const initialRef = React.useRef(defaultValues)
|
||||
const defaultsSignature = React.useMemo(
|
||||
@@ -151,6 +174,81 @@ export function PaymentSettingsSection({
|
||||
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),
|
||||
@@ -562,11 +660,77 @@ export function PaymentSettingsSection({
|
||||
'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='space-y-8'
|
||||
className={cn(
|
||||
'space-y-8',
|
||||
!complianceConfirmed && 'pointer-events-none opacity-40'
|
||||
)}
|
||||
data-no-autosubmit='true'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
|
||||
@@ -39,6 +39,17 @@ export type UpdateOptionResponse = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ConfirmPaymentComplianceResponse = {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: {
|
||||
confirmed: boolean
|
||||
terms_version: string
|
||||
confirmed_at: number
|
||||
confirmed_by: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteLogsResponse = {
|
||||
success: boolean
|
||||
message: string
|
||||
@@ -215,6 +226,11 @@ export type BillingSettings = {
|
||||
PayMethods: string
|
||||
'payment_setting.amount_options': string
|
||||
'payment_setting.amount_discount': string
|
||||
'payment_setting.compliance_confirmed': boolean
|
||||
'payment_setting.compliance_terms_version': string
|
||||
'payment_setting.compliance_confirmed_at': number
|
||||
'payment_setting.compliance_confirmed_by': number
|
||||
'payment_setting.compliance_confirmed_ip': string
|
||||
StripeApiSecret: string
|
||||
StripeWebhookSecret: string
|
||||
StripePriceId: string
|
||||
|
||||
@@ -30,6 +30,7 @@ interface AffiliateRewardsCardProps {
|
||||
user: UserWalletData | null
|
||||
affiliateLink: string
|
||||
onTransfer: () => void
|
||||
complianceConfirmed?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ export function AffiliateRewardsCard({
|
||||
user,
|
||||
affiliateLink,
|
||||
onTransfer,
|
||||
complianceConfirmed = true,
|
||||
loading,
|
||||
}: AffiliateRewardsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
@@ -110,6 +112,7 @@ export function AffiliateRewardsCard({
|
||||
{hasRewards && (
|
||||
<Button
|
||||
onClick={onTransfer}
|
||||
disabled={!complianceConfirmed}
|
||||
className='h-9 shrink-0 px-3'
|
||||
size='sm'
|
||||
>
|
||||
@@ -117,6 +120,13 @@ export function AffiliateRewardsCard({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!complianceConfirmed ? (
|
||||
<p className='text-muted-foreground text-xs lg:col-span-3'>
|
||||
{t(
|
||||
'Referral reward transfer is disabled until the administrator confirms compliance terms.'
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -135,6 +135,7 @@ export function RechargeFormCard({
|
||||
const hasWaffoPaymentMethods =
|
||||
Array.isArray(waffoPayMethods) && waffoPayMethods.length > 0
|
||||
const minTopup = getMinTopupAmount(topupInfo)
|
||||
const redemptionEnabled = topupInfo?.enable_redemption !== false
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -445,49 +446,59 @@ export function RechargeFormCard({
|
||||
)}
|
||||
|
||||
{/* Redemption Code Section */}
|
||||
<div className='space-y-2.5 border-t pt-4 sm:space-y-3 sm:pt-6'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Gift className='text-muted-foreground h-4 w-4' />
|
||||
<Label
|
||||
htmlFor='redemption-code'
|
||||
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t('Have a Code?')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
|
||||
<Input
|
||||
id='redemption-code'
|
||||
value={redemptionCode}
|
||||
onChange={(e) => onRedemptionCodeChange(e.target.value)}
|
||||
placeholder={t('Enter your redemption code')}
|
||||
className='h-9 min-w-0'
|
||||
/>
|
||||
<Button
|
||||
onClick={onRedeem}
|
||||
disabled={redeeming}
|
||||
variant='outline'
|
||||
className='h-9 px-4'
|
||||
>
|
||||
{redeeming && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{t('Redeem')}
|
||||
</Button>
|
||||
</div>
|
||||
{topupLink && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Need a redemption code?')}{' '}
|
||||
<a
|
||||
href={topupLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-1 underline-offset-4 hover:underline'
|
||||
{redemptionEnabled ? (
|
||||
<div className='space-y-2.5 border-t pt-4 sm:space-y-3 sm:pt-6'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Gift className='text-muted-foreground h-4 w-4' />
|
||||
<Label
|
||||
htmlFor='redemption-code'
|
||||
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t('Get one here')}
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{t('Have a Code?')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
|
||||
<Input
|
||||
id='redemption-code'
|
||||
value={redemptionCode}
|
||||
onChange={(e) => onRedemptionCodeChange(e.target.value)}
|
||||
placeholder={t('Enter your redemption code')}
|
||||
className='h-9 min-w-0'
|
||||
/>
|
||||
<Button
|
||||
onClick={onRedeem}
|
||||
disabled={redeeming}
|
||||
variant='outline'
|
||||
className='h-9 px-4'
|
||||
>
|
||||
{redeeming && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{t('Redeem')}
|
||||
</Button>
|
||||
</div>
|
||||
{topupLink && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Need a redemption code?')}{' '}
|
||||
<a
|
||||
href={topupLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-1 underline-offset-4 hover:underline'
|
||||
>
|
||||
{t('Get one here')}
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Alert className='border-t'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Redemption codes are disabled until the administrator confirms compliance terms.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</TitledCard>
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -319,6 +319,9 @@ export function Wallet(props: WalletProps) {
|
||||
user={user}
|
||||
affiliateLink={affiliateLink}
|
||||
onTransfer={() => setTransferDialogOpen(true)}
|
||||
complianceConfirmed={
|
||||
topupInfo?.payment_compliance_confirmed !== false
|
||||
}
|
||||
loading={affiliateLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+6
@@ -145,6 +145,12 @@ export interface TopupInfo {
|
||||
enable_waffo_pancake_topup?: boolean
|
||||
/** Minimum topup amount for Waffo Pancake */
|
||||
waffo_pancake_min_topup?: number
|
||||
/** Whether redemption code usage is enabled */
|
||||
enable_redemption?: boolean
|
||||
/** Whether compliance confirmation has been completed */
|
||||
payment_compliance_confirmed?: boolean
|
||||
/** Current compliance terms version */
|
||||
payment_compliance_terms_version?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user