Compare commits
No commits in common. "dd650c6ba69252f3a0898fe68d63bb380d7ce243" and "4bca956cbb4fe34a1ba575845bab82893248bf1e" have entirely different histories.
dd650c6ba6
...
4bca956cbb
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: {
|
||||||
|
|||||||
@ -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">
|
||||||
|
×
|
||||||
|
</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">
|
|
||||||
×
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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);
|
×
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
×
|
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>
|
>×</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user