Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions frontend/src/app/api/events/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ type EventRow = {
capacity: number | null;
registered: number | null;
require_approval: boolean | null;
form_questions: any;
form_questions: any;
created_at: string | null;
post_event_survey: any;
};

function mapRowToEvent(row: EventRow): EventData {
Expand All @@ -28,12 +29,14 @@ function mapRowToEvent(row: EventRow): EventData {
if (row.form_questions) {
if (Array.isArray(row.form_questions)) {
questions = row.form_questions;
} else if (typeof row.form_questions === 'object') {
questions = Object.entries(row.form_questions).map(([key, value], index) => ({
id: index + 1,
text: String(value),
required: true
}));
} else if (typeof row.form_questions === "object") {
questions = Object.entries(row.form_questions).map(
([key, value], index) => ({
id: index + 1,
text: String(value),
required: true,
}),
);
}
}

Expand All @@ -57,12 +60,13 @@ function mapRowToEvent(row: EventRow): EventData {
theme: "Minimal Dark",
questions,
createdAt: row.created_at ?? new Date().toISOString(),
postEventSurvey: row.post_event_survey,
};
}

export async function GET(
_request: Request,
context: { params: Promise<{ slug: string }> }
context: { params: Promise<{ slug: string }> },
) {
const { slug } = await context.params;
if (!slug) {
Expand All @@ -83,12 +87,12 @@ export async function GET(

return NextResponse.json(
{ error: error.message || "Failed to fetch event" },
{ status: 500 }
{ status: 500 },
);
}

const event = mapRowToEvent(data as EventRow);

// Use the registered column from the events table if available,
// otherwise fall back to counting from the guests table
if (data.registered !== null && data.registered !== undefined) {
Expand All @@ -100,9 +104,9 @@ export async function GET(
.select("*", { count: "exact", head: true })
.eq("event_slug", slug)
.in("status", ["approved", "pending"]);

event.registeredCount = count ?? 0;
}

return NextResponse.json({ event });
}
8 changes: 7 additions & 1 deletion frontend/src/app/event/[slug]/manage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
EventManagementForm,
} from "@/components/manage-event";
import BatchmailWorkspace from "@/components/batchmail/BatchmailWorkspace";
import SurveyBuilder from "@/components/manage-event/survey/SurveyBuilder";

export default function ManageEventPage() {
const params = useParams();
Expand Down Expand Up @@ -95,7 +96,7 @@ export default function ManageEventPage() {

{/* Tab Navigation */}
<div className="flex gap-4 md:gap-6 border-b border-white/10 mb-6 md:mb-8 overflow-x-auto -mx-3 md:mx-0 px-3 md:px-0">
{["Overview", "Guests", "Batchmail"].map((tab) => (
{["Overview", "Guests", "Batchmail", "Survey"].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab.toLowerCase())}
Expand Down Expand Up @@ -157,6 +158,11 @@ export default function ManageEventPage() {
<div className={activeTab === "batchmail" ? "" : "hidden"}>
<BatchmailWorkspace guests={guests} />
</div>

{/* Survey Tab Content */}
<div className={activeTab === "survey" ? "" : "hidden"}>
<SurveyBuilder slug={slug} initialConfig={event.postEventSurvey} />
</div>
</main>

{/* Cover Image Change Modal */}
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/app/event/[slug]/manage/survey-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use server";

import { createClient } from "@/lib/supabase/server";
import { SurveyConfig } from "@/types/survey";
import { revalidatePath } from "next/cache";

export async function saveEventSurvey(slug: string, surveyData: SurveyConfig) {
const supabase = await createClient();

// 1. Check if user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();

if (!user) {
throw new Error("Unauthorized");
}

// 2. Authorization Check
// Fetch event to verify ownership
const { data: event, error: eventError } = await supabase
.from("events")
.select("organizer_id")
.eq("slug", slug)
.single();

if (eventError || !event) {
throw new Error("Event not found");
}

const isOrganizer = event.organizer_id === user.id;

if (!isOrganizer) {
throw new Error("Forbidden: You must be the event organizer.");
}

// 3. Update the event's survey configuration
const { error } = await supabase
.from("events")
.update({
post_event_survey: surveyData,
})
.eq("slug", slug);

if (error) {
console.error("Error saving survey:", error);
throw new Error("Failed to save survey configuration");
}

// 4. Revalidate the manage page to reflect changes
revalidatePath(`/event/${slug}/manage`);

return { success: true };
}
158 changes: 158 additions & 0 deletions frontend/src/components/manage-event/survey/QuestionEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { SurveyQuestion } from "@/types/survey";
import { Trash2, GripVertical, Plus, Check } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";

interface QuestionEditorProps {
question: SurveyQuestion;
onUpdate: (updates: Partial<SurveyQuestion>) => void;
onDelete: () => void;
index: number;
}

export function QuestionEditor({
question,
onUpdate,
onDelete,
index,
}: QuestionEditorProps) {
const [showOptions, setShowOptions] = useState(
question.type === "multiple_choice",
);

return (
<div className="group relative bg-white/5 border border-white/10 rounded-xl p-4 md:p-6 mb-4 hover:border-white/20 transition-all font-urbanist">
<div className="flex items-start gap-4">
{/* Drag Handle */}
<div className="mt-3 text-white/20 cursor-grab active:cursor-grabbing hover:text-white/40">
<GripVertical size={20} />
</div>

<div className="flex-1 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-[1fr_200px] gap-6">
{/* Question Text */}
<div className="flex-1">
<label className="text-sm font-medium text-white/80 mb-2 block tracking-wide">
Question {index + 1}
</label>
<input
type="text"
value={question.text}
onChange={(e) => onUpdate({ text: e.target.value })}
placeholder="e.g. How would you rate the event?"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white text-base placeholder-white/40 focus:outline-none focus:border-cyan-500 transition-colors"
/>
</div>

{/* Question Type */}
<div className="w-full">
<label className="text-sm font-medium text-white/80 mb-2 block tracking-wide">
Type
</label>
<div className="relative">
<select
value={question.type}
onChange={(e) => {
const newType = e.target.value as SurveyQuestion["type"];
onUpdate({
type: newType,
options:
newType === "multiple_choice"
? ["Option 1", "Option 2"]
: undefined,
});
setShowOptions(newType === "multiple_choice");
}}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white text-base focus:outline-none focus:border-cyan-500 appearance-none cursor-pointer [&>option]:bg-[#0a1520]"
>
<option value="text">Text Answer</option>
<option value="rating">Star Rating (1-5)</option>
<option value="yes_no">Yes / No</option>
<option value="multiple_choice">Multiple Choice</option>
</select>
{/* Custom Arrow Icon could go here */}
</div>
</div>
</div>

{/* Multiple Choice Options */}
{question.type === "multiple_choice" && (
<div className="pl-4 border-l-2 border-cyan-500/30 space-y-3 mt-4 bg-white/5 p-4 rounded-r-lg">
<label className="text-sm font-bold text-white/90 block mb-2">
Options
</label>
{question.options?.map((opt, i) => (
<div key={i} className="flex gap-3 items-center">
<div className="w-2 h-2 rounded-full bg-cyan-500/50" />
<input
type="text"
value={opt}
onChange={(e) => {
const newOptions = [...(question.options || [])];
newOptions[i] = e.target.value;
onUpdate({ options: newOptions });
}}
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-cyan-500 transition-colors"
/>
<button
onClick={() => {
const newOptions =
question.options?.filter((_, idx) => idx !== i) || [];
onUpdate({ options: newOptions });
}}
className="text-white/40 hover:text-red-400 transition-colors p-2 hover:bg-white/5 rounded-md"
>
<Trash2 size={14} />
</button>
</div>
))}
<button
onClick={() =>
onUpdate({
options: [
...(question.options || []),
`Option ${question.options!.length + 1}`,
],
})
}
className="text-sm text-cyan-400 hover:text-cyan-300 flex items-center gap-2 mt-2 px-2 py-1 hover:bg-white/5 rounded-md transition-colors"
>
<Plus size={14} /> Add Option
</button>
</div>
)}

{/* Settings Row */}
<div className="flex items-center justify-between pt-4 border-t border-white/5 mt-4">
<label className="flex items-center gap-3 cursor-pointer select-none group/check">
<div className="relative flex items-center">
<input
type="checkbox"
checked={question.required}
onChange={(e) => onUpdate({ required: e.target.checked })}
className="peer appearance-none w-5 h-5 rounded border border-white/20 bg-white/5 checked:bg-cyan-600 checked:border-cyan-600 focus:ring-offset-0 focus:ring-cyan-500/50 transition-all cursor-pointer"
/>
<Check
size={12}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 peer-checked:opacity-100 pointer-events-none"
/>
</div>
<span className="text-sm text-white/70 group-hover/check:text-white transition-colors font-medium">
Required
</span>
</label>

<button
onClick={onDelete}
className="text-white/40 hover:text-red-400 transition-colors p-2 hover:bg-white/5 rounded-lg flex items-center gap-2"
title="Delete Question"
>
<Trash2 size={16} />
<span className="text-sm font-medium">Delete</span>
</button>
</div>
</div>
</div>
</div>
);
}
Loading