From e375f9ac292a28ed338c669065c00470f9363522 Mon Sep 17 00:00:00 2001 From: Justyna Bereza Date: Wed, 26 Aug 2026 16:42:59 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Finish=20event=20creation=20and?= =?UTF-8?q?=20editing=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- public/locales/de/translations.json | 33 + public/locales/en/translations.json | 33 + .../dashboard/calendar/[id]/edit/page.tsx | 6 + .../Dashboard/Calendar/CalendarContent.tsx | 586 ++++++++++++------ .../Calendar/CreateEvent/CreateEvent.tsx | 127 +++- .../Calendar/CreateEvent/StepDateTime.tsx | 35 +- .../Calendar/CreateEvent/StepLocation.tsx | 24 +- .../Calendar/CreateEvent/StepTitle.tsx | 7 + .../sections/shared/ConfirmationDialog.tsx | 17 +- src/config/constants.ts | 1 + src/hooks/index.ts | 1 + src/hooks/useEvents.ts | 39 ++ yarn.lock | 8 +- 14 files changed, 712 insertions(+), 207 deletions(-) create mode 100644 src/app/[lang]/dashboard/calendar/[id]/edit/page.tsx create mode 100644 src/hooks/useEvents.ts 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..7e41dff5 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -1522,6 +1522,33 @@ }, "calendar": { "createEvent": "Veranstaltung erstellen", + "management": "Veranstaltungsverwaltung", + "events": "Veranstaltungen", + "upcomingEvents": "Kommende Veranstaltungen", + "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", + "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", "titleLabel": "Titel der ehrenamtlichen Tätigkeit", @@ -1529,6 +1556,7 @@ "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 +1564,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..cc72fd63 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -1510,6 +1510,33 @@ }, "calendar": { "createEvent": "Create an event", + "management": "Event management", + "events": "Events", + "upcomingEvents": "Upcoming events", + "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", + "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", "titleLabel": "Title of the voluntary offer", @@ -1517,6 +1544,7 @@ "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 +1552,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..0743072d --- /dev/null +++ b/src/app/[lang]/dashboard/calendar/[id]/edit/page.tsx @@ -0,0 +1,6 @@ +import { CreateEvent } from "@/components/Dashboard/Calendar/CreateEvent"; + +export default async function EditEventPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/src/components/Dashboard/Calendar/CalendarContent.tsx b/src/components/Dashboard/Calendar/CalendarContent.tsx index 357e1848..f8d06dc4 100644 --- a/src/components/Dashboard/Calendar/CalendarContent.tsx +++ b/src/components/Dashboard/Calendar/CalendarContent.tsx @@ -1,244 +1,478 @@ "use client"; -import { CaretLeftIcon, CaretRightIcon, HandHeartIcon } from "@phosphor-icons/react"; +import { + CalendarBlankIcon, + CaretDownIcon, + CaretLeftIcon, + CaretRightIcon, + LinkIcon, + MapPinIcon, +} from "@phosphor-icons/react"; +import type { ApiEventN4DGetList } from "need4deed-sdk"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; import Button from "@/components/core/button/Button/Button"; +import { ConfirmationDialog } from "@/components/Dashboard/Profile/sections/shared/ConfirmationDialog"; +import { Heading2, Heading3, Paragraph } from "@/components/styled/text"; +import { useDeleteEvent, useEvents } from "@/hooks"; -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 }); - } - } +function cells(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; +} - return cells; +function key(date: Date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } -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; +function range(event: ApiEventN4DGetList, locale: string) { + const start = new Date(event.date); + const end = event.dateEnd ? new Date(event.dateEnd) : undefined; + const date = start.toLocaleDateString(locale, { weekday: "long", day: "numeric", month: "long", year: "numeric" }); + const time = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); + const endTime = end?.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); + return `${date} · ${time}${endTime ? `–${endTime}` : ""}`; } export function CalendarContent() { const { t, i18n } = useTranslation(); const router = useRouter(); + const today = useMemo(() => new Date(), []); + const [monthDate, setMonthDate] = useState(new Date(today.getFullYear(), today.getMonth(), 1)); + const [showPast, setShowPast] = useState(false); + const [deleting, setDeleting] = useState(null); + const { data: events = [], isLoading, isError } = useEvents(); + const remove = useDeleteEvent(); + const year = monthDate.getFullYear(); + const month = monthDate.getMonth(); + const upcoming = events.filter((event) => new Date(event.dateEnd ?? event.date) >= today); + const past = events.filter((event) => new Date(event.dateEnd ?? event.date) < today).reverse(); + const eventDays = new Set(events.map((event) => key(new Date(event.date)))); + const create = (date?: string) => + router.push(`/${i18n.language}/dashboard/calendar/create${date ? `?date=${date}` : ""}`); - 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 clickDay = (day: number) => { + const selected = new Date(year, month, day); + const event = events.find((item) => key(new Date(item.date)) === key(selected)); + if (event) document.getElementById(`event-${event.id}`)?.scrollIntoView({ behavior: "smooth", block: "center" }); + else create(key(selected)); }; return ( - - - + + + {t("dashboard.calendar.events")} + + )) + ) : ( + {t("dashboard.calendar.noPastEvents")} + ))} + + + + + {deleting && ( + setDeleting(null)} + onConfirm={() => remove.mutate(deleting.id, { onSettled: () => setDeleting(null) })} + /> + )} + ); } -const PageWrapper = styled.div` - display: flex; - flex-direction: column; - gap: var(--spacing-24); +interface EventCardProps { + event: ApiEventN4DGetList; + locale: string; + onEdit: () => void; + onDelete: () => void; + t: ReturnType["t"]; +} +function EventCard({ event, locale, onEdit, onDelete, t }: EventCardProps) { + const date = new Date(event.date); + return ( + + + {date.toLocaleString(locale, { month: "short" })} + {date.getDate()} + +
+ +
+ {event.title} + {event.shortDescription && {event.shortDescription}} +
+ + {t(event.active ? "dashboard.calendar.published" : "dashboard.calendar.draft")} + +
+ + + {range(event, locale)} + + + + {event.address} + + + + + {t("dashboard.calendar.openRegistration")} + + + +
+
+ ); +} + +const Page = styled.div` padding: var(--spacing-32) var(--spacing-48) var(--spacing-48); width: 100%; box-sizing: border-box; + color: var(--color-midnight); `; - -const TopRow = styled.div` +const PageHeading = styled.div` display: flex; align-items: center; justify-content: space-between; + gap: var(--spacing-24); + margin-bottom: var(--spacing-32); +`; +const Grid = styled.div` + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); + gap: var(--spacing-24); + align-items: start; + @media (max-width: 1000px) { + grid-template-columns: 1fr; + } +`; +const Events = styled.section` + min-width: 0; +`; +const SectionHeading = styled.div` + min-height: 64px; + margin-bottom: var(--spacing-16); + p { + margin: var(--spacing-4) 0 0; + color: var(--color-midnight); + } +`; +const State = styled.div` + padding: var(--spacing-32); + border-radius: var(--border-radius-large); background: var(--color-white); + text-align: center; +`; +const Card = styled.article` + display: grid; + grid-template-columns: 72px 1fr; + gap: var(--spacing-20); + padding: var(--spacing-24); + margin-bottom: var(--spacing-16); + border: var(--border-width-thin) solid var(--color-orchid); border-radius: var(--border-radius-large); - padding: var(--spacing-16) var(--spacing-24); - width: 100%; + background: var(--color-white); box-sizing: border-box; + @media (max-width: 600px) { + grid-template-columns: 1fr; + } `; - -const CalendarCenter = styled.div` +const DateBox = styled.div` + width: 64px; + height: 76px; display: flex; + flex-direction: column; + align-items: center; justify-content: center; + border-radius: var(--border-radius-medium); + background: var(--color-orchid-subtle); + span { + text-transform: uppercase; + font-size: var(--font-size-xs); + font-weight: bold; + } + strong { + font-size: var(--font-size-xl); + } +`; +const Details = styled.div` + min-width: 0; +`; +const CardHeader = styled.div` + display: flex; + justify-content: space-between; + gap: var(--spacing-16); +`; +const Description = styled.p` + color: var(--color-midnight); + line-height: 1.5; + margin: var(--spacing-8) 0 var(--spacing-16); +`; +const Status = styled.span<{ $active: boolean }>` + height: max-content; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--border-radius-small); + font-size: var(--font-size-xs); + font-weight: bold; + color: ${({ $active }) => ($active ? "var(--color-green-700)" : "var(--color-grey-500)")}; + background: var(--color-orchid-subtle); +`; +const Meta = styled.div` + display: flex; + align-items: center; + gap: var(--spacing-8); + margin-top: var(--spacing-8); + color: var(--color-midnight); +`; +const Actions = styled.div` + display: flex; + align-items: center; + gap: var(--spacing-16); + flex-wrap: wrap; + margin-top: var(--spacing-20); + padding-top: var(--spacing-16); + border-top: var(--border-width-thin) solid var(--color-grey-200); +`; +const Registration = styled.a` + color: var(--color-midnight); + font-weight: var(--font-weight-semibold); + text-decoration: none; + &:hover { + text-decoration: underline; + } +`; +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; + text-align: left; + span { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + } + strong { + font-size: var(--text-h3-font-size); + line-height: var(--text-h3-line-height); + font-weight: var(--text-h3-font-weight); + letter-spacing: var(--text-h3-letter-spacing); + } +`; +const PastRow = styled.div` + display: grid; + grid-template-columns: 1fr auto auto; + gap: var(--spacing-16); + padding: var(--spacing-12) 0; + border-bottom: var(--border-width-thin) solid var(--color-grey-200); + button { + border: 0; + background: transparent; + color: var(--color-aubergine); + cursor: pointer; + font-weight: bold; + } `; - const CalendarBox = styled.div` - border: var(--border-width-medium) solid var(--color-midnight); + padding: var(--spacing-20); + box-sizing: border-box; + border: var(--border-width-thin) solid var(--color-orchid); 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); + strong { + text-transform: capitalize; + } `; - -const NavButton = styled.button` - background: none; - border: none; - cursor: pointer; +const Nav = styled.button` display: flex; - align-items: center; - padding: var(--spacing-8); + border: 0; 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; + padding: var(--spacing-8); + background: var(--color-orchid-subtle); + cursor: pointer; `; - -const DaysGrid = styled.div` +const Days = styled.div` display: grid; grid-template-columns: repeat(7, 1fr); gap: var(--spacing-4); `; - -const DayHeader = styled.div` +const DayName = styled.div` + padding: var(--spacing-8) 0; text-align: center; - font-weight: bold; - font-size: var(--font-size-xs); color: var(--color-midnight); - padding: var(--spacing-8) 0; + font-size: var(--font-size-xs); + font-weight: bold; `; - -interface DayCellProps { - $isToday: boolean; - $isSelected: boolean; - $faded: boolean; - $clickable: boolean; -} - -const DayCell = styled.div` +const Day = styled.button<{ $faded: boolean; $today: boolean; $event: boolean }>` + position: relative; display: flex; align-items: center; justify-content: center; - width: var(--spacing-40); - height: var(--spacing-40); - margin: auto; + aspect-ratio: 1; + border: ${({ $today }) => ($today ? "var(--border-width-medium) solid var(--color-salmon)" : "0")}; 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}; + background: ${({ $event }) => ($event ? "var(--color-midnight)" : "transparent")}; + color: ${({ $faded, $event }) => + $faded ? "var(--color-grey-300)" : $event ? "var(--color-white)" : "var(--color-midnight)"}; + cursor: ${({ $faded }) => ($faded ? "default" : "pointer")}; + font-weight: ${({ $today, $event }) => ($today || $event ? "bold" : "normal")}; + &:hover:not(:disabled) { + background: var(--color-orchid); } `; +const Dot = styled.i` + position: absolute; + bottom: 3px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--color-salmon); +`; diff --git a/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx b/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx index d3d7c330..2857591a 100644 --- a/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx +++ b/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx @@ -1,14 +1,16 @@ "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 } from "need4deed-sdk"; import { PageLayout } from "@/components/Layout"; +import { useCreateEvent, useEvents, useUpdateEvent } from "@/hooks"; import { StepDateTime } from "./StepDateTime"; import { StepLocation } from "./StepLocation"; -import { StepTitle } from "./StepTitle"; +import { DESCRIPTION_MAX, StepTitle } from "./StepTitle"; export interface EventFormData { title: string; @@ -16,13 +18,36 @@ export interface EventFormData { street: string; houseNumber: string; postcode: string; - date: string; - time: string; + registrationLink: string; + startDate: string; + startTime: string; + endDate: string; + endTime: string; } const TOTAL_STEPS = 3; -export function CreateEvent() { +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] } + : { street: address, houseNumber: "", postcode: "" }; +} + +export function CreateEvent({ eventId }: Props) { const { i18n } = useTranslation(); const router = useRouter(); const searchParams = useSearchParams(); @@ -35,9 +60,32 @@ export function CreateEvent() { street: "", houseNumber: "", postcode: "", - date: prefilledDate, - time: "", + registrationLink: "", + startDate: prefilledDate, + startTime: "", + endDate: prefilledDate, + endTime: "", }); + const [initializedEventId, setInitializedEventId] = useState(null); + const { data: events = [], isLoading } = useEvents(); + const editingEvent = eventId ? events.find((event) => event.id === eventId) : undefined; + + useEffect(() => { + if (!eventId || !editingEvent || initializedEventId === eventId) return; + const start = dateParts(editingEvent.date); + const end = dateParts(editingEvent.dateEnd ?? editingEvent.date); + setFormData({ + 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 })); @@ -51,23 +99,65 @@ 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 toPayload = (): ApiEventN4DCreate => ({ + date: new Date(`${formData.startDate}T${formData.startTime}`), + dateEnd: new Date(`${formData.endDate}T${formData.endTime}`), + type: EventN4DType.WORKSHOP, + linkRSVP: formData.registrationLink, + address: `${formData.street} ${formData.houseNumber}, ${formData.postcode} Berlin`, + active: true, + translations: [ + { + language: i18n.language === Lang.DE ? Lang.DE : Lang.EN, + title: formData.title, + menuTitle: formData.title, + description: formData.description, + shortDescription: formData.description, + }, + ], + }); + + const handleSubmit = () => (eventId ? updateEvent.mutate(toPayload()) : createEvent.mutate(toPayload())); 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.postcode.trim().length > 0 && + /^https?:\/\//.test(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 ( + + + + ); + if (eventId && !isLoading && !editingEvent) + return ( + + + + ); + return ( @@ -93,6 +183,7 @@ export function CreateEvent() { street={formData.street} houseNumber={formData.houseNumber} postcode={formData.postcode} + registrationLink={formData.registrationLink} onChange={update} onNext={handleNext} onBack={handleBack} @@ -101,12 +192,14 @@ export function CreateEvent() { )} {step === 3 && ( )} 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..cb93ee6f 100644 --- a/src/components/Dashboard/Calendar/CreateEvent/StepTitle.tsx +++ b/src/components/Dashboard/Calendar/CreateEvent/StepTitle.tsx @@ -6,6 +6,7 @@ import { EventFormData } from "./CreateEvent"; import { ButtonRow, CharCount, FieldGroup, FieldLabel, HelperText, StyledInput, StyledTextarea } from "./styles"; const TITLE_MAX = 128; +export const DESCRIPTION_MAX = 512; interface Props { title: string; @@ -42,11 +43,17 @@ export function StepTitle({ title, description, onChange, onNext, onCancel, isNe {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/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} /> - - )) - ) : ( - {t("dashboard.calendar.noPastEvents")} - ))} - - - - - {deleting && ( - setDeleting(null)} - onConfirm={() => remove.mutate(deleting.id, { onSettled: () => setDeleting(null) })} - /> - )} + ); } -interface EventCardProps { - event: ApiEventN4DGetList; - locale: string; - onEdit: () => void; - onDelete: () => void; - t: ReturnType["t"]; -} -function EventCard({ event, locale, onEdit, onDelete, t }: EventCardProps) { - const date = new Date(event.date); - return ( - - - {date.toLocaleString(locale, { month: "short" })} - {date.getDate()} - -
- -
- {event.title} - {event.shortDescription && {event.shortDescription}} -
- - {t(event.active ? "dashboard.calendar.published" : "dashboard.calendar.draft")} - -
- - - {range(event, locale)} - - - - {event.address} - - - - - {t("dashboard.calendar.openRegistration")} - - - -
-
- ); -} - const Page = styled.div` padding: var(--spacing-32) var(--spacing-48) var(--spacing-48); width: 100%; box-sizing: border-box; color: var(--color-midnight); + @media (max-width: 600px) { + padding: var(--spacing-24) var(--spacing-16) var(--spacing-32); + } `; + const PageHeading = styled.div` display: flex; align-items: center; @@ -270,209 +58,3 @@ const PageHeading = styled.div` gap: var(--spacing-24); margin-bottom: var(--spacing-32); `; -const Grid = styled.div` - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); - gap: var(--spacing-24); - align-items: start; - @media (max-width: 1000px) { - grid-template-columns: 1fr; - } -`; -const Events = styled.section` - min-width: 0; -`; -const SectionHeading = styled.div` - min-height: 64px; - margin-bottom: var(--spacing-16); - p { - margin: var(--spacing-4) 0 0; - color: var(--color-midnight); - } -`; -const State = styled.div` - padding: var(--spacing-32); - border-radius: var(--border-radius-large); - background: var(--color-white); - text-align: center; -`; -const Card = styled.article` - display: grid; - grid-template-columns: 72px 1fr; - gap: var(--spacing-20); - padding: var(--spacing-24); - margin-bottom: var(--spacing-16); - border: var(--border-width-thin) solid var(--color-orchid); - border-radius: var(--border-radius-large); - background: var(--color-white); - box-sizing: border-box; - @media (max-width: 600px) { - grid-template-columns: 1fr; - } -`; -const DateBox = styled.div` - width: 64px; - height: 76px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - border-radius: var(--border-radius-medium); - background: var(--color-orchid-subtle); - span { - text-transform: uppercase; - font-size: var(--font-size-xs); - font-weight: bold; - } - strong { - font-size: var(--font-size-xl); - } -`; -const Details = styled.div` - min-width: 0; -`; -const CardHeader = styled.div` - display: flex; - justify-content: space-between; - gap: var(--spacing-16); -`; -const Description = styled.p` - color: var(--color-midnight); - line-height: 1.5; - margin: var(--spacing-8) 0 var(--spacing-16); -`; -const Status = styled.span<{ $active: boolean }>` - height: max-content; - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--border-radius-small); - font-size: var(--font-size-xs); - font-weight: bold; - color: ${({ $active }) => ($active ? "var(--color-green-700)" : "var(--color-grey-500)")}; - background: var(--color-orchid-subtle); -`; -const Meta = styled.div` - display: flex; - align-items: center; - gap: var(--spacing-8); - margin-top: var(--spacing-8); - color: var(--color-midnight); -`; -const Actions = styled.div` - display: flex; - align-items: center; - gap: var(--spacing-16); - flex-wrap: wrap; - margin-top: var(--spacing-20); - padding-top: var(--spacing-16); - border-top: var(--border-width-thin) solid var(--color-grey-200); -`; -const Registration = styled.a` - color: var(--color-midnight); - font-weight: var(--font-weight-semibold); - text-decoration: none; - &:hover { - text-decoration: underline; - } -`; -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; - text-align: left; - span { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - } - strong { - font-size: var(--text-h3-font-size); - line-height: var(--text-h3-line-height); - font-weight: var(--text-h3-font-weight); - letter-spacing: var(--text-h3-letter-spacing); - } -`; -const PastRow = styled.div` - display: grid; - grid-template-columns: 1fr auto auto; - gap: var(--spacing-16); - padding: var(--spacing-12) 0; - border-bottom: var(--border-width-thin) solid var(--color-grey-200); - button { - border: 0; - background: transparent; - color: var(--color-aubergine); - cursor: pointer; - font-weight: bold; - } -`; -const CalendarBox = styled.div` - padding: var(--spacing-20); - box-sizing: border-box; - border: var(--border-width-thin) solid var(--color-orchid); - border-radius: var(--border-radius-large); - background: var(--color-white); -`; -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); - 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; $event: boolean }>` - position: relative; - display: flex; - align-items: center; - justify-content: center; - aspect-ratio: 1; - border: ${({ $today }) => ($today ? "var(--border-width-medium) solid var(--color-salmon)" : "0")}; - border-radius: 50%; - background: ${({ $event }) => ($event ? "var(--color-midnight)" : "transparent")}; - color: ${({ $faded, $event }) => - $faded ? "var(--color-grey-300)" : $event ? "var(--color-white)" : "var(--color-midnight)"}; - cursor: ${({ $faded }) => ($faded ? "default" : "pointer")}; - font-weight: ${({ $today, $event }) => ($today || $event ? "bold" : "normal")}; - &:hover:not(:disabled) { - background: var(--color-orchid); - } -`; -const Dot = styled.i` - position: absolute; - bottom: 3px; - width: 4px; - height: 4px; - border-radius: 50%; - background: var(--color-salmon); -`; diff --git a/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx b/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx index 2857591a..5c91dba7 100644 --- a/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx +++ b/src/components/Dashboard/Calendar/CreateEvent/CreateEvent.tsx @@ -6,13 +6,14 @@ import styled from "styled-components"; import { EventN4DType, Lang, type ApiEventN4DCreate } from "need4deed-sdk"; import { PageLayout } from "@/components/Layout"; -import { useCreateEvent, useEvents, useUpdateEvent } from "@/hooks"; +import { useCreateEvent, useEvent, useUpdateEvent } from "@/hooks"; import { StepDateTime } from "./StepDateTime"; import { StepLocation } from "./StepLocation"; import { DESCRIPTION_MAX, StepTitle } from "./StepTitle"; export interface EventFormData { + type: EventN4DType; title: string; description: string; street: string; @@ -55,6 +56,7 @@ export function CreateEvent({ eventId }: Props) { const [step, setStep] = useState(1); const [formData, setFormData] = useState({ + type: EventN4DType.WORKSHOP, title: "", description: "", street: "", @@ -67,14 +69,14 @@ export function CreateEvent({ eventId }: Props) { endTime: "", }); const [initializedEventId, setInitializedEventId] = useState(null); - const { data: events = [], isLoading } = useEvents(); - const editingEvent = eventId ? events.find((event) => event.id === eventId) : undefined; + const { data: editingEvent, isLoading } = useEvent(eventId); 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), @@ -106,7 +108,7 @@ export function CreateEvent({ eventId }: Props) { const toPayload = (): ApiEventN4DCreate => ({ date: new Date(`${formData.startDate}T${formData.startTime}`), dateEnd: new Date(`${formData.endDate}T${formData.endTime}`), - type: EventN4DType.WORKSHOP, + type: formData.type, linkRSVP: formData.registrationLink, address: `${formData.street} ${formData.houseNumber}, ${formData.postcode} Berlin`, active: true, @@ -170,6 +172,7 @@ export function CreateEvent({ eventId }: Props) { {step === 1 && ( ) => void; @@ -17,13 +28,26 @@ 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(); return ( <> {t("dashboard.calendar.createForm.eventTitle")} + + {t("dashboard.calendar.createForm.typeLabel")} + onChange({ type: event.target.value as EventN4DType })} + > + + + + + {t("dashboard.calendar.createForm.titleLabel")} void; +} + +export function CreateEventCta({ onCreate }: Props) { + const { t } = useTranslation(); + return ( +