feat: add company settings management and guest registration functionality

This commit is contained in:
seaznCode 2026-03-09 22:07:28 +01:00
parent de290cd9ef
commit 48c63a896f
6 changed files with 495 additions and 33 deletions

View File

@ -0,0 +1,140 @@
'use client'
import { useState, useEffect } from 'react'
import useContractManagement from '../hooks/useContractManagement'
export default function CompanySettingsPanel() {
const { getCompanySettings, updateCompanySettings } = useContractManagement()
const [form, setForm] = useState({
company_name: '',
company_street: '',
company_postal_city: '',
company_country: '',
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
getCompanySettings()
.then(data => {
setForm({
company_name: data.company_name || '',
company_street: data.company_street || '',
company_postal_city: data.company_postal_city || '',
company_country: data.company_country || '',
})
})
.catch(() => {})
.finally(() => setLoading(false))
}, [getCompanySettings])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setForm(prev => ({ ...prev, [name]: value }))
setSaved(false)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setSaved(false)
try {
await updateCompanySettings(form)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
} catch {
// silent
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-gray-500 py-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-900" />
Loading settings
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="company_name" className="block text-sm font-medium text-gray-700 mb-1">
Company Name
</label>
<input
type="text"
id="company_name"
name="company_name"
value={form.company_name}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="ProfitPlanet GmbH"
/>
</div>
<div>
<label htmlFor="company_street" className="block text-sm font-medium text-gray-700 mb-1">
Street
</label>
<input
type="text"
id="company_street"
name="company_street"
value={form.company_street}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Musterstraße 1"
/>
</div>
<div>
<label htmlFor="company_postal_city" className="block text-sm font-medium text-gray-700 mb-1">
Postal Code &amp; City
</label>
<input
type="text"
id="company_postal_city"
name="company_postal_city"
value={form.company_postal_city}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="12345 Berlin"
/>
</div>
<div>
<label htmlFor="company_country" className="block text-sm font-medium text-gray-700 mb-1">
Country
</label>
<input
type="text"
id="company_country"
name="company_country"
value={form.company_country}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Germany"
/>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className={`px-5 py-2 rounded-lg text-sm font-semibold text-white transition-colors ${
saving ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-900 hover:bg-blue-800'
}`}
>
{saving ? 'Saving…' : 'Save'}
</button>
{saved && (
<span className="text-sm text-green-600 font-medium">Saved successfully</span>
)}
</div>
</form>
)
}

View File

