feat: implement zap revenue splits functionality across article and blog post forms

This commit is contained in:
2025-10-29 20:58:11 +01:00
parent 420bab49d1
commit 551275c505
5 changed files with 169 additions and 1 deletions

View File

@@ -58,6 +58,21 @@ export function ArticleView({ post }: ArticleViewProps) {
.filter(([name]) => name === 't')
.map(([, value]) => value);
// Parse zap splits from tags (community convention under NIP-57)
const zapSplits = post.tags
.filter(([n]) => n === 'zap')
.map((t) => {
const pub = t[1] ?? '';
let weight = 0;
const idx = t.findIndex((x) => x === 'weight');
if (idx >= 0 && t[idx + 1]) weight = parseInt(t[idx + 1] as string) || 0;
else if (t[2]) {
const n = parseInt(t[2] as string); if (!Number.isNaN(n)) weight = n;
}
return { pubkey: pub, weight };
})
.filter((s) => s.pubkey && s.weight > 0);
const date = publishedAt
? new Date(parseInt(publishedAt) * 1000)
: new Date(post.created_at * 1000);
@@ -202,6 +217,20 @@ export function ArticleView({ post }: ArticleViewProps) {
<Separator className="my-8" />
{zapSplits.length > 0 && (
<div className="mb-8">
<div className="text-sm text-muted-foreground mb-2">Zap revenue splits</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{zapSplits.map((s, i) => (
<Link key={i} to={`/${nip19.npubEncode(s.pubkey)}`} className="flex items-center justify-between rounded-md border p-3 hover:bg-accent">
<div className="truncate mr-4">{genUserName(s.pubkey)}</div>
<Badge variant="secondary">{s.weight}%</Badge>
</Link>
))}
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-4 mb-12">
<Button
variant={hasReacted ? "default" : "outline"}

View File

@@ -14,8 +14,10 @@ import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, Loader2, Upload, Image as ImageIcon, FileText, Hash, Calendar } from 'lucide-react';
import { AlertCircle, Loader2, Upload, Image as ImageIcon, FileText, Hash, Calendar, Percent } from 'lucide-react';
import { Editor } from '@/components/blocks/editor-00/editor';
import { Slider } from '@/components/ui/slider';
import { HOUSE_PUBKEY_HEX, HOUSE_SPLIT_ENABLED } from '@/config';
interface ProfessionalBlogPostFormProps {
/** Existing post identifier for editing (optional) */
@@ -43,6 +45,9 @@ const initialEditorState = {
} as unknown as SerializedEditorState;
export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPostFormProps) {
// Static destination pubkey (hex) for platform split (hidden and not editable)
const HOUSE_HEX = HOUSE_PUBKEY_HEX;
const { user } = useCurrentUser();
const navigate = useNavigate();
const isMobile = useIsMobile();
@@ -64,6 +69,7 @@ export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPos
hashtags: '',
});
const [showMetadata, setShowMetadata] = useState(true);
const [splitPercent, setSplitPercent] = useState<number>(30);
// Load existing post data when editing
useEffect(() => {
@@ -126,6 +132,24 @@ export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPos
console.error('Failed to parse existing content:', error);
}
}
// Prefill slider from existing zap split for the configured house pubkey (if present)
try {
const zapTags = existingPost.tags.filter(([name]) => name === 'zap');
const target = zapTags.find((t) => t[1] === HOUSE_HEX);
if (target) {
let w = 30;
const idx = target.findIndex((x) => x === 'weight');
if (idx >= 0 && target[idx + 1]) w = parseInt(target[idx + 1] as string) || 30;
else if (target[2]) {
const n = parseInt(target[2] as string);
if (!Number.isNaN(n)) w = n;
}
setSplitPercent(Math.max(0, Math.min(100, w)));
}
} catch (e) {
console.debug('No zap split to prefill or parse error', e);
}
}
}, [existingPost, editIdentifier]);
@@ -196,6 +220,37 @@ export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPos
? parseInt(existingPost.tags.find(([name]) => name === 'published_at')?.[1] || '0')
: Math.floor(Date.now() / 1000);
// Preserve any existing non-house zap splits when editing
let preservedSplits: Array<{ pubkey: string; weight: number; relays?: string[] }> = [];
try {
if (existingPost) {
const zapTags = existingPost.tags.filter(([n]) => n === 'zap');
preservedSplits = zapTags
.filter((t) => t[1] !== HOUSE_HEX)
.map((t) => {
const pubkey = t[1] ?? '';
let weight = 0;
const idx = t.findIndex((x) => x === 'weight');
if (idx >= 0 && t[idx + 1]) weight = parseInt(t[idx + 1] as string) || 0;
else if (t[2]) {
const n = parseInt(t[2] as string);
if (!Number.isNaN(n)) weight = n;
}
// Parse optional relays
const relaysIdx = t.findIndex((x) => x === 'relays');
const relays = relaysIdx >= 0 ? t.slice(relaysIdx + 1) : undefined;
return { pubkey, weight, relays };
})
.filter((s) => s.pubkey && s.weight > 0);
}
} catch (e) {
console.debug('Failed to preserve existing zap splits', e);
}
const finalSplits = HOUSE_SPLIT_ENABLED
? [{ pubkey: HOUSE_HEX, weight: Math.trunc(splitPercent) }, ...preservedSplits]
: preservedSplits;
const event = await publishPost({
identifier: metadata.identifier,
title: metadata.title,
@@ -206,6 +261,7 @@ export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPos
? metadata.hashtags.split(',').map(t => t.trim()).filter(Boolean)
: undefined,
publishedAt: publishedAt || undefined,
splits: finalSplits.length > 0 ? finalSplits : undefined,
});
// Navigate to the post
@@ -427,6 +483,27 @@ export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPos
</div>
</div>
)}
{/* Zap revenue split (fixed recipient, adjustable percentage) */}
{HOUSE_SPLIT_ENABLED && (
<div className="pt-4 border-t">
<Label className="flex items-center gap-2">
Revenue share <Percent className="h-4 w-4" />
</Label>
<div className="mt-2">
<Slider
value={[splitPercent]}
min={0}
max={100}
step={1}
onValueChange={(v) => setSplitPercent(v[0] ?? 0)}
/>
<div className="mt-2 text-sm text-muted-foreground">
{splitPercent}% goes to the platform. {100 - splitPercent}% goes to you.
</div>
</div>
</div>
)}
</CardContent>
)}
</Card>

