Skip to content

Commit e4a752b

Browse files
fix: resolve review alerts for share card feature
- Move ogMeta block below selectedCampaign to fix TDZ reference error - Reject data: URLs for og:image so social crawlers can load them - Remove unused toDataUrl from App.tsx - Remove unused Share2 import and toDataUrl from CampaignDetailPanel - Use LinkIcon from lucide and restore Link from react-router-dom - Thread onToast from App into CampaignDetailPanel instead of a local useToast store - Add Clipboard API fallback with textarea execCommand - Decode brand logo image before drawing the share card canvas - Add fallback OG/Twitter meta tags to index.html
1 parent 07f6e88 commit e4a752b

4 files changed

Lines changed: 122 additions & 74 deletions

File tree

frontend/index.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
<link rel="icon" type="image/svg+xml" href="/icon-192.svg" />
1717
<!-- SEO / share -->
1818
<meta name="description" content="Campaign management and funding dashboard for the Stellar ecosystem" />
19+
<meta property="og:title" content="Stellar Goal Vault" />
20+
<meta property="og:description" content="Campaign management and funding dashboard for the Stellar ecosystem" />
21+
<meta property="og:type" content="website" />
22+
<meta property="og:url" content="https://stellar-goal-vault.vercel.app/" />
23+
<meta name="twitter:card" content="summary" />
24+
<meta name="twitter:title" content="Stellar Goal Vault" />
25+
<meta name="twitter:description" content="Campaign management and funding dashboard for the Stellar ecosystem" />
1926
<title>Stellar Goal Vault</title>
2027
<!-- Inline theme detection to avoid FOUC — runs before React hydrates -->
2128
<script>

