238 lines
9.0 KiB
TypeScript
238 lines
9.0 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { Calendar, MapPin } from "lucide-react";
|
||
import { useLocation, useNavigate } from "react-router-dom";
|
||
import { AuthShell } from "../components/auth/AuthShell";
|
||
import { EventBuzzLogo } from "../components/EventBuzzLogo";
|
||
import { postVisitorStartOtp, postVisitorVerifyOtp } from "../lib/api";
|
||
import { setAttendeeSession } from "../lib/attendeeAuth";
|
||
import { notifyRewardFromApiData } from "../lib/rewardCelebration";
|
||
import { buildPostAuthUrl, readStoredVisitorContext } from "../types/visitorContext";
|
||
|
||
type OtpLocationState = {
|
||
eventId?: string;
|
||
email?: string;
|
||
inviteToken?: string;
|
||
firstName?: string;
|
||
lastName?: string;
|
||
phone?: string;
|
||
displayName?: string;
|
||
};
|
||
|
||
function formatEventSchedule(startIso: string, endIso: string): string {
|
||
const s = new Date(startIso);
|
||
const e = new Date(endIso);
|
||
if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) return "";
|
||
const opts: Intl.DateTimeFormatOptions = { month: "long", day: "numeric", year: "numeric" };
|
||
if (s.toDateString() === e.toDateString()) {
|
||
return s.toLocaleDateString(undefined, opts);
|
||
}
|
||
return `${s.toLocaleDateString(undefined, opts)} – ${e.toLocaleDateString(undefined, opts)}`;
|
||
}
|
||
|
||
export function RegisterOtpPage() {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const st = (location.state as OtpLocationState | null) ?? {};
|
||
const eventId = st.eventId ?? "";
|
||
const email = st.email ?? "";
|
||
const ctx = readStoredVisitorContext();
|
||
const event = ctx?.event;
|
||
|
||
const [digits, setDigits] = useState<string[]>(() => Array(6).fill(""));
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [resendCooldown, setResendCooldown] = useState(0);
|
||
|
||
useEffect(() => {
|
||
if (resendCooldown <= 0) return;
|
||
const t = window.setTimeout(() => setResendCooldown((c) => c - 1), 1000);
|
||
return () => window.clearTimeout(t);
|
||
}, [resendCooldown]);
|
||
|
||
const schedule = useMemo(() => {
|
||
if (!event?.startDate || !event?.endDate) return "";
|
||
const a =
|
||
typeof event.startDate === "string"
|
||
? event.startDate
|
||
: new Date(event.startDate as unknown as Date).toISOString();
|
||
const b =
|
||
typeof event.endDate === "string"
|
||
? event.endDate
|
||
: new Date(event.endDate as unknown as Date).toISOString();
|
||
return formatEventSchedule(a, b);
|
||
}, [event]);
|
||
|
||
if (!eventId || !email) {
|
||
return (
|
||
<AuthShell>
|
||
<div className="px-4 text-center text-sm">
|
||
<p className="text-[#24282f]">Missing session. Start from the login page.</p>
|
||
<button type="button" className="mt-4 text-[#1e72ef] underline" onClick={() => navigate("/login")}>
|
||
Back to login
|
||
</button>
|
||
</div>
|
||
</AuthShell>
|
||
);
|
||
}
|
||
|
||
function setDigit(i: number, v: string) {
|
||
const d = v.replace(/\D/g, "").slice(-1);
|
||
const next = [...digits];
|
||
next[i] = d;
|
||
setDigits(next);
|
||
if (d && i < 5) {
|
||
document.getElementById(`otp-${i + 1}`)?.focus();
|
||
}
|
||
}
|
||
|
||
async function handleVerify(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
const otp = digits.join("");
|
||
if (otp.length !== 6) {
|
||
setError("Enter the 6-digit code.");
|
||
return;
|
||
}
|
||
setError(null);
|
||
setLoading(true);
|
||
try {
|
||
const data = await postVisitorVerifyOtp(eventId, {
|
||
email,
|
||
otp,
|
||
inviteToken: st.inviteToken,
|
||
firstName: st.firstName,
|
||
lastName: st.lastName,
|
||
phone: st.phone,
|
||
displayName: st.displayName,
|
||
});
|
||
setAttendeeSession(data.token, data.participant);
|
||
notifyRewardFromApiData(data);
|
||
if (data.pendingApproval) {
|
||
navigate("/pending-approval");
|
||
return;
|
||
}
|
||
if (data.needsProfile) {
|
||
navigate("/complete-profile");
|
||
return;
|
||
}
|
||
navigate(buildPostAuthUrl(ctx));
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Verification failed.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleResend() {
|
||
if (resendCooldown > 0 || !ctx) return;
|
||
setError(null);
|
||
try {
|
||
await postVisitorStartOtp(
|
||
eventId,
|
||
email,
|
||
{ type: ctx.type, sessionId: ctx.sessionId, trackId: ctx.trackId },
|
||
st.inviteToken,
|
||
);
|
||
setResendCooldown(45);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Could not resend.");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<AuthShell>
|
||
<div className="flex w-full max-w-[354px] flex-col items-center">
|
||
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
|
||
<div className="rounded-xl bg-white">
|
||
<div className="px-6 pb-2 pt-6">
|
||
<div className="flex h-[103px] items-center justify-center rounded-xl border border-[#f2f2f7] px-4">
|
||
{event?.logoUrl ? (
|
||
<img
|
||
src={event.logoUrl}
|
||
alt=""
|
||
className="max-h-[72px] w-full max-w-[240px] object-contain"
|
||
/>
|
||
) : (
|
||
<EventBuzzLogo variant="color" className="max-h-[72px] w-auto object-center" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
<form onSubmit={handleVerify} className="flex flex-col gap-8 px-6 pb-8 pt-2">
|
||
<div className="flex flex-col items-center gap-4 text-center">
|
||
<p className="font-inter text-base font-semibold text-[#24282f]">Enter code</p>
|
||
<div className="min-w-0 space-y-1">
|
||
<p className="font-inter text-xl font-bold text-[#24282f]">{event?.name ?? "Event"}</p>
|
||
</div>
|
||
{schedule ? (
|
||
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-[#24282f]">
|
||
<Calendar className="size-4 shrink-0" strokeWidth={1.75} />
|
||
<span className="font-inter">{schedule}</span>
|
||
{event?.location?.trim() ? (
|
||
<>
|
||
<span className="hidden h-3 w-px bg-[rgba(36,40,47,0.5)] sm:inline" aria-hidden />
|
||
<MapPin className="size-4 shrink-0" strokeWidth={1.75} />
|
||
<span className="font-inter">{event.location.trim()}</span>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4 text-center">
|
||
<div className="rounded-lg border border-dashed border-[#34c759] bg-[#ebffee] px-2 py-2 text-base leading-5 text-[#009951]">
|
||
Code sent to your email
|
||
</div>
|
||
<div className="text-xs text-[rgba(36,40,47,0.87)]">
|
||
<p className="mb-1">Sent to:</p>
|
||
<p className="font-semibold text-[#24282f]">{email}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<p className="font-inter text-base font-semibold text-[#24282f]">Enter OTP</p>
|
||
<div className="flex justify-center gap-2">
|
||
{digits.map((d, i) => (
|
||
<input
|
||
key={i}
|
||
id={`otp-${i}`}
|
||
inputMode="numeric"
|
||
maxLength={1}
|
||
value={d}
|
||
onChange={(e) => setDigit(i, e.target.value)}
|
||
className="size-11 rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] text-center font-inter text-lg font-semibold text-[#24282f]"
|
||
/>
|
||
))}
|
||
</div>
|
||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="w-full rounded-full border border-[rgba(214,200,200,0.6)] bg-[#ef8d1d] py-3 text-xs font-semibold uppercase tracking-wide text-white shadow-[0_4px_4px_rgba(0,0,0,0.15)] disabled:opacity-60"
|
||
>
|
||
{loading ? "Verifying…" : "Verify OTP"}
|
||
</button>
|
||
<div className="flex items-center justify-center gap-4 text-sm">
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate("/login")}
|
||
className="font-inter text-[#1e72ef] underline"
|
||
>
|
||
Change email
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={resendCooldown > 0}
|
||
onClick={() => void handleResend()}
|
||
className="font-inter font-semibold text-[#1e72ef] underline disabled:opacity-40"
|
||
>
|
||
{resendCooldown > 0 ? `Resend (${resendCooldown}s)` : "Resend code"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</AuthShell>
|
||
);
|
||
}
|