diff --git a/package.json b/package.json index c622f191..c6c4e267 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "date-fns": "^4.1.0", "email-validator": "^2.0.4", "i18next": "^25.3.2", - "need4deed-sdk": "0.0.145", + "need4deed-sdk": "0.0.146", "next": "15.3.8", "react": "^19.0.0", "react-day-picker": "^9.13.0", diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index cec53318..96cc9a0f 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -1522,13 +1522,49 @@ }, "calendar": { "createEvent": "Veranstaltung erstellen", + "management": "Veranstaltungsverwaltung", + "events": "Veranstaltungen", + "upcomingEvents": "Kommende Veranstaltungen", + "noUpcomingEvents": "Keine kommenden Veranstaltungen in diesem Monat", + "upcomingHelp": "Für Freiwillige sichtbare Veranstaltungen", + "calendarTitle": "Kalender", + "calendarHelp": "Wählen Sie ein freies Datum, um eine Veranstaltung zu erstellen", + "pastEvents": "Vergangene Veranstaltungen", + "pastHelp": "Bereits stattgefundene Veranstaltungen ansehen", + "noPastEvents": "Keine vergangenen Veranstaltungen vorhanden.", + "published": "Veröffentlicht", + "draft": "Entwurf", + "editEvent": "Veranstaltung bearbeiten", + "deleteEvent": "Veranstaltung löschen", + "openRegistration": "Anmeldelink öffnen", + "registrationUnavailable": "Anmeldelink nicht verfügbar", + "editLoading": "Veranstaltung wird geladen…", + "editLoadError": "Diese Veranstaltung konnte nicht geladen werden. Möglicherweise wurde sie gelöscht.", + "backToCalendar": "Zurück zum Kalender", + "emptyTitle": "Noch keine Veranstaltungen", + "emptyText": "Erstellen Sie über die Schaltfläche eine Veranstaltung für Freiwillige.", + "loading": "Veranstaltungen werden geladen…", + "loadError": "Veranstaltungen konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "previousMonth": "Vorheriger Monat", + "nextMonth": "Nächster Monat", + "deleteConfirmTitle": "Diese Veranstaltung löschen?", + "deleteConfirmText": "\"{{title}}\" wird dauerhaft gelöscht.", + "messages": { + "created": "Veranstaltung erfolgreich erstellt", + "updated": "Veranstaltung erfolgreich aktualisiert", + "deleted": "Veranstaltung erfolgreich gelöscht" + }, "createForm": { "eventTitle": "Veranstaltungstitel", + "typeLabel": "Veranstaltungsart", + "typeWorkshop": "Workshop", + "typeParty": "Feier", "titleLabel": "Titel der ehrenamtlichen Tätigkeit", "titlePlaceholder": "Titel", "titleHelper": "z.B. \"Begleitung zur Augenklinik\", \"Basteln & Malen\"", "descriptionLabel": "Beschreibung", "descriptionPlaceholder": "Eine kurze Beschreibung der Veranstaltung", + "descriptionHelper": "Maximal {{max}} Zeichen", "location": "Standort", "street": "Straßenname", "streetHelper": "z.B. \"Elsenstr.\"", @@ -1536,9 +1572,14 @@ "houseNumberHelper": "z.B. \"87\"", "postcode": "Postleitzahl", "postcodeHelper": "z.B. \"12435\"", + "registrationLink": "Anmeldelink", "dateTime": "Datum & Uhrzeit", "date": "Termindatum", "time": "Terminzeit", + "startDate": "Startdatum", + "startTime": "Startzeit", + "endDate": "Enddatum", + "endTime": "Endzeit", "cancel": "Abbrechen", "back": "Zurück", "next": "Weiter", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 5cd0a8c1..2f0b41ec 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -1510,13 +1510,49 @@ }, "calendar": { "createEvent": "Create an event", + "management": "Event management", + "events": "Events", + "upcomingEvents": "Upcoming events", + "noUpcomingEvents": "No upcoming events this month", + "upcomingHelp": "Events visible to volunteers", + "calendarTitle": "Calendar", + "calendarHelp": "Select an empty date to create an event", + "pastEvents": "Past events", + "pastHelp": "Review events that have already taken place", + "noPastEvents": "No past events to display.", + "published": "Published", + "draft": "Draft", + "editEvent": "Edit event", + "deleteEvent": "Delete event", + "openRegistration": "Open registration link", + "registrationUnavailable": "Registration link unavailable", + "editLoading": "Loading event…", + "editLoadError": "This event could not be loaded. It may have been deleted.", + "backToCalendar": "Back to calendar", + "emptyTitle": "No events yet", + "emptyText": "Use the Create an event button to publish an event for volunteers.", + "loading": "Loading events…", + "loadError": "Events could not be loaded. Please try again.", + "previousMonth": "Previous month", + "nextMonth": "Next month", + "deleteConfirmTitle": "Delete this event?", + "deleteConfirmText": "\"{{title}}\" will be permanently deleted.", + "messages": { + "created": "Event created successfully", + "updated": "Event updated successfully", + "deleted": "Event deleted successfully" + }, "createForm": { "eventTitle": "Event Title", + "typeLabel": "Event type", + "typeWorkshop": "Workshop", + "typeParty": "Party", "titleLabel": "Title of the voluntary offer", "titlePlaceholder": "Title", "titleHelper": "e.g. \"Escort to the eye clinic\", \"Arts & Crafts\"", "descriptionLabel": "Description", "descriptionPlaceholder": "A short description of the event", + "descriptionHelper": "Maximum {{max}} characters", "location": "Location", "street": "Street name", "streetHelper": "e.g. \"Elsenstr.\"", @@ -1524,9 +1560,14 @@ "houseNumberHelper": "e.g. \"87\"", "postcode": "Postcode", "postcodeHelper": "e.g. \"12435\"", + "registrationLink": "Registration link", "dateTime": "Date & Time", "date": "Event date", "time": "Event time", + "startDate": "Start date", + "startTime": "Start time", + "endDate": "End date", + "endTime": "End time", "cancel": "Cancel", "back": "Back", "next": "Next", diff --git a/src/app/[lang]/dashboard/calendar/[id]/edit/page.tsx b/src/app/[lang]/dashboard/calendar/[id]/edit/page.tsx new file mode 100644 index 00000000..83905941 --- /dev/null +++ b/src/app/[lang]/dashboard/calendar/[id]/edit/page.tsx @@ -0,0 +1,9 @@ +import { CreateEvent } from "@/components/Dashboard/Calendar/CreateEvent"; +import { notFound } from "next/navigation"; + +export default async function EditEventPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const eventId = Number(id); + if (!Number.isInteger(eventId) || eventId <= 0) notFound(); + return ; +} diff --git a/src/components/Dashboard/Calendar/Calendar/CalendarGrid.tsx b/src/components/Dashboard/Calendar/Calendar/CalendarGrid.tsx new file mode 100644 index 00000000..2af11bc8 --- /dev/null +++ b/src/components/Dashboard/Calendar/Calendar/CalendarGrid.tsx @@ -0,0 +1,150 @@ +import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react"; +import type { ApiEventN4DGetList } from "need4deed-sdk"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; + +import { calendarCells, dateKey, eventDateKeys } from "@/utils/calendar"; + +interface Props { + events: ApiEventN4DGetList[]; + monthDate: Date; + selectedDateKey: string | null; + onPreviousMonth: () => void; + onNextMonth: () => void; + onDateClick: (date: Date, hasEvents: boolean) => void; +} + +export function CalendarGrid(props: Props) { + const { events, monthDate, selectedDateKey, onPreviousMonth, onNextMonth, onDateClick } = props; + const { t, i18n } = useTranslation(); + const today = useMemo(() => new Date(), []); + const year = monthDate.getFullYear(); + const month = monthDate.getMonth(); + const eventCounts = events.reduce>((counts, event) => { + eventDateKeys(event).forEach((key) => { + counts[key] = (counts[key] ?? 0) + 1; + }); + return counts; + }, {}); + + return ( + + + + + + {monthDate.toLocaleString(i18n.language, { month: "long", year: "numeric" })} + + + + + + {Array.from({ length: 7 }, (_, index) => ( + + {new Date(2024, 0, 1 + index).toLocaleString(i18n.language, { weekday: "narrow" })} + + ))} + {calendarCells(year, month).map((cell, index) => { + const date = new Date(year, month, cell.day); + const key = dateKey(date); + const eventCount = cell.current ? (eventCounts[key] ?? 0) : 0; + const label = date.toLocaleDateString(i18n.language, { dateStyle: "full" }); + return ( + 0} + onClick={() => onDateClick(date, eventCount > 0)} + aria-label={`${label}${eventCount ? `, ${eventCount} ${t("dashboard.calendar.events")}` : ""}`} + aria-pressed={cell.current ? key === selectedDateKey : undefined} + > + {cell.day} + {eventCount > 0 && {eventCount}} + + ); + })} + + + ); +} + +const CalendarBox = styled.div` + padding: var(--spacing-20); + border: var(--border-width-thin) solid var(--color-orchid); + border-radius: var(--border-radius-large); + background: var(--color-white); + box-sizing: border-box; +`; +const CalendarHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-16); + strong { + text-transform: capitalize; + } +`; +const Nav = styled.button` + display: flex; + border: 0; + border-radius: 50%; + padding: var(--spacing-8); + background: var(--color-orchid-subtle); + color: var(--color-midnight); + cursor: pointer; +`; +const Days = styled.div` + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: var(--spacing-4); +`; +const DayName = styled.div` + padding: var(--spacing-8) 0; + text-align: center; + color: var(--color-midnight); + font-size: var(--font-size-xs); + font-weight: bold; +`; +const Day = styled.button<{ $faded: boolean; $today: boolean; $selected: boolean; $hasEvents: boolean }>` + position: relative; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + border: ${({ $today, $selected }) => + $selected + ? "var(--border-width-medium) solid var(--color-aubergine)" + : $today + ? "var(--border-width-medium) solid var(--color-salmon)" + : "var(--border-width-medium) solid transparent"}; + border-radius: 50%; + background: ${({ $selected, $hasEvents }) => + $selected ? "var(--color-aubergine)" : $hasEvents ? "var(--color-orchid-subtle)" : "transparent"}; + color: ${({ $faded, $selected }) => + $faded ? "var(--color-grey-300)" : $selected ? "var(--color-white)" : "var(--color-midnight)"}; + cursor: ${({ $faded }) => ($faded ? "default" : "pointer")}; + font-weight: ${({ $today, $selected, $hasEvents }) => ($today || $selected || $hasEvents ? "bold" : "normal")}; + &:hover:not(:disabled) { + background: ${({ $selected }) => ($selected ? "var(--color-aubergine)" : "var(--color-orchid)")}; + } +`; +const Count = styled.span` + position: absolute; + right: 1px; + bottom: 1px; + display: grid; + place-items: center; + min-width: 16px; + height: 16px; + padding: 0 3px; + border-radius: 999px; + background: var(--color-salmon); + color: var(--color-midnight); + font-size: 10px; + box-sizing: border-box; +`; diff --git a/src/components/Dashboard/Calendar/Calendar/EventCard.tsx b/src/components/Dashboard/Calendar/Calendar/EventCard.tsx new file mode 100644 index 00000000..ba2b10ac --- /dev/null +++ b/src/components/Dashboard/Calendar/Calendar/EventCard.tsx @@ -0,0 +1,186 @@ +import Button from "@/components/core/button/Button/Button"; +import { CalendarBlankIcon, LinkIcon, MapPinIcon } from "@phosphor-icons/react"; +import type { ApiEventN4DGetList } from "need4deed-sdk"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; + +import { eventDateRange } from "@/utils/calendar"; + +interface Props { + event: ApiEventN4DGetList; + variant?: "card" | "bar"; + onEdit: (event: ApiEventN4DGetList) => void; + onDelete: (event: ApiEventN4DGetList) => void; +} + +export function EventCard({ event, variant = "card", onEdit, onDelete }: Props) { + const { t, i18n } = useTranslation(); + const registrationUrl = getHttpUrl(event.linkRSVP); + + if (variant === "bar") { + return ( + + + {event.title} + {eventDateRange(event, i18n.language)} + + + onEdit(event)}> + {t("dashboard.calendar.editEvent")} + + onDelete(event)}> + {t("dashboard.calendar.deleteEvent")} + + + + ); + } + + return ( + + + + {event.title} + {event.shortDescription && {event.shortDescription}} + + + {t(event.active ? "dashboard.calendar.published" : "dashboard.calendar.draft")} + + + + + {eventDateRange(event, i18n.language)} + + + + {event.address} + + + + {registrationUrl ? ( + + {t("dashboard.calendar.openRegistration")} + + ) : ( + {t("dashboard.calendar.registrationUnavailable")} + )} + + + onEdit(event)} + height="40px" + width="auto" + textFontSize="var(--font-size-sm)" + padding="var(--spacing-8) var(--spacing-16)" + backgroundcolor="transparent" + border="var(--border-width-medium) solid var(--color-aubergine)" + textColor="var(--color-aubergine)" + /> + onDelete(event)} + height="40px" + width="auto" + textFontSize="var(--font-size-sm)" + padding="var(--spacing-8) var(--spacing-16)" + /> + + + ); +} + +function getHttpUrl(value?: string) { + if (!value) return null; + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + +const Card = styled.article` + padding: var(--spacing-20); + margin-bottom: var(--spacing-12); + border: var(--border-width-thin) solid var(--color-orchid); + border-radius: var(--border-radius-large); + background: var(--color-white); +`; +const CardHeader = styled.div` + display: flex; + justify-content: space-between; + gap: var(--spacing-16); + h4 { + margin: 0; + font-size: var(--font-size-md); + color: var(--color-midnight); + } +`; +const Description = styled.p` + margin: var(--spacing-8) 0 var(--spacing-12); + color: var(--color-midnight); + line-height: 1.5; +`; +const Status = styled.span<{ $active: boolean }>` + height: max-content; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--border-radius-small); + background: var(--color-orchid-subtle); + color: ${({ $active }) => ($active ? "var(--color-green-700)" : "var(--color-grey-500)")}; + font-size: var(--font-size-xs); + font-weight: bold; +`; +const Meta = styled.div` + display: flex; + align-items: center; + gap: var(--spacing-8); + margin-top: var(--spacing-8); + color: var(--color-midnight); +`; +const Registration = styled.a` + color: var(--color-midnight); + font-weight: var(--font-weight-semibold); + text-decoration: none; + &:hover { + text-decoration: underline; + } +`; +const Actions = styled.div` + display: flex; + gap: var(--spacing-12); + flex-wrap: wrap; + margin-top: var(--spacing-16); + padding-top: var(--spacing-12); + border-top: var(--border-width-thin) solid var(--color-grey-200); +`; +const Bar = styled.article` + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-16); + padding: var(--spacing-12) var(--spacing-16); + border-bottom: var(--border-width-thin) solid var(--color-grey-200); + background: var(--color-white); +`; +const BarDetails = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; + time { + color: var(--color-grey-500); + font-size: var(--font-size-xs); + } +`; +const BarActions = styled.div` + display: flex; + gap: var(--spacing-8); +`; +const TextButton = styled.button` + border: 0; + background: transparent; + color: var(--color-aubergine); + cursor: pointer; + font-weight: bold; +`; diff --git a/src/components/Dashboard/Calendar/Calendar/PastEvents.tsx b/src/components/Dashboard/Calendar/Calendar/PastEvents.tsx new file mode 100644 index 00000000..5587c897 --- /dev/null +++ b/src/components/Dashboard/Calendar/Calendar/PastEvents.tsx @@ -0,0 +1,78 @@ +import { CaretDownIcon } from "@phosphor-icons/react"; +import type { ApiEventN4DGetList } from "need4deed-sdk"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; + +import { eventOccursOnDate, groupEventsByDate } from "@/utils/calendar"; +import { EventCard } from "./EventCard"; +import { DateGroup, DateHeading } from "./styles"; + +interface Props { + events: ApiEventN4DGetList[]; + selectedDateKey: string | null; + expanded: boolean; + onToggle: () => void; + onEdit: (event: ApiEventN4DGetList) => void; + onDelete: (event: ApiEventN4DGetList) => void; +} + +export function PastEvents({ events, selectedDateKey, expanded, onToggle, onEdit, onDelete }: Props) { + const { t, i18n } = useTranslation(); + const groups = groupEventsByDate(events); + return ( + + + {t("dashboard.calendar.pastEvents")} + + + {expanded && + (events.length ? ( + Object.entries(groups).map(([key, groupedEvents]) => ( + eventOccursOnDate(event, selectedDateKey)), + )} + > + + {new Date(groupedEvents[0].date).toLocaleDateString(i18n.language, { + weekday: "long", + day: "numeric", + month: "long", + })} + + {groupedEvents.map((event) => ( + + ))} + + )) + ) : ( + {t("dashboard.calendar.noPastEvents")} + ))} + + ); +} + +const Past = styled.div` + margin-top: var(--spacing-24); +`; +const PastToggle = styled.button` + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + border: 0; + border-bottom: var(--border-width-thin) solid var(--color-grey-300); + background: transparent; + padding: var(--spacing-12) 0; + color: var(--color-midnight); + cursor: pointer; + strong { + font-size: var(--text-h3-font-size); + line-height: var(--text-h3-line-height); + } +`; +const Empty = styled.p` + color: var(--color-midnight); +`; diff --git a/src/components/Dashboard/Calendar/Calendar/UpcomingEvents.tsx b/src/components/Dashboard/Calendar/Calendar/UpcomingEvents.tsx new file mode 100644 index 00000000..3c5be645 --- /dev/null +++ b/src/components/Dashboard/Calendar/Calendar/UpcomingEvents.tsx @@ -0,0 +1,68 @@ +import { Heading3, Paragraph } from "@/components/styled/text"; +import type { ApiEventN4DGetList } from "need4deed-sdk"; +import { useTranslation } from "react-i18next"; + +import { eventOccursOnDate, groupEventsByDate } from "@/utils/calendar"; +import { EventCard } from "./EventCard"; +import { DateGroup, DateHeading, SectionHeading, State } from "./styles"; + +interface Props { + events: ApiEventN4DGetList[]; + hasMonthEvents: boolean; + selectedDateKey: string | null; + isLoading: boolean; + isError: boolean; + onEdit: (event: ApiEventN4DGetList) => void; + onDelete: (event: ApiEventN4DGetList) => void; +} + +export function UpcomingEvents({ + events, + hasMonthEvents, + selectedDateKey, + isLoading, + isError, + onEdit, + onDelete, +}: Props) { + const { t, i18n } = useTranslation(); + const groups = groupEventsByDate(events); + + return ( + <> + + {t("dashboard.calendar.upcomingEvents")} + + {isLoading && {t("dashboard.calendar.loading")}} + {isError && {t("dashboard.calendar.loadError")}} + {!isLoading && !isError && !events.length && ( + + + {t(hasMonthEvents ? "dashboard.calendar.noUpcomingEvents" : "dashboard.calendar.emptyTitle")} + + {!hasMonthEvents && {t("dashboard.calendar.emptyText")}} + + )} + {Object.entries(groups).map(([key, groupedEvents]) => ( + eventOccursOnDate(event, selectedDateKey)), + )} + > + + {new Date(groupedEvents[0].date).toLocaleDateString(i18n.language, { + weekday: "long", + day: "numeric", + month: "long", + })} + + {groupedEvents.map((event) => ( + + ))} + + ))} + > + ); +} diff --git a/src/components/Dashboard/Calendar/Calendar/index.tsx b/src/components/Dashboard/Calendar/Calendar/index.tsx new file mode 100644 index 00000000..0b079df3 --- /dev/null +++ b/src/components/Dashboard/Calendar/Calendar/index.tsx @@ -0,0 +1,81 @@ +import { Heading3, Paragraph } from "@/components/styled/text"; +import { ConfirmationDialog } from "@/components/Dashboard/Profile/sections/shared/ConfirmationDialog"; +import type { ApiEventN4DGetList } from "need4deed-sdk"; +import { useTranslation } from "react-i18next"; + +import { CalendarGrid } from "./CalendarGrid"; +import { PastEvents } from "./PastEvents"; +import { UpcomingEvents } from "./UpcomingEvents"; +import { Agenda, CalendarAside, Layout, SectionHeading } from "./styles"; + +interface Props { + events: ApiEventN4DGetList[]; + upcomingEvents: ApiEventN4DGetList[]; + pastEvents: ApiEventN4DGetList[]; + monthDate: Date; + selectedDateKey: string | null; + showPast: boolean; + isLoading: boolean; + isError: boolean; + onPreviousMonth: () => void; + onNextMonth: () => void; + onDateClick: (date: Date, hasEvents: boolean) => void; + onTogglePast: () => void; + onEdit: (event: ApiEventN4DGetList) => void; + onDelete: (event: ApiEventN4DGetList) => void; + deletingEvent: ApiEventN4DGetList | null; + onCancelDelete: () => void; + onConfirmDelete: () => void; +} + +export function Calendar(props: Props) { + const { t } = useTranslation(); + return ( + + + + {t("dashboard.calendar.calendarTitle")} + {t("dashboard.calendar.calendarHelp")} + + + + + 0} + selectedDateKey={props.selectedDateKey} + isLoading={props.isLoading} + isError={props.isError} + onEdit={props.onEdit} + onDelete={props.onDelete} + /> + + + {props.deletingEvent && ( + + )} + + ); +} diff --git a/src/components/Dashboard/Calendar/Calendar/styles.ts b/src/components/Dashboard/Calendar/Calendar/styles.ts new file mode 100644 index 00000000..8c5a9c71 --- /dev/null +++ b/src/components/Dashboard/Calendar/Calendar/styles.ts @@ -0,0 +1,45 @@ +import styled from "styled-components"; + +export const Layout = styled.div` + display: grid; + grid-template-columns: minmax(340px, 0.9fr) minmax(0, 1.25fr); + gap: var(--spacing-32); + align-items: start; + @media (max-width: 1000px) { + grid-template-columns: 1fr; + } +`; +export const CalendarAside = styled.aside` + position: sticky; + top: var(--spacing-24); + @media (max-width: 1000px) { + position: static; + } +`; +export const Agenda = styled.section` + min-width: 0; +`; +export const SectionHeading = styled.div` + margin-bottom: var(--spacing-16); +`; +export const State = styled.div` + padding: var(--spacing-32); + border-radius: var(--border-radius-large); + background: var(--color-white); + text-align: center; +`; +export const DateGroup = styled.section<{ $selected?: boolean }>` + margin-bottom: var(--spacing-20); + padding: var(--spacing-4); + border-radius: var(--border-radius-large); + outline: ${({ $selected }) => ($selected ? "var(--border-width-medium) solid var(--color-salmon)" : "none")}; + outline-offset: var(--spacing-4); + scroll-margin-top: var(--spacing-32); +`; +export const DateHeading = styled.h4` + margin: 0 0 var(--spacing-8); + color: var(--color-midnight); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + text-transform: capitalize; +`; diff --git a/src/components/Dashboard/Calendar/CalendarContent.tsx b/src/components/Dashboard/Calendar/CalendarContent.tsx index 357e1848..ce7cc67e 100644 --- a/src/components/Dashboard/Calendar/CalendarContent.tsx +++ b/src/components/Dashboard/Calendar/CalendarContent.tsx @@ -1,244 +1,60 @@ "use client"; -import { CaretLeftIcon, CaretRightIcon, HandHeartIcon } from "@phosphor-icons/react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; + +import { Heading2 } from "@/components/styled/text"; +import { useCalendar } from "@/hooks"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; -import Button from "@/components/core/button/Button/Button"; - -function buildCalendarCells(year: number, month: number): { day: number; currentMonth: boolean }[] { - const rawDow = new Date(year, month, 1).getDay(); // 0=Sunday - const firstDow = (rawDow + 6) % 7; // Monday=0, ..., Sunday=6 - const daysInMonth = new Date(year, month + 1, 0).getDate(); - const daysInPrev = new Date(year, month, 0).getDate(); - - const cells: { day: number; currentMonth: boolean }[] = []; - - for (let i = firstDow - 1; i >= 0; i--) { - cells.push({ day: daysInPrev - i, currentMonth: false }); - } - for (let d = 1; d <= daysInMonth; d++) { - cells.push({ day: d, currentMonth: true }); - } - const remaining = 7 - (cells.length % 7); - if (remaining < 7) { - for (let d = 1; d <= remaining; d++) { - cells.push({ day: d, currentMonth: false }); - } - } - - return cells; -} - -function getDayLabels(locale: string): string[] { - const labels: string[] = []; - // Jan 1 2024 was a Monday — iterate Mon→Sun - for (let i = 0; i < 7; i++) { - const d = new Date(2024, 0, 1 + i); - labels.push(d.toLocaleString(locale, { weekday: "narrow" })); - } - return labels; -} +import { Calendar } from "./Calendar/index"; +import { CreateEventCta } from "./CreateEventCta"; export function CalendarContent() { - const { t, i18n } = useTranslation(); - const router = useRouter(); - - const today = new Date(); - const [displayDate, setDisplayDate] = useState(new Date(today.getFullYear(), today.getMonth(), 1)); - const [selectedDay, setSelectedDay] = useState(null); - - const year = displayDate.getFullYear(); - const month = displayDate.getMonth(); - - const monthLabel = displayDate.toLocaleString(i18n.language, { month: "long", year: "numeric" }); - const dayLabels = getDayLabels(i18n.language); - const cells = buildCalendarCells(year, month); - - const prevMonth = () => { - setSelectedDay(null); - setDisplayDate(new Date(year, month - 1, 1)); - }; - const nextMonth = () => { - setSelectedDay(null); - setDisplayDate(new Date(year, month + 1, 1)); - }; - - const isToday = (day: number, currentMonth: boolean) => - currentMonth && day === today.getDate() && month === today.getMonth() && year === today.getFullYear(); - - const handleDayClick = (day: number, currentMonth: boolean) => { - if (!currentMonth) return; - setSelectedDay(day === selectedDay ? null : day); - }; - - const handleCreateEvent = () => { - const params = new URLSearchParams(); - if (selectedDay !== null) { - const date = new Date(year, month, selectedDay); - params.set("date", date.toISOString().split("T")[0]); - } - const query = params.toString() ? `?${params.toString()}` : ""; - router.push(`/${i18n.language}/dashboard/calendar/create${query}`); - }; + const { t } = useTranslation(); + const calendar = useCalendar(); return ( - - - - - - - - - - - - - {monthLabel} - - - - - - - {dayLabels.map((label, i) => ( - {label} - ))} - - {cells.map((cell, i) => ( - handleDayClick(cell.day, cell.currentMonth)} - > - {cell.day} - - ))} - - - - + + + {t("dashboard.calendar.calendarTitle")} + calendar.createEvent(calendar.selectedDateKey ?? undefined)} /> + + + ); } -const PageWrapper = styled.div` - display: flex; - flex-direction: column; - gap: var(--spacing-24); +const Page = styled.div` padding: var(--spacing-32) var(--spacing-48) var(--spacing-48); width: 100%; box-sizing: border-box; -`; - -const TopRow = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - background: var(--color-white); - border-radius: var(--border-radius-large); - padding: var(--spacing-16) var(--spacing-24); - width: 100%; - box-sizing: border-box; -`; - -const CalendarCenter = styled.div` - display: flex; - justify-content: center; -`; - -const CalendarBox = styled.div` - border: var(--border-width-medium) solid var(--color-midnight); - border-radius: var(--border-radius-large); - padding: var(--spacing-24); - background: var(--color-white); - width: 100%; - max-width: 560px; - margin: 0 auto; -`; - -const CalendarHeader = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--spacing-16); -`; - -const NavButton = styled.button` - background: none; - border: none; - cursor: pointer; - display: flex; - align-items: center; - padding: var(--spacing-8); - border-radius: 50%; - - &:hover { - background: var(--color-orchid-subtle); - } -`; - -const MonthLabel = styled.span` - font-size: var(--font-size-lg); - font-weight: bold; - color: var(--color-midnight); - text-transform: capitalize; -`; - -const DaysGrid = styled.div` - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: var(--spacing-4); -`; - -const DayHeader = styled.div` - text-align: center; - font-weight: bold; - font-size: var(--font-size-xs); color: var(--color-midnight); - padding: var(--spacing-8) 0; + @media (max-width: 600px) { + padding: var(--spacing-24) var(--spacing-16) var(--spacing-32); + } `; -interface DayCellProps { - $isToday: boolean; - $isSelected: boolean; - $faded: boolean; - $clickable: boolean; -} - -const DayCell = styled.div` +const PageHeading = styled.div` display: flex; align-items: center; - justify-content: center; - width: var(--spacing-40); - height: var(--spacing-40); - margin: auto; - border-radius: 50%; - font-size: var(--font-size-xs); - cursor: ${({ $clickable }) => ($clickable ? "pointer" : "default")}; - color: ${({ $isToday, $isSelected, $faded }) => { - if ($isToday || $isSelected) return "var(--color-white)"; - if ($faded) return "var(--color-grey-300)"; - return "var(--color-midnight)"; - }}; - background: ${({ $isToday, $isSelected }) => { - if ($isToday) return "var(--color-midnight)"; - if ($isSelected) return "var(--color-aubergine)"; - return "transparent"; - }}; - font-weight: ${({ $isToday, $isSelected }) => ($isToday || $isSelected ? "bold" : "normal")}; - - &:hover { - background: ${({ $clickable, $isToday, $isSelected }) => - $clickable && !$isToday && !$isSelected ? "var(--color-orchid-subtle)" : undefined}; - } + justify-content: space-between; + gap: var(--spacing-24); + margin-bottom: var(--spacing-32); `; diff --git a/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx b/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx index d3d7c330..e5cfb6e2 100644 --- a/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx +++ b/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx @@ -1,45 +1,112 @@ "use client"; import { useRouter, useSearchParams } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; +import { EventN4DType, Lang, type ApiEventN4DCreate, type ApiEventN4DPatch } from "need4deed-sdk"; import { PageLayout } from "@/components/Layout"; +import Button from "@/components/core/button/Button/Button"; +import { useCreateEvent, useEvent, useUpdateEvent } from "@/hooks"; import { StepDateTime } from "./StepDateTime"; import { StepLocation } from "./StepLocation"; -import { StepTitle } from "./StepTitle"; +import { DESCRIPTION_MAX, StepTitle } from "./StepTitle"; export interface EventFormData { + type: EventN4DType; title: string; description: string; street: string; houseNumber: string; postcode: string; - date: string; - time: string; + registrationLink: string; + startDate: string; + startTime: string; + endDate: string; + endTime: string; + unstructuredAddress: boolean; } const TOTAL_STEPS = 3; -export function CreateEvent() { - const { i18n } = useTranslation(); +interface Props { + eventId?: number; +} + +function dateParts(value?: Date) { + if (!value) return { date: "", time: "" }; + const parsed = new Date(value); + return { + date: `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, "0")}-${String(parsed.getDate()).padStart(2, "0")}`, + time: `${String(parsed.getHours()).padStart(2, "0")}:${String(parsed.getMinutes()).padStart(2, "0")}`, + }; +} + +function parseAddress(address = "") { + const match = address.match(/^(.*)\s+(\S+),\s*(\d{5})/); + return match + ? { street: match[1], houseNumber: match[2], postcode: match[3], unstructuredAddress: false } + : { street: address, houseNumber: "", postcode: "", unstructuredAddress: Boolean(address) }; +} + +function isHttpUrl(value: string) { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +export function CreateEvent({ eventId }: Props) { + const { t, i18n } = useTranslation(); const router = useRouter(); const searchParams = useSearchParams(); const prefilledDate = searchParams.get("date") ?? ""; const [step, setStep] = useState(1); const [formData, setFormData] = useState({ + type: EventN4DType.WORKSHOP, title: "", description: "", street: "", houseNumber: "", postcode: "", - date: prefilledDate, - time: "", + registrationLink: "", + startDate: prefilledDate, + startTime: "", + endDate: prefilledDate, + endTime: "", + unstructuredAddress: false, }); + const [initializedEventId, setInitializedEventId] = useState(null); + const { data: editingEvent, isLoading, isError } = useEvent(eventId); - const update = (fields: Partial) => setFormData((prev) => ({ ...prev, ...fields })); + useEffect(() => { + if (!eventId || !editingEvent || initializedEventId === eventId) return; + const start = dateParts(editingEvent.date); + const end = dateParts(editingEvent.dateEnd ?? editingEvent.date); + setFormData({ + type: editingEvent.type, + title: editingEvent.title, + description: editingEvent.description, + ...parseAddress(editingEvent.address), + registrationLink: editingEvent.linkRSVP, + startDate: start.date, + startTime: start.time, + endDate: end.date, + endTime: end.time, + }); + setInitializedEventId(eventId); + }, [editingEvent, eventId, initializedEventId]); + + const update = (fields: Partial) => + setFormData((prev) => ({ + ...prev, + ...fields, + unstructuredAddress: "houseNumber" in fields || "postcode" in fields ? false : prev.unstructuredAddress, + })); const handleNext = () => setStep((s) => Math.min(s + 1, TOTAL_STEPS)); const handleBack = () => { @@ -51,23 +118,96 @@ export function CreateEvent() { }; const handleCancel = () => router.push(`/${i18n.language}/dashboard/calendar`); - // TODO: call POST /event once BE #458 is ready - const handleSubmit = async () => { - router.push(`/${i18n.language}/dashboard/calendar`); + const returnToEvents = () => router.push(`/${i18n.language}/dashboard/calendar`); + const createEvent = useCreateEvent(returnToEvents); + const updateEvent = useUpdateEvent(eventId ?? 0, returnToEvents); + + const translation = { + language: i18n.language === Lang.DE ? Lang.DE : Lang.EN, + title: formData.title, + subTitle: editingEvent?.subTitle, + menuTitle: formData.title, + locationComment: editingEvent?.locationComment, + description: formData.description, + shortDescription: formData.description, + additionalTitle: editingEvent?.additionalTitle, + additionalInfo: editingEvent?.additionalInfo, + }; + const address = formData.unstructuredAddress + ? formData.street.trim() + : `${formData.street.trim()} ${formData.houseNumber.trim()}, ${formData.postcode.trim()} Berlin`; + const toBasePayload = (): Omit => ({ + date: new Date(`${formData.startDate}T${formData.startTime}`), + dateEnd: new Date(`${formData.endDate}T${formData.endTime}`), + type: formData.type, + linkRSVP: formData.registrationLink, + address, + active: true, + }); + const toCreatePayload = (): ApiEventN4DCreate => ({ + ...toBasePayload(), + translations: [translation], + }); + const toUpdatePayload = (): ApiEventN4DPatch => { + const translationChanged = + formData.title !== editingEvent?.title || formData.description !== editingEvent?.description; + + return { + ...toBasePayload(), + active: editingEvent?.active, + ...(translationChanged ? { translations: [translation] } : {}), + }; }; + const handleSubmit = () => (eventId ? updateEvent.mutate(toUpdatePayload()) : createEvent.mutate(toCreatePayload())); + const isNextEnabled = () => { - if (step === 1) return formData.title.trim().length > 0; + if (step === 1) + return ( + formData.title.trim().length > 0 && + formData.description.trim().length > 0 && + formData.description.length <= DESCRIPTION_MAX + ); if (step === 2) return ( formData.street.trim().length > 0 && - formData.houseNumber.trim().length > 0 && - formData.postcode.trim().length > 0 + (formData.unstructuredAddress || + (formData.houseNumber.trim().length > 0 && formData.postcode.trim().length > 0)) && + (formData.registrationLink === editingEvent?.linkRSVP || isHttpUrl(formData.registrationLink)) ); - if (step === 3) return formData.date.length > 0 && formData.time.length > 0; + if (step === 3) { + const start = new Date(`${formData.startDate}T${formData.startTime}`); + const end = new Date(`${formData.endDate}T${formData.endTime}`); + return Boolean(formData.startDate && formData.startTime && formData.endDate && formData.endTime && end > start); + } return false; }; + if (eventId && isLoading) + return ( + + + {t("dashboard.calendar.editLoading")} + + + ); + if (eventId && !isLoading && (isError || !editingEvent)) + return ( + + + + {t("dashboard.calendar.editLoadError")} + + + + + ); + return ( @@ -80,6 +220,7 @@ export function CreateEvent() { {step === 1 && ( )} @@ -150,3 +294,15 @@ const Card = styled.div` flex-direction: column; gap: var(--spacing-24); `; + +const StateMessage = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-16); + padding: var(--spacing-32); + border-radius: var(--border-radius-large); + background: var(--color-white); + color: var(--color-midnight); + text-align: center; +`; diff --git a/src/components/Dashboard/Calendar/CreateEvent/StepDateTime.tsx b/src/components/Dashboard/Calendar/CreateEvent/StepDateTime.tsx index c410cf26..cbda5fab 100644 --- a/src/components/Dashboard/Calendar/CreateEvent/StepDateTime.tsx +++ b/src/components/Dashboard/Calendar/CreateEvent/StepDateTime.tsx @@ -6,15 +6,26 @@ import { EventFormData } from "./CreateEvent"; import { ButtonRow, FieldGroup, FieldRow, FieldRowLabel, StyledInput } from "./styles"; interface Props { - date: string; - time: string; + startDate: string; + startTime: string; + endDate: string; + endTime: string; onChange: (fields: Partial) => void; onBack: () => void; onSubmit: () => void; isSubmitEnabled: boolean; } -export function StepDateTime({ date, time, onChange, onBack, onSubmit, isSubmitEnabled }: Props) { +export function StepDateTime({ + startDate, + startTime, + endDate, + endTime, + onChange, + onBack, + onSubmit, + isSubmitEnabled, +}: Props) { const { t } = useTranslation(); return ( @@ -23,13 +34,23 @@ export function StepDateTime({ date, time, onChange, onBack, onSubmit, isSubmitE - {t("dashboard.calendar.createForm.date")} - onChange({ date: e.target.value })} /> + {t("dashboard.calendar.createForm.startDate")} + onChange({ startDate: e.target.value })} /> - {t("dashboard.calendar.createForm.time")} - onChange({ time: e.target.value })} /> + {t("dashboard.calendar.createForm.startTime")} + onChange({ startTime: e.target.value })} /> + + + + {t("dashboard.calendar.createForm.endDate")} + onChange({ endDate: e.target.value })} /> + + + + {t("dashboard.calendar.createForm.endTime")} + onChange({ endTime: e.target.value })} /> diff --git a/src/components/Dashboard/Calendar/CreateEvent/StepLocation.tsx b/src/components/Dashboard/Calendar/CreateEvent/StepLocation.tsx index 7ffcf291..b0da0f39 100644 --- a/src/components/Dashboard/Calendar/CreateEvent/StepLocation.tsx +++ b/src/components/Dashboard/Calendar/CreateEvent/StepLocation.tsx @@ -11,13 +11,23 @@ interface Props { street: string; houseNumber: string; postcode: string; + registrationLink: string; onChange: (fields: Partial) => void; onNext: () => void; onBack: () => void; isNextEnabled: boolean; } -export function StepLocation({ street, houseNumber, postcode, onChange, onNext, onBack, isNextEnabled }: Props) { +export function StepLocation({ + street, + houseNumber, + postcode, + registrationLink, + onChange, + onNext, + onBack, + isNextEnabled, +}: Props) { const { t } = useTranslation(); return ( @@ -66,6 +76,18 @@ export function StepLocation({ street, houseNumber, postcode, onChange, onNext, + + {t("dashboard.calendar.createForm.registrationLink")} + onChange({ registrationLink: e.target.value })} + /> + + {t("dashboard.calendar.createForm.locationHelper")} diff --git a/src/components/Dashboard/Calendar/CreateEvent/StepTitle.tsx b/src/components/Dashboard/Calendar/CreateEvent/StepTitle.tsx index 1d68d1f4..67bf1331 100644 --- a/src/components/Dashboard/Calendar/CreateEvent/StepTitle.tsx +++ b/src/components/Dashboard/Calendar/CreateEvent/StepTitle.tsx @@ -1,13 +1,31 @@ "use client"; import Button from "@/components/core/button/Button/Button"; import { Heading2, Heading3 } from "@/components/styled/text"; +import { useClickOutside } from "@/hooks"; +import { CaretDownIcon } from "@phosphor-icons/react"; +import { EventN4DType } from "need4deed-sdk"; +import { type KeyboardEvent, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { EventFormData } from "./CreateEvent"; -import { ButtonRow, CharCount, FieldGroup, FieldLabel, HelperText, StyledInput, StyledTextarea } from "./styles"; +import { + ButtonRow, + CharCount, + FieldGroup, + FieldLabel, + HelperText, + SelectButton, + SelectContainer, + SelectOption, + SelectOptions, + StyledInput, + StyledTextarea, +} from "./styles"; const TITLE_MAX = 128; +export const DESCRIPTION_MAX = 512; interface Props { + type: EventN4DType; title: string; description: string; onChange: (fields: Partial) => void; @@ -16,13 +34,99 @@ interface Props { isNextEnabled: boolean; } -export function StepTitle({ title, description, onChange, onNext, onCancel, isNextEnabled }: Props) { +export function StepTitle({ type, title, description, onChange, onNext, onCancel, isNextEnabled }: Props) { const { t } = useTranslation(); + const [isTypeOpen, setIsTypeOpen] = useState(false); + const [activeTypeIndex, setActiveTypeIndex] = useState(0); + const typeSelectRef = useRef(null); + const typeButtonRef = useRef(null); + const typeOptions = [ + { value: EventN4DType.WORKSHOP, label: t("dashboard.calendar.createForm.typeWorkshop") }, + { value: EventN4DType.PARTY, label: t("dashboard.calendar.createForm.typeParty") }, + ]; + const selectedType = typeOptions.find((option) => option.value === type) ?? typeOptions[0]; + + useClickOutside(typeSelectRef, () => setIsTypeOpen(false)); + + const openTypeSelect = () => { + setActiveTypeIndex( + Math.max( + 0, + typeOptions.findIndex((option) => option.value === type), + ), + ); + setIsTypeOpen(true); + }; + + const selectType = (value: EventN4DType) => { + onChange({ type: value }); + setIsTypeOpen(false); + typeButtonRef.current?.focus(); + }; + + const handleTypeKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsTypeOpen(false); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (!isTypeOpen) { + openTypeSelect(); + return; + } + const direction = event.key === "ArrowDown" ? 1 : -1; + setActiveTypeIndex((index) => (index + direction + typeOptions.length) % typeOptions.length); + return; + } + if ((event.key === "Enter" || event.key === " ") && isTypeOpen) { + event.preventDefault(); + selectType(typeOptions[activeTypeIndex].value); + } + }; return ( <> {t("dashboard.calendar.createForm.eventTitle")} + + {t("dashboard.calendar.createForm.typeLabel")} + + (isTypeOpen ? setIsTypeOpen(false) : openTypeSelect())} + onKeyDown={handleTypeKeyDown} + > + {selectedType.label} + + + {isTypeOpen && ( + + {typeOptions.map((option, index) => ( + setActiveTypeIndex(index)} + onClick={() => selectType(option.value)} + > + {option.label} + + ))} + + )} + + + {t("dashboard.calendar.createForm.titleLabel")} {t("dashboard.calendar.createForm.descriptionLabel")} onChange({ description: e.target.value })} /> + {t("dashboard.calendar.createForm.descriptionHelper", { max: DESCRIPTION_MAX })} + + {description.length}/{DESCRIPTION_MAX} + diff --git a/src/components/Dashboard/Calendar/CreateEvent/styles.ts b/src/components/Dashboard/Calendar/CreateEvent/styles.ts index 3d542fdc..20ef7387 100644 --- a/src/components/Dashboard/Calendar/CreateEvent/styles.ts +++ b/src/components/Dashboard/Calendar/CreateEvent/styles.ts @@ -38,6 +38,65 @@ export const StyledInput = styled.input` } `; +export const SelectContainer = styled.div` + position: relative; + width: 100%; +`; + +export const SelectButton = styled.button` + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-12); + width: 100%; + border: var(--border-width-thin) solid var(--color-grey-400); + border-radius: var(--border-radius-small); + padding: var(--spacing-12) var(--spacing-16); + font-size: var(--font-size-sm); + color: var(--color-midnight); + background: var(--color-white); + box-sizing: border-box; + outline: none; + font-family: inherit; + cursor: pointer; + + &:focus { + border-color: var(--color-midnight); + } +`; + +export const SelectOptions = styled.div` + position: absolute; + top: calc(100% + var(--spacing-4)); + left: 0; + z-index: 10; + width: 100%; + overflow: hidden; + border: var(--border-width-thin) solid var(--color-grey-400); + border-radius: var(--border-radius-small); + background: var(--color-white); + box-shadow: var(--dropdown-box-shadow); + box-sizing: border-box; +`; + +export const SelectOption = styled.button<{ $active: boolean }>` + display: block; + width: 100%; + border: 0; + padding: var(--spacing-12) var(--spacing-16); + background: ${({ $active }) => ($active ? "var(--color-grey-50)" : "var(--color-white)")}; + color: var(--color-midnight); + font: inherit; + text-align: left; + cursor: pointer; + + &:hover, + &:focus-visible { + background: var(--color-grey-50); + outline: none; + } +`; + export const StyledTextarea = styled.textarea` width: 100%; border: var(--border-width-thin) solid var(--color-grey-400); diff --git a/src/components/Dashboard/Calendar/CreateEventCta.tsx b/src/components/Dashboard/Calendar/CreateEventCta.tsx new file mode 100644 index 00000000..3539adcc --- /dev/null +++ b/src/components/Dashboard/Calendar/CreateEventCta.tsx @@ -0,0 +1,18 @@ +import Button from "@/components/core/button/Button/Button"; +import { useTranslation } from "react-i18next"; + +interface Props { + onCreate: () => void; +} + +export function CreateEventCta({ onCreate }: Props) { + const { t } = useTranslation(); + return ( + + ); +} diff --git a/src/components/Dashboard/Profile/sections/shared/ConfirmationDialog.tsx b/src/components/Dashboard/Profile/sections/shared/ConfirmationDialog.tsx index 3133f5bc..d82278e3 100644 --- a/src/components/Dashboard/Profile/sections/shared/ConfirmationDialog.tsx +++ b/src/components/Dashboard/Profile/sections/shared/ConfirmationDialog.tsx @@ -11,6 +11,7 @@ type Props = { cancelText?: string; onCancel: () => void; onConfirm: () => void; + compact?: boolean; }; const Title = styled.h3` @@ -38,7 +39,15 @@ const ButtonGroup = styled.div` margin-top: var(--spacing-24); `; -export function ConfirmationDialog({ title, message, confirmText, cancelText, onCancel, onConfirm }: Props) { +export function ConfirmationDialog({ + title, + message, + confirmText, + cancelText, + onCancel, + onConfirm, + compact = false, +}: Props) { const { t } = useTranslation(); return ( @@ -56,12 +65,18 @@ export function ConfirmationDialog({ title, message, confirmText, cancelText, on backgroundcolor="transparent" textColor="var(--color-aubergine)" border="var(--border-width-medium) solid var(--color-aubergine)" + height={compact ? "40px" : undefined} + padding={compact ? "var(--spacing-8) var(--spacing-16)" : undefined} + textFontSize={compact ? "var(--font-size-sm)" : undefined} /> diff --git a/src/config/constants.ts b/src/config/constants.ts index 47c26fc2..e25ee47c 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -9,6 +9,7 @@ export const apiPathComment = `/${apiPrefix}/comment`; export const apiPathCommunication = `/${apiPrefix}/communication`; export const apiPathAppreciation = `/${apiPrefix}/appreciation`; export const apiPathActivityLog = `/${apiPrefix}/activity-log`; +export const apiPathEvent = `/${apiPrefix}/event`; export const apiPathLogin = `/${apiPrefix}/auth/login`; export const apiPathAuthRefresh = `/${apiPrefix}/auth/refresh`; export const apiPathAuthLogout = `/${apiPrefix}/auth/logout`; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 30739cf6..957cb3d3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,6 +4,8 @@ export * from "./useGetOpportunity"; export * from "./useGetVolunteer"; export * from "./useGetQuery"; export * from "./useMutationQuery"; +export * from "./useEvents"; +export * from "./useCalendar"; export * from "./useSwipe"; export * from "./useUpdateAgentStatus"; export * from "./useUpdateVolunteerStatus"; diff --git a/src/hooks/useCalendar.ts b/src/hooks/useCalendar.ts new file mode 100644 index 00000000..1f483658 --- /dev/null +++ b/src/hooks/useCalendar.ts @@ -0,0 +1,80 @@ +import type { ApiEventN4DGetList } from "need4deed-sdk"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { dateKey, eventOccursOnDate } from "@/utils/calendar"; +import { useDeleteEvent, useEvents } from "./useEvents"; + +export function useCalendar() { + const { i18n } = useTranslation(); + const router = useRouter(); + const today = useMemo(() => new Date(), []); + const [monthDate, setMonthDate] = useState(new Date(today.getFullYear(), today.getMonth(), 1)); + const [selectedDateKey, setSelectedDateKey] = useState(null); + const [showPast, setShowPast] = useState(false); + const [deletingEvent, setDeletingEvent] = useState(null); + const { data: events = [], isLoading, isError } = useEvents(); + const remove = useDeleteEvent(deletingEvent?.id); + + const monthEvents = useMemo(() => { + const monthStart = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); + const monthEnd = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1); + return events + .filter((event) => new Date(event.date) < monthEnd && new Date(event.dateEnd ?? event.date) >= monthStart) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + }, [events, monthDate]); + const upcomingEvents = monthEvents.filter((event) => new Date(event.dateEnd ?? event.date) >= today); + const pastEvents = monthEvents + .filter((event) => new Date(event.dateEnd ?? event.date) < today) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + const createEvent = (date?: string) => + router.push(`/${i18n.language}/dashboard/calendar/create${date ? `?date=${date}` : ""}`); + const editEvent = (event: ApiEventN4DGetList) => router.push(`/${i18n.language}/dashboard/calendar/${event.id}/edit`); + + const selectDate = (date: Date, hasEvents: boolean) => { + const key = dateKey(date); + setSelectedDateKey(key); + if (!hasEvents) { + createEvent(key); + return; + } + const target = monthEvents.find((event) => eventOccursOnDate(event, key)); + if (target && pastEvents.some((event) => event.id === target.id)) setShowPast(true); + window.requestAnimationFrame(() => { + const targetKey = target ? dateKey(new Date(target.date)) : key; + document.getElementById(`event-date-${targetKey}`)?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + }; + + const changeMonth = (offset: number) => { + const next = new Date(monthDate.getFullYear(), monthDate.getMonth() + offset, 1); + setMonthDate(next); + setShowPast(next < new Date(today.getFullYear(), today.getMonth(), 1)); + setSelectedDateKey(null); + }; + + return { + monthDate, + monthEvents, + upcomingEvents, + pastEvents, + selectedDateKey, + showPast, + deletingEvent, + isLoading, + isError, + createEvent, + editEvent, + selectDate, + previousMonth: () => changeMonth(-1), + nextMonth: () => changeMonth(1), + togglePast: () => setShowPast((value) => !value), + requestDelete: setDeletingEvent, + cancelDelete: () => setDeletingEvent(null), + confirmDelete: () => { + if (deletingEvent) remove.mutate(undefined, { onSettled: () => setDeletingEvent(null) }); + }, + }; +} diff --git a/src/hooks/useEvents.ts b/src/hooks/useEvents.ts new file mode 100644 index 00000000..96e29189 --- /dev/null +++ b/src/hooks/useEvents.ts @@ -0,0 +1,47 @@ +import { apiPathEvent } from "@/config/constants"; +import type { ApiEventN4DCreate, ApiEventN4DGet, ApiEventN4DGetList, ApiEventN4DPatch } from "need4deed-sdk"; + +import { useGetQuery } from "./useGetQuery"; +import { useMutationQuery } from "./useMutationQuery"; + +export const EVENT_QUERY_KEY = ["events"]; + +export function useEvents() { + return useGetQuery({ queryKey: EVENT_QUERY_KEY, apiPath: apiPathEvent }); +} + +export function useEvent(id?: number) { + const query = useEvents(); + return { + ...query, + data: id ? query.data?.find((event) => event.id === id) : undefined, + }; +} + +export function useCreateEvent(onSuccess: (event: ApiEventN4DGet) => void) { + return useMutationQuery({ + apiPath: apiPathEvent, + queryKeyToInvalidate: EVENT_QUERY_KEY, + successMessage: "dashboard.calendar.messages.created", + onSuccessCallback: (response) => onSuccess(response.data), + }); +} + +export function useUpdateEvent(id: number, onSuccess: () => void) { + return useMutationQuery({ + apiPath: `${apiPathEvent}/${id}`, + method: "patch", + queryKeyToInvalidate: EVENT_QUERY_KEY, + successMessage: "dashboard.calendar.messages.updated", + onSuccessCallback: onSuccess, + }); +} + +export function useDeleteEvent(id?: number) { + return useMutationQuery({ + apiPath: `${apiPathEvent}/${id ?? 0}`, + method: "delete", + queryKeyToInvalidate: EVENT_QUERY_KEY, + successMessage: "dashboard.calendar.messages.deleted", + }); +} diff --git a/src/utils/calendar.ts b/src/utils/calendar.ts new file mode 100644 index 00000000..3d30b4c8 --- /dev/null +++ b/src/utils/calendar.ts @@ -0,0 +1,54 @@ +import type { ApiEventN4DGetList } from "need4deed-sdk"; + +export function dateKey(date: Date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; +} + +export function eventDateKeys(event: ApiEventN4DGetList) { + const start = new Date(event.date); + const end = new Date(event.dateEnd ?? event.date); + const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate()); + const last = new Date(end.getFullYear(), end.getMonth(), end.getDate()); + const keys: string[] = []; + while (cursor <= last) { + keys.push(dateKey(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + return keys; +} + +export function eventOccursOnDate(event: ApiEventN4DGetList, key: string) { + return eventDateKeys(event).includes(key); +} + +export function calendarCells(year: number, month: number) { + const first = (new Date(year, month, 1).getDay() + 6) % 7; + const previous = new Date(year, month, 0).getDate(); + const count = new Date(year, month + 1, 0).getDate(); + const result: { day: number; current: boolean }[] = []; + for (let index = first - 1; index >= 0; index--) result.push({ day: previous - index, current: false }); + for (let day = 1; day <= count; day++) result.push({ day, current: true }); + for (let day = 1; result.length < 42; day++) result.push({ day, current: false }); + return result; +} + +export function eventDateRange(event: ApiEventN4DGetList, locale: string) { + const start = new Date(event.date); + const end = event.dateEnd ? new Date(event.dateEnd) : undefined; + const dateOptions: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long", year: "numeric" }; + const timeOptions: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" }; + const startDate = start.toLocaleDateString(locale, dateOptions); + const startTime = start.toLocaleTimeString(locale, timeOptions); + if (!end) return `${startDate} · ${startTime}`; + const endTime = end.toLocaleTimeString(locale, timeOptions); + if (dateKey(start) === dateKey(end)) return `${startDate} · ${startTime}–${endTime}`; + return `${startDate} · ${startTime} – ${end.toLocaleDateString(locale, dateOptions)} · ${endTime}`; +} + +export function groupEventsByDate(events: ApiEventN4DGetList[]) { + return events.reduce>((groups, event) => { + const key = dateKey(new Date(event.date)); + groups[key] = [...(groups[key] ?? []), event]; + return groups; + }, {}); +} diff --git a/yarn.lock b/yarn.lock index 69cdad33..f81ad157 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2412,10 +2412,10 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -need4deed-sdk@0.0.145: - version "0.0.145" - resolved "https://registry.yarnpkg.com/need4deed-sdk/-/need4deed-sdk-0.0.145.tgz#79c2e4ffefbe2bed5520dd356e644f7b572862fe" - integrity sha512-xLfKK9W9AvpwAoq95c/ifBTBGGU9ndALyGLpiUAp7DNLnWjyQjLghhVVFTwyKygKSxY04p6h/hvY0G7amsJSKg== +need4deed-sdk@0.0.146: + version "0.0.146" + resolved "https://registry.yarnpkg.com/need4deed-sdk/-/need4deed-sdk-0.0.146.tgz#42f62886f87e2797f987f74e3280a6b16256cc4c" + integrity sha512-7bQV6H5z0HtrCnCa9GnK1aXQi8wVs45xBoJanqjdfUNdLKaFCLuN1Zts3pzxToRq2jmhks5/7uZljyTr0wsJpw== next@15.3.8: version "15.3.8"
{t("dashboard.calendar.editLoadError")}