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'
|
'use client'
|
||||||
|
|
||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
|
import Waves from '../components/waves'
|
||||||
import {
|
import {
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
@ -84,74 +85,94 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div
|
||||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
||||||
{/* Header */}
|
style={{ backgroundImage: 'none', background: 'none' }}
|
||||||
<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">
|
>
|
||||||
<div>
|
<Waves
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Admin Dashboard</h1>
|
className="pointer-events-none"
|
||||||
<p className="text-lg text-blue-700 mt-2">
|
lineColor="#0f172a"
|
||||||
Manage all administrative features, user management, permissions, and global settings.
|
backgroundColor="rgba(245, 245, 240, 1)"
|
||||||
</p>
|
waveSpeedX={0.02}
|
||||||
</div>
|
waveSpeedY={0.01}
|
||||||
</header>
|
waveAmpX={40}
|
||||||
|
waveAmpY={20}
|
||||||
|
friction={0.9}
|
||||||
|
tension={0.01}
|
||||||
|
maxCursorMove={120}
|
||||||
|
xGap={12}
|
||||||
|
yGap={36}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Warning banner */}
|
<div className="relative z-10 min-h-screen flex flex-col">
|
||||||
<div className="rounded-2xl border border-red-300 bg-red-50 text-red-700 px-8 py-6 flex gap-3 items-start text-base mb-8 shadow">
|
<main className="flex-1 max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8 w-full">
|
||||||
<ExclamationTriangleIcon className="h-6 w-6 flex-shrink-0 text-red-500 mt-0.5" />
|
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
|
||||||
<div className="leading-relaxed">
|
{/* Header */}
|
||||||
<p className="font-semibold mb-0.5">
|
<header className="flex flex-col gap-4 mb-8">
|
||||||
Warning: Settings and actions below this point can have consequences for the entire system!
|
|
||||||
</p>
|
|
||||||
<p className="text-red-600/80 hidden sm:block">
|
|
||||||
Manage all administrative features, user management, permissions, and global settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Card */}
|
|
||||||
<div className="mb-8 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6">
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Total Users</div>
|
|
||||||
<div className="text-xl font-semibold text-blue-900">{displayStats.totalUsers}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Admins</div>
|
|
||||||
<div className="text-xl font-semibold text-indigo-700">{displayStats.adminUsers}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Active</div>
|
|
||||||
<div className="text-xl font-semibold text-green-700">{displayStats.activeUsers}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Pending Verification</div>
|
|
||||||
<div className="text-xl font-semibold text-amber-700">{displayStats.verificationPending}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Personal</div>
|
|
||||||
<div className="text-xl font-semibold text-blue-700">{displayStats.personalUsers}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Company</div>
|
|
||||||
<div className="text-xl font-semibold text-purple-700">{displayStats.companyUsers}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Management Shortcuts Card */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg hover:shadow-xl transition">
|
|
||||||
<div className="flex items-start gap-4 mb-6">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
|
|
||||||
<Squares2X2Icon className="h-7 w-7 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Management Shortcuts</h2>
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Admin Dashboard</h1>
|
||||||
<p className="text-sm text-blue-700 mt-0.5">
|
<p className="text-lg text-blue-700 mt-2">
|
||||||
Quick access to common admin modules.
|
Manage all administrative features, user management, permissions, and global settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Warning banner */}
|
||||||
|
<div className="rounded-2xl border border-red-300 bg-red-50 text-red-700 px-8 py-6 flex gap-3 items-start text-base mb-8 shadow">
|
||||||
|
<ExclamationTriangleIcon className="h-6 w-6 flex-shrink-0 text-red-500 mt-0.5" />
|
||||||
|
<div className="leading-relaxed">
|
||||||
|
<p className="font-semibold mb-0.5">
|
||||||
|
Warning: Settings and actions below this point can have consequences for the entire system!
|
||||||
|
</p>
|
||||||
|
<p className="text-red-600/80 hidden sm:block">
|
||||||
|
Manage all administrative features, user management, permissions, and global settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
|
{/* Stats Card */}
|
||||||
|
<div className="mb-8 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6">
|
||||||
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
|
<div className="text-xs text-gray-500">Total Users</div>
|
||||||
|
<div className="text-xl font-semibold text-blue-900">{displayStats.totalUsers}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
|
<div className="text-xs text-gray-500">Admins</div>
|
||||||
|
<div className="text-xl font-semibold text-indigo-700">{displayStats.adminUsers}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
|
<div className="text-xs text-gray-500">Active</div>
|
||||||
|
<div className="text-xl font-semibold text-green-700">{displayStats.activeUsers}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
|
<div className="text-xs text-gray-500">Pending Verification</div>
|
||||||
|
<div className="text-xl font-semibold text-amber-700">{displayStats.verificationPending}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
|
<div className="text-xs text-gray-500">Personal</div>
|
||||||
|
<div className="text-xl font-semibold text-blue-700">{displayStats.personalUsers}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||||||
|
<div className="text-xs text-gray-500">Company</div>
|
||||||
|
<div className="text-xl font-semibold text-purple-700">{displayStats.companyUsers}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Management Shortcuts Card */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg hover:shadow-xl transition">
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
|
||||||
|
<Squares2X2Icon className="h-7 w-7 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-blue-900">Management Shortcuts</h2>
|
||||||
|
<p className="text-sm text-blue-700 mt-0.5">
|
||||||
|
Quick access to common admin modules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Matrix Management */}
|
{/* Matrix Management */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -303,80 +324,81 @@ export default function AdminDashboardPage() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Server Status & Logs */}
|
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg hover:shadow-xl transition">
|
|
||||||
<div className="flex items-start gap-4 mb-6">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gray-100">
|
|
||||||
<ServerStackIcon className="h-7 w-7 text-gray-700" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
|
||||||
Server Status & Logs
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
|
||||||
System health, resource usage & recent error insights.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-8 lg:grid-cols-3">
|
|
||||||
{/* Metrics */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={`h-2.5 w-2.5 rounded-full ${serverStats.status === 'Online' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
|
||||||
<p className="text-base">
|
|
||||||
<span className="font-semibold">Server Status:</span>{' '}
|
|
||||||
<span className={serverStats.status === 'Online' ? 'text-emerald-600 font-medium' : 'text-red-600 font-medium'}>
|
|
||||||
{serverStats.status === 'Online' ? 'Server Online' : 'Offline'}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm space-y-1 text-gray-600">
|
|
||||||
<p><span className="font-medium text-gray-700">Uptime:</span> {serverStats.uptime}</p>
|
|
||||||
<p><span className="font-medium text-gray-700">CPU Usage:</span> {serverStats.cpu}</p>
|
|
||||||
<p><span className="font-medium text-gray-700">Memory Usage:</span> {serverStats.memory} GB</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
||||||
<CpuChipIcon className="h-4 w-4" />
|
|
||||||
<span>Autoscaled environment (mock)</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Server Status & Logs */}
|
||||||
<div className="hidden lg:block border-l border-gray-200" />
|
<div className="rounded-2xl border border-gray-100 bg-white p-8 shadow-lg hover:shadow-xl transition">
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gray-100">
|
||||||
|
<ServerStackIcon className="h-7 w-7 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
Server Status & Logs
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
System health, resource usage & recent error insights.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Logs */}
|
<div className="grid gap-8 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2">
|
{/* Metrics */}
|
||||||
<h3 className="text-base font-semibold text-gray-800 mb-3">
|
<div className="space-y-4">
|
||||||
Recent Error Logs
|
<div className="flex items-center gap-3">
|
||||||
</h3>
|
<span className={`h-2.5 w-2.5 rounded-full ${serverStats.status === 'Online' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||||
{serverStats.recentErrors.length === 0 && (
|
<p className="text-base">
|
||||||
<p className="text-sm text-gray-500 italic">
|
<span className="font-semibold">Server Status:</span>{' '}
|
||||||
No recent logs.
|
<span className={serverStats.status === 'Online' ? 'text-emerald-600 font-medium' : 'text-red-600 font-medium'}>
|
||||||
</p>
|
{serverStats.status === 'Online' ? 'Server Online' : 'Offline'}
|
||||||
)}
|
</span>
|
||||||
{/* Placeholder for future logs list */}
|
</p>
|
||||||
{/* TODO: Replace with mapped log entries */}
|
</div>
|
||||||
<div className="mt-6">
|
<div className="text-sm space-y-1 text-gray-600">
|
||||||
<button
|
<p><span className="font-medium text-gray-700">Uptime:</span> {serverStats.uptime}</p>
|
||||||
type="button"
|
<p><span className="font-medium text-gray-700">CPU Usage:</span> {serverStats.cpu}</p>
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-gray-50 hover:bg-gray-100 text-gray-700 text-sm font-medium px-4 py-3 transition"
|
<p><span className="font-medium text-gray-700">Memory Usage:</span> {serverStats.memory} GB</p>
|
||||||
// TODO: navigate to logs / monitoring page
|
</div>
|
||||||
onClick={() => {}}
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
>
|
<CpuChipIcon className="h-4 w-4" />
|
||||||
View Full Logs
|
<span>Autoscaled environment (mock)</span>
|
||||||
<ArrowRightIcon className="h-5 w-5" />
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="hidden lg:block border-l border-gray-200" />
|
||||||
|
|
||||||
|
{/* Logs */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 mb-3">
|
||||||
|
Recent Error Logs
|
||||||
|
</h3>
|
||||||
|
{serverStats.recentErrors.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-500 italic">
|
||||||
|
No recent logs.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* Placeholder for future logs list */}
|
||||||
|
{/* TODO: Replace with mapped log entries */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-gray-50 hover:bg-gray-100 text-gray-700 text-sm font-medium px-4 py-3 transition"
|
||||||
|
// TODO: navigate to logs / monitoring page
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
View Full Logs
|
||||||
|
<ArrowRightIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
</div>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
|
import Waves from '../components/waves'
|
||||||
|
|
||||||
type Affiliate = {
|
type Affiliate = {
|
||||||
id: string
|
id: string
|
||||||
@ -89,105 +90,127 @@ export default function AffiliateLinksPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div
|
||||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
||||||
{/* Header (aligned with management pages) */}
|
style={{ backgroundImage: 'none', background: 'none' }}
|
||||||
<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">
|
>
|
||||||
<div>
|
<Waves
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Affiliate Partners</h1>
|
className="pointer-events-none"
|
||||||
<p className="text-lg text-blue-700 mt-2">
|
lineColor="#0f172a"
|
||||||
Discover our trusted partners and earn commissions through affiliate links.
|
backgroundColor="rgba(245, 245, 240, 1)"
|
||||||
</p>
|
waveSpeedX={0.02}
|
||||||
</div>
|
waveSpeedY={0.01}
|
||||||
{/* NEW: Category filter */}
|
waveAmpX={40}
|
||||||
<div className="flex items-center gap-2">
|
waveAmpY={20}
|
||||||
<label className="text-sm text-blue-900 font-medium">Filter by category:</label>
|
friction={0.9}
|
||||||
<select
|
tension={0.01}
|
||||||
value={selectedCategory}
|
maxCursorMove={120}
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
xGap={12}
|
||||||
className="rounded-md border border-blue-200 bg-white px-3 py-1.5 text-sm text-blue-900 shadow-sm"
|
yGap={36}
|
||||||
>
|
/>
|
||||||
{categories.map(c => (
|
|
||||||
<option key={c} value={c}>{c === 'all' ? 'All' : c}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* States */}
|
<div className="relative z-10 min-h-screen flex flex-col">
|
||||||
{loading && (
|
<main className="flex-1 max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8 w-full">
|
||||||
<div className="mx-auto max-w-2xl text-center">
|
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
|
||||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-b-transparent" />
|
{/* Header (aligned with management pages) */}
|
||||||
<p className="mt-4 text-sm text-gray-600">Loading affiliate partners...</p>
|
<header className="flex flex-col gap-4 mb-8">
|
||||||
</div>
|
<div>
|
||||||
)}
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Affiliate Partners</h1>
|
||||||
|
<p className="text-lg text-blue-700 mt-2">
|
||||||
{error && !loading && (
|
Discover our trusted partners and earn commissions through affiliate links.
|
||||||
<div className="mx-auto max-w-2xl rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 text-center">
|
</p>
|
||||||
{error}
|
</div>
|
||||||
</div>
|
{/* NEW: Category filter */}
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-blue-900 font-medium">Filter by category:</label>
|
||||||
{!loading && !error && posts.length === 0 && (
|
<select
|
||||||
<div className="mx-auto max-w-2xl text-center text-sm text-gray-600">
|
value={selectedCategory}
|
||||||
No affiliate partners available at the moment.
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
</div>
|
className="rounded-md border border-blue-200 bg-white px-3 py-1.5 text-sm text-blue-900 shadow-sm"
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cards (aligned to white panels, border, shadow) */}
|
|
||||||
{!loading && !error && posts.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
||||||
{posts.map((post) => {
|
|
||||||
// NEW: highlight when matches selected category (keep all visible)
|
|
||||||
const isHighlighted = selectedCategory !== 'all' && post.category.title === selectedCategory
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={post.id}
|
|
||||||
className={`rounded-2xl bg-white border shadow-lg overflow-hidden flex flex-col transition
|
|
||||||
${isHighlighted ? 'border-2 border-indigo-400 ring-2 ring-indigo-200' : 'border-gray-100'}`}
|
|
||||||
>
|
>
|
||||||
<div className="relative">
|
{categories.map(c => (
|
||||||
<img alt="" src={post.imageUrl} className="aspect-video w-full object-cover" />
|
<option key={c} value={c}>{c === 'all' ? 'All' : c}</option>
|
||||||
</div>
|
))}
|
||||||
<div className="p-6 flex-1 flex flex-col">
|
</select>
|
||||||
<div className="flex items-start justify-between gap-3">
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-blue-900">{post.title}</h3>
|
</header>
|
||||||
{post.commissionRate && (
|
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border border-indigo-200 bg-indigo-50 text-indigo-700">
|
{/* States */}
|
||||||
{post.commissionRate}
|
{loading && (
|
||||||
</span>
|
<div className="mx-auto max-w-2xl text-center">
|
||||||
)}
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-400 border-b-transparent" />
|
||||||
</div>
|
<p className="mt-4 text-sm text-gray-600">Loading affiliate partners...</p>
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
</div>
|
||||||
<a
|
)}
|
||||||
href={post.category.href}
|
|
||||||
className={`inline-flex items-center rounded-full px-2 py-0.5 border text-blue-900
|
{error && !loading && (
|
||||||
${isHighlighted ? 'border-indigo-300 bg-indigo-50' : 'border-blue-200 bg-blue-50'}`}
|
<div className="mx-auto max-w-2xl rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 text-center">
|
||||||
>
|
{error}
|
||||||
{post.category.title}
|
</div>
|
||||||
</a>
|
)}
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm text-gray-700 line-clamp-4">{post.description}</p>
|
{!loading && !error && posts.length === 0 && (
|
||||||
<div className="mt-5 flex items-center justify-between">
|
<div className="mx-auto max-w-2xl text-center text-sm text-gray-600">
|
||||||
<a
|
No affiliate partners available at the moment.
|
||||||
href={post.href}
|
</div>
|
||||||
target="_blank"
|
)}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow transition"
|
{/* Cards (aligned to white panels, border, shadow) */}
|
||||||
>
|
{!loading && !error && posts.length > 0 && (
|
||||||
Visit Affiliate Link
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
</a>
|
{posts.map((post) => {
|
||||||
<span className="text-[11px] text-gray-500">
|
// NEW: highlight when matches selected category (keep all visible)
|
||||||
External partner website.
|
const isHighlighted = selectedCategory !== 'all' && post.category.title === selectedCategory
|
||||||
</span>
|
return (
|
||||||
</div>
|
<article
|
||||||
</div>
|
key={post.id}
|
||||||
</article>
|
className={`rounded-2xl bg-white border shadow-lg overflow-hidden flex flex-col transition
|
||||||
)
|
${isHighlighted ? 'border-2 border-indigo-400 ring-2 ring-indigo-200' : 'border-gray-100'}`}
|
||||||
})}
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<img alt="" src={post.imageUrl} className="aspect-video w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="p-6 flex-1 flex flex-col">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<h3 className="text-xl font-semibold text-blue-900">{post.title}</h3>
|
||||||
|
{post.commissionRate && (
|
||||||
|
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border border-indigo-200 bg-indigo-50 text-indigo-700">
|
||||||
|
{post.commissionRate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
<a
|
||||||
|
href={post.category.href}
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 border text-blue-900
|
||||||
|
${isHighlighted ? 'border-indigo-300 bg-indigo-50' : 'border-blue-200 bg-blue-50'}`}
|
||||||
|
>
|
||||||
|
{post.category.title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-gray-700 line-clamp-4">{post.description}</p>
|
||||||
|
<div className="mt-5 flex items-center justify-between">
|
||||||
|
<a
|
||||||
|
href={post.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow transition"
|
||||||
|
>
|
||||||
|
Visit Affiliate Link
|
||||||
|
</a>
|
||||||
|
<span className="text-[11px] text-gray-500">
|
||||||
|
External partner website.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</main>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import Header from './nav/Header';
|
import Header from './nav/Header';
|
||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
import PageTransitionEffect from './animation/pageTransitionEffect';
|
import PageTransitionEffect from './animation/pageTransitionEffect';
|
||||||
@ -15,18 +16,39 @@ interface PageLayoutProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
showFooter?: boolean;
|
showFooter?: boolean;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageLayout({
|
export default function PageLayout({
|
||||||
children,
|
children,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
showFooter = true
|
showFooter = true,
|
||||||
|
className = 'bg-white text-gray-900',
|
||||||
|
contentClassName = 'flex-1 relative z-10 w-full',
|
||||||
}: PageLayoutProps) {
|
}: PageLayoutProps) {
|
||||||
const isMobile = isMobileDevice();
|
const isMobile = isMobileDevice();
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW
|
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 (
|
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 && (
|
{showHeader && (
|
||||||
<div className="relative z-50 w-full flex-shrink-0">
|
<div className="relative z-50 w-full flex-shrink-0">
|
||||||
@ -35,7 +57,7 @@ export default function PageLayout({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 relative z-10 w-full">
|
<div className={contentClassName}>
|
||||||
<PageTransitionEffect>{children}</PageTransitionEffect>
|
<PageTransitionEffect>{children}</PageTransitionEffect>
|
||||||
</div>
|
</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_MEMBERSHIP = process.env.NEXT_PUBLIC_DISPLAY_MEMBERSHIP !== 'false'
|
||||||
const DISPLAY_ABOUT_US = process.env.NEXT_PUBLIC_DISPLAY_ABOUT_US !== '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_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'
|
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 [mounted, setMounted] = useState(false)
|
||||||
const [animateIn, setAnimateIn] = useState(false)
|
const [animateIn, setAnimateIn] = useState(false)
|
||||||
const [scrollY, setScrollY] = useState(0)
|
const [scrollY, setScrollY] = useState(0)
|
||||||
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const logout = useAuthStore(s => s.logout)
|
const logout = useAuthStore(s => s.logout)
|
||||||
const accessToken = useAuthStore(s => s.accessToken)
|
const accessToken = useAuthStore(s => s.accessToken)
|
||||||
const refreshAuthToken = useAuthStore(s => s.refreshAuthToken)
|
const refreshAuthToken = useAuthStore(s => s.refreshAuthToken)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
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 [hasReferralPerm, setHasReferralPerm] = useState(false)
|
||||||
const [adminMgmtOpen, setAdminMgmtOpen] = useState(false)
|
const [adminMgmtOpen, setAdminMgmtOpen] = useState(false)
|
||||||
const managementRef = useRef<HTMLDivElement | null>(null)
|
const managementRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [canSeeDashboard, setCanSeeDashboard] = useState(false)
|
const [canSeeDashboard, setCanSeeDashboard] = useState(false)
|
||||||
|
const headerElRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
@ -119,12 +132,25 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
setAnimateIn(true)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
|
|
||||||
if (!isHome) {
|
if (!parallaxEnabled) {
|
||||||
// non-home: header always visible, no scroll listeners
|
// non-parallax (and mobile): header always visible, no scroll listeners
|
||||||
setScrollY(100)
|
setScrollY(100)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -149,7 +175,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
window.removeEventListener('wheel', handleWheel)
|
window.removeEventListener('wheel', handleWheel)
|
||||||
}
|
}
|
||||||
}, [mounted, isHome])
|
}, [mounted, parallaxEnabled])
|
||||||
|
|
||||||
// Fetch user permissions and set hasReferralPerm
|
// Fetch user permissions and set hasReferralPerm
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -309,22 +335,82 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
)
|
)
|
||||||
const isAdmin = mounted && rawIsAdmin
|
const isAdmin = mounted && rawIsAdmin
|
||||||
|
|
||||||
// Only gate visibility by scroll on home; elsewhere just use animateIn
|
// Only gate visibility by scroll on parallax-enabled pages
|
||||||
const headerVisible = isHome ? animateIn && scrollY > 24 : animateIn
|
const headerVisible = parallaxEnabled ? animateIn && scrollY > 24 : animateIn
|
||||||
const parallaxOffset = isHome ? Math.max(-16, -scrollY * 0.15) : 0
|
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 (
|
return (
|
||||||
<header
|
<header
|
||||||
className={`${
|
ref={headerElRef}
|
||||||
isHome ? 'fixed top-0 left-0 w-full' : 'relative'
|
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%)] ${
|
||||||
} 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%)] ${
|
|
||||||
isAdmin ? '' : 'border-b border-white/10'
|
isAdmin ? '' : 'border-b border-white/10'
|
||||||
} ${
|
} ${
|
||||||
headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-6 pointer-events-none'
|
headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-6 pointer-events-none'
|
||||||
} transition-all duration-500 ease-out`}
|
} transition-all duration-500 ease-out`}
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #0F1D37 0%, #0A162A 50%, #081224 100%)',
|
background: 'linear-gradient(135deg, #0F1D37 0%, #0A162A 50%, #081224 100%)',
|
||||||
...(isHome ? { transform: `translateY(${parallaxOffset}px)` } : {}),
|
...(parallaxEnabled ? { transform: `translateY(${parallaxOffset}px)` } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
@ -886,7 +972,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
<div className="py-6 space-y-4 px-1">
|
<div className="py-6 space-y-4 px-1">
|
||||||
{/* Information disclosure */}
|
{/* Information disclosure */}
|
||||||
<Disclosure as="div">
|
<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
|
Information
|
||||||
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
|
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
@ -908,7 +994,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
<button
|
<button
|
||||||
key={link.href}
|
key={link.href}
|
||||||
onClick={() => { router.push(link.href); setMobileMenuOpen(false); }}
|
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}
|
{link.name}
|
||||||
</button>
|
</button>
|
||||||
@ -916,7 +1002,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { router.push('/login'); setMobileMenuOpen(false); }}
|
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
|
Log in
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import { createIntlTelInput, IntlTelInputInstance } from '../../utils/phoneUtils
|
|||||||
export type TelephoneInputHandle = {
|
export type TelephoneInputHandle = {
|
||||||
getNumber: () => string
|
getNumber: () => string
|
||||||
isValid: () => boolean
|
isValid: () => boolean
|
||||||
|
// NEW: allow callers to require a selected country code
|
||||||
|
getDialCode: () => string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TelephoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
interface TelephoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||||
@ -24,20 +26,33 @@ interface TelephoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>
|
|||||||
* Always takes full available width.
|
* Always takes full available width.
|
||||||
*/
|
*/
|
||||||
const TelephoneInput = forwardRef<TelephoneInputHandle, TelephoneInputProps>(
|
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 inputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const itiRef = useRef<IntlTelInputInstance | null>(null)
|
const itiRef = useRef<IntlTelInputInstance | null>(null)
|
||||||
|
const readyRef = useRef(false)
|
||||||
|
const lastSyncDigitsRef = useRef<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false
|
let disposed = false
|
||||||
let instance: IntlTelInputInstance | null = null
|
let instance: IntlTelInputInstance | null = null
|
||||||
|
readyRef.current = false
|
||||||
|
lastSyncDigitsRef.current = ''
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
try {
|
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,
|
id: rest.id,
|
||||||
name: rest.name,
|
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) {
|
if (!inputRef.current) {
|
||||||
@ -48,20 +63,90 @@ const TelephoneInput = forwardRef<TelephoneInputHandle, TelephoneInputProps>(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[TelephoneInput] calling createIntlTelInput with options', {
|
||||||
|
id: rest.id,
|
||||||
|
name: rest.name,
|
||||||
|
options: {
|
||||||
|
initialCountry: resolvedCountry,
|
||||||
|
nationalMode: true,
|
||||||
|
strictMode: true,
|
||||||
|
autoPlaceholder: 'aggressive',
|
||||||
|
validationNumberTypes: ['MOBILE'],
|
||||||
|
// Help keep display consistent once utils is available
|
||||||
|
formatOnDisplay: true,
|
||||||
|
formatAsYouType: true,
|
||||||
|
// NEW: keep dropdown anchored (no fullscreen takeover on mobile)
|
||||||
|
useFullscreenPopup: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
instance = await createIntlTelInput(inputRef.current, {
|
instance = await createIntlTelInput(inputRef.current, {
|
||||||
initialCountry,
|
initialCountry: resolvedCountry,
|
||||||
nationalMode: true,
|
nationalMode: true,
|
||||||
strictMode: true,
|
strictMode: true,
|
||||||
autoPlaceholder: 'aggressive',
|
autoPlaceholder: 'aggressive',
|
||||||
validationNumberTypes: ['MOBILE'],
|
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) {
|
if (disposed) {
|
||||||
console.log('[TelephoneInput] setup() finished but component is disposed, destroying instance', {
|
console.log(
|
||||||
id: rest.id,
|
'[TelephoneInput] setup() finished but component is disposed, destroying instance',
|
||||||
name: rest.name,
|
{ id: rest.id, name: rest.name }
|
||||||
})
|
)
|
||||||
instance.destroy()
|
if (instance) {
|
||||||
|
instance.destroy()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,9 +154,23 @@ const TelephoneInput = forwardRef<TelephoneInputHandle, TelephoneInputProps>(
|
|||||||
console.log('[TelephoneInput] intl-tel-input instance attached to input', {
|
console.log('[TelephoneInput] intl-tel-input instance attached to input', {
|
||||||
id: rest.id,
|
id: rest.id,
|
||||||
name: rest.name,
|
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) {
|
} 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 () => {
|
return () => {
|
||||||
disposed = true
|
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) {
|
if (instance) {
|
||||||
console.log('[TelephoneInput] Destroying intl-tel-input instance for', {
|
console.log('[TelephoneInput] Destroying intl-tel-input instance for', {
|
||||||
id: rest.id,
|
id: rest.id,
|
||||||
name: rest.name,
|
name: rest.name,
|
||||||
})
|
})
|
||||||
instance.destroy()
|
try {
|
||||||
|
instance.destroy()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TelephoneInput] Error while destroying instance', {
|
||||||
|
id: rest.id,
|
||||||
|
name: rest.name,
|
||||||
|
error: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
if (itiRef.current === instance) itiRef.current = null
|
if (itiRef.current === instance) itiRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,63 +215,99 @@ const TelephoneInput = forwardRef<TelephoneInputHandle, TelephoneInputProps>(
|
|||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
getNumber: () => {
|
getNumber: () => {
|
||||||
const raw = inputRef.current?.value || ''
|
const raw = inputRef.current?.value || ''
|
||||||
if (itiRef.current) {
|
if (!itiRef.current || !readyRef.current) return raw
|
||||||
|
try {
|
||||||
const intl = itiRef.current.getNumber()
|
const intl = itiRef.current.getNumber()
|
||||||
console.log('[TelephoneInput] getNumber()', {
|
return intl || raw
|
||||||
id: rest.id,
|
} catch {
|
||||||
name: rest.name,
|
return raw
|
||||||
raw,
|
|
||||||
intl,
|
|
||||||
})
|
|
||||||
return intl
|
|
||||||
}
|
}
|
||||||
console.warn(
|
|
||||||
'[TelephoneInput] getNumber() called before intl-tel-input ready, returning raw value',
|
|
||||||
{ id: rest.id, name: rest.name, raw }
|
|
||||||
)
|
|
||||||
return raw
|
|
||||||
},
|
},
|
||||||
isValid: () => {
|
isValid: () => {
|
||||||
if (!itiRef.current) {
|
const raw = inputRef.current?.value || ''
|
||||||
const raw = inputRef.current?.value || ''
|
if (!itiRef.current || !readyRef.current) {
|
||||||
console.warn('[TelephoneInput] isValid() called before intl-tel-input ready', {
|
return /^\+?\d{7,}$/.test(raw.replace(/\s+/g, ''))
|
||||||
id: rest.id,
|
|
||||||
name: rest.name,
|
|
||||||
raw,
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
const instance = itiRef.current
|
try {
|
||||||
const intl = instance.getNumber()
|
return itiRef.current.isValidNumber()
|
||||||
const valid = instance.isValidNumber()
|
} catch {
|
||||||
const errorCode = typeof instance.getValidationError === 'function'
|
return /^\+?\d{7,}$/.test(raw.replace(/\s+/g, ''))
|
||||||
? instance.getValidationError()
|
}
|
||||||
: undefined
|
},
|
||||||
const country = typeof instance.getSelectedCountryData === 'function'
|
getDialCode: () => {
|
||||||
? instance.getSelectedCountryData()
|
const iti = itiRef.current as any
|
||||||
: undefined
|
const data = iti?.getSelectedCountryData?.()
|
||||||
|
const dial = data?.dialCode
|
||||||
console.log('[TelephoneInput] isValid() check', {
|
return typeof dial === 'string' && dial.trim() ? dial.trim() : null
|
||||||
id: rest.id,
|
|
||||||
name: rest.name,
|
|
||||||
intl,
|
|
||||||
valid,
|
|
||||||
errorCode,
|
|
||||||
country,
|
|
||||||
})
|
|
||||||
|
|
||||||
return valid
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full pp-iti-dark">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="tel"
|
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}
|
{...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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -133,6 +133,8 @@ export interface WavesProps {
|
|||||||
maxCursorMove?: number;
|
maxCursorMove?: number;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
animate?: boolean;
|
||||||
|
interactive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Waves: React.FC<WavesProps> = ({
|
const Waves: React.FC<WavesProps> = ({
|
||||||
@ -149,6 +151,8 @@ const Waves: React.FC<WavesProps> = ({
|
|||||||
maxCursorMove = 100,
|
maxCursorMove = 100,
|
||||||
style = {},
|
style = {},
|
||||||
className = '',
|
className = '',
|
||||||
|
animate = true,
|
||||||
|
interactive = true,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(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) {
|
function movePoints(time: number) {
|
||||||
const lines = linesRef.current;
|
const lines = linesRef.current;
|
||||||
const mouse = mouseRef.current;
|
const mouse = mouseRef.current;
|
||||||
@ -265,6 +313,8 @@ const Waves: React.FC<WavesProps> = ({
|
|||||||
p.wave.x = Math.cos(move) * waveAmpX;
|
p.wave.x = Math.cos(move) * waveAmpX;
|
||||||
p.wave.y = Math.sin(move) * waveAmpY;
|
p.wave.y = Math.sin(move) * waveAmpY;
|
||||||
|
|
||||||
|
if (!interactive) return;
|
||||||
|
|
||||||
const dx = p.x - mouse.sx;
|
const dx = p.x - mouse.sx;
|
||||||
const dy = p.y - mouse.sy;
|
const dy = p.y - mouse.sy;
|
||||||
const dist = Math.hypot(dx, dy);
|
const dist = Math.hypot(dx, dy);
|
||||||
@ -288,75 +338,46 @@ const Waves: React.FC<WavesProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function moved(point: Point, withCursor = true): { x: number; y: number } {
|
|
||||||
const x = point.x + point.wave.x + (withCursor ? point.cursor.x : 0);
|
|
||||||
const y = point.y + point.wave.y + (withCursor ? point.cursor.y : 0);
|
|
||||||
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawLines() {
|
|
||||||
const { width, height } = boundingRef.current;
|
|
||||||
const ctx = ctxRef.current;
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, width, height);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.strokeStyle = configRef.current.lineColor;
|
|
||||||
|
|
||||||
linesRef.current.forEach(points => {
|
|
||||||
let p1 = moved(points[0], false);
|
|
||||||
ctx.moveTo(p1.x, p1.y);
|
|
||||||
points.forEach((p, idx) => {
|
|
||||||
const isLast = idx === points.length - 1;
|
|
||||||
p1 = moved(p, !isLast);
|
|
||||||
const p2 = moved(points[idx + 1] || points[points.length - 1], !isLast);
|
|
||||||
ctx.lineTo(p1.x, p1.y);
|
|
||||||
if (isLast) ctx.moveTo(p2.x, p2.y);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tick(t: number) {
|
function tick(t: number) {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const mouse = mouseRef.current;
|
if (interactive) {
|
||||||
mouse.sx += (mouse.x - mouse.sx) * 0.1;
|
const mouse = mouseRef.current;
|
||||||
mouse.sy += (mouse.y - mouse.sy) * 0.1;
|
mouse.sx += (mouse.x - mouse.sx) * 0.1;
|
||||||
const dx = mouse.x - mouse.lx;
|
mouse.sy += (mouse.y - mouse.sy) * 0.1;
|
||||||
const dy = mouse.y - mouse.ly;
|
const dx = mouse.x - mouse.lx;
|
||||||
const d = Math.hypot(dx, dy);
|
const dy = mouse.y - mouse.ly;
|
||||||
mouse.v = d;
|
const d = Math.hypot(dx, dy);
|
||||||
mouse.vs += (d - mouse.vs) * 0.1;
|
mouse.v = d;
|
||||||
mouse.vs = Math.min(100, mouse.vs);
|
mouse.vs += (d - mouse.vs) * 0.1;
|
||||||
mouse.lx = mouse.x;
|
mouse.vs = Math.min(100, mouse.vs);
|
||||||
mouse.ly = mouse.y;
|
mouse.lx = mouse.x;
|
||||||
mouse.a = Math.atan2(dy, dx);
|
mouse.ly = mouse.y;
|
||||||
container.style.setProperty('--x', `${mouse.sx}px`);
|
mouse.a = Math.atan2(dy, dx);
|
||||||
container.style.setProperty('--y', `${mouse.sy}px`);
|
container.style.setProperty('--x', `${mouse.sx}px`);
|
||||||
|
container.style.setProperty('--y', `${mouse.sy}px`);
|
||||||
|
}
|
||||||
|
|
||||||
movePoints(t);
|
movePoints(t);
|
||||||
drawLines();
|
drawLines(true);
|
||||||
frameIdRef.current = requestAnimationFrame(tick);
|
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() {
|
function onResize() {
|
||||||
setSize();
|
setSize();
|
||||||
setLines();
|
setLines();
|
||||||
}
|
if (!animate) drawStatic();
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
|
||||||
updateMouse(e.clientX, e.clientY);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchMove(e: TouchEvent) {
|
|
||||||
const touch = e.touches[0];
|
|
||||||
updateMouse(touch.clientX, touch.clientY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMouse(x: number, y: number) {
|
function updateMouse(x: number, y: number) {
|
||||||
|
if (!interactive) return;
|
||||||
const mouse = mouseRef.current;
|
const mouse = mouseRef.current;
|
||||||
const b = boundingRef.current;
|
const b = boundingRef.current;
|
||||||
mouse.x = x - b.left;
|
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();
|
setSize();
|
||||||
setLines();
|
setLines();
|
||||||
frameIdRef.current = requestAnimationFrame(tick);
|
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
window.addEventListener('mousemove', onMouseMove);
|
ro?.observe(container);
|
||||||
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
||||||
|
if (interactive) {
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
|
window.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
frameIdRef.current = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
drawStatic();
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', onResize);
|
window.removeEventListener('resize', onResize);
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
ro?.disconnect();
|
||||||
window.removeEventListener('touchmove', onTouchMove);
|
if (interactive) {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('touchmove', onTouchMove);
|
||||||
|
}
|
||||||
if (frameIdRef.current !== null) cancelAnimationFrame(frameIdRef.current);
|
if (frameIdRef.current !== null) cancelAnimationFrame(frameIdRef.current);
|
||||||
|
frameIdRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [animate, interactive]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -391,8 +435,14 @@ const Waves: React.FC<WavesProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
...style,
|
...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" />
|
<canvas ref={canvasRef} className="block w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import Header from '../components/nav/Header'
|
import PageLayout from '../components/PageLayout'
|
||||||
import Footer from '../components/Footer'
|
import Waves from '../components/waves'
|
||||||
import {
|
import {
|
||||||
ShoppingBagIcon,
|
ShoppingBagIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
@ -18,6 +18,20 @@ export default function DashboardPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(state => state.user)
|
const user = useAuthStore(state => state.user)
|
||||||
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
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)
|
// Redirect if not logged in (only after auth is ready)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -55,7 +69,9 @@ export default function DashboardPage() {
|
|||||||
description: 'Explore sustainable products',
|
description: 'Explore sustainable products',
|
||||||
icon: ShoppingBagIcon,
|
icon: ShoppingBagIcon,
|
||||||
href: '/shop',
|
href: '/shop',
|
||||||
color: 'bg-blue-500'
|
color: 'bg-blue-500',
|
||||||
|
disabled: !isShopEnabled,
|
||||||
|
disabledText: 'This is currently disabled.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Browse Affiliate Links',
|
title: 'Browse Affiliate Links',
|
||||||
@ -112,147 +128,195 @@ export default function DashboardPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-gray-50">
|
<div
|
||||||
<Header />
|
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">
|
||||||
<div className="max-w-7xl mx-auto">
|
<PageLayout className="bg-transparent text-gray-900">
|
||||||
{/* Welcome Section */}
|
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="mb-8">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-8">
|
||||||
Welcome back, {getUserName()}! 👋
|
{/* Welcome Section */}
|
||||||
</h1>
|
<div className="mb-8">
|
||||||
<p className="text-gray-600 mt-2">
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
Here's what's happening with your Profit Planet account
|
Welcome back, {getUserName()}! 👋
|
||||||
</p>
|
</h1>
|
||||||
</div>
|
<p className="text-gray-600 mt-2">
|
||||||
|
Here's what's happening with your Profit Planet account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* News Section (replaces Account setup + Stats Grid) */}
|
{/* News Section (replaces Account setup + Stats Grid) */}
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Latest News & Articles</h2>
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Latest News & Articles</h2>
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{news.map(item => (
|
{news.map(item => (
|
||||||
<article key={item.id} className="group relative overflow-hidden rounded-xl bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
<article key={item.id} className="group relative overflow-hidden rounded-xl bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
|
||||||
{/* Image/placeholder */}
|
{/* Image/placeholder */}
|
||||||
<div className="aspect-[16/9] w-full bg-gradient-to-br from-gray-100 to-gray-200" />
|
<div className="aspect-[16/9] w-full bg-gradient-to-br from-gray-100 to-gray-200" />
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 ring-1 ring-amber-200">
|
<span className="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 ring-1 ring-amber-200">
|
||||||
{item.category}
|
{item.category}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">{new Date(item.date).toLocaleDateString()}</span>
|
<span className="text-xs text-gray-500">{new Date(item.date).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-semibold text-gray-900 group-hover:text-[#8D6B1D] transition-colors">
|
<h3 className="text-base font-semibold text-gray-900 group-hover:text-[#8D6B1D] transition-colors">
|
||||||
<button
|
<button
|
||||||
onClick={() => (window.location.href = item.href)}
|
onClick={() => (window.location.href = item.href)}
|
||||||
className="text-left w-full"
|
className="text-left w-full"
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm text-gray-600 line-clamp-3">{item.excerpt}</p>
|
<p className="mt-2 text-sm text-gray-600 line-clamp-3">{item.excerpt}</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => (window.location.href = item.href)}
|
onClick={() => (window.location.href = item.href)}
|
||||||
className="text-sm font-medium text-[#8D6B1D] hover:text-[#7A5E1A]"
|
className="text-sm font-medium text-[#8D6B1D] hover:text-[#7A5E1A]"
|
||||||
>
|
>
|
||||||
Read more →
|
Read more →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{quickActions.map((action, index) => (
|
{quickActions.map((action, index) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => router.push(action.href)}
|
onClick={() => {
|
||||||
className="bg-white rounded-lg p-6 shadow-sm border border-gray-200 hover:shadow-md transition-shadow text-left group"
|
if (!action.disabled) {
|
||||||
>
|
router.push(action.href)
|
||||||
<div className="flex items-start">
|
}
|
||||||
<div className={`${action.color} rounded-lg p-3 group-hover:scale-105 transition-transform`}>
|
}}
|
||||||
<action.icon className="h-6 w-6 text-white" />
|
disabled={Boolean(action.disabled)}
|
||||||
</div>
|
className={`bg-white rounded-lg p-6 border border-gray-200 text-left group transition-all duration-200 ${
|
||||||
<div className="ml-4 flex-1">
|
action.disabled
|
||||||
<h3 className="text-lg font-medium text-gray-900 group-hover:text-[#8D6B1D] transition-colors">
|
? 'opacity-60 cursor-not-allowed'
|
||||||
{action.title}
|
: 'shadow-sm hover:shadow-lg hover:-translate-y-1 hover:-translate-y-1 hover:-translate-y-1 transform hover:-translate-y-1'
|
||||||
</h3>
|
}`}
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
>
|
||||||
{action.description}
|
<div className="flex items-start">
|
||||||
|
<div
|
||||||
|
className={`${action.color} rounded-lg p-3 ${
|
||||||
|
action.disabled
|
||||||
|
? 'grayscale'
|
||||||
|
: 'group-hover:scale-105 transition-transform'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<action.icon className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex-1">
|
||||||
|
<h3
|
||||||
|
className={`text-lg font-medium transition-colors ${
|
||||||
|
action.disabled
|
||||||
|
? 'text-gray-500'
|
||||||
|
: 'text-gray-900 group-hover:text-[#8D6B1D]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{action.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{action.description}
|
||||||
|
</p>
|
||||||
|
{action.disabled && action.disabledText && (
|
||||||
|
<p className="mt-3 text-xs font-medium text-amber-700">
|
||||||
|
{action.disabledText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gold Member Status */}
|
||||||
|
<div className="bg-gradient-to-r from-[#8D6B1D] to-[#B8860B] rounded-lg p-6 text-white mb-8">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<StarIcon className="h-12 w-12 text-yellow-300" />
|
||||||
|
<div className="ml-4">
|
||||||
|
<h2 className="text-2xl font-bold">Gold Member Status</h2>
|
||||||
|
<p className="text-yellow-100 mt-1">
|
||||||
|
Enjoy exclusive benefits and discounts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<button className="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg text-white font-medium transition-colors">
|
||||||
|
View Benefits
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gold Member Status */}
|
{/* Recent Activity */}
|
||||||
<div className="bg-gradient-to-r from-[#8D6B1D] to-[#B8860B] rounded-lg p-6 text-white mb-8">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
<div className="flex items-center">
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Recent Activity</h2>
|
||||||
<StarIcon className="h-12 w-12 text-yellow-300" />
|
<div className="space-y-4">
|
||||||
<div className="ml-4">
|
<div className="flex items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||||
<h2 className="text-2xl font-bold">Gold Member Status</h2>
|
<div className="bg-green-100 rounded-full p-2">
|
||||||
<p className="text-yellow-100 mt-1">
|
<ShoppingBagIcon className="h-5 w-5 text-green-600" />
|
||||||
Enjoy exclusive benefits and discounts
|
</div>
|
||||||
</p>
|
<div className="ml-4 flex-1">
|
||||||
</div>
|
<p className="text-sm font-medium text-gray-900">Order completed</p>
|
||||||
<div className="ml-auto">
|
<p className="text-sm text-gray-600">Eco-friendly water bottle</p>
|
||||||
<button className="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg text-white font-medium transition-colors">
|
</div>
|
||||||
View Benefits
|
<span className="text-sm text-gray-500">2 days ago</span>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||||
|
<div className="bg-blue-100 rounded-full p-2">
|
||||||
|
<HeartIcon className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">Added to favorites</p>
|
||||||
|
<p className="text-sm text-gray-600">Sustainable backpack</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">1 week ago</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center py-3">
|
||||||
|
<div className="bg-purple-100 rounded-full p-2">
|
||||||
|
<UsersIcon className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">Joined community</p>
|
||||||
|
<p className="text-sm text-gray-600">Eco Warriors Group</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">2 weeks ago</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
</PageLayout>
|
||||||
{/* Recent Activity */}
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Recent Activity</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center py-3 border-b border-gray-100 last:border-b-0">
|
|
||||||
<div className="bg-green-100 rounded-full p-2">
|
|
||||||
<ShoppingBagIcon className="h-5 w-5 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 flex-1">
|
|
||||||
<p className="text-sm font-medium text-gray-900">Order completed</p>
|
|
||||||
<p className="text-sm text-gray-600">Eco-friendly water bottle</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-500">2 days ago</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center py-3 border-b border-gray-100 last:border-b-0">
|
|
||||||
<div className="bg-blue-100 rounded-full p-2">
|
|
||||||
<HeartIcon className="h-5 w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 flex-1">
|
|
||||||
<p className="text-sm font-medium text-gray-900">Added to favorites</p>
|
|
||||||
<p className="text-sm text-gray-600">Sustainable backpack</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-500">1 week ago</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center py-3">
|
|
||||||
<div className="bg-purple-100 rounded-full p-2">
|
|
||||||
<UsersIcon className="h-5 w-5 text-purple-600" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 flex-1">
|
|
||||||
<p className="text-sm font-medium text-gray-900">Joined community</p>
|
|
||||||
<p className="text-sm text-gray-600">Eco Warriors Group</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-500">2 weeks ago</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -6,32 +6,23 @@ import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
|||||||
import { useLogin } from '../hooks/useLogin'
|
import { useLogin } from '../hooks/useLogin'
|
||||||
import { useToast } from '../../components/toast/toastComponent'
|
import { useToast } from '../../components/toast/toastComponent'
|
||||||
|
|
||||||
|
const GLASS_BG = 'rgba(255,255,255,0.55)'
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [showBall, setShowBall] = useState(true)
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
rememberMe: false
|
rememberMe: false
|
||||||
})
|
})
|
||||||
// FIX: use a static initial width so SSR and first client render match
|
|
||||||
const [viewportWidth, setViewportWidth] = useState<number>(1200)
|
const [viewportWidth, setViewportWidth] = useState<number>(1200)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { login, error, setError, loading } = useLogin()
|
const { login, error, setError, loading } = useLogin()
|
||||||
const { showToast } = useToast()
|
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(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => setViewportWidth(window.innerWidth)
|
const handleResize = () => setViewportWidth(window.innerWidth)
|
||||||
handleResize() // initialize on mount (runs only on client)
|
handleResize()
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
}, [])
|
}, [])
|
||||||
@ -47,22 +38,22 @@ export default function LoginForm() {
|
|||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
if (!formData.email.trim()) {
|
if (!formData.email.trim()) {
|
||||||
setError('E-Mail-Adresse ist erforderlich')
|
setError('Email address is required')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.password.trim()) {
|
if (!formData.password.trim()) {
|
||||||
setError('Passwort ist erforderlich')
|
setError('Password is required')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.password.length < 6) {
|
if (formData.password.length < 6) {
|
||||||
setError('Passwort muss mindestens 6 Zeichen lang sein')
|
setError('Password must be at least 6 characters long')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +97,7 @@ export default function LoginForm() {
|
|||||||
|
|
||||||
// CHANGED: Wider base widths; no transform scaling
|
// CHANGED: Wider base widths; no transform scaling
|
||||||
const formWidth = isMobile
|
const formWidth = isMobile
|
||||||
? '94vw'
|
? '100%'
|
||||||
: isTablet
|
: isTablet
|
||||||
? '80vw'
|
? '80vw'
|
||||||
: isSmallLaptop
|
: isSmallLaptop
|
||||||
@ -114,7 +105,7 @@ export default function LoginForm() {
|
|||||||
: '52vw'
|
: '52vw'
|
||||||
|
|
||||||
const formMaxWidth = isMobile
|
const formMaxWidth = isMobile
|
||||||
? '480px'
|
? '420px'
|
||||||
: isTablet
|
: isTablet
|
||||||
? '760px'
|
? '760px'
|
||||||
: isSmallLaptop
|
: isSmallLaptop
|
||||||
@ -125,145 +116,62 @@ export default function LoginForm() {
|
|||||||
<div
|
<div
|
||||||
className="w-full relative"
|
className="w-full relative"
|
||||||
style={{
|
style={{
|
||||||
// CHANGED: full-height flex box for perfect vertical centering
|
// removed full-height so curved loop is visible right under the form
|
||||||
minHeight: '100vh',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
// REMOVE marble image so Waves shows through
|
// REMOVE marble image so Waves shows through
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
// Subtle padding to breathe on mobile
|
// move the card slightly down on mobile, reduce bottom padding
|
||||||
padding: isMobile ? '0.75rem' : '1.5rem',
|
padding: isMobile ? '0.5rem 0.75rem 0' : '0.2rem 1.5rem 1.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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={{
|
style={{
|
||||||
width: formWidth,
|
width: formWidth,
|
||||||
maxWidth: formMaxWidth,
|
maxWidth: formMaxWidth,
|
||||||
minWidth: isMobile ? '0' : '420px',
|
minWidth: isMobile ? '0' : '420px',
|
||||||
// CHANGED: tighter padding; removed transform scaling
|
// slightly tighter on mobile
|
||||||
padding: isMobile ? '1rem' : '2rem',
|
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 */}
|
{/* Content (title + earth removed) */}
|
||||||
{showBall && !isMobile && (
|
<div
|
||||||
<div className="absolute -top-16 left-1/2 -translate-x-1/2 w-28 z-20">
|
style={{
|
||||||
<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">
|
// CHANGED: smaller margins; the card is centered now
|
||||||
{/* Inner small circle with cartoony Earth */}
|
marginTop: isMobile ? '0.15rem' : isTablet ? '0.5rem' : '0.75rem',
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
marginBottom: isMobile ? '0.75rem' : isTablet ? '1.25rem' : '1.5rem',
|
||||||
<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">
|
width: '100%',
|
||||||
<svg
|
}}
|
||||||
viewBox="0 0 64 64"
|
>
|
||||||
className="w-14 h-14"
|
{/* Title + Subtitle (restored) */}
|
||||||
role="img"
|
<div className="mb-6 text-center">
|
||||||
aria-label="Cartoon Earth"
|
<h1 className="text-2xl md:text-3xl font-extrabold tracking-tight text-[#0F172A] drop-shadow-sm">
|
||||||
>
|
PROFIT PLANET
|
||||||
<defs>
|
</h1>
|
||||||
<radialGradient id="earth-ocean" cx="50%" cy="40%" r="65%">
|
<p className="mt-1 text-sm md:text-base text-slate-700/90">
|
||||||
<stop offset="0%" stopColor="#3fa9f5" />
|
Welcome back! Log in to continue.
|
||||||
<stop offset="100%" stopColor="#1d5fae" />
|
</p>
|
||||||
</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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<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',
|
|
||||||
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
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="space-y-6 w-full"
|
className={`${isMobile ? 'space-y-4' : 'space-y-6'} w-full`}
|
||||||
style={{
|
style={{
|
||||||
gap: isMobile ? '0.75rem' : isTablet ? '0.9rem' : '1rem',
|
gap: isMobile ? '0.6rem' : isTablet ? '0.9rem' : '1rem',
|
||||||
}}
|
}}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
{/* Email Field */}
|
{/* Email Field */}
|
||||||
<div>
|
<div className="field-animated">
|
||||||
<label
|
<label
|
||||||
htmlFor="email"
|
htmlFor="email"
|
||||||
className="block text-base font-semibold text-[#0F172A] mb-1"
|
className="block text-base font-semibold text-[#0F172A] mb-1"
|
||||||
@ -272,7 +180,7 @@ export default function LoginForm() {
|
|||||||
marginBottom: isMobile ? '0.25rem' : undefined,
|
marginBottom: isMobile ? '0.25rem' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
E-Mail-Adresse
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
@ -281,18 +189,18 @@ export default function LoginForm() {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleInputChange}
|
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={{
|
style={{
|
||||||
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1rem',
|
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1rem',
|
||||||
padding: isMobile ? '0.5rem 0.75rem' : isTablet ? '0.6rem 0.875rem' : '0.7rem 1rem',
|
padding: isMobile ? '0.5rem 0.75rem' : isTablet ? '0.6rem 0.875rem' : '0.7rem 1rem',
|
||||||
}}
|
}}
|
||||||
placeholder="deine@email.com"
|
placeholder="you@example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password Field */}
|
{/* Password Field */}
|
||||||
<div>
|
<div className="field-animated">
|
||||||
<label
|
<label
|
||||||
htmlFor="password"
|
htmlFor="password"
|
||||||
className="block text-base font-semibold text-[#0F172A] mb-1"
|
className="block text-base font-semibold text-[#0F172A] mb-1"
|
||||||
@ -301,22 +209,26 @@ export default function LoginForm() {
|
|||||||
marginBottom: isMobile ? '0.25rem' : undefined,
|
marginBottom: isMobile ? '0.25rem' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Passwort
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? 'text' : 'password'}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={handleInputChange}
|
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={{
|
style={{
|
||||||
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1rem',
|
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
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -331,35 +243,6 @@ export default function LoginForm() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
@ -374,10 +257,10 @@ export default function LoginForm() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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
|
loading
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
? 'border-white/30 bg-white/20 text-slate-300 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/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={{
|
style={{
|
||||||
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1rem',
|
fontSize: isMobile ? '0.95rem' : isTablet ? '1rem' : '1rem',
|
||||||
@ -385,12 +268,12 @@ export default function LoginForm() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||||
Anmeldung läuft...
|
Signing in...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
'Anmelden'
|
'Sign in'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -402,74 +285,50 @@ export default function LoginForm() {
|
|||||||
className="text-[#8D6B1D] hover:text-[#7A5E1A] hover:underline text-sm font-medium transition-colors"
|
className="text-[#8D6B1D] hover:text-[#7A5E1A] hover:underline text-sm font-medium transition-colors"
|
||||||
onClick={() => router.push("/password-reset")}
|
onClick={() => router.push("/password-reset")}
|
||||||
>
|
>
|
||||||
Passwort vergessen?
|
Forgot password?
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* CSS Animations */}
|
{/* Input animations */}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
@keyframes orbit-1 {
|
@keyframes field-fade-in {
|
||||||
0% { transform: rotate(0deg); }
|
from {
|
||||||
100% { transform: rotate(360deg); }
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.99);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@keyframes orbit-2 {
|
|
||||||
0% { transform: rotate(0deg); }
|
@keyframes input-focus-pulse {
|
||||||
100% { transform: rotate(-360deg); }
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.animate-orbit-1 {
|
|
||||||
animation: orbit-1 3s linear infinite;
|
.field-animated {
|
||||||
transform-origin: 0 0;
|
animation: field-fade-in 0.45s ease-out both;
|
||||||
}
|
}
|
||||||
.animate-orbit-2 {
|
|
||||||
animation: orbit-2 4s linear infinite;
|
.input-animated {
|
||||||
transform-origin: 0 0;
|
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>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,9 +8,11 @@ import useAuthStore from '../store/authStore'
|
|||||||
import { ToastProvider } from '../components/toast/toastComponent'
|
import { ToastProvider } from '../components/toast/toastComponent'
|
||||||
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
||||||
import Waves from '../components/waves'
|
import Waves from '../components/waves'
|
||||||
|
import CurvedLoop from '../components/curvedLoop'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [hasHydrated, setHasHydrated] = useState(false)
|
const [hasHydrated, setHasHydrated] = useState(false)
|
||||||
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(state => state.user)
|
const user = useAuthStore(state => state.user)
|
||||||
|
|
||||||
@ -19,6 +21,18 @@ export default function LoginPage() {
|
|||||||
setHasHydrated(true)
|
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
|
// Redirect if user is already logged in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@ -47,33 +61,75 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<PageTransitionEffect>
|
<PageTransitionEffect>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<PageLayout showFooter={true}>
|
{/* NEW: page-level background wrapper so Waves covers everything */}
|
||||||
<div
|
<div className="relative min-h-screen w-full overflow-x-hidden" style={{ backgroundImage: 'none', background: 'none' }}>
|
||||||
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
<Waves
|
||||||
style={{ backgroundImage: 'none', background: 'none' }}
|
className="pointer-events-none"
|
||||||
>
|
lineColor="#0f172a"
|
||||||
{/* Waves background */}
|
backgroundColor="rgba(245, 245, 240, 1)"
|
||||||
<Waves
|
waveSpeedX={0.02}
|
||||||
className="pointer-events-none"
|
waveSpeedY={0.01}
|
||||||
lineColor="#0f172a"
|
waveAmpX={40}
|
||||||
backgroundColor="rgba(245, 245, 240, 1)"
|
waveAmpY={20}
|
||||||
waveSpeedX={0.02}
|
friction={0.9}
|
||||||
waveSpeedY={0.01}
|
tension={0.01}
|
||||||
waveAmpX={40}
|
maxCursorMove={120}
|
||||||
waveAmpY={20}
|
xGap={12}
|
||||||
friction={0.9}
|
yGap={36}
|
||||||
tension={0.01}
|
animate={!isMobile}
|
||||||
maxCursorMove={120}
|
interactive={!isMobile}
|
||||||
xGap={12}
|
/>
|
||||||
yGap={36}
|
|
||||||
/>
|
<PageLayout showFooter={true} className="bg-transparent text-gray-900">
|
||||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
{/* ...existing code... */}
|
||||||
<div className="w-full">
|
<div
|
||||||
<LoginForm />
|
className={`relative z-10 w-full flex flex-col flex-1 min-h-0 ${
|
||||||
</div>
|
isMobile ? 'overflow-y-hidden' : ''
|
||||||
|
}`}
|
||||||
|
style={{ backgroundImage: 'none', background: 'none' }}
|
||||||
|
>
|
||||||
|
{/* REMOVED: Waves background moved to wrapper */}
|
||||||
|
|
||||||
|
{isMobile ? (
|
||||||
|
// ...existing code...
|
||||||
|
<div
|
||||||
|
className="relative z-10 flex-1 min-h-0 grid place-items-center px-3"
|
||||||
|
style={{ paddingTop: '6rem', paddingBottom: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full"
|
||||||
|
style={{
|
||||||
|
// push a bit down (visual centering with header + footer)
|
||||||
|
transform: 'translateY(clamp(10px, 2vh, 28px))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// ...existing code...
|
||||||
|
<div
|
||||||
|
className="relative z-10 flex-1 min-h-0 flex flex-col justify-between"
|
||||||
|
style={{ paddingTop: '0.75rem', paddingBottom: '1rem' }}
|
||||||
|
>
|
||||||
|
<div className="w-full px-4 sm:px-0">
|
||||||
|
<CurvedLoop
|
||||||
|
marqueeText="Welcome to profit planet ✦"
|
||||||
|
speed={1}
|
||||||
|
interactive={false}
|
||||||
|
className="tracking-[0.2em]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex items-center justify-center px-3 sm:px-0">
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* ...existing code... */}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
</div>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</PageTransitionEffect>
|
</PageTransitionEffect>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -11,10 +11,33 @@ import SplitText from './components/SplitText';
|
|||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isHover, setIsHover] = useState(false);
|
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();
|
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 = () => {
|
const handleLoginClick = () => {
|
||||||
if (!containerRef.current) {
|
// Mobile: no page fade animation
|
||||||
|
if (isMobile || !containerRef.current) {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
return;
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (isMobile) return;
|
||||||
const resetHover = () => setIsHover(false);
|
const resetHover = () => setIsHover(false);
|
||||||
window.addEventListener('wheel', resetHover, { passive: true });
|
window.addEventListener('wheel', resetHover, { passive: true });
|
||||||
window.addEventListener('scroll', resetHover, { passive: true });
|
window.addEventListener('scroll', resetHover, { passive: true });
|
||||||
@ -36,7 +60,10 @@ export default function HomePage() {
|
|||||||
window.removeEventListener('wheel', resetHover);
|
window.removeEventListener('wheel', resetHover);
|
||||||
window.removeEventListener('scroll', resetHover);
|
window.removeEventListener('scroll', resetHover);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [isMobile]);
|
||||||
|
|
||||||
|
// Prevent any home UI flash on mobile
|
||||||
|
if (isMobile) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
@ -44,7 +71,7 @@ export default function HomePage() {
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="min-h-screen flex items-center justify-center relative overflow-hidden bg-black text-white"
|
className="min-h-screen flex items-center justify-center relative overflow-hidden bg-black text-white"
|
||||||
>
|
>
|
||||||
{/* Waves background (reverted settings) */}
|
{/* Waves background */}
|
||||||
<Waves
|
<Waves
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
lineColor="#0f172a"
|
lineColor="#0f172a"
|
||||||
@ -58,36 +85,45 @@ export default function HomePage() {
|
|||||||
maxCursorMove={120}
|
maxCursorMove={120}
|
||||||
xGap={12}
|
xGap={12}
|
||||||
yGap={36}
|
yGap={36}
|
||||||
|
animate={!isMobile}
|
||||||
|
interactive={!isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h1 className="z-10">
|
<h1 className="z-10">
|
||||||
<a
|
<a
|
||||||
onMouseEnter={() => setIsHover(true)}
|
|
||||||
onMouseLeave={() => setIsHover(false)}
|
|
||||||
onClick={handleLoginClick}
|
onClick={handleLoginClick}
|
||||||
|
onMouseEnter={isMobile ? undefined : () => setIsHover(true)}
|
||||||
|
onMouseLeave={isMobile ? undefined : () => setIsHover(false)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<SplitText
|
{isMobile ? (
|
||||||
key={isHover ? 'login' : 'profit-planet'}
|
<span className="block text-5xl sm:text-6xl font-bold text-gray-500 text-center px-4">
|
||||||
text={isHover ? 'LOGIN' : 'PROFIT PLANET'}
|
PROFIT PLANET
|
||||||
tag="span"
|
</span>
|
||||||
className={`text-9xl md:text-9xl font-bold transition-colors duration-300 ${
|
) : (
|
||||||
isHover ? 'text-black' : 'text-gray-500'
|
<SplitText
|
||||||
}`}
|
key={isHover ? 'login' : 'profit-planet'}
|
||||||
delay={100}
|
text={isHover ? 'LOGIN' : 'PROFIT PLANET'}
|
||||||
duration={0.6}
|
tag="span"
|
||||||
ease="power3.out"
|
className={`text-7xl sm:text-8xl md:text-9xl font-bold transition-colors duration-300 ${
|
||||||
splitType="chars"
|
isHover ? 'text-black' : 'text-gray-500'
|
||||||
from={{ opacity: 0, y: 40 }}
|
}`}
|
||||||
to={{ opacity: 1, y: 0 }}
|
delay={100}
|
||||||
threshold={0.1}
|
duration={0.6}
|
||||||
rootMargin="-100px"
|
ease="power3.out"
|
||||||
textAlign="center"
|
splitType="chars"
|
||||||
/>
|
from={{ opacity: 0, y: 40 }}
|
||||||
|
to={{ opacity: 1, y: 0 }}
|
||||||
|
threshold={0.1}
|
||||||
|
rootMargin="-100px"
|
||||||
|
textAlign="center"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<Crosshair containerRef={containerRef} color="#0f172a" />
|
{/* No parallax/crosshair on mobile */}
|
||||||
|
{!isMobile && <Crosshair containerRef={containerRef} color="#0f172a" />}
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,8 +3,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import PageLayout from '../components/PageLayout'
|
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 searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const token = searchParams.get('token')
|
const token = searchParams.get('token')
|
||||||
@ -22,6 +24,7 @@ export default function PasswordResetPage() {
|
|||||||
const [resetLoading, setResetLoading] = useState(false)
|
const [resetLoading, setResetLoading] = useState(false)
|
||||||
const [resetSuccess, setResetSuccess] = useState(false)
|
const [resetSuccess, setResetSuccess] = useState(false)
|
||||||
const [resetError, setResetError] = useState('')
|
const [resetError, setResetError] = useState('')
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
// Basic validators
|
// Basic validators
|
||||||
const validEmail = (val: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
|
const validEmail = (val: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
|
||||||
@ -40,7 +43,13 @@ export default function PasswordResetPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (requestLoading) return
|
if (requestLoading) return
|
||||||
if (!validEmail(email)) {
|
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
|
return
|
||||||
}
|
}
|
||||||
setRequestError('')
|
setRequestError('')
|
||||||
@ -49,8 +58,19 @@ export default function PasswordResetPage() {
|
|||||||
// TODO: call API endpoint: POST /auth/password-reset/request
|
// TODO: call API endpoint: POST /auth/password-reset/request
|
||||||
await new Promise(r => setTimeout(r, 1100))
|
await new Promise(r => setTimeout(r, 1100))
|
||||||
setRequestSuccess(true)
|
setRequestSuccess(true)
|
||||||
|
showToast({
|
||||||
|
variant: 'success',
|
||||||
|
title: 'Password reset email',
|
||||||
|
message: 'If this email exists, a reset link has been sent.',
|
||||||
|
})
|
||||||
} catch {
|
} 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 {
|
} finally {
|
||||||
setRequestLoading(false)
|
setRequestLoading(false)
|
||||||
}
|
}
|
||||||
@ -60,11 +80,23 @@ export default function PasswordResetPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (resetLoading) return
|
if (resetLoading) return
|
||||||
if (!validPassword(password)) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if (password !== confirmPassword) {
|
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
|
return
|
||||||
}
|
}
|
||||||
setResetError('')
|
setResetError('')
|
||||||
@ -73,89 +105,92 @@ export default function PasswordResetPage() {
|
|||||||
// TODO: call API endpoint: POST /auth/password-reset/confirm { token, password }
|
// TODO: call API endpoint: POST /auth/password-reset/confirm { token, password }
|
||||||
await new Promise(r => setTimeout(r, 1200))
|
await new Promise(r => setTimeout(r, 1200))
|
||||||
setResetSuccess(true)
|
setResetSuccess(true)
|
||||||
|
showToast({
|
||||||
|
variant: 'success',
|
||||||
|
title: 'Password updated',
|
||||||
|
message: 'Your password has been changed. Redirecting to login...',
|
||||||
|
})
|
||||||
} catch {
|
} 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 {
|
} finally {
|
||||||
setResetLoading(false)
|
setResetLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHints = [
|
const passwordHints = [
|
||||||
{ label: 'Mindestens 8 Zeichen', pass: password.length >= 8 },
|
{ label: 'At least 8 characters', pass: password.length >= 8 },
|
||||||
{ label: 'Großbuchstabe (A-Z)', pass: /[A-Z]/.test(password) },
|
{ label: 'Uppercase letter (A-Z)', pass: /[A-Z]/.test(password) },
|
||||||
{ label: 'Kleinbuchstabe (a-z)', pass: /[a-z]/.test(password) },
|
{ label: 'Lowercase letter (a-z)', pass: /[a-z]/.test(password) },
|
||||||
{ label: 'Ziffer (0-9)', pass: /\d/.test(password) },
|
{ label: 'Number (0-9)', pass: /\d/.test(password) },
|
||||||
{ label: 'Sonderzeichen (!@#$...)', pass: /[\W_]/.test(password) }
|
{ label: 'Special character (!@#$...)', pass: /[\W_]/.test(password) }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<main className="relative flex flex-col flex-1 pt-20 sm:pt-28 pb-12 sm:pb-16 overflow-hidden">
|
<div
|
||||||
{/* Background Pattern */}
|
className="relative w-full flex flex-col min-h-screen overflow-hidden"
|
||||||
<svg
|
style={{ backgroundImage: 'none', background: 'none' }}
|
||||||
aria-hidden="true"
|
>
|
||||||
className="absolute inset-0 -z-10 h-full w-full stroke-white/10"
|
<Waves
|
||||||
>
|
className="pointer-events-none"
|
||||||
<defs>
|
lineColor="#0f172a"
|
||||||
<pattern
|
backgroundColor="rgba(245, 245, 240, 1)"
|
||||||
x="50%"
|
waveSpeedX={0.02}
|
||||||
y={-1}
|
waveSpeedY={0.01}
|
||||||
id="affiliate-pattern"
|
waveAmpX={40}
|
||||||
width={200}
|
waveAmpY={20}
|
||||||
height={200}
|
friction={0.9}
|
||||||
patternUnits="userSpaceOnUse"
|
tension={0.01}
|
||||||
|
maxCursorMove={120}
|
||||||
|
xGap={12}
|
||||||
|
yGap={36}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* push content a bit further down while still centering */}
|
||||||
|
<main className="relative z-10 flex flex-col flex-1 items-center justify-center pt-32 sm:pt-0 pb-8 sm:pb-10">
|
||||||
|
{/* Widened container to match header */}
|
||||||
|
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex flex-col w-full">
|
||||||
|
{/* Translucent form card (matching login glass style) */}
|
||||||
|
<div
|
||||||
|
className="mx-auto w-full max-w-3xl rounded-3xl shadow-2xl relative border border-white/35 overflow-hidden p-6 sm:p-10 md:py-12 md:px-14"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.55)',
|
||||||
|
backdropFilter: 'blur(18px)',
|
||||||
|
WebkitBackdropFilter: 'blur(18px)',
|
||||||
|
boxShadow: '0 18px 45px rgba(15,23,42,0.45)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
|
<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" />
|
||||||
</pattern>
|
<div className="relative">
|
||||||
</defs>
|
<div className="mx-auto max-w-2xl text-center mb-8">
|
||||||
<rect fill="url(#affiliate-pattern)" width="100%" height="100%" strokeWidth={0} />
|
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">
|
||||||
</svg>
|
Reset password
|
||||||
{/* Colored Blur Effect */}
|
</h1>
|
||||||
<div
|
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
||||||
aria-hidden="true"
|
{!token
|
||||||
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"
|
? 'Request a link to reset your password.'
|
||||||
>
|
: 'Set a new secure password.'}
|
||||||
<div
|
</p>
|
||||||
style={{
|
</div>
|
||||||
clipPath:
|
|
||||||
'polygon(63.1% 29.5%, 100% 17.1%, 76.6% 3%, 48.4% 0%, 44.6% 4.7%, 54.5% 25.3%, 59.8% 49%, 55.2% 57.8%, 44.4% 57.2%, 27.8% 47.9%, 35.1% 81.5%, 0% 97.7%, 39.2% 100%, 35.2% 81.4%, 97.2% 52.8%, 63.1% 29.5%)',
|
|
||||||
}}
|
|
||||||
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Gradient base */}
|
|
||||||
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
|
|
||||||
|
|
||||||
{/* Widened container to match header */}
|
|
||||||
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex-1 flex flex-col w-full">
|
|
||||||
<div className="mx-auto max-w-2xl text-center mb-10">
|
|
||||||
<h1 className="text-4xl font-semibold tracking-tight text-white sm:text-5xl">
|
|
||||||
Passwort zurücksetzen
|
|
||||||
</h1>
|
|
||||||
<p className="mt-3 text-gray-300 text-lg/7">
|
|
||||||
{!token
|
|
||||||
? 'Fordere einen Link zum Zurücksetzen deines Passworts an.'
|
|
||||||
: 'Lege ein neues sicheres Passwort fest.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wider form card */}
|
|
||||||
<div className="mx-auto w-full max-w-3xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl ring-1 ring-gray-200 dark:ring-white/10 p-6 sm:p-10 md:py-12 md:px-14 relative overflow-hidden">
|
|
||||||
<div className="absolute -top-24 -right-24 size-56 rounded-full bg-gradient-to-br from-indigo-500/10 to-fuchsia-500/10 blur-3xl pointer-events-none" />
|
|
||||||
<div className="relative">
|
|
||||||
{!token && (
|
{!token && (
|
||||||
<form onSubmit={handleRequestSubmit} className="space-y-6">
|
<form onSubmit={handleRequestSubmit} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2" htmlFor="email">
|
<label className="block text-sm font-semibold text-[#0F172A] mb-2" htmlFor="email">
|
||||||
E-Mail-Adresse
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => { setEmail(e.target.value); setRequestError(''); setRequestSuccess(false)}}
|
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"
|
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="dein.email@example.com"
|
placeholder="your.email@example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -168,17 +203,17 @@ export default function PasswordResetPage() {
|
|||||||
|
|
||||||
{requestSuccess && (
|
{requestSuccess && (
|
||||||
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={requestLoading}
|
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
|
requestLoading
|
||||||
? 'bg-gray-400 cursor-wait'
|
? 'border-white/30 bg-white/20 text-slate-300 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/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 ? (
|
{requestLoading ? (
|
||||||
@ -187,18 +222,18 @@ export default function PasswordResetPage() {
|
|||||||
Senden...
|
Senden...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Zurücksetzlink anfordern'
|
'Request reset link'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-center text-sm text-gray-700">
|
||||||
Erinnerst du dich?{' '}
|
Remember it now?{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push('/login')}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -208,8 +243,8 @@ export default function PasswordResetPage() {
|
|||||||
<form onSubmit={handleResetSubmit} className="space-y-6">
|
<form onSubmit={handleResetSubmit} className="space-y-6">
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
<div className="sm:col-span-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">
|
<label className="block text-sm font-medium text-gray-900 mb-2" htmlFor="password">
|
||||||
Neues Passwort
|
New password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@ -217,16 +252,16 @@ export default function PasswordResetPage() {
|
|||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => { setPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
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"
|
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="••••••••"
|
placeholder="Your new password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(p => !p)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-xs">
|
<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>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-2" htmlFor="confirm">
|
<label className="block text-sm font-medium text-gray-900 mb-2" htmlFor="confirm">
|
||||||
Passwort bestätigen
|
Confirm password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="confirm"
|
id="confirm"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={e => { setConfirmPassword(e.target.value); setResetError(''); setResetSuccess(false)}}
|
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"
|
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="Bestätigung"
|
placeholder="Confirm password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{confirmPassword && password !== confirmPassword && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@ -270,37 +305,37 @@ export default function PasswordResetPage() {
|
|||||||
)}
|
)}
|
||||||
{resetSuccess && (
|
{resetSuccess && (
|
||||||
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={resetLoading}
|
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
|
resetLoading
|
||||||
? 'bg-gray-400 cursor-wait'
|
? 'border-white/30 bg-white/20 text-slate-300 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/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 ? (
|
{resetLoading ? (
|
||||||
<>
|
<>
|
||||||
<span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" />
|
<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>
|
</button>
|
||||||
|
|
||||||
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
Link abgelaufen?{' '}
|
Link expired?{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => router.push('/password-reset')}
|
onClick={() => router.push('/password-reset')}
|
||||||
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
className="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
|
||||||
>
|
>
|
||||||
Erneut anfordern
|
Request again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -308,7 +343,16 @@ export default function PasswordResetPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PasswordResetPage() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<PasswordResetPageInner />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -17,74 +17,42 @@ export default function BankInformation({
|
|||||||
setBankInfo: (v: { accountHolder: string, iban: string }) => void,
|
setBankInfo: (v: { accountHolder: string, iban: string }) => void,
|
||||||
onEdit?: () => void
|
onEdit?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
// editing disabled for now; keep props to avoid refactors
|
||||||
|
const accountHolder = profileData.accountHolder || ''
|
||||||
|
const iban = profileData.iban || ''
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Bank Information</h2>
|
<h2 className="text-lg font-semibold text-gray-900">Bank Information</h2>
|
||||||
{!editingBank && (
|
<span className="text-xs text-gray-500">Editing disabled</span>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<form
|
|
||||||
className="space-y-4"
|
<div className="space-y-4">
|
||||||
onSubmit={e => {
|
|
||||||
e.preventDefault()
|
|
||||||
setBankInfo(bankDraft)
|
|
||||||
setEditingBank(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Account Holder</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Account Holder</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full p-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-900"
|
className="w-full p-2 border border-white/60 rounded-lg bg-white/50 text-gray-900"
|
||||||
value={editingBank ? bankDraft.accountHolder : (profileData.accountHolder || '')}
|
value={accountHolder}
|
||||||
onChange={e => setBankDraft({ ...bankDraft, accountHolder: e.target.value })}
|
disabled
|
||||||
disabled={!editingBank}
|
placeholder="Not provided"
|
||||||
placeholder={profileData.accountHolder ? '' : 'Not provided'}
|
|
||||||
/>
|
/>
|
||||||
{!editingBank && !profileData.accountHolder && (
|
{!accountHolder && <div className="mt-1 text-sm italic text-gray-400">Not provided</div>}
|
||||||
<div className="mt-1 text-sm italic text-gray-400">Not provided</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">IBAN</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">IBAN</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full p-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-900"
|
className="w-full p-2 border border-white/60 rounded-lg bg-white/50 text-gray-900"
|
||||||
value={editingBank ? bankDraft.iban : (profileData.iban || '')}
|
value={iban}
|
||||||
onChange={e => setBankDraft({ ...bankDraft, iban: e.target.value })}
|
disabled
|
||||||
disabled={!editingBank}
|
placeholder="Not provided"
|
||||||
placeholder={profileData.iban ? '' : 'Not provided'}
|
|
||||||
/>
|
/>
|
||||||
{!editingBank && !profileData.iban && (
|
{!iban && <div className="mt-1 text-sm italic text-gray-400">Not provided</div>}
|
||||||
<div className="mt-1 text-sm italic text-gray-400">Not provided</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{editingBank && (
|
</div>
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-[#8D6B1D] rounded-lg hover:bg-[#7A5E1A] transition"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition"
|
|
||||||
onClick={() => setEditingBank(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
onEdit?: () => void
|
onEdit?: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
|
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
|
||||||
<button
|
<button
|
||||||
@ -25,7 +25,7 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
First Name
|
First Name
|
||||||
</label>
|
</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" />
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.firstName}>
|
<HighlightIfMissing value={profileData.firstName}>
|
||||||
<span className="text-gray-900">{profileData.firstName}</span>
|
<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">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Last Name
|
Last Name
|
||||||
</label>
|
</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" />
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.lastName}>
|
<HighlightIfMissing value={profileData.lastName}>
|
||||||
<span className="text-gray-900">{profileData.lastName}</span>
|
<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">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Contact Person
|
Contact Person
|
||||||
</label>
|
</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" />
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.contactPersonName}>
|
<HighlightIfMissing value={profileData.contactPersonName}>
|
||||||
<span className="text-gray-900">{profileData.contactPersonName}</span>
|
<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">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Email Address
|
Email Address
|
||||||
</label>
|
</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" />
|
<EnvelopeIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.email}>
|
<HighlightIfMissing value={profileData.email}>
|
||||||
<span className="text-gray-900">{profileData.email}</span>
|
<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">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Phone Number
|
Phone Number
|
||||||
</label>
|
</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" />
|
<PhoneIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.phone}>
|
<HighlightIfMissing value={profileData.phone}>
|
||||||
<span className="text-gray-900">{profileData.phone}</span>
|
<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">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Address
|
Address
|
||||||
</label>
|
</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" />
|
<MapPinIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.address}>
|
<HighlightIfMissing value={profileData.address}>
|
||||||
<span className="text-gray-900">{profileData.address}</span>
|
<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({
|
export default function EditModal({
|
||||||
open,
|
open,
|
||||||
@ -19,16 +19,54 @@ export default function EditModal({
|
|||||||
onCancel: () => void,
|
onCancel: () => void,
|
||||||
children?: React.ReactNode
|
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(() => {
|
useEffect(() => {
|
||||||
|
const body = document.body
|
||||||
|
const html = document.documentElement
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
document.body.style.overflow = 'hidden';
|
prevStylesRef.current = {
|
||||||
|
bodyOverflow: body.style.overflow || '',
|
||||||
|
bodyPaddingRight: body.style.paddingRight || '',
|
||||||
|
htmlOverflow: html.style.overflow || '',
|
||||||
|
htmlPaddingRight: html.style.paddingRight || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
||||||
|
|
||||||
|
// lock scroll (some libs lock html, some lock body)
|
||||||
|
body.style.overflow = 'hidden'
|
||||||
|
html.style.overflow = 'hidden'
|
||||||
|
|
||||||
|
// prevent layout shift + ensure we can restore cleanly
|
||||||
|
const pr = scrollbarWidth > 0 ? `${scrollbarWidth}px` : ''
|
||||||
|
body.style.paddingRight = pr
|
||||||
|
html.style.paddingRight = pr
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = '';
|
body.style.overflow = prevStylesRef.current.bodyOverflow
|
||||||
|
body.style.paddingRight = prevStylesRef.current.bodyPaddingRight
|
||||||
|
html.style.overflow = prevStylesRef.current.htmlOverflow
|
||||||
|
html.style.paddingRight = prevStylesRef.current.htmlPaddingRight
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
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]);
|
}, [open]);
|
||||||
|
|
||||||
// Animation state
|
// Animation state
|
||||||
@ -52,9 +90,15 @@ export default function EditModal({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<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'
|
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">
|
<h2 className="text-xl font-semibold mb-4 text-gray-900">
|
||||||
Edit {type === 'basic' ? 'Basic Information' : 'Bank Information'}
|
Edit {type === 'basic' ? 'Basic Information' : 'Bank Information'}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import React from 'react'
|
|||||||
export default function MediaSection({ documents }: { documents: any[] }) {
|
export default function MediaSection({ documents }: { documents: any[] }) {
|
||||||
const hasDocuments = Array.isArray(documents) && documents.length > 0;
|
const hasDocuments = Array.isArray(documents) && documents.length > 0;
|
||||||
return (
|
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>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Media & Documents</h2>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{hasDocuments ? (
|
{hasDocuments ? (
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
export default function ProfileCompletion({ profileComplete }: { profileComplete: number }) {
|
export default function ProfileCompletion({ profileComplete }: { profileComplete: number }) {
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Profile Completion</h2>
|
<h2 className="text-lg font-semibold text-gray-900">Profile Completion</h2>
|
||||||
<span className="text-sm font-medium text-[#8D6B1D]">
|
<span className="text-sm font-medium text-[#8D6B1D]">
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export default function UserAbo() {
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
<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…
|
Loading subscriptions…
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -19,7 +19,7 @@ export default function UserAbo() {
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
<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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -30,11 +30,11 @@ export default function UserAbo() {
|
|||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
<h2 className="text-lg font-semibold text-gray-900">My Subscriptions</h2>
|
||||||
{(!abos || abos.length === 0) ? (
|
{(!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.
|
No subscriptions yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-3 sm:gap-4">
|
||||||
{abos.map(abo => {
|
{abos.map(abo => {
|
||||||
const status = (abo.status || 'active') as 'active' | 'paused' | 'canceled'
|
const status = (abo.status || 'active') as 'active' | 'paused' | 'canceled'
|
||||||
const nextBilling = abo.nextBillingAt ? new Date(abo.nextBillingAt).toLocaleDateString() : '—'
|
const nextBilling = abo.nextBillingAt ? new Date(abo.nextBillingAt).toLocaleDateString() : '—'
|
||||||
@ -53,7 +53,7 @@ export default function UserAbo() {
|
|||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
return (
|
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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">{abo.name || 'Coffee Subscription'}</p>
|
<p className="text-sm font-medium text-gray-900">{abo.name || 'Coffee Subscription'}</p>
|
||||||
|
|||||||
@ -3,26 +3,18 @@
|
|||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import Header from '../components/nav/Header'
|
import PageLayout from '../components/PageLayout'
|
||||||
import Footer from '../components/Footer'
|
import Waves from '../components/waves'
|
||||||
import ProfileCompletion from './components/profileCompletion'
|
import ProfileCompletion from './components/profileCompletion'
|
||||||
import BasicInformation from './components/basicInformation'
|
import BasicInformation from './components/basicInformation'
|
||||||
import MediaSection from './components/mediaSection'
|
import MediaSection from './components/mediaSection'
|
||||||
import BankInformation from './components/bankInformation'
|
import BankInformation from './components/bankInformation'
|
||||||
import EditModal from './components/editModal'
|
import EditModal from './components/editModal'
|
||||||
import UserAbo from './components/userAbo'
|
import UserAbo from './components/userAbo'
|
||||||
import {
|
import { getProfileCompletion } from './hooks/getProfileCompletion'
|
||||||
UserCircleIcon,
|
import { useProfileData } from './hooks/getProfileData'
|
||||||
EnvelopeIcon,
|
import { useMedia } from './hooks/getMedia'
|
||||||
PhoneIcon,
|
import { editProfileBasic } from './hooks/editProfile'
|
||||||
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';
|
|
||||||
|
|
||||||
// Helper to display missing fields in subtle gray italic (no yellow highlight)
|
// Helper to display missing fields in subtle gray italic (no yellow highlight)
|
||||||
function HighlightIfMissing({ value, children }: { value: any, children: React.ReactNode }) {
|
function HighlightIfMissing({ value, children }: { value: any, children: React.ReactNode }) {
|
||||||
@ -60,85 +52,97 @@ const defaultProfileData = {
|
|||||||
userType: '',
|
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() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = useAuthStore(state => state.user)
|
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
|
// --- declare ALL hooks before any early return (Rules of Hooks) ---
|
||||||
useEffect(() => {
|
const [refreshKey, setRefreshKey] = React.useState(0)
|
||||||
if (user?.id) setUserId(user.id);
|
const [showRefreshing, setShowRefreshing] = React.useState(false)
|
||||||
}, [user]);
|
const [completionLoading, setCompletionLoading] = React.useState(false)
|
||||||
|
|
||||||
// Add refresh key and UI states for smooth refresh
|
// Progress bar state (MOVED ABOVE EARLY RETURN)
|
||||||
const [refreshKey, setRefreshKey] = React.useState(0);
|
const [progressPercent, setProgressPercent] = React.useState<number>(0)
|
||||||
const [showRefreshing, setShowRefreshing] = React.useState(false);
|
const [completedSteps, setCompletedSteps] = React.useState<string[]>([])
|
||||||
const [completionLoading, setCompletionLoading] = React.useState(false);
|
const [allSteps, setAllSteps] = React.useState<string[]>([])
|
||||||
|
|
||||||
// Fetch profile data on page load/navigation, now with refreshKey
|
// Bank/edit state (keep, but bank editing disabled)
|
||||||
const { data: profileDataApi, loading: profileLoading, error: profileError } = useProfileData(userId, refreshKey);
|
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 [editModalOpen, setEditModalOpen] = React.useState(false)
|
||||||
const { data: mediaData, loading: mediaLoading, error: mediaError } = useMedia(userId, refreshKey);
|
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(() => { setHasHydrated(true) }, [])
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (user?.id) setUserId(user.id)
|
||||||
router.push('/login');
|
}, [user])
|
||||||
return;
|
|
||||||
}
|
// Fetch hooks can run with undefined userId; they should handle it internally
|
||||||
async function fetchCompletion() {
|
const { data: profileDataApi, loading: profileLoading } = useProfileData(userId, refreshKey)
|
||||||
setCompletionLoading(true);
|
const { data: mediaData, loading: mediaLoading } = useMedia(userId, refreshKey)
|
||||||
const progress = await getProfileCompletion();
|
|
||||||
// progress can be percent or object
|
// 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 (progress && typeof progress === 'object') {
|
||||||
// If not admin-verified, cap progress below 100 to reflect pending verification
|
const pct = progress.progressPercent ?? 0
|
||||||
const pct = progress.progressPercent ?? 0;
|
const isAdminVerified = Boolean(profileDataApi?.userStatus?.is_admin_verified)
|
||||||
// Try to read admin verification from profileDataApi if available; otherwise assume false until data loads
|
setProgressPercent(isAdminVerified ? pct : Math.min(pct, 95))
|
||||||
const isAdminVerified = Boolean(profileDataApi?.userStatus?.is_admin_verified);
|
setCompletedSteps(progress.completedSteps ?? [])
|
||||||
setProgressPercent(isAdminVerified ? pct : Math.min(pct, 95));
|
setAllSteps(progress.steps?.map((s: any) => s.name || s.title || '') ?? [])
|
||||||
setCompletedSteps(progress.completedSteps ?? []);
|
|
||||||
setAllSteps(progress.steps?.map((s: any) => s.name || s.title || '') ?? []);
|
|
||||||
} else if (typeof progress === 'number') {
|
} 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(() => {
|
useEffect(() => {
|
||||||
const verified = Boolean(profileDataApi?.userStatus?.is_admin_verified);
|
const verified = Boolean(profileDataApi?.userStatus?.is_admin_verified)
|
||||||
if (verified) {
|
if (verified) setProgressPercent(prev => (prev < 100 ? 100 : prev))
|
||||||
setProgressPercent(prev => (prev < 100 ? 100 : prev));
|
}, [profileDataApi?.userStatus?.is_admin_verified])
|
||||||
}
|
|
||||||
}, [profileDataApi?.userStatus?.is_admin_verified]);
|
|
||||||
|
|
||||||
// Use API profile data if available, fallback to mock
|
|
||||||
const profileData = React.useMemo(() => {
|
const profileData = React.useMemo(() => {
|
||||||
if (!profileDataApi) {
|
if (!profileDataApi) {
|
||||||
return {
|
return {
|
||||||
@ -150,13 +154,13 @@ export default function ProfilePage() {
|
|||||||
joinDate: 'Oktober 2024',
|
joinDate: 'Oktober 2024',
|
||||||
memberStatus: 'Gold Member',
|
memberStatus: 'Gold Member',
|
||||||
profileComplete: progressPercent,
|
profileComplete: progressPercent,
|
||||||
accountHolder: '', // Always empty string if not provided
|
accountHolder: '',
|
||||||
iban: '',
|
iban: '',
|
||||||
contactPersonName: '',
|
contactPersonName: '',
|
||||||
userType: user?.userType || '',
|
userType: user?.userType || '',
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
const { user: apiUser = {}, profile: apiProfile = {}, userStatus = {} } = profileDataApi;
|
const { user: apiUser = {}, profile: apiProfile = {}, userStatus = {} } = profileDataApi
|
||||||
return {
|
return {
|
||||||
firstName: apiUser.firstName ?? apiProfile.first_name ?? '',
|
firstName: apiUser.firstName ?? apiProfile.first_name ?? '',
|
||||||
lastName: apiUser.lastName ?? apiProfile.last_name ?? '',
|
lastName: apiUser.lastName ?? apiProfile.last_name ?? '',
|
||||||
@ -168,305 +172,224 @@ export default function ProfilePage() {
|
|||||||
: '',
|
: '',
|
||||||
memberStatus: userStatus.status ?? '',
|
memberStatus: userStatus.status ?? '',
|
||||||
profileComplete: progressPercent,
|
profileComplete: progressPercent,
|
||||||
accountHolder: apiProfile.account_holder_name ?? '', // Only use account_holder_name
|
accountHolder: apiProfile.account_holder_name ?? '',
|
||||||
iban: apiUser.iban ?? '',
|
iban: apiUser.iban ?? '',
|
||||||
contactPersonName: apiProfile.contact_person_name ?? '',
|
contactPersonName: apiProfile.contact_person_name ?? '',
|
||||||
userType: apiUser.userType ?? '',
|
userType: apiUser.userType ?? '',
|
||||||
};
|
|
||||||
}, [profileDataApi, user, progressPercent]);
|
|
||||||
|
|
||||||
// Dummy data for new sections
|
|
||||||
const documents = Array.isArray(mediaData?.documents) ? mediaData.documents : [];
|
|
||||||
|
|
||||||
// Adjusted bankInfo state to only have accountHolder and iban, always strings
|
|
||||||
const [bankInfo, setBankInfo] = React.useState({
|
|
||||||
accountHolder: '',
|
|
||||||
iban: '',
|
|
||||||
});
|
|
||||||
const [editingBank, setEditingBank] = React.useState(false);
|
|
||||||
const [bankDraft, setBankDraft] = React.useState(bankInfo)
|
|
||||||
|
|
||||||
// Modal state
|
|
||||||
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
|
||||||
const [editModalType, setEditModalType] = React.useState<'basic' | 'bank'>('basic');
|
|
||||||
const [editModalValues, setEditModalValues] = React.useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
// Modal error state
|
|
||||||
const [editModalError, setEditModalError] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
// Modal field definitions
|
|
||||||
const basicFields = [
|
|
||||||
{ key: 'firstName', label: 'First Name' },
|
|
||||||
{ key: 'lastName', label: 'Last Name' },
|
|
||||||
{ key: 'email', label: 'Email Address', type: 'email' },
|
|
||||||
{ key: 'phone', label: 'Phone Number' },
|
|
||||||
{ key: 'address', label: 'Address' },
|
|
||||||
];
|
|
||||||
const bankFields = [
|
|
||||||
{ key: 'accountHolder', label: 'Account Holder' },
|
|
||||||
{ key: 'iban', label: 'IBAN' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Modal open handlers
|
|
||||||
function openEditModal(type: 'basic' | 'bank', values: Record<string, string>) {
|
|
||||||
setEditModalType(type);
|
|
||||||
setEditModalValues(values);
|
|
||||||
setEditModalOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal save handler (calls API)
|
|
||||||
async function handleEditModalSave() {
|
|
||||||
setEditModalError(null);
|
|
||||||
if (editModalType === 'basic') {
|
|
||||||
const payload: Partial<typeof defaultProfileData> = {};
|
|
||||||
(['firstName', 'lastName', 'email', 'phone', 'address'] as const).forEach(key => {
|
|
||||||
if (editModalValues[key] !== getProfileField(profileData, key)) {
|
|
||||||
payload[key] = editModalValues[key]?.trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const res = await editProfileBasic(payload);
|
|
||||||
if (res.success) {
|
|
||||||
setEditModalOpen(false);
|
|
||||||
// Start smooth refresh with overlay spinner
|
|
||||||
setShowRefreshing(true);
|
|
||||||
setRefreshKey(k => k + 1);
|
|
||||||
} else if (res.status === 409) {
|
|
||||||
setEditModalError('Email already in use.');
|
|
||||||
} else if (res.status === 401) {
|
|
||||||
router.push('/login');
|
|
||||||
} else {
|
|
||||||
setEditModalError(res.error || 'Failed to update profile.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const payload: Partial<typeof defaultProfileData> = {};
|
|
||||||
(['accountHolder', 'iban'] as const).forEach(key => {
|
|
||||||
if (editModalValues[key] !== getProfileField(profileData, key)) {
|
|
||||||
payload[key] = editModalValues[key]?.trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const res = await editProfileBank(payload);
|
|
||||||
if (res.success) {
|
|
||||||
setBankInfo({
|
|
||||||
accountHolder: res.data?.profile?.account_holder_name ?? '',
|
|
||||||
iban: res.data?.user?.iban ?? '',
|
|
||||||
});
|
|
||||||
setEditModalOpen(false);
|
|
||||||
// Start smooth refresh with overlay spinner
|
|
||||||
setShowRefreshing(true);
|
|
||||||
setRefreshKey(k => k + 1);
|
|
||||||
} else if (res.status === 400 && res.error?.toLowerCase().includes('iban')) {
|
|
||||||
setEditModalError('Invalid IBAN.');
|
|
||||||
} else if (res.status === 401) {
|
|
||||||
router.push('/login');
|
|
||||||
} else {
|
|
||||||
setEditModalError(res.error || 'Failed to update bank info.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}, [profileDataApi, user, progressPercent])
|
||||||
|
|
||||||
// Modal change handler
|
const documents = Array.isArray(mediaData?.documents) ? mediaData.documents : []
|
||||||
function handleEditModalChange(key: string, value: string) {
|
|
||||||
setEditModalValues(prev => ({ ...prev, [key]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide overlay when all data re-fetches complete
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showRefreshing && !profileLoading && !mediaLoading && !completionLoading) {
|
if (showRefreshing && !profileLoading && !mediaLoading && !completionLoading) {
|
||||||
const t = setTimeout(() => setShowRefreshing(false), 200); // small delay for smoothness
|
const t = setTimeout(() => setShowRefreshing(false), 200)
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t)
|
||||||
}
|
}
|
||||||
}, [showRefreshing, profileLoading, mediaLoading, completionLoading]);
|
}, [showRefreshing, profileLoading, mediaLoading, completionLoading])
|
||||||
|
|
||||||
const loadingUser = !user;
|
function openEditModal(type: 'basic' | 'bank', values: Record<string, string>) {
|
||||||
|
setEditModalType(type)
|
||||||
|
setEditModalValues(values)
|
||||||
|
setEditModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditModalSave() {
|
||||||
|
setEditModalError(null)
|
||||||
|
if (editModalType === 'basic') {
|
||||||
|
const payload: Partial<typeof defaultProfileData> = {}
|
||||||
|
;(['firstName', 'lastName', 'phone', 'address'] as const).forEach(key => {
|
||||||
|
if (editModalValues[key] !== getProfileField(profileData, key)) {
|
||||||
|
payload[key] = editModalValues[key]?.trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const res = await editProfileBasic(payload)
|
||||||
|
if (res.success) {
|
||||||
|
setEditModalOpen(false)
|
||||||
|
setShowRefreshing(true)
|
||||||
|
setRefreshKey(k => k + 1)
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
router.push('/login')
|
||||||
|
} else {
|
||||||
|
setEditModalError(res.error || 'Failed to update profile.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEditModalError('Bank information editing is disabled for now.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditModalChange(key: string, value: string) {
|
||||||
|
setEditModalValues(prev => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 768px)')
|
||||||
|
const apply = () => setIsMobile(mq.matches)
|
||||||
|
apply()
|
||||||
|
mq.addEventListener?.('change', apply)
|
||||||
|
window.addEventListener('resize', apply, { passive: true })
|
||||||
|
return () => {
|
||||||
|
mq.removeEventListener?.('change', apply)
|
||||||
|
window.removeEventListener('resize', apply)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// --- EARLY RETURN AFTER ALL HOOKS ---
|
||||||
|
if (!hasHydrated || !isAuthReady || !user) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
|
<p className="text-[#4A4A4A]">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-gray-50" suppressHydrationWarning>
|
<div className="relative w-full min-h-screen overflow-x-hidden">
|
||||||
<Header />
|
<Waves
|
||||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
|
className="pointer-events-none"
|
||||||
<div className="max-w-4xl mx-auto">
|
lineColor="#0f172a"
|
||||||
{loadingUser && (
|
backgroundColor="rgba(245, 245, 240, 1)"
|
||||||
<div className="flex items-center justify-center py-20">
|
waveSpeedX={0.02}
|
||||||
<div className="text-center">
|
waveSpeedY={0.01}
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
waveAmpX={40}
|
||||||
<p className="text-[#4A4A4A]">Loading...</p>
|
waveAmpY={20}
|
||||||
</div>
|
friction={0.9}
|
||||||
</div>
|
tension={0.01}
|
||||||
)}
|
maxCursorMove={120}
|
||||||
{!loadingUser && (
|
xGap={12}
|
||||||
<>
|
yGap={36}
|
||||||
{/* Page Header */}
|
animate={!isMobile}
|
||||||
<div className="mb-8">
|
interactive={!isMobile}
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Profile Settings</h1>
|
/>
|
||||||
<p className="text-gray-600 mt-2">
|
|
||||||
Manage your account information and preferences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pending admin verification notice (above progress) */}
|
<div className="relative z-10 min-h-screen">
|
||||||
{profileDataApi?.userStatus && profileDataApi.userStatus.is_admin_verified === 0 && (
|
<PageLayout className="bg-transparent text-gray-900">
|
||||||
<div className="rounded-md bg-yellow-50 border border-yellow-200 p-3 text-sm text-yellow-800 mb-2">
|
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
|
||||||
Your account is fully submitted. Our team will verify your account shortly.
|
<div className="max-w-4xl mx-auto">
|
||||||
</div>
|
{/* 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">
|
||||||
{/* Profile Completion Progress Bar */}
|
{/* Page Header */}
|
||||||
<ProfileCompletion
|
<div className="mb-8">
|
||||||
profileComplete={profileData.profileComplete}
|
<h1 className="text-3xl font-bold text-gray-900">Profile Settings</h1>
|
||||||
/>
|
<p className="text-gray-600 mt-2">
|
||||||
|
Manage your account information and preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Basic Info + Sidebar */}
|
{/* Pending admin verification notice (above progress) */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
{profileDataApi?.userStatus && profileDataApi.userStatus.is_admin_verified === 0 && (
|
||||||
{/* Basic Information */}
|
<div className="rounded-md bg-yellow-50/80 backdrop-blur border border-yellow-200 p-3 text-sm text-yellow-800 mb-2">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
Your account is fully submitted. Our team will verify your account shortly.
|
||||||
<BasicInformation
|
|
||||||
profileData={profileData}
|
|
||||||
HighlightIfMissing={HighlightIfMissing}
|
|
||||||
// Add edit button handler
|
|
||||||
onEdit={() => openEditModal('basic', {
|
|
||||||
firstName: profileData.firstName,
|
|
||||||
lastName: profileData.lastName,
|
|
||||||
email: profileData.email,
|
|
||||||
phone: profileData.phone,
|
|
||||||
address: profileData.address,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Sidebar: Account Status + Quick Actions */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Account Status */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-4">Account Status</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Member Since</span>
|
|
||||||
<span className="text-sm font-medium text-gray-900">{profileData.joinDate}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<ProfileCompletion profileComplete={profileData.profileComplete} />
|
||||||
<span className="text-sm text-gray-600">Status</span>
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gradient-to-r from-[#8D6B1D] to-[#C49225] text-white">
|
{/* Basic Info + Sidebar */}
|
||||||
{profileData.memberStatus}
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 sm:gap-8 mb-8">
|
||||||
</span>
|
{/* Basic Information */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<BasicInformation
|
||||||
|
profileData={profileData}
|
||||||
|
HighlightIfMissing={HighlightIfMissing}
|
||||||
|
// Add edit button handler
|
||||||
|
onEdit={() => openEditModal('basic', {
|
||||||
|
firstName: profileData.firstName,
|
||||||
|
lastName: profileData.lastName,
|
||||||
|
phone: profileData.phone,
|
||||||
|
address: profileData.address,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Sidebar: Account Status + Quick Actions */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Account Status (make translucent) */}
|
||||||
|
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Account Status</h3>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-3">
|
||||||
<span className="text-sm text-gray-600">Profile</span>
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-green-600">Verified</span>
|
<span className="text-sm text-gray-600">Member Since</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{profileData.joinDate}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">Status</span>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gradient-to-r from-[#8D6B1D] to-[#C49225] text-white">
|
||||||
|
{profileData.memberStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">Profile</span>
|
||||||
|
<span className="text-sm font-medium text-green-600">Verified</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Quick Actions (make translucent) */}
|
||||||
|
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Quick Actions</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</button>
|
||||||
|
<button className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors">
|
||||||
|
Download Account Data
|
||||||
|
</button>
|
||||||
|
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-4">Quick Actions</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* Bank Info, Media */}
|
||||||
<button
|
<div className="space-y-6 sm:space-y-8 mb-8">
|
||||||
onClick={() => router.push('/dashboard')}
|
{/* --- My Abo Section (above bank info) --- */}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
<UserAbo />
|
||||||
>
|
{/* --- Edit Bank Information Section --- */}
|
||||||
Go to Dashboard
|
<BankInformation
|
||||||
</button>
|
profileData={profileData}
|
||||||
<button className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors">
|
editingBank={false} // force read-only
|
||||||
Download Account Data
|
bankDraft={bankDraft}
|
||||||
</button>
|
setEditingBank={setEditingBank}
|
||||||
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
setBankDraft={setBankDraft}
|
||||||
Delete Account
|
setBankInfo={setBankInfo}
|
||||||
</button>
|
// onEdit disabled for now
|
||||||
|
// onEdit={() => openEditModal('bank', { ... })}
|
||||||
|
/>
|
||||||
|
{/* --- Media Section --- */}
|
||||||
|
<MediaSection documents={documents} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
{/* Bank Info, Media */}
|
{/* Edit Modal */}
|
||||||
<div className="space-y-8 mb-8">
|
<EditModal
|
||||||
{/* --- My Abo Section (above bank info) --- */}
|
open={editModalOpen}
|
||||||
<UserAbo />
|
type={editModalType}
|
||||||
{/* --- Edit Bank Information Section --- */}
|
fields={editModalType === 'basic' ? basicFields : bankFields}
|
||||||
<BankInformation
|
values={editModalValues}
|
||||||
profileData={profileData}
|
onChange={handleEditModalChange}
|
||||||
editingBank={editingBank}
|
onSave={handleEditModalSave}
|
||||||
bankDraft={bankDraft}
|
onCancel={() => { setEditModalOpen(false); setEditModalError(null); }}
|
||||||
setEditingBank={setEditingBank}
|
>
|
||||||
setBankDraft={setBankDraft}
|
{/* Show error message if present */}
|
||||||
setBankInfo={setBankInfo}
|
{editModalError && (
|
||||||
// Add edit button handler
|
<div className="text-sm text-red-600 mb-2">{editModalError}</div>
|
||||||
onEdit={() => openEditModal('bank', {
|
)}
|
||||||
accountHolder: profileData.accountHolder,
|
</EditModal>
|
||||||
iban: profileData.iban,
|
</PageLayout>
|
||||||
})}
|
</div>
|
||||||
/>
|
|
||||||
{/* --- Media Section --- */}
|
|
||||||
<MediaSection documents={documents} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account Settings */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">Account Settings</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between py-3 border-b border-gray-100">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Email Notifications</p>
|
|
||||||
<p className="text-sm text-gray-600">Receive updates about orders and promotions</p>
|
|
||||||
</div>
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
|
||||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#8D6B1D]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#8D6B1D]"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-3 border-b border-gray-100">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">SMS Notifications</p>
|
|
||||||
<p className="text-sm text-gray-600">Get text messages for important updates</p>
|
|
||||||
</div>
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input type="checkbox" className="sr-only peer" />
|
|
||||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#8D6B1D]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#8D6B1D]"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-3">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">Two-Factor Authentication</p>
|
|
||||||
<p className="text-sm text-gray-600">Add extra security to your account</p>
|
|
||||||
</div>
|
|
||||||
<button className="px-4 py-2 text-sm font-medium text-[#8D6B1D] border border-[#8D6B1D] rounded-lg hover:bg-[#8D6B1D]/10 transition-colors">
|
|
||||||
Enable
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
|
|
||||||
{/* Global refreshing overlay */}
|
|
||||||
{showRefreshing && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/60 backdrop-blur-sm">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-[#8D6B1D]/30 border-t-[#8D6B1D] mb-3"></div>
|
|
||||||
<p className="text-sm text-gray-700">Updating...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Modal */}
|
|
||||||
<EditModal
|
|
||||||
open={editModalOpen}
|
|
||||||
type={editModalType}
|
|
||||||
fields={editModalType === 'basic' ? basicFields : bankFields}
|
|
||||||
values={editModalValues}
|
|
||||||
onChange={handleEditModalChange}
|
|
||||||
onSave={handleEditModalSave}
|
|
||||||
onCancel={() => { setEditModalOpen(false); setEditModalError(null); }}
|
|
||||||
>
|
|
||||||
{/* Show error message if present */}
|
|
||||||
{editModalError && (
|
|
||||||
<div className="text-sm text-red-600 mb-2">{editModalError}</div>
|
|
||||||
)}
|
|
||||||
</EditModal>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -142,17 +142,16 @@ export default function RegisterForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const phoneApi = personalPhoneRef.current
|
const phoneApi = personalPhoneRef.current
|
||||||
|
const dialCode = phoneApi?.getDialCode?.()
|
||||||
const intlNumber = phoneApi?.getNumber() || ''
|
const intlNumber = phoneApi?.getNumber() || ''
|
||||||
const valid = phoneApi?.isValid() ?? false
|
const valid = phoneApi?.isValid() ?? false
|
||||||
|
|
||||||
console.log('[RegisterForm] validatePersonalForm phone check', {
|
if (!dialCode) {
|
||||||
rawState: personalForm.phoneNumber,
|
setError('Please select a country code from the dropdown before continuing.')
|
||||||
intlFromApi: intlNumber,
|
return false
|
||||||
isValidFromApi: valid,
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (!intlNumber) {
|
if (!intlNumber) {
|
||||||
setError('Please enter your phone number including country code.')
|
setError('Please enter your phone number.')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@ -191,22 +190,20 @@ export default function RegisterForm({
|
|||||||
const companyApi = companyPhoneRef.current
|
const companyApi = companyPhoneRef.current
|
||||||
const contactApi = contactPhoneRef.current
|
const contactApi = contactPhoneRef.current
|
||||||
|
|
||||||
|
const companyDialCode = companyApi?.getDialCode?.()
|
||||||
|
const contactDialCode = contactApi?.getDialCode?.()
|
||||||
|
|
||||||
const companyNumber = companyApi?.getNumber() || ''
|
const companyNumber = companyApi?.getNumber() || ''
|
||||||
const contactNumber = contactApi?.getNumber() || ''
|
const contactNumber = contactApi?.getNumber() || ''
|
||||||
const companyValid = companyApi?.isValid() ?? false
|
const companyValid = companyApi?.isValid() ?? false
|
||||||
const contactValid = contactApi?.isValid() ?? false
|
const contactValid = contactApi?.isValid() ?? false
|
||||||
|
|
||||||
console.log('[RegisterForm] validateCompanyForm phone check', {
|
if (!companyDialCode || !contactDialCode) {
|
||||||
rawCompany: companyForm.companyPhone,
|
setError('Please select country codes (dropdown) for both company and contact phone numbers.')
|
||||||
rawContact: companyForm.contactPersonPhone,
|
return false
|
||||||
intlCompany: companyNumber,
|
}
|
||||||
intlContact: contactNumber,
|
|
||||||
companyValid,
|
|
||||||
contactValid,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!companyNumber || !contactNumber) {
|
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
|
return false
|
||||||
}
|
}
|
||||||
if (!companyValid || !contactValid) {
|
if (!companyValid || !contactValid) {
|
||||||
@ -394,7 +391,8 @@ export default function RegisterForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header */}
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-2">
|
<h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-2">
|
||||||
@ -409,7 +407,7 @@ export default function RegisterForm({
|
|||||||
|
|
||||||
{/* Mode Toggle */}
|
{/* Mode Toggle */}
|
||||||
<div className="flex justify-center mb-8">
|
<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
|
<button
|
||||||
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||||
mode === 'personal'
|
mode === 'personal'
|
||||||
@ -437,7 +435,7 @@ export default function RegisterForm({
|
|||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{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>
|
<p className="text-red-600 text-sm font-medium">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -457,7 +455,7 @@ export default function RegisterForm({
|
|||||||
name="firstName"
|
name="firstName"
|
||||||
value={personalForm.firstName}
|
value={personalForm.firstName}
|
||||||
onChange={handlePersonalChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -472,7 +470,7 @@ export default function RegisterForm({
|
|||||||
name="lastName"
|
name="lastName"
|
||||||
value={personalForm.lastName}
|
value={personalForm.lastName}
|
||||||
onChange={handlePersonalChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -489,7 +487,7 @@ export default function RegisterForm({
|
|||||||
name="email"
|
name="email"
|
||||||
value={personalForm.email}
|
value={personalForm.email}
|
||||||
onChange={handlePersonalChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -504,7 +502,7 @@ export default function RegisterForm({
|
|||||||
name="confirmEmail"
|
name="confirmEmail"
|
||||||
value={personalForm.confirmEmail}
|
value={personalForm.confirmEmail}
|
||||||
onChange={handlePersonalChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -518,7 +516,8 @@ export default function RegisterForm({
|
|||||||
id="phoneNumber"
|
id="phoneNumber"
|
||||||
name="phoneNumber"
|
name="phoneNumber"
|
||||||
ref={personalPhoneRef}
|
ref={personalPhoneRef}
|
||||||
placeholder="+49 123 456 7890"
|
autoComplete="tel"
|
||||||
|
placeholder="e.g. +43 676 1234567"
|
||||||
required
|
required
|
||||||
onChange={e =>
|
onChange={e =>
|
||||||
setPersonalForm(prev => ({ ...prev, phoneNumber: (e.target as HTMLInputElement).value }))
|
setPersonalForm(prev => ({ ...prev, phoneNumber: (e.target as HTMLInputElement).value }))
|
||||||
@ -538,7 +537,8 @@ export default function RegisterForm({
|
|||||||
name="password"
|
name="password"
|
||||||
value={personalForm.password}
|
value={personalForm.password}
|
||||||
onChange={handlePersonalChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -566,7 +566,8 @@ export default function RegisterForm({
|
|||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
value={personalForm.confirmPassword}
|
value={personalForm.confirmPassword}
|
||||||
onChange={handlePersonalChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -604,7 +605,7 @@ export default function RegisterForm({
|
|||||||
name="companyName"
|
name="companyName"
|
||||||
value={companyForm.companyName}
|
value={companyForm.companyName}
|
||||||
onChange={handleCompanyChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -619,7 +620,7 @@ export default function RegisterForm({
|
|||||||
name="contactPersonName"
|
name="contactPersonName"
|
||||||
value={companyForm.contactPersonName}
|
value={companyForm.contactPersonName}
|
||||||
onChange={handleCompanyChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -636,7 +637,7 @@ export default function RegisterForm({
|
|||||||
name="companyEmail"
|
name="companyEmail"
|
||||||
value={companyForm.companyEmail}
|
value={companyForm.companyEmail}
|
||||||
onChange={handleCompanyChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -651,7 +652,7 @@ export default function RegisterForm({
|
|||||||
name="confirmCompanyEmail"
|
name="confirmCompanyEmail"
|
||||||
value={companyForm.confirmCompanyEmail}
|
value={companyForm.confirmCompanyEmail}
|
||||||
onChange={handleCompanyChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -666,7 +667,8 @@ export default function RegisterForm({
|
|||||||
id="companyPhone"
|
id="companyPhone"
|
||||||
name="companyPhone"
|
name="companyPhone"
|
||||||
ref={companyPhoneRef}
|
ref={companyPhoneRef}
|
||||||
placeholder="+49 123 456 7890"
|
autoComplete="tel"
|
||||||
|
placeholder="e.g. +43 1 234567"
|
||||||
required
|
required
|
||||||
onChange={e =>
|
onChange={e =>
|
||||||
setCompanyForm(prev => ({ ...prev, companyPhone: (e.target as HTMLInputElement).value }))
|
setCompanyForm(prev => ({ ...prev, companyPhone: (e.target as HTMLInputElement).value }))
|
||||||
@ -682,7 +684,8 @@ export default function RegisterForm({
|
|||||||
id="contactPersonPhone"
|
id="contactPersonPhone"
|
||||||
name="contactPersonPhone"
|
name="contactPersonPhone"
|
||||||
ref={contactPhoneRef}
|
ref={contactPhoneRef}
|
||||||
placeholder="+49 123 456 7890"
|
autoComplete="tel"
|
||||||
|
placeholder="e.g. +43 676 1234567"
|
||||||
required
|
required
|
||||||
onChange={e =>
|
onChange={e =>
|
||||||
setCompanyForm(prev => ({
|
setCompanyForm(prev => ({
|
||||||
@ -706,7 +709,8 @@ export default function RegisterForm({
|
|||||||
name="password"
|
name="password"
|
||||||
value={companyForm.password}
|
value={companyForm.password}
|
||||||
onChange={handleCompanyChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -734,7 +738,8 @@ export default function RegisterForm({
|
|||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
value={companyForm.confirmPassword}
|
value={companyForm.confirmPassword}
|
||||||
onChange={handleCompanyChange}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,38 +17,39 @@ export default function SessionDetectedModal({
|
|||||||
onCancel,
|
onCancel,
|
||||||
inline = false
|
inline = false
|
||||||
}: SessionDetectedModalProps) {
|
}: SessionDetectedModalProps) {
|
||||||
|
// Make inline + non-inline consistent
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
if (inline) {
|
if (inline) {
|
||||||
|
// Inline wrapper removed: parent already wraps/centers
|
||||||
return (
|
return (
|
||||||
// removed flex-1 and min-h to avoid extra white gap
|
<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="w-full flex justify-center items-center py-8">
|
<div className="flex gap-4">
|
||||||
<div className="bg-white px-6 py-6 rounded-xl shadow-xl max-w-lg w-full border border-gray-200">
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100/80">
|
||||||
<div className="flex gap-4">
|
<ExclamationTriangleIcon className="h-6 w-6 text-orange-600" aria-hidden="true" />
|
||||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100">
|
</div>
|
||||||
<ExclamationTriangleIcon className="h-6 w-6 text-orange-600" aria-hidden="true" />
|
<div>
|
||||||
</div>
|
<h3 className="text-base font-semibold leading-6 text-[#0F172A]">
|
||||||
<div>
|
Active session detected
|
||||||
<h3 className="text-base font-semibold leading-6 text-[#0F172A]">
|
</h3>
|
||||||
Active session detected
|
<p className="mt-2 text-sm text-[#4A4A4A]">
|
||||||
</h3>
|
You are already logged in. To register, you must first log out or you can go to the dashboard.
|
||||||
<p className="mt-2 text-sm text-[#4A4A4A]">
|
</p>
|
||||||
You are already logged in. To register, you must first log out or you can go to the dashboard.
|
<div className="mt-5 flex flex-col sm:flex-row gap-3 sm:justify-end">
|
||||||
</p>
|
<button
|
||||||
<div className="mt-5 flex flex-col sm:flex-row gap-3 sm:justify-end">
|
type="button"
|
||||||
<button
|
onClick={onCancel}
|
||||||
type="button"
|
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"
|
||||||
onClick={onCancel}
|
>
|
||||||
className="inline-flex justify-center rounded-md bg-white px-4 py-2 text-sm font-medium text-[#4A4A4A] shadow-sm ring-1 ring-gray-300 hover:bg-gray-50 transition-colors"
|
Go to dashboard
|
||||||
>
|
</button>
|
||||||
Go to dashboard
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={onLogout}
|
||||||
type="button"
|
className="inline-flex justify-center rounded-md bg-[#8D6B1D] px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] transition-colors"
|
||||||
onClick={onLogout}
|
>
|
||||||
className="inline-flex justify-center rounded-md bg-[#8D6B1D] px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] transition-colors"
|
Log out and register
|
||||||
>
|
</button>
|
||||||
Log out and register
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -82,7 +83,9 @@ export default function SessionDetectedModal({
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
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="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">
|
<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
|
<ExclamationTriangleIcon
|
||||||
|
|||||||
@ -21,30 +21,32 @@ export default function InvalidRefLinkModal({
|
|||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
const Content = (
|
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">
|
<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>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Invalid invitation link</h3>
|
<h3 className="text-lg font-semibold text-[#0F172A]">Invalid invitation link</h3>
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-slate-700">
|
||||||
This registration link is invalid or no longer active. Please request a new link.
|
This registration link is invalid or no longer active. Please request a new link.
|
||||||
</p>
|
</p>
|
||||||
{token ? (
|
{token && (
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
Token: <span className="font-mono break-all">{token}</span>
|
Token: <span className="font-mono break-all">{token}</span>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
)}
|
||||||
<div className="mt-4 flex items-center gap-2">
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onGoHome}
|
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
|
Go to homepage
|
||||||
</button>
|
</button>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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
|
Close
|
||||||
</button>
|
</button>
|
||||||
@ -55,13 +57,7 @@ export default function InvalidRefLinkModal({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (inline) {
|
if (inline) return Content
|
||||||
return (
|
|
||||||
<div className="w-full flex items-center justify-center py-16">
|
|
||||||
{Content}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Content
|
return Content
|
||||||
}
|
}
|
||||||
@ -1,20 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, type CSSProperties } from 'react'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import RegisterForm from './components/RegisterForm'
|
import RegisterForm from './components/RegisterForm'
|
||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
import SessionDetectedModal from './components/SessionDetectedModal'
|
import SessionDetectedModal from './components/SessionDetectedModal'
|
||||||
import InvalidRefLinkModal from './components/invalidRefLinkModal'
|
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 searchParams = useSearchParams()
|
||||||
const refToken = searchParams.get('ref')
|
const refToken = searchParams.get('ref')
|
||||||
const [registered, setRegistered] = useState(false)
|
const [registered, setRegistered] = useState(false)
|
||||||
const [mode, setMode] = useState<'personal' | 'company'>('personal')
|
const [mode, setMode] = useState<'personal' | 'company'>('personal')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
// Auth state
|
// Auth state
|
||||||
const user = useAuthStore(state => state.user)
|
const user = useAuthStore(state => state.user)
|
||||||
@ -24,7 +27,7 @@ export default function RegisterPage() {
|
|||||||
const [showSessionModal, setShowSessionModal] = useState(false)
|
const [showSessionModal, setShowSessionModal] = useState(false)
|
||||||
const [sessionCleared, setSessionCleared] = useState(false)
|
const [sessionCleared, setSessionCleared] = useState(false)
|
||||||
|
|
||||||
// NEW: Referral validation state
|
// Referral validation state
|
||||||
const [isRefChecked, setIsRefChecked] = useState(false)
|
const [isRefChecked, setIsRefChecked] = useState(false)
|
||||||
const [invalidRef, setInvalidRef] = useState(false)
|
const [invalidRef, setInvalidRef] = useState(false)
|
||||||
const [refInfo, setRefInfo] = useState<{
|
const [refInfo, setRefInfo] = useState<{
|
||||||
@ -34,42 +37,43 @@ export default function RegisterPage() {
|
|||||||
usesRemaining?: number
|
usesRemaining?: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
// Redirect to login after simulated registration
|
// Redirect after registration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (registered) {
|
if (registered) {
|
||||||
const t = setTimeout(() => router.push('/login'), 4000) // was 1200
|
const t = setTimeout(() => router.push('/login'), 4000)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}
|
}
|
||||||
}, [registered, router])
|
}, [registered, router])
|
||||||
|
|
||||||
// NEW: Validate referral token (must exist and be valid)
|
// Validate referral token
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const validateRef = async () => {
|
const validateRef = async () => {
|
||||||
if (!refToken) {
|
if (!refToken) {
|
||||||
console.warn('⚠️ Register: Missing ?ref token in URL')
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setInvalidRef(true)
|
setInvalidRef(true)
|
||||||
setIsRefChecked(true)
|
setIsRefChecked(true)
|
||||||
}
|
}
|
||||||
|
showToast({
|
||||||
|
variant: 'error',
|
||||||
|
title: 'Invitation error',
|
||||||
|
message: 'No invitation token found in the link.'
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||||
const url = `${base}/api/referral/info/${encodeURIComponent(refToken)}`
|
const url = `${base}/api/referral/info/${encodeURIComponent(refToken)}`
|
||||||
console.log('🌐 Register: fetching referral info:', url)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { method: 'GET', credentials: 'include' })
|
const res = await fetch(url, { method: 'GET', credentials: 'include' })
|
||||||
console.log('📡 Register: referral info status:', res.status)
|
|
||||||
const body = await res.json().catch(() => null)
|
const body = await res.json().catch(() => null)
|
||||||
console.log('📦 Register: referral info body:', body)
|
|
||||||
|
|
||||||
const success = !!body?.success
|
const success = !!body?.success
|
||||||
const isUnlimited = !!body?.isUnlimited
|
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)
|
const isActive = success && (isUnlimited || usesRemaining > 0)
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@ -81,28 +85,46 @@ export default function RegisterPage() {
|
|||||||
usesRemaining
|
usesRemaining
|
||||||
})
|
})
|
||||||
setInvalidRef(false)
|
setInvalidRef(false)
|
||||||
|
showToast({
|
||||||
|
variant: 'success',
|
||||||
|
title: 'Invitation verified',
|
||||||
|
message: 'Your invitation link is valid. You can register now.'
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
console.warn('⛔ Register: referral not active/invalid')
|
|
||||||
setInvalidRef(true)
|
setInvalidRef(true)
|
||||||
|
showToast({
|
||||||
|
variant: 'error',
|
||||||
|
title: 'Invalid invitation',
|
||||||
|
message: 'This invitation link is invalid or no longer active.'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
setIsRefChecked(true)
|
setIsRefChecked(true)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.error('❌ Register: referral info fetch error:', e)
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setInvalidRef(true)
|
setInvalidRef(true)
|
||||||
setIsRefChecked(true)
|
setIsRefChecked(true)
|
||||||
}
|
}
|
||||||
|
showToast({
|
||||||
|
variant: 'error',
|
||||||
|
title: 'Network error',
|
||||||
|
message: 'Could not validate the invitation link. Please try again.'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
validateRef()
|
validateRef()
|
||||||
return () => { cancelled = true }
|
return () => {
|
||||||
}, [refToken])
|
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(() => {
|
useEffect(() => {
|
||||||
if (isRefChecked && !invalidRef && user && !sessionCleared) setShowSessionModal(true)
|
if (isRefChecked && !invalidRef && user && !sessionCleared) {
|
||||||
|
setShowSessionModal(true)
|
||||||
|
}
|
||||||
}, [isRefChecked, invalidRef, user, sessionCleared])
|
}, [isRefChecked, invalidRef, user, sessionCleared])
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@ -116,79 +138,110 @@ export default function RegisterPage() {
|
|||||||
router.push('/dashboard')
|
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) {
|
if (!isRefChecked) {
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<PageLayout>
|
||||||
<PageLayout>
|
<div className="relative w-full flex flex-col min-h-screen overflow-hidden" style={{ backgroundImage: 'none', background: 'none' }}>
|
||||||
<main className="w-full flex flex-col flex-1 items-center justify-center py-24 min-h-screen">
|
<Waves
|
||||||
<div className="text-center">
|
className="pointer-events-none"
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
lineColor="#0f172a"
|
||||||
<p className="text-slate-700">Checking invitation link…</p>
|
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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</PageLayout>
|
</div>
|
||||||
</ToastProvider>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Invalid referral link state — show modal instead of form with same background as register form
|
|
||||||
if (invalidRef) {
|
if (invalidRef) {
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<PageLayout>
|
||||||
<PageLayout>
|
<div className="relative w-full flex flex-col min-h-screen overflow-hidden" style={{ backgroundImage: 'none', background: 'none' }}>
|
||||||
<main className="w-full flex flex-col flex-1 gap-10 min-h-screen">
|
<Waves
|
||||||
{/* make wrapper flex-1 so background reaches the footer */}
|
className="pointer-events-none"
|
||||||
<div className="relative flex-1 overflow-hidden pt-16 sm:pt-20 pb-20 sm:pb-24">
|
lineColor="#0f172a"
|
||||||
{/* Pattern */}
|
backgroundColor="rgba(245, 245, 240, 1)"
|
||||||
<svg
|
waveSpeedX={0.02}
|
||||||
aria-hidden="true"
|
waveSpeedY={0.01}
|
||||||
className="absolute inset-0 -z-10 h-full w-full stroke-white/10"
|
waveAmpX={40}
|
||||||
>
|
waveAmpY={20}
|
||||||
<defs>
|
friction={0.9}
|
||||||
<pattern
|
tension={0.01}
|
||||||
id="register-pattern"
|
maxCursorMove={120}
|
||||||
x="50%"
|
xGap={12}
|
||||||
y={-1}
|
yGap={36}
|
||||||
width={200}
|
/>
|
||||||
height={200}
|
<main
|
||||||
patternUnits="userSpaceOnUse"
|
className="relative z-10 flex flex-col flex-1 items-center justify-start sm:justify-center pb-10 sm:pb-20"
|
||||||
>
|
style={mainStyle}
|
||||||
<path
|
>
|
||||||
d="M.5 200V.5H200"
|
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex flex-col w-full">
|
||||||
fill="none"
|
|
||||||
stroke="rgba(255,255,255,0.05)"
|
|
||||||
/>
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<rect
|
|
||||||
fill="url(#register-pattern)"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
strokeWidth={0}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Colored blur */}
|
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
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"
|
||||||
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"
|
style={{
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.55)', // Use a translucent white for glass effect
|
||||||
|
backdropFilter: 'blur(18px)', // Glass blur
|
||||||
|
WebkitBackdropFilter: 'blur(18px)',
|
||||||
|
boxShadow: '0 18px 45px rgba(15,23,42,0.45)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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" />
|
||||||
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
<div className="relative flex items-center justify-center">
|
||||||
style={{
|
|
||||||
clipPath:
|
|
||||||
'polygon(63.1% 29.5%, 100% 17.1%, 76.6% 3%, 48.4% 0%, 44.6% 4.7%, 54.5% 25.3%, 59.8% 49%, 55.2% 57.8%, 44.4% 57.2%, 27.8% 47.9%, 35.1% 81.5%, 0% 97.7%, 39.2% 100%, 35.2% 81.4%, 97.2% 52.8%, 63.1% 29.5%)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional background layers */}
|
|
||||||
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
|
|
||||||
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.1),transparent_65%)]" />
|
|
||||||
|
|
||||||
<div className="mx-auto max-w-7xl px-6 lg:px-8 relative z-10">
|
|
||||||
<div className="flex flex-col flex-1 items-center justify-center">
|
|
||||||
<InvalidRefLinkModal
|
<InvalidRefLinkModal
|
||||||
inline
|
inline
|
||||||
open
|
open
|
||||||
@ -200,111 +253,97 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</PageLayout>
|
</div>
|
||||||
</ToastProvider>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normal register
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<PageLayout>
|
||||||
<PageLayout>
|
<div className="relative w-full flex flex-col min-h-screen overflow-hidden" style={{ backgroundImage: 'none', background: 'none' }}>
|
||||||
<main className="w-full flex flex-col flex-1 gap-10 min-h-screen">
|
<Waves
|
||||||
{/* Background section wrapper */}
|
className="pointer-events-none"
|
||||||
{/* make wrapper flex-1 so background reaches the footer */}
|
lineColor="#0f172a"
|
||||||
<div className="relative flex-1 overflow-hidden pt-16 sm:pt-20 pb-20 sm:pb-24">
|
backgroundColor="rgba(245, 245, 240, 1)"
|
||||||
{/* Pattern */}
|
waveSpeedX={0.02}
|
||||||
<svg
|
waveSpeedY={0.01}
|
||||||
aria-hidden="true"
|
waveAmpX={40}
|
||||||
className="absolute inset-0 -z-10 h-full w-full stroke-white/10"
|
waveAmpY={20}
|
||||||
>
|
friction={0.9}
|
||||||
<defs>
|
tension={0.01}
|
||||||
<pattern
|
maxCursorMove={120}
|
||||||
id="register-pattern"
|
xGap={12}
|
||||||
x="50%"
|
yGap={36}
|
||||||
y={-1}
|
/>
|
||||||
width={200}
|
<main
|
||||||
height={200}
|
className="relative z-10 flex flex-col flex-1 items-center justify-start sm:justify-center pb-10 sm:pb-20"
|
||||||
patternUnits="userSpaceOnUse"
|
style={mainStyle}
|
||||||
>
|
>
|
||||||
<path
|
<div className="mx-auto max-w-7xl px-6 lg:px-10 flex flex-col w-full">
|
||||||
d="M.5 200V.5H200"
|
|
||||||
fill="none"
|
|
||||||
stroke="rgba(255,255,255,0.05)"
|
|
||||||
/>
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<rect
|
|
||||||
fill="url(#register-pattern)"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
strokeWidth={0}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Colored blur */}
|
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
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"
|
||||||
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"
|
style={{
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)', // Use a translucent white for glass effect
|
||||||
|
backdropFilter: 'blur(40px)', // Glass blur
|
||||||
|
WebkitBackdropFilter: 'blur(40px)',
|
||||||
|
boxShadow: '0 18px 45px rgba(15,23,42,0.45)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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" />
|
||||||
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
<div className="relative">
|
||||||
style={{
|
<div className="mx-auto max-w-2xl text-center mb-8">
|
||||||
clipPath:
|
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">
|
||||||
'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%)'
|
Register now
|
||||||
}}
|
</h1>
|
||||||
/>
|
<p className="mt-3 text-slate-700 text-base sm:text-lg">
|
||||||
</div>
|
Create your personal or company account with Profit Planet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Additional background layers */}
|
<div className="mt-2">
|
||||||
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
|
{showSessionModal ? (
|
||||||
<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="flex items-center justify-center">
|
||||||
|
<SessionDetectedModal
|
||||||
<div className="mx-auto max-w-7xl px-6 lg:px-8 relative z-10">
|
inline
|
||||||
{/* Heading (optional – adjusted to registration context) */}
|
open
|
||||||
<div className="mx-auto max-w-2xl text-center mb-10">
|
onLogout={handleLogout}
|
||||||
<h1 className="text-4xl font-semibold tracking-tight text-white sm:text-5xl">
|
onCancel={handleCancel}
|
||||||
Register now
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-lg/8 text-gray-200">
|
|
||||||
Create your personal or company account with Profit Planet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content area */}
|
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
{showSessionModal ? (
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<SessionDetectedModal
|
|
||||||
inline
|
|
||||||
open
|
|
||||||
onLogout={handleLogout}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Register form (only if ref valid) */}
|
|
||||||
{(!user || sessionCleared) && (
|
|
||||||
<RegisterForm
|
|
||||||
mode={mode}
|
|
||||||
setMode={setMode}
|
|
||||||
refToken={refToken}
|
|
||||||
onRegistered={() => setRegistered(true)}
|
|
||||||
referrerEmail={refInfo?.referrerEmail}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
{registered && (
|
) : (
|
||||||
<div className="mt-6 mx-auto text-center text-sm text-gray-200">
|
<>
|
||||||
Registration successful – redirecting...
|
{(!user || sessionCleared) && (
|
||||||
</div>
|
<RegisterForm
|
||||||
)}
|
mode={mode}
|
||||||
</>
|
setMode={setMode}
|
||||||
)}
|
refToken={refToken}
|
||||||
|
onRegistered={() => setRegistered(true)}
|
||||||
|
referrerEmail={refInfo?.referrerEmail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{registered && (
|
||||||
|
<div className="mt-6 mx-auto text-center text-sm text-gray-700">
|
||||||
|
Registration successful – redirecting...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</PageLayout>
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: default export only provides the ToastProvider wrapper
|
||||||
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<RegisterPageInner />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -11,15 +11,16 @@
|
|||||||
|
|
||||||
const ITI_CDN_CSS =
|
const ITI_CDN_CSS =
|
||||||
'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/css/intlTelInput.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 =
|
const ITI_CDN_JS =
|
||||||
'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/js/intlTelInput.min.js'
|
'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/js/intlTelInputWithUtils.min.js'
|
||||||
const ITI_CDN_UTILS =
|
|
||||||
'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/js/utils.js'
|
|
||||||
|
|
||||||
export type IntlTelInputInstance = {
|
export type IntlTelInputInstance = {
|
||||||
destroy: () => void
|
destroy: () => void
|
||||||
getNumber: () => string
|
getNumber: () => string
|
||||||
isValidNumber: () => boolean
|
isValidNumber: () => boolean
|
||||||
|
setNumber?: (number: string) => void
|
||||||
getValidationError?: () => number
|
getValidationError?: () => number
|
||||||
getSelectedCountryData?: () => { name: string; iso2: string; dialCode: string }
|
getSelectedCountryData?: () => { name: string; iso2: string; dialCode: string }
|
||||||
promise?: Promise<unknown>
|
promise?: Promise<unknown>
|
||||||
@ -27,7 +28,9 @@ export type IntlTelInputInstance = {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
intlTelInput?: (input: HTMLInputElement, options: any) => IntlTelInputInstance
|
intlTelInput?: ((input: HTMLInputElement, options: any) => IntlTelInputInstance) & {
|
||||||
|
getInstance?: (input: HTMLInputElement) => IntlTelInputInstance | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,26 +73,36 @@ async function loadIntlTelInputFromCdn(): Promise<
|
|||||||
document.head.appendChild(style)
|
document.head.appendChild(style)
|
||||||
}
|
}
|
||||||
|
|
||||||
// JS once
|
// JS once (but replace if the existing script points to a different bundle)
|
||||||
if (window.intlTelInput) {
|
if (window.intlTelInput) {
|
||||||
console.log('[phoneUtils] intl-tel-input already loaded on window')
|
console.log('[phoneUtils] intl-tel-input already loaded on window')
|
||||||
return window.intlTelInput
|
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) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const existing = document.querySelector<HTMLScriptElement>(
|
const existing = document.querySelector<HTMLScriptElement>(
|
||||||
'script[data-intl-tel-input-js="true"]'
|
'script[data-intl-tel-input-js="true"]'
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log('[phoneUtils] Reusing existing intl-tel-input <script> tag, waiting for load')
|
const existingSrc = existing.getAttribute('src') || ''
|
||||||
existing.addEventListener('load', () => resolve(), { once: true })
|
if (existingSrc !== ITI_CDN_JS) {
|
||||||
existing.addEventListener(
|
console.warn('[phoneUtils] Replacing existing intl-tel-input script with different src', {
|
||||||
'error',
|
existingSrc,
|
||||||
() => reject(new Error('Failed to load intl-tel-input')),
|
desiredSrc: ITI_CDN_JS,
|
||||||
{ once: true }
|
})
|
||||||
)
|
existing.remove()
|
||||||
return
|
} else {
|
||||||
|
console.log('[phoneUtils] Reusing existing intl-tel-input <script> tag, waiting for load')
|
||||||
|
existing.addEventListener('load', () => resolve(), { once: true })
|
||||||
|
existing.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => reject(new Error('Failed to load intl-tel-input')),
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const script = document.createElement('script')
|
const script = document.createElement('script')
|
||||||
@ -97,22 +110,22 @@ async function loadIntlTelInputFromCdn(): Promise<
|
|||||||
script.async = true
|
script.async = true
|
||||||
script.dataset.intlTelInputJs = 'true'
|
script.dataset.intlTelInputJs = 'true'
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
console.log('[phoneUtils] intl-tel-input core script loaded successfully')
|
console.log('[phoneUtils] intl-tel-input script loaded successfully')
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
script.onerror = () => {
|
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'))
|
reject(new Error('Failed to load intl-tel-input'))
|
||||||
}
|
}
|
||||||
document.head.appendChild(script)
|
document.head.appendChild(script)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!window.intlTelInput) {
|
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')
|
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
|
return window.intlTelInput
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,7 +136,10 @@ export async function ensureIntlCoreLoaded(): Promise<
|
|||||||
(input: HTMLInputElement, options: any) => IntlTelInputInstance
|
(input: HTMLInputElement, options: any) => IntlTelInputInstance
|
||||||
> {
|
> {
|
||||||
if (!intlLoaderPromise) {
|
if (!intlLoaderPromise) {
|
||||||
intlLoaderPromise = loadIntlTelInputFromCdn()
|
intlLoaderPromise = loadIntlTelInputFromCdn().catch(err => {
|
||||||
|
intlLoaderPromise = null
|
||||||
|
throw err
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return intlLoaderPromise
|
return intlLoaderPromise
|
||||||
}
|
}
|
||||||
@ -138,25 +154,26 @@ export async function createIntlTelInput(
|
|||||||
): Promise<IntlTelInputInstance> {
|
): Promise<IntlTelInputInstance> {
|
||||||
const intlTelInput = await ensureIntlCoreLoaded()
|
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,
|
id: input.id,
|
||||||
name: input.name,
|
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, {
|
const instance = intlTelInput(input, {
|
||||||
...options,
|
...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
|
const anyInst = instance as any
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user