qa-dashboard #2

Merged
Seazn merged 6 commits from qa-dashboard into dev 2025-10-12 12:09:51 +00:00
16 changed files with 1933 additions and 77 deletions
Showing only changes of commit 25fff9b1c3 - Show all commits

View File

@ -1,7 +1,14 @@
'use client'; 'use client';
import { I18nProvider } from './i18n/useTranslation'; import { I18nProvider } from './i18n/useTranslation';
import AuthInitializer from './components/AuthInitializer';
export default function ClientWrapper({ children }: { children: React.ReactNode }) { export default function ClientWrapper({ children }: { children: React.ReactNode }) {
return <I18nProvider>{children}</I18nProvider>; return (
<I18nProvider>
<AuthInitializer>
{children}
</AuthInitializer>
</I18nProvider>
);
} }

View File

@ -0,0 +1,26 @@
'use client'
import { useEffect } from 'react'
import useAuthStore from '../store/authStore'
export default function AuthInitializer({ children }: { children: React.ReactNode }) {
const { refreshAuthToken, setAuthReady } = useAuthStore()
useEffect(() => {
const initializeAuth = async () => {
try {
// Try to refresh token from httpOnly cookie
await refreshAuthToken()
} catch (error) {
console.log('No valid refresh token found')
} finally {
// Set auth as ready regardless of success/failure
setAuthReady(true)
}
}
initializeAuth()
}, [refreshAuthToken, setAuthReady])
return <>{children}</>
}

View File

