feat(client): add KYC for event joining

Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
2026-02-05 19:12:57 +08:00
committed by Asai Neko
parent f793a7516f
commit 69a7756886
31 changed files with 1760 additions and 187 deletions

View File

@@ -2,7 +2,6 @@ import type { EventInfo } from './types';
import dayjs from 'dayjs';
import { Calendar } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardAction,
@@ -13,7 +12,8 @@ import {
} from '@/components/ui/card';
import { Skeleton } from '../ui/skeleton';
export function EventCardView({ type, coverImage, eventName, description, startTime, endTime, onJoinEvent }: EventInfo) {
export function EventCardView({ eventInfo, actionFooter }: { eventInfo: EventInfo; actionFooter: React.ReactNode }) {
const { type, coverImage, eventName, description, startTime, endTime } = eventInfo;
const startDayJs = dayjs(startTime);
const endDayJs = dayjs(endTime);
return (
@@ -41,7 +41,7 @@ export function EventCardView({ type, coverImage, eventName, description, startT
</CardDescription>
</CardHeader>
<CardFooter>
<Button className="w-full" onClick={onJoinEvent}></Button>
{actionFooter}
</CardFooter>
</Card>
);

View File

@@ -1,23 +1,40 @@
import type { EventInfo } from './types';
import PlaceholderImage from '@/assets/event-placeholder.png';
import { useGetEvents } from '@/hooks/data/useGetEvents';
import { useJoinEvent } from '@/hooks/data/useJoinEvent';
import { Button } from '../ui/button';
import { DialogTrigger } from '../ui/dialog';
import { EventGridView } from './event-grid.view';
import { KycDialogContainer } from './kyc/kyc.dialog.container';
export function EventGridContainer() {
const { data, isLoading } = useGetEvents();
const { mutate } = useJoinEvent();
const allEvents: EventInfo[] = isLoading
? []
: data.pages.flatMap(page => page.data!).map(it => ({
type: it.type! as EventInfo['type'],
coverImage: it.thumbnail! || PlaceholderImage,
eventName: it.name!,
description: it.description!,
startTime: new Date(it.start_time!),
endTime: new Date(it.end_time!),
onJoinEvent: () => mutate({ body: { event_id: it.event_id } }),
} satisfies EventInfo));
type: it.type! as EventInfo['type'],
eventId: it.event_id!,
isJoined: it.is_joined!,
requireKyc: it.enable_kyc!,
coverImage: it.thumbnail! || PlaceholderImage,
eventName: it.name!,
description: it.description!,
startTime: new Date(it.start_time!),
endTime: new Date(it.end_time!),
} satisfies EventInfo));
return <EventGridView events={allEvents} />;
return (
<EventGridView
events={allEvents}
assembleFooter={eventInfo => (eventInfo.isJoined
? <Button className="w-full" disabled></Button>
: (
<KycDialogContainer eventIdToJoin={eventInfo.eventId}>
<DialogTrigger asChild>
<Button className="w-full"></Button>
</DialogTrigger>
</KycDialogContainer>
)
)}
/>
);
}

View File

@@ -1,11 +1,11 @@
import type { EventInfo } from './types';
import { EventCardView } from './event-card.view';
export function EventGridView({ events }: { events: EventInfo[] }) {
export function EventGridView({ events, assembleFooter }: { events: EventInfo[]; assembleFooter: (event: EventInfo) => React.ReactNode }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{events.map(event => (
<EventCardView key={event.eventName} {...event} />
<EventCardView key={event.eventId} eventInfo={event} actionFooter={assembleFooter(event)} />
))}
</div>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>;

View File

@@ -0,0 +1,8 @@
export type KycSubmission = {
method: 'cnrid';
cnrid: string;
name: string;
} | {
method: 'passport';
passportId: string;
};

View File

@@ -1,9 +1,11 @@
export interface EventInfo {
type: 'official' | 'party';
eventId: string;
isJoined: boolean;
requireKyc: boolean;
coverImage: string;
eventName: string;
description: string;
startTime: Date;
endTime: Date;
onJoinEvent: () => void;
}

View File

@@ -30,7 +30,7 @@ const formSchema = z.object({
username: z.string().min(5),
nickname: z.string(),
subtitle: z.string(),
avatar: z.url().or(z.literal('')),
avatar: z.string().url().or(z.literal('')),
allow_public: z.boolean(),
});
export function EditProfileDialogView({ user, updateProfile }: { user: ServiceUserUserInfoData; updateProfile: (data: ServiceUserUserInfoData) => Promise<void> }) {