diff --git a/apps/passport-client/components/screens/ClaimScreen.tsx b/apps/passport-client/components/screens/ClaimScreen.tsx new file mode 100644 index 0000000000..7a2cad753f --- /dev/null +++ b/apps/passport-client/components/screens/ClaimScreen.tsx @@ -0,0 +1,365 @@ +import { + EdDSATicketPCD, + EdDSATicketPCDPackage, + EdDSATicketPCDTypeName +} from "@pcd/eddsa-ticket-pcd"; +import { + NetworkFeedApi, + PODBOX_CREDENTIAL_REQUEST +} from "@pcd/passport-interface"; +import { ReplaceInFolderAction } from "@pcd/pcd-collection"; +import { + PODTicketPCD, + PODTicketPCDPackage, + PODTicketPCDTypeName +} from "@pcd/pod-ticket-pcd"; +import { + QueryClient, + QueryClientProvider, + useQuery +} from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; +import styled from "styled-components"; +import * as v from "valibot"; +import { BottomModal } from "../../new-components/shared/BottomModal"; +import { Button2 } from "../../new-components/shared/Button"; +import { NewModals } from "../../new-components/shared/Modals/NewModals"; +import { NewLoader } from "../../new-components/shared/NewLoader"; +import { Typography } from "../../new-components/shared/Typography"; +import { appConfig } from "../../src/appConfig"; +import { useCredentialManager, useDispatch, useSelf } from "../../src/appHooks"; +import { Spacer } from "../core"; +import { PCDCard } from "../shared/PCDCard"; + +const ClaimRequestSchema = v.object({ + feedUrl: v.pipe(v.string(), v.url()), + type: v.literal("ticket") +}); + +function validateRequest( + params: URLSearchParams +): v.SafeParseResult { + return v.safeParse(ClaimRequestSchema, Object.fromEntries(params.entries())); +} + +/** + * ClaimScreen is the main screen for claiming a ticket. It validates the request + * and then displays the claim screen. + */ +export function ClaimScreen(): JSX.Element | null { + const location = useLocation(); + const params = new URLSearchParams(location.search); + const request = validateRequest(params); + const queryClient = new QueryClient(); + + return ( +
+ {request.success && + // Only allow feeds from the Zupass server/Podbox for now. + request.output.feedUrl.startsWith(appConfig.zupassServer) ? ( + + + + ) : ( + +
Invalid claim link.
+
+ )} +
+ ); +} + +/** + * ClaimScreenInner is the main screen for claiming a ticket. + * + * This appears at /#/claim?type=ticket&url= + * + * The feed URL should be the URL of a feed on Podbox, as given in the Podbox + * UI. On load, the feed will be polled, and a ticket extracted from the + * actions returned. + * + * A button is shown to allow the user to claim the ticket. + * + * This will only show the first ticket available from the feed. It is not + * designed to handle multiple tickets from the same feed. + */ +export function ClaimScreenInner({ + feedUrl +}: { + feedUrl: string; +}): JSX.Element | null { + const credentialManager = useCredentialManager(); + const dispatch = useDispatch(); + const self = useSelf(); + const initialEmails = useRef(self?.emails); + + // Poll the feed to get the actions for the current user. + // This happens on load, and will send a feed credential to the server. + // As the feed credential contains email addresses, we earlier restrict the + // use of this mechanism to Zupass server/Podbox feeds. + // In the future, we will allow other feeds to be used, but we may want to + // give the user a way to verify that the feed is trusted before making the + // request. + const feedActionsQuery = useQuery({ + queryKey: ["feedActions"], + queryFn: async () => { + return new NetworkFeedApi().pollFeed(feedUrl, { + feedId: feedUrl.split("/").pop() as string, + // Pass in the user's credential to poll the feed. + pcd: await credentialManager.requestCredential( + PODBOX_CREDENTIAL_REQUEST + ) + }); + } + }); + + const [ticket, setTicket] = useState(null); + const [eddsaTicket, setEddsaTicket] = useState< + EdDSATicketPCD | undefined | null + >(null); + const [folder, setFolder] = useState(null); + const [ticketNotFound, setTicketNotFound] = useState(false); + const [error, setError] = useState(null); + const [complete, setComplete] = useState(false); + + useEffect(() => { + // If we have feed actions, we can extract the folder name and the ticket. + if (feedActionsQuery.data) { + if (feedActionsQuery.data.success) { + // Filter out the actions that are not ReplaceInFolder actions. + const actions = feedActionsQuery.data.value.actions.filter( + (action): action is ReplaceInFolderAction => + action.type === "ReplaceInFolder_action" + ); + if (actions.length > 0) { + // Extract the folder name from the first action. + const folderName = actions[0].folder; + setFolder(folderName); + + // Extract PCDs from the actions. + const pcds = actions.flatMap((action) => action.pcds); + + // Filter out the PODTicketPCDs. + const podTicketPcds = pcds.filter( + (pcd) => pcd.type === PODTicketPCDTypeName + ); + + if (podTicketPcds.length > 0) { + // Deserialize the first PODTicketPCD. + PODTicketPCDPackage.deserialize(podTicketPcds[0].pcd) + .then((pcd) => { + setTicket(pcd); + + // Find the EdDSATicketPCD that matches the PODTicketPCD, if + // one exists. + Promise.all( + pcds + .filter((pcd) => pcd.type === EdDSATicketPCDTypeName) + .map((pcd) => EdDSATicketPCDPackage.deserialize(pcd.pcd)) + ).then((tickets) => { + // Will set to 'undefined' if no matching EdDSATicketPCD is + // found. + setEddsaTicket( + tickets.find( + (ticket) => + ticket.claim.ticket.ticketId === + pcd.claim.ticket.ticketId + ) + ); + }); + }) + .catch(() => { + // If this happens then either the PODTicketPCD or + // EdDSATicketPCD failed to deserialize. This is highly + // unlikely to happen, but if it does then we should show an + // error. Reaching this point would indicate that the feed + // contains invalid PCDs, which might be a temporary issue on + // the server side. + setError("Ticket feed contains invalid data."); + }); + } else { + setTicketNotFound(true); + } + } + } + } + }, [feedActionsQuery.data]); + + const loading = feedActionsQuery.isLoading; + + let content = null; + + if (complete) { + content = ( +
+ + CLAIMED + + + + Go to Zupass + +
+ ); + } else if (loading) { + content = ( + + + + LOADING + + + ); + } else if (feedActionsQuery.error || feedActionsQuery.data?.error || error) { + content = ( + +

Unable to load ticket. Please try again later.

+ + Error:{" "} + {feedActionsQuery.error?.message ?? + feedActionsQuery.data?.error ?? + error} + +
+ ); + } else if (ticketNotFound) { + content = ( + +

+ No ticket found for your email address. Check with the event organizer + to ensure that your email is included. +

+

+ Your email addresses are: + + {self?.emails.map((email) => ( + {email} + ))} + + + { + dispatch({ + type: "set-bottom-modal", + modal: { modalType: "help-modal" } + }); + }} + > + Manage my Emails + + {!self?.emails.every( + (email) => initialEmails.current?.includes(email) + ) && ( + <> + + { + window.location.reload(); + }} + > + Reload and try again + + + )} + +