@ -0,0 +1,870 @@
'use client'
import { useState, useEffect } from 'react'
import {
EnvelopeIcon,
DocumentTextIcon,
UserPlusIcon,
DocumentCheckIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline'
import useAuthStore from '../../store/authStore'
import { useUserStatus } from '../../hooks/useUserStatus'
interface QuickAction {
id: string
title: string
description: string
icon: any
color: string
status: 'pending' | 'completed' | 'unavailable'
onClick: () => void
}
// UserStatus interface is now imported from useUserStatus hook
export default function QuickActions() {
const { userStatus, loading, error, refreshStatus } = useUserStatus()
const [showEmailVerification, setShowEmailVerification] = useState(false)
const [showDocumentUpload, setShowDocumentUpload] = useState(false)
const [showProfileCompletion, setShowProfileCompletion] = useState(false)
const [showContractSigning, setShowContractSigning] = useState(false)
const [isClient, setIsClient] = useState(false)
const user = useAuthStore(state => state.user)
// Handle SSR hydration
useEffect(() => {
setIsClient(true)
}, [])
// Debug logging (can be removed in production)
useEffect(() => {
if (isClient && process.env.NODE_ENV === 'development') {
console.log('🔍 [QuickActions] userStatus changed:', userStatus)
console.log('🔍 [QuickActions] loading state:', loading)
console.log('🔍 [QuickActions] error state:', error)
}
}, [isClient, userStatus, loading, error])
// Don't render until client-side hydration is complete
if (!isClient) {
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Account Setup</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white rounded-lg p-6 shadow-sm border border-gray-200">
<div className="flex items-start">
<div className="bg-gray-200 rounded-lg p-3 w-12 h-12"></div>
<div className="ml-4 flex-1">
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-3 bg-gray-200 rounded"></div>
</div>
</div>
</div>
))}
</div>
</div>
)
}
const getActionStatus = (action: string): 'pending' | 'completed' | 'unavailable' => {
// If loading or no userStatus, show initial states (email is pending, others unavailable)
if (!userStatus) {
if (process.env.NODE_ENV === 'development') {
console.log(`🔍 [getActionStatus] No userStatus for action: ${action}`)
}
switch (action) {
case 'email':
return 'pending'
default:
return 'unavailable'
}
}
if (process.env.NODE_ENV === 'development') {
console.log(`🔍 [getActionStatus] ${action}:`, {
email_verified: userStatus.email_verified,
documents_uploaded: userStatus.documents_uploaded,
profile_completed: userStatus.profile_completed,
contract_signed: userStatus.contract_signed
})
}
switch (action) {
case 'email':
return userStatus.email_verified ? 'completed' : 'pending'
case 'documents':
return !userStatus.email_verified ? 'unavailable' :
userStatus.documents_uploaded ? 'completed' : 'pending'
case 'profile':
return !userStatus.documents_uploaded ? 'unavailable' :
userStatus.profile_completed ? 'completed' : 'pending'
case 'contract':
return !userStatus.profile_completed ? 'unavailable' :
userStatus.contract_signed ? 'completed' : 'pending'
default:
return 'pending'
}
}
const quickActions: QuickAction[] = [
{
id: 'email',
title: 'Verify Email',
description: 'Confirm your email address to activate your account',
icon: EnvelopeIcon,
color: 'bg-blue-500',
status: getActionStatus('email'),
onClick: () => setShowEmailVerification(true)
},
{
id: 'documents',
title: 'Upload ID Documents',
description: user?.userType === 'company' ? 'Upload company registration documents' : 'Upload personal identification documents',
icon: DocumentTextIcon,
color: 'bg-green-500',
status: getActionStatus('documents'),
onClick: () => setShowDocumentUpload(true)
},
{
id: 'profile',
title: 'Complete Profile',
description: 'Add additional information to complete your profile',
icon: UserPlusIcon,
color: 'bg-purple-500',
status: getActionStatus('profile'),
onClick: () => setShowProfileCompletion(true)
},
{
id: 'contract',
title: 'Sign Contract',
description: 'Review and sign your service agreement',
icon: DocumentCheckIcon,
color: 'bg-orange-500',
status: getActionStatus('contract'),
onClick: () => setShowContractSigning(true)
}
]
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircleIcon className="h-5 w-5 text-green-500" />
case 'unavailable':
return <XCircleIcon className="h-5 w-5 text-gray-400" />
default:
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'completed':
return 'Completed'
case 'unavailable':
return 'Locked'
default:
return 'Pending'
}
}
// Show error state if there's an error
if (error && !userStatus) {
return (
<div className="mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Account Setup</h2>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex">
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error loading account status</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
<div className="mt-4">
<button
type="button"
onClick={refreshStatus}
className="bg-red-100 px-2 py-1 text-xs font-semibold text-red-800 hover:bg-red-200 rounded"
>
Try again
</button>
</div>
</div>
</div>
</div>
</div>
)
}
return (
<>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Account Setup</h2>
{loading && (
<div className="flex items-center text-sm text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#8D6B1D] mr-2"></div>
Updating status...
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{quickActions.map((action) => (
<button
key={action.id}
onClick={action.onClick}
disabled={action.status === 'unavailable' || loading}
className={`
bg-white rounded-lg p-6 shadow-sm border text-left group transition-all duration-200
${loading ? 'opacity-75' : ''}
${action.status === 'unavailable'
? 'border-gray-200 cursor-not-allowed opacity-60'
: action.status === 'completed'
? 'border-green-200 hover:border-green-300'
: 'border-gray-200 hover:shadow-md hover:border-gray-300'
}
`}
>
<div className="flex items-start">
<div className={`
${loading ? 'animate-pulse bg-gray-300' : action.color} rounded-lg p-3 transition-transform
${action.status === 'unavailable' ? 'opacity-60' : 'group-hover:scale-105'}
${action.status === 'completed' && !loading ? 'bg-green-500' : ''}
`}>
<action.icon className="h-6 w-6 text-white" />
</div>
<div className="ml-4 flex-1">
<div className="flex items-center justify-between mb-1">
<h3 className={`
text-lg font-medium transition-colors
${loading ? 'text-gray-400' : ''}
${action.status === 'unavailable'
? 'text-gray-400'
: action.status === 'completed'
? 'text-green-700'
: 'text-gray-900 group-hover:text-[#8D6B1D]'
}
`}>
{action.title}
</h3>
{loading ? (
<div className="animate-pulse bg-gray-300 rounded-full h-5 w-5"></div>
) : (
getStatusIcon(action.status)
)}
</div>
<p className={`
text-sm mt-1
${loading ? 'text-gray-400' : ''}
${action.status === 'unavailable' ? 'text-gray-400' : 'text-gray-600'}
`}>
{action.description}
</p>
<div className="mt-2">
{loading ? (
<div className="animate-pulse bg-gray-200 rounded-full h-5 w-16"></div>
) : (
<span className={`
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${action.status === 'completed'
? 'bg-green-100 text-green-800'
: action.status === 'unavailable'
? 'bg-gray-100 text-gray-800'
: 'bg-yellow-100 text-yellow-800'
}
`}>
{getStatusText(action.status)}
</span>
)}
</div>
</div>
</div>
</button>
))}
</div>
</div>
{/* Modals */}
{showEmailVerification && (
<EmailVerificationModal
onClose={() => setShowEmailVerification(false)}
onSuccess={refreshStatus}
/>
)}
{showDocumentUpload && (
<DocumentUploadModal
userType={user?.userType || 'personal'}
onClose={() => setShowDocumentUpload(false)}
onSuccess={refreshStatus}
/>
)}
{showProfileCompletion && (
<ProfileCompletionModal
userType={user?.userType || 'personal'}
onClose={() => setShowProfileCompletion(false)}
onSuccess={refreshStatus}
/>
)}
{showContractSigning && (
<ContractSigningModal
userType={user?.userType || 'personal'}
onClose={() => setShowContractSigning(false)}
onSuccess={refreshStatus}
/>
)}
</>
)
}
// Email Verification Modal Component
function EmailVerificationModal({ onClose, onSuccess }: { onClose: () => void, onSuccess: () => void }) {
const [verificationCode, setVerificationCode] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const token = useAuthStore(state => state.accessToken)
const sendVerificationEmail = async () => {
if (!token) return
setLoading(true)
setError('')
setMessage('')
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/send-verification-email`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
const data = await response.json()
if (response.ok) {
setMessage('Verification email sent! Check your inbox.')
} else {
setError(data.message || 'Failed to send verification email')
}
} catch (error) {
setError('Network error occurred')
} finally {
setLoading(false)
}
}
const verifyCode = async () => {
if (!token || !verificationCode.trim()) return
setLoading(true)
setError('')
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/verify-email-code`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ code: verificationCode })
})
const data = await response.json()
if (response.ok) {
setMessage('Email verified successfully!')
setTimeout(() => {
onSuccess()
onClose()
}, 1500)
} else {
setError(data.message || 'Invalid verification code')
}
} catch (error) {
setError('Network error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Email Verification</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XCircleIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-4">
We'll send a verification code to your email address.
</p>
<button
onClick={sendVerificationEmail}
disabled={loading}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Sending...' : 'Send Verification Email'}
</button>
</div>
<div>
<label htmlFor="code" className="block text-sm font-medium text-gray-700 mb-1">
Verification Code
</label>
<input
type="text"
id="code"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
placeholder="Enter 6-digit code"
maxLength={6}
/>
</div>
<button
onClick={verifyCode}
disabled={loading || !verificationCode.trim()}
className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Verifying...' : 'Verify Email'}
</button>
{message && (
<div className="text-green-600 text-sm text-center">{message}</div>
)}
{error && (
<div className="text-red-600 text-sm text-center">{error}</div>
)}
</div>
</div>
</div>
)
}
// Document Upload Modal Component
function DocumentUploadModal({ userType, onClose, onSuccess }: {
userType: string,
onClose: () => void,
onSuccess: () => void
}) {
const [frontFile, setFrontFile] = useState<File | null>(null)
const [backFile, setBackFile] = useState<File | null>(null)
const [idType, setIdType] = useState('')
const [idNumber, setIdNumber] = useState('')
const [expiryDate, setExpiryDate] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const token = useAuthStore(state => state.accessToken)
const handleUpload = async () => {
if (!token || !frontFile || !idType || !idNumber || !expiryDate) {
setError('Please fill in all required fields and select front image')
return
}
setLoading(true)
setError('')
setMessage('')
const formData = new FormData()
formData.append('front', frontFile)
if (backFile) {
formData.append('back', backFile)
}
formData.append('idType', idType)
formData.append('idNumber', idNumber)
formData.append('expiryDate', expiryDate)
try {
const endpoint = userType === 'company' ? '/api/upload/company-id' : '/api/upload/personal-id'
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
const data = await response.json()
if (response.ok) {
setMessage('Documents uploaded successfully!')
setTimeout(() => {
onSuccess()
onClose()
}, 1500)
} else {
setError(data.message || 'Failed to upload documents')
}
} catch (error) {
setError('Network error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Upload {userType === 'company' ? 'Company' : 'Personal'} Documents
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XCircleIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{userType === 'company' ? 'Company Registration (Front)' : 'ID Document (Front)'}
</label>
<input
type="file"
accept="image/*,.pdf"
onChange={(e) => setFrontFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{userType === 'company' ? 'Additional Documents (Optional)' : 'ID Document (Back)'}
</label>
<input
type="file"
accept="image/*,.pdf"
onChange={(e) => setBackFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{userType === 'company' ? 'Document Type' : 'ID Type'} <span className="text-red-500">*</span>
</label>
<select
value={idType}
onChange={(e) => setIdType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
>
<option value="">Select Document Type</option>
{userType === 'company' ? (
<>
<option value="business_registration">Business Registration</option>
<option value="tax_certificate">Tax Certificate</option>
<option value="business_license">Business License</option>
<option value="other">Other</option>
</>
) : (
<>
<option value="passport">Passport</option>
<option value="driver_license">Driver's License</option>
<option value="national_id">National ID</option>
<option value="other">Other</option>
</>
)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{userType === 'company' ? 'Registration/Document Number' : 'ID Number'} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={idNumber}
onChange={(e) => setIdNumber(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
placeholder={userType === 'company' ? 'Enter registration number' : 'Enter ID number'}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Expiry Date <span className="text-red-500">*</span>
</label>
<input
type="date"
value={expiryDate}
onChange={(e) => setExpiryDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<button
onClick={handleUpload}
disabled={loading || !frontFile || !idType || !idNumber || !expiryDate}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Uploading...' : 'Upload Documents'}
</button>
{message && (
<div className="text-green-600 text-sm text-center">{message}</div>
)}
{error && (
<div className="text-red-600 text-sm text-center">{error}</div>
)}
</div>
</div>
</div>
)
}
// Profile Completion Modal Component
function ProfileCompletionModal({ userType, onClose, onSuccess }: {
userType: string,
onClose: () => void,
onSuccess: () => void
}) {
const [formData, setFormData] = useState<any>({})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const token = useAuthStore(state => state.accessToken)
const handleSubmit = async () => {
if (!token) return
setLoading(true)
setError('')
setMessage('')
try {
const endpoint = userType === 'company' ? '/api/profile/company/complete' : '/api/profile/personal/complete'
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
})
const data = await response.json()
if (response.ok) {
setMessage('Profile completed successfully!')
setTimeout(() => {
onSuccess()
onClose()
}, 1500)
} else {
setError(data.message || 'Failed to complete profile')
}
} catch (error) {
setError('Network error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Complete Profile</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XCircleIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
{userType === 'company' ? (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Company Name</label>
<input
type="text"
value={formData.companyName || ''}
onChange={(e) => setFormData({...formData, companyName: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Industry</label>
<input
type="text"
value={formData.industry || ''}
onChange={(e) => setFormData({...formData, industry: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
</>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone Number</label>
<input
type="tel"
value={formData.phone || ''}
onChange={(e) => setFormData({...formData, phone: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Address</label>
<textarea
value={formData.address || ''}
onChange={(e) => setFormData({...formData, address: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
rows={3}
/>
</div>
</>
)}
<button
onClick={handleSubmit}
disabled={loading}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Saving...' : 'Complete Profile'}
</button>
{message && (
<div className="text-green-600 text-sm text-center">{message}</div>
)}
{error && (
<div className="text-red-600 text-sm text-center">{error}</div>
)}
</div>
</div>
</div>
)
}
// Contract Signing Modal Component
function ContractSigningModal({ userType, onClose, onSuccess }: {
userType: string,
onClose: () => void,
onSuccess: () => void
}) {
const [contractFile, setContractFile] = useState<File | null>(null)
const [agreed, setAgreed] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const token = useAuthStore(state => state.accessToken)
const handleUpload = async () => {
if (!token || !contractFile || !agreed) return
setLoading(true)
setError('')
setMessage('')
const formData = new FormData()
formData.append('contract', contractFile)
try {
const endpoint = userType === 'company' ? '/api/upload/contract/company' : '/api/upload/contract/personal'
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
const data = await response.json()
if (response.ok) {
setMessage('Contract signed successfully!')
setTimeout(() => {
onSuccess()
onClose()
}, 1500)
} else {
setError(data.message || 'Failed to upload contract')
}
} catch (error) {
setError('Network error occurred')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Sign Contract</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<XCircleIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-4">
Please review and upload your signed service agreement.
</p>
<label className="block text-sm font-medium text-gray-700 mb-2">
Signed Contract Document
</label>
<input
type="file"
accept="image/*,.pdf"
onChange={(e) => setContractFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div className="flex items-start">
<input
type="checkbox"
id="agreement"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
className="mt-1 h-4 w-4 text-[#8D6B1D] focus:ring-[#8D6B1D] border-gray-300 rounded"
/>
<label htmlFor="agreement" className="ml-2 text-sm text-gray-600">
I have read, understood, and agree to the terms and conditions of this service agreement.
</label>
</div>
<button
onClick={handleUpload}
disabled={loading || !contractFile || !agreed}
className="w-full bg-[#8D6B1D] text-white py-2 px-4 rounded-md hover:bg-[#7A5E1A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
{loading ? 'Uploading...' : 'Upload Signed Contract'}
</button>
{message && (
<div className="text-green-600 text-sm text-center">{message}</div>
)}
{error && (
<div className="text-red-600 text-sm text-center">{error}</div>
)}
</div>
</div>
</div>
)
}

View File

@ -18,16 +18,17 @@ import {
export default function DashboardPage() { 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)
// Redirect if not logged in // Redirect if not logged in (only after auth is ready)
useEffect(() => { useEffect(() => {
if (!user) { if (isAuthReady && !user) {
router.push('/login') router.push('/login')
} }
}, [user, router]) }, [isAuthReady, user, router])
// Don't render if no user // Show loading until auth is ready or user is confirmed
if (!user) { if (!isAuthReady || !user) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -104,6 +105,31 @@ export default function DashboardPage() {
</p> </p>
</div> </div>
{/* Account setup note */}
<div className="mb-8 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<UserCircleIcon className="h-5 w-5 text-blue-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
Complete your account setup
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
Complete your verification process to unlock all features.{' '}
<button
onClick={() => router.push('/quickaction-dashboard')}
className="font-medium underline text-blue-800 hover:text-blue-900"
>
Start verification
</button>
</p>
</div>
</div>
</div>
</div>
{/* Stats Grid - Tailwind UI Plus "With brand icon" */} {/* Stats Grid - Tailwind UI Plus "With brand icon" */}
<div className="mb-8"> <div className="mb-8">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">

112
src/app/debug-auth/page.tsx Normal file
View File

@ -0,0 +1,112 @@
'use client'
import { useEffect, useState } from 'react'
import useAuthStore from '../store/authStore'
import { useUserStatus } from '../hooks/useUserStatus'
export default function DebugAuthPage() {
const [debugInfo, setDebugInfo] = useState<any>({})
const { accessToken, user, isAuthReady, refreshAuthToken, getAuthState } = useAuthStore()
const { userStatus, loading, error } = useUserStatus()
useEffect(() => {
const updateDebugInfo = () => {
const authState = getAuthState()
setDebugInfo({
...authState,
timestamp: new Date().toISOString(),
sessionStorageUser: typeof window !== 'undefined' ? sessionStorage.getItem('user') : null,
sessionStorageToken: typeof window !== 'undefined' ? sessionStorage.getItem('accessToken') : null,
apiBaseUrl: process.env.NEXT_PUBLIC_API_BASE_URL
})
}
updateDebugInfo()
const interval = setInterval(updateDebugInfo, 1000)
return () => clearInterval(interval)
}, [getAuthState])
const handleRefreshToken = async () => {
console.log('Manual token refresh...')
const result = await refreshAuthToken()
console.log('Refresh result:', result)
}
const handleTestApiCall = async () => {
if (!accessToken) {
console.error('No access token available')
return
}
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/user/status`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
})
console.log('API Response Status:', response.status)
const data = await response.json().catch(() => null)
console.log('API Response Data:', data)
} catch (error) {
console.error('API Test Error:', error)
}
}
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Auth Debug Page</h1>
<div className="grid gap-6 md:grid-cols-2">
{/* Auth Store State */}
<div className="bg-white rounded-lg p-6 shadow">
<h2 className="text-xl font-semibold mb-4">Auth Store State</h2>
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto">
{JSON.stringify(debugInfo, null, 2)}
</pre>
<div className="mt-4 space-x-2">
<button
onClick={handleRefreshToken}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Refresh Token
</button>
<button
onClick={handleTestApiCall}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Test API Call
</button>
</div>
</div>
{/* User Status */}
<div className="bg-white rounded-lg p-6 shadow">
<h2 className="text-xl font-semibold mb-4">User Status Hook</h2>
<div className="space-y-2 text-sm">
<p><strong>Loading:</strong> {loading ? 'Yes' : 'No'}</p>
<p><strong>Error:</strong> {error || 'None'}</p>
<p><strong>Status:</strong></p>
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto">
{JSON.stringify(userStatus, null, 2)}
</pre>
</div>
</div>
{/* Environment */}
<div className="bg-white rounded-lg p-6 shadow md:col-span-2">
<h2 className="text-xl font-semibold mb-4">Environment</h2>
<div className="text-sm space-y-1">
<p><strong>API Base URL:</strong> {process.env.NEXT_PUBLIC_API_BASE_URL}</p>
<p><strong>Node Env:</strong> {process.env.NODE_ENV}</p>
<p><strong>Current URL:</strong> {typeof window !== 'undefined' ? window.location.href : 'SSR'}</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,161 @@
import { useState, useEffect, useCallback } from 'react'
import useAuthStore from '../store/authStore'
export interface UserStatus {
email_verified: boolean
documents_uploaded: boolean
profile_completed: boolean
contract_signed: boolean
is_admin_verified?: boolean
email_verified_at?: string
documents_uploaded_at?: string
profile_completed_at?: string
contract_signed_at?: string
}
export interface UserStatusResponse {
success: boolean
status: UserStatus
message?: string
}
export const useUserStatus = () => {
const [userStatus, setUserStatus] = useState<UserStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isClient, setIsClient] = useState(false)
const { accessToken, user } = useAuthStore()
// Debug logging
useEffect(() => {
console.log('🔍 useUserStatus debug:', {
isClient,
hasToken: !!accessToken,
tokenPrefix: accessToken ? accessToken.substring(0, 20) + '...' : null,
hasUser: !!user,
userEmail: user?.email || user?.companyName
})
}, [isClient, accessToken, user])
// Handle SSR hydration
useEffect(() => {
setIsClient(true)
}, [])
const fetchUserStatus = useCallback(async () => {
if (!isClient) {
setLoading(false)
return
}
// If no user, clear status and don't show error
if (!user) {
setUserStatus(null)
setLoading(false)
setError(null)
return
}
try {
setLoading(true)
setError(null)
// Get current token - it might be null initially
let currentToken = accessToken
// If no token, try to refresh first
if (!currentToken) {
console.log('No access token, attempting refresh...')
const { refreshAuthToken } = useAuthStore.getState()
const refreshed = await refreshAuthToken()
if (refreshed) {
currentToken = useAuthStore.getState().accessToken
}
}
// If still no token after refresh attempt, this is an auth error
if (!currentToken) {
throw new Error('Not authenticated - please log in again')
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/user/status`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${currentToken}`,
'Content-Type': 'application/json'
}
})
// If 401, try token refresh once
if (response.status === 401) {
console.log('Got 401, attempting token refresh...')
const { refreshAuthToken } = useAuthStore.getState()
const refreshed = await refreshAuthToken()
if (refreshed) {
const newToken = useAuthStore.getState().accessToken
if (newToken) {
// Retry with new token
const retryResponse = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/user/status`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${newToken}`,
'Content-Type': 'application/json'
}
})
if (!retryResponse.ok) {
throw new Error(`Failed to fetch user status: ${retryResponse.status}`)
}
const retryData: UserStatusResponse = await retryResponse.json()
if (retryData.success) {
setUserStatus(retryData.status)
return
} else {
throw new Error(retryData.message || 'Failed to fetch user status')
}
}
}
throw new Error('Authentication failed - please log in again')
}
if (!response.ok) {
throw new Error(`Failed to fetch user status: ${response.status}`)
}
const data: UserStatusResponse = await response.json()
if (data.success) {
setUserStatus(data.status)
} else {
throw new Error(data.message || 'Failed to fetch user status')
}
} catch (err) {
console.error('Error fetching user status:', err)
setError(err instanceof Error ? err.message : 'Unknown error occurred')
} finally {
setLoading(false)
}
}, [isClient, accessToken, user])
// Fetch status on mount and when auth changes
useEffect(() => {
if (isClient) {
fetchUserStatus()
}
}, [isClient, fetchUserStatus])
// Refresh function for manual updates
const refreshStatus = useCallback(() => {
return fetchUserStatus()
}, [fetchUserStatus])
return {
userStatus,
loading,
error,
refreshStatus
}
}

View File

@ -1,8 +1,10 @@
'use client' 'use client'
import { useState, useCallback } from 'react' import { useState, useCallback, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../components/PageLayout' import PageLayout from '../components/PageLayout'
import useAuthStore from '../store/authStore' import useAuthStore from '../store/authStore'
import { useUserStatus } from '../hooks/useUserStatus'
import { import {
CheckCircleIcon, CheckCircleIcon,
XCircleIcon, XCircleIcon,
@ -24,13 +26,20 @@ interface StatusItem {
} }
export default function QuickActionDashboardPage() { export default function QuickActionDashboardPage() {
const router = useRouter()
const user = useAuthStore(s => s.user) const user = useAuthStore(s => s.user)
const { userStatus, loading, error, refreshStatus } = useUserStatus()
const [isClient, setIsClient] = useState(false)
// Mock status derivation (replace with real user flags) useEffect(() => {
const [emailVerified, setEmailVerified] = useState(!!user?.emailVerified) setIsClient(true)
const [idUploaded, setIdUploaded] = useState(false) }, [])
const [additionalInfo, setAdditionalInfo] = useState(false)
const [contractSigned, setContractSigned] = useState(false) // Derive status from real backend data
const emailVerified = userStatus?.email_verified || false
const idUploaded = userStatus?.documents_uploaded || false
const additionalInfo = userStatus?.profile_completed || false
const contractSigned = userStatus?.contract_signed || false
const statusItems: StatusItem[] = [ const statusItems: StatusItem[] = [
{ {
@ -63,30 +72,25 @@ export default function QuickActionDashboardPage() {
} }
] ]
// Action handlers (placeholder async simulations) // Action handlers - navigate to proper QuickAction pages
const handleVerifyEmail = useCallback(async () => { const handleVerifyEmail = useCallback(() => {
// TODO: trigger backend email verification flow router.push('/quickaction-dashboard/register-email-verify')
await new Promise(r => setTimeout(r, 600)) }, [router])
setEmailVerified(true)
}, [])
const handleUploadId = useCallback(async () => { const handleUploadId = useCallback(() => {
// TODO: open upload modal / navigate const userType = user?.userType || 'personal'
await new Promise(r => setTimeout(r, 600)) router.push(`/quickaction-dashboard/register-upload-id/${userType}`)
setIdUploaded(true) }, [router, user])
}, [])
const handleCompleteInfo = useCallback(async () => { const handleCompleteInfo = useCallback(() => {
// TODO: navigate to profile completion const userType = user?.userType || 'personal'
await new Promise(r => setTimeout(r, 600)) router.push(`/quickaction-dashboard/register-additional-information/${userType}`)
setAdditionalInfo(true) }, [router, user])
}, [])
const handleSignContract = useCallback(async () => { const handleSignContract = useCallback(() => {
// TODO: open contract signing flow const userType = user?.userType || 'personal'
await new Promise(r => setTimeout(r, 600)) router.push(`/quickaction-dashboard/register-sign-contract/${userType}`)
setContractSigned(true) }, [router, user])
}, [])
const canUploadId = emailVerified const canUploadId = emailVerified
const canCompleteInfo = emailVerified && idUploaded const canCompleteInfo = emailVerified && idUploaded
@ -133,11 +137,41 @@ export default function QuickActionDashboardPage() {
{/* Welcome */} {/* Welcome */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight"> <h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
Welcome{user?.firstName ? `, ${user.firstName}` : ''}! Welcome{isClient && user?.firstName ? `, ${user.firstName}` : ''}!
</h1> </h1>
<p className="text-sm sm:text-base text-blue-100 font-medium mt-2"> <p className="text-sm sm:text-base text-blue-100 font-medium mt-2">
Personal Account {isClient && user?.userType === 'company' ? 'Company Account' : 'Personal Account'}
</p> </p>
{loading && (
<p className="text-xs text-blue-200 mt-1">Loading status...</p>
)}
{error && (
<div className="mt-4 max-w-md mx-auto rounded-md bg-red-50 border border-red-200 px-4 py-3">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Error loading account status
</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
</div>
<div className="mt-4">
<button
onClick={() => refreshStatus()}
className="text-sm bg-red-100 text-red-800 px-3 py-1 rounded-md hover:bg-red-200 transition-colors"
>
Try again
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
{/* Outer container card mimic (like screenshot) */} {/* Outer container card mimic (like screenshot) */}

View File

@ -1,7 +1,10 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout' import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
interface CompanyProfileData { interface CompanyProfileData {
companyName: string companyName: string
@ -34,6 +37,10 @@ const init: CompanyProfileData = {
} }
export default function CompanyAdditionalInformationPage() { export default function CompanyAdditionalInformationPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const [form, setForm] = useState(init) const [form, setForm] = useState(init)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
@ -67,13 +74,56 @@ export default function CompanyAdditionalInformationPage() {
e.preventDefault() e.preventDefault()
if (loading || success) return if (loading || success) return
if (!validate()) return if (!validate()) return
if (!accessToken) {
setError('Not authenticated. Please log in again.')
return
}
setLoading(true) setLoading(true)
try { try {
// TODO: POST to backend // Prepare data for backend with correct field names
await new Promise(r => setTimeout(r, 1300)) const profileData = {
address: form.street, // Backend expects 'address', not nested object
zip_code: form.postalCode, // Backend expects 'zip_code'
city: form.city,
country: form.country,
registrationNumber: form.vatNumber, // Map VAT number to registration number
businessType: 'company', // Default business type
branch: null, // Not collected in form, set to null
numberOfEmployees: null, // Not collected in form, set to null
accountHolderName: form.accountHolder, // Backend expects 'accountHolderName'
iban: form.iban.replace(/\s+/g, '') // Remove spaces from IBAN
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/profile/company/complete`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(profileData)
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Save failed' }))
throw new Error(errorData.message || 'Save failed')
}
setSuccess(true) setSuccess(true)
} catch {
setError('Speichern fehlgeschlagen.') // Refresh user status to update profile completion state
await refreshStatus()
// Redirect to next step after short delay
setTimeout(() => {
router.push('/quickaction-dashboard/register-sign-contract/company')
}, 1500)
} catch (error: any) {
console.error('Company profile save error:', error)
setError(error.message || 'Speichern fehlgeschlagen.')
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@ -1,7 +1,10 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout' import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
interface PersonalProfileData { interface PersonalProfileData {
dob: string dob: string
@ -32,6 +35,10 @@ const initialData: PersonalProfileData = {
} }
export default function PersonalAdditionalInformationPage() { export default function PersonalAdditionalInformationPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const [form, setForm] = useState(initialData) const [form, setForm] = useState(initialData)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
@ -66,13 +73,57 @@ export default function PersonalAdditionalInformationPage() {
e.preventDefault() e.preventDefault()
if (loading || success) return if (loading || success) return
if (!validate()) return if (!validate()) return
if (!accessToken) {
setError('Not authenticated. Please log in again.')
return
}
setLoading(true) setLoading(true)
try { try {
// TODO: POST to backend // Prepare data for backend with correct field names
await new Promise(r => setTimeout(r, 1200)) const profileData = {
dateOfBirth: form.dob,
nationality: form.nationality,
address: form.street, // Backend expects 'address', not nested object
zip_code: form.postalCode, // Backend expects 'zip_code'
city: form.city,
country: form.country,
phoneSecondary: form.secondPhone || null, // Backend expects 'phoneSecondary'
emergencyContactName: form.emergencyName || null,
emergencyContactPhone: form.emergencyPhone || null,
accountHolderName: form.accountHolder, // Backend expects 'accountHolderName'
iban: form.iban.replace(/\s+/g, '') // Remove spaces from IBAN
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/profile/personal/complete`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(profileData)
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Save failed' }))
throw new Error(errorData.message || 'Save failed')
}
setSuccess(true) setSuccess(true)
} catch {
setError('Speichern fehlgeschlagen. Bitte erneut versuchen.') // Refresh user status to update profile completion state
await refreshStatus()
// Redirect to next step after short delay
setTimeout(() => {
router.push('/quickaction-dashboard/register-sign-contract/personal')
}, 1500)
} catch (error: any) {
console.error('Personal profile save error:', error)
setError(error.message || 'Speichern fehlgeschlagen. Bitte erneut versuchen.')
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@ -3,9 +3,12 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import PageLayout from '../../components/PageLayout' import PageLayout from '../../components/PageLayout'
import useAuthStore from '../../store/authStore' import useAuthStore from '../../store/authStore'
import { useUserStatus } from '../../hooks/useUserStatus'
export default function EmailVerifyPage() { export default function EmailVerifyPage() {
const user = useAuthStore(s => s.user) const user = useAuthStore(s => s.user)
const token = useAuthStore(s => s.accessToken)
const { refreshStatus } = useUserStatus()
const [code, setCode] = useState(['', '', '', '', '', '']) const [code, setCode] = useState(['', '', '', '', '', ''])
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -54,25 +57,68 @@ export default function EmailVerifyPage() {
setError('Bitte 6-stelligen Code eingeben.') setError('Bitte 6-stelligen Code eingeben.')
return return
} }
if (!token) {
setError('Nicht authentifiziert. Bitte erneut einloggen.')
return
}
setSubmitting(true) setSubmitting(true)
setError('') setError('')
try { try {
// TODO: call backend verify endpoint const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/verify-email-code`, {
await new Promise(r => setTimeout(r, 1000)) method: 'POST',
setSuccess(true) headers: {
} catch { 'Authorization': `Bearer ${token}`,
setError('Verifizierung fehlgeschlagen. Bitte erneut versuchen.') 'Content-Type': 'application/json'
},
body: JSON.stringify({ code: fullCode })
})
const data = await response.json()
if (response.ok && data.success) {
setSuccess(true)
await refreshStatus() // Refresh user status
// Redirect after 2 seconds
setTimeout(() => {
window.location.href = '/quickaction-dashboard'
}, 2000)
} else {
setError(data.error || 'Verifizierung fehlgeschlagen. Bitte erneut versuchen.')
}
} catch (error) {
console.error('Email verification error:', error)
setError('Netzwerkfehler. Bitte erneut versuchen.')
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
} }
const handleResend = useCallback(async () => { const handleResend = useCallback(async () => {
if (resendCooldown) return if (resendCooldown || !token) return
setError('') setError('')
// TODO: call resend endpoint
setResendCooldown(30) try {
}, [resendCooldown]) const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/send-verification-email`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
const data = await response.json()
if (response.ok && data.success) {
setResendCooldown(30)
} else {
setError(data.message || 'Fehler beim Senden der E-Mail.')
}
} catch (error) {
console.error('Resend email error:', error)
setError('Netzwerkfehler beim Senden der E-Mail.')
}
}, [resendCooldown, token])
return ( return (
<PageLayout> <PageLayout>

View File

@ -1,9 +1,16 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout' import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
export default function CompanySignContractPage() { export default function CompanySignContractPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const [companyName, setCompanyName] = useState('') const [companyName, setCompanyName] = useState('')
const [repName, setRepName] = useState('') const [repName, setRepName] = useState('')
const [repTitle, setRepTitle] = useState('') const [repTitle, setRepTitle] = useState('')
@ -36,14 +43,65 @@ export default function CompanySignContractPage() {
setError('Bitte alle Pflichtfelder & Bestätigungen ausfüllen.') setError('Bitte alle Pflichtfelder & Bestätigungen ausfüllen.')
return return
} }
if (!accessToken) {
setError('Not authenticated. Please log in again.')
return
}
setError('') setError('')
setSubmitting(true) setSubmitting(true)
try { try {
// TODO: POST /contracts/company/sign { companyName, repName, repTitle, location, date, note } const contractData = {
await new Promise(r => setTimeout(r, 1400)) companyName: companyName.trim(),
representativeName: repName.trim(),
representativeTitle: repTitle.trim(),
location: location.trim(),
date,
note: note.trim() || null,
contractType: 'company',
confirmations: {
agreeContract,
agreeData,
confirmSignature
}
}
// Create FormData for the existing backend endpoint
const formData = new FormData()
formData.append('contractData', JSON.stringify(contractData))
// Create a dummy file since the backend expects one (we'll update backend later)
const dummyFile = new Blob(['Electronic signature data'], { type: 'text/plain' })
formData.append('contract', dummyFile, 'electronic_signature.txt')
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/upload/contract/company`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`
// Don't set Content-Type, let browser set it for FormData
},
body: formData
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Contract signing failed' }))
throw new Error(errorData.message || 'Contract signing failed')
}
setSuccess(true) setSuccess(true)
} catch {
setError('Signatur fehlgeschlagen. Bitte erneut versuchen.') // Refresh user status to update contract signed state
await refreshStatus()
// Redirect to main dashboard after short delay
setTimeout(() => {
router.push('/quickaction-dashboard')
}, 2000)
} catch (error: any) {
console.error('Contract signing error:', error)
setError(error.message || 'Signatur fehlgeschlagen. Bitte erneut versuchen.')
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }

View File

@ -1,9 +1,16 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout' import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
export default function PersonalSignContractPage() { export default function PersonalSignContractPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const [fullName, setFullName] = useState('') const [fullName, setFullName] = useState('')
const [location, setLocation] = useState('') const [location, setLocation] = useState('')
const [date, setDate] = useState('') const [date, setDate] = useState('')
@ -32,14 +39,63 @@ export default function PersonalSignContractPage() {
setError('Bitte alle Pflichtfelder & Bestätigungen ausfüllen.') setError('Bitte alle Pflichtfelder & Bestätigungen ausfüllen.')
return return
} }
if (!accessToken) {
setError('Not authenticated. Please log in again.')
return
}
setError('') setError('')
setSubmitting(true) setSubmitting(true)
try { try {
// TODO: POST /contracts/personal/sign { fullName, location, date, note } const contractData = {
await new Promise(r => setTimeout(r, 1200)) fullName: fullName.trim(),
location: location.trim(),
date,
note: note.trim() || null,
contractType: 'personal',
confirmations: {
agreeContract,
agreeData,
confirmSignature
}
}
// Create FormData for the existing backend endpoint
const formData = new FormData()
formData.append('contractData', JSON.stringify(contractData))
// Create a dummy file since the backend expects one (we'll update backend later)
const dummyFile = new Blob(['Electronic signature data'], { type: 'text/plain' })
formData.append('contract', dummyFile, 'electronic_signature.txt')
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/upload/contract/personal`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`
// Don't set Content-Type, let browser set it for FormData
},
body: formData
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Contract signing failed' }))
throw new Error(errorData.message || 'Contract signing failed')
}
setSuccess(true) setSuccess(true)
} catch {
setError('Signatur fehlgeschlagen. Bitte erneut versuchen.') // Refresh user status to update contract signed state
await refreshStatus()
// Redirect to main dashboard after short delay
setTimeout(() => {
router.push('/quickaction-dashboard')
}, 2000)
} catch (error: any) {
console.error('Contract signing error:', error)
setError(error.message || 'Signatur fehlgeschlagen. Bitte erneut versuchen.')
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }

View File

@ -1,12 +1,19 @@
'use client' 'use client'
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout' import PageLayout from '../../../components/PageLayout'
import { DocumentArrowUpIcon, XMarkIcon } from '@heroicons/react/24/outline' import { DocumentArrowUpIcon, XMarkIcon } from '@heroicons/react/24/outline'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
const DOC_TYPES = ['Handelsregisterauszug', 'Gewerbeanmeldung', 'Steuerbescheid', 'Sonstiges'] const DOC_TYPES = ['Handelsregisterauszug', 'Gewerbeanmeldung', 'Steuerbescheid', 'Sonstiges']
export default function CompanyIdUploadPage() { export default function CompanyIdUploadPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const [docNumber, setDocNumber] = useState('') const [docNumber, setDocNumber] = useState('')
const [docType, setDocType] = useState('') const [docType, setDocType] = useState('')
const [issueDate, setIssueDate] = useState('') const [issueDate, setIssueDate] = useState('')
@ -39,14 +46,51 @@ export default function CompanyIdUploadPage() {
setError('Bitte alle Pflichtfelder (mit *) ausfüllen.') setError('Bitte alle Pflichtfelder (mit *) ausfüllen.')
return return
} }
if (!accessToken) {
setError('Not authenticated. Please log in again.')
return
}
setError('') setError('')
setSubmitting(true) setSubmitting(true)
try { try {
// TODO: API upload const formData = new FormData()
await new Promise(r => setTimeout(r, 1200)) formData.append('frontFile', frontFile)
if (extraFile) {
formData.append('backFile', extraFile)
}
formData.append('docType', docType)
formData.append('docNumber', docNumber.trim())
formData.append('issueDate', issueDate)
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/upload/company-id`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`
},
body: formData
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Upload failed' }))
throw new Error(errorData.message || 'Upload failed')
}
setSuccess(true) setSuccess(true)
} catch {
setError('Upload fehlgeschlagen.') // Refresh user status to update verification state
await refreshStatus()
// Redirect to next step after short delay
setTimeout(() => {
router.push('/quickaction-dashboard/register-additional-information')
}, 1500)
} catch (error: any) {
console.error('Company ID upload error:', error)
setError(error.message || 'Upload fehlgeschlagen.')
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }

View File

@ -2,14 +2,23 @@
import { useState, useRef, useCallback } from 'react' import { useState, useRef, useCallback } from 'react'
import PageLayout from '../../../components/PageLayout' import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { import {
DocumentArrowUpIcon, DocumentArrowUpIcon,
XMarkIcon XMarkIcon
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
const ID_TYPES = ['Personalausweis', 'Reisepass', 'Führerschein', 'Aufenthaltstitel'] const ID_TYPES = [
{ value: 'national_id', label: 'Personalausweis' },
{ value: 'passport', label: 'Reisepass' },
{ value: 'driver_license', label: 'Führerschein' },
{ value: 'other', label: 'Aufenthaltstitel' }
]
export default function PersonalIdUploadPage() { export default function PersonalIdUploadPage() {
const token = useAuthStore(s => s.accessToken)
const { refreshStatus } = useUserStatus()
const [idNumber, setIdNumber] = useState('') const [idNumber, setIdNumber] = useState('')
const [idType, setIdType] = useState('') const [idType, setIdType] = useState('')
const [expiry, setExpiry] = useState('') const [expiry, setExpiry] = useState('')
@ -58,13 +67,47 @@ export default function PersonalIdUploadPage() {
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!validate()) return if (!validate()) return
if (!token) {
setError('Nicht authentifiziert. Bitte erneut einloggen.')
return
}
setSubmitting(true) setSubmitting(true)
setError('')
try { try {
// TODO: Upload logic (multipart/form-data) const formData = new FormData()
await new Promise(r => setTimeout(r, 1200)) formData.append('front', frontFile!)
setSuccess(true) if (hasBack && backFile) {
} catch { formData.append('back', backFile)
setError('Upload fehlgeschlagen. Bitte erneut versuchen.') }
formData.append('idType', idType)
formData.append('idNumber', idNumber)
formData.append('expiryDate', expiry)
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/upload/personal-id`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
})
const data = await response.json()
if (response.ok && data.success) {
setSuccess(true)
await refreshStatus() // Refresh user status
// Redirect after 2 seconds
setTimeout(() => {
window.location.href = '/quickaction-dashboard'
}, 2000)
} else {
setError(data.message || 'Upload fehlgeschlagen. Bitte erneut versuchen.')
}
} catch (error) {
console.error('Upload error:', error)
setError('Netzwerkfehler. Bitte erneut versuchen.')
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@ -146,7 +189,7 @@ export default function PersonalIdUploadPage() {
required required
> >
<option value="">Select ID type</option> <option value="">Select ID type</option>
{ID_TYPES.map(t => <option key={t} value={t}>{t}</option>)} {ID_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select> </select>
</div> </div>

View File

@ -27,6 +27,28 @@ const getStoredUser = () => {
} }
}; };
const getStoredToken = () => {
if (typeof window === 'undefined') return null; // SSR check
try {
const token = sessionStorage.getItem('accessToken');
if (token) {
const expiry = getTokenExpiry(token);
if (expiry && expiry.getTime() > Date.now()) {
log("🔑 Retrieved valid token from sessionStorage");
return token;
} else {
log("⏰ Stored token expired, removing");
sessionStorage.removeItem('accessToken');
return null;
}
}
return null;
} catch (error) {
log("❌ Error retrieving token from sessionStorage:", error);
return null;
}
};
interface User { interface User {
email?: string; email?: string;
companyName?: string; companyName?: string;
@ -50,7 +72,7 @@ interface AuthStore {
const useAuthStore = create<AuthStore>((set, get) => ({ const useAuthStore = create<AuthStore>((set, get) => ({
// Initialize with SSR-safe defaults // Initialize with SSR-safe defaults
accessToken: null, accessToken: typeof window !== 'undefined' ? getStoredToken() : null,
user: typeof window !== 'undefined' ? getStoredUser() : null, user: typeof window !== 'undefined' ? getStoredUser() : null,
isAuthReady: false, isAuthReady: false,
isRefreshing: false, isRefreshing: false,
@ -63,11 +85,20 @@ const useAuthStore = create<AuthStore>((set, get) => ({
setAccessToken: (token) => { setAccessToken: (token) => {
log("🔑 Zustand: Setting access token in memory:", token ? `${token.substring(0, 20)}...` : null); log("🔑 Zustand: Setting access token in memory:", token ? `${token.substring(0, 20)}...` : null);
if (token) { if (typeof window !== 'undefined') {
const expiry = getTokenExpiry(token); try {
log("⏳ Zustand: Token expiry:", expiry ? expiry.toLocaleString() : "Unknown"); if (token) {
} else { const expiry = getTokenExpiry(token);
log("🗑️ Zustand: Clearing in-memory access token"); log("⏳ Zustand: Token expiry:", expiry ? expiry.toLocaleString() : "Unknown");
sessionStorage.setItem('accessToken', token);
log("✅ Token stored in sessionStorage");
} else {
sessionStorage.removeItem('accessToken');
log("🗑️ Token removed from sessionStorage");
}
} catch (error) {
log("❌ Error storing token in sessionStorage:", error);
}
} }
set({ accessToken: token }); set({ accessToken: token });
}, },

241
src/app/utils/api.ts Normal file
View File

@ -0,0 +1,241 @@
// API Configuration
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
// API Endpoints
export const API_ENDPOINTS = {
// Auth
LOGIN: '/api/login',
REFRESH: '/api/refresh',
LOGOUT: '/api/logout',
ME: '/api/me',
// User Status & Settings
USER_STATUS: '/api/user/status',
USER_STATUS_PROGRESS: '/api/user/status-progress',
USER_SETTINGS: '/api/user/settings',
// Email Verification
SEND_VERIFICATION_EMAIL: '/api/send-verification-email',
VERIFY_EMAIL_CODE: '/api/verify-email-code',
// Document Upload
UPLOAD_PERSONAL_ID: '/api/upload/personal-id',
UPLOAD_COMPANY_ID: '/api/upload/company-id',
// Profile Completion
COMPLETE_PERSONAL_PROFILE: '/api/profile/personal/complete',
COMPLETE_COMPANY_PROFILE: '/api/profile/company/complete',
// Contract Signing
UPLOAD_PERSONAL_CONTRACT: '/api/upload/contract/personal',
UPLOAD_COMPANY_CONTRACT: '/api/upload/contract/company',
// Documents
USER_DOCUMENTS: '/api/users/:id/documents',
// Admin
ADMIN_USERS: '/api/admin/users/:id/full',
}
// API Helper Functions
export class ApiClient {
static async makeRequest(
endpoint: string,
options: RequestInit = {},
token?: string
): Promise<Response> {
const url = `${API_BASE_URL}${endpoint}`
const defaultHeaders: HeadersInit = {
'Content-Type': 'application/json',
}
if (token) {
defaultHeaders['Authorization'] = `Bearer ${token}`
}
const config: RequestInit = {
...options,
headers: {
...defaultHeaders,
...options.headers,
},
}
return fetch(url, config)
}
static async get(endpoint: string, token?: string): Promise<Response> {
return this.makeRequest(endpoint, { method: 'GET' }, token)
}
static async post(
endpoint: string,
data?: any,
token?: string
): Promise<Response> {
return this.makeRequest(
endpoint,
{
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
},
token
)
}
static async postFormData(
endpoint: string,
formData: FormData,
token?: string
): Promise<Response> {
const url = `${API_BASE_URL}${endpoint}`
const headers: HeadersInit = {}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
return fetch(url, {
method: 'POST',
headers,
body: formData,
})
}
static async put(
endpoint: string,
data?: any,
token?: string
): Promise<Response> {
return this.makeRequest(
endpoint,
{
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
},
token
)
}
static async patch(
endpoint: string,
data?: any,
token?: string
): Promise<Response> {
return this.makeRequest(
endpoint,
{
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
},
token
)
}
static async delete(endpoint: string, token?: string): Promise<Response> {
return this.makeRequest(endpoint, { method: 'DELETE' }, token)
}
}
// Specific API Functions for QuickActions
export class QuickActionsAPI {
static async getUserStatus(token: string) {
const response = await ApiClient.get(API_ENDPOINTS.USER_STATUS, token)
if (!response.ok) {
throw new Error('Failed to fetch user status')
}
return response.json()
}
static async sendVerificationEmail(token: string) {
const response = await ApiClient.post(API_ENDPOINTS.SEND_VERIFICATION_EMAIL, {}, token)
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to send verification email')
}
return response.json()
}
static async verifyEmailCode(token: string, code: string) {
const response = await ApiClient.post(
API_ENDPOINTS.VERIFY_EMAIL_CODE,
{ code },
token
)
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Invalid verification code')
}
return response.json()
}
static async uploadDocuments(token: string, userType: string, files: { front: File, back?: File }) {
const formData = new FormData()
formData.append('front', files.front)
if (files.back) {
formData.append('back', files.back)
}
const endpoint = userType === 'company'
? API_ENDPOINTS.UPLOAD_COMPANY_ID
: API_ENDPOINTS.UPLOAD_PERSONAL_ID
const response = await ApiClient.postFormData(endpoint, formData, token)
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to upload documents')
}
return response.json()
}
static async completeProfile(token: string, userType: string, profileData: any) {
const endpoint = userType === 'company'
? API_ENDPOINTS.COMPLETE_COMPANY_PROFILE
: API_ENDPOINTS.COMPLETE_PERSONAL_PROFILE
const response = await ApiClient.post(endpoint, profileData, token)
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to complete profile')
}
return response.json()
}
static async uploadContract(token: string, userType: string, contractFile: File) {
const formData = new FormData()
formData.append('contract', contractFile)
const endpoint = userType === 'company'
? API_ENDPOINTS.UPLOAD_COMPANY_CONTRACT
: API_ENDPOINTS.UPLOAD_PERSONAL_CONTRACT
const response = await ApiClient.postFormData(endpoint, formData, token)
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to upload contract')
}
return response.json()
}
}
// Error Types
export interface ApiError {
message: string
status?: number
code?: string
}
// Response Types
export interface UserStatus {
emailVerified: boolean
documentsUploaded: boolean
profileCompleted: boolean
contractSigned: boolean
}
export interface ApiResponse<T = any> {
success: boolean
message?: string
data?: T
}