Turn the static newsletter into a live, Nostr-native marketing channel that npubs can subscribe to and receive in-app. Publishing (admins): - Newsletter issues are published as NIP-23 long-form articles (kind 30023) tagged `skatespotsnews`, on the community relay. - NewsletterComposer: admin-only dialog to write + publish an issue (title, slug, summary, cover image, Markdown body). Subscribing (any npub): - useNewsletterSubscription: per-user opt-in stored as a NIP-78 (kind 30078) marker, so it syncs across every device the user logs into. - NewsletterSubscribeButton on the /newsletter page and issue reader. Mailbox / notifications: - New "News" tab in the NotificationBell showing all issues, with an unread badge for subscribers (tracked via a per-account last-read timestamp). Latest unread drop also surfaces in the "All" tab. - Opening the bell or the /newsletter page clears the News badge. Reading: - useNewsletterIssues: queries admin-authored issues (author-scoped + tag + admin re-validation as the trust boundary against spam). - LiveIssuesSection on /newsletter shows issue cards linking to a reader. - NewsletterIssueView: naddr reader (wired into NIP19Page for kind 30023) with a small safe Markdown renderer, zap + subscribe CTAs. Docs: NIP.md documents the 30023 issue schema, the 30078 subscription marker, and the admin trust boundary. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
104 lines
3.3 KiB
TypeScript
104 lines
3.3 KiB
TypeScript
import { useState } from 'react';
|
|
import { Bell, BellOff, Loader2, Check } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { LoginArea } from '@/components/auth/LoginArea';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* Opt-in / opt-out of the SkateSpots newsletter mailbox. When subscribed, new
|
|
* issues surface in the notification bell's "News" tab. Logged-out users see a
|
|
* login prompt instead.
|
|
*/
|
|
export function NewsletterSubscribeButton({ className }: { className?: string }) {
|
|
const { user } = useCurrentUser();
|
|
const { isSubscribed, isLoading, setSubscribed, isUpdating } = useNewsletterSubscription();
|
|
const { toast } = useToast();
|
|
const [justSubscribed, setJustSubscribed] = useState(false);
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className={cn('flex flex-col items-center gap-2', className)}>
|
|
<p className="text-xs text-gray-500">Log in with Nostr to get new drops in your mailbox.</p>
|
|
<LoginArea className="max-w-60" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handleToggle = async () => {
|
|
try {
|
|
const next = !isSubscribed;
|
|
await setSubscribed(next);
|
|
if (next) {
|
|
setJustSubscribed(true);
|
|
setTimeout(() => setJustSubscribed(false), 2500);
|
|
toast({
|
|
title: "You're subscribed! 🛹",
|
|
description: 'New drops will land in your notification bell under "News".',
|
|
});
|
|
} else {
|
|
toast({
|
|
title: 'Unsubscribed',
|
|
description: 'You can still read every issue here anytime.',
|
|
});
|
|
}
|
|
} catch (err) {
|
|
toast({
|
|
title: 'Something went wrong',
|
|
description: err instanceof Error ? err.message : 'Please try again.',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Button disabled className={cn('gap-2 h-11', className)}>
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Checking…
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
if (isSubscribed) {
|
|
return (
|
|
<Button
|
|
onClick={handleToggle}
|
|
disabled={isUpdating}
|
|
variant="outline"
|
|
className={cn(
|
|
'group gap-2 h-11 border-[#ccff00]/50 bg-[#ccff00]/10 font-black uppercase text-xs text-[#ccff00] hover:bg-[#ff0040]/10 hover:border-[#ff0040]/50 hover:text-[#ff0040]',
|
|
className,
|
|
)}
|
|
>
|
|
{isUpdating ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : justSubscribed ? (
|
|
<Check className="h-4 w-4" />
|
|
) : (
|
|
<>
|
|
<Check className="h-4 w-4 group-hover:hidden" />
|
|
<BellOff className="hidden h-4 w-4 group-hover:block" />
|
|
</>
|
|
)}
|
|
<span className="group-hover:hidden">Subscribed</span>
|
|
<span className="hidden group-hover:inline">Unsubscribe</span>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
onClick={handleToggle}
|
|
disabled={isUpdating}
|
|
className={cn(
|
|
'gap-2 h-11 bg-[#ccff00] font-black uppercase text-xs text-black hover:bg-[#ccff00]/90',
|
|
className,
|
|
)}
|
|
>
|
|
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
|
|
Subscribe to the newsletter
|
|
</Button>
|
|
);
|
|
}
|