feat: add personal and company profile fields with phone number validation

This commit is contained in:
seaznCode 2026-01-19 20:28:09 +01:00
parent 71f4fdfd02
commit 860e88c3be
2 changed files with 350 additions and 25 deletions

View File

@ -7,9 +7,14 @@ import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { useToast } from '../../../components/toast/toastComponent'
import { ChevronDownIcon } from '@heroicons/react/20/solid' // NEW
import TelephoneInput, { TelephoneInputHandle } from '../../../components/phone/telephoneInput'
interface CompanyProfileData {
companyName: string
companyEmail: string
companyPhone: string
contactPersonName: string
contactPersonPhone: string
vatNumber: string
street: string
postalCode: string
@ -35,6 +40,10 @@ const COUNTRIES = [
const init: CompanyProfileData = {
companyName: '',
companyEmail: '',
companyPhone: '',
contactPersonName: '',
contactPersonPhone: '',
vatNumber: '',
street: '',
postalCode: '',
@ -175,12 +184,54 @@ export default function CompanyAdditionalInformationPage() {
const { accessToken } = useAuthStore()
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus()
const { showToast } = useToast()
const companyPhoneRef = useRef<TelephoneInputHandle | null>(null)
const contactPhoneRef = useRef<TelephoneInputHandle | null>(null)
const secondPhoneRef = useRef<TelephoneInputHandle | null>(null)
const emergencyPhoneRef = useRef<TelephoneInputHandle | null>(null)
const [form, setForm] = useState(init)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
// Prefill form with existing profile/user data
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 me = data?.user
if ((!profile && !me) || abort) return
setForm(prev => ({
...prev,
companyName: profile?.company_name || me?.companyName || prev.companyName,
companyEmail: me?.email || prev.companyEmail,
companyPhone: profile?.phone || me?.companyPhone || prev.companyPhone,
contactPersonName: profile?.contact_person_name || me?.contactPersonName || prev.contactPersonName,
contactPersonPhone: profile?.contact_person_phone || me?.contactPersonPhone || prev.contactPersonPhone,
vatNumber: profile?.registration_number || prev.vatNumber,
street: profile?.address || prev.street,
postalCode: profile?.zip_code || prev.postalCode,
city: profile?.city || prev.city,
country: profile?.country || prev.country,
accountHolder: profile?.account_holder_name || prev.accountHolder,
iban: (me?.iban ?? prev.iban) as string,
}))
} catch (_) {
// ignore prefill errors; user can still fill manually
}
}
loadProfile()
return () => { abort = true }
}, [accessToken])
// NEW: smooth redirect
const [redirectTo, setRedirectTo] = useState<string | null>(null)
const redirectOnceRef = useRef(false)
@ -223,7 +274,8 @@ export default function CompanyAdditionalInformationPage() {
const validate = () => {
const required: (keyof CompanyProfileData)[] = [
'companyName','vatNumber','street','postalCode','city','country','accountHolder','iban'
'companyName','companyEmail','companyPhone','contactPersonName','contactPersonPhone',
'vatNumber','street','postalCode','city','country','accountHolder','iban'
]
for (const k of required) {
if (!form[k].trim()) {
@ -237,6 +289,77 @@ export default function CompanyAdditionalInformationPage() {
return false
}
}
const companyApi = companyPhoneRef.current
const contactApi = contactPhoneRef.current
const companyDialCode = companyApi?.getDialCode?.()
const contactDialCode = contactApi?.getDialCode?.()
const companyNumber = companyApi?.getNumber() || ''
const contactNumber = contactApi?.getNumber() || ''
const companyValid = companyApi?.isValid() ?? false
const contactValid = contactApi?.isValid() ?? false
if (!companyDialCode || !contactDialCode) {
const msg = 'Please select country codes for company and contact phone numbers.'
setError(msg)
showToast({
variant: 'error',
title: 'Missing country code',
message: msg,
})
return false
}
if (!companyNumber || !contactNumber) {
const msg = 'Please enter both company and contact phone numbers.'
setError(msg)
showToast({
variant: 'error',
title: 'Missing phone numbers',
message: msg,
})
return false
}
if (!companyValid || !contactValid) {
const msg = 'Please enter valid phone numbers for company and contact person.'
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone numbers',
message: msg,
})
return false
}
const optionalSecond = form.secondPhone.trim()
if (optionalSecond) {
const secondApi = secondPhoneRef.current
const ok = secondApi?.isValid?.() ?? false
if (!ok) {
const msg = 'Please enter a valid second phone number.'
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone number',
message: msg,
})
return false
}
}
const optionalEmergency = form.emergencyPhone.trim()
if (optionalEmergency) {
const emergencyApi = emergencyPhoneRef.current
const ok = emergencyApi?.isValid?.() ?? false
if (!ok) {
const msg = 'Please enter a valid emergency phone number.'
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone number',
message: msg,
})
return false
}
}
if (!/^([A-Z]{2}\d{2}[A-Z0-9]{10,30})$/i.test(form.iban.replace(/\s+/g,''))) {
const msg = 'Ungültige IBAN.'
setError(msg)
@ -270,9 +393,17 @@ export default function CompanyAdditionalInformationPage() {
setLoading(true)
try {
const normalizedCompanyPhone = companyPhoneRef.current?.getNumber() || form.companyPhone
const normalizedContactPhone = contactPhoneRef.current?.getNumber() || form.contactPersonPhone
const normalizedSecondPhone = secondPhoneRef.current?.getNumber() || form.secondPhone
const normalizedEmergencyPhone = emergencyPhoneRef.current?.getNumber() || form.emergencyPhone
// Prepare data for backend with correct field names
const profileData = {
companyName: user?.companyName || '',
companyName: form.companyName,
companyPhone: normalizedCompanyPhone,
contactPersonName: form.contactPersonName,
contactPersonPhone: normalizedContactPhone,
address: form.street, // Backend expects 'address', not nested object
zip_code: form.postalCode, // Backend expects 'zip_code'
city: form.city,
@ -282,7 +413,10 @@ export default function CompanyAdditionalInformationPage() {
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
iban: form.iban.replace(/\s+/g, ''), // Remove spaces from IBAN
secondPhone: normalizedSecondPhone || null,
emergencyContactName: form.emergencyName || null,
emergencyContactPhone: normalizedEmergencyPhone || null
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/profile/company/complete`, {
@ -385,6 +519,57 @@ export default function CompanyAdditionalInformationPage() {
required
/>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Company Email *
</label>
<input
name="companyEmail"
value={form.companyEmail}
onChange={handleChange}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50 text-gray-700 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
readOnly
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Company Phone *
</label>
<TelephoneInput
name="companyPhone"
value={form.companyPhone}
onChange={handleChange}
placeholder="e.g. +43 1 234567"
ref={companyPhoneRef}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Contact Person *
</label>
<input
name="contactPersonName"
value={form.contactPersonName}
onChange={handleChange}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Contact Person Phone *
</label>
<TelephoneInput
name="contactPersonPhone"
value={form.contactPersonPhone}
onChange={handleChange}
placeholder="e.g. +43 676 1234567"
ref={contactPhoneRef}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
VAT / Reg No. *
@ -507,12 +692,12 @@ export default function CompanyAdditionalInformationPage() {
<label className="block text-sm font-medium text-gray-700 mb-1">
Second Phone (optional)
</label>
<input
<TelephoneInput
name="secondPhone"
value={form.secondPhone}
onChange={handleChange}
placeholder="+49 123 456 7890"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
ref={secondPhoneRef}
/>
</div>
<div>
@ -531,12 +716,12 @@ export default function CompanyAdditionalInformationPage() {
<label className="block text-sm font-medium text-gray-700 mb-1">
Emergency Contact Phone
</label>
<input
<TelephoneInput
name="emergencyPhone"
value={form.emergencyPhone}
onChange={handleChange}
placeholder="+49 123 456 7890"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
ref={emergencyPhoneRef}
/>
</div>
<div className="hidden lg:block" />

View File

@ -7,8 +7,13 @@ import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { useToast } from '../../../components/toast/toastComponent'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import TelephoneInput, { TelephoneInputHandle } from '../../../components/phone/telephoneInput'
interface PersonalProfileData {
firstName: string
lastName: string
email: string
phone: string
dob: string
nationality: string
street: string
@ -43,6 +48,10 @@ const COUNTRIES = [
]
const initialData: PersonalProfileData = {
firstName: '',
lastName: '',
email: '',
phone: '',
dob: '',
nationality: '',
street: '',
@ -204,6 +213,9 @@ export default function PersonalAdditionalInformationPage() {
const { accessToken } = useAuthStore()
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus()
const { showToast } = useToast()
const phoneRef = useRef<TelephoneInputHandle | null>(null)
const secondPhoneRef = useRef<TelephoneInputHandle | null>(null)
const emergencyPhoneRef = useRef<TelephoneInputHandle | null>(null)
const [form, setForm] = useState(initialData)
const [loading, setLoading] = useState(false)
@ -224,7 +236,7 @@ export default function PersonalAdditionalInformationPage() {
const data = await res.json().catch(() => null)
const profile = data?.profile
const user = data?.user
if (!profile || abort) return
if ((!profile && !user) || abort) return
const toDateInput = (d?: string) => {
if (!d) return ''
const dt = new Date(d)
@ -233,18 +245,22 @@ export default function PersonalAdditionalInformationPage() {
}
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,
firstName: user?.firstName || profile?.first_name || prev.firstName,
lastName: user?.lastName || profile?.last_name || prev.lastName,
email: user?.email || prev.email,
phone: user?.phone || profile?.phone || prev.phone,
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,
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
@ -287,6 +303,7 @@ export default function PersonalAdditionalInformationPage() {
const validate = () => {
const requiredKeys: (keyof PersonalProfileData)[] = [
'firstName','lastName','email','phone',
'dob','nationality','street','postalCode','city','country','accountHolder','iban'
]
for (const k of requiredKeys) {
@ -325,6 +342,72 @@ export default function PersonalAdditionalInformationPage() {
})
return false
}
const phoneApi = phoneRef.current
const dialCode = phoneApi?.getDialCode?.()
const intlNumber = phoneApi?.getNumber() || ''
const valid = phoneApi?.isValid() ?? false
if (!dialCode) {
const msg = 'Please select a country code for your phone number.'
setError(msg)
showToast({
variant: 'error',
title: 'Missing country code',
message: msg,
})
return false
}
if (!intlNumber) {
const msg = 'Please enter your phone number.'
setError(msg)
showToast({
variant: 'error',
title: 'Missing phone number',
message: msg,
})
return false
}
if (!valid) {
const msg = 'Please enter a valid phone number.'
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone number',
message: msg,
})
return false
}
const optionalSecond = form.secondPhone.trim()
if (optionalSecond) {
const secondApi = secondPhoneRef.current
const ok = secondApi?.isValid?.() ?? false
if (!ok) {
const msg = 'Please enter a valid second phone number.'
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone number',
message: msg,
})
return false
}
}
const optionalEmergency = form.emergencyPhone.trim()
if (optionalEmergency) {
const emergencyApi = emergencyPhoneRef.current
const ok = emergencyApi?.isValid?.() ?? false
if (!ok) {
const msg = 'Please enter a valid emergency phone number.'
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone number',
message: msg,
})
return false
}
}
setError('')
return true
}
@ -348,17 +431,24 @@ export default function PersonalAdditionalInformationPage() {
setLoading(true)
try {
const normalizedPhone = phoneRef.current?.getNumber() || form.phone
const normalizedSecondPhone = secondPhoneRef.current?.getNumber() || form.secondPhone
const normalizedEmergencyPhone = emergencyPhoneRef.current?.getNumber() || form.emergencyPhone
// Prepare data for backend with correct field names
const profileData = {
firstName: form.firstName,
lastName: form.lastName,
phone: normalizedPhone,
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'
phoneSecondary: normalizedSecondPhone || null, // Backend expects 'phoneSecondary'
emergencyContactName: form.emergencyName || null,
emergencyContactPhone: form.emergencyPhone || null,
emergencyContactPhone: normalizedEmergencyPhone || null,
accountHolderName: form.accountHolder, // Backend expects 'accountHolderName'
iban: form.iban.replace(/\s+/g, '') // Remove spaces from IBAN
}
@ -485,6 +575,56 @@ export default function PersonalAdditionalInformationPage() {
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">
First Name *
</label>
<input
name="firstName"
value={form.firstName}
onChange={handleChange}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Last Name *
</label>
<input
name="lastName"
value={form.lastName}
onChange={handleChange}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
required
/>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
name="email"
value={form.email}
onChange={handleChange}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50 text-gray-700 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
readOnly
required
/>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone Number *
</label>
<TelephoneInput
name="phone"
value={form.phone}
onChange={handleChange}
placeholder="e.g. +43 676 1234567"
ref={phoneRef}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Date of Birth *
@ -609,12 +749,12 @@ export default function PersonalAdditionalInformationPage() {
<label className="block text-sm font-medium text-gray-700 mb-1">
Second Phone Number (optional)
</label>
<input
<TelephoneInput
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-[#8D6B1D] focus:border-transparent"
ref={secondPhoneRef}
/>
</div>
<div>
@ -633,12 +773,12 @@ export default function PersonalAdditionalInformationPage() {
<label className="block text-sm font-medium text-gray-700 mb-1">
Emergency Contact Phone
</label>
<input
<TelephoneInput
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-[#8D6B1D] focus:border-transparent"
ref={emergencyPhoneRef}
/>
</div>
<div className="hidden lg:block" />