The offer → publish loop had two dead ends: a provider's own page only offered Delete (no Edit), and there was no way to share a listing (the whole point of announcing a service). - Owner "Manage" card gains an "Edit profile" button deep-linking to /become/<craft> (prefills the existing profile). Admins still get only take-down, not a misleading self-edit link. - A Share button on every provider page opens ShareProfileDialog (QR + copy + native share) with the service URL /coach/<pubkey>/<identifier>. - ShareProfileDialog accepts an optional `path` to override the default /<npub> share URL. - main.tsx clean-link shim now also rewrites /coach/<pubkey>/<d> to the hash route, so shared /coach/... links resolve on the live site. Follow-ups (not done): live preview before publishing, draft/autosave for the long forms, a multi-step/progress layout, and a "your Lab profiles" manage strip on /lab.
133 lines
5.4 KiB
TypeScript
133 lines
5.4 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Check, Copy, Share2, Loader2 } from 'lucide-react';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { generateQRCode } from '@/lib/wallet/qrCodeGenerator';
|
|
|
|
interface ShareProfileDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
/** The npub (or nprofile) to build the shareable URL from. */
|
|
npub: string;
|
|
/** Display name, used in the share sheet + QR caption. */
|
|
displayName: string;
|
|
/** Optional path (e.g. `/coach/<pubkey>/<d>`) appended to the canonical origin
|
|
* instead of the default `/<npub>` — for sharing a specific Lab profile. */
|
|
path?: string;
|
|
}
|
|
|
|
/**
|
|
* Share a skater's profile IRL or online. Skateboarding is a physical, local
|
|
* community — a QR you can flash at a spot (or slap on a sticker) is worth more
|
|
* than a raw npub. Falls back gracefully to copy-link when the native share
|
|
* sheet isn't available.
|
|
*/
|
|
export function ShareProfileDialog({ open, onOpenChange, npub, displayName, path }: ShareProfileDialogProps) {
|
|
const { t } = useTranslation();
|
|
const { toast } = useToast();
|
|
const [qr, setQr] = useState<string | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
// Always anchor to the canonical public site so shared links/QRs point to the
|
|
// real domain — never the preview/sandbox iframe host that window.location
|
|
// would give us inside the editor.
|
|
const CANONICAL_ORIGIN = 'https://skate-spots.org';
|
|
const shareUrl = `${CANONICAL_ORIGIN}${path ?? `/${npub}`}`;
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
let active = true;
|
|
setQr(null);
|
|
// qrcode must be imported lazily (see generateQRCode) — a top-level import
|
|
// crashes on its canvas dependency at module load.
|
|
generateQRCode(shareUrl)
|
|
.then((url) => { if (active) setQr(url); })
|
|
.catch((err) => {
|
|
console.error('[ShareProfileDialog] QR generation failed:', err);
|
|
if (active) setQr(null);
|
|
});
|
|
return () => { active = false; };
|
|
}, [open, shareUrl]);
|
|
|
|
const handleCopy = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch {
|
|
toast({ title: t('walletUi.share.copyFailed'), description: t('walletUi.share.copyFailedDesc'), variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
|
|
|
|
const handleNativeShare = async () => {
|
|
try {
|
|
await navigator.share({
|
|
title: `${displayName} on SkateSpots`,
|
|
text: `Check out ${displayName} on SkateSpots 🛹`,
|
|
url: shareUrl,
|
|
});
|
|
} catch {
|
|
// user cancelled or unsupported — no-op
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="w-[calc(100vw-2rem)] max-w-sm overflow-hidden bg-black border-white/10">
|
|
<DialogHeader className="min-w-0">
|
|
<DialogTitle className="flex items-center gap-2 uppercase font-black">
|
|
<Share2 className="h-5 w-5 text-[#ff0040] flex-shrink-0" />
|
|
{t('walletUi.share.title')}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-gray-400">
|
|
{t('walletUi.share.desc', { name: displayName })}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* QR */}
|
|
<div className="flex flex-col items-center gap-4 py-2 min-w-0 w-full">
|
|
<div className="rounded-2xl bg-white p-3 shadow-lg">
|
|
{qr ? (
|
|
<img src={qr} alt={t('walletUi.share.qrAlt')} className="w-48 h-48 sm:w-52 sm:h-52" />
|
|
) : (
|
|
<div className="w-48 h-48 sm:w-52 sm:h-52 flex items-center justify-center">
|
|
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* URL + copy */}
|
|
<button
|
|
onClick={handleCopy}
|
|
className="group w-full max-w-full flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 hover:border-[#ff0040]/40 transition-colors"
|
|
>
|
|
<span className="flex-1 min-w-0 text-left text-sm text-gray-300 truncate font-mono">
|
|
{shareUrl.replace(/^https?:\/\//, '')}
|
|
</span>
|
|
{copied ? (
|
|
<Check className="h-4 w-4 text-[#ccff00] flex-shrink-0" />
|
|
) : (
|
|
<Copy className="h-4 w-4 text-gray-500 group-hover:text-white flex-shrink-0" />
|
|
)}
|
|
</button>
|
|
|
|
<div className="grid grid-cols-1 gap-2 w-full">
|
|
<Button onClick={handleCopy} variant="outline" className="w-full border-white/15 font-bold">
|
|
{copied ? <><Check className="h-4 w-4 mr-2 text-[#ccff00]" />{t('common.copied')}</> : <><Copy className="h-4 w-4 mr-2" />{t('walletUi.share.copyLink')}</>}
|
|
</Button>
|
|
{canNativeShare && (
|
|
<Button onClick={handleNativeShare} className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase">
|
|
<Share2 className="h-4 w-4 mr-2" />
|
|
{t('walletUi.share.shareEllipsis')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|