479 lines
19 KiB
TypeScript
479 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, 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
|
|
nationality: string
|
|
street: string
|
|
postalCode: string
|
|
city: string
|
|
country: string
|
|
accountHolder: string
|
|
iban: string
|
|
secondPhone: string
|
|
emergencyName: string
|
|
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: '',
|
|
street: '',
|
|
postalCode: '',
|
|
city: '',
|
|
country: '',
|
|
accountHolder: '',
|
|
iban: '',
|
|
secondPhone: '',
|
|
emergencyName: '',
|
|
emergencyPhone: ''
|
|
}
|
|
|
|
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('')
|
|
|
|
// Prefill form if profile already exists
|
|
useEffect(() => {
|
|
let abort = false
|
|
async function loadProfile() {
|
|
if (!accessToken) return
|
|
try {
|
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/me`, {
|
|
method: 'GET',
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
})
|
|
if (!res.ok) return
|
|
const data = await res.json().catch(() => null)
|
|
const profile = data?.profile
|
|
const user = data?.user
|
|
if (!profile || abort) return
|
|
const toDateInput = (d?: string) => {
|
|
if (!d) return ''
|
|
const dt = new Date(d)
|
|
if (Number.isNaN(dt.getTime())) return ''
|
|
return dt.toISOString().split('T')[0]
|
|
}
|
|
setForm(prev => ({
|
|
...prev,
|
|
dob: toDateInput(profile.date_of_birth || profile.dateOfBirth),
|
|
nationality: profile.nationality || prev.nationality,
|
|
street: profile.address || prev.street,
|
|
postalCode: profile.zip_code || profile.zipCode || prev.postalCode,
|
|
city: profile.city || prev.city,
|
|
country: profile.country || prev.country,
|
|
accountHolder: profile.account_holder_name || profile.accountHolderName || prev.accountHolder,
|
|
// Prefer IBAN from users table (data.user.iban), fallback to profile if any
|
|
iban: (user?.iban ?? profile.iban ?? prev.iban) as string,
|
|
secondPhone: profile.phone_secondary || profile.phoneSecondary || prev.secondPhone,
|
|
emergencyName: profile.emergency_contact_name || profile.emergencyContactName || prev.emergencyName,
|
|
emergencyPhone: profile.emergency_contact_phone || profile.emergencyContactPhone || prev.emergencyPhone,
|
|
}))
|
|
} catch (_) {
|
|
// ignore prefill errors; user can still fill manually
|
|
}
|
|
}
|
|
loadProfile()
|
|
return () => { abort = true }
|
|
}, [accessToken])
|
|
|
|
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'
|
|
]
|
|
for (const k of requiredKeys) {
|
|
if (!form[k].trim()) {
|
|
setError('Please fill in all required fields.')
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Date of birth validation
|
|
if (!validateDateOfBirth(form.dob)) {
|
|
setError('Invalid date of birth. You must be at least 18 years old.')
|
|
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('Invalid IBAN.')
|
|
return false
|
|
}
|
|
setError('')
|
|
return true
|
|
}
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (loading || success) return
|
|
if (!validate()) return
|
|
|
|
if (!accessToken) {
|
|
setError('Not authenticated. Please log in again.')
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
|
|
try {
|
|
// 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)
|
|
|
|
// Refresh user status to update profile completion state
|
|
await refreshStatus()
|
|
|
|
// Redirect to next step after short delay
|
|
setTimeout(() => {
|
|
// Check if we came from tutorial
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const fromTutorial = urlParams.get('tutorial') === 'true'
|
|
|
|
if (fromTutorial) {
|
|
router.push('/quickaction-dashboard?tutorial=true')
|
|
} else {
|
|
router.push('/quickaction-dashboard/register-sign-contract/personal')
|
|
}
|
|
}, 1500)
|
|
|
|
} catch (error: any) {
|
|
console.error('Personal profile save error:', error)
|
|
setError(error.message || 'Save failed. Please try again.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<PageLayout>
|
|
<div className="relative flex flex-col flex-1 w-full px-5 sm:px-8 lg:px-12 py-12">
|
|
{/* Background */}
|
|
<div className="fixed inset-0 -z-10">
|
|
<div className="absolute inset-0 -z-20 bg-gradient-to-b from-gray-900/95 via-gray-900/80 to-gray-900" />
|
|
<svg aria-hidden="true" className="absolute inset-0 -z-10 h-full w-full stroke-white/10">
|
|
<defs>
|
|
<pattern id="personal-additional-pattern" x="50%" y={-1} width={200} height={200} patternUnits="userSpaceOnUse">
|
|
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
|
|
</pattern>
|
|
</defs>
|
|
<rect fill="url(#personal-additional-pattern)" width="100%" height="100%" strokeWidth={0} />
|
|
</svg>
|
|
<div aria-hidden="true" className="absolute top-0 right-0 left-1/2 -ml-24 blur-3xl transform-gpu overflow-hidden lg:ml-24 xl:ml-48">
|
|
<div
|
|
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-50"
|
|
style={{ clipPath: 'polygon(63.1% 29.5%,100% 17.1%,76.6% 3%,48.4% 0%,44.6% 4.7%,54.5% 25.3%,59.8% 49%,55.2% 57.8%,44.4% 57.2%,27.8% 47.9%,35.1% 81.5%,0% 97.7%,39.2% 100%,35.2% 81.4%,97.2% 52.8%,63.1% 29.5%)' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="relative max-w-6xl w-full mx-auto bg-white rounded-2xl shadow-xl ring-1 ring-black/10"
|
|
>
|
|
<div className="px-6 py-8 sm:px-10 lg:px-16">
|
|
<h1 className="text-center text-xl sm:text-2xl font-semibold text-[#0F172A] mb-6">
|
|
Complete Your Profile
|
|
</h1>
|
|
|
|
{/* Personal Information */}
|
|
<section>
|
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
|
Personal Information
|
|
</h2>
|
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Date of Birth *
|
|
</label>
|
|
<input
|
|
type="date"
|
|
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
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Nationality *
|
|
</label>
|
|
<select
|
|
name="nationality"
|
|
value={form.nationality}
|
|
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 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">
|
|
Street & House Number *
|
|
</label>
|
|
<input
|
|
name="street"
|
|
value={form.street}
|
|
onChange={handleChange}
|
|
placeholder="Street & House Number"
|
|
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
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Postal Code *
|
|
</label>
|
|
<input
|
|
name="postalCode"
|
|
value={form.postalCode}
|
|
onChange={handleChange}
|
|
placeholder="e.g. 12345"
|
|
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
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
City *
|
|
</label>
|
|
<input
|
|
name="city"
|
|
value={form.city}
|
|
onChange={handleChange}
|
|
placeholder="e.g. Berlin"
|
|
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
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Country *
|
|
</label>
|
|
<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>
|
|
|
|
<hr className="my-8 border-gray-200" />
|
|
|
|
{/* Bank Details */}
|
|
<section>
|
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
|
Bank Details
|
|
</h2>
|
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Account Holder *
|
|
</label>
|
|
<input
|
|
name="accountHolder"
|
|
value={form.accountHolder}
|
|
onChange={handleChange}
|
|
placeholder="Full name"
|
|
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
|
|
/>
|
|
</div>
|
|
<div className="sm:col-span-1 lg:col-span-2">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
IBAN *
|
|
</label>
|
|
<input
|
|
name="iban"
|
|
value={form.iban}
|
|
onChange={handleChange}
|
|
placeholder="e.g. DE89 3704 0044 0532 0130 00"
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tracking-wide uppercase focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<hr className="my-8 border-gray-200" />
|
|
|
|
{/* Additional Information */}
|
|
<section>
|
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
|
Additional Information
|
|
</h2>
|
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
<div className="sm:col-span-2 lg:col-span-3">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Second Phone Number (optional)
|
|
</label>
|
|
<input
|
|
name="secondPhone"
|
|
value={form.secondPhone}
|
|
onChange={handleChange}
|
|
placeholder="+43 660 1234567"
|
|
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"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Emergency Contact Name
|
|
</label>
|
|
<input
|
|
name="emergencyName"
|
|
value={form.emergencyName}
|
|
onChange={handleChange}
|
|
placeholder="Contact name"
|
|
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"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Emergency Contact Phone
|
|
</label>
|
|
<input
|
|
name="emergencyPhone"
|
|
value={form.emergencyPhone}
|
|
onChange={handleChange}
|
|
placeholder="+43 660 1234567"
|
|
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"
|
|
/>
|
|
</div>
|
|
<div className="hidden lg:block" />
|
|
</div>
|
|
</section>
|
|
|
|
{error && (
|
|
<div className="mt-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-600">
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div className="mt-6 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-xs text-green-700">
|
|
Data saved. Redirecting shortly…
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-10 flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={loading || success}
|
|
className="inline-flex items-center rounded-md bg-indigo-600 px-6 py-2.5 text-sm font-semibold text-white shadow hover:bg-indigo-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
|
>
|
|
{loading ? 'Saving…' : success ? 'Saved' : 'Save & Continue'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</PageLayout>
|
|
)
|
|
}
|