15
src/config.tsx Normal file
View File

@@ -0,0 +1,15 @@
// Global app configuration
// Central place to store static keys and app-level constants
// Hex-encoded public key for platform revenue share (house account)
// Note: Keep this in hex to avoid decoding at runtime.
export const HOUSE_PUBKEY_HEX =
'1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
// Toggle showing and applying the house zap split on the article creation page
export const HOUSE_SPLIT_ENABLED = true;
export default {
HOUSE_PUBKEY_HEX,
HOUSE_SPLIT_ENABLED,
};

View File

@@ -11,6 +11,12 @@ interface BlogPostData {
content: string;
hashtags?: string[];
publishedAt?: number;
/** Optional zap revenue splits. Weights are integers (percent). */
splits?: Array<{
pubkey: string; // hex or npub1
weight: number; // 1..100
relays?: string[];
}>;
}
/**
@@ -51,6 +57,32 @@ export function usePublishBlogPost() {
});
}
// Zap splits (NIP-57 community convention): add one `zap` tag per recipient.
// Tag shape we emit: ["zap", <hex-pubkey>, "weight", "<n>", "relays", <relay1>, <relay2> ...]
if (data.splits && data.splits.length > 0) {
for (const split of data.splits) {
// Normalize pubkey: accept npub or hex; if npub, decode to hex
let hexPub = split.pubkey;
try {
if (hexPub.startsWith('npub1')) {
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(hexPub);
if (decoded.type === 'npub') {
hexPub = decoded.data as string;
}
}
} catch {
// keep as-is; signer/relays may ignore invalid entries
}
const parts: string[] = ['zap', hexPub, 'weight', String(Math.max(0, Math.min(100, Math.trunc(split.weight || 0))))];
if (split.relays && split.relays.length > 0) {
parts.push('relays', ...split.relays);
}
tags.push(parts);
}
}
// Add client tag
// tags.push(['client', 'zelo.news']);

View File

@@ -209,6 +209,21 @@ export function useZaps(
comment
});
// If target event contains zap split tags, include them in the zap request,
// so compatible zap servers can honor the split distribution.
try {
if (Array.isArray(actualTarget.tags)) {
const splitTags = actualTarget.tags.filter((t) => t[0] === 'zap');
if (splitTags.length > 0) {
const zr = zapRequest as unknown as { tags?: string[][] };
zr.tags = Array.isArray(zr.tags) ? zr.tags : [];
zr.tags.push(...splitTags);
}
}
} catch (e) {
console.debug('Failed to include zap split tags in zap request', e);
}
// Sign the zap request (but don't publish to relays - only send to LNURL endpoint)
if (!user.signer) {
throw new Error('No signer available');