Files
eventspace/frontend/src/components/rsvp/rsvp-form.tsx
Felipe Cardoso 678c55a1e2 Integrate RSVP context and enhance form responsiveness
Added RSVP context to manage current guest and RSVP data, ensuring dynamic updates based on state changes. Adjusted styling to improve button responsiveness and removed redundant borders for a cleaner UI. These updates enhance functionality and provide a more seamless user experience.
2025-03-19 08:40:47 +01:00

515 lines
16 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { useGuests } from "@/context/guest-context";
import { useEventThemes } from "@/context/event-theme-context";
import { useEvents } from "@/context/event-context";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { RsvpStatus } from "@/client/types.gen";
import { useSearchParams, useParams } from "next/navigation";
import { Loader2, Plus, Minus } from "lucide-react";
import { motion } from "framer-motion";
import { useTheme } from "next-themes";
import { useRSVPs } from "@/context/rsvp-context";
interface RSVPProps {
eventId: string;
onRSVPSuccess?: () => void;
}
export const RSVP: React.FC<RSVPProps> = ({ eventId, onRSVPSuccess }) => {
const {
guests,
isLoadingGuests,
findGuestByInvitationCode,
submitGuestRsvp,
} = useGuests();
const { rsvps } = useRSVPs();
const searchParams = useSearchParams();
const { slug } = useParams<{ slug: string }>();
const { event, fetchEventBySlug, isLoadingEvent } = useEvents();
const { themes, isLoadingThemes } = useEventThemes();
const [status, setStatus] = useState<RsvpStatus>(RsvpStatus.ATTENDING);
const [number_of_guests, setNumberOfGuests] = useState<number>(1);
const [response_message, setResponseMessage] = useState("");
const [dietary_requirements, setDietaryRequirements] = useState("");
const [guestId, setGuestId] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<boolean>(false);
const [currentGuest, setCurrentGuest] = useState<any | null>(null);
const [currentRSVP, setCurrentRSVP] = useState<any | null>(null);
const { setTheme } = useTheme();
useEffect(() => {
setTheme("light");
}, [setTheme]);
// Load event data based on slug or event ID
useEffect(() => {
if (slug) {
fetchEventBySlug(slug);
}
}, [slug, fetchEventBySlug]);
useEffect(() => {
if (rsvps && currentGuest) {
setCurrentRSVP(rsvps.find((rsvp) => rsvp.guest_id === currentGuest?.id));
}
}, [rsvps, currentGuest]);
useEffect(() => {
if (currentRSVP) {
setStatus(currentRSVP.status);
setNumberOfGuests(currentRSVP.number_of_guests);
setResponseMessage(currentRSVP.response_message);
setDietaryRequirements(currentRSVP.dietary_requirements);
}
}, [currentRSVP]);
// Find the theme for this event
const eventTheme =
event && themes?.find((theme) => theme.id === event.theme_id);
// Enhanced color palette that works well with safari animals
const colors = eventTheme?.color_palette || {
primary: "#90B77D", // Soft jungle green
secondary: "#D2AB67", // Warm giraffe yellow
accent: "#B5A9EA", // Soft hippo purple
accent2: "#8FBDD3", // Elephant blue
accent3: "#E8B87D", // Lion tan
background: "#F9F5F0", // Cream paper texture
backgroundDark: "#F0E9D6", // Slightly darker cream for panels
text: "#5B4B49", // Warm dark brown
textLight: "#7D6D6B", // Lighter text variant
gold: "#D4AF37", // Gold accent for special elements
};
// Font selections
const fonts = eventTheme?.fonts || {
heading: "Georgia, serif",
body: "Arial, sans-serif",
};
// Animation variants
const formVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.3 },
},
};
// Extract invitation code from URL parameters
useEffect(() => {
const invitationCode = searchParams.get("code");
if (!invitationCode) {
setError(
"Codice invito mancante. Si prega di utilizzare il link fornito nell'invito.",
);
return;
}
// Find the guest with matching invitation code
if (guests) {
const matchingGuest = findGuestByInvitationCode(invitationCode);
setCurrentGuest(matchingGuest);
if (matchingGuest) {
console.log("matchingGuest ", matchingGuest);
setGuestId(matchingGuest.id);
setError(null);
} else {
setError("Codice invito non valido. Controlla il link dell'invito.");
}
}
}, [searchParams, guests, findGuestByInvitationCode]);
// Handler for incrementing the number of guests
const incrementGuests = () => {
setNumberOfGuests((prev) => prev + 1);
};
// Handler for decrementing the number of guests
const decrementGuests = () => {
setNumberOfGuests((prev) => Math.max(1, prev - 1));
};
// Handler for direct number input
const handleGuestsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === "" ? 1 : parseInt(e.target.value, 10);
if (!isNaN(value)) {
setNumberOfGuests(Math.max(1, value));
}
};
const submitRsvp = async (e: React.FormEvent) => {
e.preventDefault();
if (!guestId) {
setError(
"Impossibile identificare il tuo invito. Controlla il link o contatta l'organizzatore.",
);
return;
}
setIsProcessing(true);
setError(null);
try {
// Use the combined endpoint to update both RSVP and Guest status
await submitGuestRsvp(guestId, {
event_id: eventId,
guest_id: guestId,
status,
number_of_guests,
response_message,
dietary_requirements,
});
setSuccess(true);
if (onRSVPSuccess) {
setTimeout(() => {
onRSVPSuccess();
}, 2000);
}
} catch (err) {
console.error("Error submitting RSVP:", err);
setError(
"Impossibile inviare la tua risposta. Riprova o contatta l'organizzatore.",
);
} finally {
setIsProcessing(false);
}
};
// Show loading state while checking invitation code
if (isLoadingGuests || isLoadingEvent || isLoadingThemes) {
return (
<div
className="w-full rounded-lg p-6 flex justify-center items-center min-h-[300px]"
style={{
backgroundColor: colors.background,
borderColor: colors.backgroundDark,
color: colors.text,
}}
>
<Loader2
className="h-8 w-8 animate-spin"
style={{ color: colors.primary }}
/>
</div>
);
}
// Show error if no valid invitation code
if (error) {
return (
<div
className="w-full rounded-lg border shadow-sm"
style={{
backgroundColor: colors.background,
borderColor: colors.backgroundDark,
color: colors.text,
}}
>
<div className="p-6 flex flex-col gap-6">
<div>
<h2
className="text-lg font-medium"
style={{
color: "#EF4444",
fontFamily: fonts.heading,
}}
>
Errore Invito
</h2>
<p className="mt-2" style={{ color: colors.textLight }}>
{error}
</p>
</div>
<Button
onClick={() => (window.location.href = "/")}
className="w-full md:w-auto self-end"
style={{
backgroundColor: colors.primary,
color: "white",
}}
>
Torna alla Home
</Button>
</div>
</div>
);
}
// Show success message
if (success) {
return (
<motion.div
className="w-full rounded-lg shadow-sm p-8 text-center"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
style={{
backgroundColor: colors.background,
color: colors.text,
}}
>
<div className="flex flex-col items-center gap-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center mb-2"
style={{ backgroundColor: colors.primary + "20" }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
style={{ color: colors.primary }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2
className="text-xl font-bold"
style={{
fontFamily: fonts.heading,
color: colors.secondary,
}}
>
Grazie per la tua risposta!
</h2>
<p style={{ color: colors.textLight }}>
La tua presenza è stata registrata con successo.
</p>
</div>
</motion.div>
);
}
return (
<motion.div
className="w-full rounded-lg shadow-sm"
initial="hidden"
animate="visible"
variants={formVariants}
style={{
backgroundColor: colors.background,
color: colors.text,
}}
>
<div className="p-6 flex flex-col gap-6">
<div>
<h2
className="text-xl font-bold mb-1"
style={{
fontFamily: fonts.heading,
color: colors.secondary,
}}
>
Conferma la tua presenza
</h2>
<p style={{ color: colors.textLight }}>
Ti preghiamo di farci sapere se potrai partecipare
</p>
</div>
<form onSubmit={submitRsvp} className="space-y-5">
<motion.div variants={itemVariants}>
<Label
htmlFor="status"
className="mb-2 block font-medium"
style={{ color: colors.text }}
>
La tua Partecipazione
</Label>
<Select
defaultValue={status}
onValueChange={(val) => setStatus(val as any)}
>
<SelectTrigger
id="status"
className="w-full"
style={{
borderColor: colors.background,
backgroundColor: "white",
color: colors.text,
}}
>
<SelectValue>
{status === "attending" && "Parteciperò"}
{status === "not_attending" && "Non parteciperò"}
{status === "maybe" && "Forse"}
</SelectValue>
</SelectTrigger>
<SelectContent className="data-[radix-select-content]:rounded-md data-[radix-select-content]:bg-[--event-background] data-[radix-select-content]:border-[--event-backgroundDark]">
<SelectItem value="attending">Parteciperò</SelectItem>
<SelectItem value="not_attending">Non parteciperò</SelectItem>
<SelectItem value="maybe">Forse</SelectItem>
</SelectContent>
</Select>
</motion.div>
<motion.div variants={itemVariants}>
<Label
htmlFor="number_of_guests"
className="mb-2 block font-medium"
style={{ color: colors.text }}
>
Numero di Ospiti
</Label>
{/* Custom number input with buttons for better mobile experience */}
<div className="flex items-center">
<Button
type="button"
onClick={decrementGuests}
variant="outline"
size="icon"
className="h-10 w-10 rounded-r-none"
style={{
borderColor: colors.backgroundDark,
backgroundColor: "white",
color: colors.text,
}}
>
<Minus className="h-4 w-4" />
</Button>
<Input
id="number_of_guests"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={number_of_guests}
onChange={handleGuestsInputChange}
className="h-10 rounded-none text-center"
style={{
borderColor: colors.backgroundDark,
backgroundColor: "white",
color: colors.text,
}}
/>
<Button
type="button"
onClick={incrementGuests}
variant="outline"
size="icon"
className="h-10 w-10 rounded-l-none"
style={{
borderColor: colors.backgroundDark,
backgroundColor: "white",
color: colors.text,
}}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<p className="text-sm mt-1" style={{ color: colors.textLight }}>
Quante persone (incluso te) parteciperanno?
</p>
</motion.div>
<motion.div variants={itemVariants}>
<Label
htmlFor="response_message"
className="mb-2 block font-medium"
style={{ color: colors.text }}
>
Messaggio agli Organizzatori (opzionale)
</Label>
<Textarea
id="response_message"
value={response_message}
onChange={(e) => setResponseMessage(e.target.value)}
placeholder="Scrivi un breve messaggio per gli organizzatori"
rows={3}
style={{
borderColor: colors.backgroundDark,
backgroundColor: "white",
color: colors.text,
}}
/>
</motion.div>
<motion.div variants={itemVariants}>
<Label
htmlFor="dietary_requirements"
className="mb-2 block font-medium"
style={{ color: colors.text }}
>
Esigenze Alimentari
</Label>
<Textarea
id="dietary_requirements"
value={dietary_requirements}
onChange={(e) => setDietaryRequirements(e.target.value)}
placeholder="Eventuali restrizioni alimentari o allergie?"
rows={2}
style={{
borderColor: colors.backgroundDark,
backgroundColor: "white",
color: colors.text,
}}
/>
</motion.div>
<motion.div className="flex justify-end mt-6" variants={itemVariants}>
<Button
type="submit"
className="w-full md:w-auto text-white transition-colors duration-300"
disabled={isProcessing}
style={{ backgroundColor: colors.accent }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = colors.accent2)
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = colors.accent)
}
>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Invio in corso...
</>
) : (
"Invia Risposta"
)}
</Button>
</motion.div>
</form>
</div>
</motion.div>
);
};
export default RSVP;