created frontend of a form to create events
This commit is contained in:
parent
e6b7336440
commit
33c4e6f479
10 changed files with 950 additions and 0 deletions
24
frontend/README.md
Normal file
24
frontend/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Localist Frontend (React + Vite)
|
||||
|
||||
This directory contains the frontend application for Localist built with React + Vite.
|
||||
|
||||
It provides an event creation form with an embedded Leaflet map and a search bar (leaflet-geosearch). The form uses react-hook-form with a Zod schema to validate and transform outgoing payloads (the schema nests flat latitude/longitude into a `location` object before sending).
|
||||
|
||||
Quick links
|
||||
- Source: `src/`
|
||||
- Main component: `src/components/eventForm.jsx`
|
||||
- Validation/transform schema: `src/schemas/eventPostSchema.js`
|
||||
- API client: `src/services/eventService.js`
|
||||
- Vite config: `vite.config.js`
|
||||
|
||||
Prerequisites
|
||||
- Node.js (16+ recommended)
|
||||
- npm or yarn
|
||||
- Django backend running locally at http://127.0.0.1:8000 if you want the API to respond (the Vite config proxies `/api` to that address by default)
|
||||
|
||||
Install dependencies
|
||||
```bash
|
||||
cd localist/frontend
|
||||
npm install
|
||||
# or
|
||||
yarn
|
||||
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@mui/material": "^9.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-defaulticon-compatibility": "^0.1.2",
|
||||
"leaflet-geosearch": "^4.4.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
184
frontend/src/App.css
Normal file
184
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
62
frontend/src/App.jsx
Normal file
62
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import { useMapEvents } from 'react-leaflet';
|
||||
import { Routes, Route, Link } from "react-router-dom";
|
||||
import EventForm from './components/eventForm.jsx'; // <- add this import
|
||||
|
||||
function ClickHandler() {
|
||||
useMapEvents({
|
||||
click(e) {
|
||||
console.log(
|
||||
e.latlng.lat,
|
||||
e.latlng.lng
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// src/App.jsx
|
||||
function Home() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Home</h1>
|
||||
<p><Link to="/createevent">Create an event</Link></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/createevent" element={<EventForm />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// export default function App() {
|
||||
// return (
|
||||
// <div style={{ height: '100vh' }}>
|
||||
// <MapContainer
|
||||
// center={[40.437, -74.199]} // Keyport area
|
||||
// zoom={13}
|
||||
// style={{ height: '100%', width: '100%' }}
|
||||
// >
|
||||
// <TileLayer
|
||||
// attribution="© OpenStreetMap contributors"
|
||||
// url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
// />
|
||||
// <ClickHandler />
|
||||
//
|
||||
// <Marker position={[40.437, -74.199]}>
|
||||
// <Popup>
|
||||
// Hello from Leaflet!
|
||||
// </Popup>
|
||||
// </Marker>
|
||||
// </MapContainer>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
124
frontend/src/components/eventForm.css
Normal file
124
frontend/src/components/eventForm.css
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/* eventForm.css */
|
||||
.event-form {
|
||||
max-width: 900px;
|
||||
margin: 28px auto;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.06);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* On wider screens use two columns */
|
||||
@media (min-width: 700px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Make these fields span both columns */
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 6px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-field input[type="text"],
|
||||
.form-field input[type="url"],
|
||||
.form-field input[type="email"],
|
||||
.form-field input[type="tel"],
|
||||
.form-field input[type="number"],
|
||||
.form-field input[type="datetime-local"],
|
||||
.form-field textarea,
|
||||
.form-field select {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: box-shadow .12s ease, border-color .12s ease;
|
||||
}
|
||||
|
||||
.form-field textarea {
|
||||
min-height: 110px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-field input:focus,
|
||||
.form-field textarea:focus,
|
||||
.form-field select:focus {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 4px 10px rgba(96,165,250,0.12);
|
||||
}
|
||||
|
||||
/* Checkbox row styling */
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(180deg, #2563eb, #1e40af);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 18px rgba(30,64,175,0.12);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgba(30,64,175,0.14);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 6px 18px rgba(30,64,175,0.12);
|
||||
}
|
||||
|
||||
/* Small screens tweak */
|
||||
@media (max-width: 420px) {
|
||||
.event-form {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-control-attribution .leaflet-attrib-flag {
|
||||
display: none;
|
||||
}
|
||||
257
frontend/src/components/eventForm.jsx
Normal file
257
frontend/src/components/eventForm.jsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/*
|
||||
EventForm - create/edit event UI with Leaflet map + leaflet-geosearch.
|
||||
- Uses react-hook-form + Zod resolver for validation/coercion
|
||||
- Map provides a search bar (OpenStreetMapProvider) and click-to-place marker
|
||||
- When a place is chosen, address + coordinates are written into the form values
|
||||
- Coordinates are submitted as flat fields; the schema transforms them into `location`
|
||||
*/
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useForm} from "react-hook-form";
|
||||
import {zodResolver} from "@hookform/resolvers/zod";
|
||||
import EventService from "../services/eventService";
|
||||
import {EventPostSchema} from "../schemas/eventPostSchema";
|
||||
|
||||
// Leaflet icon compatibility helper (handles bundler issues)
|
||||
import 'leaflet-defaulticon-compatibility';
|
||||
import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css';
|
||||
|
||||
// Leaflet / React-Leaflet
|
||||
import {MapContainer, TileLayer, Marker, useMap, useMapEvents} from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "./eventForm.css"
|
||||
|
||||
// GeoSearch
|
||||
import {GeoSearchControl, OpenStreetMapProvider} from "leaflet-geosearch";
|
||||
import "leaflet-geosearch/dist/geosearch.css";
|
||||
|
||||
/* Fix leaflet icon paths for many bundlers (Vite/CRA/Webpack) */
|
||||
import markerUrl from "leaflet/dist/images/marker-icon.png";
|
||||
import markerRetinaUrl from "leaflet/dist/images/marker-icon-2x.png";
|
||||
import markerShadowUrl from "leaflet/dist/images/marker-shadow.png";
|
||||
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: markerRetinaUrl,
|
||||
iconUrl: markerUrl,
|
||||
shadowUrl: markerShadowUrl,
|
||||
});
|
||||
|
||||
export default function EventForm() {
|
||||
const eventService = new EventService();
|
||||
const [serverError, setServerError] = useState(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: {errors, isSubmitting, isDirty},
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm({
|
||||
resolver: zodResolver(EventPostSchema),
|
||||
defaultValues: {
|
||||
name: "Madeleine's birthday",
|
||||
description: "test",
|
||||
url: "http://localhost:8080",
|
||||
address: "31 Fleetwood",
|
||||
status: "scheduled",
|
||||
price: "10",
|
||||
require_rsvp: false,
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
rain_date: "",
|
||||
email: "madeleienma07@gmail.com",
|
||||
phone_number: "8482189789",
|
||||
latitude: "", // hidden field for latitude
|
||||
longitude: "", // hidden field for longitude
|
||||
},
|
||||
});
|
||||
// Status options for the select dropdown
|
||||
const STATUS_OPTIONS = [
|
||||
{value: "scheduled", label: "Scheduled"},
|
||||
{value: "completed", label: "Completed"},
|
||||
{value: "canceled", label: "Canceled"},
|
||||
];
|
||||
|
||||
// submit
|
||||
const onSubmit = async (data) => {
|
||||
setServerError(null);
|
||||
try {
|
||||
// Schema handles transformation - data is already nested as { location: { latitude, longitude } }
|
||||
await eventService.createEvent(data);
|
||||
alert("Your event has been created successfully");
|
||||
reset();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setServerError(err.body ?? err.message ?? "Network error");
|
||||
alert("Failed to save: " + (err.status || "") + " " + JSON.stringify(err.body || err.message));
|
||||
}
|
||||
};
|
||||
// Reading lat/lng so the marker can follow them
|
||||
const latitude = watch("latitude");
|
||||
const longitude = watch("longitude");
|
||||
|
||||
// Map child that adds the GeoSearch control and listens for results
|
||||
function GeoSearchControlComponent() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const provider = new OpenStreetMapProvider();
|
||||
|
||||
const searchControl = new GeoSearchControl({
|
||||
provider,
|
||||
style: "bar",
|
||||
showMarker: false,
|
||||
retainZoomLevel: false,
|
||||
autoClose: true,
|
||||
keepResult: false,
|
||||
});
|
||||
|
||||
map.addControl(searchControl);
|
||||
|
||||
const onShowLocation = (e) => {
|
||||
const {x: lon, y: lat, label} = e.location;
|
||||
// Set all three fields from the search result
|
||||
setValue("address", label, {shouldDirty: true, shouldValidate: true});
|
||||
setValue("latitude", lat, {shouldDirty: true, shouldValidate: true});
|
||||
setValue("longitude", lon, {shouldDirty: true, shouldValidate: true});
|
||||
map.setView([lat, lon], Math.max(map.getZoom(), 13));
|
||||
};
|
||||
|
||||
map.on("geosearch/showlocation", onShowLocation);
|
||||
|
||||
return () => {
|
||||
map.removeControl(searchControl);
|
||||
map.off("geosearch/showlocation", onShowLocation);
|
||||
};
|
||||
}, [map, setValue]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map child that listens for click events to set coordinates
|
||||
function ClickToSetMarker() {
|
||||
useMapEvents({
|
||||
click(e) {
|
||||
setValue("latitude", e.latlng.lat, {shouldDirty: true, shouldValidate: true});
|
||||
setValue("longitude", e.latlng.lng, {shouldDirty: true, shouldValidate: true});
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Default center if no coordinates set
|
||||
const defaultCenter = [40.35, -74.31];
|
||||
|
||||
// parse lat/lng from watched values
|
||||
const markerPosition = (latitude && longitude) ? [parseFloat(latitude), parseFloat(longitude)] : null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||
<div>
|
||||
<label>Name
|
||||
<input {...register("name")} placeholder="Name"/>
|
||||
{errors.name && <div style={{color: "red"}}>{errors.name.message}</div>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Description
|
||||
<textarea {...register("description")} placeholder="Description"/>
|
||||
{errors.description && <div style={{color: "red"}}>{errors.description.message}</div>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input {...register("url")} placeholder="URL" type="url"/>
|
||||
{errors.url && <div style={{color: "red"}}>{errors.url.message}</div>}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Map and search control */}
|
||||
<div style={{marginTop: 8}}>
|
||||
<div style={{height: 320, width: "100%"}}>
|
||||
<MapContainer
|
||||
center={markerPosition || defaultCenter}
|
||||
zoom={markerPosition ? 13 : 11}
|
||||
style={{height: "100%", width: "100%"}}
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"/>
|
||||
<GeoSearchControlComponent/>
|
||||
<ClickToSetMarker/>
|
||||
{markerPosition && <Marker position={markerPosition}/>}
|
||||
</MapContainer>
|
||||
</div>
|
||||
<div style={{fontSize: 12, color: "#666", marginTop: 6}}>
|
||||
Tip: search an address on the map bar or click the map to set location.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{/* Address input - if you want you can also update address automatically from search result */}
|
||||
<input {...register("address")} placeholder="Address"/>
|
||||
{errors.address && <div style={{color: "red"}}>{errors.address.message}</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="status">Status</label>
|
||||
<select id="status" {...register("status")}>
|
||||
{STATUS_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.status && <div style={{color: "red"}}>{errors.status.message}</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input {...register("price")} placeholder="Price USD" type="number" step="1.0"/>
|
||||
{errors.price && <div style={{color: "red"}}>{errors.price.message}</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>
|
||||
<input {...register("require_rsvp")} type="checkbox"/>
|
||||
Require RSVP
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Start time
|
||||
<input {...register("start_time")} type="datetime-local"/>
|
||||
</label>
|
||||
{errors.start_time && <div style={{color: "red"}}>{errors.start_time.message}</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>End time
|
||||
<input {...register("end_time")} type="datetime-local"/>
|
||||
</label>
|
||||
{errors.end_time && <div style={{color: "red"}}>{errors.end_time.message}</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Rain date
|
||||
<input {...register("rain_date")} type="datetime-local"/>
|
||||
</label>
|
||||
{errors.rain_date && <div style={{color: "red"}}>{errors.rain_date.message}</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input {...register("email")} placeholder="Email" type="email"/>
|
||||
{errors.email && <div style={{color: "red"}}>{errors.email.message}</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input {...register("phone_number")} placeholder="Phone number" type="tel"/>
|
||||
{errors.phone_number && <div style={{color: "red"}}>{errors.phone_number.message}</div>}
|
||||
</div>
|
||||
|
||||
{/* Hidden fields for coordinates (these will be submitted) */}
|
||||
<input type="hidden" {...register("latitude")} />
|
||||
<input type="hidden" {...register("longitude")} />
|
||||
|
||||
<div style={{marginTop: 12}}>
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Event"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
111
frontend/src/index.css
Normal file
111
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
16
frontend/src/main.jsx
Normal file
16
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { StrictMode } from 'react'
|
||||
import React from "react";
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
const root = createRoot(document.getElementById("root"));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
85
frontend/src/schemas/eventPostSchema.js
Normal file
85
frontend/src/schemas/eventPostSchema.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
Schema for outgoing POST body to /api/event
|
||||
- Accepts form values (strings/numbers) and coerces where useful.
|
||||
- Transforms flat latitude/longitude fields into a nested `location` object:
|
||||
location: { latitude: number | null, longitude: number | null }
|
||||
- Removes the flat latitude/longitude fields from the final payload.
|
||||
*/
|
||||
import {z} from "zod";
|
||||
|
||||
export const EventPostSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().min(5, "Description has to be more than 5 letters."),
|
||||
url: z.string().url().nullable().optional(),
|
||||
address: z.string().min(5, "Address is required"),
|
||||
|
||||
status: z.enum(["scheduled", "completed", "canceled"]).optional(),
|
||||
|
||||
// price: allow number or string or null; coerce to number or null
|
||||
price: z
|
||||
.union([z.string(), z.number(), z.null()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val === "") return null;
|
||||
const n = typeof val === "string" ? parseFloat(val) : val;
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}),
|
||||
|
||||
require_rsvp: z.boolean().optional().default(false),
|
||||
|
||||
// start_time: optional, but if provided must be a valid Date and in the future.
|
||||
start_time: z.preprocess((val) => {
|
||||
if (val === "" || val === undefined || val === null) return null;
|
||||
if (typeof val === "string") {
|
||||
const d = new Date(val);
|
||||
return isNaN(d.getTime()) ? val : d;
|
||||
}
|
||||
return val;
|
||||
},
|
||||
z.union([
|
||||
z.date({invalid_type_error: "Start date must be a valid datetime"}),
|
||||
z.null()
|
||||
])
|
||||
.refine((v) => v === null || v.getTime() > Date.now(), {
|
||||
message: "Start date must be in the future",
|
||||
})
|
||||
)
|
||||
.transform((val) => (val === null ? null : val.toISOString())),
|
||||
|
||||
end_time: z.union([z.string(), z.date(), z.null()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (!val) return null;
|
||||
return val instanceof Date ? val.toISOString() : val;
|
||||
}),
|
||||
|
||||
rain_date: z
|
||||
.union([z.string(), z.date(), z.null()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (!val) return null;
|
||||
return val instanceof Date ? val.toISOString() : val;
|
||||
}),
|
||||
|
||||
email: z.string().email().nullable().optional(),
|
||||
phone_number: z.string().nullable().optional(),
|
||||
|
||||
// Flat latitude and longitude fields for form handling
|
||||
latitude: z.union([z.string(), z.number(), z.null()]).optional(),
|
||||
longitude: z.union([z.string(), z.number(), z.null()]).optional(),
|
||||
})
|
||||
.transform((data) => {
|
||||
// Transform flat latitude/longitude into nested location object
|
||||
if (data.latitude || data.longitude) {
|
||||
data.location = {
|
||||
lat: data.latitude ? parseFloat(data.latitude) : null,
|
||||
lng: data.longitude ? parseFloat(data.longitude) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Remove flat fields from payload
|
||||
delete data.latitude;
|
||||
delete data.longitude;
|
||||
|
||||
return data;
|
||||
});
|
||||
46
frontend/src/services/eventService.js
Normal file
46
frontend/src/services/eventService.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import {EventPostSchema} from "../schemas/eventPostSchema";
|
||||
|
||||
// Service responsible for sending event POST requests to the API.
|
||||
// - Validates and coerces the outgoing payload using EventPostSchema
|
||||
// - Logs validation errors (if any) and the final payload sent
|
||||
// - Sends the request with CSRF token (if available) and returns parsed JSON response
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE
|
||||
|
||||
function getCSRFToken() {
|
||||
const name = "csrftoken";
|
||||
const match = document.cookie.match(new RegExp('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)'));
|
||||
return match ? match.pop() : null;
|
||||
}
|
||||
|
||||
export default class EventService {
|
||||
constructor(base = API_BASE) {
|
||||
this.base = base;
|
||||
}
|
||||
|
||||
async createEvent(rawPayload) {
|
||||
// Validate and coerce the outgoing payload
|
||||
const parsed = EventPostSchema.parse(rawPayload); // will throw if invalid
|
||||
console.log("EventService sending payload:", JSON.stringify(rawPayload, null, 2)); // Log here
|
||||
const res = await fetch(`${this.base}/event`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// X-CSRFToken if using django session auth in browser
|
||||
...(getCSRFToken() ? {"X-CSRFToken": getCSRFToken()} : {}),
|
||||
},
|
||||
credentials: "same-origin", // change to 'include' if cross-site cookies
|
||||
body: JSON.stringify(parsed),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error("Failed to create event");
|
||||
err.status = res.status;
|
||||
err.body = text;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue