mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-06 02:21:11 +02:00
feat: implement zap revenue splits functionality across article and blog post forms
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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
15
src/config.tsx
Normal 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,
|
||||
};
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user