Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow claiming of tickets without subscription #2187

Merged
merged 5 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 246 additions & 0 deletions apps/passport-client/components/screens/ClaimScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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, useState } from "react";
import { useLocation } from "react-router-dom";
import * as v from "valibot";
import styled from "../../../../packages/lib/passport-ui/src/StyledWrapper";
import { BottomModal } from "../../new-components/shared/BottomModal";
import { Button2 } from "../../new-components/shared/Button";
import { NewLoader } from "../../new-components/shared/NewLoader";
import { Typography } from "../../new-components/shared/Typography";
import { appConfig } from "../../src/appConfig";
import { useCredentialManager, useDispatch } 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<typeof ClaimRequestSchema> {
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 (
<div>
{request.success &&
// Only allow feeds from the Zupass server/Podbox for now.
request.output.feedUrl.startsWith(appConfig.zupassServer) ? (
<QueryClientProvider client={queryClient}>
<ClaimScreenInner feedUrl={request.output.feedUrl} />
</QueryClientProvider>
) : (
<BottomModal
modalContainerStyle={{ padding: 24 }}
isOpen={true}
dismissable={false}
>
<div>Invalid claim link.</div>
</BottomModal>
)}
</div>
);
}

/**
* ClaimScreenInner is the main screen for claiming a ticket.
*
* This appears at /#/claim?type=ticket&url=<feed 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();

// 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<PODTicketPCD | null>(null);
const [folder, setFolder] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 the ticket from the actions. Filter out any non-PODTicketPCDs.
const pcds = actions
.flatMap((action) => action.pcds)
.filter((pcd) => pcd.type === PODTicketPCDTypeName);
if (pcds.length > 0) {
// Deserialize the first PODTicketPCD.
PODTicketPCDPackage.deserialize(pcds[0].pcd).then((pcd) => {
setTicket(pcd);
});
} else {
setError("No ticket found");
}
} else {
setError("No ticket found");
}
} else {
setError("No ticket found");
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrap these in one if/else statement?

}
}, [feedActionsQuery.data]);

const loading = feedActionsQuery.isLoading;

let content = null;

if (complete) {
content = (
<div>
<Typography fontSize={18} fontWeight={800} color="#8B94AC">
CLAIMED
</Typography>
<Spacer h={24} />
<a href="/">
<Button2>Go to Zupass</Button2>
</a>
</div>
);
} else if (loading) {
content = (
<LoaderContainer>
<NewLoader columns={5} rows={5} />
<Typography fontSize={18} fontWeight={800} color="#8B94AC">
LOADING
</Typography>
</LoaderContainer>
);
} else if (feedActionsQuery.error || error) {
content = (
<div>
<p>Unable to load ticket. Please try again later.</p>
<p>Error: {feedActionsQuery.error?.message ?? error}</p>
</div>
);
} else if (ticket && folder) {
content = (
<div>
<div>
<Typography family="Barlow" fontWeight={800} fontSize={20}>
ADD{" "}
<span style={{ color: "var(--core-accent)" }}>
{ticket.claim.ticket.eventName.toLocaleUpperCase()}
</span>{" "}
TO YOUR ZUPASS
</Typography>
</div>
<CardWrapper>
<PCDCard
pcd={ticket}
expanded={true}
hidePadding={true}
hideRemoveButton={true}
/>
</CardWrapper>
<Button2
onClick={async () => {
await dispatch({
type: "add-pcds",
pcds: [await PODTicketPCDPackage.serialize(ticket)],
folder: folder,
upsert: true
});
setComplete(true);
}}
>
Claim
</Button2>
</div>
);
}

return (
<BottomModal
modalContainerStyle={{ padding: 24 }}
isOpen={true}
dismissable={false}
>
{content}
</BottomModal>
);
}

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;
`;
1 change: 1 addition & 0 deletions apps/passport-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions apps/passport-client/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ function RouterImpl(): JSX.Element {
)
);

const LazyClaimScreen = React.lazy(() =>
import("../components/screens/ClaimScreen").then((module) => ({
default: module.ClaimScreen
}))
);

return (
<HashRouter>
<Routes>
Expand Down Expand Up @@ -256,6 +262,22 @@ function RouterImpl(): JSX.Element {
element={<AuthenticateIFrameScreen />}
/>
<Route path="embedded" element={<EmbeddedScreen />} />
<Route
path="claim"
element={
<React.Suspense
fallback={
<AppContainer bg="gray" fullscreen>
<LoaderContainer>
<NewLoader />
</LoaderContainer>
</AppContainer>
}
>
<LazyClaimScreen />
</React.Suspense>
}
/>
<Route path="*" element={<MissingScreen />} />
</Route>
</Routes>
Expand Down
17 changes: 11 additions & 6 deletions packages/ui/pod-ticket-pcd-ui/src/CardBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,17 @@ export function PODTicketCardBodyImpl({
"Unknown"}
</NEW_UI__AttendeeName>
<NEW_UI__ExtraInfoContainer>
{ticketData?.attendeeEmail && (
<NEW_UI__ExtraInfo>{ticketData.attendeeEmail}</NEW_UI__ExtraInfo>
)}
{ticketData?.attendeeEmail && ticketData?.ticketName && (
<NEW_UI__ExtraInfo>•</NEW_UI__ExtraInfo>
)}
{ticketData?.attendeeEmail &&
ticketData?.attendeeEmail !== ticketData?.attendeeName && (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we hiding if email == name?

<>
<NEW_UI__ExtraInfo>
{ticketData.attendeeEmail}
</NEW_UI__ExtraInfo>
{ticketData?.ticketName && (
<NEW_UI__ExtraInfo>•</NEW_UI__ExtraInfo>
)}
</>
)}
{ticketData?.ticketName && (
<NEW_UI__ExtraInfo>{ticketData.ticketName}</NEW_UI__ExtraInfo>
)}
Expand Down
Loading
Loading