This commit is contained in:
seaznCode 2026-01-13 19:03:07 +01:00
commit 231b65dc32
8 changed files with 702 additions and 95 deletions

8
package-lock.json generated
View File

@ -21,7 +21,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"country-flag-icons": "^1.5.21", "country-flag-icons": "^1.5.21",
"country-select-js": "^2.1.0", "country-select-js": "^2.1.0",
"intl-tel-input": "^25.10.11", "intl-tel-input": "^25.15.0",
"motion": "^12.23.22", "motion": "^12.23.22",
"next": "^16.0.7", "next": "^16.0.7",
"pdfjs-dist": "^5.4.149", "pdfjs-dist": "^5.4.149",
@ -6504,9 +6504,9 @@
} }
}, },
"node_modules/intl-tel-input": { "node_modules/intl-tel-input": {
"version": "25.11.2", "version": "25.15.0",
"resolved": "https://registry.npmjs.org/intl-tel-input/-/intl-tel-input-25.11.2.tgz", "resolved": "https://registry.npmjs.org/intl-tel-input/-/intl-tel-input-25.15.0.tgz",
"integrity": "sha512-3a9+bbtR6s7E8TjZauqodMz+MRMd31OcUhTJuQOg95lA+viZ53OTU8XzVuyldEE089nMtLhPF1NbRU1ff2Sf7g==", "integrity": "sha512-ux50qr6qCAWrd3BMioVN3te54h2+2pobvgq7vbKQ1GUP5P8XUcVuxKN/5bSxZuOYJqyqeCRqfcH7zlYErFZMtw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {

View File

@ -22,7 +22,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"country-flag-icons": "^1.5.21", "country-flag-icons": "^1.5.21",
"country-select-js": "^2.1.0", "country-select-js": "^2.1.0",
"intl-tel-input": "^25.10.11", "intl-tel-input": "^25.15.0",
"motion": "^12.23.22", "motion": "^12.23.22",
"next": "^16.0.7", "next": "^16.0.7",
"pdfjs-dist": "^5.4.149", "pdfjs-dist": "^5.4.149",

View File

@ -15,6 +15,11 @@ import { useMemo, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useAdminUsers } from '../hooks/useAdminUsers' import { useAdminUsers } from '../hooks/useAdminUsers'
// env-based feature flags
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
const DISPLAY_ABONEMENTS = process.env.NEXT_PUBLIC_DISPLAY_ABONEMMENTS !== 'false'
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const router = useRouter() const router = useRouter()
const { userStats, isAdmin } = useAdminUsers() const { userStats, isAdmin } = useAdminUsers()
@ -147,45 +152,92 @@ export default function AdminDashboardPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Matrix Management */}
<button <button
type="button" type="button"
onClick={() => router.push('/admin/matrix-management')} disabled={!DISPLAY_MATRIX}
className="group w-full flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 px-4 py-4 transition" onClick={DISPLAY_MATRIX ? () => router.push('/admin/matrix-management') : undefined}
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
DISPLAY_MATRIX
? 'border border-blue-200 bg-blue-50 hover:bg-blue-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-blue-100 border border-blue-200"> <span
<Squares2X2Icon className="h-6 w-6 text-blue-600" /> className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
DISPLAY_MATRIX
? 'bg-blue-100 border-blue-200 group-hover:animate-pulse'
: 'bg-gray-100 border-gray-300'
}`}
>
<Squares2X2Icon className={`h-6 w-6 ${DISPLAY_MATRIX ? 'text-blue-600' : 'text-gray-400'}`} />
</span> </span>
<div className="text-left"> <div className="text-left">
<div className="text-base font-semibold text-blue-900">Matrix Management</div> <div className="text-base font-semibold text-blue-900">Matrix Management</div>
<div className="text-xs text-blue-700">Configure matrices and users</div> <div className="text-xs text-blue-700">Configure matrices and users</div>
{!DISPLAY_MATRIX && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
</p>
)}
</div> </div>
</div> </div>
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" /> <ArrowRightIcon
className={`h-5 w-5 ${
DISPLAY_MATRIX ? 'text-blue-600 opacity-70 group-hover:opacity-100' : 'text-gray-400 opacity-60'
}`}
/>
</button> </button>
{/* Coffee Subscription Management */}
<button <button
type="button" type="button"
onClick={() => router.push('/admin/subscriptions')} disabled={!DISPLAY_ABONEMENTS}
className="group w-full flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 hover:bg-amber-100 px-4 py-4 transition" onClick={DISPLAY_ABONEMENTS ? () => router.push('/admin/subscriptions') : undefined}
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
DISPLAY_ABONEMENTS
? 'border border-amber-200 bg-amber-50 hover:bg-amber-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-amber-100 border border-amber-200"> <span
<BanknotesIcon className="h-6 w-6 text-amber-600" /> className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
DISPLAY_ABONEMENTS
? 'bg-amber-100 border-amber-200 group-hover:animate-pulse'
: 'bg-gray-100 border-gray-300'
}`}
>
<BanknotesIcon className={`h-6 w-6 ${DISPLAY_ABONEMENTS ? 'text-amber-600' : 'text-gray-400'}`} />
</span> </span>
<div className="text-left"> <div className="text-left">
<div className="text-base font-semibold text-amber-900">Coffee Subscription Management</div> <div className="text-base font-semibold text-amber-900">Coffee Subscription Management</div>
<div className="text-xs text-amber-700">Plans, billing and renewals</div> <div className="text-xs text-amber-700">Plans, billing and renewals</div>
{!DISPLAY_ABONEMENTS && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
</p>
)}
</div> </div>
</div> </div>
<ArrowRightIcon className="h-5 w-5 text-amber-600 opacity-70 group-hover:opacity-100" /> <ArrowRightIcon
className={`h-5 w-5 ${
DISPLAY_ABONEMENTS
? 'text-amber-600 opacity-70 group-hover:opacity-100'
: 'text-gray-400 opacity-60'
}`}
/>
</button> </button>
{/* Contract Management (unchanged) */}
<button <button
type="button" type="button"
onClick={() => router.push('/admin/contract-management')} onClick={() => router.push('/admin/contract-management')}
className="group w-full flex items-center justify-between rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 px-4 py-4 transition" className="group w-full flex items-center justify-between rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-indigo-100 border border-indigo-200"> <span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-indigo-100 border border-indigo-200 group-hover:animate-pulse">
<ClipboardDocumentListIcon className="h-6 w-6 text-indigo-600" /> <ClipboardDocumentListIcon className="h-6 w-6 text-indigo-600" />
</span> </span>
<div className="text-left"> <div className="text-left">
@ -195,13 +247,15 @@ export default function AdminDashboardPage() {
</div> </div>
<ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" /> <ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" />
</button> </button>
{/* User Management (unchanged) */}
<button <button
type="button" type="button"
onClick={() => router.push('/admin/user-management')} onClick={() => router.push('/admin/user-management')}
className="group w-full flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 hover:bg-blue-50 px-4 py-4 transition" className="group w-full flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 hover:bg-blue-50 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-blue-100 border border-blue-200"> <span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-blue-100 border border-blue-200 group-hover:animate-pulse">
<UsersIcon className="h-6 w-6 text-blue-600" /> <UsersIcon className="h-6 w-6 text-blue-600" />
</span> </span>
<div className="text-left"> <div className="text-left">
@ -211,21 +265,43 @@ export default function AdminDashboardPage() {
</div> </div>
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" /> <ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
</button> </button>
{/* News Management */}
<button <button
type="button" type="button"
onClick={() => router.push('/admin/news-management')} disabled={!DISPLAY_NEWS}
className="group w-full flex items-center justify-between rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 px-4 py-4 transition" onClick={DISPLAY_NEWS ? () => router.push('/admin/news-management') : undefined}
className={`group w-full flex items-center justify-between rounded-lg px-4 py-4 ${
DISPLAY_NEWS
? 'border border-green-200 bg-green-50 hover:bg-green-100 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md'
: 'border border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
> >
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-green-100 border border-green-200"> <span
<ClipboardDocumentListIcon className="h-6 w-6 text-green-600" /> className={`inline-flex h-10 w-10 items-center justify-center rounded-md border ${
DISPLAY_NEWS
? 'bg-green-100 border-green-200 group-hover:animate-pulse'
: 'bg-gray-100 border-gray-300'
}`}
>
<ClipboardDocumentListIcon className={`h-6 w-6 ${DISPLAY_NEWS ? 'text-green-600' : 'text-gray-400'}`} />
</span> </span>
<div className="text-left"> <div className="text-left">
<div className="text-base font-semibold text-green-900">News Management</div> <div className="text-base font-semibold text-green-900">News Management</div>
<div className="text-xs text-green-700">Create and manage news articles</div> <div className="text-xs text-green-700">Create and manage news articles</div>
{!DISPLAY_NEWS && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
</p>
)}
</div> </div>
</div> </div>
<ArrowRightIcon className="h-5 w-5 text-green-600 opacity-70 group-hover:opacity-100" /> <ArrowRightIcon
className={`h-5 w-5 ${
DISPLAY_NEWS ? 'text-green-600 opacity-70 group-hover:opacity-100' : 'text-gray-400 opacity-60'
}`}
/>
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import React from 'react'; import React, { useState } from 'react';
import Header from './nav/Header'; import Header from './nav/Header';
import Footer from './Footer'; import Footer from './Footer';
import PageTransitionEffect from './animation/pageTransitionEffect'; import PageTransitionEffect from './animation/pageTransitionEffect';
@ -23,13 +23,14 @@ export default function PageLayout({
showFooter = true showFooter = true
}: PageLayoutProps) { }: PageLayoutProps) {
const isMobile = isMobileDevice(); const isMobile = isMobileDevice();
const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW
return ( return (
<div className="min-h-screen w-full flex flex-col bg-white text-gray-900"> <div className="min-h-screen w-full flex flex-col bg-white text-gray-900">
{showHeader && ( {showHeader && (
<div className="relative z-50 w-full flex-shrink-0"> <div className="relative z-50 w-full flex-shrink-0">
<Header /> <Header setGlobalLoggingOut={setIsLoggingOut} />
</div> </div>
)} )}
@ -43,6 +44,16 @@ export default function PageLayout({
<Footer /> <Footer />
</div> </div>
)} )}
{/* Global logout transition overlay (covers whole page) */}
{isLoggingOut && (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3 text-white">
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white animate-spin" />
<p className="text-sm font-medium">Logging you out...</p>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -53,11 +53,14 @@ const navLinks = [
// Toggle visibility of Shop navigation across header (desktop + mobile) // Toggle visibility of Shop navigation across header (desktop + mobile)
const showShop = false const showShop = false
export default function Header() { interface HeaderProps {
setGlobalLoggingOut?: (value: boolean) => void; // NEW
}
export default function Header({ setGlobalLoggingOut }: HeaderProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const [animateIn, setAnimateIn] = useState(false) const [animateIn, setAnimateIn] = useState(false)
const [loggingOut, setLoggingOut] = useState(false) // NEW
const user = useAuthStore(s => s.user) const user = useAuthStore(s => s.user)
const logout = useAuthStore(s => s.logout) const logout = useAuthStore(s => s.logout)
const accessToken = useAuthStore(s => s.accessToken) const accessToken = useAuthStore(s => s.accessToken)
@ -67,16 +70,18 @@ export default function Header() {
const [hasReferralPerm, setHasReferralPerm] = useState(false) const [hasReferralPerm, setHasReferralPerm] = useState(false)
const [adminMgmtOpen, setAdminMgmtOpen] = useState(false) const [adminMgmtOpen, setAdminMgmtOpen] = useState(false)
const managementRef = useRef<HTMLDivElement | null>(null) const managementRef = useRef<HTMLDivElement | null>(null)
const [canSeeDashboard, setCanSeeDashboard] = useState(false)
const handleLogout = async () => { const handleLogout = async () => {
try { try {
setLoggingOut(true) // NEW: start logout transition // start global logout transition
setGlobalLoggingOut?.(true)
await logout() await logout()
setMobileMenuOpen(false) setMobileMenuOpen(false)
router.push('/login') router.push('/login')
} catch (err) { } catch (err) {
console.error('Logout failed:', err) console.error('Logout failed:', err)
setLoggingOut(false) // reset if something goes wrong setGlobalLoggingOut?.(false)
} }
}; };
@ -189,11 +194,78 @@ export default function Header() {
return () => { cancelled = true } return () => { cancelled = true }
}, [mounted, user, accessToken, refreshAuthToken]) }, [mounted, user, accessToken, refreshAuthToken])
// NEW: fetch onboarding status to decide if dashboard should be visible
useEffect(() => {
let cancelled = false
const fetchOnboardingStatus = async () => {
if (!mounted || !user) {
if (!cancelled) setCanSeeDashboard(false)
return
}
let tokenToUse = accessToken
try {
if (!tokenToUse && refreshAuthToken) {
const ok = await refreshAuthToken()
if (ok) tokenToUse = useAuthStore.getState().accessToken
}
} catch (e) {
console.error('❌ Header: refreshAuthToken (status-progress) error:', e)
}
if (!tokenToUse) {
if (!cancelled) setCanSeeDashboard(false)
return
}
try {
const statusUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/user/status-progress`
console.log('🌐 Header: fetching status-progress:', statusUrl)
const res = await fetch(statusUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${tokenToUse}`,
'Content-Type': 'application/json',
},
credentials: 'include',
})
if (!res.ok) {
console.warn('⚠️ Header: status-progress failed with', res.status)
if (!cancelled) setCanSeeDashboard(false)
return
}
const statusData = await res.json().catch(() => null)
const progressData = statusData?.progress || statusData || {}
const steps = progressData.steps || []
const allStepsCompleted =
steps.length === 4 && steps.every((step: any) => step?.completed === true)
const isActive = progressData.status === 'active'
if (!cancelled) {
setCanSeeDashboard(allStepsCompleted && isActive)
}
} catch (e) {
console.error('❌ Header: status-progress fetch error:', e)
if (!cancelled) setCanSeeDashboard(false)
}
}
fetchOnboardingStatus()
return () => {
cancelled = true
}
}, [mounted, user, accessToken, refreshAuthToken])
const isLoggedIn = !!user const isLoggedIn = !!user
const userPresent = mounted && isLoggedIn const userPresent = mounted && isLoggedIn
// NEW: detect admin role across common shapes (guarded by mount to avoid SSR/CSR mismatch) // NEW: detect admin role across common shapes, but only after mount
const rawIsAdmin = const isAdmin =
mounted &&
!!user && !!user &&
( (
(user as any)?.role === 'admin' || (user as any)?.role === 'admin' ||
@ -236,13 +308,32 @@ export default function Header() {
<div className="flex lg:hidden"> <div className="flex lg:hidden">
<button <button
type="button" type="button"
onClick={() => setMobileMenuOpen(true)} onClick={() => setMobileMenuOpen(open => !open)}
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-400 transition-transform duration-300 ease-out data-[open=true]:rotate-90" className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-400 transition-transform duration-300 ease-out data-[open=true]:rotate-90"
data-open={mobileMenuOpen ? 'true' : 'false'} data-open={mobileMenuOpen ? 'true' : 'false'}
> >
<span className="sr-only">Open main menu</span> <span className="sr-only">
<Bars3Icon aria-hidden="true" className="size-6" /> {mobileMenuOpen ? 'Close main menu' : 'Open main menu'}
</span>
<span className="relative flex h-6 w-6 items-center justify-center">
<Bars3Icon
aria-hidden="true"
className={`size-6 transition-all duration-300 ease-out ${
mobileMenuOpen
? 'opacity-0 -rotate-90 scale-75'
: 'opacity-100 rotate-0 scale-100'
}`}
/>
<XMarkIcon
aria-hidden="true"
className={`pointer-events-none absolute size-6 transition-all duration-300 ease-out ${
mobileMenuOpen
? 'opacity-100 rotate-0 scale-100'
: 'opacity-0 rotate-90 scale-75'
}`}
/>
</span>
</button> </button>
</div> </div>
@ -318,6 +409,7 @@ export default function Header() {
{user?.email || 'user@example.com'} {user?.email || 'user@example.com'}
</div> </div>
</div> </div>
{canSeeDashboard && (
<button <button
onClick={() => router.push('/dashboard')} onClick={() => router.push('/dashboard')}
className="flex items-center gap-x-2 w-full text-left p-2 text-sm text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/5 rounded-md" className="flex items-center gap-x-2 w-full text-left p-2 text-sm text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/5 rounded-md"
@ -325,6 +417,7 @@ export default function Header() {
<Bars3Icon className="size-5 text-gray-400" /> <Bars3Icon className="size-5 text-gray-400" />
Dashboard Dashboard
</button> </button>
)}
<button <button
onClick={() => router.push('/profile')} onClick={() => router.push('/profile')}
className="flex items-center gap-x-2 w-full text-left p-2 text-sm text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/5 rounded-md" className="flex items-center gap-x-2 w-full text-left p-2 text-sm text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/5 rounded-md"
@ -351,12 +444,31 @@ export default function Header() {
{/* Desktop hamburger (right side, next to login/profile) */} {/* Desktop hamburger (right side, next to login/profile) */}
<button <button
type="button" type="button"
onClick={() => setMobileMenuOpen(true)} onClick={() => setMobileMenuOpen(open => !open)}
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
className="inline-flex items-center justify-center rounded-md p-2.5 text-gray-300 hover:text-white hover:bg-white/10 transition-colors" className="inline-flex items-center justify-center rounded-md p-2.5 text-gray-300 hover:text-white hover:bg-white/10 transition-colors"
> >
<span className="sr-only">Open main menu</span> <span className="sr-only">
<Bars3Icon aria-hidden="true" className="size-6" /> {mobileMenuOpen ? 'Close main menu' : 'Open main menu'}
</span>
<span className="relative flex h-6 w-6 items-center justify-center">
<Bars3Icon
aria-hidden="true"
className={`size-6 transition-all duration-300 ease-out ${
mobileMenuOpen
? 'opacity-0 -rotate-90 scale-75'
: 'opacity-100 rotate-0 scale-100'
}`}
/>
<XMarkIcon
aria-hidden="true"
className={`pointer-events-none absolute size-6 transition-all duration-300 ease-out ${
mobileMenuOpen
? 'opacity-100 rotate-0 scale-100'
: 'opacity-0 rotate-90 scale-75'
}`}
/>
</span>
</button> </button>
</div> </div>
</nav> </nav>
@ -487,24 +599,24 @@ export default function Header() {
{/* Side drawer menu: mobile + desktop */} {/* Side drawer menu: mobile + desktop */}
<Dialog open={mobileMenuOpen} onClose={setMobileMenuOpen}> <Dialog open={mobileMenuOpen} onClose={setMobileMenuOpen}>
<Transition appear show={mobileMenuOpen}> <Transition appear show={mobileMenuOpen}>
{/* Overlay: slightly slower fade for a smoother feel */} {/* Overlay: smoother, longer fade-out */}
<Transition.Child <Transition.Child
enter="transition-opacity duration-400 ease-out" enter="transition-opacity duration-400 ease-out"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="transition-opacity duration-300 ease-in" leave="transition-opacity duration-800 ease-out"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm" /> <div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm" />
</Transition.Child> </Transition.Child>
{/* Sliding panel: slide + fade + scale for a modern, flashy effect */} {/* Sliding panel: smoother, longer close animation */}
<Transition.Child <Transition.Child
enter="transform transition-all duration-500 ease-out" enter="transform transition-all duration-500 ease-out"
enterFrom="translate-x-full opacity-0 scale-95" enterFrom="translate-x-full opacity-0 scale-95"
enterTo="translate-x-0 opacity-100 scale-100" enterTo="translate-x-0 opacity-100 scale-100"
leave="transform transition-all duration-400 ease-in" leave="transform transition-all duration-800 ease-in-out"
leaveFrom="translate-x-0 opacity-100 scale-100" leaveFrom="translate-x-0 opacity-100 scale-100"
leaveTo="translate-x-full opacity-0 scale-95" leaveTo="translate-x-full opacity-0 scale-95"
> >
@ -557,14 +669,22 @@ export default function Header() {
{user?.email || 'user@example.com'} {user?.email || 'user@example.com'}
</div> </div>
</div> </div>
{canSeeDashboard && (
<button <button
onClick={() => { router.push('/dashboard'); setMobileMenuOpen(false); }} onClick={() => {
router.push('/dashboard')
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" 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"
> >
Dashboard Dashboard
</button> </button>
)}
<button <button
onClick={() => { router.push('/profile'); setMobileMenuOpen(false); }} onClick={() => {
router.push('/profile')
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" 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"
> >
Profile Profile
@ -778,16 +898,6 @@ export default function Header() {
</Transition.Child> </Transition.Child>
</Transition> </Transition>
</Dialog> </Dialog>
{/* Logout transition overlay */}
{loggingOut && (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3 text-white">
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white animate-spin" />
<p className="text-sm font-medium">Logging you out...</p>
</div>
</div>
)}
</header> </header>
) )
} }

View File

@ -0,0 +1,160 @@
'use client'
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
InputHTMLAttributes,
} from 'react'
import { createIntlTelInput, IntlTelInputInstance } from '../../utils/phoneUtils'
export type TelephoneInputHandle = {
getNumber: () => string
isValid: () => boolean
}
interface TelephoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
/** e.g. "de" */
initialCountry?: string
}
/**
* Reusable telephone input with intl-tel-input.
* Always takes full available width.
*/
const TelephoneInput = forwardRef<TelephoneInputHandle, TelephoneInputProps>(
({ initialCountry = (process.env.NEXT_PUBLIC_GEO_FALLBACK_COUNTRY || 'DE').toLowerCase(), ...rest }, ref) => {
const inputRef = useRef<HTMLInputElement | null>(null)
const itiRef = useRef<IntlTelInputInstance | null>(null)
useEffect(() => {
let disposed = false
let instance: IntlTelInputInstance | null = null
const setup = async () => {
try {
console.log('[TelephoneInput] setup() start for', {
id: rest.id,
name: rest.name,
initialCountry,
})
if (!inputRef.current) {
console.warn('[TelephoneInput] setup() aborted: inputRef is null', {
id: rest.id,
name: rest.name,
})
return
}
instance = await createIntlTelInput(inputRef.current, {
initialCountry,
nationalMode: true,
strictMode: true,
autoPlaceholder: 'aggressive',
validationNumberTypes: ['MOBILE'],
})
if (disposed) {
console.log('[TelephoneInput] setup() finished but component is disposed, destroying instance', {
id: rest.id,
name: rest.name,
})
instance.destroy()
return
}
itiRef.current = instance
console.log('[TelephoneInput] intl-tel-input instance attached to input', {
id: rest.id,
name: rest.name,
})
} catch (e) {
console.error('[TelephoneInput] Failed to init intl-tel-input:', e)
}
}
setup()
return () => {
disposed = true
if (instance) {
console.log('[TelephoneInput] Destroying intl-tel-input instance for', {
id: rest.id,
name: rest.name,
})
instance.destroy()
if (itiRef.current === instance) itiRef.current = null
}
}
}, [initialCountry, rest.id, rest.name])
useImperativeHandle(ref, () => ({
getNumber: () => {
const raw = inputRef.current?.value || ''
if (itiRef.current) {
const intl = itiRef.current.getNumber()
console.log('[TelephoneInput] getNumber()', {
id: rest.id,
name: rest.name,
raw,
intl,
})
return intl
}
console.warn(
'[TelephoneInput] getNumber() called before intl-tel-input ready, returning raw value',
{ id: rest.id, name: rest.name, raw }
)
return raw
},
isValid: () => {
if (!itiRef.current) {
const raw = inputRef.current?.value || ''
console.warn('[TelephoneInput] isValid() called before intl-tel-input ready', {
id: rest.id,
name: rest.name,
raw,
})
return false
}
const instance = itiRef.current
const intl = instance.getNumber()
const valid = instance.isValidNumber()
const errorCode = typeof instance.getValidationError === 'function'
? instance.getValidationError()
: undefined
const country = typeof instance.getSelectedCountryData === 'function'
? instance.getSelectedCountryData()
: undefined
console.log('[TelephoneInput] isValid() check', {
id: rest.id,
name: rest.name,
intl,
valid,
errorCode,
country,
})
return valid
},
}))
return (
<div className="w-full">
<input
ref={inputRef}
type="tel"
className={`w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary ${rest.className || ''}`}
{...rest}
/>
</div>
)
}
)
TelephoneInput.displayName = 'TelephoneInput'
export default TelephoneInput

View File

@ -1,9 +1,10 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline' import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
import { useRegister } from '../hooks/useRegister' import { useRegister } from '../hooks/useRegister'
import { useToast } from '../../components/toast/toastComponent' import { useToast } from '../../components/toast/toastComponent'
import TelephoneInput, { TelephoneInputHandle } from '../../components/phone/telephoneInput'
interface RegisterFormProps { interface RegisterFormProps {
mode: 'personal' | 'company' mode: 'personal' | 'company'
@ -71,6 +72,11 @@ export default function RegisterForm({
const [error, setError] = useState('') const [error, setError] = useState('')
const [formFade, setFormFade] = useState('fade-in') const [formFade, setFormFade] = useState('fade-in')
// Phone input refs (to access intl-tel-input via TelephoneInput)
const personalPhoneRef = useRef<TelephoneInputHandle | null>(null)
const companyPhoneRef = useRef<TelephoneInputHandle | null>(null)
const contactPhoneRef = useRef<TelephoneInputHandle | null>(null)
// Hook for backend calls // Hook for backend calls
const { registerPersonalReferral, registerCompanyReferral, error: regError } = useRegister() const { registerPersonalReferral, registerCompanyReferral, error: regError } = useRegister()
const { showToast } = useToast() const { showToast } = useToast()
@ -114,8 +120,7 @@ export default function RegisterForm({
const validatePersonalForm = (): boolean => { const validatePersonalForm = (): boolean => {
if (!personalForm.firstName.trim() || !personalForm.lastName.trim() || if (!personalForm.firstName.trim() || !personalForm.lastName.trim() ||
!personalForm.email.trim() || !personalForm.confirmEmail.trim() || !personalForm.email.trim() || !personalForm.confirmEmail.trim() ||
!personalForm.password.trim() || !personalForm.confirmPassword.trim() || !personalForm.password.trim() || !personalForm.confirmPassword.trim()
!personalForm.phoneNumber.trim()
) { ) {
setError('All fields are required') setError('All fields are required')
return false return false
@ -136,6 +141,25 @@ export default function RegisterForm({
return false return false
} }
const phoneApi = personalPhoneRef.current
const intlNumber = phoneApi?.getNumber() || ''
const valid = phoneApi?.isValid() ?? false
console.log('[RegisterForm] validatePersonalForm phone check', {
rawState: personalForm.phoneNumber,
intlFromApi: intlNumber,
isValidFromApi: valid,
})
if (!intlNumber) {
setError('Please enter your phone number including country code.')
return false
}
if (!valid) {
setError('Please enter a valid mobile phone number.')
return false
}
setError('') setError('')
return true return true
} }
@ -143,8 +167,7 @@ export default function RegisterForm({
const validateCompanyForm = (): boolean => { const validateCompanyForm = (): boolean => {
if (!companyForm.companyName.trim() || !companyForm.companyEmail.trim() || if (!companyForm.companyName.trim() || !companyForm.companyEmail.trim() ||
!companyForm.confirmCompanyEmail.trim() || !companyForm.contactPersonName.trim() || !companyForm.confirmCompanyEmail.trim() || !companyForm.contactPersonName.trim() ||
!companyForm.password.trim() || !companyForm.confirmPassword.trim() || !companyForm.password.trim() || !companyForm.confirmPassword.trim()
!companyForm.companyPhone.trim() || !companyForm.contactPersonPhone.trim()
) { ) {
setError('All fields are required') setError('All fields are required')
return false return false
@ -165,6 +188,32 @@ export default function RegisterForm({
return false return false
} }
const companyApi = companyPhoneRef.current
const contactApi = contactPhoneRef.current
const companyNumber = companyApi?.getNumber() || ''
const contactNumber = contactApi?.getNumber() || ''
const companyValid = companyApi?.isValid() ?? false
const contactValid = contactApi?.isValid() ?? false
console.log('[RegisterForm] validateCompanyForm phone check', {
rawCompany: companyForm.companyPhone,
rawContact: companyForm.contactPersonPhone,
intlCompany: companyNumber,
intlContact: contactNumber,
companyValid,
contactValid,
})
if (!companyNumber || !contactNumber) {
setError('Please enter both company and contact phone numbers including country codes.')
return false
}
if (!companyValid || !contactValid) {
setError('Please enter valid phone numbers for company and contact person.')
return false
}
setError('') setError('')
return true return true
} }
@ -180,13 +229,20 @@ export default function RegisterForm({
setError('') setError('')
try { try {
const normalizedPhone =
personalPhoneRef.current?.getNumber() || personalForm.phoneNumber
console.log('[RegisterForm] handlePersonalSubmit normalized phone', {
normalizedPhone,
})
const res = await registerPersonalReferral({ const res = await registerPersonalReferral({
refToken: refToken || '', refToken: refToken || '',
firstName: personalForm.firstName, firstName: personalForm.firstName,
lastName: personalForm.lastName, lastName: personalForm.lastName,
email: personalForm.email, email: personalForm.email,
password: personalForm.password, password: personalForm.password,
phone: personalForm.phoneNumber, phone: normalizedPhone,
}) })
if (res.ok) { if (res.ok) {
showToast({ showToast({
@ -227,14 +283,24 @@ export default function RegisterForm({
setError('') setError('')
try { try {
const normalizedCompanyPhone =
companyPhoneRef.current?.getNumber() || companyForm.companyPhone
const normalizedContactPhone =
contactPhoneRef.current?.getNumber() || companyForm.contactPersonPhone
console.log('[RegisterForm] handleCompanySubmit normalized phones', {
normalizedCompanyPhone,
normalizedContactPhone,
})
const res = await registerCompanyReferral({ const res = await registerCompanyReferral({
refToken: refToken || '', refToken: refToken || '',
companyEmail: companyForm.companyEmail, companyEmail: companyForm.companyEmail,
password: companyForm.password, password: companyForm.password,
companyName: companyForm.companyName, companyName: companyForm.companyName,
companyPhone: companyForm.companyPhone, companyPhone: normalizedCompanyPhone,
contactPersonName: companyForm.contactPersonName, contactPersonName: companyForm.contactPersonName,
contactPersonPhone: companyForm.contactPersonPhone, contactPersonPhone: normalizedContactPhone,
}) })
if (res.ok) { if (res.ok) {
showToast({ showToast({
@ -314,7 +380,10 @@ export default function RegisterForm({
<div className="text-sm text-slate-700 mb-2">Password requirements:</div> <div className="text-sm text-slate-700 mb-2">Password requirements:</div>
<ul className="text-sm space-y-1"> <ul className="text-sm space-y-1">
{rules.map((rule, index) => ( {rules.map((rule, index) => (
<li key={index} className={`flex items-center gap-2 ${rule.test ? 'text-green-600' : 'text-slate-600'}`}> <li
key={index}
className={`flex items-center gap-2 ${rule.test ? 'text-green-600' : 'text-slate-600'}`}
>
<span>{rule.test ? '✓' : '○'}</span> <span>{rule.test ? '✓' : '○'}</span>
<span>{rule.text}</span> <span>{rule.text}</span>
</li> </li>
@ -331,7 +400,6 @@ export default function RegisterForm({
<h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-2"> <h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-2">
Registration for Profit Planet Registration for Profit Planet
</h2> </h2>
{/* Replace generic invite with referrer email inside the form */}
{referrerEmail && ( {referrerEmail && (
<p className="text-base sm:text-sm text-[#8D6B1D] font-medium"> <p className="text-base sm:text-sm text-[#8D6B1D] font-medium">
You were invited by <span className="font-semibold">{referrerEmail}</span>! You were invited by <span className="font-semibold">{referrerEmail}</span>!
@ -446,15 +514,15 @@ export default function RegisterForm({
<label htmlFor="phoneNumber" className="block text-sm font-medium text-[#0F172A] mb-2"> <label htmlFor="phoneNumber" className="block text-sm font-medium text-[#0F172A] mb-2">
Phone number * Phone number *
</label> </label>
<input <TelephoneInput
type="tel"
id="phoneNumber" id="phoneNumber"
name="phoneNumber" name="phoneNumber"
value={personalForm.phoneNumber} ref={personalPhoneRef}
onChange={handlePersonalChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
placeholder="+49 123 456 7890" placeholder="+49 123 456 7890"
required required
onChange={e =>
setPersonalForm(prev => ({ ...prev, phoneNumber: (e.target as HTMLInputElement).value }))
}
/> />
</div> </div>
@ -594,15 +662,15 @@ export default function RegisterForm({
<label htmlFor="companyPhone" className="block text-sm font-medium text-[#0F172A] mb-2"> <label htmlFor="companyPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
Company phone * Company phone *
</label> </label>
<input <TelephoneInput
type="tel"
id="companyPhone" id="companyPhone"
name="companyPhone" name="companyPhone"
value={companyForm.companyPhone} ref={companyPhoneRef}
onChange={handleCompanyChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
placeholder="+49 123 456 7890" placeholder="+49 123 456 7890"
required required
onChange={e =>
setCompanyForm(prev => ({ ...prev, companyPhone: (e.target as HTMLInputElement).value }))
}
/> />
</div> </div>
@ -610,15 +678,18 @@ export default function RegisterForm({
<label htmlFor="contactPersonPhone" className="block text-sm font-medium text-[#0F172A] mb-2"> <label htmlFor="contactPersonPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
Contact person phone * Contact person phone *
</label> </label>
<input <TelephoneInput
type="tel"
id="contactPersonPhone" id="contactPersonPhone"
name="contactPersonPhone" name="contactPersonPhone"
value={companyForm.contactPersonPhone} ref={contactPhoneRef}
onChange={handleCompanyChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
placeholder="+49 123 456 7890" placeholder="+49 123 456 7890"
required required
onChange={e =>
setCompanyForm(prev => ({
...prev,
contactPersonPhone: (e.target as HTMLInputElement).value,
}))
}
/> />
</div> </div>
</div> </div>

179
src/app/utils/phoneUtils.ts Normal file
View File

@ -0,0 +1,179 @@
'use client'
/**
* Shared intl-tel-input utilities for the frontend (CDN-based).
* Handles:
* - CSS injection
* - JS script loading
* - utils.js loading via loadUtils
* - instance creation
*/
const ITI_CDN_CSS =
'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/css/intlTelInput.css'
const ITI_CDN_JS =
'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/js/intlTelInput.min.js'
const ITI_CDN_UTILS =
'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/js/utils.js'
export type IntlTelInputInstance = {
destroy: () => void
getNumber: () => string
isValidNumber: () => boolean
getValidationError?: () => number
getSelectedCountryData?: () => { name: string; iso2: string; dialCode: string }
promise?: Promise<unknown>
}
declare global {
interface Window {
intlTelInput?: (input: HTMLInputElement, options: any) => IntlTelInputInstance
}
}
let intlLoaderPromise: Promise<(input: HTMLInputElement, options: any) => IntlTelInputInstance> | null =
null
async function loadIntlTelInputFromCdn(): Promise<
(input: HTMLInputElement, options: any) => IntlTelInputInstance
> {
if (typeof window === 'undefined') {
throw new Error('[phoneUtils] intl-tel-input can only be used in the browser')
}
// CSS once
if (!document.querySelector('link[data-intl-tel-input-css="true"]')) {
console.log('[phoneUtils] Injecting intl-tel-input CSS from CDN')
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = ITI_CDN_CSS
link.dataset.intlTelInputCss = 'true'
document.head.appendChild(link)
} else {
console.log('[phoneUtils] intl-tel-input CSS already present')
}
// Helper CSS to make .iti full-width
if (!document.querySelector('style[data-intl-tel-input-width="true"]')) {
console.log('[phoneUtils] Injecting full-width .iti CSS helper')
const style = document.createElement('style')
style.dataset.intlTelInputWidth = 'true'
style.innerHTML = `
.iti {
display: block;
width: 100%;
}
.iti input {
width: 100%;
}
`
document.head.appendChild(style)
}
// JS once
if (window.intlTelInput) {
console.log('[phoneUtils] intl-tel-input already loaded on window')
return window.intlTelInput
}
console.log('[phoneUtils] Loading intl-tel-input core (no utils) from CDN…')
await new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
'script[data-intl-tel-input-js="true"]'
)
if (existing) {
console.log('[phoneUtils] Reusing existing intl-tel-input <script> tag, waiting for load')
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener(
'error',
() => reject(new Error('Failed to load intl-tel-input')),
{ once: true }
)
return
}
const script = document.createElement('script')
script.src = ITI_CDN_JS
script.async = true
script.dataset.intlTelInputJs = 'true'
script.onload = () => {
console.log('[phoneUtils] intl-tel-input core script loaded successfully')
resolve()
}
script.onerror = () => {
console.error('[phoneUtils] intl-tel-input core script failed to load')
reject(new Error('Failed to load intl-tel-input'))
}
document.head.appendChild(script)
})
if (!window.intlTelInput) {
console.error('[phoneUtils] window.intlTelInput missing after core script load')
throw new Error('intl-tel-input not found on window after script load')
}
console.log('[phoneUtils] window.intlTelInput is ready (core only, utils will be loaded via loadUtils)')
return window.intlTelInput
}
/**
* Public: ensure intl-tel-input is loaded and return the factory function.
*/
export async function ensureIntlCoreLoaded(): Promise<
(input: HTMLInputElement, options: any) => IntlTelInputInstance
> {
if (!intlLoaderPromise) {
intlLoaderPromise = loadIntlTelInputFromCdn()
}
return intlLoaderPromise
}
/**
* Public: create an intl-tel-input instance on the given input,
* including loading utils.js via the documented loadUtils option.
*/
export async function createIntlTelInput(
input: HTMLInputElement,
options: any = {}
): Promise<IntlTelInputInstance> {
const intlTelInput = await ensureIntlCoreLoaded()
console.log('[phoneUtils] Creating intl-tel-input instance with loadUtils', {
id: input.id,
name: input.name,
})
const instance = intlTelInput(input, {
...options,
loadUtils: () => {
console.log('[phoneUtils] loadUtils() called for', { id: input.id, name: input.name })
// docs: load utils from CDN via dynamic import
return import(/* @vite-ignore */ ITI_CDN_UTILS).then(mod => {
console.log('[phoneUtils] utils.js module loaded for', {
id: input.id,
name: input.name,
keys: Object.keys(mod || {}),
})
return mod
})
},
})
const anyInst = instance as any
if (anyInst.promise && typeof anyInst.promise.then === 'function') {
anyInst.promise.then(() => {
console.log('[phoneUtils] intl-tel-input instance promise resolved (utils ready)', {
id: input.id,
name: input.name,
})
})
}
console.log('[phoneUtils] intl-tel-input instance created', {
id: input.id,
name: input.name,
hasIsValidNumber: typeof instance.isValidNumber === 'function',
})
return instance
}