Compare commits

..

No commits in common. "dd650c6ba69252f3a0898fe68d63bb380d7ce243" and "4bca956cbb4fe34a1ba575845bab82893248bf1e" have entirely different histories.

8 changed files with 706 additions and 555 deletions

View File

@ -2,18 +2,20 @@ import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma"; import { prisma } from "@utils/prisma";
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const { requestType, requestingUserId, scientistId, comment } = await req.json(); const { requestType, requestingUserId, scientistId, comment } = await req.json();
const request = await prisma.request.create({ const request = await prisma.request.create({
data: { data: {
requestType, requestType,
requestingUser: { connect: { id: requestingUserId } }, requestingUser: { connect: { id: requestingUserId } },
outcome: "IN_PROGRESS", outcome: "IN_PROGRESS",
}, // Optionally you can connect to Scientist via an inline relation if you have a foreign key
}); // If the model has comment or details fields, add it!
return NextResponse.json({ request }, { status: 201 }); },
} catch (error) { });
console.error("Request create error:", error); return NextResponse.json({ request }, { status: 201 });
return NextResponse.json({ error: "Failed to create request" }, { status: 500 }); } catch (error) {
} console.error("Request create error:", error);
} return NextResponse.json({ error: "Failed to create request" }, { status: 500 });
}
}

View File

