fix: remove sponsor mention + add Nostr preview modal before publishing

- "Sponsors see the organic number" -> "Users can now see both numbers"
- PublishToNostrButton now opens a preview modal first (NostrPreviewModal):
  - Mock Nostr post card: avatar, display name, timestamp, post body
  - nostrText rendered with hashtags in blue and URL underlined
  - Fake engagement bar (replies/reposts/zaps at 0) for visual context
  - Character count shown below the card
  - Two actions: "Edit first" (closes modal) / "Publish" (confirms)
  - Full-width on mobile, correct safe-area handling via Dialog component
  - isPending state flows through so button shows "Publishing..." while signing

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-18 15:12:37 +07:00
parent e4a9b454a4
commit 9ed2c12de1

View file

@ -5,15 +5,25 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Link } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Zap, MapPin, Shield, Layers, Crown, Radio,
ArrowRight, Share2, CheckCircle2, Users, Store, Calendar,
GraduationCap, Route,
GraduationCap, Route, Eye, Send,
} from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useGrowthStats } from '@/hooks/useGrowthStats';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
// ─── Admin pubkeys allowed to publish newsletter as kind 1 ───────────────────
@ -128,7 +138,7 @@ export const NEWSLETTER_ISSUES: NewsletterIssue[] = [
icon: 'feature',
title: 'Trusted count on community stats',
tag: 'Transparency',
body: "The stats page now shows spots from 'active accounts' — pubkeys that have published at least 2 items — alongside the raw total. Sponsors see the organic number.",
body: "The stats page now shows spots from 'active accounts' — pubkeys that have published at least 2 items — alongside the raw total. Users can now see both the raw number and the organic number side by side.",
},
{
icon: 'shield',
@ -156,16 +166,125 @@ https://skate-spots.org/newsletter
},
];
// ─── Publish to Nostr button ──────────────────────────────────────────────────
// ─── Nostr post preview + publish ────────────────────────────────────────────
function NostrPreviewModal({
issue,
open,
onClose,
onConfirm,
isPending,
}: {
issue: NewsletterIssue;
open: boolean;
onClose: () => void;
onConfirm: () => void;
isPending: boolean;
}) {
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey ?? '');
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(user?.pubkey ?? '');
const avatar = metadata?.picture;
const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
// Render the nostrText with hashtags highlighted and URL as a link-style span
const renderPreviewText = (text: string) => {
return text.split('\n').map((line, i) => {
const parts = line.split(/(\s+)/).map((word, j) => {
if (word.startsWith('#')) {
return <span key={j} className="text-[#1d9bf0]">{word}</span>;
}
if (word.startsWith('https://')) {
return <span key={j} className="text-[#1d9bf0] underline underline-offset-2">{word}</span>;
}
return <span key={j}>{word}</span>;
});
return <p key={i} className={line === '' ? 'h-3' : ''}>{parts}</p>;
});
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="bg-black border-2 border-[#ccff00]/40 max-w-lg w-[calc(100vw-2rem)] mx-auto">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Eye className="h-4 w-4 text-[#ccff00]" />
Preview — Drop #{String(issue.number).padStart(3, '0')}
</DialogTitle>
<DialogDescription className="text-gray-500 text-xs">
This is how the note will appear on most Nostr clients. Formatting varies by client.
</DialogDescription>
</DialogHeader>
{/* Mock Nostr post card */}
<div className="mt-2 rounded-2xl border border-white/10 bg-[#0f0f0f] overflow-hidden">
{/* Post header */}
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
<Avatar className="h-10 w-10 border-2 border-[#ccff00]/30 flex-shrink-0">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-[#ccff00]/10 text-[#ccff00] text-xs font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-white font-bold text-sm truncate">{displayName}</p>
<p className="text-gray-500 text-xs">{timeStr} · via skate-spots.org</p>
</div>
</div>
{/* Post body */}
<div className="px-4 pb-4 text-sm text-gray-200 leading-relaxed space-y-0">
{renderPreviewText(issue.nostrText)}
</div>
{/* Fake engagement bar */}
<div className="flex items-center gap-5 px-4 py-3 border-t border-white/5 text-gray-600 text-xs">
<span>💬 0</span>
<span>🔁 0</span>
<span>⚡ 0</span>
</div>
</div>
{/* Character count */}
<p className="text-[10px] text-gray-600 text-right -mt-1">
{issue.nostrText.length} characters
</p>
{/* Actions */}
<div className="flex gap-3 mt-1">
<Button
variant="outline"
onClick={onClose}
className="flex-1 border-gray-700 text-gray-300 hover:text-white h-11"
>
Edit first
</Button>
<Button
onClick={onConfirm}
disabled={isPending}
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase text-xs h-11"
>
<Send className="h-3.5 w-3.5" />
{isPending ? 'Publishing...' : 'Publish'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function PublishToNostrButton({ issue }: { issue: NewsletterIssue }) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { toast } = useToast();
const [published, setPublished] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const isAdmin = NEWSLETTER_ADMIN_PUBKEYS.includes(user?.pubkey ?? '');
if (!user || !isAdmin) return null;
if (published) {
return (
<div className="flex items-center gap-2 text-green-400 text-sm font-bold">
@ -175,7 +294,7 @@ function PublishToNostrButton({ issue }: { issue: NewsletterIssue }) {
);
}
const handlePublish = async () => {
const handleConfirmPublish = async () => {
try {
await publishEvent({
kind: 1,
@ -191,6 +310,7 @@ function PublishToNostrButton({ issue }: { issue: NewsletterIssue }) {
],
});
setPublished(true);
setShowPreview(false);
toast({ title: 'Published!', description: `Drop #${String(issue.number).padStart(3, '0')} posted to Nostr.` });
} catch (err) {
toast({
@ -202,14 +322,23 @@ function PublishToNostrButton({ issue }: { issue: NewsletterIssue }) {
};
return (
<Button
onClick={handlePublish}
disabled={isPending}
className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase text-xs h-10 w-full sm:w-auto"
>
<Share2 className="h-3.5 w-3.5" />
{isPending ? 'Publishing...' : 'Post to Nostr'}
</Button>
<>
<Button
onClick={() => setShowPreview(true)}
className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase text-xs h-10 w-full sm:w-auto"
>
<Share2 className="h-3.5 w-3.5" />
Post to Nostr
</Button>
<NostrPreviewModal
issue={issue}
open={showPreview}
onClose={() => setShowPreview(false)}
onConfirm={handleConfirmPublish}
isPending={isPending}
/>
</>
);
}