diff --git a/components/home/ProjectCard.tsx b/components/home/ProjectCard.tsx index 182c49bb..df0493be 100644 --- a/components/home/ProjectCard.tsx +++ b/components/home/ProjectCard.tsx @@ -1,44 +1,204 @@ 'use client' -import type { getRecentFrameList } from '@/lib/frame' +import { BaseInput } from '@/components/shadcn/BaseInput' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@/components/shadcn/ContextMenu' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/shadcn/Dialog' +import { deleteFrame, duplicateFrame, type getRecentFrameList } from '@/lib/frame' +import { Button } from '@/sdk/components' +import { CopyPlusIcon, DeleteIcon, LinkIcon, LoaderIcon } from 'lucide-react' +import { useState } from 'react' +import toast from 'react-hot-toast' export default function ProjectCard({ frame, }: { frame: Awaited>[number] }) { const { id, name, template, interactionCount } = frame + const frameUrl = `${process.env.NEXT_PUBLIC_HOST}/f/${frame.id}` + + const [isDeletingFrame, setIsDeletingFrame] = useState(false) + const [showDeleteFrameModal, setShowDeleteFrameModal] = useState(false) return ( - -
-
- {name} { - e.currentTarget.srcset = '' - e.currentTarget.src = - 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIiB2aWV3Qm94PSIwIDAgMTI0IDEyNCIgZmlsbD0ibm9uZSI+CjxyZWN0IHdpZHRoPSIxMjQiIGhlaWdodD0iMTI0IiByeD0iMjQiIGZpbGw9IiNGOTczMTYiLz4KPHBhdGggZD0iTTE5LjM3NSAzNi43ODE4VjEwMC42MjVDMTkuMzc1IDEwMi44MzQgMjEuMTY1OSAxMDQuNjI1IDIzLjM3NSAxMDQuNjI1SDg3LjIxODFDOTAuNzgxOCAxMDQuNjI1IDkyLjU2NjQgMTAwLjMxNiA5MC4wNDY2IDk3Ljc5NjZMMjYuMjAzNCAzMy45NTM0QzIzLjY4MzYgMzEuNDMzNiAxOS4zNzUgMzMuMjE4MiAxOS4zNzUgMzYuNzgxOFoiIGZpbGw9IndoaXRlIi8+CjxjaXJjbGUgY3g9IjYzLjIxMDkiIGN5PSIzNy41MzkxIiByPSIxOC4xNjQxIiBmaWxsPSJibGFjayIvPgo8cmVjdCBvcGFjaXR5PSIwLjQiIHg9IjgxLjEzMjgiIHk9IjgwLjcxOTgiIHdpZHRoPSIxNy41Njg3IiBoZWlnaHQ9IjE3LjM4NzYiIHJ4PSI0IiB0cmFuc2Zvcm09InJvdGF0ZSgtNDUgODEuMTMyOCA4MC43MTk4KSIgZmlsbD0iI0ZEQkE3NCIvPgo8L3N2Zz4=' - e.currentTarget.style.objectFit = 'contain' - }} - /> -
-
-

{name}

-
-
- {template[0].toUpperCase() + template.slice(1)} + + + + +
+
+ {name} { + e.currentTarget.srcset = '' + e.currentTarget.src = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIiB2aWV3Qm94PSIwIDAgMTI0IDEyNCIgZmlsbD0ibm9uZSI+CjxyZWN0IHdpZHRoPSIxMjQiIGhlaWdodD0iMTI0IiByeD0iMjQiIGZpbGw9IiNGOTczMTYiLz4KPHBhdGggZD0iTTE5LjM3NSAzNi43ODE4VjEwMC42MjVDMTkuMzc1IDEwMi44MzQgMjEuMTY1OSAxMDQuNjI1IDIzLjM3NSAxMDQuNjI1SDg3LjIxODFDOTAuNzgxOCAxMDQuNjI1IDkyLjU2NjQgMTAwLjMxNiA5MC4wNDY2IDk3Ljc5NjZMMjYuMjAzNCAzMy45NTM0QzIzLjY4MzYgMzEuNDMzNiAxOS4zNzUgMzMuMjE4MiAxOS4zNzUgMzYuNzgxOFoiIGZpbGw9IndoaXRlIi8+CjxjaXJjbGUgY3g9IjYzLjIxMDkiIGN5PSIzNy41MzkxIiByPSIxOC4xNjQxIiBmaWxsPSJibGFjayIvPgo8cmVjdCBvcGFjaXR5PSIwLjQiIHg9IjgxLjEzMjgiIHk9IjgwLjcxOTgiIHdpZHRoPSIxNy41Njg3IiBoZWlnaHQ9IjE3LjM4NzYiIHJ4PSI0IiB0cmFuc2Zvcm09InJvdGF0ZSgtNDUgODEuMTMyOCA4MC43MTk4KSIgZmlsbD0iI0ZEQkE3NCIvPgo8L3N2Zz4=' + e.currentTarget.style.objectFit = 'contain' + }} + /> +
+
+

{name}

+
+
+ {template[0].toUpperCase() + template.slice(1)} +
+
+ {interactionCount === 0 + ? 'No taps' + : `${interactionCount} taps`} +
+
+
-
- {interactionCount === 0 ? 'No taps' : `${interactionCount} taps`} + + + + { + navigator.clipboard.writeText(frameUrl) + toast.success('Frame Url copied!') + }} + > + + Copy Frame URL + + { + try { + await duplicateFrame(id) + toast.success(`${name} has been duplicated successfully`) + } catch { + toast.error(`Failed to duplicate ${name}`) + } + }} + > + + Duplicate + + + + + Delete + + + + + + + Delete {frameUrl} + + Warning: Are you absolutely sure? Deleting this frame will remove all of its + config and state. This action cannot be undone – proceed with caution. + + +
{ + e.preventDefault() + setIsDeletingFrame(true) + try { + await deleteFrame(id) + toast.success('Frame deleted successfully!') + } catch (e) { + const error = e as Error + toast.error(error.message) + } finally { + setIsDeletingFrame(false) + setShowDeleteFrameModal(false) + } + }} + > +
+
+ +
+ +
-
-
- + + + + + + ) } + +// function DeleteFrameModal(frameId: string) { +// return ( +// +// +// +// +// +// +// Share link +// +// Anyone who has this link will be able to view this. +// +// +//
+//
+// +// +//
+// +//
+// +// +// +// +// +//
+//
+// ) +// } diff --git a/components/shadcn/ContextMenu.tsx b/components/shadcn/ContextMenu.tsx new file mode 100644 index 00000000..5eb39f6d --- /dev/null +++ b/components/shadcn/ContextMenu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/shadcn" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/lib/frame.ts b/lib/frame.ts index a2d8d8c6..dc52ee2c 100644 --- a/lib/frame.ts +++ b/lib/frame.ts @@ -72,6 +72,30 @@ export async function getFrame(id: string) { return frame } +export async function duplicateFrame(id: string) { + const sesh = await auth() + + if (!sesh?.user) { + notFound() + } + + const frame = await getFrame(id) + + const args: InferInsertModel = { + owner: sesh.user.id!, + name: `${frame.name} Copy`, + description: frame.name, + config: templates[frame.template].initialConfig, + draftConfig: frame.draftConfig, + storage: {}, + template: frame.template, + } + + await client.insert(frameTable).values(args).run() + + revalidatePath('/') +} + export async function createFrame({ name, description, diff --git a/lib/gating.ts b/lib/gating.ts index e5c467ed..d57adadc 100644 --- a/lib/gating.ts +++ b/lib/gating.ts @@ -12,6 +12,95 @@ const ERC1155_ABI = parseAbi([ 'function balanceOf(address _owner, uint256 _id) public view returns (uint256)', ]) +async function getMoxieUserVestingAddresses(beneficiary: string[]) { + if (beneficiary.length === 0) { + return [] + } + + try { + const GET_VESTING_ADDRESSES = `query UserVestingAddresses($beneficiaryAddresses: [Bytes!]) { + tokenLockWallets(where: { beneficiary_in: $beneficiary }) { + address:id + } + }` + + const variables = { + beneficiary, + } + const response = await fetch( + 'https://api.studio.thegraph.com/query/23537/moxie_vesting_mainnet/version/latest', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query: GET_VESTING_ADDRESSES, variables }), + } + ) + + const json = (await response.json()) as { + data: { tokenLockWallets: { address: string }[] } + } + + if (!json.data?.tokenLockWallets) { + return [] + } + const vestingAddresses = json.data.tokenLockWallets.map((wallet) => wallet.address) + return vestingAddresses + } catch { + return [] + } +} +async function checkMoxieFanToken(userAddresses: string[], tokenSymbol: string, minAmount = 1) { + if (userAddresses.length === 0) { + return + } + + try { + const GET_BALANCES = `query GetBalances($userAddresses: [ID!], $fanTokenAddress: String) { + users(where: { id_in: $userAddresses }) { + portfolio(where: { subjectToken: $fanTokenAddress }) { + balance + } + } + }` + + const [symbol, fanTokenAddress] = tokenSymbol.split(',') + + const variables = { + userAddresses, + fanTokenAddress, + } + const response = await fetch( + 'https://api.studio.thegraph.com/query/23537/moxie_protocol_stats_mainnet/version/latest', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query: GET_BALANCES, variables }), + } + ) + + const json = (await response.json()) as { + data: { users: { portfolio: { balance: string }[] }[] } + } + + const isPositive = json.data.users.some((user) => + user.portfolio.some((folio) => Number(folio.balance) >= minAmount * 10 ** 18) + ) + if (isPositive) { + return + } + throw new Error(`FT_ERROR:You must have at least ${minAmount} of ${symbol} fanToken`) + } catch (e) { + const error = e as Error + const message = error.message.startsWith('FT_ERROR:') + ? error.message.replace('FT_ERROR:', '') + : 'Failed to fetch Moxie FanToken data' + throw new FrameError(message) + } +} async function checkOpenRankScore(fid: number, owner: number, score: number) { const url = `https://graph.cast.k3l.io/scores/personalized/engagement/fids?k=${score}&limit=1000&lite=true` @@ -142,61 +231,49 @@ async function checkFid(fid: number, minFid: number, maxFid: number) { } } -async function checkLiked(body: { - validatedData: { cast: { viewer_context: { liked: boolean } } } -}) { - if (!body.validatedData.cast.viewer_context.liked) { +async function checkLiked(body: FramePayloadValidated) { + if (!body.cast.viewer_context?.liked) { throw new FrameError('You must like this frame.') } } -async function checkRecasted(body: { - validatedData: { cast: { viewer_context: { recasted: boolean } } } -}) { - if (!body.validatedData.cast.viewer_context.recasted) { +async function checkRecasted(body: FramePayloadValidated) { + if (!body.cast.viewer_context?.recasted) { throw new FrameError('You must recast this frame.') } } -async function checkFollowingMe(body: { - validatedData: { interactor: { viewer_context: { following: boolean } } } -}) { - if (!body.validatedData.interactor.viewer_context.following) { +async function checkFollowingMe(body: FramePayloadValidated) { + if (!body.interactor.viewer_context?.following) { throw new FrameError('You must follow the creator.') } } -async function checkFollowedByMe(body: { - validatedData: { interactor: { viewer_context: { followed_by: boolean } } } -}) { - if (!body.validatedData.interactor.viewer_context.followed_by) { +async function checkFollowedByMe(body: FramePayloadValidated) { + if (!body.interactor.viewer_context?.followed_by) { throw new FrameError('You must be followed by the creator.') } } -async function checkEthWallet(body: { - validatedData: { interactor: { verified_addresses: { eth_addresses: string[] } } } -}) { - if (!body.validatedData.interactor.verified_addresses.eth_addresses.length) { +async function checkEthWallet(body: FramePayloadValidated) { + if (!body.interactor.verified_addresses.eth_addresses.length) { throw new FrameError('You must have an Ethereum wallet.') } } -async function checkSolWallet(body: { - validatedData: { interactor: { verified_addresses: { sol_addresses: string[] } } } -}) { - if (!body.validatedData.interactor.verified_addresses.sol_addresses.length) { +async function checkSolWallet(body: FramePayloadValidated) { + if (!body.interactor.verified_addresses.sol_addresses.length) { throw new FrameError('You must have a Solana wallet.') } } const keyToValidator: Record< (typeof GATING_ALL_OPTIONS)[number], - (requirements: GatingRequirementsType, body: any) => Promise + (requirements: GatingRequirementsType, body: FramePayloadValidated) => Promise > = { channels: async (requirements, body) => { for (const channel of requirements['channels']!) { - await checkChannelMembership(body.validatedData.interactor.fid, channel) + await checkChannelMembership(body.interactor.fid, channel) } }, followedByMe: async (_requirements, body) => await checkFollowedByMe(body), @@ -206,18 +283,18 @@ const keyToValidator: Record< eth: async (_requirements, body) => await checkEthWallet(body), sol: async (_requirements, body) => await checkSolWallet(body), minFid: async (requirements, body) => - await checkFid(body.validatedData.interactor.fid, requirements['minFid']!, 0), + await checkFid(body.interactor.fid, requirements['minFid']!, 0), maxFid: async (requirements, body) => - await checkFid(body.validatedData.interactor.fid, 0, requirements['maxFid']!), + await checkFid(body.interactor.fid, 0, requirements['maxFid']!), exactFids: async (requirements, body) => { for (const fid of requirements['exactFids']!) { - await checkFid(body.validatedData.interactor.fid, fid, fid) + await checkFid(body.interactor.fid, fid, fid) } }, erc20: async (requirements, body) => { for (const token of requirements['erc20']!) { await checkOwnsErc20( - body.validatedData.interactor.verified_addresses.eth_addresses, + body.interactor.verified_addresses.eth_addresses, token.network, token.address, token.symbol, @@ -228,7 +305,7 @@ const keyToValidator: Record< erc721: async (requirements, body) => { for (const token of requirements['erc721']!) { await checkOwnsErc721( - body.validatedData.interactor.verified_addresses.eth_addresses, + body.interactor.verified_addresses.eth_addresses, token.network, token.address, token.symbol, @@ -239,7 +316,7 @@ const keyToValidator: Record< erc1155: async (requirements, body) => { for (const token of requirements['erc1155']!) { await checkOwnsErc1155( - body.validatedData.interactor.verified_addresses.eth_addresses, + body.interactor.verified_addresses.eth_addresses, token.network, token.address, token.symbol, @@ -250,10 +327,23 @@ const keyToValidator: Record< }, score: async (requirements, body) => await checkOpenRankScore( - body.validatedData.interactor, + body.interactor.fid, requirements['score']!.owner, requirements['score']!.score ), + moxie: async (requirements, body) => { + const userAddresses = body.interactor.verified_addresses.eth_addresses + for (const token of requirements['moxie']!) { + // NOTE(copied from moxie dev docs): Moxie protocol users have Moxie token currently vesting in their vesting contract. A huge portion of them use these to bid on auctions and buy/sell fan tokens on the Moxie protocol. + // Therefore, it is important that you include the user's vesting addresses in the query to get all the fan tokens owned by a certain user. + const userVestingAddresses = await getMoxieUserVestingAddresses(userAddresses) + await checkMoxieFanToken( + [...userAddresses, ...userVestingAddresses], + `${token.symbol},${token.address}`, + token.balance + ) + } + }, } export async function runGatingChecks( diff --git a/package-lock.json b/package-lock.json index b28e4c6b..1b0cd547 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", - "@radix-ui/react-context-menu": "^2.1.5", + "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-hover-card": "^1.0.7", @@ -4555,23 +4555,267 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.1.5.tgz", - "integrity": "sha512-R5XaDj06Xul1KGb+WP8qiOh7tKJNz2durpLBXAGZjSVtctcRFCuEvy2gtMwRJGePwQQE5nV77gs4FwRi8T+r2g==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.1.tgz", + "integrity": "sha512-wvMKKIeb3eOrkJ96s722vcidZ+2ZNfcYZWBPRHIB1VWrF+fiF851Io6LX0kmK5wTDQFKdulCCKJk2c3SBaQHvA==", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-menu": "2.0.6", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-controllable-state": "1.0.1" + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-menu": "2.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-arrow": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", + "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", + "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz", + "integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", + "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.1.tgz", + "integrity": "sha512-oa3mXRRVjHi6DZu/ghuzdylyjaMXLymx83irM7hTxutQbD+7IhPKdMdRHD26Rm+kHRrWcrUkkRPv5pd47a2xFQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.0", + "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.1", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-popper": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", + "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -4582,6 +4826,246 @@ } } }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-portal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", + "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-presence": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", + "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" + }, + "node_modules/@radix-ui/react-context-menu/node_modules/react-remove-scroll": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", + "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.4", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", diff --git a/package.json b/package.json index 6005f10f..44c28fa4 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", - "@radix-ui/react-context-menu": "^2.1.5", + "@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-hover-card": "^1.0.7", diff --git a/sdk/components/gating/GatingInspector.tsx b/sdk/components/gating/GatingInspector.tsx index 548ce117..7f6fd0b7 100644 --- a/sdk/components/gating/GatingInspector.tsx +++ b/sdk/components/gating/GatingInspector.tsx @@ -184,6 +184,24 @@ export function GatingInspector({ fid: string disabledOptions?: typeof GATING_ALL_OPTIONS }) { + // the frame preview's page breaks when an option has been enabled no requirements + // eg: existing frames built on cal template + if (config?.enabled && !config?.requirements) { + config = { + ...config, + requirements: { + maxFid: 0, + minFid: 0, + score: undefined, + channels: [], + exactFids: [], + erc20: undefined, + erc721: undefined, + erc1155: undefined, + moxie: undefined, + }, + } + } const [enabledOptions, setEnabledOptions] = useState< Record<(typeof GATING_ALL_OPTIONS)[number], boolean> >({ @@ -201,6 +219,7 @@ export function GatingInspector({ erc721: Boolean(config?.enabled.includes('erc721')), erc1155: Boolean(config?.enabled.includes('erc1155')), erc20: Boolean(config?.enabled.includes('erc20')), + moxie: Boolean(config?.enabled.includes('moxie')), }) const options = useMemo< @@ -424,6 +443,160 @@ export function GatingInspector({
), }, + { + key: 'moxie', + label: 'Must hold Moxie FanToken(s)', + children: ( +
+ {Boolean(config?.requirements?.moxie?.length) && ( + + + + Symbol + Address + Min Balance + Actions + + + + {config?.requirements?.moxie?.map((group) => ( + + + {group.symbol} + + + {group.address} + + {group.balance} + + + + + ))} + +
+ )} +
{ + e.preventDefault() + const symbol = e.currentTarget.symbol.value + const balance = Number(e.currentTarget.balance.value) || 1 + let address: string | undefined + if ( + config?.requirements?.moxie?.find( + (c) => c.symbol === symbol.toLowerCase() + ) + ) { + toast.error(`${symbol} has already been added!`) + return + } + + try { + const GET_SYMBOL = `query TokenAddressFromSymbol($fanTokenSymbol: String!) { + subjectTokens(where: { symbol: $fanTokenSymbol }) { + address:id + } + }` + const response = await fetch( + 'https://api.studio.thegraph.com/query/23537/moxie_protocol_stats_mainnet/version/latest', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: GET_SYMBOL, + variables: { fanTokenSymbol: symbol }, + }), + } + ) + const json = (await response.json()) as { + data: { subjectTokens: { address: string }[] } + } + const tokenAddress = json.data?.subjectTokens?.[0]?.address + if (!tokenAddress) { + toast.error(`${symbol} is an invalid fan token symbol!`) + + return + } + + address = tokenAddress + } catch { + toast.error( + 'Could not fetch fan token data, are you sure this is a valid symbol?' + ) + return + } + onUpdate({ + requirements: { + ...(config?.requirements || {}), + moxie: [ + ...(config?.requirements.moxie || []), + { + address, + symbol, + balance, + }, + ], + }, + }) + + e.currentTarget.reset() + }} + > +
+ + +
+
+ + +
+ +
+
+ ), + }, { key: 'erc721', label: 'Must hold ERC-721 NFT(s)', diff --git a/sdk/components/gating/constants.ts b/sdk/components/gating/constants.ts index 753f4291..f9cc8d29 100644 --- a/sdk/components/gating/constants.ts +++ b/sdk/components/gating/constants.ts @@ -16,7 +16,8 @@ export const GATING_ADVANCED_OPTIONS = [ 'erc20', 'erc721', 'erc1155', + 'moxie', // 'followedBy', // 'following', ] -export const GATING_ALL_OPTIONS = [...GATING_SIMPLE_OPTIONS, ...GATING_ADVANCED_OPTIONS] \ No newline at end of file +export const GATING_ALL_OPTIONS = [...GATING_SIMPLE_OPTIONS, ...GATING_ADVANCED_OPTIONS] diff --git a/sdk/components/gating/types.d.ts b/sdk/components/gating/types.d.ts index 19589039..1f0c6bc1 100644 --- a/sdk/components/gating/types.d.ts +++ b/sdk/components/gating/types.d.ts @@ -17,9 +17,10 @@ export type GatingRequirementsType = { erc20?: GatingErcType[] | undefined erc721?: GatingErcType[] | undefined erc1155?: GatingErcType[] | undefined + moxie?: { symbol: string; balance: number; address: string }[] | undefined } export type GatingType = { enabled: string[] requirements: GatingRequirementsType -} \ No newline at end of file +} diff --git a/templates/beehiiv/Inspector.tsx b/templates/beehiiv/Inspector.tsx new file mode 100644 index 00000000..0915b6f9 --- /dev/null +++ b/templates/beehiiv/Inspector.tsx @@ -0,0 +1,197 @@ +'use client' +import { ColorPicker, Input } from '@/sdk/components' +import { useFrameConfig } from '@/sdk/hooks' +import { Configuration } from '@/sdk/inspector' +import { LoaderIcon } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { toast } from 'react-hot-toast' +import type { Config } from '.' +import getBeehiivArticle from './utils' + +export default function Inspector() { + const [config, updateConfig] = useFrameConfig() + const [loading, setLoading] = useState(false) + + const urlInputRef = useRef(null) + const imgSizeInputRef = useRef(null) + const pagesFontSizeInputRef = useRef(null) + const textPositionOverlayRef = useRef(null) + const linkOnAllPagesRef = useRef(null) + const hideTitleAuthorRef = useRef(null) + + // keep the url input updated with the article URL + useEffect(() => { + if (!urlInputRef.current) return + if (!urlInputRef.current.value) return + + urlInputRef.current.value = config.article?.url ?? '' + }, [config.article]) + + // handler for the article URL input + + const urlInputHandler = async (url: string) => { + if (url === config.article?.url) return + if (url === '') { + updateConfig({ article: null }) + return + } + + if (!/^(https?:\/\/[^\s]+)/.test(url)) { + toast.error('Please enter a valid beehiv article URL') + return + } + + try { + setLoading(true) + const newArticle = await getBeehiivArticle(url) + updateConfig({ article: newArticle }) + console.log(newArticle) + toast.success('Successfully fetched the beehiv article') + } catch (e) { + console.error('beehiiv', e) + toast.error('Please enter a valid beehiv article URL') + } finally { + setLoading(false) + } + } + + return ( + + +
+ urlInputHandler(e.target.value)} + /> + {loading && } +
+
+ + + + + + + +
+

Background Color

+ updateConfig({ coverBgColor: value })} + /> +
+
+

