All checks were successful
		
		
	
	Add copyright notice / copyright_notice (pull_request) Successful in 1m3s
				
			Check usage of free licenses / build-static-assets (pull_request) Successful in 2m2s
				
			Build Nginx-based docker image / build-static-assets (push) Successful in 4m31s
				
			Playwright Tests / test (pull_request) Successful in 11m42s
				
			
		
			
				
	
	
		
			219 lines
		
	
	
		
			6.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			219 lines
		
	
	
		
			6.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | |
| 
 | |
| 'use client';
 | |
| 
 | |
| import { AbstractApi } from "@/app/api/abstract-api";
 | |
| import { Guest } from "@/app/lib/guest";
 | |
| import { Invitation, InvitationSerializer } from "@/app/lib/invitation";
 | |
| import { getSlug } from "@/app/lib/utils";
 | |
| import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
 | |
| import { LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
 | |
| import { useEffect, useRef } from "react";
 | |
| import { useState } from "react";
 | |
| import { classNames } from "../components/button";
 | |
| import { Toast } from "primereact/toast";
 | |
| 
 | |
| function InvitationCard({ invitation, allGuests, onGuestAdded, onDestroy }: {
 | |
|   invitation: Invitation,
 | |
|   allGuests: Guest[],
 | |
|   onGuestAdded: (guest: Guest) => void,
 | |
|   onDestroy: (invitation: Invitation) => void
 | |
| }
 | |
| ) {
 | |
|   const [guests, setGuests] = useState<Guest[]>(invitation.guests);
 | |
| 
 | |
|   const ref = useRef<HTMLDivElement | null>(null);
 | |
| 
 | |
|   const api = new AbstractApi<Invitation>();
 | |
|   const serializer = new InvitationSerializer();
 | |
| 
 | |
|   const iconClassName = "w-5 h-5 text-white absolute top-2 opacity-0 group-hover:opacity-100 cursor-pointer";
 | |
| 
 | |
|   useEffect(() => {
 | |
|     if (ref.current) {
 | |
|       return dropTargetForElements({
 | |
|         element: ref.current,
 | |
|         onDrop: (data) => {
 | |
|           const guestId = data.source.element.dataset.guestId;
 | |
|           if (guestId) {
 | |
|             const guestToAdd = allGuests.find((guest) => guest.id === guestId);
 | |
|             if (guestToAdd) {
 | |
|               setGuests((prevGuests) => [...prevGuests, guestToAdd]);
 | |
|               invitation.guests.push(guestToAdd);
 | |
| 
 | |
|               api.update(serializer, invitation, () => {
 | |
|                 onGuestAdded(guestToAdd);
 | |
|               });
 | |
|             }
 | |
|           }
 | |
|         },
 | |
|       });
 | |
|     }
 | |
|   }, []);
 | |
| 
 | |
|   return (
 | |
|     <div
 | |
|       key={invitation.id}
 | |
|       ref={ref}
 | |
|       className="relative flex items-center justify-center w-full bg-green-800 border border-green-900 group"
 | |
|       style={{ aspectRatio: "1.618 / 1" }}
 | |
|     >
 | |
|       <LinkIcon
 | |
|         className={`${iconClassName} right-8`}
 | |
|         onClick={() => {
 | |
|             navigator.clipboard.writeText(`https://${window.location.host}/${getSlug()}/site/invitation/${invitation.id}`);
 | |
|         }}
 | |
|       />
 | |
|       <TrashIcon
 | |
|         className={`${iconClassName} right-2`}
 | |
|         onClick={() => {
 | |
|           if (window.confirm("Are you sure you want to delete this invitation?")) {
 | |
|             api.destroy(serializer, invitation, () => {
 | |
|               onDestroy(invitation);
 | |
|             });
 | |
|           }
 | |
|         }}
 | |
|       />
 | |
| 
 | |
| 
 | |
|       {guests.length === 0 ? (
 | |
|         <p className="text-center text-yellow-500 text-lg italic">
 | |
|           (empty invitation)
 | |
|         </p>
 | |
|       ) : (
 | |
|         <ul className="text-center text-yellow-500 text-lg">
 | |
|           {guests.map((guest) => (
 | |
|             <li key={guest.id}>{guest.name}</li>
 | |
|           ))}
 | |
|         </ul>
 | |
|       )}
 | |
|     </div>
 | |
|   )
 | |
| }
 | |
| 
 | |
| function GuestCard(guest: Guest) {
 | |
|   const ref = useRef<HTMLDivElement | null>(null);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     if (ref.current) {
 | |
|       return draggable({
 | |
|         element: ref.current,
 | |
|       });
 | |
|     }
 | |
|   }, [guest.id]);
 | |
| 
 | |
