mirror of
https://github.com/Such-Software/smirk-website.git
synced 2026-10-17 05:51:28 +02:00
add tipping page
This commit is contained in:
@@ -7,7 +7,8 @@ User dashboard for Smirk wallet users. Requires the Smirk browser extension to a
|
||||
This website serves as a hub for Smirk wallet users to:
|
||||
- View their account UUID and balances
|
||||
- See tipping history (sent and received)
|
||||
- Manage connected accounts (Telegram, Discord, etc.)
|
||||
- **Register & verify social platform usernames** (Telegram, Discord)
|
||||
- **Manage social tips** - view sent/received, clawback unclaimed
|
||||
- Future: Chat/messaging between users, direct tipping, etc.
|
||||
|
||||
**Important**: This site is ONLY accessible to users with the Smirk extension installed. Authentication happens via cryptographic signatures from the user's wallet keys.
|
||||
|
||||
+40
-7
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getChallenge, verifySignature, type AuthResponse } from '@/lib/api';
|
||||
import { getChallenge, verifySignature, getMe, type AuthResponse } from '@/lib/api';
|
||||
|
||||
type Asset = 'btc' | 'ltc' | 'xmr' | 'wow' | 'grin';
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function Home() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthResponse['user'] | null>(null);
|
||||
const [hasExtension, setHasExtension] = useState<boolean | null>(null);
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
|
||||
// Check for extension on mount
|
||||
useEffect(() => {
|
||||
@@ -97,11 +98,36 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('smirk_token');
|
||||
if (token) {
|
||||
// TODO: Validate token with /auth/me
|
||||
// For now, just clear it and require re-auth
|
||||
// Validate token with /auth/me
|
||||
getMe(token)
|
||||
.then((userData) => {
|
||||
setUser({
|
||||
id: userData.id,
|
||||
telegram_id: userData.telegram_id,
|
||||
telegram_username: userData.telegram_username,
|
||||
});
|
||||
setStep('logged-in');
|
||||
})
|
||||
.catch(() => {
|
||||
// Token invalid or expired - clear it
|
||||
localStorage.removeItem('smirk_token');
|
||||
localStorage.removeItem('smirk_refresh');
|
||||
})
|
||||
.finally(() => setCheckingSession(false));
|
||||
} else {
|
||||
setCheckingSession(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Show loading while checking session
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-[#fbeb0a] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-8">
|
||||
{/* Logo */}
|
||||
@@ -222,17 +248,24 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/tips"
|
||||
className="px-6 py-2 border border-zinc-700 rounded-lg text-sm
|
||||
hover:border-[#fbeb0a] hover:shadow-[0_0_12px_rgba(251,235,10,0.4)] transition-all"
|
||||
>
|
||||
Tips
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="px-6 py-2 bg-[#fbeb0a] text-black font-medium rounded-lg
|
||||
hover:bg-[#d4c708] transition-colors text-sm"
|
||||
className="px-6 py-2 border border-zinc-700 rounded-lg text-sm
|
||||
hover:border-[#fbeb0a] hover:shadow-[0_0_12px_rgba(251,235,10,0.4)] transition-all"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-6 py-2 border border-zinc-700 rounded-lg
|
||||
hover:border-zinc-500 transition-colors text-sm"
|
||||
className="px-6 py-2 border border-zinc-700 rounded-lg text-sm
|
||||
hover:border-[#fbeb0a] hover:shadow-[0_0_12px_rgba(251,235,10,0.4)] transition-all"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
|
||||
@@ -129,6 +129,12 @@ export default function SettingsPage() {
|
||||
<Image src="/logo.svg" alt="Smirk" width={40} height={40} />
|
||||
<span className="creepster-text text-xl">SMIRK</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/tips"
|
||||
className="text-sm text-zinc-400 hover:text-white"
|
||||
>
|
||||
Tips
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-8">Settings</h1>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getPublicTipInfo, type PublicTipInfo } from '@/lib/api';
|
||||
|
||||
const COINS: Record<string, { name: string; icon: string; divisor: number; decimals: number }> = {
|
||||
btc: { name: 'Bitcoin', icon: '/coins/bitcoin.svg', divisor: 100_000_000, decimals: 8 },
|
||||
ltc: { name: 'Litecoin', icon: '/coins/litecoin.svg', divisor: 100_000_000, decimals: 8 },
|
||||
xmr: { name: 'Monero', icon: '/coins/monero.svg', divisor: 1_000_000_000_000, decimals: 12 },
|
||||
wow: { name: 'Wownero', icon: '/coins/wownero.svg', divisor: 100_000_000_000, decimals: 11 },
|
||||
grin: { name: 'Grin', icon: '/coins/grin.svg', divisor: 1_000_000_000, decimals: 9 },
|
||||
};
|
||||
|
||||
function formatAmount(amount: number, asset: string): string {
|
||||
const coin = COINS[asset.toLowerCase()];
|
||||
if (!coin) return `${amount} ${asset.toUpperCase()}`;
|
||||
const value = amount / coin.divisor;
|
||||
return `${value.toFixed(Math.min(coin.decimals, 8))} ${asset.toUpperCase()}`;
|
||||
}
|
||||
|
||||
export default function TipPage() {
|
||||
const params = useParams();
|
||||
const tipId = params.id as string;
|
||||
|
||||
const [tip, setTip] = useState<PublicTipInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasExtension, setHasExtension] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for extension
|
||||
const check = () => setHasExtension(!!window.smirk);
|
||||
check();
|
||||
const timeout = setTimeout(check, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tipId) return;
|
||||
|
||||
async function loadTip() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const tipInfo = await getPublicTipInfo(tipId);
|
||||
setTip(tipInfo);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load tip');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadTip();
|
||||
}, [tipId]);
|
||||
|
||||
const coin = tip ? COINS[tip.asset.toLowerCase()] : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-8">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<Link href="/" className="flex flex-col items-center">
|
||||
<h1 className="creepster-text text-3xl mb-2">SMIRK WALLET</h1>
|
||||
<div className="logo-glow">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="Smirk"
|
||||
width={100}
|
||||
height={100}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-[#fbeb0a] border-t-transparent rounded-full" />
|
||||
<p className="text-zinc-400">Loading tip...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && !loading && (
|
||||
<div className="flex flex-col items-center gap-6 text-center">
|
||||
<div className="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center">
|
||||
<span className="text-3xl">!</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">Tip Not Found</h2>
|
||||
<p className="text-zinc-400 text-sm max-w-md">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-6 py-2 border border-zinc-700 rounded-lg hover:border-zinc-500 transition-colors text-sm"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tip found */}
|
||||
{tip && !loading && (
|
||||
<div className="flex flex-col items-center gap-6 text-center max-w-md">
|
||||
{/* Status indicator */}
|
||||
{tip.status === 'pending' ? (
|
||||
<div className="w-16 h-16 bg-[#fbeb0a] rounded-full flex items-center justify-center">
|
||||
<span className="text-3xl">🎁</span>
|
||||
</div>
|
||||
) : tip.status === 'claimed' ? (
|
||||
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-zinc-700 rounded-full flex items-center justify-center">
|
||||
<span className="text-3xl text-zinc-400">X</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tip info */}
|
||||
<div className="bg-zinc-900 rounded-xl p-6 w-full">
|
||||
{coin && (
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<Image src={coin.icon} alt={coin.name} width={48} height={48} />
|
||||
<span className="text-2xl font-bold">{formatAmount(tip.amount, tip.asset)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-zinc-400 space-y-1">
|
||||
<p>Status: <span className={
|
||||
tip.status === 'pending' ? 'text-[#fbeb0a]' :
|
||||
tip.status === 'claimed' ? 'text-green-400' :
|
||||
'text-zinc-500'
|
||||
}>{tip.status}</span></p>
|
||||
<p>Created: {new Date(tip.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claim instructions */}
|
||||
{tip.status === 'pending' && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{hasExtension ? 'Claim This Tip' : 'Install Smirk Wallet'}
|
||||
</h2>
|
||||
|
||||
{hasExtension ? (
|
||||
<div className="text-zinc-400 text-sm space-y-2">
|
||||
<p>To claim this tip:</p>
|
||||
<ol className="text-left list-decimal list-inside space-y-1">
|
||||
<li>Click the Smirk extension icon</li>
|
||||
<li>Go to <strong>Inbox</strong></li>
|
||||
<li>Click <strong>Claim</strong> on this tip</li>
|
||||
</ol>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-zinc-400 text-sm space-y-2">
|
||||
<p>You need the Smirk Wallet extension to claim tips.</p>
|
||||
<a
|
||||
href="https://chrome.google.com/webstore/detail/smirk-wallet"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block px-6 py-3 bg-[#fbeb0a] text-black font-bold rounded-xl
|
||||
hover:bg-[#d4c708] transition-colors"
|
||||
>
|
||||
Install Smirk Wallet
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tip.status === 'claimed' && (
|
||||
<p className="text-green-400">This tip has already been claimed.</p>
|
||||
)}
|
||||
|
||||
{tip.status !== 'pending' && tip.status !== 'claimed' && (
|
||||
<p className="text-zinc-500">This tip is no longer available.</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="px-6 py-2 border border-zinc-700 rounded-lg hover:border-zinc-500 transition-colors text-sm"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
getMe,
|
||||
getSentTips,
|
||||
getReceivedTips,
|
||||
getClaimableTips,
|
||||
type MeResponse,
|
||||
type SentTip,
|
||||
type ReceivedTip,
|
||||
type ClaimableTip,
|
||||
} from '@/lib/api';
|
||||
|
||||
// Asset display info
|
||||
const ASSET_CONFIG: Record<string, { name: string; decimals: number; symbol: string }> = {
|
||||
btc: { name: 'Bitcoin', decimals: 8, symbol: 'BTC' },
|
||||
ltc: { name: 'Litecoin', decimals: 8, symbol: 'LTC' },
|
||||
xmr: { name: 'Monero', decimals: 12, symbol: 'XMR' },
|
||||
wow: { name: 'Wownero', decimals: 11, symbol: 'WOW' },
|
||||
grin: { name: 'Grin', decimals: 9, symbol: 'GRIN' },
|
||||
};
|
||||
|
||||
function formatAmount(amount: number, asset: string): string {
|
||||
const config = ASSET_CONFIG[asset] || { decimals: 8, symbol: asset.toUpperCase() };
|
||||
const value = amount / Math.pow(10, config.decimals);
|
||||
// Show up to 8 decimal places, trim trailing zeros
|
||||
const formatted = value.toFixed(8).replace(/\.?0+$/, '');
|
||||
return `${formatted} ${config.symbol}`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50',
|
||||
claimed: 'bg-green-500/20 text-green-400 border-green-500/50',
|
||||
clawed_back: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/50',
|
||||
};
|
||||
const color = colors[status] || colors.pending;
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded border ${color}`}>
|
||||
{status.replace('_', ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TipsPage() {
|
||||
const [user, setUser] = useState<MeResponse | null>(null);
|
||||
const [sentTips, setSentTips] = useState<SentTip[]>([]);
|
||||
const [receivedTips, setReceivedTips] = useState<ReceivedTip[]>([]);
|
||||
const [claimableTipIds, setClaimableTipIds] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasExtension, setHasExtension] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'sent' | 'received'>('sent');
|
||||
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('smirk_token') : null;
|
||||
|
||||
// Check for extension
|
||||
useEffect(() => {
|
||||
const checkExtension = () => {
|
||||
if (typeof window !== 'undefined' && window.smirk) {
|
||||
setHasExtension(true);
|
||||
}
|
||||
};
|
||||
checkExtension();
|
||||
// Also check after a delay for late injection
|
||||
const timeout = setTimeout(checkExtension, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
// Fetch data
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([getMe(token), getSentTips(token), getReceivedTips(token), getClaimableTips(token)])
|
||||
.then(([userData, sentData, receivedData, claimableData]) => {
|
||||
setUser(userData);
|
||||
setSentTips(sentData.tips);
|
||||
setReceivedTips(receivedData.tips);
|
||||
// Track which tips are claimable (pending, have encrypted_key)
|
||||
setClaimableTipIds(new Set(claimableData.tips.map((t: ClaimableTip) => t.id)));
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
const handleOpenExtension = (action: 'claim' | 'clawback', tipId?: string) => {
|
||||
// The extension doesn't have a direct deep-link API yet
|
||||
// For now, just show instructions
|
||||
if (action === 'claim') {
|
||||
alert('Open Smirk extension → Inbox → Claim the pending tip');
|
||||
} else {
|
||||
alert('Open Smirk extension → Settings → Pending Tips → Clawback');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-[#fbeb0a] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token || !user) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-8">
|
||||
<p className="text-zinc-400 mb-4">Please sign in first</p>
|
||||
<Link href="/" className="text-[#fbeb0a] hover:underline">
|
||||
Go to home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingSent = sentTips.filter((t) => t.status === 'pending');
|
||||
const pendingReceived = receivedTips.filter((t) => t.status === 'pending');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8 max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<Image src="/logo.svg" alt="Smirk" width={40} height={40} />
|
||||
<span className="creepster-text text-xl">SMIRK</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="text-sm text-zinc-400 hover:text-white"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-2">Tips</h1>
|
||||
<p className="text-zinc-500 text-sm mb-6">
|
||||
Manage tips you've sent and received
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-900/20 border border-red-500/50 rounded-lg">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasExtension && (
|
||||
<div className="mb-6 p-4 bg-yellow-900/20 border border-yellow-500/50 rounded-lg">
|
||||
<p className="text-yellow-400 text-sm">
|
||||
Install the{' '}
|
||||
<a
|
||||
href="https://chromewebstore.google.com/detail/smirk/placeholder"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Smirk extension
|
||||
</a>{' '}
|
||||
to claim or clawback tips.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('sent')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'sent'
|
||||
? 'bg-[#fbeb0a] text-black'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Sent ({sentTips.length})
|
||||
{pendingSent.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-xs bg-yellow-500/30 text-yellow-400 rounded">
|
||||
{pendingSent.length} pending
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('received')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'received'
|
||||
? 'bg-[#fbeb0a] text-black'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Received ({receivedTips.length})
|
||||
{pendingReceived.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-xs bg-green-500/30 text-green-400 rounded">
|
||||
{pendingReceived.length} claimable
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sent Tips */}
|
||||
{activeTab === 'sent' && (
|
||||
<div className="space-y-3">
|
||||
{sentTips.length === 0 ? (
|
||||
<div className="bg-zinc-900 rounded-xl p-8 text-center">
|
||||
<p className="text-zinc-500">No tips sent yet</p>
|
||||
<p className="text-zinc-600 text-sm mt-2">
|
||||
Use the Smirk extension to send tips to social media users
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sentTips.map((tip) => (
|
||||
<div
|
||||
key={tip.id}
|
||||
className="bg-zinc-900 rounded-xl p-4 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="font-mono font-medium">
|
||||
{formatAmount(tip.amount, tip.asset)}
|
||||
</span>
|
||||
<StatusBadge status={tip.status} />
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">
|
||||
To{' '}
|
||||
{tip.recipient_username ? (
|
||||
<span className="text-zinc-400">
|
||||
@{tip.recipient_username}
|
||||
{tip.recipient_platform && (
|
||||
<span className="text-zinc-600">
|
||||
{' '}
|
||||
({tip.recipient_platform})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : tip.is_public ? (
|
||||
<span className="text-zinc-400">Public tip</span>
|
||||
) : (
|
||||
<span className="text-zinc-600">Unknown</span>
|
||||
)}
|
||||
<span className="text-zinc-600 ml-2">
|
||||
{formatDate(tip.created_at)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{tip.status === 'pending' && hasExtension && (
|
||||
<button
|
||||
onClick={() => handleOpenExtension('clawback', tip.id)}
|
||||
className="px-3 py-1.5 text-sm border border-zinc-600 text-zinc-400 rounded-lg hover:border-zinc-500 hover:text-white transition-colors"
|
||||
>
|
||||
Clawback
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Received Tips */}
|
||||
{activeTab === 'received' && (
|
||||
<div className="space-y-3">
|
||||
{receivedTips.length === 0 ? (
|
||||
<div className="bg-zinc-900 rounded-xl p-8 text-center">
|
||||
<p className="text-zinc-500">No tips received yet</p>
|
||||
<p className="text-zinc-600 text-sm mt-2">
|
||||
Link your social accounts in Settings to receive tips
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
receivedTips.map((tip) => (
|
||||
<div
|
||||
key={tip.id}
|
||||
className="bg-zinc-900 rounded-xl p-4 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className={`font-mono font-medium ${tip.status === 'claimed' ? 'text-green-400' : 'text-white'}`}>
|
||||
+{formatAmount(tip.amount, tip.asset)}
|
||||
</span>
|
||||
<StatusBadge status={tip.status} />
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{tip.recipient_platform && (
|
||||
<span className="text-zinc-400">
|
||||
Via {tip.recipient_platform}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-zinc-600 ml-2">
|
||||
{formatDate(tip.created_at)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{tip.status === 'pending' && claimableTipIds.has(tip.id) && hasExtension && (
|
||||
<button
|
||||
onClick={() => handleOpenExtension('claim', tip.id)}
|
||||
className="px-4 py-1.5 text-sm bg-[#fbeb0a] text-black font-medium rounded-lg hover:bg-[#d4c708] transition-colors"
|
||||
>
|
||||
Claim
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+102
@@ -142,3 +142,105 @@ export async function unlinkSocial(token: string, platform: string): Promise<voi
|
||||
throw new Error(data.error || 'Failed to unlink account');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public Tips
|
||||
// =============================================================================
|
||||
|
||||
export interface PublicTipInfo {
|
||||
id: string;
|
||||
asset: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
export async function getPublicTipInfo(tipId: string): Promise<PublicTipInfo> {
|
||||
const res = await fetch(`${API_URL}/api/v1/tips/social/${tipId}/public`);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
throw new Error('Tip not found or not available');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to get tip info');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Tips
|
||||
// =============================================================================
|
||||
|
||||
export interface SentTip {
|
||||
id: string;
|
||||
recipient_platform: string | null;
|
||||
recipient_username: string | null;
|
||||
asset: string;
|
||||
amount: number;
|
||||
is_public: boolean;
|
||||
status: string;
|
||||
created_at: string;
|
||||
claimed_at: string | null;
|
||||
clawed_back_at: string | null;
|
||||
}
|
||||
|
||||
export interface ClaimableTip {
|
||||
id: string;
|
||||
asset: string;
|
||||
amount: number;
|
||||
from_platform: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ReceivedTip {
|
||||
id: string;
|
||||
sender_user_id: string;
|
||||
recipient_platform: string | null;
|
||||
recipient_username: string | null;
|
||||
asset: string;
|
||||
amount: number;
|
||||
is_public: boolean;
|
||||
status: string;
|
||||
created_at: string;
|
||||
claimed_at: string | null;
|
||||
clawed_back_at: string | null;
|
||||
}
|
||||
|
||||
export async function getSentTips(token: string): Promise<{ tips: SentTip[] }> {
|
||||
const res = await fetch(`${API_URL}/api/v1/tips/social/sent`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to get sent tips');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getReceivedTips(token: string): Promise<{ tips: ReceivedTip[] }> {
|
||||
const res = await fetch(`${API_URL}/api/v1/tips/social/received`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to get received tips');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getClaimableTips(token: string): Promise<{ tips: ClaimableTip[] }> {
|
||||
const res = await fetch(`${API_URL}/api/v1/tips/social/claimable`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to get claimable tips');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user