| |
| 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"; |
|
|
| |
| 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(() => { |
| |
| 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 }) { |
| |
| const params = useParams(); |
| const token = params?.token; |
| const navigate = useNavigate(); |
| |
| |
| const isFromDashboard = !!invite; |
| const needsFetch = !invite && (inviteId || token); |
| |
| const [step, setStep] = useState(isFromDashboard ? 3 : 1); |
| const [email, setEmail] = useState(""); |
| const [otp, setOtp] = useState(""); |
| const [serverOtp, setServerOtp] = useState(""); |
| const [loading, setLoading] = useState(false); |
| const [message, setMessage] = useState(""); |
| |
| const [memberDetails, setMemberDetails] = useState({ |
| first_name: "", |
| last_name: "", |
| phone_number: "", |
| emergency_contact_person: "", |
| emergency_contact_number: "", |
| }); |
| |
| const [students, setStudents] = useState([{ first_name: "", last_name: "", date_of_birth: "", gender: "" }]); |
| |
| |
| const firstInputRef = useRef(null); |
|
|
| |
| 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) { |
| |
| 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) { |
| |
| 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, |
| retry: false, |
| refetchOnWindowFocus: false, |
| }); |
| |
| |
| const currentInvite = invite || fetchedInvite; |
| |
| |
| useEffect(() => { |
| if (isFromDashboard && currentInvite) { |
| const stored = JSON.parse(localStorage.getItem("karateStudent") || "{}"); |
| setEmail(stored.email || currentInvite.email); |
| setStep(3); |
| } |
| }, [isFromDashboard, currentInvite]); |
|
|
| |
| useEffect(() => { |
| if (step === 3 && isFromDashboard && currentInvite) { |
| console.log('🔵 Auto-focus: useEffect triggered', { step, isFromDashboard, hasCurrentInvite: !!currentInvite }); |
| |
| |
| const attemptFocus = (attempts = 0) => { |
| if (attempts > 30) { |
| console.log('⚠️ Auto-focus: Max attempts reached'); |
| return; |
| } |
| |
| |
| 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'); |
| |
| |
| setTimeout(() => { |
| requestAnimationFrame(() => { |
| try { |
| |
| const rect = firstInput.getBoundingClientRect(); |
| const isVisible = rect.width > 0 && rect.height > 0; |
| |
| if (isVisible && !firstInput.disabled) { |
| firstInput.focus({ preventScroll: false }); |
| |
| 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 }); |
| |
| setTimeout(() => { |
| firstInput.focus({ preventScroll: false }); |
| firstInput.select(); |
| }, 200); |
| } |
| } catch (err) { |
| console.log('⚠️ Auto-focus: Focus error', err); |
| } |
| }); |
| }, 600); |
| } else { |
| |
| setTimeout(() => attemptFocus(attempts + 1), 150); |
| } |
| }; |
| |
| |
| setTimeout(() => attemptFocus(), 400); |
| } |
| }, [step, isFromDashboard, currentInvite]); |
|
|
| |
| const requestOtpMutation = useMutation({ |
| mutationFn: async (email) => { |
| const res = await api.post("/auth/request-otp", { email }); |
| return res.data; |
| }, |
| }); |
|
|
| |
| const verifyOtpMutation = useMutation({ |
| mutationFn: async ({ email, otp }) => { |
| const res = await api.post("/auth/login", { email, otp }); |
| return res.data; |
| }, |
| }); |
|
|
| |
| const saveStudentsMutation = useMutation({ |
| mutationFn: async (payload) => { |
| const tokenToUse = currentInvite?.invite_token || token; |
| |
| const res = await api.post(`/invite/${tokenToUse}/students`, payload); |
| return res.data; |
| }, |
| }); |
|
|
| |
| useEffect(() => { |
| if (saveStudentsMutation.isSuccess) { |
| console.log('🔵 Payment scroll: Save successful, scrolling to payment button'); |
| |
| |
| 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`); |
| |
| setTimeout(() => attemptScroll(attempts + 1), 150); |
| } |
| }; |
| |
| |
| setTimeout(() => attemptScroll(), 300); |
| } |
| }, [saveStudentsMutation.isSuccess]); |
|
|
| |
| 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; |
| |
| |
| 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); |
| } 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(); |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| |
| 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."); |
| |
| |
| } catch (err) { |
| setMessage(err?.response?.data?.detail || "Failed to save student information. Please try again."); |
| } finally { |
| setLoading(false); |
| } |
| } |
|
|
| |
| if (isFromDashboard) { |
| |
| if (currentInvite) { |
| |
| } else if (needsFetch) { |
| |
| 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 { |
| |
| 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 (!currentInvite) { |
| return ( |
| <div className="text-center py-8"> |
| <p className="text-slate-600">Preparing invite form...</p> |
| </div> |
| ); |
| } |
| |
| |
| 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> |
| ); |
| } |
|
|
| |
| 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> |
| ); |
| } |
|
|
|
|