refactor: PhoneUtils
This commit is contained in:
parent
e3198991a9
commit
c78dc64ac2
8
package-lock.json
generated
8
package-lock.json
generated
@ -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": {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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
|
// NEW: detect admin role across common shapes, but only after mount
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
|
mounted &&
|
||||||
!!user &&
|
!!user &&
|
||||||
(
|
(
|
||||||
(user as any)?.role === 'admin' ||
|
(user as any)?.role === 'admin' ||
|
||||||
@ -235,13 +307,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>
|
||||||
|
|
||||||
@ -317,6 +408,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"
|
||||||
@ -324,6 +416,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"
|
||||||
@ -350,12 +443,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>
|
||||||
@ -486,24 +598,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"
|
||||||
>
|
>
|
||||||
@ -556,14 +668,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
|
||||||
@ -777,16 +897,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
160
src/app/components/phone/telephoneInput.tsx
Normal file
160
src/app/components/phone/telephoneInput.tsx
Normal 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
|
||||||
@ -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
179
src/app/utils/phoneUtils.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user