feat: initial commit

Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
2026-02-18 11:54:42 +08:00
parent 23fb6f163d
commit 0a7d69e86b
167 changed files with 23952 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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