feat(client): add KYC for event joining
Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
export function KycFailedDialogView() {
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>失败</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>提交身份认证失败,请重试。</p>
|
||||
<div className="flex justify-center my-12">
|
||||
<X size={100} />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { KycSubmission } from './kyc.types';
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import z from 'zod';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '../../ui/button';
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
const CnridSchema = z.object({
|
||||
cnrid: z.string().min(18, '身份证号应为18位').max(18, '身份证号应为18位'),
|
||||
name: z.string().min(2, '姓名应至少2个字符').max(10, '姓名应不超过10个字符'),
|
||||
});
|
||||
|
||||
function CnridForm({ onSubmit }: { onSubmit: OnSubmit }) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
cnrid: '',
|
||||
name: '',
|
||||
},
|
||||
validators: {
|
||||
onSubmit: CnridSchema,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await onSubmit({
|
||||
method: 'cnrid',
|
||||
...values.value,
|
||||
}).catch(() => {
|
||||
toast('认证失败,请稍后再试');
|
||||
});
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<form.Field name="name">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">姓名</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="cnrid">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cnrid">身份证号</FieldLabel>
|
||||
<Input
|
||||
id="cnrid"
|
||||
name="cnrid"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<DialogFooter>
|
||||
<form.Subscribe
|
||||
selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]}
|
||||
children={([canSubmit, isPristine, isSubmitting]) => (
|
||||
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? '...' : '开始认证'}</Button>
|
||||
)}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const PassportSchema = z.object({
|
||||
passportId: z.string().min(9, '护照号应为9个字符').max(9, '护照号应为9个字符'),
|
||||
});
|
||||
|
||||
function PassportForm({ onSubmit }: { onSubmit: OnSubmit }) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
passportId: '',
|
||||
},
|
||||
validators: {
|
||||
onSubmit: PassportSchema,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await onSubmit({
|
||||
method: 'passport',
|
||||
...values.value,
|
||||
}).catch(() => {
|
||||
toast('认证失败,请稍后再试');
|
||||
});
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<form.Field name="passportId">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="passportId">护照号</FieldLabel>
|
||||
<Input
|
||||
id="passportId"
|
||||
name="passportId"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<DialogFooter>
|
||||
<form.Subscribe
|
||||
selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]}
|
||||
children={([canSubmit, isPristine, isSubmitting]) => (
|
||||
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? '...' : '开始认证'}</Button>
|
||||
)}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type OnSubmit = (submission: KycSubmission) => Promise<void>;
|
||||
|
||||
export function KycMethodSelectionDialogView({ onSubmit }: { onSubmit: OnSubmit }) {
|
||||
const [kycMethod, setKycMethod] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择身份认证模式</DialogTitle>
|
||||
<DialogDescription className="prose">
|
||||
<p>我们支持身份证和护照认证。</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Label htmlFor="selection">身份认证模式</Label>
|
||||
<Select onValueChange={setKycMethod}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="请选择..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup id="selection">
|
||||
<SelectItem value="cnrid">身份证</SelectItem>
|
||||
<SelectItem value="passport">护照</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{kycMethod === 'cnrid' && <CnridForm onSubmit={onSubmit} />}
|
||||
{kycMethod === 'passport' && <PassportForm onSubmit={onSubmit} />}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { HashLoader } from 'react-spinners/esm';
|
||||
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
export function KycPendingDialogView() {
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>等待身份认证结果</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>认证页面已打开。正在等待认证服务器回传数据...</p>
|
||||
<div className="flex justify-center my-12">
|
||||
<HashLoader color="#e0e0e0" size={100} />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Button } from '../../ui/button';
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
export function KycPromptDialogView({ next }: { next: () => void }) {
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>需要身份认证</DialogTitle>
|
||||
<DialogDescription className="prose">
|
||||
<p>为了确保会议的安全性及合规性,我们需要对参会者进行实名认证。</p>
|
||||
<p>您的个人隐私对我们至关重要:</p>
|
||||
<ul>
|
||||
<li>数据加密:您的所有个人敏感信息均会通过 AES-256 标准进行强加密存储。</li>
|
||||
<li>用途限制:收集的信息仅用于本次活动的身份核实,不会用于任何商业用途。</li>
|
||||
<li>按时销毁:所有身份证明文件及关联的敏感原始数据将在活动完成后最多 30 天内从服务器中彻底删除。</li>
|
||||
</ul>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={next}>下一步</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
export function KycSuccessDialogView() {
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>成功</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>已完成身份认证。</p>
|
||||
<div className="flex justify-center my-12">
|
||||
<Check size={100} />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
129
client/cms/src/components/events/kyc/kyc.dialog.container.tsx
Normal file
129
client/cms/src/components/events/kyc/kyc.dialog.container.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { KycSubmission } from './kyc.types';
|
||||
import { Dialog } from '@radix-ui/react-dialog';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useStore } from 'zustand';
|
||||
import { postEventJoin, postKycQuery } from '@/client';
|
||||
import { getEventListInfiniteQueryKey } from '@/client/@tanstack/react-query.gen';
|
||||
import { useCreateKycSession } from '@/hooks/data/useCreateKycSession';
|
||||
import { ver } from '@/lib/apiVersion';
|
||||
import { KycFailedDialogView } from './kyc-failed.dialog.view';
|
||||
import { KycMethodSelectionDialogView } from './kyc-method-selection.dialog.view';
|
||||
import { KycPendingDialogView } from './kyc-pending.dialog.view';
|
||||
import { KycPromptDialogView } from './kyc-prompt.dialog.view';
|
||||
import { KycSuccessDialogView } from './kyc-success.dialog.view';
|
||||
import { createKycStore } from './kyc.state';
|
||||
|
||||
export function KycDialogContainer({ eventIdToJoin, children }: { eventIdToJoin: string; children: React.ReactNode }) {
|
||||
const [store] = useState(() => createKycStore(eventIdToJoin));
|
||||
const isDialogOpen = useStore(store, s => s.isDialogOpen);
|
||||
const setIsDialogOpen = useStore(store, s => s.setIsDialogOpen);
|
||||
const stage = useStore(store, s => s.stage);
|
||||
const setStage = useStore(store, s => s.setStage);
|
||||
const setKycId = useStore(store, s => s.setKycId);
|
||||
|
||||
const { mutateAsync } = useCreateKycSession();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const joinEvent = useCallback(async (eventId: string, kycId: string, abortSignal?: AbortSignal) => {
|
||||
try {
|
||||
await postEventJoin({
|
||||
signal: abortSignal,
|
||||
body: { event_id: eventId, kyc_id: kycId },
|
||||
headers: ver('20260205'),
|
||||
});
|
||||
setStage('success');
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Error joining event:', e);
|
||||
setStage('failed');
|
||||
}
|
||||
}, [setStage]);
|
||||
|
||||
const onKycSessionCreate = useCallback(async (submission: KycSubmission) => {
|
||||
try {
|
||||
const { data } = await mutateAsync(submission);
|
||||
setKycId(data!.kyc_id!);
|
||||
if (data!.status === 'success') {
|
||||
await joinEvent(eventIdToJoin, data!.kyc_id!, undefined);
|
||||
}
|
||||
else if (data!.status === 'processing') {
|
||||
window.open(data!.redirect_uri, '_blank');
|
||||
setStage('pending');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
setStage('failed');
|
||||
}
|
||||
}, [eventIdToJoin, joinEvent, mutateAsync, setKycId, setStage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stage !== 'pending' || !isDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { data } = await postKycQuery({
|
||||
signal: controller.signal,
|
||||
body: { kyc_id: store.getState().kycId! },
|
||||
headers: ver('20260205'),
|
||||
});
|
||||
|
||||
const status = data?.data?.status;
|
||||
|
||||
if (status === 'success') {
|
||||
void joinEvent(eventIdToJoin, store.getState().kycId!, controller.signal);
|
||||
}
|
||||
else if (status === 'failed') {
|
||||
setStage('failed');
|
||||
}
|
||||
else if (status === 'pending') {
|
||||
timer = setTimeout(() => void poll(), 1000);
|
||||
}
|
||||
else {
|
||||
// What the fuck?
|
||||
setStage('failed');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if ((e as Error).name === 'AbortError')
|
||||
return;
|
||||
console.error('Error fetching KYC status:', e);
|
||||
setStage('failed');
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [stage, store, setStage, isDialogOpen, joinEvent, eventIdToJoin]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: getEventListInfiniteQueryKey({ query: {}, headers: ver('20260205') }),
|
||||
});
|
||||
}
|
||||
setIsDialogOpen(open);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{stage === 'prompt' && <KycPromptDialogView next={() => setStage('methodSelection')} />}
|
||||
{stage === 'methodSelection' && <KycMethodSelectionDialogView onSubmit={onKycSessionCreate} />}
|
||||
{stage === 'pending' && <KycPendingDialogView />}
|
||||
{stage === 'success' && <KycSuccessDialogView />}
|
||||
{stage === 'failed' && <KycFailedDialogView />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
34
client/cms/src/components/events/kyc/kyc.state.ts
Normal file
34
client/cms/src/components/events/kyc/kyc.state.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createStore } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
interface KycState {
|
||||
isDialogOpen: boolean;
|
||||
eventIdToJoin: string;
|
||||
kycId: string | null;
|
||||
stage: 'prompt' | 'methodSelection' | 'pending' | 'success' | 'failed';
|
||||
|
||||
setIsDialogOpen: (open: boolean) => void;
|
||||
setStage: (stage: KycState['stage']) => void;
|
||||
setKycId: (kycId: string) => void;
|
||||
}
|
||||
|
||||
export function createKycStore(eventIdToJoin: string) {
|
||||
const initialState = {
|
||||
isDialogOpen: false,
|
||||
eventIdToJoin,
|
||||
kycId: null,
|
||||
stage: 'prompt' as const,
|
||||
};
|
||||
return createStore<KycState>()(devtools(set => ({
|
||||
...initialState,
|
||||
setIsDialogOpen: (open: boolean) => set(() =>
|
||||
open
|
||||
? { ...initialState, isDialogOpen: true }
|
||||
: { ...initialState, isDialogOpen: false },
|
||||
),
|
||||
setStage: (stage: KycState['stage']) => set(() => ({ stage })),
|
||||
setKycId: (kycId: string) => set(() => ({ kycId })),
|
||||
})));
|
||||
}
|
||||
|
||||
export type KycStore = ReturnType<typeof createKycStore>;
|
||||
8
client/cms/src/components/events/kyc/kyc.types.ts
Normal file
8
client/cms/src/components/events/kyc/kyc.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type KycSubmission = {
|
||||
method: 'cnrid';
|
||||
cnrid: string;
|
||||
name: string;
|
||||
} | {
|
||||
method: 'passport';
|
||||
passportId: string;
|
||||
};
|
||||
Reference in New Issue
Block a user