Compare commits
	
		
			1 Commits
		
	
	
		
			main
			...
			groups-tre
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| c9dab33e85 | 
							
								
								
									
										31
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										31
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							@ -1,6 +1,8 @@
 | 
				
			|||||||
name: Build Nginx-based docker image
 | 
					name: Build Nginx-based docker image
 | 
				
			||||||
on:
 | 
					on:
 | 
				
			||||||
  push:
 | 
					  push:
 | 
				
			||||||
 | 
					    branches:
 | 
				
			||||||
 | 
					      - main
 | 
				
			||||||
concurrency:
 | 
					concurrency:
 | 
				
			||||||
  group: ${{ github.ref }}
 | 
					  group: ${{ github.ref }}
 | 
				
			||||||
  cancel-in-progress: true
 | 
					  cancel-in-progress: true
 | 
				
			||||||
@ -23,31 +25,12 @@ jobs:
 | 
				
			|||||||
          username: ${{ secrets.PRIVATE_REGISTRY_USERNAME }}
 | 
					          username: ${{ secrets.PRIVATE_REGISTRY_USERNAME }}
 | 
				
			||||||
          password: ${{ secrets.PRIVATE_REGISTRY_TOKEN }}
 | 
					          password: ${{ secrets.PRIVATE_REGISTRY_TOKEN }}
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
      - name: Build and push intermediate stages (deps)
 | 
					      - name: Build and push
 | 
				
			||||||
        uses: docker/build-push-action@v6
 | 
					        uses: docker/build-push-action@v6
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          context: .
 | 
					          context: .
 | 
				
			||||||
          target: deps
 | 
					          push: ${{ github.event_name != 'pull_request' }}
 | 
				
			||||||
          push: ${{ github.ref == 'refs/heads/main' }}
 | 
					          tags: |
 | 
				
			||||||
          tags: ${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:deps
 | 
					            ${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:latest
 | 
				
			||||||
          cache-from: type=registry,ref=${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:deps
 | 
					          cache-from: type=registry,ref=user/app:latest
 | 
				
			||||||
          cache-to: type=inline
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      - name: Build and push intermediate stages (builder)
 | 
					 | 
				
			||||||
        uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          context: .
 | 
					 | 
				
			||||||
          target: builder
 | 
					 | 
				
			||||||
          push: ${{ github.ref == 'refs/heads/main' }}
 | 
					 | 
				
			||||||
          tags: ${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:builder
 | 
					 | 
				
			||||||
          cache-from: type=registry,ref=${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:builder
 | 
					 | 
				
			||||||
          cache-to: type=inline
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
      - name: Build and push (final)
 | 
					 | 
				
			||||||
        uses: docker/build-push-action@v6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          context: .
 | 
					 | 
				
			||||||
          push: ${{ github.ref == 'refs/heads/main' }}
 | 
					 | 
				
			||||||
          tags: ${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:latest
 | 
					 | 
				
			||||||
          cache-from: type=registry,ref=${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:latest
 | 
					 | 
				
			||||||
          cache-to: type=inline
 | 
					          cache-to: type=inline
 | 
				
			||||||
							
								
								
									
										2
									
								
								.github/workflows/copyright_notice.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/copyright_notice.yml
									
									
									
									
										vendored
									
									
								
							@ -16,7 +16,7 @@ jobs:
 | 
				
			|||||||
          ref: ${{ github.head_ref }}
 | 
					          ref: ${{ github.head_ref }}
 | 
				
			||||||
      - uses: VinnyBabuManjaly/copyright-action@v1.0.0
 | 
					      - uses: VinnyBabuManjaly/copyright-action@v1.0.0
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          CopyrightString: '/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/\n\n'
 | 
					          CopyrightString: '/* Copyright (C) 2024 Manuel Bustillo*/\n\n'
 | 
				
			||||||
          FileType: '.tsx, .jsx, .ts'
 | 
					          FileType: '.tsx, .jsx, .ts'
 | 
				
			||||||
          Path: 'app/, config/, db/'
 | 
					          Path: 'app/, config/, db/'
 | 
				
			||||||
          IgnorePath: 'testfolder1/a/, testfolder3'
 | 
					          IgnorePath: 'testfolder1/a/, testfolder3'
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										12
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/workflows/playwright.yml
									
									
									
									
										vendored
									
									
								
							@ -9,6 +9,7 @@ concurrency:
 | 
				
			|||||||
  cancel-in-progress: true
 | 
					  cancel-in-progress: true
 | 
				
			||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
  test:
 | 
					  test:
 | 
				
			||||||
 | 
					    if: false
 | 
				
			||||||
    timeout-minutes: 60
 | 
					    timeout-minutes: 60
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    container:
 | 
					    container:
 | 
				
			||||||
@ -17,19 +18,14 @@ jobs:
 | 
				
			|||||||
    - uses: actions/checkout@v4
 | 
					    - uses: actions/checkout@v4
 | 
				
			||||||
    - uses: actions/setup-node@v4
 | 
					    - uses: actions/setup-node@v4
 | 
				
			||||||
      with:
 | 
					      with:
 | 
				
			||||||
        node-version-file: .nvmrc
 | 
					        node-version: lts/*
 | 
				
			||||||
    - name: Install dependencies
 | 
					    - name: Install dependencies
 | 
				
			||||||
      run: npm install -g pnpm && pnpm install
 | 
					      run: npm install -g pnpm && pnpm install
 | 
				
			||||||
    - name: Build the service that will be tested
 | 
					    - name: Build the service that will be tested
 | 
				
			||||||
      run: |
 | 
					      run: npm run build
 | 
				
			||||||
        pnpm run build
 | 
					 | 
				
			||||||
        cp -r public .next/standalone/
 | 
					 | 
				
			||||||
        cp -r .next/static .next/standalone/.next/
 | 
					 | 
				
			||||||
    - name: Install Playwright Browsers
 | 
					    - name: Install Playwright Browsers
 | 
				
			||||||
      run: pnpm exec playwright install --with-deps
 | 
					      run: pnpm exec playwright install --with-deps
 | 
				
			||||||
    - name: Run the service that will be tested
 | 
					    - name: Run the service that will be tested
 | 
				
			||||||
      run: HOSTNAME=127.0.0.1 node .next/standalone/server.js &
 | 
					      run: npm run start &
 | 
				
			||||||
    - name: Wait for the service to be ready
 | 
					 | 
				
			||||||
      run: npx wait-on http://127.0.0.1:3000/default/ --timeout 30000
 | 
					 | 
				
			||||||
    - name: Run Playwright tests
 | 
					    - name: Run Playwright tests
 | 
				
			||||||
      run: pnpm exec playwright test
 | 
					      run: pnpm exec playwright test
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
# Based on https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
 | 
					# Based on https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
FROM node:24-alpine AS base
 | 
					FROM node:23-alpine AS base
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Install dependencies only when needed
 | 
					# Install dependencies only when needed
 | 
				
			||||||
FROM base AS deps
 | 
					FROM base AS deps
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
# Based on https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
 | 
					# Based on https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
FROM node:24-alpine AS base
 | 
					FROM node:23-alpine AS base
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Install dependencies only when needed
 | 
					# Install dependencies only when needed
 | 
				
			||||||
FROM base AS deps
 | 
					FROM base AS deps
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client'
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,9 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AbstractApi,  } from '@/app/api/abstract-api';
 | 
					import { AbstractApi,  } from '@/app/api/abstract-api';
 | 
				
			||||||
import { Group, GroupSerializer } from '@/app/lib/group';
 | 
					 | 
				
			||||||
import { Guest, GuestSerializer } from '@/app/lib/guest';
 | 
					import { Guest, GuestSerializer } from '@/app/lib/guest';
 | 
				
			||||||
import { getCsrfToken, getSlug } from '@/app/lib/utils';
 | 
					 | 
				
			||||||
import AffinitiesFormDialog from '@/app/ui/components/affinities-form-dialog';
 | 
					 | 
				
			||||||
import { classNames } from '@/app/ui/components/button';
 | 
					import { classNames } from '@/app/ui/components/button';
 | 
				
			||||||
import GroupFormDialog from '@/app/ui/components/group-form-dialog';
 | 
					import GroupFormDialog from '@/app/ui/components/group-form-dialog';
 | 
				
			||||||
import GuestFormDialog from '@/app/ui/components/guest-form-dialog';
 | 
					import GuestFormDialog from '@/app/ui/components/guest-form-dialog';
 | 
				
			||||||
@ -14,97 +11,35 @@ import GroupsTable from '@/app/ui/groups/table';
 | 
				
			|||||||
import SkeletonTable from '@/app/ui/guests/skeleton-row';
 | 
					import SkeletonTable from '@/app/ui/guests/skeleton-row';
 | 
				
			||||||
import GuestsTable from '@/app/ui/guests/table';
 | 
					import GuestsTable from '@/app/ui/guests/table';
 | 
				
			||||||
import { TabPanel, TabView } from 'primereact/tabview';
 | 
					import { TabPanel, TabView } from 'primereact/tabview';
 | 
				
			||||||
import { Toast } from 'primereact/toast';
 | 
					import { Suspense, useState } from 'react';
 | 
				
			||||||
import { Suspense, useEffect, useRef, useState } from 'react';
 | 
					import { Group, GroupSerializer } from '@/app/lib/group';
 | 
				
			||||||
import InvitationsBoard from '@/app/ui/invitations/board';
 | 
					 | 
				
			||||||
import { Invitation, InvitationSerializer } from '@/app/lib/invitation';
 | 
					 | 
				
			||||||
import { Entity } from '@/app/lib/definitions';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Page() {
 | 
					export default function Page() {
 | 
				
			||||||
  const [slug, setSlug] = useState<string>("default");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    setSlug(getSlug());
 | 
					 | 
				
			||||||
    refreshGroups();
 | 
					 | 
				
			||||||
    refreshGuests();
 | 
					 | 
				
			||||||
    refreshInvitations();
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const toast = useRef<Toast>(null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  function refreshGuests() {
 | 
					  function refreshGuests() {
 | 
				
			||||||
    new AbstractApi<Guest>().getAll(new GuestSerializer(), (objects: Guest[]) => {
 | 
					    new AbstractApi<Guest>().getAll(new GuestSerializer(), (objects: Guest[]) => {
 | 
				
			||||||
      setGuests(objects);
 | 
					      setGuests(objects);
 | 
				
			||||||
 | 
					      setGuestsLoaded(true);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function refreshGroups() {
 | 
					  function refreshGroups() {
 | 
				
			||||||
    new AbstractApi<Group>().getAll(new GroupSerializer(), (objects: Group[]) => {
 | 
					    new AbstractApi<Group>().getAll(new GroupSerializer(), (objects: Group[]) => {
 | 
				
			||||||
      setGroups(objects);
 | 
					      setGroups(objects);
 | 
				
			||||||
 | 
					      setGroupsLoaded(true);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function refreshInvitations() {
 | 
					  const [groupsLoaded, setGroupsLoaded] = useState(false);
 | 
				
			||||||
    new AbstractApi<Invitation>().getAll(new InvitationSerializer(), (objects: Invitation[]) => {
 | 
					 | 
				
			||||||
      setInvitations(objects);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  function resetAffinities() {
 | 
					 | 
				
			||||||
    fetch(`/api/${slug}/groups/affinities/reset`, {
 | 
					 | 
				
			||||||
      method: 'POST',
 | 
					 | 
				
			||||||
      headers: {
 | 
					 | 
				
			||||||
        'Accept': 'application/json',
 | 
					 | 
				
			||||||
        'X-CSRF-TOKEN': getCsrfToken(),
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
      .then(response => {
 | 
					 | 
				
			||||||
        if (response.ok) {
 | 
					 | 
				
			||||||
          showAffinitiesResetSuccess();
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          console.error('Failed to reset affinities');
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      .catch(error => {
 | 
					 | 
				
			||||||
        console.error('Error resetting affinities:', error);
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  function showAffinitiesResetSuccess() {
 | 
					 | 
				
			||||||
    toast.current?.show({
 | 
					 | 
				
			||||||
      severity: 'success',
 | 
					 | 
				
			||||||
      summary: 'Affinities reset',
 | 
					 | 
				
			||||||
      detail: 'All affinities have been reset to default values.'
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [groups, setGroups] = useState<Array<Group>>([]);
 | 
					  const [groups, setGroups] = useState<Array<Group>>([]);
 | 
				
			||||||
  const [groupBeingEdited, setGroupBeingEdited] = useState<Group | undefined>(undefined);
 | 
					  const [groupBeingEdited, setGroupBeingEdited] = useState<Group | undefined>(undefined);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [groupAffinitiesBeingEditted, setGroupAffinitiesBeingEditted] = useState<Group | undefined>(undefined);
 | 
					  const [guestsLoaded, setGuestsLoaded] = useState(false);
 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [guests, setGuests] = useState<Array<Guest>>([]);
 | 
					  const [guests, setGuests] = useState<Array<Guest>>([]);
 | 
				
			||||||
  const [guestBeingEdited, setGuestBeingEdited] = useState<Guest | undefined>(undefined);
 | 
					  const [guestBeingEdited, setGuestBeingEdited] = useState<Guest | undefined>(undefined);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [invitations, setInvitations] = useState<Array<Invitation>>([]);
 | 
					  !groupsLoaded && refreshGroups();
 | 
				
			||||||
 | 
					  !guestsLoaded && refreshGuests();
 | 
				
			||||||
  function updateList<T extends Entity>(originalList: T[], element: T): T[] {
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      const index = originalList.findIndex(g => g.id === element?.id);
 | 
					 | 
				
			||||||
      if (index !== -1) {
 | 
					 | 
				
			||||||
        // Replace existing element
 | 
					 | 
				
			||||||
        return [
 | 
					 | 
				
			||||||
          element!,
 | 
					 | 
				
			||||||
          ...originalList.slice(0, index),
 | 
					 | 
				
			||||||
          ...originalList.slice(index + 1)
 | 
					 | 
				
			||||||
        ];
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        // Add new element at the start
 | 
					 | 
				
			||||||
        return [element!, ...originalList];
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="w-full">
 | 
					    <div className="w-full">
 | 
				
			||||||
@ -115,10 +50,7 @@ export default function Page() {
 | 
				
			|||||||
            <GuestFormDialog
 | 
					            <GuestFormDialog
 | 
				
			||||||
              key={guestBeingEdited?.id}
 | 
					              key={guestBeingEdited?.id}
 | 
				
			||||||
              groups={groups}
 | 
					              groups={groups}
 | 
				
			||||||
              onCreate={(newGuest) => {
 | 
					              onCreate={() => { refreshGuests(); setGuestBeingEdited(undefined) }}
 | 
				
			||||||
                setGuests(guests => updateList(guests, newGuest));
 | 
					 | 
				
			||||||
                setGuestBeingEdited(undefined);
 | 
					 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
              guest={guestBeingEdited}
 | 
					              guest={guestBeingEdited}
 | 
				
			||||||
              visible={guestBeingEdited !== undefined}
 | 
					              visible={guestBeingEdited !== undefined}
 | 
				
			||||||
              onHide={() => { setGuestBeingEdited(undefined) }}
 | 
					              onHide={() => { setGuestBeingEdited(undefined) }}
 | 
				
			||||||
@ -135,44 +67,25 @@ export default function Page() {
 | 
				
			|||||||
        <TabPanel header="Groups" leftIcon="pi pi-sitemap mx-2">
 | 
					        <TabPanel header="Groups" leftIcon="pi pi-sitemap mx-2">
 | 
				
			||||||
          <div className="flex flex-col w-full items-center justify-between">
 | 
					          <div className="flex flex-col w-full items-center justify-between">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div>
 | 
					 | 
				
			||||||
              <Toast ref={toast} />
 | 
					 | 
				
			||||||
            <button onClick={() => setGroupBeingEdited({})} className={classNames('primary')}>Add new</button>
 | 
					            <button onClick={() => setGroupBeingEdited({})} className={classNames('primary')}>Add new</button>
 | 
				
			||||||
              <button onClick={resetAffinities} className={classNames('yellow')}>Reset affinities</button>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            <GroupFormDialog
 | 
					            <GroupFormDialog
 | 
				
			||||||
              key={groupBeingEdited?.id}
 | 
					              key={groupBeingEdited?.id}
 | 
				
			||||||
              groups={groups}
 | 
					              groups={groups}
 | 
				
			||||||
              onCreate={(newGroup) => {
 | 
					              onCreate={() => { refreshGroups(); setGroupBeingEdited(undefined) }}
 | 
				
			||||||
                setGroups(groups => updateList(groups, newGroup));
 | 
					 | 
				
			||||||
                setGroupBeingEdited(undefined)
 | 
					 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
              group={groupBeingEdited}
 | 
					              group={groupBeingEdited}
 | 
				
			||||||
              visible={groupBeingEdited !== undefined}
 | 
					              visible={groupBeingEdited !== undefined}
 | 
				
			||||||
              onHide={() => { setGroupBeingEdited(undefined) }}
 | 
					              onHide={() => { setGroupBeingEdited(undefined) }}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <AffinitiesFormDialog
 | 
					 | 
				
			||||||
              groups={groups}
 | 
					 | 
				
			||||||
              group={groupAffinitiesBeingEditted}
 | 
					 | 
				
			||||||
              visible={groupAffinitiesBeingEditted !== undefined}
 | 
					 | 
				
			||||||
              onHide={() => { setGroupAffinitiesBeingEditted(undefined) }}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            <Suspense fallback={<SkeletonTable />}>
 | 
					            <Suspense fallback={<SkeletonTable />}>
 | 
				
			||||||
              <GroupsTable 
 | 
					              <GroupsTable 
 | 
				
			||||||
                groups={groups}
 | 
					                groups={groups}
 | 
				
			||||||
                onUpdate={refreshGroups}
 | 
					                onUpdate={refreshGroups}
 | 
				
			||||||
                onEdit={(group) => setGroupBeingEdited(group)}
 | 
					                onEdit={(group) => setGroupBeingEdited(group)}
 | 
				
			||||||
                onEditAffinities={(group) => setGroupAffinitiesBeingEditted(group)}
 | 
					 | 
				
			||||||
              />
 | 
					              />
 | 
				
			||||||
            </Suspense>
 | 
					            </Suspense>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </ TabPanel>
 | 
					        </ TabPanel>
 | 
				
			||||||
        <TabPanel header="Invitations" leftIcon="pi pi-envelope mx-2">
 | 
					 | 
				
			||||||
          <InvitationsBoard guests={guests} invitations={invitations} />
 | 
					 | 
				
			||||||
        </TabPanel>
 | 
					 | 
				
			||||||
      </ TabView>
 | 
					      </ TabView>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import SideNav from '@/app/ui/dashboard/sidenav';
 | 
					import SideNav from '@/app/ui/dashboard/sidenav';
 | 
				
			||||||
 
 | 
					 
 | 
				
			||||||
 | 
				
			|||||||
@ -1,26 +1,9 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { GlobalSummary as Summary } from '@/app/lib/definitions';
 | 
					 | 
				
			||||||
import { getSlug } from '@/app/lib/utils';
 | 
					 | 
				
			||||||
import GlobalSummary from '@/app/ui/dashboard/global-summary';
 | 
					import GlobalSummary from '@/app/ui/dashboard/global-summary';
 | 
				
			||||||
import { useEffect, useState } from 'react';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Page() {
 | 
					export default function Page() {
 | 
				
			||||||
  const [globalSummary, setGlobalSummary] = useState<Summary | undefined>(undefined);
 | 
					  return(
 | 
				
			||||||
 | 
					    <GlobalSummary />
 | 
				
			||||||
  function refreshSummary() {
 | 
					 | 
				
			||||||
    fetch(`/api/${getSlug()}/summary`)
 | 
					 | 
				
			||||||
      .then((response) => response.json())
 | 
					 | 
				
			||||||
      .then((data) => {
 | 
					 | 
				
			||||||
        setGlobalSummary(data);
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(refreshSummary, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    globalSummary && <GlobalSummary summary={globalSummary} />
 | 
					 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -1,42 +1,16 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AbstractApi } from '@/app/api/abstract-api';
 | 
					 | 
				
			||||||
import { TableSimulation, TableSimulationSerializer } from '@/app/lib/tableSimulation';
 | 
					 | 
				
			||||||
import Arrangement from '@/app/ui/arrangements/arrangement';
 | 
					import Arrangement from '@/app/ui/arrangements/arrangement';
 | 
				
			||||||
 | 
					import React, { useState } from 'react';
 | 
				
			||||||
import ArrangementsTable from '@/app/ui/arrangements/arrangements-table';
 | 
					import ArrangementsTable from '@/app/ui/arrangements/arrangements-table';
 | 
				
			||||||
import { classNames } from '@/app/ui/components/button';
 | 
					 | 
				
			||||||
import { Toast } from 'primereact/toast';
 | 
					 | 
				
			||||||
import React, { useEffect, useRef, useState } from 'react';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Page() {
 | 
					export default function Page() {
 | 
				
			||||||
  const toast = useRef<Toast>(null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const show = () => {
 | 
					 | 
				
			||||||
    toast.current?.show({
 | 
					 | 
				
			||||||
      severity: 'success',
 | 
					 | 
				
			||||||
      summary: 'Simulation created',
 | 
					 | 
				
			||||||
      detail: 'Table distributions will be calculated shortly, please come back in some minutes'
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [currentArrangement, setCurrentArrangement] = useState<string | null>(null);
 | 
					  const [currentArrangement, setCurrentArrangement] = useState<string | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function createSimulation() {
 | 
					 | 
				
			||||||
    const api = new AbstractApi<TableSimulation>();
 | 
					 | 
				
			||||||
    const serializer = new TableSimulationSerializer();
 | 
					 | 
				
			||||||
    api.create(serializer, new TableSimulation(), show);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="flex flex-col w-full items-center justify-between">
 | 
					 | 
				
			||||||
        <Toast ref={toast} />
 | 
					 | 
				
			||||||
        <button onClick={createSimulation} className={classNames('primary')}>Add new</button>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <ArrangementsTable onArrangementSelected={setCurrentArrangement} />
 | 
					      <ArrangementsTable onArrangementSelected={setCurrentArrangement} />
 | 
				
			||||||
      {currentArrangement && <Arrangement key={currentArrangement} id={currentArrangement} />}
 | 
					      {currentArrangement && <Arrangement key={currentArrangement} id={currentArrangement} />}
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,65 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
'use client'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { AbstractApi } from '@/app/api/abstract-api';
 | 
					 | 
				
			||||||
import { getSlug } from '@/app/lib/utils';
 | 
					 | 
				
			||||||
import { Website, WebsiteSerializer } from '@/app/lib/website';
 | 
					 | 
				
			||||||
import { useEffect, useState } from 'react';
 | 
					 | 
				
			||||||
import Tiptap from '../../../components/Tiptap';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function Page() {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [website, setWebsite] = useState<Website>()
 | 
					 | 
				
			||||||
  const api = new AbstractApi<Website>();
 | 
					 | 
				
			||||||
  const serializer = new WebsiteSerializer();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [slug, setSlug] = useState<string>("default");
 | 
					 | 
				
			||||||
  useEffect(() => { setSlug(getSlug()) }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [iframeKey, setIframeKey] = useState<number>(Math.random());
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    api.get(serializer, undefined, (loadedWebsite) => {
 | 
					 | 
				
			||||||
      setWebsite(loadedWebsite);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const updateWebsite = (newContent: string) => {
 | 
					 | 
				
			||||||
    // Debounce API update: send after 500ms of no further changes
 | 
					 | 
				
			||||||
    if (timeoutId) {
 | 
					 | 
				
			||||||
      clearTimeout(timeoutId);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    setTimeoutId(
 | 
					 | 
				
			||||||
      setTimeout(() => {
 | 
					 | 
				
			||||||
        api.update(serializer, new Website('', newContent), () => {
 | 
					 | 
				
			||||||
          setIframeKey(Math.random()); // Change key to force iframe reload
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      }, 500)
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className="flex">
 | 
					 | 
				
			||||||
      <div className="w-1/2 border rounded-lg p-4">
 | 
					 | 
				
			||||||
        <Tiptap
 | 
					 | 
				
			||||||
          key={website?.content ?? 'empty'}
 | 
					 | 
				
			||||||
          content={website?.content || ''}
 | 
					 | 
				
			||||||
          onUpdate={updateWebsite}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
      <div className="w-1/2 border rounded-lg p-4 ml-4">
 | 
					 | 
				
			||||||
        <iframe
 | 
					 | 
				
			||||||
          key={iframeKey}
 | 
					 | 
				
			||||||
          src={`/${slug}/site`}
 | 
					 | 
				
			||||||
          title="Website Preview"
 | 
					 | 
				
			||||||
          className="w-full h-[80vh] rounded"
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -16,11 +16,11 @@ export default async function Page() {
 | 
				
			|||||||
    if (getCsrfToken() == 'unknown') {
 | 
					    if (getCsrfToken() == 'unknown') {
 | 
				
			||||||
      retrieveCSRFToken();
 | 
					      retrieveCSRFToken();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (typeof window !== 'undefined') {
 | 
					  if (typeof window !== 'undefined') {
 | 
				
			||||||
      localStorage.setItem('slug', params.slug);
 | 
					    localStorage.setItem('slug', await params.slug);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <main className="flex min-h-screen flex-col p-6">
 | 
					    <main className="flex min-h-screen flex-col p-6">
 | 
				
			||||||
 | 
				
			|||||||
@ -1,119 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
'use client'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { AbstractApi } from "@/app/api/abstract-api";
 | 
					 | 
				
			||||||
import { Invitation, InvitationSerializer } from "@/app/lib/invitation";
 | 
					 | 
				
			||||||
import { useParams } from "next/navigation";
 | 
					 | 
				
			||||||
import { useEffect, useState } from "react";
 | 
					 | 
				
			||||||
import { FloatLabel } from "primereact/floatlabel";
 | 
					 | 
				
			||||||
import { Dropdown } from "primereact/dropdown";
 | 
					 | 
				
			||||||
import { Guest, GuestSerializer, GuestStatus } from "@/app/lib/guest";
 | 
					 | 
				
			||||||
import { Button } from "primereact/button";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type FormResponse = {
 | 
					 | 
				
			||||||
  attendance: GuestStatus;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function GuestForm({ guest, idx }: { guest: Guest, idx: number }) {
 | 
					 | 
				
			||||||
  const [response, setResponse] = useState<FormResponse>({
 | 
					 | 
				
			||||||
    attendance: guest.status!
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [pendingChanges, setPendingChanges] = useState(false);
 | 
					 | 
				
			||||||
  const [sending, setSending] = useState(false);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  console.log('GuestForm response', response.attendance);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const attendanceOptions: { name: string, code: GuestStatus }[] = [
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      name: 'Attending',
 | 
					 | 
				
			||||||
      code: 'confirmed'
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      name: 'Declined',
 | 
					 | 
				
			||||||
      code: 'declined'
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
      name: 'Tentative',
 | 
					 | 
				
			||||||
      code: 'tentative'
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const api = new AbstractApi<Guest>();
 | 
					 | 
				
			||||||
  const serializer = new GuestSerializer();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const submitForm = () => {
 | 
					 | 
				
			||||||
    setSending(true);
 | 
					 | 
				
			||||||
    setPendingChanges(false);
 | 
					 | 
				
			||||||
    api.update(serializer, {
 | 
					 | 
				
			||||||
      id: guest.id!,
 | 
					 | 
				
			||||||
      status: response.attendance,
 | 
					 | 
				
			||||||
    }, () => setSending(false));
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div
 | 
					 | 
				
			||||||
      key={guest.id}
 | 
					 | 
				
			||||||
      className={`px-2 py-6 flex flex-col items-center ${idx !== 0 ? 'border-t border-gray-300' : ''}`}
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      <h2 className="m-2 text-xl font-semibold">{guest.name}</h2>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <Dropdown
 | 
					 | 
				
			||||||
        value={response.attendance}
 | 
					 | 
				
			||||||
        options={attendanceOptions}
 | 
					 | 
				
			||||||
        optionLabel="name"
 | 
					 | 
				
			||||||
        optionValue="code"
 | 
					 | 
				
			||||||
        className="rounded-md w-full max-w-xs border border-gray-300"
 | 
					 | 
				
			||||||
        checkmark={true}
 | 
					 | 
				
			||||||
        highlightOnSelect={false}
 | 
					 | 
				
			||||||
        onChange={(e) => {
 | 
					 | 
				
			||||||
          setPendingChanges(true);
 | 
					 | 
				
			||||||
          setResponse({ ...response, attendance: e.value })
 | 
					 | 
				
			||||||
        }}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <Button
 | 
					 | 
				
			||||||
        label="Save"
 | 
					 | 
				
			||||||
        icon="pi pi-save"
 | 
					 | 
				
			||||||
        loading={sending}
 | 
					 | 
				
			||||||
        onClick={submitForm}
 | 
					 | 
				
			||||||
        disabled={!pendingChanges || sending}
 | 
					 | 
				
			||||||
        className="mt-4 max-w-xs"
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function Page() {
 | 
					 | 
				
			||||||
  const params = useParams<{ slug: string, id: string }>()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [invitation, setInvitation] = useState<Invitation>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    localStorage.setItem('slug', params.slug);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const api = new AbstractApi<Invitation>();
 | 
					 | 
				
			||||||
    const serializer = new InvitationSerializer();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    api.get(serializer, params.id, (invitation: Invitation) => {
 | 
					 | 
				
			||||||
      setInvitation(invitation);
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className="flex flex-col items-center">
 | 
					 | 
				
			||||||
      <h1 className="text-2xl font-bold mb-4">Invitation</h1>
 | 
					 | 
				
			||||||
      {invitation ? (
 | 
					 | 
				
			||||||
        <div>
 | 
					 | 
				
			||||||
          <p>We have reserved {invitation.guests.length} seats in your honor. Please, confirm attendance submitting the following form:</p>
 | 
					 | 
				
			||||||
          {invitation.guests.map((guest, idx) => (
 | 
					 | 
				
			||||||
            <GuestForm key={guest.id} guest={guest} idx={idx} />
 | 
					 | 
				
			||||||
          ))}
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      ) : (
 | 
					 | 
				
			||||||
        <p>Loading...</p>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,41 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import SideNav from '@/app/ui/dashboard/sidenav';
 | 
					 | 
				
			||||||
import Image from 'next/image';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function Layout({ children }: { children: React.ReactNode }) {
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className="flex h-screen flex-col">
 | 
					 | 
				
			||||||
      <div className="w-full lg:h-72 h-36 relative">
 | 
					 | 
				
			||||||
        <Image
 | 
					 | 
				
			||||||
          src="/header.png"
 | 
					 | 
				
			||||||
          alt="Header"
 | 
					 | 
				
			||||||
          fill
 | 
					 | 
				
			||||||
          style={{ objectFit: 'cover', objectPosition: 'center', zIndex: 0 }}
 | 
					 | 
				
			||||||
          priority
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
      <div className="flex-grow flex items-center justify-center lg:p-24 py-8 bg-[#e1d5c7] relative">
 | 
					 | 
				
			||||||
        <div className="absolute left-1/2 lg:top-24 top-8 z-10 -translate-x-1/2 -translate-y-1/2 flex justify-center w-full pointer-events-none w-12 h-12 lg:w-24 lg:h-24">
 | 
					 | 
				
			||||||
          <Image
 | 
					 | 
				
			||||||
            src="/stamp.png"
 | 
					 | 
				
			||||||
            alt="Stamp"
 | 
					 | 
				
			||||||
            width={120}
 | 
					 | 
				
			||||||
            height={120}
 | 
					 | 
				
			||||||
            className="object-contain"
 | 
					 | 
				
			||||||
            priority
 | 
					 | 
				
			||||||
          />
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div className="max-w-4xl w-full h-full bg-[#f9f9f7] shadow-lg">
 | 
					 | 
				
			||||||
            <div
 | 
					 | 
				
			||||||
              className="max-w-4xl lg:m-6 m-3 lg:px-6 px-3 lg:py-24 py-2 border-2 border-[#d3d3d1] rounded-xl text-[#958971] flex justify-center"
 | 
					 | 
				
			||||||
              style={{ height: 'calc(100% - 3rem)' }}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              {children}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,34 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
'use client'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { AbstractApi } from '@/app/api/abstract-api';
 | 
					 | 
				
			||||||
import { Website, WebsiteSerializer } from '@/app/lib/website';
 | 
					 | 
				
			||||||
import { useState, useEffect } from 'react';
 | 
					 | 
				
			||||||
import DOMPurify from "dompurify";
 | 
					 | 
				
			||||||
import { useParams } from 'next/navigation';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function Page() {
 | 
					 | 
				
			||||||
    const params = useParams<{ slug: string }>()
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
    useEffect(() => {
 | 
					 | 
				
			||||||
      if (typeof window !== 'undefined') {
 | 
					 | 
				
			||||||
        localStorage.setItem('slug', params.slug);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [websiteContent, setWebsiteContent] = useState<string>("");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const api = new AbstractApi<Website>();
 | 
					 | 
				
			||||||
  const serializer = new WebsiteSerializer();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    api.get(serializer, undefined, (loadedWebsite) => {
 | 
					 | 
				
			||||||
      setWebsiteContent(loadedWebsite.content || "");
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(websiteContent) }} />
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,17 +1,13 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Entity } from '@/app/lib/definitions';
 | 
					import { Entity } from '@/app/lib/definitions';
 | 
				
			||||||
import { getCsrfToken, getSlug } from '@/app/lib/utils';
 | 
					import { getCsrfToken, getSlug } from '@/app/lib/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
export interface Api<T extends Entity> {
 | 
					export interface Api<T extends Entity> {
 | 
				
			||||||
  getAll(serializable: Serializable<T>, callback: (objets: T[]) => void): void;
 | 
					  getAll(serializable: Serializable<T> ,callback: (objets: T[]) => void): void;
 | 
				
			||||||
  get(serializable: Serializable<T>, id: string, callback: (object: T) => void): void;
 | 
					  create(serializable: Serializable<T>, object: T, callback: () => void): void;
 | 
				
			||||||
  create(serializable: Serializable<T>, object: T, callback: (object: T) => void): void;
 | 
					 | 
				
			||||||
  update(serializable: Serializable<T>, object: T, callback: () => void): void;
 | 
					  update(serializable: Serializable<T>, object: T, callback: () => void): void;
 | 
				
			||||||
  destroy(serializable: Serializable<T>, object: T, callback: () => void): void;
 | 
					  destroy(serializable: Serializable<T>, object: T, callback: () => void): void;
 | 
				
			||||||
 | 
					 | 
				
			||||||
  post(serializable: Serializable<T>, path: string, callback: () => void): void;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface Serializable<T> {
 | 
					export interface Serializable<T> {
 | 
				
			||||||
@ -33,61 +29,27 @@ export class AbstractApi<T extends Entity> implements Api<T> {
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getAllPdf(serializable: Serializable<T>, callback: () => void): void {
 | 
					  update(serializable: Serializable<T>, object: T, callback: () => void): void {
 | 
				
			||||||
    fetch(`/api/${getSlug()}/${serializable.apiPath()}`, {
 | 
					    fetch(`/api/${getSlug()}/${serializable.apiPath()}/${object.id}`, {
 | 
				
			||||||
      headers: {
 | 
					 | 
				
			||||||
        'Accept': 'application/pdf',
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }).then(res => res.blob())
 | 
					 | 
				
			||||||
      .then(blob => {
 | 
					 | 
				
			||||||
        var file = window.URL.createObjectURL(blob);
 | 
					 | 
				
			||||||
        window.location.assign(file);
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  get(serializable: Serializable<T>, id: (string | undefined), callback: (object: T) => void): void {
 | 
					 | 
				
			||||||
    const endpoint = id ? `/api/${getSlug()}/${serializable.apiPath()}/${id}` : `/api/${getSlug()}/${serializable.apiPath()}`;
 | 
					 | 
				
			||||||
    fetch(endpoint)
 | 
					 | 
				
			||||||
      .then((response) => response.json())
 | 
					 | 
				
			||||||
      .then((data) => {
 | 
					 | 
				
			||||||
        callback(serializable.fromJson(data));
 | 
					 | 
				
			||||||
      }, (error) => {
 | 
					 | 
				
			||||||
        return [];
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  update(serializable: Serializable<T>, object: T, callback: (updatedObject: T) => void): void {
 | 
					 | 
				
			||||||
    const endpoint = object.id ? `/api/${getSlug()}/${serializable.apiPath()}/${object.id}` : `/api/${getSlug()}/${serializable.apiPath()}`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    fetch(endpoint, {
 | 
					 | 
				
			||||||
      method: 'PUT',
 | 
					      method: 'PUT',
 | 
				
			||||||
      body: serializable.toJson(object),
 | 
					      body: serializable.toJson(object),
 | 
				
			||||||
      headers: {
 | 
					      headers: {
 | 
				
			||||||
        'Content-Type': 'application/json',
 | 
					        'Content-Type': 'application/json',
 | 
				
			||||||
        'Accept': 'application/json',
 | 
					 | 
				
			||||||
        'X-CSRF-TOKEN': getCsrfToken(),
 | 
					        'X-CSRF-TOKEN': getCsrfToken(),
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }).then((response) => response.json())
 | 
					    }).then(callback)
 | 
				
			||||||
      .then((data) => {
 | 
					 | 
				
			||||||
        callback(serializable.fromJson(data));
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      .catch((error) => console.error(error));
 | 
					      .catch((error) => console.error(error));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  create(serializable: Serializable<T>, object: T, callback: (createdObject: T) => void): void {
 | 
					  create(serializable: Serializable<T>, object: T, callback: () => void): void {
 | 
				
			||||||
    fetch(`/api/${getSlug()}/${serializable.apiPath()}`, {
 | 
					    fetch(`/api/${getSlug()}/${serializable.apiPath()}`, {
 | 
				
			||||||
      method: 'POST',
 | 
					      method: 'POST',
 | 
				
			||||||
      body: serializable.toJson(object),
 | 
					      body: serializable.toJson(object),
 | 
				
			||||||
      headers: {
 | 
					      headers: {
 | 
				
			||||||
        'Content-Type': 'application/json',
 | 
					        'Content-Type': 'application/json',
 | 
				
			||||||
        'Accept': 'application/json',
 | 
					 | 
				
			||||||
        'X-CSRF-TOKEN': getCsrfToken(),
 | 
					        'X-CSRF-TOKEN': getCsrfToken(),
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    })
 | 
					    }).then(callback)
 | 
				
			||||||
      .then((response) => response.json())
 | 
					 | 
				
			||||||
      .then((data) => {
 | 
					 | 
				
			||||||
        callback(serializable.fromJson(data));
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      .catch((error) => console.error(error));
 | 
					      .catch((error) => console.error(error));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -100,14 +62,4 @@ export class AbstractApi<T extends Entity> implements Api<T> {
 | 
				
			|||||||
    }).then(callback)
 | 
					    }).then(callback)
 | 
				
			||||||
      .catch((error) => console.error(error));
 | 
					      .catch((error) => console.error(error));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					 | 
				
			||||||
  post(serializable: Serializable<T>, path: string, callback: () => void): void {
 | 
					 | 
				
			||||||
    fetch(`/api/${getSlug()}/${serializable.apiPath()}/${path}`, {
 | 
					 | 
				
			||||||
      method: 'POST',
 | 
					 | 
				
			||||||
      headers: {
 | 
					 | 
				
			||||||
        'X-CSRF-TOKEN': getCsrfToken(),
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }).then(callback)
 | 
					 | 
				
			||||||
      .catch((error) => console.error(error));
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { asArray, getCsrfToken, getSlug } from '@/app/lib/utils';
 | 
					import { asArray, getCsrfToken, getSlug } from '@/app/lib/utils';
 | 
				
			||||||
import { Captcha, StructuredErrors, User } from '@/app/lib/definitions';
 | 
					import { Captcha, StructuredErrors, User } from '@/app/lib/definitions';
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { data } from "autoprefixer";
 | 
					import { data } from "autoprefixer";
 | 
				
			||||||
import { getCsrfToken } from "../lib/utils";
 | 
					import { getCsrfToken } from "../lib/utils";
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { NextResponse } from "next/server";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function GET() {
 | 
					 | 
				
			||||||
  return NextResponse.json({});
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { TableArrangement } from '@/app/lib/definitions';
 | 
					import { TableArrangement } from '@/app/lib/definitions';
 | 
				
			||||||
import { getSlug } from '../lib/utils';
 | 
					import { getSlug } from '../lib/utils';
 | 
				
			||||||
@ -12,9 +12,6 @@ export function loadTableSimulations(onLoad?: (tableSimulations: TableArrangemen
 | 
				
			|||||||
          id: record.id,
 | 
					          id: record.id,
 | 
				
			||||||
          name: record.name,
 | 
					          name: record.name,
 | 
				
			||||||
          discomfort: record.discomfort,
 | 
					          discomfort: record.discomfort,
 | 
				
			||||||
          valid: record.valid,
 | 
					 | 
				
			||||||
          progress: record.progress,
 | 
					 | 
				
			||||||
          status : record.status
 | 
					 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
      }));
 | 
					      }));
 | 
				
			||||||
    }, (error) => {
 | 
					    }, (error) => {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,22 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
'use client'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { useEditor, EditorContent } from '@tiptap/react'
 | 
					 | 
				
			||||||
import StarterKit from '@tiptap/starter-kit'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const Tiptap = ({ content, onUpdate }: { content: string, onUpdate: (newContent:string) => void }) => {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const editor = useEditor({
 | 
					 | 
				
			||||||
    extensions: [StarterKit],
 | 
					 | 
				
			||||||
    content: content || '<p>Type something here...</p>',
 | 
					 | 
				
			||||||
     onUpdate({ editor }) {
 | 
					 | 
				
			||||||
        onUpdate(editor.getHTML());
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    immediatelyRender: false,
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return <EditorContent editor={editor} />
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default Tiptap
 | 
					 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import '@/app/ui/global.css'
 | 
					import '@/app/ui/global.css'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class Affinities {
 | 
					 | 
				
			||||||
  [key:string]: number;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,6 +1,5 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AttendanceSummary } from "./group";
 | 
					 | 
				
			||||||
import { Guest } from "./guest";
 | 
					import { Guest } from "./guest";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface Entity {
 | 
					export interface Entity {
 | 
				
			||||||
@ -12,12 +11,20 @@ export type TableArrangement = {
 | 
				
			|||||||
  number: number;
 | 
					  number: number;
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
  guests?: Guest[];
 | 
					  guests?: Guest[];
 | 
				
			||||||
  discomfort?: number;
 | 
					  discomfort?: number
 | 
				
			||||||
  valid?: boolean;
 | 
					 | 
				
			||||||
  progress: number;
 | 
					 | 
				
			||||||
  status: 'in_progress' | 'completed' | 'not_started';
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type guestsTable = {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  customer_id: string;
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					  email: string;
 | 
				
			||||||
 | 
					  image_url: string;
 | 
				
			||||||
 | 
					  date: string;
 | 
				
			||||||
 | 
					  amount: number;
 | 
				
			||||||
 | 
					  status: 'pending' | 'paid';
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type User = {
 | 
					export type User = {
 | 
				
			||||||
  id: string;
 | 
					  id: string;
 | 
				
			||||||
  email: string;
 | 
					  email: string;
 | 
				
			||||||
@ -31,22 +38,3 @@ export type Captcha = {
 | 
				
			|||||||
export type StructuredErrors = {
 | 
					export type StructuredErrors = {
 | 
				
			||||||
  [key: string]: string[] | string;
 | 
					  [key: string]: string[] | string;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					 | 
				
			||||||
export type GlobalSummary = {
 | 
					 | 
				
			||||||
  expenses: ExpenseSummary; 
 | 
					 | 
				
			||||||
  guests: AttendanceSummary
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type ExpenseSummary = {
 | 
					 | 
				
			||||||
  projected: ExpensePossibleSummary;
 | 
					 | 
				
			||||||
  confirmed: ExpensePossibleSummary;
 | 
					 | 
				
			||||||
  status: StatusSummary;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type ExpensePossibleSummary = {
 | 
					 | 
				
			||||||
  total: number;
 | 
					 | 
				
			||||||
  guests: number;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
export type StatusSummary = {
 | 
					 | 
				
			||||||
  paid: number;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Serializable } from "../api/abstract-api";
 | 
					import { Serializable } from "../api/abstract-api";
 | 
				
			||||||
import { Entity } from "./definitions";
 | 
					import { Entity } from "./definitions";
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Entity } from "./definitions";
 | 
					import { Entity } from "./definitions";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,8 +1,7 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Serializable } from "../api/abstract-api";
 | 
					import { Serializable } from "../api/abstract-api";
 | 
				
			||||||
import { Entity } from "./definitions";
 | 
					import { Entity } from "./definitions";
 | 
				
			||||||
import { Group } from "./group";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const guestStatuses = ['considered', 'invited', 'confirmed', 'declined', 'tentative'] as const;
 | 
					export const guestStatuses = ['considered', 'invited', 'confirmed', 'declined', 'tentative'] as const;
 | 
				
			||||||
export type GuestStatus = typeof guestStatuses[number];
 | 
					export type GuestStatus = typeof guestStatuses[number];
 | 
				
			||||||
@ -10,28 +9,30 @@ export type GuestStatus = typeof guestStatuses[number];
 | 
				
			|||||||
export class Guest implements Entity {
 | 
					export class Guest implements Entity {
 | 
				
			||||||
  id?: string;
 | 
					  id?: string;
 | 
				
			||||||
  name?: string;
 | 
					  name?: string;
 | 
				
			||||||
 | 
					  group_name?: string;
 | 
				
			||||||
 | 
					  groupId?: string;
 | 
				
			||||||
  color?: string;
 | 
					  color?: string;
 | 
				
			||||||
  status?: GuestStatus;
 | 
					  status?: GuestStatus;
 | 
				
			||||||
  children?: Guest[];
 | 
					  children?: Guest[];
 | 
				
			||||||
  group?: Group;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(id?: string, name?: string, color?: string, status?: GuestStatus, children?: Guest[], Group?: Group) {
 | 
					  constructor(id?: string, name?: string, group_name?: string, groupId?: string, color?: string, status?: GuestStatus, children?: Guest[]) {
 | 
				
			||||||
    this.id = id;
 | 
					    this.id = id;
 | 
				
			||||||
    this.name = name;
 | 
					    this.name = name;
 | 
				
			||||||
 | 
					    this.group_name = group_name;
 | 
				
			||||||
 | 
					    this.groupId = groupId;
 | 
				
			||||||
    this.color = color;
 | 
					    this.color = color;
 | 
				
			||||||
    this.status = status;
 | 
					    this.status = status;
 | 
				
			||||||
    this.children = children;
 | 
					    this.children = children;
 | 
				
			||||||
    this.group = Group;
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class GuestSerializer implements Serializable<Guest> {
 | 
					export class GuestSerializer implements Serializable<Guest> {
 | 
				
			||||||
  fromJson(data: any): Guest {
 | 
					  fromJson(data: any): Guest {
 | 
				
			||||||
    return new Guest(data.id, data.name, data.color, data.status, data.children, new Group(data.group?.id, data.group?.name));
 | 
					    return new Guest(data.id, data.name, data.group_name, data.group_id, data.color, data.status, data.children);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  toJson(guest: Guest): string {
 | 
					  toJson(guest: Guest): string {
 | 
				
			||||||
    return JSON.stringify({ guest: { name: guest.name, status: guest.status, group_id: guest.group?.id } });
 | 
					    return JSON.stringify({ guest: { name: guest.name, status: guest.status, group_id: guest.groupId } });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  apiPath(): string {
 | 
					  apiPath(): string {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,28 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { Entity } from "./definitions";
 | 
					 | 
				
			||||||
import { Guest } from "./guest";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class Invitation implements Entity {
 | 
					 | 
				
			||||||
  id?: string;
 | 
					 | 
				
			||||||
  guests: Array<Guest>;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  constructor(id?: string, guests: Array<Guest> = []) {
 | 
					 | 
				
			||||||
    this.id = id;
 | 
					 | 
				
			||||||
    this.guests = guests;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class InvitationSerializer {
 | 
					 | 
				
			||||||
  fromJson(data: any): Invitation {
 | 
					 | 
				
			||||||
    return new Invitation(data.id, (data.guests || []).map((guest: any) => new Guest(guest.id, guest.name, guest.color, guest.status, guest.children)));
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  toJson(invitation: Invitation): string {
 | 
					 | 
				
			||||||
    return JSON.stringify({ invitation: { guest_ids: invitation.guests.map(guest => guest.id) } });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  apiPath(): string {
 | 
					 | 
				
			||||||
    return 'invitations';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,74 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { Serializable } from "../api/abstract-api";
 | 
					 | 
				
			||||||
import { Entity } from "./definitions";
 | 
					 | 
				
			||||||
import { Group } from "./group";
 | 
					 | 
				
			||||||
import { Guest } from "./guest";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type Discomfort = {
 | 
					 | 
				
			||||||
  discomfort: number;
 | 
					 | 
				
			||||||
  breakdown: {
 | 
					 | 
				
			||||||
    tableSizePenalty: number;
 | 
					 | 
				
			||||||
    cohesionPenalty: number;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type Table = {
 | 
					 | 
				
			||||||
  number: number;
 | 
					 | 
				
			||||||
  guests: Guest[];
 | 
					 | 
				
			||||||
  discomfort: Discomfort;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class TableSimulation implements Entity {
 | 
					 | 
				
			||||||
  id?: string;
 | 
					 | 
				
			||||||
  tables: Table[];
 | 
					 | 
				
			||||||
  progress: number;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  constructor(id?: string, tables?: Table[], progress?: number) {
 | 
					 | 
				
			||||||
    this.id = id;
 | 
					 | 
				
			||||||
    this.tables = tables || [];
 | 
					 | 
				
			||||||
    this.progress = progress || 0;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class TableSimulationSerializer implements Serializable<TableSimulation> {
 | 
					 | 
				
			||||||
  fromJson(data: any): TableSimulation {
 | 
					 | 
				
			||||||
    return new TableSimulation(data.id, data.tables.map((table: any) => {
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        number: table.number,
 | 
					 | 
				
			||||||
        guests: table.guests.map((guest: any) => new Guest(guest.id, guest.name, guest.color, guest.status, [], guest.group)),
 | 
					 | 
				
			||||||
        discomfort: {
 | 
					 | 
				
			||||||
          discomfort: table.discomfort.discomfort,
 | 
					 | 
				
			||||||
          breakdown: {
 | 
					 | 
				
			||||||
            tableSizePenalty: table.discomfort.breakdown.table_size_penalty,
 | 
					 | 
				
			||||||
            cohesionPenalty: table.discomfort.breakdown.cohesion_penalty,
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }), data.progress);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  toJson(simulation: TableSimulation): string {
 | 
					 | 
				
			||||||
    return JSON.stringify({ simulation: { tables: simulation.tables.map((table) => {
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        number: table.number,
 | 
					 | 
				
			||||||
        guests: table.guests.map((guest) => {
 | 
					 | 
				
			||||||
          return {
 | 
					 | 
				
			||||||
            id: guest.id,
 | 
					 | 
				
			||||||
            name: guest.name,
 | 
					 | 
				
			||||||
            color: guest.color,
 | 
					 | 
				
			||||||
            status: guest.status,
 | 
					 | 
				
			||||||
            children: guest.children,
 | 
					 | 
				
			||||||
            group: new Group(guest.group?.id, guest.group?.name)
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }),
 | 
					 | 
				
			||||||
        discomfort: table.discomfort,
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }) } });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  apiPath(): string {
 | 
					 | 
				
			||||||
    return 'tables_arrangements';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const getCsrfToken = () => {
 | 
					export const getCsrfToken = () => {
 | 
				
			||||||
  return document.cookie
 | 
					  return document.cookie
 | 
				
			||||||
 | 
				
			|||||||
@ -1,30 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { Serializable } from "../api/abstract-api";
 | 
					 | 
				
			||||||
import { Entity } from "./definitions";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class Website implements Entity {
 | 
					 | 
				
			||||||
  id?: string;
 | 
					 | 
				
			||||||
  content?: string;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  constructor(id: string, content: string) {
 | 
					 | 
				
			||||||
    this.id = id;
 | 
					 | 
				
			||||||
    this.content = content;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class WebsiteSerializer implements Serializable<Website> {
 | 
					 | 
				
			||||||
  fromJson(data: any): Website {
 | 
					 | 
				
			||||||
    return new Website("", data.content);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  toJson(website: Website): string {
 | 
					 | 
				
			||||||
    return JSON.stringify({ website: { content: website.content } });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  apiPath(): string {
 | 
					 | 
				
			||||||
    return 'website';
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import * as HeroIcon from '@heroicons/react/24/outline'
 | 
					import * as HeroIcon from '@heroicons/react/24/outline'
 | 
				
			||||||
import { ComponentProps } from 'react'
 | 
					import { ComponentProps } from 'react'
 | 
				
			||||||
 | 
				
			|||||||
@ -1,33 +1,41 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AbstractApi } from '@/app/api/abstract-api';
 | 
					import React, { useState } from 'react';
 | 
				
			||||||
import { TableArrangement } from '@/app/lib/definitions';
 | 
					import { TableArrangement } from '@/app/lib/definitions';
 | 
				
			||||||
import { TableSimulation, TableSimulationSerializer } from '@/app/lib/tableSimulation';
 | 
					 | 
				
			||||||
import { Table } from '@/app/ui/components/table';
 | 
					 | 
				
			||||||
import { lusitana } from '@/app/ui/fonts';
 | 
					import { lusitana } from '@/app/ui/fonts';
 | 
				
			||||||
import { useState, useEffect } from 'react';
 | 
					import { Table } from '@/app/ui/components/table';
 | 
				
			||||||
 | 
					import { getSlug } from '@/app/lib/utils';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Arrangement({ id }: { id: string }) {
 | 
					export default function Arrangement({ id }: { id: string }) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [simulation, setSimulation] = useState<TableSimulation | undefined>(undefined);
 | 
					    const [tables, setTables] = useState<Array<TableArrangement>>([]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function loadSimulation() {
 | 
					    function loadTables() {
 | 
				
			||||||
    new AbstractApi<TableSimulation>().get(new TableSimulationSerializer(), id, (object: TableSimulation) => {
 | 
					        fetch(`/api/${getSlug()}/tables_arrangements/${id}`)
 | 
				
			||||||
      setSimulation(object);
 | 
					            .then((response) => response.json())
 | 
				
			||||||
 | 
					            .then((data) => {
 | 
				
			||||||
 | 
					                setTables(data.map((record: any) => {
 | 
				
			||||||
 | 
					                    return ({
 | 
				
			||||||
 | 
					                        id: record.number,
 | 
				
			||||||
 | 
					                        guests: record.guests
 | 
				
			||||||
 | 
					                    });
 | 
				
			||||||
 | 
					                }));
 | 
				
			||||||
 | 
					            }, (error) => {
 | 
				
			||||||
 | 
					                return [];
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(loadSimulation, []);
 | 
					    tables.length === 0 && loadTables();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <div className="w-full">
 | 
					        <div className="w-full">
 | 
				
			||||||
            <div className="w-full items-center justify-between">
 | 
					            <div className="w-full items-center justify-between">
 | 
				
			||||||
                <h1 className={`${lusitana.className} text-2xl my-5`}>Table distributions</h1>
 | 
					                <h1 className={`${lusitana.className} text-2xl my-5`}>Table distributions</h1>
 | 
				
			||||||
                <div className="flex flex-row flex-wrap justify-around">
 | 
					                <div className="flex flex-row flex-wrap justify-around">
 | 
				
			||||||
          {simulation && simulation.tables.map((table) => (
 | 
					                    {tables.map((table) => (
 | 
				
			||||||
            <Table key={table.number} table={table} style="rounded" />
 | 
					                        <Table key={table.number} guests={table.guests || []} style="rounded" />
 | 
				
			||||||
                    ))}
 | 
					                    ))}
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client'
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -7,24 +7,11 @@ import { TableArrangement } from '@/app/lib/definitions';
 | 
				
			|||||||
import { classNames } from "../components/button";
 | 
					import { classNames } from "../components/button";
 | 
				
			||||||
import TableOfContents from "../components/table-of-contents";
 | 
					import TableOfContents from "../components/table-of-contents";
 | 
				
			||||||
import { loadTableSimulations } from "@/app/api/tableSimulations";
 | 
					import { loadTableSimulations } from "@/app/api/tableSimulations";
 | 
				
			||||||
import { ArchiveBoxXMarkIcon, CheckBadgeIcon } from "@heroicons/react/24/outline";
 | 
					 | 
				
			||||||
import { Tooltip } from "primereact/tooltip";
 | 
					 | 
				
			||||||
import clsx from "clsx";
 | 
					 | 
				
			||||||
import { ProgressBar } from "primereact/progressbar";
 | 
					 | 
				
			||||||
import { useEffect } from "react";
 | 
					 | 
				
			||||||
import { TableSimulation, TableSimulationSerializer } from "@/app/lib/tableSimulation";
 | 
					 | 
				
			||||||
import { AbstractApi } from "@/app/api/abstract-api";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function ArrangementsTable({ onArrangementSelected }: { onArrangementSelected: (arrangementId: string) => void }) {
 | 
					export default function ArrangementsTable ({onArrangementSelected}: {onArrangementSelected: (arrangementId: string) => void}) {
 | 
				
			||||||
    const [arrangements, setArrangements] = useState<Array<TableArrangement>>([]);
 | 
					    const [arrangements, setArrangements] = useState<Array<TableArrangement>>([]);
 | 
				
			||||||
    const [arrangementsLoaded, setArrangementsLoaded] = useState(false);
 | 
					    const [arrangementsLoaded, setArrangementsLoaded] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    refreshSimulations();
 | 
					 | 
				
			||||||
    const interval = setInterval(refreshSimulations, 10000);
 | 
					 | 
				
			||||||
    return () => clearInterval(interval);
 | 
					 | 
				
			||||||
  }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    function refreshSimulations() {
 | 
					    function refreshSimulations() {
 | 
				
			||||||
      loadTableSimulations((arrangements) => {
 | 
					      loadTableSimulations((arrangements) => {
 | 
				
			||||||
        setArrangements(arrangements);
 | 
					        setArrangements(arrangements);
 | 
				
			||||||
@ -36,32 +23,21 @@ export default function ArrangementsTable({ onArrangementSelected }: { onArrange
 | 
				
			|||||||
        onArrangementSelected(e.currentTarget.getAttribute('data-arrangement-id') || '');
 | 
					        onArrangementSelected(e.currentTarget.getAttribute('data-arrangement-id') || '');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  return (
 | 
					    !arrangementsLoaded && refreshSimulations();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return(
 | 
				
			||||||
      <TableOfContents
 | 
					      <TableOfContents
 | 
				
			||||||
      headers={['Name', 'Discomfort', 'Status', 'Actions']}
 | 
					        headers={['Name', 'Discomfort', 'Actions']}
 | 
				
			||||||
        caption='Simulations'
 | 
					        caption='Simulations'
 | 
				
			||||||
        elements={arrangements}
 | 
					        elements={arrangements}
 | 
				
			||||||
        rowRender={(arrangement) => (
 | 
					        rowRender={(arrangement) => (
 | 
				
			||||||
        <tr key={arrangement.id} className={clsx("border-b", {
 | 
					          <tr key={arrangement.id} className="bg-white border-b odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800">
 | 
				
			||||||
          "bg-white odd:bg-white even:bg-gray-50": arrangement.valid,
 | 
					            <th scope="row" className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
 | 
				
			||||||
          "bg-red-50 odd:bg-red-50 even:bg-red-100": !arrangement.valid
 | 
					 | 
				
			||||||
        })}>
 | 
					 | 
				
			||||||
          <th scope="row" className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap">
 | 
					 | 
				
			||||||
              {arrangement.name}
 | 
					              {arrangement.name}
 | 
				
			||||||
            </th>
 | 
					            </th>
 | 
				
			||||||
            <td className="px-6 py-4">
 | 
					            <td className="px-6 py-4">
 | 
				
			||||||
              {arrangement.discomfort}
 | 
					              {arrangement.discomfort}
 | 
				
			||||||
            </td>
 | 
					            </td>
 | 
				
			||||||
          <td className="px-4">
 | 
					 | 
				
			||||||
            <Tooltip target=".tooltip-status" />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            <>
 | 
					 | 
				
			||||||
              { arrangement.valid && arrangement.status === 'not_started' && <ProgressBar mode="indeterminate" style={{ height: '6px' }}></ProgressBar>  }
 | 
					 | 
				
			||||||
              { arrangement.valid && arrangement.status !== 'not_started' && <ProgressBar value={(100 * arrangement.progress).toFixed(2) }></ProgressBar> }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              { !arrangement.valid && 'The list of potential guests has changed since this simulation.' }
 | 
					 | 
				
			||||||
            </>
 | 
					 | 
				
			||||||
          </td>
 | 
					 | 
				
			||||||
            <td>
 | 
					            <td>
 | 
				
			||||||
                <button data-arrangement-id={arrangement.id} onClick={arrangementClicked} className={classNames('primary')}>Load</button>
 | 
					                <button data-arrangement-id={arrangement.id} onClick={arrangementClicked} className={classNames('primary')}>Load</button>
 | 
				
			||||||
            </td>
 | 
					            </td>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import clsx from 'clsx';
 | 
					import clsx from 'clsx';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,82 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
'use client';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { Affinities } from '@/app/lib/affinities';
 | 
					 | 
				
			||||||
import { Group } from '@/app/lib/group';
 | 
					 | 
				
			||||||
import { getCsrfToken, getSlug } from '@/app/lib/utils';
 | 
					 | 
				
			||||||
import { classNames } from '@/app/ui/components/button';
 | 
					 | 
				
			||||||
import { Dialog } from 'primereact/dialog';
 | 
					 | 
				
			||||||
import { useEffect, useState } from 'react';
 | 
					 | 
				
			||||||
import AffinitySlider from './form/affinitySlider';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function AffinitiesFormDialog({ groups, group, visible, onHide }: {
 | 
					 | 
				
			||||||
  groups: Group[],
 | 
					 | 
				
			||||||
  group?: Group,
 | 
					 | 
				
			||||||
  visible: boolean,
 | 
					 | 
				
			||||||
  onHide: () => void,
 | 
					 | 
				
			||||||
}) {
 | 
					 | 
				
			||||||
  const [affinities, setAffinities] = useState<Affinities>({});
 | 
					 | 
				
			||||||
  const [isLoadingAffinities, setIsLoadingAffinities] = useState(true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    setIsLoadingAffinities(true);
 | 
					 | 
				
			||||||
    if (group?.id === undefined) {
 | 
					 | 
				
			||||||
      setAffinities({});
 | 
					 | 
				
			||||||
      setIsLoadingAffinities(false)
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      fetch(`/api/${getSlug()}/groups/${group?.id}/affinities`)
 | 
					 | 
				
			||||||
        .then((response) => response.json())
 | 
					 | 
				
			||||||
        .then((data) => {
 | 
					 | 
				
			||||||
          setAffinities(data);
 | 
					 | 
				
			||||||
          setIsLoadingAffinities(false)
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }, [group]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  function submitAffinities() {
 | 
					 | 
				
			||||||
    const formattedAffinities = Object.entries(affinities).map(([groupId, affinity]) => ({ group_id: groupId, affinity: affinity }));
 | 
					 | 
				
			||||||
    fetch(`/api/${getSlug()}/groups/${group?.id}/affinities/bulk_update`, {
 | 
					 | 
				
			||||||
      method: 'PUT',
 | 
					 | 
				
			||||||
      headers: {
 | 
					 | 
				
			||||||
        'Content-Type': 'application/json',
 | 
					 | 
				
			||||||
        'X-CSRF-TOKEN': getCsrfToken(),
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      body: JSON.stringify({ affinities: formattedAffinities })
 | 
					 | 
				
			||||||
    }).then(() => {
 | 
					 | 
				
			||||||
      onHide();
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  function resetAffinities() {
 | 
					 | 
				
			||||||
    fetch(`/api/${getSlug()}/groups/${group?.id}/affinities/default`, {
 | 
					 | 
				
			||||||
      method: 'GET',
 | 
					 | 
				
			||||||
      headers: {
 | 
					 | 
				
			||||||
        'Accept': 'application/json',
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }).then((response) => response.json())
 | 
					 | 
				
			||||||
      .then(setAffinities);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <Dialog header="Update affinities" visible={visible} style={{ width: '60vw' }} onHide={onHide}>
 | 
					 | 
				
			||||||
      {!isLoadingAffinities && <div className="card justify-evenly py-5 bg-gray-200 flex flex-col">
 | 
					 | 
				
			||||||
        <span className="text-center p-4">Describe the affinity with the rest of the groups</span>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          groups.filter((currentGroup) => currentGroup.id !== group?.id).map((group) =>
 | 
					 | 
				
			||||||
            <div key={group.id} className="flex flex-row hover:bg-gray-300 px-3 py-2 items-center">
 | 
					 | 
				
			||||||
              <span className="w-1/3 text-right px-4">{group.name}</span>
 | 
					 | 
				
			||||||
              <AffinitySlider value={group.id && affinities[group.id] || 1} onChange={(value) => setAffinities({ ...affinities, [group.id || "default"]: value })} />
 | 
					 | 
				
			||||||
            </div>)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div className="flex justify-center">
 | 
					 | 
				
			||||||
          <button className={classNames('gray')} onClick={resetAffinities} >Reset</button>
 | 
					 | 
				
			||||||
          <button className={classNames('primary')} onClick={submitAffinities} >Update</button>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    </Dialog>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,15 +1,14 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import clsx from "clsx";
 | 
					import clsx from "clsx";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ButtonColor = 'primary' | 'blue' | 'green' | 'red' | 'yellow' | 'gray';
 | 
					type ButtonColor = 'primary' | 'blue' | 'green' | 'red' | 'yellow';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function classNames(type: ButtonColor) {
 | 
					export function classNames(type: ButtonColor) {
 | 
				
			||||||
    return (clsx("text-white py-1 px-2 m-2 rounded disabled:opacity-50 disabled:cursor-not-allowed", {
 | 
					    return (clsx("text-white py-1 px-2 mx-1 rounded disabled:opacity-50 disabled:cursor-not-allowed", {
 | 
				
			||||||
        'bg-blue-400 hover:bg-blue-600': type === 'primary' || type === 'blue',
 | 
					        'bg-blue-400 hover:bg-blue-600': type === 'primary' || type === 'blue',
 | 
				
			||||||
        'bg-green-500 hover:bg-green-600': type === 'green',
 | 
					        'bg-green-500 hover:bg-green-600': type === 'green',
 | 
				
			||||||
        'bg-red-500 hover:bg-red-600': type === 'red',
 | 
					        'bg-red-500 hover:bg-red-600': type === 'red',
 | 
				
			||||||
        'bg-yellow-500 hover:bg-yellow-700': type === 'yellow',
 | 
					        'bg-yellow-500 hover:bg-yellow-700': type === 'yellow'
 | 
				
			||||||
        'bg-gray-500 hover:bg-gray-700': type === 'gray'
 | 
					 | 
				
			||||||
    }))
 | 
					    }))
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import clsx from "clsx"
 | 
					import clsx from "clsx"
 | 
				
			||||||
import { Icon } from "../../types";
 | 
					import { Icon } from "../../types";
 | 
				
			||||||
@ -21,7 +21,7 @@ const colorClasses = (style: Style) => {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function MainCard({ amount, title, subtitle, style, iconName }:
 | 
					export async function MainCard({ amount, title, subtitle, style, iconName }:
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        amount: string,
 | 
					        amount: string,
 | 
				
			||||||
        title: string,
 | 
					        title: string,
 | 
				
			||||||
@ -42,7 +42,7 @@ export function MainCard({ amount, title, subtitle, style, iconName }:
 | 
				
			|||||||
    );
 | 
					    );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function SecondaryCard({ amount, title, iconName, style }: { amount: string, title: string, iconName: keyof typeof HeroIcon, style: Style }) {
 | 
					export async function SecondaryCard({ amount, title, iconName, style }: { amount: string, title: string, iconName: keyof typeof HeroIcon, style: Style }) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <div className={`h-12 w-80 m-1 p-2 text-white flex flex-row items-center ${colorClasses(style)}`}>
 | 
					        <div className={`h-12 w-80 m-1 p-2 text-white flex flex-row items-center ${colorClasses(style)}`}>
 | 
				
			||||||
            <Icon className="m-3 h-7 w-7" name={iconName} />
 | 
					            <Icon className="m-3 h-7 w-7" name={iconName} />
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,40 +0,0 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { Slider } from 'primereact/slider';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function AffinitySlider({ value, onChange }: { value: number, onChange: (value: number) => void }) {
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const toNumber = (value : number | [number, number])  => {
 | 
					 | 
				
			||||||
    if(value instanceof Array) {
 | 
					 | 
				
			||||||
      return value[0];
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return value;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const label = (value: number) => {
 | 
					 | 
				
			||||||
    if (value < 0.2) {
 | 
					 | 
				
			||||||
      return 'Nemesis';
 | 
					 | 
				
			||||||
    } else if (value < 0.5) {
 | 
					 | 
				
			||||||
      return 'Enemies';
 | 
					 | 
				
			||||||
    } else if (value < 0.9) {
 | 
					 | 
				
			||||||
      return 'Bad vibes';
 | 
					 | 
				
			||||||
    } else if (value < 1.1) {
 | 
					 | 
				
			||||||
      return 'Neutral';
 | 
					 | 
				
			||||||
    } else if (value < 1.5) {
 | 
					 | 
				
			||||||
      return 'Good vibes';
 | 
					 | 
				
			||||||
    } else if (value < 1.8) {
 | 
					 | 
				
			||||||
      return 'Good friends';
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      return 'Besties';
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <>
 | 
					 | 
				
			||||||
      <Slider value={value} min={0} max={2} step={.1} onChange={(e) => onChange(toNumber(e.value))} className='w-80 bg-gray-400' />
 | 
					 | 
				
			||||||
      <span className="px-4 w-1/5">
 | 
					 | 
				
			||||||
        {label(value)}
 | 
					 | 
				
			||||||
      </span>
 | 
					 | 
				
			||||||
    </>
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React, { useState } from 'react';
 | 
					import React, { useState } from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,20 +1,21 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AbstractApi } from '@/app/api/abstract-api';
 | 
					 | 
				
			||||||
import { Group, GroupSerializer } from '@/app/lib/group';
 | 
					 | 
				
			||||||
import { classNames } from '@/app/ui/components/button';
 | 
					import { classNames } from '@/app/ui/components/button';
 | 
				
			||||||
import { ColorPicker } from 'primereact/colorpicker';
 | 
					 | 
				
			||||||
import { Dialog } from 'primereact/dialog';
 | 
					import { Dialog } from 'primereact/dialog';
 | 
				
			||||||
 | 
					import { ColorPicker } from 'primereact/colorpicker';
 | 
				
			||||||
import { Dropdown } from 'primereact/dropdown';
 | 
					import { Dropdown } from 'primereact/dropdown';
 | 
				
			||||||
import { FloatLabel } from 'primereact/floatlabel';
 | 
					import { FloatLabel } from 'primereact/floatlabel';
 | 
				
			||||||
import { InputText } from 'primereact/inputtext';
 | 
					import { InputText } from 'primereact/inputtext';
 | 
				
			||||||
import { useState } from 'react';
 | 
					import { useState } from 'react';
 | 
				
			||||||
 | 
					import { Group, GroupSerializer } from '@/app/lib/group';
 | 
				
			||||||
 | 
					import { ApiError } from 'next/dist/server/api-utils';
 | 
				
			||||||
 | 
					import { AbstractApi } from '@/app/api/abstract-api';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function GroupFormDialog({ groups, onCreate, onHide, group, visible }: {
 | 
					export default function GroupFormDialog({groups, onCreate, onHide, group, visible }: {
 | 
				
			||||||
  groups: Group[],
 | 
					  groups: Group[],
 | 
				
			||||||
  onCreate?: (newGroup: Group) => void,
 | 
					  onCreate?: () => void,
 | 
				
			||||||
  onHide: () => void,
 | 
					  onHide: () => void,
 | 
				
			||||||
  group?: Group,
 | 
					  group?: Group,
 | 
				
			||||||
  visible: boolean,
 | 
					  visible: boolean,
 | 
				
			||||||
@ -46,15 +47,15 @@ export default function GroupFormDialog({ groups, onCreate, onHide, group, visib
 | 
				
			|||||||
      group.color = color;
 | 
					      group.color = color;
 | 
				
			||||||
      group.parentId = parentId;
 | 
					      group.parentId = parentId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      api.update(serializer, group, (newGroup) => {
 | 
					      api.update(serializer, group, () => {
 | 
				
			||||||
        resetForm();
 | 
					        resetForm();
 | 
				
			||||||
        onCreate && onCreate(newGroup);
 | 
					        onCreate && onCreate();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      api.create(serializer, new Group(undefined, name, undefined, icon, undefined, parentId, color), (newGroup) => {
 | 
					      api.create(serializer, new Group(undefined, name, undefined, icon, undefined, parentId, color), () => {
 | 
				
			||||||
        resetForm();
 | 
					        resetForm();
 | 
				
			||||||
        onCreate && onCreate(newGroup);
 | 
					        onCreate && onCreate();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -15,14 +15,14 @@ import { useState } from 'react';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export default function GuestFormDialog({ groups, onCreate, onHide, guest, visible }: {
 | 
					export default function GuestFormDialog({ groups, onCreate, onHide, guest, visible }: {
 | 
				
			||||||
  groups: Group[],
 | 
					  groups: Group[],
 | 
				
			||||||
  onCreate?: (guest: Guest) => void,
 | 
					  onCreate?: () => void,
 | 
				
			||||||
  onHide: () => void,
 | 
					  onHide: () => void,
 | 
				
			||||||
  guest?: Guest,
 | 
					  guest?: Guest,
 | 
				
			||||||
  visible: boolean,
 | 
					  visible: boolean,
 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [name, setName] = useState(guest?.name || '');
 | 
					  const [name, setName] = useState(guest?.name || '');
 | 
				
			||||||
  const [group, setGroup] = useState(guest?.group?.id || null);
 | 
					  const [group, setGroup] = useState(guest?.groupId || null);
 | 
				
			||||||
  const [status, setStatus] = useState<GuestStatus | null>(guest?.status || null);
 | 
					  const [status, setStatus] = useState<GuestStatus | null>(guest?.status || null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const api = new AbstractApi<Guest>();
 | 
					  const api = new AbstractApi<Guest>();
 | 
				
			||||||
@ -41,17 +41,17 @@ export default function GuestFormDialog({ groups, onCreate, onHide, guest, visib
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    if (guest?.id !== undefined) {
 | 
					    if (guest?.id !== undefined) {
 | 
				
			||||||
      guest.name = name;
 | 
					      guest.name = name;
 | 
				
			||||||
      guest.group!.id = group;  
 | 
					      guest.groupId = group;  
 | 
				
			||||||
      guest.status = status;
 | 
					      guest.status = status;
 | 
				
			||||||
     
 | 
					     
 | 
				
			||||||
      api.update(serializer, guest, (updatedGuest) => {
 | 
					      api.update(serializer, guest, () => {
 | 
				
			||||||
        resetForm();
 | 
					        resetForm();
 | 
				
			||||||
        onCreate && onCreate(updatedGuest);
 | 
					        onCreate && onCreate();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      api.create(serializer, new Guest(undefined, name, undefined, status, [], groups.find((g) => g.id === group)), (newGuest)=> {
 | 
					      api.create(serializer, new Guest(undefined, name, undefined, group, undefined, status), ()=> {
 | 
				
			||||||
        resetForm();
 | 
					        resetForm();
 | 
				
			||||||
        onCreate && onCreate(newGuest);
 | 
					        onCreate && onCreate();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -62,8 +62,8 @@ export default function GuestFormDialog({ groups, onCreate, onHide, guest, visib
 | 
				
			|||||||
      <Dialog header="Add guest" visible={visible} style={{ width: '60vw' }} onHide={onHide}>
 | 
					      <Dialog header="Add guest" visible={visible} style={{ width: '60vw' }} onHide={onHide}>
 | 
				
			||||||
        <div className="card flex justify-evenly py-5">
 | 
					        <div className="card flex justify-evenly py-5">
 | 
				
			||||||
          <FloatLabel>
 | 
					          <FloatLabel>
 | 
				
			||||||
            <InputText id="name" className='rounded-sm' value={name} onChange={(e) => setName(e.target.value)} />
 | 
					            <InputText id="username" className='rounded-sm' value={name} onChange={(e) => setName(e.target.value)} />
 | 
				
			||||||
            <label htmlFor="name">Name</label>
 | 
					            <label htmlFor="username">Username</label>
 | 
				
			||||||
          </FloatLabel>
 | 
					          </FloatLabel>
 | 
				
			||||||
          <FloatLabel>
 | 
					          <FloatLabel>
 | 
				
			||||||
            <Dropdown id="group" className='rounded-sm min-w-32' value={group} onChange={(e) => setGroup(e.target.value)} options={
 | 
					            <Dropdown id="group" className='rounded-sm min-w-32' value={group} onChange={(e) => setGroup(e.target.value)} options={
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -15,9 +15,6 @@ export default function LoginForm() {
 | 
				
			|||||||
  const [email, setEmail] = useState("");
 | 
					  const [email, setEmail] = useState("");
 | 
				
			||||||
  const [password, setPassword] = useState("");
 | 
					  const [password, setPassword] = useState("");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [slug, setSlug] = useState<string>("default");
 | 
					 | 
				
			||||||
  useEffect(() => {setSlug(getSlug())}, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const router = useRouter();
 | 
					  const router = useRouter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [currentUser, setCurrentUser] = useState<User | null>(null);
 | 
					  const [currentUser, setCurrentUser] = useState<User | null>(null);
 | 
				
			||||||
@ -44,7 +41,7 @@ export default function LoginForm() {
 | 
				
			|||||||
          password: password,
 | 
					          password: password,
 | 
				
			||||||
          onLogin: (user) => {
 | 
					          onLogin: (user) => {
 | 
				
			||||||
            setCurrentUser(user);
 | 
					            setCurrentUser(user);
 | 
				
			||||||
            router.push(`${slug}/dashboard`)
 | 
					            router.push(`${getSlug()}/dashboard`)
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        })}>
 | 
					        })}>
 | 
				
			||||||
        Sign in
 | 
					        Sign in
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -17,9 +17,7 @@ export default function RegistrationForm() {
 | 
				
			|||||||
  const [email, setEmail] = useState<string>("");
 | 
					  const [email, setEmail] = useState<string>("");
 | 
				
			||||||
  const [password, setPassword] = useState<string>("");
 | 
					  const [password, setPassword] = useState<string>("");
 | 
				
			||||||
  const [passwordConfirmation, setPasswordConfirmation] = useState<string>("");
 | 
					  const [passwordConfirmation, setPasswordConfirmation] = useState<string>("");
 | 
				
			||||||
 | 
					  const [slug, setSlug] = useState<string>(getSlug());
 | 
				
			||||||
  const [slug, setSlug] = useState<string>("default");
 | 
					 | 
				
			||||||
  useEffect(() => { setSlug(getSlug()) }, []);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [captchaId, setCaptchaId] = useState<string>("");
 | 
					  const [captchaId, setCaptchaId] = useState<string>("");
 | 
				
			||||||
  const [captchaUrl, setCaptchaUrl] = useState<string>("");
 | 
					  const [captchaUrl, setCaptchaUrl] = useState<string>("");
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function TableOfContents<Type>({ headers, caption, elements, rowRender }: { headers: string[], caption: string, elements: Type[], rowRender: (element: Type) => JSX.Element }) {
 | 
					export default function TableOfContents<Type>({ headers, caption, elements, rowRender }: { headers: string[], caption: string, elements: Type[], rowRender: (element: Type) => JSX.Element }) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
 | 
				
			|||||||
@ -1,11 +1,6 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Guest } from "@/app/lib/guest";
 | 
					import { Guest } from "@/app/lib/guest";
 | 
				
			||||||
import { Table as TableType } from "@/app/lib/tableSimulation";
 | 
					 | 
				
			||||||
import { RectangleGroupIcon, UserGroupIcon } from "@heroicons/react/24/outline";
 | 
					 | 
				
			||||||
import { v4 as uuidv4 } from 'uuid';
 | 
					 | 
				
			||||||
import { Tooltip } from "primereact/tooltip";
 | 
					 | 
				
			||||||
import clsx from "clsx";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function Dish({ guest, rotation }: { guest: Guest, rotation?: number }) {
 | 
					function Dish({ guest, rotation }: { guest: Guest, rotation?: number }) {
 | 
				
			||||||
@ -20,6 +15,7 @@ function Dish({ guest, rotation }: { guest: Guest, rotation?: number }) {
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function GuestRow({ guests }: { guests: Guest[] }) {
 | 
					function GuestRow({ guests }: { guests: Guest[] }) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <div className="justify-around flex flex-row">
 | 
					        <div className="justify-around flex flex-row">
 | 
				
			||||||
@ -28,8 +24,7 @@ function GuestRow({ guests }: { guests: Guest[] }) {
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function RectangularTable({ table }: { table: TableType }) {
 | 
					function RectangularTable({ guests }: { guests: Guest[] }) {
 | 
				
			||||||
  const guests = table.guests;
 | 
					 | 
				
			||||||
    const halfwayThrough = Math.floor(guests.length / 2)
 | 
					    const halfwayThrough = Math.floor(guests.length / 2)
 | 
				
			||||||
    const arrayFirstHalf = guests.slice(0, halfwayThrough);
 | 
					    const arrayFirstHalf = guests.slice(0, halfwayThrough);
 | 
				
			||||||
    const arraySecondHalf = guests.slice(halfwayThrough, guests.length);
 | 
					    const arraySecondHalf = guests.slice(halfwayThrough, guests.length);
 | 
				
			||||||
@ -42,24 +37,11 @@ function RectangularTable({ table }: { table: TableType }) {
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function RoundedTable({ table }: { table: TableType }) {
 | 
					function RoundedTable({ guests }: { guests: Guest[] }) {
 | 
				
			||||||
  const guests = table.guests;
 | 
					 | 
				
			||||||
    const size = 500
 | 
					    const size = 500
 | 
				
			||||||
    const rotation = 360 / guests.length
 | 
					    const rotation = 360 / guests.length
 | 
				
			||||||
 | 
					 | 
				
			||||||
  const className = (penalty: number) => {
 | 
					 | 
				
			||||||
    return clsx("px-2 tooltip-cohesion", {
 | 
					 | 
				
			||||||
      "hidden": penalty === 0,
 | 
					 | 
				
			||||||
      "text-orange-300": penalty <= 5,
 | 
					 | 
				
			||||||
      "text-orange-500": penalty > 5 && penalty <= 10,
 | 
					 | 
				
			||||||
      "text-orange-700": penalty > 10,
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
    <div className={`m-12 rounded-full bg-cyan-800 relative z-0 grid flex items-center justify-items-center`} style={{ width: `${size}px`, height: `${size}px` }}>
 | 
					        <div className={`m-12 rounded-full bg-cyan-800 relative z-0`} style={{ width: `${size}px`, height: `${size}px` }}>
 | 
				
			||||||
 | 
					 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                guests.map((guest, index) => {
 | 
					                guests.map((guest, index) => {
 | 
				
			||||||
                    return (
 | 
					                    return (
 | 
				
			||||||
@ -73,35 +55,15 @@ function RoundedTable({ table }: { table: TableType }) {
 | 
				
			|||||||
                    )
 | 
					                    )
 | 
				
			||||||
                })
 | 
					                })
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="bg-zinc-200 w-48 h-12 p-3 flex flex-row rounded z-10">
 | 
					 | 
				
			||||||
        <div className="px-2 text-slate-800">{`Table #${table.number}`}</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <Tooltip target=".tooltip-cohesion" />
 | 
					 | 
				
			||||||
        <Tooltip target=".tooltip-size" />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <RectangleGroupIcon
 | 
					 | 
				
			||||||
          className={className(table.discomfort.breakdown.cohesionPenalty)}
 | 
					 | 
				
			||||||
          data-pr-tooltip={`Cohesion penalty: ${Math.round(table.discomfort.breakdown.cohesionPenalty)}`}
 | 
					 | 
				
			||||||
          data-pr-position="top"
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <UserGroupIcon
 | 
					 | 
				
			||||||
          className={className(table.discomfort.breakdown.tableSizePenalty)}
 | 
					 | 
				
			||||||
          data-pr-tooltip={`Table size penalty: ${Math.round(table.discomfort.breakdown.tableSizePenalty)}`}
 | 
					 | 
				
			||||||
          data-pr-position="top"
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function Table({ table, style }: { table: TableType, style: "rectangular" | "rounded" }) {
 | 
					export function Table({ guests, style }: { guests: Guest[], style: "rectangular" | "rounded" }) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <>
 | 
					        <>
 | 
				
			||||||
      {style === "rectangular" && <RectangularTable table={table} />}
 | 
					            {style === "rectangular" && <RectangularTable guests={guests} />}
 | 
				
			||||||
      {style === "rounded" && <RoundedTable table={table} />}
 | 
					            {style === "rounded" && <RoundedTable guests={guests} />}
 | 
				
			||||||
        </>
 | 
					        </>
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -1,41 +1,45 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { GlobalSummary as Summary} from '@/app/lib/definitions';
 | 
					 | 
				
			||||||
import { MainCard, SecondaryCard } from '../components/dashboard-cards';
 | 
					import { MainCard, SecondaryCard } from '../components/dashboard-cards';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function GlobalSummary({ summary }: { summary: Summary }) {
 | 
					export default async function GlobalSummary() {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <div className="my-4">
 | 
					        <div className="my-4">
 | 
				
			||||||
      <div className="flex flex-row w-full my-2">
 | 
					 | 
				
			||||||
        <div className="flex flex-col">
 | 
					 | 
				
			||||||
          <MainCard amount={`${summary.expenses.projected.total}€`} title="Projected" subtitle={`${summary.expenses.projected.guests} guests`} style="blue" iconName="ArrowTrendingUpIcon" />
 | 
					 | 
				
			||||||
          <MainCard amount={`${Math.round(summary.expenses.projected.total / summary.expenses.projected.guests)}€`} title="/ guest" iconName="UserIcon" style='blue' />
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        <div className="flex flex-col">
 | 
					 | 
				
			||||||
          <MainCard amount={`${summary.expenses.confirmed.total}€`} title="Min." subtitle={`${summary.expenses.confirmed.guests} guests`} iconName="ChevronDoubleDownIcon" style="green" />
 | 
					 | 
				
			||||||
          <MainCard amount={`${Math.round(summary.expenses.confirmed.total / summary.expenses.confirmed.guests)}€`} title="/ guest" iconName="UserIcon" style='green' />
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div className="flex flex-row w-full my-2">
 | 
				
			||||||
 | 
					                <MainCard style="green" amount="65000€" title="Projected" subtitle="150 guests" iconName="ArrowTrendingUpIcon" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <div className="flex flex-col">
 | 
				
			||||||
 | 
					                    <MainCard amount="10000€" title="Paid already" iconName="Square3Stack3DIcon" style='blue' />
 | 
				
			||||||
 | 
					                    <MainCard amount="198€" title="/ guest" iconName="UserIcon" style='blue' />
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
                <div className="flex flex-col">
 | 
					                <div className="flex flex-col">
 | 
				
			||||||
          <MainCard amount={`${summary.expenses.status.paid}€`} title="Paid already" subtitle={`${Math.round(summary.expenses.status.paid / summary.expenses.projected.total * 100)}% of projected`} iconName="CheckIcon" style='blue' />
 | 
					                    <MainCard amount="78000€" title="Max." subtitle='200 guests' iconName="ChevronDoubleUpIcon" style="orange" />
 | 
				
			||||||
          <MainCard amount={`${summary.expenses.projected.total - summary.expenses.status.paid}€`} title="To pay" subtitle={`${100 - Math.round(summary.expenses.status.paid / summary.expenses.projected.total * 100)}% of projected`} iconName="BanknotesIcon" style="orange" />
 | 
					                    <MainCard amount="45000€" title="Min." subtitle="125 guests" iconName="ChevronDoubleDownIcon" style="green" />
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div className="flex flex-row w-full my-2">
 | 
					            <div className="flex flex-row w-full my-2">
 | 
				
			||||||
        <MainCard style="blue" amount={summary.guests.total.toString()} title="Invites sent" iconName="UsersIcon" />
 | 
					                <MainCard style="blue" amount="150" title="Invites sent" iconName="UsersIcon" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <div className="flex flex-col">
 | 
					                <div className="flex flex-col">
 | 
				
			||||||
          <SecondaryCard amount={`${Math.round(summary.guests.confirmed / summary.guests.total * 100)}%`} title={`confirmed (${summary.guests.confirmed} guests)`} iconName="CheckIcon" style='green' />
 | 
					                    <SecondaryCard amount="31%" title="confirmed (27 guests)" iconName="CheckIcon" style='green' />
 | 
				
			||||||
          <SecondaryCard amount={`${Math.round(summary.guests.declined / summary.guests.total * 100)}%`} title={`declined (${summary.guests.declined} guests)`} iconName="XMarkIcon" style='red' />
 | 
					                    <SecondaryCard amount="5%" title="declined (8 guests)" iconName="XMarkIcon" style='red' />
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <div className="flex flex-col">
 | 
					                <div className="flex flex-col">
 | 
				
			||||||
          <SecondaryCard amount={`${Math.round(summary.guests.tentative / summary.guests.total * 100)}%`} title={`tentative (${summary.guests.tentative} guests)`} iconName="QuestionMarkCircleIcon" style='orange' />
 | 
					                <SecondaryCard amount="17%" title="tentative (14 guests)" iconName="QuestionMarkCircleIcon" style='orange' />
 | 
				
			||||||
          <SecondaryCard amount={`${Math.round(summary.guests.invited / summary.guests.total * 100)}%`} title={`awaiting (${summary.guests.invited} guests)`} iconName="EllipsisHorizontalIcon" style='gray' />
 | 
					                    <SecondaryCard amount="65%" title="awaiting (72 guests)" iconName="EllipsisHorizontalIcon" style='gray' />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div className="flex flex-row w-full my-2">
 | 
				
			||||||
 | 
					                <MainCard style="blue" amount="5" title="Table simulations" iconName="ServerStackIcon" />
 | 
				
			||||||
 | 
					                <MainCard style="blue" amount="9" title="Bus simulations" iconName="TruckIcon" />
 | 
				
			||||||
 | 
					                <MainCard style="blue" amount="98" title="QR codes" iconName="QrCodeIcon" />
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Loading() {
 | 
					export default function Loading() {
 | 
				
			||||||
    return <div>Loading...</div>;
 | 
					    return <div>Loading...</div>;
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client'
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -6,30 +6,24 @@ import {
 | 
				
			|||||||
  UserGroupIcon,
 | 
					  UserGroupIcon,
 | 
				
			||||||
  RectangleGroupIcon,
 | 
					  RectangleGroupIcon,
 | 
				
			||||||
  BanknotesIcon,
 | 
					  BanknotesIcon,
 | 
				
			||||||
  GlobeAltIcon,
 | 
					 | 
				
			||||||
} from '@heroicons/react/24/outline';
 | 
					} from '@heroicons/react/24/outline';
 | 
				
			||||||
import Link from 'next/link';
 | 
					import Link from 'next/link';
 | 
				
			||||||
import { usePathname } from 'next/navigation';
 | 
					import { usePathname } from 'next/navigation';
 | 
				
			||||||
import clsx from 'clsx';
 | 
					import clsx from 'clsx';
 | 
				
			||||||
import { getSlug } from '@/app/lib/utils';
 | 
					import { getSlug } from '@/app/lib/utils';
 | 
				
			||||||
import { useEffect, useState } from 'react';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Map of links to display in the side navigation.
 | 
					// Map of links to display in the side navigation.
 | 
				
			||||||
// Depending on the size of the application, this would be stored in a database.
 | 
					// Depending on the size of the application, this would be stored in a database.
 | 
				
			||||||
 | 
					const links = [
 | 
				
			||||||
 | 
					  { name: 'Guests', href: `/${getSlug()}/dashboard/guests`, icon: UserGroupIcon },
 | 
				
			||||||
 | 
					  { name: 'Expenses', href: `/${getSlug()}/dashboard/expenses`, icon: BanknotesIcon },
 | 
				
			||||||
 | 
					  { name: 'Table distributions', href: `/${getSlug()}/dashboard/tables`, icon: RectangleGroupIcon },
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function NavLinks() {
 | 
					export default function NavLinks() {
 | 
				
			||||||
  const pathname = usePathname();
 | 
					  const pathname = usePathname();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [slug, setSlug] = useState<string>("default");
 | 
					 | 
				
			||||||
  useEffect(() => { setSlug(getSlug()) }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const links = [
 | 
					 | 
				
			||||||
    { name: 'Guests', href: `/${slug}/dashboard/guests`, icon: UserGroupIcon },
 | 
					 | 
				
			||||||
    { name: 'Expenses', href: `/${slug}/dashboard/expenses`, icon: BanknotesIcon },
 | 
					 | 
				
			||||||
    { name: 'Table distributions', href: `/${slug}/dashboard/tables`, icon: RectangleGroupIcon },
 | 
					 | 
				
			||||||
    { name: 'Website builder', href: `/${slug}/dashboard/website`, icon: GlobeAltIcon },
 | 
					 | 
				
			||||||
  ];
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
      {links.map((link) => {
 | 
					      {links.map((link) => {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -9,23 +9,15 @@ import { gloriaHallelujah } from '@/app/ui/fonts';
 | 
				
			|||||||
import { logout } from '@/app/api/authentication';
 | 
					import { logout } from '@/app/api/authentication';
 | 
				
			||||||
import { useRouter } from 'next/navigation';
 | 
					import { useRouter } from 'next/navigation';
 | 
				
			||||||
import { getSlug } from '@/app/lib/utils';
 | 
					import { getSlug } from '@/app/lib/utils';
 | 
				
			||||||
import { useEffect, useState } from 'react';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function SideNav() {
 | 
					export default function SideNav() {
 | 
				
			||||||
  const router = useRouter();
 | 
					  const router = useRouter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [slug, setSlug] = useState<string>("default");
 | 
					 | 
				
			||||||
  useEffect(() => { setSlug(getSlug()) }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const [currentUser, setCurrentUser] = useState<{ email: string } | null>(null);
 | 
					 | 
				
			||||||
  useEffect(() => { setCurrentUser(JSON.parse(localStorage.getItem('currentUser') || '{}')) }, []);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="flex h-full flex-col px-3 py-4 md:px-2">
 | 
					    <div className="flex h-full flex-col px-3 py-4 md:px-2">
 | 
				
			||||||
      <Link
 | 
					      <Link
 | 
				
			||||||
        className="mb-2 flex h-20 items-center justify-start rounded-md bg-blue-600 p-4 md:h-20"
 | 
					        className="mb-2 flex h-20 items-center justify-start rounded-md bg-blue-600 p-4 md:h-20"
 | 
				
			||||||
        href={`/${slug}/dashboard`}
 | 
					        href="/dashboard/guests"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div className={`${gloriaHallelujah.className} "w-32 text-white md:w-40 antialiased`}>
 | 
					        <div className={`${gloriaHallelujah.className} "w-32 text-white md:w-40 antialiased`}>
 | 
				
			||||||
          <h1>Wedding Planner</h1>
 | 
					          <h1>Wedding Planner</h1>
 | 
				
			||||||
@ -34,14 +26,14 @@ export default function SideNav() {
 | 
				
			|||||||
      <div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
 | 
					      <div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
 | 
				
			||||||
        <NavLinks />
 | 
					        <NavLinks />
 | 
				
			||||||
        <div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
 | 
					        <div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
 | 
				
			||||||
        <span>Logged in as {currentUser?.email}</span>
 | 
					        <span>Logged in as {JSON.parse(localStorage.getItem('currentUser') || '{}').email}</span>
 | 
				
			||||||
        <button
 | 
					        <button
 | 
				
			||||||
          className="flex h-[48px] w-full grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3"
 | 
					          className="flex h-[48px] w-full grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3"
 | 
				
			||||||
          onClick={() => {
 | 
					          onClick={() => {
 | 
				
			||||||
            logout({
 | 
					            logout({
 | 
				
			||||||
              onLogout: () => {
 | 
					              onLogout: () => {
 | 
				
			||||||
                localStorage.clear();
 | 
					                localStorage.clear();
 | 
				
			||||||
                router.push(`/${slug}`);
 | 
					                router.push(`/${getSlug()}`);
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
          }}
 | 
					          }}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client'
 | 
					'use client'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Inter, Lusitana, Gloria_Hallelujah} from 'next/font/google';
 | 
					import { Inter, Lusitana, Gloria_Hallelujah} from 'next/font/google';
 | 
				
			||||||
 
 | 
					 
 | 
				
			||||||
 | 
				
			|||||||
@ -1,19 +1,19 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AbstractApi } from '@/app/api/abstract-api';
 | 
					 | 
				
			||||||
import { Group, GroupSerializer } from '@/app/lib/group';
 | 
					import { Group, GroupSerializer } from '@/app/lib/group';
 | 
				
			||||||
import { AdjustmentsHorizontalIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
 | 
					import TableOfContents from '../components/table-of-contents';
 | 
				
			||||||
 | 
					import { MapPinIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
 | 
				
			||||||
 | 
					import { AbstractApi } from '@/app/api/abstract-api';
 | 
				
			||||||
 | 
					import { TreeTable } from 'primereact/treetable';
 | 
				
			||||||
import { Column } from 'primereact/column';
 | 
					import { Column } from 'primereact/column';
 | 
				
			||||||
import { TreeNode } from 'primereact/treenode';
 | 
					import { TreeNode } from 'primereact/treenode';
 | 
				
			||||||
import { TreeTable } from 'primereact/treetable';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function GroupsTable({ groups, onUpdate, onEdit, onEditAffinities }: {
 | 
					export default function GroupsTable({ groups, onUpdate, onEdit }: {
 | 
				
			||||||
  groups: Group[],
 | 
					  groups: Group[],
 | 
				
			||||||
  onUpdate: () => void,
 | 
					  onUpdate: () => void,
 | 
				
			||||||
  onEdit: (group: Group) => void,
 | 
					  onEdit: (group: Group) => void,
 | 
				
			||||||
  onEditAffinities: (group: Group) => void,
 | 
					 | 
				
			||||||
}) {
 | 
					}) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const api = new AbstractApi<Group>();
 | 
					  const api = new AbstractApi<Group>();
 | 
				
			||||||
@ -21,14 +21,8 @@ export default function GroupsTable({ groups, onUpdate, onEdit, onEditAffinities
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const actions = (group: Group) => (
 | 
					  const actions = (group: Group) => (
 | 
				
			||||||
    <div className="flex flex-row items-center">
 | 
					    <div className="flex flex-row items-center">
 | 
				
			||||||
      <TrashIcon className='size-6 cursor-pointer' onClick={() => {
 | 
					      <TrashIcon className='size-6 cursor-pointer' onClick={() => { api.destroy(serializer, group, onUpdate) }} />
 | 
				
			||||||
        if (window.confirm(`Are you sure you want to delete guest "${group.name}"?`)) {
 | 
					 | 
				
			||||||
          api.destroy(serializer, group, onUpdate)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
      <PencilIcon className='size-6 cursor-pointer' onClick={() => onEdit(group)} />
 | 
					      <PencilIcon className='size-6 cursor-pointer' onClick={() => onEdit(group)} />
 | 
				
			||||||
      <AdjustmentsHorizontalIcon className='size-6 cursor-pointer' onClick={() => onEditAffinities(group)} />
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -40,7 +34,6 @@ export default function GroupsTable({ groups, onUpdate, onEdit, onEditAffinities
 | 
				
			|||||||
    return acc;
 | 
					    return acc;
 | 
				
			||||||
  }, new Map());
 | 
					  }, new Map());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  groups.forEach(group => group.children = []);
 | 
					 | 
				
			||||||
  groups.forEach(group => {
 | 
					  groups.forEach(group => {
 | 
				
			||||||
    if (group.parentId) {
 | 
					    if (group.parentId) {
 | 
				
			||||||
      const parent = index.get(group.parentId);
 | 
					      const parent = index.get(group.parentId);
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import Skeleton from '@/app/ui/skeleton';
 | 
					import Skeleton from '@/app/ui/skeleton';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,9 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { AbstractApi } from '@/app/api/abstract-api';
 | 
					import { AbstractApi } from '@/app/api/abstract-api';
 | 
				
			||||||
import { Guest, GuestSerializer } from '@/app/lib/guest';
 | 
					import { Guest , GuestSerializer} from '@/app/lib/guest';
 | 
				
			||||||
import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
 | 
					import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
 | 
				
			||||||
import clsx from 'clsx';
 | 
					import clsx from 'clsx';
 | 
				
			||||||
import TableOfContents from '../components/table-of-contents';
 | 
					import TableOfContents from '../components/table-of-contents';
 | 
				
			||||||
@ -28,7 +28,7 @@ export default function guestsTable({ guests, onUpdate, onEdit }: {
 | 
				
			|||||||
            {guest.name}
 | 
					            {guest.name}
 | 
				
			||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
          <td className="px-6 py-4">
 | 
					          <td className="px-6 py-4">
 | 
				
			||||||
            {guest.group?.name}
 | 
					            {guest.group_name}
 | 
				
			||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
          <td className="px-6 py-4">
 | 
					          <td className="px-6 py-4">
 | 
				
			||||||
            <span className="flex items-center text-sm dark:text-white me-3">
 | 
					            <span className="flex items-center text-sm dark:text-white me-3">
 | 
				
			||||||
@ -48,12 +48,7 @@ export default function guestsTable({ guests, onUpdate, onEdit }: {
 | 
				
			|||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
          <td>
 | 
					          <td>
 | 
				
			||||||
            <div className="flex flex-row items-center">
 | 
					            <div className="flex flex-row items-center">
 | 
				
			||||||
              <TrashIcon className='size-6 cursor-pointer' onClick={() => {
 | 
					              <TrashIcon className='size-6 cursor-pointer' onClick={() => { api.destroy(serializer, guest, onUpdate)}} />
 | 
				
			||||||
                if (window.confirm(`Are you sure you want to delete guest "${guest.name}"?`)) {
 | 
					 | 
				
			||||||
                  api.destroy(serializer, guest, onUpdate)
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
              <PencilIcon className='size-6 cursor-pointer' onClick={() => onEdit(guest)} />
 | 
					              <PencilIcon className='size-6 cursor-pointer' onClick={() => onEdit(guest)} />
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          </td>
 | 
					          </td>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,218 +0,0 @@
 | 
				
			|||||||
/* 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>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
'use client';
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
/* Copyright (C) 2024-2025 LibreWeddingPlanner contributors*/
 | 
					/* Copyright (C) 2024 Manuel Bustillo*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Skeleton({ className }: { className: string }) {
 | 
					export default function Skeleton({ className }: { className: string }) {
 | 
				
			||||||
    return <div className={`bg-slate-200 motion-safe:animate-pulse rounded ${className}`} />;
 | 
					    return <div className={`bg-slate-200 motion-safe:animate-pulse rounded ${className}`} />;
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										33
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								package.json
									
									
									
									
									
								
							@ -6,39 +6,32 @@
 | 
				
			|||||||
    "start": "next start"
 | 
					    "start": "next start"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
    "@atlaskit/pragmatic-drag-and-drop": "^1.7.0",
 | 
					 | 
				
			||||||
    "@heroicons/react": "^2.1.4",
 | 
					    "@heroicons/react": "^2.1.4",
 | 
				
			||||||
    "@tailwindcss/forms": "^0.5.7",
 | 
					    "@tailwindcss/forms": "^0.5.7",
 | 
				
			||||||
    "@tiptap/pm": "^2.14.0",
 | 
					    "autoprefixer": "10.4.20",
 | 
				
			||||||
    "@tiptap/react": "^2.14.0",
 | 
					    "bcrypt": "^5.1.1",
 | 
				
			||||||
    "@tiptap/starter-kit": "^2.14.0",
 | 
					 | 
				
			||||||
    "autoprefixer": "10.4.21",
 | 
					 | 
				
			||||||
    "bcrypt": "^6.0.0",
 | 
					 | 
				
			||||||
    "clsx": "^2.1.1",
 | 
					    "clsx": "^2.1.1",
 | 
				
			||||||
    "dompurify": "^3.2.6",
 | 
					    "next": "15.0.3",
 | 
				
			||||||
    "next": "15.4.5",
 | 
					    "next-auth": "5.0.0-beta.25",
 | 
				
			||||||
    "next-auth": "5.0.0-beta.29",
 | 
					    "postcss": "8.4.49",
 | 
				
			||||||
    "postcss": "8.5.6",
 | 
					 | 
				
			||||||
    "primeicons": "^7.0.0",
 | 
					    "primeicons": "^7.0.0",
 | 
				
			||||||
    "primereact": "^10.8.2",
 | 
					    "primereact": "^10.8.2",
 | 
				
			||||||
    "react": "19.0.0-rc-f38c22b244-20240704",
 | 
					    "react": "19.0.0-rc-f38c22b244-20240704",
 | 
				
			||||||
    "react-dom": "19.0.0-rc-f38c22b244-20240704",
 | 
					    "react-dom": "19.0.0-rc-f38c22b244-20240704",
 | 
				
			||||||
    "tailwindcss": "3.4.17",
 | 
					    "tailwindcss": "3.4.16",
 | 
				
			||||||
    "typescript": "5.8.3",
 | 
					    "typescript": "5.7.2",
 | 
				
			||||||
    "use-debounce": "^10.0.1",
 | 
					    "use-debounce": "^10.0.1",
 | 
				
			||||||
    "uuid": "11.1.0",
 | 
					    "zod": "^3.23.8"
 | 
				
			||||||
    "zod": "^4.0.0"
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@playwright/test": "^1.52.0",
 | 
					    "@playwright/test": "^1.46.0",
 | 
				
			||||||
    "@types/bcrypt": "^5.0.2",
 | 
					    "@types/bcrypt": "^5.0.2",
 | 
				
			||||||
    "@types/node": "24.3.1",
 | 
					    "@types/node": "22.10.1",
 | 
				
			||||||
    "@types/react": "18.3.23",
 | 
					    "@types/react": "18.3.12",
 | 
				
			||||||
    "@types/react-dom": "18.3.7",
 | 
					    "@types/react-dom": "18.3.1"
 | 
				
			||||||
    "wait-on": "^8.0.3"
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "engines": {
 | 
					  "engines": {
 | 
				
			||||||
    "node": ">=23.0.0"
 | 
					    "node": ">=23.0.0"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad"
 | 
					  "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -43,6 +43,11 @@ export default defineConfig({
 | 
				
			|||||||
      use: { ...devices['Desktop Firefox'] },
 | 
					      use: { ...devices['Desktop Firefox'] },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      name: 'webkit',
 | 
				
			||||||
 | 
					      use: { ...devices['Desktop Safari'] },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /* Test against mobile viewports. */
 | 
					    /* Test against mobile viewports. */
 | 
				
			||||||
    // {
 | 
					    // {
 | 
				
			||||||
    //   name: 'Mobile Chrome',
 | 
					    //   name: 'Mobile Chrome',
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										1908
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1908
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 506 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								public/stamp.png
									
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/stamp.png
									
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 45 KiB  | 
@ -1,82 +0,0 @@
 | 
				
			|||||||
import { test, expect, Page } from '@playwright/test'
 | 
					 | 
				
			||||||
import mockGroupsAPI from './mocks/groups';
 | 
					 | 
				
			||||||
import mockGuestsAPI from './mocks/guests';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
test('should allow CRUD on groups', async ({ page }) => {
 | 
					 | 
				
			||||||
  await mockGuestsAPI({ page });
 | 
					 | 
				
			||||||
  await mockGroupsAPI({ page });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await page.goto('/default/dashboard/guests');
 | 
					 | 
				
			||||||
  await page.getByRole('tab', { name: 'Groups' }).click();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByRole('button', { name: 'Add new' })).toBeVisible();
 | 
					 | 
				
			||||||
  await expect(page.getByRole('button', { name: 'Reset affinities' })).toBeVisible();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // List all groups
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row')).toHaveCount(3); // 1 header row + 2 data rows
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(0).getByRole('columnheader').nth(0)).toHaveText('Name');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(0).getByRole('columnheader').nth(1)).toHaveText('Color');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(0).getByRole('columnheader').nth(2)).toHaveText('Confirmed');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(0).getByRole('columnheader').nth(3)).toHaveText('Tentative');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(0).getByRole('columnheader').nth(4)).toHaveText('Pending');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(0).getByRole('columnheader').nth(5)).toHaveText('Declined');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(0).getByRole('columnheader').nth(6)).toHaveText('Considered');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(0).getByRole('columnheader').nth(7)).toHaveText('Total');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(0).getByRole('columnheader').nth(8)).toHaveText('Actions');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toContainText('Pam\'s family');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(4)).toHaveText('1');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(5)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(6)).toHaveText('2');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(7)).toHaveText('3');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).locator('svg:visible')).toHaveCount(3);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).getByRole('cell').nth(0)).toContainText('Pam\'s work');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).getByRole('cell').nth(2)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('2');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).getByRole('cell').nth(4)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).getByRole('cell').nth(5)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).getByRole('cell').nth(6)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).getByRole('cell').nth(7)).toHaveText('2');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).locator('svg:visible')).toHaveCount(3);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Add a new group
 | 
					 | 
				
			||||||
  await page.getByRole('button', { name: 'Add new' }).click();
 | 
					 | 
				
			||||||
  const dialog = page.getByRole('dialog');
 | 
					 | 
				
			||||||
  await expect(dialog).toBeVisible();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await dialog.getByLabel('Name').fill("Pam's friends");
 | 
					 | 
				
			||||||
  await dialog.getByRole('button', { name: 'Create' }).click();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row')).toHaveCount(4); // 1 header row + 3 data rows
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(dialog).not.toBeVisible();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toContainText('Pam\'s friends');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(4)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(5)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(6)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(7)).toHaveText('0');
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).locator('svg:visible')).toHaveCount(3);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Modify the newly added group
 | 
					 | 
				
			||||||
  await page.getByRole('row').nth(1).locator('svg').nth(2).click(); // Click edit icon
 | 
					 | 
				
			||||||
  await expect(dialog).toBeVisible();
 | 
					 | 
				
			||||||
  await expect(dialog.getByLabel('Name')).toHaveValue("Pam's friends");
 | 
					 | 
				
			||||||
  await dialog.getByLabel('Name').fill('Pam\'s best friends');
 | 
					 | 
				
			||||||
  await dialog.getByRole('button', { name: 'Update' }).click();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toContainText('Pam\'s best friends');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Delete the newly added group
 | 
					 | 
				
			||||||
  page.on('dialog', dialog => dialog.accept());
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await page.getByRole('row').nth(1).locator('svg').nth(1).click(); // Click delete icon
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row')).toHaveCount(3); // 1 header row + 2 data rows
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
@ -1,76 +1,100 @@
 | 
				
			|||||||
