Files
membership-fe/src/pages/AcceptInvitation.js
2025-12-16 20:04:00 +07:00

462 lines
17 KiB
JavaScript

import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../utils/api';
import { Card } from '../components/ui/card';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Badge } from '../components/ui/badge';
import { toast } from 'sonner';
import { Loader2, Mail, Shield, CheckCircle, XCircle, Calendar } from 'lucide-react';
const AcceptInvitation = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { login } = useAuth();
const [token, setToken] = useState(null);
const [invitation, setInvitation] = useState(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
first_name: '',
last_name: '',
phone: '',
address: '',
city: '',
state: '',
zipcode: '',
date_of_birth: ''
});
const [formErrors, setFormErrors] = useState({});
useEffect(() => {
const invitationToken = searchParams.get('token');
if (!invitationToken) {
setError('Invalid invitation link. No token provided.');
setLoading(false);
return;
}
setToken(invitationToken);
verifyInvitation(invitationToken);
}, [searchParams]);
const verifyInvitation = async (invitationToken) => {
try {
const response = await api.get(`/invitations/verify/${invitationToken}`);
setInvitation(response.data);
// Pre-fill form with invitation data
setFormData(prev => ({
...prev,
first_name: response.data.first_name || '',
last_name: response.data.last_name || '',
phone: response.data.phone || ''
}));
setLoading(false);
} catch (error) {
setError(error.response?.data?.detail || 'Invalid or expired invitation token');
setLoading(false);
}
};
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (formErrors[field]) {
setFormErrors(prev => ({ ...prev, [field]: null }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.password || formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
if (!formData.first_name) {
newErrors.first_name = 'First name is required';
}
if (!formData.last_name) {
newErrors.last_name = 'Last name is required';
}
if (!formData.phone) {
newErrors.phone = 'Phone is required';
}
setFormErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) {
return;
}
setSubmitting(true);
try {
// Prepare payload
const payload = {
token,
password: formData.password,
first_name: formData.first_name,
last_name: formData.last_name,
phone: formData.phone
};
// Add optional fields if provided
if (formData.address) payload.address = formData.address;
if (formData.city) payload.city = formData.city;
if (formData.state) payload.state = formData.state;
if (formData.zipcode) payload.zipcode = formData.zipcode;
if (formData.date_of_birth) payload.date_of_birth = formData.date_of_birth;
// Accept invitation
const response = await api.post('/invitations/accept', payload);
// Auto-login with returned token
const { access_token, user } = response.data;
localStorage.setItem('token', access_token);
toast.success('Welcome to LOAF! Your account has been created successfully.');
// Call login to update auth context
if (login) {
await login(invitation.email, formData.password);
}
// Redirect based on role
if (user.role === 'admin' || user.role === 'superadmin') {
navigate('/admin/dashboard');
} else {
navigate('/dashboard');
}
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to accept invitation';
toast.error(errorMessage);
} finally {
setSubmitting(false);
}
};
const getRoleBadge = (role) => {
const config = {
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
admin: { label: 'Admin', className: 'bg-[#81B29A] text-white' },
member: { label: 'Member', className: 'bg-[#DDD8EB] text-[#422268]' }
};
const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
return (
<Badge className={`${roleConfig.className} px-4 py-2 rounded-full text-sm`}>
<Shield className="h-4 w-4 mr-2 inline" />
{roleConfig.label}
</Badge>
);
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
<Card className="w-full max-w-md p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
<Loader2 className="h-12 w-12 text-[#664fa3] mx-auto mb-4 animate-spin" />
<p className="text-lg text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Verifying your invitation...
</p>
</Card>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
<Card className="w-full max-w-md p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
<XCircle className="h-16 w-16 text-red-500 mx-auto mb-6" />
<h1 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Invalid Invitation
</h1>
<p className="text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{error}
</p>
<Button
onClick={() => navigate('/login')}
className="rounded-xl bg-[#664fa3] hover:bg-[#422268] text-white"
>
Go to Login
</Button>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
<Card className="w-full max-w-3xl p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb]">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="h-16 w-16 rounded-full bg-gradient-to-br from-[#664fa3] to-[#422268] flex items-center justify-center">
<Mail className="h-8 w-8 text-white" />
</div>
</div>
<h1 className="text-3xl md:text-4xl font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Welcome to LOAF!
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Complete your profile to accept the invitation
</p>
</div>
{/* Invitation Details */}
<div className="mb-8 p-6 bg-gradient-to-r from-[#DDD8EB] to-[#F9F8FB] rounded-xl">
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<p className="text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Email Address
</p>
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{invitation?.email}
</p>
</div>
<div>
<p className="text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Role
</p>
<div>{getRoleBadge(invitation?.role)}</div>
</div>
<div className="md:col-span-2">
<p className="text-[#664fa3] mb-1 flex items-center gap-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Calendar className="h-4 w-4" />
Invitation Expires
</p>
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{invitation?.expires_at ? new Date(invitation.expires_at).toLocaleString() : 'N/A'}
</p>
</div>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
{/* Password Fields */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="password" className="text-[#422268]">
Password <span className="text-red-500">*</span>
</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Minimum 8 characters"
/>
{formErrors.password && (
<p className="text-sm text-red-500">{formErrors.password}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword" className="text-[#422268]">
Confirm Password <span className="text-red-500">*</span>
</Label>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Re-enter password"
/>
{formErrors.confirmPassword && (
<p className="text-sm text-red-500">{formErrors.confirmPassword}</p>
)}
</div>
</div>
{/* Name Fields */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[#422268]">
First Name <span className="text-red-500">*</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="John"
/>
{formErrors.first_name && (
<p className="text-sm text-red-500">{formErrors.first_name}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[#422268]">
Last Name <span className="text-red-500">*</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Doe"
/>
{formErrors.last_name && (
<p className="text-sm text-red-500">{formErrors.last_name}</p>
)}
</div>
</div>
{/* Phone */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[#422268]">
Phone <span className="text-red-500">*</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="(555) 123-4567"
/>
{formErrors.phone && (
<p className="text-sm text-red-500">{formErrors.phone}</p>
)}
</div>
{/* Optional Fields Section */}
{invitation?.role === 'member' && (
<>
<div className="border-t border-[#ddd8eb] pt-6 mt-2">
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Additional Information (Optional)
</h3>
</div>
{/* Address */}
<div className="grid gap-2">
<Label htmlFor="address" className="text-[#422268]">Address</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="123 Main St"
/>
</div>
{/* City, State, Zipcode */}
<div className="grid md:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="city" className="text-[#422268]">City</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleChange('city', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="San Francisco"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="state" className="text-[#422268]">State</Label>
<Input
id="state"
value={formData.state}
onChange={(e) => handleChange('state', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="CA"
maxLength={2}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="zipcode" className="text-[#422268]">Zipcode</Label>
<Input
id="zipcode"
value={formData.zipcode}
onChange={(e) => handleChange('zipcode', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="94102"
/>
</div>
</div>
{/* Date of Birth */}
<div className="grid gap-2">
<Label htmlFor="date_of_birth" className="text-[#422268]">Date of Birth</Label>
<Input
id="date_of_birth"
type="date"
value={formData.date_of_birth}
onChange={(e) => handleChange('date_of_birth', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
</>
)}
</div>
{/* Submit Button */}
<div className="mt-8">
<Button
type="submit"
disabled={submitting}
className="w-full h-14 rounded-xl bg-gradient-to-r from-[#81B29A] to-[#6DA085] hover:from-[#6DA085] hover:to-[#5A8F72] text-white text-lg font-semibold"
>
{submitting ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
Creating Your Account...
</>
) : (
<>
<CheckCircle className="h-5 w-5 mr-2" />
Accept Invitation & Create Account
</>
)}
</Button>
</div>
</form>
{/* Footer Note */}
<div className="mt-6 text-center">
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Already have an account?{' '}
<button
onClick={() => navigate('/login')}
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
>
Sign in instead
</button>
</p>
</div>
</Card>
</div>
);
};
export default AcceptInvitation;