Text Color

+ updateConfig({ coverTextColor: value })} + /> +
+
+

Image Size

+ updateConfig({ imageSize: imgSizeInputRef.current?.value })} + /> +

+ Enter a percent value and test (frame size limit is 256kb!) +

+
+
+ +
+

Background Color

+ updateConfig({ pagesBgColor: value })} + /> +
+
+

Text Color

+ updateConfig({ pagesTextColor: value })} + /> +
+
+

Font Size

+ + updateConfig({ pagesFontSize: pagesFontSizeInputRef.current?.value }) + } + /> +
+
+
+ ) +} diff --git a/templates/beehiiv/cover.avif b/templates/beehiiv/cover.avif new file mode 100644 index 00000000..e58bd375 Binary files /dev/null and b/templates/beehiiv/cover.avif differ diff --git a/templates/beehiiv/handlers/index.ts b/templates/beehiiv/handlers/index.ts new file mode 100644 index 00000000..b38e28a1 --- /dev/null +++ b/templates/beehiiv/handlers/index.ts @@ -0,0 +1,7 @@ +import initial from './initial' +import page from './page' + +export default { + initial, + page, +} diff --git a/templates/beehiiv/handlers/initial.ts b/templates/beehiiv/handlers/initial.ts new file mode 100644 index 00000000..0a6cd8a5 --- /dev/null +++ b/templates/beehiiv/handlers/initial.ts @@ -0,0 +1,28 @@ +'use server' +import type { BuildFrameData } from '@/lib/farcaster' +import { loadGoogleFontAllVariants } from '@/sdk/fonts' +import type { Config } from '..' +import CoverView from '../views/Cover' + +export default async function initial({ config }: { config: Config }): Promise { + const georgia = await loadGoogleFontAllVariants('Georgia') + + return { + buttons: [ + { + label: 'Read →', + }, + ], + aspectRatio: '1:1', + fonts: georgia, + component: CoverView({ + article: config.article, + bgColor: config.coverBgColor, + textColor: config.coverTextColor, + imageSize: config.imageSize, + textPosition: config.textPosition, + hideTitleAuthor: config.hideTitleAuthor, + }), + handler: 'page', + } +} diff --git a/templates/beehiiv/handlers/page.ts b/templates/beehiiv/handlers/page.ts new file mode 100644 index 00000000..09bd4f4a --- /dev/null +++ b/templates/beehiiv/handlers/page.ts @@ -0,0 +1,72 @@ +'use server' +import type { BuildFrameData, FrameButtonMetadata, FramePayloadValidated } from '@/lib/farcaster' +import { loadGoogleFontAllVariants } from '@/sdk/fonts' +import type { Config } from '..' +import PageView from '../views/Page' +import initial from './initial' + +export default async function page({ + body, + config, + params, +}: { + body: FramePayloadValidated + config: Config + params: any +}): Promise { + const nextPage = + params?.currentPage !== undefined + ? body.tapped_button.index === 1 + ? Number(params?.currentPage) - 1 + : Number(params?.currentPage) + 1 + : 1 + + const slideCount = (config.article?.pages ?? []).length || 1 + + //console.log('slide count', slideCount) + + const buttons: FrameButtonMetadata[] = [ + { + label: '←', + }, + ] + + if (nextPage < slideCount) { + buttons.push({ + label: '→', + }) + } + + if (config.article?.url && (config.showLinkOnAllPages || nextPage === slideCount)) { + buttons.push({ + label: 'Beehiiv', + action: 'link', + target: config.article.url, + }) + } + + if (body.tapped_button.index === 1 && nextPage === 0) { + return initial({ config }) + } + + const page = config.article?.pages[nextPage - 1] + const georgia = await loadGoogleFontAllVariants('Georgia') + + return { + buttons: buttons, + aspectRatio: '1:1', + fonts: georgia, + component: PageView({ + page: page || [], + currentPage: nextPage, + slideCount: slideCount, + pagesBgColor: config.pagesBgColor, + pagesTextColor: config.pagesTextColor, + pagesFontSize: config.pagesFontSize, + }), + handler: 'page', + params: { + currentPage: nextPage, + }, + } +} diff --git a/templates/beehiiv/icon.avif b/templates/beehiiv/icon.avif new file mode 100644 index 00000000..b665c599 Binary files /dev/null and b/templates/beehiiv/icon.avif differ diff --git a/templates/beehiiv/index.ts b/templates/beehiiv/index.ts new file mode 100644 index 00000000..95b90a72 --- /dev/null +++ b/templates/beehiiv/index.ts @@ -0,0 +1,37 @@ +import type { BaseConfig, BaseStorage, BaseTemplate } from '@/lib/types' +import Inspector from './Inspector' +import cover from './cover.avif' +import handlers from './handlers' +import icon from './icon.avif' +import type { BeehiivArticle } from './utils' + +export interface Config extends BaseConfig { + article?: BeehiivArticle + coverBgColor: string + coverTextColor: string + imageSize: number + textPosition: boolean + showLinkOnAllPages: boolean + hideTitleAuthor: boolean + pagesBgColor: string + pagesTextColor: string + pagesFontSize: number +} + +export interface Storage extends BaseStorage {} + +export default { + name: 'Beehiiv', + description: 'Convert any Beehiiv article into a Farcaster Frame.', + shortDescription: 'Beehiiv article to Frame', + octicon: 'log', + icon: icon, + creatorFid: '260812', + creatorName: 'Steve', + enabled: true, + Inspector, + handlers, + cover, + initialConfig: {}, + events: [], +} satisfies BaseTemplate diff --git a/templates/beehiiv/utils.ts b/templates/beehiiv/utils.ts new file mode 100644 index 00000000..c6d79133 --- /dev/null +++ b/templates/beehiiv/utils.ts @@ -0,0 +1,254 @@ +import { corsFetch } from '@/sdk/scrape' + +// metadata tags pulled from the medium article html +interface BeehiivMetadata { + author: string | null + title: string | null + subtitle: string | null + image: string | null +} + +// One paragraph, heading, or image tag and its contents +type BeehiivElement = { + tag: string + text: string + src?: string +} + +// A page (frame) will be made up of an array of elements +export type BeehiivPage = BeehiivElement[] + +// Our frame-adapted medium article to be passed back to the inspector +export type BeehiivArticle = { + pages: BeehiivPage[] + url: string + metadata: BeehiivMetadata +} + +// Entry function to pass in a medium article URL and return an Article to the inspector +export async function getBeehiivArticle(url: string): Promise { + console.log('fetching beehiiv article', url) + const response = await corsFetch(`${url}?_data=routes%2Fp%2F%24slug`) + if (!response) throw new Error('Invalid beehiv article') + + const data = JSON.parse(response) as { + post: { + web_title: string + web_subtitle: string + post_date: string + image_url: string + } + html: string + publication: { + name: string + authors: [ + { + name: string + profile_picture: { url: string } + }, + ] + } + truncatedHtml: string + requestUrl: string + } + + const requestBaseUrl = new URL(data.requestUrl) + const articleBaseUrl = new URL(url) + const requestUrl = `${requestBaseUrl.host}${requestBaseUrl.pathname}` + const articleUrl = `${articleBaseUrl.host}${articleBaseUrl.pathname}` + + if (requestUrl !== articleUrl) { + throw new Error('Invalid beehiv article') + } + + const metadata: BeehiivMetadata = { + author: data.publication.name, + title: data.post.web_title, + subtitle: data.post.web_subtitle, + image: data.post.image_url, + } + const extractedTags: BeehiivElement[] = extractTagsFromHTML(data.truncatedHtml, metadata) + + const article = { pages: paginateElements(extractedTags, metadata), url, metadata } // character limit + return article +} + +// Attempts to pull out only the relevant content from the HTML - paragraph, heading, and image tags, stripped of attributes +function extractTagsFromHTML(htmlContent: string, metadata: BeehiivMetadata): BeehiivElement[] { + const tempDiv = document.createElement('article') + tempDiv.innerHTML = htmlContent + + // Get the relevant child elements from the article tag + const article = tempDiv.querySelector('#content-blocks') + const elements = article?.querySelectorAll('p, h1, h2, h3, h4, h5, h6, img') + + if (!elements) return [] + + const elementsArray: HTMLElement[] = Array.from(elements) as HTMLElement[] + + // Filter images - remove images that match the author, publication, or cover image, or if they're really small + const indexesToRemove: number[] = [] + elementsArray.forEach((element, index) => { + if (element.tagName === 'IMG') { + const alt = element.getAttribute('alt') + const src = element.getAttribute('src') + const width = Number.parseInt(element.getAttribute('width') || '') || 0 + if ( + width < 300 || + alt === 'Top highlight' || + alt === metadata.author || + (src && + metadata.image && + getFilenameFromUrl(src) == getFilenameFromUrl(metadata.image)) + ) { + // keep track of indexes to remove + indexesToRemove.push(index) + } + } + }) + // remove elements without iterating the same array + for (let i = indexesToRemove.length - 1; i >= 0; i--) { + elementsArray.splice(indexesToRemove[i], 1) + } + + // Clean elements so we only have the tag and its contents, and src if it's an image + const cleanedElements: BeehiivElement[] = elementsArray + .map((e) => { + return { + tag: e.tagName, + text: e.textContent?.trim() || '', + src: e.getAttribute('src') || '', + } + }) + .filter((e) => e.text.length > 0 || e.src.length > 0) + + return cleanedElements +} + +// Function to estimate the number of chars per line for each element based on its type +function estimateElementCharsAndLines(element: BeehiivElement): number { + const linesPerType: any = { + 'p': 80, + 'h1': 40, + 'h2': 60, + 'h3': 70, + 'h4': 70, + 'h5': 70, + 'other': 80, + } + + return linesPerType[element.tag] || linesPerType['other'] +} + +function paginateElements(elements: BeehiivElement[], metadata: BeehiivMetadata): BeehiivPage[] { + // ignore elements if their text is equal to certain keywords including author, 'Follow', 'Listen', 'Share', or just a number + const filteredElements = elements.filter((element) => { + const text = element.text.trim() + return !( + [ + 'Follow', + 'Listen', + 'Share', + metadata.author, + metadata.title, + 'About', + 'Contact', + 'Sign Up', + 'Subscribe', + ' | ', + 'Subscribe now', + ' View more posts → ', + ' View more new products → ', + 'Top highlight', + 'Advertise', + `Discover more from ${metadata.author}`, + 'beehiv.com', + ].includes(text) || text.match(/^\d+(\.\d+)?[KMB]?$/) + ) + }) + + // Split elements that are too long + const chunkedElements: BeehiivElement[] = filteredElements.reduce( + (acc: BeehiivElement[], currentElement) => { + const charsPerLine = estimateElementCharsAndLines(currentElement) + const charsPerPage = charsPerLine * 24 // 24 lines per page + if (currentElement.text.length > charsPerPage) { + const newElements: BeehiivElement[] = splitElement(currentElement, charsPerPage) + acc.push(...newElements) + } else { + acc.push(currentElement) + } + return acc + }, + [] + ) + + const pages: BeehiivPage[] = [] + let currentPage: BeehiivPage = [] + let currentPageLinesRemaining = 24 // 24 lines per page + + for (const currentElement of chunkedElements) { + const currentElementEstimatedCharsPerLine = estimateElementCharsAndLines(currentElement) + const currentElementLinesRequired = currentElement.src + ? 15 // images take up 15 lines + : currentElement.text.length / currentElementEstimatedCharsPerLine + 2 + + // If this element will exceed the lines remaining, then create a new page + if (currentPageLinesRemaining - currentElementLinesRequired <= 0) { + pages.push(currentPage) + currentPageLinesRemaining = 24 + currentPage = [] + } + + currentPage.push(currentElement) + currentPageLinesRemaining -= currentElementLinesRequired + } + + // Add the last page if it contains any elements + if (currentPage.length > 0) { + pages.push(currentPage) + } + + return pages +} + +// splits an element into multiple elements based on a character limit +function splitElement(element: BeehiivElement, charLimit: number): BeehiivElement[] { + const { text } = element + + let currentLength = 0 + const newElements: BeehiivElement[] = [] + const words = text.split(' ') + let currentText = '' + + for (const word of words) { + const wordLength = word.length + + if (currentLength + wordLength > charLimit) { + // Add the current text to the current page + newElements.push({ tag: element.tag, text: currentText }) + + // Reset the current text and length + currentText = '' + currentLength = 0 + } + + currentText += word + ' ' + currentLength += wordLength + } + + // Add the last element + newElements.push({ tag: element.tag, text: currentText }) + + return newElements +} + +// Function to extract metadata from the provided HTML content + +// Helper function to extract the filename from a URL +function getFilenameFromUrl(url: string) { + if (!url) return '' + return url.substring(url.lastIndexOf('/') + 1) +} + +export default getBeehiivArticle diff --git a/templates/beehiiv/views/Cover.tsx b/templates/beehiiv/views/Cover.tsx new file mode 100644 index 00000000..5be17f56 --- /dev/null +++ b/templates/beehiiv/views/Cover.tsx @@ -0,0 +1,191 @@ +import type { BeehiivArticle } from '../utils' + +export default function CoverView({ + article, + bgColor, + textColor, + imageSize, + textPosition, + hideTitleAuthor, +}: { + article?: BeehiivArticle + bgColor?: string + textColor?: string + imageSize: number + textPosition: boolean + hideTitleAuthor: boolean +}) { + let titleFontSize = '40px' + let authorFontSize = '20px' + console.log('old size >> metadata', article?.metadata) + if ( + article && + (article?.metadata?.title?.length ?? 0) + (article?.metadata?.author?.length ?? 0) > 80 + ) { + console.log('new sizes') + titleFontSize = '25px' + authorFontSize = '20px' + } + + return ( +
+ {article?.metadata.image && ( +
+ . +
+ )} + { + // image is overlaid with text + textPosition && !hideTitleAuthor && ( +
+ + {article?.metadata.title ?? ''} + + + {article?.metadata.subtitle} + + {article?.metadata.author ? ( +
+ {/*
{post.pubDate}
*/} + +
+ {article.metadata.author} +
+
+ ) : ( + + Paste your beehiv article link into the URL input + + )} +
+ ) + } + { + // biome-ignore lint/complexity: + !textPosition && !hideTitleAuthor && ( +
+ + {article?.metadata.title ?? ''} + + + {article?.metadata.subtitle} + + + + {article?.metadata.author ?? + 'Paste your beehiv article link into the URL input'} + +
+ ) + } +
+ ) +} diff --git a/templates/beehiiv/views/Page.tsx b/templates/beehiiv/views/Page.tsx new file mode 100644 index 00000000..a7451e7b --- /dev/null +++ b/templates/beehiiv/views/Page.tsx @@ -0,0 +1,129 @@ +import type { BeehiivPage } from '../utils' + +export default function PageView({ + page, + currentPage, + slideCount, + pagesBgColor, + pagesTextColor, + pagesFontSize, +}: { + page: BeehiivPage + currentPage: number + slideCount: number + pagesBgColor?: string + pagesTextColor?: string + pagesFontSize?: number +}) { + return ( +
+
+ {page.map((element, index) => { + switch (element.tag) { + case 'IMG': + return ( + frame + ) + case 'P': + return ( +

+ {element.text} +

+ ) + case 'H1': + return ( +

+ {element.text} +

+ ) + case 'H2': + return ( +

+ {element.text} +

+ ) + case 'H3': + return ( +

+ {element.text} +

+ ) + case 'H4': + return ( +

+ {element.text} +

+ ) + case 'H5': + return ( +
+ {element.text} +
+ ) + case 'H6': + return ( +
+ {element.text} +
+ ) + case 'STRONG': + return ( + + {element.text} + + ) + case 'EM': + return ( + + {element.text} + + ) + default: + return ( + + {element.text} + + ) + } + })} +
+
+ {currentPage}/{slideCount} +
+
+ ) +} diff --git a/templates/cal/Inspector.tsx b/templates/cal/Inspector.tsx index 80b60c5b..711ac0ea 100644 --- a/templates/cal/Inspector.tsx +++ b/templates/cal/Inspector.tsx @@ -6,7 +6,9 @@ import { FontFamilyPicker, FontStylePicker, FontWeightPicker, + GatingInspector, Input, + Label, Select, Switch, } from '@/sdk/components' @@ -14,14 +16,12 @@ import { useFarcasterId, useFrameConfig, useResetPreview, useUploadImage } from import { Configuration } from '@/sdk/inspector' import { corsFetch } from '@/sdk/scrape' import { LoaderIcon, TrashIcon } from 'lucide-react' -import Link from 'next/link' import { useRef, useState } from 'react' import toast from 'react-hot-toast' import { useDebouncedCallback } from 'use-debounce' import type { Config } from '.' import { fetchProfileData } from './utils/cal' import { getDurationFormatted } from './utils/date' -import { getName } from './utils/nft' export default function Inspector() { const [config, updateConfig] = useFrameConfig() @@ -34,6 +34,7 @@ export default function Inspector() { const events = config.events || [] const eventSlugs = events.map((evt) => evt.slug) const disableFields = config.events.length === 0 + const enabledGating = config.enableGating ?? false const timezones = Intl.supportedValuesOf('timeZone') const timezoneOptions = timezones.map((tz) => { @@ -71,41 +72,6 @@ export default function Inspector() { } catch {} }, 1000) - const handleNFT = async (nftAddress: string) => { - const nftName = await getName(nftAddress, config.nftOptions.nftChain) - updateConfig({ - nftOptions: { - ...config.nftOptions, - nftAddress: nftAddress, - nftName: nftName, - }, - }) - } - const handleChainChange = (value: any) => { - updateConfig({ - nftOptions: { - ...config.nftOptions, - nftChain: value, - }, - }) - } - const handleNftTypeChange = (value: any) => { - updateConfig({ - nftOptions: { - ...config.nftOptions, - nftType: value, - }, - }) - } - const handleTokenIdChange = async (e: any) => { - updateConfig({ - nftOptions: { - ...config.nftOptions, - tokenId: e.target.value, - }, - }) - } - const fetchEventDetails = async (eventSlug: string) => { if (eventSlugs.includes(eventSlug)) { setLoading(false) @@ -275,236 +241,37 @@ export default function Inspector() { ))} - -
-
-

Karma Gating

- - { - updateConfig({ - gatingOptions: { - ...config.gatingOptions, - karmaGating: checked, - }, - }) - }} - /> -
- -

- Only allow Farcaster users within your second-degree to book a call. To - learn more check out{' '} - - OpenRank - - . -

+ +
+ + { + updateConfig({ enableGating }) + }} + />
-
-
-

