From 4f4a2f9d2a1409dccff2565373a0837a57464f21 Mon Sep 17 00:00:00 2001
From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com>
Date: Sat, 29 Aug 2026 23:35:18 +0000
Subject: [PATCH] Guard the pledge share clipboard fallback
The pledge "Share this pledge" button could fail silently. After the native
share sheet is dismissed, the handler fell through to a bare
navigator.clipboard.writeText, which the browser rejects because the transient
user activation is gone. The rejection surfaced as an unhandled rejection, the
copied state never flipped, and the user saw no error.
Move the share behaviour into a shared usePledgeShare hook that:
- treats a dismissed share sheet (AbortError) as done and skips the clipboard,
- wraps the clipboard write in try/catch and shows a toast on failure,
matching the existing ShareButtons pattern.
The four region pledge pages (Toronto, Brampton, Ottawa, Hamilton) now use the
one hook instead of four copies of the same handler.
Generated-By: PostHog Desktop
Task-Id: 2cb29cbb-01ef-4cdf-8120-ebcaef8571e1
---
.../2026/pledge/[slug]/SharedPledgeClient.tsx | 24 ++--------
.../2026/pledge/[slug]/SharedPledgeClient.tsx | 24 ++--------
.../2026/pledge/[slug]/SharedPledgeClient.tsx | 24 ++--------
.../2026/pledge/[slug]/SharedPledgeClient.tsx | 24 ++--------
src/lib/elections/use-pledge-share.ts | 48 +++++++++++++++++++
5 files changed, 64 insertions(+), 80 deletions(-)
create mode 100644 src/lib/elections/use-pledge-share.ts
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 };
+}