Merge pull request 'qa-dashboard' (#2) from qa-dashboard into dev

Reviewed-on: #2
This commit is contained in:
Seazn 2025-10-12 12:09:50 +00:00
commit 1bdfd38ef5
16 changed files with 2116 additions and 109 deletions

View File

@ -1,7 +1,14 @@
'use client';
import { I18nProvider } from './i18n/useTranslation';
import AuthInitializer from './components/AuthInitializer';
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() {
const router = useRouter()
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(() => {
if (!user) {
if (isAuthReady && !user) {
router.push('/login')
}
}, [user, router])
}, [isAuthReady, user, router])
// Don't render if no user
if (!user) {
// Show loading until auth is ready or user is confirmed
if (!isAuthReady || !user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
@ -104,6 +105,31 @@ export default function DashboardPage() {
</p>
</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" */}
<div className="mb-8">
<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'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../components/PageLayout'
import useAuthStore from '../store/authStore'
import { useUserStatus } from '../hooks/useUserStatus'
import {
CheckCircleIcon,
XCircleIcon,
@ -24,13 +26,20 @@ interface StatusItem {
}
export default function QuickActionDashboardPage() {
const router = useRouter()
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)
const [emailVerified, setEmailVerified] = useState(!!user?.emailVerified)
const [idUploaded, setIdUploaded] = useState(false)
const [additionalInfo, setAdditionalInfo] = useState(false)
const [contractSigned, setContractSigned] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
// 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[] = [
{
@ -63,30 +72,25 @@ export default function QuickActionDashboardPage() {
}
]
// Action handlers (placeholder async simulations)
const handleVerifyEmail = useCallback(async () => {
// TODO: trigger backend email verification flow
await new Promise(r => setTimeout(r, 600))
setEmailVerified(true)
}, [])
// Action handlers - navigate to proper QuickAction pages
const handleVerifyEmail = useCallback(() => {
router.push('/quickaction-dashboard/register-email-verify')
}, [router])
const handleUploadId = useCallback(async () => {
// TODO: open upload modal / navigate
await new Promise(r => setTimeout(r, 600))
setIdUploaded(true)
}, [])
const handleUploadId = useCallback(() => {
const userType = user?.userType || 'personal'
router.push(`/quickaction-dashboard/register-upload-id/${userType}`)
}, [router, user])
const handleCompleteInfo = useCallback(async () => {
// TODO: navigate to profile completion
await new Promise(r => setTimeout(r, 600))
setAdditionalInfo(true)
}, [])
const handleCompleteInfo = useCallback(() => {
const userType = user?.userType || 'personal'
router.push(`/quickaction-dashboard/register-additional-information/${userType}`)
}, [router, user])
const handleSignContract = useCallback(async () => {
// TODO: open contract signing flow
await new Promise(r => setTimeout(r, 600))
setContractSigned(true)
}, [])
const handleSignContract = useCallback(() => {
const userType = user?.userType || 'personal'
router.push(`/quickaction-dashboard/register-sign-contract/${userType}`)
}, [router, user])
const canUploadId = emailVerified
const canCompleteInfo = emailVerified && idUploaded
@ -133,11 +137,41 @@ export default function QuickActionDashboardPage() {
{/* Welcome */}
<div className="text-center mb-8">
<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>
<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>
{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>
{/* Outer container card mimic (like screenshot) */}

View File

@ -1,7 +1,10 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
interface CompanyProfileData {
companyName: string
@ -18,6 +21,16 @@ interface CompanyProfileData {
emergencyPhone: string
}
// Common countries list
const COUNTRIES = [
'Germany', 'Austria', 'Switzerland', 'Italy', 'France', 'Spain', 'Portugal', 'Netherlands',
'Belgium', 'Poland', 'Czech Republic', 'Hungary', 'Croatia', 'Slovenia', 'Slovakia',
'United Kingdom', 'Ireland', 'Sweden', 'Norway', 'Denmark', 'Finland', 'Russia',
'Turkey', 'Greece', 'Romania', 'Bulgaria', 'Serbia', 'Albania', 'Bosnia and Herzegovina',
'United States', 'Canada', 'Brazil', 'Argentina', 'Mexico', 'China', 'Japan',
'India', 'Pakistan', 'Australia', 'South Africa', 'Other'
]
const init: CompanyProfileData = {
companyName: '',
vatNumber: '',
@ -34,12 +47,16 @@ const init: CompanyProfileData = {
}
export default function CompanyAdditionalInformationPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const [form, setForm] = useState(init)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target
setForm(p => ({ ...p, [name]: value }))
setError('')
@ -67,13 +84,56 @@ export default function CompanyAdditionalInformationPage() {
e.preventDefault()
if (loading || success) return
if (!validate()) return
if (!accessToken) {
setError('Not authenticated. Please log in again.')
return
}
setLoading(true)
try {
// TODO: POST to backend
await new Promise(r => setTimeout(r, 1300))
// Prepare data for backend with correct field names
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)
} 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 {
setLoading(false)
}
@ -181,13 +241,20 @@ export default function CompanyAdditionalInformationPage() {
<label className="block text-sm font-medium text-gray-700 mb-1">
Country *
</label>
<input
<select
name="country"
value={form.country}
onChange={handleChange}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
>
<option value="">Select country...</option>
{COUNTRIES.map(country => (
<option key={country} value={country}>
{country}
</option>
))}
</select>
</div>
</div>
</section>

View File

@ -1,7 +1,10 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
interface PersonalProfileData {
dob: string
@ -17,6 +20,26 @@ interface PersonalProfileData {
emergencyPhone: string
}
// Common nationalities list
const NATIONALITIES = [
'German', 'Austrian', 'Swiss', 'Italian', 'French', 'Spanish', 'Portuguese', 'Dutch',
'Belgian', 'Polish', 'Czech', 'Hungarian', 'Croatian', 'Slovenian', 'Slovak',
'British', 'Irish', 'Swedish', 'Norwegian', 'Danish', 'Finnish', 'Russian',
'Turkish', 'Greek', 'Romanian', 'Bulgarian', 'Serbian', 'Albanian', 'Bosnian',
'American', 'Canadian', 'Brazilian', 'Argentinian', 'Mexican', 'Chinese',
'Japanese', 'Indian', 'Pakistani', 'Australian', 'South African', 'Other'
]
// Common countries list
const COUNTRIES = [
'Germany', 'Austria', 'Switzerland', 'Italy', 'France', 'Spain', 'Portugal', 'Netherlands',
'Belgium', 'Poland', 'Czech Republic', 'Hungary', 'Croatia', 'Slovenia', 'Slovakia',
'United Kingdom', 'Ireland', 'Sweden', 'Norway', 'Denmark', 'Finland', 'Russia',
'Turkey', 'Greece', 'Romania', 'Bulgaria', 'Serbia', 'Albania', 'Bosnia and Herzegovina',
'United States', 'Canada', 'Brazil', 'Argentina', 'Mexico', 'China', 'Japan',
'India', 'Pakistan', 'Australia', 'South Africa', 'Other'
]
const initialData: PersonalProfileData = {
dob: '',
nationality: '',
@ -32,17 +55,46 @@ const initialData: PersonalProfileData = {
}
export default function PersonalAdditionalInformationPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const [form, setForm] = useState(initialData)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target
setForm(p => ({ ...p, [name]: value }))
setError('')
}
const validateDateOfBirth = (dob: string) => {
if (!dob) return false
const birthDate = new Date(dob)
const today = new Date()
// Check if date is valid
if (isNaN(birthDate.getTime())) return false
// Check if birth date is not in the future
if (birthDate > today) return false
// Check minimum age (18 years)
const minDate = new Date()
minDate.setFullYear(today.getFullYear() - 18)
if (birthDate > minDate) return false
// Check maximum age (120 years)
const maxDate = new Date()
maxDate.setFullYear(today.getFullYear() - 120)
if (birthDate < maxDate) return false
return true
}
const validate = () => {
const requiredKeys: (keyof PersonalProfileData)[] = [
'dob','nationality','street','postalCode','city','country','accountHolder','iban'
@ -53,6 +105,13 @@ export default function PersonalAdditionalInformationPage() {
return false
}
}
// Date of birth validation
if (!validateDateOfBirth(form.dob)) {
setError('Ungültiges Geburtsdatum. Sie müssen mindestens 18 Jahre alt sein.')
return false
}
// very loose IBAN check
if (!/^([A-Z]{2}\d{2}[A-Z0-9]{10,30})$/i.test(form.iban.replace(/\s+/g,''))) {
setError('Ungültige IBAN.')
@ -66,13 +125,57 @@ export default function PersonalAdditionalInformationPage() {
e.preventDefault()
if (loading || success) return
if (!validate()) return
if (!accessToken) {
setError('Not authenticated. Please log in again.')
return
}
setLoading(true)
try {
// TODO: POST to backend
await new Promise(r => setTimeout(r, 1200))
// Prepare data for backend with correct field names
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)
} 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 {
setLoading(false)
}
@ -124,6 +227,8 @@ export default function PersonalAdditionalInformationPage() {
name="dob"
value={form.dob}
onChange={handleChange}
min={new Date(new Date().getFullYear() - 120, 0, 1).toISOString().split('T')[0]}
max={new Date(new Date().getFullYear() - 18, 11, 31).toISOString().split('T')[0]}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
@ -132,14 +237,20 @@ export default function PersonalAdditionalInformationPage() {
<label className="block text-sm font-medium text-gray-700 mb-1">
Nationality *
</label>
<input
<select
name="nationality"
value={form.nationality}
onChange={handleChange}
placeholder="e.g. German"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
>
<option value="">Select nationality...</option>
{NATIONALITIES.map(nationality => (
<option key={nationality} value={nationality}>
{nationality}
</option>
))}
</select>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
@ -184,14 +295,20 @@ export default function PersonalAdditionalInformationPage() {
<label className="block text-sm font-medium text-gray-700 mb-1">
Country *
</label>
<input
<select
name="country"
value={form.country}
onChange={handleChange}
placeholder="e.g. Germany"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
>
<option value="">Select country...</option>
{COUNTRIES.map(country => (
<option key={country} value={country}>
{country}
</option>
))}
</select>
</div>
</div>
</section>

View File

@ -3,16 +3,50 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import PageLayout from '../../components/PageLayout'
import useAuthStore from '../../store/authStore'
import { useUserStatus } from '../../hooks/useUserStatus'
export default function EmailVerifyPage() {
const user = useAuthStore(s => s.user)
const token = useAuthStore(s => s.accessToken)
const { refreshStatus } = useUserStatus()
const [code, setCode] = useState(['', '', '', '', '', ''])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [resendCooldown, setResendCooldown] = useState(0)
const [initialEmailSent, setInitialEmailSent] = useState(false)
const inputsRef = useRef<Array<HTMLInputElement | null>>([])
// Send verification email automatically on page load
useEffect(() => {
if (!token || initialEmailSent) return
const sendInitialEmail = async () => {
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 && data.success) {
setInitialEmailSent(true)
setResendCooldown(30) // Start cooldown after initial send
} else {
console.error('Failed to send initial verification email:', data.message)
}
} catch (error) {
console.error('Error sending initial verification email:', error)
}
}
sendInitialEmail()
}, [token, initialEmailSent])
// Cooldown timer
useEffect(() => {
if (!resendCooldown) return
@ -54,25 +88,68 @@ export default function EmailVerifyPage() {
setError('Bitte 6-stelligen Code eingeben.')
return
}
if (!token) {
setError('Nicht authentifiziert. Bitte erneut einloggen.')
return
}
setSubmitting(true)
setError('')
try {
// TODO: call backend verify endpoint
await new Promise(r => setTimeout(r, 1000))
setSuccess(true)
} catch {
setError('Verifizierung fehlgeschlagen. Bitte erneut versuchen.')
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: 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 {
setSubmitting(false)
}
}
const handleResend = useCallback(async () => {
if (resendCooldown) return
if (resendCooldown || !token) return
setError('')
// TODO: call resend endpoint
setResendCooldown(30)
}, [resendCooldown])
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 && 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 (
<PageLayout>
@ -120,12 +197,23 @@ export default function EmailVerifyPage() {
E-Mail verifizieren
</h1>
<p className="mt-3 text-gray-300 text-sm sm:text-base">
Gib den 6-stelligen Code ein, den wir an
{' '}
<span className="text-indigo-300 font-medium">
{user?.email || 'deine E-Mail'}
</span>{' '}
gesendet haben.
{initialEmailSent ? (
<>
Wir haben einen 6-stelligen Code an{' '}
<span className="text-indigo-300 font-medium">
{user?.email || 'deine E-Mail'}
</span>{' '}
gesendet. Gib ihn unten ein.
</>
) : (
<>
E-Mail wird gesendet an{' '}
<span className="text-indigo-300 font-medium">
{user?.email || 'deine E-Mail'}
</span>
...
</>
)}
</p>
</div>

View File

@ -1,9 +1,16 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
export default function CompanySignContractPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const [companyName, setCompanyName] = useState('')
const [repName, setRepName] = useState('')
const [repTitle, setRepTitle] = useState('')
@ -21,29 +28,94 @@ export default function CompanySignContractPage() {
setDate(new Date().toISOString().slice(0,10))
}, [])
const valid = () =>
companyName.trim().length > 2 &&
repName.trim().length > 4 &&
repTitle.trim().length > 1 &&
location.trim().length > 1 &&
agreeContract &&
agreeData &&
confirmSignature
const valid = () => {
const companyValid = companyName.trim().length >= 3 // Min 3 characters for company name
const repNameValid = repName.trim().length >= 3 // Min 3 characters for representative name
const repTitleValid = repTitle.trim().length >= 2 // Min 2 characters for title
const locationValid = location.trim().length >= 2 // Min 2 characters for location
const contractChecked = agreeContract
const dataChecked = agreeData
const signatureChecked = confirmSignature
return companyValid && repNameValid && repTitleValid && locationValid && contractChecked && dataChecked && signatureChecked
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!valid()) {
setError('Bitte alle Pflichtfelder & Bestätigungen ausfüllen.')
// Detailed error message to help debug
const issues = []
if (companyName.trim().length < 3) issues.push('Firmenname (mindestens 3 Zeichen)')
if (repName.trim().length < 3) issues.push('Vertreter Name (mindestens 3 Zeichen)')
if (repTitle.trim().length < 2) issues.push('Vertretertitel (mindestens 2 Zeichen)')
if (location.trim().length < 2) issues.push('Ort (mindestens 2 Zeichen)')
if (!agreeContract) issues.push('Vertrag gelesen und verstanden')
if (!agreeData) issues.push('Datenschutzerklärung zugestimmt')
if (!confirmSignature) issues.push('Elektronische Signatur bestätigt')
setError(`Bitte vervollständigen: ${issues.join(', ')}`)
return
}
if (!accessToken) {
setError('Not authenticated. Please log in again.')
return
}
setError('')
setSubmitting(true)
try {
// TODO: POST /contracts/company/sign { companyName, repName, repTitle, location, date, note }
await new Promise(r => setTimeout(r, 1400))
const contractData = {
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 PDF file since the backend expects one (electronic signature)
const dummyPdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\n2 0 obj\n<<\n/Type /Pages\n/Kids [3 0 R]\n/Count 1\n>>\nendobj\n3 0 obj\n<<\n/Type /Page\n/Parent 2 0 R\n/MediaBox [0 0 612 792]\n/Contents 4 0 R\n>>\nendobj\n4 0 obj\n<<\n/Length 44\n>>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Electronic Signature) Tj\nET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n0000000079 00000 n \n0000000136 00000 n \n0000000225 00000 n \ntrailer\n<<\n/Size 5\n/Root 1 0 R\n>>\nstartxref\n319\n%%EOF'
const dummyFile = new Blob([dummyPdfContent], { type: 'application/pdf' })
formData.append('contract', dummyFile, 'electronic_signature.pdf')
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)
} 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 {
setSubmitting(false)
}

View File

@ -1,9 +1,16 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
export default function PersonalSignContractPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const [fullName, setFullName] = useState('')
const [location, setLocation] = useState('')
const [date, setDate] = useState('')
@ -19,27 +26,88 @@ export default function PersonalSignContractPage() {
setDate(new Date().toISOString().slice(0, 10))
}, [])
const valid = () =>
fullName.trim().length > 4 &&
location.trim().length > 1 &&
agreeContract &&
agreeData &&
confirmSignature
const valid = () => {
const nameValid = fullName.trim().length >= 3 // Min 3 characters for name
const locationValid = location.trim().length >= 2 // Min 2 characters for location
const contractChecked = agreeContract
const dataChecked = agreeData
const signatureChecked = confirmSignature
return nameValid && locationValid && contractChecked && dataChecked && signatureChecked
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!valid()) {
setError('Bitte alle Pflichtfelder & Bestätigungen ausfüllen.')
// Detailed error message to help debug
const issues = []
if (fullName.trim().length < 3) issues.push('Vollständiger Name (mindestens 3 Zeichen)')
if (location.trim().length < 2) issues.push('Ort (mindestens 2 Zeichen)')
if (!agreeContract) issues.push('Vertrag gelesen und verstanden')
if (!agreeData) issues.push('Datenschutzerklärung zugestimmt')
if (!confirmSignature) issues.push('Elektronische Signatur bestätigt')
setError(`Bitte vervollständigen: ${issues.join(', ')}`)
return
}
if (!accessToken) {
setError('Not authenticated. Please log in again.')
return
}
setError('')
setSubmitting(true)
try {
// TODO: POST /contracts/personal/sign { fullName, location, date, note }
await new Promise(r => setTimeout(r, 1200))
const contractData = {
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 PDF file since the backend expects one (electronic signature)
const dummyPdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\n2 0 obj\n<<\n/Type /Pages\n/Kids [3 0 R]\n/Count 1\n>>\nendobj\n3 0 obj\n<<\n/Type /Page\n/Parent 2 0 R\n/MediaBox [0 0 612 792]\n/Contents 4 0 R\n>>\nendobj\n4 0 obj\n<<\n/Length 44\n>>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Electronic Signature) Tj\nET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n0000000079 00000 n \n0000000136 00000 n \n0000000225 00000 n \ntrailer\n<<\n/Size 5\n/Root 1 0 R\n>>\nstartxref\n319\n%%EOF'
const dummyFile = new Blob([dummyPdfContent], { type: 'application/pdf' })
formData.append('contract', dummyFile, 'electronic_signature.pdf')
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)
} 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 {
setSubmitting(false)
}

View File

@ -1,12 +1,19 @@
'use client'
import { useState, useRef } from 'react'
import { useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout'
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']
export default function CompanyIdUploadPage() {
const router = useRouter()
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const [docNumber, setDocNumber] = useState('')
const [docType, setDocType] = useState('')
const [issueDate, setIssueDate] = useState('')
@ -39,14 +46,51 @@ export default function CompanyIdUploadPage() {
setError('Bitte alle Pflichtfelder (mit *) ausfüllen.')
return
}
if (!accessToken) {
setError('Not authenticated. Please log in again.')
return
}
setError('')
setSubmitting(true)
try {
// TODO: API upload
await new Promise(r => setTimeout(r, 1200))
const formData = new FormData()
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)
} 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 {
setSubmitting(false)
}

View File

@ -2,14 +2,23 @@
import { useState, useRef, useCallback } from 'react'
import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import {
DocumentArrowUpIcon,
XMarkIcon
} 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() {
const token = useAuthStore(s => s.accessToken)
const { refreshStatus } = useUserStatus()
const [idNumber, setIdNumber] = useState('')
const [idType, setIdType] = useState('')
const [expiry, setExpiry] = useState('')
@ -58,13 +67,47 @@ export default function PersonalIdUploadPage() {
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
if (!token) {
setError('Nicht authentifiziert. Bitte erneut einloggen.')
return
}
setSubmitting(true)
setError('')
try {
// TODO: Upload logic (multipart/form-data)
await new Promise(r => setTimeout(r, 1200))
setSuccess(true)
} catch {
setError('Upload fehlgeschlagen. Bitte erneut versuchen.')
const formData = new FormData()
formData.append('front', frontFile!)
if (hasBack && backFile) {
formData.append('back', backFile)
}
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 {
setSubmitting(false)
}
@ -146,7 +189,7 @@ export default function PersonalIdUploadPage() {
required
>
<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>
</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 {
email?: string;
companyName?: string;
@ -50,7 +72,7 @@ interface AuthStore {
const useAuthStore = create<AuthStore>((set, get) => ({
// Initialize with SSR-safe defaults
accessToken: null,
accessToken: typeof window !== 'undefined' ? getStoredToken() : null,
user: typeof window !== 'undefined' ? getStoredUser() : null,
isAuthReady: false,
isRefreshing: false,
@ -63,11 +85,20 @@ const useAuthStore = create<AuthStore>((set, get) => ({
setAccessToken: (token) => {
log("🔑 Zustand: Setting access token in memory:", token ? `${token.substring(0, 20)}...` : null);
if (token) {
const expiry = getTokenExpiry(token);
log("⏳ Zustand: Token expiry:", expiry ? expiry.toLocaleString() : "Unknown");
} else {
log("🗑️ Zustand: Clearing in-memory access token");
if (typeof window !== 'undefined') {
try {
if (token) {
const expiry = getTokenExpiry(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 });
},

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
}