Skip to content

Commit dde8a74

Browse files
committed
Add 'Share with a friend' — Web Share API + clipboard fallback
Convention used: navigator.share() with a clipboard.writeText() fallback plus a brief 'Link copied' toast. No platform-specific buttons (Twitter, WhatsApp, etc.) — the OS share sheet covers all of those on mobile, and the clipboard fallback covers any browser where Web Share isn't supported. Two surfaces: 1. Results screen, on a passed lesson — full-width "Share with a friend" pill below the Next-lesson / Try-again CTA. Catches the user at the dopamine moment. Shared text reads "I just got 80% on Backyard Regulars in goodbird — a field guide you can hear." 2. Species detail page header — small share icon next to the favorite heart. Shared text reads 'Listen: "{mnemonic}"' with a deep link to /species/:id, so the recipient lands on that exact bird. URLs are derived from window.location.origin so the share works on whatever domain the app is served from (good-bird.netlify.app today, anything later) without code changes. Implementation: - src/lib/share.ts: tryShare() returns 'shared' | 'copied' | 'dismissed' | 'failed'. AbortError (user dismissed share sheet) is treated as a valid no-op rather than a failure. - src/components/ShareButton.tsx: pill + icon variants, with an inline Toast component that lives 2s after a successful copy.
1 parent 8fb3ef0 commit dde8a74

4 files changed

Lines changed: 153 additions & 13 deletions

File tree

src/components/ShareButton.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { useEffect, useState } from "react";
2+
import { tryShare, type ShareData } from "@/lib/share";
3+
import { cn } from "@/lib/cn";
4+
5+
interface Props {
6+
data: ShareData;
7+
/** "pill" = full-width text button (Results CTA). "icon" = round icon (page headers). */
8+
variant?: "pill" | "icon";
9+
className?: string;
10+
label?: string;
11+
}
12+
13+
export function ShareButton({ data, variant = "pill", className, label = "Share with a friend" }: Props) {
14+
const [feedback, setFeedback] = useState<string | null>(null);
15+
16+
useEffect(() => {
17+
if (!feedback) return;
18+
const t = setTimeout(() => setFeedback(null), 2000);
19+
return () => clearTimeout(t);
20+
}, [feedback]);
21+
22+
const onClick = async (e: React.MouseEvent) => {
23+
e.preventDefault();
24+
e.stopPropagation();
25+
const r = await tryShare(data);
26+
if (r === "copied") setFeedback("Link copied");
27+
else if (r === "failed") setFeedback("Couldn't share");
28+
};
29+
30+
if (variant === "icon") {
31+
return (
32+
<div className={cn("relative", className)}>
33+
<button
34+
onClick={onClick}
35+
aria-label="Share"
36+
className="grid h-10 w-10 place-items-center rounded-full text-(--color-ink-soft) hover:bg-(--color-line) cursor-pointer"
37+
>
38+
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
39+
<path d="M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7" />
40+
<polyline points="16 6 12 2 8 6" />
41+
<line x1="12" y1="2" x2="12" y2="15" />
42+
</svg>
43+
</button>
44+
{feedback && <Toast text={feedback} placement="left" />}
45+
</div>
46+
);
47+
}
48+
49+
return (
50+
<button
51+
onClick={onClick}
52+
className={cn(
53+
"tap-target relative w-full rounded-full border-2 border-(--color-line) bg-(--color-surface) px-6 py-3 text-center font-semibold text-(--color-ink) transition-colors hover:border-(--color-moss-300)",
54+
className,
55+
)}
56+
>
57+
<span className="inline-flex items-center justify-center gap-2">
58+
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
59+
<path d="M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7" />
60+
<polyline points="16 6 12 2 8 6" />
61+
<line x1="12" y1="2" x2="12" y2="15" />
62+
</svg>
63+
{feedback ?? label}
64+
</span>
65+
</button>
66+
);
67+
}
68+
69+
function Toast({ text, placement }: { text: string; placement: "left" | "above" }) {
70+
return (
71+
<span
72+
role="status"
73+
aria-live="polite"
74+
className={cn(
75+
"pointer-events-none absolute z-10 whitespace-nowrap rounded-full bg-(--color-ink) px-3 py-1 text-xs font-medium text-white shadow-(--shadow-pop)",
76+
placement === "left" && "right-full top-1/2 mr-2 -translate-y-1/2",
77+
placement === "above" && "bottom-full left-1/2 mb-2 -translate-x-1/2",
78+
)}
79+
>
80+
{text}
81+
</span>
82+
);
83+
}

