From da6a39ba8455ed86f44269e2ae9f4376e5ea6854 Mon Sep 17 00:00:00 2001 From: aunali8812 Date: Fri, 17 Apr 2026 16:19:52 +0500 Subject: [PATCH 1/5] Pingpay level 1 integration --- .env.example | 36 +-- src/app/api/pingpay/create-checkout/route.ts | 93 +++++++ .../pingpay/create-project-checkout/route.ts | 91 +++++++ .../campaign/components/CampaignBanner.tsx | 10 + .../components/CampaignDonorsTable.tsx | 7 + .../pingpay/components/FastDonateButton.tsx | 58 +++++ .../components/FastDonateToProjectButton.tsx | 51 ++++ .../pingpay/components/PingPayModal.tsx | 226 ++++++++++++++++++ src/features/pingpay/constants.ts | 8 + src/features/pingpay/index.ts | 2 + .../profile/_deprecated/DonationItem.tsx | 12 +- src/layout/profile/components/summary.tsx | 6 + 12 files changed, 583 insertions(+), 17 deletions(-) create mode 100644 src/app/api/pingpay/create-checkout/route.ts create mode 100644 src/app/api/pingpay/create-project-checkout/route.ts create mode 100644 src/features/pingpay/components/FastDonateButton.tsx create mode 100644 src/features/pingpay/components/FastDonateToProjectButton.tsx create mode 100644 src/features/pingpay/components/PingPayModal.tsx create mode 100644 src/features/pingpay/constants.ts create mode 100644 src/features/pingpay/index.ts diff --git a/.env.example b/.env.example index 4189d9a7..a3d0c9e7 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,20 @@ -##* Deployment environment -PINATA_JWT= -NEXT_PUBLIC_IPFS_GATEWAY_URL=potlock.mypinata.cloud - -# test | staging | production -NEXT_PUBLIC_ENV=test - -##* Debugging and testing only -##! WARNING: DO NOT USE THESE VARIABLES IN PRODUCTION DEPLOYMENTS, OTHERWISE IT WILL BREAK UX - -# true | false -NEXT_PUBLIC_DEBUG=false - -# An account id of the account you want to impersonate for testing purposes -# Useful for permission control testing -NEXT_PUBLIC_DEBUG_VIEW_AS= +##* Deployment environment +PINATA_JWT= +NEXT_PUBLIC_IPFS_GATEWAY_URL=potlock.mypinata.cloud + +##* PingPay Hosted Checkout (server-side only — never expose to client) +## Get a key at https://pay.pingpay.io/dashboard +PINGPAY_API_KEY= + +# test | staging | production +NEXT_PUBLIC_ENV=test + +##* Debugging and testing only +##! WARNING: DO NOT USE THESE VARIABLES IN PRODUCTION DEPLOYMENTS, OTHERWISE IT WILL BREAK UX + +# true | false +NEXT_PUBLIC_DEBUG=false + +# An account id of the account you want to impersonate for testing purposes +# Useful for permission control testing +NEXT_PUBLIC_DEBUG_VIEW_AS= diff --git a/src/app/api/pingpay/create-checkout/route.ts b/src/app/api/pingpay/create-checkout/route.ts new file mode 100644 index 00000000..257e20be --- /dev/null +++ b/src/app/api/pingpay/create-checkout/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server"; + +import { CAMPAIGNS_CONTRACT_ACCOUNT_ID } from "@/common/_config"; + +export const dynamic = "force-dynamic"; + +const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; + +/** + * Creates a PingPay Hosted Checkout session that settles to the POTLOCK + * campaigns contract. Routing to a specific campaign is done via + * `customRecipientMsg`, which the contract's `ft_on_transfer` decodes. + * + * Setup requirement: in the PingPay dashboard, the NEAR recipient address + * must be set to {@link CAMPAIGNS_CONTRACT_ACCOUNT_ID}. PingPay's API ignores + * any per-request recipient and always settles to the dashboard-configured one. + * + * @link https://pingpay.gitbook.io/docs/pingpay-api/hosted-checkout/create-checkout + */ +export async function POST(req: Request) { + try { + const apiKey = process.env.PINGPAY_API_KEY; + + if (!apiKey) { + return NextResponse.json({ error: "PingPay API key not configured" }, { status: 500 }); + } + + const body = await req.json(); + const { + amount, + asset, + campaignId, + referrerAccountId, + donorMessage, + successUrl, + cancelUrl, + } = body ?? {}; + + if (!amount || campaignId === undefined || campaignId === null) { + return NextResponse.json( + { error: "Missing required fields: amount, campaignId" }, + { status: 400 }, + ); + } + + const taggedMessage = donorMessage ? `[via PingPay] ${donorMessage}` : "[via PingPay]"; + + const customRecipientMsg = JSON.stringify({ + campaign_id: typeof campaignId === "string" ? Number(campaignId) : campaignId, + referrer_id: referrerAccountId ?? null, + bypass_protocol_fee: false, + bypass_creator_fee: false, + message: taggedMessage, + }); + + const response = await fetch(`${PINGPAY_API_BASE}/checkout/sessions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + amount, + asset, + recipient: CAMPAIGNS_CONTRACT_ACCOUNT_ID, + customRecipientMsg, + successUrl, + cancelUrl, + metadata: { source: "potlock", campaignId: campaignId.toString() }, + }), + }); + + const text = await response.text(); + let data: any; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { error: text || "Non-JSON response from PingPay" }; + } + + if (!response.ok) { + return NextResponse.json( + { error: data?.message ?? data?.error ?? "Failed to create PingPay checkout" }, + { status: response.status }, + ); + } + + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("PingPay checkout error:", error); + return NextResponse.json({ error: "Failed to create checkout" }, { status: 500 }); + } +} diff --git a/src/app/api/pingpay/create-project-checkout/route.ts b/src/app/api/pingpay/create-project-checkout/route.ts new file mode 100644 index 00000000..96dbeb1b --- /dev/null +++ b/src/app/api/pingpay/create-project-checkout/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; + +import { DONATION_CONTRACT_ACCOUNT_ID } from "@/common/_config"; + +export const dynamic = "force-dynamic"; + +const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; + +/** + * Creates a PingPay Hosted Checkout session that settles to the POTLOCK + * donation contract for a direct project donation. Routing to the recipient + * account is done via `customRecipientMsg`, which the contract's + * `ft_on_transfer` decodes. + * + * Setup requirement: in the PingPay dashboard, the NEAR recipient address + * must be set to {@link DONATION_CONTRACT_ACCOUNT_ID}. PingPay's API ignores + * any per-request recipient and always settles to the dashboard-configured one. + */ +export async function POST(req: Request) { + try { + const apiKey = process.env.PINGPAY_API_KEY; + + if (!apiKey) { + return NextResponse.json({ error: "PingPay API key not configured" }, { status: 500 }); + } + + const body = await req.json(); + const { + amount, + asset, + recipientAccountId, + referrerAccountId, + donorMessage, + successUrl, + cancelUrl, + } = body ?? {}; + + if (!amount || !recipientAccountId) { + return NextResponse.json( + { error: "Missing required fields: amount, recipientAccountId" }, + { status: 400 }, + ); + } + + const taggedMessage = donorMessage ? `[via PingPay] ${donorMessage}` : "[via PingPay]"; + + const customRecipientMsg = JSON.stringify({ + recipient_id: recipientAccountId, + referrer_id: referrerAccountId ?? null, + bypass_protocol_fee: false, + message: taggedMessage, + }); + + const response = await fetch(`${PINGPAY_API_BASE}/checkout/sessions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + }, + body: JSON.stringify({ + amount, + asset, + recipient: DONATION_CONTRACT_ACCOUNT_ID, + customRecipientMsg, + successUrl, + cancelUrl, + metadata: { source: "potlock", recipientAccountId }, + }), + }); + + const text = await response.text(); + let data: any; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { error: text || "Non-JSON response from PingPay" }; + } + + if (!response.ok) { + return NextResponse.json( + { error: data?.message ?? data?.error ?? "Failed to create PingPay checkout" }, + { status: response.status }, + ); + } + + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("PingPay project checkout error:", error); + return NextResponse.json({ error: "Failed to create checkout" }, { status: 500 }); + } +} diff --git a/src/entities/campaign/components/CampaignBanner.tsx b/src/entities/campaign/components/CampaignBanner.tsx index d8cefd56..74feaeab 100644 --- a/src/entities/campaign/components/CampaignBanner.tsx +++ b/src/entities/campaign/components/CampaignBanner.tsx @@ -20,6 +20,7 @@ import { useWalletUserSession } from "@/common/wallet"; import { AccountProfileLink } from "@/entities/_shared/account"; import { useFungibleToken } from "@/entities/_shared/token"; import { DonateToCampaign } from "@/features/donation"; +import { FastDonateButton } from "@/features/pingpay"; import { CampaignProgressBar } from "./CampaignProgressBar"; import { useCampaignForm } from "../hooks/forms"; @@ -273,6 +274,15 @@ export const CampaignBanner: React.FC = ({ campaignId }) => {...{ campaignId }} /> + + = ({ campai cell: ({ row }) => { const donorId = row.original.donor_id; const isIntent = donorId.includes("potluck_intents.near"); + const isPingPay = (row.original.message ?? "").startsWith("[via PingPay]"); const explorerUrl = isIntent ? `https://nearblocks.io/address/${donorId}` @@ -49,6 +50,12 @@ export const CampaignDonorsTable: React.FC = ({ campai )} + {isPingPay && ( + + via PingPay + + )} + {row.original?.returned_at_ms && (