+
+ ); + } else if (ticket && folder) { + content = ( +
+
+ + ADD{" "} + + {ticket.claim.ticket.eventName.toLocaleUpperCase()} + {" "} + TO YOUR ZUPASS + +
+ + + + { + await dispatch({ + type: "add-pcds", + pcds: [ + // There may not be an EdDSATicketPCD; if so, add only the POD + // ticket. + ...(eddsaTicket + ? [await EdDSATicketPCDPackage.serialize(eddsaTicket)] + : []), + await PODTicketPCDPackage.serialize(ticket) + ], + folder: folder, + upsert: false + }); + setComplete(true); + }} + > + Claim + +
+ ); + } + + return ( + // This isn't really a modal, but this is what we do for the other screens + // in the new UX. + // At some point this should be given a more sensible name or be + // refactored. + + {content} + + ); +} + +const CardWrapper = styled.div` + margin: 16px 0px; + border-radius: 8px; + border: 1px solid #e0e0e0; +`; + +const LoaderContainer = styled.div` + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; +`; + +const ClaimError = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const ErrorText = styled.p` + font-size: 14px; +`; + +const EmailList = styled.ul` + margin: 8px 0px; +`; + +const EmailListItem = styled.li` + font-size: 14px; + list-style-type: square; + margin-left: 16px; +`; diff --git a/apps/passport-client/package.json b/apps/passport-client/package.json index 6f61646afd..4e09e16590 100644 --- a/apps/passport-client/package.json +++ b/apps/passport-client/package.json @@ -72,6 +72,7 @@ "@pcd/zk-eddsa-frog-pcd-ui": "0.6.0", "@rollbar/react": "^0.11.1", "@semaphore-protocol/identity": "^3.15.2", + "@tanstack/react-query": "^5.62.7", "@types/react-swipeable-views": "^0.13.5", "boring-avatars": "^1.10.1", "broadcast-channel": "^5.3.0", diff --git a/apps/passport-client/pages/index.tsx b/apps/passport-client/pages/index.tsx index 8fc881a33d..bae8eb16f1 100644 --- a/apps/passport-client/pages/index.tsx +++ b/apps/passport-client/pages/index.tsx @@ -176,6 +176,12 @@ function RouterImpl(): JSX.Element { ) ); + const LazyClaimScreen = React.lazy(() => + import("../components/screens/ClaimScreen").then((module) => ({ + default: module.ClaimScreen + })) + ); + return ( @@ -256,6 +262,22 @@ function RouterImpl(): JSX.Element { element={} /> } /> + + + + + + } + > + + + } + /> } /> diff --git a/packages/ui/pod-ticket-pcd-ui/src/CardBody.tsx b/packages/ui/pod-ticket-pcd-ui/src/CardBody.tsx index f447a97c1f..3b89b8a437 100644 --- a/packages/ui/pod-ticket-pcd-ui/src/CardBody.tsx +++ b/packages/ui/pod-ticket-pcd-ui/src/CardBody.tsx @@ -73,12 +73,17 @@ export function PODTicketCardBodyImpl({ "Unknown"} - {ticketData?.attendeeEmail && ( - {ticketData.attendeeEmail} - )} - {ticketData?.attendeeEmail && ticketData?.ticketName && ( - - )} + {ticketData?.attendeeEmail && + ticketData?.attendeeEmail !== ticketData?.attendeeName && ( + <> + + {ticketData.attendeeEmail} + + {ticketData?.ticketName && ( + + )} + + )} {ticketData?.ticketName && ( {ticketData.ticketName} )} diff --git a/yarn.lock b/yarn.lock index 4f63d0aa0f..fa7f2bf73a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6676,6 +6676,18 @@ lodash.merge "^4.6.2" postcss-selector-parser "6.0.10" +"@tanstack/query-core@5.62.7": + version "5.62.7" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.62.7.tgz#c7f6d0131c08cd2f60e73ec6e7b70e2e9e335def" + integrity sha512-fgpfmwatsrUal6V+8EC2cxZIQVl9xvL7qYa03gsdsCy985UTUlS4N+/3hCzwR0PclYDqisca2AqR1BVgJGpUDA== + +"@tanstack/react-query@^5.62.7": + version "5.62.7" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.62.7.tgz#8f253439a38ad6ce820bc6d42d89ca2556574d1a" + integrity sha512-+xCtP4UAFDTlRTYyEjLx0sRtWyr5GIk7TZjZwBu4YaNahi3Rt2oMyRqfpfVrtwsqY2sayP4iXVCwmC+ZqqFmuw== + dependencies: + "@tanstack/query-core" "5.62.7" + "@tanstack/react-table@^8.11.8": version "8.11.8" resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.11.8.tgz#4eef4a2d91116ca51c8c9b2f00b455d8d99886c7" @@ -21620,9 +21632,9 @@ ts-api-utils@^1.0.1: integrity sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg== ts-api-utils@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.0.tgz#709c6f2076e511a81557f3d07a0cbd566ae8195c" - integrity sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ== + version "1.4.3" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064" + integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== ts-custom-error@^3.2.0: version "3.3.1"