Skip to content

Commit 8ba70c6

Browse files
authored
Merge pull request #674 from Moh-dakai/feat/share-buttons
feat: add ShareButtons component with Twitter, Farcaster, Lens, and c…
2 parents 17f2b01 + 3ab7208 commit 8ba70c6

4 files changed

Lines changed: 371 additions & 0 deletions

File tree

frontend/src/components/CampaignDetailPanel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AddressAvatar } from './AddressAvatar';
1212
import { EmptyState } from './EmptyState';
1313
import { ContributorSummary } from './ContributorSummary';
1414
import { CampaignImage } from './CampaignImage';
15+
import { ShareButtons } from './ShareButtons';
1516
import { useMinDisplayTime } from '../hooks/useMinDisplayTime';
1617

1718
interface CampaignDetailPanelProps {
@@ -452,6 +453,8 @@ export function CampaignDetailPanel({
452453
</a>
453454
</div>
454455
) : null}
456+
457+
<ShareButtons campaign={activeCampaign} />
455458
</section>
456459
);
457460
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { render } from '@testing-library/react';
2+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
3+
import { ShareButtons } from './ShareButtons';
4+
import { runAxeAudit, THEMES, type ThemeMode } from '../test/a11yTestUtils';
5+
import type { Campaign } from '../types/campaign';
6+
7+
const mockCampaign: Campaign = {
8+
id: '42',
9+
title: 'Build a Solar Farm',
10+
description: 'A solar energy crowdfund.',
11+
creator: `G${'A'.repeat(55)}`,
12+
assetCode: 'XLM',
13+
acceptedTokens: ['XLM'],
14+
targetAmount: 5000,
15+
pledgedAmount: 1200,
16+
deadline: Math.floor(Date.now() / 1000) + 86400,
17+
createdAt: Math.floor(Date.now() / 1000),
18+
pledges: [],
19+
progress: {
20+
status: 'open',
21+
percentFunded: 24,
22+
remainingAmount: 3800,
23+
hoursLeft: 24,
24+
pledgeCount: 3,
25+
canPledge: true,
26+
canClaim: false,
27+
canRefund: false,
28+
},
29+
metadata: {},
30+
};
31+
32+
describe.each(THEMES)('ShareButtons Accessibility (%s theme)', (theme: ThemeMode) => {
33+
const originalLocation = window.location;
34+
35+
beforeEach(() => {
36+
Object.defineProperty(window, 'location', {
37+
writable: true,
38+
value: { origin: 'https://example.com' },
39+
});
40+
});
41+
42+
afterEach(() => {
43+
Object.defineProperty(window, 'location', {
44+
writable: true,
45+
value: originalLocation,
46+
});
47+
});
48+
49+
it('has no accessibility violations', async () => {
50+
const { container } = render(<ShareButtons campaign={mockCampaign} />);
51+
const results = await runAxeAudit(container, theme);
52+
expect(results).toHaveNoViolations();
53+
});
54+
});
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { render, screen, fireEvent, act } from '@testing-library/react';
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3+
import { ShareButtons } from './ShareButtons';
4+
import type { Campaign } from '../types/campaign';
5+
6+
const mockCampaign: Campaign = {
7+
id: '42',
8+
title: 'Build a Solar Farm',
9+
description: 'A solar energy crowdfund.',
10+
creator: `G${'A'.repeat(55)}`,
11+
assetCode: 'XLM',
12+
acceptedTokens: ['XLM'],
13+
targetAmount: 5000,
14+
pledgedAmount: 1200,
15+
deadline: Math.floor(Date.now() / 1000) + 86400,
16+
createdAt: Math.floor(Date.now() / 1000),
17+
pledges: [],
18+
progress: {
19+
status: 'open',
20+
percentFunded: 24,
21+
remainingAmount: 3800,
22+
hoursLeft: 24,
23+
pledgeCount: 3,
24+
canPledge: true,
25+
canClaim: false,
26+
canRefund: false,
27+
},
28+
metadata: {},
29+
};
30+
31+
describe('ShareButtons', () => {
32+
const originalLocation = window.location;
33+
34+
beforeEach(() => {
35+
Object.defineProperty(window, 'location', {
36+
writable: true,
37+
value: { origin: 'https://example.com' },
38+
});
39+
40+
vi.useFakeTimers({ shouldAdvanceTime: true });
41+
});
42+
43+
afterEach(() => {
44+
Object.defineProperty(window, 'location', {
45+
writable: true,
46+
value: originalLocation,
47+
});
48+
vi.useRealTimers();
49+
vi.restoreAllMocks();
50+
});
51+
52+
it('renders all four share controls', () => {
53+
render(<ShareButtons campaign={mockCampaign} />);
54+
55+
expect(screen.getByRole('link', { name: /share.*twitter/i })).toBeInTheDocument();
56+
expect(screen.getByRole('link', { name: /share.*farcaster/i })).toBeInTheDocument();
57+
expect(screen.getByRole('link', { name: /share.*lens/i })).toBeInTheDocument();
58+
expect(screen.getByRole('button', { name: /copy campaign link/i })).toBeInTheDocument();
59+
});
60+
61+
it('Twitter link points to twitter.com intent with campaign title, goal, and URL', () => {
62+
render(<ShareButtons campaign={mockCampaign} />);
63+
64+
const link = screen.getByRole('link', { name: /share.*twitter/i }) as HTMLAnchorElement;
65+
const url = new URL(link.href);
66+
67+
expect(url.hostname).toBe('twitter.com');
68+
expect(url.pathname).toBe('/intent/tweet');
69+
70+
const text = url.searchParams.get('text') ?? '';
71+
expect(text).toContain('Build a Solar Farm');
72+
expect(text).toContain('5000');
73+
expect(text).toContain('XLM');
74+
75+
const tweetUrl = url.searchParams.get('url') ?? '';
76+
expect(tweetUrl).toBe('https://example.com/campaigns/42');
77+
});
78+
79+
it('Farcaster link points to warpcast.com with campaign details', () => {
80+
render(<ShareButtons campaign={mockCampaign} />);
81+
82+
const link = screen.getByRole('link', { name: /share.*farcaster/i }) as HTMLAnchorElement;
83+
const url = new URL(link.href);
84+
85+
expect(url.hostname).toBe('warpcast.com');
86+
87+
const text = url.searchParams.get('text') ?? '';
88+
expect(text).toContain('Build a Solar Farm');
89+
expect(text).toContain('5000');
90+
expect(text).toContain('XLM');
91+
expect(text).toContain('https://example.com/campaigns/42');
92+
});
93+
94+
it('Lens link points to hey.xyz with campaign details', () => {
95+
render(<ShareButtons campaign={mockCampaign} />);
96+
97+
const link = screen.getByRole('link', { name: /share.*lens/i }) as HTMLAnchorElement;
98+
const url = new URL(link.href);
99+
100+
expect(url.hostname).toBe('hey.xyz');
101+
102+
const content = url.searchParams.get('content') ?? '';
103+
expect(content).toContain('Build a Solar Farm');
104+
expect(content).toContain('5000');
105+
expect(content).toContain('XLM');
106+
expect(content).toContain('https://example.com/campaigns/42');
107+
});
108+
109+
it('external links open in a new tab with rel=noopener noreferrer', () => {
110+
render(<ShareButtons campaign={mockCampaign} />);
111+
112+
for (const name of [/share.*twitter/i, /share.*farcaster/i, /share.*lens/i]) {
113+
const link = screen.getByRole('link', { name }) as HTMLAnchorElement;
114+
expect(link).toHaveAttribute('target', '_blank');
115+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
116+
}
117+
});
118+
119+
it('copy button copies canonical campaign URL to clipboard', async () => {
120+
const writeText = vi.fn().mockResolvedValue(undefined);
121+
Object.defineProperty(navigator, 'clipboard', {
122+
value: { writeText },
123+
writable: true,
124+
configurable: true,
125+
});
126+
127+
render(<ShareButtons campaign={mockCampaign} />);
128+
129+
const btn = screen.getByRole('button', { name: /copy campaign link/i });
130+
await act(async () => {
131+
fireEvent.click(btn);
132+
});
133+
134+
expect(writeText).toHaveBeenCalledWith('https://example.com/campaigns/42');
135+
});
136+
137+
it('copy button shows "Copied" feedback after click, then reverts after 2 s', async () => {
138+
const writeText = vi.fn().mockResolvedValue(undefined);
139+
Object.defineProperty(navigator, 'clipboard', {
140+
value: { writeText },
141+
writable: true,
142+
configurable: true,
143+
});
144+
145+
render(<ShareButtons campaign={mockCampaign} />);
146+
147+
const btn = screen.getByRole('button', { name: /copy campaign link/i });
148+
149+
await act(async () => {
150+
fireEvent.click(btn);
151+
});
152+
153+
expect(btn).toHaveTextContent('Copied');
154+
155+
act(() => {
156+
vi.advanceTimersByTime(2000);
157+
});
158+
159+
expect(btn).toHaveTextContent('Copy link');
160+
});
161+
162+
it('copy button falls back to execCommand when clipboard API is unavailable', async () => {
163+
// Remove clipboard API to trigger the fallback
164+
Object.defineProperty(navigator, 'clipboard', {
165+
value: undefined,
166+
writable: true,
167+
configurable: true,
168+
});
169+
170+
// jsdom does not implement execCommand — stub it
171+
Object.defineProperty(document, 'execCommand', {
172+
value: vi.fn().mockReturnValue(true),
173+
writable: true,
174+
configurable: true,
175+
});
176+
177+
render(<ShareButtons campaign={mockCampaign} />);
178+
179+
const btn = screen.getByRole('button', { name: /copy campaign link/i });
180+
await act(async () => {
181+
fireEvent.click(btn);
182+
});
183+
184+
expect(document.execCommand).toHaveBeenCalledWith('copy');
185+
});
186+
187+
it('share group has an accessible group label', () => {
188+
render(<ShareButtons campaign={mockCampaign} />);
189+
expect(screen.getByRole('group', { name: /share campaign/i })).toBeInTheDocument();
190+
});
191+
192+
it('each button and link has a descriptive aria-label', () => {
193+
render(<ShareButtons campaign={mockCampaign} />);
194+
195+
expect(
196+
screen.getByRole('link', { name: 'Share "Build a Solar Farm" on Twitter' }),
197+
).toBeInTheDocument();
198+
expect(
199+
screen.getByRole('link', { name: 'Share "Build a Solar Farm" on Farcaster' }),
200+
).toBeInTheDocument();
201+
expect(
202+
screen.getByRole('link', { name: 'Share "Build a Solar Farm" on Lens' }),
203+
).toBeInTheDocument();
204+
expect(screen.getByRole('button', { name: 'Copy campaign link' })).toBeInTheDocument();
205+
});
206+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { useState } from 'react';
2+
import { Campaign } from '../types/campaign';
3+
4+
interface ShareButtonsProps {
5+
campaign: Campaign;
6+
}
7+
8+
function buildCampaignUrl(campaignId: string): string {
9+
return `${window.location.origin}/campaigns/${campaignId}`;
10+
}
11+
12+
function buildTwitterUrl(campaign: Campaign, campaignUrl: string): string {
13+
const text = `Check out "${campaign.title}" — goal: ${campaign.targetAmount} ${campaign.assetCode}`;
14+
const params = new URLSearchParams({ text, url: campaignUrl });
15+
return `https://twitter.com/intent/tweet?${params.toString()}`;
16+
}
17+
18+
function buildFarcasterUrl(campaign: Campaign, campaignUrl: string): string {
19+
const text = `Check out "${campaign.title}" — goal: ${campaign.targetAmount} ${campaign.assetCode} ${campaignUrl}`;
20+
const params = new URLSearchParams({ text });
21+
return `https://warpcast.com/~/compose?${params.toString()}`;
22+
}
23+
24+
function buildLensUrl(campaign: Campaign, campaignUrl: string): string {
25+
const content = `Check out "${campaign.title}" — goal: ${campaign.targetAmount} ${campaign.assetCode} ${campaignUrl}`;
26+
const params = new URLSearchParams({ content });
27+
return `https://hey.xyz/?${params.toString()}`;
28+
}
29+
30+
export function ShareButtons({ campaign }: ShareButtonsProps) {
31+
const [copied, setCopied] = useState(false);
32+
33+
const campaignUrl = buildCampaignUrl(campaign.id);
34+
const twitterUrl = buildTwitterUrl(campaign, campaignUrl);
35+
const farcasterUrl = buildFarcasterUrl(campaign, campaignUrl);
36+
const lensUrl = buildLensUrl(campaign, campaignUrl);
37+
38+
async function handleCopyLink() {
39+
try {
40+
await navigator.clipboard.writeText(campaignUrl);
41+
setCopied(true);
42+
window.setTimeout(() => setCopied(false), 2000);
43+
} catch {
44+
// navigator.clipboard may be unavailable in some embedded contexts; fallback
45+
const ta = document.createElement('textarea');
46+
ta.value = campaignUrl;
47+
ta.style.position = 'fixed';
48+
ta.style.opacity = '0';
49+
document.body.appendChild(ta);
50+
ta.select();
51+
try {
52+
document.execCommand('copy');
53+
setCopied(true);
54+
window.setTimeout(() => setCopied(false), 2000);
55+
} catch {
56+
// ignore
57+
} finally {
58+
document.body.removeChild(ta);
59+
}
60+
}
61+
}
62+
63+
return (
64+
<div className="share-buttons" role="group" aria-label="Share campaign">
65+
<a
66+
href={twitterUrl}
67+
target="_blank"
68+
rel="noopener noreferrer"
69+
className="btn-ghost"
70+
aria-label={`Share "${campaign.title}" on Twitter`}
71+
>
72+
Twitter
73+
</a>
74+
75+
<a
76+
href={farcasterUrl}
77+
target="_blank"
78+
rel="noopener noreferrer"
79+
className="btn-ghost"
80+
aria-label={`Share "${campaign.title}" on Farcaster`}
81+
>
82+
Farcaster
83+
</a>
84+
85+
<a
86+
href={lensUrl}
87+
target="_blank"
88+
rel="noopener noreferrer"
89+
className="btn-ghost"
90+
aria-label={`Share "${campaign.title}" on Lens`}
91+
>
92+
Lens
93+
</a>
94+
95+
<button
96+
type="button"
97+
className="btn-ghost btn-copy"
98+
onClick={() => { void handleCopyLink(); }}
99+
aria-label="Copy campaign link"
100+
title={copied ? 'Copied!' : 'Copy link'}
101+
>
102+
{copied ? 'Copied' : 'Copy link'}
103+
</button>
104+
</div>
105+
);
106+
}
107+
108+
export default ShareButtons;

0 commit comments

Comments
 (0)