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:
CaIon
2026-05-13 22:18:46 +08:00
parent aa56667b8f
commit 0526a22643
57 changed files with 1806 additions and 216 deletions
@@ -0,0 +1,308 @@
/*
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 { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
type RequiredTextPart = {
type: 'input' | 'static'
text: string
placeholder?: string
}
type NormalizedRequiredTextPart = RequiredTextPart & {
inputIndex?: number
}
type RiskAcknowledgementDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: React.ReactNode
items?: string[]
checklist?: string[]
requiredText?: string
requiredTextParts?: RequiredTextPart[]
inputPrompt?: string
inputPlaceholder?: string
mismatchHint?: string
confirmText?: string
cancelText?: string
destructive?: boolean
isLoading?: boolean
onConfirm: () => void
className?: string
}
export function RiskAcknowledgementDialog({
open,
onOpenChange,
title,
description,
items = [],
checklist = [],
requiredText = '',
requiredTextParts = [],
inputPrompt,
inputPlaceholder,
mismatchHint,
confirmText,
cancelText,
destructive = true,
isLoading = false,
onConfirm,
className,
}: RiskAcknowledgementDialogProps) {
const { t } = useTranslation()
const [checkedItems, setCheckedItems] = useState<boolean[]>([])
const [typedText, setTypedText] = useState('')
const [typedTextParts, setTypedTextParts] = useState<string[]>([])
const normalizedRequiredTextParts = useMemo<
NormalizedRequiredTextPart[]
>(() => {
let inputIndex = 0
return requiredTextParts.map((part) => {
if (part.type === 'input') {
const normalizedPart = { ...part, inputIndex }
inputIndex += 1
return normalizedPart
}
return part
})
}, [requiredTextParts])
const requiredTextInputCount = useMemo(
() =>
normalizedRequiredTextParts.filter((part) => part.type === 'input')
.length,
[normalizedRequiredTextParts]
)
const hasSegmentedRequiredText = requiredTextInputCount > 0
const requiredTextToDisplay = hasSegmentedRequiredText
? normalizedRequiredTextParts.map((part) => part.text).join('')
: requiredText
useEffect(() => {
if (!open) return
setCheckedItems(Array(checklist.length).fill(false))
setTypedText('')
setTypedTextParts(Array(requiredTextInputCount).fill(''))
}, [open, checklist.length, requiredTextInputCount])
const allChecked = useMemo(() => {
if (checklist.length === 0) return true
return (
checkedItems.length === checklist.length &&
checkedItems.every((checked) => checked)
)
}, [checkedItems, checklist.length])
const typedMatched = useMemo(() => {
if (hasSegmentedRequiredText) {
return normalizedRequiredTextParts.every((part) => {
if (part.type === 'static') return true
return typedTextParts[part.inputIndex ?? 0]?.trim() === part.text.trim()
})
}
if (!requiredText) return true
return typedText.trim() === requiredText.trim()
}, [
hasSegmentedRequiredText,
normalizedRequiredTextParts,
requiredText,
typedText,
typedTextParts,
])
const hasTypedRequiredText = hasSegmentedRequiredText
? typedTextParts.some((part) => part.trim() !== '')
: typedText.length > 0
const canConfirm = allChecked && typedMatched && !isLoading
const handleChecklistChange = (index: number, checked: boolean) => {
setCheckedItems((previous) => {
const next = [...previous]
next[index] = checked
return next
})
}
const handleTextPartChange = (index: number, value: string) => {
setTypedTextParts((previous) => {
const next = [...previous]
next[index] = value
return next
})
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent
className={cn(
'flex max-h-[min(88dvh,760px)] w-[calc(100vw-1.5rem)] !max-w-[44rem] grid-rows-none flex-col gap-0 overflow-hidden !p-0 sm:w-[min(44rem,calc(100vw-3rem))]',
className
)}
>
<AlertDialogHeader className='shrink-0 px-4 pt-4 pb-3 text-left sm:px-6 sm:pt-6'>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription
render={<div />}
className='mt-1 text-left leading-5'
>
{description}
</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<div className='min-h-0 flex-1 space-y-4 overflow-y-auto px-4 pb-4 sm:px-6'>
{items.length > 0 ? (
<ol className='border-border/70 bg-muted/30 text-foreground list-decimal space-y-2 rounded-lg border px-4 py-3 pl-8 text-sm leading-6 sm:px-5 sm:py-4 sm:pl-9'>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
) : null}
{checklist.length > 0 ? (
<div className='border-border/70 bg-muted/30 space-y-3 rounded-lg border p-3 sm:p-4'>
{checklist.map((item, index) => {
const id = `risk-acknowledgement-${index}`
return (
<div key={item} className='flex items-start gap-3'>
<Checkbox
id={id}
checked={checkedItems[index] ?? false}
onCheckedChange={(checked) =>
handleChecklistChange(index, checked === true)
}
className='mt-0.5'
/>
<Label
htmlFor={id}
className='text-muted-foreground text-sm leading-5 font-normal'
>
{item}
</Label>
</div>
)
})}
</div>
) : null}
{requiredTextToDisplay ? (
<div className='border-destructive/30 bg-destructive/5 space-y-3 rounded-lg border p-3 sm:p-4'>
<Label className='text-sm font-medium'>
{inputPrompt ?? t('Please type the following text to confirm:')}
</Label>
<div className='bg-background border-border rounded-md border px-3 py-2 font-mono text-sm break-all'>
{requiredTextToDisplay}
</div>
{hasSegmentedRequiredText ? (
<div className='flex flex-wrap items-center gap-2'>
{normalizedRequiredTextParts.map((part, index) =>
part.type === 'static' ? (
<span
key={`static-${index}`}
className='text-muted-foreground bg-background/70 border-border rounded-md border px-2 py-1.5 font-mono text-sm select-none'
>
{part.text}
</span>
) : (
<Input
key={`input-${index}`}
value={typedTextParts[part.inputIndex ?? 0] ?? ''}
onChange={(event) =>
handleTextPartChange(
part.inputIndex ?? 0,
event.target.value
)
}
placeholder={
part.placeholder ??
part.text ??
inputPlaceholder ??
t('Type the confirmation text here')
}
autoFocus={open && part.inputIndex === 0}
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
onPaste={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
aria-invalid={hasTypedRequiredText && !typedMatched}
className='w-full font-mono sm:w-64'
/>
)
)}
</div>
) : (
<Input
value={typedText}
onChange={(event) => setTypedText(event.target.value)}
placeholder={
inputPlaceholder ?? t('Type the confirmation text here')
}
autoFocus={open}
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
onPaste={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
aria-invalid={hasTypedRequiredText && !typedMatched}
/>
)}
{hasTypedRequiredText && !typedMatched ? (
<p className='text-destructive text-xs'>
{mismatchHint ??
t('The entered text does not match the required text.')}
</p>
) : null}
</div>
) : null}
</div>
<AlertDialogFooter className='mx-0 mb-0 shrink-0 rounded-b-xl border-t p-3 sm:p-4'>
<AlertDialogCancel disabled={isLoading}>
{cancelText ?? t('Cancel')}
</AlertDialogCancel>
<Button
variant={destructive ? 'destructive' : 'default'}
disabled={!canConfirm}
onClick={onConfirm}
>
{confirmText ?? t('Confirm')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}