diff --git a/src/app/admin/events/page.tsx b/src/app/admin/events/page.tsx index 47d7144..83bfda3 100644 --- a/src/app/admin/events/page.tsx +++ b/src/app/admin/events/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { FilePlus2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Award, FilePlus2, Pencil, Trash2, Undo2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SectionCard } from "@/components/admin/section-card"; @@ -10,7 +10,22 @@ import { adminButtonClass, adminInputClass, } from "@/components/admin/admin-field"; +import { + BadgeFormDialog, + type BadgeDraft, +} from "@/components/admin/badge-form-dialog"; import { useAdmin } from "@/components/admin/use-admin"; +import type { Badge } from "@/lib/schemas/badge"; +import BadgesService from "@/services/badges/badges-service"; + +/** Fills the badge form from a saved badge, for editing. */ +const draftFromBadge = (badge: Badge): BadgeDraft => ({ + name: badge.name, + description: badge.description, + criteria: badge.criteria, + imageFile: null, + imagePreviewUrl: badge.imageurl, +}); const formatDateString = (value?: string | null) => value ? new Date(value).toLocaleString() : "N/A"; @@ -41,9 +56,50 @@ export default function AdminEventsPage() { const [imageFile, setImageFile] = useState(null); const [imagePreviewUrl, setImagePreviewUrl] = useState(null); const [imageUrl, setImageUrl] = useState(null); - const [imageUploading, setImageUploading] = useState(false); - const [imageError, setImageError] = useState(null); + const [saving, setSaving] = useState(false); const [editingId, setEditingId] = useState(null); + // The badge only exists here until the event is saved; the server then + // creates the event and badge together, or neither. + const [badgeDraft, setBadgeDraft] = useState(null); + const [badgeDialogOpen, setBadgeDialogOpen] = useState(false); + // Badges linked to an event, keyed by event id. badges.eventid is unique, so + // an event has at most one. + const [eventBadges, setEventBadges] = useState>({}); + const [badgesError, setBadgesError] = useState(null); + // The badge the event being edited already has, as saved. + const [existingBadge, setExistingBadge] = useState(null); + // Set when the admin removes the existing badge: on save it is deleted, + // along with every member's award of it. + const [removeBadge, setRemoveBadge] = useState(false); + + // Badges are public, so they are read directly rather than through an + // admin route. Reloaded whenever the event list is, i.e. after every save. + useEffect(() => { + let cancelled = false; + + new BadgesService() + .getAllBadges() + .then((badges) => { + if (cancelled) return; + + const byEvent: Record = {}; + for (const badge of badges) { + if (badge.eventid) byEvent[badge.eventid] = badge; + } + setEventBadges(byEvent); + setBadgesError(null); + }) + .catch((error) => { + console.error("Failed to load badges", error); + if (!cancelled) { + setBadgesError("Couldn't load existing badges. Refresh before editing one."); + } + }); + + return () => { + cancelled = true; + }; + }, [events]); /** Clears every event field and leaves edit mode. Backs the Reset button. */ const resetForm = () => { @@ -60,69 +116,45 @@ export default function AdminEventsPage() { setImageFile(null); setImagePreviewUrl(null); setImageUrl(null); - setImageError(null); - }; - - const uploadImage = async (): Promise<{ - event_path: string; - event_url: string; - } | null> => { - if (!imageFile) return null; - setImageError(null); - setImageUploading(true); - - try { - const formData = new FormData(); - formData.append("image", imageFile); - - const res = await adminFetch("/api/admin/events/image", { - method: "POST", - body: formData, - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Unknown error" })); - throw new Error(err.error || "Failed to upload image"); - } - - return await res.json(); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - setImageError(message || "Image upload failed"); - return null; - } finally { - setImageUploading(false); - } + setBadgeDraft(null); + setExistingBadge(null); + setRemoveBadge(false); }; const createEvent = async () => { + setSaving(true); try { - let uploadedImage: { event_path: string; event_url: string } | null = - null; - if (imageFile) { - uploadedImage = await uploadImage(); - if (!uploadedImage) { - throw new Error("Unable to upload image before saving event"); - } - } - - const payload: Record = { + const event: Record = { title, description, location: location.trim() || null, capacity: capacity.trim() ? Number(capacity) : null, start_time: startTime ? new Date(startTime).toISOString() : null, end_time: endTime ? new Date(endTime).toISOString() : null, - event_url: uploadedImage?.event_url ?? imageUrl, - event_path: uploadedImage?.event_path ?? undefined, + // Keeps the current image when no new file is chosen. A new file is + // sent as event_image below and replaces both URL and path. + event_url: imageUrl, }; - if (editingId) payload.id = editingId; + if (editingId) event.id = editingId; if (checkInOpen) - payload.check_in_open_time = new Date(checkInOpen).toISOString(); + event.check_in_open_time = new Date(checkInOpen).toISOString(); if (checkInClose) - payload.check_in_close_time = new Date(checkInClose).toISOString(); + event.check_in_close_time = new Date(checkInClose).toISOString(); + + const payload: Record = { event }; + + if (removeBadge) { + payload.remove_badge = true; + } else if (badgeDraft) { + // Creates the event's badge, or updates the one it already has. + payload.badge = { + name: badgeDraft.name, + description: badgeDraft.description, + criteria: badgeDraft.criteria, + }; + } const assigned = assignedEmailsText .split(",") @@ -130,9 +162,17 @@ export default function AdminEventsPage() { .filter(Boolean); if (assigned.length) payload.assignedEmails = assigned; + // One request carries the event, the badge and both images, so the + // server can save all of it or none of it. + const formData = new FormData(); + formData.append("payload", JSON.stringify(payload)); + if (imageFile) formData.append("event_image", imageFile); + if (badgeDraft?.imageFile) + formData.append("badge_image", badgeDraft.imageFile); + const res = await adminFetch("/api/admin/events", { method: "PUT", - body: JSON.stringify(payload), + body: formData, }); if (!res.ok) { @@ -146,13 +186,22 @@ export default function AdminEventsPage() { fetchData(); alert(wasEditing ? "Event saved" : "Event created"); } catch (err: unknown) { + // The form is left as it was, badge included, so the admin can fix the + // problem and try again. Nothing was saved. const message = err instanceof Error ? err.message : String(err); alert(message || "Failed to create event"); + } finally { + setSaving(false); } }; const handleDeleteEvent = async (eventId: string) => { - if (!confirm("Are you sure you want to delete this event?")) return; + const badge = eventBadges[eventId]; + const message = badge + ? `Are you sure you want to delete this event? Its badge "${badge.name}" will also be deleted, and every member who earned it will lose it.` + : "Are you sure you want to delete this event?"; + + if (!confirm(message)) return; try { const res = await adminFetch("/api/admin/events", { @@ -262,61 +311,149 @@ export default function AdminEventsPage() { /> -
- - Event Image - - {/* Native input is hidden; the label is the visible button. */} - - - {imagePreviewUrl && ( - Event preview - )} - {imageError && ( -

- {imageError} -

- )} +
+
+ + Event Image + + {/* Native input is hidden; the label is the visible button. */} + + + {imagePreviewUrl && ( + Event preview + )} +
+ +
+ + Event Badge (optional) + + + {badgeDraft ? ( +
+
+
+ {badgeDraft.imagePreviewUrl ? ( + {`${badgeDraft.name} + ) : ( + + )} +
+

+ {badgeDraft.name} +

+
+ +
+ + +
+
+ ) : removeBadge && existingBadge ? ( + // Removing and adding a new badge can't happen in one save, so + // Add Badge waits until this one has been saved. +
+

+ “{existingBadge.name}” will be deleted when you + save, and every member who earned it will lose it. +

+ +
+ ) : ( + + )} + + {badgesError && ( +

+ {badgesError} +

+ )} +
+ {badgeDialogOpen && ( + { + setBadgeDraft(draft); + setBadgeDialogOpen(false); + }} + onCancel={() => setBadgeDialogOpen(false)} + /> + )} +
-
-
+
+

Badges

- {profile.badges.length > 0 ? ( + {badges.length > 0 ? (
- {profile.badges.map((badge, index) => ( -
- - {badge} - -
+ {badges.map((award) => ( + ))}
) : ( diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 84a1136..acfc43c 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -9,6 +9,9 @@ import { useSignMessage } from "wagmi"; import { ProfileVisibilityToggle } from "@/components/profile-visibility-toggle"; import { IoQrCode } from "react-icons/io5"; import QrCode from "qrcode"; +import MemberBadgesService from "@/services/member-badges/member-badges-service"; +import type { MemberBadgeWithBadge } from "@/lib/schemas/member-badge"; +import { BadgeCard } from "@/components/badge-card"; import { AttendedEvents } from "@/components/attended-events"; const getErrorMessage = (error: unknown) => { @@ -30,7 +33,7 @@ export default function ProfilePage() { const [displayName, setDisplayName] = useState("Name"); const [isEditingName, setIsEditingName] = useState(false); const [profileImage, setProfileImage] = useState(null); - const [badges, setBadges] = useState([]); + const [badges, setBadges] = useState([]); const [eventsAttended, setEventsAttended] = useState([]); const [profileLoading, setProfileLoading] = useState(true); const [walletRegistered, setWalletRegistered] = useState(false); @@ -157,7 +160,6 @@ export default function ProfilePage() { }); setProfileImage(result.profilePictureUrl); - setBadges(result.record.badges ?? []); setStatusMessage("Profile picture saved."); } catch (error) { const message = getErrorMessage(error); @@ -172,6 +174,24 @@ export default function ProfilePage() { useEffect(() => { let cancelled = false; + // Badges live in member_badges, keyed on registrations.id. The query embeds + // the parent badge, so the image, description and criteria arrive in a + // single round trip and the hover panel needs no follow-up request. + // Failures are swallowed so a badge outage leaves the profile intact. + const fetchBadges = async (memberId: number) => { + try { + const awards = + await new MemberBadgesService().getBadgesForMember(memberId); + + if (!cancelled) { + setBadges(awards); + } + } catch (error) { + console.error("Failed to load badges", error); + if (!cancelled) setBadges([]); + } + }; + const loadProfile = async () => { if (!address) { setWalletRegistered(false); @@ -220,8 +240,8 @@ export default function ProfilePage() { const attended = registration.events_attended || []; setDisplayName(displayNameFallback || "Name"); setProfileImage(registration.profile_picture_url || null); - setBadges(registration.badges || []); setEventsAttended(attended); + await fetchBadges(registration.id); setProfileVisible(registration.profile_visible ?? true); } else { setDisplayName("Name"); @@ -279,7 +299,6 @@ export default function ProfilePage() { setIsEditingName(false); setDisplayName(updated.unique_name ?? displayName); - setBadges(updated.badges ?? badges); setStatusMessage("Display name saved."); } catch (error) { const message = getErrorMessage(error); @@ -515,9 +534,9 @@ export default function ProfilePage() {
-
+
{/* Badges*/} -
+

Badges

@@ -527,15 +546,8 @@ export default function ProfilePage() {

) : badges.length > 0 ? (
- {badges.map((badge, index) => ( -
- - {badge} - -
+ {badges.map((award) => ( + ))}
) : ( diff --git a/src/components/admin/badge-form-dialog.tsx b/src/components/admin/badge-form-dialog.tsx new file mode 100644 index 0000000..6edea76 --- /dev/null +++ b/src/components/admin/badge-form-dialog.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; +import { createPortal } from "react-dom"; +import { FilePlus2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + AdminField, + adminButtonClass, + adminInputClass, +} from "@/components/admin/admin-field"; + +/** + * A badge the admin has filled in but not yet saved. It only reaches the + * database when the event form is submitted, together with the event. + */ +export type BadgeDraft = { + name: string; + description: string | null; + criteria: string | null; + /** A newly chosen image, uploaded on submit. */ + imageFile: File | null; + /** What to show in the preview: the chosen file, or the saved image. */ + imagePreviewUrl: string | null; +}; + +type BadgeFormDialogProps = { + /** The draft to edit, or null to start a new badge. */ + initial: BadgeDraft | null; + onSave: (draft: BadgeDraft) => void; + onCancel: () => void; +}; + +export function BadgeFormDialog({ + initial, + onSave, + onCancel, +}: BadgeFormDialogProps) { + const titleId = useId(); + const [isMounted, setIsMounted] = useState(false); + + const [name, setName] = useState(initial?.name ?? ""); + const [description, setDescription] = useState(initial?.description ?? ""); + const [criteria, setCriteria] = useState(initial?.criteria ?? ""); + const [imageFile, setImageFile] = useState( + initial?.imageFile ?? null, + ); + const [imagePreviewUrl, setImagePreviewUrl] = useState( + initial?.imagePreviewUrl ?? null, + ); + const [nameError, setNameError] = useState(null); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onCancel(); + } + }; + + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("keydown", handleEscape); + }; + }, [onCancel]); + + useEffect(() => { + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = ""; + }; + }, []); + + const handleSave = () => { + const trimmedName = name.trim(); + if (!trimmedName) { + setNameError("Badge name is required"); + return; + } + + onSave({ + name: trimmedName, + description: description.trim() || null, + criteria: criteria.trim() || null, + imageFile, + imagePreviewUrl, + }); + }; + + const overlay = ( +
+
+ +
+

+ {initial ? "Edit Badge" : "Add Badge"} +

+ + + { + setName(e.target.value); + setNameError(null); + }} + placeholder="Launch Night 2026" + /> + + {nameError && ( +

+ {nameError} +

+ )} + + +