diff --git a/webapp/src/features/event/messaging/MessagePasteCard.tsx b/webapp/src/components/MessagePasteCard.tsx
similarity index 100%
rename from webapp/src/features/event/messaging/MessagePasteCard.tsx
rename to webapp/src/components/MessagePasteCard.tsx
diff --git a/webapp/src/components/currentOrNextEvent/CurrentOrNextEvent.tsx b/webapp/src/components/currentOrNextEvent/CurrentOrNextEvent.tsx
index b057e98e..56124142 100644
--- a/webapp/src/components/currentOrNextEvent/CurrentOrNextEvent.tsx
+++ b/webapp/src/components/currentOrNextEvent/CurrentOrNextEvent.tsx
@@ -78,7 +78,7 @@ export const CurrentOrNextEvent = () => {
Event Setup
Lineup
- Public Announcements
+ Preflight Checklist
diff --git a/webapp/src/features/event/EventAnnouncements.tsx b/webapp/src/features/event/EventAnnouncements.tsx
deleted file mode 100644
index 6b1fcb23..00000000
--- a/webapp/src/features/event/EventAnnouncements.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { Tabs, Tab, Form } from "react-bootstrap";
-import MessagePasteCard from "./messaging/MessagePasteCard";
-import { getDiscordMessage, getTwitterMessage } from "../../util/messageWriters";
-import { useEventOperations } from "./outletContext";
-
-const EventAnnouncements = () => {
-
- const [eventScratchpad, proposeEventChange] = useEventOperations();
-
- const discordMessage = getDiscordMessage(eventScratchpad);
- const twitterMessage = getTwitterMessage(eventScratchpad);
-
- const discordFooterInstructions = <>
-
Paste your announcement text to
- #s4-vrchat-events
- in Discord.
-
- Be sure to press 📢 Publish after posting, so other servers that subscribe to the channel get notified!
- >
- const twitterFooterInstructions = Paste this text to Twitter and Bluesky.
-
- return
- Public Announcements
-
-
- Discord Announcement Message
-
- { proposeEventChange({ ...eventScratchpad, message: event.target.value }); }}
- />
-
-
-
-
-
-
-
-
-
-
-
-};
-
-export default EventAnnouncements;
diff --git a/webapp/src/features/event/EventRoot.tsx b/webapp/src/features/event/EventRoot.tsx
index 50f0746a..a76f0cf8 100644
--- a/webapp/src/features/event/EventRoot.tsx
+++ b/webapp/src/features/event/EventRoot.tsx
@@ -205,12 +205,6 @@ const EventRoot = () => {
Lineup
-
- {/* Verify DJs removed — verification is available via Verify Lineup modal */}
-
-
- Messaging
-
Preflight
diff --git a/webapp/src/features/event/PreflightChecklist.tsx b/webapp/src/features/event/PreflightChecklist.tsx
deleted file mode 100644
index ab3956d7..00000000
--- a/webapp/src/features/event/PreflightChecklist.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-import { Container, Card, ButtonGroup, Button, Alert, Stack, Badge } from 'react-bootstrap';
-import { useEventOperations } from './outletContext';
-import { useEventStore } from '../../hooks/useEventStore/useEventStore';
-import toast from 'react-hot-toast';
-import { EventPublishedStatusBadge } from './EventPublishedStatusBadge';
-import { Link } from 'react-router-dom';
-
-// Add types for checklist items
-type RequiredChecklistItem = {
- label: string;
- completed: boolean;
- description: string;
- link: string;
- optional?: false;
-};
-
-type OptionalChecklistItem = {
- label: string;
- completed: boolean;
- description: string;
- link: string;
- buttonLabel: string;
- external: true;
- optional: true;
-};
-
-type ChecklistItem = RequiredChecklistItem | OptionalChecklistItem;
-
-const PreflightChecklist = () => {
- const [eventScratchpad, proposeEventChange] = useEventOperations();
- const { saveEvent } = useEventStore();
-
- const handlePublish = async () => {
- try {
- const newEvent = { ...eventScratchpad, published: true };
- await saveEvent(newEvent, eventScratchpad);
- proposeEventChange(newEvent);
- toast.success('Event published successfully!');
- } catch (error) {
- toast.error(`Error publishing event: ${(error as Error).message}`);
- }
- };
-
- const handleUnpublish = async () => {
- try {
- const newEvent = { ...eventScratchpad, published: false };
- await saveEvent(newEvent, eventScratchpad);
- proposeEventChange(newEvent);
- toast.success('Event unpublished successfully!');
- } catch (error) {
- toast.error(`Error unpublishing event: ${(error as Error).message}`);
- }
- };
-
- const hasMinimumRequirements = () => {
- return eventScratchpad.name &&
- eventScratchpad.host &&
- eventScratchpad.start_datetime &&
- eventScratchpad.slots.length > 0;
- };
-
- const getChecklistItems = (): ChecklistItem[] => {
- const required: RequiredChecklistItem[] = [
- {
- label: 'Event Name',
- completed: !!eventScratchpad.name,
- description: 'Event has a name assigned',
- link: `/events/${eventScratchpad.id}/setup`,
- optional: false,
- },
- {
- label: 'Host Assigned',
- completed: !!eventScratchpad.host,
- description: 'Event has a host assigned',
- link: `/events/${eventScratchpad.id}/setup`,
- optional: false,
- },
- {
- label: 'Date & Time Set',
- completed: !!eventScratchpad.start_datetime,
- description: 'Event has a start date and time',
- link: `/events/${eventScratchpad.id}/setup`,
- optional: false,
- },
- {
- label: 'Lineup Created',
- completed: eventScratchpad.slots.length > 0,
- description: `${eventScratchpad.slots.length} slot(s) in lineup`,
- link: `/events/${eventScratchpad.id}/lineup`,
- optional: false,
- },
- ];
-
- const optional: OptionalChecklistItem[] = [
- {
- label: 'Add post to vrchat',
- completed: false,
- description: 'Post this event to VRChat.',
- external: true,
- link: 'https://vrchat.com/home/group/grp_626cd923-6dea-4583-abcb-8c09a765969f/posts',
- buttonLabel: 'Go to VRChat',
- optional: true,
- },
- {
- label: 'Add the event to vrc.tl',
- completed: false,
- description: 'Share your event on the VRCTL event listing site.',
- external: true,
- link: 'https://vrc.tl',
- buttonLabel: 'Go to vrc.tl',
- optional: true,
- },
- {
- label: 'Create a post on Bluesky',
- completed: false,
- description: 'Announce your event to the Bluesky community.',
- external: true,
- link: 'https://bsky.app/profile/sundayservice.bsky.social',
- buttonLabel: 'Go to Bluesky',
- optional: true,
- },
- {
- label: 'Update the assets repo',
- completed: false,
- description: 'Update posters and host images in the assets repository.',
- external: true,
- link: 'https://github.com/StrawbsProtato/strawbsprotato.github.io',
- buttonLabel: 'Go to GitHub',
- optional: true,
- },
- ];
-
- return [...required, ...optional];
- };
-
- const checklistItems = getChecklistItems();
- const requiredItems = checklistItems.filter(item => !item.optional);
- const optionalItems = checklistItems.filter(item => item.optional);
- const completedCount = requiredItems.filter(item => item.completed).length;
- const totalCount = requiredItems.length;
-
- return (
-
- Preflight Checklist
-
-
-
- Event Status
-
-
-
-
-
- Checklist Progress: {completedCount}/{totalCount} items completed
-
-
-
- {Math.round((completedCount / totalCount) * 100)}% Complete
-
-
-
-
-
-
- Publish/Unpublish Event
-
-
- {eventScratchpad.published ? '✓ Published' : 'Publish Event'}
-
-
- {!eventScratchpad.published ? '✓ Unpublished' : 'Unpublish Event'}
-
-
-
- {!hasMinimumRequirements() && (
-
- Requirements Not Met
- Publishing requires: Event name, host, date/time, and at least one slot in the lineup.
-
- )}
-
- {eventScratchpad.published && (
-
- Event is Live!
- This event is currently published and visible to the public.
-
- )}
-
-
-
-
-
- Preflight Checklist
-
-
-
- {requiredItems.map((item, index) => (
-
-
-
- {item.completed ? '✓' : '○'}
-
-
-
-
- {item.label}
-
- {item.description}
-
-
-
- {item.completed ? 'Review' : 'Complete'}
-
-
-
- ))}
-
-
-
-
-
-
- Additional Steps (Optional)
-
-
-
- {optionalItems.map((item, index) => (
-
-
-
- i
-
-
-
-
- {item.label}
-
- {item.description}
-
-
-
- ))}
-
-
-
-
- );
-};
-
-export default PreflightChecklist;
\ No newline at end of file
diff --git a/webapp/src/features/event/basic/EventDetails.tsx b/webapp/src/features/event/basic/EventDetails.tsx
index 7378f28b..226fcfb7 100644
--- a/webapp/src/features/event/basic/EventDetails.tsx
+++ b/webapp/src/features/event/basic/EventDetails.tsx
@@ -2,7 +2,7 @@ import { useEventOperations } from "../outletContext";
import EventBasicDetailsForm from "./EventBasicDetailsForm";
import { Card, CardBody } from "react-bootstrap";
import { getSignupsPostedMessage } from "../../../util/messageWriters";
-import MessagePasteCard from "../messaging/MessagePasteCard";
+import MessagePasteCard from "../../../components/MessagePasteCard";
import { useParams } from "react-router-dom";
import "react-datepicker/dist/react-datepicker.css";
diff --git a/webapp/src/features/event/lineup/EventLineup.tsx b/webapp/src/features/event/lineup/EventLineup.tsx
index f24613c2..959197e3 100644
--- a/webapp/src/features/event/lineup/EventLineup.tsx
+++ b/webapp/src/features/event/lineup/EventLineup.tsx
@@ -12,7 +12,7 @@ import { AddOrCreateDjModal } from "./components/AddOrCreateDjModal";
import { updateSignupForEvent } from "../util";
import { useReconciledEvent } from "../../../hooks/useEventStore/useReconciledEvent";
import { getProposedLineupMessage } from "../../../util/messageWriters";
-import MessagePasteCard from "../messaging/MessagePasteCard";
+import MessagePasteCard from "../../../components/MessagePasteCard";
type ViewMode = "signups" | "build" | "lineup";
diff --git a/webapp/src/features/event/lineup/EventLineupSlot.tsx b/webapp/src/features/event/lineup/EventLineupSlot.tsx
index 72fd0e79..94ed743f 100644
--- a/webapp/src/features/event/lineup/EventLineupSlot.tsx
+++ b/webapp/src/features/event/lineup/EventLineupSlot.tsx
@@ -29,7 +29,7 @@ const EventLineupSlot = ({
onSlotMoveLater,
onRemoveSlot,
}: Props) => {
- const { djCache } = useEventDjCache();
+ const { djCache, getEventsByDjId } = useEventDjCache();
const hasConflict = hasAvailabilityConflict(slot, signup);
@@ -106,7 +106,7 @@ const EventLineupSlot = ({
avatar: avatarUrl, // Pass the avatar URL
}}
djRef={djRef}
- djEvents={[]} // No events available for `signup.dj_refs`
+ djEvents={getEventsByDjId(djRef.id)} // Fetch DJ events
/>
);
})}
diff --git a/webapp/src/features/event/preflight/EventStatusCard.tsx b/webapp/src/features/event/preflight/EventStatusCard.tsx
new file mode 100644
index 00000000..23985f5b
--- /dev/null
+++ b/webapp/src/features/event/preflight/EventStatusCard.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { Card, ButtonGroup, Button, Alert, Stack, Badge } from 'react-bootstrap';
+import { EventPublishedStatusBadge } from '../EventPublishedStatusBadge';
+import { Event } from '../../../util/types';
+
+interface EventStatusCardProps {
+ event: Event;
+ completedCount: number;
+ totalCount: number;
+ hasMinimumRequirements: boolean;
+ onPublish: () => void;
+ onUnpublish: () => void;
+}
+
+export const EventStatusCard: React.FC = ({
+ event,
+ completedCount,
+ totalCount,
+ hasMinimumRequirements,
+ onPublish,
+ onUnpublish,
+}) => {
+ return (
+
+
+ Event Status
+
+
+
+
+
+ Checklist Progress: {completedCount}/{totalCount} items completed
+
+
+
+ {Math.round((completedCount / totalCount) * 100)}% Complete
+
+
+
+
+
+
+ Publish/Unpublish Event
+
+
+ {event.published ? '✓ Published' : 'Publish Event'}
+
+
+ {!event.published ? '✓ Unpublished' : 'Unpublish Event'}
+
+
+
+ {!hasMinimumRequirements && (
+
+ Requirements Not Met
+ Publishing requires: Event name, host, date/time, and at least one slot in the lineup.
+
+ )}
+
+ {event.published && (
+
+ Event is Live!
+ This event is currently published and visible to the public.
+
+ )}
+
+
+ );
+};
diff --git a/webapp/src/features/event/preflight/LineupPosterModal.tsx b/webapp/src/features/event/preflight/LineupPosterModal.tsx
new file mode 100644
index 00000000..3bbec4a3
--- /dev/null
+++ b/webapp/src/features/event/preflight/LineupPosterModal.tsx
@@ -0,0 +1,124 @@
+import React, { useState } from 'react';
+import { Modal, Button, Form } from 'react-bootstrap';
+import { Event } from '../../../util/types';
+import { toast } from 'react-hot-toast';
+import { LINEUP_POSTER_MAX_FILE_SIZE_MB, validateLineupPosterFile } from '../../../util/util';
+
+interface LineupPosterModalProps {
+ show: boolean;
+ onHide: () => void;
+ event: Event;
+ onFileChange: (file: File | null) => void;
+}
+
+export const LineupPosterModal: React.FC = ({
+ show,
+ onHide,
+ event,
+ onFileChange,
+}) => {
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [previewUrl, setPreviewUrl] = useState(null);
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const input = e.target as HTMLInputElement;
+ const file = input.files?.[0] ?? null;
+
+ const { valid, errorMessage } = validateLineupPosterFile(file);
+ if (!valid) {
+ if (errorMessage) {
+ toast.error(errorMessage);
+ }
+ input.value = "";
+ return;
+ }
+
+ setSelectedFile(file);
+ if (file) {
+ const url = URL.createObjectURL(file);
+ setPreviewUrl(url);
+ } else {
+ setPreviewUrl(null);
+ }
+ };
+
+ const handleSave = () => {
+ if (selectedFile) {
+ onFileChange(selectedFile);
+ toast.success('Lineup poster will be saved when you save the event');
+ }
+ handleClose();
+ };
+
+ const handleRemove = () => {
+ onFileChange(null);
+ toast.success('Lineup poster removed');
+ handleClose();
+ };
+
+ const handleClose = () => {
+ if (previewUrl) {
+ URL.revokeObjectURL(previewUrl);
+ }
+ setSelectedFile(null);
+ setPreviewUrl(null);
+ onHide();
+ };
+
+ return (
+
+
+ Upload Lineup Poster
+
+
+
+ Select Lineup Poster Image
+
+
+ Upload an image for the lineup whiteboard/OBS overlays. Maximum file size: {LINEUP_POSTER_MAX_FILE_SIZE_MB}MB.
+
+
+
+ {(previewUrl || event.lineup_poster_url) && (
+
+
Preview
+
+
+
+
+ )}
+
+ {event.lineup_poster_url && !selectedFile && (
+
+
+ Current Status: A lineup poster is already uploaded for this event.
+
+
+ )}
+
+
+ {event.lineup_poster_url && (
+
+ Remove Current Poster
+
+ )}
+
+ Cancel
+
+ {selectedFile && (
+
+ Upload Poster
+
+ )}
+
+
+ );
+};
diff --git a/webapp/src/features/event/preflight/OutdatedStepsAccordion.tsx b/webapp/src/features/event/preflight/OutdatedStepsAccordion.tsx
new file mode 100644
index 00000000..72e4cb99
--- /dev/null
+++ b/webapp/src/features/event/preflight/OutdatedStepsAccordion.tsx
@@ -0,0 +1,96 @@
+import React from 'react';
+import { Accordion, ButtonGroup, Button } from 'react-bootstrap';
+
+interface OutdatedStepsAccordionProps {
+ onShowSocialMediaModal: () => void;
+}
+
+export const OutdatedStepsAccordion: React.FC = ({
+ onShowSocialMediaModal,
+}) => {
+ const outdatedItems = [
+ {
+ label: 'Add event to vrc.tl',
+ description: 'Share your event on the VRCTL event listing site.',
+ link: 'https://vrc.tl',
+ buttonLabel: 'Go to vrc.tl',
+ isMessaging: false,
+ },
+ {
+ label: 'Social Media Announcement',
+ description: 'Share your event on Bluesky and other platforms.',
+ link: 'https://bsky.app/profile/sundayservice.bsky.social',
+ buttonLabel: 'Go to Bluesky',
+ isMessaging: true,
+ },
+ {
+ label: 'Update the assets repo',
+ description: 'Update posters and host images in the assets repository.',
+ link: 'https://github.com/StrawbsProtato/strawbsprotato.github.io',
+ buttonLabel: 'Go to GitHub',
+ isMessaging: false,
+ },
+ ];
+
+ return (
+
+
+
+ Outdated Steps (No Longer Used)
+
+
+
+ {outdatedItems.map((item, index) => (
+
+
+
+ ✕
+
+
+
+
+ {item.label}
+
+ {item.description}
+
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/webapp/src/features/event/preflight/PreflightChecklist.tsx b/webapp/src/features/event/preflight/PreflightChecklist.tsx
new file mode 100644
index 00000000..4b746032
--- /dev/null
+++ b/webapp/src/features/event/preflight/PreflightChecklist.tsx
@@ -0,0 +1,119 @@
+import { Container, Card } from 'react-bootstrap';
+import { useEventOperations } from '../outletContext';
+import { useEventStore } from '../../../hooks/useEventStore/useEventStore';
+import UnifiedMessageEditModal from './messaging/UnifiedMessageEditModal';
+import { EventStatusCard } from './EventStatusCard';
+import { PreflightChecklistStep } from './PreflightChecklistStep';
+import { OutdatedStepsAccordion } from './OutdatedStepsAccordion';
+import { usePreflightHandlers } from './usePreflightHandlers';
+import { useChecklistItems } from './useChecklistItems';
+import { getDiscordMessage, getTwitterMessage } from '../../../util/messageWriters';
+import { Event } from '../../../util/types';
+import { LineupPosterModal } from './LineupPosterModal';
+
+const PreflightChecklist = () => {
+ const [eventScratchpad, proposeEventChange, onLineupPosterFileSelected] = useEventOperations();
+ const { saveEvent } = useEventStore();
+
+ const handlers = usePreflightHandlers(eventScratchpad, proposeEventChange, saveEvent);
+ const checklistItems = useChecklistItems(eventScratchpad);
+
+ const hasMinimumRequirements = () => {
+ return eventScratchpad.name != "" &&
+ eventScratchpad.host != "" &&
+ eventScratchpad.start_datetime &&
+ eventScratchpad.slots.length > 0;
+ };
+
+ const requiredItems = checklistItems.filter(item => !item.optional);
+ const optionalItems = checklistItems.filter(item => item.optional);
+ const completedCount = requiredItems.filter(item => item.completed).length;
+ const totalCount = requiredItems.length;
+
+ return (
+
+ Preflight Checklist
+
+
+
+
+
+ Preflight Checklist
+
+
+
+ {requiredItems.map((item, index) => (
+
handlers.setShowMessageModal(true)}
+ onUploadPoster={() => handlers.setShowPosterModal(true)}
+ />
+ ))}
+ {optionalItems.map((item, index) => (
+ handlers.setShowMessageModal(true)
+ : () => handlers.setShowSocialMediaModal(true)
+ }
+ onUploadPoster={() => handlers.setShowPosterModal(true)}
+ />
+ ))}
+
+
+
+
+ handlers.setShowSocialMediaModal(true)}
+ />
+
+ handlers.setShowMessageModal(false)}
+ event={eventScratchpad}
+ onSave={handlers.handleSaveMessage}
+ messageType="discord"
+ getMessageFromEvent={(event: Event) => event.message}
+ generatePreview={(event: Event) => getDiscordMessage(event)}
+ title="Edit Discord Announcement Message"
+ label="Discord Announcement Message"
+ helpText="This message will be included in your Discord announcement."
+ previewLabel="Discord Announcement Preview"
+ previewHelpText="This is how your Discord announcement will look."
+ />
+
+ handlers.setShowSocialMediaModal(false)}
+ event={eventScratchpad}
+ onSave={handlers.handleSaveSocialMediaMessage}
+ messageType="social"
+ getMessageFromEvent={(event: Event) => event.socialMediaMessage || getTwitterMessage(event)}
+ title="Social Media / VRChat Message"
+ label="Social Media & VRChat Message"
+ helpText="Customize your message for VRChat and social media platforms. The default message is generated from your event details."
+ />
+
+ handlers.setShowPosterModal(false)}
+ event={eventScratchpad}
+ onFileChange={onLineupPosterFileSelected}
+ />
+
+ );
+};
+
+export default PreflightChecklist;
\ No newline at end of file
diff --git a/webapp/src/features/event/preflight/PreflightChecklistStep.tsx b/webapp/src/features/event/preflight/PreflightChecklistStep.tsx
new file mode 100644
index 00000000..b679f934
--- /dev/null
+++ b/webapp/src/features/event/preflight/PreflightChecklistStep.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import { ButtonGroup, Button } from 'react-bootstrap';
+import { Link } from 'react-router-dom';
+import { ChecklistItem } from './types';
+import { Event } from '../../../util/types';
+
+interface PreflightChecklistStepProps {
+ item: ChecklistItem;
+ eventScratchpad: Event;
+ onEditMessage: () => void;
+ onUploadPoster?: () => void;
+}
+
+export const PreflightChecklistStep: React.FC = ({
+ item,
+ eventScratchpad,
+ onEditMessage,
+ onUploadPoster,
+}) => {
+ const renderActions = () => {
+ if (item.action.type === 'internal') {
+ return (
+
+ {item.completed ? 'Review' : item.action.label}
+
+ );
+ }
+
+ if (item.action.type === 'messaging') {
+ const isDiscord = item.action.platform === 'discord';
+ return (
+
+
+ Copy/Edit
+
+
+ Open Page
+
+
+ );
+ }
+
+ if (item.action.type === 'poster') {
+ return (
+
+ {eventScratchpad.lineup_poster_url ? 'Change Poster' : 'Upload Poster'}
+
+ );
+ }
+
+ if (item.action.type === 'external') {
+ return (
+
+ {item.action.label}
+
+ );
+ }
+
+ return null;
+ };
+
+ const getBadgeInfo = () => {
+ if (item.optional) {
+ return {
+ className: 'badge bg-info',
+ content: 'i'
+ };
+ }
+ return {
+ className: `badge ${item.completed ? 'bg-success' : 'bg-secondary'}`,
+ content: item.completed ? '✓' : '○'
+ };
+ };
+
+ const badge = getBadgeInfo();
+
+ return (
+
+
+
+ {badge.content}
+
+
+
+
+ {item.label}
+
+ {item.description}
+
+
+ {renderActions()}
+
+
+ );
+};
diff --git a/webapp/src/features/event/preflight/messaging/UnifiedMessageEditModal.tsx b/webapp/src/features/event/preflight/messaging/UnifiedMessageEditModal.tsx
new file mode 100644
index 00000000..6df5e5a4
--- /dev/null
+++ b/webapp/src/features/event/preflight/messaging/UnifiedMessageEditModal.tsx
@@ -0,0 +1,137 @@
+import React from 'react';
+import { Modal, Button, Form, Tabs, Tab } from 'react-bootstrap';
+import toast from 'react-hot-toast';
+import { Event } from '../../../../util/types';
+
+type MessageType = 'discord' | 'social';
+
+type UnifiedMessageEditModalProps = {
+ show: boolean;
+ onHide: () => void;
+ event: Event;
+ onSave: (updatedMessage: string) => void | Promise;
+ messageType: MessageType;
+ getMessageFromEvent: (event: Event) => string;
+ generatePreview?: (event: Event, message: string) => string;
+ title: string;
+ label: string;
+ helpText: string;
+ previewLabel?: string;
+ previewHelpText?: string;
+};
+
+const UnifiedMessageEditModal = ({
+ show,
+ onHide,
+ event,
+ onSave,
+ messageType,
+ getMessageFromEvent,
+ generatePreview,
+ title,
+ label,
+ helpText,
+ previewLabel,
+ previewHelpText
+}: UnifiedMessageEditModalProps) => {
+ const [message, setMessage] = React.useState('');
+ const [isSaving, setIsSaving] = React.useState(false);
+
+ React.useEffect(() => {
+ if (show) {
+ setMessage(getMessageFromEvent(event));
+ }
+ }, [show, event, getMessageFromEvent]);
+
+ const handleSave = async () => {
+ setIsSaving(true);
+ try {
+ await onSave(message);
+ } catch (error) {
+ console.error('Error saving message:', error);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleCopyAndClose = () => {
+ navigator.clipboard.writeText(generatePreview ? generatePreview(event, message) : message);
+ toast.success('Message copied to clipboard!');
+ onHide();
+ };
+
+ // Create a temporary event with the current message for preview
+ const previewEvent = { ...event };
+ if (messageType === 'discord') {
+ previewEvent.message = message;
+ } else {
+ previewEvent.socialMediaMessage = message;
+ }
+ const previewMessage = generatePreview ? generatePreview(previewEvent, message) : message;
+
+ return (
+
+
+ {title}
+
+
+
+
+
+ {label}
+ setMessage(e.target.value)}
+ className="has-fixed-size"
+ />
+
+ {helpText}
+
+
+
+ {generatePreview && (
+
+
+
+ {previewLabel || 'Preview'}
+
+
+
+ {previewHelpText || 'This is how your message will look.'}
+
+
+
+ )}
+
+
+
+
+ Cancel
+
+
+ {isSaving ? 'Saving...' : 'Save'}
+
+
+ Copy and Close
+
+
+
+ );
+};
+
+export default UnifiedMessageEditModal;
diff --git a/webapp/src/features/event/preflight/types.ts b/webapp/src/features/event/preflight/types.ts
new file mode 100644
index 00000000..bccbe426
--- /dev/null
+++ b/webapp/src/features/event/preflight/types.ts
@@ -0,0 +1,15 @@
+// Checklist item types
+export type ChecklistItemAction =
+ | { type: 'internal'; label: string } // Navigate within app (Link)
+ | { type: 'external'; label: string } // Open external link
+ | { type: 'messaging'; platform: 'discord' | 'vrchat' | 'social' } // Special messaging actions
+ | { type: 'poster' }; // Poster upload modal action
+
+export type ChecklistItem = {
+ label: string;
+ completed: boolean;
+ description: string;
+ link: string;
+ optional: boolean;
+ action: ChecklistItemAction;
+};
diff --git a/webapp/src/features/event/preflight/useChecklistItems.ts b/webapp/src/features/event/preflight/useChecklistItems.ts
new file mode 100644
index 00000000..e38c6866
--- /dev/null
+++ b/webapp/src/features/event/preflight/useChecklistItems.ts
@@ -0,0 +1,52 @@
+import { useMemo } from 'react';
+import { ChecklistItem } from './types';
+import { Event } from '../../../util/types';
+
+export const useChecklistItems = (eventScratchpad: Event) => {
+ return useMemo((): ChecklistItem[] => [
+ {
+ label: 'Host Assigned',
+ completed: !!eventScratchpad.host,
+ description: 'Event has a host assigned',
+ link: `/events/${eventScratchpad.id}/setup`,
+ optional: false,
+ action: { type: 'internal', label: 'Complete' },
+ },
+ {
+ label: 'Lineup Created',
+ completed: eventScratchpad.slots.length > 0,
+ description: `${eventScratchpad.slots.length} slot(s) in lineup`,
+ link: `/events/${eventScratchpad.id}/lineup`,
+ optional: false,
+ action: { type: 'internal', label: 'Complete' },
+ },
+ {
+ label: 'Lineup Poster',
+ completed: !!eventScratchpad.lineup_poster_url,
+ description: eventScratchpad.lineup_poster_url
+ ? 'Lineup poster uploaded and ready'
+ : 'Upload a lineup poster image',
+ link: '',
+ optional: true,
+ action: { type: 'poster' },
+ },
+ {
+ label: 'Discord Announcement',
+ completed: false,
+ description: eventScratchpad.message && eventScratchpad.message.trim() !== ''
+ ? 'Message customized and ready'
+ : 'Customize your Discord announcement message',
+ link: 'https://discord.com/channels/1004489038159413248/1004489042890588165',
+ optional: true,
+ action: { type: 'messaging', platform: 'discord' },
+ },
+ {
+ label: 'VRChat Announcement',
+ completed: false,
+ description: 'Post this event to VRChat group.',
+ link: 'https://vrchat.com/home/group/grp_626cd923-6dea-4583-abcb-8c09a765969f/posts',
+ optional: true,
+ action: { type: 'messaging', platform: 'vrchat' },
+ },
+ ], [eventScratchpad.host, eventScratchpad.id, eventScratchpad.slots.length, eventScratchpad.message, eventScratchpad.lineup_poster_url]);
+};
diff --git a/webapp/src/features/event/preflight/usePreflightHandlers.ts b/webapp/src/features/event/preflight/usePreflightHandlers.ts
new file mode 100644
index 00000000..46d9943b
--- /dev/null
+++ b/webapp/src/features/event/preflight/usePreflightHandlers.ts
@@ -0,0 +1,70 @@
+import { useState } from 'react';
+import toast from 'react-hot-toast';
+import { Event } from '../../../util/types';
+
+export const usePreflightHandlers = (
+ eventScratchpad: Event,
+ proposeEventChange: (event: Event) => void,
+ saveEvent: (newEvent: Event, oldEvent: Event) => Promise
+) => {
+ const [showMessageModal, setShowMessageModal] = useState(false);
+ const [showSocialMediaModal, setShowSocialMediaModal] = useState(false);
+ const [showPosterModal, setShowPosterModal] = useState(false);
+
+ const handlePublish = async () => {
+ try {
+ const newEvent = { ...eventScratchpad, published: true };
+ await saveEvent(newEvent, eventScratchpad);
+ proposeEventChange(newEvent);
+ toast.success('Event published successfully!');
+ } catch (error) {
+ toast.error(`Error publishing event: ${(error as Error).message}`);
+ }
+ };
+
+ const handleUnpublish = async () => {
+ try {
+ const newEvent = { ...eventScratchpad, published: false };
+ await saveEvent(newEvent, eventScratchpad);
+ proposeEventChange(newEvent);
+ toast.success('Event unpublished successfully!');
+ } catch (error) {
+ toast.error(`Error unpublishing event: ${(error as Error).message}`);
+ }
+ };
+
+ const handleSaveMessage = async (updatedMessage: string) => {
+ try {
+ const newEvent = { ...eventScratchpad, message: updatedMessage };
+ await saveEvent(newEvent, eventScratchpad);
+ proposeEventChange(newEvent);
+ toast.success('Message updated successfully!');
+ } catch (error) {
+ toast.error(`Error saving message: ${(error as Error).message}`);
+ }
+ };
+
+ const handleSaveSocialMediaMessage = async (updatedMessage: string) => {
+ try {
+ const newEvent = { ...eventScratchpad, socialMediaMessage: updatedMessage };
+ await saveEvent(newEvent, eventScratchpad);
+ proposeEventChange(newEvent);
+ toast.success('Social media message updated successfully!');
+ } catch (error) {
+ toast.error(`Error saving social media message: ${(error as Error).message}`);
+ }
+ };
+
+ return {
+ showMessageModal,
+ setShowMessageModal,
+ showSocialMediaModal,
+ setShowSocialMediaModal,
+ showPosterModal,
+ setShowPosterModal,
+ handlePublish,
+ handleUnpublish,
+ handleSaveMessage,
+ handleSaveSocialMediaMessage,
+ };
+};
diff --git a/webapp/src/features/event/routes.tsx b/webapp/src/features/event/routes.tsx
index 8030b43c..2ada7fb1 100644
--- a/webapp/src/features/event/routes.tsx
+++ b/webapp/src/features/event/routes.tsx
@@ -1,9 +1,8 @@
import { Link, RouteObject } from "react-router-dom";
import EventDetails from "./basic/EventDetails";
-import EventAnnouncements from "./EventAnnouncements";
import EventLineup from "./lineup/EventLineup";
import DebuggingDetails from "./DebuggingDetails";
-import PreflightChecklist from "./PreflightChecklist";
+import PreflightChecklist from "./preflight/PreflightChecklist";
export const eventRoutes: RouteObject[] = [
{
@@ -27,15 +26,10 @@ export const eventRoutes: RouteObject[] = [
element: ,
handle: { crumb: () => Preflight}
},
- {
- path: "announcements",
- element: ,
- handle: { crumb: () => Announcements},
- },
{
path: "debug",
element: ,
- handle: { crumb: () => Debugging},
+ handle: { crumb: () => Debugging},
},
];
\ No newline at end of file
diff --git a/webapp/src/util/types.ts b/webapp/src/util/types.ts
index a1d0063f..d2851003 100644
--- a/webapp/src/util/types.ts
+++ b/webapp/src/util/types.ts
@@ -36,6 +36,7 @@ export type Event = {
name: string;
published: boolean,
message: string;
+ socialMediaMessage?: string;
start_datetime: Date;
end_datetime?: Date;
host: string;