import { useOrderStore } from "@/entities/order";
import { apiClient } from "@/shared/api";
import { pluralTickets } from "@/shared/lib/pluralDays";
import {
fetchSiwePayload,
fetchSiweStatus,
fetchSiweVerify,
} from "@/shared/lib/siwe/api";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog";
import { useQueries } from "@tanstack/react-query";
import { Suspense, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { useActiveAccount } from "thirdweb/react";
import type { Ticket } from "../model/types";
import { TicketCard } from "./TicketCard";
type ShowTicketProps = {
tickets: Ticket[];
hasPassed: boolean;
event: {
id: number;
title: string;
ticketPrice: string;
};
};
export const ShowTicket: React.FC<ShowTicketProps> = ({
tickets,
hasPassed,
event,
}) => {
const navigate = useNavigate();
const { setTicketOrder } = useOrderStore();
const [open, setOpen] = useState(false);
const wasClosed = useRef<boolean>(false);
const account = useActiveAccount();
const [proofToken, setProofToken] = useState<string | null>(null);
const [isSigning, setIsSigning] = useState(false);
const [isTrusted, setIsTrusted] = useState(false);
const shouldPollRedeemStatus = open && !!proofToken;
const dialogFullscreenClass =
// Fullscreen dialog that behaves on mobile browsers (Safari/Chrome address bars).
// `svh` avoids the classic 100vh "behind the browser UI" issue on iOS.
"inset-0 translate-x-0 translate-y-0 w-screen max-w-screen sm:max-w-screen " +
"h-[100svh] max-h-[100svh] " +
"pb-[env(safe-area-inset-bottom)] " +
"flex flex-col z-50 border-black overflow-y-auto";
const ticketQueries = useQueries({
queries: tickets.map((ticket) => ({
queryFn: () =>
apiClient.GET("/api/events/{event_id}/tickets/{ticket_id}", {
params: {
path: {
event_id: ticket.eventId,
ticket_id: Number(ticket.id),
},
// biome-ignore lint/suspicious/noExplicitAny: OpenAPI types mismatch - runtime uses snake_case, types use camelCase
} as any,
}),
select: (data: {
data?: { isRedeemed: boolean; pendingIsRedeemed: boolean };
}) => data.data,
queryKey: ["redeemed", ticket.eventId, ticket.id],
enabled: shouldPollRedeemStatus,
refetchInterval: shouldPollRedeemStatus ? 1000 : -1,
})),
});
const hasError = ticketQueries.some((query) => query.error);
const allData = ticketQueries.map((query) => query.data);
const canCheckRedeemStatus = !hasError && !allData.some((data) => !data);
useEffect(() => {
if (!canCheckRedeemStatus) return;
const anyPendingRedeemed = allData.some(
(data) => data?.pendingIsRedeemed && !data?.isRedeemed,
);
if (anyPendingRedeemed && !wasClosed.current) {
setOpen(false);
wasClosed.current = true;
}
}, [allData, canCheckRedeemStatus]);
useEffect(() => {
if (!open) return;
const addr = account?.address;
if (!addr) {
setIsTrusted(false);
setProofToken(null);
return;
}
// Check server-side trust cookie; avoid forcing SIWE each time.
fetchSiweStatus({ address: addr, scope: "ticket:nonce" })
.then((s) => {
setIsTrusted(s.trusted);
setProofToken(s.trusted ? "cookie" : null);
})
.catch(() => {
setIsTrusted(false);
setProofToken(null);
});
}, [open, account?.address]);
const signToShowQr = async () => {
if (!account) {
alert("No active wallet");
return;
}
setIsSigning(true);
try {
const addr = account.address;
const { payload, message } = await fetchSiwePayload({
address: addr,
purpose: "ticket_qr",
});
const signature = await account.signMessage({ message });
await fetchSiweVerify({ payload, signature });
setIsTrusted(true);
setProofToken("cookie");
} catch (e) {
console.error(e);
alert("Failed to sign");
} finally {
setIsSigning(false);
}
};
const allRedeemed = canCheckRedeemStatus
? allData.every((data) => data?.isRedeemed)
: tickets.every((t) => t.isRedeemed);
const anyPending = canCheckRedeemStatus
? allData.some((data) => data?.pendingIsRedeemed && !data?.isRedeemed)
: tickets.some((t) => t.pendingIsRedeemed && !t.isRedeemed);
function handleBuyMoreClick() {
setTicketOrder({
eventId: event.id,
eventTitle: event.title,
ticketPrice: BigInt(event.ticketPrice),
totalTickets: 1,
allocations: [],
});
navigate("/purchase");
}
const ticketCount = tickets.length;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{allRedeemed ? (
<Button className="w-full" disabled>
Tickets redeemed
</Button>
) : (
<Button className="w-full" disabled={hasPassed}>
{ticketCount === 1
? "Show ticket"
: `Show ${pluralTickets(ticketCount)}`}
</Button>
)}
</DialogTrigger>
<DialogContent
className={dialogFullscreenClass}
aria-describedby={undefined}
>
<DialogTitle className="text-center">
Your Tickets ({ticketCount}){anyPending && <br />}
{anyPending && "(redeem pending)"}
</DialogTitle>
{!isTrusted ? (
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-muted-foreground">
To prevent QR screenshot reuse, tickets use rotating nonces. Sign
a message to prove wallet ownership and show your QR codes.
</p>
<Button
className="w-full max-w-sm"
onClick={signToShowQr}
disabled={isSigning}
>
{isSigning ? "Signing..." : "Sign to show QR codes"}
</Button>
</div>
) : (
<Suspense fallback={null}>
<div
className={`flex-1 grid ${ticketCount === 1 ? "grid-cols-1" : "grid-cols-1 sm:grid-cols-2"} items-center justify-center justify-items-center gap-5 py-4`}
>
{tickets.map((ticket) => (
<TicketCard
key={ticket.id}
ticket={ticket}
userAddress={account?.address || ""}
nonceEnabled={open && isTrusted}
/>
))}
</div>
</Suspense>
)}
<div className="p-4 border-t border-border">
<Button
className="w-full"
onClick={handleBuyMoreClick}
disabled={hasPassed}
>
Buy more tickets
</Button>
</div>
</DialogContent>
</Dialog>
);
};