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>
This commit is contained in:
parent
cdf8cee3a6
commit
08f74d7991
11 changed files with 1031 additions and 16 deletions
61
NIP.md
61
NIP.md
|
|
@ -2904,3 +2904,64 @@ pubkeys always have Pro.
|
||||||
decrypted locally by the signer and never sent to any AI or server.
|
decrypted locally by the signer and never sent to any AI or server.
|
||||||
- Free Sasquatch remains fully usable (public spot/event/coach/gear/crew search
|
- Free Sasquatch remains fully usable (public spot/event/coach/gear/crew search
|
||||||
plus the trick glossary); Pro adds the personal + action layer.
|
plus the trick glossary); Pro adds the personal + action layer.
|
||||||
|
|
||||||
|
## Newsletter (kind 30023) + Subscription (kind 30078)
|
||||||
|
|
||||||
|
SkateSpots publishes a community newsletter as standard **NIP-23 long-form
|
||||||
|
articles (kind 30023)**, so issues are readable in any Nostr long-form client
|
||||||
|
(Habla, Highlighter, etc.) and by the app's in-app mailbox.
|
||||||
|
|
||||||
|
### Trust boundary
|
||||||
|
|
||||||
|
An issue is only surfaced in the app (mailbox / notification bell) if it is:
|
||||||
|
|
||||||
|
1. **Kind 30023**, and
|
||||||
|
2. authored by a **SkateSpots admin pubkey** (see `src/lib/admins.ts`), and
|
||||||
|
3. tagged `["t", "skatespotsnews"]`.
|
||||||
|
|
||||||
|
This prevents anyone from spamming the community newsletter feed.
|
||||||
|
|
||||||
|
### Issue format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 30023,
|
||||||
|
"content": "<Markdown body of the issue>",
|
||||||
|
"tags": [
|
||||||
|
["d", "issue-6"],
|
||||||
|
["title", "SkateSpots Drop #006"],
|
||||||
|
["summary", "One-line teaser of the issue"],
|
||||||
|
["image", "<cover image url>"],
|
||||||
|
["published_at", "<unix seconds>"],
|
||||||
|
["t", "skatespotsnews"],
|
||||||
|
["t", "skatespots"],
|
||||||
|
["t", "newsletter"],
|
||||||
|
["alt", "SkateSpots newsletter issue"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subscription marker (kind 30078)
|
||||||
|
|
||||||
|
A reader opts in to the in-app newsletter mailbox by publishing a per-user
|
||||||
|
**NIP-78 application-specific data** event (kind 30078, addressable):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 30078,
|
||||||
|
"content": "",
|
||||||
|
"tags": [
|
||||||
|
["d", "skatespots:newsletter:subscription"],
|
||||||
|
["subscribed", "true"],
|
||||||
|
["alt", "SkateSpots newsletter subscription"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `subscribed` is `"true"` while subscribed and `"false"` after unsubscribing
|
||||||
|
(the event is replaced, not deleted, so state syncs across devices).
|
||||||
|
- It carries nothing sensitive (just a boolean), so it is published in
|
||||||
|
plaintext, which also lets the community tally an honest subscriber count.
|
||||||
|
- Subscription is opt-in for notifications only: everyone can read issues on
|
||||||
|
the `/newsletter` page regardless. Subscribing routes new issues into the
|
||||||
|
notification bell's "News" tab and unread badge.
|
||||||
|
|
|
||||||
187
src/components/NewsletterComposer.tsx
Normal file
187
src/components/NewsletterComposer.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { PenLine, Send, Loader2, Sparkles } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
|
import { NEWSLETTER_KIND, NEWSLETTER_TAG } from '@/lib/types';
|
||||||
|
|
||||||
|
/** Slugify a title into a stable `d` identifier. */
|
||||||
|
function slugify(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')
|
||||||
|
.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only composer that publishes a newsletter issue as a NIP-23 long-form
|
||||||
|
* article (kind 30023) tagged `skatespotsnews`. Subscribers then receive it in
|
||||||
|
* their notification bell's "News" tab and it appears on the /newsletter page.
|
||||||
|
*/
|
||||||
|
export function NewsletterComposer() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { mutateAsync: publish, isPending } = useNostrPublish();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [summary, setSummary] = useState('');
|
||||||
|
const [image, setImage] = useState('');
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
|
||||||
|
if (!isAdminPubkey(user?.pubkey)) return null;
|
||||||
|
|
||||||
|
const effectiveSlug = slug.trim() || slugify(title);
|
||||||
|
const canPublish = title.trim().length >= 2 && body.trim().length >= 10 && !!effectiveSlug;
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
if (!canPublish) return;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
try {
|
||||||
|
await publish({
|
||||||
|
kind: NEWSLETTER_KIND,
|
||||||
|
content: body,
|
||||||
|
tags: [
|
||||||
|
['d', effectiveSlug],
|
||||||
|
['title', title.trim()],
|
||||||
|
...(summary.trim() ? [['summary', summary.trim()]] : []),
|
||||||
|
...(image.trim() ? [['image', image.trim()]] : []),
|
||||||
|
['published_at', String(now)],
|
||||||
|
['t', NEWSLETTER_TAG],
|
||||||
|
['t', 'skatespots'],
|
||||||
|
['t', 'newsletter'],
|
||||||
|
['t', 'skateboarding'],
|
||||||
|
['alt', `SkateSpots newsletter issue: ${title.trim()}`],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Issue published! 🛹',
|
||||||
|
description: 'Subscribers will see it in their notification bell.',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['newsletter-issues'] });
|
||||||
|
setOpen(false);
|
||||||
|
setTitle('');
|
||||||
|
setSlug('');
|
||||||
|
setSummary('');
|
||||||
|
setImage('');
|
||||||
|
setBody('');
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Publish failed',
|
||||||
|
description: err instanceof Error ? err.message : 'Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||||
|
>
|
||||||
|
<PenLine className="h-4 w-4" /> New issue
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-lg w-[calc(100vw-2rem)] bg-black border-2 border-[#ccff00]/30">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-white">
|
||||||
|
<Sparkles className="h-4 w-4 text-[#ccff00]" /> Publish a newsletter issue
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">
|
||||||
|
Published as a NIP-23 article (kind 30023) on the community relay. Subscribers get it in
|
||||||
|
their notification bell. Body supports Markdown.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-400">Title</Label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="SkateSpots Drop #006"
|
||||||
|
maxLength={120}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-400">
|
||||||
|
Identifier (slug) <span className="text-gray-600">· leave blank to auto-generate</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
placeholder={slugify(title) || 'issue-6'}
|
||||||
|
maxLength={60}
|
||||||
|
className="mt-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-gray-600">
|
||||||
|
Re-using an existing slug edits that issue instead of creating a new one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-400">Summary <span className="text-gray-600">· optional</span></Label>
|
||||||
|
<Input
|
||||||
|
value={summary}
|
||||||
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
|
placeholder="One-line teaser shown in the mailbox & cards"
|
||||||
|
maxLength={200}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-400">Cover image URL <span className="text-gray-600">· optional</span></Label>
|
||||||
|
<Input
|
||||||
|
value={image}
|
||||||
|
onChange={(e) => setImage(e.target.value)}
|
||||||
|
placeholder="https://…"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-400">Body (Markdown)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder={'## What shipped\n\n- New thing one\n- New thing two\n\nRead more at **skate-spots.org**.'}
|
||||||
|
className="mt-1 min-h-[200px] font-mono text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-right text-[10px] text-gray-600">{body.length} characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handlePublish}
|
||||||
|
disabled={!canPublish || isPending}
|
||||||
|
className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
Publish issue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/components/NewsletterIssueView.tsx
Normal file
210
src/components/NewsletterIssueView.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useSeoMeta } from '@unhead/react';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Radio, Calendar } from 'lucide-react';
|
||||||
|
import { Layout } from '@/components/Layout';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
|
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { NEWSLETTER_KIND, parseNewsletterIssue, type SkateNewsletterIssue } from '@/lib/types';
|
||||||
|
import NotFound from '@/pages/NotFound';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Very small, safe Markdown → React renderer for newsletter bodies.
|
||||||
|
* Supports headings, bold, italics, links, list items and paragraphs — enough
|
||||||
|
* for a newsletter without pulling in a full Markdown dependency. All text is
|
||||||
|
* rendered as plain React text nodes (never dangerouslySetInnerHTML), and only
|
||||||
|
* http(s) links are linkified, so there's no XSS surface from issue content.
|
||||||
|
*/
|
||||||
|
function renderMarkdown(md: string) {
|
||||||
|
const lines = md.split('\n');
|
||||||
|
const blocks: React.ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
const renderInline = (text: string): React.ReactNode[] => {
|
||||||
|
const nodes: React.ReactNode[] = [];
|
||||||
|
// Tokenise **bold**, *italic*, and [label](url) / bare http(s) links.
|
||||||
|
const regex = /(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\[([^\]]+)\]\((https?:\/\/[^\s)]+)\))|(https?:\/\/[^\s]+)/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
let i = 0;
|
||||||
|
while ((m = regex.exec(text)) !== null) {
|
||||||
|
if (m.index > last) nodes.push(text.slice(last, m.index));
|
||||||
|
if (m[1]) {
|
||||||
|
nodes.push(<strong key={i++} className="font-black text-white">{m[2]}</strong>);
|
||||||
|
} else if (m[3]) {
|
||||||
|
nodes.push(<em key={i++} className="italic">{m[4]}</em>);
|
||||||
|
} else if (m[5]) {
|
||||||
|
nodes.push(
|
||||||
|
<a key={i++} href={m[7]} target="_blank" rel="noopener noreferrer" className="text-[#ccff00] underline underline-offset-2 hover:opacity-80">
|
||||||
|
{m[6]}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
} else if (m[8]) {
|
||||||
|
nodes.push(
|
||||||
|
<a key={i++} href={m[8]} target="_blank" rel="noopener noreferrer" className="text-[#ccff00] underline underline-offset-2 hover:opacity-80 break-all">
|
||||||
|
{m[8]}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last = regex.lastIndex;
|
||||||
|
}
|
||||||
|
if (last < text.length) nodes.push(text.slice(last));
|
||||||
|
return nodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
let listBuffer: string[] = [];
|
||||||
|
const flushList = () => {
|
||||||
|
if (listBuffer.length === 0) return;
|
||||||
|
blocks.push(
|
||||||
|
<ul key={key++} className="my-4 list-disc space-y-1.5 pl-5 text-gray-300">
|
||||||
|
{listBuffer.map((item, idx) => (
|
||||||
|
<li key={idx}>{renderInline(item)}</li>
|
||||||
|
))}
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
listBuffer = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trimEnd();
|
||||||
|
if (/^\s*[-*]\s+/.test(line)) {
|
||||||
|
listBuffer.push(line.replace(/^\s*[-*]\s+/, ''));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushList();
|
||||||
|
if (line.trim() === '') continue;
|
||||||
|
if (line.startsWith('### ')) {
|
||||||
|
blocks.push(<h3 key={key++} className="mt-6 mb-2 text-lg font-black uppercase text-white">{renderInline(line.slice(4))}</h3>);
|
||||||
|
} else if (line.startsWith('## ')) {
|
||||||
|
blocks.push(<h2 key={key++} className="mt-8 mb-3 text-xl font-black uppercase text-white">{renderInline(line.slice(3))}</h2>);
|
||||||
|
} else if (line.startsWith('# ')) {
|
||||||
|
blocks.push(<h1 key={key++} className="mt-8 mb-3 text-2xl font-black uppercase text-white">{renderInline(line.slice(2))}</h1>);
|
||||||
|
} else {
|
||||||
|
blocks.push(<p key={key++} className="my-3 leading-relaxed text-gray-300">{renderInline(line)}</p>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushList();
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewsletterIssueView({ pubkey, identifier }: { pubkey: string; identifier: string }) {
|
||||||
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const author = useAuthor(pubkey);
|
||||||
|
|
||||||
|
const { data: issue, isLoading } = useQuery({
|
||||||
|
queryKey: ['newsletter-issue', pubkey, identifier, getQueryKeySuffix()],
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
queryFn: async (c): Promise<SkateNewsletterIssue | null> => {
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
const events = await querySkateContent(
|
||||||
|
[{ kinds: [NEWSLETTER_KIND], authors: [pubkey], '#d': [identifier], limit: 1 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
for (const ev of events) {
|
||||||
|
const parsed = parseNewsletterIssue(ev);
|
||||||
|
if (parsed) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = useMemo(() => (issue ? renderMarkdown(issue.content) : null), [issue]);
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: issue ? `${issue.title} - SkateSpots Newsletter` : 'Newsletter - SkateSpots',
|
||||||
|
description: issue?.summary || 'A SkateSpots newsletter drop.',
|
||||||
|
...(issue?.image ? { ogImage: issue.image } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="container max-w-2xl px-4 py-8">
|
||||||
|
<Skeleton className="mb-6 h-6 w-32" />
|
||||||
|
<Skeleton className="mb-4 aspect-video w-full rounded-2xl" />
|
||||||
|
<Skeleton className="mb-3 h-10 w-3/4" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-5/6" />
|
||||||
|
<Skeleton className="h-4 w-4/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a valid / admin-authored newsletter issue → 404 (keeps the trust boundary).
|
||||||
|
if (!issue) return <NotFound />;
|
||||||
|
|
||||||
|
const authorName = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||||
|
const authorPic = author.data?.metadata?.picture;
|
||||||
|
const date = new Date(issue.publishedAt * 1000).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<article className="container max-w-2xl px-4 py-8 pb-24 md:pb-14">
|
||||||
|
<Link
|
||||||
|
to="/newsletter"
|
||||||
|
className="mb-6 inline-flex items-center gap-2 text-sm text-gray-400 transition-colors hover:text-[#ccff00]"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" /> All issues
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-[#ccff00]/30 bg-[#ccff00]/5 px-3 py-1.5 text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
|
||||||
|
<Radio className="h-3 w-3" /> SkateSpots Newsletter
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issue.image && (
|
||||||
|
<div className="mb-6 aspect-video w-full overflow-hidden rounded-2xl border border-white/10">
|
||||||
|
<img src={issue.image} alt={issue.title} className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="mb-3 text-3xl font-black uppercase leading-tight text-white sm:text-4xl">
|
||||||
|
{issue.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mb-6 flex flex-wrap items-center gap-4 text-xs text-gray-500">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3.5 w-3.5" /> {date}
|
||||||
|
</span>
|
||||||
|
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-1.5 hover:text-gray-300">
|
||||||
|
<Avatar className="h-5 w-5">
|
||||||
|
<AvatarImage src={authorPic} alt={authorName} />
|
||||||
|
<AvatarFallback className="text-[8px]">{authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{authorName}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issue.summary && (
|
||||||
|
<p className="mb-6 border-l-2 border-[#ccff00]/40 pl-4 text-lg leading-relaxed text-gray-300">
|
||||||
|
{issue.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="prose-invert">{body}</div>
|
||||||
|
|
||||||
|
{/* Footer CTA: zap the issue + subscribe */}
|
||||||
|
<div className="mt-10 flex flex-col items-center gap-4 rounded-2xl border-2 border-[#ccff00]/20 bg-white/[0.03] p-6 text-center">
|
||||||
|
<p className="text-sm font-bold text-white">Dig the drop? Zap it and never miss the next one.</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<ZapButton target={issue.event} className="bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" />
|
||||||
|
<NewsletterSubscribeButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/components/NewsletterSubscribeButton.tsx
Normal file
104
src/components/NewsletterSubscribeButton.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart } from 'lucide-react';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart, Radio, ArrowRight } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
@ -13,11 +14,14 @@ import { ActivityNotificationCard } from '@/components/ActivityNotificationCard'
|
||||||
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
||||||
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
||||||
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
||||||
|
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
|
||||||
|
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
|
||||||
|
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
|
||||||
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { SKATE_CREW_KIND, type MemberVisibility, type SkateCrew } from '@/lib/types';
|
import { NEWSLETTER_KIND, SKATE_CREW_KIND, type MemberVisibility, type SkateCrew, type SkateNewsletterIssue } from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -122,6 +126,56 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A newsletter issue row in the bell's News tab. */
|
||||||
|
function NewsletterItem({
|
||||||
|
issue,
|
||||||
|
unread,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
issue: SkateNewsletterIssue;
|
||||||
|
unread: boolean;
|
||||||
|
onNavigate: () => void;
|
||||||
|
}) {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: NEWSLETTER_KIND,
|
||||||
|
pubkey: issue.pubkey,
|
||||||
|
identifier: issue.identifier,
|
||||||
|
});
|
||||||
|
const date = new Date(issue.publishedAt * 1000).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/${naddr}`}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={cn(
|
||||||
|
'group flex gap-3 rounded-xl border p-3 transition-all hover:border-[#ccff00]/50 hover:bg-white/[0.06]',
|
||||||
|
unread ? 'border-[#ccff00]/30 bg-[#ccff00]/[0.06]' : 'border-white/10 bg-white/[0.02]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[#ccff00]/15">
|
||||||
|
{issue.image ? (
|
||||||
|
<img src={issue.image} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Radio className="h-4 w-4 text-[#ccff00]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="truncate text-sm font-black text-white">{issue.title}</p>
|
||||||
|
{unread && <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#ccff00]" />}
|
||||||
|
</div>
|
||||||
|
{issue.summary && <p className="line-clamp-2 text-xs text-gray-400">{issue.summary}</p>}
|
||||||
|
<p className="mt-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-gray-600">
|
||||||
|
{date} · Read <ArrowRight className="h-2.5 w-2.5" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyState({ label }: { label: string }) {
|
function EmptyState({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||||
|
|
@ -151,6 +205,11 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
||||||
const { lastRead, markAllRead } = useNotificationsRead();
|
const { lastRead, markAllRead } = useNotificationsRead();
|
||||||
|
|
||||||
|
// Newsletter mailbox — only nags subscribers, but always browsable in the tab.
|
||||||
|
const { data: newsletterIssues = [], isLoading: newsletterLoading } = useNewsletterIssues();
|
||||||
|
const { lastRead: newsletterLastRead, markNewsletterRead } = useNewsletterRead();
|
||||||
|
const { isSubscribed } = useNewsletterSubscription();
|
||||||
|
|
||||||
const crewTotal = useMemo(
|
const crewTotal = useMemo(
|
||||||
() => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length,
|
() => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length,
|
||||||
[invites, listedCrews, joinRequests, collectiveRequests],
|
[invites, listedCrews, joinRequests, collectiveRequests],
|
||||||
|
|
@ -162,13 +221,26 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
[activity, lastRead],
|
[activity, lastRead],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Crew items are always "actionable" so they always count toward the badge.
|
// Unread newsletter issues (only counts for subscribers).
|
||||||
const badgeTotal = crewTotal + unreadActivity;
|
const unreadNewsletter = useMemo(
|
||||||
|
() =>
|
||||||
|
isSubscribed
|
||||||
|
? newsletterIssues.filter((i) => i.publishedAt > newsletterLastRead).length
|
||||||
|
: 0,
|
||||||
|
[isSubscribed, newsletterIssues, newsletterLastRead],
|
||||||
|
);
|
||||||
|
|
||||||
// Opening the bell marks everything as read.
|
// Crew items are always "actionable" so they always count toward the badge.
|
||||||
|
const badgeTotal = crewTotal + unreadActivity + unreadNewsletter;
|
||||||
|
|
||||||
|
// Opening the bell marks social + crew as read (and newsletter, since the
|
||||||
|
// News tab is right there). Newsletter read state also clears on the /newsletter page.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) markAllRead();
|
if (open) {
|
||||||
}, [open, markAllRead]);
|
markAllRead();
|
||||||
|
markNewsletterRead();
|
||||||
|
}
|
||||||
|
}, [open, markAllRead, markNewsletterRead]);
|
||||||
|
|
||||||
const isLoading = crewLoading || activityLoading;
|
const isLoading = crewLoading || activityLoading;
|
||||||
|
|
||||||
|
|
@ -207,7 +279,7 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="all" className="w-full">
|
<Tabs defaultValue="all" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3 rounded-none border-b border-white/10 bg-transparent p-0">
|
<TabsList className="grid w-full grid-cols-4 rounded-none border-b border-white/10 bg-transparent p-0">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="all"
|
value="all"
|
||||||
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||||
|
|
@ -221,6 +293,13 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
>
|
>
|
||||||
<Heart className="mr-1 h-3 w-3" /> Social
|
<Heart className="mr-1 h-3 w-3" /> Social
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="news"
|
||||||
|
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-[#ccff00] data-[state=active]:bg-transparent data-[state=active]:text-[#ccff00]"
|
||||||
|
>
|
||||||
|
<Radio className="mr-1 h-3 w-3" /> News
|
||||||
|
{unreadNewsletter > 0 && <span className="ml-1 text-[#ccff00]">{unreadNewsletter}</span>}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="crews"
|
value="crews"
|
||||||
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||||
|
|
@ -238,8 +317,8 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && crewTotal === 0 && activity.length === 0 && (
|
{!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && (
|
||||||
<EmptyState label="Zaps, reactions, replies and crew activity will show up here." />
|
<EmptyState label="Zaps, reactions, replies, newsletter drops and crew activity will show up here." />
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||||
|
|
@ -254,6 +333,15 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
{listedCrews.map((crew) => (
|
{listedCrews.map((crew) => (
|
||||||
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
||||||
))}
|
))}
|
||||||
|
{/* Latest newsletter drop surfaces in All when unread (subscribers). */}
|
||||||
|
{isSubscribed && newsletterIssues[0] && newsletterIssues[0].publishedAt > newsletterLastRead && (
|
||||||
|
<NewsletterItem
|
||||||
|
key={newsletterIssues[0].id}
|
||||||
|
issue={newsletterIssues[0]}
|
||||||
|
unread
|
||||||
|
onNavigate={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activity.map((n) => (
|
{activity.map((n) => (
|
||||||
<ActivityNotificationCard
|
<ActivityNotificationCard
|
||||||
key={n.id}
|
key={n.id}
|
||||||
|
|
@ -287,6 +375,39 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ─── NEWS (newsletter mailbox) ───────────────────────────── */}
|
||||||
|
<TabsContent value="news" className="m-0 p-3">
|
||||||
|
{newsletterLoading && newsletterIssues.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading the latest drops…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!newsletterLoading && newsletterIssues.length === 0 && (
|
||||||
|
<EmptyState label="SkateSpots newsletter drops will land here. Subscribe on the Newsletter page." />
|
||||||
|
)}
|
||||||
|
{!isSubscribed && newsletterIssues.length > 0 && (
|
||||||
|
<Link
|
||||||
|
to="/newsletter"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="mb-2.5 flex items-center gap-2 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/[0.06] p-3 text-xs font-bold text-[#ccff00] transition-colors hover:bg-[#ccff00]/10"
|
||||||
|
>
|
||||||
|
<Radio className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span className="flex-1">Subscribe to get new drops in your bell</span>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{newsletterIssues.map((issue) => (
|
||||||
|
<NewsletterItem
|
||||||
|
key={issue.id}
|
||||||
|
issue={issue}
|
||||||
|
unread={isSubscribed && issue.publishedAt > newsletterLastRead}
|
||||||
|
onNavigate={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* ─── CREWS ───────────────────────────────────────────────── */}
|
{/* ─── CREWS ───────────────────────────────────────────────── */}
|
||||||
<TabsContent value="crews" className="m-0 p-3">
|
<TabsContent value="crews" className="m-0 p-3">
|
||||||
{crewLoading && crewTotal === 0 && (
|
{crewLoading && crewTotal === 0 && (
|
||||||
|
|
|
||||||
60
src/hooks/useNewsletterIssues.ts
Normal file
60
src/hooks/useNewsletterIssues.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { ADMIN_PUBKEYS } from '@/lib/admins';
|
||||||
|
import {
|
||||||
|
NEWSLETTER_KIND,
|
||||||
|
NEWSLETTER_TAG,
|
||||||
|
parseNewsletterIssue,
|
||||||
|
type SkateNewsletterIssue,
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live SkateSpots newsletter issues, published by app admins as NIP-23
|
||||||
|
* long-form articles (kind 30023) tagged `skatespotsnews`.
|
||||||
|
*
|
||||||
|
* Trust: the query is scoped to `authors: ADMIN_PUBKEYS` at the relay level AND
|
||||||
|
* re-validated client-side via `parseNewsletterIssue` (which re-checks the
|
||||||
|
* admin allowlist + the newsletter tag) so a rogue relay can't inject issues.
|
||||||
|
*
|
||||||
|
* Sorted newest-first by `published_at`.
|
||||||
|
*/
|
||||||
|
export function useNewsletterIssues(limit = 30) {
|
||||||
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['newsletter-issues', getQueryKeySuffix()],
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 30 * 60 * 1000,
|
||||||
|
queryFn: async (c): Promise<SkateNewsletterIssue[]> => {
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
|
||||||
|
const events = await querySkateContent(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [NEWSLETTER_KIND],
|
||||||
|
authors: ADMIN_PUBKEYS,
|
||||||
|
'#t': [NEWSLETTER_TAG],
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
const issues: SkateNewsletterIssue[] = [];
|
||||||
|
for (const ev of events) {
|
||||||
|
const parsed = parseNewsletterIssue(ev);
|
||||||
|
if (parsed) issues.push(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe by addressable coordinate (pubkey:d) keeping the newest.
|
||||||
|
const byCoord = new Map<string, SkateNewsletterIssue>();
|
||||||
|
for (const issue of issues) {
|
||||||
|
const key = `${issue.pubkey}:${issue.identifier}`;
|
||||||
|
const existing = byCoord.get(key);
|
||||||
|
if (!existing || issue.createdAt > existing.createdAt) byCoord.set(key, issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byCoord.values()).sort((a, b) => b.publishedAt - a.publishedAt);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
20
src/hooks/useNewsletterRead.ts
Normal file
20
src/hooks/useNewsletterRead.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the timestamp (unix seconds) at which the user last read the newsletter
|
||||||
|
* mailbox. Any issue published after this is "unread" and contributes to the
|
||||||
|
* News badge. Stored per-pubkey so switching accounts keeps independent state.
|
||||||
|
*/
|
||||||
|
export function useNewsletterRead() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const key = user ? `newsletter-last-read:${user.pubkey}` : 'newsletter-last-read:anon';
|
||||||
|
const [lastRead, setLastRead] = useLocalStorage<number>(key, 0);
|
||||||
|
|
||||||
|
const markNewsletterRead = useCallback(() => {
|
||||||
|
setLastRead(Math.floor(Date.now() / 1000));
|
||||||
|
}, [setLastRead]);
|
||||||
|
|
||||||
|
return { lastRead, markNewsletterRead };
|
||||||
|
}
|
||||||
85
src/hooks/useNewsletterSubscription.ts
Normal file
85
src/hooks/useNewsletterSubscription.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { APP_DATA_KIND, NEWSLETTER_SUB_D } from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user SkateSpots newsletter subscription, stored as a NIP-78
|
||||||
|
* application-specific data event (kind 30078, addressable). Because it's the
|
||||||
|
* user's own replaceable event, the subscribed/unsubscribed state syncs across
|
||||||
|
* every device they log in from.
|
||||||
|
*
|
||||||
|
* The marker carries nothing sensitive — just a `subscribed` boolean tag — so
|
||||||
|
* it's published in plaintext (which also lets the community tally subscribers).
|
||||||
|
*
|
||||||
|
* Subscription is opt-in for the in-app mailbox / notification bell only.
|
||||||
|
* Everyone can read issues on the /newsletter page regardless.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function subscriptionKey(pubkey: string | undefined) {
|
||||||
|
return ['newsletter-subscription', pubkey] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNewsletterSubscription() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: subscriptionKey(user?.pubkey),
|
||||||
|
enabled: !!user?.pubkey,
|
||||||
|
staleTime: 60_000,
|
||||||
|
queryFn: async (c): Promise<boolean> => {
|
||||||
|
if (!user?.pubkey) return false;
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
|
||||||
|
try {
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: [APP_DATA_KIND], authors: [user.pubkey], '#d': [NEWSLETTER_SUB_D], limit: 1 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const ev = events[0];
|
||||||
|
if (!ev) return false;
|
||||||
|
const subscribed = ev.tags.find(([n]) => n === 'subscribed')?.[1];
|
||||||
|
return subscribed === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (subscribe: boolean) => {
|
||||||
|
if (!user?.pubkey) throw new Error('You must be logged in to subscribe.');
|
||||||
|
|
||||||
|
// Optimistic update so the toggle feels instant.
|
||||||
|
queryClient.setQueryData(subscriptionKey(user.pubkey), subscribe);
|
||||||
|
|
||||||
|
const event = await user.signer.signEvent({
|
||||||
|
kind: APP_DATA_KIND,
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['d', NEWSLETTER_SUB_D],
|
||||||
|
['subscribed', subscribe ? 'true' : 'false'],
|
||||||
|
['alt', 'SkateSpots newsletter subscription'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await nostr.event(event, { signal: AbortSignal.timeout(10000) });
|
||||||
|
return subscribe;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// Roll back optimistic state on failure.
|
||||||
|
queryClient.invalidateQueries({ queryKey: subscriptionKey(user?.pubkey) });
|
||||||
|
},
|
||||||
|
onSuccess: (subscribe) => {
|
||||||
|
queryClient.setQueryData(subscriptionKey(user?.pubkey), subscribe);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSubscribed: query.data ?? false,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
setSubscribed: mutation.mutateAsync,
|
||||||
|
isUpdating: mutation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,70 @@ export const CREW_INVITE_KIND = 36229; // Addressable crew invitation — founde
|
||||||
export const CREW_JOIN_REQUEST_KIND = 31969; // Addressable crew join-request — a skater asks to join a crew (notifiable). SkateSpots-native.
|
export const CREW_JOIN_REQUEST_KIND = 31969; // Addressable crew join-request — a skater asks to join a crew (notifiable). SkateSpots-native.
|
||||||
export const CREW_ENDORSEMENT_KIND = 9087; // Regular event — an admin endorses ("vouches for") a join-request; the founder ratifies. SkateSpots-native.
|
export const CREW_ENDORSEMENT_KIND = 9087; // Regular event — an admin endorses ("vouches for") a join-request; the founder ratifies. SkateSpots-native.
|
||||||
export const COLLECTIVE_JOIN_REQUEST_KIND = 36387; // Addressable Lab-collective join-request — someone asks to join a Lab collective (studio/build team). Notifiable. SkateSpots-native.
|
export const COLLECTIVE_JOIN_REQUEST_KIND = 36387; // Addressable Lab-collective join-request — someone asks to join a Lab collective (studio/build team). Notifiable. SkateSpots-native.
|
||||||
|
export const NEWSLETTER_KIND = 30023; // NIP-23 long-form article — SkateSpots newsletter issues (admin-published, tagged `skatespotsnews`).
|
||||||
|
export const APP_DATA_KIND = 30078; // NIP-78 application-specific data (addressable). Used for the newsletter subscription marker.
|
||||||
|
|
||||||
|
/** `d`-tag identifier for the per-user newsletter subscription marker (kind 30078). */
|
||||||
|
export const NEWSLETTER_SUB_D = 'skatespots:newsletter:subscription';
|
||||||
|
|
||||||
|
/** `t`-tag every SkateSpots newsletter issue carries, so it's queryable at the relay level. */
|
||||||
|
export const NEWSLETTER_TAG = 'skatespotsnews';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A SkateSpots newsletter issue published as a NIP-23 long-form article (kind
|
||||||
|
* 30023) by an app admin. The `content` is Markdown; standard NIP-23 tags carry
|
||||||
|
* the metadata (title, summary, image, published_at).
|
||||||
|
*/
|
||||||
|
export interface SkateNewsletterIssue {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
/** NIP-23 `d` identifier — stable slug for the issue (e.g. "issue-6"). */
|
||||||
|
identifier: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
image?: string;
|
||||||
|
/** Markdown body. */
|
||||||
|
content: string;
|
||||||
|
/** Publication time (NIP-23 `published_at` if present, else created_at). */
|
||||||
|
publishedAt: number;
|
||||||
|
createdAt: number;
|
||||||
|
event: NostrEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse + validate a newsletter issue. Only trusts issues authored by a
|
||||||
|
* SkateSpots admin and tagged with NEWSLETTER_TAG — this is the trust boundary
|
||||||
|
* that stops anyone from spamming the community's newsletter mailbox.
|
||||||
|
*/
|
||||||
|
export function parseNewsletterIssue(event: NostrEvent): SkateNewsletterIssue | null {
|
||||||
|
if (event.kind !== NEWSLETTER_KIND) return null;
|
||||||
|
if (!isAdminPubkey(event.pubkey)) return null;
|
||||||
|
|
||||||
|
const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
|
||||||
|
|
||||||
|
const identifier = tag('d');
|
||||||
|
const title = tag('title');
|
||||||
|
if (!identifier || !title) return null;
|
||||||
|
|
||||||
|
const isNewsletter = event.tags.some(([n, v]) => n === 't' && v === NEWSLETTER_TAG);
|
||||||
|
if (!isNewsletter) return null;
|
||||||
|
|
||||||
|
const publishedAtRaw = tag('published_at');
|
||||||
|
const publishedAt = publishedAtRaw ? parseInt(publishedAtRaw, 10) : event.created_at;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
identifier,
|
||||||
|
title,
|
||||||
|
summary: tag('summary'),
|
||||||
|
image: tag('image'),
|
||||||
|
content: event.content ?? '',
|
||||||
|
publishedAt: Number.isFinite(publishedAt) ? publishedAt : event.created_at,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lab provider entity type.
|
* Lab provider entity type.
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ import { useCrewNip05 } from '@/hooks/useCrewNip05';
|
||||||
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
|
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types';
|
||||||
|
import { NewsletterIssueView } from '@/components/NewsletterIssueView';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
|
|
@ -930,9 +931,15 @@ export function NIP19Page() {
|
||||||
// AI agent should implement event view here
|
// AI agent should implement event view here
|
||||||
return <NotFound />;
|
return <NotFound />;
|
||||||
|
|
||||||
case 'naddr':
|
case 'naddr': {
|
||||||
// AI agent should implement addressable event view here
|
const addr = data as { kind: number; pubkey: string; identifier: string };
|
||||||
|
// Newsletter issues (NIP-23 long-form used by SkateSpots) get a reader.
|
||||||
|
if (addr.kind === NEWSLETTER_KIND) {
|
||||||
|
return <NewsletterIssueView pubkey={addr.pubkey} identifier={addr.identifier} />;
|
||||||
|
}
|
||||||
|
// Other addressable kinds aren't rendered here yet.
|
||||||
return <NotFound />;
|
return <NotFound />;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <NotFound />;
|
return <NotFound />;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -25,6 +26,11 @@ import { useGrowthStats } from '@/hooks/useGrowthStats';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
|
||||||
|
import { NewsletterComposer } from '@/components/NewsletterComposer';
|
||||||
|
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
|
||||||
|
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
|
||||||
|
import { NEWSLETTER_KIND, type SkateNewsletterIssue } from '@/lib/types';
|
||||||
|
|
||||||
// ─── Admin pubkeys allowed to publish newsletter as kind 1 ───────────────────
|
// ─── Admin pubkeys allowed to publish newsletter as kind 1 ───────────────────
|
||||||
|
|
||||||
|
|
@ -1064,6 +1070,75 @@ function IssueArticle({ issue }: { issue: NewsletterIssue }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Live issues (published to Nostr as NIP-23 articles) ─────────────────────
|
||||||
|
|
||||||
|
function LiveIssueCard({ issue }: { issue: SkateNewsletterIssue }) {
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: NEWSLETTER_KIND,
|
||||||
|
pubkey: issue.pubkey,
|
||||||
|
identifier: issue.identifier,
|
||||||
|
});
|
||||||
|
const date = new Date(issue.publishedAt * 1000).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/${naddr}`}
|
||||||
|
className="group block overflow-hidden rounded-2xl border-2 border-[#ccff00]/20 bg-white/[0.03] transition-all hover:border-[#ccff00]/50 hover:bg-white/[0.06]"
|
||||||
|
>
|
||||||
|
{issue.image && (
|
||||||
|
<div className="aspect-video w-full overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={issue.image}
|
||||||
|
alt={issue.title}
|
||||||
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-5">
|
||||||
|
<p className="mb-1.5 text-[10px] font-black uppercase tracking-widest text-gray-500">{date}</p>
|
||||||
|
<h3 className="mb-1 text-lg font-black uppercase leading-tight text-white group-hover:text-[#ccff00]">
|
||||||
|
{issue.title}
|
||||||
|
</h3>
|
||||||
|
{issue.summary && <p className="line-clamp-2 text-sm text-gray-400">{issue.summary}</p>}
|
||||||
|
<span className="mt-3 inline-flex items-center gap-1.5 text-xs font-bold text-[#ccff00]">
|
||||||
|
Read the full issue <ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveIssuesSection() {
|
||||||
|
const { data: issues, isLoading } = useNewsletterIssues();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Skeleton className="h-56 w-full rounded-2xl" />
|
||||||
|
<Skeleton className="h-56 w-full rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!issues || issues.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-16">
|
||||||
|
<SectionHeader emoji="📡" label="Latest Drops · Live from Nostr" color="#ccff00" />
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<LiveIssueCard key={issue.id} issue={issue} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function NewsletterPage() {
|
export default function NewsletterPage() {
|
||||||
|
|
@ -1072,12 +1147,18 @@ export default function NewsletterPage() {
|
||||||
description: 'Weekly drops from the SkateSpots community. App updates, new spots, events and the people keeping skateboarding alive.',
|
description: 'Weekly drops from the SkateSpots community. App updates, new spots, events and the people keeping skateboarding alive.',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Opening the newsletter clears the "News" unread badge in the bell.
|
||||||
|
const { markNewsletterRead } = useNewsletterRead();
|
||||||
|
useEffect(() => {
|
||||||
|
markNewsletterRead();
|
||||||
|
}, [markNewsletterRead]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="container max-w-2xl px-4 py-8 md:py-14 pb-24 md:pb-14">
|
<div className="container max-w-2xl px-4 py-8 md:py-14 pb-24 md:pb-14">
|
||||||
|
|
||||||
{/* ── Masthead ── */}
|
{/* ── Masthead ── */}
|
||||||
<div className="mb-12 text-center">
|
<div className="mb-10 text-center">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-[#ccff00]/30 bg-[#ccff00]/5 text-[10px] font-black uppercase tracking-widest text-[#ccff00] mb-5">
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-[#ccff00]/30 bg-[#ccff00]/5 text-[10px] font-black uppercase tracking-widest text-[#ccff00] mb-5">
|
||||||
<Radio className="h-3 w-3" />
|
<Radio className="h-3 w-3" />
|
||||||
Weekly Drops
|
Weekly Drops
|
||||||
|
|
@ -1091,11 +1172,26 @@ export default function NewsletterPage() {
|
||||||
</span>
|
</span>
|
||||||
<span className="text-white">SPOTS</span>
|
<span className="text-white">SPOTS</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 text-xs uppercase tracking-widest">
|
<p className="text-gray-500 text-xs uppercase tracking-widest mb-6">
|
||||||
A weekly newsletter · shipped whenever the f*** we want
|
A weekly newsletter · shipped whenever the f*** we want
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Subscribe — routes new drops into the notification bell */}
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<NewsletterSubscribeButton />
|
||||||
|
<NewsletterComposer />
|
||||||
|
</div>
|
||||||
|
<p className="max-w-xs text-[11px] leading-snug text-gray-600">
|
||||||
|
Free. Powered by Nostr — your subscription lives on your keys and follows you
|
||||||
|
across devices. New drops land in your notification bell.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Live issues published to Nostr ── */}
|
||||||
|
<LiveIssuesSection />
|
||||||
|
|
||||||
{/* ── All issues ── */}
|
{/* ── All issues ── */}
|
||||||
<div className="space-y-20">
|
<div className="space-y-20">
|
||||||
{NEWSLETTER_ISSUES.map((issue) => (
|
{NEWSLETTER_ISSUES.map((issue) => (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue