Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions frontend/src/BountyDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,30 @@ describe("BountyDetailPage copy actions", () => {
expect(print).toHaveBeenCalledOnce();
});

it("renders social share links with prefilled bounty details", () => {
renderDetail();

const twitterLink = screen.getByRole("link", { name: /share bounty on x/i });
const linkedInLink = screen.getByRole("link", { name: /share bounty on linkedin/i });

expect(twitterLink).toHaveAttribute("target", "_blank");
expect(twitterLink).toHaveAttribute("rel", "noopener noreferrer");
expect(twitterLink.getAttribute("href")).toContain("https://twitter.com/intent/tweet?");
expect(twitterLink.getAttribute("href")).toContain(
encodeURIComponent("Copy button test bounty — 150 XLM bounty"),
);
expect(twitterLink.getAttribute("href")).toContain(
encodeURIComponent("http://localhost:3000/bounties/BNTY-42"),
);

expect(linkedInLink).toHaveAttribute("target", "_blank");
expect(linkedInLink).toHaveAttribute("rel", "noopener noreferrer");
expect(linkedInLink).toHaveAttribute(
"href",
"https://www.linkedin.com/sharing/share-offsite/?url=http%3A%2F%2Flocalhost%3A3000%2Fbounties%2FBNTY-42",
);
});

it("announces status changes for assistive technology", () => {
const { rerender } = renderDetail();
const reservedBounty: Bounty = {
Expand Down
52 changes: 49 additions & 3 deletions frontend/src/BountyDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import { updateSocialMetaTags } from "./metaTags";
import CopyIcon from "./CopyIcons";
import { extendDeadline } from "./api";
import { findSimilarBounties, type BountyRecommendation } from "./recommendations";
import {
buildBountyPermalink,
buildBountyShareText,
buildLinkedInShareUrl,
buildTwitterShareUrl,
} from "./shareLinks";


type BountyAction = "reserve" | "submit" | "release" | "refund";
Expand Down Expand Up @@ -143,16 +149,18 @@ export default function BountyDetailPage({
window.print();
}

const sharePermalink = bounty ? buildBountyPermalink(bounty.id) : "";
const shareText = bounty ? buildBountyShareText(bounty) : "";

function handleShare() {
if (!bounty) return;
const permalink = `${window.location.origin}/bounties/${encodeURIComponent(bounty.id)}`;
navigator.clipboard.writeText(permalink).then(() => {
navigator.clipboard.writeText(sharePermalink).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch((err) => {
console.error("Failed to copy URL:", err);
// Fallback: show the URL in a prompt so the user can manually copy it
window.prompt("Copy the bounty URL manually:", permalink);
window.prompt("Copy the bounty URL manually:", sharePermalink);
});
}

Expand All @@ -171,6 +179,44 @@ export default function BountyDetailPage({
<h2>{bounty ? bounty.title : "Bounty"}</h2>
</div>
<div className="panel-header__actions">
<div className="share-buttons">
<a
className="secondary-button share-button share-button--twitter"
href={bounty ? buildTwitterShareUrl(sharePermalink, shareText) : undefined}
target="_blank"
rel="noopener noreferrer"
aria-label="Share bounty on X (Twitter)"
title="Share on X (Twitter)"
tabIndex={loading || !bounty ? -1 : undefined}
aria-disabled={loading || !bounty}
onClick={(event) => {
if (loading || !bounty) {
event.preventDefault();
}
}}
>
<span className="share-button__icon" aria-hidden="true">𝕏</span>
<span className="share-button__label">X</span>
</a>
<a
className="secondary-button share-button share-button--linkedin"
href={bounty ? buildLinkedInShareUrl(sharePermalink) : undefined}
target="_blank"
rel="noopener noreferrer"
aria-label="Share bounty on LinkedIn"
title="Share on LinkedIn"
tabIndex={loading || !bounty ? -1 : undefined}
aria-disabled={loading || !bounty}
onClick={(event) => {
if (loading || !bounty) {
event.preventDefault();
}
}}
>
<span className="share-button__icon" aria-hidden="true">in</span>
<span className="share-button__label">LinkedIn</span>
</a>
</div>
<button
type="button"
className="secondary-button"
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,41 @@ textarea {
flex-wrap: wrap;
}

.share-buttons {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}

.share-button {
display: inline-flex;
align-items: center;
gap: 6px;
text-decoration: none;
white-space: nowrap;
}

.share-button[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}

.share-button__icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
font-size: 0.85rem;
font-weight: 700;
line-height: 1;
}

.share-button--linkedin .share-button__icon {
font-family: "IBM Plex Sans", sans-serif;
font-size: 0.95rem;
}

.print-button {
display: inline-flex;
align-items: center;
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/shareLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";

import {
buildBountyPermalink,
buildBountyShareText,
buildLinkedInShareUrl,
buildTwitterShareUrl,
} from "./shareLinks";

describe("shareLinks", () => {
const bounty = {
title: "Add authentication feature",
amount: 500,
tokenSymbol: "XLM",
};

it("builds a bounty permalink from the current origin", () => {
expect(buildBountyPermalink("BNTY-42")).toBe(
"http://localhost:3000/bounties/BNTY-42",
);
});

it("builds share text with title and reward amount", () => {
expect(buildBountyShareText(bounty)).toBe(
"Add authentication feature — 500 XLM bounty",
);
});

it("builds a Twitter intent URL with text and link", () => {
const url = buildTwitterShareUrl(
"http://localhost:3000/bounties/BNTY-42",
"Add authentication feature — 500 XLM bounty",
);

expect(url).toContain("https://twitter.com/intent/tweet?");
expect(url).toContain(
encodeURIComponent("Add authentication feature — 500 XLM bounty http://localhost:3000/bounties/BNTY-42"),
);
});

it("builds a LinkedIn share URL with the bounty permalink", () => {
const url = buildLinkedInShareUrl("http://localhost:3000/bounties/BNTY-42");

expect(url).toBe(
"https://www.linkedin.com/sharing/share-offsite/?url=http%3A%2F%2Flocalhost%3A3000%2Fbounties%2FBNTY-42",
);
});
});
25 changes: 25 additions & 0 deletions frontend/src/shareLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Bounty } from "./types";

export function buildBountyPermalink(bountyId: string): string {
return `${window.location.origin}/bounties/${encodeURIComponent(bountyId)}`;
}

export function buildBountyShareText(
bounty: Pick<Bounty, "title" | "amount" | "tokenSymbol">,
): string {
return `${bounty.title} — ${bounty.amount} ${bounty.tokenSymbol} bounty`;
}

export function buildTwitterShareUrl(permalink: string, text: string): string {
const params = new URLSearchParams({
text: `${text} ${permalink}`,
});

return `https://twitter.com/intent/tweet?${params.toString()}`;
}

export function buildLinkedInShareUrl(permalink: string): string {
const params = new URLSearchParams({ url: permalink });

return `https://www.linkedin.com/sharing/share-offsite/?${params.toString()}`;
}
Loading