mirror of
https://github.com/Such-Software/smirk-website.git
synced 2026-10-17 05:51:28 +02:00
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:
+15
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user