|   return (
 | |
|     <div
 | |
|       key={guest.id}
 | |
|       ref={ref}
 | |
|       className="mb-4 p-4 border border-gray-300 rounded-lg shadow-sm bg-white cursor-move"
 | |
|       draggable="true"
 | |
|       data-guest-id={guest.id}>
 | |
|       <h3 className="text-md font-medium">{guest.name}</h3>
 | |
|     </div>
 | |
|   )
 | |
| }
 | |
| 
 | |
| export default function InvitationsBoard({ guests, invitations: originalInvitations }: {
 | |
|   guests: Array<Guest>,
 | |
|   invitations: Array<Invitation>
 | |
| }) {
 | |
|   const toast = useRef<Toast>(null);
 | |
|   const api = new AbstractApi<Invitation>();
 | |
|   const serializer = new InvitationSerializer();
 | |
| 
 | |
|   const [invitations, setInvitations] = useState<Invitation[]>(originalInvitations);
 | |
|   const [unassignedGuests, setUnassignedGuests] = useState<Guest[]>(
 | |
|     guests.filter(
 | |
|       (guest) =>
 | |
|         guest.status !== 'considered' &&
 | |
|         !invitations.some((invitation) =>
 | |
|           invitation.guests.some((invitedGuest) => invitedGuest.id === guest.id)
 | |
|         )
 | |
|     )
 | |
|   );
 | |
| 
 | |
| 
 | |
|   // Sort invitations to display those without guests at the top
 | |
|   const sortedInvitations = [...invitations].sort((a, b) => {
 | |
|     if (a.guests.length === 0 && b.guests.length > 0) return -1;
 | |
|     if (a.guests.length > 0 && b.guests.length === 0) return 1;
 | |
|     return 0;
 | |
|   });
 | |
| 
 | |
|   function handleCreateInvitation() {
 | |
|     api.create(serializer, new Invitation(), (createdInvitation) => {
 | |
|       setInvitations((prevInvitations) => [createdInvitation, ...prevInvitations]);
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   function handleDownloadQrCodes() {
 | |
|     api.post(serializer, 'email', () => {
 | |
|       toast.current?.show({
 | |
|         severity: 'success',
 | |
|         summary: 'Email scheduled',
 | |
|         detail: 'A document with the QR codes will be sent to the organizer\'s email.'
 | |
|     });
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   return (
 | |
|     <div className="flex h-screen">
 | |
|       {/* Left Column: Guests */}
 | |
|       <Toast ref={toast} />
 | |
|       <div className="w-1/4 h-full overflow-auto border-r border-gray-300 p-4">
 | |
|         <h2 className="text-lg font-semibold mb-4">{unassignedGuests.length} guests without invitation</h2>
 | |
|         <div>
 | |
|           {unassignedGuests.map((guest) => (
 | |
|             <GuestCard key={guest.id} {...guest} />
 | |
|           ))}
 | |
|         </div>
 | |
|       </div>
 | |
| 
 | |
|       {/* Right Column: Invitations */}
 | |
|       <div className="w-3/4 h-full overflow-auto p-4">
 | |
|         <h2 className="text-lg font-semibold mb-4">
 | |
|           {invitations.length} invitations
 | |
|         </h2>
 | |
| 
 | |
|         <button
 | |
|           onClick={handleCreateInvitation}
 | |
|           className={classNames('primary')}
 | |
|         >
 | |
|           Create New Invitation
 | |
|         </button>
 | |
| 
 | |
|         <button
 | |
|           onClick={handleDownloadQrCodes}
 | |
|           className={classNames('primary')}
 | |
|         >
 | |
|           Send QR codes via email
 | |
|         </button>
 | |
| 
 | |
| 
 | |
|         <div className="grid grid-cols-4 gap-6">
 | |
| 
 | |
|           {sortedInvitations.map((invitation) => (
 | |
|             <InvitationCard
 | |
|               key={invitation.id}
 | |
|               invitation={invitation}
 | |
|               allGuests={guests}
 | |
|               onGuestAdded={(guestAdded: Guest) => {
 | |
|                 setUnassignedGuests((prevUnassignedGuests) => prevUnassignedGuests.filter(g => g.id !== guestAdded.id));
 | |
|               }}
 | |
|               onDestroy={(invitationToDestroy: Invitation) => {
 | |
|                 setInvitations((prevInvitations) => prevInvitations.filter(i => i.id !== invitationToDestroy.id));
 | |
|                 setUnassignedGuests((prevUnassignedGuests) => [
 | |
|                   ...prevUnassignedGuests,
 | |
|                   ...invitationToDestroy.guests
 | |
|                 ]);
 | |
|               }}
 | |
|             />
 | |
|           ))}
 | |
|         </div>
 | |
| 
 | |
|       </div>
 | |
|     </div>
 | |
|   );
 | |
| }
 |