feat: mobile register / login
This commit is contained in:
parent
7defd9e596
commit
86c953654f
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Header from './nav/Header';
|
||||
import Footer from './Footer';
|
||||
import PageTransitionEffect from './animation/pageTransitionEffect';
|
||||
@ -15,18 +16,39 @@ interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
showHeader?: boolean;
|
||||
showFooter?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
export default function PageLayout({
|
||||
children,
|
||||
showHeader = true,
|
||||
showFooter = true
|
||||
showFooter = true,
|
||||
className = 'bg-white text-gray-900',
|
||||
contentClassName = 'flex-1 relative z-10 w-full',
|
||||
}: PageLayoutProps) {
|
||||
const isMobile = isMobileDevice();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW
|
||||
const pathname = usePathname();
|
||||
|
||||
// Global scrollbar restore / leak cleanup (runs on navigation)
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
|
||||
// ensure a visible/stable vertical scrollbar on desktop
|
||||
html.style.overflowY = 'scroll';
|
||||
body.style.overflowY = 'auto';
|
||||
|
||||
// clear common scroll-lock leftovers (gap where scrollbar should be)
|
||||
if (html.style.overflow === 'hidden') html.style.overflow = '';
|
||||
if (body.style.overflow === 'hidden') body.style.overflow = '';
|
||||
html.style.paddingRight = '';
|
||||
body.style.paddingRight = '';
|
||||
}, [pathname]);
|
||||
|
||||
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 ${className}`}>
|
||||
|
||||
{showHeader && (
|
||||
<div className="relative z-50 w-full flex-shrink-0">
|
||||
@ -35,7 +57,7 @@ export default function PageLayout({
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 relative z-10 w-full">
|
||||
<div className={contentClassName}>
|
||||
<PageTransitionEffect>{children}</PageTransitionEffect>
|
||||
</div>
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
|
||||
const DISPLAY_MEMBERSHIP = process.env.NEXT_PUBLIC_DISPLAY_MEMBERSHIP !== 'false'
|
||||
const DISPLAY_ABOUT_US = process.env.NEXT_PUBLIC_DISPLAY_ABOUT_US !== 'false'
|
||||
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
|
||||
const DISPLAY_ABONEMENTS = process.env.NEXT_PUBLIC_DISPLAY_ABONEMMENTS !== 'false'
|
||||
const DISPLAY_ABONEMENTS = process.env.NEXT_PUBLIC_DISPLAY_ABONEMENTS !== 'false'
|
||||
const DISPLAY_POOLS = process.env.NEXT_PUBLIC_DISPLAY_POOLS !== 'false'
|
||||
|
||||
|
||||
@ -62,18 +62,31 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [animateIn, setAnimateIn] = useState(false)
|
||||
const [scrollY, setScrollY] = useState(0)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const user = useAuthStore(s => s.user)
|
||||
const logout = useAuthStore(s => s.logout)
|
||||
const accessToken = useAuthStore(s => s.accessToken)
|
||||
const refreshAuthToken = useAuthStore(s => s.refreshAuthToken)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const isParallaxPage = pathname === '/' || pathname === '/login'
|
||||
const isParallaxPage =
|
||||
pathname === '/' ||
|
||||
pathname === '/login' ||
|
||||
pathname === '/password-reset' ||
|
||||
pathname === '/register'
|
||||
|
||||
const parallaxEnabled = isParallaxPage && !isMobile
|
||||
const headerIsFixedOverlay = isParallaxPage && !isMobile
|
||||
|
||||
const headerPositionClass = isParallaxPage
|
||||
? (isMobile ? 'sticky top-0 w-full' : 'fixed top-0 left-0 w-full')
|
||||
: 'relative'
|
||||
|
||||
const [hasReferralPerm, setHasReferralPerm] = useState(false)
|
||||
const [adminMgmtOpen, setAdminMgmtOpen] = useState(false)
|
||||
const managementRef = useRef<HTMLDivElement | null>(null)
|
||||
const [canSeeDashboard, setCanSeeDashboard] = useState(false)
|
||||
const headerElRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
@ -119,12 +132,25 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
setAnimateIn(true)
|
||||
}, [])
|
||||
|
||||
// Detect mobile devices (for disabling parallax)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 768px)')
|
||||
const apply = () => setIsMobile(mq.matches)
|
||||
apply()
|
||||
mq.addEventListener?.('change', apply)
|
||||
window.addEventListener('resize', apply, { passive: true })
|
||||
return () => {
|
||||
mq.removeEventListener?.('change', apply)
|
||||
window.removeEventListener('resize', apply)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Home + login scroll listener: reveal header after first scroll with slight parallax
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
|
||||
if (!isParallaxPage) {
|
||||
// non-parallax pages: header always visible, no scroll listeners
|
||||
if (!parallaxEnabled) {
|
||||
// non-parallax (and mobile): header always visible, no scroll listeners
|
||||
setScrollY(100)
|
||||
return
|
||||
}
|
||||
@ -149,7 +175,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('wheel', handleWheel)
|
||||
}
|
||||
}, [mounted, isParallaxPage])
|
||||
}, [mounted, parallaxEnabled])
|
||||
|
||||
// Fetch user permissions and set hasReferralPerm
|
||||
useEffect(() => {
|
||||
@ -309,22 +335,82 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
)
|
||||
const isAdmin = mounted && rawIsAdmin
|
||||
|
||||
// Only gate visibility by scroll on home; elsewhere just use animateIn
|
||||
const headerVisible = isParallaxPage ? animateIn && scrollY > 24 : animateIn
|
||||
const parallaxOffset = isParallaxPage ? Math.max(-16, -scrollY * 0.15) : 0
|
||||
// Only gate visibility by scroll on parallax-enabled pages
|
||||
const headerVisible = parallaxEnabled ? animateIn && scrollY > 24 : animateIn
|
||||
const parallaxOffset = parallaxEnabled ? Math.max(-16, -scrollY * 0.15) : 0
|
||||
|
||||
// When the fixed header becomes visible, expose its height as a CSS variable so
|
||||
// pages (e.g. /register) can pad their content and avoid being overlapped.
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
|
||||
let raf1 = 0
|
||||
let raf2 = 0
|
||||
|
||||
const applySpacer = () => {
|
||||
// Only reserve space when header is fixed/overlaying content.
|
||||
if (!headerIsFixedOverlay) {
|
||||
document.documentElement.style.setProperty('--pp-header-spacer', '0px')
|
||||
return
|
||||
}
|
||||
|
||||
const h = headerElRef.current?.getBoundingClientRect().height ?? 0
|
||||
const spacer = headerVisible ? `${Math.ceil(h)}px` : '0px'
|
||||
document.documentElement.style.setProperty('--pp-header-spacer', spacer)
|
||||
}
|
||||
|
||||
const applyShiftFade = () => {
|
||||
if (!headerVisible) {
|
||||
document.documentElement.style.setProperty('--pp-page-shift-opacity', '1')
|
||||
return
|
||||
}
|
||||
|
||||
// Less noticeable dip to avoid "too translucent" look during the shift.
|
||||
document.documentElement.style.setProperty('--pp-page-shift-opacity', '0.99')
|
||||
raf1 = window.requestAnimationFrame(() => {
|
||||
raf2 = window.requestAnimationFrame(() => {
|
||||
document.documentElement.style.setProperty('--pp-page-shift-opacity', '1')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
applySpacer()
|
||||
applyShiftFade()
|
||||
|
||||
const onResize = () => applySpacer()
|
||||
window.addEventListener('resize', onResize, { passive: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
if (raf1) cancelAnimationFrame(raf1)
|
||||
if (raf2) cancelAnimationFrame(raf2)
|
||||
}
|
||||
}, [mounted, headerVisible, headerIsFixedOverlay])
|
||||
|
||||
// Hard cleanup: if any scroll-lock left padding/overflow on <body>/<html>, remove it when the drawer is closed.
|
||||
useEffect(() => {
|
||||
if (mobileMenuOpen) return
|
||||
const html = document.documentElement
|
||||
const body = document.body
|
||||
|
||||
body.style.paddingRight = ''
|
||||
html.style.paddingRight = ''
|
||||
|
||||
if (body.style.overflow === 'hidden') body.style.overflow = ''
|
||||
if (html.style.overflow === 'hidden') html.style.overflow = ''
|
||||
}, [mobileMenuOpen])
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`${
|
||||
isParallaxPage ? 'fixed top-0 left-0 w-full' : 'relative'
|
||||
} isolate z-10 shadow-lg shadow-black/30 after:pointer-events-none after:absolute after:inset-0 after:-z-10 after:bg-[radial-gradient(circle_at_20%_20%,rgba(56,124,255,0.18),transparent_55%),radial-gradient(circle_at_80%_35%,rgba(139,92,246,0.16),transparent_60%)] ${
|
||||
ref={headerElRef}
|
||||
className={`${headerPositionClass} isolate z-30 shadow-lg shadow-black/30 after:pointer-events-none after:absolute after:inset-0 after:-z-10 after:bg-[radial-gradient(circle_at_20%_20%,rgba(56,124,255,0.18),transparent_55%),radial-gradient(circle_at_80%_35%,rgba(139,92,246,0.16),transparent_60%)] ${
|
||||
isAdmin ? '' : 'border-b border-white/10'
|
||||
} ${
|
||||
headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-6 pointer-events-none'
|
||||
} transition-all duration-500 ease-out`}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #0F1D37 0%, #0A162A 50%, #081224 100%)',
|
||||
...(isParallaxPage ? { transform: `translateY(${parallaxOffset}px)` } : {}),
|
||||
...(parallaxEnabled ? { transform: `translateY(${parallaxOffset}px)` } : {}),
|
||||
}}
|
||||
>
|
||||
<nav
|
||||
@ -886,7 +972,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
<div className="py-6 space-y-4 px-1">
|
||||
{/* Information disclosure */}
|
||||
<Disclosure as="div">
|
||||
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 px-3 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5">
|
||||
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 px-3 textbase/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5">
|
||||
Information
|
||||
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
|
||||
</DisclosureButton>
|
||||
@ -908,7 +994,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
<button
|
||||
key={link.href}
|
||||
onClick={() => { router.push(link.href); 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 textbase/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
{link.name}
|
||||
</button>
|
||||
@ -916,7 +1002,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||
<div className="px-3">
|
||||
<button
|
||||
onClick={() => { router.push('/login'); setMobileMenuOpen(false); }}
|
||||
className="block rounded-lg px-3 py-2.5 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.5 textbase/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
|
||||
@ -12,6 +12,8 @@ import { createIntlTelInput, IntlTelInputInstance } from '../../utils/phoneUtils
|
||||
export type TelephoneInputHandle = {
|
||||
getNumber: () => string
|
||||
isValid: () => boolean
|
||||
// NEW: allow callers to require a selected country code
|
||||
getDialCode: () => string | null
|
||||
}
|
||||
|
||||
interface TelephoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
@ -24,20 +26,33 @@ interface TelephoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>
|
||||
* Always takes full available width.
|
||||
*/
|
||||
const TelephoneInput = forwardRef<TelephoneInputHandle, TelephoneInputProps>(
|
||||
({ initialCountry = (process.env.NEXT_PUBLIC_GEO_FALLBACK_COUNTRY || 'DE').toLowerCase(), ...rest }, ref) => {
|
||||
({ initialCountry, ...rest }, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const itiRef = useRef<IntlTelInputInstance | null>(null)
|
||||
const readyRef = useRef(false)
|
||||
const lastSyncDigitsRef = useRef<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let instance: IntlTelInputInstance | null = null
|
||||
readyRef.current = false
|
||||
lastSyncDigitsRef.current = ''
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
console.log('[TelephoneInput] setup() start for', {
|
||||
const fallbackCountry =
|
||||
(process.env.NEXT_PUBLIC_GEO_FALLBACK_COUNTRY || 'DE').toLowerCase()
|
||||
const resolvedCountry = (initialCountry || fallbackCountry).toLowerCase()
|
||||
|
||||
console.log('[TelephoneInput] EFFECT setup() ENTER', {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
initialCountry,
|
||||
initialCountryProp: initialCountry,
|
||||
fallbackCountry,
|
||||
resolvedCountry,
|
||||
hasWindow: typeof window !== 'undefined',
|
||||
hasIntlTelInputOnWindow: typeof (window as any)?.intlTelInput === 'function',
|
||||
hasIntlTelInputGlobals: typeof (window as any)?.intlTelInputGlobals !== 'undefined',
|
||||
})
|
||||
|
||||
if (!inputRef.current) {
|
||||
@ -48,20 +63,90 @@ const TelephoneInput = forwardRef<TelephoneInputHandle, TelephoneInputProps>(
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[TelephoneInput] calling createIntlTelInput with options', {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
options: {
|
||||
initialCountry: resolvedCountry,
|
||||
nationalMode: true,
|
||||
strictMode: true,
|
||||
autoPlaceholder: 'aggressive',
|
||||
validationNumberTypes: ['MOBILE'],
|
||||
// Help keep display consistent once utils is available
|
||||
formatOnDisplay: true,
|
||||
formatAsYouType: true,
|
||||
// NEW: keep dropdown anchored (no fullscreen takeover on mobile)
|
||||
useFullscreenPopup: false,
|
||||
},
|
||||
})
|
||||
|
||||
instance = await createIntlTelInput(inputRef.current, {
|
||||
initialCountry,
|
||||
initialCountry: resolvedCountry,
|
||||
nationalMode: true,
|
||||
strictMode: true,
|
||||
autoPlaceholder: 'aggressive',
|
||||
validationNumberTypes: ['MOBILE'],
|
||||
// Help keep display consistent once utils is available
|
||||
formatOnDisplay: true,
|
||||
formatAsYouType: true,
|
||||
// NEW: keep dropdown anchored (no fullscreen takeover on mobile)
|
||||
useFullscreenPopup: false,
|
||||
})
|
||||
|
||||
// Sync selected country/flag from typed dial code (e.g. +43 => AT),
|
||||
// but only once the user has typed enough digits to avoid cursor-jank.
|
||||
const inputEl = inputRef.current
|
||||
const syncFromValue = () => {
|
||||
if (!inputEl || !instance) return
|
||||
|
||||
const raw = (inputEl.value || '').trim()
|
||||
if (!raw) return
|
||||
|
||||
// normalize "00" prefix to "+"
|
||||
const normalized = raw.startsWith('00') ? `+${raw.slice(2)}` : raw
|
||||
if (!normalized.startsWith('+')) return
|
||||
|
||||
const digits = normalized.replace(/\D/g, '')
|
||||
if (digits.length < 4) return // wait until "+CCx" at least
|
||||
if (digits === lastSyncDigitsRef.current) return
|
||||
lastSyncDigitsRef.current = digits
|
||||
|
||||
try {
|
||||
instance.setNumber?.(`+${digits}`)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Mark ready once the plugin finishes any async init work.
|
||||
const anyInstance = instance as any
|
||||
if (anyInstance?.promise && typeof anyInstance.promise.then === 'function') {
|
||||
anyInstance.promise
|
||||
.then(() => {
|
||||
readyRef.current = true
|
||||
// resync once utils/formatting is definitely available
|
||||
try { syncFromValue() } catch {}
|
||||
})
|
||||
.catch(() => {})
|
||||
} else {
|
||||
readyRef.current = true
|
||||
try { syncFromValue() } catch {}
|
||||
}
|
||||
|
||||
inputEl.addEventListener('input', syncFromValue)
|
||||
inputEl.addEventListener('blur', syncFromValue)
|
||||
|
||||
// one initial sync (covers paste/autofill after mount)
|
||||
syncFromValue()
|
||||
|
||||
if (disposed) {
|
||||
console.log('[TelephoneInput] setup() finished but component is disposed, destroying instance', {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
})
|
||||
instance.destroy()
|
||||
console.log(
|
||||
'[TelephoneInput] setup() finished but component is disposed, destroying instance',
|
||||
{ id: rest.id, name: rest.name }
|
||||
)
|
||||
if (instance) {
|
||||
instance.destroy()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -69,9 +154,23 @@ const TelephoneInput = forwardRef<TelephoneInputHandle, TelephoneInputProps>(
|
||||
console.log('[TelephoneInput] intl-tel-input instance attached to input', {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
inputCurrentValue: inputRef.current.value,
|
||||
})
|
||||
|
||||
// cleanup listeners when disposed
|
||||
const prevCleanup = () => {
|
||||
inputEl.removeEventListener('input', syncFromValue)
|
||||
inputEl.removeEventListener('blur', syncFromValue)
|
||||
}
|
||||
;(anyInstance.__pp_cleanup as undefined | (() => void))?.()
|
||||
anyInstance.__pp_cleanup = prevCleanup
|
||||
} catch (e) {
|
||||
console.error('[TelephoneInput] Failed to init intl-tel-input:', e)
|
||||
console.error('[TelephoneInput] Failed to init intl-tel-input:', {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
error: e,
|
||||
stack: (e as any)?.stack,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,12 +178,35 @@ const TelephoneInput = forwardRef<TelephoneInputHandle, TelephoneInputProps>(
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
readyRef.current = false
|
||||
|
||||
// remove listeners (if we attached them)
|
||||
try {
|
||||
;(instance as any)?.__pp_cleanup?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
console.log('[TelephoneInput] EFFECT cleanup ENTER', {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
hadInstance: !!instance,
|
||||
hadItiRef: !!itiRef.current,
|
||||
})
|
||||
if (instance) {
|
||||
console.log('[TelephoneInput] Destroying intl-tel-input instance for', {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
})
|
||||
instance.destroy()
|
||||
try {
|
||||
instance.destroy()
|
||||
} catch (e) {
|
||||
console.error('[TelephoneInput] Error while destroying instance', {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
if (itiRef.current === instance) itiRef.current = null
|
||||
}
|
||||
}
|
||||
@ -93,63 +215,99 @@ const TelephoneInput = forwardRef<TelephoneInputHandle, TelephoneInputProps>(
|
||||
useImperativeHandle(ref, () => ({
|
||||
getNumber: () => {
|
||||
const raw = inputRef.current?.value || ''
|
||||
if (itiRef.current) {
|
||||
if (!itiRef.current || !readyRef.current) return raw
|
||||
try {
|
||||
const intl = itiRef.current.getNumber()
|
||||
console.log('[TelephoneInput] getNumber()', {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
raw,
|
||||
intl,
|
||||
})
|
||||
return intl
|
||||
return intl || raw
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
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 raw = inputRef.current?.value || ''
|
||||
if (!itiRef.current || !readyRef.current) {
|
||||
return /^\+?\d{7,}$/.test(raw.replace(/\s+/g, ''))
|
||||
}
|
||||
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
|
||||
try {
|
||||
return itiRef.current.isValidNumber()
|
||||
} catch {
|
||||
return /^\+?\d{7,}$/.test(raw.replace(/\s+/g, ''))
|
||||
}
|
||||
},
|
||||
getDialCode: () => {
|
||||
const iti = itiRef.current as any
|
||||
const data = iti?.getSelectedCountryData?.()
|
||||
const dial = data?.dialCode
|
||||
return typeof dial === 'string' && dial.trim() ? dial.trim() : null
|
||||
},
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="w-full pp-iti-dark">
|
||||
<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 || ''}`}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary ${rest.className || ''}`}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
{/* UPDATED: also cover mobile/fullscreen dropdown container (often appended to body) */}
|
||||
<style jsx global>{`
|
||||
/* Scoped (works when dropdown stays inside the component) */
|
||||
.pp-iti-dark .iti__country-list {
|
||||
color: #0f172a;
|
||||
background: #ffffff;
|
||||
}
|
||||
.pp-iti-dark .iti__country,
|
||||
.pp-iti-dark .iti__country-name,
|
||||
.pp-iti-dark .iti__dial-code,
|
||||
.pp-iti-dark .iti__selected-dial-code,
|
||||
.pp-iti-dark .iti__search-input {
|
||||
color: #0f172a !important;
|
||||
}
|
||||
.pp-iti-dark .iti__dial-code {
|
||||
color: #334155 !important;
|
||||
}
|
||||
.pp-iti-dark .iti__country.iti__highlight {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.pp-iti-dark .iti__divider {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Global (mobile fullscreen popup / container appended to body) */
|
||||
.iti--container,
|
||||
.iti--container .iti__country-list,
|
||||
.iti-mobile .iti__country-list {
|
||||
background: #ffffff !important;
|
||||
color: #0f172a !important;
|
||||
}
|
||||
.iti--container .iti__country,
|
||||
.iti--container .iti__country-name,
|
||||
.iti--container .iti__dial-code,
|
||||
.iti--container .iti__selected-dial-code,
|
||||
.iti--container .iti__search-input,
|
||||
.iti-mobile .iti__country-name,
|
||||
.iti-mobile .iti__dial-code,
|
||||
.iti-mobile .iti__search-input {
|
||||
color: #0f172a !important;
|
||||
}
|
||||
.iti--container .iti__dial-code,
|
||||
.iti-mobile .iti__dial-code {
|
||||
color: #334155 !important;
|
||||
}
|
||||
.iti--container .iti__country.iti__highlight,
|
||||
.iti-mobile .iti__country.iti__highlight {
|
||||
background: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* NEW: ensure dropdown scrolls instead of growing (anchored dropdown UX) */
|
||||
.iti__country-list {
|
||||
max-height: min(320px, 45vh) !important;
|
||||
overflow-y: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -133,6 +133,8 @@ export interface WavesProps {
|
||||
maxCursorMove?: number;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
animate?: boolean;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
const Waves: React.FC<WavesProps> = ({
|
||||
@ -149,6 +151,8 @@ const Waves: React.FC<WavesProps> = ({
|
||||
maxCursorMove = 100,
|
||||
style = {},
|
||||
className = '',
|
||||
animate = true,
|
||||
interactive = true,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@ -253,6 +257,50 @@ const Waves: React.FC<WavesProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
function moved(point: Point, withCursor = true): { x: number; y: number } {
|
||||
const x = point.x + point.wave.x + (withCursor ? point.cursor.x : 0);
|
||||
const y = point.y + point.wave.y + (withCursor ? point.cursor.y : 0);
|
||||
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
|
||||
}
|
||||
|
||||
function drawLines(withCursor = true) {
|
||||
const { width, height } = boundingRef.current;
|
||||
const ctx = ctxRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = configRef.current.lineColor;
|
||||
|
||||
linesRef.current.forEach(points => {
|
||||
let p1 = moved(points[0], false);
|
||||
ctx.moveTo(p1.x, p1.y);
|
||||
points.forEach((p, idx) => {
|
||||
const isLast = idx === points.length - 1;
|
||||
p1 = moved(p, withCursor && !isLast);
|
||||
const p2 = moved(points[idx + 1] || points[points.length - 1], withCursor && !isLast);
|
||||
ctx.lineTo(p1.x, p1.y);
|
||||
if (isLast) ctx.moveTo(p2.x, p2.y);
|
||||
});
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawStatic() {
|
||||
linesRef.current.forEach(pts => {
|
||||
pts.forEach(p => {
|
||||
p.wave.x = 0;
|
||||
p.wave.y = 0;
|
||||
p.cursor.x = 0;
|
||||
p.cursor.y = 0;
|
||||
p.cursor.vx = 0;
|
||||
p.cursor.vy = 0;
|
||||
});
|
||||
});
|
||||
drawLines(false);
|
||||
}
|
||||
|
||||
function movePoints(time: number) {
|
||||
const lines = linesRef.current;
|
||||
const mouse = mouseRef.current;
|
||||
@ -265,6 +313,8 @@ const Waves: React.FC<WavesProps> = ({
|
||||
p.wave.x = Math.cos(move) * waveAmpX;
|
||||
p.wave.y = Math.sin(move) * waveAmpY;
|
||||
|
||||
if (!interactive) return;
|
||||
|
||||
const dx = p.x - mouse.sx;
|
||||
const dy = p.y - mouse.sy;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
@ -288,75 +338,46 @@ const Waves: React.FC<WavesProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
function moved(point: Point, withCursor = true): { x: number; y: number } {
|
||||
const x = point.x + point.wave.x + (withCursor ? point.cursor.x : 0);
|
||||
const y = point.y + point.wave.y + (withCursor ? point.cursor.y : 0);
|
||||
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
|
||||
}
|
||||
|
||||
function drawLines() {
|
||||
const { width, height } = boundingRef.current;
|
||||
const ctx = ctxRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = configRef.current.lineColor;
|
||||
|
||||
linesRef.current.forEach(points => {
|
||||
let p1 = moved(points[0], false);
|
||||
ctx.moveTo(p1.x, p1.y);
|
||||
points.forEach((p, idx) => {
|
||||
const isLast = idx === points.length - 1;
|
||||
p1 = moved(p, !isLast);
|
||||
const p2 = moved(points[idx + 1] || points[points.length - 1], !isLast);
|
||||
ctx.lineTo(p1.x, p1.y);
|
||||
if (isLast) ctx.moveTo(p2.x, p2.y);
|
||||
});
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function tick(t: number) {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const mouse = mouseRef.current;
|
||||
mouse.sx += (mouse.x - mouse.sx) * 0.1;
|
||||
mouse.sy += (mouse.y - mouse.sy) * 0.1;
|
||||
const dx = mouse.x - mouse.lx;
|
||||
const dy = mouse.y - mouse.ly;
|
||||
const d = Math.hypot(dx, dy);
|
||||
mouse.v = d;
|
||||
mouse.vs += (d - mouse.vs) * 0.1;
|
||||
mouse.vs = Math.min(100, mouse.vs);
|
||||
mouse.lx = mouse.x;
|
||||
mouse.ly = mouse.y;
|
||||
mouse.a = Math.atan2(dy, dx);
|
||||
container.style.setProperty('--x', `${mouse.sx}px`);
|
||||
container.style.setProperty('--y', `${mouse.sy}px`);
|
||||
if (interactive) {
|
||||
const mouse = mouseRef.current;
|
||||
mouse.sx += (mouse.x - mouse.sx) * 0.1;
|
||||
mouse.sy += (mouse.y - mouse.sy) * 0.1;
|
||||
const dx = mouse.x - mouse.lx;
|
||||
const dy = mouse.y - mouse.ly;
|
||||
const d = Math.hypot(dx, dy);
|
||||
mouse.v = d;
|
||||
mouse.vs += (d - mouse.vs) * 0.1;
|
||||
mouse.vs = Math.min(100, mouse.vs);
|
||||
mouse.lx = mouse.x;
|
||||
mouse.ly = mouse.y;
|
||||
mouse.a = Math.atan2(dy, dx);
|
||||
container.style.setProperty('--x', `${mouse.sx}px`);
|
||||
container.style.setProperty('--y', `${mouse.sy}px`);
|
||||
}
|
||||
|
||||
movePoints(t);
|
||||
drawLines();
|
||||
drawLines(true);
|
||||
frameIdRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// NEW: react to parent size changes (content height, header/footer, etc.)
|
||||
const ro =
|
||||
typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(() => onResize())
|
||||
: null;
|
||||
|
||||
function onResize() {
|
||||
setSize();
|
||||
setLines();
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
updateMouse(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
const touch = e.touches[0];
|
||||
updateMouse(touch.clientX, touch.clientY);
|
||||
if (!animate) drawStatic();
|
||||
}
|
||||
|
||||
function updateMouse(x: number, y: number) {
|
||||
if (!interactive) return;
|
||||
const mouse = mouseRef.current;
|
||||
const b = boundingRef.current;
|
||||
mouse.x = x - b.left;
|
||||
@ -370,20 +391,43 @@ const Waves: React.FC<WavesProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
updateMouse(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
const touch = e.touches[0];
|
||||
if (touch) updateMouse(touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
setSize();
|
||||
setLines();
|
||||
frameIdRef.current = requestAnimationFrame(tick);
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
ro?.observe(container);
|
||||
|
||||
if (interactive) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||
}
|
||||
|
||||
if (animate) {
|
||||
frameIdRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
drawStatic();
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
ro?.disconnect();
|
||||
if (interactive) {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
}
|
||||
if (frameIdRef.current !== null) cancelAnimationFrame(frameIdRef.current);
|
||||
frameIdRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [animate, interactive]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -391,8 +435,14 @@ const Waves: React.FC<WavesProps> = ({
|
||||
style={{
|
||||
backgroundColor,
|
||||
...style,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
className={`fixed inset-0 w-full h-full overflow-hidden ${className}`}
|
||||
className={`w-full h-full overflow-hidden ${className}`}
|
||||
>
|
||||
<canvas ref={canvasRef} className="block w-full h-full" />
|
||||
</div>
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import Header from '../components/nav/Header'
|
||||
import Footer from '../components/Footer'
|
||||
import PageLayout from '../components/PageLayout'
|
||||
import Waves from '../components/waves'
|
||||
import {
|
||||
ShoppingBagIcon,
|
||||
UsersIcon,
|
||||
import {
|
||||
ShoppingBagIcon,
|
||||
UsersIcon,
|
||||
UserCircleIcon,
|
||||
StarIcon,
|
||||
HeartIcon,
|
||||
@ -20,7 +19,20 @@ export default function DashboardPage() {
|
||||
const user = useAuthStore(state => state.user)
|
||||
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
||||
const isShopEnabled = process.env.NEXT_PUBLIC_SHOW_SHOP !== 'false'
|
||||
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 768px)')
|
||||
const apply = () => setIsMobile(mq.matches)
|
||||
apply()
|
||||
mq.addEventListener?.('change', apply)
|
||||
window.addEventListener('resize', apply, { passive: true })
|
||||
return () => {
|
||||
mq.removeEventListener?.('change', apply)
|
||||
window.removeEventListener('resize', apply)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Redirect if not logged in (only after auth is ready)
|
||||
useEffect(() => {
|
||||
if (isAuthReady && !user) {
|
||||
@ -117,7 +129,7 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
||||
className="relative w-full min-h-[100dvh] flex flex-col overflow-x-hidden"
|
||||
style={{ backgroundImage: 'none', background: 'none' }}
|
||||
>
|
||||
<Waves
|
||||
@ -133,177 +145,177 @@ export default function DashboardPage() {
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
animate={!isMobile}
|
||||
interactive={!isMobile}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="relative z-10 flex-1 min-h-0">
|
||||
<PageLayout className="bg-transparent text-gray-900">
|
||||
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-8">
|
||||
{/* Welcome Section */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Welcome back, {getUserName()}! 👋
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Here's what's happening with your Profit Planet account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
|
||||
{/* Welcome Section */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Welcome back, {getUserName()}! 👋
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Here's what's happening with your Profit Planet account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* News Section (replaces Account setup + Stats Grid) */}
|
||||
<div className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Latest News & Articles</h2>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{news.map(item => (
|
||||
<article key={item.id} className="group relative overflow-hidden rounded-xl bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
{/* Image/placeholder */}
|
||||
<div className="aspect-[16/9] w-full bg-gradient-to-br from-gray-100 to-gray-200" />
|
||||
<div className="p-5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 ring-1 ring-amber-200">
|
||||
{item.category}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{new Date(item.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 group-hover:text-[#8D6B1D] transition-colors">
|
||||
<button
|
||||
onClick={() => (window.location.href = item.href)}
|
||||
className="text-left w-full"
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-600 line-clamp-3">{item.excerpt}</p>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => (window.location.href = item.href)}
|
||||
className="text-sm font-medium text-[#8D6B1D] hover:text-[#7A5E1A]"
|
||||
>
|
||||
Read more →
|
||||
</button>
|
||||
</div>
|
||||
{/* News Section (replaces Account setup + Stats Grid) */}
|
||||
<div className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Latest News & Articles</h2>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{news.map(item => (
|
||||
<article key={item.id} className="group relative overflow-hidden rounded-xl bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
{/* Image/placeholder */}
|
||||
<div className="aspect-[16/9] w-full bg-gradient-to-br from-gray-100 to-gray-200" />
|
||||
<div className="p-5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 ring-1 ring-amber-200">
|
||||
{item.category}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{new Date(item.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 group-hover:text-[#8D6B1D] transition-colors">
|
||||
<button
|
||||
onClick={() => (window.location.href = item.href)}
|
||||
className="text-left w-full"
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-600 line-clamp-3">{item.excerpt}</p>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => (window.location.href = item.href)}
|
||||
className="text-sm font-medium text-[#8D6B1D] hover:text-[#7A5E1A]"
|
||||
>
|
||||
Read more →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{quickActions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (!action.disabled) {
|
||||
router.push(action.href)
|
||||
}
|
||||
}}
|
||||
disabled={Boolean(action.disabled)}
|
||||
className={`bg-white rounded-lg p-6 border border-gray-200 text-left group transition-all duration-200 ${
|
||||
action.disabled
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: 'shadow-sm hover:shadow-lg hover:-translate-y-1 hover:-translate-y-1 hover:-translate-y-1 transform hover:-translate-y-1'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div
|
||||
className={`${action.color} rounded-lg p-3 ${
|
||||
action.disabled
|
||||
? 'grayscale'
|
||||
: 'group-hover:scale-105 transition-transform'
|
||||
}`}
|
||||
>
|
||||
<action.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<h3
|
||||
className={`text-lg font-medium transition-colors ${
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{quickActions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (!action.disabled) {
|
||||
router.push(action.href)
|
||||
}
|
||||
}}
|
||||
disabled={Boolean(action.disabled)}
|
||||
className={`bg-white rounded-lg p-6 border border-gray-200 text-left group transition-all duration-200 ${
|
||||
action.disabled
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-900 group-hover:text-[#8D6B1D]'
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: 'shadow-sm hover:shadow-lg hover:-translate-y-1 hover:-translate-y-1 hover:-translate-y-1 transform hover:-translate-y-1'
|
||||
}`}
|
||||
>
|
||||
{action.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{action.description}
|
||||
<div className="flex items-start">
|
||||
<div
|
||||
className={`${action.color} rounded-lg p-3 ${
|
||||
action.disabled
|
||||
? 'grayscale'
|
||||
: 'group-hover:scale-105 transition-transform'
|
||||
}`}
|
||||
>
|
||||
<action.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<h3
|
||||
className={`text-lg font-medium transition-colors ${
|
||||
action.disabled
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-900 group-hover:text-[#8D6B1D]'
|
||||
}`}
|
||||
>
|
||||
{action.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{action.description}
|
||||
</p>
|
||||
{action.disabled && action.disabledText && (
|
||||
<p className="mt-3 text-xs font-medium text-amber-700">
|
||||
{action.disabledText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gold Member Status */}
|
||||
<div className="bg-gradient-to-r from-[#8D6B1D] to-[#B8860B] rounded-lg p-6 text-white mb-8">
|
||||
<div className="flex items-center">
|
||||
<StarIcon className="h-12 w-12 text-yellow-300" />
|
||||
<div className="ml-4">
|
||||
<h2 className="text-2xl font-bold">Gold Member Status</h2>
|
||||
<p className="text-yellow-100 mt-1">
|
||||
Enjoy exclusive benefits and discounts
|
||||
</p>
|
||||
{action.disabled && action.disabledText && (
|
||||
<p className="mt-3 text-xs font-medium text-amber-700">
|
||||
{action.disabledText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<button className="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg text-white font-medium transition-colors">
|
||||
View Benefits
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gold Member Status */}
|
||||
<div className="bg-gradient-to-r from-[#8D6B1D] to-[#B8860B] rounded-lg p-6 text-white mb-8">
|
||||
<div className="flex items-center">
|
||||
<StarIcon className="h-12 w-12 text-yellow-300" />
|
||||
<div className="ml-4">
|
||||
<h2 className="text-2xl font-bold">Gold Member Status</h2>
|
||||
<p className="text-yellow-100 mt-1">
|
||||
Enjoy exclusive benefits and discounts
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<button className="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg text-white font-medium transition-colors">
|
||||
View Benefits
|
||||
</button>
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Recent Activity</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||
<div className="bg-green-100 rounded-full p-2">
|
||||
<ShoppingBagIcon className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">Order completed</p>
|
||||
<p className="text-sm text-gray-600">Eco-friendly water bottle</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">2 days ago</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||
<div className="bg-blue-100 rounded-full p-2">
|
||||
<HeartIcon className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">Added to favorites</p>
|
||||
<p className="text-sm text-gray-600">Sustainable backpack</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">1 week ago</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center py-3">
|
||||
<div className="bg-purple-100 rounded-full p-2">
|
||||
<UsersIcon className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">Joined community</p>
|
||||
<p className="text-sm text-gray-600">Eco Warriors Group</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">2 weeks ago</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Recent Activity</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||
<div className="bg-green-100 rounded-full p-2">
|
||||
<ShoppingBagIcon className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">Order completed</p>
|
||||
<p className="text-sm text-gray-600">Eco-friendly water bottle</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">2 days ago</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||
<div className="bg-blue-100 rounded-full p-2">
|
||||
<HeartIcon className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">Added to favorites</p>
|
||||
<p className="text-sm text-gray-600">Sustainable backpack</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">1 week ago</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center py-3">
|
||||
<div className="bg-purple-100 rounded-full p-2">
|
||||
<UsersIcon className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">Joined community</p>
|
||||
<p className="text-sm text-gray-600">Eco Warriors Group</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">2 weeks ago</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
</PageLayout>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -6,6 +6,8 @@ import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
||||
import { useLogin } from '../hooks/useLogin'
|
||||
import { useToast } from '../../components/toast/toastComponent'
|
||||
|
||||
const GLASS_BG = 'rgba(255,255,255,0.55)'
|
||||
|
||||
export default function LoginForm() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
@ -36,22 +38,22 @@ export default function LoginForm() {
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.email.trim()) {
|
||||
setError('E-Mail-Adresse ist erforderlich')
|
||||
setError('Email address is required')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
setError('Bitte gib eine gültige E-Mail-Adresse ein')
|
||||
setError('Please enter a valid email address')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!formData.password.trim()) {
|
||||
setError('Passwort ist erforderlich')
|
||||
setError('Password is required')
|
||||
return false
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen lang sein')
|
||||
setError('Password must be at least 6 characters long')
|
||||
return false
|
||||
}
|
||||
|
||||
@ -95,7 +97,7 @@ export default function LoginForm() {
|
||||
|
||||
// CHANGED: Wider base widths; no transform scaling
|
||||
const formWidth = isMobile
|
||||
? '94vw'
|
||||
? '100%'
|
||||
: isTablet
|
||||
? '80vw'
|
||||
: isSmallLaptop
|
||||
@ -103,7 +105,7 @@ export default function LoginForm() {
|
||||
: '52vw'
|
||||
|
||||
const formMaxWidth = isMobile
|
||||
? '480px'
|
||||
? '420px'
|
||||
: isTablet
|
||||
? '760px'
|
||||
: isSmallLaptop
|
||||
@ -120,8 +122,8 @@ export default function LoginForm() {
|
||||
justifyContent: 'center',
|
||||
// REMOVE marble image so Waves shows through
|
||||
background: 'transparent',
|
||||
// Reduced top padding so the curved loop is closer to the form
|
||||
padding: isMobile ? '0.5rem 0.75rem 0.75rem' : '0.2rem 1.5rem 1.5rem',
|
||||
// move the card slightly down on mobile, reduce bottom padding
|
||||
padding: isMobile ? '0.5rem 0.75rem 0' : '0.2rem 1.5rem 1.5rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@ -130,21 +132,24 @@ export default function LoginForm() {
|
||||
width: formWidth,
|
||||
maxWidth: formMaxWidth,
|
||||
minWidth: isMobile ? '0' : '420px',
|
||||
// CHANGED: tighter padding; removed transform scaling
|
||||
padding: isMobile ? '1rem' : '2rem',
|
||||
// slightly tighter on mobile
|
||||
padding: isMobile ? '0.75rem' : '2rem',
|
||||
// more translucent, glassy background
|
||||
backgroundColor: 'rgba(255,255,255,0.55)',
|
||||
backgroundColor: GLASS_BG,
|
||||
backdropFilter: 'blur(18px)',
|
||||
WebkitBackdropFilter: 'blur(18px)',
|
||||
boxShadow: '0 18px 45px rgba(15,23,42,0.45)',
|
||||
// smoother / less bottom-heavy shadow on mobile
|
||||
boxShadow: isMobile
|
||||
? '0 10px 22px rgba(15,23,42,0.18), 0 2px 6px rgba(15,23,42,0.12)'
|
||||
: '0 18px 45px rgba(15,23,42,0.45)',
|
||||
}}
|
||||
>
|
||||
{/* Content (title + earth removed) */}
|
||||
<div
|
||||
style={{
|
||||
// CHANGED: smaller margins; the card is centered now
|
||||
marginTop: isMobile ? '0.25rem' : isTablet ? '0.5rem' : '0.75rem',
|
||||
marginBottom: isMobile ? '1rem' : isTablet ? '1.25rem' : '1.5rem',
|
||||
marginTop: isMobile ? '0.15rem' : isTablet ? '0.5rem' : '0.75rem',
|
||||
marginBottom: isMobile ? '0.75rem' : isTablet ? '1.25rem' : '1.5rem',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
@ -154,14 +159,14 @@ export default function LoginForm() {
|
||||
PROFIT PLANET
|
||||
</h1>
|
||||
<p className="mt-1 text-sm md:text-base text-slate-700/90">
|
||||
Welcome back! Login to continue.
|
||||
Welcome back! Log in to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6 w-full"
|
||||
className={`${isMobile ? 'space-y-4' : 'space-y-6'} w-full`}
|
||||
style={{
|
||||
gap: isMobile ? '0.75rem' : isTablet ? '0.9rem' : '1rem',
|
||||
gap: isMobile ? '0.6rem' : isTablet ? '0.9rem' : '1rem',
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
@ -175,7 +180,7 @@ export default function LoginForm() {
|
||||
marginBottom: isMobile ? '0.25rem' : undefined,
|
||||
}}
|
||||
>
|
||||
E-Mail-Adresse
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
@ -189,7 +194,7 @@ export default function LoginForm() {
|
||||
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1rem',
|
||||
padding: isMobile ? '0.5rem 0.75rem' : isTablet ? '0.6rem 0.875rem' : '0.7rem 1rem',
|
||||
}}
|
||||
placeholder="deine@email.com"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -204,7 +209,7 @@ export default function LoginForm() {
|
||||
marginBottom: isMobile ? '0.25rem' : undefined,
|
||||
}}
|
||||
>
|
||||
Passwort
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@ -223,7 +228,7 @@ export default function LoginForm() {
|
||||
? '0.6rem 2.75rem 0.6rem 0.875rem'
|
||||
: '0.7rem 3rem 0.7rem 1rem',
|
||||
}}
|
||||
placeholder="Dein Passwort"
|
||||
placeholder="Your password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
@ -238,35 +243,6 @@ export default function LoginForm() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Show Password */}
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="rememberMe"
|
||||
name="rememberMe"
|
||||
type="checkbox"
|
||||
checked={formData.rememberMe}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-[#8D6B1D] border-2 border-gray-300 rounded focus:ring-[#8D6B1D] focus:ring-2"
|
||||
/>
|
||||
<label htmlFor="rememberMe" className="ml-2 text-sm text-slate-700">
|
||||
Angemeldet bleiben
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="show-password"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 border-2 border-gray-300 rounded focus:ring-[#8D6B1D] focus:ring-2"
|
||||
checked={showPassword}
|
||||
onChange={(e) => setShowPassword(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="show-password" className="ml-2 text-sm text-slate-700">
|
||||
Passwort anzeigen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
@ -294,10 +270,10 @@ export default function LoginForm() {
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
Anmeldung läuft...
|
||||
Signing in...
|
||||
</div>
|
||||
) : (
|
||||
'Anmelden'
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@ -309,7 +285,7 @@ export default function LoginForm() {
|
||||
className="text-[#8D6B1D] hover:text-[#7A5E1A] hover:underline text-sm font-medium transition-colors"
|
||||
onClick={() => router.push("/password-reset")}
|
||||
>
|
||||
Passwort vergessen?
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -12,6 +12,7 @@ import CurvedLoop from '../components/curvedLoop'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const router = useRouter()
|
||||
const user = useAuthStore(state => state.user)
|
||||
|
||||
@ -20,6 +21,18 @@ export default function LoginPage() {
|
||||
setHasHydrated(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 768px)')
|
||||
const apply = () => setIsMobile(mq.matches)
|
||||
apply()
|
||||
mq.addEventListener?.('change', apply)
|
||||
window.addEventListener('resize', apply, { passive: true })
|
||||
return () => {
|
||||
mq.removeEventListener?.('change', apply)
|
||||
window.removeEventListener('resize', apply)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Redirect if user is already logged in
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@ -48,41 +61,75 @@ export default function LoginPage() {
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
<ToastProvider>
|
||||
<PageLayout showFooter={true}>
|
||||
<div
|
||||
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
||||
style={{ backgroundImage: 'none', background: 'none' }}
|
||||
>
|
||||
{/* Waves background */}
|
||||
<Waves
|
||||
className="pointer-events-none"
|
||||
lineColor="#0f172a"
|
||||
backgroundColor="rgba(245, 245, 240, 1)"
|
||||
waveSpeedX={0.02}
|
||||
waveSpeedY={0.01}
|
||||
waveAmpX={40}
|
||||
waveAmpY={20}
|
||||
friction={0.9}
|
||||
tension={0.01}
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
/>
|
||||
<div className="relative z-10 flex-1 flex flex-col justify-start space-y-4 pt-10 pb-10">
|
||||
<div className="w-full">
|
||||
<CurvedLoop
|
||||
marqueeText="Welcome to profit planet ✦"
|
||||
speed={1}
|
||||
interactive={false}
|
||||
className="tracking-[0.2em]"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<LoginForm />
|
||||
</div>
|
||||
{/* NEW: page-level background wrapper so Waves covers everything */}
|
||||
<div className="relative min-h-screen w-full overflow-x-hidden" style={{ backgroundImage: 'none', background: 'none' }}>
|
||||
<Waves
|
||||
className="pointer-events-none"
|
||||
lineColor="#0f172a"
|
||||
backgroundColor="rgba(245, 245, 240, 1)"
|
||||
waveSpeedX={0.02}
|
||||
waveSpeedY={0.01}
|
||||
waveAmpX={40}
|
||||
waveAmpY={20}
|
||||
friction={0.9}
|
||||
tension={0.01}
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
animate={!isMobile}
|
||||
interactive={!isMobile}
|
||||
/>
|
||||
|
||||
<PageLayout showFooter={true} className="bg-transparent text-gray-900">
|
||||
{/* ...existing code... */}
|
||||
<div
|
||||
className={`relative z-10 w-full flex flex-col flex-1 min-h-0 ${
|
||||
isMobile ? 'overflow-y-hidden' : ''
|
||||
}`}
|
||||
style={{ backgroundImage: 'none', background: 'none' }}
|
||||
>
|
||||
{/* REMOVED: Waves background moved to wrapper */}
|
||||
|
||||
{isMobile ? (
|
||||
// ...existing code...
|
||||
<div
|
||||
className="relative z-10 flex-1 min-h-0 grid place-items-center px-3"
|
||||
style={{ paddingTop: '6rem', paddingBottom: '0.5rem' }}
|
||||
>
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
// push a bit down (visual centering with header + footer)
|
||||
transform: 'translateY(clamp(10px, 2vh, 28px))',
|
||||
}}
|
||||
>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// ...existing code...
|
||||
<div
|
||||
className="relative z-10 flex-1 min-h-0 flex flex-col justify-between"
|
||||
style={{ paddingTop: '0.75rem', paddingBottom: '1rem' }}
|
||||
>
|
||||
<div className="w-full px-4 sm:px-0">
|
||||
<CurvedLoop
|
||||
marqueeText="Welcome to profit planet ✦"
|
||||
speed={1}
|
||||
interactive={false}
|
||||
className="tracking-[0.2em]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex items-center justify-center px-3 sm:px-0">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
{/* ...existing code... */}
|
||||
</PageLayout>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
|
||||
@ -11,10 +11,33 @@ import SplitText from './components/SplitText';
|
||||
export default function HomePage() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.matchMedia('(max-width: 768px)').matches;
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
// Mobile: instantly redirect to login
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
router.replace('/login');
|
||||
}, [isMobile, router]);
|
||||
|
||||
// Keep breakpoint updated (resize/orientation)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 768px)');
|
||||
const apply = () => setIsMobile(mq.matches);
|
||||
mq.addEventListener?.('change', apply);
|
||||
window.addEventListener('resize', apply, { passive: true });
|
||||
return () => {
|
||||
mq.removeEventListener?.('change', apply);
|
||||
window.removeEventListener('resize', apply);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLoginClick = () => {
|
||||
if (!containerRef.current) {
|
||||
// Mobile: no page fade animation
|
||||
if (isMobile || !containerRef.current) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
@ -27,8 +50,9 @@ export default function HomePage() {
|
||||
});
|
||||
};
|
||||
|
||||
// Ensure LOGIN never stays stuck after scrolling / wheel
|
||||
// Ensure LOGIN never stays stuck after scrolling / wheel (desktop only)
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
const resetHover = () => setIsHover(false);
|
||||
window.addEventListener('wheel', resetHover, { passive: true });
|
||||
window.addEventListener('scroll', resetHover, { passive: true });
|
||||
@ -36,7 +60,10 @@ export default function HomePage() {
|
||||
window.removeEventListener('wheel', resetHover);
|
||||
window.removeEventListener('scroll', resetHover);
|
||||
};
|
||||
}, []);
|
||||
}, [isMobile]);
|
||||
|
||||
// Prevent any home UI flash on mobile
|
||||
if (isMobile) return null;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
@ -44,7 +71,7 @@ export default function HomePage() {
|
||||
ref={containerRef}
|
||||
className="min-h-screen flex items-center justify-center relative overflow-hidden bg-black text-white"
|
||||
>
|
||||
{/* Waves background (reverted settings) */}
|
||||
{/* Waves background */}
|
||||
<Waves
|
||||
className="pointer-events-none"
|
||||
lineColor="#0f172a"
|
||||
@ -58,36 +85,45 @@ export default function HomePage() {
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
animate={!isMobile}
|
||||
interactive={!isMobile}
|
||||
/>
|
||||
|
||||
<h1 className="z-10">
|
||||
<a
|
||||
onMouseEnter={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
onClick={handleLoginClick}
|
||||
onMouseEnter={isMobile ? undefined : () => setIsHover(true)}
|
||||
onMouseLeave={isMobile ? undefined : () => setIsHover(false)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<SplitText
|
||||
key={isHover ? 'login' : 'profit-planet'}
|
||||
text={isHover ? 'LOGIN' : 'PROFIT PLANET'}
|
||||
tag="span"
|
||||
className={`text-9xl md:text-9xl font-bold transition-colors duration-300 ${
|
||||
isHover ? 'text-black' : 'text-gray-500'
|
||||
}`}
|
||||
delay={100}
|
||||
duration={0.6}
|
||||
ease="power3.out"
|
||||
splitType="chars"
|
||||
from={{ opacity: 0, y: 40 }}
|
||||
to={{ opacity: 1, y: 0 }}
|
||||
threshold={0.1}
|
||||
rootMargin="-100px"
|
||||
textAlign="center"
|
||||
/>
|
||||
{isMobile ? (
|
||||
<span className="block text-5xl sm:text-6xl font-bold text-gray-500 text-center px-4">
|
||||
PROFIT PLANET
|
||||
</span>
|
||||
) : (
|
||||
<SplitText
|
||||
key={isHover ? 'login' : 'profit-planet'}
|
||||
text={isHover ? 'LOGIN' : 'PROFIT PLANET'}
|
||||
tag="span"
|
||||
className={`text-7xl sm:text-8xl md:text-9xl font-bold transition-colors duration-300 ${
|
||||
isHover ? 'text-black' : 'text-gray-500'
|
||||
}`}
|
||||
delay={100}
|
||||
duration={0.6}
|
||||
ease="power3.out"
|
||||
splitType="chars"
|
||||
from={{ opacity: 0, y: 40 }}
|
||||
to={{ opacity: 1, y: 0 }}
|
||||
threshold={0.1}
|
||||
rootMargin="-100px"
|
||||
textAlign="center"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<Crosshair containerRef={containerRef} color="#0f172a" />
|
||||
{/* No parallax/crosshair on mobile */}
|
||||
{!isMobile && <Crosshair containerRef={containerRef} color="#0f172a" />}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import PageLayout from '../components/PageLayout'
|
||||
import Waves from '../components/waves'
|
||||
import { ToastProvider, useToast } from '../components/toast/toastComponent'
|
||||
|
||||
export default function PasswordResetPage() {
|
||||
function PasswordResetPageInner() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const token = searchParams.get('token')
|
||||
@ -22,6 +24,7 @@ export default function PasswordResetPage() {
|
||||
const [resetLoading, setResetLoading] = useState(false)
|
||||
const [resetSuccess, setResetSuccess] = useState(false)
|
||||
const [resetError, setResetError] = useState('')
|
||||
const { showToast } = useToast()
|
||||
|
||||
// Basic validators
|
||||
const validEmail = (val: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
|
||||
@ -40,7 +43,13 @@ export default function PasswordResetPage() {
|
||||
e.preventDefault()
|
||||
if (requestLoading) return
|
||||
if (!validEmail(email)) {
|
||||
setRequestError('Bitte eine gültige E-Mail eingeben.')
|
||||
const msg = 'Please enter a valid email address.'
|
||||
setRequestError(msg)
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Invalid email',
|
||||
message: msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
setRequestError('')
|
||||
@ -49,8 +58,19 @@ export default function PasswordResetPage() {
|
||||
// TODO: call API endpoint: POST /auth/password-reset/request
|
||||
await new Promise(r => setTimeout(r, 1100))
|
||||
setRequestSuccess(true)
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: 'Password reset email',
|
||||
message: 'If this email exists, a reset link has been sent.',
|
||||
})
|
||||
} catch {
|
||||
setRequestError('Anfrage fehlgeschlagen. Bitte erneut versuchen.')
|
||||
const msg = 'Request failed. Please try again.'
|
||||
setRequestError(msg)
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Request failed',
|
||||
message: msg,
|
||||
})
|
||||
} finally {
|
||||
setRequestLoading(false)
|
||||
}
|
||||
@ -60,11 +80,23 @@ export default function PasswordResetPage() {
|
||||
e.preventDefault()
|
||||
if (resetLoading) return
|
||||
if (!validPassword(password)) {
|
||||
setResetError('Passwort erfüllt nicht die Anforderungen.')
|
||||
const msg = 'Password does not meet the requirements.'
|
||||
setResetError(msg)
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Invalid password',
|
||||
message: msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setResetError('Passwörter stimmen nicht überein.')
|
||||
const msg = 'Passwords do not match.'
|
||||
setResetError(msg)
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Passwords do not match',
|
||||
message: msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
setResetError('')
|
||||
@ -73,89 +105,92 @@ export default function PasswordResetPage() {
|
||||
// TODO: call API endpoint: POST /auth/password-reset/confirm { token, password }
|
||||
await new Promise(r => setTimeout(r, 1200))
|
||||
setResetSuccess(true)
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: 'Password updated',
|
||||
message: 'Your password has been changed. Redirecting to login...',
|
||||
})
|
||||
} catch {
|
||||
setResetError('Zurücksetzen fehlgeschlagen. Bitte erneut versuchen.')
|
||||
const msg = 'Reset failed. Please try again.'
|
||||
setResetError(msg)
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Reset failed',
|
||||
message: msg,
|
||||
})
|
||||
} finally {
|
||||
setResetLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHints = [
|
||||
{ label: 'Mindestens 8 Zeichen', pass: password.length >= 8 },
|
||||
{ label: 'Großbuchstabe (A-Z)', pass: /[A-Z]/.test(password) },
|
||||
{ label: 'Kleinbuchstabe (a-z)', pass: /[a-z]/.test(password) },
|
||||
{ label: 'Ziffer (0-9)', pass: /\d/.test(password) },
|
||||
{ label: 'Sonderzeichen (!@#$...)', pass: /[\W_]/.test(password) }
|
||||
{ label: 'At least 8 characters', pass: password.length >= 8 },
|
||||
{ label: 'Uppercase letter (A-Z)', pass: /[A-Z]/.test(password) },
|
||||
{ label: 'Lowercase letter (a-z)', pass: /[a-z]/.test(password) },
|
||||
{ label: 'Number (0-9)', pass: /\d/.test(password) },
|
||||
{ label: 'Special character (!@#$...)', pass: /[\W_]/.test(password) }
|
||||
]
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<main className="relative flex flex-col flex-1 pt-20 sm:pt-28 pb-12 sm:pb-16 overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-10 h-full w-full stroke-white/10"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
x="50%"
|
||||
y={-1}
|
||||
id="affiliate-pattern"
|
||||
width={200}
|
||||
height={200}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect fill="url(#affiliate-pattern)" width="100%" height="100%" strokeWidth={0} />
|
||||
</svg>
|
||||
{/* Colored Blur Effect */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 right-0 left-1/2 -z-10 -ml-24 transform-gpu overflow-hidden blur-3xl lg:ml-24 xl:ml-48"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(63.1% 29.5%, 100% 17.1%, 76.6% 3%, 48.4% 0%, 44.6% 4.7%, 54.5% 25.3%, 59.8% 49%, 55.2% 57.8%, 44.4% 57.2%, 27.8% 47.9%, 35.1% 81.5%, 0% 97.7%, 39.2% 100%, 35.2% 81.4%, 97.2% 52.8%, 63.1% 29.5%)',
|
||||
}}
|
||||
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{/* Gradient base */}
|
||||
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
|
||||
|
||||
{/* Widened container to match header */}
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex-1 flex flex-col w-full">
|
||||
<div className="mx-auto max-w-2xl text-center mb-10">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-white sm:text-5xl">
|
||||
Passwort zurücksetzen
|
||||
</h1>
|
||||
<p className="mt-3 text-gray-300 text-lg/7">
|
||||
{!token
|
||||
? 'Fordere einen Link zum Zurücksetzen deines Passworts an.'
|
||||
: 'Lege ein neues sicheres Passwort fest.'}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
||||
style={{ backgroundImage: 'none', background: 'none' }}
|
||||
>
|
||||
<Waves
|
||||
className="pointer-events-none"
|
||||
lineColor="#0f172a"
|
||||
backgroundColor="rgba(245, 245, 240, 1)"
|
||||
waveSpeedX={0.02}
|
||||
waveSpeedY={0.01}
|
||||
waveAmpX={40}
|
||||
waveAmpY={20}
|
||||
friction={0.9}
|
||||
tension={0.01}
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
/>
|
||||
|
||||
{/* Wider form card */}
|
||||
<div className="mx-auto w-full max-w-3xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl ring-1 ring-gray-200 dark:ring-white/10 p-6 sm:p-10 md:py-12 md:px-14 relative overflow-hidden">
|
||||
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
||||
<div className="relative">
|
||||
{/* push content a bit further down while still centering */}
|
||||
<main className="relative z-10 flex flex-col flex-1 items-center justify-center pt-32 sm:pt-0 pb-8 sm:pb-10">
|
||||
{/* Widened container to match header */}
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex flex-col w-full">
|
||||
{/* Translucent form card (matching login glass style) */}
|
||||
<div
|
||||
className="mx-auto w-full max-w-3xl rounded-3xl shadow-2xl relative border border-white/35 overflow-hidden p-6 sm:p-10 md:py-12 md:px-14"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
WebkitBackdropFilter: 'blur(18px)',
|
||||
boxShadow: '0 18px 45px rgba(15,23,42,0.45)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
||||
<div className="relative">
|
||||
<div className="mx-auto max-w-2xl text-center mb-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">
|
||||
Reset password
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
||||
{!token
|
||||
? 'Request a link to reset your password.'
|
||||
: 'Set a new secure password.'}
|
||||
</p>
|
||||
</div>
|
||||
{!token && (
|
||||
<form onSubmit={handleRequestSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2" htmlFor="email">
|
||||
E-Mail-Adresse
|
||||
<label className="block text-sm font-semibold text-[#0F172A] mb-2" htmlFor="email">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => { setEmail(e.target.value); setRequestError(''); setRequestSuccess(false)}}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-3 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="dein.email@example.com"
|
||||
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
||||
placeholder="your.email@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -168,17 +203,17 @@ export default function PasswordResetPage() {
|
||||
|
||||
{requestSuccess && (
|
||||
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
||||
E-Mail gesendet (falls Adresse existiert). Prüfe dein Postfach.
|
||||
Email sent (if the address exists). Please check your inbox.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={requestLoading}
|
||||
className={`w-full flex items-center justify-center rounded-lg px-5 py-3 font-semibold text-white transition-colors ${
|
||||
className={`w-full flex items-center justify-center rounded-xl px-6 py-3 text-base font-semibold transition-all duration-200 transform hover:-translate-y-0.5 border ${
|
||||
requestLoading
|
||||
? 'bg-gray-400 cursor-wait'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-900'
|
||||
? 'border-white/30 bg-white/20 text-slate-300 cursor-wait'
|
||||
: 'border-white/55 bg-white/30 text-[#0F172A] shadow-[0_10px_30px_rgba(15,23,42,0.45)] hover:bg-white/40 hover:shadow-[0_16px_40px_rgba(15,23,42,0.6)] focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80'
|
||||
}`}
|
||||
>
|
||||
{requestLoading ? (
|
||||
@ -187,18 +222,18 @@ export default function PasswordResetPage() {
|
||||
Senden...
|
||||
</>
|
||||
) : (
|
||||
'Zurücksetzlink anfordern'
|
||||
'Request reset link'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Erinnerst du dich?{' '}
|
||||
<div className="text-center text-sm text-gray-700">
|
||||
Remember it now?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/login')}
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
||||
className="text-[#8D6B1D] hover:text-[#7A5E1A] hover:underline font-medium"
|
||||
>
|
||||
Zum Login
|
||||
Back to login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -208,8 +243,8 @@ export default function PasswordResetPage() {
|
||||
<form onSubmit={handleResetSubmit} className="space-y-6">
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2" htmlFor="password">
|
||||
Neues Passwort
|
||||
<label className="block text-sm font-medium text-gray-900 mb-2" htmlFor="password">
|
||||
New password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@ -217,16 +252,16 @@ export default function PasswordResetPage() {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-3 pr-12 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 pr-12 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
||||
placeholder="Your new password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(p => !p)}
|
||||
className="absolute inset-y-0 right-0 px-3 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
className="absolute inset-y-0 right-0 px-3 text-xs font-medium text-indigo-700 hover:underline"
|
||||
>
|
||||
{showPassword ? 'Verbergen' : 'Anzeigen'}
|
||||
{showPassword ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-xs">
|
||||
@ -245,20 +280,20 @@ export default function PasswordResetPage() {
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2" htmlFor="confirm">
|
||||
Passwort bestätigen
|
||||
<label className="block text-sm font-medium text-gray-900 mb-2" htmlFor="confirm">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirm"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={e => { setConfirmPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-3 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="Bestätigung"
|
||||
className="w-full rounded-xl border border-white/40 bg-white/60 px-4 py-3 text-[#0F172A] placeholder-slate-600/80 shadow-sm focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] transition"
|
||||
placeholder="Confirm password"
|
||||
required
|
||||
/>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="mt-2 text-xs text-red-500">Passwörter stimmen nicht überein.</p>
|
||||
<p className="mt-2 text-xs text-red-500">Passwords do not match.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -270,37 +305,37 @@ export default function PasswordResetPage() {
|
||||
)}
|
||||
{resetSuccess && (
|
||||
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
||||
Passwort gespeichert. Weiterleitung zum Login...
|
||||
Password saved. Redirecting to login...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={resetLoading}
|
||||
className={`w-full flex items-center justify-center rounded-lg px-5 py-3 font-semibold text-white transition-colors ${
|
||||
className={`w-full flex items-center justify-center rounded-xl px-6 py-3 text-base font-semibold transition-all duration-200 transform hover:-translate-y-0.5 border ${
|
||||
resetLoading
|
||||
? 'bg-gray-400 cursor-wait'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-gray-900'
|
||||
? 'border-white/30 bg-white/20 text-slate-300 cursor-wait'
|
||||
: 'border-white/55 bg-white/30 text-[#0F172A] shadow-[0_10px_30px_rgba(15,23,42,0.45)] hover:bg-white/40 hover:shadow-[0_16px_40px_rgba(15,23,42,0.6)] focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80'
|
||||
}`}
|
||||
>
|
||||
{resetLoading ? (
|
||||
<>
|
||||
<span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" />
|
||||
Speichern...
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Neues Passwort setzen'
|
||||
'Set new password'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Link abgelaufen?{' '}
|
||||
Link expired?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/password-reset')}
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
||||
>
|
||||
Erneut anfordern
|
||||
Request again
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -308,7 +343,16 @@ export default function PasswordResetPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PasswordResetPage() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<PasswordResetPageInner />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@ -17,74 +17,42 @@ export default function BankInformation({
|
||||
setBankInfo: (v: { accountHolder: string, iban: string }) => void,
|
||||
onEdit?: () => void
|
||||
}) {
|
||||
// editing disabled for now; keep props to avoid refactors
|
||||
const accountHolder = profileData.accountHolder || ''
|
||||
const iban = profileData.iban || ''
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Bank Information</h2>
|
||||
{!editingBank && (
|
||||
<button
|
||||
className="flex items-center text-[#8D6B1D] hover:text-[#7A5E1A] transition-colors"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<svg className="h-4 w-4 mr-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M16.862 3.487a2.1 2.1 0 013.03 2.91l-9.193 9.193a2.1 2.1 0 01-.595.395l-3.03 1.212a.525.525 0 01-.684-.684l1.212-3.03a2.1 2.1 0 01.395-.595l9.193-9.193z"></path></svg>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">Editing disabled</span>
|
||||
</div>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
setBankInfo(bankDraft)
|
||||
setEditingBank(false)
|
||||
}}
|
||||
>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Account Holder</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-900"
|
||||
value={editingBank ? bankDraft.accountHolder : (profileData.accountHolder || '')}
|
||||
onChange={e => setBankDraft({ ...bankDraft, accountHolder: e.target.value })}
|
||||
disabled={!editingBank}
|
||||
placeholder={profileData.accountHolder ? '' : 'Not provided'}
|
||||
className="w-full p-2 border border-white/60 rounded-lg bg-white/50 text-gray-900"
|
||||
value={accountHolder}
|
||||
disabled
|
||||
placeholder="Not provided"
|
||||
/>
|
||||
{!editingBank && !profileData.accountHolder && (
|
||||
<div className="mt-1 text-sm italic text-gray-400">Not provided</div>
|
||||
)}
|
||||
{!accountHolder && <div className="mt-1 text-sm italic text-gray-400">Not provided</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">IBAN</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-900"
|
||||
value={editingBank ? bankDraft.iban : (profileData.iban || '')}
|
||||
onChange={e => setBankDraft({ ...bankDraft, iban: e.target.value })}
|
||||
disabled={!editingBank}
|
||||
placeholder={profileData.iban ? '' : 'Not provided'}
|
||||
className="w-full p-2 border border-white/60 rounded-lg bg-white/50 text-gray-900"
|
||||
value={iban}
|
||||
disabled
|
||||
placeholder="Not provided"
|
||||
/>
|
||||
{!editingBank && !profileData.iban && (
|
||||
<div className="mt-1 text-sm italic text-gray-400">Not provided</div>
|
||||
)}
|
||||
{!iban && <div className="mt-1 text-sm italic text-gray-400">Not provided</div>}
|
||||
</div>
|
||||
{editingBank && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-[#8D6B1D] rounded-lg hover:bg-[#7A5E1A] transition"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition"
|
||||
onClick={() => setEditingBank(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
||||
onEdit?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
|
||||
<button
|
||||
@ -25,7 +25,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First Name
|
||||
</label>
|
||||
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<HighlightIfMissing value={profileData.firstName}>
|
||||
<span className="text-gray-900">{profileData.firstName}</span>
|
||||
@ -36,7 +36,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last Name
|
||||
</label>
|
||||
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<HighlightIfMissing value={profileData.lastName}>
|
||||
<span className="text-gray-900">{profileData.lastName}</span>
|
||||
@ -50,7 +50,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Contact Person
|
||||
</label>
|
||||
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<HighlightIfMissing value={profileData.contactPersonName}>
|
||||
<span className="text-gray-900">{profileData.contactPersonName}</span>
|
||||
@ -62,7 +62,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||
<EnvelopeIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<HighlightIfMissing value={profileData.email}>
|
||||
<span className="text-gray-900">{profileData.email}</span>
|
||||
@ -74,7 +74,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phone Number
|
||||
</label>
|
||||
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||
<PhoneIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<HighlightIfMissing value={profileData.phone}>
|
||||
<span className="text-gray-900">{profileData.phone}</span>
|
||||
@ -85,7 +85,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Address
|
||||
</label>
|
||||
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
||||
<MapPinIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<HighlightIfMissing value={profileData.address}>
|
||||
<span className="text-gray-900">{profileData.address}</span>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
|
||||
export default function EditModal({
|
||||
open,
|
||||
@ -19,16 +19,54 @@ export default function EditModal({
|
||||
onCancel: () => void,
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
// Prevent background scroll when modal is open
|
||||
// Prevent background scroll when modal is open (and avoid leaving a right-gap)
|
||||
const prevStylesRef = useRef<{
|
||||
bodyOverflow: string
|
||||
bodyPaddingRight: string
|
||||
htmlOverflow: string
|
||||
htmlPaddingRight: string
|
||||
}>({
|
||||
bodyOverflow: '',
|
||||
bodyPaddingRight: '',
|
||||
htmlOverflow: '',
|
||||
htmlPaddingRight: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const body = document.body
|
||||
const html = document.documentElement
|
||||
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
prevStylesRef.current = {
|
||||
bodyOverflow: body.style.overflow || '',
|
||||
bodyPaddingRight: body.style.paddingRight || '',
|
||||
htmlOverflow: html.style.overflow || '',
|
||||
htmlPaddingRight: html.style.paddingRight || '',
|
||||
}
|
||||
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
||||
|
||||
// lock scroll (some libs lock html, some lock body)
|
||||
body.style.overflow = 'hidden'
|
||||
html.style.overflow = 'hidden'
|
||||
|
||||
// prevent layout shift + ensure we can restore cleanly
|
||||
const pr = scrollbarWidth > 0 ? `${scrollbarWidth}px` : ''
|
||||
body.style.paddingRight = pr
|
||||
html.style.paddingRight = pr
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
body.style.overflow = prevStylesRef.current.bodyOverflow
|
||||
body.style.paddingRight = prevStylesRef.current.bodyPaddingRight
|
||||
html.style.overflow = prevStylesRef.current.htmlOverflow
|
||||
html.style.paddingRight = prevStylesRef.current.htmlPaddingRight
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
body.style.overflow = prevStylesRef.current.bodyOverflow
|
||||
body.style.paddingRight = prevStylesRef.current.bodyPaddingRight
|
||||
html.style.overflow = prevStylesRef.current.htmlOverflow
|
||||
html.style.paddingRight = prevStylesRef.current.htmlPaddingRight
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Animation state
|
||||
@ -52,9 +90,15 @@ export default function EditModal({
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-lg p-6 w-full max-w-md transform transition-all duration-200 ${
|
||||
className={`rounded-lg shadow-lg p-4 sm:p-6 w-[calc(100%-2rem)] max-w-md max-h-[85dvh] overflow-y-auto transform transition-all duration-200 ${
|
||||
open ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.78)',
|
||||
backdropFilter: 'blur(14px)',
|
||||
WebkitBackdropFilter: 'blur(14px)',
|
||||
border: '1px solid rgba(255,255,255,0.55)',
|
||||
}}
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900">
|
||||
Edit {type === 'basic' ? 'Basic Information' : 'Bank Information'}
|
||||
|
||||
@ -3,7 +3,7 @@ import React from 'react'
|
||||
export default function MediaSection({ documents }: { documents: any[] }) {
|
||||
const hasDocuments = Array.isArray(documents) && documents.length > 0;
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Media & Documents</h2>
|
||||
<div className="overflow-x-auto">
|
||||
{hasDocuments ? (
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
export default function ProfileCompletion({ profileComplete }: { profileComplete: number }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6 mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Profile Completion</h2>
|
||||
<span className="text-sm font-medium text-[#8D6B1D]">
|
||||
|
||||
@ -8,7 +8,7 @@ export default function UserAbo() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
||||
<div className="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-600">
|
||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
||||
Loading subscriptions…
|
||||
</div>
|
||||
</section>
|
||||
@ -19,7 +19,7 @@ export default function UserAbo() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<div className="rounded-md border border-red-200 bg-red-50/80 backdrop-blur-md p-4 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
</section>
|
||||
@ -30,11 +30,11 @@ export default function UserAbo() {
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
||||
{(!abos || abos.length === 0) ? (
|
||||
<div className="rounded-md border border-gray-200 bg-white p-4 text-sm text-gray-600">
|
||||
<div className="rounded-md border border-white/60 bg-white/70 backdrop-blur-md p-4 text-sm text-gray-600 shadow-lg">
|
||||
No subscriptions yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3 sm:gap-4">
|
||||
{abos.map(abo => {
|
||||
const status = (abo.status || 'active') as 'active' | 'paused' | 'canceled'
|
||||
const nextBilling = abo.nextBillingAt ? new Date(abo.nextBillingAt).toLocaleDateString() : '—'
|
||||
@ -53,7 +53,7 @@ export default function UserAbo() {
|
||||
</span>
|
||||
))
|
||||
return (
|
||||
<div key={abo.id} className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div key={abo.id} className="rounded-lg border border-white/60 bg-white/70 backdrop-blur-md p-4 shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{abo.name || 'Coffee Subscription'}</p>
|
||||
|
||||
@ -3,26 +3,18 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import Header from '../components/nav/Header'
|
||||
import Footer from '../components/Footer'
|
||||
import PageLayout from '../components/PageLayout'
|
||||
import Waves from '../components/waves'
|
||||
import ProfileCompletion from './components/profileCompletion'
|
||||
import BasicInformation from './components/basicInformation'
|
||||
import MediaSection from './components/mediaSection'
|
||||
import BankInformation from './components/bankInformation'
|
||||
import EditModal from './components/editModal'
|
||||
import UserAbo from './components/userAbo'
|
||||
import {
|
||||
UserCircleIcon,
|
||||
EnvelopeIcon,
|
||||
PhoneIcon,
|
||||
MapPinIcon,
|
||||
PencilIcon,
|
||||
CheckCircleIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { getProfileCompletion } from './hooks/getProfileCompletion';
|
||||
import { useProfileData } from './hooks/getProfileData';
|
||||
import { useMedia } from './hooks/getMedia';
|
||||
import { editProfileBasic, editProfileBank } from './hooks/editProfile';
|
||||
import { getProfileCompletion } from './hooks/getProfileCompletion'
|
||||
import { useProfileData } from './hooks/getProfileData'
|
||||
import { useMedia } from './hooks/getMedia'
|
||||
import { editProfileBasic } from './hooks/editProfile'
|
||||
|
||||
// Helper to display missing fields in subtle gray italic (no yellow highlight)
|
||||
function HighlightIfMissing({ value, children }: { value: any, children: React.ReactNode }) {
|
||||
@ -60,85 +52,97 @@ const defaultProfileData = {
|
||||
userType: '',
|
||||
};
|
||||
|
||||
// Define fields for EditModal
|
||||
const basicFields = [
|
||||
{ key: 'firstName', label: 'First Name', type: 'text' },
|
||||
{ key: 'lastName', label: 'Last Name', type: 'text' },
|
||||
{ key: 'phone', label: 'Phone', type: 'text' },
|
||||
{ key: 'address', label: 'Address', type: 'text' },
|
||||
];
|
||||
|
||||
const bankFields = [
|
||||
{ key: 'accountHolder', label: 'Account Holder', type: 'text' },
|
||||
{ key: 'iban', label: 'IBAN', type: 'text' },
|
||||
];
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter()
|
||||
const user = useAuthStore(state => state.user)
|
||||
const [userId, setUserId] = React.useState<string | number | undefined>(undefined);
|
||||
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
||||
const [hasHydrated, setHasHydrated] = React.useState(false)
|
||||
const [isMobile, setIsMobile] = React.useState(false)
|
||||
const [userId, setUserId] = React.useState<string | number | undefined>(undefined)
|
||||
|
||||
// Update userId when user changes
|
||||
useEffect(() => {
|
||||
if (user?.id) setUserId(user.id);
|
||||
}, [user]);
|
||||
// --- declare ALL hooks before any early return (Rules of Hooks) ---
|
||||
const [refreshKey, setRefreshKey] = React.useState(0)
|
||||
const [showRefreshing, setShowRefreshing] = React.useState(false)
|
||||
const [completionLoading, setCompletionLoading] = React.useState(false)
|
||||
|
||||
// Add refresh key and UI states for smooth refresh
|
||||
const [refreshKey, setRefreshKey] = React.useState(0);
|
||||
const [showRefreshing, setShowRefreshing] = React.useState(false);
|
||||
const [completionLoading, setCompletionLoading] = React.useState(false);
|
||||
// Progress bar state (MOVED ABOVE EARLY RETURN)
|
||||
const [progressPercent, setProgressPercent] = React.useState<number>(0)
|
||||
const [completedSteps, setCompletedSteps] = React.useState<string[]>([])
|
||||
const [allSteps, setAllSteps] = React.useState<string[]>([])
|
||||
|
||||
// Fetch profile data on page load/navigation, now with refreshKey
|
||||
const { data: profileDataApi, loading: profileLoading, error: profileError } = useProfileData(userId, refreshKey);
|
||||
// Bank/edit state (keep, but bank editing disabled)
|
||||
const [bankInfo, setBankInfo] = React.useState({ accountHolder: '', iban: '' })
|
||||
const [editingBank, setEditingBank] = React.useState(false)
|
||||
const [bankDraft, setBankDraft] = React.useState(bankInfo)
|
||||
|
||||
// Fetch media/documents for user, now with refreshKey
|
||||
const { data: mediaData, loading: mediaLoading, error: mediaError } = useMedia(userId, refreshKey);
|
||||
const [editModalOpen, setEditModalOpen] = React.useState(false)
|
||||
const [editModalType, setEditModalType] = React.useState<'basic' | 'bank'>('basic')
|
||||
const [editModalValues, setEditModalValues] = React.useState<Record<string, string>>({})
|
||||
const [editModalError, setEditModalError] = React.useState<string | null>(null)
|
||||
|
||||
// Redirect if not logged in
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
// Don't render if no user
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||
<p className="text-[#4A4A4A]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Progress bar state
|
||||
const [progressPercent, setProgressPercent] = React.useState<number>(0);
|
||||
const [completedSteps, setCompletedSteps] = React.useState<string[]>([]);
|
||||
const [allSteps, setAllSteps] = React.useState<string[]>([]);
|
||||
useEffect(() => { setHasHydrated(true) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
async function fetchCompletion() {
|
||||
setCompletionLoading(true);
|
||||
const progress = await getProfileCompletion();
|
||||
// progress can be percent or object
|
||||
if (user?.id) setUserId(user.id)
|
||||
}, [user])
|
||||
|
||||
// Fetch hooks can run with undefined userId; they should handle it internally
|
||||
const { data: profileDataApi, loading: profileLoading } = useProfileData(userId, refreshKey)
|
||||
const { data: mediaData, loading: mediaLoading } = useMedia(userId, refreshKey)
|
||||
|
||||
// Redirect only after hydration + auth ready
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || !isAuthReady) return
|
||||
if (!user) router.replace('/login')
|
||||
}, [hasHydrated, isAuthReady, user, router])
|
||||
|
||||
// Completion fetch (gated inside effect)
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || !isAuthReady || !user) return
|
||||
let cancelled = false
|
||||
|
||||
;(async () => {
|
||||
setCompletionLoading(true)
|
||||
const progress = await getProfileCompletion()
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (progress && typeof progress === 'object') {
|
||||
// If not admin-verified, cap progress below 100 to reflect pending verification
|
||||
const pct = progress.progressPercent ?? 0;
|
||||
// Try to read admin verification from profileDataApi if available; otherwise assume false until data loads
|
||||
const isAdminVerified = Boolean(profileDataApi?.userStatus?.is_admin_verified);
|
||||
setProgressPercent(isAdminVerified ? pct : Math.min(pct, 95));
|
||||
setCompletedSteps(progress.completedSteps ?? []);
|
||||
setAllSteps(progress.steps?.map((s: any) => s.name || s.title || '') ?? []);
|
||||
const pct = progress.progressPercent ?? 0
|
||||
const isAdminVerified = Boolean(profileDataApi?.userStatus?.is_admin_verified)
|
||||
setProgressPercent(isAdminVerified ? pct : Math.min(pct, 95))
|
||||
setCompletedSteps(progress.completedSteps ?? [])
|
||||
setAllSteps(progress.steps?.map((s: any) => s.name || s.title || '') ?? [])
|
||||
} else if (typeof progress === 'number') {
|
||||
setProgressPercent(progress);
|
||||
setProgressPercent(progress)
|
||||
}
|
||||
setCompletionLoading(false);
|
||||
}
|
||||
fetchCompletion();
|
||||
}, [user, router, refreshKey]);
|
||||
|
||||
// If admin verification flips to true, ensure progress shows 100%
|
||||
setCompletionLoading(false)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hasHydrated, isAuthReady, user, refreshKey, profileDataApi?.userStatus?.is_admin_verified])
|
||||
|
||||
useEffect(() => {
|
||||
const verified = Boolean(profileDataApi?.userStatus?.is_admin_verified);
|
||||
if (verified) {
|
||||
setProgressPercent(prev => (prev < 100 ? 100 : prev));
|
||||
}
|
||||
}, [profileDataApi?.userStatus?.is_admin_verified]);
|
||||
const verified = Boolean(profileDataApi?.userStatus?.is_admin_verified)
|
||||
if (verified) setProgressPercent(prev => (prev < 100 ? 100 : prev))
|
||||
}, [profileDataApi?.userStatus?.is_admin_verified])
|
||||
|
||||
// Use API profile data if available, fallback to mock
|
||||
const profileData = React.useMemo(() => {
|
||||
if (!profileDataApi) {
|
||||
return {
|
||||
@ -150,13 +154,13 @@ export default function ProfilePage() {
|
||||
joinDate: 'Oktober 2024',
|
||||
memberStatus: 'Gold Member',
|
||||
profileComplete: progressPercent,
|
||||
accountHolder: '', // Always empty string if not provided
|
||||
accountHolder: '',
|
||||
iban: '',
|
||||
contactPersonName: '',
|
||||
userType: user?.userType || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
const { user: apiUser = {}, profile: apiProfile = {}, userStatus = {} } = profileDataApi;
|
||||
const { user: apiUser = {}, profile: apiProfile = {}, userStatus = {} } = profileDataApi
|
||||
return {
|
||||
firstName: apiUser.firstName ?? apiProfile.first_name ?? '',
|
||||
lastName: apiUser.lastName ?? apiProfile.last_name ?? '',
|
||||
@ -168,305 +172,224 @@ export default function ProfilePage() {
|
||||
: '',
|
||||
memberStatus: userStatus.status ?? '',
|
||||
profileComplete: progressPercent,
|
||||
accountHolder: apiProfile.account_holder_name ?? '', // Only use account_holder_name
|
||||
accountHolder: apiProfile.account_holder_name ?? '',
|
||||
iban: apiUser.iban ?? '',
|
||||
contactPersonName: apiProfile.contact_person_name ?? '',
|
||||
userType: apiUser.userType ?? '',
|
||||
};
|
||||
}, [profileDataApi, user, progressPercent]);
|
||||
|
||||
// Dummy data for new sections
|
||||
const documents = Array.isArray(mediaData?.documents) ? mediaData.documents : [];
|
||||
|
||||
// Adjusted bankInfo state to only have accountHolder and iban, always strings
|
||||
const [bankInfo, setBankInfo] = React.useState({
|
||||
accountHolder: '',
|
||||
iban: '',
|
||||
});
|
||||
const [editingBank, setEditingBank] = React.useState(false);
|
||||
const [bankDraft, setBankDraft] = React.useState(bankInfo)
|
||||
|
||||
// Modal state
|
||||
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
||||
const [editModalType, setEditModalType] = React.useState<'basic' | 'bank'>('basic');
|
||||
const [editModalValues, setEditModalValues] = React.useState<Record<string, string>>({});
|
||||
|
||||
// Modal error state
|
||||
const [editModalError, setEditModalError] = React.useState<string | null>(null);
|
||||
|
||||
// Modal field definitions
|
||||
const basicFields = [
|
||||
{ key: 'firstName', label: 'First Name' },
|
||||
{ key: 'lastName', label: 'Last Name' },
|
||||
{ key: 'email', label: 'Email Address', type: 'email' },
|
||||
{ key: 'phone', label: 'Phone Number' },
|
||||
{ key: 'address', label: 'Address' },
|
||||
];
|
||||
const bankFields = [
|
||||
{ key: 'accountHolder', label: 'Account Holder' },
|
||||
{ key: 'iban', label: 'IBAN' },
|
||||
];
|
||||
|
||||
// Modal open handlers
|
||||
function openEditModal(type: 'basic' | 'bank', values: Record<string, string>) {
|
||||
setEditModalType(type);
|
||||
setEditModalValues(values);
|
||||
setEditModalOpen(true);
|
||||
}
|
||||
|
||||
// Modal save handler (calls API)
|
||||
async function handleEditModalSave() {
|
||||
setEditModalError(null);
|
||||
if (editModalType === 'basic') {
|
||||
const payload: Partial<typeof defaultProfileData> = {};
|
||||
(['firstName', 'lastName', 'email', 'phone', 'address'] as const).forEach(key => {
|
||||
if (editModalValues[key] !== getProfileField(profileData, key)) {
|
||||
payload[key] = editModalValues[key]?.trim();
|
||||
}
|
||||
});
|
||||
const res = await editProfileBasic(payload);
|
||||
if (res.success) {
|
||||
setEditModalOpen(false);
|
||||
// Start smooth refresh with overlay spinner
|
||||
setShowRefreshing(true);
|
||||
setRefreshKey(k => k + 1);
|
||||
} else if (res.status === 409) {
|
||||
setEditModalError('Email already in use.');
|
||||
} else if (res.status === 401) {
|
||||
router.push('/login');
|
||||
} else {
|
||||
setEditModalError(res.error || 'Failed to update profile.');
|
||||
}
|
||||
} else {
|
||||
const payload: Partial<typeof defaultProfileData> = {};
|
||||
(['accountHolder', 'iban'] as const).forEach(key => {
|
||||
if (editModalValues[key] !== getProfileField(profileData, key)) {
|
||||
payload[key] = editModalValues[key]?.trim();
|
||||
}
|
||||
});
|
||||
const res = await editProfileBank(payload);
|
||||
if (res.success) {
|
||||
setBankInfo({
|
||||
accountHolder: res.data?.profile?.account_holder_name ?? '',
|
||||
iban: res.data?.user?.iban ?? '',
|
||||
});
|
||||
setEditModalOpen(false);
|
||||
// Start smooth refresh with overlay spinner
|
||||
setShowRefreshing(true);
|
||||
setRefreshKey(k => k + 1);
|
||||
} else if (res.status === 400 && res.error?.toLowerCase().includes('iban')) {
|
||||
setEditModalError('Invalid IBAN.');
|
||||
} else if (res.status === 401) {
|
||||
router.push('/login');
|
||||
} else {
|
||||
setEditModalError(res.error || 'Failed to update bank info.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [profileDataApi, user, progressPercent])
|
||||
|
||||
// Modal change handler
|
||||
function handleEditModalChange(key: string, value: string) {
|
||||
setEditModalValues(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
const documents = Array.isArray(mediaData?.documents) ? mediaData.documents : []
|
||||
|
||||
// Hide overlay when all data re-fetches complete
|
||||
useEffect(() => {
|
||||
if (showRefreshing && !profileLoading && !mediaLoading && !completionLoading) {
|
||||
const t = setTimeout(() => setShowRefreshing(false), 200); // small delay for smoothness
|
||||
return () => clearTimeout(t);
|
||||
const t = setTimeout(() => setShowRefreshing(false), 200)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [showRefreshing, profileLoading, mediaLoading, completionLoading]);
|
||||
}, [showRefreshing, profileLoading, mediaLoading, completionLoading])
|
||||
|
||||
const loadingUser = !user;
|
||||
function openEditModal(type: 'basic' | 'bank', values: Record<string, string>) {
|
||||
setEditModalType(type)
|
||||
setEditModalValues(values)
|
||||
setEditModalOpen(true)
|
||||
}
|
||||
|
||||
async function handleEditModalSave() {
|
||||
setEditModalError(null)
|
||||
if (editModalType === 'basic') {
|
||||
const payload: Partial<typeof defaultProfileData> = {}
|
||||
;(['firstName', 'lastName', 'phone', 'address'] as const).forEach(key => {
|
||||
if (editModalValues[key] !== getProfileField(profileData, key)) {
|
||||
payload[key] = editModalValues[key]?.trim()
|
||||
}
|
||||
})
|
||||
const res = await editProfileBasic(payload)
|
||||
if (res.success) {
|
||||
setEditModalOpen(false)
|
||||
setShowRefreshing(true)
|
||||
setRefreshKey(k => k + 1)
|
||||
} else if (res.status === 401) {
|
||||
router.push('/login')
|
||||
} else {
|
||||
setEditModalError(res.error || 'Failed to update profile.')
|
||||
}
|
||||
} else {
|
||||
setEditModalError('Bank information editing is disabled for now.')
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditModalChange(key: string, value: string) {
|
||||
setEditModalValues(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 768px)')
|
||||
const apply = () => setIsMobile(mq.matches)
|
||||
apply()
|
||||
mq.addEventListener?.('change', apply)
|
||||
window.addEventListener('resize', apply, { passive: true })
|
||||
return () => {
|
||||
mq.removeEventListener?.('change', apply)
|
||||
window.removeEventListener('resize', apply)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// --- EARLY RETURN AFTER ALL HOOKS ---
|
||||
if (!hasHydrated || !isAuthReady || !user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||
<p className="text-[#4A4A4A]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-50" suppressHydrationWarning>
|
||||
<Header />
|
||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{loadingUser && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||
<p className="text-[#4A4A4A]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loadingUser && (
|
||||
<>
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Profile Settings</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Manage your account information and preferences
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full min-h-screen overflow-x-hidden">
|
||||
<Waves
|
||||
className="pointer-events-none"
|
||||
lineColor="#0f172a"
|
||||
backgroundColor="rgba(245, 245, 240, 1)"
|
||||
waveSpeedX={0.02}
|
||||
waveSpeedY={0.01}
|
||||
waveAmpX={40}
|
||||
waveAmpY={20}
|
||||
friction={0.9}
|
||||
tension={0.01}
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
animate={!isMobile}
|
||||
interactive={!isMobile}
|
||||
/>
|
||||
|
||||
{/* Pending admin verification notice (above progress) */}
|
||||
{profileDataApi?.userStatus && profileDataApi.userStatus.is_admin_verified === 0 && (
|
||||
<div className="rounded-md bg-yellow-50 border border-yellow-200 p-3 text-sm text-yellow-800 mb-2">
|
||||
Your account is fully submitted. Our team will verify your account shortly.
|
||||
</div>
|
||||
)}
|
||||
{/* Profile Completion Progress Bar */}
|
||||
<ProfileCompletion
|
||||
profileComplete={profileData.profileComplete}
|
||||
/>
|
||||
<div className="relative z-10 min-h-screen">
|
||||
<PageLayout className="bg-transparent text-gray-900">
|
||||
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* MASTER GLASS PANEL (prevents non-translucent gaps between cards) */}
|
||||
<div className="rounded-3xl bg-white/60 backdrop-blur-md border border-white/50 shadow-xl p-4 sm:p-6 lg:p-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Profile Settings</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Manage your account information and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Basic Info + Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
||||
{/* Basic Information */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<BasicInformation
|
||||
profileData={profileData}
|
||||
HighlightIfMissing={HighlightIfMissing}
|
||||
// Add edit button handler
|
||||
onEdit={() => openEditModal('basic', {
|
||||
firstName: profileData.firstName,
|
||||
lastName: profileData.lastName,
|
||||
email: profileData.email,
|
||||
phone: profileData.phone,
|
||||
address: profileData.address,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{/* Sidebar: Account Status + Quick Actions */}
|
||||
<div className="space-y-6">
|
||||
{/* Account Status */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Account Status</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Member Since</span>
|
||||
<span className="text-sm font-medium text-gray-900">{profileData.joinDate}</span>
|
||||
{/* Pending admin verification notice (above progress) */}
|
||||
{profileDataApi?.userStatus && profileDataApi.userStatus.is_admin_verified === 0 && (
|
||||
<div className="rounded-md bg-yellow-50/80 backdrop-blur border border-yellow-200 p-3 text-sm text-yellow-800 mb-2">
|
||||
Your account is fully submitted. Our team will verify your account shortly.
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Status</span>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gradient-to-r from-[#8D6B1D] to-[#C49225] text-white">
|
||||
{profileData.memberStatus}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ProfileCompletion profileComplete={profileData.profileComplete} />
|
||||
|
||||
{/* Basic Info + Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 sm:gap-8 mb-8">
|
||||
{/* Basic Information */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<BasicInformation
|
||||
profileData={profileData}
|
||||
HighlightIfMissing={HighlightIfMissing}
|
||||
// Add edit button handler
|
||||
onEdit={() => openEditModal('basic', {
|
||||
firstName: profileData.firstName,
|
||||
lastName: profileData.lastName,
|
||||
phone: profileData.phone,
|
||||
address: profileData.address,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Profile</span>
|
||||
<span className="text-sm font-medium text-green-600">Verified</span>
|
||||
{/* Sidebar: Account Status + Quick Actions */}
|
||||
<div className="space-y-6">
|
||||
{/* Account Status (make translucent) */}
|
||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Account Status</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Member Since</span>
|
||||
<span className="text-sm font-medium text-gray-900">{profileData.joinDate}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Status</span>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gradient-to-r from-[#8D6B1D] to-[#C49225] text-white">
|
||||
{profileData.memberStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Profile</span>
|
||||
<span className="text-sm font-medium text-green-600">Verified</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick Actions (make translucent) */}
|
||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Quick Actions</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
<button className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
Download Account Data
|
||||
</button>
|
||||
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Quick Actions</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
<button className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
Download Account Data
|
||||
</button>
|
||||
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
Delete Account
|
||||
</button>
|
||||
|
||||
{/* Bank Info, Media */}
|
||||
<div className="space-y-6 sm:space-y-8 mb-8">
|
||||
{/* --- My Abo Section (above bank info) --- */}
|
||||
<UserAbo />
|
||||
{/* --- Edit Bank Information Section --- */}
|
||||
<BankInformation
|
||||
profileData={profileData}
|
||||
editingBank={false} // force read-only
|
||||
bankDraft={bankDraft}
|
||||
setEditingBank={setEditingBank}
|
||||
setBankDraft={setBankDraft}
|
||||
setBankInfo={setBankInfo}
|
||||
// onEdit disabled for now
|
||||
// onEdit={() => openEditModal('bank', { ... })}
|
||||
/>
|
||||
{/* --- Media Section --- */}
|
||||
<MediaSection documents={documents} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Bank Info, Media */}
|
||||
<div className="space-y-8 mb-8">
|
||||
{/* --- My Abo Section (above bank info) --- */}
|
||||
<UserAbo />
|
||||
{/* --- Edit Bank Information Section --- */}
|
||||
<BankInformation
|
||||
profileData={profileData}
|
||||
editingBank={editingBank}
|
||||
bankDraft={bankDraft}
|
||||
setEditingBank={setEditingBank}
|
||||
setBankDraft={setBankDraft}
|
||||
setBankInfo={setBankInfo}
|
||||
// Add edit button handler
|
||||
onEdit={() => openEditModal('bank', {
|
||||
accountHolder: profileData.accountHolder,
|
||||
iban: profileData.iban,
|
||||
})}
|
||||
/>
|
||||
{/* --- Media Section --- */}
|
||||
<MediaSection documents={documents} />
|
||||
</div>
|
||||
|
||||
{/* Account Settings */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Account Settings</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Email Notifications</p>
|
||||
<p className="text-sm text-gray-600">Receive updates about orders and promotions</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#8D6B1D]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#8D6B1D]"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">SMS Notifications</p>
|
||||
<p className="text-sm text-gray-600">Get text messages for important updates</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" />
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#8D6B1D]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#8D6B1D]"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Two-Factor Authentication</p>
|
||||
<p className="text-sm text-gray-600">Add extra security to your account</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 text-sm font-medium text-[#8D6B1D] border border-[#8D6B1D] rounded-lg hover:bg-[#8D6B1D]/10 transition-colors">
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
{/* Global refreshing overlay */}
|
||||
{showRefreshing && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/60 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-[#8D6B1D]/30 border-t-[#8D6B1D] mb-3"></div>
|
||||
<p className="text-sm text-gray-700">Updating...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
<EditModal
|
||||
open={editModalOpen}
|
||||
type={editModalType}
|
||||
fields={editModalType === 'basic' ? basicFields : bankFields}
|
||||
values={editModalValues}
|
||||
onChange={handleEditModalChange}
|
||||
onSave={handleEditModalSave}
|
||||
onCancel={() => { setEditModalOpen(false); setEditModalError(null); }}
|
||||
>
|
||||
{/* Show error message if present */}
|
||||
{editModalError && (
|
||||
<div className="text-sm text-red-600 mb-2">{editModalError}</div>
|
||||
)}
|
||||
</EditModal>
|
||||
{/* Edit Modal */}
|
||||
<EditModal
|
||||
open={editModalOpen}
|
||||
type={editModalType}
|
||||
fields={editModalType === 'basic' ? basicFields : bankFields}
|
||||
values={editModalValues}
|
||||
onChange={handleEditModalChange}
|
||||
onSave={handleEditModalSave}
|
||||
onCancel={() => { setEditModalOpen(false); setEditModalError(null); }}
|
||||
>
|
||||
{/* Show error message if present */}
|
||||
{editModalError && (
|
||||
<div className="text-sm text-red-600 mb-2">{editModalError}</div>
|
||||
)}
|
||||
</EditModal>
|
||||
</PageLayout>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -142,17 +142,16 @@ export default function RegisterForm({
|
||||
}
|
||||
|
||||
const phoneApi = personalPhoneRef.current
|
||||
const dialCode = phoneApi?.getDialCode?.()
|
||||
const intlNumber = phoneApi?.getNumber() || ''
|
||||
const valid = phoneApi?.isValid() ?? false
|
||||
|
||||
console.log('[RegisterForm] validatePersonalForm phone check', {
|
||||
rawState: personalForm.phoneNumber,
|
||||
intlFromApi: intlNumber,
|
||||
isValidFromApi: valid,
|
||||
})
|
||||
|
||||
if (!dialCode) {
|
||||
setError('Please select a country code from the dropdown before continuing.')
|
||||
return false
|
||||
}
|
||||
if (!intlNumber) {
|
||||
setError('Please enter your phone number including country code.')
|
||||
setError('Please enter your phone number.')
|
||||
return false
|
||||
}
|
||||
if (!valid) {
|
||||
@ -191,22 +190,20 @@ export default function RegisterForm({
|
||||
const companyApi = companyPhoneRef.current
|
||||
const contactApi = contactPhoneRef.current
|
||||
|
||||
const companyDialCode = companyApi?.getDialCode?.()
|
||||
const contactDialCode = contactApi?.getDialCode?.()
|
||||
|
||||
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 (!companyDialCode || !contactDialCode) {
|
||||
setError('Please select country codes (dropdown) for both company and contact phone numbers.')
|
||||
return false
|
||||
}
|
||||
if (!companyNumber || !contactNumber) {
|
||||
setError('Please enter both company and contact phone numbers including country codes.')
|
||||
setError('Please enter both company and contact phone numbers.')
|
||||
return false
|
||||
}
|
||||
if (!companyValid || !contactValid) {
|
||||
@ -394,7 +391,8 @@ export default function RegisterForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto bg-white rounded-2xl shadow-2xl px-6 py-8 sm:px-12 sm:py-10">
|
||||
// softened outer container, no own solid white card – parent provides glass card
|
||||
<div className="w-full">
|
||||
{/* Header */}
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-2">
|
||||
@ -409,7 +407,7 @@ export default function RegisterForm({
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="bg-gray-100 p-1 rounded-lg">
|
||||
<div className="bg-white/40 backdrop-blur-[18px] border border-white/35 shadow-sm p-1 rounded-lg">
|
||||
<button
|
||||
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||
mode === 'personal'
|
||||
@ -437,7 +435,7 @@ export default function RegisterForm({
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="mb-6 p-4 bg-red-50/70 backdrop-blur-[18px] border border-red-200/70 rounded-lg">
|
||||
<p className="text-red-600 text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
@ -457,7 +455,7 @@ export default function RegisterForm({
|
||||
name="firstName"
|
||||
value={personalForm.firstName}
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -472,7 +470,7 @@ export default function RegisterForm({
|
||||
name="lastName"
|
||||
value={personalForm.lastName}
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -489,7 +487,7 @@ export default function RegisterForm({
|
||||
name="email"
|
||||
value={personalForm.email}
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -504,7 +502,7 @@ export default function RegisterForm({
|
||||
name="confirmEmail"
|
||||
value={personalForm.confirmEmail}
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -518,7 +516,8 @@ export default function RegisterForm({
|
||||
id="phoneNumber"
|
||||
name="phoneNumber"
|
||||
ref={personalPhoneRef}
|
||||
placeholder="+49 123 456 7890"
|
||||
autoComplete="tel"
|
||||
placeholder="e.g. +43 676 1234567"
|
||||
required
|
||||
onChange={e =>
|
||||
setPersonalForm(prev => ({ ...prev, phoneNumber: (e.target as HTMLInputElement).value }))
|
||||
@ -538,7 +537,8 @@ export default function RegisterForm({
|
||||
name="password"
|
||||
value={personalForm.password}
|
||||
onChange={handlePersonalChange}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
@ -566,7 +566,8 @@ export default function RegisterForm({
|
||||
name="confirmPassword"
|
||||
value={personalForm.confirmPassword}
|
||||
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"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -604,7 +605,7 @@ export default function RegisterForm({
|
||||
name="companyName"
|
||||
value={companyForm.companyName}
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -619,7 +620,7 @@ export default function RegisterForm({
|
||||
name="contactPersonName"
|
||||
value={companyForm.contactPersonName}
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -636,7 +637,7 @@ export default function RegisterForm({
|
||||
name="companyEmail"
|
||||
value={companyForm.companyEmail}
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -651,7 +652,7 @@ export default function RegisterForm({
|
||||
name="confirmCompanyEmail"
|
||||
value={companyForm.confirmCompanyEmail}
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -666,7 +667,8 @@ export default function RegisterForm({
|
||||
id="companyPhone"
|
||||
name="companyPhone"
|
||||
ref={companyPhoneRef}
|
||||
placeholder="+49 123 456 7890"
|
||||
autoComplete="tel"
|
||||
placeholder="e.g. +43 1 234567"
|
||||
required
|
||||
onChange={e =>
|
||||
setCompanyForm(prev => ({ ...prev, companyPhone: (e.target as HTMLInputElement).value }))
|
||||
@ -682,7 +684,8 @@ export default function RegisterForm({
|
||||
id="contactPersonPhone"
|
||||
name="contactPersonPhone"
|
||||
ref={contactPhoneRef}
|
||||
placeholder="+49 123 456 7890"
|
||||
autoComplete="tel"
|
||||
placeholder="e.g. +43 676 1234567"
|
||||
required
|
||||
onChange={e =>
|
||||
setCompanyForm(prev => ({
|
||||
@ -706,7 +709,8 @@ export default function RegisterForm({
|
||||
name="password"
|
||||
value={companyForm.password}
|
||||
onChange={handleCompanyChange}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
@ -734,7 +738,8 @@ export default function RegisterForm({
|
||||
name="confirmPassword"
|
||||
value={companyForm.confirmPassword}
|
||||
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"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -17,38 +17,39 @@ export default function SessionDetectedModal({
|
||||
onCancel,
|
||||
inline = false
|
||||
}: SessionDetectedModalProps) {
|
||||
// Make inline + non-inline consistent
|
||||
if (!open) return null
|
||||
|
||||
if (inline) {
|
||||
// Inline wrapper removed: parent already wraps/centers
|
||||
return (
|
||||
// removed flex-1 and min-h to avoid extra white gap
|
||||
<div className="w-full flex justify-center items-center py-8">
|
||||
<div className="bg-white px-6 py-6 rounded-xl shadow-xl max-w-lg w-full border border-gray-200">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-orange-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold leading-6 text-[#0F172A]">
|
||||
Active session detected
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[#4A4A4A]">
|
||||
You are already logged in. To register, you must first log out or you can go to the dashboard.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col sm:flex-row gap-3 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="inline-flex justify-center rounded-md bg-white px-4 py-2 text-sm font-medium text-[#4A4A4A] shadow-sm ring-1 ring-gray-300 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Go to dashboard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="inline-flex justify-center rounded-md bg-[#8D6B1D] px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] transition-colors"
|
||||
>
|
||||
Log out and register
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-w-lg w-full rounded-2xl border border-amber-200/70 bg-white/70 backdrop-blur-xl shadow-2xl px-6 py-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100/80">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-orange-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold leading-6 text-[#0F172A]">
|
||||
Active session detected
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[#4A4A4A]">
|
||||
You are already logged in. To register, you must first log out or you can go to the dashboard.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col sm:flex-row gap-3 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="inline-flex justify-center rounded-md bg-white/80 px-4 py-2 text-sm font-medium text-[#4A4A4A] shadow-sm ring-1 ring-gray-300/70 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Go to dashboard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="inline-flex justify-center rounded-md bg-[#8D6B1D] px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] transition-colors"
|
||||
>
|
||||
Log out and register
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -82,7 +83,9 @@ export default function SessionDetectedModal({
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<Dialog.Panel
|
||||
className="relative transform overflow-hidden rounded-2xl border border-white/30 bg-white/70 backdrop-blur-xl px-4 pb-4 pt-5 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6"
|
||||
>
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ExclamationTriangleIcon
|
||||
|
||||
@ -21,30 +21,32 @@ export default function InvalidRefLinkModal({
|
||||
if (!open) return null
|
||||
|
||||
const Content = (
|
||||
<div className="w-full max-w-md rounded-lg border border-red-200 bg-white p-6 shadow-md">
|
||||
<div className="w-full max-w-md rounded-2xl border border-red-300/70 bg-white/60 backdrop-blur-xl shadow-2xl p-6 sm:p-7">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-600 shrink-0" />
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-red-100/80">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Invalid invitation link</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
<h3 className="text-lg font-semibold text-[#0F172A]">Invalid invitation link</h3>
|
||||
<p className="mt-1 text-sm text-slate-700">
|
||||
This registration link is invalid or no longer active. Please request a new link.
|
||||
</p>
|
||||
{token ? (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{token && (
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Token: <span className="font-mono break-all">{token}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={onGoHome}
|
||||
className="inline-flex items-center rounded-md bg-[#8D6B1D] px-3 py-2 text-sm text-white hover:bg-[#7A5E1A]"
|
||||
className="inline-flex items-center rounded-md bg-[#8D6B1D] px-3.5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] transition-colors"
|
||||
>
|
||||
Go to homepage
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 hover:bg-gray-50"
|
||||
className="inline-flex items-center rounded-md border border-slate-300/80 bg-white/70 px-3.5 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50/90 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@ -55,13 +57,7 @@ export default function InvalidRefLinkModal({
|
||||
</div>
|
||||
)
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div className="w-full flex items-center justify-center py-16">
|
||||
{Content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (inline) return Content
|
||||
|
||||
return Content
|
||||
}
|
||||
@ -1,20 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import RegisterForm from './components/RegisterForm'
|
||||
import PageLayout from '../components/PageLayout'
|
||||
import SessionDetectedModal from './components/SessionDetectedModal'
|
||||
import InvalidRefLinkModal from './components/invalidRefLinkModal'
|
||||
import { ToastProvider } from '../components/toast/toastComponent'
|
||||
import { ToastProvider, useToast } from '../components/toast/toastComponent'
|
||||
import Waves from '../components/waves'
|
||||
|
||||
export default function RegisterPage() {
|
||||
// NEW: inner component that actually uses useToast and all the logic
|
||||
function RegisterPageInner() {
|
||||
const searchParams = useSearchParams()
|
||||
const refToken = searchParams.get('ref')
|
||||
const [registered, setRegistered] = useState(false)
|
||||
const [mode, setMode] = useState<'personal' | 'company'>('personal')
|
||||
const router = useRouter()
|
||||
const { showToast } = useToast()
|
||||
|
||||
// Auth state
|
||||
const user = useAuthStore(state => state.user)
|
||||
@ -24,7 +27,7 @@ export default function RegisterPage() {
|
||||
const [showSessionModal, setShowSessionModal] = useState(false)
|
||||
const [sessionCleared, setSessionCleared] = useState(false)
|
||||
|
||||
// NEW: Referral validation state
|
||||
// Referral validation state
|
||||
const [isRefChecked, setIsRefChecked] = useState(false)
|
||||
const [invalidRef, setInvalidRef] = useState(false)
|
||||
const [refInfo, setRefInfo] = useState<{
|
||||
@ -34,42 +37,43 @@ export default function RegisterPage() {
|
||||
usesRemaining?: number
|
||||
} | null>(null)
|
||||
|
||||
// Redirect to login after simulated registration
|
||||
// Redirect after registration
|
||||
useEffect(() => {
|
||||
if (registered) {
|
||||
const t = setTimeout(() => router.push('/login'), 4000) // was 1200
|
||||
const t = setTimeout(() => router.push('/login'), 4000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [registered, router])
|
||||
|
||||
// NEW: Validate referral token (must exist and be valid)
|
||||
// Validate referral token
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const validateRef = async () => {
|
||||
if (!refToken) {
|
||||
console.warn('⚠️ Register: Missing ?ref token in URL')
|
||||
if (!cancelled) {
|
||||
setInvalidRef(true)
|
||||
setIsRefChecked(true)
|
||||
}
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Invitation error',
|
||||
message: 'No invitation token found in the link.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||
const url = `${base}/api/referral/info/${encodeURIComponent(refToken)}`
|
||||
console.log('🌐 Register: fetching referral info:', url)
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET', credentials: 'include' })
|
||||
console.log('📡 Register: referral info status:', res.status)
|
||||
const body = await res.json().catch(() => null)
|
||||
console.log('📦 Register: referral info body:', body)
|
||||
|
||||
const success = !!body?.success
|
||||
const isUnlimited = !!body?.isUnlimited
|
||||
const usesRemaining = typeof body?.usesRemaining === 'number' ? body.usesRemaining : 0
|
||||
|
||||
const usesRemaining =
|
||||
typeof body?.usesRemaining === 'number' ? body.usesRemaining : 0
|
||||
const isActive = success && (isUnlimited || usesRemaining > 0)
|
||||
|
||||
if (!cancelled) {
|
||||
@ -81,28 +85,46 @@ export default function RegisterPage() {
|
||||
usesRemaining
|
||||
})
|
||||
setInvalidRef(false)
|
||||
showToast({
|
||||
variant: 'success',
|
||||
title: 'Invitation verified',
|
||||
message: 'Your invitation link is valid. You can register now.'
|
||||
})
|
||||
} else {
|
||||
console.warn('⛔ Register: referral not active/invalid')
|
||||
setInvalidRef(true)
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Invalid invitation',
|
||||
message: 'This invitation link is invalid or no longer active.'
|
||||
})
|
||||
}
|
||||
setIsRefChecked(true)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Register: referral info fetch error:', e)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setInvalidRef(true)
|
||||
setIsRefChecked(true)
|
||||
}
|
||||
showToast({
|
||||
variant: 'error',
|
||||
title: 'Network error',
|
||||
message: 'Could not validate the invitation link. Please try again.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
validateRef()
|
||||
return () => { cancelled = true }
|
||||
}, [refToken])
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// showToast intentionally omitted to avoid effect re-run loops (provider value can change)
|
||||
}, [refToken]) // note: showToast intentionally omitted to avoid effect re-run loops
|
||||
|
||||
// Detect existing logged-in session (only if ref is valid)
|
||||
// Detect existing logged-in session
|
||||
useEffect(() => {
|
||||
if (isRefChecked && !invalidRef && user && !sessionCleared) setShowSessionModal(true)
|
||||
if (isRefChecked && !invalidRef && user && !sessionCleared) {
|
||||
setShowSessionModal(true)
|
||||
}
|
||||
}, [isRefChecked, invalidRef, user, sessionCleared])
|
||||
|
||||
const handleLogout = async () => {
|
||||
@ -116,79 +138,110 @@ export default function RegisterPage() {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
// NEW: Gate rendering until referral check is done
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 768px)')
|
||||
const apply = () => setIsMobile(mq.matches)
|
||||
apply()
|
||||
mq.addEventListener?.('change', apply)
|
||||
window.addEventListener('resize', apply, { passive: true })
|
||||
return () => {
|
||||
mq.removeEventListener?.('change', apply)
|
||||
window.removeEventListener('resize', apply)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const mainStyle: CSSProperties = {
|
||||
paddingTop: isMobile
|
||||
? 'calc(var(--pp-header-spacer, 0px) + clamp(1.25rem, 3.5vh, 2.25rem))'
|
||||
: 'calc(var(--pp-header-spacer, 0px) + clamp(5rem, 8vh, 7rem))',
|
||||
transition: 'padding-top 260ms ease, opacity 260ms ease',
|
||||
willChange: 'padding-top, opacity',
|
||||
opacity: 'var(--pp-page-shift-opacity, 1)',
|
||||
}
|
||||
|
||||
// --- Render branches (unchanged except classNames) ---
|
||||
|
||||
if (!isRefChecked) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<PageLayout>
|
||||
<main className="w-full flex flex-col flex-1 items-center justify-center py-24 min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||
<p className="text-slate-700">Checking invitation link…</p>
|
||||
<PageLayout>
|
||||
<div className="relative w-full flex flex-col min-h-screen overflow-hidden" style={{ backgroundImage: 'none', background: 'none' }}>
|
||||
<Waves
|
||||
className="pointer-events-none"
|
||||
lineColor="#0f172a"
|
||||
backgroundColor="rgba(245, 245, 240, 1)"
|
||||
waveSpeedX={0.02}
|
||||
waveSpeedY={0.01}
|
||||
waveAmpX={40}
|
||||
waveAmpY={20}
|
||||
friction={0.9}
|
||||
tension={0.01}
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
/>
|
||||
<main
|
||||
className="relative z-10 flex flex-col flex-1 items-center justify-start sm:justify-center pb-10 sm:pb-20"
|
||||
style={mainStyle}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex flex-col w-full">
|
||||
<div
|
||||
className="mx-auto w-full max-w-md rounded-3xl shadow-2xl relative border border-white/35 overflow-hidden p-6 sm:p-8"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.55)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
WebkitBackdropFilter: 'blur(18px)',
|
||||
boxShadow: '0 18px 45px rgba(15,23,42,0.45)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
||||
<div className="relative text-center">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-2 border-b-transparent border-[#8D6B1D] mx-auto mb-4" />
|
||||
<p className="text-slate-700">Checking invitation link…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageLayout>
|
||||
</ToastProvider>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// NEW: Invalid referral link state — show modal instead of form with same background as register form
|
||||
if (invalidRef) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<PageLayout>
|
||||
<main className="w-full flex flex-col flex-1 gap-10 min-h-screen">
|
||||
{/* make wrapper flex-1 so background reaches the footer */}
|
||||
<div className="relative flex-1 overflow-hidden pt-16 sm:pt-20 pb-20 sm:pb-24">
|
||||
{/* Pattern */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-10 h-full w-full stroke-white/10"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="register-pattern"
|
||||
x="50%"
|
||||
y={-1}
|
||||
width={200}
|
||||
height={200}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path
|
||||
d="M.5 200V.5H200"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.05)"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
fill="url(#register-pattern)"
|
||||
width="100%"
|
||||
height="100%"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Colored blur */}
|
||||
<PageLayout>
|
||||
<div className="relative w-full flex flex-col min-h-screen overflow-hidden" style={{ backgroundImage: 'none', background: 'none' }}>
|
||||
<Waves
|
||||
className="pointer-events-none"
|
||||
lineColor="#0f172a"
|
||||
backgroundColor="rgba(245, 245, 240, 1)"
|
||||
waveSpeedX={0.02}
|
||||
waveSpeedY={0.01}
|
||||
waveAmpX={40}
|
||||
waveAmpY={20}
|
||||
friction={0.9}
|
||||
tension={0.01}
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
/>
|
||||
<main
|
||||
className="relative z-10 flex flex-col flex-1 items-center justify-start sm:justify-center pb-10 sm:pb-20"
|
||||
style={mainStyle}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex flex-col w-full">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 right-0 left-1/2 -z-10 -ml-24 transform-gpu overflow-hidden blur-3xl lg:ml-24 xl:ml-48"
|
||||
className="mx-auto w-full max-w-3xl min-h-[520px] sm:min-h-[560px] rounded-3xl shadow-2xl relative border border-white/35 overflow-hidden p-6 sm:p-10 md:py-12 md:px-14"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.55)', // Use a translucent white for glass effect
|
||||
backdropFilter: 'blur(18px)', // Glass blur
|
||||
WebkitBackdropFilter: 'blur(18px)',
|
||||
boxShadow: '0 18px 45px rgba(15,23,42,0.45)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(63.1% 29.5%, 100% 17.1%, 76.6% 3%, 48.4% 0%, 44.6% 4.7%, 54.5% 25.3%, 59.8% 49%, 55.2% 57.8%, 44.4% 57.2%, 27.8% 47.9%, 35.1% 81.5%, 0% 97.7%, 39.2% 100%, 35.2% 81.4%, 97.2% 52.8%, 63.1% 29.5%)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional background layers */}
|
||||
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.1),transparent_65%)]" />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8 relative z-10">
|
||||
<div className="flex flex-col flex-1 items-center justify-center">
|
||||
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
||||
<div className="relative flex items-center justify-center">
|
||||
<InvalidRefLinkModal
|
||||
inline
|
||||
open
|
||||
@ -200,111 +253,97 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageLayout>
|
||||
</ToastProvider>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// normal register
|
||||
return (
|
||||
<ToastProvider>
|
||||
<PageLayout>
|
||||
<main className="w-full flex flex-col flex-1 gap-10 min-h-screen">
|
||||
{/* Background section wrapper */}
|
||||
{/* make wrapper flex-1 so background reaches the footer */}
|
||||
<div className="relative flex-1 overflow-hidden pt-16 sm:pt-20 pb-20 sm:pb-24">
|
||||
{/* Pattern */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 -z-10 h-full w-full stroke-white/10"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="register-pattern"
|
||||
x="50%"
|
||||
y={-1}
|
||||
width={200}
|
||||
height={200}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path
|
||||
d="M.5 200V.5H200"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.05)"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
fill="url(#register-pattern)"
|
||||
width="100%"
|
||||
height="100%"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Colored blur */}
|
||||
<PageLayout>
|
||||
<div className="relative w-full flex flex-col min-h-screen overflow-hidden" style={{ backgroundImage: 'none', background: 'none' }}>
|
||||
<Waves
|
||||
className="pointer-events-none"
|
||||
lineColor="#0f172a"
|
||||
backgroundColor="rgba(245, 245, 240, 1)"
|
||||
waveSpeedX={0.02}
|
||||
waveSpeedY={0.01}
|
||||
waveAmpX={40}
|
||||
waveAmpY={20}
|
||||
friction={0.9}
|
||||
tension={0.01}
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
/>
|
||||
<main
|
||||
className="relative z-10 flex flex-col flex-1 items-center justify-start sm:justify-center pb-10 sm:pb-20"
|
||||
style={mainStyle}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex flex-col w-full">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 right-0 left-1/2 -z-10 -ml-24 transform-gpu overflow-hidden blur-3xl lg:ml-24 xl:ml-48"
|
||||
className="mx-auto w-full max-w-4xl min-h-[520px] sm:min-h-[560px] rounded-3xl shadow-2xl relative border border-white/35 overflow-hidden p-6 sm:p-10 md:py-12 md:px-14"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)', // Use a translucent white for glass effect
|
||||
backdropFilter: 'blur(40px)', // Glass blur
|
||||
WebkitBackdropFilter: 'blur(40px)',
|
||||
boxShadow: '0 18px 45px rgba(15,23,42,0.45)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
||||
style={{
|
||||
clipPath:
|
||||
'polygon(63.1% 29.5%, 100% 17.1%, 76.6% 3%, 48.4% 0%, 44.6% 4.7%, 54.5% 25.3%, 59.8% 49%, 55.2% 57.8%, 44.4% 57.2%, 27.8% 47.9%, 35.1% 81.5%, 0% 97.7%, 39.2% 100%, 35.2% 81.4%, 97.2% 52.8%, 63.1% 29.5%)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
||||
<div className="relative">
|
||||
<div className="mx-auto max-w-2xl text-center mb-8">
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">
|
||||
Register now
|
||||
</h1>
|
||||
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
||||
Create your personal or company account with Profit Planet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Additional background layers */}
|
||||
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.1),transparent_65%)]" />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8 relative z-10">
|
||||
{/* Heading (optional – adjusted to registration context) */}
|
||||
<div className="mx-auto max-w-2xl text-center mb-10">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-white sm:text-5xl">
|
||||
Register now
|
||||
</h1>
|
||||
<p className="mt-2 text-lg/8 text-gray-200">
|
||||
Create your personal or company account with Profit Planet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex flex-col flex-1">
|
||||
{showSessionModal ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<SessionDetectedModal
|
||||
inline
|
||||
open
|
||||
onLogout={handleLogout}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Register form (only if ref valid) */}
|
||||
{(!user || sessionCleared) && (
|
||||
<RegisterForm
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
refToken={refToken}
|
||||
onRegistered={() => setRegistered(true)}
|
||||
referrerEmail={refInfo?.referrerEmail}
|
||||
<div className="mt-2">
|
||||
{showSessionModal ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<SessionDetectedModal
|
||||
inline
|
||||
open
|
||||
onLogout={handleLogout}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
{registered && (
|
||||
<div className="mt-6 mx-auto text-center text-sm text-gray-200">
|
||||
Registration successful – redirecting...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(!user || sessionCleared) && (
|
||||
<RegisterForm
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
refToken={refToken}
|
||||
onRegistered={() => setRegistered(true)}
|
||||
referrerEmail={refInfo?.referrerEmail}
|
||||
/>
|
||||
)}
|
||||
{registered && (
|
||||
<div className="mt-6 mx-auto text-center text-sm text-gray-700">
|
||||
Registration successful – redirecting...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageLayout>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// NEW: default export only provides the ToastProvider wrapper
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<RegisterPageInner />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@ -11,15 +11,16 @@
|
||||
|
||||
const ITI_CDN_CSS =
|
||||
'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/css/intlTelInput.css'
|
||||
|
||||
// Use the official bundle that includes utils to avoid "getCoreNumber" being undefined.
|
||||
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'
|
||||
'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/js/intlTelInputWithUtils.min.js'
|
||||
|
||||
export type IntlTelInputInstance = {
|
||||
destroy: () => void
|
||||
getNumber: () => string
|
||||
isValidNumber: () => boolean
|
||||
setNumber?: (number: string) => void
|
||||
getValidationError?: () => number
|
||||
getSelectedCountryData?: () => { name: string; iso2: string; dialCode: string }
|
||||
promise?: Promise<unknown>
|
||||
@ -27,7 +28,9 @@ export type IntlTelInputInstance = {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
intlTelInput?: (input: HTMLInputElement, options: any) => IntlTelInputInstance
|
||||
intlTelInput?: ((input: HTMLInputElement, options: any) => IntlTelInputInstance) & {
|
||||
getInstance?: (input: HTMLInputElement) => IntlTelInputInstance | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,26 +73,36 @@ async function loadIntlTelInputFromCdn(): Promise<
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
// JS once
|
||||
// JS once (but replace if the existing script points to a different bundle)
|
||||
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…')
|
||||
console.log('[phoneUtils] Loading intl-tel-input (with 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 existingSrc = existing.getAttribute('src') || ''
|
||||
if (existingSrc !== ITI_CDN_JS) {
|
||||
console.warn('[phoneUtils] Replacing existing intl-tel-input script with different src', {
|
||||
existingSrc,
|
||||
desiredSrc: ITI_CDN_JS,
|
||||
})
|
||||
existing.remove()
|
||||
} else {
|
||||
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')
|
||||
@ -97,22 +110,22 @@ async function loadIntlTelInputFromCdn(): Promise<
|
||||
script.async = true
|
||||
script.dataset.intlTelInputJs = 'true'
|
||||
script.onload = () => {
|
||||
console.log('[phoneUtils] intl-tel-input core script loaded successfully')
|
||||
console.log('[phoneUtils] intl-tel-input script loaded successfully')
|
||||
resolve()
|
||||
}
|
||||
script.onerror = () => {
|
||||
console.error('[phoneUtils] intl-tel-input core script failed to load')
|
||||
console.error('[phoneUtils] intl-tel-input 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')
|
||||
console.error('[phoneUtils] window.intlTelInput missing after 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)')
|
||||
console.log('[phoneUtils] window.intlTelInput is ready (with utils bundle)')
|
||||
return window.intlTelInput
|
||||
}
|
||||
|
||||
@ -123,7 +136,10 @@ export async function ensureIntlCoreLoaded(): Promise<
|
||||
(input: HTMLInputElement, options: any) => IntlTelInputInstance
|
||||
> {
|
||||
if (!intlLoaderPromise) {
|
||||
intlLoaderPromise = loadIntlTelInputFromCdn()
|
||||
intlLoaderPromise = loadIntlTelInputFromCdn().catch(err => {
|
||||
intlLoaderPromise = null
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return intlLoaderPromise
|
||||
}
|
||||
@ -138,25 +154,26 @@ export async function createIntlTelInput(
|
||||
): Promise<IntlTelInputInstance> {
|
||||
const intlTelInput = await ensureIntlCoreLoaded()
|
||||
|
||||
console.log('[phoneUtils] Creating intl-tel-input instance with loadUtils', {
|
||||
console.log('[phoneUtils] Creating intl-tel-input instance', {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
})
|
||||
|
||||
// Defensive: if an instance already exists on this input (common in dev/StrictMode),
|
||||
// destroy it to avoid stale state (e.g. wrong selected country/flag).
|
||||
try {
|
||||
const anyFactory = intlTelInput as any
|
||||
const existing =
|
||||
typeof anyFactory?.getInstance === 'function' ? anyFactory.getInstance(input) : null
|
||||
if (existing && typeof existing.destroy === 'function') {
|
||||
existing.destroy()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user