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",
|
||||
"country-flag-icons": "^1.5.21",
|
||||
"country-select-js": "^2.1.0",
|
||||
"intl-tel-input": "^25.10.11",
|
||||
"intl-tel-input": "^25.15.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^16.0.7",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
@ -6504,9 +6504,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/intl-tel-input": {
|
||||
"version": "25.11.2",
|
||||
"resolved": "https://registry.npmjs.org/intl-tel-input/-/intl-tel-input-25.11.2.tgz",
|
||||
"integrity": "sha512-3a9+bbtR6s7E8TjZauqodMz+MRMd31OcUhTJuQOg95lA+viZ53OTU8XzVuyldEE089nMtLhPF1NbRU1ff2Sf7g==",
|
||||
"version": "25.15.0",
|
||||
"resolved": "https://registry.npmjs.org/intl-tel-input/-/intl-tel-input-25.15.0.tgz",
|
||||
"integrity": "sha512-ux50qr6qCAWrd3BMioVN3te54h2+2pobvgq7vbKQ1GUP5P8XUcVuxKN/5bSxZuOYJqyqeCRqfcH7zlYErFZMtw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.5.21",
|
||||
"country-select-js": "^2.1.0",
|
||||
"intl-tel-input": "^25.10.11",
|
||||
"intl-tel-input": "^25.15.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^16.0.7",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
|
||||
@ -15,6 +15,11 @@ import { useMemo, useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
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() {
|
||||
const router = useRouter()
|
||||
const { userStats, isAdmin } = useAdminUsers()
|
||||
@ -147,45 +152,92 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Matrix Management */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/matrix-management')}
|
||||
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"
|
||||
disabled={!DISPLAY_MATRIX}
|
||||
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">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-blue-100 border border-blue-200">
|
||||
<Squares2X2Icon className="h-6 w-6 text-blue-600" />
|
||||
<span
|
||||
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>
|
||||
<div className="text-left">
|
||||
<div className="text-base font-semibold text-blue-900">Matrix Management</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>
|
||||
<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>
|
||||
|
||||
{/* Coffee Subscription Management */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/subscriptions')}
|
||||
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"
|
||||
disabled={!DISPLAY_ABONEMENTS}
|
||||
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">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-amber-100 border border-amber-200">
|
||||
<BanknotesIcon className="h-6 w-6 text-amber-600" />
|
||||
<span
|
||||
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>
|
||||
<div className="text-left">
|
||||
<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>
|
||||
{!DISPLAY_ABONEMENTS && (
|
||||
<p className="mt-1 text-xs text-gray-500 italic">
|
||||
This module is currently disabled in the system configuration.
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Contract Management (unchanged) */}
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<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" />
|
||||
</span>
|
||||
<div className="text-left">
|
||||
@ -195,13 +247,15 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
{/* User Management (unchanged) */}
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<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" />
|
||||
</span>
|
||||
<div className="text-left">
|
||||
@ -211,21 +265,43 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
{/* News Management */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/news-management')}
|
||||
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"
|
||||
disabled={!DISPLAY_NEWS}
|
||||
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">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-green-100 border border-green-200">
|
||||
<ClipboardDocumentListIcon className="h-6 w-6 text-green-600" />
|
||||
<span
|
||||
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>
|
||||
<div className="text-left">
|
||||
<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>
|
||||
{!DISPLAY_NEWS && (
|
||||
<p className="mt-1 text-xs text-gray-500 italic">
|
||||
This module is currently disabled in the system configuration.
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import Header from './nav/Header';
|
||||
import Footer from './Footer';
|
||||
import PageTransitionEffect from './animation/pageTransitionEffect';
|
||||
@ -23,13 +23,14 @@ export default function PageLayout({
|
||||
showFooter = true
|
||||
}: PageLayoutProps) {
|
||||
const isMobile = isMobileDevice();
|
||||
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col bg-white text-gray-900">
|
||||
|
||||
{showHeader && (
|
||||
<div className="relative z-50 w-full flex-shrink-0">
|
||||
<Header />
|
||||
<Header setGlobalLoggingOut={setIsLoggingOut} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -43,6 +44,16 @@ export default function PageLayout({
|
||||
<Footer />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -53,11 +53,14 @@ const navLinks = [
|
||||
// Toggle visibility of Shop navigation across header (desktop + mobile)
|
||||
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 [mounted, setMounted] = useState(false)
|
||||
const [animateIn, setAnimateIn] = useState(false)
|
||||
const [loggingOut, setLoggingOut] = useState(false) // NEW
|
||||
const user = useAuthStore(s => s.user)
|
||||
const logout = useAuthStore(s => s.logout)
|
||||
const accessToken = useAuthStore(s => s.accessToken)
|
||||
@ -67,16 +70,18 @@ export default function Header() {
|
||||
const [hasReferralPerm, setHasReferralPerm] = useState(false)
|
||||
const [adminMgmtOpen, setAdminMgmtOpen] = useState(false)
|
||||
const managementRef = useRef<HTMLDivElement | null>(null)
|
||||
const [canSeeDashboard, setCanSeeDashboard] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
setLoggingOut(true) // NEW: start logout transition
|
||||
// start global logout transition
|
||||
setGlobalLoggingOut?.(true)
|
||||
await logout()
|
||||
setMobileMenuOpen(false)
|
||||
router.push('/login')
|
||||
} catch (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 }
|
||||
}, [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 userPresent = mounted && isLoggedIn
|
||||
|
||||
// NEW: detect admin role across common shapes
|
||||
// NEW: detect admin role across common shapes, but only after mount
|
||||
const isAdmin =
|
||||
mounted &&
|
||||
!!user &&
|
||||
(
|
||||
(user as any)?.role === 'admin' ||
|
||||
@ -235,13 +307,32 @@ export default function Header() {
|
||||
<div className="flex lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
onClick={() => setMobileMenuOpen(open => !open)}
|
||||
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"
|
||||
data-open={mobileMenuOpen ? 'true' : 'false'}
|
||||
>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
<Bars3Icon aria-hidden="true" className="size-6" />
|
||||
<span className="sr-only">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
@ -317,13 +408,15 @@ export default function Header() {
|
||||
{user?.email || 'user@example.com'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Bars3Icon className="size-5 text-gray-400" />
|
||||
Dashboard
|
||||
</button>
|
||||
{canSeeDashboard && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Bars3Icon className="size-5 text-gray-400" />
|
||||
Dashboard
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
@ -350,12 +443,31 @@ export default function Header() {
|
||||
{/* Desktop hamburger (right side, next to login/profile) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
onClick={() => setMobileMenuOpen(open => !open)}
|
||||
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"
|
||||
>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
<Bars3Icon aria-hidden="true" className="size-6" />
|
||||
<span className="sr-only">
|
||||
{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>
|
||||
</div>
|
||||
</nav>
|
||||
@ -486,24 +598,24 @@ export default function Header() {
|
||||
{/* Side drawer menu: mobile + desktop */}
|
||||
<Dialog open={mobileMenuOpen} onClose={setMobileMenuOpen}>
|
||||
<Transition appear show={mobileMenuOpen}>
|
||||
{/* Overlay: slightly slower fade for a smoother feel */}
|
||||
{/* Overlay: smoother, longer fade-out */}
|
||||
<Transition.Child
|
||||
enter="transition-opacity duration-400 ease-out"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-300 ease-in"
|
||||
leave="transition-opacity duration-800 ease-out"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* Sliding panel: slide + fade + scale for a modern, flashy effect */}
|
||||
{/* Sliding panel: smoother, longer close animation */}
|
||||
<Transition.Child
|
||||
enter="transform transition-all duration-500 ease-out"
|
||||
enterFrom="translate-x-full opacity-0 scale-95"
|
||||
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"
|
||||
leaveTo="translate-x-full opacity-0 scale-95"
|
||||
>
|
||||
@ -556,14 +668,22 @@ export default function Header() {
|
||||
{user?.email || 'user@example.com'}
|
||||
</div>
|
||||
</div>
|
||||
{canSeeDashboard && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Dashboard
|
||||
</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"
|
||||
>
|
||||
Profile
|
||||
@ -777,16 +897,6 @@ export default function Header() {
|
||||
</Transition.Child>
|
||||
</Transition>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
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'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
||||
import { useRegister } from '../hooks/useRegister'
|
||||
import { useToast } from '../../components/toast/toastComponent'
|
||||
import TelephoneInput, { TelephoneInputHandle } from '../../components/phone/telephoneInput'
|
||||
|
||||
interface RegisterFormProps {
|
||||
mode: 'personal' | 'company'
|
||||
@ -70,7 +71,12 @@ export default function RegisterForm({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
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
|
||||
const { registerPersonalReferral, registerCompanyReferral, error: regError } = useRegister()
|
||||
const { showToast } = useToast()
|
||||
@ -114,8 +120,7 @@ export default function RegisterForm({
|
||||
const validatePersonalForm = (): boolean => {
|
||||
if (!personalForm.firstName.trim() || !personalForm.lastName.trim() ||
|
||||
!personalForm.email.trim() || !personalForm.confirmEmail.trim() ||
|
||||
!personalForm.password.trim() || !personalForm.confirmPassword.trim() ||
|
||||
!personalForm.phoneNumber.trim()
|
||||
!personalForm.password.trim() || !personalForm.confirmPassword.trim()
|
||||
) {
|
||||
setError('All fields are required')
|
||||
return false
|
||||
@ -135,6 +140,25 @@ export default function RegisterForm({
|
||||
setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters')
|
||||
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('')
|
||||
return true
|
||||
@ -143,8 +167,7 @@ export default function RegisterForm({
|
||||
const validateCompanyForm = (): boolean => {
|
||||
if (!companyForm.companyName.trim() || !companyForm.companyEmail.trim() ||
|
||||
!companyForm.confirmCompanyEmail.trim() || !companyForm.contactPersonName.trim() ||
|
||||
!companyForm.password.trim() || !companyForm.confirmPassword.trim() ||
|
||||
!companyForm.companyPhone.trim() || !companyForm.contactPersonPhone.trim()
|
||||
!companyForm.password.trim() || !companyForm.confirmPassword.trim()
|
||||
) {
|
||||
setError('All fields are required')
|
||||
return false
|
||||
@ -164,6 +187,32 @@ export default function RegisterForm({
|
||||
setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters')
|
||||
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('')
|
||||
return true
|
||||
@ -180,13 +229,20 @@ export default function RegisterForm({
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const normalizedPhone =
|
||||
personalPhoneRef.current?.getNumber() || personalForm.phoneNumber
|
||||
|
||||
console.log('[RegisterForm] handlePersonalSubmit normalized phone', {
|
||||
normalizedPhone,
|
||||
})
|
||||
|
||||
const res = await registerPersonalReferral({
|
||||
refToken: refToken || '',
|
||||
firstName: personalForm.firstName,
|
||||
lastName: personalForm.lastName,
|
||||
email: personalForm.email,
|
||||
password: personalForm.password,
|
||||
phone: personalForm.phoneNumber,
|
||||
phone: normalizedPhone,
|
||||
})
|
||||
if (res.ok) {
|
||||
showToast({
|
||||
@ -227,14 +283,24 @@ export default function RegisterForm({
|
||||
setError('')
|
||||
|
||||
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({
|
||||
refToken: refToken || '',
|
||||
companyEmail: companyForm.companyEmail,
|
||||
password: companyForm.password,
|
||||
companyName: companyForm.companyName,
|
||||
companyPhone: companyForm.companyPhone,
|
||||
companyPhone: normalizedCompanyPhone,
|
||||
contactPersonName: companyForm.contactPersonName,
|
||||
contactPersonPhone: companyForm.contactPersonPhone,
|
||||
contactPersonPhone: normalizedContactPhone,
|
||||
})
|
||||
if (res.ok) {
|
||||
showToast({
|
||||
@ -314,7 +380,10 @@ export default function RegisterForm({
|
||||
<div className="text-sm text-slate-700 mb-2">Password requirements:</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
{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.text}</span>
|
||||
</li>
|
||||
@ -331,7 +400,6 @@ export default function RegisterForm({
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-2">
|
||||
Registration for Profit Planet
|
||||
</h2>
|
||||
{/* Replace generic invite with referrer email inside the form */}
|
||||
{referrerEmail && (
|
||||
<p className="text-base sm:text-sm text-[#8D6B1D] font-medium">
|
||||
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">
|
||||
Phone number *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
<TelephoneInput
|
||||
id="phoneNumber"
|
||||
name="phoneNumber"
|
||||
value={personalForm.phoneNumber}
|
||||
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"
|
||||
ref={personalPhoneRef}
|
||||
placeholder="+49 123 456 7890"
|
||||
required
|
||||
onChange={e =>
|
||||
setPersonalForm(prev => ({ ...prev, phoneNumber: (e.target as HTMLInputElement).value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -594,31 +662,34 @@ export default function RegisterForm({
|
||||
<label htmlFor="companyPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||
Company phone *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
<TelephoneInput
|
||||
id="companyPhone"
|
||||
name="companyPhone"
|
||||
value={companyForm.companyPhone}
|
||||
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"
|
||||
ref={companyPhoneRef}
|
||||
placeholder="+49 123 456 7890"
|
||||
required
|
||||
onChange={e =>
|
||||
setCompanyForm(prev => ({ ...prev, companyPhone: (e.target as HTMLInputElement).value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label htmlFor="contactPersonPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||
Contact person phone *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
<TelephoneInput
|
||||
id="contactPersonPhone"
|
||||
name="contactPersonPhone"
|
||||
value={companyForm.contactPersonPhone}
|
||||
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"
|
||||
ref={contactPhoneRef}
|
||||
placeholder="+49 123 456 7890"
|
||||
required
|
||||
onChange={e =>
|
||||
setCompanyForm(prev => ({
|
||||
...prev,
|
||||
contactPersonPhone: (e.target as HTMLInputElement).value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -705,4 +776,4 @@ export default function RegisterForm({
|
||||
</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