Refactor event creation page to use reusable EventForm component

Replaced inlined event creation logic on `CreateEventPage` with a new reusable `EventForm` component. This decouples the form functionality, improves modularity, and enables future reuse for editing events. Enhanced validation and added support for event themes and additional settings within `EventForm`.
This commit is contained in:
2025-03-14 02:55:59 +01:00
parent a5ce709e9e
commit fa5daa605c
2 changed files with 606 additions and 207 deletions

View File

@@ -1,218 +1,26 @@
// src/app/dashboard/events/new/page.tsx
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import React from "react";
import { useEvents } from "@/context/event-context";
import { EventCreate } from "@/client";
import Navbar from "@/components/layout/navbar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { RESERVED_SLUGS } from "@/lib/constants";
import { EventForm } from "@/components/events/event-form";
export default function CreateEventPage() {
const router = useRouter();
const { createEvent, isCreating } = useEvents();
const [slugError, setSlugError] = useState<string | null>(null);
const [formData, setFormData] = useState<EventCreate>({
title: "",
description: "",
location_name: "",
location_address: "",
location_url: "",
event_date: "",
event_start_time: "",
event_end_time: "",
timezone: "UTC",
slug: "",
is_public: false,
rsvp_enabled: true,
});
const onChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value, type } = e.target;
const checked =
type === "checkbox" ? (e.target as HTMLInputElement).checked : undefined;
setFormData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
// live validation for slug
if (name === "slug") {
if (RESERVED_SLUGS.includes(value)) {
setSlugError(`The slug "${value}" is reserved.`);
} else {
setSlugError(null);
}
}
};
const onTogglePublic = (checked: boolean) => {
setFormData((prev) => ({ ...prev, is_public: checked }));
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (RESERVED_SLUGS.includes(formData.slug)) {
alert(
`The slug "${formData.slug}" is reserved and cannot be used. Please choose another slug.`,
);
return;
}
try {
const event = await createEvent(formData);
router.push(`/dashboard/events/${event.slug}`);
} catch (error) {
console.error("Failed to create event:", error);
alert("Failed to create event. Please check your data and try again.");
}
};
return (
<>
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="text-2xl">Create New Event</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="grid grid-cols-1 gap-6">
<div>
<Label className={"mb-2"}>Event Title *</Label>
<Input
required
name="title"
placeholder="My Awesome Event"
value={formData.title}
onChange={onChange}
/>
</div>
<div>
<Label className={"mb-2"}>Description</Label>
<Textarea
name="description"
placeholder="Quick description of your event"
value={formData.description || ""}
onChange={onChange}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className={"mb-2"}>Event Date *</Label>
<Input
type="date"
required
name="event_date"
value={formData.event_date}
onChange={onChange}
/>
</div>
<div>
<Label className={"mb-2"}>Slug *</Label>
<Input
required
name="slug"
pattern="^[a-z0-9-]+$"
placeholder="my-awesome-event"
value={formData.slug}
onChange={onChange}
className={slugError ? "border-red-500" : ""}
/>
{slugError && (
<p className="text-sm text-red-500 mt-1">{slugError}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className={"mb-2"}>Start Time</Label>
<Input
type="time"
name="event_start_time"
value={formData.event_start_time || ""}
onChange={onChange}
/>
</div>
<div>
<Label className={"mb-2"}>End Time</Label>
<Input
type="time"
name="event_end_time"
value={formData.event_end_time || ""}
onChange={onChange}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className={"mb-2"}>Location Name</Label>
<Input
name="location_name"
placeholder="Venue name"
value={formData.location_name || ""}
onChange={onChange}
/>
</div>
<div>
<Label className={"mb-2"}>Location Address</Label>
<Input
name="location_address"
placeholder="123 Main Street"
value={formData.location_address || ""}
onChange={onChange}
/>
</div>
</div>
<div>
<Label className={"mb-2"}>Location URL</Label>
<Input
type="url"
name="location_url"
placeholder="https://maps.app/location"
value={formData.location_url || ""}
onChange={onChange}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="is_public"
checked={formData.is_public}
onCheckedChange={onTogglePublic}
/>
<Label htmlFor="is_public">Public Event</Label>
</div>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
type="button"
onClick={() => router.back()}
>
Cancel
</Button>
<Button disabled={isCreating} type="submit">
{isCreating ? "Creating..." : "Create Event"}
</Button>
</div>
</form>
</CardContent>
</Card>
</>
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="text-2xl">Create New Event</CardTitle>
</CardHeader>
<CardContent>
<EventForm
mode="create"
onSubmit={createEvent}
isSubmitting={isCreating}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,591 @@
// src/components/events/event-form.tsx
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { EventCreate, EventResponse, EventThemeResponse } from "@/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RESERVED_SLUGS } from "@/lib/constants";
import { useEventThemes } from "@/context/event-theme-context";
// Define timezones - this should be expanded with a complete list
const TIMEZONES = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Europe/London",
"Europe/Paris",
"Asia/Tokyo",
"Australia/Sydney",
];
type EventFormProps = {
event?: EventResponse;
// themes?: EventThemeResponse[];
mode: "create" | "edit";
onSubmit: (data: EventCreate) => Promise<EventResponse>;
isSubmitting?: boolean;
};
export function EventForm({
event,
// themes = [],
mode,
onSubmit,
isSubmitting = false,
}: EventFormProps) {
const router = useRouter();
const [slugError, setSlugError] = useState<string | null>(null);
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const { themes } = useEventThemes();
const [formData, setFormData] = useState<EventCreate>({
title: event?.title || "",
description: event?.description || "",
location_name: event?.location_name || "",
location_address: event?.location_address || "",
location_url: event?.location_url || "",
event_date: event?.event_date || "",
event_start_time: event?.event_start_time || "",
event_end_time: event?.event_end_time || "",
timezone: event?.timezone || "UTC",
rsvp_deadline: event?.rsvp_deadline || "",
is_public: event?.is_public !== undefined ? event.is_public : false,
access_code: event?.access_code || "",
theme_id: event?.theme_id || null,
custom_theme_settings: event?.custom_theme_settings || null,
additional_info: event?.additional_info || null,
is_active: event?.is_active !== undefined ? event.is_active : true,
rsvp_enabled: event?.rsvp_enabled !== undefined ? event.rsvp_enabled : true,
gift_registry_enabled:
event?.gift_registry_enabled !== undefined
? event.gift_registry_enabled
: false,
updates_enabled:
event?.updates_enabled !== undefined ? event.updates_enabled : false,
max_guests_per_invitation: event?.max_guests_per_invitation || null,
contact_email: event?.contact_email || "",
contact_phone: event?.contact_phone || "",
slug: event?.slug || "",
});
const onChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value, type } = e.target;
const checked =
type === "checkbox" ? (e.target as HTMLInputElement).checked : undefined;
setFormData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
// live validation for slug
if (name === "slug" && mode === "create") {
if (RESERVED_SLUGS.includes(value)) {
setSlugError(`The slug "${value}" is reserved.`);
} else {
setSlugError(null);
}
}
// Clear error for field when changed
if (formErrors[name]) {
setFormErrors((prev) => {
const updated = { ...prev };
delete updated[name];
return updated;
});
}
};
const onSelectChange = (name: string, value: string | null) => {
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const onToggleSwitch = (name: string, checked: boolean) => {
setFormData((prev) => ({
...prev,
[name]: checked,
}));
};
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!formData.title.trim()) {
errors.title = "Event title is required";
}
if (!formData.event_date) {
errors.event_date = "Event date is required";
}
if (!formData.slug.trim()) {
errors.slug = "Event slug is required";
} else if (mode === "create" && RESERVED_SLUGS.includes(formData.slug)) {
errors.slug = `The slug "${formData.slug}" is reserved`;
}
if (formData.is_public === false && !formData.access_code) {
errors.access_code = "Access code is required for private events";
}
if (formData.contact_email && !validateEmail(formData.contact_email)) {
errors.contact_email = "Invalid email address";
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const validateEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
const result = await onSubmit(formData);
router.push(`/dashboard/events/${result.slug}`);
} catch (error) {
console.error(`Failed to ${mode} event:`, error);
alert(`Failed to ${mode} event. Please check your data and try again.`);
}
};
return (
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-6">
{/* Basic Event Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Basic Information</h3>
<div>
<Label htmlFor="title" className="mb-2">
Event Title *
</Label>
<Input
id="title"
required
name="title"
placeholder="My Awesome Event"
value={formData.title}
onChange={onChange}
className={formErrors.title ? "border-red-500" : ""}
/>
{formErrors.title && (
<p className="text-red-500 text-sm mt-1">{formErrors.title}</p>
)}
</div>
<div>
<Label htmlFor="description" className="mb-2">
Description
</Label>
<Textarea
id="description"
name="description"
placeholder="Quick description of your event"
value={formData.description || ""}
onChange={onChange}
rows={4}
/>
</div>
<div>
<Label htmlFor="theme_id" className="mb-2">
Event Theme
</Label>
<Select
name="theme_id"
value={formData.theme_id || ""}
onValueChange={(value) =>
onSelectChange("theme_id", value === "none" ? null : value)
}
>
<SelectTrigger id="theme_id">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{themes &&
themes.map((theme) => (
<SelectItem key={theme.id} value={theme.id}>
{theme.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-1">
Choose a visual theme for your event or select 'None' for a default
appearance
</p>
</div>
</div>
{/* Date & Time Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Date & Time</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label htmlFor="event_date" className="mb-2">
Event Date *
</Label>
<Input
id="event_date"
type="date"
required
name="event_date"
value={formData.event_date}
onChange={onChange}
className={formErrors.event_date ? "border-red-500" : ""}
/>
{formErrors.event_date && (
<p className="text-red-500 text-sm mt-1">
{formErrors.event_date}
</p>
)}
</div>
<div>
<Label htmlFor="event_start_time" className="mb-2">
Start Time
</Label>
<Input
id="event_start_time"
type="time"
name="event_start_time"
value={formData.event_start_time || ""}
onChange={onChange}
/>
</div>
<div>
<Label htmlFor="event_end_time" className="mb-2">
End Time
</Label>
<Input
id="event_end_time"
type="time"
name="event_end_time"
value={formData.event_end_time || ""}
onChange={onChange}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="timezone" className="mb-2">
Timezone *
</Label>
<Select
name="timezone"
value={formData.timezone}
onValueChange={(value) => onSelectChange("timezone", value)}
>
<SelectTrigger id="timezone">
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
<SelectContent>
{TIMEZONES.map((timezone) => (
<SelectItem key={timezone} value={timezone}>
{timezone}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="rsvp_deadline" className="mb-2">
RSVP Deadline
</Label>
<Input
id="rsvp_deadline"
type="date"
name="rsvp_deadline"
value={formData.rsvp_deadline || ""}
onChange={onChange}
/>
</div>
</div>
</div>
{/* Location Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Location</h3>
<div>
<Label htmlFor="location_name" className="mb-2">
Location Name
</Label>
<Input
id="location_name"
name="location_name"
placeholder="Venue Name"
value={formData.location_name || ""}
onChange={onChange}
/>
</div>
<div>
<Label htmlFor="location_address" className="mb-2">
Address
</Label>
<Textarea
id="location_address"
name="location_address"
placeholder="Full address"
value={formData.location_address || ""}
onChange={onChange}
/>
</div>
<div>
<Label htmlFor="location_url" className="mb-2">
Location URL
</Label>
<Input
id="location_url"
name="location_url"
type="url"
placeholder="https://maps.example.com/venue"
value={formData.location_url || ""}
onChange={onChange}
/>
<p className="text-sm text-muted-foreground mt-1">
Optional: Add a map link or venue website
</p>
</div>
</div>
{/* Contact Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Contact Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="contact_email" className="mb-2">
Contact Email
</Label>
<Input
id="contact_email"
name="contact_email"
type="email"
placeholder="event@example.com"
value={formData.contact_email || ""}
onChange={onChange}
className={formErrors.contact_email ? "border-red-500" : ""}
/>
{formErrors.contact_email && (
<p className="text-red-500 text-sm mt-1">
{formErrors.contact_email}
</p>
)}
</div>
<div>
<Label htmlFor="contact_phone" className="mb-2">
Contact Phone
</Label>
<Input
id="contact_phone"
name="contact_phone"
type="tel"
placeholder="+1 (555) 123-4567"
value={formData.contact_phone || ""}
onChange={onChange}
/>
</div>
</div>
</div>
{/* Event Settings */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Event Settings</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="slug" className="mb-2">
Event Slug *
</Label>
<Input
id="slug"
required
name="slug"
placeholder="my-event"
value={formData.slug}
onChange={onChange}
disabled={mode === "edit"}
className={formErrors.slug ? "border-red-500" : ""}
/>
{formErrors.slug && (
<p className="text-red-500 text-sm mt-1">{formErrors.slug}</p>
)}
{mode === "create" && (
<p className="text-sm text-muted-foreground mt-1">
This will be used as the URL for your event
</p>
)}
</div>
<div>
<Label htmlFor="max_guests_per_invitation" className="mb-2">
Max Guests Per RSVP
</Label>
<Input
id="max_guests_per_invitation"
name="max_guests_per_invitation"
type="number"
min="1"
placeholder="1"
value={
formData.max_guests_per_invitation === null
? ""
: formData.max_guests_per_invitation
}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value, 10)
: null;
setFormData((prev) => ({
...prev,
max_guests_per_invitation: value,
}));
}}
/>
<p className="text-sm text-muted-foreground mt-1">
Leave empty for unlimited guests per invitation
</p>
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<Switch
id="is_public"
checked={formData.is_public}
onCheckedChange={(checked) =>
onToggleSwitch("is_public", checked)
}
/>
<Label htmlFor="is_public">Make event public</Label>
</div>
<p className="text-sm text-muted-foreground ml-10">
Public events can be viewed by anyone with the link
</p>
</div>
{!formData.is_public && (
<div>
<Label htmlFor="access_code" className="mb-2">
Access Code *
</Label>
<Input
id="access_code"
name="access_code"
placeholder="secret-code"
value={formData.access_code || ""}
onChange={onChange}
className={formErrors.access_code ? "border-red-500" : ""}
required={!formData.is_public}
/>
{formErrors.access_code && (
<p className="text-red-500 text-sm mt-1">
{formErrors.access_code}
</p>
)}
<p className="text-sm text-muted-foreground mt-1">
Required for private events - guests will need this code to access
the event
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Switch
id="rsvp_enabled"
checked={formData.rsvp_enabled}
onCheckedChange={(checked) =>
onToggleSwitch("rsvp_enabled", checked)
}
/>
<Label htmlFor="rsvp_enabled">Enable RSVP functionality</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="gift_registry_enabled"
checked={formData.gift_registry_enabled}
onCheckedChange={(checked) =>
onToggleSwitch("gift_registry_enabled", checked)
}
/>
<Label htmlFor="gift_registry_enabled">Enable Gift Registry</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="updates_enabled"
checked={formData.updates_enabled}
onCheckedChange={(checked) =>
onToggleSwitch("updates_enabled", checked)
}
/>
<Label htmlFor="updates_enabled">Enable Event Updates</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="is_active"
checked={formData.is_active}
onCheckedChange={(checked) =>
onToggleSwitch("is_active", checked)
}
/>
<Label htmlFor="is_active">Event Active</Label>
</div>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end gap-2 mt-4">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? "Saving..."
: mode === "create"
? "Create Event"
: "Update Event"}
</Button>
</div>
</form>
);
}