diff --git a/src/app/brampton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx b/src/app/brampton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx index 844e8ec..c31ca73 100644 --- a/src/app/brampton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx +++ b/src/app/brampton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx @@ -2,9 +2,9 @@ import dynamic from "next/dynamic"; import Link from "next/link"; -import { useState } from "react"; import { ArrowLeft, ArrowRight, Check, Link2 } from "lucide-react"; import { getElection } from "@/lib/elections/registry"; +import { usePledgeShare } from "@/lib/elections/use-pledge-share"; import stampImage from "../brampton-stamp.png"; const ELECTION = getElection("brampton-2026"); @@ -21,25 +21,9 @@ const StampScene = dynamic(() => import("@/components/elections/StampScene"), { }); export default function SharedPledgeClient({ name }: { name: string }) { - const [copied, setCopied] = useState(false); - - async function share() { - const url = window.location.href; - if (navigator.share) { - try { - await navigator.share({ - title: `${name} pledged to vote — Brampton 2026`, - url, - }); - return; - } catch { - // fall through to the clipboard if the user dismissed the sheet - } - } - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } + const { copied, share } = usePledgeShare( + `${name} pledged to vote — Brampton 2026`, + ); return ( /* On small screens the copy would sit on top of the stamp, so the three diff --git a/src/app/hamilton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx b/src/app/hamilton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx index 75e3a86..1c9c1d4 100644 --- a/src/app/hamilton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx +++ b/src/app/hamilton/vote/2026/pledge/[slug]/SharedPledgeClient.tsx @@ -2,9 +2,9 @@ import dynamic from "next/dynamic"; import Link from "next/link"; -import { useState } from "react"; import { ArrowLeft, ArrowRight, Check, Link2 } from "lucide-react"; import { getElection } from "@/lib/elections/registry"; +import { usePledgeShare } from "@/lib/elections/use-pledge-share"; import stampImage from "../hamilton-stamp.png"; const ELECTION = getElection("hamilton-2026"); @@ -21,25 +21,9 @@ const StampScene = dynamic(() => import("@/components/elections/StampScene"), { }); export default function SharedPledgeClient({ name }: { name: string }) { - const [copied, setCopied] = useState(false); - - async function share() { - const url = window.location.href; - if (navigator.share) { - try { - await navigator.share({ - title: `${name} pledged to vote — Hamilton 2026`, - url, - }); - return; - } catch { - // fall through to the clipboard if the user dismissed the sheet - } - } - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } + const { copied, share } = usePledgeShare( + `${name} pledged to vote — Hamilton 2026`, + ); return (
diff --git a/src/app/ottawa/vote/2026/pledge/[slug]/SharedPledgeClient.tsx b/src/app/ottawa/vote/2026/pledge/[slug]/SharedPledgeClient.tsx index 0000628..b394497 100644 --- a/src/app/ottawa/vote/2026/pledge/[slug]/SharedPledgeClient.tsx +++ b/src/app/ottawa/vote/2026/pledge/[slug]/SharedPledgeClient.tsx @@ -2,9 +2,9 @@ import dynamic from "next/dynamic"; import Link from "next/link"; -import { useState } from "react"; import { ArrowLeft, ArrowRight, Check, Link2 } from "lucide-react"; import { getElection } from "@/lib/elections/registry"; +import { usePledgeShare } from "@/lib/elections/use-pledge-share"; import stampImage from "../ottawa-stamp.png"; const ELECTION = getElection("ottawa-2026"); @@ -21,25 +21,9 @@ const StampScene = dynamic(() => import("@/components/elections/StampScene"), { }); export default function SharedPledgeClient({ name }: { name: string }) { - const [copied, setCopied] = useState(false); - - async function share() { - const url = window.location.href; - if (navigator.share) { - try { - await navigator.share({ - title: `${name} pledged to vote — Ottawa 2026`, - url, - }); - return; - } catch { - // fall through to the clipboard if the user dismissed the sheet - } - } - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } + const { copied, share } = usePledgeShare( + `${name} pledged to vote — Ottawa 2026`, + ); return (
diff --git a/src/app/toronto/vote/2026/pledge/[slug]/SharedPledgeClient.tsx b/src/app/toronto/vote/2026/pledge/[slug]/SharedPledgeClient.tsx index 51eac4d..e401ed3 100644 --- a/src/app/toronto/vote/2026/pledge/[slug]/SharedPledgeClient.tsx +++ b/src/app/toronto/vote/2026/pledge/[slug]/SharedPledgeClient.tsx @@ -2,8 +2,8 @@ import dynamic from "next/dynamic"; import Link from "next/link"; -import { useState } from "react"; import { ArrowLeft, ArrowRight, Check, Link2 } from "lucide-react"; +import { usePledgeShare } from "@/lib/elections/use-pledge-share"; import stampImage from "../toronto-stamp.png"; const StampScene = dynamic(() => import("@/components/elections/StampScene"), { @@ -18,25 +18,9 @@ const StampScene = dynamic(() => import("@/components/elections/StampScene"), { }); export default function SharedPledgeClient({ name }: { name: string }) { - const [copied, setCopied] = useState(false); - - async function share() { - const url = window.location.href; - if (navigator.share) { - try { - await navigator.share({ - title: `${name} pledged to vote — Toronto 2026`, - url, - }); - return; - } catch { - // fall through to the clipboard if the user dismissed the sheet - } - } - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } + const { copied, share } = usePledgeShare( + `${name} pledged to vote — Toronto 2026`, + ); return (
diff --git a/src/lib/elections/use-pledge-share.ts b/src/lib/elections/use-pledge-share.ts new file mode 100644 index 0000000..18d0087 --- /dev/null +++ b/src/lib/elections/use-pledge-share.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; + +/** + * Share the current pledge page, and report failure instead of failing + * silently. Every region's pledge page uses this, so the share-sheet handling + * and the clipboard guard live in one place. + * + * The order is: try the native share sheet, then fall back to the clipboard. + * Two failures need care: + * - The user dismisses the share sheet. That rejects with `AbortError`. Treat + * it as done, because the user chose to cancel — and skip the clipboard, + * because the transient user activation is already gone and the write would + * reject with `NotAllowedError`. + * - The clipboard write rejects for any reason. Show a toast, so the button + * no longer sits on "Share this pledge" with nothing to explain the miss. + */ +export function usePledgeShare(title: string) { + const [copied, setCopied] = useState(false); + + async function share() { + const url = window.location.href; + + if (navigator.share) { + try { + await navigator.share({ title, url }); + return; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + // Any other share failure falls through to the clipboard. + } + } + + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Could not copy the link. Copy it from the address bar."); + } + } + + return { copied, share }; +}