Refunded diff --git a/src/features/pingpay/components/FastDonateButton.tsx b/src/features/pingpay/components/FastDonateButton.tsx new file mode 100644 index 00000000..f3d3f916 --- /dev/null +++ b/src/features/pingpay/components/FastDonateButton.tsx @@ -0,0 +1,58 @@ +import { useCallback } from "react"; + +import { show } from "@ebay/nice-modal-react"; + +import { Button } from "@/common/ui/layout/components"; +import { cn } from "@/common/ui/layout/utils"; +import { useFungibleToken } from "@/entities/_shared/token"; + +import { PingPayModal } from "./PingPayModal"; + +export type FastDonateButtonProps = { + recipientAccountId: string; + tokenId: string; + campaignId?: string | number; + campaignName?: string; + className?: string; + disabled?: boolean; +}; + +export const FastDonateButton: React.FC = ({ + tokenId, + campaignId, + campaignName, + className, + disabled, +}) => { + const { data: token } = useFungibleToken({ tokenId }); + const tokenSymbol = token?.metadata?.symbol; + const tokenDecimals = token?.metadata?.decimals; + + const handleClick = useCallback(() => { + if (campaignId === undefined || !tokenSymbol || tokenDecimals === undefined) return; + + show(PingPayModal, { + tokenSymbol, + tokenDecimals, + campaignId, + campaignName, + }); + }, [tokenSymbol, tokenDecimals, campaignId, campaignName]); + + return ( + + ); +}; diff --git a/src/features/pingpay/components/FastDonateToProjectButton.tsx b/src/features/pingpay/components/FastDonateToProjectButton.tsx new file mode 100644 index 00000000..63cc731e --- /dev/null +++ b/src/features/pingpay/components/FastDonateToProjectButton.tsx @@ -0,0 +1,51 @@ +import { useCallback } from "react"; + +import { show } from "@ebay/nice-modal-react"; + +import { NATIVE_TOKEN_DECIMALS } from "@/common/constants"; +import { Button } from "@/common/ui/layout/components"; +import { cn } from "@/common/ui/layout/utils"; + +import { PingPayModal } from "./PingPayModal"; + +export type FastDonateToProjectButtonProps = { + recipientAccountId: string; + recipientName?: string; + className?: string; + disabled?: boolean; +}; + +export const FastDonateToProjectButton: React.FC = ({ + recipientAccountId, + recipientName, + className, + disabled, +}) => { + const handleClick = useCallback(() => { + if (!recipientAccountId) return; + + show(PingPayModal, { + tokenSymbol: "NEAR", + tokenDecimals: NATIVE_TOKEN_DECIMALS, + recipientAccountId, + recipientName, + }); + }, [recipientAccountId, recipientName]); + + return ( + + ); +}; diff --git a/src/features/pingpay/components/PingPayModal.tsx b/src/features/pingpay/components/PingPayModal.tsx new file mode 100644 index 00000000..5ab84fc9 --- /dev/null +++ b/src/features/pingpay/components/PingPayModal.tsx @@ -0,0 +1,226 @@ +import { useCallback, useState } from "react"; + +import { create, useModal } from "@ebay/nice-modal-react"; +import { useForm } from "react-hook-form"; + +import { floatToIndivisible } from "@/common/lib/format"; +import { TextField } from "@/common/ui/form/components"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Form, + FormField, +} from "@/common/ui/layout/components"; + +export type PingPayModalProps = { + tokenSymbol: string; + tokenDecimals: number; +} & ( + | { + campaignId: string | number; + campaignName?: string; + recipientAccountId?: never; + recipientName?: never; + } + | { + recipientAccountId: string; + recipientName?: string; + campaignId?: never; + campaignName?: never; + } +); + +type PingPayFormValues = { + amount: number; +}; + +export const PingPayModal = create((props: PingPayModalProps) => { + const { tokenSymbol, tokenDecimals } = props; + const campaignId = "campaignId" in props ? props.campaignId : undefined; + const campaignName = "campaignName" in props ? props.campaignName : undefined; + const recipientAccountId = + "recipientAccountId" in props ? props.recipientAccountId : undefined; + const recipientName = "recipientName" in props ? props.recipientName : undefined; + const isCampaign = campaignId !== undefined; + + const self = useModal(); + + const form = useForm({ + mode: "onChange", + defaultValues: { amount: 0.1 }, + }); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const close = useCallback(() => { + self.hide(); + self.remove(); + }, [self]); + + const onSubmit = form.handleSubmit(async ({ amount: amountValue }) => { + const amountFloat = Number(amountValue); + + if (!Number.isFinite(amountFloat) || amountFloat <= 0) { + setError("Please enter a valid positive amount."); + return; + } + + setError(null); + setIsSubmitting(true); + + try { + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const indivisibleAmount = floatToIndivisible(amountFloat, tokenDecimals).toString(); + + const endpoint = isCampaign + ? "/api/pingpay/create-checkout" + : "/api/pingpay/create-project-checkout"; + + const successUrl = isCampaign + ? `${origin}/campaign/${campaignId}` + : `${origin}/profile/${recipientAccountId}`; + + const cancelUrl = successUrl; + + const requestBody = isCampaign + ? { + amount: indivisibleAmount, + asset: { chain: "NEAR", symbol: tokenSymbol }, + campaignId, + successUrl, + cancelUrl, + } + : { + amount: indivisibleAmount, + asset: { chain: "NEAR", symbol: tokenSymbol }, + recipientAccountId, + successUrl, + cancelUrl, + }; + + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }); + + const data = await res.json(); + const sessionUrl = data?.session?.sessionUrl ?? data?.sessionUrl; + + if (!res.ok || !sessionUrl) { + setError(data?.error ?? "Failed to create payment link."); + return; + } + + const width = 500; + const height = 720; + const left = window.screenX + Math.max(0, (window.outerWidth - width) / 2); + const top = window.screenY + Math.max(0, (window.outerHeight - height) / 2); + + const features = [ + `width=${width}`, + `height=${height}`, + `left=${Math.round(left)}`, + `top=${Math.round(top)}`, + "resizable=yes", + "scrollbars=yes", + "noopener", + "noreferrer", + ].join(","); + + const popup = window.open(sessionUrl, "pingpay-checkout", features); + + if (!popup) { + setError("Popup blocked. Please allow popups for this site and try again."); + return; + } + + popup.focus?.(); + close(); + } catch { + setError("Failed to create payment link."); + } finally { + setIsSubmitting(false); + } + }); + + const title = isCampaign + ? campaignName + ? `Donate to ${campaignName} Campaign` + : "Create Payment link" + : recipientName + ? `Donate to ${recipientName}` + : "Create Payment link"; + + return ( +

+ + + {title} + + +
+ + + ( + field.onChange(e.target.valueAsNumber)} + onClick={undefined} + onBlur={undefined} + onFocus={undefined} + type="number" + placeholder="0.00" + min={0} + step={0.01} + appendix={tokenSymbol} + /> + )} + /> + + {error &&

{error}

} + +

