diff --git a/src/api/functions/tickets.ts b/src/api/functions/tickets.ts index 517335ba..e019366c 100644 --- a/src/api/functions/tickets.ts +++ b/src/api/functions/tickets.ts @@ -32,6 +32,7 @@ export type RawMerchEntry = { scannerEmail?: string; size: string; total_paid?: number; + purchased_at?: number; }; export async function getUserTicketingPurchases({ @@ -135,6 +136,7 @@ export async function getUserMerchPurchases({ refunded: item.refunded, fulfilled: item.fulfilled, totalPaid: item.total_paid, + purchasedAt: item.purchased_at, }); } return issuedTickets; diff --git a/src/api/routes/tickets.ts b/src/api/routes/tickets.ts index ed571eb3..a3e69d61 100644 --- a/src/api/routes/tickets.ts +++ b/src/api/routes/tickets.ts @@ -70,6 +70,10 @@ const ticketEntryZod = z description: "The total amount paid by the customer, in cents, net of refunds.", }), + purchasedAt: z.optional(z.number()).meta({ + description: + "The time at which the user's checkout session completed, in seconds since Epoch.", + }), }) .meta({ description: "An entry describing one merch or tickets transaction.", @@ -297,7 +301,25 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { message: `Retrieving tickets currently only supported on type "merch"!`, }); } - const response = { tickets: issuedTickets }; + const response = { + tickets: issuedTickets.sort((a, b) => { + // Valid tickets first + if (a.valid !== b.valid) { + return a.valid ? -1 : 1; + } + + if (a.purchasedAt === undefined && b.purchasedAt === undefined) { + return 0; + } + if (a.purchasedAt === undefined) { + return 1; + } + if (b.purchasedAt === undefined) { + return -1; + } + return b.purchasedAt - a.purchasedAt; + }), + }; return reply.send(response); }, ); diff --git a/src/ui/pages/tickets/ViewTickets.page.tsx b/src/ui/pages/tickets/ViewTickets.page.tsx index 89c21b11..a21414e9 100644 --- a/src/ui/pages/tickets/ViewTickets.page.tsx +++ b/src/ui/pages/tickets/ViewTickets.page.tsx @@ -11,6 +11,7 @@ import { Stack, TextInput, Alert, + Tooltip, } from "@mantine/core"; import { IconAlertCircle } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; @@ -30,6 +31,7 @@ const purchaseSchema = z.object({ productId: z.string(), quantity: z.number().int().positive(), size: z.string().optional(), + purchasedAt: z.number().optional(), }); const ticketEntrySchema = z.object({ @@ -59,6 +61,13 @@ const getTicketStatus = ( return { status: "unfulfilled", color: "orange" }; }; +const formatPurchaseTime = (timestamp?: number): string => { + if (!timestamp) { + return "N/A"; + } + return new Date(timestamp * 1000).toLocaleString(); +}; + enum TicketsCopyMode { ALL, FULFILLED, @@ -257,6 +266,14 @@ const ViewTicketsPage: React.FC = () => { async function checkInUser(ticket: TicketEntry) { handleOpenConfirmModal(ticket); } + + const copyTicketId = (ticketId: string) => { + navigator.clipboard.writeText(ticketId); + notifications.show({ + message: "Ticket ID copied to clipboard!", + }); + }; + const getTickets = async () => { try { setLoading(true); @@ -335,7 +352,7 @@ const ViewTicketsPage: React.FC = () => {