30
src/components/events/AGENTS.md
Normal file
30
src/components/events/AGENTS.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# EVENTS & KYC SYSTEM
|
||||
|
||||
## OVERVIEW
|
||||
This module uses a Container-View architecture to manage event participation. `EventGridContainer` acts as the primary orchestrator, handling data fetching and determining the appropriate interaction flow based on event requirements. This separation ensures that the presentation layer remains decoupled from the complex business logic of KYC and event joining.
|
||||
|
||||
## STRUCTURE
|
||||
- `event-grid/`: Core listing components (Container/View/Skeleton).
|
||||
- `kyc/`: Isolated KYC state machine and multi-stage dialog views.
|
||||
- `event-join.dialog.container.tsx`: Standard participation flow for non-KYC events.
|
||||
- `nickname-needed.dialog.container.tsx`: Interstitial for profile completion.
|
||||
- `joined-events.containers.tsx`: Specialized views for events the user has already entered.
|
||||
- `types.ts`: Shared domain types and mappers for event data.
|
||||
|
||||
## KEY CONCEPTS
|
||||
### Branching Logic
|
||||
`EventGridContainer` dynamically selects the interaction container based on `eventInfo.requireKyc`. This ensures the UI remains responsive to backend configuration without bloating the view components.
|
||||
- `requireKyc: true` -> `KycDialogContainer`
|
||||
- `requireKyc: false` -> `EventJoinDialogContainer`
|
||||
|
||||
### KYC State Machine
|
||||
The KYC flow is managed via a per-instance Zustand store (`createKycStore`). It handles transitions between stages: `prompt`, `methodSelection`, `pending`, `success`, and `failed`. This isolation prevents KYC state from leaking into the global application state and allows for independent testing of the flow.
|
||||
|
||||
### Dynamic Dialogs
|
||||
Dialogs are triggered via `DialogTrigger`, but their internal content is controlled by the container's state. This allows for complex, multi-step interactions within a single modal context, reducing the need for multiple separate dialog components.
|
||||
|
||||
## GOTCHAS
|
||||
- **Cache Invalidation**: The `useEvents` query must be invalidated after successful KYC or event joining to update the "Joined" status in the grid.
|
||||
- **Store Lifecycle**: The KYC store is created per event instance. It must reset correctly when the dialog closes to avoid stale state on subsequent opens.
|
||||
- **Zustand Devtools**: Use the devtools middleware to debug state transitions during the KYC flow.
|
||||
- **Type Safety**: Ensure `toEventInfo` mapper in `types.ts` is updated when the OpenAPI schema changes to prevent runtime errors in the grid.
|
||||
40
src/components/events/event-card.skeleton.tsx
Normal file
40
src/components/events/event-card.skeleton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Calendar } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
export function EventCardSkeleton() {
|
||||
return (
|
||||
<Card className="relative mx-auto w-full max-w-sm pt-0">
|
||||
<div className="absolute inset-0 z-30 aspect-video bg-black/10" />
|
||||
<Skeleton
|
||||
className="relative z-20 aspect-video w-full object-cover rounded-t-xl"
|
||||
/>
|
||||
<CardHeader>
|
||||
<CardAction>
|
||||
<Badge variant="secondary" className="bg-accent animate-pulse text-accent select-none">Official</Badge>
|
||||
</CardAction>
|
||||
<CardTitle>
|
||||
<Skeleton className="h-4 max-w-48" />
|
||||
</CardTitle>
|
||||
<CardDescription className="flex flex-row items-center text-xs">
|
||||
<Calendar className="size-4 mr-2" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardDescription>
|
||||
<CardDescription className="mt-1">
|
||||
<Skeleton className="h-5 max-w-64" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Skeleton className="h-9 px-4 py-2 w-full"></Skeleton>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
48
src/components/events/event-card.view.tsx
Normal file
48
src/components/events/event-card.view.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { EventInfo } from './types';
|
||||
import dayjs from 'dayjs';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
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 (
|
||||
<Card className="relative mx-auto w-full max-w-sm pt-0">
|
||||
<div className="absolute inset-0 z-30 aspect-video bg-black/10" />
|
||||
<img
|
||||
src={coverImage}
|
||||
alt="Event cover"
|
||||
className="relative z-20 aspect-video w-full object-cover rounded-t-xl"
|
||||
/>
|
||||
<Skeleton
|
||||
className="absolute z-15 aspect-video w-full object-cover rounded-t-xl"
|
||||
/>
|
||||
<CardHeader>
|
||||
<CardAction>
|
||||
{type === 'official' ? <Badge variant="secondary">Official</Badge> : <Badge variant="destructive">Party</Badge>}
|
||||
</CardAction>
|
||||
<CardTitle>{eventName}</CardTitle>
|
||||
<CardDescription className="flex flex-row items-center text-xs">
|
||||
<Calendar className="size-4 mr-2" />
|
||||
{`${startDayJs.format('YYYY/MM/DD')} - ${endDayJs.format('YYYY/MM/DD')}`}
|
||||
</CardDescription>
|
||||
<CardDescription className="mt-1">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
{actionFooter}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
46
src/components/events/event-grid/event-grid.container.tsx
Normal file
46
src/components/events/event-grid/event-grid.container.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEvents } from '@/hooks/data/useEvents';
|
||||
import { Button } from '../../ui/button';
|
||||
import { DialogTrigger } from '../../ui/dialog';
|
||||
import { EventJoinDialogContainer } from '../event-join.dialog.container';
|
||||
import { KycDialogContainer } from '../kyc/kyc.dialog.container';
|
||||
import { toEventInfo } from '../types';
|
||||
import { EventGridSkeleton } from './event-grid.skeleton';
|
||||
import { EventGridView } from './event-grid.view';
|
||||
|
||||
export function EventGridContainer() {
|
||||
const { data, isLoading } = useEvents();
|
||||
|
||||
const events = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.data ?? []).map(toEventInfo) ?? [];
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
isLoading
|
||||
? <EventGridSkeleton />
|
||||
: (
|
||||
<EventGridView
|
||||
events={events}
|
||||
footer={(eventInfo) => {
|
||||
const Container = eventInfo.requireKyc ? KycDialogContainer : EventJoinDialogContainer;
|
||||
|
||||
return (
|
||||
<Container event={eventInfo}>
|
||||
{eventInfo.isJoined
|
||||
? (
|
||||
<Button className="w-full" disabled>
|
||||
已加入
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full">加入活动</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
16
src/components/events/event-grid/event-grid.empty.tsx
Normal file
16
src/components/events/event-grid/event-grid.empty.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { FileQuestionMark } from 'lucide-react';
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../../ui/empty';
|
||||
|
||||
export function EventGridEmpty() {
|
||||
return (
|
||||
<Empty className="h-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FileQuestionMark />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>暂无活动</EmptyTitle>
|
||||
<EmptyDescription>前面的区域 以后再来探索吧</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
16
src/components/events/event-grid/event-grid.error.tsx
Normal file
16
src/components/events/event-grid/event-grid.error.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { FileExclamationPoint } from 'lucide-react';
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../../ui/empty';
|
||||
|
||||
export function EventGridError() {
|
||||
return (
|
||||
<Empty className="h-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FileExclamationPoint />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>活动列表加载失败</EmptyTitle>
|
||||
<EmptyDescription>前面的区域 以后再来探索吧</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
12
src/components/events/event-grid/event-grid.skeleton.tsx
Normal file
12
src/components/events/event-grid/event-grid.skeleton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { EventCardSkeleton } from '../event-card.skeleton';
|
||||
|
||||
export function EventGridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<EventCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/components/events/event-grid/event-grid.view.tsx
Normal file
18
src/components/events/event-grid/event-grid.view.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { EventInfo } from '../types';
|
||||
import { EventCardView } from '../event-card.view';
|
||||
import { EventGridEmpty } from './event-grid.empty';
|
||||
|
||||
export function EventGridView({ events, footer }: { events: EventInfo[]; footer: (event: EventInfo) => React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
{events.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{events.map(event => (
|
||||
<EventCardView key={event.eventId} eventInfo={event} actionFooter={footer(event)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{events.length === 0 && <EventGridEmpty />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/components/events/event-join.dialog.container.tsx
Normal file
25
src/components/events/event-join.dialog.container.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { EventInfo } from './types';
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useJoinEvent } from '@/hooks/data/useJoinEvent';
|
||||
import { Dialog } from '../ui/dialog';
|
||||
import { EventJoinDialogView } from './event-join.dialog.view';
|
||||
|
||||
export function EventJoinDialogContainer({ event, children }: { event: EventInfo; children: React.ReactNode }) {
|
||||
const { mutateAsync, isPending } = useJoinEvent();
|
||||
const join = useCallback(() => {
|
||||
mutateAsync({ body: { event_id: event.eventId } }).then(() => {
|
||||
toast('加入活动成功');
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('加入活动失败');
|
||||
});
|
||||
}, [event.eventId, mutateAsync]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
{children}
|
||||
<EventJoinDialogView event={event} onJoinEvent={join} isPending={isPending} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
26
src/components/events/event-join.dialog.view.tsx
Normal file
26
src/components/events/event-join.dialog.view.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { EventInfo } from './types';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
|
||||
export function EventJoinDialogView({ event, onJoinEvent, isPending }: { event: EventInfo; onJoinEvent: () => void; isPending: boolean }) {
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>加入活动</DialogTitle>
|
||||
<DialogDescription>
|
||||
是否确认要加入活动
|
||||
{' '}
|
||||
{event.eventName}
|
||||
?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={onJoinEvent} disabled={isPending}>{isPending ? <Spinner /> : '加入'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
48
src/components/events/joined-events.containers.tsx
Normal file
48
src/components/events/joined-events.containers.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { EventInfo } from './types';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useJoinedEvents } from '@/hooks/data/useJoinedEvents';
|
||||
import { isInDateRange } from '@/lib/utils';
|
||||
import { CheckinQrDialogContainer } from '../checkin/checkin-qr.dialog.container';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogTrigger } from '../ui/dialog';
|
||||
import { EventGridSkeleton } from './event-grid/event-grid.skeleton';
|
||||
import { EventGridView } from './event-grid/event-grid.view';
|
||||
import { toEventInfo } from './types';
|
||||
|
||||
export function JoinedEventGridFooter({ event }: { event: EventInfo }) {
|
||||
const isOutOfDateRange = !isInDateRange(event.startTime, event.endTime);
|
||||
const isCheckedIn = event.isCheckedIn;
|
||||
const canCheckIn = !isOutOfDateRange && !isCheckedIn;
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex flex-row justify-between w-full gap-4">
|
||||
<CheckinQrDialogContainer event={event}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex-1" disabled={!canCheckIn}>
|
||||
{isOutOfDateRange && '未到签到时间'}
|
||||
{isCheckedIn && '已签到'}
|
||||
{canCheckIn && '签到'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</CheckinQrDialogContainer>
|
||||
<Button className="flex-1" onClick={() => void navigate({ to: `/events/$eventId`, params: { eventId: event.eventId } })}>查看详情</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JoinedEventsContainer() {
|
||||
const { data, isLoading } = useJoinedEvents();
|
||||
|
||||
return (
|
||||
isLoading
|
||||
? <EventGridSkeleton />
|
||||
: (
|
||||
<EventGridView
|
||||
events={data.pages.flatMap(page => page.data ?? []).map(toEventInfo)}
|
||||
footer={event => (
|
||||
<JoinedEventGridFooter event={event} />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
18
src/components/events/kyc/kyc-failed.dialog.view.tsx
Normal file
18
src/components/events/kyc/kyc-failed.dialog.view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/components/events/kyc/kyc-method-selection.dialog.view.tsx
Normal file
178
src/components/events/kyc/kyc-method-selection.dialog.view.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
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 { Spinner } from '@/components/ui/spinner';
|
||||
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 ? <Spinner /> : '开始认证'}</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>
|
||||
);
|
||||
}
|
||||
18
src/components/events/kyc/kyc-pending.dialog.view.tsx
Normal file
18
src/components/events/kyc/kyc-pending.dialog.view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/events/kyc/kyc-prompt.dialog.view.tsx
Normal file
24
src/components/events/kyc/kyc-prompt.dialog.view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/events/kyc/kyc-success.dialog.view.tsx
Normal file
18
src/components/events/kyc/kyc-success.dialog.view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
src/components/events/kyc/kyc.dialog.container.tsx
Normal file
119
src/components/events/kyc/kyc.dialog.container.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { EventInfo } from '../types';
|
||||
import type { KycSubmission } from './kyc.types';
|
||||
import { Dialog } from '@radix-ui/react-dialog';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useStore } from 'zustand';
|
||||
import { postKycQuery } from '@/client';
|
||||
import { useCreateKycSession } from '@/hooks/data/useCreateKycSession';
|
||||
import { useJoinEvent } from '@/hooks/data/useJoinEvent';
|
||||
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({ event, children }: { event: EventInfo; children: React.ReactNode }) {
|
||||
const [store] = useState(() => createKycStore(event.eventId));
|
||||
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: createKycSessionAsync } = useCreateKycSession();
|
||||
const { mutateAsync: joinEventAsync } = useJoinEvent();
|
||||
|
||||
const joinEvent = useCallback(async (eventId: string, kycId: string, abortSignal?: AbortSignal) => {
|
||||
try {
|
||||
await joinEventAsync({
|
||||
signal: abortSignal,
|
||||
body: { event_id: eventId, kyc_id: kycId },
|
||||
});
|
||||
setStage('success');
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Error joining event:', e);
|
||||
setStage('failed');
|
||||
}
|
||||
}, [joinEventAsync, setStage]);
|
||||
|
||||
const onKycSessionCreate = useCallback(async (submission: KycSubmission) => {
|
||||
try {
|
||||
const { data } = await createKycSessionAsync(submission);
|
||||
setKycId(data!.kyc_id!);
|
||||
if (data!.status === 'success') {
|
||||
await joinEvent(event.eventId, data!.kyc_id!, undefined);
|
||||
}
|
||||
else if (data!.status === 'processing') {
|
||||
window.open(data!.redirect_uri, '_blank');
|
||||
setStage('pending');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
setStage('failed');
|
||||
}
|
||||
}, [event.eventId, joinEvent, createKycSessionAsync, 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! },
|
||||
});
|
||||
|
||||
const status = data?.data?.status;
|
||||
|
||||
if (status === 'success') {
|
||||
void joinEvent(event.eventId, 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, event.eventId]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
>
|
||||
{children}
|
||||
{stage === 'prompt' && <KycPromptDialogView next={() => setStage('methodSelection')} />}
|
||||
{stage === 'methodSelection' && <KycMethodSelectionDialogView onSubmit={onKycSessionCreate} />}
|
||||
{stage === 'pending' && <KycPendingDialogView />}
|
||||
{stage === 'success' && <KycSuccessDialogView />}
|
||||
{stage === 'failed' && <KycFailedDialogView />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
34
src/components/events/kyc/kyc.state.ts
Normal file
34
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
src/components/events/kyc/kyc.types.ts
Normal file
8
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;
|
||||
};
|
||||
15
src/components/events/nickname-needed.dialog.container.tsx
Normal file
15
src/components/events/nickname-needed.dialog.container.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { Dialog } from '../ui/dialog';
|
||||
import { NicknameNeededDialogView } from './nickname-needed.dialog.view';
|
||||
|
||||
export function NicknameNeededDialogContainer() {
|
||||
const { data } = useUserInfo();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Dialog open={isEmpty(data?.data?.nickname)}>
|
||||
<NicknameNeededDialogView onAction={() => void navigate({ to: '/profile' })} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
24
src/components/events/nickname-needed.dialog.view.tsx
Normal file
24
src/components/events/nickname-needed.dialog.view.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { HatGlasses } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
export function NicknameNeededDialogView({ onAction }: { onAction: () => void }) {
|
||||
return (
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>请先填写昵称</DialogTitle>
|
||||
<DialogDescription>
|
||||
如要加入活动,请先于「个人资料」页面填写昵称。
|
||||
<div className="flex justify-center my-12">
|
||||
<HatGlasses size={100} className="stroke-[1.5]" />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={onAction}>去填写</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
30
src/components/events/types.ts
Normal file
30
src/components/events/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { DataEventIndexDoc } from '@/client';
|
||||
import PlaceholderImage from '@/assets/event-placeholder.png';
|
||||
|
||||
export interface EventInfo {
|
||||
type: 'official' | 'party';
|
||||
eventId: string;
|
||||
isJoined: boolean;
|
||||
isCheckedIn: boolean;
|
||||
requireKyc: boolean;
|
||||
coverImage: string;
|
||||
eventName: string;
|
||||
description: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
}
|
||||
|
||||
export function toEventInfo(raw: DataEventIndexDoc): EventInfo {
|
||||
return {
|
||||
type: raw.type! as EventInfo['type'],
|
||||
eventId: raw.event_id!,
|
||||
isJoined: raw.is_joined!,
|
||||
requireKyc: raw.enable_kyc!,
|
||||
isCheckedIn: raw.is_checked_in ?? false,
|
||||
coverImage: raw.thumbnail! || PlaceholderImage,
|
||||
eventName: raw.name!,
|
||||
description: raw.description!,
|
||||
startTime: new Date(raw.start_time!),
|
||||
endTime: new Date(raw.end_time!),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user