+ {`You'll be redirected to PingPay to pay with card, wallet, or any supported asset. Funds settle as ${tokenSymbol} to ${ + isCampaign ? "this campaign" : "this project" + }.`} +

+
+ + + + + + +
+ +
+
+ ); +}); diff --git a/src/features/pingpay/constants.ts b/src/features/pingpay/constants.ts new file mode 100644 index 00000000..a1c09a0d --- /dev/null +++ b/src/features/pingpay/constants.ts @@ -0,0 +1,8 @@ +/** + * Reserved for future PingPay-related constants. + * Token gating was previously done here but removed: PingPay's Hosted Checkout + * accepts whatever NEAR-chain asset symbol you pass; the API surfaces an error + * if the asset is unsupported. The button now enables for every campaign and + * defers asset validation to PingPay. + */ +export {}; diff --git a/src/features/pingpay/index.ts b/src/features/pingpay/index.ts new file mode 100644 index 00000000..f2a9cf9d --- /dev/null +++ b/src/features/pingpay/index.ts @@ -0,0 +1,2 @@ +export { FastDonateButton } from "./components/FastDonateButton"; +export { FastDonateToProjectButton } from "./components/FastDonateToProjectButton"; diff --git a/src/layout/profile/_deprecated/DonationItem.tsx b/src/layout/profile/_deprecated/DonationItem.tsx index a73bfb0f..2ed347c6 100644 --- a/src/layout/profile/_deprecated/DonationItem.tsx +++ b/src/layout/profile/_deprecated/DonationItem.tsx @@ -76,8 +76,11 @@ export const DonationItem = ({ recipient: _recipient, donated_at, token, + message, } = donation; + const isPingPay = (message ?? "").startsWith("[via PingPay]"); + const { id: donorId } = donor; const potId = pot?.id; const baseCurrency = pot?.base_currency; @@ -124,7 +127,14 @@ export const DonationItem = ({ )}{" "} {name} -
{isPot ? "Matched donation" : "Direct donation"}
+
+ {isPot ? "Matched donation" : "Direct donation"} + {isPingPay && ( + + via PingPay + + )} +
diff --git a/src/layout/profile/components/summary.tsx b/src/layout/profile/components/summary.tsx index 91da771e..dfd64732 100644 --- a/src/layout/profile/components/summary.tsx +++ b/src/layout/profile/components/summary.tsx @@ -21,6 +21,7 @@ import { useAccountSocialProfile, } from "@/entities/_shared/account"; import { DonateToAccountButton } from "@/features/donation"; +import { FastDonateToProjectButton } from "@/features/pingpay"; import { rootPathnames, routeSelectors } from "@/navigation"; const Linktree: React.FC = ({ accountId }) => { @@ -159,6 +160,11 @@ export const ProfileLayoutSummary: React.FC = ({ acco
+ + From 4837922260ba7257c77368ad8553d31980d3ad70 Mon Sep 17 00:00:00 2001 From: aunali8812 Date: Fri, 17 Apr 2026 16:23:24 +0500 Subject: [PATCH 2/5] fix linting issue --- src/app/api/pingpay/create-checkout/route.ts | 13 ++++--------- .../api/pingpay/create-project-checkout/route.ts | 2 ++ .../pingpay/components/FastDonateButton.tsx | 4 +--- src/features/pingpay/components/PingPayModal.tsx | 5 +++-- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/app/api/pingpay/create-checkout/route.ts b/src/app/api/pingpay/create-checkout/route.ts index 257e20be..66892660 100644 --- a/src/app/api/pingpay/create-checkout/route.ts +++ b/src/app/api/pingpay/create-checkout/route.ts @@ -26,15 +26,9 @@ export async function POST(req: Request) { } const body = await req.json(); - const { - amount, - asset, - campaignId, - referrerAccountId, - donorMessage, - successUrl, - cancelUrl, - } = body ?? {}; + + const { amount, asset, campaignId, referrerAccountId, donorMessage, successUrl, cancelUrl } = + body ?? {}; if (!amount || campaignId === undefined || campaignId === null) { return NextResponse.json( @@ -72,6 +66,7 @@ export async function POST(req: Request) { const text = await response.text(); let data: any; + try { data = text ? JSON.parse(text) : {}; } catch { diff --git a/src/app/api/pingpay/create-project-checkout/route.ts b/src/app/api/pingpay/create-project-checkout/route.ts index 96dbeb1b..8cc15615 100644 --- a/src/app/api/pingpay/create-project-checkout/route.ts +++ b/src/app/api/pingpay/create-project-checkout/route.ts @@ -25,6 +25,7 @@ export async function POST(req: Request) { } const body = await req.json(); + const { amount, asset, @@ -70,6 +71,7 @@ export async function POST(req: Request) { const text = await response.text(); let data: any; + try { data = text ? JSON.parse(text) : {}; } catch { diff --git a/src/features/pingpay/components/FastDonateButton.tsx b/src/features/pingpay/components/FastDonateButton.tsx index f3d3f916..9ceebc5e 100644 --- a/src/features/pingpay/components/FastDonateButton.tsx +++ b/src/features/pingpay/components/FastDonateButton.tsx @@ -48,9 +48,7 @@ export const FastDonateButton: React.FC = ({ e.stopPropagation(); handleClick(); }} - aria-label={ - campaignName ? `Create Payment link for ${campaignName}` : "Create Payment link" - } + aria-label={campaignName ? `Create Payment link for ${campaignName}` : "Create Payment link"} > {tokenSymbol ? "Create Payment link" : "Loading…"} diff --git a/src/features/pingpay/components/PingPayModal.tsx b/src/features/pingpay/components/PingPayModal.tsx index 5ab84fc9..a8f949aa 100644 --- a/src/features/pingpay/components/PingPayModal.tsx +++ b/src/features/pingpay/components/PingPayModal.tsx @@ -43,8 +43,9 @@ export const PingPayModal = create((props: PingPayModalProps) => { const { tokenSymbol, tokenDecimals } = props; const campaignId = "campaignId" in props ? props.campaignId : undefined; const campaignName = "campaignName" in props ? props.campaignName : undefined; - const recipientAccountId = - "recipientAccountId" in props ? props.recipientAccountId : undefined; + + const recipientAccountId = "recipientAccountId" in props ? props.recipientAccountId : undefined; + const recipientName = "recipientName" in props ? props.recipientName : undefined; const isCampaign = campaignId !== undefined; From dfb987e91095e1267c12ccac348e0c63a340cc3f Mon Sep 17 00:00:00 2001 From: aunali8812 Date: Fri, 17 Apr 2026 17:20:14 +0500 Subject: [PATCH 3/5] Commiing missed file --- src/layout/profile/components/summary.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/layout/profile/components/summary.tsx b/src/layout/profile/components/summary.tsx index dfd64732..6626b3fa 100644 --- a/src/layout/profile/components/summary.tsx +++ b/src/layout/profile/components/summary.tsx @@ -161,10 +161,7 @@ export const ProfileLayoutSummary: React.FC = ({ acco - + From 62ad0ade3edd17ba953077ae5df37e3027495588 Mon Sep 17 00:00:00 2001 From: aunali8812 Date: Mon, 27 Apr 2026 22:04:39 +0500 Subject: [PATCH 4/5] Complete Pingpay project donation integration --- src/app/api/pingpay/create-checkout/route.ts | 31 ++- .../pingpay/create-project-checkout/route.ts | 23 +- src/app/api/pingpay/session-status/route.ts | 75 +++++++ src/app/api/pingpay/verify-tx/route.ts | 132 ++++++++++++ src/app/layout.tsx | 12 ++ src/app/pingpay/callback/page.tsx | 147 +++++++++++++ .../pingpay/components/FastDonateButton.tsx | 18 +- .../components/FastDonateToProjectButton.tsx | 5 +- .../pingpay/components/PingPayModal.tsx | 197 +++++++++++------- src/pages/_app.tsx | 34 +++ 10 files changed, 572 insertions(+), 102 deletions(-) create mode 100644 src/app/api/pingpay/session-status/route.ts create mode 100644 src/app/api/pingpay/verify-tx/route.ts create mode 100644 src/app/layout.tsx create mode 100644 src/app/pingpay/callback/page.tsx diff --git a/src/app/api/pingpay/create-checkout/route.ts b/src/app/api/pingpay/create-checkout/route.ts index 66892660..70195199 100644 --- a/src/app/api/pingpay/create-checkout/route.ts +++ b/src/app/api/pingpay/create-checkout/route.ts @@ -6,6 +6,10 @@ export const dynamic = "force-dynamic"; const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; +// TODO: move to env var and rotate before going public. +const PINGPAY_API_KEY_FALLBACK = + "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; + /** * Creates a PingPay Hosted Checkout session that settles to the POTLOCK * campaigns contract. Routing to a specific campaign is done via @@ -19,16 +23,20 @@ const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io */ export async function POST(req: Request) { try { - const apiKey = process.env.PINGPAY_API_KEY; - - if (!apiKey) { - return NextResponse.json({ error: "PingPay API key not configured" }, { status: 500 }); - } + const apiKey = process.env.PINGPAY_API_KEY ?? PINGPAY_API_KEY_FALLBACK; const body = await req.json(); - const { amount, asset, campaignId, referrerAccountId, donorMessage, successUrl, cancelUrl } = - body ?? {}; + const { + amount, + asset, + campaignId, + referrerAccountId, + donorAccountId, + donorMessage, + successUrl, + cancelUrl, + } = body ?? {}; if (!amount || campaignId === undefined || campaignId === null) { return NextResponse.json( @@ -37,7 +45,8 @@ export async function POST(req: Request) { ); } - const taggedMessage = donorMessage ? `[via PingPay] ${donorMessage}` : "[via PingPay]"; + const donorTag = donorAccountId ? `[PingPay donor: ${donorAccountId}]` : "[via PingPay]"; + const taggedMessage = donorMessage ? `${donorTag} ${donorMessage}` : donorTag; const customRecipientMsg = JSON.stringify({ campaign_id: typeof campaignId === "string" ? Number(campaignId) : campaignId, @@ -60,7 +69,11 @@ export async function POST(req: Request) { customRecipientMsg, successUrl, cancelUrl, - metadata: { source: "potlock", campaignId: campaignId.toString() }, + metadata: { + source: "potlock", + campaignId: campaignId.toString(), + donorAccountId: donorAccountId ?? null, + }, }), }); diff --git a/src/app/api/pingpay/create-project-checkout/route.ts b/src/app/api/pingpay/create-project-checkout/route.ts index 8cc15615..e37dae5d 100644 --- a/src/app/api/pingpay/create-project-checkout/route.ts +++ b/src/app/api/pingpay/create-project-checkout/route.ts @@ -6,6 +6,10 @@ export const dynamic = "force-dynamic"; const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; +// TODO: move to env var and rotate before going public. +const PINGPAY_API_KEY_FALLBACK = + "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; + /** * Creates a PingPay Hosted Checkout session that settles to the POTLOCK * donation contract for a direct project donation. Routing to the recipient @@ -18,11 +22,7 @@ const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io */ export async function POST(req: Request) { try { - const apiKey = process.env.PINGPAY_API_KEY; - - if (!apiKey) { - return NextResponse.json({ error: "PingPay API key not configured" }, { status: 500 }); - } + const apiKey = process.env.PINGPAY_API_KEY ?? PINGPAY_API_KEY_FALLBACK; const body = await req.json(); @@ -31,6 +31,7 @@ export async function POST(req: Request) { asset, recipientAccountId, referrerAccountId, + donorAccountId, donorMessage, successUrl, cancelUrl, @@ -43,7 +44,11 @@ export async function POST(req: Request) { ); } - const taggedMessage = donorMessage ? `[via PingPay] ${donorMessage}` : "[via PingPay]"; + // Embed donor account in the on-chain message so the real donor is + // recoverable — sender_id on the FT transfer is intent.near (PingPay's + // settlement gateway), not the human donor. + const donorTag = donorAccountId ? `[PingPay donor: ${donorAccountId}]` : "[via PingPay]"; + const taggedMessage = donorMessage ? `${donorTag} ${donorMessage}` : donorTag; const customRecipientMsg = JSON.stringify({ recipient_id: recipientAccountId, @@ -65,7 +70,11 @@ export async function POST(req: Request) { customRecipientMsg, successUrl, cancelUrl, - metadata: { source: "potlock", recipientAccountId }, + metadata: { + source: "potlock", + recipientAccountId, + donorAccountId: donorAccountId ?? null, + }, }), }); diff --git a/src/app/api/pingpay/session-status/route.ts b/src/app/api/pingpay/session-status/route.ts new file mode 100644 index 00000000..e0b98545 --- /dev/null +++ b/src/app/api/pingpay/session-status/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; + +// TODO: move to env var and rotate before going public. +const PINGPAY_API_KEY_FALLBACK = + "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; + +/** + * Fetches PingPay session + payment details to extract txHash and sender. + * Used by the callback page to fire the correct sync call. + */ +export async function GET(req: Request) { + try { + const apiKey = process.env.PINGPAY_API_KEY ?? PINGPAY_API_KEY_FALLBACK; + + const { searchParams } = new URL(req.url); + const sessionId = searchParams.get("sessionId"); + + if (!sessionId) { + return NextResponse.json({ error: "Missing sessionId" }, { status: 400 }); + } + + // Fetch session to get paymentId + const sessionRes = await fetch(`${PINGPAY_API_BASE}/checkout/sessions/${sessionId}`, { + headers: { "x-api-key": apiKey }, + }); + + if (!sessionRes.ok) { + return NextResponse.json({ error: "Failed to fetch session" }, { status: sessionRes.status }); + } + + const sessionData = await sessionRes.json(); + const paymentId = sessionData?.session?.paymentId; + + if (!paymentId) { + return NextResponse.json({ + status: sessionData?.session?.status ?? "UNKNOWN", + txHash: null, + senderId: null, + }); + } + + // Fetch payment to get txHash and sender + const paymentRes = await fetch(`${PINGPAY_API_BASE}/payments/${paymentId}`, { + headers: { "x-api-key": apiKey }, + }); + + if (!paymentRes.ok) { + return NextResponse.json({ + status: sessionData?.session?.status ?? "UNKNOWN", + txHash: null, + senderId: null, + }); + } + + const paymentData = await paymentRes.json(); + const payment = paymentData?.payment; + + return NextResponse.json({ + status: payment?.status ?? sessionData?.session?.status ?? "UNKNOWN", + txHash: payment?.txHash ?? null, + senderId: payment?.request?.payer?.address ?? null, + recipient: payment?.request?.recipient?.address ?? null, + amount: payment?.request?.amount ?? null, + assetSymbol: payment?.request?.asset?.symbol ?? null, + sessionMetadata: sessionData?.session?.metadata ?? null, + }); + } catch (error) { + console.error("PingPay session-status error:", error); + return NextResponse.json({ error: "Failed to fetch session status" }, { status: 500 }); + } +} diff --git a/src/app/api/pingpay/verify-tx/route.ts b/src/app/api/pingpay/verify-tx/route.ts new file mode 100644 index 00000000..b0ce0743 --- /dev/null +++ b/src/app/api/pingpay/verify-tx/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server"; + +import { DONATION_CONTRACT_ACCOUNT_ID, NETWORK } from "@/common/_config"; + +export const dynamic = "force-dynamic"; + +const NEAR_RPC_URL = + NETWORK === "mainnet" ? "https://rpc.mainnet.near.org" : "https://rpc.testnet.near.org"; + +/** + * Confirms a PingPay-sourced donation actually landed on-chain (not refunded). + * + * Why: PingPay settles through intent.near. The deployed donation contract's + * ft_on_transfer can refund when it can't attribute the donor. Before we call + * the sync API (which makes the donation visible in history), we verify the + * transaction: + * 1. Succeeded. + * 2. Produced a `donation` log event (standard: potlock) on the donation + * contract for the expected recipient + amount. + * + * If the log is missing, the donation was refunded — we must not sync it. + */ +export async function POST(req: Request) { + try { + const body = await req.json(); + const { txHash, senderId, expectedRecipient, expectedAmount } = body ?? {}; + + if (!txHash || !senderId) { + return NextResponse.json({ error: "Missing txHash or senderId" }, { status: 400 }); + } + + const rpcRes = await fetch(NEAR_RPC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "verify", + method: "EXPERIMENTAL_tx_status", + params: [txHash, senderId, "EXECUTED_OPTIMISTIC"], + }), + }); + + if (!rpcRes.ok) { + return NextResponse.json({ error: `RPC returned ${rpcRes.status}` }, { status: 502 }); + } + + const rpcData = await rpcRes.json(); + + if (rpcData?.error) { + return NextResponse.json( + { landed: false, reason: "rpc_error", detail: rpcData.error }, + { status: 200 }, + ); + } + + const receipts: any[] = rpcData?.result?.receipts_outcome ?? []; + const donationLog = findDonationLog(receipts, expectedRecipient); + + if (!donationLog) { + return NextResponse.json( + { + landed: false, + reason: "no_donation_log", + note: "Donation contract did not emit a donation event for the expected recipient — likely refunded.", + }, + { status: 200 }, + ); + } + + if (expectedAmount && donationLog.net_amount && donationLog.total_amount) { + // Basic sanity: total should be >= expected (we sent amount, fees deducted net) + const total = BigInt(donationLog.total_amount); + const expected = BigInt(expectedAmount); + + if (total < expected / 2n) { + return NextResponse.json( + { + landed: false, + reason: "amount_mismatch", + onChainTotal: donationLog.total_amount, + expected: expectedAmount, + }, + { status: 200 }, + ); + } + } + + return NextResponse.json({ + landed: true, + donation: donationLog, + }); + } catch (error) { + console.error("PingPay verify-tx error:", error); + return NextResponse.json({ error: "Failed to verify tx" }, { status: 500 }); + } +} + +/** + * Walks receipts_outcome looking for a `donation` event emitted by the donation + * contract for the expected recipient. The contract emits + * `EVENT_JSON:{"standard":"potlock","event":"donation","data":[{"donation":{...}}]}` + * after `ft_on_transfer` successfully forwards funds. If no matching event is + * found the donation was refunded. + */ +function findDonationLog(receipts: any[], expectedRecipient?: string): any | null { + for (const receipt of receipts) { + if (receipt?.outcome?.executor_id !== DONATION_CONTRACT_ACCOUNT_ID) continue; + + const logs: string[] = receipt?.outcome?.logs ?? []; + + for (const log of logs) { + if (!log.startsWith("EVENT_JSON:")) continue; + + try { + const event = JSON.parse(log.slice("EVENT_JSON:".length)); + if (event?.event !== "donation") continue; + + const data = Array.isArray(event?.data) ? event.data[0] : event?.data; + const donation = data?.donation ?? data; + if (!donation) continue; + + if (expectedRecipient && donation.recipient_id !== expectedRecipient) continue; + + return donation; + } catch { + // skip malformed event + } + } + } + + return null; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000..c9194cf7 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,12 @@ +export const metadata = { + title: "Next.js", + description: "Generated by Next.js", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/pingpay/callback/page.tsx b/src/app/pingpay/callback/page.tsx new file mode 100644 index 00000000..2dc9d30c --- /dev/null +++ b/src/app/pingpay/callback/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; + +import { Loader2 } from "lucide-react"; +import { useSearchParams } from "next/navigation"; + +import { syncApi } from "@/common/api/indexer/sync"; + +const Spinner = () => ( +
+ +
+); + +function PingPayCallbackInner() { + const searchParams = useSearchParams(); + const status = searchParams?.get("status") ?? null; + const type = searchParams?.get("type") ?? null; + const id = searchParams?.get("id") ?? null; + + const [showFallback, setShowFallback] = useState(false); + + const returnUrl = + type === "campaign" ? `/campaign/${id}` : type === "project" ? `/profile/${id}` : "/"; + + useEffect(() => { + const run = async () => { + if (status === "success" && id) { + try { + let sessionId: string | null = null; + + try { + sessionId = localStorage.getItem("pingpay_session_id"); + localStorage.removeItem("pingpay_session_id"); + localStorage.removeItem("pingpay_donor_account_id"); + } catch { + // localStorage unavailable + } + + if (sessionId) { + const data = await pollSessionStatus(sessionId); + const txHash = data?.txHash ?? null; + + // PingPay routes donations through NEAR Intents — the on-chain tx + // signer is always `intents.near`, regardless of which user wallet + // PingPay reports as the payer. The sync endpoint passes this to + // NEAR RPC's `tx` method, which requires the actual signer. + const senderId = "intents.near"; + + if (txHash) { + if (type === "campaign") { + await syncApi.campaignDonation(id, txHash, senderId); + } else if (type === "project") { + await syncApi.directDonation(txHash, senderId); + // Recalculate the recipient's aggregate stats (total_donations_in_usd, + // donors_count) — the donation sync only inserts the row. + await syncApi.account(id); + } + } + } + } catch (e) { + console.error("PingPay sync error:", e); + } + } + + try { + const channel = new BroadcastChannel("pingpay"); + channel.postMessage({ type: "pingpay-complete", paymentStatus: status, kind: type, id }); + channel.close(); + } catch { + // BroadcastChannel unsupported + } + + window.close(); + setTimeout(() => setShowFallback(true), 800); + }; + + run(); + }, [status, type, id]); + + if (!showFallback) return ; + + return ( +
+
+

+ {"Donation processed. You can close this tab."} +

+ + {"Back to Potlock"} + +
+
+ ); +} + +export default function PingPayCallbackPage() { + return ( + }> + + + ); +} + +async function pollSessionStatus(sessionId: string): Promise<{ + status: string; + txHash: string | null; + senderId: string | null; +} | null> { + const maxAttempts = 15; + const delayMs = 2000; + + let lastData: { + status: string; + txHash: string | null; + senderId: string | null; + } | null = null; + + for (let i = 0; i < maxAttempts; i++) { + try { + const res = await fetch( + `/api/pingpay/session-status?sessionId=${encodeURIComponent(sessionId)}`, + ); + + if (res.ok) { + const data = await res.json(); + lastData = data; + const s = (data.status ?? "").toUpperCase(); + const isFail = s === "FAILED" || s === "CANCELLED" || s === "EXPIRED"; + const isSuccess = s === "COMPLETED" || s === "SUCCESS" || s === "SUCCEEDED"; + + if (isFail) return data; + if (isSuccess && data.txHash) return data; + } + } catch { + // retry + } + + await new Promise((r) => setTimeout(r, delayMs)); + } + + return lastData; +} diff --git a/src/features/pingpay/components/FastDonateButton.tsx b/src/features/pingpay/components/FastDonateButton.tsx index 9ceebc5e..40d46c2d 100644 --- a/src/features/pingpay/components/FastDonateButton.tsx +++ b/src/features/pingpay/components/FastDonateButton.tsx @@ -4,7 +4,6 @@ import { show } from "@ebay/nice-modal-react"; import { Button } from "@/common/ui/layout/components"; import { cn } from "@/common/ui/layout/utils"; -import { useFungibleToken } from "@/entities/_shared/token"; import { PingPayModal } from "./PingPayModal"; @@ -18,31 +17,26 @@ export type FastDonateButtonProps = { }; export const FastDonateButton: React.FC = ({ - tokenId, campaignId, campaignName, className, disabled, }) => { - const { data: token } = useFungibleToken({ tokenId }); - const tokenSymbol = token?.metadata?.symbol; - const tokenDecimals = token?.metadata?.decimals; - const handleClick = useCallback(() => { - if (campaignId === undefined || !tokenSymbol || tokenDecimals === undefined) return; + if (campaignId === undefined) return; show(PingPayModal, { - tokenSymbol, - tokenDecimals, + tokenSymbol: "USDC", + tokenDecimals: 6, campaignId, campaignName, }); - }, [tokenSymbol, tokenDecimals, campaignId, campaignName]); + }, [campaignId, campaignName]); return ( ); }; diff --git a/src/features/pingpay/components/FastDonateToProjectButton.tsx b/src/features/pingpay/components/FastDonateToProjectButton.tsx index 63cc731e..9962c9d7 100644 --- a/src/features/pingpay/components/FastDonateToProjectButton.tsx +++ b/src/features/pingpay/components/FastDonateToProjectButton.tsx @@ -2,7 +2,6 @@ import { useCallback } from "react"; import { show } from "@ebay/nice-modal-react"; -import { NATIVE_TOKEN_DECIMALS } from "@/common/constants"; import { Button } from "@/common/ui/layout/components"; import { cn } from "@/common/ui/layout/utils"; @@ -25,8 +24,8 @@ export const FastDonateToProjectButton: React.FC if (!recipientAccountId) return; show(PingPayModal, { - tokenSymbol: "NEAR", - tokenDecimals: NATIVE_TOKEN_DECIMALS, + tokenSymbol: "USDC", + tokenDecimals: 6, recipientAccountId, recipientName, }); diff --git a/src/features/pingpay/components/PingPayModal.tsx b/src/features/pingpay/components/PingPayModal.tsx index a8f949aa..0834faa1 100644 --- a/src/features/pingpay/components/PingPayModal.tsx +++ b/src/features/pingpay/components/PingPayModal.tsx @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import { create, useModal } from "@ebay/nice-modal-react"; +import { Check, Copy, ExternalLink } from "lucide-react"; import { useForm } from "react-hook-form"; import { floatToIndivisible } from "@/common/lib/format"; @@ -16,6 +17,7 @@ import { Form, FormField, } from "@/common/ui/layout/components"; +import { useWalletUserSession } from "@/common/wallet"; export type PingPayModalProps = { tokenSymbol: string; @@ -43,13 +45,15 @@ export const PingPayModal = create((props: PingPayModalProps) => { const { tokenSymbol, tokenDecimals } = props; const campaignId = "campaignId" in props ? props.campaignId : undefined; const campaignName = "campaignName" in props ? props.campaignName : undefined; - - const recipientAccountId = "recipientAccountId" in props ? props.recipientAccountId : undefined; - + const recipientAccountId = + "recipientAccountId" in props ? props.recipientAccountId : undefined; const recipientName = "recipientName" in props ? props.recipientName : undefined; const isCampaign = campaignId !== undefined; const self = useModal(); + const walletUser = useWalletUserSession(); + const donorAccountId = + walletUser.isSignedIn && walletUser.accountId ? walletUser.accountId : null; const form = useForm({ mode: "onChange", @@ -58,12 +62,28 @@ export const PingPayModal = create((props: PingPayModalProps) => { const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const [sessionUrl, setSessionUrl] = useState(null); + const [copied, setCopied] = useState(false); const close = useCallback(() => { self.hide(); self.remove(); }, [self]); + const handleCopy = useCallback(() => { + if (!sessionUrl) return; + navigator.clipboard.writeText(sessionUrl).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [sessionUrl]); + + const handlePayNow = useCallback(() => { + if (!sessionUrl) return; + window.open(sessionUrl, "_blank", "noopener,noreferrer"); + close(); + }, [sessionUrl, close]); + const onSubmit = form.handleSubmit(async ({ amount: amountValue }) => { const amountFloat = Number(amountValue); @@ -72,6 +92,11 @@ export const PingPayModal = create((props: PingPayModalProps) => { return; } + if (!donorAccountId) { + setError("Please connect your NEAR wallet before creating a payment link."); + return; + } + setError(null); setIsSubmitting(true); @@ -84,16 +109,19 @@ export const PingPayModal = create((props: PingPayModalProps) => { : "/api/pingpay/create-project-checkout"; const successUrl = isCampaign - ? `${origin}/campaign/${campaignId}` - : `${origin}/profile/${recipientAccountId}`; + ? `${origin}/pingpay/callback?type=campaign&id=${campaignId}&status=success` + : `${origin}/pingpay/callback?type=project&id=${encodeURIComponent(recipientAccountId ?? "")}&status=success`; - const cancelUrl = successUrl; + const cancelUrl = isCampaign + ? `${origin}/pingpay/callback?type=campaign&id=${campaignId}&status=cancelled` + : `${origin}/pingpay/callback?type=project&id=${encodeURIComponent(recipientAccountId ?? "")}&status=cancelled`; const requestBody = isCampaign ? { amount: indivisibleAmount, asset: { chain: "NEAR", symbol: tokenSymbol }, campaignId, + donorAccountId, successUrl, cancelUrl, } @@ -101,6 +129,7 @@ export const PingPayModal = create((props: PingPayModalProps) => { amount: indivisibleAmount, asset: { chain: "NEAR", symbol: tokenSymbol }, recipientAccountId, + donorAccountId, successUrl, cancelUrl, }; @@ -112,38 +141,25 @@ export const PingPayModal = create((props: PingPayModalProps) => { }); const data = await res.json(); - const sessionUrl = data?.session?.sessionUrl ?? data?.sessionUrl; + const url = data?.session?.sessionUrl ?? data?.sessionUrl; + const sid = data?.session?.sessionId; - if (!res.ok || !sessionUrl) { + if (!res.ok || !url) { setError(data?.error ?? "Failed to create payment link."); return; } - const width = 500; - const height = 720; - const left = window.screenX + Math.max(0, (window.outerWidth - width) / 2); - const top = window.screenY + Math.max(0, (window.outerHeight - height) / 2); - - const features = [ - `width=${width}`, - `height=${height}`, - `left=${Math.round(left)}`, - `top=${Math.round(top)}`, - "resizable=yes", - "scrollbars=yes", - "noopener", - "noreferrer", - ].join(","); - - const popup = window.open(sessionUrl, "pingpay-checkout", features); - - if (!popup) { - setError("Popup blocked. Please allow popups for this site and try again."); - return; + // Store sessionId + donor so the callback page can attribute the sync + if (sid) { + try { + localStorage.setItem("pingpay_session_id", sid); + localStorage.setItem("pingpay_donor_account_id", donorAccountId); + } catch { + // localStorage unavailable, sync will be best-effort + } } - popup.focus?.(); - close(); + setSessionUrl(url); } catch { setError("Failed to create payment link."); } finally { @@ -163,40 +179,77 @@ export const PingPayModal = create((props: PingPayModalProps) => { - {title} + {sessionUrl ? "Payment Link Ready" : title} -
- + {!sessionUrl ? ( + + + + ( + field.onChange(e.target.valueAsNumber)} + onClick={undefined} + onBlur={undefined} + onFocus={undefined} + type="number" + placeholder="0.00" + min={0} + step={0.01} + appendix={tokenSymbol} + /> + )} + /> + + {error &&

{error}

} + +

+ {`You'll be redirected to PingPay to pay with card, wallet, or any supported asset. Funds settle as ${tokenSymbol} to ${ + isCampaign ? "this campaign" : "this project" + }.`} +

+
+ + + + + + +
+ + ) : ( + <> - ( - field.onChange(e.target.valueAsNumber)} - onClick={undefined} - onBlur={undefined} - onFocus={undefined} - type="number" - placeholder="0.00" - min={0} - step={0.01} - appendix={tokenSymbol} - /> - )} - /> - - {error &&

{error}

} - -

- {`You'll be redirected to PingPay to pay with card, wallet, or any supported asset. Funds settle as ${tokenSymbol} to ${ - isCampaign ? "this campaign" : "this project" - }.`} -

+
+
+

{sessionUrl}

+
+ +

+ {"Share this link with anyone to let them complete the payment, or pay now yourself."} +

+
@@ -204,23 +257,25 @@ export const PingPayModal = create((props: PingPayModalProps) => { type="button" variant="brand-outline" color="black" - onClick={close} - disabled={isSubmitting} + onClick={handleCopy} + className="gap-2" > - {"Cancel"} + {copied ? : } + {copied ? "Copied!" : "Copy Link"} - - + + )}
); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c24fa6fb..ef6cb444 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -14,6 +14,9 @@ import { AppProps } from "next/app"; import { Lora } from "next/font/google"; import Head from "next/head"; import { Provider as ReduxProvider } from "react-redux"; +import { useSWRConfig } from "swr"; + +import { useToast } from "@/common/ui/layout/hooks"; import { APP_METADATA } from "@/common/constants"; import { TooltipProvider } from "@/common/ui/layout/components"; @@ -38,6 +41,36 @@ type AppPropsWithLayout = AppProps & { Component: NextPageWithLayout; }; +function PingPayBroadcastListener() { + const { mutate } = useSWRConfig(); + const { toast } = useToast(); + + useEffect(() => { + let channel: BroadcastChannel | null = null; + try { + channel = new BroadcastChannel("pingpay"); + channel.onmessage = (e) => { + if (e.data?.type === "pingpay-complete") { + if (e.data?.paymentStatus === "success") { + toast({ + title: "Donation Successful", + description: "Your donation has been recorded.", + }); + } + mutate(() => true, undefined, { revalidate: true }); + } + }; + } catch { + // BroadcastChannel unsupported + } + return () => { + channel?.close(); + }; + }, [mutate, toast]); + + return null; +} + export default function RootLayout({ Component, pageProps }: AppPropsWithLayout) { useEffect(() => void store.dispatch.core.init(), []); @@ -51,6 +84,7 @@ export default function RootLayout({ Component, pageProps }: AppPropsWithLayout) +
Date: Mon, 27 Apr 2026 22:07:33 +0500 Subject: [PATCH 5/5] Fixed linting issues --- src/app/api/pingpay/create-checkout/route.ts | 3 +-- src/app/api/pingpay/create-project-checkout/route.ts | 3 +-- src/app/api/pingpay/session-status/route.ts | 3 +-- src/features/pingpay/components/PingPayModal.tsx | 11 ++++++++--- src/pages/_app.tsx | 7 +++++-- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/app/api/pingpay/create-checkout/route.ts b/src/app/api/pingpay/create-checkout/route.ts index 70195199..3acabe88 100644 --- a/src/app/api/pingpay/create-checkout/route.ts +++ b/src/app/api/pingpay/create-checkout/route.ts @@ -7,8 +7,7 @@ export const dynamic = "force-dynamic"; const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; // TODO: move to env var and rotate before going public. -const PINGPAY_API_KEY_FALLBACK = - "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; +const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; /** * Creates a PingPay Hosted Checkout session that settles to the POTLOCK diff --git a/src/app/api/pingpay/create-project-checkout/route.ts b/src/app/api/pingpay/create-project-checkout/route.ts index e37dae5d..f6ffad55 100644 --- a/src/app/api/pingpay/create-project-checkout/route.ts +++ b/src/app/api/pingpay/create-project-checkout/route.ts @@ -7,8 +7,7 @@ export const dynamic = "force-dynamic"; const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; // TODO: move to env var and rotate before going public. -const PINGPAY_API_KEY_FALLBACK = - "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; +const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; /** * Creates a PingPay Hosted Checkout session that settles to the POTLOCK diff --git a/src/app/api/pingpay/session-status/route.ts b/src/app/api/pingpay/session-status/route.ts index e0b98545..6350dab6 100644 --- a/src/app/api/pingpay/session-status/route.ts +++ b/src/app/api/pingpay/session-status/route.ts @@ -5,8 +5,7 @@ export const dynamic = "force-dynamic"; const PINGPAY_API_BASE = process.env.PINGPAY_API_BASE ?? "https://pay.pingpay.io/api"; // TODO: move to env var and rotate before going public. -const PINGPAY_API_KEY_FALLBACK = - "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; +const PINGPAY_API_KEY_FALLBACK = "VquZNJbyXyPLyduKgCQDSttpXvRITYqjSGnguJogjezGINxYhsjsBAoEFCMXOVEk"; /** * Fetches PingPay session + payment details to extract txHash and sender. diff --git a/src/features/pingpay/components/PingPayModal.tsx b/src/features/pingpay/components/PingPayModal.tsx index 0834faa1..f673f7f4 100644 --- a/src/features/pingpay/components/PingPayModal.tsx +++ b/src/features/pingpay/components/PingPayModal.tsx @@ -45,13 +45,15 @@ export const PingPayModal = create((props: PingPayModalProps) => { const { tokenSymbol, tokenDecimals } = props; const campaignId = "campaignId" in props ? props.campaignId : undefined; const campaignName = "campaignName" in props ? props.campaignName : undefined; - const recipientAccountId = - "recipientAccountId" in props ? props.recipientAccountId : undefined; + + const recipientAccountId = "recipientAccountId" in props ? props.recipientAccountId : undefined; + const recipientName = "recipientName" in props ? props.recipientName : undefined; const isCampaign = campaignId !== undefined; const self = useModal(); const walletUser = useWalletUserSession(); + const donorAccountId = walletUser.isSignedIn && walletUser.accountId ? walletUser.accountId : null; @@ -72,6 +74,7 @@ export const PingPayModal = create((props: PingPayModalProps) => { const handleCopy = useCallback(() => { if (!sessionUrl) return; + navigator.clipboard.writeText(sessionUrl).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); @@ -247,7 +250,9 @@ export const PingPayModal = create((props: PingPayModalProps) => {

- {"Share this link with anyone to let them complete the payment, or pay now yourself."} + { + "Share this link with anyone to let them complete the payment, or pay now yourself." + }

diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index ef6cb444..e5571d51 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -16,11 +16,10 @@ import Head from "next/head"; import { Provider as ReduxProvider } from "react-redux"; import { useSWRConfig } from "swr"; -import { useToast } from "@/common/ui/layout/hooks"; - import { APP_METADATA } from "@/common/constants"; import { TooltipProvider } from "@/common/ui/layout/components"; import { Toaster } from "@/common/ui/layout/components/molecules/toaster"; +import { useToast } from "@/common/ui/layout/hooks"; import { cn } from "@/common/ui/layout/utils"; import { WalletUserSessionProvider } from "@/common/wallet"; import { AppBar } from "@/layout/components/app-bar"; @@ -47,8 +46,10 @@ function PingPayBroadcastListener() { useEffect(() => { let channel: BroadcastChannel | null = null; + try { channel = new BroadcastChannel("pingpay"); + channel.onmessage = (e) => { if (e.data?.type === "pingpay-complete") { if (e.data?.paymentStatus === "success") { @@ -57,12 +58,14 @@ function PingPayBroadcastListener() { description: "Your donation has been recorded.", }); } + mutate(() => true, undefined, { revalidate: true }); } }; } catch { // BroadcastChannel unsupported } + return () => { channel?.close(); };