diff --git a/src/app/api/send-notification/route.ts b/src/app/api/send-notification/route.ts new file mode 100644 index 0000000..1b2f871 --- /dev/null +++ b/src/app/api/send-notification/route.ts @@ -0,0 +1,66 @@ +import { + SendNotificationRequest, + sendNotificationResponseSchema, +} from "@farcaster/frame-sdk"; +import { NextRequest } from "next/server"; +import { z } from "zod"; + +const requestSchema = z.object({ + token: z.string(), + url: z.string(), + targetUrl: z.string(), +}); + +export async function POST(request: NextRequest) { + const requestJson = await request.json(); + const requestBody = requestSchema.safeParse(requestJson); + + if (requestBody.success === false) { + return Response.json( + { success: false, errors: requestBody.error.errors }, + { status: 400 } + ); + } + + const response = await fetch(requestBody.data.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + notificationId: crypto.randomUUID(), + title: "Hello from Frames v2!", + body: "This is a test notification", + targetUrl: requestBody.data.targetUrl, + tokens: [requestBody.data.token], + } satisfies SendNotificationRequest), + }); + + const responseJson = await response.json(); + + if (response.status === 200) { + // Ensure correct response + const responseBody = sendNotificationResponseSchema.safeParse(responseJson); + if (responseBody.success === false) { + return Response.json( + { success: false, errors: responseBody.error.errors }, + { status: 500 } + ); + } + + // Fail when rate limited + if (responseBody.data.result.rateLimitedTokens.length) { + return Response.json( + { success: false, error: "Rate limited" }, + { status: 429 } + ); + } + + return Response.json({ success: true }); + } else { + return Response.json( + { success: false, error: responseJson }, + { status: 500 } + ); + } +} diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts new file mode 100644 index 0000000..443b426 --- /dev/null +++ b/src/app/api/webhook/route.ts @@ -0,0 +1,72 @@ +import { + eventHeaderSchema, + eventPayloadSchema, + eventSchema, +} from "@farcaster/frame-sdk"; +import { NextRequest } from "next/server"; + +export async function POST(request: NextRequest) { + const requestJson = await request.json(); + + const requestBody = eventSchema.safeParse(requestJson); + + if (requestBody.success === false) { + return Response.json( + { success: false, errors: requestBody.error.errors }, + { status: 400 } + ); + } + + // TODO: verify signature + + const headerData = JSON.parse( + Buffer.from(requestBody.data.header, "base64url").toString("utf-8") + ); + const header = eventHeaderSchema.safeParse(headerData); + if (header.success === false) { + return Response.json( + { success: false, errors: header.error.errors }, + { status: 400 } + ); + } + const fid = header.data.fid; + + const payloadData = JSON.parse( + Buffer.from(requestBody.data.payload, "base64url").toString("utf-8") + ); + const payload = eventPayloadSchema.safeParse(payloadData); + + if (payload.success === false) { + return Response.json( + { success: false, errors: payload.error.errors }, + { status: 400 } + ); + } + + switch (payload.data.event) { + case "frame-added": + console.log( + payload.data.notificationDetails + ? `Got frame-added event for fid ${fid} with notification token ${payload.data.notificationDetails.token} and url ${payload.data.notificationDetails.url}` + : `Got frame-added event for fid ${fid} with no notification details` + ); + break; + case "frame-removed": + console.log(`Got frame-removed event for fid ${fid}`); + break; + case "notifications-enabled": + console.log( + `Got notifications-enabled event for fid ${fid} with token ${ + payload.data.notificationDetails.token + } and url ${payload.data.notificationDetails.url} ${JSON.stringify( + payload.data + )}` + ); + break; + case "notifications-disabled": + console.log(`Got notifications-disabled event for fid ${fid}`); + break; + } + + return Response.json({ success: true }); +} diff --git a/src/components/Demo.tsx b/src/components/Demo.tsx index a44f190..276518d 100644 --- a/src/components/Demo.tsx +++ b/src/components/Demo.tsx @@ -1,5 +1,8 @@ import { useEffect, useCallback, useState } from "react"; -import sdk, { type FrameContext } from "@farcaster/frame-sdk"; +import sdk, { + FrameNotificationDetails, + type FrameContext, +} from "@farcaster/frame-sdk"; import { useAccount, useSendTransaction, @@ -21,6 +24,10 @@ export default function Demo( const [context, setContext] = useState(); const [isContextOpen, setIsContextOpen] = useState(false); const [txHash, setTxHash] = useState(null); + const [addFrameResult, setAddFrameResult] = useState(""); + const [notificationDetails, setNotificationDetails] = + useState(null); + const [sendNotificationResult, setSendNotificationResult] = useState(""); const { address, isConnected } = useAccount(); const { @@ -75,6 +82,60 @@ export default function Demo( sdk.actions.close(); }, []); + const addFrame = useCallback(async () => { + try { + // setAddFrameResult(""); + setNotificationDetails(null); + + const result = await sdk.actions.addFrame(); + + if (result.added) { + if (result.notificationDetails) { + setNotificationDetails(result.notificationDetails); + } + setAddFrameResult( + result.notificationDetails + ? `Added, got notificaton token ${result.notificationDetails.token} and url ${result.notificationDetails.url}` + : "Added, got no notification details" + ); + } else { + setAddFrameResult(`Not added: ${result.reason}`); + } + } catch (error) { + setAddFrameResult(`Error: ${error}`); + } + }, []); + + const sendNotification = useCallback(async () => { + setSendNotificationResult(""); + if (!notificationDetails) { + return; + } + + try { + const response = await fetch("/api/send-notification", { + method: "POST", + mode: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token: notificationDetails.token, + url: notificationDetails.url, + targetUrl: window.location.href, + }), + }); + + if (response.status === 200) { + setSendNotificationResult("Success"); + return; + } + + const data = await response.text(); + setSendNotificationResult(`Error: ${data}`); + } catch (error) { + setSendNotificationResult(`Error: ${error}`); + } + }, [notificationDetails]); + const sendTx = useCallback(() => { sendTransaction( { @@ -181,8 +242,35 @@ export default function Demo( + +
+
+
+              sdk.actions.addFrame
+            
+
+ {addFrameResult && ( +
Add frame result: {addFrameResult}
+ )} + +
+ {notificationDetails && ( +
+

Notify

+ + {sendNotificationResult && ( +
+ Send notification result: {sendNotificationResult} +
+ )} +
+ +
+
+ )} +

Wallet

diff --git a/yarn.lock b/yarn.lock index b511f3e..840d788 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5242,6 +5242,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== + zustand@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.0.tgz#71f8aaecf185592a3ba2743d7516607361899da9"