SkateSpots/src/components/NewsletterSubscribeButton.tsx
shakespeare.diy 08f74d7991 Add Nostr-native newsletter: subscribe, mailbox & notifications
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>
2026-09-03 19:16:49 +07:00

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>
);
}