keeai / frontend /src /pages /InviteAcceptance.jsx
Seth0330's picture
Update frontend/src/pages/InviteAcceptance.jsx
801c143 verified
// frontend/src/pages/InviteAcceptance.jsx
import { useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation } from "@tanstack/react-query";
import api from "../api/client";
import { Plus, X, CreditCard } from "lucide-react";
// Payment Button Component
function PaymentButton({ invite, token }) {
const [checkoutUrl, setCheckoutUrl] = useState(null);
const [loading, setLoading] = useState(false);
const createCheckoutMutation = useMutation({
mutationFn: async () => {
if (!token) {
throw new Error("Invite token is required");
}
const res = await api.post(`/invite/${token}/checkout`);
return res.data;
},
});
useEffect(() => {
// Create dynamic checkout session when component mounts
if (token && invite) {
setLoading(true);
createCheckoutMutation.mutate(undefined, {
onSuccess: (data) => {
if (data?.checkout_url) {
setCheckoutUrl(data.checkout_url);
}
setLoading(false);
},
onError: (error) => {
console.error("Error creating checkout session:", error);
setLoading(false);
},
});
}
}, [token, invite]);
if (loading) {
return (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-900">Creating payment session...</p>
</div>
);
}
if (!checkoutUrl) {
return (
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-sm text-amber-900">
Failed to create payment session. Please try again or contact the administrator.
</p>
</div>
);
}
return (
<div id="complete-payment-container" className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-900 mb-3">
Student information saved! Click below to complete payment and activate your membership.
</p>
<a
href={checkoutUrl}
className="inline-flex items-center justify-center gap-2 w-full rounded-lg bg-green-600 text-white text-sm font-medium py-2.5 hover:bg-green-700"
>
<CreditCard className="w-4 h-4" />
Complete Payment
</a>
</div>
);
}
export default function InviteAcceptance({ invite, inviteId, onComplete, onCancel }) {
// useParams and useNavigate must be called unconditionally (React hook rules)
const params = useParams();
const token = params?.token;
const navigate = useNavigate();
// If invite object is provided directly (from dashboard), use it. Otherwise fetch by ID or token
const isFromDashboard = !!invite;
const needsFetch = !invite && (inviteId || token);
const [step, setStep] = useState(isFromDashboard ? 3 : 1); // Skip email/OTP if from dashboard
const [email, setEmail] = useState("");
const [otp, setOtp] = useState("");
const [serverOtp, setServerOtp] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
// Member details (common for all students)
const [memberDetails, setMemberDetails] = useState({
first_name: "",
last_name: "",
phone_number: "",
emergency_contact_person: "",
emergency_contact_number: "",
});
// Student details (one per student)
const [students, setStudents] = useState([{ first_name: "", last_name: "", date_of_birth: "", gender: "" }]);
// Ref for the first input field
const firstInputRef = useRef(null);
// Only fetch if we don't have the invite object already
const { data: fetchedInvite, isLoading: inviteLoading, error: inviteError, isFetching } = useQuery({
queryKey: ["invite", inviteId, token],
queryFn: async () => {
console.log("🔵 Query function called:", { inviteId, token });
try {
if (inviteId) {
// Get by ID - note: api client already has /api baseURL, so don't include /api prefix
console.log(`🔵 Calling /invite/id/${inviteId}`);
const res = await api.get(`/invite/id/${inviteId}`);
console.log("✅ Invite response:", res.data);
return res.data;
} else if (token) {
// Get by token - note: api client already has /api baseURL, so don't include /api prefix
console.log(`🔵 Calling /invite/${token}`);
const res = await api.get(`/invite/${token}`);
console.log("✅ Invite response:", res.data);
return res.data;
} else {
throw new Error("No invite identifier provided");
}
} catch (error) {
console.error("❌ Error fetching invite:", error);
throw error;
}
},
enabled: !!needsFetch, // Only fetch if we need to (ensure boolean)
retry: false,
refetchOnWindowFocus: false,
});
// Use the provided invite or the fetched one
const currentInvite = invite || fetchedInvite;
// Set email from stored session if from dashboard
useEffect(() => {
if (isFromDashboard && currentInvite) {
const stored = JSON.parse(localStorage.getItem("karateStudent") || "{}");
setEmail(stored.email || currentInvite.email);
setStep(3); // Skip to student details
}
}, [isFromDashboard, currentInvite]);
// Auto-scroll and focus when form is rendered (step 3)
useEffect(() => {
if (step === 3 && isFromDashboard && currentInvite) {
console.log('🔵 Auto-focus: useEffect triggered', { step, isFromDashboard, hasCurrentInvite: !!currentInvite });
// Wait for form to be in DOM, then focus
const attemptFocus = (attempts = 0) => {
if (attempts > 30) {
console.log('⚠️ Auto-focus: Max attempts reached');
return;
}
// Only check for elements that exist in this component
const formElement = document.getElementById('invite-acceptance-form');
const firstInput = firstInputRef.current || document.getElementById('member-first-name-input');
console.log(`🔄 Auto-focus: Attempt ${attempts + 1}`, {
formElement: !!formElement,
firstInput: !!firstInput,
refCurrent: !!firstInputRef.current
});
if (formElement && firstInput) {
console.log('✅ Auto-focus: Elements found, focusing');
// Wait a bit for any scroll animation to settle, then focus
setTimeout(() => {
requestAnimationFrame(() => {
try {
// Check if input is visible and focusable
const rect = firstInput.getBoundingClientRect();
const isVisible = rect.width > 0 && rect.height > 0;
if (isVisible && !firstInput.disabled) {
firstInput.focus({ preventScroll: false });
// Small delay before select to ensure focus is applied
setTimeout(() => {
firstInput.select();
console.log('✅ Auto-focus: Input focused and selected successfully');
}, 100);
} else {
console.log('⚠️ Auto-focus: Input not ready', { isVisible, disabled: firstInput.disabled });
// Retry once more
setTimeout(() => {
firstInput.focus({ preventScroll: false });
firstInput.select();
}, 200);
}
} catch (err) {
console.log('⚠️ Auto-focus: Focus error', err);
}
});
}, 600); // Wait for scroll to complete
} else {
// Retry if elements not found yet
setTimeout(() => attemptFocus(attempts + 1), 150);
}
};
// Start attempting after a delay to let React render
setTimeout(() => attemptFocus(), 400);
}
}, [step, isFromDashboard, currentInvite]);
// Request OTP mutation
const requestOtpMutation = useMutation({
mutationFn: async (email) => {
const res = await api.post("/auth/request-otp", { email });
return res.data;
},
});
// Verify OTP mutation
const verifyOtpMutation = useMutation({
mutationFn: async ({ email, otp }) => {
const res = await api.post("/auth/login", { email, otp });
return res.data;
},
});
// Save students mutation
const saveStudentsMutation = useMutation({
mutationFn: async (payload) => {
const tokenToUse = currentInvite?.invite_token || token;
// Payload already contains member_details and students, send it directly
const res = await api.post(`/invite/${tokenToUse}/students`, payload);
return res.data;
},
});
// Auto-scroll to payment button when save is successful
useEffect(() => {
if (saveStudentsMutation.isSuccess) {
console.log('🔵 Payment scroll: Save successful, scrolling to payment button');
// Wait for payment button to render, then scroll
const attemptScroll = (attempts = 0) => {
if (attempts > 20) {
console.log('⚠️ Payment scroll: Max attempts reached');
return;
}
const paymentContainer = document.getElementById('complete-payment-container');
if (paymentContainer) {
console.log('✅ Payment scroll: Payment container found, scrolling');
paymentContainer.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
console.log(`🔄 Payment scroll: Attempt ${attempts + 1} - Payment container not found yet`);
// Retry if element not found yet
setTimeout(() => attemptScroll(attempts + 1), 150);
}
};
// Start attempting after a short delay to let React render
setTimeout(() => attemptScroll(), 300);
}
}, [saveStudentsMutation.isSuccess]);
// Verify email matches invite
useEffect(() => {
if (currentInvite && email && email.toLowerCase() !== currentInvite.email.toLowerCase()) {
setMessage("Email must match the invited email address.");
} else if (currentInvite && email && email.toLowerCase() === currentInvite.email.toLowerCase()) {
setMessage("");
}
}, [email, currentInvite]);
async function handleRequestOtp(e) {
e.preventDefault();
if (!email.trim()) return;
// Verify email matches invite
if (currentInvite && email.toLowerCase() !== currentInvite.email.toLowerCase()) {
setMessage("Email must match the invited email address.");
return;
}
setLoading(true);
setMessage("");
try {
const res = await requestOtpMutation.mutateAsync(email);
if (res?.debug_code) {
setServerOtp(res.debug_code);
setMessage(`Test OTP (email not configured): ${res.debug_code}`);
} else {
setMessage("OTP sent to your email address.");
}
setStep(2);
} catch (err) {
setMessage(err?.response?.data?.detail || "Could not send OTP. Please try again.");
} finally {
setLoading(false);
}
}
async function handleVerifyOtp(e) {
e.preventDefault();
setLoading(true);
setMessage("");
try {
await verifyOtpMutation.mutateAsync({ email, otp });
setStep(3); // Move to student details step
} catch (err) {
setMessage(err?.response?.data?.detail || "Invalid or expired OTP. Please try again.");
} finally {
setLoading(false);
}
}
function addStudent() {
if (students.length < (currentInvite?.max_students || 1)) {
setStudents([...students, { first_name: "", last_name: "", date_of_birth: "", gender: "" }]);
}
}
function updateMemberDetail(field, value) {
setMemberDetails(prev => ({ ...prev, [field]: value }));
}
function removeStudent(index) {
if (students.length > 1) {
setStudents(students.filter((_, i) => i !== index));
}
}
function updateStudent(index, field, value) {
const updated = [...students];
updated[index][field] = value;
setStudents(updated);
}
async function handleSaveStudents(e) {
e.preventDefault();
// Validate member details - handle undefined/null safely
const memberFirstName = (memberDetails.first_name || "").trim();
const memberLastName = (memberDetails.last_name || "").trim();
if (!memberFirstName || !memberLastName) {
setMessage("Please fill in member first name and last name.");
return;
}
// Validate at least one student has first and last name
const validStudents = students.filter(s => s.first_name.trim() && s.last_name.trim());
if (validStudents.length === 0) {
setMessage("Please add at least one student with first name and last name.");
return;
}
setLoading(true);
setMessage("");
try {
const tokenToUse = currentInvite?.invite_token || token;
// Send both member details and students data - ensure we send trimmed values
await saveStudentsMutation.mutateAsync({
member_details: {
first_name: memberDetails.first_name?.trim() || "",
last_name: memberDetails.last_name?.trim() || "",
phone_number: memberDetails.phone_number?.trim() || "",
emergency_contact_person: memberDetails.emergency_contact_person?.trim() || "",
emergency_contact_number: memberDetails.emergency_contact_number?.trim() || "",
},
students: validStudents.map(s => ({
first_name: s.first_name?.trim() || "",
last_name: s.last_name?.trim() || "",
date_of_birth: s.date_of_birth || "",
gender: s.gender || "",
})),
});
setMessage("Student information saved! Click the payment link below to complete your membership.");
// Don't call onComplete here - let the user see and click the payment link
// onComplete will be called after payment is confirmed (via webhook or manual refresh)
} catch (err) {
setMessage(err?.response?.data?.detail || "Failed to save student information. Please try again.");
} finally {
setLoading(false);
}
}
// If embedded in dashboard, show simplified layout
if (isFromDashboard) {
// If we have the invite object directly, use it immediately
if (currentInvite) {
// Render the form directly with the invite data - continue below
} else if (needsFetch) {
// We need to fetch the invite
if (inviteLoading || isFetching) {
return (
<div className="text-center py-8">
<p className="text-slate-600">Loading invite details...</p>
</div>
);
}
if (inviteError) {
return (
<div className="text-center py-8">
<h1 className="text-2xl font-semibold mb-2">Error Loading Invite</h1>
<p className="text-slate-600 mb-4">
{inviteError?.message || inviteError?.response?.data?.detail || "Failed to load invitation."}
</p>
{onCancel && (
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg bg-slate-200 text-slate-700 hover:bg-slate-300"
>
Close
</button>
)}
</div>
);
}
if (!currentInvite) {
return (
<div className="text-center py-8">
<h1 className="text-2xl font-semibold mb-2">Invite Not Found</h1>
<p className="text-slate-600 mb-4">This invitation is invalid or has expired.</p>
{onCancel && (
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg bg-slate-200 text-slate-700 hover:bg-slate-300"
>
Close
</button>
)}
</div>
);
}
} else {
// No invite and no way to fetch it
return (
<div className="text-center py-8">
<h1 className="text-2xl font-semibold mb-2">No Invite Provided</h1>
<p className="text-slate-600 mb-4">Please select an invite to continue.</p>
{onCancel && (
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg bg-slate-200 text-slate-700 hover:bg-slate-300"
>
Close
</button>
)}
</div>
);
}
// If we get here, we should have currentInvite
if (!currentInvite) {
return (
<div className="text-center py-8">
<p className="text-slate-600">Preparing invite form...</p>
</div>
);
}
// Render the same content but without full page wrapper (inline version)
return (
<div className="w-full space-y-6">
{/* Invite Details - Compact for inline display */}
<div className="bg-stone-50 rounded-lg p-4 border border-stone-200">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-stone-500 text-xs mb-1">Plan</div>
<div className="font-semibold text-stone-900">{currentInvite.plan_name}</div>
</div>
<div>
<div className="text-stone-500 text-xs mb-1">Price</div>
<div className="font-semibold text-stone-900">${currentInvite.plan_price}/{currentInvite.billing_period || "period"}</div>
</div>
<div>
<div className="text-stone-500 text-xs mb-1">Max Students</div>
<div className="font-semibold text-stone-900">{currentInvite.max_students}</div>
</div>
{currentInvite.class_details && (
<div className="col-span-2 md:col-span-1">
<div className="text-stone-500 text-xs mb-1">Classes</div>
<div className="font-medium text-stone-900 text-xs">{currentInvite.class_details}</div>
</div>
)}
</div>
</div>
{/* Student Details Form */}
{step === 3 && (
<div>
<h3 className="text-base font-semibold mb-2 text-stone-900">Add Student Information</h3>
<p className="text-sm text-stone-500 mb-4">
Please add information for up to {currentInvite.max_students} student{currentInvite.max_students !== 1 ? "s" : ""}.
</p>
<form id="invite-acceptance-form" onSubmit={handleSaveStudents} className="space-y-6">
{/* Member Details Section (common for all students) */}
<div className="border border-blue-200 rounded-lg p-4 bg-blue-50">
<h4 className="text-sm font-semibold text-stone-900 mb-3">Member Details</h4>
<p className="text-xs text-stone-600 mb-4">
This information is shared for all students in this membership.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Member First Name *
</label>
<input
ref={firstInputRef}
id="member-first-name-input"
type="text"
required
autoFocus={isFromDashboard && step === 3}
value={memberDetails.first_name}
onChange={(e) => updateMemberDetail("first_name", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Member Last Name *
</label>
<input
type="text"
required
value={memberDetails.last_name}
onChange={(e) => updateMemberDetail("last_name", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Phone Number
</label>
<input
type="tel"
value={memberDetails.phone_number}
onChange={(e) => updateMemberDetail("phone_number", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Emergency Contact Person
</label>
<input
type="text"
value={memberDetails.emergency_contact_person}
onChange={(e) => updateMemberDetail("emergency_contact_person", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-slate-600 mb-1">
Emergency Contact Number
</label>
<input
type="tel"
value={memberDetails.emergency_contact_number}
onChange={(e) => updateMemberDetail("emergency_contact_number", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Student Details Section */}
<div>
<h4 className="text-sm font-semibold text-stone-900 mb-3">Student Details</h4>
{students.map((student, index) => (
<div key={index} className="border border-slate-200 rounded-lg p-4 space-y-3 mb-4">
<div className="flex items-center justify-between mb-2">
<h5 className="text-sm font-medium text-slate-700">
Student {index + 1}
</h5>
{students.length > 1 && (
<button
type="button"
onClick={() => removeStudent(index)}
className="text-red-600 hover:text-red-700"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
First Name *
</label>
<input
type="text"
required
value={student.first_name}
onChange={(e) => updateStudent(index, "first_name", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Last Name *
</label>
<input
type="text"
required
value={student.last_name}
onChange={(e) => updateStudent(index, "last_name", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Date of Birth
</label>
<input
type="date"
value={student.date_of_birth}
onChange={(e) => updateStudent(index, "date_of_birth", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Gender
</label>
<select
value={student.gender}
onChange={(e) => updateStudent(index, "gender", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
<option value="Prefer not to say">Prefer not to say</option>
</select>
</div>
</div>
</div>
))}
</div>
{students.length < currentInvite.max_students && (
<button
type="button"
onClick={addStudent}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg border border-slate-200 text-slate-700 text-sm font-medium py-2 hover:bg-slate-50"
>
<Plus className="w-4 h-4" />
Add Another Student
</button>
)}
<div className="flex gap-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
className="flex-1 inline-flex items-center justify-center rounded-lg border border-slate-200 text-slate-700 text-sm font-medium py-2.5 hover:bg-slate-50"
>
Cancel
</button>
)}
<button
type="submit"
disabled={loading || saveStudentsMutation.isSuccess}
className="flex-1 inline-flex items-center justify-center rounded-lg bg-blue-600 text-white text-sm font-medium py-2.5 hover:bg-blue-700 disabled:opacity-60"
>
{loading ? "Saving…" : saveStudentsMutation.isSuccess ? "Saved!" : "Save Student Information"}
</button>
</div>
</form>
{/* Payment Link */}
{saveStudentsMutation.isSuccess && (
<PaymentButton invite={currentInvite} token={currentInvite?.invite_token || token} />
)}
</div>
)}
{message && (
<p className={`text-xs whitespace-pre-line ${
message.includes("error") || message.includes("Error") || message.includes("Invalid")
? "text-red-600"
: "text-slate-600"
}`}>
{message}
</p>
)}
</div>
);
}
// Full page version (when accessed via direct link)
if (needsFetch && (inviteLoading || isFetching)) {
return (
<div className="min-h-screen bg-slate-50 text-slate-900 flex items-center justify-center px-4">
<div className="text-center">
<p className="text-slate-600">Loading invite details...</p>
</div>
</div>
);
}
if (needsFetch && (inviteError || (!inviteLoading && !currentInvite))) {
return (
<div className="min-h-screen bg-slate-50 text-slate-900 flex items-center justify-center px-4">
<div className="text-center">
<h1 className="text-2xl font-semibold mb-2">Invite Not Found</h1>
<p className="text-slate-600">
{inviteError?.message || "This invitation link is invalid or has expired."}
</p>
</div>
</div>
);
}
if (!currentInvite) {
return (
<div className="min-h-screen bg-slate-50 text-slate-900 flex items-center justify-center px-4">
<div className="text-center">
<p className="text-slate-600">Loading invite details...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 text-slate-900 flex items-center justify-center px-4 py-8">
<div className="max-w-2xl w-full">
<div className="mb-8 text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Membership Invitation
</h1>
<p className="mt-2 text-sm text-slate-500">
Complete your membership registration
</p>
</div>
<div className="bg-white shadow-xl rounded-2xl border border-slate-100 p-8 space-y-6">
{/* Invite Details */}
<div className="border-b border-slate-100 pb-6">
<h2 className="text-lg font-semibold mb-4">Membership Plan</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">Plan:</span>
<span className="font-medium">{invite.plan_name}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Price:</span>
<span className="font-medium">${invite.plan_price}/{invite.billing_period || "period"}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Max Students:</span>
<span className="font-medium">{invite.max_students}</span>
</div>
{invite.class_details && (
<div className="mt-3 pt-3 border-t border-slate-100">
<p className="text-slate-600 mb-1">Classes Included:</p>
<p className="text-slate-800">{invite.class_details}</p>
</div>
)}
</div>
</div>
{/* Step 1 & 2: Email/OTP */}
{(step === 1 || step === 2) && (
<div>
<h2 className="text-lg font-semibold mb-1">
{step === 1 ? "Verify Your Email" : "Enter OTP"}
</h2>
<p className="text-xs text-slate-500 mb-6">
{step === 1
? `Please enter your email address (${currentInvite.email}) to receive a verification code.`
: "Check your email for the verification code."}
</p>
{step === 1 && (
<form className="space-y-4" onSubmit={handleRequestOtp}>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Email
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={currentInvite.email}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
/>
</div>
<button
type="submit"
disabled={loading || (email && email.toLowerCase() !== currentInvite.email.toLowerCase())}
className="w-full inline-flex items-center justify-center rounded-lg bg-blue-600 text-white text-sm font-medium py-2.5 hover:bg-blue-700 disabled:opacity-60"
>
{loading ? "Sending…" : "Send OTP"}
</button>
</form>
)}
{step === 2 && (
<form className="space-y-4" onSubmit={handleVerifyOtp}>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Email
</label>
<input
type="email"
value={email}
disabled
className="w-full rounded-lg border border-slate-100 px-3 py-2 text-sm bg-slate-50 text-slate-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
One-time code
</label>
<input
type="text"
required
value={otp}
onChange={(e) => setOtp(e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white tracking-[0.35em]"
placeholder="••••••"
/>
</div>
{serverOtp && (
<p className="text-[11px] text-amber-600 bg-amber-50 border border-amber-100 px-3 py-2 rounded-lg">
Sandbox hint: your OTP is{" "}
<span className="font-mono font-semibold">{serverOtp}</span>
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full inline-flex items-center justify-center rounded-lg bg-blue-600 text-white text-sm font-medium py-2.5 hover:bg-blue-700 disabled:opacity-60"
>
{loading ? "Verifying…" : "Verify & Continue"}
</button>
</form>
)}
</div>
)}
{/* Step 3: Student Details */}
{step === 3 && (
<div>
<h2 className="text-lg font-semibold mb-1">Add Student Information</h2>
<p className="text-xs text-slate-500 mb-6">
Please add information for up to {currentInvite.max_students} student{currentInvite.max_students !== 1 ? "s" : ""}.
</p>
<form id="invite-acceptance-form" onSubmit={handleSaveStudents} className="space-y-6">
{/* Member Details Section (common for all students) */}
<div className="border border-blue-200 rounded-lg p-4 bg-blue-50">
<h4 className="text-sm font-semibold text-stone-900 mb-3">Member Details</h4>
<p className="text-xs text-stone-600 mb-4">
This information is shared for all students in this membership.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Member First Name *
</label>
<input
ref={firstInputRef}
id="member-first-name-input"
type="text"
required
autoFocus={isFromDashboard && step === 3}
value={memberDetails.first_name}
onChange={(e) => updateMemberDetail("first_name", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Member Last Name *
</label>
<input
type="text"
required
value={memberDetails.last_name}
onChange={(e) => updateMemberDetail("last_name", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Phone Number
</label>
<input
type="tel"
value={memberDetails.phone_number}
onChange={(e) => updateMemberDetail("phone_number", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Emergency Contact Person
</label>
<input
type="text"
value={memberDetails.emergency_contact_person}
onChange={(e) => updateMemberDetail("emergency_contact_person", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-slate-600 mb-1">
Emergency Contact Number
</label>
<input
type="tel"
value={memberDetails.emergency_contact_number}
onChange={(e) => updateMemberDetail("emergency_contact_number", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Student Details Section */}
<div>
<h4 className="text-sm font-semibold text-stone-900 mb-3">Student Details</h4>
{students.map((student, index) => (
<div key={index} className="border border-slate-200 rounded-lg p-4 space-y-3 mb-4">
<div className="flex items-center justify-between mb-2">
<h5 className="text-sm font-medium text-slate-700">
Student {index + 1}
</h5>
{students.length > 1 && (
<button
type="button"
onClick={() => removeStudent(index)}
className="text-red-600 hover:text-red-700"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
First Name *
</label>
<input
type="text"
required
value={student.first_name}
onChange={(e) => updateStudent(index, "first_name", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Last Name *
</label>
<input
type="text"
required
value={student.last_name}
onChange={(e) => updateStudent(index, "last_name", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Date of Birth
</label>
<input
type="date"
value={student.date_of_birth}
onChange={(e) => updateStudent(index, "date_of_birth", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Gender
</label>
<select
value={student.gender}
onChange={(e) => updateStudent(index, "gender", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select gender</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Other">Other</option>
<option value="Prefer not to say">Prefer not to say</option>
</select>
</div>
</div>
</div>
))}
</div>
{students.length < currentInvite.max_students && (
<button
type="button"
onClick={addStudent}
className="w-full inline-flex items-center justify-center gap-2 rounded-lg border border-slate-200 text-slate-700 text-sm font-medium py-2 hover:bg-slate-50"
>
<Plus className="w-4 h-4" />
Add Another Student
</button>
)}
<button
type="submit"
disabled={loading || saveStudentsMutation.isSuccess}
className="w-full inline-flex items-center justify-center rounded-lg bg-blue-600 text-white text-sm font-medium py-2.5 hover:bg-blue-700 disabled:opacity-60"
>
{loading ? "Saving…" : saveStudentsMutation.isSuccess ? "Saved!" : "Save Student Information"}
</button>
</form>
{/* Payment Link */}
{saveStudentsMutation.isSuccess && (
<PaymentButton invite={currentInvite} token={currentInvite?.invite_token || token} />
)}
</div>
)}
{message && (
<p className={`text-xs whitespace-pre-line ${
message.includes("error") || message.includes("Error") || message.includes("Invalid")
? "text-red-600"
: "text-slate-600"
}`}>
{message}
</p>
)}
</div>
</div>
</div>
);
}