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 && } + + ); + })} + + + ); +} + +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} + + + + 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")} + )} + + +