@ -444,6 +444,21 @@ export default function useContractManagement() {
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
}, [authorizedFetch]); }, [authorizedFetch]);
// Company settings (invoice address info)
const getCompanySettings = useCallback(async () => {
return authorizedFetch<{ company_name: string; company_street: string; company_postal_city: string; company_country: string }>(
'/api/admin/company-settings', { method: 'GET' }
);
}, [authorizedFetch]);
const updateCompanySettings = useCallback(async (data: {
company_name: string; company_street: string; company_postal_city: string; company_country: string;
}) => {
return authorizedFetch<{ company_name: string; company_street: string; company_postal_city: string; company_country: string }>(
'/api/admin/company-settings', { method: 'PUT', body: JSON.stringify(data) }
);
}, [authorizedFetch]);
return { return {
// templates // templates
listTemplates, listTemplates,
@ -466,6 +481,9 @@ export default function useContractManagement() {
activateCompanyStamp, activateCompanyStamp,
deactivateCompanyStamp, deactivateCompanyStamp,
deleteCompanyStamp, deleteCompanyStamp,
// company settings
getCompanySettings,
updateCompanySettings,
// utils // utils
downloadBlobFile, downloadBlobFile,
}; };

View File

@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import PageLayout from '../../components/PageLayout'; import PageLayout from '../../components/PageLayout';
import ContractEditor from './components/contractEditor'; import ContractEditor from './components/contractEditor';
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp'; import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
import CompanySettingsPanel from './components/companySettingsPanel';
import ContractTemplateList from './components/contractTemplateList'; import ContractTemplateList from './components/contractTemplateList';
import useAuthStore from '../../store/authStore'; import useAuthStore from '../../store/authStore';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@ -98,6 +99,15 @@ export default function ContractManagementPage() {
Company Stamp Company Stamp
</h2> </h2>
<ContractUploadCompanyStamp onUploaded={bumpRefresh} /> <ContractUploadCompanyStamp onUploaded={bumpRefresh} />
<div className="mt-8 pt-6 border-t border-gray-200">
<h3 className="text-lg font-semibold text-blue-900 mb-3 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
Company Information
</h3>
<p className="text-sm text-gray-500 mb-4">Address details used on invoices.</p>
<CompanySettingsPanel />
</div>
</section> </section>
)} )}
{section === 'templates' && ( {section === 'templates' && (

View File

@ -7,8 +7,8 @@ import { useToast } from '../../components/toast/toastComponent'
import TelephoneInput, { TelephoneInputHandle } from '../../components/phone/telephoneInput' import TelephoneInput, { TelephoneInputHandle } from '../../components/phone/telephoneInput'
interface RegisterFormProps { interface RegisterFormProps {
mode: 'personal' | 'company' mode: 'personal' | 'company' | 'guest'
setMode: (mode: 'personal' | 'company') => void setMode: (mode: 'personal' | 'company' | 'guest') => void
refToken: string | null refToken: string | null
onRegistered: () => void onRegistered: () => void
referrerEmail?: string referrerEmail?: string
@ -78,9 +78,19 @@ export default function RegisterForm({
const contactPhoneRef = useRef<TelephoneInputHandle | null>(null) const contactPhoneRef = useRef<TelephoneInputHandle | null>(null)
// Hook for backend calls // Hook for backend calls
const { registerPersonalReferral, registerCompanyReferral, error: regError } = useRegister() const { registerPersonalReferral, registerCompanyReferral, registerGuest, error: regError } = useRegister()
const { showToast } = useToast() const { showToast } = useToast()
// Guest form state
const [guestForm, setGuestForm] = useState({
firstName: '',
lastName: '',
email: '',
confirmEmail: '',
password: '',
confirmPassword: '',
})
// Animate form when mode changes // Animate form when mode changes
useEffect(() => { useEffect(() => {
setFormFade('fade-out') setFormFade('fade-out')
@ -350,7 +360,72 @@ export default function RegisterForm({
const { name, value } = e.target const { name, value } = e.target
setCompanyForm(prev => ({ ...prev, [name]: value })) setCompanyForm(prev => ({ ...prev, [name]: value }))
} }
const handleGuestChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setGuestForm(prev => ({ ...prev, [name]: value }))
}
const validateGuestForm = (): boolean => {
if (!guestForm.firstName.trim() || !guestForm.lastName.trim() ||
!guestForm.email.trim() || !guestForm.confirmEmail.trim() ||
!guestForm.password.trim() || !guestForm.confirmPassword.trim()
) {
setError('All fields are required')
return false
}
if (guestForm.email !== guestForm.confirmEmail) {
setError('Email addresses do not match')
return false
}
if (guestForm.password !== guestForm.confirmPassword) {
setError('Passwords do not match')
return false
}
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(guestForm.password)) {
setError('Password must be at least 8 characters long and contain uppercase and lowercase letters, numbers and special characters')
return false
}
setError('')
return true
}
const handleGuestSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (loading) return
if (!validateGuestForm()) return
setLoading(true)
setError('')
try {
const res = await registerGuest({
refToken: refToken || '',
firstName: guestForm.firstName,
lastName: guestForm.lastName,
email: guestForm.email,
password: guestForm.password,
})
if (res.ok) {
showToast({
variant: 'success',
title: 'Registration successful',
message: 'You can now log in to view your coffee abonnement.'
})
onRegistered()
} else {
const msg = res.message || 'Registration failed. Please try again.'
setError(msg)
showToast({ variant: 'error', title: 'Registration failed', message: msg })
}
} catch {
const msg = 'Registration failed. Please try again.'
setError(msg)
showToast({ variant: 'error', title: 'Registration failed', message: msg })
} finally {
setLoading(false)
}
}
// Password strength indicator // Password strength indicator
const getPasswordStrength = (password: string) => { const getPasswordStrength = (password: string) => {
let strength = 0 let strength = 0
@ -408,28 +483,39 @@ export default function RegisterForm({
{/* Mode Toggle */} {/* Mode Toggle */}
<div className="flex justify-center mb-8"> <div className="flex justify-center mb-8">
<div className="bg-white/40 backdrop-blur-[18px] border border-white/35 shadow-sm p-1 rounded-lg"> <div className="bg-white/40 backdrop-blur-[18px] border border-white/35 shadow-sm p-1 rounded-lg">
<button {mode === 'guest' ? (
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${ <button
mode === 'personal' className="px-6 py-2 rounded-md font-semibold text-sm bg-[#8D6B1D] text-white shadow-sm cursor-default"
? 'bg-[#8D6B1D] text-white shadow-sm' type="button"
: 'bg-transparent text-slate-700 hover:text-[#8D6B1D]' >
}`} Guest
onClick={() => setMode('personal')} </button>
type="button" ) : (
> <>
Individual <button
</button> className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
<button mode === 'personal'
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${ ? 'bg-[#8D6B1D] text-white shadow-sm'
mode === 'company' : 'bg-transparent text-slate-700 hover:text-[#8D6B1D]'
? 'bg-[#8D6B1D] text-white shadow-sm' }`}
: 'bg-transparent text-slate-700 hover:text-[#8D6B1D]' onClick={() => setMode('personal')}
}`} type="button"
onClick={() => setMode('company')} >
type="button" Individual
> </button>
Company <button
</button> className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
mode === 'company'
? 'bg-[#8D6B1D] text-white shadow-sm'
: 'bg-transparent text-slate-700 hover:text-[#8D6B1D]'
}`}
onClick={() => setMode('company')}
type="button"
>
Company
</button>
</>
)}
</div> </div>
</div> </div>
@ -592,7 +678,7 @@ export default function RegisterForm({
)} )}
</button> </button>
</form> </form>
) : ( ) : mode === 'company' ? (
<form onSubmit={handleCompanySubmit} className="space-y-6"> <form onSubmit={handleCompanySubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
@ -764,6 +850,140 @@ export default function RegisterForm({
)} )}
</button> </button>
</form> </form>
) : (
<form onSubmit={handleGuestSubmit} className="space-y-6">
<div className="p-4 bg-amber-50/70 backdrop-blur-[18px] border border-amber-200/70 rounded-lg mb-2">
<p className="text-amber-800 text-sm font-medium">
You are registering as a guest. You will have access to your coffee abonnements only.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="guestFirstName" className="block text-sm font-medium text-[#0F172A] mb-2">
First name *
</label>
<input
type="text"
id="guestFirstName"
name="firstName"
value={guestForm.firstName}
onChange={handleGuestChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
required
/>
</div>
<div>
<label htmlFor="guestLastName" className="block text-sm font-medium text-[#0F172A] mb-2">
Last name *
</label>
<input
type="text"
id="guestLastName"
name="lastName"
value={guestForm.lastName}
onChange={handleGuestChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="guestEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
Email *
</label>
<input
type="email"
id="guestEmail"
name="email"
value={guestForm.email}
onChange={handleGuestChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
required
/>
</div>
<div>
<label htmlFor="guestConfirmEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
Confirm email *
</label>
<input
type="email"
id="guestConfirmEmail"
name="confirmEmail"
value={guestForm.confirmEmail}
onChange={handleGuestChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="guestPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
Password *
</label>
<div className="relative">
<input
type={showPersonalPassword ? 'text' : 'password'}
id="guestPassword"
name="password"
value={guestForm.password}
onChange={handleGuestChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary pr-10"
required
/>
<button
type="button"
onClick={() => setShowPersonalPassword(!showPersonalPassword)}
className="absolute inset-y-0 right-0 px-3 flex items-center text-slate-500 hover:text-[#8D6B1D]"
>
{showPersonalPassword ? (
<EyeSlashIcon className="h-5 w-5" />
) : (
<EyeIcon className="h-5 w-5" />
)}
</button>
</div>
{guestForm.password && renderPasswordStrength(guestForm.password)}
</div>
<div>
<label htmlFor="guestConfirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
Confirm password *
</label>
<input
type="password"
id="guestConfirmPassword"
name="confirmPassword"
value={guestForm.confirmPassword}
onChange={handleGuestChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white/60 focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors text-primary"
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className={`w-full flex items-center justify-center py-3 px-4 rounded-lg text-white font-semibold transition-colors ${
loading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-[#8D6B1D] hover:bg-[#7A5E1A] focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2'
}`}
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Registration in progress...
</>
) : (
'Register as Guest'
)}
</button>
</form>
)} )}
</div> </div>

View File

@ -23,6 +23,15 @@ export type CompanyReferralPayload = {
lang?: 'de' | 'en' lang?: 'de' | 'en'
} }
export type GuestReferralPayload = {
refToken: string
firstName: string
lastName: string
email: string
password: string
lang?: 'de' | 'en'
}
type RegisterResult<T = any> = { type RegisterResult<T = any> = {
ok: boolean ok: boolean
status: number status: number
@ -144,11 +153,69 @@ export function useRegister() {
} }
} }
const registerGuest = async (payload: GuestReferralPayload): Promise<RegisterResult> => {
setError('')
setLoading(true)
const body = {
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
confirmEmail: payload.email,
password: payload.password,
confirmPassword: payload.password,
referralEmail: undefined as string | undefined,
lang: payload.lang || detectLang(),
}
// Try to resolve referral email from token
if (payload.refToken) {
try {
const infoRes = await fetch(`${base}/api/referral/info/${encodeURIComponent(payload.refToken)}`, {
method: 'GET',
credentials: 'include',
})
const infoJson = await infoRes.json().catch(() => null)
if (infoJson?.referrerEmail) {
body.referralEmail = infoJson.referrerEmail
}
} catch {}
}
const url = `${base}/api/register/guest`
console.log('🌐 useRegister: POST guest', { url })
try {
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json().catch(() => null)
console.log('📡 useRegister: guest status:', res.status, 'body:', json)
if (!res.ok) {
const msg = json?.message || mapError(res.status)
setError(msg)
return { ok: false, status: res.status, data: json, message: msg }
}
return { ok: true, status: res.status, data: json, message: json?.message }
} catch (e) {
console.error('❌ useRegister: guest error:', e)
const msg = 'Network error. Please try again later.'
setError(msg)
return { ok: false, status: 0, data: null, message: msg }
} finally {
setLoading(false)
}
}
return { return {
loading, loading,
error, error,
setError, setError,
registerPersonalReferral, registerPersonalReferral,
registerCompanyReferral, registerCompanyReferral,
registerGuest,
} }
} }

View File

@ -15,8 +15,9 @@ import BlueBlurryBackground from '../components/background/blueblurry' // NEW
function RegisterPageInner() { function RegisterPageInner() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const refToken = searchParams.get('ref') const refToken = searchParams.get('ref')
const isGuestInvite = searchParams.get('guest') === 'true'
const [registered, setRegistered] = useState(false) const [registered, setRegistered] = useState(false)
const [mode, setMode] = useState<'personal' | 'company'>('personal') const [mode, setMode] = useState<'personal' | 'company' | 'guest'>(isGuestInvite ? 'guest' : 'personal')
const router = useRouter() const router = useRouter()
const { showToast } = useToast() const { showToast } = useToast()
@ -71,6 +72,8 @@ function RegisterPageInner() {
const res = await fetch(url, { method: 'GET', credentials: 'include' }) const res = await fetch(url, { method: 'GET', credentials: 'include' })
const body = await res.json().catch(() => null) const body = await res.json().catch(() => null)
console.log('[REGISTER] Token validation response:', { status: res.status, body })
const success = !!body?.success const success = !!body?.success
const isUnlimited = !!body?.isUnlimited const isUnlimited = !!body?.isUnlimited
const usesRemaining = const usesRemaining =
@ -92,16 +95,18 @@ function RegisterPageInner() {
message: 'Your invitation link is valid. You can register now.' message: 'Your invitation link is valid. You can register now.'
}) })
} else { } else {
const reason = body?.reason || `HTTP ${res.status}`
setInvalidRef(true) setInvalidRef(true)
showToast({ showToast({
variant: 'error', variant: 'error',
title: 'Invalid invitation', title: 'Invalid invitation',
message: 'This invitation link is invalid or no longer active.' message: `Reason: ${reason}. This invitation link is invalid or no longer active.`
}) })
} }
setIsRefChecked(true) setIsRefChecked(true)
} }
} catch { } catch (err) {
console.error('[REGISTER] Token validation network error:', err)
if (!cancelled) { if (!cancelled) {
setInvalidRef(true) setInvalidRef(true)
setIsRefChecked(true) setIsRefChecked(true)
@ -109,7 +114,7 @@ function RegisterPageInner() {
showToast({ showToast({
variant: 'error', variant: 'error',
title: 'Network error', title: 'Network error',
message: 'Could not validate the invitation link. Please try again.' message: 'Could not reach the server. Is the backend running?'
}) })
} }
} }
@ -277,10 +282,12 @@ function RegisterPageInner() {
<div className="relative"> <div className="relative">
<div className="mx-auto max-w-2xl text-center mb-8"> <div className="mx-auto max-w-2xl text-center mb-8">
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]"> <h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-[#0F172A]">
Register now {mode === 'guest' ? 'Guest Registration' : 'Register now'}
</h1> </h1>
<p className="mt-3 text-slate-700 text-base sm:text-lg"> <p className="mt-3 text-slate-700 text-base sm:text-lg">
Create your personal or company account with Profit Planet. {mode === 'guest'
? 'Register as a guest to access your coffee abonnement.'
: 'Create your personal or company account with Profit Planet.'}
</p> </p>
</div> </div>