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 { useState, useEffect } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
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';
|
type Asset = 'btc' | 'ltc' | 'xmr' | 'wow' | 'grin';
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export default function Home() {
|
|||||||
const [user, setUser] = useState<AuthResponse['user'] | null>(null);
|
const [user, setUser] = useState<AuthResponse['user'] | null>(null);
|
||||||
const [hasExtension, setHasExtension] = useState<boolean | null>(null);
|
const [hasExtension, setHasExtension] = useState<boolean | null>(null);
|
||||||
const [checkingSession, setCheckingSession] = useState(true);
|
const [checkingSession, setCheckingSession] = useState(true);
|
||||||
|
const [userCount, setUserCount] = useState<number | null>(null);
|
||||||
|
|
||||||
// Check for extension on mount
|
// Check for extension on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,6 +35,13 @@ export default function Home() {
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch user count on mount
|
||||||
|
useEffect(() => {
|
||||||
|
getUserCount()
|
||||||
|
.then((data) => setUserCount(data.count))
|
||||||
|
.catch(() => {}); // Silently ignore errors
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
if (!window.smirk) {
|
if (!window.smirk) {
|
||||||
setError('Smirk extension not found');
|
setError('Smirk extension not found');
|
||||||
@@ -143,6 +151,12 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="creepster-text text-2xl mt-4">smirk.ca$h</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Initial state - Connect button */}
|
{/* Initial state - Connect button */}
|
||||||
|
|||||||
+103
-2
@@ -8,6 +8,8 @@ import {
|
|||||||
getLinkedSocials,
|
getLinkedSocials,
|
||||||
registerSocial,
|
registerSocial,
|
||||||
unlinkSocial,
|
unlinkSocial,
|
||||||
|
getMyUsername,
|
||||||
|
setUsername,
|
||||||
type MeResponse,
|
type MeResponse,
|
||||||
type LinkedSocial,
|
type LinkedSocial,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
@@ -20,6 +22,13 @@ export default function SettingsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// Telegram linking state
|
||||||
const [linkStep, setLinkStep] = useState<LinkStep>('idle');
|
const [linkStep, setLinkStep] = useState<LinkStep>('idle');
|
||||||
const [telegramUsername, setTelegramUsername] = useState('');
|
const [telegramUsername, setTelegramUsername] = useState('');
|
||||||
@@ -35,15 +44,43 @@ export default function SettingsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all([getMe(token), getLinkedSocials(token)])
|
Promise.all([getMe(token), getLinkedSocials(token), getMyUsername(token)])
|
||||||
.then(([userData, socialsData]) => {
|
.then(([userData, socialsData, username]) => {
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
setSocials(socialsData.socials);
|
setSocials(socialsData.socials);
|
||||||
|
setSmirkUsername(username || '');
|
||||||
|
setUsernameInput(username || '');
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [token]);
|
}, [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 = () => {
|
const handleStartLink = () => {
|
||||||
setLinkStep('entering');
|
setLinkStep('entering');
|
||||||
setLinkError(null);
|
setLinkError(null);
|
||||||
@@ -154,6 +191,70 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Linked Accounts */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg font-semibold mb-4 text-zinc-400">Linked Accounts</h2>
|
<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();
|
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