feat: add username management and user counter

- Add username setting UI to settings page
- Add user counter on home page
- Add API functions for username/count endpoints
This commit is contained in:
jwinterm
2026-01-27 10:34:41 -05:00
parent fa1b3230d1
commit 570b48b464
3 changed files with 188 additions and 3 deletions
+15 -1
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { getChallenge, verifySignature, getMe, type AuthResponse } from '@/lib/api';
import { getChallenge, verifySignature, getMe, getUserCount, type AuthResponse } from '@/lib/api';
type Asset = 'btc' | 'ltc' | 'xmr' | 'wow' | 'grin';
@@ -24,6 +24,7 @@ export default function Home() {
const [user, setUser] = useState<AuthResponse['user'] | null>(null);
const [hasExtension, setHasExtension] = useState<boolean | null>(null);
const [checkingSession, setCheckingSession] = useState(true);
const [userCount, setUserCount] = useState<number | null>(null);
// Check for extension on mount
useEffect(() => {
@@ -34,6 +35,13 @@ export default function Home() {
return () => clearTimeout(timeout);
}, []);
// Fetch user count on mount
useEffect(() => {
getUserCount()
.then((data) => setUserCount(data.count))
.catch(() => {}); // Silently ignore errors
}, []);
const handleConnect = async () => {
if (!window.smirk) {
setError('Smirk extension not found');
@@ -143,6 +151,12 @@ export default function Home() {
/>
</div>
<p className="creepster-text text-2xl mt-4">smirk.ca$h</p>
{userCount !== null && userCount > 0 && (
<p className="text-zinc-500 text-sm mt-4">
<span className="text-[#fbeb0a] font-semibold">{userCount.toLocaleString()}</span>{' '}
{userCount === 1 ? 'smirker' : 'smirkers'} and counting
</p>
)}
</div>
{/* Initial state - Connect button */}
+103 -2
View File
@@ -8,6 +8,8 @@ import {
getLinkedSocials,
registerSocial,
unlinkSocial,
getMyUsername,
setUsername,
type MeResponse,
type LinkedSocial,
} from '@/lib/api';
@@ -20,6 +22,13 @@ export default function SettingsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Username state
const [smirkUsername, setSmirkUsername] = useState<string>('');
const [editingUsername, setEditingUsername] = useState(false);
const [usernameInput, setUsernameInput] = useState('');
const [usernameError, setUsernameError] = useState<string | null>(null);
const [savingUsername, setSavingUsername] = useState(false);
// Telegram linking state
const [linkStep, setLinkStep] = useState<LinkStep>('idle');
const [telegramUsername, setTelegramUsername] = useState('');
@@ -35,15 +44,43 @@ export default function SettingsPage() {
return;
}
Promise.all([getMe(token), getLinkedSocials(token)])
.then(([userData, socialsData]) => {
Promise.all([getMe(token), getLinkedSocials(token), getMyUsername(token)])
.then(([userData, socialsData, username]) => {
setUser(userData);
setSocials(socialsData.socials);
setSmirkUsername(username || '');
setUsernameInput(username || '');
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [token]);
const handleSaveUsername = async () => {
if (!token) return;
const trimmed = usernameInput.trim().toLowerCase();
if (trimmed.length < 3 || trimmed.length > 32) {
setUsernameError('Username must be 3-32 characters');
return;
}
if (!/^[a-z0-9_]+$/.test(trimmed)) {
setUsernameError('Only lowercase letters, numbers, and underscores allowed');
return;
}
setSavingUsername(true);
setUsernameError(null);
try {
const result = await setUsername(token, trimmed);
setSmirkUsername(result.username);
setEditingUsername(false);
} catch (err) {
setUsernameError(err instanceof Error ? err.message : 'Failed to save username');
} finally {
setSavingUsername(false);
}
};
const handleStartLink = () => {
setLinkStep('entering');
setLinkError(null);
@@ -154,6 +191,70 @@ export default function SettingsPage() {
</div>
</section>
{/* Smirk Username */}
<section className="mb-8">
<h2 className="text-lg font-semibold mb-4 text-zinc-400">Smirk Username</h2>
<div className="bg-zinc-900 rounded-xl p-4">
{!editingUsername ? (
<div className="flex items-center justify-between">
<div>
{smirkUsername ? (
<>
<p className="text-zinc-500 text-xs mb-1">Your username</p>
<p className="text-[#fbeb0a] font-medium">@{smirkUsername}</p>
</>
) : (
<p className="text-zinc-500 text-sm">No username set</p>
)}
</div>
<button
onClick={() => setEditingUsername(true)}
className="px-4 py-2 bg-[#fbeb0a] text-black font-medium rounded-lg hover:bg-[#d4c708]"
>
{smirkUsername ? 'Change' : 'Set Username'}
</button>
</div>
) : (
<div>
<label className="block text-sm text-zinc-400 mb-2">
Choose a username (3-32 chars, lowercase alphanumeric + underscores)
</label>
<div className="flex gap-2">
<input
type="text"
value={usernameInput}
onChange={(e) => setUsernameInput(e.target.value.toLowerCase())}
placeholder="my_username"
maxLength={32}
className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:border-[#fbeb0a]"
/>
<button
onClick={handleSaveUsername}
disabled={savingUsername}
className="px-4 py-2 bg-[#fbeb0a] text-black font-medium rounded-lg hover:bg-[#d4c708] disabled:opacity-50"
>
{savingUsername ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => {
setEditingUsername(false);
setUsernameInput(smirkUsername);
setUsernameError(null);
}}
className="px-4 py-2 border border-zinc-700 rounded-lg hover:border-zinc-500"
>
Cancel
</button>
</div>
{usernameError && <p className="mt-2 text-sm text-red-400">{usernameError}</p>}
<p className="mt-3 text-xs text-zinc-500">
Others can tip you directly using your Smirk username
</p>
</div>
)}
</div>
</section>
{/* Linked Accounts */}
<section>
<h2 className="text-lg font-semibold mb-4 text-zinc-400">Linked Accounts</h2>
+70
View File
@@ -256,3 +256,73 @@ export async function getClaimableTips(token: string): Promise<{ tips: Claimable
return res.json();
}
// =============================================================================
// Username Management
// =============================================================================
export async function getMyUsername(token: string): Promise<string | null> {
const res = await fetch(`${API_URL}/api/v1/users/me/username`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error('Failed to get username');
}
return res.json();
}
export async function setUsername(token: string, username: string): Promise<{ username: string }> {
const res = await fetch(`${API_URL}/api/v1/users/me/username`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ username }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to set username');
}
return res.json();
}
export interface UserLookupResponse {
user_id: string;
username: string;
}
export async function lookupByUsername(username: string): Promise<UserLookupResponse> {
const res = await fetch(`${API_URL}/api/v1/users/by-username/${encodeURIComponent(username)}`);
if (!res.ok) {
if (res.status === 404) {
throw new Error('User not found');
}
throw new Error('Failed to lookup user');
}
return res.json();
}
// =============================================================================
// User Count (Public)
// =============================================================================
export interface UserCountResponse {
count: number;
}
export async function getUserCount(): Promise<UserCountResponse> {
const res = await fetch(`${API_URL}/api/v1/users/count`);
if (!res.ok) {
throw new Error('Failed to get user count');
}
return res.json();
}