From f02cbd8a4ca999084aa5c5b3a08f3ab1257c81e4 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 26 Jan 2026 17:08:12 -0500 Subject: [PATCH] add tipping page --- README.md | 3 +- src/app/page.tsx | 47 +++++- src/app/settings/page.tsx | 6 + src/app/tip/[id]/page.tsx | 204 ++++++++++++++++++++++++ src/app/tips/page.tsx | 323 ++++++++++++++++++++++++++++++++++++++ src/lib/api.ts | 102 ++++++++++++ 6 files changed, 677 insertions(+), 8 deletions(-) create mode 100644 src/app/tip/[id]/page.tsx create mode 100644 src/app/tips/page.tsx diff --git a/README.md b/README.md index 38c590c..94b1f49 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app/page.tsx b/src/app/page.tsx index 5faf20a..4ba154a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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(null); const [user, setUser] = useState(null); const [hasExtension, setHasExtension] = useState(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 ( +
+
+
+ ); + } + return (
{/* Logo */} @@ -222,17 +248,24 @@ export default function Home() {
+ + Tips + Settings diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index ee54c09..cb18d05 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -129,6 +129,12 @@ export default function SettingsPage() { Smirk SMIRK + + Tips +

Settings

diff --git a/src/app/tip/[id]/page.tsx b/src/app/tip/[id]/page.tsx new file mode 100644 index 0000000..a16c7f0 --- /dev/null +++ b/src/app/tip/[id]/page.tsx @@ -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 = { + 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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hasExtension, setHasExtension] = useState(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 ( +
+ {/* Logo */} +
+ +

SMIRK WALLET

+
+ Smirk +
+ +
+ + {/* Loading state */} + {loading && ( +
+
+

Loading tip...

+
+ )} + + {/* Error state */} + {error && !loading && ( +
+
+ ! +
+
+

Tip Not Found

+

+ {error} +

+
+ + Go Home + +
+ )} + + {/* Tip found */} + {tip && !loading && ( +
+ {/* Status indicator */} + {tip.status === 'pending' ? ( +
+ 🎁 +
+ ) : tip.status === 'claimed' ? ( +
+ + + +
+ ) : ( +
+ X +
+ )} + + {/* Tip info */} +
+ {coin && ( +
+ {coin.name} + {formatAmount(tip.amount, tip.asset)} +
+ )} + +
+

Status: {tip.status}

+

Created: {new Date(tip.created_at).toLocaleDateString()}

+
+
+ + {/* Claim instructions */} + {tip.status === 'pending' && ( +
+

+ {hasExtension ? 'Claim This Tip' : 'Install Smirk Wallet'} +

+ + {hasExtension ? ( +
+

To claim this tip:

+
    +
  1. Click the Smirk extension icon
  2. +
  3. Go to Inbox
  4. +
  5. Click Claim on this tip
  6. +
+
+ ) : ( +
+

You need the Smirk Wallet extension to claim tips.

+ + Install Smirk Wallet + +
+ )} +
+ )} + + {tip.status === 'claimed' && ( +

This tip has already been claimed.

+ )} + + {tip.status !== 'pending' && tip.status !== 'claimed' && ( +

This tip is no longer available.

+ )} + + + Go Home + +
+ )} +
+ ); +} diff --git a/src/app/tips/page.tsx b/src/app/tips/page.tsx new file mode 100644 index 0000000..796c68e --- /dev/null +++ b/src/app/tips/page.tsx @@ -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 = { + 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 = { + 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 ( + + {status.replace('_', ' ')} + + ); +} + +export default function TipsPage() { + const [user, setUser] = useState(null); + const [sentTips, setSentTips] = useState([]); + const [receivedTips, setReceivedTips] = useState([]); + const [claimableTipIds, setClaimableTipIds] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ ); + } + + if (!token || !user) { + return ( +
+

Please sign in first

+ + Go to home + +
+ ); + } + + const pendingSent = sentTips.filter((t) => t.status === 'pending'); + const pendingReceived = receivedTips.filter((t) => t.status === 'pending'); + + return ( +
+ {/* Header */} +
+ + Smirk + SMIRK + + + Settings + +
+ +

Tips

+

+ Manage tips you've sent and received +

+ + {error && ( +
+

{error}

+
+ )} + + {!hasExtension && ( +
+

+ Install the{' '} + + Smirk extension + {' '} + to claim or clawback tips. +

+
+ )} + + {/* Tabs */} +
+ + +
+ + {/* Sent Tips */} + {activeTab === 'sent' && ( +
+ {sentTips.length === 0 ? ( +
+

No tips sent yet

+

+ Use the Smirk extension to send tips to social media users +

+
+ ) : ( + sentTips.map((tip) => ( +
+
+
+ + {formatAmount(tip.amount, tip.asset)} + + +
+

+ To{' '} + {tip.recipient_username ? ( + + @{tip.recipient_username} + {tip.recipient_platform && ( + + {' '} + ({tip.recipient_platform}) + + )} + + ) : tip.is_public ? ( + Public tip + ) : ( + Unknown + )} + + {formatDate(tip.created_at)} + +

+
+ + {tip.status === 'pending' && hasExtension && ( + + )} +
+ )) + )} +
+ )} + + {/* Received Tips */} + {activeTab === 'received' && ( +
+ {receivedTips.length === 0 ? ( +
+

No tips received yet

+

+ Link your social accounts in Settings to receive tips +

+
+ ) : ( + receivedTips.map((tip) => ( +
+
+
+ + +{formatAmount(tip.amount, tip.asset)} + + +
+

+ {tip.recipient_platform && ( + + Via {tip.recipient_platform} + + )} + + {formatDate(tip.created_at)} + +

+
+ + {tip.status === 'pending' && claimableTipIds.has(tip.id) && hasExtension && ( + + )} +
+ )) + )} +
+ )} +
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index a0daeb6..aed2feb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -142,3 +142,105 @@ export async function unlinkSocial(token: string, platform: string): Promise { + 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(); +}