feat: pool management fuckhead

This commit is contained in:
DeathKaioken 2025-11-29 13:13:36 +01:00
parent 198e41e601
commit 51c54eb905
7 changed files with 924 additions and 1 deletions

View File

@ -0,0 +1,51 @@
import { authFetch } from '../../../utils/authFetch';
export type AddPoolPayload = {
name: string;
description?: string;
state?: 'active' | 'inactive';
};
export async function addPool(payload: AddPoolPayload) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/pools`;
const res = await authFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
let body: any = null;
try {
body = await res.json();
} catch {
body = null;
}
const ok = res.status === 201 || res.ok;
const message =
body?.message ||
(res.status === 409
? 'Pool name already exists.'
: res.status === 400
? 'Invalid request. Check name/state.'
: res.status === 401
? 'Unauthorized.'
: res.status === 403
? 'Forbidden.'
: res.status === 500
? 'Internal server error.'
: !ok
? `Request failed (${res.status}).`
: '');
return {
ok,
status: res.status,
body,
message,
};
}

View File

@ -0,0 +1,45 @@
import { authFetch } from '../../../utils/authFetch';
export async function setPoolState(
id: string | number,
state: 'active' | 'inactive' | 'archived'
) {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const url = `${BASE_URL}/api/admin/pools/${id}/state`;
const res = await authFetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ state }),
});
let body: any = null;
try {
body = await res.json();
} catch {
body = null;
}
const ok = res.ok;
const message =
body?.message ||
(res.status === 404
? 'Pool not found.'
: res.status === 400
? 'Invalid request.'
: res.status === 403
? 'Forbidden.'
: res.status === 500
? 'Server error.'
: !ok
? `Request failed (${res.status}).`
: '');
return { ok, status: res.status, body, message };
}
export async function archivePoolById(id: string | number) {
return setPoolState(id, 'archived');
}

View File

@ -0,0 +1,107 @@
import { useEffect, useState } from 'react';
import { authFetch } from '../../../utils/authFetch';
import { log } from '../../../utils/logger';
export type AdminPool = {
id: string;
name: string;
description?: string;
membersCount: number;
createdAt: string;
archived?: boolean;
};
export function useAdminPools() {
const [pools, setPools] = useState<AdminPool[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError('');
const url = `${BASE_URL}/api/admin/pools`; // reverted to /api/admin/pools
log("🌐 Pools: GET", url);
try {
const headers = { Accept: 'application/json' };
log("📤 Pools: Request headers:", headers);
const res = await authFetch(url, { headers });
log("📡 Pools: Response status:", res.status);
let body: any = null;
try {
body = await res.clone().json();
const preview = JSON.stringify(body).slice(0, 600);
log("📦 Pools: Response body preview:", preview);
} catch {
log("📦 Pools: Response body is not JSON or failed to parse");
}
if (res.status === 401) {
if (!cancelled) setError('Unauthorized. Please log in.');
return;
}
if (res.status === 403) {
if (!cancelled) setError('Forbidden. Admin access required.');
return;
}
if (!res.ok) {
if (!cancelled) setError('Failed to load pools.');
return;
}
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
log("🔧 Pools: Mapping items count:", apiItems.length);
const mapped: AdminPool[] = apiItems.map(item => ({
id: String(item.id),
name: String(item.name ?? 'Unnamed Pool'),
description: String(item.description ?? ''),
membersCount: 0,
createdAt: String(item.created_at ?? new Date().toISOString()),
archived: String(item.state ?? '').toLowerCase() === 'archived',
}));
log("✅ Pools: Mapped sample:", mapped.slice(0, 3));
if (!cancelled) setPools(mapped);
} catch (e: any) {
log("❌ Pools: Network or parsing error:", e?.message || e);
if (!cancelled) setError('Network error while loading pools.');
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [BASE_URL]);
return {
pools,
loading,
error,
refresh: async () => {
const url = `${BASE_URL}/api/admin/pools`; // reverted to /api/admin/pools
log("🔁 Pools: Refresh GET", url);
const res = await authFetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) {
log("❌ Pools: Refresh failed status:", res.status);
return false;
}
const body = await res.json();
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
setPools(apiItems.map(item => ({
id: String(item.id),
name: String(item.name ?? 'Unnamed Pool'),
description: String(item.description ?? ''),
membersCount: 0,
createdAt: String(item.created_at ?? new Date().toISOString()),
archived: String(item.state ?? '').toLowerCase() === 'archived',
})));
log("✅ Pools: Refresh succeeded, items:", apiItems.length);
return true;
}
};
}

View File

@ -0,0 +1,351 @@
'use client'
import React from 'react'
import Header from '../../../components/nav/Header'
import Footer from '../../../components/Footer'
import { UsersIcon, PlusIcon, BanknotesIcon, CalendarDaysIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { useRouter, useSearchParams } from 'next/navigation'
import useAuthStore from '../../../store/authStore'
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
type PoolUser = {
id: string
name: string
email: string
contributed: number
joinedAt: string // NEW: member since
}
export default function PoolManagePage() {
const router = useRouter()
const searchParams = useSearchParams()
const user = useAuthStore(s => s.user)
const isAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin'))
)
// Auth gate
const [authChecked, setAuthChecked] = React.useState(false)
React.useEffect(() => {
if (user === null) {
router.replace('/login')
return
}
if (user && !isAdmin) {
router.replace('/')
return
}
setAuthChecked(true)
}, [user, isAdmin, router])
// Read pool data from query params with fallbacks (hooks must be before any return)
const poolId = searchParams.get('id') ?? 'pool-unknown'
const poolName = searchParams.get('name') ?? 'Unnamed Pool'
const poolDescription = searchParams.get('description') ?? ''
const poolState = searchParams.get('state') ?? 'active'
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
// Members (no dummy data)
const [users, setUsers] = React.useState<PoolUser[]>([])
// Stats (no dummy data)
const [totalAmount, setTotalAmount] = React.useState<number>(0)
const [amountThisYear, setAmountThisYear] = React.useState<number>(0)
const [amountThisMonth, setAmountThisMonth] = React.useState<number>(0)
// Search modal state
const [searchOpen, setSearchOpen] = React.useState(false)
const [query, setQuery] = React.useState('')
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState<string>('')
const [candidates, setCandidates] = React.useState<Array<{ id: string; name: string; email: string }>>([])
const [hasSearched, setHasSearched] = React.useState(false)
// Early return AFTER all hooks are declared to keep consistent order
if (!authChecked) return null
// Remove dummy candidate source; keep search scaffolding returning empty
async function doSearch() {
setError('')
const q = query.trim().toLowerCase()
if (q.length < 3) {
setHasSearched(false)
setCandidates([])
return
}
setHasSearched(true)
setLoading(true)
setTimeout(() => {
setCandidates([]) // no local dummy results
setLoading(false)
}, 300)
}
function addUserFromModal(u: { id: string; name: string; email: string }) {
// Append user to pool; contribution stays zero; joinedAt is now.
setUsers(prev => [{ id: u.id, name: u.name, email: u.email, contributed: 0, joinedAt: new Date().toISOString() }, ...prev])
setSearchOpen(false)
setQuery('')
setCandidates([])
setHasSearched(false)
setError('')
setLoading(false)
}
return (
<PageTransitionEffect>
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
<Header />
{/* main wrapper: avoid high z-index stacking */}
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
<div className="max-w-7xl mx-auto relative z-0">
{/* Header (remove sticky/z-10) */}
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
<UsersIcon className="h-5 w-5 text-blue-900" />
</div>
<div>
<h1 className="text-3xl font-extrabold text-blue-900 tracking-tight">{poolName}</h1>
<p className="text-sm text-blue-700">
{poolDescription ? poolDescription : 'Manage users and track pool funds'}
</p>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-600">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${poolState === 'archived' ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${poolState === 'archived' ? 'bg-gray-400' : 'bg-green-500'}`} />
{poolState === 'archived' ? 'Archived' : 'Active'}
</span>
<span></span>
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
<span></span>
<span className="text-gray-500">ID: {poolId}</span>
</div>
</div>
</div>
{/* Back to Pool Management */}
<button
onClick={() => router.push('/admin/pool-management')}
className="inline-flex items-center gap-2 rounded-lg bg-white text-blue-900 border border-blue-200 px-4 py-2 text-sm font-medium hover:bg-blue-50 transition"
title="Back to Pool Management"
>
Back
</button>
</div>
</header>
{/* Stats (now zero until backend wired) */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8 relative z-0">
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-900 p-2">
<BanknotesIcon className="h-5 w-5 text-white" />
</div>
<div>
<p className="text-sm text-gray-600">Total in Pool</p>
<p className="text-2xl font-semibold text-gray-900"> {totalAmount.toLocaleString()}</p>
</div>
</div>
</div>
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
<div className="flex items-center gap-3">
<div className="rounded-md bg-amber-600 p-2">
<CalendarDaysIcon className="h-5 w-5 text-white" />
</div>
<div>
<p className="text-sm text-gray-600">This Year</p>
<p className="text-2xl font-semibold text-gray-900"> {amountThisYear.toLocaleString()}</p>
</div>
</div>
</div>
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
<div className="flex items-center gap-3">
<div className="rounded-md bg-green-600 p-2">
<CalendarDaysIcon className="h-5 w-5 text-white" />
</div>
<div>
<p className="text-sm text-gray-600">Current Month</p>
<p className="text-2xl font-semibold text-gray-900"> {amountThisMonth.toLocaleString()}</p>
</div>
</div>
</div>
</div>
{/* Unified Members card: add button + list */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-blue-900">Members</h2>
<button
onClick={() => { setSearchOpen(true); setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
>
<PlusIcon className="h-5 w-5" />
Add User
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{users.map(u => (
<article key={u.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
<UsersIcon className="h-5 w-5 text-blue-900" />
</div>
<div>
<h3 className="text-sm font-semibold text-blue-900">{u.name}</h3>
<p className="text-xs text-gray-600">{u.email}</p>
</div>
</div>
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2 py-0.5 text-xs text-blue-900">
{u.contributed.toLocaleString()}
</span>
</div>
<div className="mt-3 text-xs text-gray-600">
Member since:{' '}
<span className="font-medium text-gray-900">
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
</span>
</div>
</article>
))}
{users.length === 0 && (
<div className="col-span-full text-center text-gray-500 italic py-6">
No users in this pool yet.
</div>
)}
</div>
</div>
</div>
</main>
<Footer />
{/* Search Modal (keep above with high z) */}
{searchOpen && (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setSearchOpen(false)} />
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
<div className="w-full max-w-2xl rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
{/* Header */}
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
<h4 className="text-lg font-semibold text-blue-900">Add user to pool</h4>
<button
onClick={() => setSearchOpen(false)}
className="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition"
aria-label="Close"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
{/* Form */}
<form
onSubmit={e => { e.preventDefault(); void doSearch(); }}
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-gray-100"
>
<div className="md:col-span-3">
<div className="relative">
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search name or email…"
className="w-full rounded-md bg-gray-50 border border-gray-300 text-sm text-gray-900 placeholder-gray-400 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent transition"
/>
</div>
</div>
<div className="flex gap-2 md:col-span-2">
<button
type="submit"
disabled={loading || query.trim().length < 3}
className="flex-1 rounded-md bg-blue-900 hover:bg-blue-800 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
>
{loading ? 'Searching…' : 'Search'}
</button>
<button
type="button"
onClick={() => { setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
>
Clear
</button>
</div>
</form>
<div className="px-6 pt-1 pb-3 text-right text-xs text-gray-500">
Min. 3 characters
</div>
{/* Results */}
<div className="px-6 py-4">
{error && <div className="text-sm text-red-600 mb-3">{error}</div>}
{!error && query.trim().length < 3 && (
<div className="py-8 text-sm text-gray-500 text-center">
Enter at least 3 characters and click Search.
</div>
)}
{!error && hasSearched && loading && candidates.length === 0 && (
<ul className="space-y-0 divide-y divide-gray-200 border border-gray-200 rounded-md bg-gray-50">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i} className="animate-pulse px-4 py-3">
<div className="h-3.5 w-36 bg-gray-200 rounded" />
<div className="mt-2 h-3 w-56 bg-gray-100 rounded" />
</li>
))}
</ul>
)}
{!error && hasSearched && !loading && candidates.length === 0 && (
<div className="py-8 text-sm text-gray-500 text-center">
No users match your search.
</div>
)}
{!error && candidates.length > 0 && (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
{candidates.map(u => (
<li key={u.id} className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-gray-50 transition">
<div className="min-w-0">
<div className="flex items-center gap-2">
<UsersIcon className="h-4 w-4 text-blue-900" />
<span className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{u.name}</span>
</div>
<div className="mt-0.5 text-[11px] text-gray-600 break-all">{u.email}</div>
</div>
<button
onClick={() => addUserFromModal(u)}
className="shrink-0 inline-flex items-center rounded-md bg-blue-900 hover:bg-blue-800 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
>
Add
</button>
</li>
))}
</ul>
)}
{loading && candidates.length > 0 && (
<div className="pointer-events-none relative">
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-gray-100 flex items-center justify-end bg-gray-50">
<button
onClick={() => setSearchOpen(false)}
className="text-sm rounded-md px-4 py-2 font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 transition"
>
Done
</button>
</div>
</div>
</div>
</div>
)}
</div>
</PageTransitionEffect>
)
}

View File

@ -0,0 +1,342 @@
'use client'
import React from 'react'
import Header from '../../components/nav/Header'
import Footer from '../../components/Footer'
import { UsersIcon } from '@heroicons/react/24/outline'
import { useAdminPools } from './hooks/getlist'
import useAuthStore from '../../store/authStore'
import { addPool } from './hooks/addPool'
import { useRouter } from 'next/navigation'
import { archivePoolById, setPoolState } from './hooks/archivePool'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
type Pool = {
id: string
name: string
description?: string
membersCount: number
createdAt: string
archived?: boolean
}
export default function PoolManagementPage() {
const router = useRouter()
// Form state, dropdown, errors
const [form, setForm] = React.useState({ name: '', description: '' })
const [creating, setCreating] = React.useState(false)
const [formError, setFormError] = React.useState<string>('')
const [formSuccess, setFormSuccess] = React.useState<string>('')
const [createOpen, setCreateOpen] = React.useState(false)
const [archiveError, setArchiveError] = React.useState<string>('')
// Initialize dropdown open state from localStorage and persist on changes
React.useEffect(() => {
const stored = typeof window !== 'undefined' ? localStorage.getItem('admin:pools:createOpen') : null;
if (stored != null) setCreateOpen(stored === 'true');
}, []);
const setCreateOpenPersist = (val: boolean | ((v: boolean) => boolean)) => {
setCreateOpen(prev => {
const next = typeof val === 'function' ? (val as (v: boolean) => boolean)(prev) : val;
if (typeof window !== 'undefined') localStorage.setItem('admin:pools:createOpen', String(next));
return next;
});
};
// Token and API URL
const token = useAuthStore.getState().accessToken
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
// Replace local fetch with hook
const { pools: initialPools, loading, error, refresh } = useAdminPools()
const [pools, setPools] = React.useState<Pool[]>([])
React.useEffect(() => {
if (!loading && !error) {
setPools(initialPools)
}
}, [initialPools, loading, error])
async function handleCreatePool(e: React.FormEvent) {
e.preventDefault()
setFormError('')
setFormSuccess('')
const name = form.name.trim()
const description = form.description.trim()
if (!name) {
setFormError('Please provide a pool name.')
return
}
setCreating(true)
try {
const res = await addPool({ name, description: description || undefined, state: 'active' })
if (res.ok && res.body?.data) {
setFormSuccess('Pool created successfully.')
setForm({ name: '', description: '' })
// Refresh list from backend to include the new pool
await refresh?.()
// Do NOT close; keep dropdown open across refresh
// setCreateOpenPersist(false) // removed to keep it open
} else {
setFormError(res.message || 'Failed to create pool.')
}
} catch {
setFormError('Network error while creating pool.')
} finally {
setCreating(false)
}
}
async function handleArchive(poolId: string) {
setArchiveError('')
const res = await archivePoolById(poolId)
if (res.ok) {
await refresh?.()
} else {
setArchiveError(res.message || 'Failed to archive pool.')
}
}
async function handleSetActive(poolId: string) {
setArchiveError('')
const res = await setPoolState(poolId, 'active')
if (res.ok) {
await refresh?.()
} else {
setArchiveError(res.message || 'Failed to activate pool.')
}
}
const user = useAuthStore(s => s.user)
const isAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin'))
)
// NEW: block rendering until we decide access
const [authChecked, setAuthChecked] = React.useState(false)
React.useEffect(() => {
// When user is null -> unauthenticated; undefined means not loaded yet (store default may be null in this app).
if (user === null) {
router.replace('/login')
return
}
if (user && !isAdmin) {
router.replace('/')
return
}
// user exists and is admin
setAuthChecked(true)
}, [user, isAdmin, router])
// Early return: render nothing until authorized, prevents any flash
if (!authChecked) return null
// Remove Access Denied overlay; render normal content
return (
<PageTransitionEffect>
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
<Header />
{/* main wrapper: avoid high z-index stacking */}
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
<div className="max-w-7xl mx-auto relative z-0">
{/* Page Header: remove sticky and high z-index */}
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8 relative z-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Pool Management</h1>
<p className="text-lg text-blue-700 mt-2">Create and manage user pools.</p>
</div>
<button
onClick={() => setCreateOpenPersist(o => !o)}
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
aria-expanded={createOpen}
aria-controls="create-pool-section"
>
{createOpen ? 'Close' : 'Create New Pool'}
</button>
</div>
</header>
{/* Create Pool dropdown */}
<div id="create-pool-section" className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-visible mb-8 relative z-0">
<button
onClick={() => setCreateOpenPersist(o => !o)}
className="w-full flex items-center justify-between px-6 py-4 text-left"
aria-expanded={createOpen}
>
<span className="text-lg font-semibold text-blue-900">Create New Pool</span>
<span className={`transition-transform ${createOpen ? 'rotate-180' : 'rotate-0'} text-gray-500`} aria-hidden="true"></span>
</button>
{createOpen && (
<div className="px-6 pb-6">
{/* Success/Error banners */}
{formSuccess && (
<div className="mb-3 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
{formSuccess}
</div>
)}
{formError && (
<div className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</div>
)}
<form onSubmit={handleCreatePool} className="space-y-4">
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Name</label>
<input
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
placeholder="e.g., VIP Members"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Description</label>
<textarea
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
rows={3}
placeholder="Short description of the pool"
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={creating}
className="px-5 py-3 text-sm font-semibold text-blue-50 rounded-lg bg-blue-900 hover:bg-blue-800 shadow inline-flex items-center gap-2 disabled:opacity-60"
>
{creating && <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
{creating ? 'Creating...' : 'Create Pool'}
</button>
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition"
onClick={() => { setForm({ name: '', description: '' }); setFormError(''); setFormSuccess(''); }}
>
Reset
</button>
</div>
</form>
</div>
)}
</div>
{/* Pools List card */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-blue-900">Existing Pools</h2>
<span className="text-sm text-gray-600">{pools.length} total</span>
</div>
{/* Show archive errors */}
{archiveError && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{archiveError}
</div>
)}
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-white border border-gray-100 shadow p-5">
<div className="animate-pulse space-y-3">
<div className="h-5 w-1/2 bg-gray-200 rounded" />
<div className="h-4 w-3/4 bg-gray-200 rounded" />
<div className="h-4 w-2/3 bg-gray-100 rounded" />
<div className="h-8 w-full bg-gray-100 rounded" />
</div>
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{pools.map(pool => (
<article key={pool.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col relative z-0">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
<UsersIcon className="h-5 w-5 text-blue-900" />
</div>
<h3 className="text-lg font-semibold text-blue-900">{pool.name}</h3>
</div>
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${pool.archived ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${pool.archived ? 'bg-gray-400' : 'bg-green-500'}`} />
{pool.archived ? 'Archived' : 'Active'}
</span>
</div>
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
<div>
<span className="text-gray-500">Members</span>
<div className="font-medium text-gray-900">{pool.membersCount}</div>
</div>
<div>
<span className="text-gray-500">Created</span>
<div className="font-medium text-gray-900">
{new Date(pool.createdAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
</div>
</div>
</div>
<div className="mt-5 flex items-center justify-between">
<button
className="px-4 py-2 text-xs font-medium rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
onClick={() => {
const params = new URLSearchParams({
id: String(pool.id),
name: pool.name ?? '',
description: pool.description ?? '',
state: pool.archived ? 'archived' : 'active',
createdAt: pool.createdAt ?? '',
})
router.push(`/admin/pool-management/manage?${params.toString()}`)
}}
>
Manage
</button>
{pool.archived ? (
<button
className="px-4 py-2 text-xs font-medium rounded-lg bg-green-100 text-green-800 hover:bg-green-200 transition"
onClick={() => handleSetActive(pool.id)}
title="Activate this pool"
>
Set Active
</button>
) : (
<button
className="px-4 py-2 text-xs font-medium rounded-lg bg-amber-100 text-amber-800 hover:bg-amber-200 transition"
onClick={() => handleArchive(pool.id)}
title="Archive this pool"
>
Archive
</button>
)}
</div>
</article>
))}
{pools.length === 0 && !loading && !error && (
<div className="col-span-full text-center text-gray-500 italic py-6">
No pools found.
</div>
)}
</div>
)}
</div>
</div>
</main>
<Footer />
</div>
</PageTransitionEffect>
)
}

View File

@ -468,6 +468,13 @@ export default function Header() {
>
Coffee Subscription Management
</button>
<button
onClick={() => { router.push('/admin/pool-management'); setAdminMgmtOpen(false); }}
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
role="menuitem"
>
Pool Management
</button>
</div>
</div>
)}

View File

@ -44,14 +44,28 @@ interface CustomRequestInit extends RequestInit {
headers?: Record<string, string>;
}
// Helper: safe stringify body for logging
function safeBodyPreview(body: any, max = 500): string | null {
if (body == null) return null;
try {
const s = typeof body === "string" ? body : JSON.stringify(body);
return s.length > max ? s.slice(0, max) + "…" : s;
} catch {
return "[unserializable body]";
}
}
// Main authFetch function
export async function authFetch(input: RequestInfo | URL, init: CustomRequestInit = {}): Promise<Response> {
// Always get the fresh token from store at call time
let accessToken = getAccessToken();
const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url;
const method = (init.method || "GET").toUpperCase();
const bodyPreview = safeBodyPreview(init.body);
log("🌐 authFetch: Making API call to:", url);
log("🔑 authFetch: Using token:", accessToken ? `${accessToken.substring(0, 20)}...` : "No token");
log("🔧 authFetch: Method:", method);
log("📤 authFetch: Body preview:", bodyPreview);
// Add Authorization header if accessToken exists
const buildHeaders = (token: string | null): Record<string, string> => ({
@ -63,6 +77,7 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
const fetchWithAuth = async (hdrs: Record<string, string>): Promise<Response> => {
log("📡 authFetch: Sending request", {
url,
method,
headers: Object.keys(hdrs),
hasAuth: !!hdrs.Authorization,
authPrefix: hdrs.Authorization ? `${hdrs.Authorization.substring(0, 24)}...` : null,
@ -94,6 +109,11 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
log("🔁 authFetch: Retrieved new token from store after refresh:", newToken ? `${newToken.substring(0,20)}...` : null);
log("🔄 authFetch: Retrying original request with refreshed token (if available)");
log("📡 authFetch: Retrying request", {
url,
method,
bodyPreview,
});
res = await fetch(url, { ...init, headers: buildHeaders(newToken), credentials: "include" });
log("📡 authFetch: Retry response status:", res.status);
} else {