add tipping page

This commit is contained in:
jwinterm
2026-01-26 17:08:12 -05:00
parent 7cf90c08db
commit f02cbd8a4c
6 changed files with 677 additions and 8 deletions
+2 -1
View File
@@ -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
View File
@@ -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>
+6
View File
@@ -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>
+204
View File
@@ -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>
);
}
+323
View File
@@ -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&apos;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
View File
@@ -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();
}