frontend/src/App.tsx

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ import { useWallet } from "./hooks/useWallet";
3939
import { useLocalStorage } from "./hooks/useLocalStorage";
4040
import { useToast } from "./hooks/useToast";
4141
import { useOpenGraph } from "./hooks/useOpenGraph";
42-
import { useCampaignShareCard } from "./components/CampaignShareCard";
4342
import { didCampaignBecomeFunded } from "./lib/fundingCelebration";
4443
import {
4544
ApiError,
@@ -333,22 +332,6 @@ function App() {
333332
await Promise.all([refreshHistory(campaignId), refreshSelectedCampaign(campaignId)]);
334333
}
335334

336-
const { toDataUrl } = useCampaignShareCard();
337-
338-
const ogMeta = useMemo(() => {
339-
const c = selectedCampaign;
340-
if (!c) return null;
341-
const baseUrl = window.location.origin;
342-
return {
343-
title: `${c.title} — Stellar Goal Vault`,
344-
description: c.description.slice(0, 200),
345-
image: c.metadata?.imageUrl ?? undefined,
346-
url: `${baseUrl}/campaigns/${c.id}`,
347-
};
348-
}, [selectedCampaign]);
349-
350-
useOpenGraph(ogMeta);
351-
352335
const initialParamIdRef = useRef(paramId);
353336

354337
useEffect(() => {
@@ -451,6 +434,22 @@ function App() {
451434
};
452435
}, [campaigns, selectedCampaignDetails, selectedCampaignId]);
453436

437+
const ogMeta = useMemo(() => {
438+
const c = selectedCampaign;
439+
if (!c) return null;
440+
const baseUrl = window.location.origin;
441+
const imageUrl = c.metadata?.imageUrl;
442+
const safeImage = imageUrl && !imageUrl.startsWith("data:") ? imageUrl : undefined;
443+
return {
444+
title: `${c.title} — Stellar Goal Vault`,
445+
description: c.description.slice(0, 200),
446+
image: safeImage,
447+
url: `${baseUrl}/campaigns/${c.id}`,
448+
};
449+
}, [selectedCampaign]);
450+
451+
useOpenGraph(ogMeta);
452+
454453
const metrics = useMemo(() => {
455454
const open = campaigns.filter((campaign) => campaign.progress.status === "open").length;
456455
const funded = campaigns.filter((campaign) => campaign.progress.status === "funded").length;
@@ -772,6 +771,7 @@ function App() {
772771
onClaim={handleClaim}
773772
onSoftDelete={handleSoftDelete}
774773
onRefund={handleRefund}
774+
onToast={addToast}
775775
/>
776776
</ErrorBoundary>
777777
</section>

frontend/src/components/CampaignDetailPanel.tsx

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11

22

33
import { FormEvent, useState, useEffect, useCallback } from 'react';
4-
import { MousePointer2, Share2, Download, Link } from 'lucide-react';
4+
import { MousePointer2, Download, LinkIcon } from 'lucide-react';
5+
import { Link } from 'react-router-dom';
56
import { Campaign, AppConfig } from '../types/campaign';
67
import CopyButton from './CopyButton';
78
import { AddressAvatar } from './AddressAvatar';
89
import { EmptyState } from './EmptyState';
910
import { ContributorSummary } from './ContributorSummary';
1011
import { CampaignImage } from './CampaignImage';
1112
import { useCampaignShareCard } from './CampaignShareCard';
12-
import { useToast } from '../hooks/useToast';
1313
import { ShareButtons } from './ShareButtons';
1414
import { useMinDisplayTime } from '../hooks/useMinDisplayTime';
15+
import type { ToastVariant } from '../hooks/useToast';
1516

1617
interface CampaignDetailPanelProps {
1718
campaign: Campaign | null;
@@ -28,6 +29,11 @@ interface CampaignDetailPanelProps {
2829
onSoftDelete?: (campaignId: string) => Promise<void>;
2930
onRefund?: (campaignId: string, contributor: string) => Promise<void>;
3031
onClose?: () => void;
32+
onToast?: (
33+
message: string,
34+
variant?: ToastVariant,
35+
link?: { href: string; label: string },
36+
) => void;
3137
}
3238

3339
const FEE_ESTIMATION_ERROR_CODES = new Set([
@@ -77,6 +83,7 @@ export function CampaignDetailPanel({
7783
onPledge = async () => {},
7884
onClaim = async () => {},
7985
onRefund = async () => {},
86+
onToast,
8087
}: CampaignDetailPanelProps) {
8188
const [pledgeAmount, setPledgeAmount] = useState('25');
8289
const [pledgeToken, setPledgeToken] = useState('');
@@ -85,24 +92,39 @@ export function CampaignDetailPanel({
8592
const [pledgeError, setPledgeError] = useState<string | null>(null);
8693
const [bannerImageError, setBannerImageError] = useState(false);
8794
const walletReady = appConfig?.walletIntegrationReady ?? false;
88-
const { downloadPng, toDataUrl } = useCampaignShareCard();
89-
const { addToast } = useToast();
95+
const { downloadPng } = useCampaignShareCard();
96+
const notify = onToast ?? (() => {});
9097

91-
const handleDownloadPng = useCallback(() => {
98+
const handleDownloadPng = useCallback(async () => {
9299
if (!campaign) return;
93-
downloadPng(campaign, campaign.metadata?.imageUrl);
94-
addToast('Campaign card downloaded as PNG.', 'success');
95-
}, [campaign, downloadPng, addToast]);
100+
await downloadPng(campaign, campaign.metadata?.imageUrl);
101+
notify('Campaign card downloaded as PNG.', 'success');
102+
}, [campaign, downloadPng, notify]);
96103

97-
const handleCopyLink = useCallback(() => {
104+
const handleCopyLink = useCallback(async () => {
98105
if (!campaign) return;
99106
const url = `${window.location.origin}/campaigns/${campaign.id}`;
100-
navigator.clipboard.writeText(url).then(() => {
101-
addToast('Campaign link copied to clipboard.', 'success', { label: url.slice(0, 40) + '…' });
102-
}).catch(() => {
103-
addToast('Failed to copy link.', 'error');
104-
});
105-
}, [campaign, addToast]);
107+
try {
108+
if (navigator.clipboard?.writeText) {
109+
await navigator.clipboard.writeText(url);
110+
} else {
111+
const ta = document.createElement('textarea');
112+
ta.value = url;
113+
ta.style.position = 'fixed';
114+
ta.style.opacity = '0';
115+
document.body.appendChild(ta);
116+
ta.select();
117+
document.execCommand('copy');
118+
document.body.removeChild(ta);
119+
}
120+
notify('Campaign link copied to clipboard.', 'success', {
121+
href: url,
122+
label: url.slice(0, 40) + '…',
123+
});
124+
} catch {
125+
notify('Failed to copy link.', 'error');
126+
}
127+
}, [campaign, notify]);
106128

107129
useEffect(() => {
108130
setBannerImageError(false);
@@ -471,12 +493,12 @@ export function CampaignDetailPanel({
471493
) : null}
472494

473495
<div className="share-actions">
474-
<button className="btn-ghost" type="button" onClick={handleDownloadPng}>
496+
<button className="btn-ghost" type="button" onClick={() => { void handleDownloadPng(); }}>
475497
<Download size={16} />
476498
Download PNG
477499
</button>
478-
<button className="btn-ghost" type="button" onClick={handleCopyLink}>
479-
<Link size={16} />
500+
<button className="btn-ghost" type="button" onClick={() => { void handleCopyLink(); }}>
501+
<LinkIcon size={16} />
480502
Copy link
481503
</button>
482504
</div>

frontend/src/components/CampaignShareCard.tsx

Lines changed: 58 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,21 @@ function truncate(text: string, max: number): string {
1414
return text.length > max ? text.slice(0, max - 1) + '…' : text;
1515
}
1616

17-
function drawCard(
17+
function loadImage(url: string): Promise<HTMLImageElement | null> {
18+
return new Promise((resolve) => {
19+
const img = new Image();
20+
img.crossOrigin = 'anonymous';
21+
img.onload = () => resolve(img);
22+
img.onerror = () => resolve(null);
23+
img.src = url;
24+
});
25+
}
26+
27+
async function drawCard(
1828
ctx: CanvasRenderingContext2D,
1929
campaign: Campaign,
2030
brandLogoUrl?: string,
21-
): void {
31+
): Promise<void> {
2232
const w = CANVAS_W;
2333
const h = CANVAS_H;
2434

@@ -53,17 +63,14 @@ function drawCard(
5363
ctx.fillText('STELLAR GOAL VAULT', barX, 60);
5464

5565
if (brandLogoUrl) {
56-
try {
57-
const img = new Image();
58-
img.crossOrigin = 'anonymous';
59-
img.src = brandLogoUrl;
66+
const img = await loadImage(brandLogoUrl);
67+
if (img) {
6068
ctx.save();
6169
ctx.beginPath();
6270
ctx.arc(w - 60, 60, 24, 0, Math.PI * 2);
6371
ctx.clip();
6472
ctx.drawImage(img, w - 84, 36, 48, 48);
6573
ctx.restore();
66-
} catch {
6774
}
6875
}
6976

@@ -149,38 +156,50 @@ function drawCard(
149156
export function useCampaignShareCard() {
150157
const canvasRef = useRef<HTMLCanvasElement | null>(null);
151158

152-
const generate = useCallback((campaign: Campaign, brandLogoUrl?: string): HTMLCanvasElement => {
153-
const canvas = document.createElement('canvas');
154-
canvas.width = CANVAS_W;
155-
canvas.height = CANVAS_H;
156-
const ctx = canvas.getContext('2d');
157-
if (!ctx) throw new Error('Canvas 2D context unavailable');
158-
drawCard(ctx, campaign, brandLogoUrl);
159-
canvasRef.current = canvas;
160-
return canvas;
161-
}, []);
162-
163-
const downloadPng = useCallback((campaign: Campaign, brandLogoUrl?: string) => {
164-
const canvas = generate(campaign, brandLogoUrl);
165-
const link = document.createElement('a');
166-
link.download = `campaign_share_${campaign.id}.png`;
167-
link.href = canvas.toDataURL('image/png');
168-
document.body.appendChild(link);
169-
link.click();
170-
document.body.removeChild(link);
171-
}, [generate]);
172-
173-
const toDataUrl = useCallback((campaign: Campaign, brandLogoUrl?: string): string => {
174-
const canvas = generate(campaign, brandLogoUrl);
175-
return canvas.toDataURL('image/png');
176-
}, [generate]);
177-
178-
const toBlob = useCallback(async (campaign: Campaign, brandLogoUrl?: string): Promise<Blob | null> => {
179-
const canvas = generate(campaign, brandLogoUrl);
180-
return new Promise((resolve) => {
181-
canvas.toBlob((blob) => resolve(blob), 'image/png');
182-
});
183-
}, [generate]);
159+
const generate = useCallback(
160+
async (campaign: Campaign, brandLogoUrl?: string): Promise<HTMLCanvasElement> => {
161+
const canvas = document.createElement('canvas');
162+
canvas.width = CANVAS_W;
163+
canvas.height = CANVAS_H;
164+
const ctx = canvas.getContext('2d');
165+
if (!ctx) throw new Error('Canvas 2D context unavailable');
166+
await drawCard(ctx, campaign, brandLogoUrl);
167+
canvasRef.current = canvas;
168+
return canvas;
169+
},
170+
[],
171+
);
172+
173+
const downloadPng = useCallback(
174+
async (campaign: Campaign, brandLogoUrl?: string): Promise<void> => {
175+
const canvas = await generate(campaign, brandLogoUrl);
176+
const link = document.createElement('a');
177+
link.download = `campaign_share_${campaign.id}.png`;
178+
link.href = canvas.toDataURL('image/png');
179+
document.body.appendChild(link);
180+
link.click();
181+
document.body.removeChild(link);
182+
},
183+
[generate],
184+
);
185+
186+
const toDataUrl = useCallback(
187+
async (campaign: Campaign, brandLogoUrl?: string): Promise<string> => {
188+
const canvas = await generate(campaign, brandLogoUrl);
189+
return canvas.toDataURL('image/png');
190+
},
191+
[generate],
192+
);
193+
194+
const toBlob = useCallback(
195+
async (campaign: Campaign, brandLogoUrl?: string): Promise<Blob | null> => {
196+
const canvas = await generate(campaign, brandLogoUrl);
197+
return new Promise((resolve) => {
198+
canvas.toBlob((blob) => resolve(blob), 'image/png');
199+
});
200+
},
201+
[generate],
202+
);
184203

185204
return { generate, downloadPng, toDataUrl, toBlob };
186205
}

0 commit comments

Comments
 (0)