NFT Gating

- { + {enabledGating && ( +
+

Poll Gating options

+ { updateConfig({ - gatingOptions: { - ...config.gatingOptions, - nftGating: checked, + gating: { + ...config.gating, + ...option, }, }) }} />
- -

- Only allow users users holding a specific NFT to book a call. -

-
- {config.gatingOptions.nftGating && ( - <> -
-

Choose Chain

- -
-
-

- Choose NFT Type -

- -
- -
-

NFT address

- { - await handleNFT(e.target.value) - }} - /> -
- {config.nftOptions.nftType === 'ERC1155' && ( -
-

Token ID

- -
- )} - )} -
-
-

Recasted

- - { - if (checked) { - updateConfig({ - gatingOptions: { - ...config.gatingOptions, - recasted: true, - }, - }) - } else { - updateConfig({ - gatingOptions: { - ...config.gatingOptions, - recasted: false, - }, - }) - } - }} - /> -
-

- Only allow users who recasted this cast to book a call. -

-
-
-
-

Liked

- - { - if (checked) { - updateConfig({ - gatingOptions: { - ...config.gatingOptions, - liked: true, - }, - }) - } else { - updateConfig({ - gatingOptions: { - ...config.gatingOptions, - liked: false, - }, - }) - } - }} - /> -
-