src/lib/share.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Web Share API + clipboard fallback. Caller doesn't care which path fires;
2+
// the returned result tells the UI whether to show a "Link copied" toast.
3+
4+
export interface ShareData {
5+
title: string;
6+
text: string;
7+
url: string;
8+
}
9+
10+
export type ShareResult = "shared" | "copied" | "dismissed" | "failed";
11+
12+
export async function tryShare(data: ShareData): Promise<ShareResult> {
13+
// Native OS share sheet (iOS Safari, Android Chrome, Edge, Safari 17+,
14+
// recent Chrome on desktop). Lets the user pick any installed app.
15+
if (typeof navigator !== "undefined" && typeof navigator.share === "function") {
16+
try {
17+
await navigator.share(data);
18+
return "shared";
19+
} catch (e: unknown) {
20+
// User dismissed the share sheet — that's a valid no-op, not an error.
21+
if (e instanceof Error && e.name === "AbortError") return "dismissed";
22+
// Other errors fall through to clipboard.
23+
}
24+
}
25+
// Fallback: copy URL to clipboard so the user can paste it themselves.
26+
try {
27+
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
28+
await navigator.clipboard.writeText(data.url);
29+
return "copied";
30+
}
31+
} catch {
32+
/* ignore */
33+
}
34+
return "failed";
35+
}

src/routes/Results.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useGame } from "@/game/store";
66
import { lessonComplete } from "@/lib/feedback";
77
import { getLesson, getSpecies, getUnitForLesson, nextLesson as findNextLesson } from "@/lib/manifest";
88
import { PlaySoundButton } from "@/components/PlaySoundButton";
9+
import { ShareButton } from "@/components/ShareButton";
910
import { ACCENTS } from "@/lib/theme";
1011
import { cn } from "@/lib/cn";
1112

@@ -143,6 +144,16 @@ export function ResultsRoute() {
143144
Try again
144145
</Link>
145146
)}
147+
148+
{snap.passed && (
149+
<ShareButton
150+
data={{
151+
title: "goodbird",
152+
text: `I just got ${accuracy}% on ${lessonTitle} in goodbird — a field guide you can hear.`,
153+
url: typeof window !== "undefined" ? window.location.origin : "",
154+
}}
155+
/>
156+
)}
146157
<Link
147158
to="/"
148159
className="tap-target rounded-full border-2 border-(--color-line) bg-(--color-surface) px-6 py-3 text-center font-semibold text-(--color-ink) transition-colors hover:border-(--color-moss-300)"

src/routes/Species.tsx

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Link, useNavigate, useParams } from "react-router-dom";
22
import { allLessons, getSpecies, units } from "@/lib/manifest";
33
import { AudioPlayer } from "@/components/AudioPlayer";
4+
import { ShareButton } from "@/components/ShareButton";
45
import { useGame } from "@/game/store";
56
import { ACCENTS } from "@/lib/theme";
67
import { cn } from "@/lib/cn";
@@ -49,19 +50,29 @@ export function SpeciesRoute() {
4950
<path d="M15 6l-6 6 6 6" />
5051
</svg>
5152
</button>
52-
<button
53-
onClick={() => toggleFavorite(id)}
54-
aria-label={isFavorite ? "Remove favorite" : "Add favorite"}
55-
aria-pressed={isFavorite}
56-
className={cn(
57-
"grid h-10 w-10 place-items-center rounded-full transition-colors cursor-pointer",
58-
isFavorite ? "text-(--color-wrong)" : "text-(--color-ink-soft) hover:bg-(--color-line)",
59-
)}
60-
>
61-
<svg viewBox="0 0 24 24" className="h-6 w-6" fill={isFavorite ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
62-
<path d="M12 21s-7.5-4.6-9.6-9.2C1 8.5 2.6 5 6.1 5c2 0 3.6 1.1 4.4 2.7C11.3 6.1 12.9 5 14.9 5c3.5 0 5.1 3.5 3.7 6.8C19.5 16.4 12 21 12 21z" />
63-
</svg>
64-
</button>
53+
<div className="flex items-center gap-1">
54+
<ShareButton
55+
variant="icon"
56+
data={{
57+
title: `goodbird — ${species.commonName}`,
58+
text: `Listen: "${species.mnemonic}"`,
59+
url: typeof window !== "undefined" ? `${window.location.origin}/species/${species.id}` : "",
60+
}}
61+
/>
62+
<button
63+
onClick={() => toggleFavorite(id)}
64+
aria-label={isFavorite ? "Remove favorite" : "Add favorite"}
65+
aria-pressed={isFavorite}
66+
className={cn(
67+
"grid h-10 w-10 place-items-center rounded-full transition-colors cursor-pointer",
68+
isFavorite ? "text-(--color-wrong)" : "text-(--color-ink-soft) hover:bg-(--color-line)",
69+
)}
70+
>
71+
<svg viewBox="0 0 24 24" className="h-6 w-6" fill={isFavorite ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
72+
<path d="M12 21s-7.5-4.6-9.6-9.2C1 8.5 2.6 5 6.1 5c2 0 3.6 1.1 4.4 2.7C11.3 6.1 12.9 5 14.9 5c3.5 0 5.1 3.5 3.7 6.8C19.5 16.4 12 21 12 21z" />
73+
</svg>
74+
</button>
75+
</div>
6576
</div>
6677

6778
<div

0 commit comments

Comments
 (0)