Merge branch 'dev' of https://git.profit-planet.partners/Seazn/profit-planet-frontend into dev
This commit is contained in:
commit
1045debc32
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import PageLayout from '../components/PageLayout'
|
||||
import Waves from '../components/waves'
|
||||
import {
|
||||
UsersIcon,
|
||||
ExclamationTriangleIcon,
|
||||
@ -84,10 +85,30 @@ export default function AdminDashboardPage() {
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||
<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}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 min-h-screen flex flex-col">
|
||||
<main className="flex-1 max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8 w-full">
|
||||
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||
<header className="flex flex-col gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Admin Dashboard</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">
|
||||
@ -375,9 +396,10 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import PageLayout from '../components/PageLayout'
|
||||
import Waves from '../components/waves'
|
||||
|
||||
type Affiliate = {
|
||||
id: string
|
||||
@ -89,10 +90,30 @@ export default function AffiliateLinksPage() {
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||
<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}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 min-h-screen flex flex-col">
|
||||
<main className="flex-1 max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8 w-full">
|
||||
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
|
||||
{/* Header (aligned with management pages) */}
|
||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
||||
<header className="flex flex-col gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Affiliate Partners</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">
|
||||
@ -187,8 +208,10 @@ export default function AffiliateLinksPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
153
src/app/components/curvedLoop.tsx
Normal file
153
src/app/components/curvedLoop.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useEffect, useState, useMemo, useId, FC, PointerEvent } from 'react';
|
||||
|
||||
interface CurvedLoopProps {
|
||||
marqueeText?: string;
|
||||
speed?: number;
|
||||
className?: string;
|
||||
curveAmount?: number;
|
||||
direction?: 'left' | 'right';
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
const CurvedLoop: FC<CurvedLoopProps> = ({
|
||||
marqueeText = '',
|
||||
speed = 1,
|
||||
className,
|
||||
curveAmount = -50,
|
||||
direction = 'left',
|
||||
interactive = true
|
||||
}) => {
|
||||
const text = useMemo(() => {
|
||||
const hasTrailing = /\s|\u00A0$/.test(marqueeText);
|
||||
return (hasTrailing ? marqueeText.replace(/\s+$/, '') : marqueeText) + '\u00A0';
|
||||
}, [marqueeText]);
|
||||
|
||||
const measureRef = useRef<SVGTextElement | null>(null);
|
||||
const textPathRef = useRef<SVGTextPathElement | null>(null);
|
||||
const pathRef = useRef<SVGPathElement | null>(null);
|
||||
const [spacing, setSpacing] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const uid = useId();
|
||||
const pathId = `curve-${uid}`;
|
||||
const pathD = `M-100,40 Q500,${40 + curveAmount} 1540,40`;
|
||||
|
||||
const dragRef = useRef(false);
|
||||
const lastXRef = useRef(0);
|
||||
const dirRef = useRef<'left' | 'right'>(direction);
|
||||
const velRef = useRef(0);
|
||||
|
||||
const textLength = spacing;
|
||||
const totalText = textLength
|
||||
? Array(Math.ceil(1800 / textLength) + 2)
|
||||
.fill(text)
|
||||
.join('')
|
||||
: text;
|
||||
const ready = spacing > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (measureRef.current) setSpacing(measureRef.current.getComputedTextLength());
|
||||
}, [text, className]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!spacing) return;
|
||||
if (textPathRef.current) {
|
||||
const initial = -spacing;
|
||||
textPathRef.current.setAttribute('startOffset', initial + 'px');
|
||||
setOffset(initial);
|
||||
}
|
||||
}, [spacing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!spacing || !ready) return;
|
||||
let frame = 0;
|
||||
const step = () => {
|
||||
if (!dragRef.current && textPathRef.current) {
|
||||
const delta = dirRef.current === 'right' ? speed : -speed;
|
||||
const currentOffset = parseFloat(textPathRef.current.getAttribute('startOffset') || '0');
|
||||
let newOffset = currentOffset + delta;
|
||||
const wrapPoint = spacing;
|
||||
if (newOffset <= -wrapPoint) newOffset += wrapPoint;
|
||||
if (newOffset > 0) newOffset -= wrapPoint;
|
||||
textPathRef.current.setAttribute('startOffset', newOffset + 'px');
|
||||
setOffset(newOffset);
|
||||
}
|
||||
frame = requestAnimationFrame(step);
|
||||
};
|
||||
frame = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [spacing, speed, ready]);
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!interactive) return;
|
||||
dragRef.current = true;
|
||||
lastXRef.current = e.clientX;
|
||||
velRef.current = 0;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!interactive || !dragRef.current || !textPathRef.current) return;
|
||||
const dx = e.clientX - lastXRef.current;
|
||||
lastXRef.current = e.clientX;
|
||||
velRef.current = dx;
|
||||
const currentOffset = parseFloat(textPathRef.current.getAttribute('startOffset') || '0');
|
||||
let newOffset = currentOffset + dx;
|
||||
const wrapPoint = spacing;
|
||||
if (newOffset <= -wrapPoint) newOffset += wrapPoint;
|
||||
if (newOffset > 0) newOffset -= wrapPoint;
|
||||
textPathRef.current.setAttribute('startOffset', newOffset + 'px');
|
||||
setOffset(newOffset);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!interactive) return;
|
||||
dragRef.current = false;
|
||||
dirRef.current = velRef.current > 0 ? 'right' : 'left';
|
||||
};
|
||||
|
||||
const cursorStyle = interactive ? (dragRef.current ? 'grabbing' : 'grab') : 'auto';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-center justify-center"
|
||||
style={{ visibility: ready ? 'visible' : 'hidden', cursor: cursorStyle }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerLeave={endDrag}
|
||||
>
|
||||
<svg
|
||||
className="select-none w-full overflow-visible block aspect-[100/12] text-[2.25rem] md:text-[2.75rem] lg:text-[3rem] font-bold uppercase leading-none"
|
||||
viewBox="0 0 1440 120"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<text
|
||||
ref={measureRef}
|
||||
xmlSpace="preserve"
|
||||
style={{ visibility: 'hidden', opacity: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
<defs>
|
||||
<path ref={pathRef} id={pathId} d={pathD} fill="none" stroke="transparent" />
|
||||
</defs>
|
||||
{ready && (
|
||||
<text xmlSpace="preserve" className={`fill-[#0F172A] ${className ?? ''}`}>
|
||||
<textPath
|
||||
ref={textPathRef}
|
||||
href={`#${pathId}`}
|
||||
startOffset={offset + 'px'}
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
{totalText}
|
||||
</textPath>
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurvedLoop;
|
||||
@ -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 isHome = pathname === '/'
|
||||
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)
|
||||
}, [])
|
||||
|
||||
// Home-page scroll listener: reveal header after first scroll with slight parallax
|
||||
// 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 (!isHome) {
|
||||
// non-home: 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, isHome])
|
||||
}, [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 = isHome ? animateIn && scrollY > 24 : animateIn
|
||||
const parallaxOffset = isHome ? 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={`${
|
||||
isHome ? '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%)',
|
||||
...(isHome ? { 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
|
||||
}
|
||||
|
||||
instance = await createIntlTelInput(inputRef.current, {
|
||||
initialCountry,
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
if (disposed) {
|
||||
console.log('[TelephoneInput] setup() finished but component is disposed, destroying instance', {
|
||||
id: rest.id,
|
||||
name: rest.name,
|
||||
instance = await createIntlTelInput(inputRef.current, {
|
||||
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 }
|
||||
)
|
||||
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,
|
||||
})
|
||||
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
|
||||
}
|
||||
console.warn(
|
||||
'[TelephoneInput] getNumber() called before intl-tel-input ready, returning raw value',
|
||||
{ id: rest.id, name: rest.name, raw }
|
||||
)
|
||||
return intl || raw
|
||||
} catch {
|
||||
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
|
||||
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,40 +338,11 @@ 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;
|
||||
|
||||
if (interactive) {
|
||||
const mouse = mouseRef.current;
|
||||
mouse.sx += (mouse.x - mouse.sx) * 0.1;
|
||||
mouse.sy += (mouse.y - mouse.sy) * 0.1;
|
||||
@ -336,27 +357,27 @@ const Waves: React.FC<WavesProps> = ({
|
||||
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);
|
||||
ro?.observe(container);
|
||||
|
||||
if (interactive) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||
}
|
||||
|
||||
if (animate) {
|
||||
frameIdRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
drawStatic();
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
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,10 +1,10 @@
|
||||
'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,
|
||||
@ -18,6 +18,20 @@ export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
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(() => {
|
||||
@ -55,7 +69,9 @@ export default function DashboardPage() {
|
||||
description: 'Explore sustainable products',
|
||||
icon: ShoppingBagIcon,
|
||||
href: '/shop',
|
||||
color: 'bg-blue-500'
|
||||
color: 'bg-blue-500',
|
||||
disabled: !isShopEnabled,
|
||||
disabledText: 'This is currently disabled.'
|
||||
},
|
||||
{
|
||||
title: 'Browse Affiliate Links',
|
||||
@ -112,11 +128,32 @@ export default function DashboardPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-50">
|
||||
<Header />
|
||||
<div
|
||||
className="relative w-full min-h-[100dvh] flex flex-col 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}
|
||||
/>
|
||||
|
||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
|
||||
<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">
|
||||
@ -172,20 +209,46 @@ export default function DashboardPage() {
|
||||
{quickActions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => router.push(action.href)}
|
||||
className="bg-white rounded-lg p-6 shadow-sm border border-gray-200 hover:shadow-md transition-shadow text-left group"
|
||||
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 group-hover:scale-105 transition-transform`}>
|
||||
<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 text-gray-900 group-hover:text-[#8D6B1D] transition-colors">
|
||||
<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>
|
||||
@ -250,9 +313,10 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</PageLayout>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -6,32 +6,23 @@ 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 [showBall, setShowBall] = useState(true)
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false
|
||||
})
|
||||
// FIX: use a static initial width so SSR and first client render match
|
||||
const [viewportWidth, setViewportWidth] = useState<number>(1200)
|
||||
const router = useRouter()
|
||||
const { login, error, setError, loading } = useLogin()
|
||||
const { showToast } = useToast()
|
||||
|
||||
// Responsive ball visibility
|
||||
useEffect(() => {
|
||||
const handleResizeBall = () => setShowBall(window.innerWidth >= 768)
|
||||
handleResizeBall()
|
||||
window.addEventListener('resize', handleResizeBall)
|
||||
return () => window.removeEventListener('resize', handleResizeBall)
|
||||
}, [])
|
||||
|
||||
// Track viewport width for dynamic scaling
|
||||
useEffect(() => {
|
||||
const handleResize = () => setViewportWidth(window.innerWidth)
|
||||
handleResize() // initialize on mount (runs only on client)
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
@ -47,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
|
||||
}
|
||||
|
||||
@ -106,7 +97,7 @@ export default function LoginForm() {
|
||||
|
||||
// CHANGED: Wider base widths; no transform scaling
|
||||
const formWidth = isMobile
|
||||
? '94vw'
|
||||
? '100%'
|
||||
: isTablet
|
||||
? '80vw'
|
||||
: isSmallLaptop
|
||||
@ -114,7 +105,7 @@ export default function LoginForm() {
|
||||
: '52vw'
|
||||
|
||||
const formMaxWidth = isMobile
|
||||
? '480px'
|
||||
? '420px'
|
||||
: isTablet
|
||||
? '760px'
|
||||
: isSmallLaptop
|
||||
@ -125,145 +116,62 @@ export default function LoginForm() {
|
||||
<div
|
||||
className="w-full relative"
|
||||
style={{
|
||||
// CHANGED: full-height flex box for perfect vertical centering
|
||||
minHeight: '100vh',
|
||||
// removed full-height so curved loop is visible right under the form
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// REMOVE marble image so Waves shows through
|
||||
background: 'transparent',
|
||||
// Subtle padding to breathe on mobile
|
||||
padding: isMobile ? '0.75rem' : '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
|
||||
className="bg-white rounded-2xl shadow-2xl flex flex-col items-center relative border-t-4 border-[#8D6B1D]"
|
||||
className="rounded-3xl shadow-2xl flex flex-col items-center relative border border-white/35"
|
||||
style={{
|
||||
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: GLASS_BG,
|
||||
backdropFilter: 'blur(18px)',
|
||||
WebkitBackdropFilter: 'blur(18px)',
|
||||
// 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)',
|
||||
}}
|
||||
>
|
||||
{/* Animated Ball - Desktop Only */}
|
||||
{showBall && !isMobile && (
|
||||
<div className="absolute -top-16 left-1/2 -translate-x-1/2 w-28 z-20">
|
||||
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-[#8D6B1D] via-[#A67C20] to-[#C49225] shadow-xl border-4 border-white relative">
|
||||
{/* Inner small circle with cartoony Earth */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-white/15 backdrop-blur-sm border border-white/25 flex items-center justify-center shadow-inner relative overflow-hidden">
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
className="w-14 h-14"
|
||||
role="img"
|
||||
aria-label="Cartoon Earth"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="earth-ocean" cx="50%" cy="40%" r="65%">
|
||||
<stop offset="0%" stopColor="#3fa9f5" />
|
||||
<stop offset="100%" stopColor="#1d5fae" />
|
||||
</radialGradient>
|
||||
<linearGradient id="earth-glow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgba(255,255,255,0.55)" />
|
||||
<stop offset="60%" stopColor="rgba(255,255,255,0)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="30" fill="url(#earth-ocean)" />
|
||||
{/* Land masses (stylized) */}
|
||||
<path
|
||||
fill="#4caf50"
|
||||
d="M18 30c4-6 10-9 16-9 3 0 5 1 7 3 2 2 1 4-1 5-4 2-8 2-11 5-2 2-3 4-6 4-5 0-8-5-5-8Z"
|
||||
/>
|
||||
<path
|
||||
fill="#66bb6a"
|
||||
d="M40 18c3 1 6 3 7 6 1 3 0 5-2 6-2 1-3 0-5-2-3-3-6-5-6-7 0-3 3-4 6-3Z"
|
||||
opacity=".9"
|
||||
/>
|
||||
<path
|
||||
fill="#43a047"
|
||||
d="M26 44c2-2 5-3 8-2 2 1 3 3 1 5-2 3-6 5-9 4-3-1-3-5 0-7Z"
|
||||
opacity=".85"
|
||||
/>
|
||||
{/* Atmospheric rim */}
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="30"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.35)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Light sheen */}
|
||||
<ellipse
|
||||
cx="26"
|
||||
cy="22"
|
||||
rx="11"
|
||||
ry="7"
|
||||
fill="url(#earth-glow)"
|
||||
opacity=".6"
|
||||
/>
|
||||
</svg>
|
||||
{/* Subtle gloss overlay */}
|
||||
<span className="pointer-events-none absolute inset-0 before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_35%_30%,rgba(255,255,255,0.45),transparent_70%)]" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Orbiting balls (unchanged) */}
|
||||
<span className="absolute left-1/2 top-1/2 w-0 h-0">
|
||||
<span className="block absolute animate-orbit-1" style={{ width: 0, height: 0 }}>
|
||||
<span
|
||||
className="block w-3 h-3 bg-[#8D6B1D] rounded-full shadow-lg"
|
||||
style={{ transform: 'translateX(44px)' }}
|
||||
/>
|
||||
</span>
|
||||
<span className="block absolute animate-orbit-2" style={{ width: 0, height: 0 }}>
|
||||
<span
|
||||
className="block w-2.5 h-2.5 bg-[#A67C20] rounded-full shadow-md"
|
||||
style={{ transform: 'translateX(-36px)' }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div style={{
|
||||
{/* 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%',
|
||||
}}>
|
||||
<h1
|
||||
className="mb-2 text-center font-extrabold text-[#0F172A] tracking-tight drop-shadow-lg"
|
||||
style={{
|
||||
// CHANGED: slightly smaller headline on mobile to reduce vertical space
|
||||
fontSize: isMobile ? '1.75rem' : isTablet ? '2rem' : '2.25rem',
|
||||
marginTop: isMobile ? '0.25rem' : undefined,
|
||||
}}
|
||||
>
|
||||
Profit Planet
|
||||
{/* Title + Subtitle (restored) */}
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold tracking-tight text-[#0F172A] drop-shadow-sm">
|
||||
PROFIT PLANET
|
||||
</h1>
|
||||
<p
|
||||
className="mb-6 text-center text-[#8D6B1D] font-medium"
|
||||
style={{
|
||||
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1.05rem',
|
||||
// CHANGED: reduce bottom margin
|
||||
marginBottom: isMobile ? '0.75rem' : isTablet ? '1rem' : '1rem',
|
||||
}}
|
||||
>
|
||||
Welcome back! Login to continue.
|
||||
<p className="mt-1 text-sm md:text-base text-slate-700/90">
|
||||
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}
|
||||
>
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<div className="field-animated">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-base font-semibold text-[#0F172A] mb-1"
|
||||
@ -272,7 +180,7 @@ export default function LoginForm() {
|
||||
marginBottom: isMobile ? '0.25rem' : undefined,
|
||||
}}
|
||||
>
|
||||
E-Mail-Adresse
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
@ -281,18 +189,18 @@ export default function LoginForm() {
|
||||
autoComplete="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none block w-full px-4 py-3 border border-gray-300 rounded-lg placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-[#8D6B1D] text-base bg-white text-[#0F172A] transition"
|
||||
className="input-animated appearance-none block w-full px-4 py-3 border border-white/40 rounded-xl placeholder-slate-600/80 focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] text-base bg-white/60 text-[#0F172A] shadow-sm transition"
|
||||
style={{
|
||||
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>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<div className="field-animated">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-base font-semibold text-[#0F172A] mb-1"
|
||||
@ -301,22 +209,26 @@ export default function LoginForm() {
|
||||
marginBottom: isMobile ? '0.25rem' : undefined,
|
||||
}}
|
||||
>
|
||||
Passwort
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none block w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-[#8D6B1D] text-base bg-white text-[#0F172A] transition"
|
||||
className="input-animated appearance-none block w-full px-4 py-3 pr-12 border border-white/40 rounded-xl placeholder-slate-600/80 focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]/80 focus:border-[#8D6B1D] text-base bg-white/60 text-[#0F172A] shadow-sm transition"
|
||||
style={{
|
||||
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1rem',
|
||||
padding: isMobile ? '0.5rem 2.5rem 0.5rem 0.75rem' : isTablet ? '0.6rem 2.75rem 0.6rem 0.875rem' : '0.7rem 3rem 0.7rem 1rem',
|
||||
padding: isMobile
|
||||
? '0.5rem 2.5rem 0.5rem 0.75rem'
|
||||
: isTablet
|
||||
? '0.6rem 2.75rem 0.6rem 0.875rem'
|
||||
: '0.7rem 3rem 0.7rem 1rem',
|
||||
}}
|
||||
placeholder="Dein Passwort"
|
||||
placeholder="Your password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
@ -331,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 */}
|
||||
@ -374,10 +257,10 @@ export default function LoginForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full py-3 px-6 rounded-lg shadow-md text-base font-bold text-white transition-all duration-200 transform hover:-translate-y-0.5 ${
|
||||
className={`w-full py-3 px-6 rounded-xl text-base font-semibold transition-all duration-200 transform hover:-translate-y-0.5 border ${
|
||||
loading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-[#8D6B1D] via-[#A67C20] to-[#C49225] hover:from-[#7A5E1A] hover:to-[#B8851F] focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2'
|
||||
? 'border-white/30 bg-white/20 text-slate-300 cursor-not-allowed'
|
||||
: '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'
|
||||
}`}
|
||||
style={{
|
||||
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1rem',
|
||||
@ -385,12 +268,12 @@ export default function LoginForm() {
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Anmeldung läuft...
|
||||
<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>
|
||||
Signing in...
|
||||
</div>
|
||||
) : (
|
||||
'Anmelden'
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@ -402,74 +285,50 @@ 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>
|
||||
|
||||
{/* Registration Section */}
|
||||
<div
|
||||
className="mt-8 w-full"
|
||||
style={{
|
||||
marginTop: isMobile ? '0.75rem' : isTablet ? '1rem' : '1rem',
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200"></div>
|
||||
</div>
|
||||
<div
|
||||
className="relative flex justify-center text-base"
|
||||
style={{
|
||||
fontSize: isMobile ? '0.875rem' : isTablet ? '0.9rem' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* <a href="/register" className="px-3 bg-white text-[#8D6B1D]">Noch kein Account?</a> */}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-7 text-center"
|
||||
style={{
|
||||
marginTop: isMobile ? '0.75rem' : isTablet ? '1rem' : undefined,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-base text-slate-700"
|
||||
style={{
|
||||
fontSize: isMobile ? '0.8rem' : isTablet ? '0.85rem' : undefined,
|
||||
}}
|
||||
>
|
||||
Profit Planet is available by invitation only.
|
||||
</p>
|
||||
<p
|
||||
className="text-base text-[#8D6B1D] mt-2"
|
||||
style={{
|
||||
fontSize: isMobile ? '0.8rem' : isTablet ? '0.85rem' : undefined,
|
||||
}}
|
||||
>
|
||||
Contact us for an invitation!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS Animations */}
|
||||
{/* Input animations */}
|
||||
<style jsx>{`
|
||||
@keyframes orbit-1 {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
@keyframes field-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.99);
|
||||
}
|
||||
@keyframes orbit-2 {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(-360deg); }
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
.animate-orbit-1 {
|
||||
animation: orbit-1 3s linear infinite;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
.animate-orbit-2 {
|
||||
animation: orbit-2 4s linear infinite;
|
||||
transform-origin: 0 0;
|
||||
|
||||
@keyframes input-focus-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(141, 107, 29, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px rgba(141, 107, 29, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.field-animated {
|
||||
animation: field-fade-in 0.45s ease-out both;
|
||||
}
|
||||
|
||||
.input-animated {
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.input-animated:focus {
|
||||
animation: input-focus-pulse 0.22s ease-out;
|
||||
background-color: rgba(255, 255, 255, 0.96);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
@ -8,9 +8,11 @@ import useAuthStore from '../store/authStore'
|
||||
import { ToastProvider } from '../components/toast/toastComponent'
|
||||
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
||||
import Waves from '../components/waves'
|
||||
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)
|
||||
|
||||
@ -19,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) {
|
||||
@ -47,12 +61,8 @@ 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 */}
|
||||
{/* 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"
|
||||
@ -66,14 +76,60 @@ export default function LoginPage() {
|
||||
maxCursorMove={120}
|
||||
xGap={12}
|
||||
yGap={36}
|
||||
animate={!isMobile}
|
||||
interactive={!isMobile}
|
||||
/>
|
||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||
<div className="w-full">
|
||||
|
||||
<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>
|
||||
{/* ...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,20 +85,27 @@ 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"
|
||||
>
|
||||
{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-9xl md:text-9xl font-bold transition-colors duration-300 ${
|
||||
className={`text-7xl sm:text-8xl md:text-9xl font-bold transition-colors duration-300 ${
|
||||
isHover ? 'text-black' : 'text-gray-500'
|
||||
}`}
|
||||
delay={100}
|
||||
@ -84,10 +118,12 @@ export default function HomePage() {
|
||||
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"
|
||||
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
||||
style={{ backgroundImage: 'none', background: 'none' }}
|
||||
>
|
||||
<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"
|
||||
<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>
|
||||
{/* Gradient base */}
|
||||
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
|
||||
|
||||
{/* 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-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>
|
||||
|
||||
{/* 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="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>
|
||||
@ -309,6 +344,15 @@ export default function PasswordResetPage() {
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
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 {
|
||||
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,132 +172,105 @@ 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);
|
||||
}
|
||||
}, [profileDataApi, user, progressPercent])
|
||||
|
||||
// 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
const documents = Array.isArray(mediaData?.documents) ? mediaData.documents : []
|
||||
|
||||
// Modal change handler
|
||||
function handleEditModalChange(key: string, value: string) {
|
||||
setEditModalValues(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
// 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 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="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>
|
||||
)}
|
||||
{!loadingUser && (
|
||||
<>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
@ -304,17 +281,15 @@ export default function ProfilePage() {
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
)}
|
||||
{/* Profile Completion Progress Bar */}
|
||||
<ProfileCompletion
|
||||
profileComplete={profileData.profileComplete}
|
||||
/>
|
||||
|
||||
<ProfileCompletion profileComplete={profileData.profileComplete} />
|
||||
|
||||
{/* Basic Info + Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
||||
<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
|
||||
@ -324,7 +299,6 @@ export default function ProfilePage() {
|
||||
onEdit={() => openEditModal('basic', {
|
||||
firstName: profileData.firstName,
|
||||
lastName: profileData.lastName,
|
||||
email: profileData.email,
|
||||
phone: profileData.phone,
|
||||
address: profileData.address,
|
||||
})}
|
||||
@ -332,8 +306,8 @@ export default function ProfilePage() {
|
||||
</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">
|
||||
{/* 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">
|
||||
@ -355,8 +329,8 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
{/* 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">
|
||||
@ -378,79 +352,26 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
|
||||
{/* Bank Info, Media */}
|
||||
<div className="space-y-8 mb-8">
|
||||
<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={editingBank}
|
||||
editingBank={false} // force read-only
|
||||
bankDraft={bankDraft}
|
||||
setEditingBank={setEditingBank}
|
||||
setBankDraft={setBankDraft}
|
||||
setBankInfo={setBankInfo}
|
||||
// Add edit button handler
|
||||
onEdit={() => openEditModal('bank', {
|
||||
accountHolder: profileData.accountHolder,
|
||||
iban: profileData.iban,
|
||||
})}
|
||||
// onEdit disabled for now
|
||||
// onEdit={() => openEditModal('bank', { ... })}
|
||||
/>
|
||||
{/* --- 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
|
||||
@ -467,6 +388,8 @@ export default function ProfilePage() {
|
||||
<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,13 +17,15 @@ 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="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">
|
||||
<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>
|
||||
@ -37,7 +39,7 @@ export default function SessionDetectedModal({
|
||||
<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"
|
||||
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>
|
||||
@ -52,7 +54,6 @@ export default function SessionDetectedModal({
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// 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)"
|
||||
<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}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
fill="url(#register-pattern)"
|
||||
width="100%"
|
||||
height="100%"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Colored blur */}
|
||||
<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"
|
||||
<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="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
||||
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={{
|
||||
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%)'
|
||||
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>
|
||||
|
||||
{/* 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,80 +253,57 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// 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)"
|
||||
<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}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
fill="url(#register-pattern)"
|
||||
width="100%"
|
||||
height="100%"
|
||||
strokeWidth={0}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Colored blur */}
|
||||
<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"
|
||||
<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="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
||||
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={{
|
||||
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%)'
|
||||
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>
|
||||
|
||||
{/* 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">
|
||||
>
|
||||
<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-2 text-lg/8 text-gray-200">
|
||||
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
||||
Create your personal or company account with Profit Planet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="mt-2">
|
||||
{showSessionModal ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<SessionDetectedModal
|
||||
inline
|
||||
open
|
||||
@ -283,7 +313,6 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Register form (only if ref valid) */}
|
||||
{(!user || sessionCleared) && (
|
||||
<RegisterForm
|
||||
mode={mode}
|
||||
@ -294,7 +323,7 @@ export default function RegisterPage() {
|
||||
/>
|
||||
)}
|
||||
{registered && (
|
||||
<div className="mt-6 mx-auto text-center text-sm text-gray-200">
|
||||
<div className="mt-6 mx-auto text-center text-sm text-gray-700">
|
||||
Registration successful – redirecting...
|
||||
</div>
|
||||
)}
|
||||
@ -303,8 +332,18 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</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,18 +73,27 @@ 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) {
|
||||
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(
|
||||
@ -91,28 +103,29 @@ async function loadIntlTelInputFromCdn(): Promise<
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = ITI_CDN_JS
|
||||
script.async = true
|
||||
script.dataset.intlTelInputJs = 'true'
|
||||
script.onload = () => {
|
||||
console.log('[phoneUtils] intl-tel-input core script loaded successfully')
|
||||
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