diff --git a/package-lock.json b/package-lock.json index f54968f..0402af3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "clsx": "^2.1.1", "country-flag-icons": "^1.5.21", "country-select-js": "^2.1.0", - "intl-tel-input": "^25.10.11", + "intl-tel-input": "^25.15.0", "motion": "^12.23.22", "next": "^16.0.7", "pdfjs-dist": "^5.4.149", @@ -6504,9 +6504,9 @@ } }, "node_modules/intl-tel-input": { - "version": "25.11.2", - "resolved": "https://registry.npmjs.org/intl-tel-input/-/intl-tel-input-25.11.2.tgz", - "integrity": "sha512-3a9+bbtR6s7E8TjZauqodMz+MRMd31OcUhTJuQOg95lA+viZ53OTU8XzVuyldEE089nMtLhPF1NbRU1ff2Sf7g==", + "version": "25.15.0", + "resolved": "https://registry.npmjs.org/intl-tel-input/-/intl-tel-input-25.15.0.tgz", + "integrity": "sha512-ux50qr6qCAWrd3BMioVN3te54h2+2pobvgq7vbKQ1GUP5P8XUcVuxKN/5bSxZuOYJqyqeCRqfcH7zlYErFZMtw==", "license": "MIT" }, "node_modules/is-array-buffer": { diff --git a/package.json b/package.json index 0135661..a876b2d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "clsx": "^2.1.1", "country-flag-icons": "^1.5.21", "country-select-js": "^2.1.0", - "intl-tel-input": "^25.10.11", + "intl-tel-input": "^25.15.0", "motion": "^12.23.22", "next": "^16.0.7", "pdfjs-dist": "^5.4.149", diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 343d6f9..5a6727a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -15,6 +15,11 @@ import { useMemo, useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import { useAdminUsers } from '../hooks/useAdminUsers' +// env-based feature flags +const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false' +const DISPLAY_ABONEMENTS = process.env.NEXT_PUBLIC_DISPLAY_ABONEMMENTS !== 'false' +const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false' + export default function AdminDashboardPage() { const router = useRouter() const { userStats, isAdmin } = useAdminUsers() @@ -147,45 +152,92 @@ export default function AdminDashboardPage() {
+ {/* Matrix Management */} + + {/* Coffee Subscription Management */} + + {/* Contract Management (unchanged) */} + + {/* User Management (unchanged) */} + + {/* News Management */}
diff --git a/src/app/components/PageLayout.tsx b/src/app/components/PageLayout.tsx index 4bc61b8..11f1a9e 100644 --- a/src/app/components/PageLayout.tsx +++ b/src/app/components/PageLayout.tsx @@ -1,6 +1,6 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; import Header from './nav/Header'; import Footer from './Footer'; import PageTransitionEffect from './animation/pageTransitionEffect'; @@ -23,13 +23,14 @@ export default function PageLayout({ showFooter = true }: PageLayoutProps) { const isMobile = isMobileDevice(); - + const [isLoggingOut, setIsLoggingOut] = useState(false); // NEW + return (
{showHeader && (
-
+
)} @@ -43,6 +44,16 @@ export default function PageLayout({
)} + + {/* Global logout transition overlay (covers whole page) */} + {isLoggingOut && ( +
+
+
+

Logging you out...

+
+
+ )}
); } \ No newline at end of file diff --git a/src/app/components/nav/Header.tsx b/src/app/components/nav/Header.tsx index be9f0ce..33a46bb 100644 --- a/src/app/components/nav/Header.tsx +++ b/src/app/components/nav/Header.tsx @@ -53,11 +53,14 @@ const navLinks = [ // Toggle visibility of Shop navigation across header (desktop + mobile) const showShop = false -export default function Header() { +interface HeaderProps { + setGlobalLoggingOut?: (value: boolean) => void; // NEW +} + +export default function Header({ setGlobalLoggingOut }: HeaderProps) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mounted, setMounted] = useState(false) const [animateIn, setAnimateIn] = useState(false) - const [loggingOut, setLoggingOut] = useState(false) // NEW const user = useAuthStore(s => s.user) const logout = useAuthStore(s => s.logout) const accessToken = useAuthStore(s => s.accessToken) @@ -67,16 +70,18 @@ export default function Header() { const [hasReferralPerm, setHasReferralPerm] = useState(false) const [adminMgmtOpen, setAdminMgmtOpen] = useState(false) const managementRef = useRef(null) + const [canSeeDashboard, setCanSeeDashboard] = useState(false) const handleLogout = async () => { try { - setLoggingOut(true) // NEW: start logout transition + // start global logout transition + setGlobalLoggingOut?.(true) await logout() setMobileMenuOpen(false) router.push('/login') } catch (err) { console.error('Logout failed:', err) - setLoggingOut(false) // reset if something goes wrong + setGlobalLoggingOut?.(false) } }; @@ -189,11 +194,78 @@ export default function Header() { return () => { cancelled = true } }, [mounted, user, accessToken, refreshAuthToken]) + // NEW: fetch onboarding status to decide if dashboard should be visible + useEffect(() => { + let cancelled = false + + const fetchOnboardingStatus = async () => { + if (!mounted || !user) { + if (!cancelled) setCanSeeDashboard(false) + return + } + + let tokenToUse = accessToken + try { + if (!tokenToUse && refreshAuthToken) { + const ok = await refreshAuthToken() + if (ok) tokenToUse = useAuthStore.getState().accessToken + } + } catch (e) { + console.error('❌ Header: refreshAuthToken (status-progress) error:', e) + } + + if (!tokenToUse) { + if (!cancelled) setCanSeeDashboard(false) + return + } + + try { + const statusUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/user/status-progress` + console.log('🌐 Header: fetching status-progress:', statusUrl) + + const res = await fetch(statusUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${tokenToUse}`, + 'Content-Type': 'application/json', + }, + credentials: 'include', + }) + + if (!res.ok) { + console.warn('⚠️ Header: status-progress failed with', res.status) + if (!cancelled) setCanSeeDashboard(false) + return + } + + const statusData = await res.json().catch(() => null) + const progressData = statusData?.progress || statusData || {} + const steps = progressData.steps || [] + const allStepsCompleted = + steps.length === 4 && steps.every((step: any) => step?.completed === true) + const isActive = progressData.status === 'active' + + if (!cancelled) { + setCanSeeDashboard(allStepsCompleted && isActive) + } + } catch (e) { + console.error('❌ Header: status-progress fetch error:', e) + if (!cancelled) setCanSeeDashboard(false) + } + } + + fetchOnboardingStatus() + return () => { + cancelled = true + } + }, [mounted, user, accessToken, refreshAuthToken]) + const isLoggedIn = !!user const userPresent = mounted && isLoggedIn - // NEW: detect admin role across common shapes (guarded by mount to avoid SSR/CSR mismatch) - const rawIsAdmin = + // NEW: detect admin role across common shapes, but only after mount + const isAdmin = + mounted && !!user && ( (user as any)?.role === 'admin' || @@ -236,13 +308,32 @@ export default function Header() {
@@ -318,13 +409,15 @@ export default function Header() { {user?.email || 'user@example.com'} - + {canSeeDashboard && ( + + )} @@ -487,24 +599,24 @@ export default function Header() { {/* Side drawer menu: mobile + desktop */} - {/* Overlay: slightly slower fade for a smoother feel */} + {/* Overlay: smoother, longer fade-out */}
- {/* Sliding panel: slide + fade + scale for a modern, flashy effect */} + {/* Sliding panel: smoother, longer close animation */} @@ -557,14 +669,22 @@ export default function Header() { {user?.email || 'user@example.com'}
+ {canSeeDashboard && ( + + )} -
- - {/* Logout transition overlay */} - {loggingOut && ( -
-
-
-

Logging you out...

-
-
- )} ) } \ No newline at end of file diff --git a/src/app/components/phone/telephoneInput.tsx b/src/app/components/phone/telephoneInput.tsx new file mode 100644 index 0000000..ea9b89e --- /dev/null +++ b/src/app/components/phone/telephoneInput.tsx @@ -0,0 +1,160 @@ +'use client' + +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + InputHTMLAttributes, +} from 'react' +import { createIntlTelInput, IntlTelInputInstance } from '../../utils/phoneUtils' + +export type TelephoneInputHandle = { + getNumber: () => string + isValid: () => boolean +} + +interface TelephoneInputProps extends Omit, 'type'> { + /** e.g. "de" */ + initialCountry?: string +} + +/** + * Reusable telephone input with intl-tel-input. + * Always takes full available width. + */ +const TelephoneInput = forwardRef( + ({ initialCountry = (process.env.NEXT_PUBLIC_GEO_FALLBACK_COUNTRY || 'DE').toLowerCase(), ...rest }, ref) => { + const inputRef = useRef(null) + const itiRef = useRef(null) + + useEffect(() => { + let disposed = false + let instance: IntlTelInputInstance | null = null + + const setup = async () => { + try { + console.log('[TelephoneInput] setup() start for', { + id: rest.id, + name: rest.name, + initialCountry, + }) + + if (!inputRef.current) { + console.warn('[TelephoneInput] setup() aborted: inputRef is null', { + id: rest.id, + name: rest.name, + }) + return + } + + instance = await createIntlTelInput(inputRef.current, { + initialCountry, + nationalMode: true, + strictMode: true, + autoPlaceholder: 'aggressive', + validationNumberTypes: ['MOBILE'], + }) + + if (disposed) { + console.log('[TelephoneInput] setup() finished but component is disposed, destroying instance', { + id: rest.id, + name: rest.name, + }) + instance.destroy() + return + } + + itiRef.current = instance + console.log('[TelephoneInput] intl-tel-input instance attached to input', { + id: rest.id, + name: rest.name, + }) + } catch (e) { + console.error('[TelephoneInput] Failed to init intl-tel-input:', e) + } + } + + setup() + + return () => { + disposed = true + if (instance) { + console.log('[TelephoneInput] Destroying intl-tel-input instance for', { + id: rest.id, + name: rest.name, + }) + instance.destroy() + if (itiRef.current === instance) itiRef.current = null + } + } + }, [initialCountry, rest.id, rest.name]) + + useImperativeHandle(ref, () => ({ + getNumber: () => { + const raw = inputRef.current?.value || '' + if (itiRef.current) { + const intl = itiRef.current.getNumber() + console.log('[TelephoneInput] getNumber()', { + id: rest.id, + name: rest.name, + raw, + intl, + }) + return intl + } + console.warn( + '[TelephoneInput] getNumber() called before intl-tel-input ready, returning raw value', + { id: rest.id, name: rest.name, raw } + ) + return raw + }, + isValid: () => { + if (!itiRef.current) { + const raw = inputRef.current?.value || '' + console.warn('[TelephoneInput] isValid() called before intl-tel-input ready', { + id: rest.id, + name: rest.name, + raw, + }) + return false + } + const instance = itiRef.current + const intl = instance.getNumber() + const valid = instance.isValidNumber() + const errorCode = typeof instance.getValidationError === 'function' + ? instance.getValidationError() + : undefined + const country = typeof instance.getSelectedCountryData === 'function' + ? instance.getSelectedCountryData() + : undefined + + console.log('[TelephoneInput] isValid() check', { + id: rest.id, + name: rest.name, + intl, + valid, + errorCode, + country, + }) + + return valid + }, + })) + + return ( +
+ +
+ ) + } +) + +TelephoneInput.displayName = 'TelephoneInput' + +export default TelephoneInput diff --git a/src/app/register/components/RegisterForm.tsx b/src/app/register/components/RegisterForm.tsx index 2cb7adb..e21d74a 100644 --- a/src/app/register/components/RegisterForm.tsx +++ b/src/app/register/components/RegisterForm.tsx @@ -1,9 +1,10 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline' import { useRegister } from '../hooks/useRegister' import { useToast } from '../../components/toast/toastComponent' +import TelephoneInput, { TelephoneInputHandle } from '../../components/phone/telephoneInput' interface RegisterFormProps { mode: 'personal' | 'company' @@ -70,7 +71,12 @@ export default function RegisterForm({ const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [formFade, setFormFade] = useState('fade-in') - + + // Phone input refs (to access intl-tel-input via TelephoneInput) + const personalPhoneRef = useRef(null) + const companyPhoneRef = useRef(null) + const contactPhoneRef = useRef(null) + // Hook for backend calls const { registerPersonalReferral, registerCompanyReferral, error: regError } = useRegister() const { showToast } = useToast() @@ -114,8 +120,7 @@ export default function RegisterForm({ const validatePersonalForm = (): boolean => { if (!personalForm.firstName.trim() || !personalForm.lastName.trim() || !personalForm.email.trim() || !personalForm.confirmEmail.trim() || - !personalForm.password.trim() || !personalForm.confirmPassword.trim() || - !personalForm.phoneNumber.trim() + !personalForm.password.trim() || !personalForm.confirmPassword.trim() ) { setError('All fields are required') return false @@ -135,6 +140,25 @@ export default function RegisterForm({ setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters') return false } + + const phoneApi = personalPhoneRef.current + const intlNumber = phoneApi?.getNumber() || '' + const valid = phoneApi?.isValid() ?? false + + console.log('[RegisterForm] validatePersonalForm phone check', { + rawState: personalForm.phoneNumber, + intlFromApi: intlNumber, + isValidFromApi: valid, + }) + + if (!intlNumber) { + setError('Please enter your phone number including country code.') + return false + } + if (!valid) { + setError('Please enter a valid mobile phone number.') + return false + } setError('') return true @@ -143,8 +167,7 @@ export default function RegisterForm({ const validateCompanyForm = (): boolean => { if (!companyForm.companyName.trim() || !companyForm.companyEmail.trim() || !companyForm.confirmCompanyEmail.trim() || !companyForm.contactPersonName.trim() || - !companyForm.password.trim() || !companyForm.confirmPassword.trim() || - !companyForm.companyPhone.trim() || !companyForm.contactPersonPhone.trim() + !companyForm.password.trim() || !companyForm.confirmPassword.trim() ) { setError('All fields are required') return false @@ -164,6 +187,32 @@ export default function RegisterForm({ setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters') return false } + + const companyApi = companyPhoneRef.current + const contactApi = contactPhoneRef.current + + const companyNumber = companyApi?.getNumber() || '' + const contactNumber = contactApi?.getNumber() || '' + const companyValid = companyApi?.isValid() ?? false + const contactValid = contactApi?.isValid() ?? false + + console.log('[RegisterForm] validateCompanyForm phone check', { + rawCompany: companyForm.companyPhone, + rawContact: companyForm.contactPersonPhone, + intlCompany: companyNumber, + intlContact: contactNumber, + companyValid, + contactValid, + }) + + if (!companyNumber || !contactNumber) { + setError('Please enter both company and contact phone numbers including country codes.') + return false + } + if (!companyValid || !contactValid) { + setError('Please enter valid phone numbers for company and contact person.') + return false + } setError('') return true @@ -180,13 +229,20 @@ export default function RegisterForm({ setError('') try { + const normalizedPhone = + personalPhoneRef.current?.getNumber() || personalForm.phoneNumber + + console.log('[RegisterForm] handlePersonalSubmit normalized phone', { + normalizedPhone, + }) + const res = await registerPersonalReferral({ refToken: refToken || '', firstName: personalForm.firstName, lastName: personalForm.lastName, email: personalForm.email, password: personalForm.password, - phone: personalForm.phoneNumber, + phone: normalizedPhone, }) if (res.ok) { showToast({ @@ -227,14 +283,24 @@ export default function RegisterForm({ setError('') try { + const normalizedCompanyPhone = + companyPhoneRef.current?.getNumber() || companyForm.companyPhone + const normalizedContactPhone = + contactPhoneRef.current?.getNumber() || companyForm.contactPersonPhone + + console.log('[RegisterForm] handleCompanySubmit normalized phones', { + normalizedCompanyPhone, + normalizedContactPhone, + }) + const res = await registerCompanyReferral({ refToken: refToken || '', companyEmail: companyForm.companyEmail, password: companyForm.password, companyName: companyForm.companyName, - companyPhone: companyForm.companyPhone, + companyPhone: normalizedCompanyPhone, contactPersonName: companyForm.contactPersonName, - contactPersonPhone: companyForm.contactPersonPhone, + contactPersonPhone: normalizedContactPhone, }) if (res.ok) { showToast({ @@ -314,7 +380,10 @@ export default function RegisterForm({
Password requirements:
    {rules.map((rule, index) => ( -
  • +
  • {rule.test ? '✓' : '○'} {rule.text}
  • @@ -331,7 +400,6 @@ export default function RegisterForm({

    Registration for Profit Planet

    - {/* Replace generic invite with referrer email inside the form */} {referrerEmail && (

    You were invited by {referrerEmail}! @@ -446,15 +514,15 @@ export default function RegisterForm({ - + setPersonalForm(prev => ({ ...prev, phoneNumber: (e.target as HTMLInputElement).value })) + } />

@@ -594,31 +662,34 @@ export default function RegisterForm({ - + setCompanyForm(prev => ({ ...prev, companyPhone: (e.target as HTMLInputElement).value })) + } /> - +
- + setCompanyForm(prev => ({ + ...prev, + contactPersonPhone: (e.target as HTMLInputElement).value, + })) + } />
@@ -705,4 +776,4 @@ export default function RegisterForm({ ) -} \ No newline at end of file +} diff --git a/src/app/utils/phoneUtils.ts b/src/app/utils/phoneUtils.ts new file mode 100644 index 0000000..7342419 --- /dev/null +++ b/src/app/utils/phoneUtils.ts @@ -0,0 +1,179 @@ +'use client' + +/** + * Shared intl-tel-input utilities for the frontend (CDN-based). + * Handles: + * - CSS injection + * - JS script loading + * - utils.js loading via loadUtils + * - instance creation + */ + +const ITI_CDN_CSS = + 'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/css/intlTelInput.css' +const ITI_CDN_JS = + 'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/js/intlTelInput.min.js' +const ITI_CDN_UTILS = + 'https://cdn.jsdelivr.net/npm/intl-tel-input@25.15.0/build/js/utils.js' + +export type IntlTelInputInstance = { + destroy: () => void + getNumber: () => string + isValidNumber: () => boolean + getValidationError?: () => number + getSelectedCountryData?: () => { name: string; iso2: string; dialCode: string } + promise?: Promise +} + +declare global { + interface Window { + intlTelInput?: (input: HTMLInputElement, options: any) => IntlTelInputInstance + } +} + +let intlLoaderPromise: Promise<(input: HTMLInputElement, options: any) => IntlTelInputInstance> | null = + null + +async function loadIntlTelInputFromCdn(): Promise< + (input: HTMLInputElement, options: any) => IntlTelInputInstance +> { + if (typeof window === 'undefined') { + throw new Error('[phoneUtils] intl-tel-input can only be used in the browser') + } + + // CSS once + if (!document.querySelector('link[data-intl-tel-input-css="true"]')) { + console.log('[phoneUtils] Injecting intl-tel-input CSS from CDN') + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = ITI_CDN_CSS + link.dataset.intlTelInputCss = 'true' + document.head.appendChild(link) + } else { + console.log('[phoneUtils] intl-tel-input CSS already present') + } + + // Helper CSS to make .iti full-width + if (!document.querySelector('style[data-intl-tel-input-width="true"]')) { + console.log('[phoneUtils] Injecting full-width .iti CSS helper') + const style = document.createElement('style') + style.dataset.intlTelInputWidth = 'true' + style.innerHTML = ` + .iti { + display: block; + width: 100%; + } + .iti input { + width: 100%; + } + ` + document.head.appendChild(style) + } + + // JS once + if (window.intlTelInput) { + console.log('[phoneUtils] intl-tel-input already loaded on window') + return window.intlTelInput + } + + console.log('[phoneUtils] Loading intl-tel-input core (no utils) from CDN…') + await new Promise((resolve, reject) => { + const existing = document.querySelector( + 'script[data-intl-tel-input-js="true"]' + ) + if (existing) { + console.log('[phoneUtils] Reusing existing intl-tel-input