@ -13,7 +13,8 @@ export async function POST(req: Request) {
if ("user" in authResult === false) return authResult; if ("user" in authResult === false) return authResult;
const { user } = authResult; const { user } = authResult;
const { userId, email, name, password, requestedRole } = await req.json(); // todo handle requestedRole
const { userId, email, name, password } = await req.json();
// Trying to update a different user than themselves // Trying to update a different user than themselves
// Only available to admins // Only available to admins
@ -57,12 +58,6 @@ export async function POST(req: Request) {
passwordHash = await bcryptjs.hash(password, 10); passwordHash = await bcryptjs.hash(password, 10);
} }
if (requestedRole && ["GUEST", "SCIENTIST", "ADMIN"].includes(requestedRole) && requestedRole !== user.role) {
await prisma.request.create({
data: { requestType: requestedRole, requestingUserId: userId || user.id },
});
}
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { id: userId || user.id }, where: { id: userId || user.id },
data: { data: {

View File

@ -11,6 +11,28 @@ import EarthquakeSearchModal from "@components/EarthquakeSearchModal";
import EarthquakeLogModal from "@components/EarthquakeLogModal"; // If you use a separate log modal import EarthquakeLogModal from "@components/EarthquakeLogModal"; // If you use a separate log modal
import { useStoreState } from "@hooks/store"; import { useStoreState } from "@hooks/store";
// Optional: "No Access Modal" - as in your original
function NoAccessModal({ open, onClose }) {
if (!open) return null;
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-xs w-full text-center relative">
<button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg" aria-label="Close">
&times;
</button>
<h2 className="font-bold text-xl mb-4">Access Denied</h2>
<p className="text-gray-600 mb-3">
Sorry, you do not have access rights to Log an Earthquake. Please Log in here, or contact an Admin if you believe this
is a mistake
</p>
<button onClick={onClose} className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2">
OK
</button>
</div>
</div>
);
}
export default function Earthquakes() { export default function Earthquakes() {
const [selectedEventId, setSelectedEventId] = useState(""); const [selectedEventId, setSelectedEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState(""); const [hoveredEventId, setHoveredEventId] = useState("");
@ -18,9 +40,12 @@ export default function Earthquakes() {
const [logModalOpen, setLogModalOpen] = useState(false); const [logModalOpen, setLogModalOpen] = useState(false);
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false); const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
// Your user/role logic
const user = useStoreState((state) => state.user); const user = useStoreState((state) => state.user);
const canLogEarthquake = user?.role === "SCIENTIST" || user?.role === "ADMIN"; const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
// Fetch earthquakes (10 days recent)
const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 })); const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 }));
// Shape for Map/Sidebar // Shape for Map/Sidebar
@ -55,27 +80,6 @@ export default function Earthquakes() {
} }
}; };
function NoAccessModal({ open, onClose }: { open: typeof noAccessModalOpen; onClose: () => void }) {
if (!open) return null;
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-xs w-full text-center relative">
<button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg" aria-label="Close">
&times;
</button>
<h2 className="font-bold text-xl mb-4">Access Denied</h2>
<p className="text-gray-600 mb-3">
Sorry, you do not have access rights to Log an Earthquake. Please Log in here, or contact an Admin if you believe this
is a mistake
</p>
<button onClick={onClose} className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2">
OK
</button>
</div>
</div>
);
}
return ( return (
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden"> <div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
<div className="flex-grow h-full"> <div className="flex-grow h-full">
@ -103,12 +107,15 @@ export default function Earthquakes() {
onButton2Click={() => setSearchModalOpen(true)} onButton2Click={() => setSearchModalOpen(true)}
button1Disabled={!canLogEarthquake} button1Disabled={!canLogEarthquake}
/> />
{/* ---- SEARCH MODAL ---- */}
<EarthquakeSearchModal <EarthquakeSearchModal
open={searchModalOpen} open={searchModalOpen}
onClose={() => setSearchModalOpen(false)} onClose={() => setSearchModalOpen(false)}
onSelect={(eq) => setSelectedEventId(eq.code)} onSelect={(eq) => setSelectedEventId(eq.code)}
/> />
{/* ---- LOGGING MODAL ---- */}
<EarthquakeLogModal open={logModalOpen} onClose={() => setLogModalOpen(false)} onSuccess={() => mutate()} /> <EarthquakeLogModal open={logModalOpen} onClose={() => setLogModalOpen(false)} onSuccess={() => mutate()} />
{/* ---- NO ACCESS ---- */}
<NoAccessModal open={noAccessModalOpen} onClose={() => setNoAccessModalOpen(false)} /> <NoAccessModal open={noAccessModalOpen} onClose={() => setNoAccessModalOpen(false)} />
</div> </div>
); );

View File

@ -229,7 +229,6 @@ export default function Profile() {
} }
setIsDeleting(true); setIsDeleting(true);
try { try {
// todo add delete user route
const res = await axios.post( const res = await axios.post(
"/api/delete-user", "/api/delete-user",
{ userId: user!.id }, { userId: user!.id },

View File

@ -1,43 +1,41 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ExtendedArtefact } from "@appTypes/ApiTypes"; import { ExtendedArtefact } from "@appTypes/ApiTypes";
import { Currency } from "@appTypes/StoreModel"; import { Currency } from "@appTypes/StoreModel";
import BottomFooter from "@components/BottomFooter"; import BottomFooter from "@components/BottomFooter";
import { useStoreState } from "@hooks/store"; import { useStoreState } from "@hooks/store";
interface SuperExtendedArtefact extends ExtendedArtefact {
location: string;
dateReleased: string;
image: string;
price: number;
}
export default function Shop() { export default function Shop() {
const [artefacts, setArtefacts] = useState<SuperExtendedArtefact[]>([]); const [artefacts, setArtefacts] = useState<ExtendedArtefact[]>([]);
const [hiddenArtefactIds, setHiddenArtefactIds] = useState<number[]>([]); const [hiddenArtefactIds, setHiddenArtefactIds] = useState<number[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [cart, setCart] = useState<ExtendedArtefact[]>([]);
const [showCartModal, setShowCartModal] = useState(false);
const user = useStoreState((state) => state.user); const user = useStoreState((state) => state.user);
// 3. Fetch from your API route and map data to fit your existing fields
useEffect(() => { useEffect(() => {
async function fetchArtefacts() { async function fetchArtefacts() {
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/artefacts"); const res = await fetch("/api/artefacts");
const data: { artefact: ExtendedArtefact[] } = await res.json(); const data = await res.json();
const transformed = data.artefact.map((a: any) => ({
const transformed = data.artefact.map((a) => ({ id: a.id,
...a, name: a.name,
location: a.warehouseArea, // your database description: a.description,
location: a.warehouseArea,
earthquakeID: a.earthquakeId?.toString() ?? "",
observatory: a.type ?? "",
dateReleased: a.createdAt ? new Date(a.createdAt).toLocaleDateString() : "", dateReleased: a.createdAt ? new Date(a.createdAt).toLocaleDateString() : "",
image: "/artefactImages/" + (a.imageName || "NoImageFound.PNG"), image: "/artefactImages/" + (a.imageName || "NoImageFound.PNG"),
price: a.shopPrice ?? 100, // fallback price if not in DB price: a.shopPrice ?? 100,
})); }));
setArtefacts(transformed); setArtefacts(transformed);
} catch (e) { } catch (e) {
// Optionally handle error
console.error("Failed to fetch artefacts", e); console.error("Failed to fetch artefacts", e);
} finally { } finally {
setLoading(false); setLoading(false);
@ -47,9 +45,10 @@ export default function Shop() {
}, []); }, []);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [selectedArtefact, setSelectedArtefact] = useState<SuperExtendedArtefact | null>(null); const [selectedArtefact, setSelectedArtefact] = useState<ExtendedArtefact | null>(null);
const [showPaymentModal, setShowPaymentModal] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false);
const [artefactToBuy, setArtefactToBuy] = useState<SuperExtendedArtefact | null>(null); const [artefactToBuy, setArtefactToBuy] = useState<ExtendedArtefact | null>(null);
const [cartCheckout, setCartCheckout] = useState(false); // true = checkout cart (not single artefact)
const [showThankYouModal, setShowThankYouModal] = useState(false); const [showThankYouModal, setShowThankYouModal] = useState(false);
const [orderNumber, setOrderNumber] = useState<string | null>(null); const [orderNumber, setOrderNumber] = useState<string | null>(null);
@ -74,7 +73,7 @@ export default function Shop() {
if (currentPage > 1) setCurrentPage((prev) => prev - 1); if (currentPage > 1) setCurrentPage((prev) => prev - 1);
}; };
function ArtefactCard({ artefact }: { artefact: SuperExtendedArtefact }) { function ArtefactCard({ artefact }: { artefact: ExtendedArtefact }) {
return ( return (
<div <div
className="flex flex-col bg-white shadow-md rounded-md overflow-hidden cursor-pointer hover:scale-105 transition-transform" className="flex flex-col bg-white shadow-md rounded-md overflow-hidden cursor-pointer hover:scale-105 transition-transform"
@ -84,7 +83,7 @@ export default function Shop() {
<div className="p-4"> <div className="p-4">
<h3 className="text-lg font-semibold">{artefact.name}</h3> <h3 className="text-lg font-semibold">{artefact.name}</h3>
<p className="text-neutral-500 mb-2">{artefact.location}</p> <p className="text-neutral-500 mb-2">{artefact.location}</p>
<p className="text-neutral-500 mb-2">{artefact.earthquakeCode}</p> <p className="text-neutral-500 mb-2">{artefact.earthquakeID}</p>
<p className="text-black font-bold text-md mt-2"> <p className="text-black font-bold text-md mt-2">
{currencyTickers[selectedCurrency]} {currencyTickers[selectedCurrency]}
{convertPrice(artefact.price, selectedCurrency)} {convertPrice(artefact.price, selectedCurrency)}
@ -93,11 +92,14 @@ export default function Shop() {
</div> </div>
); );
} }
function Modal({ artefact }: { artefact: SuperExtendedArtefact }) {
function Modal({ artefact }: { artefact: ExtendedArtefact }) {
if (!artefact) return null; if (!artefact) return null;
const handleOverlayClick = (e: React.MouseEvent) => { const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) setSelectedArtefact(null); if (e.target === e.currentTarget) setSelectedArtefact(null);
}; };
const inCart = cart.some((a) => a.id === artefact.id);
return ( return (
<div <div
className="fixed inset-0 bg-neutral-900 bg-opacity-50 flex justify-center items-center z-50" className="fixed inset-0 bg-neutral-900 bg-opacity-50 flex justify-center items-center z-50"
@ -118,19 +120,35 @@ export default function Shop() {
</p> </p>
<p className="text-neutral-600 mt-2">{artefact.description}</p> <p className="text-neutral-600 mt-2">{artefact.description}</p>
<p className="text-neutral-500 font-bold mt-1">Location: {artefact.location}</p> <p className="text-neutral-500 font-bold mt-1">Location: {artefact.location}</p>
<p className="text-neutral-500 mb-2">{artefact.earthquakeCode}</p> <p className="text-neutral-500 mb-2">{artefact.earthquakeID}</p>
<p className="text-neutral-500 mb-2">{artefact.type}</p> <p className="text-neutral-500 mb-2">{artefact.observatory}</p>
<p className="text-neutral-500 mb-2">{artefact.dateReleased}</p> <p className="text-neutral-500 mb-2">{artefact.dateReleased}</p>
<div className="flex justify-end gap-4 mt-4 mr-2"> <div className="flex flex-col sm:flex-row justify-end gap-4 mt-4 mr-2">
<button <button
onClick={() => { onClick={() => {
setArtefactToBuy(artefact); // Set artefact for payment modal if (!inCart) setCart((cart) => [...cart, artefact]);
setShowPaymentModal(true); // Show payment modal
setSelectedArtefact(null); // Close this modal
}} }}
className="px-10 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" disabled={inCart}
className={`px-6 py-2 rounded-md font-bold border
${
inCart
? "bg-gray-300 text-gray-400 cursor-not-allowed"
: "bg-green-500 hover:bg-green-600 text-white"
}
`}
> >
Buy {inCart ? "In Cart" : "Add to Cart"}
</button>
<button
onClick={() => {
setArtefactToBuy(artefact);
setShowPaymentModal(true);
setCartCheckout(false);
setSelectedArtefact(null);
}}
className="px-8 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Buy Now
</button> </button>
</div> </div>
</div> </div>
@ -138,15 +156,99 @@ export default function Shop() {
); );
} }
function PaymentModal({ artefact, onClose }: { artefact: SuperExtendedArtefact; onClose: () => void }) { function CartModal() {
const total = cart.reduce((sum, art) => sum + art.price, 0);
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) setShowCartModal(false);
};
return (
<div
className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-[999]"
onClick={handleOverlayClick}
>
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">Your Cart</h2>
<button onClick={() => setShowCartModal(false)} className="text-xl font-bold px-2 py-1 rounded">
</button>
</div>
{cart.length === 0 ? (
<p className="text-neutral-500">Your cart is empty.</p>
) : (
<>
<ul className="mb-4">
{cart.map((art) => (
<li key={art.id} className="flex items-center border-b py-2">
<div className="flex-shrink-0 mr-3">
<Image src={art.image} alt={art.name} width={60} height={40} className="rounded" />
</div>
<div className="flex-grow">
<p className="font-bold">{art.name}</p>
<p className="text-neutral-500 text-sm">{art.location}</p>
</div>
<p className="font-bold mr-2">
{currencyTickers[selectedCurrency]}
{convertPrice(art.price, selectedCurrency)}
</p>
<button
className="px-3 py-1 bg-red-400 hover:bg-red-500 text-white rounded"
onClick={() => setCart((c) => c.filter((a) => a.id !== art.id))}
>
Remove
</button>
</li>
))}
</ul>
<div className="flex justify-between items-center mb-2">
<span className="font-bold">Total:</span>
<span className="text-lg font-bold">
{currencyTickers[selectedCurrency]}
{convertPrice(total, selectedCurrency)}
</span>
</div>
<div className="text-right">
<button
className="px-8 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
onClick={() => {
setShowCartModal(false);
setArtefactToBuy(null);
setShowPaymentModal(true);
setCartCheckout(true);
}}
>
Checkout
</button>
</div>
</>
)}
</div>
</div>
);
}
function PaymentModal({
artefact,
onClose,
cartItems,
}: {
artefact?: ExtendedArtefact;
onClose: () => void;
cartItems?: ExtendedArtefact[];
}) {
const [cardNumber, setCardNumber] = useState(""); const [cardNumber, setCardNumber] = useState("");
const [expiry, setExpiry] = useState(""); const [expiry, setExpiry] = useState("");
const [cvc, setCvc] = useState(""); const [cvc, setCvc] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState(user?.email || "");
const [remember, setRemember] = useState(false); const [remember, setRemember] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const artefactsToBuy = artefact ? [artefact] : cartItems || [];
const total = artefactsToBuy.reduce((sum, art) => sum + art.price, 0);
function validateEmail(email: string) { function validateEmail(email: string) {
return ( return (
email.includes("@") && email.includes("@") &&
@ -162,18 +264,13 @@ export default function Shop() {
function validateExpiry(exp: string) { function validateExpiry(exp: string) {
return /^\d{2}\/\d{2}$/.test(exp); return /^\d{2}\/\d{2}$/.test(exp);
} }
function handlePay() { function handlePay() {
setError(""); setError("");
if (email || user?.email) { const paymentEmail = user?.email || email;
if (!validateEmail(email)) { if (!validateEmail(paymentEmail)) {
setError("Please enter a valid email ending"); setError("Please enter a valid email");
return;
}
} else {
return; return;
} }
if (!validateCardNumber(cardNumber)) { if (!validateCardNumber(cardNumber)) {
setError("Card number must be 12-19 digits."); setError("Card number must be 12-19 digits.");
return; return;
@ -186,44 +283,51 @@ export default function Shop() {
setError("CVC must be 3 or 4 digits."); setError("CVC must be 3 or 4 digits.");
return; return;
} }
// remove all artefacts that were bought (works for both cart and single)
setHiddenArtefactIds((ids) => [...ids, artefact.id]); setHiddenArtefactIds((ids) => [...ids, ...artefactsToBuy.map((a) => a.id)]);
// todo create receiving api route
// todo!! create receiving api route // todo handle sending to api route
// todo!! handle sending to api route
const genOrder = () => "#" + Math.random().toString(36).substring(2, 10).toUpperCase(); const genOrder = () => "#" + Math.random().toString(36).substring(2, 10).toUpperCase();
setOrderNumber(genOrder()); setOrderNumber(genOrder());
onClose(); onClose();
setShowThankYouModal(true); setShowThankYouModal(true);
setCart((c) => c.filter((a) => !artefactsToBuy.map((x) => x.id).includes(a.id)));
} }
const handleOverlayClick = (e: React.MouseEvent) => { const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}; };
return ( return (
<div <div
className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-10" className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-[12000]"
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6"> <div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold mb-4">Buy {artefact.name}</h2> <h2 className="text-2xl font-bold mb-4">
{/* ...Image... */} Checkout {artefact ? artefact.name : artefactsToBuy.length + " item(s)"}
{!artefact && <span className="ml-1">({artefactsToBuy.map((x) => x.name).join(", ")})</span>}
</h2>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
handlePay(); handlePay();
}} }}
> >
{!user ? ( {/* Email autofill */}
<input <input
className="w-full mb-2 px-3 py-2 border rounded" className="w-full mb-2 px-3 py-2 border rounded"
placeholder="Email Address" placeholder="Email Address"
value={email} value={user?.email ? user.email : email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
type="email" type="email"
required required
autoFocus autoFocus
/> disabled={!!user?.email}
) : null} />
{user?.email && (
<p className="text-sm text-gray-500 mb-2">
Signed in as <span className="font-bold">{user.email}</span>
</p>
)}
<input <input
className="w-full mb-2 px-3 py-2 border rounded" className="w-full mb-2 px-3 py-2 border rounded"
placeholder="Cardholder Name" placeholder="Cardholder Name"
@ -266,6 +370,13 @@ export default function Shop() {
Remember me Remember me
</label> </label>
{error && <p className="text-red-600 mb-2">{error}</p>} {error && <p className="text-red-600 mb-2">{error}</p>}
<div className="flex justify-between items-center mb-2">
<span className="font-bold">Total:</span>
<span className="text-lg font-bold">
{currencyTickers[selectedCurrency]}
{convertPrice(total, selectedCurrency)}
</span>
</div>
<div className="flex justify-end gap-2 mt-2"> <div className="flex justify-end gap-2 mt-2">
<button type="button" onClick={onClose} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md mr-2"> <button type="button" onClick={onClose} className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md mr-2">
Cancel Cancel
@ -310,6 +421,28 @@ export default function Shop() {
}} }}
> >
<div className="absolute inset-0 bg-black bg-opacity-0 z-0"></div> <div className="absolute inset-0 bg-black bg-opacity-0 z-0"></div>
{/* --- Cart Button fixed at top right --- */}
<button
className="absolute top-6 right-6 z-[11000] bg-white border border-blue-500 shadow-lg rounded-full p-3 hover:bg-blue-100 flex flex-row items-center"
onClick={() => setShowCartModal(true)}
aria-label="Open your cart"
>
<span className="mr-2 font-bold">{cart.length || ""}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-7 h-7 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13l-1.35 2.7a1 1 0 00.9 1.45h12.2M7 13l1.2-2.4M3 3l.01 0"
/>
</svg>
</button>
<div className="relative z-10 flex flex-col items-center w-full px-2 py-12"> <div className="relative z-10 flex flex-col items-center w-full px-2 py-12">
<h1 className="text-4xl md:text-4xl font-bold text-center text-blue-300 mb-2 tracking-tight drop-shadow-lg"> <h1 className="text-4xl md:text-4xl font-bold text-center text-blue-300 mb-2 tracking-tight drop-shadow-lg">
Artefact Shop Artefact Shop
@ -320,7 +453,7 @@ export default function Shop() {
</p> </p>
<div className="w-full max-w-7xl grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10 p-2"> <div className="w-full max-w-7xl grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10 p-2">
{currentArtefacts {currentArtefacts
.filter((x) => !hiddenArtefactIds.includes(x.id) && x.isRequired === false) .filter((x) => !hiddenArtefactIds.includes(x.id))
.map((artefact) => ( .map((artefact) => (
<ArtefactCard key={artefact.id} artefact={artefact} /> <ArtefactCard key={artefact.id} artefact={artefact} />
))} ))}
@ -348,19 +481,22 @@ export default function Shop() {
</footer> </footer>
</div> </div>
{selectedArtefact && <Modal artefact={selectedArtefact} />} {selectedArtefact && <Modal artefact={selectedArtefact} />}
{artefactToBuy && showPaymentModal && ( {showCartModal && <CartModal />}
{showPaymentModal && (cartCheckout || artefactToBuy) && (
<PaymentModal <PaymentModal
artefact={artefactToBuy} artefact={cartCheckout ? undefined : artefactToBuy!}
cartItems={cartCheckout ? cart : undefined}
onClose={() => { onClose={() => {
setShowPaymentModal(false); setShowPaymentModal(false);
setArtefactToBuy(null); setArtefactToBuy(null);
setCartCheckout(false);
}} }}
/> />
)} )}
{showThankYouModal && orderNumber && ( {showThankYouModal && orderNumber && (
<ThankYouModal orderNumber={orderNumber} onClose={() => setShowThankYouModal(false)} /> <ThankYouModal orderNumber={orderNumber} onClose={() => setShowThankYouModal(false)} />
)} )}
{!selectedArtefact && !showPaymentModal && !showThankYouModal && ( {!selectedArtefact && !showPaymentModal && !showThankYouModal && !showCartModal && (
<div className="relative z-50"> <div className="relative z-50">
<BottomFooter /> <BottomFooter />
</div> </div>

View File

@ -95,7 +95,6 @@ function LogModal({ onClose }: { onClose: () => void }) {
} }
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// todo!! add log api route
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulated API call await new Promise((resolve) => setTimeout(resolve, 500)); // Simulated API call
alert(`Logged ${name} to storage: ${storageLocation}`); alert(`Logged ${name} to storage: ${storageLocation}`);
onClose(); onClose();

View File

@ -1,245 +1,248 @@
"use client"; "use client";
import { FormEvent, useState, useRef } from "react"; import { useState } from "react";
import DatePicker from "react-datepicker"; import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css"; import "react-datepicker/dist/react-datepicker.css";
const typeOptions = [ const typeOptions = [
{ value: "volcanic", label: "Volcanic" }, { value: "volcanic", label: "Volcanic" },
{ value: "tectonic", label: "Tectonic" }, { value: "tectonic", label: "Tectonic" },
{ value: "collapse", label: "Collapse" }, { value: "collapse", label: "Collapse" },
{ value: "explosion", label: "Explosion" }, { value: "explosion", label: "Explosion" }
]; ];
export default function EarthquakeLogModal({ export default function EarthquakeLogModal({ open, onClose, onSuccess }) {
open, const [date, setDate] = useState<Date | null>(new Date());
onClose, const [magnitude, setMagnitude] = useState("");
onSuccess, const [type, setType] = useState(typeOptions[0].value);
}: { const [city, setCity] = useState("");
open: boolean; const [country, setCountry] = useState("");
onClose: () => void; const [latitude, setLatitude] = useState("");
onSuccess: () => void; const [longitude, setLongitude] = useState("");
}) { const [depth, setDepth] = useState("");
const [date, setDate] = useState<Date | null>(new Date()); const [loading, setLoading] = useState(false);
const [magnitude, setMagnitude] = useState(""); const [successCode, setSuccessCode] = useState<string | null>(null);
const [type, setType] = useState(typeOptions[0].value);
const [city, setCity] = useState("");
const [country, setCountry] = useState("");
const [latitude, setLatitude] = useState("");
const [longitude, setLongitude] = useState("");
const [depth, setDepth] = useState("");
const [loading, setLoading] = useState(false);
const [successCode, setSuccessCode] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
async function handleLatLonChange(lat: string, lon: string) { async function handleLatLonChange(lat: string, lon: string) {
setLatitude(lat); setLatitude(lat);
setLongitude(lon); setLongitude(lon);
if (/^-?\d+(\.\d+)?$/.test(lat) && /^-?\d+(\.\d+)?$/.test(lon)) { if (/^-?\d+(\.\d+)?$/.test(lat) && /^-?\d+(\.\d+)?$/.test(lon)) {
try { try {
const resp = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`); const resp = await fetch(
if (resp.ok) { `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`
const data = await resp.json(); );
setCity( if (resp.ok) {
data.address.city || const data = await resp.json();
data.address.town || setCity(
data.address.village || data.address.city ||
data.address.hamlet || data.address.town ||
data.address.county || data.address.village ||
data.address.state || data.address.hamlet ||
"" data.address.county ||
); data.address.state ||
setCountry(data.address.country || ""); ""
} );
} catch (e) { setCountry(data.address.country || "");
// ignore }
} } catch (e) {
} // ignore
} }
}
}
async function handleSubmit(e: FormEvent) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
if (!date || !magnitude || !type || !city || !country || !latitude || !longitude || !depth) { if (!date || !magnitude || !type || !city || !country || !latitude || !longitude || !depth) {
alert("Please complete all fields."); alert("Please complete all fields.");
setLoading(false); setLoading(false);
return; return;
} }
try { try {
const res = await fetch("/api/earthquakes/log", { const res = await fetch("/api/earthquakes/log", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
date, date,
magnitude: parseFloat(magnitude), magnitude: parseFloat(magnitude),
type, type,
location: `${city.trim()}, ${country.trim()}`, location: `${city.trim()}, ${country.trim()}`,
country: country.trim(), country: country.trim(),
latitude: parseFloat(latitude), latitude: parseFloat(latitude),
longitude: parseFloat(longitude), longitude: parseFloat(longitude),
depth, depth
}), })
}); });
if (res.ok) { if (res.ok) {
const result = await res.json(); const result = await res.json();
setSuccessCode(result.code); setSuccessCode(result.code);
setLoading(false); setLoading(false);
if (onSuccess) onSuccess(); if (onSuccess) onSuccess();
} else { } else {
const err = await res.json(); const err = await res.json();
alert("Failed to log earthquake! " + (err.error || "")); alert("Failed to log earthquake! " + (err.error || ""));
setLoading(false); setLoading(false);
} }
} catch (e: any) { } catch (e: any) {
alert("Failed to log. " + e.message); alert("Failed to log. " + e.message);
setLoading(false); setLoading(false);
} }
} }
function handleOutsideClick(e: React.MouseEvent<HTMLDivElement>) { if (!open) return null;
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
}
if (!open) return null; // Success popup overlay
if (successCode) {
return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-lg px-8 py-8 max-w-md w-full relative shadow-lg">
<button
onClick={() => {
setSuccessCode(null);
onClose();
}}
className="absolute right-4 top-4 text-xl text-gray-400 hover:text-gray-700"
aria-label="Close"
>
&times;
</button>
<div className="text-center">
<h2 className="text-xl font-semibold mb-3">
Thank you for logging an earthquake!
</h2>
<div className="mb-0">The Earthquake Identifier is</div>
<div className="font-mono text-lg mb-4 bg-slate-100 rounded px-2 py-1 inline-block text-blue-800">{successCode}</div>
</div>
</div>
</div>
);
}
// Success popup overlay return (
if (successCode) { <div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
return ( <div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative">
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center" onClick={handleOutsideClick}> <button
<div className="bg-white rounded-lg px-8 py-8 max-w-md w-full relative shadow-lg" ref={modalRef}> onClick={onClose}
<button className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
onClick={() => { >
setSuccessCode(null); &times;
onClose(); </button>
}} <h2 className="font-bold text-xl mb-4">Log Earthquake</h2>
className="absolute right-4 top-4 text-xl text-gray-400 hover:text-gray-700" <form onSubmit={handleSubmit} className="space-y-3">
aria-label="Close" <div>
> <label className="block text-sm font-medium">Date</label>
× <DatePicker
</button> selected={date}
<div className="text-center"> onChange={date => setDate(date)}
<h2 className="text-xl font-semibold mb-3">Thank you for logging an earthquake!</h2> className="border rounded px-3 py-2 w-full"
<div className="mb-0">The Earthquake Identifier is</div> dateFormat="yyyy-MM-dd"
<div className="font-mono text-lg mb-4 bg-slate-100 rounded px-2 py-1 inline-block text-blue-800">{successCode}</div> maxDate={new Date()}
</div> showMonthDropdown
</div> showYearDropdown
</div> dropdownMode="select"
); required
} />
</div>
return ( <div>
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center" onClick={handleOutsideClick}> <label className="block text-sm font-medium">Magnitude</label>
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full relative" ref={modalRef}> <input
<button onClick={onClose} className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"> type="number"
× className="border rounded px-3 py-2 w-full"
</button> min="0"
<h2 className="font-bold text-xl mb-4">Log Earthquake</h2> max="10"
<form onSubmit={handleSubmit} className="space-y-3"> step="0.1"
<div> value={magnitude}
<label className="block text-sm font-medium">Date</label> onChange={e => {
<DatePicker const val = e.target.value;
selected={date} if (parseFloat(val) > 10) return;
onChange={(date) => setDate(date)} setMagnitude(val);
className="border rounded px-3 py-2 w-full" }}
dateFormat="yyyy-MM-dd" required
maxDate={new Date()} />
showMonthDropdown </div>
showYearDropdown <div>
dropdownMode="select" <label className="block text-sm font-medium">Type</label>
required <select
/> className="border rounded px-3 py-2 w-full"
</div> value={type}
<div> onChange={e => setType(e.target.value)}
<label className="block text-sm font-medium">Magnitude</label> required
<input >
type="number" {typeOptions.map(opt => (
className="border rounded px-3 py-2 w-full" <option key={opt.value} value={opt.value}>
min="0" {opt.label}
max="10" </option>
step="0.1" ))}
value={magnitude} </select>
onChange={(e) => { </div>
const val = e.target.value; <div>
if (parseFloat(val) > 10) return; <label className="block text-sm font-medium">City/Area</label>
setMagnitude(val); <span className="block text-xs text-gray-400">
}} (Use Lat/Lon then press Enter for reverse lookup)
required </span>
/> <input
</div> type="text"
<div> className="border rounded px-3 py-2 w-full"
<label className="block text-sm font-medium">Type</label> value={city}
<select className="border rounded px-3 py-2 w-full" value={type} onChange={(e) => setType(e.target.value)} required> onChange={e => setCity(e.target.value)}
{typeOptions.map((opt) => ( required
<option key={opt.value} value={opt.value}> />
{opt.label} </div>
</option> <div>
))} <label className="block text-sm font-medium">Country</label>
</select> <input
</div> type="text"
<div> className="border rounded px-3 py-2 w-full"
<label className="block text-sm font-medium">City/Area</label> value={country}
<span className="block text-xs text-gray-400">(Use Lat/Lon then press Enter for reverse lookup)</span> onChange={e => setCountry(e.target.value)}
<input required
type="text" />
className="border rounded px-3 py-2 w-full" </div>
value={city} <div className="flex space-x-2">
onChange={(e) => setCity(e.target.value)} <div className="flex-1">
required <label className="block text-sm font-medium">Latitude</label>
/> <input
</div> type="number"
<div> className="border rounded px-3 py-2 w-full"
<label className="block text-sm font-medium">Country</label> value={latitude}
<input onChange={e => handleLatLonChange(e.target.value, longitude)}
type="text" placeholder="e.g. 36.12"
className="border rounded px-3 py-2 w-full" step="any"
value={country} required
onChange={(e) => setCountry(e.target.value)} />
required </div>
/> <div className="flex-1">
</div> <label className="block text-sm font-medium">Longitude</label>
<div className="flex space-x-2"> <input
<div className="flex-1"> type="number"
<label className="block text-sm font-medium">Latitude</label> className="border rounded px-3 py-2 w-full"
<input value={longitude}
type="number" onChange={e => handleLatLonChange(latitude, e.target.value)}
className="border rounded px-3 py-2 w-full" placeholder="e.g. -115.17"
value={latitude} step="any"
onChange={(e) => handleLatLonChange(e.target.value, longitude)} required
placeholder="e.g. 36.12" />
step="any" </div>
required </div>
/> <div>
</div> <label className="block text-sm font-medium">Depth</label>
<div className="flex-1"> <input
<label className="block text-sm font-medium">Longitude</label> type="text"
<input className="border rounded px-3 py-2 w-full"
type="number" value={depth}
className="border rounded px-3 py-2 w-full" onChange={e => setDepth(e.target.value)}
value={longitude} placeholder="e.g. 10 km"
onChange={(e) => handleLatLonChange(latitude, e.target.value)} required
placeholder="e.g. -115.17" />
step="any" </div>
required <button
/> type="submit"
</div> className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 w-full"
</div> disabled={loading}
<div> >
<label className="block text-sm font-medium">Depth</label> {loading ? "Logging..." : "Log Earthquake"}
<input </button>
type="text" </form>
className="border rounded px-3 py-2 w-full" </div>
value={depth} </div>
onChange={(e) => setDepth(e.target.value)} );
placeholder="e.g. 10 km" }
required
/>
</div>
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 w-full" disabled={loading}>
{loading ? "Logging..." : "Log Earthquake"}
</button>
</form>
</div>
</div>
);
}

View File

@ -3,233 +3,243 @@ import { useState, useEffect, useMemo } from "react";
import axios from "axios"; import axios from "axios";
export type Earthquake = { export type Earthquake = {
id: string; id: string;
code: string; code: string;
magnitude: number; magnitude: number;
location: string; location: string;
date: string; date: string;
longitude: number; longitude: number;
latitude: number; latitude: number;
}; };
function formatDate(iso: string) { function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(); return new Date(iso).toLocaleDateString();
} }
const COLUMNS = [ const COLUMNS = [
{ label: "Code", key: "code", className: "font-mono font-bold" }, { label: "Code", key: "code", className: "font-mono font-bold" },
{ label: "Location", key: "location" }, { label: "Location", key: "location" },
{ label: "Magnitude", key: "magnitude", numeric: true }, { label: "Magnitude", key: "magnitude", numeric: true },
{ label: "Date", key: "date" }, { label: "Date", key: "date" },
]; ];
// todo modify slightly
export default function EarthquakeSearchModal({ export default function EarthquakeSearchModal({
open, open,
onClose, onClose,
onSelect, onSelect,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSelect: (eq: Earthquake) => void; onSelect: (eq: Earthquake) => void;
}) { }) {
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
const [results, setResults] = useState<Earthquake[]>([]); const [results, setResults] = useState<Earthquake[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
// Filters per column // Filters per column
const [filters, setFilters] = useState<{ [k: string]: string }>({ const [filters, setFilters] = useState<{ [k: string]: string }>({
code: "", code: "",
location: "", location: "",
magnitude: "", magnitude: "",
date: "", date: "",
}); });
// Sort state // Sort state
const [sort, setSort] = useState<{ key: keyof Earthquake; dir: "asc" | "desc" } | null>(null); const [sort, setSort] = useState<{ key: keyof Earthquake; dir: "asc" | "desc" } | null>(null);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setSearch(""); setSearch("");
setResults([]); setResults([]);
setFilters({ code: "", location: "", magnitude: "", date: "" }); setFilters({ code: "", location: "", magnitude: "", date: "" });
setError(""); setError("");
setSort(null); setSort(null);
} }
}, [open]); }, [open]);
const doSearch = async (q = search) => { const doSearch = async (q = search) => {
setLoading(true); setLoading(true);
setResults([]); setResults([]);
setError(""); setError("");
try { try {
const resp = await axios.post("/api/earthquakes/search", { query: q }); const resp = await axios.post("/api/earthquakes/search", { query: q });
setResults(resp.data.earthquakes || []); setResults(resp.data.earthquakes || []);
} catch (e: any) { } catch (e: any) {
setError("Failed to search earthquakes."); setError("Failed to search earthquakes.");
} }
setLoading(false); setLoading(false);
}; };
// Filter logic // Filter logic
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
return results.filter( return results.filter((row) =>
(row) => (!filters.code ||
(!filters.code || row.code.toLowerCase().includes(filters.code.toLowerCase())) && row.code.toLowerCase().includes(filters.code.toLowerCase())) &&
(!filters.location || (row.location || "").toLowerCase().includes(filters.location.toLowerCase())) && (!filters.location ||
(!filters.magnitude || String(row.magnitude).startsWith(filters.magnitude)) && (row.location || "").toLowerCase().includes(filters.location.toLowerCase())) &&
(!filters.date || row.date.slice(0, 10) === filters.date) (!filters.magnitude ||
); String(row.magnitude).startsWith(filters.magnitude)) &&
}, [results, filters]); (!filters.date ||
row.date.slice(0, 10) === filters.date)
);
}, [results, filters]);
// Sort logic // Sort logic
const sortedRows = useMemo(() => { const sortedRows = useMemo(() => {
if (!sort) return filteredRows; if (!sort) return filteredRows;
const sorted = [...filteredRows].sort((a, b) => { const sorted = [...filteredRows].sort((a, b) => {
let valA = a[sort.key]; let valA = a[sort.key];
let valB = b[sort.key]; let valB = b[sort.key];
if (sort.key === "magnitude") { if (sort.key === "magnitude") {
valA = Number(valA); valA = Number(valA);
valB = Number(valB); valB = Number(valB);
} else if (sort.key === "date") { } else if (sort.key === "date") {
valA = a.date; valA = a.date;
valB = b.date; valB = b.date;
} else { } else {
valA = String(valA || ""); valA = String(valA || "");
valB = String(valB || ""); valB = String(valB || "");
} }
if (valA < valB) return sort.dir === "asc" ? -1 : 1; if (valA < valB) return sort.dir === "asc" ? -1 : 1;
if (valA > valB) return sort.dir === "asc" ? 1 : -1; if (valA > valB) return sort.dir === "asc" ? 1 : -1;
return 0; return 0;
}); });
return sorted; return sorted;
}, [filteredRows, sort]); }, [filteredRows, sort]);
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center animate-fadein"> <div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center animate-fadein">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl relative"> <div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl relative">
<button onClick={onClose} className="absolute right-4 top-4 text-2xl text-gray-400 hover:text-red-500 font-bold"> <button
&times; onClick={onClose}
</button> className="absolute right-4 top-4 text-2xl text-gray-400 hover:text-red-500 font-bold"
<h2 className="font-bold text-xl mb-4">Search Earthquakes</h2> >&times;</button>
<form <h2 className="font-bold text-xl mb-4">Search Earthquakes</h2>
onSubmit={(e) => { <form
e.preventDefault(); onSubmit={(e) => {
doSearch(); e.preventDefault();
}} doSearch();
className="flex gap-2 mb-3" }}
> className="flex gap-2 mb-3"
<input >
type="text" <input
placeholder="e.g. Mexico, EV-7.4-Mexico-00035" type="text"
value={search} placeholder="e.g. Mexico, EV-7.4-Mexico-00035"
onChange={(e) => setSearch(e.target.value)} value={search}
className="flex-grow px-3 py-2 border rounded" onChange={(e) => setSearch(e.target.value)}
disabled={loading} className="flex-grow px-3 py-2 border rounded"
/> disabled={loading}
<button />
type="submit" <button
disabled={loading} type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 min-w-[6.5rem] flex items-center justify-center" disabled={loading}
> className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 min-w-[6.5rem] flex items-center justify-center"
{loading ? ( >
<> {loading ? (
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24"> <>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
</svg> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
Search... </svg>
</> Search...
) : ( </>
<>Search</> ) : (
)} <>Search</>
</button> )}
<button </button>
type="button" <button
onClick={() => { type="button"
setSearch(""); onClick={() => {
setResults([]); setSearch("");
setFilters({ code: "", location: "", magnitude: "", date: "" }); setResults([]);
}} setFilters({ code: "", location: "", magnitude: "", date: "" });
className="bg-gray-100 text-gray-600 px-3 py-2 rounded hover:bg-gray-200" }}
> className="bg-gray-100 text-gray-600 px-3 py-2 rounded hover:bg-gray-200"
Clear >
</button> Clear
</form> </button>
{error && <div className="text-red-600 font-medium mb-2">{error}</div>} </form>
{/* Filter Row */} {error && (
<div className="mb-2"> <div className="text-red-600 font-medium mb-2">{error}</div>
<div className="flex gap-3"> )}
{COLUMNS.map((col) => ( {/* Filter Row */}
<input <div className="mb-2">
key={col.key} <div className="flex gap-3">
type={col.key === "magnitude" ? "number" : col.key === "date" ? "date" : "text"} {COLUMNS.map((col) => (
value={filters[col.key] || ""} <input
onChange={(e) => setFilters((f) => ({ ...f, [col.key]: e.target.value }))} key={col.key}
className="border border-neutral-200 rounded px-2 py-1 text-xs" type={col.key === "magnitude" ? "number" : col.key === "date" ? "date" : "text"}
style={{ value={filters[col.key] || ""}
width: col.key === "magnitude" ? 70 : col.key === "date" ? 130 : 120, onChange={e =>
}} setFilters(f => ({ ...f, [col.key]: e.target.value }))
placeholder={`Filter ${col.label}`} }
aria-label={`Filter ${col.label}`} className="border border-neutral-200 rounded px-2 py-1 text-xs"
disabled={loading || results.length === 0} style={{
/> width:
))} col.key === "magnitude"
</div> ? 70
</div> : col.key === "date"
{/* Results Table */} ? 130
<div className="border rounded shadow-inner bg-white max-h-72 overflow-y-auto"> : 120,
<table className="w-full text-sm"> }}
<thead> placeholder={`Filter ${col.label}`}
<tr className="bg-neutral-100 border-b"> aria-label={`Filter ${col.label}`}
{COLUMNS.map((col) => ( disabled={loading || results.length === 0}
<th />
key={col.key} ))}
className={`font-semibold px-3 py-2 cursor-pointer select-none text-left`} </div>
onClick={() => </div>
setSort( {/* Results Table */}
sort && sort.key === col.key <div className="border rounded shadow-inner bg-white max-h-72 overflow-y-auto">
? { key: col.key as keyof Earthquake, dir: sort.dir === "asc" ? "desc" : "asc" } <table className="w-full text-sm">
: { key: col.key as keyof Earthquake, dir: "asc" } <thead>
) <tr className="bg-neutral-100 border-b">
} {COLUMNS.map((col) => (
> <th
{col.label} key={col.key}
{sort?.key === col.key && (sort.dir === "asc" ? " ↑" : " ↓")} className={`font-semibold px-3 py-2 cursor-pointer select-none text-left`}
</th> onClick={() =>
))} setSort(sort && sort.key === col.key
</tr> ? { key: col.key as keyof Earthquake, dir: sort.dir === "asc" ? "desc" : "asc" }
</thead> : { key: col.key as keyof Earthquake, dir: "asc" })
<tbody> }
{sortedRows.length === 0 && !loading && ( >
<tr> {col.label}
<td colSpan={COLUMNS.length} className="p-3 text-center text-gray-400"> {sort?.key === col.key &&
No results found. (sort.dir === "asc" ? " ↑" : " ↓")}
</td> </th>
</tr> ))}
)} </tr>
{sortedRows.map((eq) => ( </thead>
<tr <tbody>
key={eq.id} {sortedRows.length === 0 && !loading && (
className="hover:bg-blue-50 cursor-pointer border-b" <tr>
onClick={() => { <td colSpan={COLUMNS.length} className="p-3 text-center text-gray-400">
onSelect(eq); No results found.
onClose(); </td>
}} </tr>
tabIndex={0} )}
> {sortedRows.map(eq => (
<td className="px-3 py-2 font-mono">{eq.code}</td> <tr
<td className="px-3 py-2">{eq.location}</td> key={eq.id}
<td className="px-3 py-2 font-bold">{eq.magnitude}</td> className="hover:bg-blue-50 cursor-pointer border-b"
<td className="px-3 py-2">{formatDate(eq.date)}</td> onClick={() => {
</tr> onSelect(eq);
))} onClose();
</tbody> }}
</table> tabIndex={0}
</div> >
</div> <td className="px-3 py-2 font-mono">{eq.code}</td>
</div> <td className="px-3 py-2">{eq.location}</td>
); <td className="px-3 py-2 font-bold">{eq.magnitude}</td>
} <td className="px-3 py-2">{formatDate(eq.date)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}