From b94e607d64d1cb1e025cc431bc0d644492696413 Mon Sep 17 00:00:00 2001 From: Even Date: Mon, 6 Oct 2025 23:37:01 +0200 Subject: [PATCH] feat: add Discord sharing to PostEventCreationModal - Add Discord share button that appears only if event not already shared - Implement sharedToDiscord flag in event types and Firestore logic - Add sendDiscordNotification integration for post-creation sharing - Button disappears after successful sharing to prevent duplicate posts - Include calendar export button alongside Discord sharing - Auto-enroll event creator as participant --- frontend/app/components/AddEventForm.tsx | 72 +++++----- frontend/app/components/Calendar.tsx | 110 +++++++++++----- frontend/app/components/EventDetails.tsx | 57 +++++--- .../app/components/PostEventCreationModal.tsx | 123 ++++++++++++++++++ frontend/hooks/useEventActions.ts | 71 +++++++--- frontend/hooks/useEventModal.ts | 2 +- frontend/hooks/useEvents.ts | 4 +- frontend/lib/types.ts | 12 +- frontend/package-lock.json | 17 +-- 9 files changed, 340 insertions(+), 128 deletions(-) create mode 100644 frontend/app/components/PostEventCreationModal.tsx diff --git a/frontend/app/components/AddEventForm.tsx b/frontend/app/components/AddEventForm.tsx index b9b3d43..3aefb2d 100644 --- a/frontend/app/components/AddEventForm.tsx +++ b/frontend/app/components/AddEventForm.tsx @@ -2,7 +2,7 @@ // --- Imports --- import { useState } from "react"; -import { EventType, Place } from "@/lib/types"; +import { EventType, Place, RawEventWithLocation } from "@/lib/types"; import { EVENT_TYPES, TIME_OPTIONS } from "@/lib/constants"; // --- Component Props Interface --- @@ -14,7 +14,7 @@ interface AddEventFormProps { datePart: string; timePart: string; locationInput: string; - }) => Promise; + }) => Promise; } // --- Add Event Form Component --- @@ -61,9 +61,9 @@ export default function AddEventForm({ places, onSubmit }: AddEventFormProps) { return (
{/* Event Type Selection */} - setType(e.target.value as EventType)} className="bg-sky-950 text-sky-200 border border-sky-200 px-2 py-2 rounded w-full focus:border-blue-500 focus:outline-none appearance-none text-left" style={{ backgroundImage: 'url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%23cbd5e1\' stroke-width=\'2\' stroke-linecap=\'round\' stroke-linejoin=\'round\'%3e%3cpolyline points=\'6,9 12,15 18,9\'%3e%3c/polyline%3e%3c/svg%3e")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 8px center', backgroundSize: '16px' }} > @@ -73,33 +73,33 @@ export default function AddEventForm({ places, onSubmit }: AddEventFormProps) { ))} - + {/* Event Description Input */} - setDescription(e.target.value)} - className="bg-sky-950 text-sky-200 border border-sky-200 px-2 py-2 rounded w-full focus:border-blue-500 focus:outline-none placeholder-sky-200 text-left" + setDescription(e.target.value)} + className="bg-sky-950 text-sky-200 border border-sky-200 px-2 py-2 rounded w-full focus:border-blue-500 focus:outline-none placeholder-sky-200 text-left" required={type === "other"} /> - + {/* Date Selection */} - setDatePart(e.target.value)} - className="bg-sky-950 text-sky-200 border border-sky-200 px-2 py-2 rounded w-full box-border focus:border-blue-500 focus:outline-none text-left" - style={{ - WebkitAppearance: 'none', + setDatePart(e.target.value)} + className="bg-sky-950 text-sky-200 border border-sky-200 px-2 py-2 rounded w-full box-border focus:border-blue-500 focus:outline-none text-left" + style={{ + WebkitAppearance: 'none', MozAppearance: 'textfield', colorScheme: 'dark' }} /> - + {/* Time Selection */} - setTimePart(e.target.value)} className="bg-sky-950 text-sky-200 border border-sky-200 px-2 py-2 rounded w-full focus:border-blue-500 focus:outline-none appearance-none text-left" style={{ backgroundImage: 'url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%23cbd5e1\' stroke-width=\'2\' stroke-linecap=\'round\' stroke-linejoin=\'round\'%3e%3cpolyline points=\'6,9 12,15 18,9\'%3e%3c/polyline%3e%3c/svg%3e")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 8px center', backgroundSize: '16px' }} > @@ -107,12 +107,12 @@ export default function AddEventForm({ places, onSubmit }: AddEventFormProps) { ))} - + {/* Location Selection - Indoor/Gym Events */} {(type === "boulder" || type === "toprope") ? ( - setLocationInput(e.target.value)} className="bg-sky-950 text-sky-200 border border-sky-200 px-2 py-2 rounded w-full focus:border-blue-500 focus:outline-none appearance-none text-left" style={{ backgroundImage: 'url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%23cbd5e1\' stroke-width=\'2\' stroke-linecap=\'round\' stroke-linejoin=\'round\'%3e%3cpolyline points=\'6,9 12,15 18,9\'%3e%3c/polyline%3e%3c/svg%3e")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 8px center', backgroundSize: '16px' }} > @@ -123,18 +123,18 @@ export default function AddEventForm({ places, onSubmit }: AddEventFormProps) { ) : ( /* Location Input - Outdoor Events */ - setLocationInput(e.target.value)} - className="bg-sky-950 text-sky-200 border border-sky-200 px-2 py-2 rounded w-full focus:border-blue-500 focus:outline-none placeholder-gray-400 text-left" + setLocationInput(e.target.value)} + className="bg-sky-950 text-sky-200 border border-sky-200 px-2 py-2 rounded w-full focus:border-blue-500 focus:outline-none placeholder-gray-400 text-left" /> )} - + {/* Action Buttons */} -
); } diff --git a/frontend/app/components/EventDetails.tsx b/frontend/app/components/EventDetails.tsx index 20718b8..5a629de 100644 --- a/frontend/app/components/EventDetails.tsx +++ b/frontend/app/components/EventDetails.tsx @@ -10,19 +10,23 @@ interface EventDetailsProps { user: User | null; onJoinLeave: () => void; onDelete: () => Promise; + onShareToDiscord?: (eventId: string) => Promise; } // --- Event Details Component --- -export default function EventDetails({ - event, - user, - onJoinLeave, - onDelete +export default function EventDetails({ + event, + user, + onJoinLeave, + onDelete, + onShareToDiscord }: EventDetailsProps) { // --- User Permission Checks --- const isUserJoined = user && event.participants.some(p => p.id === user.uid); const isUpcoming = event.date.split("T")[0] >= new Date().toISOString().split("T")[0]; const canDelete = user && (user.uid === event.creatorId || user.role === "admin"); + const isCreator = user && user.uid === event.creatorId; + const canShareToDiscord = isCreator && !event.sharedToDiscord; // --- Date and Time Helpers --- const formatDate = (dateStr: string): string => { @@ -57,6 +61,18 @@ export default function EventDetails({ }); }; + // --- Discord Share Handler --- + const handleShareToDiscord = async () => { + if (!onShareToDiscord) return; + + try { + await onShareToDiscord(event.id); + } catch (error) { + console.error("Error sharing to Discord:", error); + alert("Kunne ikke dele på Discord. Prøv igjen senere."); + } + }; + // --- Render Event Details --- return (
@@ -69,7 +85,7 @@ export default function EventDetails({

Tid: {formatTime(event.date)}

{event.description &&

Beskrivelse: {event.description}

}

Opprettet av: {event.creatorName}

- + {/* Participants List */}

Påmeldte:

    @@ -81,35 +97,44 @@ export default function EventDetails({ {/* Action Buttons Section - Right Side */}
    -
    +
    {/* Join/Leave Button - Top Right */} {isUpcoming && ( )} - + {/* Calendar Export Button - Below Join/Leave */} {isUserJoined && isUpcoming && ( - )} + + {/* Discord Share Button - Below Calendar Export */} + {canShareToDiscord && isUpcoming && ( + + )}
    - + {/* Delete Button - Fixed to Bottom */} {canDelete && ( - + +

    + Økten er opprettet! +

    + +
    +

    Type: {getEventTypeLabel(event.type)}

    +

    Dato: {formatDate(event.date)}

    +

    Tid: {formatTime(event.date)}

    +

    Sted: {event.locationName}

    + {event.description &&

    Beskrivelse: {event.description}

    } +

    + Du er automatisk påmeldt som deltaker. +

    +
    + +
    + + + {!event.sharedToDiscord && ( + + )} +
    +
    +
    + ); +} \ No newline at end of file diff --git a/frontend/hooks/useEventActions.ts b/frontend/hooks/useEventActions.ts index f9dbac3..c2d13f2 100644 --- a/frontend/hooks/useEventActions.ts +++ b/frontend/hooks/useEventActions.ts @@ -65,7 +65,7 @@ export function useEventActions({ // --- Join/Leave Toggle Handler --- const handleJoinLeaveToggle = async () => { if (!user || !selectedEvent) return; - + const isJoined = selectedEvent.participants.some(p => p.id === user.uid); if (isJoined) { await handleLeaveEvent(); @@ -84,7 +84,7 @@ export function useEventActions({ }) => { if (!user || !db) { alert("Du må være logget inn for å lage et event!"); - return; + return null; } const { type, description, datePart, timePart, locationInput } = eventData; @@ -97,25 +97,27 @@ export function useEventActions({ date: combinedDate, createdBy: doc(db, "users", user.uid), location: locationInput, - participants: [doc(db, "users", user.uid)] // Auto-join: Creator automatically joins their own event + participants: [doc(db, "users", user.uid)], // Auto-join: Creator automatically joins their own event + sharedToDiscord: false // Default to not shared }); - // --- Send Discord Notification --- - try { - await sendDiscordNotification({ - eventType: type, - description: description, - date: combinedDate, - locationName: locationInput, - creatorName: user.displayName || user.email || 'Unknown User' - }); - } catch (error) { - console.error('Failed to send Discord notification:', error); - // Don't block the UI if Discord notification fails - } + // --- Return the created event data --- + const createdEvent = { + id: docRef.id, + type, + description, + date: combinedDate, + createdBy: doc(db, "users", user.uid), + location: locationInput, + creatorName: user.displayName || user.email || 'Unknown User', + creatorId: user.uid, + locationName: locationInput, + participants: [{ id: user.uid, displayName: user.displayName || user.uid }], + sharedToDiscord: false + }; - closeEventModal(); await fetchEvents(); + return createdEvent; }; // --- Delete Event Handler --- @@ -128,10 +130,43 @@ export function useEventActions({ closeEventModal(); }; + // --- Share Event to Discord Handler --- + const handleShareToDiscord = async (eventId: string) => { + if (!selectedEvent || !db) return false; + + try { + const success = await sendDiscordNotification({ + eventType: selectedEvent.type, + description: selectedEvent.description, + date: typeof selectedEvent.date === 'string' ? selectedEvent.date : selectedEvent.date.toISOString(), + locationName: selectedEvent.locationName || "", + creatorName: selectedEvent.creatorName || "Unknown User" + }); + + if (success) { + // Update the event to mark it as shared to Discord + const eventRef = doc(db, "events", eventId); + await updateDoc(eventRef, { + sharedToDiscord: true + }); + + // The real-time listener will automatically update the UI + // No need for manual local state updates + + return true; + } + return false; + } catch (error) { + console.error('Failed to send Discord notification:', error); + return false; + } + }; + // --- Return Hook Interface --- return { handleJoinLeaveToggle, handleAddEvent, - handleDeleteEvent + handleDeleteEvent, + handleShareToDiscord }; } \ No newline at end of file diff --git a/frontend/hooks/useEventModal.ts b/frontend/hooks/useEventModal.ts index 11dbf53..3aad852 100644 --- a/frontend/hooks/useEventModal.ts +++ b/frontend/hooks/useEventModal.ts @@ -88,7 +88,7 @@ export function useEventModal() { selectedEvent, modalLoading, namesCache, - + // --- Actions --- openEventModal, closeEventModal, diff --git a/frontend/hooks/useEvents.ts b/frontend/hooks/useEvents.ts index 2ded440..98a94d3 100644 --- a/frontend/hooks/useEvents.ts +++ b/frontend/hooks/useEvents.ts @@ -29,6 +29,7 @@ export function useEvents() { createdBy: DocumentReference; location: string | DocumentReference; participants?: DocumentReference[]; + sharedToDiscord?: boolean; }; // --- Handle Date Field Conversion --- @@ -49,12 +50,13 @@ export function useEvents() { createdBy: raw.createdBy, location: raw.location, participants: raw.participants || [], + sharedToDiscord: raw.sharedToDiscord || false, } as RawEvent; }); setEvents(data); setLoading(false); - }, []); // Remove the problematic dependency + }, []); // --- Add Event Helper --- const addEvent = async (event: Omit) => { diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 74881c2..ffb0bd9 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -41,6 +41,7 @@ export interface Event { participants: string[]; cancelled?: boolean; imageUrl?: string; + sharedToDiscord?: boolean; } // Raw Firestore event data (legacy structure) @@ -52,13 +53,14 @@ export interface RawEvent { createdBy: DocumentReference; location: string | DocumentReference; participants: DocumentReference[]; + sharedToDiscord?: boolean; } // --- Event Types --- -export type EventType = - | "toprope" - | "boulder" - | "outdoor" +export type EventType = + | "toprope" + | "boulder" + | "outdoor" | "other"; // --- Firebase Related Types --- @@ -77,6 +79,7 @@ export interface EventData { participants: string[]; cancelled?: boolean; imageUrl?: string; + sharedToDiscord?: boolean; } export interface PlaceData { @@ -104,6 +107,7 @@ export interface RawEventWithLocation { creatorId?: string; locationName?: string; participants: { id: string; displayName: string; }[]; + sharedToDiscord?: boolean; } // --- Component Prop Types --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6d82eb8..06de486 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -280,7 +280,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.3.tgz", "integrity": "sha512-by1leTfZkwGycPKRWpc+p5/IhpnOj8zaScVi4RRm9fMoFYS3IE87Wzx1Yf/ruVYowXOEuLqYY3VmJw5tU3+0Bg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/component": "0.7.0", "@firebase/logger": "0.5.0", @@ -347,7 +346,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.3.tgz", "integrity": "sha512-rRK9YOvgsAU/+edjgubL1q1FyCMjBZZs+fAWtD36tklawkh6WZV07sNLVSceuni+a21oby6xoad+3R8dfztOrA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/app": "0.14.3", "@firebase/component": "0.7.0", @@ -363,8 +361,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@firebase/auth": { "version": "1.11.0", @@ -815,7 +812,6 @@ "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -1944,7 +1940,6 @@ "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2005,7 +2000,6 @@ "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", @@ -2523,7 +2517,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2956,7 +2949,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3673,7 +3665,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5205,7 +5196,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5976,7 +5966,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6202,7 +6191,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6212,7 +6200,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7157,7 +7144,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7314,7 +7300,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"