feat: add personal matrix

This commit is contained in:
DeathKaioken 2025-11-30 13:24:43 +01:00
parent c1e250bab1
commit 6e2298eca9
3 changed files with 471 additions and 136 deletions

View File

@ -28,7 +28,6 @@ import {
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import useAuthStore from '../../store/authStore';
import { Avatar } from '../avatar';
import LanguageSwitcher from '../LanguageSwitcher';
// Replace current shopItems definition with detailed version (adds icon & description)
const shopItems = [
@ -297,22 +296,31 @@ export default function Header() {
Affiliate-Links
</button>
{/* Memberships */}
<button
onClick={() => router.push('/memberships')}
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Memberships
</button>
{/* Remove Memberships */}
{/* Referral Management - match others (no highlight) */}
{userPresent && hasReferralPerm && (
<>
<button
onClick={() => { console.log('🧭 Header: navigate to /referral-management'); router.push('/referral-management') }}
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Referral Management
</button>
{/* New: Personal Matrix */}
<button
onClick={() => router.push('/personal-matrix')}
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Personal Matrix
</button>
{/* New: Coffee Abonnements */}
<button
onClick={() => router.push('/coffee-abonnements')}
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Coffee Abonnements
</button>
</>
)}
{/* About us */}
@ -383,8 +391,20 @@ export default function Header() {
<div aria-hidden="true" className="w-20 h-8 rounded-md bg-gray-200 dark:bg-gray-700/70 animate-pulse" />
)}
</div>
{/* Language & theme remain after auth slot */}
<LanguageSwitcher variant={isDark ? 'dark' : 'light'} />
{/* Replace LanguageSwitcher with English-only dropdown + note */}
<Popover className="relative">
<PopoverButton className="p-2 rounded-md border border-gray-300 dark:border-white/10 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200 text-sm font-semibold">
English
<ChevronDownIcon aria-hidden="true" className="ml-1 inline h-4 w-4 text-gray-500" />
</PopoverButton>
<PopoverPanel
transition
className="absolute right-0 top-full mt-2 w-72 rounded-md bg-white dark:bg-gray-900 ring-1 ring-black/10 dark:ring-white/10 shadow-lg p-3 text-sm text-gray-800 dark:text-gray-200 data-closed:-translate-y-1 data-closed:opacity-0 data-enter:duration-200 data-leave:duration-150"
>
We are currently working on implementing other languages.
</PopoverPanel>
</Popover>
<button
onClick={toggleTheme}
aria-label="Toggle theme"
@ -577,9 +597,16 @@ export default function Header() {
{isDark ? <SunIcon className="h-5 w-5" /> : <MoonIcon className="h-5 w-5" />}
{isDark ? 'Light Mode' : 'Dark Mode'}
</button>
<div className="mt-4 px-1">
<LanguageSwitcher variant="dark" />
</div>
{/* Replace LanguageSwitcher with English-only dropdown + note for mobile */}
<Disclosure as="div" className="mt-4 px-1">
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 px-3 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5">
Language: English
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
</DisclosureButton>
<DisclosurePanel className="mt-2 space-y-1 px-3 text-sm text-gray-700 dark:text-gray-300">
We are currently working on implementing other languages.
</DisclosurePanel>
</Disclosure>
</div>
{/* Navigation / Shop after that */}
<div className="space-y-2 py-6">
@ -610,21 +637,29 @@ export default function Header() {
>
Affiliate-Links
</button>
{/* Memberships */}
<button
onClick={() => { router.push('/memberships'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Memberships
</button>
{/* Referral Management - match others (no highlight) */}
{/* Remove Memberships */}
{/* Referral Management + new items */}
{hasReferralPerm && (
<>
<button
onClick={() => { console.log('🧭 Header Mobile: navigate to /referral-management'); router.push('/referral-management'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Referral Management
</button>
<button
onClick={() => { router.push('/personal-matrix'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Personal Matrix
</button>
<button
onClick={() => { router.push('/coffee-abonnements'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Coffee Abonnements
</button>
</>
)}
{/* About us */}
<button
@ -663,18 +698,25 @@ export default function Header() {
>
Affiliate-Links
</button>
<button
onClick={() => { router.push('/memberships'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Memberships
</button>
{/* Remove Memberships */}
<button
onClick={() => { router.push('/about-us'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
About us
</button>
{/* Language (English-only) in logged-out mobile */}
<div className="px-3">
<Disclosure as="div">
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 px-3 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5">
Language: English
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
</DisclosureButton>
<DisclosurePanel className="mt-2 space-y-1 px-3 text-sm text-gray-700 dark:text-gray-300">
We are currently working on implementing other languages.
</DisclosurePanel>
</Disclosure>
</div>
<div className="px-3">
<button
onClick={() => { router.push('/login'); setMobileMenuOpen(false); }}

View File

@ -24,23 +24,38 @@ export type PersonalOverview = {
level2Plus: Level2PlusItem[]
}
export function usePersonalMatrixOverview() {
const [data, setData] = useState<PersonalOverview | null>(null)
export type UserMatrix = {
id: string | number
name: string
membersCount?: number
createdAt?: string
description?: string
highestFullLevel?: number
percentFull?: number
filledSlots?: number
totalSlots?: number
totalUsers?: number
// new quick-summary fields from list endpoint
totalUsersUnderMe?: number
matrixFillPercent?: number
}
export function useMyMatrices() {
const [matrices, setMatrices] = useState<UserMatrix[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
// Abort any inflight when re-running
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const url = `${base}/api/matrix/me/overview`
const url = `${base}/api/matrix/me/list`
console.log('[useMyMatrices] Fetching:', url)
setLoading(true)
// Do not clear error if we already have valid data; only clear when starting fresh
setError(null)
authFetch(url, {
@ -51,14 +66,153 @@ export function usePersonalMatrixOverview() {
})
.then(async r => {
const ct = r.headers.get('content-type') || ''
console.log('[useMyMatrices] Status:', r.status, 'CT:', ct)
if (!r.ok || !ct.includes('application/json')) {
const txt = await r.text().catch(() => '')
console.warn('[useMyMatrices] Non-JSON or error body:', txt.slice(0, 200))
throw new Error(`Request failed: ${r.status} ${txt.slice(0, 120)}`)
}
const json = await r.json()
console.log('[useMyMatrices] Raw JSON:', json)
const arr = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : []
if (!Array.isArray(arr)) {
console.warn('[useMyMatrices] Expected array, got:', typeof arr)
}
const mapped: UserMatrix[] = arr.map((m: any, idx: number) => {
const id = m.id ?? m.matrixInstanceId ?? m._id
const name = String(m.name ?? m.title ?? `Matrix ${id ?? ''}`).trim()
// summary metrics directly from the list route
const totalUsersUnderMe =
m.totalUsersUnderMe != null ? Number(m.totalUsersUnderMe) : undefined
const highestFullLevel =
m.highestFullLevel != null ? Number(m.highestFullLevel) :
m.maxFilledLevel != null ? Number(m.maxFilledLevel) :
m.highestLevelFull != null ? Number(m.highestLevelFull) :
undefined
const matrixFillPercentRaw =
m.matrixFillPercent != null ? Number(m.matrixFillPercent) : undefined
const matrixFillPercent =
matrixFillPercentRaw != null ? Math.max(0, Math.min(100, matrixFillPercentRaw)) : undefined
// legacy/optional fields (kept for backward compatibility)
const membersCount =
m.membersCount != null ? Number(m.membersCount) :
m.memberCount != null ? Number(m.memberCount) :
m.totalUsers != null ? Number(m.totalUsers) :
undefined
const filledSlots =
m.filledSlots != null ? Number(m.filledSlots) :
m.occupiedSlots != null ? Number(m.occupiedSlots) :
undefined
const totalSlots =
m.totalSlots != null ? Number(m.totalSlots) :
m.capacitySlots != null ? Number(m.capacitySlots) :
undefined
const percentFromApi =
m.percentFull != null ? Number(m.percentFull) :
m.fillPercent != null ? Number(m.fillPercent) :
undefined
const percentComputed =
filledSlots != null && totalSlots != null && totalSlots > 0
? (filledSlots / totalSlots) * 100
: undefined
const percent = percentFromApi ?? percentComputed
const percentClamped = percent != null ? Math.max(0, Math.min(100, percent)) : undefined
const createdAt = m.createdAt ? String(m.createdAt) : (m.created_at ? String(m.created_at) : undefined)
const description = m.description != null ? String(m.description) : undefined
const totalUsers = m.totalUsers != null ? Number(m.totalUsers) : undefined
const out = {
id,
name,
membersCount,
createdAt,
description,
highestFullLevel, // from list
totalUsersUnderMe, // from list
matrixFillPercent, // from list
filledSlots,
totalSlots,
percentFull: percentClamped,
totalUsers
}
console.log('[useMyMatrices] Map item', idx, { source: m, mapped: out })
return out
})
console.log('[useMyMatrices] Final mapped list:', mapped)
setMatrices(mapped)
})
.catch((e: any) => {
if (controller.signal.aborted || e?.name === 'AbortError') {
console.log('[useMyMatrices] Aborted')
return
}
console.error('[useMyMatrices] Error:', e)
setError(e?.message || 'Failed to load matrices')
setMatrices([])
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false)
console.log('[useMyMatrices] Done')
}
})
return () => {
console.log('[useMyMatrices] Cleanup abort')
controller.abort()
}
}, [])
return { matrices, loading, error }
}
export function usePersonalMatrixOverview(matrixId?: string | number) {
const [data, setData] = useState<PersonalOverview | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
if (matrixId == null) {
console.log('[usePersonalMatrixOverview] No matrixId, skipping fetch')
setData(null)
setLoading(false)
return
}
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const url = `${base}/api/matrix/${encodeURIComponent(String(matrixId))}/overview`
console.log('[usePersonalMatrixOverview] Fetching:', url)
setLoading(true)
setError(null)
authFetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include',
signal: controller.signal as any
})
.then(async r => {
const ct = r.headers.get('content-type') || ''
console.log('[usePersonalMatrixOverview] Status:', r.status, 'CT:', ct)
if (!r.ok || !ct.includes('application/json')) {
const txt = await r.text().catch(() => '')
console.warn('[usePersonalMatrixOverview] Non-JSON or error body:', txt.slice(0, 200))
throw new Error(`Request failed: ${r.status} ${txt.slice(0, 120)}`)
}
const json = await r.json()
console.log('[usePersonalMatrixOverview] Raw JSON:', json)
const d = json?.data || json
const mapped: PersonalOverview = {
matrixInstanceId: d.matrixInstanceId,
matrixInstanceId: d.matrixInstanceId ?? matrixId,
totalUsersUnderMe: Number(d.totalUsersUnderMe ?? 0),
levelsFilled: Number(d.levelsFilled ?? 0),
immediateChildrenCount: Number(d.immediateChildrenCount ?? 0),
@ -75,37 +229,39 @@ export function usePersonalMatrixOverview() {
nameMasked: String(x.name ?? '')
})) : []
}
console.log('[usePersonalMatrixOverview] Mapped:', mapped)
setData(mapped)
})
.catch((e: any) => {
// Ignore aborts (during navigation/unmount or re-run)
if (controller.signal.aborted) {
// Do not set error; just exit quietly
return
}
// Some environments throw DOMException name 'AbortError'
if (e?.name === 'AbortError') {
if (controller.signal.aborted || e?.name === 'AbortError') {
console.log('[usePersonalMatrixOverview] Aborted')
return
}
console.error('[usePersonalMatrixOverview] Error:', e)
setError(e?.message || 'Failed to load overview')
setData(null)
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false)
console.log('[usePersonalMatrixOverview] Done')
}
})
// Cleanup: abort on unmount
return () => {
console.log('[usePersonalMatrixOverview] Cleanup abort')
controller.abort()
}
}, [])
}, [matrixId])
const meta = useMemo(() => ({
const meta = useMemo(() => {
const m = {
countL1: data?.level1.length ?? 0,
countL2Plus: data?.level2Plus.length ?? 0
}), [data])
}
console.log('[usePersonalMatrixOverview] Meta computed:', m)
return m
}, [data])
return { data, loading, error, meta }
}

View File

@ -1,21 +1,44 @@
'use client'
import React, { useEffect } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import useAuthStore from '../store/authStore'
import { useRouter } from 'next/navigation'
import PageLayout from '../components/PageLayout'
import { UsersIcon, CalendarDaysIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'
import { usePersonalMatrixOverview } from './hooks/getStats'
import { usePersonalMatrixOverview, useMyMatrices } from './hooks/getStats'
export default function PersonalMatrixPage() {
const router = useRouter()
const user = useAuthStore(s => s.user)
const { data, loading, error, meta } = usePersonalMatrixOverview()
const { matrices, loading: loadingMatrices, error: matricesError } = useMyMatrices()
const [selectedId, setSelectedId] = useState<string | number | undefined>(undefined)
// Auto-select if only one matrix available
useEffect(() => {
if (selectedId == null && matrices.length === 1) {
setSelectedId(matrices[0].id)
}
}, [matrices, selectedId])
const { data, loading, error, meta } = usePersonalMatrixOverview(selectedId)
useEffect(() => {
if (user === null) router.replace('/login')
}, [user, router])
if (user === null) return null
useEffect(() => {
console.log('[PersonalMatrixPage] matrices loading:', loadingMatrices, 'error:', matricesError)
}, [loadingMatrices, matricesError])
useEffect(() => {
console.log('[PersonalMatrixPage] matrices list:', matrices)
}, [matrices])
useEffect(() => {
console.log('[PersonalMatrixPage] selectedId:', selectedId)
}, [selectedId])
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
@ -24,39 +47,149 @@ export default function PersonalMatrixPage() {
<div className="flex items-center justify-between">
<div className="space-y-2">
<button
onClick={() => router.push('/')}
onClick={() => (selectedId == null ? router.push('/') : setSelectedId(undefined))}
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
{selectedId == null ? 'Back' : 'Back to matrices'}
</button>
<h1 className="text-3xl font-extrabold text-blue-900">My Matrix Overview</h1>
<p className="text-base text-blue-700">Personal subtree, privacy-preserving beyond level 1.</p>
<h1 className="text-3xl font-extrabold text-blue-900">
{selectedId == null ? 'My Matrices' : 'My Matrix Overview'}
</h1>
<p className="text-base text-blue-700">
{selectedId == null
? 'Select which matrix you want to inspect.'
: 'Personal subtree, privacy-preserving beyond level 1.'}
</p>
</div>
</div>
</header>
{error && (
{/* Errors */}
{matricesError && (
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{matricesError}
</div>
)}
{error && selectedId != null && (
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{/* Matrix card list (overview) */}
{selectedId == null && (
<div>
{loadingMatrices && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="h-32 rounded-xl bg-white shadow animate-pulse" />
<div className="h-32 rounded-xl bg-white shadow animate-pulse" />
<div className="h-32 rounded-xl bg-white shadow animate-pulse" />
</div>
)}
{!loadingMatrices && matrices.length === 0 && (
<div className="rounded-md border border-yellow-200 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
You are not part of any matrix yet.
</div>
)}
{!loadingMatrices && matrices.length > 0 && (
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{matrices.map((m, idx) => {
// Prefer new fields from list; fall back to legacy computed percent
const percent = m.matrixFillPercent ?? m.percentFull ?? 0
const totalUsersDisplay = m.totalUsersUnderMe ?? m.membersCount ?? m.totalUsers
console.log('[PersonalMatrixPage] Card', idx, {
id: m.id,
name: m.name,
totalUsersUnderMe: m.totalUsersUnderMe,
highestFullLevel: m.highestFullLevel,
matrixFillPercent: m.matrixFillPercent,
legacyPercentFull: m.percentFull,
filledSlots: m.filledSlots,
totalSlots: m.totalSlots,
totalUsers: m.totalUsers,
membersCount: m.membersCount
})
return (
<li key={String(m.id)} className="rounded-2xl border border-gray-100 bg-white shadow-lg overflow-hidden">
<div className="px-6 py-5">
<div className="text-lg font-semibold text-blue-900">{m.name}</div>
<div className="mt-3 grid grid-cols-1 gap-2 text-xs text-gray-700">
<div>
<span className="font-medium text-gray-800">Total members:</span>{' '}
{totalUsersDisplay != null ? totalUsersDisplay : 'N/A'}
</div>
<div>
<span className="font-medium text-gray-800">Highest full level:</span>{' '}
{m.highestFullLevel != null ? m.highestFullLevel : 'N/A'}
</div>
<div className="flex flex-col gap-1">
<span className="font-medium text-gray-800">Matrix fill:</span>
<div className="h-2 w-full rounded bg-gray-200">
<div
className="h-2 rounded bg-blue-600"
style={{ width: `${Math.max(0, Math.min(100, percent)).toFixed(0)}%` }}
/>
</div>
<span className="text-[11px] text-gray-600">
{Math.max(0, Math.min(100, percent)).toFixed(2)}%
</span>
</div>
{m.createdAt && (
<div>
<span className="font-medium text-gray-800">Created:</span>{' '}
{new Date(m.createdAt).toLocaleDateString()}
</div>
)}
{m.description && (
<div className="text-gray-600 line-clamp-2">
{m.description}
</div>
)}
</div>
<div className="mt-4 flex gap-3">
<button
onClick={() => setSelectedId(m.id)}
className="inline-flex items-center rounded-md bg-blue-800 px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-900"
>
View overview
</button>
</div>
</div>
</li>
)
})}
</ul>
)}
</div>
)}
{/* Selected matrix overview */}
{selectedId != null && (
<>
{/* Stats cards */}
<div className="mb-8 grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Total users under me</div>
<div className="text-xl font-semibold text-blue-900">{data?.totalUsersUnderMe ?? (loading ? '…' : 0)}</div>
<div className="text-xl font-semibold text-blue-900">
{data?.totalUsersUnderMe ?? (loading ? '…' : 0)}
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Immediate children</div>
<div className="text-xl font-semibold text-blue-900">{data?.immediateChildrenCount ?? (loading ? '…' : 0)}</div>
<div className="text-xl font-semibold text-blue-900">
{data?.immediateChildrenCount ?? (loading ? '…' : 0)}
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Levels filled</div>
<div className="text-xl font-semibold text-blue-900">{data?.levelsFilled ?? (loading ? '…' : 0)}</div>
<div className="text-xl font-semibold text-blue-900">
{data?.levelsFilled ?? (loading ? '…' : 0)}
</div>
</div>
</div>
{/* Level 1 */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
<div className="px-8 py-6 border-b border-gray-100">
<h2 className="text-xl font-semibold text-blue-900">Level 1 (Direct Children)</h2>
@ -85,6 +218,7 @@ export default function PersonalMatrixPage() {
</div>
</div>
{/* Level 2+ */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
<div className="px-8 py-6 border-b border-gray-100">
<h2 className="text-xl font-semibold text-blue-900">Level 2+</h2>
@ -108,6 +242,7 @@ export default function PersonalMatrixPage() {
</div>
</div>
{/* Meta */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Overview meta</div>
<div className="text-xs text-gray-700">
@ -115,6 +250,8 @@ export default function PersonalMatrixPage() {
Level1: {meta.countL1} Level2+: {meta.countL2Plus}
</div>
</div>
</>
)}
</div>
</div>
</PageLayout>