- Only allow users who liked this cast to book a call. -

-
-
-
-

Follower

- - { - if (checked) { - updateConfig({ - gatingOptions: { - ...config.gatingOptions, - follower: true, - }, - }) - } else { - updateConfig({ - gatingOptions: { - ...config.gatingOptions, - follower: false, - }, - }) - } - }} - /> -
-

- Only allow users who you follow to book a call. -

-
-
-
-

Following

- - { - if (checked) { - updateConfig({ - gatingOptions: { - ...config.gatingOptions, - following: true, - }, - }) - } else { - updateConfig({ - gatingOptions: { - ...config.gatingOptions, - following: false, - }, - }) - } - }} - /> -
-

- Only allow users who follow you to book a call. -

-
evt.slug === eventTypeSlug) + const timezone = config.timezone || 'Europe/London' + + if (!(params && event) || buttonIndex === 1) { return initial({ config }) } - const dates = getCurrentAndFutureDate(30) + const dates = getCurrentAndFutureDate(month) const url = `https://cal.com/api/trpc/public/slots.getSchedule?input=${encodeURIComponent( JSON.stringify({ json: { isTeamEvent: false, usernameList: [`${config.username}`], - eventTypeSlug: params.duration, + eventTypeSlug, startTime: dates[0], endTime: dates[1], duration: null, rescheduleUid: null, orgSlug: null, - timeZone: config.timezone || 'Europe/London', + timeZone: timezone, }, meta: { values: { @@ -64,22 +71,52 @@ export default async function confirm({ const slots = await fetch(url) const slotsResponse = await slots.json() - const [datesArray] = extractDatesAndSlots(slotsResponse.result.data.json.slots, config.timezone) - const date = datesArray[params.date] + const [datesArray, slotsArray] = extractDatesAndSlots( + slotsResponse.result.data.json.slots, + timezone + ) + const dateSection = datesArray[params.date] - const email = body.input?.text - const eventTypeId = await getEventId(config.username!, params.duration) + const email = body.input?.text || 'N/A' + const eventTypeId = await getEventId(config.username!, eventTypeSlug) + const webhooks: NonNullable = [] + const baseWebhookData = { + id: eventTypeId, + user_fid: body.interactor.fid, + user_email: email, + slug: eventTypeSlug, + } try { await bookCall( email?.split('@')[0] || '', email!, - slotsResponse.result.data.json.slots[date][params.slot].time, + slotsResponse.result.data.json.slots[dateSection][slot].time, eventTypeId, config.username!, - config.timezone || 'Europe/London' + timezone ) + webhooks.push({ + event: 'calbooking.success', + data: { + ...baseWebhookData, + host: config.username, + date: formatDateMonth(date, month, timezone), + duration: event.formattedDuration, + hour_slot: slotsArray[date][slot], + booked_at: Date.now(), + timezone, + cast_url: `https://warpcast.com/~/conversations/${body.cast.hash}`, + }, + }) } catch { + webhooks.push({ + event: 'calbooking.failed', + data: { + ...baseWebhookData, + reason: 'An error occurred during booking.', + }, + }) throw new FrameError('Error booking event.') } @@ -94,5 +131,6 @@ export default async function confirm({ fonts: fonts, component: PageView(config), handler: 'initial', + webhooks, } } diff --git a/templates/cal/handlers/duration.ts b/templates/cal/handlers/duration.ts index 40fe2777..01ba2840 100644 --- a/templates/cal/handlers/duration.ts +++ b/templates/cal/handlers/duration.ts @@ -1,11 +1,11 @@ 'use server' import type { BuildFrameData, FramePayloadValidated } from '@/lib/farcaster' +import { runGatingChecks } from '@/lib/gating' import { FrameError } from '@/sdk/error' import { loadGoogleFontAllVariants } from '@/sdk/fonts' import type { Config } from '..' import { getCurrentAndFutureDate } from '../utils/date' import { extractDatesAndSlots } from '../utils/date' -import { holdsErc721, holdsErc1155 } from '../utils/nft' import DateView from '../views/Day' import PageView from '../views/Duration' @@ -23,78 +23,17 @@ export default async function duration({ fontSet.add(config.fontFamily) } - for (const font of fontSet) { - const loadedFont = await loadGoogleFontAllVariants(font) - fonts.push(...loadedFont) - } - - let containsUserFID = true - let nftGate = true - if ((config.events || []).length === 0) { throw new FrameError('No events available to schedule.') } - if (config.gatingOptions.follower && !body.interactor.viewer_context.followed_by) { - throw new FrameError('Only profiles followed by the creator can schedule a call.') - } - - if (config.gatingOptions.following && !body.interactor.viewer_context.following) { - throw new FrameError('Please follow the creator and try again.') - } - - if (config.gatingOptions.recasted && !body.cast.viewer_context.recasted) { - throw new FrameError('Please recast this frame and try again.') + if (config.enableGating) { + await runGatingChecks(body, config.gating) } - if (config.gatingOptions.liked && !body.cast.viewer_context.liked) { - throw new FrameError('Please like this frame and try again.') - } - - if (config.gatingOptions.karmaGating) { - const url = 'https://graph.cast.k3l.io/scores/personalized/engagement/fids?k=1&limit=1000' - const options = { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: `["${config.fid}"]`, - } - - try { - const response = await fetch(url, options) - const data = await response.json() - - containsUserFID = data.result.some((item: any) => item.fid === body.interactor.fid) - } catch { - throw new FrameError('Failed to fetch your engagement data') - } - } - - if (config.gatingOptions.nftGating) { - if (body.interactor.verified_addresses.eth_addresses.length === 0) { - throw new FrameError('You do not have a wallet that holds the required NFT.') - } - if (config.nftOptions.nftType === 'ERC721') { - nftGate = await holdsErc721( - body.interactor.verified_addresses.eth_addresses, - config.nftOptions.nftAddress, - config.nftOptions.nftChain - ) - } else { - nftGate = await holdsErc1155( - body.interactor.verified_addresses.eth_addresses, - config.nftOptions.nftAddress, - config.nftOptions.tokenId, - config.nftOptions.nftChain - ) - } - } - - if (!containsUserFID) { - throw new FrameError('Only people within 2nd degree of connection can schedule a call.') - } - - if (!nftGate) { - throw new FrameError(`You need to hold ${config.nftOptions.nftName} to schedule a call.`) + for (const font of fontSet) { + const loadedFont = await loadGoogleFontAllVariants(font) + fonts.push(...loadedFont) } if (config.events.length === 1) { diff --git a/templates/cal/handlers/month.ts b/templates/cal/handlers/month.ts index b434b840..87b8bf72 100644 --- a/templates/cal/handlers/month.ts +++ b/templates/cal/handlers/month.ts @@ -1,5 +1,5 @@ 'use server' -import type { BuildFrameData } from '@/lib/farcaster' +import type { BuildFrameData, FramePayloadValidated } from '@/lib/farcaster' import { FrameError } from '@/sdk/error' import { loadGoogleFontAllVariants } from '@/sdk/fonts' import type { Config } from '..' diff --git a/templates/cal/index.ts b/templates/cal/index.ts index 364399ab..d4a5c208 100644 --- a/templates/cal/index.ts +++ b/templates/cal/index.ts @@ -1,4 +1,5 @@ import type { BaseConfig, BaseStorage, BaseTemplate } from '@/lib/types' +import type { GatingType } from '@/sdk/components/gating/types' import Inspector from './Inspector' import cover from './cover.avif' import handlers from './handlers' @@ -23,22 +24,8 @@ export interface Config extends BaseConfig { duration: string formattedDuration: string }[] - - gatingOptions: { - karmaGating: boolean - nftGating: boolean - recasted: boolean - liked: boolean - follower: boolean - following: boolean - } - nftOptions: { - nftAddress: string - nftName: string - nftType: string - nftChain: string - tokenId: string - } + gating: GatingType | undefined + enableGating: boolean | undefined } export interface Storage extends BaseStorage {} @@ -59,18 +46,21 @@ export default { events: [], bio: [], timezone: 'Europe/London', - gatingOptions: { - karmaGating: false, - nftGating: false, - recasted: false, - liked: false, - follower: false, - following: false, - }, - nftOptions: { - nftChain: 'ETH', - nftType: 'ERC721', + enableGating: false, + gating: { + enabled: [], + requirements: { + maxFid: 0, + minFid: 0, + score: 0, + channels: [], + exactFids: [], + erc20: null, + erc721: null, + erc1155: null, + moxie: null, + }, }, }, - events: [], + events: ['calbooking.success', 'calbooking.failed'], } satisfies BaseTemplate diff --git a/templates/cal/utils/nft.ts b/templates/cal/utils/nft.ts deleted file mode 100644 index 584c2af4..00000000 --- a/templates/cal/utils/nft.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { - arbitrumClient, - baseClient, - blastClient, - bscClient, - ethClient, - fantomClient, - opClient, - polygonClient, - zoraClient, -} from './viem' - -const balanceOfABI721 = [ - { - 'constant': true, - 'inputs': [ - { - 'name': '_owner', - 'type': 'address', - }, - ], - 'name': 'balanceOf', - 'outputs': [ - { - 'name': 'balance', - 'type': 'uint256', - }, - ], - 'payable': false, - 'stateMutability': 'view', - 'type': 'function', - }, -] as const - -const balanceOfABI1155 = [ - { - 'constant': true, - 'inputs': [ - { - 'name': '_owner', - 'type': 'address', - }, - { - 'name': '_id', - 'type': 'uint256', - }, - ], - 'name': 'balanceOf', - 'outputs': [ - { - 'name': 'balance', - 'type': 'uint256', - }, - ], - 'payable': false, - 'stateMutability': 'view', - 'type': 'function', - }, -] as const - -const nameABI = [ - { - constant: true, - inputs: [], - name: 'name', - outputs: [{ name: '', type: 'string' }], - type: 'function', - }, -] - -function getChainClient(chain: string) { - let selectedClient - - switch (chain) { - case 'ETH': { - selectedClient = ethClient - break - } - case 'BASE': { - selectedClient = baseClient - break - } - case 'OP': { - selectedClient = opClient - break - } - - case 'ZORA': { - selectedClient = zoraClient - break - } - - case 'BLAST': { - selectedClient = blastClient - break - } - - case 'POLYGON': { - selectedClient = polygonClient - break - } - - case 'FANTOM': { - selectedClient = fantomClient - break - } - - case 'ARBITRUM': { - selectedClient = arbitrumClient - break - } - - case 'BNB': { - selectedClient = bscClient - break - } - - default: - throw new Error('Unsupported chain') - } - - return selectedClient -} - -export const holdsErc721 = async (ownerAddresses: any, contract: any, chain: any) => { - const calls = [] - let hasNFT = false - - const selectedClient = getChainClient(chain) - - // biome-ignore lint/style/useForOf: - for (let i = 0; i < ownerAddresses.length; i++) { - calls.push({ - address: contract, - abi: balanceOfABI721, - handler: 'balanceOf', - args: [ownerAddresses[i]], - }) - } - - const results = await selectedClient.multicall({ - contracts: calls, - }) - - // biome-ignore lint/style/useForOf: - for (let i = 0; i < results.length; i++) { - if (results[i].result && results[i].result?.toString() !== '0') { - hasNFT = true - break - } - } - - return hasNFT -} - -export const holdsErc1155 = async ( - ownerAddresses: any, - contract: any, - tokenId: any, - chain: any -) => { - const calls = [] - let hasNFT = false - - const selectedClient = getChainClient(chain) - - // biome-ignore lint/style/useForOf: - for (let i = 0; i < ownerAddresses.length; i++) { - calls.push({ - address: contract, - abi: balanceOfABI1155, - handler: 'balanceOf', - args: [ownerAddresses[i], tokenId], - }) - } - - const results = await selectedClient.multicall({ - contracts: calls, - }) - - // biome-ignore lint/style/useForOf: - for (let i = 0; i < results.length; i++) { - if (results[i].result && results[i].result?.toString() !== '0') { - hasNFT = true - break - } - } - - return hasNFT -} - -export async function getName(contractAddress: any, chain: any): Promise { - const selectedClient = getChainClient(chain) - - try { - let name: any = await selectedClient.readContract({ - address: contractAddress, - abi: nameABI, - handler: 'name', - args: [], - }) - name = name.toString() - return name - } catch (error) { - console.error('Error getting contract name:', error) - return '' - } -} diff --git a/templates/cal/utils/viem.ts b/templates/cal/utils/viem.ts deleted file mode 100644 index 6ef50788..00000000 --- a/templates/cal/utils/viem.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { http, createPublicClient } from 'viem' -import { base, mainnet, optimism, zora, blast, arbitrum, fantom, polygon, bsc } from 'viem/chains' - -export const ethClient = createPublicClient({ - chain: mainnet, - transport: http(), -}) - -export const baseClient = createPublicClient({ - chain: base, - transport: http(), -}) - -export const opClient = createPublicClient({ - chain: optimism, - transport: http(), -}) - -export const zoraClient = createPublicClient({ - chain: zora, - transport: http(), -}) - -export const blastClient = createPublicClient({ - chain: blast, - transport: http(), -}) - -export const arbitrumClient = createPublicClient({ - chain: arbitrum, - transport: http(), -}) - -export const fantomClient = createPublicClient({ - chain: fantom, - transport: http(), -}) - -export const polygonClient = createPublicClient({ - chain: polygon, - transport: http(), -}) - -export const bscClient = createPublicClient({ - chain: bsc, - transport: http(), -}) diff --git a/templates/figma/handlers/click.ts b/templates/figma/handlers/click.ts index 19b97e57..9e23127a 100644 --- a/templates/figma/handlers/click.ts +++ b/templates/figma/handlers/click.ts @@ -1,5 +1,5 @@ 'use server' -import type { BuildFrameData } from '@/lib/farcaster' +import type { BuildFrameData, FramePayloadValidated } from '@/lib/farcaster' import type { FramePressConfig } from '../Config' import buildFigmaFrame from '../utils/FigmaFrameBuilder' diff --git a/templates/figma/handlers/slide.ts b/templates/figma/handlers/slide.ts index 33bb1d96..abb8a64a 100644 --- a/templates/figma/handlers/slide.ts +++ b/templates/figma/handlers/slide.ts @@ -4,7 +4,6 @@ import type { FramePressConfig } from '../Config' import buildFigmaFrame from '../utils/FigmaFrameBuilder' export default async function click({ - body, config, params, }: { diff --git a/templates/form/handlers/input.ts b/templates/form/handlers/input.ts index c314cfb6..aa13d94f 100644 --- a/templates/form/handlers/input.ts +++ b/templates/form/handlers/input.ts @@ -27,7 +27,7 @@ export default async function input({ const viewer = body.interactor const fid = viewer.fid const buttonIndex = body.tapped_button.index - const textInput = (body.validatedData?.input?.text || '') as string + const textInput = (body?.input?.text || '') as string let newStorage = storage diff --git a/templates/form/index.ts b/templates/form/index.ts index c6fc3e96..fa576303 100644 --- a/templates/form/index.ts +++ b/templates/form/index.ts @@ -75,14 +75,15 @@ export default { gating: { enabled: [], requirements: { - channels: [], maxFid: 0, minFid: 0, - exactFids: [], score: 0, + channels: [], + exactFids: [], erc20: null, erc721: null, erc1155: null, + moxie: null, }, }, }, diff --git a/templates/fundraiser/handlers/confirmation.ts b/templates/fundraiser/handlers/confirmation.ts index 5ce41522..1a47c9d8 100644 --- a/templates/fundraiser/handlers/confirmation.ts +++ b/templates/fundraiser/handlers/confirmation.ts @@ -71,7 +71,9 @@ export default async function confirmation({ amount = config.amounts[buttonIndex - 2] } - const chain = Object.keys(chains).find((chain) => (chains as any)[chain].id === glide.chains[0].id) + const chain = Object.keys(chains).find( + (chain) => (chains as any)[chain].id === glide.chains[0].id + ) if (!chain) { throw new FrameError('Chain not found for the given chain ID.') @@ -115,7 +117,7 @@ export default async function confirmation({ amount: formatSymbol(amount, config.token.symbol), }), handler: 'status', - params: { sessionId: session.sessionId }, + params: { sessionId: session.sessionId, amount }, } } catch (e) { console.error('Error creating session', e) diff --git a/templates/fundraiser/handlers/status.ts b/templates/fundraiser/handlers/status.ts index 8d155522..be5792f0 100644 --- a/templates/fundraiser/handlers/status.ts +++ b/templates/fundraiser/handlers/status.ts @@ -6,6 +6,7 @@ import { getGlide } from '@/sdk/glide' import BasicView from '@/sdk/views/BasicView' import { updatePaymentTransaction, waitForSession } from '@paywithglide/glide-js' import type { Config } from '..' +import { formatSymbol } from '../common' import RefreshView from '../views/Refresh' import initial from './initial' @@ -17,7 +18,7 @@ export default async function status({ body: FramePayloadValidated config: Config storage: Storage - params: { transactionId?: string; sessionId?: string } + params: { transactionId?: string; sessionId?: string; retries?: string; amount: string } }): Promise { if (!config.address) { throw new FrameError('Fundraiser address not found.') @@ -39,6 +40,10 @@ export default async function status({ throw new FrameError('Session Id is missing') } + if (isNaN(Number(params.amount))) { + throw new FrameError('Invalid amount provided.') + } + const fontSet = new Set(['Roboto']) const fonts: any[] = [] @@ -64,7 +69,10 @@ export default async function status({ ) as `0x${string}` const glide = getGlide(config.token.chain) - + const retries = + params.retries === undefined || isNaN(Number.parseInt(params.retries)) ? 0 : +params.retries + const txUrl = `https://${glide.chains[0].blockExplorers?.default.url}/tx/${txHash}` + try { // Get the status of the payment transaction await updatePaymentTransaction(glide, { @@ -83,7 +91,7 @@ export default async function status({ { label: `View on ${glide.chains[0].blockExplorers?.default.name}`, action: 'link', - target: `https://${glide.chains[0].blockExplorers?.default.url}/tx/${txHash}`, + target: txUrl, }, { label: 'Create Your Own', @@ -100,6 +108,28 @@ export default async function status({ buildData['component'] = BasicView(config.success) } + const amountInTokenCopy = `amount_in_${config.token.symbol.toLowerCase()}` + + buildData['webhooks'] = [ + { + event: 'fundraiser.success', + data: { + fid: body.interactor.fid, + transaction_id: txHash, + transaction_chain: glide.chains[0].name, + transaction_url: txUrl, + [`${amountInTokenCopy}`]: Number(params.amount), + [`${amountInTokenCopy}_formatted`]: formatSymbol( + params.amount, + config.token.symbol + ), + token_symbol: config.token.symbol, + token_decimals: glide.chains[0].nativeCurrency.decimals, + cast_url: `https://warpcast.com/~/conversations/${body.cast.hash}`, + }, + }, + ] + return buildData as BuildFrameData } catch (e) { const buttons: FrameButtonMetadata[] = [] @@ -111,10 +141,10 @@ export default async function status({ params: { transactionId: txHash, sessionId: params.sessionId, - isFresh: false, }, fonts, } + const MAX_RETRIES = 3 if (paid) { buttons.push( @@ -136,14 +166,55 @@ export default async function status({ buttons.push({ label: 'Refresh', }) + + buildData['params'].retries = Math.min(retries + 1, MAX_RETRIES) } - if (config.success?.image) { - buildData['image'] = paid ? config.success?.image : undefined + if (MAX_RETRIES) { + buttons.length = 0 + buildData['handler'] = 'success' + buildData['webhooks'] = [ + { + event: 'fundraiser.failed', + data: { + fid: body.interactor.fid, + transaction_id: txHash, + transaction_chain: glide.chains[0].name, + transaction_url: txUrl, + amount: Number(params.amount), + amount_formatted: formatSymbol(params.amount, config.token.symbol), + token_symbol: config.token.symbol, + cast_url: `https://warpcast.com/~/conversations/${body.cast.hash}`, + }, + }, + ] + buildData['component'] = RefreshView(true) + + buttons.push( + { + label: 'Donate again', + }, + { + label: `View on ${glide.chains[0].blockExplorers?.default.name}`, + action: 'link', + target: txUrl, + }, + { + label: 'Create Your Own', + action: 'link', + target: 'https://www.frametra.in', + } + ) } else { - buildData['component'] = paid ? BasicView(config.success) : RefreshView() + if (config.success?.image) { + buildData['image'] = paid ? config.success?.image : undefined + } else { + buildData['component'] = paid ? BasicView(config.success) : RefreshView() + } } + buildData['buttons'] = buttons + return buildData as BuildFrameData } } diff --git a/templates/fundraiser/handlers/success.ts b/templates/fundraiser/handlers/success.ts index f9096c52..015b1570 100644 --- a/templates/fundraiser/handlers/success.ts +++ b/templates/fundraiser/handlers/success.ts @@ -4,9 +4,7 @@ import type { Config } from '..' import initial from './initial' export default async function success({ - body, config, - storage, }: { body: FramePayloadValidated config: Config diff --git a/templates/fundraiser/index.ts b/templates/fundraiser/index.ts index c887468d..701ab2ae 100644 --- a/templates/fundraiser/index.ts +++ b/templates/fundraiser/index.ts @@ -64,5 +64,5 @@ export default { bottomMessage: { text: 'You can customize this message.' }, }, }, - events: [], + events: ['fundraiser.success', 'fundraiser.failed'], } satisfies BaseTemplate diff --git a/templates/fundraiser/views/Refresh.tsx b/templates/fundraiser/views/Refresh.tsx index 579161ec..6c07b26a 100644 --- a/templates/fundraiser/views/Refresh.tsx +++ b/templates/fundraiser/views/Refresh.tsx @@ -1,4 +1,4 @@ -export default function RefreshView() { +export default function RefreshView(failed = false) { return (
- Waiting for payment confirmation.. + {failed + ? 'Sorry, we could not get the transaction data for your donation.' + : 'Waiting for payment confirmation..'}
) diff --git a/templates/gated/handlers/page.ts b/templates/gated/handlers/page.ts index 0303d05a..43b3c418 100644 --- a/templates/gated/handlers/page.ts +++ b/templates/gated/handlers/page.ts @@ -3,11 +3,12 @@ import type { BuildFrameData, FrameButtonMetadata, FramePayloadValidated } from import { runGatingChecks } from '@/lib/gating' import { loadGoogleFontAllVariants } from '@/sdk/fonts' import BasicView from '@/sdk/views/BasicView' -import type { Config } from '..' +import type { Config, Storage } from '..' export default async function page({ body, config, + storage, }: { body: FramePayloadValidated config: Config @@ -17,6 +18,8 @@ export default async function page({ await runGatingChecks(body, config.gating) const buttons: FrameButtonMetadata[] = [] + const webhooks: NonNullable = [] + const users = storage.users || [] const fontSet = new Set(['Roboto']) const fonts: any[] = [] @@ -54,9 +57,22 @@ export default async function page({ }) } + if (!users.find((user) => user === body.interactor.fid)) { + webhooks.push({ + event: 'gated.success', + data: { + fid: body.interactor.fid, + cast_url: `https://warpcast.com/~/conversations/${body.cast.hash}`, + }, + }) + storage.users = users.concat([body.interactor.fid]) + } + const buildData: Record = { buttons, fonts, + webhooks, + storage, } if (config.success.image) { diff --git a/templates/gated/index.ts b/templates/gated/index.ts index 2a60c286..0505db8b 100644 --- a/templates/gated/index.ts +++ b/templates/gated/index.ts @@ -22,7 +22,9 @@ export interface Config extends BaseConfig { } } -export interface Storage extends BaseStorage {} +export interface Storage extends BaseStorage { + users?: number[] +} export default { name: 'Gated', @@ -53,16 +55,17 @@ export default { gating: { enabled: [], requirements: { - channels: [], maxFid: 0, minFid: 0, - exactFids: [], score: 0, + channels: [], + exactFids: [], erc20: null, erc721: null, erc1155: null, + moxie: null, }, }, }, - events: [], + events: ['gated.success'], } satisfies BaseTemplate diff --git a/templates/index.ts b/templates/index.ts index ed23ff76..410469cb 100644 --- a/templates/index.ts +++ b/templates/index.ts @@ -1,3 +1,4 @@ +import beehiiv from './beehiiv' import cal from './cal' import contract from './contract' import discourse from './discourse' @@ -14,6 +15,7 @@ import poll from './poll' import presentation from './presentation' import quizlet from './quizlet' import rss from './rss' +import substack from './substack' import swap from './swap' import twitter from './twitter' @@ -36,4 +38,6 @@ export default { fundraiser, gated, contract, + substack, + beehiiv, } diff --git a/templates/poll/handlers/vote.ts b/templates/poll/handlers/vote.ts index 7a04fef4..88061de9 100644 --- a/templates/poll/handlers/vote.ts +++ b/templates/poll/handlers/vote.ts @@ -95,5 +95,19 @@ export default async function vote({ colors ), handler: 'results', + webhooks: [ + { + event: 'vote', + data: { + voter, + question: config.question, + votedAt: new Date(newStorage.votesForId?.[voter]?.timestamp).getTime(), + answer: config.options[buttonIndex].displayLabel, + totalVotes: newStorage.totalVotes, + cast_url: `https://warpcast.com/~/conversations/${body.cast.hash}`, + }, + }, + ], } } +Date.now() diff --git a/templates/poll/index.ts b/templates/poll/index.ts index 831b1248..e0f644e4 100644 --- a/templates/poll/index.ts +++ b/templates/poll/index.ts @@ -47,16 +47,17 @@ export default { gating: { enabled: [], requirements: { - channels: [], maxFid: 0, minFid: 0, - exactFids: [], score: 0, + channels: [], + exactFids: [], erc20: null, erc721: null, erc1155: null, + moxie: null, }, }, }, - events: [], + events: ['vote'], } satisfies BaseTemplate diff --git a/templates/quizlet/handlers/answer.ts b/templates/quizlet/handlers/answer.ts index e1e86c32..716e1aa4 100644 --- a/templates/quizlet/handlers/answer.ts +++ b/templates/quizlet/handlers/answer.ts @@ -30,8 +30,15 @@ export default async function answer({ const nextQna = config.qna[nextPage] const choiceType = isNaN(Number.parseInt(qna.answer)) ? 'alpha' : 'numeric' const userAnswer = choicesRepresentation[choiceType][choice] + const quizIdsFromStorage = storage.quizIds || [] + const quizId = `${params?.quizId}` + const currentQuizId = quizIdsFromStorage.find((id) => id === quizId) + ? quizId + : `${student}:${body.cast.hash}_${crypto.randomUUID().replaceAll('-', '')}` + const quizIds = new Set([...quizIdsFromStorage, currentQuizId]) const buttons: FrameButtonMetadata[] = [] + const webhooks: NonNullable = [] // if the user has already answered this question, find the previous answers and // if the user has answered it more than once, keep the latest answer and remove the previous ones @@ -48,6 +55,7 @@ export default async function answer({ } newStorage = { ...storage, + quizIds: [...quizIds], answers: { ...(storage.answers ?? {}), [student]: updatedAnswers, @@ -56,6 +64,7 @@ export default async function answer({ } else { pastAnswers.push({ questionIndex: qna.index, answer: userAnswer }) newStorage = Object.assign(storage, { + quizIds: [...quizIds], answers: { ...(storage.answers ?? {}), [student]: pastAnswers, @@ -88,6 +97,20 @@ export default async function answer({ ) } + webhooks.push({ + event: 'quiz.qna', + data: { + id: quizId, + fid: student, + total_questions: config.qna.length, + question_number: nextPage - 1, + question: qna.question, + answer: qna.answer, + user_answer: userAnswer, + cast_url: `https://warpcast.com/~/conversations/${body.cast.hash}`, + }, + }) + const updatedAnswers = newStorage.answers[student] const correctAnswers = updatedAnswers.reduce((acc, past) => { @@ -118,6 +141,7 @@ export default async function answer({ ) : QuestionView({ qna: nextQna, total: qnaCount }), handler: lastPage ? 'results' : 'answer', - params: lastPage ? undefined : { currentPage: nextPage }, + params: lastPage ? { quizId: currentQuizId } : { currentPage: nextPage }, + webhooks, } } diff --git a/templates/quizlet/handlers/page.ts b/templates/quizlet/handlers/page.ts index 3b449b2d..e72d8e5c 100644 --- a/templates/quizlet/handlers/page.ts +++ b/templates/quizlet/handlers/page.ts @@ -14,12 +14,15 @@ export default async function page({ config: Config storage: Storage }): Promise { - const user = body.interactor.fid.toString() + const fid = body.interactor.fid.toString() const qna = config.qna[0] const fonts = await loadGoogleFontAllVariants(qna.design?.qnaFont ?? 'Roboto') const buttons: FrameButtonMetadata[] = [] - const pastAnswers = storage.answers?.[user] ?? [] + const pastAnswers = storage.answers?.[fid] ?? [] const scores = { yes: 0, no: 0 } + const quizId = `${fid}:${body.cast.hash}_${crypto.randomUUID().replaceAll('-', '')}` + const quizIds = storage.ids || [] + let newStorage = storage if (config.answerOnce) { scores.yes = pastAnswers.reduce((acc, past) => { @@ -51,6 +54,23 @@ export default async function page({ buttons.push({ label }) } } + newStorage = { + ...storage, + quizIds: [...quizIds, quizId], + } + + const webhooks: NonNullable = [] + + webhooks.push({ + event: 'quiz.initialize', + data: { + id: quizId, + fid, + total_questions: config.qna.length, + previously_participated: pastAnswers.length > 0, + cast_url: `https://warpcast.com/~/conversations/${body.cast.hash}`, + }, + }) return { buttons, @@ -59,6 +79,8 @@ export default async function page({ ? ResultsView(config.qna.length, scores, config) : QuestionView({ qna, total: config.qna.length }), handler: config.answerOnce ? 'results' : 'answer', - params: config.answerOnce ? { currentPage: 1 } : undefined, + params: !config.answerOnce ? { quizId } : undefined, + webhooks, + storage: newStorage, } } diff --git a/templates/quizlet/handlers/results.ts b/templates/quizlet/handlers/results.ts index 2be4b06c..a0a31c8b 100644 --- a/templates/quizlet/handlers/results.ts +++ b/templates/quizlet/handlers/results.ts @@ -9,10 +9,12 @@ export default async function results({ body, config, storage, + params, }: { body: FramePayloadValidated config: Config storage: Storage + params?: any }): Promise { const userId = body.interactor.fid.toString() const choice = body.tapped_button.index @@ -33,6 +35,6 @@ export default async function results({ fonts: roboto, component: ReviewAnswersView({ qna, total: qnas.length, userAnswer }), handler: 'review', - params: { currentPage: 1 }, + params: { currentPage: 1, quizId: params?.quizId }, } } diff --git a/templates/quizlet/handlers/review.ts b/templates/quizlet/handlers/review.ts index a70e69b0..892371c2 100644 --- a/templates/quizlet/handlers/review.ts +++ b/templates/quizlet/handlers/review.ts @@ -16,13 +16,14 @@ export default async function review({ storage: Storage params: any }): Promise { - const student = body.interactor.fid.toString() - const pastAnswers = storage.answers?.[student] ?? [] + const fid = body.interactor.fid.toString() + const pastAnswers = storage.answers?.[fid] ?? [] const newStorage = storage const currentPage = params?.currentPage === undefined ? 0 : Number.parseInt(params?.currentPage) const nextPage = currentPage > 0 ? currentPage + 1 : 1 const lastPage = currentPage === config.qna.length + const quizId = `${params?.quizId}` const buttons: FrameButtonMetadata[] = [] @@ -40,7 +41,25 @@ export default async function review({ }, 0) const showImage = correctAnswers === config.qna.length + const webhooks: NonNullable = [] + if (lastPage) { + webhooks.push({ + event: 'quiz.results', + data: { + id: quizId, + fid, + total_questions: config.qna.length, + first_qna: { question: config.qna[0].question, answer: config.qna[0].answer }, + last_qna: { + question: config.qna[config.qna.length - 1].question, + answer: config.qna[config.qna.length - 1].answer, + }, + correct_answers: correctAnswers, + wrong_answers: config.qna.length - correctAnswers, + cast_url: `https://warpcast.com/~/conversations/${body.cast.hash}`, + }, + }) buttons.push({ label: '← Home', }) @@ -70,5 +89,6 @@ export default async function review({ component: ReviewAnswersView({ total: qnas.length, qna, userAnswer }), handler: lastPage ? 'success' : 'review', params: !lastPage ? { currentPage: nextPage } : undefined, + webhooks, } } diff --git a/templates/quizlet/handlers/success.ts b/templates/quizlet/handlers/success.ts index 65d51ef2..4027d256 100644 --- a/templates/quizlet/handlers/success.ts +++ b/templates/quizlet/handlers/success.ts @@ -1,12 +1,11 @@ 'use server' -import type { BuildFrameData, FrameButtonMetadata } from '@/lib/farcaster' +import type { BuildFrameData, FrameButtonMetadata, FramePayloadValidated } from '@/lib/farcaster' import type { Config, Storage } from '..' import initial from './initial' export default async function success({ body, config, - storage, }: { body: FramePayloadValidated config: Config diff --git a/templates/quizlet/index.ts b/templates/quizlet/index.ts index 603c5bf6..0b0dc5b0 100644 --- a/templates/quizlet/index.ts +++ b/templates/quizlet/index.ts @@ -89,6 +89,7 @@ export interface Storage extends BaseStorage { answer: string }[] } + quizIds?: string[] } export default { @@ -136,5 +137,5 @@ export default { labelColor: 'white', }, }, - events: [], + events: ['quiz.initialize', 'quiz.qna', 'quiz.results'], } satisfies BaseTemplate diff --git a/templates/substack/Inspector.tsx b/templates/substack/Inspector.tsx new file mode 100644 index 00000000..d6b539cc --- /dev/null +++ b/templates/substack/Inspector.tsx @@ -0,0 +1,197 @@ +'use client' +import { ColorPicker, Input } from '@/sdk/components' +import { useFrameConfig } from '@/sdk/hooks' +import { Configuration } from '@/sdk/inspector' +import { LoaderIcon } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { toast } from 'react-hot-toast' +import type { Config } from '.' +import getSubstackArticle from './utils' + +export default function Inspector() { + const [config, updateConfig] = useFrameConfig() + const [loading, setLoading] = useState(false) + + const urlInputRef = useRef(null) + const imgSizeInputRef = useRef(null) + const pagesFontSizeInputRef = useRef(null) + const textPositionOverlayRef = useRef(null) + const linkOnAllPagesRef = useRef(null) + const hideTitleAuthorRef = useRef(null) + + // keep the url input updated with the article URL + useEffect(() => { + if (!urlInputRef.current) return + if (!urlInputRef.current.value) return + + urlInputRef.current.value = config.article?.url ?? '' + }, [config.article]) + + // handler for the article URL input + + const urlInputHandler = async (url: string) => { + if (url === config.article?.url) return + if (url === '') { + updateConfig({ article: null }) + return + } + + if (!/^(https?:\/\/[^\s]+)/.test(url)) { + toast.error('Please enter a valid substack article URL') + return + } + + try { + setLoading(true) + const newArticle = await getSubstackArticle(url) + updateConfig({ article: newArticle }) + console.log(newArticle) + toast.success('Successfully fetched the substack article') + } catch (e) { + console.error('substack', e) + toast.error('Please enter a valid substack article URL') + } finally { + setLoading(false) + } + } + + return ( + + +
+ urlInputHandler(e.target.value)} + /> + {loading && } +
+
+ + + + + + + +
+

Background Color

+ updateConfig({ coverBgColor: value })} + /> +
+
+

Text Color

+ updateConfig({ coverTextColor: value })} + /> +
+
+

