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:
parent
e4a9b454a4
commit
9ed2c12de1
1 changed files with 141 additions and 12 deletions
|
|
@ -5,15 +5,25 @@ import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import {
|
||||||
Zap, MapPin, Shield, Layers, Crown, Radio,
|
Zap, MapPin, Shield, Layers, Crown, Radio,
|
||||||
ArrowRight, Share2, CheckCircle2, Users, Store, Calendar,
|
ArrowRight, Share2, CheckCircle2, Users, Store, Calendar,
|
||||||
GraduationCap, Route,
|
GraduationCap, Route, Eye, Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useGrowthStats } from '@/hooks/useGrowthStats';
|
import { useGrowthStats } from '@/hooks/useGrowthStats';
|
||||||
import { useToast } from '@/hooks/useToast';
|
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 ───────────────────
|
// ─── Admin pubkeys allowed to publish newsletter as kind 1 ───────────────────
|
||||||
|
|
||||||
|
|
@ -128,7 +138,7 @@ export const NEWSLETTER_ISSUES: NewsletterIssue[] = [
|
||||||
icon: 'feature',
|
icon: 'feature',
|
||||||
title: 'Trusted count on community stats',
|
title: 'Trusted count on community stats',
|
||||||
tag: 'Transparency',
|
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',
|
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 }) {
|
function PublishToNostrButton({ issue }: { issue: NewsletterIssue }) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [published, setPublished] = useState(false);
|
const [published, setPublished] = useState(false);
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
|
||||||
const isAdmin = NEWSLETTER_ADMIN_PUBKEYS.includes(user?.pubkey ?? '');
|
const isAdmin = NEWSLETTER_ADMIN_PUBKEYS.includes(user?.pubkey ?? '');
|
||||||
if (!user || !isAdmin) return null;
|
if (!user || !isAdmin) return null;
|
||||||
|
|
||||||
if (published) {
|
if (published) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-green-400 text-sm font-bold">
|
<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 {
|
try {
|
||||||
await publishEvent({
|
await publishEvent({
|
||||||
kind: 1,
|
kind: 1,
|
||||||
|
|
@ -191,6 +310,7 @@ function PublishToNostrButton({ issue }: { issue: NewsletterIssue }) {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
setPublished(true);
|
setPublished(true);
|
||||||
|
setShowPreview(false);
|
||||||
toast({ title: 'Published!', description: `Drop #${String(issue.number).padStart(3, '0')} posted to Nostr.` });
|
toast({ title: 'Published!', description: `Drop #${String(issue.number).padStart(3, '0')} posted to Nostr.` });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -202,14 +322,23 @@ function PublishToNostrButton({ issue }: { issue: NewsletterIssue }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePublish}
|
onClick={() => setShowPreview(true)}
|
||||||
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"
|
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" />
|
<Share2 className="h-3.5 w-3.5" />
|
||||||
{isPending ? 'Publishing...' : 'Post to Nostr'}
|
Post to Nostr
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<NostrPreviewModal
|
||||||
|
issue={issue}
|
||||||
|
open={showPreview}
|
||||||
|
onClose={() => setShowPreview(false)}
|
||||||
|
onConfirm={handleConfirmPublish}
|
||||||
|
isPending={isPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue