diff --git a/src/Alerts.css b/src/Alerts.css new file mode 100644 index 0000000..f05029e --- /dev/null +++ b/src/Alerts.css @@ -0,0 +1,101 @@ +#alerts-container { + gap: 2rem; + padding: 2rem 0; +} + +#alerts-form-section { + gap: 1.5rem; +} + +#alerts-header { + align-items: center; + display: flex; + flex-direction: column; + gap: 1rem; + text-align: center; +} + +#alerts-header h1 { + font-size: 2.5rem; + font-weight: 500; +} + +#alerts-header p { + color: #c3c3c4; + font-size: 1.1rem; + max-width: 40rem; +} + +#alerts-header-icon { + color: #89B3F7; + font-size: 3rem; +} + +#alerts-form { + display: flex; + flex-direction: column; + gap: 1.25rem; + width: 100%; +} + +.alerts-row { + display: flex; + gap: 1rem; + width: 100%; +} + +.alerts-row > * { + flex: 1; + min-width: 0; +} + +#alerts-error { + align-items: center; + display: flex; + gap: 0.5rem; +} + +#alerts-submit { + display: flex; + justify-content: center; + padding-top: 0.5rem; +} + +#alerts-success { + align-items: center; + text-align: center; +} + +#alerts-success h1 { + font-size: 2.5rem; + font-weight: 500; +} + +#alerts-success p { + color: #c3c3c4; + font-size: 1.1rem; + max-width: 40rem; +} + +#alerts-success-icon { + color: #89B3F7; + font-size: 4rem; +} + +#alerts-unsubscribed-actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; + justify-content: center; +} + +@media all and (max-width: 480px) { + .alerts-row { + flex-direction: column; + } + + #alerts-header h1, + #alerts-success h1 { + font-size: 2rem; + } +} diff --git a/src/Alerts.js b/src/Alerts.js new file mode 100644 index 0000000..cbf2759 --- /dev/null +++ b/src/Alerts.js @@ -0,0 +1,462 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { useEffect, useState } from "react"; +import { flushSync } from "react-dom"; +import { Link, useSearchParams } from "react-router-dom"; +import Turnstile from "./Turnstile"; +import "./Alerts.css"; +import AttachMoneyIcon from "@mui/icons-material/AttachMoney"; +import ErrorIcon from "@mui/icons-material/Error"; +import MailOutlineIcon from "@mui/icons-material/MailOutline"; +import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive"; +import Alert from "@mui/material/Alert"; +import Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import CircularProgress from "@mui/material/CircularProgress"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import InputAdornment from "@mui/material/InputAdornment"; +import MenuItem from "@mui/material/MenuItem"; +import Switch from "@mui/material/Switch"; +import TextField from "@mui/material/TextField"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; + +dayjs.extend(utc); + +const ACCOMMODATIONS = [ + { value: "coach", label: "Coach" }, + { value: "business", label: "Business" }, + { value: "roomette", label: "Roomette" }, + { value: "bedroom", label: "Bedroom" }, + { value: "family_room", label: "Family Room" }, +]; + +export default function Alerts() { + const [searchParams] = useSearchParams(); + + const [stations, setStations] = useState([]); + const [stationsLoaded, setStationsLoaded] = useState(false); + + const [email, setEmail] = useState(""); + const [origin, setOrigin] = useState(null); + const [destination, setDestination] = useState(null); + const [accommodation, setAccommodation] = useState("coach"); + const [priceThreshold, setPriceThreshold] = useState(""); + const [singleDay, setSingleDay] = useState(false); + const [startDate, setStartDate] = useState( + dayjs.utc().startOf("d").add(1, "d") + ); + const [endDate, setEndDate] = useState( + dayjs.utc().startOf("d").add(7, "d") + ); + + const [showTurnstile, setShowTurnstile] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); + const [success, setSuccess] = useState(false); + const [showErrors, setShowErrors] = useState(false); + const [duplicate, setDuplicate] = useState(false); + const [unsubscribing, setUnsubscribing] = useState(false); + const [unsubscribeSent, setUnsubscribeSent] = useState(false); + + useEffect(() => { + fetch(`${process.env.REACT_APP_API_DOMAIN}/stations`) + .then((res) => res.json()) + .then((data) => { + const sorted = data + .sort((a, b) => a.stateLong.localeCompare(b.stateLong)) + .map((station) => ({ ...station, group: station.stateLong })); + setStations(sorted); + const o = searchParams.get("origin"); + const d = searchParams.get("destination"); + if (o) { + const match = sorted.find((s) => s.code === o); + if (match) setOrigin(match); + } + if (d) { + const match = sorted.find((s) => s.code === d); + if (match) setDestination(match); + } + setStationsLoaded(true); + }) + .catch(() => setStationsLoaded(true)); + + const s = searchParams.get("start"); + const e = searchParams.get("end"); + if (s && dayjs(s).isValid()) { + setStartDate(dayjs(s).utc().startOf("d")); + } + if (e && dayjs(e).isValid()) { + setEndDate(dayjs(e).utc().startOf("d")); + } + const a = searchParams.get("accommodation"); + if (a) { + const match = ACCOMMODATIONS.find((opt) => opt.label === a); + if (match) setAccommodation(match.value); + } + const p = searchParams.get("price"); + if (p && !isNaN(parseFloat(p))) { + setPriceThreshold(p); + } + }, []); + + const today = dayjs.utc().startOf("d"); + const maxStartDate = today.add(30, "d"); + const priceNum = parseFloat(priceThreshold); + const effectiveEnd = singleDay ? startDate : endDate; + const rangeDays = effectiveEnd.diff(startDate, "d") + 1; + + let errorText = ""; + if (!email.trim()) { + errorText = "Enter an email address"; + } else if (!/^\S+@\S+\.\S+$/.test(email.trim())) { + errorText = "Invalid email address"; + } else if (!origin) { + errorText = "Select an origin station"; + } else if (!destination) { + errorText = "Select a destination station"; + } else if (origin.id === destination.id) { + errorText = "Origin and destination must be different"; + } else if ( + !priceThreshold || + isNaN(priceNum) || + priceNum <= 0 + ) { + errorText = "Enter a price threshold greater than $0"; + } else if (startDate.isBefore(today, "d")) { + errorText = "Start date must be today or later"; + } else if (startDate.isAfter(maxStartDate, "d")) { + errorText = "Start date must be within 30 days from today"; + } else if (!singleDay && endDate.isBefore(startDate, "d")) { + errorText = "End date must be on or after start date"; + } else if (!singleDay && rangeDays > 31) { + errorText = "Date range must be 31 days or less"; + } + + function getTurnstileToken() { + return new Promise((resolve) => { + const turnstileId = window.turnstile.render("#turnstile", { + callback: (token) => { + setShowTurnstile(false); + window.turnstile.remove(turnstileId); + resolve(token); + }, + "refresh-expired": "never", + sitekey: process.env.REACT_APP_TURNSTILE_SITE_KEY, + }); + }); + } + + async function handleSubmit(e) { + e.preventDefault(); + if (errorText) { + setShowErrors(false); + setTimeout(() => setShowErrors(true), 0); + return; + } + setShowErrors(false); + setSubmitError(""); + setDuplicate(false); + setUnsubscribeSent(false); + setSubmitting(true); + flushSync(() => setShowTurnstile(true)); + let token; + try { + token = await getTurnstileToken(); + } catch { + setSubmitting(false); + setShowTurnstile(false); + setSubmitError("Verification failed — try again"); + return; + } + try { + const body = { + email: email.trim(), + origin: origin.code, + destination: destination.code, + accommodation, + price_threshold: priceNum, + start_date: startDate.format("YYYY-MM-DD"), + }; + if (!singleDay) { + body.end_date = endDate.format("YYYY-MM-DD"); + } + const res = await fetch( + `${process.env.REACT_APP_API_DOMAIN}/subscriptions`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "auth-turnstile": token, + }, + body: JSON.stringify(body), + } + ); + if (res.status === 201) { + setSuccess(true); + } else if (res.status === 409) { + setDuplicate(true); + } else if (res.status === 400) { + const data = await res.json().catch(() => null); + const msg = + data && Array.isArray(data.error) && data.error.length > 0 + ? data.error + .map((err) => + typeof err === "string" + ? err + : err.message || JSON.stringify(err) + ) + .join(". ") + : "Invalid subscription details"; + setSubmitError(msg); + } else { + setSubmitError(`Subscription failed (HTTP ${res.status})`); + } + } catch { + setSubmitError("Network error — try again"); + } finally { + setSubmitting(false); + } + } + + async function handleUnsubscribeRequest() { + setSubmitError(""); + setUnsubscribing(true); + flushSync(() => setShowTurnstile(true)); + let token; + try { + token = await getTurnstileToken(); + } catch { + setUnsubscribing(false); + setShowTurnstile(false); + setSubmitError("Verification failed — try again"); + return; + } + try { + const res = await fetch( + `${process.env.REACT_APP_API_DOMAIN}/subscriptions/unsubscribe-request`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "auth-turnstile": token, + }, + body: JSON.stringify({ email: email.trim() }), + } + ); + if (res.ok) { + setUnsubscribeSent(true); + setDuplicate(false); + } else { + setSubmitError(`Unsubscribe request failed (HTTP ${res.status})`); + } + } catch { + setSubmitError("Network error — try again"); + } finally { + setUnsubscribing(false); + } + } + + if (success) { + return ( +
+
+ +

Check your email

+

+ We sent a verification link to {email.trim()}. Click + the link to activate your price alert. The link expires in 24 hours. +

+ +
+
+ ); + } + + return ( +
+
+
+ +

Price Drop Alerts

+

+ Get an email when fares fall below your threshold. We'll watch the + route for you and notify you as soon as prices drop. +

+
+
+ setEmail(e.target.value)} + placeholder="you@example.com" + type="email" + value={email} + /> +
+ + option ? `${option.name} (${option.code})` : "" + } + groupBy={(station) => station.group} + isOptionEqualToValue={(option, value) => option.id === value.id} + loadingText="Getting stations..." + onChange={(e, v) => setOrigin(v)} + options={stations} + renderInput={(params) => ( + + )} + value={origin} + /> + + option ? `${option.name} (${option.code})` : "" + } + groupBy={(station) => station.group} + isOptionEqualToValue={(option, value) => option.id === value.id} + loadingText="Getting stations..." + onChange={(e, v) => setDestination(v)} + options={stations} + renderInput={(params) => ( + + )} + value={destination} + /> +
+
+ setAccommodation(e.target.value)} + select + value={accommodation} + > + {ACCOMMODATIONS.map((opt) => ( + + {opt.label} + + ))} + + + + + ), + inputProps: { min: 0, step: "0.01" }, + }} + label="Price threshold" + onChange={(e) => setPriceThreshold(e.target.value)} + placeholder="79.00" + type="number" + value={priceThreshold} + /> +
+ setSingleDay(e.target.checked)} + /> + } + label="Single day only" + /> +
+ v && setStartDate(v)} + slotProps={{ textField: { fullWidth: true } }} + value={startDate} + /> + {!singleDay && ( + v && setEndDate(v)} + slotProps={{ textField: { fullWidth: true } }} + value={endDate} + /> + )} +
+ {showErrors && errorText && ( +
+ + {errorText} +
+ )} + {submitError && ( + + {submitError} + + )} + {duplicate && !unsubscribeSent && ( + + {unsubscribing ? "Sending..." : "Unsubscribe"} + + } + > + An alert already exists for this email — unsubscribe from the + existing one first. + + )} + {unsubscribeSent && ( + + Unsubscribe email sent to {email.trim()}. Click + the link to confirm, then try creating the alert again. + + )} + + {showTurnstile ? ( + + ) : ( + + )} + + +
+
+ ); +} diff --git a/src/AppRouter.js b/src/AppRouter.js index 6ad8049..be63ffb 100644 --- a/src/AppRouter.js +++ b/src/AppRouter.js @@ -1,9 +1,12 @@ import { useState } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; import About from "./About"; +import Alerts from "./Alerts"; import Home from "./Home"; import Navbar from "./Navbar"; import NotFound from "./NotFound"; +import Subscribed from "./Subscribed"; +import Unsubscribed from "./Unsubscribed"; import "./AppRouter.css"; export default function AppRouter() { @@ -40,6 +43,9 @@ export default function AppRouter() { } /> } /> + } /> + } /> + } /> +