Image Size

+ updateConfig({ imageSize: imgSizeInputRef.current?.value })} + /> +

+ Enter a percent value and test (frame size limit is 256kb!) +

+
+
+ +
+

Background Color

+ updateConfig({ pagesBgColor: value })} + /> +
+
+

Text Color

+ updateConfig({ pagesTextColor: value })} + /> +
+
+

Font Size

+ + updateConfig({ pagesFontSize: pagesFontSizeInputRef.current?.value }) + } + /> +
+
+
+ ) +} diff --git a/templates/substack/cover.avif b/templates/substack/cover.avif new file mode 100644 index 00000000..e58bd375 Binary files /dev/null and b/templates/substack/cover.avif differ diff --git a/templates/substack/handlers/index.ts b/templates/substack/handlers/index.ts new file mode 100644 index 00000000..b38e28a1 --- /dev/null +++ b/templates/substack/handlers/index.ts @@ -0,0 +1,7 @@ +import initial from './initial' +import page from './page' + +export default { + initial, + page, +} diff --git a/templates/substack/handlers/initial.ts b/templates/substack/handlers/initial.ts new file mode 100644 index 00000000..0a6cd8a5 --- /dev/null +++ b/templates/substack/handlers/initial.ts @@ -0,0 +1,28 @@ +'use server' +import type { BuildFrameData } from '@/lib/farcaster' +import { loadGoogleFontAllVariants } from '@/sdk/fonts' +import type { Config } from '..' +import CoverView from '../views/Cover' + +export default async function initial({ config }: { config: Config }): Promise { + const georgia = await loadGoogleFontAllVariants('Georgia') + + return { + buttons: [ + { + label: 'Read →', + }, + ], + aspectRatio: '1:1', + fonts: georgia, + component: CoverView({ + article: config.article, + bgColor: config.coverBgColor, + textColor: config.coverTextColor, + imageSize: config.imageSize, + textPosition: config.textPosition, + hideTitleAuthor: config.hideTitleAuthor, + }), + handler: 'page', + } +} diff --git a/templates/substack/handlers/page.ts b/templates/substack/handlers/page.ts new file mode 100644 index 00000000..8639aa03 --- /dev/null +++ b/templates/substack/handlers/page.ts @@ -0,0 +1,72 @@ +'use server' +import type { BuildFrameData, FrameButtonMetadata, FramePayloadValidated } from '@/lib/farcaster' +import { loadGoogleFontAllVariants } from '@/sdk/fonts' +import type { Config } from '..' +import PageView from '../views/Page' +import initial from './initial' + +export default async function page({ + body, + config, + params, +}: { + body: FramePayloadValidated + config: Config + params: any +}): Promise { + const nextPage = + params?.currentPage !== undefined + ? body.tapped_button.index === 1 + ? Number(params?.currentPage) - 1 + : Number(params?.currentPage) + 1 + : 1 + + const slideCount = (config.article?.pages ?? []).length || 1 + + //console.log('slide count', slideCount) + + const buttons: FrameButtonMetadata[] = [ + { + label: '←', + }, + ] + + if (nextPage < slideCount) { + buttons.push({ + label: '→', + }) + } + + if (config.article?.url && (config.showLinkOnAllPages || nextPage === slideCount)) { + buttons.push({ + label: 'Substack', + action: 'link', + target: config.article.url, + }) + } + + if (body.tapped_button.index === 1 && nextPage === 0) { + return initial({ config }) + } + + const page = config.article?.pages[nextPage - 1] + const georgia = await loadGoogleFontAllVariants('Georgia') + + return { + buttons: buttons, + aspectRatio: '1:1', + fonts: georgia, + component: PageView({ + page: page || [], + currentPage: nextPage, + slideCount: slideCount, + pagesBgColor: config.pagesBgColor, + pagesTextColor: config.pagesTextColor, + pagesFontSize: config.pagesFontSize, + }), + handler: 'page', + params: { + currentPage: nextPage, + }, + } +} diff --git a/templates/substack/icon.avif b/templates/substack/icon.avif new file mode 100644 index 00000000..b665c599 Binary files /dev/null and b/templates/substack/icon.avif differ diff --git a/templates/substack/index.ts b/templates/substack/index.ts new file mode 100644 index 00000000..f4ac2f9f --- /dev/null +++ b/templates/substack/index.ts @@ -0,0 +1,37 @@ +import type { BaseConfig, BaseStorage, BaseTemplate } from '@/lib/types' +import Inspector from './Inspector' +import cover from './cover.avif' +import handlers from './handlers' +import icon from './icon.avif' +import type { SubstackArticle } from './utils' + +export interface Config extends BaseConfig { + article?: SubstackArticle + coverBgColor: string + coverTextColor: string + imageSize: number + textPosition: boolean + showLinkOnAllPages: boolean + hideTitleAuthor: boolean + pagesBgColor: string + pagesTextColor: string + pagesFontSize: number +} + +export interface Storage extends BaseStorage {} + +export default { + name: 'Substack', + description: 'Convert any Substack article into a Farcaster Frame.', + shortDescription: 'Substack article to Frame', + octicon: 'log', + icon: icon, + creatorFid: '260812', + creatorName: 'Steve', + enabled: true, + Inspector, + handlers, + cover, + initialConfig: {}, + events: [], +} satisfies BaseTemplate diff --git a/templates/substack/utils.ts b/templates/substack/utils.ts new file mode 100644 index 00000000..e89d5c36 --- /dev/null +++ b/templates/substack/utils.ts @@ -0,0 +1,247 @@ +import { corsFetch } from '@/sdk/scrape' + +// metadata tags pulled from the medium article html +interface SubstackMetadata { + author: string | null + title: string | null + subtitle: string | null + image: string | null +} + +// One paragraph, heading, or image tag and its contents +type SubstackElement = { + tag: string + text: string + src?: string +} + +// A page (frame) will be made up of an array of elements +export type SubstackPage = SubstackElement[] + +// Our frame-adapted medium article to be passed back to the inspector +export type SubstackArticle = { + pages: SubstackPage[] + url: string + metadata: SubstackMetadata +} + +// Entry function to pass in a medium article URL and return an Article to the inspector +export async function getSubstackArticle(url: string): Promise { + const postUrl = new URL(url) + console.log('fetching substack article', url) + + if (!postUrl.pathname.startsWith('/p/')) { + throw new Error('Invalid article url') + } + + postUrl.pathname = postUrl.pathname.replace('/p/', '/api/v1/posts/') + + const response = await corsFetch(postUrl.toString()) + if (!response) throw new Error('Invalid substack article') + + const post = JSON.parse(response) as { + id: number + title: string + subtitle: string + post_date: string + canonical_url: string + cover_image: string + body_html: string + publishedBylines: [ + { + name: string + }, + ] + } + + if (post.canonical_url !== url) { + throw new Error('Invalid substack article') + } + + const metadata: SubstackMetadata = { + author: post.publishedBylines[0].name, + title: post.title, + subtitle: post.subtitle, + image: post.cover_image, + } + const extractedTags: SubstackElement[] = extractTagsFromHTML(post.body_html, metadata) + + const article = { pages: paginateElements(extractedTags, metadata), url, metadata } // character limit + return article +} + +// Attempts to pull out only the relevant content from the HTML - paragraph, heading, and image tags, stripped of attributes +function extractTagsFromHTML(htmlContent: string, metadata: SubstackMetadata): SubstackElement[] { + const tempDiv = document.createElement('article') + tempDiv.innerHTML = htmlContent + // Use the DOMParser to turn the HTML string into a Document + // const doc = parser.parseFromString(html, 'text/html') + + // Get the relevant child elements from the article tag + const elements = tempDiv.querySelectorAll('p, h1, h2, h3, h4, h5, h6, img') + + if (!elements) return [] + + const elementsArray: HTMLElement[] = Array.from(elements) as HTMLElement[] + + // Filter images - remove images that match the author, publication, or cover image, or if they're really small + const indexesToRemove: number[] = [] + elementsArray.forEach((element, index) => { + if (element.tagName === 'IMG') { + const alt = element.getAttribute('alt') + const src = element.getAttribute('src') + const width = Number.parseInt(element.getAttribute('width') || '') || 0 + if ( + width < 300 || + alt === 'Top highlight' || + alt === metadata.author || + (src && + metadata.image && + getFilenameFromUrl(src) == getFilenameFromUrl(metadata.image)) + ) { + // keep track of indexes to remove + indexesToRemove.push(index) + } + } + }) + // remove elements without iterating the same array + for (let i = indexesToRemove.length - 1; i >= 0; i--) { + elementsArray.splice(indexesToRemove[i], 1) + } + + // Clean elements so we only have the tag and its contents, and src if it's an image + const cleanedElements: SubstackElement[] = elementsArray + .map((e) => { + return { + tag: e.tagName, + text: e.textContent?.trim() || '', + src: e.getAttribute('src') || '', + } + }) + .filter((e) => e.text.length > 0 || e.src.length > 0) + + return cleanedElements +} + +// Function to estimate the number of chars per line for each element based on its type +function estimateElementCharsAndLines(element: SubstackElement): number { + const linesPerType: any = { + 'p': 80, + 'h1': 40, + 'h2': 60, + 'h3': 70, + 'h4': 70, + 'h5': 70, + 'other': 80, + } + + return linesPerType[element.tag] || linesPerType['other'] +} + +function paginateElements(elements: SubstackElement[], metadata: SubstackMetadata): SubstackPage[] { + // ignore elements if their text is equal to certain keywords including author, 'Follow', 'Listen', 'Share', or just a number + const filteredElements = elements.filter((element) => { + const text = element.text.trim() + return !( + [ + 'Follow', + 'Listen', + 'Share', + metadata.author, + metadata.title, + 'About', + 'Contact', + 'Subscribe', + 'Subscribe now', + 'Top highlight', + `Discover more from ${metadata.author}`, + 'substack.com', + ].includes(text) || text.match(/^\d+(\.\d+)?[KMB]?$/) + ) + }) + + // Split elements that are too long + const chunkedElements: SubstackElement[] = filteredElements.reduce( + (acc: SubstackElement[], currentElement) => { + const charsPerLine = estimateElementCharsAndLines(currentElement) + const charsPerPage = charsPerLine * 24 // 24 lines per page + if (currentElement.text.length > charsPerPage) { + const newElements: SubstackElement[] = splitElement(currentElement, charsPerPage) + acc.push(...newElements) + } else { + acc.push(currentElement) + } + return acc + }, + [] + ) + + const pages: SubstackPage[] = [] + let currentPage: SubstackPage = [] + let currentPageLinesRemaining = 24 // 24 lines per page + + for (const currentElement of chunkedElements) { + const currentElementEstimatedCharsPerLine = estimateElementCharsAndLines(currentElement) + const currentElementLinesRequired = currentElement.src + ? 15 // images take up 15 lines + : currentElement.text.length / currentElementEstimatedCharsPerLine + 2 + + // If this element will exceed the lines remaining, then create a new page + if (currentPageLinesRemaining - currentElementLinesRequired <= 0) { + pages.push(currentPage) + currentPageLinesRemaining = 24 + currentPage = [] + } + + currentPage.push(currentElement) + currentPageLinesRemaining -= currentElementLinesRequired + } + + // Add the last page if it contains any elements + if (currentPage.length > 0) { + pages.push(currentPage) + } + + return pages +} + +// splits an element into multiple elements based on a character limit +function splitElement(element: SubstackElement, charLimit: number): SubstackElement[] { + const { text } = element + + let currentLength = 0 + const newElements: SubstackElement[] = [] + const words = text.split(' ') + let currentText = '' + + for (const word of words) { + const wordLength = word.length + + if (currentLength + wordLength > charLimit) { + // Add the current text to the current page + newElements.push({ tag: element.tag, text: currentText }) + + // Reset the current text and length + currentText = '' + currentLength = 0 + } + + currentText += word + ' ' + currentLength += wordLength + } + + // Add the last element + newElements.push({ tag: element.tag, text: currentText }) + + return newElements +} + +// Function to extract metadata from the provided HTML content + +// Helper function to extract the filename from a URL +function getFilenameFromUrl(url: string) { + if (!url) return '' + return url.substring(url.lastIndexOf('/') + 1) +} + +export default getSubstackArticle diff --git a/templates/substack/views/Cover.tsx b/templates/substack/views/Cover.tsx new file mode 100644 index 00000000..9cb9891a --- /dev/null +++ b/templates/substack/views/Cover.tsx @@ -0,0 +1,191 @@ +import type { SubstackArticle } from '../utils' + +export default function CoverView({ + article, + bgColor, + textColor, + imageSize, + textPosition, + hideTitleAuthor, +}: { + article?: SubstackArticle + bgColor?: string + textColor?: string + imageSize: number + textPosition: boolean + hideTitleAuthor: boolean +}) { + let titleFontSize = '40px' + let authorFontSize = '20px' + console.log('old size >> metadata', article?.metadata) + if ( + article && + (article?.metadata?.title?.length ?? 0) + (article?.metadata?.author?.length ?? 0) > 80 + ) { + console.log('new sizes') + titleFontSize = '25px' + authorFontSize = '20px' + } + + return ( +
+ {article?.metadata.image && ( +
+ . +
+ )} + { + // image is overlaid with text + textPosition && !hideTitleAuthor && ( +
+ + {article?.metadata.title ?? ''} + + + {article?.metadata.subtitle} + + {article?.metadata.author ? ( +
+ {/*
{post.pubDate}
*/} + +
+ {article.metadata.author} +
+
+ ) : ( + + Paste your beehiv article link into the URL input + + )} +
+ ) + } + { + // biome-ignore lint/complexity: + !textPosition && !hideTitleAuthor && ( +
+ + {article?.metadata.title ?? ''} + + + {article?.metadata.subtitle} + + + + {article?.metadata.author ?? + 'Paste your beehiv article link into the URL input'} + +
+ ) + } +
+ ) +} diff --git a/templates/substack/views/Page.tsx b/templates/substack/views/Page.tsx new file mode 100644 index 00000000..32fabae9 --- /dev/null +++ b/templates/substack/views/Page.tsx @@ -0,0 +1,129 @@ +import type { SubstackPage } from '../utils' + +export default function PageView({ + page, + currentPage, + slideCount, + pagesBgColor, + pagesTextColor, + pagesFontSize, +}: { + page: SubstackPage + currentPage: number + slideCount: number + pagesBgColor?: string + pagesTextColor?: string + pagesFontSize?: number +}) { + return ( +
+
+ {page.map((element, index) => { + switch (element.tag) { + case 'IMG': + return ( + frame + ) + case 'P': + return ( +

+ {element.text} +

+ ) + case 'H1': + return ( +

+ {element.text} +

+ ) + case 'H2': + return ( +

+ {element.text} +

+ ) + case 'H3': + return ( +

+ {element.text} +

+ ) + case 'H4': + return ( +

+ {element.text} +

+ ) + case 'H5': + return ( +
+ {element.text} +
+ ) + case 'H6': + return ( +
+ {element.text} +
+ ) + case 'STRONG': + return ( + + {element.text} + + ) + case 'EM': + return ( + + {element.text} + + ) + default: + return ( + + {element.text} + + ) + } + })} +
+
+ {currentPage}/{slideCount} +
+
+ ) +} diff --git a/templates/swap/Inspector.tsx b/templates/swap/Inspector.tsx index 3932fdb5..5184785a 100644 --- a/templates/swap/Inspector.tsx +++ b/templates/swap/Inspector.tsx @@ -20,9 +20,8 @@ import type { AnchorHTMLAttributes, FC } from 'react' import { useEffect, useRef, useState } from 'react' import toast from 'react-hot-toast' import type { Config, PoolToken } from '.' -import { formatSymbol } from './common/format' +import { formatSymbol, uniswapChains } from './common/format' import { getPoolData } from './common/uniswap' -import { supportedChains } from './common/viem' export default function Inspector() { const [config, updateConfig] = useFrameConfig() @@ -136,7 +135,8 @@ export default function Inspector() { />

- Only the following networks are supported: {supportedChains} + Only the following networks are supported:{' '} + {uniswapChains.map((c) => c.label).join(', ')}

diff --git a/templates/swap/common/format.ts b/templates/swap/common/format.ts index 35eab1cf..ab7bb78b 100644 --- a/templates/swap/common/format.ts +++ b/templates/swap/common/format.ts @@ -1,3 +1,5 @@ +import { supportedChains } from '@/sdk/viem' + export function formatSymbol(amount: string | number, symbol: string) { const regex = /(USDT|USDC|DAI)/ if (regex.test(symbol)) { @@ -6,3 +8,7 @@ export function formatSymbol(amount: string | number, symbol: string) { return `${amount} ${symbol}` } + +export const uniswapChains = supportedChains.filter((chain) => + ['mainnet', 'optimism', 'arbitrum', 'base', 'polygon'].includes(chain.key) +) diff --git a/templates/swap/common/uniswap.ts b/templates/swap/common/uniswap.ts index 7ecd4df8..a01d88e1 100644 --- a/templates/swap/common/uniswap.ts +++ b/templates/swap/common/uniswap.ts @@ -1,7 +1,8 @@ 'use server' -import { erc20Abi, getContract } from 'viem' -import { chains, chainsByChainId, getClient } from './viem' +import { getViem } from '@/sdk/viem' import { parseAbi } from 'abitype' +import { erc20Abi, getContract } from 'viem' +import { uniswapChains } from './format' const uniswapAbi = parseAbi([ 'function token0() view returns (address)', @@ -9,8 +10,8 @@ const uniswapAbi = parseAbi([ ]) export const getPoolClient = async (address: `0x${string}`) => { - for (const chain of chains) { - const client = getClient(chain) + for (const chain of uniswapChains) { + const client = getViem(chain.key) const contract = getContract({ client, address, @@ -31,7 +32,7 @@ export const getPoolClient = async (address: `0x${string}`) => { async function getTokenData({ address, client, -}: { address: `0x${string}`; client: ReturnType }) { +}: { address: `0x${string}`; client: ReturnType }) { try { const token = getContract({ client, @@ -58,7 +59,12 @@ async function getTokenData({ } async function getTokenLogo(network: number, address: string) { - const url = `https://api.geckoterminal.com/api/v2/networks/${chainsByChainId[network]}/tokens/${address}` + const chain = uniswapChains.find((c) => c.id === network) + if (!chain) { + throw new Error('Invalid network') + } + const name = chain.key === 'mainnet' ? 'eth' : chain.key + const url = `https://api.geckoterminal.com/api/v2/networks/${name}/tokens/${address}` const response = await fetch(url) const data = (await response.json()) as { @@ -98,7 +104,7 @@ export async function getPoolData(address: `0x${string}`) { const network = { id: client.chain.id, name: client.chain.name, - explorerUrl: client.chain.blockExplorers.default.url, + explorerUrl: client.chain.blockExplorers!.default.url, } return { diff --git a/templates/swap/common/viem.ts b/templates/swap/common/viem.ts deleted file mode 100644 index 5d05aa1a..00000000 --- a/templates/swap/common/viem.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { createPublicClient, http } from 'viem' -import { base, mainnet, optimism, arbitrum, polygon } from 'viem/chains' - -/** - * Network Chain ID -Ethereum eip155:1 -Arbitrum eip155:42161 -Base eip155:8453 -Degen eip155:666666666 -Gnosis eip155:100 -Optimism eip155:10 -Zora eip155:7777777 -Polygon eip155:137 - */ - -/** Support chains */ -export const chains = ['ethereum', 'optimism', 'arbitrum', 'base', 'polygon'] as const -export type Chain = (typeof chains)[number] -export const chainsByChainId: Record = { - 1: 'eth', - 10: 'optimism', - 42161: 'arbitrum', - 8453: 'base', - 137: 'polygon', -} - -export const supportedChains = chains - .map((chain) => `${chain.charAt(0).toUpperCase()}${chain.slice(1)}`) - .join(', ') - -/** Get client for chain */ -export function getClient(chain: Chain) { - // Batch configuration for multicall optimizations - const batch = { multicall: { wait: 10, batchSize: 1000 } } - - switch (chain) { - case 'ethereum': - return createPublicClient({ - chain: mainnet, - transport: http(), - batch, - }) - - case 'optimism': - return createPublicClient({ - chain: optimism, - transport: http(), - batch, - }) - - case 'arbitrum': - return createPublicClient({ - chain: arbitrum, - transport: http(), - batch, - }) - - case 'base': - return createPublicClient({ - chain: base, - transport: http(), - batch, - }) - - case 'polygon': - return createPublicClient({ - chain: polygon, - transport: http(), - batch, - }) - - default: - throw new Error('Unsupported chain') - } -} diff --git a/templates/swap/handlers/estimate.ts b/templates/swap/handlers/estimate.ts index b3b5df18..83997c42 100644 --- a/templates/swap/handlers/estimate.ts +++ b/templates/swap/handlers/estimate.ts @@ -93,5 +93,9 @@ export default async function estimate({ ], component: PriceView({ token0, token1, network: config.pool.network, amount, estimates }), handler: 'success', + params: { + buyAmount: amount, + ts: Date.now(), + }, } } diff --git a/templates/swap/handlers/initial.ts b/templates/swap/handlers/initial.ts index 460d9b70..3ce3223f 100644 --- a/templates/swap/handlers/initial.ts +++ b/templates/swap/handlers/initial.ts @@ -61,6 +61,7 @@ export default async function initial({ newStorage = { ...storage, + swapData: storage?.swapData || [], livePriceData: { ...storage?.livePriceData, [token0.symbol.toLowerCase()]: { @@ -81,6 +82,7 @@ export default async function initial({ newStorage = { ...storage, + swapData: storage?.swapData || [], livePriceData: { ...storage?.livePriceData, [token0.symbol.toLowerCase()]: { diff --git a/templates/swap/handlers/success.ts b/templates/swap/handlers/success.ts index 85c57e86..48f12954 100644 --- a/templates/swap/handlers/success.ts +++ b/templates/swap/handlers/success.ts @@ -10,20 +10,22 @@ import initial from './initial' export default async function success({ body, config, + storage, + params, }: { body: FramePayloadValidated config: Config storage: Storage params: | { - buyAmount: string + ts: string } | undefined }): Promise { const transactionId = body.transaction?.hash const buttonIndex = body.tapped_button?.index || 1 - if (!config.pool) { + if (!(params && config.pool)) { return initial({ config }) } @@ -74,6 +76,36 @@ export default async function success({ ], handler: 'more', } + const ts = Number(params.ts) + const latestSwapData = storage.swapData[body.interactor.fid].find((tsData) => tsData.ts === ts) + const token0 = config.pool.primary === 'token0' ? config.pool.token0 : config.pool.token1 + const token1 = config.pool.primary === 'token0' ? config.pool.token1 : config.pool.token0 + + if (latestSwapData) { + buildData['webhooks'] = [ + { + event: 'swap.success', + data: { + fid: body.interactor.fid, + pool: { + address: config.pool.address, + chain: { id: config.pool.network.id, name: config.pool.network.name }, + pair: `${token0.symbol}/${token1.symbol}`, + }, + sell_token_symbol: token0.symbol, + sell_token_address: token0.address, + sell_token_decimals: token0.decimals, + buy_token_symbol: token1.symbol, + buy_token_address: token1.address, + buy_token_decimals: token1.decimals, + buy_amount: latestSwapData.amount[0], + sell_amount: latestSwapData.amount[1], + transaction_id: transactionId, + cast_url: `https://warpcast.com/~/conversations/${body.cast.hash}`, + }, + }, + ] + } if (config.success?.image) { buildData['image'] = config.success?.image diff --git a/templates/swap/handlers/txData.ts b/templates/swap/handlers/txData.ts index 977723ad..7218a5d0 100644 --- a/templates/swap/handlers/txData.ts +++ b/templates/swap/handlers/txData.ts @@ -1,13 +1,16 @@ 'use server' import type { BuildFrameData, FramePayloadValidated } from '@/lib/farcaster' import { FrameError } from '@/sdk/error' -import type { Config } from '..' +import type { Config, Storage } from '..' import { fetchQuote } from '../common/0x' import initial from './initial' +import { formatUnits } from 'viem' export default async function txData({ config, params, + storage, + body, }: { body: FramePayloadValidated config: Config @@ -15,6 +18,7 @@ export default async function txData({ params: | { buyAmount: string + ts: string } | undefined }): Promise { @@ -22,6 +26,8 @@ export default async function txData({ return initial({ config }) } + const fid = body.interactor.fid + const token0 = config.pool.primary === 'token0' ? config.pool.token0 : config.pool.token1 const token1 = config.pool.primary === 'token0' ? config.pool.token1 : config.pool.token0 @@ -37,8 +43,26 @@ export default async function txData({ throw new Error('Failed to fetch quote') } + const swapData = storage.swapData[fid] || [] + swapData.push({ + amount: [ + Number(order.buyAmount), + +formatUnits(BigInt(order.sellAmount), token0.decimals), + ], + ts: Number(params.ts), + }) + + const newStorage = { + ...storage, + swapData: { + ...storage.swapData, + [fid]: swapData, + }, + } + return { buttons: [], + storage: newStorage, transaction: { chainId: `eip155:${config.pool.network.id}`, method: 'eth_sendTransaction', diff --git a/templates/swap/index.ts b/templates/swap/index.ts index 83c4d2e3..1ad8d596 100644 --- a/templates/swap/index.ts +++ b/templates/swap/index.ts @@ -41,6 +41,12 @@ export interface Config extends BaseConfig { export interface Storage extends BaseStorage { livePriceData: Record + swapData: { + [fid: number]: { + amount: number[] // [buyAmount, sellAmount] + ts: number + }[] + } } export default { @@ -66,5 +72,5 @@ export default { bottomMessage: { text: 'We appreciate your support.' }, }, }, - events: [], + events: ['swap.success'], } satisfies BaseTemplate