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