import { expect, test } from '@playwright/test';
 | 
					import { test, expect, Page } from '@playwright/test'
 | 
				
			||||||
import mockGroupsAPI from './mocks/groups';
 | 
					 | 
				
			||||||
import mockGuestsAPI from './mocks/guests';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('should allow CRUD on guests', async ({ page }) => {
 | 
					const mockGuestsAPI = ({ page }: { page: Page }) => {
 | 
				
			||||||
 | 
					  page.route('*/**/api/default/guests', async route => {
 | 
				
			||||||
 | 
					    const json = [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "id": "f4a09c28-40ea-4553-90a5-96935a59cac6",
 | 
				
			||||||
 | 
					        "status": "tentative",
 | 
				
			||||||
 | 
					        "name": "Kristofer Rohan DVM",
 | 
				
			||||||
 | 
					        "group": {
 | 
				
			||||||
 | 
					          "id": "2fcb8b22-6b07-4c34-92e3-a2535dbc5b14",
 | 
				
			||||||
 | 
					          "name": "Childhood friends",
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "id": "bd585c40-0937-4cde-960a-bb23acfd6f18",
 | 
				
			||||||
 | 
					        "status": "invited",
 | 
				
			||||||
 | 
					        "name": "Olevia Quigley Jr.",
 | 
				
			||||||
 | 
					        "group": {
 | 
				
			||||||
 | 
					          "id": "da8edf26-3e1e-4cbb-b985-450c49fffe01",
 | 
				
			||||||
 | 
					          "name": "Work",
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await route.fulfill({ json })
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const mockGroupsAPI = ({ page }: { page: Page }) => {
 | 
				
			||||||
 | 
					  page.route('*/**/api/default/groups', async route => {
 | 
				
			||||||
 | 
					    const json = [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "id": "ee44ffb9-1147-4842-a378-9eaeb0f0871a",
 | 
				
			||||||
 | 
					        "name": "Pam's family",
 | 
				
			||||||
 | 
					        "icon": "pi pi-users",
 | 
				
			||||||
 | 
					        "parent_id": "cd9645e1-02c6-4fb9-bba6-1a960754b01c",
 | 
				
			||||||
 | 
					        "color": "#ff0000",
 | 
				
			||||||
 | 
					        "total": 3,
 | 
				
			||||||
 | 
					        "considered": 2,
 | 
				
			||||||
 | 
					        "invited": 1,
 | 
				
			||||||
 | 
					        "confirmed": 0,
 | 
				
			||||||
 | 
					        "declined": 0,
 | 
				
			||||||
 | 
					        "tentative": 0
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        "id": "c8bda6ca-d8af-4bb8-b2bf-e6ec1c21b1e6",
 | 
				
			||||||
 | 
					        "name": "Pam's work",
 | 
				
			||||||
 | 
					        "icon": "pi pi-desktop",
 | 
				
			||||||
 | 
					        "parent_id": "cd9645e1-02c6-4fb9-bba6-1a960754b01c",
 | 
				
			||||||
 | 
					        "color": "#00ff00",
 | 
				
			||||||
 | 
					        "total": 2,
 | 
				
			||||||
 | 
					        "considered": 0,
 | 
				
			||||||
 | 
					        "invited": 0,
 | 
				
			||||||
 | 
					        "confirmed": 0,
 | 
				
			||||||
 | 
					        "declined": 0,
 | 
				
			||||||
 | 
					        "tentative": 2
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await route.fulfill({ json })
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test('should display the list of guests', async ({ page }) => {
 | 
				
			||||||
  await mockGuestsAPI({ page });
 | 
					  await mockGuestsAPI({ page });
 | 
				
			||||||
  await mockGroupsAPI({ page });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  await page.goto('/default/dashboard/guests');
 | 
					  await page.goto('/default/dashboard/guests');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  await expect(page.getByRole('button', { name: 'Add new' })).toBeVisible();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByRole('tab', { name: 'Guests' })).toBeVisible();
 | 
					 | 
				
			||||||
  await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible();
 | 
					  await expect(page.getByRole('tab', { name: 'Groups' })).toBeVisible();
 | 
				
			||||||
  await expect(page.getByRole('tab', { name: 'Invitations' })).toBeVisible();
 | 
					  await expect(page.getByRole('tab', { name: 'Guests' })).toBeVisible();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // List all guests
 | 
					 | 
				
			||||||
  await expect(page.getByText('There are 2 elements in the list')).toBeVisible();
 | 
					  await expect(page.getByText('There are 2 elements in the list')).toBeVisible();
 | 
				
			||||||
  await expect(page.getByRole('row')).toHaveCount(3); // 1 header row + 2 data rows
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: 'Kristofer Rohan DVM' })).toBeVisible();
 | 
					  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: 'Kristofer Rohan DVM' })).toBeVisible();
 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: "Pam's family" })).toBeVisible();
 | 
					  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: 'Childhood friends' })).toBeVisible();
 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: 'Tentative' })).toBeVisible();
 | 
					  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: 'Tentative' })).toBeVisible();
 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).locator('svg')).toHaveCount(2);
 | 
					  await expect(page.getByRole('row').nth(1).getByRole('button', { name: 'Confirm' })).toBeVisible();
 | 
				
			||||||
 | 
					  await expect(page.getByRole('row').nth(1).getByRole('button', { name: 'Decline' })).toBeVisible();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).getByRole('cell', { name: 'Olevia Quigley Jr.' })).toBeVisible();
 | 
					  await expect(page.getByRole('row').nth(2).getByRole('cell', { name: 'Olevia Quigley Jr.' })).toBeVisible();
 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).getByRole('cell', { name: "Pam's work" })).toBeVisible();
 | 
					  await expect(page.getByRole('row').nth(2).getByRole('cell', { name: 'Work' })).toBeVisible();
 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).getByRole('cell', { name: 'Invited' })).toBeVisible();
 | 
					  await expect(page.getByRole('row').nth(2).getByRole('cell', { name: 'Invited' })).toBeVisible();
 | 
				
			||||||
  await expect(page.getByRole('row').nth(2).locator('svg')).toHaveCount(2);
 | 
					  await expect(page.getByRole('row').nth(2).getByRole('button', { name: 'Confirm' })).toBeVisible();
 | 
				
			||||||
 | 
					  await expect(page.getByRole('row').nth(2).getByRole('button', { name: 'Tentative' })).toBeVisible();
 | 
				
			||||||
  // Add a new guest
 | 
					  await expect(page.getByRole('row').nth(2).getByRole('button', { name: 'Decline' })).toBeVisible();
 | 
				
			||||||
  await page.getByRole('button', { name: 'Add new' }).click();
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  await page.getByRole('dialog').getByLabel('Name').fill('John Snow');
 | 
					test('should display the list of groups', async ({ page }) => {
 | 
				
			||||||
 | 
					  await mockGroupsAPI({ page });
 | 
				
			||||||
  await page.locator('#group').click();
 | 
					
 | 
				
			||||||
  await page.getByRole('option', { name: "Pam's work" }).click();
 | 
					  await page.goto('/default/dashboard/guests');
 | 
				
			||||||
 | 
					  await page.getByRole('tab', { name: 'Groups' }).click();
 | 
				
			||||||
  await page.locator('#status').click();
 | 
					
 | 
				
			||||||
  await page.getByRole('option', { name: 'Invited' }).click();
 | 
					  await expect(page.getByText('There are 2 elements in the list')).toBeVisible();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
 | 
					  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: "Pam's family" })).toBeVisible();
 | 
				
			||||||
 | 
					  await expect(page.getByRole('row').nth(2).getByRole('cell', { name: "Pam's work" })).toBeVisible();
 | 
				
			||||||
  await expect(page.getByText('There are 3 elements in the list')).toBeVisible();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: 'John Snow' })).toBeVisible();
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: "Pam\'s work" })).toBeVisible();
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: 'Invited' })).toBeVisible();
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).locator('svg')).toHaveCount(2);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Edit the just-added John Snow
 | 
					 | 
				
			||||||
  await page.getByRole('row').nth(1).locator('svg').nth(1).click(); // Click edit icon
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const dialog = page.getByRole('dialog');
 | 
					 | 
				
			||||||
  await expect(dialog.getByLabel('Name')).toHaveValue('John Snow');
 | 
					 | 
				
			||||||
  await dialog.getByLabel('Name').fill('John Fire');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await dialog.locator('#group').click();
 | 
					 | 
				
			||||||
  await page.getByRole('option', { name: "Pam's family" }).click();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await dialog.locator('#status').click();
 | 
					 | 
				
			||||||
  await page.getByRole('option', { name: 'Declined' }).click();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await dialog.getByRole('button', { name: 'Update' }).click();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: 'John Fire' })).toBeVisible();
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: 'Pam\'s Family' })).toBeVisible();
 | 
					 | 
				
			||||||
  await expect(page.getByRole('row').nth(1).getByRole('cell', { name: 'Declined' })).toBeVisible();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await expect(page.getByText('There are 3 elements in the list')).toBeVisible();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Delete John Fire
 | 
					 | 
				
			||||||
  page.on('dialog', dialog => dialog.accept());
 | 
					 | 
				
			||||||
  await page.getByRole('row').nth(1).locator('svg').nth(0).click(); // Click delete icon
 | 
					 | 
				
			||||||
  await expect(page.getByText('There are 2 elements in the list')).toBeVisible();
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
@ -1,86 +0,0 @@
 | 
				
			|||||||
import { Page } from "@playwright/test";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default async function mockGroupsAPI({ page }: { page: Page }): Promise<void> {
 | 
					 | 
				
			||||||
  page.route('*/**/api/default/groups', async route => {
 | 
					 | 
				
			||||||
    if (route.request().method() === 'GET') {
 | 
					 | 
				
			||||||
      const json = [
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          "id": "ee44ffb9-1147-4842-a378-9eaeb0f0871a",
 | 
					 | 
				
			||||||
          "name": "Pam's family",
 | 
					 | 
				
			||||||
          "icon": "pi pi-users",
 | 
					 | 
				
			||||||
          "parent_id": null,
 | 
					 | 
				
			||||||
          "color": "#ff0000",
 | 
					 | 
				
			||||||
          "attendance": {
 | 
					 | 
				
			||||||
            "total": 3,
 | 
					 | 
				
			||||||
            "considered": 2,
 | 
					 | 
				
			||||||
            "invited": 1,
 | 
					 | 
				
			||||||
            "confirmed": 0,
 | 
					 | 
				
			||||||
            "declined": 0,
 | 
					 | 
				
			||||||
            "tentative": 0
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          "id": "c8bda6ca-d8af-4bb8-b2bf-e6ec1c21b1e6",
 | 
					 | 
				
			||||||
          "name": "Pam's work",
 | 
					 | 
				
			||||||
          "icon": "pi pi-desktop",
 | 
					 | 
				
			||||||
          "parent_id": null,
 | 
					 | 
				
			||||||
          "color": "#00ff00",
 | 
					 | 
				
			||||||
          "attendance": {
 | 
					 | 
				
			||||||
            "total": 2,
 | 
					 | 
				
			||||||
            "considered": 0,
 | 
					 | 
				
			||||||
            "invited": 0,
 | 
					 | 
				
			||||||
            "confirmed": 0,
 | 
					 | 
				
			||||||
            "declined": 0,
 | 
					 | 
				
			||||||
            "tentative": 2
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await route.fulfill({ json })
 | 
					 | 
				
			||||||
    } else if (route.request().method() === 'POST') {
 | 
					 | 
				
			||||||
      const json = {
 | 
					 | 
				
			||||||
        "id": "4d55bc34-6f42-4e2e-82a1-71ae32da2466",
 | 
					 | 
				
			||||||
        "name": "Pam's friends",
 | 
					 | 
				
			||||||
        "icon": "pi pi-desktop",
 | 
					 | 
				
			||||||
        "parent_id": null,
 | 
					 | 
				
			||||||
        "color": "#0000ff",
 | 
					 | 
				
			||||||
        "attendance": {
 | 
					 | 
				
			||||||
          "total": 0,
 | 
					 | 
				
			||||||
          "considered": 0,
 | 
					 | 
				
			||||||
          "invited": 0,
 | 
					 | 
				
			||||||
          "confirmed": 0,
 | 
					 | 
				
			||||||
          "declined": 0,
 | 
					 | 
				
			||||||
          "tentative": 0
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await route.fulfill({ json })
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  page.route("*/**/api/default/groups/*", async route => {
 | 
					 | 
				
			||||||
    if (route.request().method() === 'PUT') {
 | 
					 | 
				
			||||||
      const json = {
 | 
					 | 
				
			||||||
        "id": "4d55bc34-6f42-4e2e-82a1-71ae32da2466",
 | 
					 | 
				
			||||||
        "name": "Pam's best friends",
 | 
					 | 
				
			||||||
        "icon": "pi pi-desktop",
 | 
					 | 
				
			||||||
        "parent_id": null,
 | 
					 | 
				
			||||||
        "color": "#0000ff",
 | 
					 | 
				
			||||||
        "attendance": {
 | 
					 | 
				
			||||||
          "total": 0,
 | 
					 | 
				
			||||||
          "considered": 0,
 | 
					 | 
				
			||||||
          "invited": 0,
 | 
					 | 
				
			||||||
          "confirmed": 0,
 | 
					 | 
				
			||||||
          "declined": 0,
 | 
					 | 
				
			||||||
          "tentative": 0
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await route.fulfill({ json });
 | 
					 | 
				
			||||||
    } else if (route.request().method() === 'DELETE') {
 | 
					 | 
				
			||||||
      const json = {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await route.fulfill({ json });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,61 +0,0 @@
 | 
				
			|||||||
import { Page } from "@playwright/test";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default async function mockGuestsAPI({ page }: { page: Page }): Promise<void> {
 | 
					 | 
				
			||||||
  page.route('*/**/api/default/guests', async route => {
 | 
					 | 
				
			||||||
    if (route.request().method() === 'GET') {
 | 
					 | 
				
			||||||
      const json = [
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          "id": "f4a09c28-40ea-4553-90a5-96935a59cac6",
 | 
					 | 
				
			||||||
          "status": "tentative",
 | 
					 | 
				
			||||||
          "name": "Kristofer Rohan DVM",
 | 
					 | 
				
			||||||
          "group": {
 | 
					 | 
				
			||||||
            "id": "ee44ffb9-1147-4842-a378-9eaeb0f0871a",
 | 
					 | 
				
			||||||
            "name": "Pam's family",
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          "id": "bd585c40-0937-4cde-960a-bb23acfd6f18",
 | 
					 | 
				
			||||||
          "status": "invited",
 | 
					 | 
				
			||||||
          "name": "Olevia Quigley Jr.",
 | 
					 | 
				
			||||||
          "group": {
 | 
					 | 
				
			||||||
            "id": "c8bda6ca-d8af-4bb8-b2bf-e6ec1c21b1e6",
 | 
					 | 
				
			||||||
            "name": "Pam's work",
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await route.fulfill({ json })
 | 
					 | 
				
			||||||
    } else if (route.request().method() === 'POST') {
 | 
					 | 
				
			||||||
      const json = {
 | 
					 | 
				
			||||||
        "id": "ff58aa2d-643d-4c29-be9c-50e10ae6853c",
 | 
					 | 
				
			||||||
        "name": "John Snow",
 | 
					 | 
				
			||||||
        "status": "invited",
 | 
					 | 
				
			||||||
        "group": {
 | 
					 | 
				
			||||||
          "id": "c8bda6ca-d8af-4bb8-b2bf-e6ec1c21b1e6",
 | 
					 | 
				
			||||||
          "name": "Pam's work",
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await route.fulfill({ json });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  page.route("*/**/api/default/guests/*", async route => {
 | 
					 | 
				
			||||||
    if (route.request().method() === 'PUT') {
 | 
					 | 
				
			||||||
      const json = {
 | 
					 | 
				
			||||||
        "id": "ff58aa2d-643d-4c29-be9c-50e10ae6853c",
 | 
					 | 
				
			||||||
        "name": "John Fire",
 | 
					 | 
				
			||||||
        "status": "declined",
 | 
					 | 
				
			||||||
        "group": {
 | 
					 | 
				
			||||||
          "id": "ee44ffb9-1147-4842-a378-9eaeb0f0871a",
 | 
					 | 
				
			||||||
          "name": "Pam's family",
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      await route.fulfill({ json });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    } else if (route.request().method() === 'DELETE') {
 | 
					 | 
				
			||||||
      const json = {}
 | 
					 | 
				
			||||||
      await route.fulfill({ json });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user