Add rich Open Graph / Twitter share metadata + canonical share link to event page

- Per-event og:title/description/image/url + twitter:summary_large_image via useSeoMeta
- Share description composes date + location + blurb; falls back to site card image
- handleShare now uses the canonical /#/event/... URL, richer text, and toast feedback on clipboard copy

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 12:46:45 +07:00
parent 87c81a63c9
commit 29d09eb6ec

View file

@ -39,6 +39,7 @@ import {
Megaphone, Megaphone,
} from 'lucide-react'; } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
function formatTime(timestamp: number): string { function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
@ -80,12 +81,53 @@ export default function EventDetailPage() {
const { data: event, isLoading, error } = useSkateEvent(identifier || '', pubkey); const { data: event, isLoading, error } = useSkateEvent(identifier || '', pubkey);
const author = useAuthor(event?.pubkey); const author = useAuthor(event?.pubkey);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { toast } = useToast();
const isOwner = user && event && user.pubkey === event.pubkey; const isOwner = user && event && user.pubkey === event.pubkey;
// Canonical, shareable URL for this event (HashRouter → keep the /#/ prefix).
const shareUrl = event
? `https://skate-spots.org/#/event/${event.pubkey}/${encodeURIComponent(event.identifier)}`
: 'https://skate-spots.org';
// og:image must be an absolute URL. Fall back to the site social card when the
// event has no cover so shared links never render blank.
const ogImage = event?.image || 'https://skate-spots.org/images/icon-1024.png';
// Compose a punchy share description: date + location, then the blurb.
const shareDescription = (() => {
if (!event) return 'View this skateboarding event on SkateSpots.';
const bits: string[] = [];
if (event.start) {
bits.push(
new Date(event.start * 1000).toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
}),
);
}
if (event.location) bits.push(`📍 ${event.location}`);
const prefix = bits.length ? `${bits.join(' · ')} — ` : '';
const body = event.description?.trim() || 'A skateboarding event on SkateSpots.';
return `${prefix}${body}`.slice(0, 200);
})();
const shareTitle = event ? `${event.title} · SkateSpots` : 'Skate Event · SkateSpots';
useSeoMeta({ useSeoMeta({
title: event ? `${event.title} - SkateSpots` : 'Skate Event - SkateSpots', title: shareTitle,
description: event?.description || 'View this skateboarding event on SkateSpots.', description: shareDescription,
ogTitle: shareTitle,
ogDescription: shareDescription,
ogType: 'article',
ogUrl: shareUrl,
ogImage,
ogSiteName: 'SkateSpots',
twitterCard: 'summary_large_image',
twitterTitle: shareTitle,
twitterDescription: shareDescription,
twitterImage: ogImage,
}); });
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
@ -132,18 +174,35 @@ export default function EventDetailPage() {
}, [event]); }, [event]);
const handleShare = async () => { const handleShare = async () => {
const shareText = event
? `${event.title} — a skate event on SkateSpots`
: 'Check out this skate event on SkateSpots';
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share({ await navigator.share({
title: event?.title, title: event?.title,
text: event?.description || `Check out this skate event: ${event?.title}`, text: shareText,
url: window.location.href, url: shareUrl,
}); });
return;
} catch { } catch {
// User cancelled or share failed // User cancelled or share failed — fall through to clipboard.
} }
} else { }
navigator.clipboard.writeText(window.location.href);
try {
await navigator.clipboard.writeText(shareUrl);
toast({
title: 'Link copied',
description: 'Event link copied to your clipboard — paste it anywhere.',
});
} catch {
toast({
title: 'Copy failed',
description: shareUrl,
variant: 'destructive',
});
} }
}; };