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 };
+}