507 lines
17 KiB
TypeScript
Raw Normal View History

2025-04-13 22:38:33 +01:00
"use client";
2025-05-12 21:38:46 +01:00
import Image from "next/image";
2025-05-31 21:58:05 +01:00
import { useCallback, useEffect, useState } from "react";
2025-04-28 19:03:29 +01:00
import { ExtendedArtefact } from "@appTypes/ApiTypes";
2025-05-12 21:38:46 +01:00
import { Currency } from "@appTypes/StoreModel";
2025-05-30 14:05:56 +01:00
import BottomFooter from "@components/BottomFooter";
2025-05-12 21:38:46 +01:00
import { useStoreState } from "@hooks/store";
2025-04-13 22:38:33 +01:00
2025-03-17 13:21:02 +00:00
export default function Shop() {
2025-05-30 14:05:56 +01:00
const [artefacts, setArtefacts] = useState<ExtendedArtefact[]>([]);
const [hiddenArtefactIds, setHiddenArtefactIds] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
2025-05-31 21:58:05 +01:00
const [cart, setCart] = useState<ExtendedArtefact[]>([]);
const [showCartModal, setShowCartModal] = useState(false);
2025-05-30 14:05:56 +01:00
const user = useStoreState((state) => state.user);
2025-05-27 13:48:32 +01:00
2025-05-30 14:05:56 +01:00
useEffect(() => {
async function fetchArtefacts() {
setLoading(true);
try {
const res = await fetch("/api/artefacts");
const data = await res.json();
const transformed = data.artefact.map((a: any) => ({
id: a.id,
name: a.name,
description: a.description,
2025-05-31 21:58:05 +01:00
location: a.warehouseArea,
2025-05-30 14:05:56 +01:00
earthquakeID: a.earthquakeId?.toString() ?? "",
2025-05-31 21:58:05 +01:00
observatory: a.type ?? "",
2025-05-30 14:05:56 +01:00
dateReleased: a.createdAt ? new Date(a.createdAt).toLocaleDateString() : "",
image: "/artefactImages/" + (a.imageName || "NoImageFound.PNG"),
2025-05-31 21:58:05 +01:00
price: a.shopPrice ?? 100,
2025-05-30 14:05:56 +01:00
}));
setArtefacts(transformed);
} catch (e) {
console.error("Failed to fetch artefacts", e);
} finally {
setLoading(false);
}
}
fetchArtefacts();
}, []);
2025-05-27 13:48:32 +01:00
2025-05-12 21:38:46 +01:00
const [currentPage, setCurrentPage] = useState(1);
2025-05-31 21:58:05 +01:00
const [selectedArtefact, setSelectedArtefact] = useState<ExtendedArtefact | null>(null);
2025-05-19 13:32:20 +01:00
const [showPaymentModal, setShowPaymentModal] = useState(false);
2025-05-31 21:58:05 +01:00
const [artefactToBuy, setArtefactToBuy] = useState<ExtendedArtefact | null>(null);
const [cartCheckout, setCartCheckout] = useState(false); // true = checkout cart (not single artefact)
2025-05-19 13:32:20 +01:00
const [showThankYouModal, setShowThankYouModal] = useState(false);
const [orderNumber, setOrderNumber] = useState<string | null>(null);
2025-05-13 10:00:09 +01:00
const artefactsPerPage = 12;
const indexOfLastArtefact = currentPage * artefactsPerPage;
const indexOfFirstArtefact = indexOfLastArtefact - artefactsPerPage;
const currentArtefacts = artefacts.slice(indexOfFirstArtefact, indexOfLastArtefact);
2025-04-13 22:38:33 +01:00
2025-05-12 21:38:46 +01:00
const selectedCurrency = useStoreState((state) => state.currency.selectedCurrency);
const conversionRates = useStoreState((state) => state.currency.conversionRates);
const currencyTickers = useStoreState((state) => state.currency.tickers);
2025-04-13 22:38:33 +01:00
2025-05-19 13:32:20 +01:00
const convertPrice = useCallback(
(price: number, currency: Currency) => (price * conversionRates[currency]).toFixed(2),
[conversionRates]
);
2025-04-14 13:50:13 +01:00
2025-05-12 21:38:46 +01:00
const handleNextPage = () => {
2025-05-19 13:32:20 +01:00
if (indexOfLastArtefact < artefacts.length) setCurrentPage((prev) => prev + 1);
2025-05-12 21:38:46 +01:00
};
const handlePreviousPage = () => {
2025-05-19 13:32:20 +01:00
if (currentPage > 1) setCurrentPage((prev) => prev - 1);
2025-05-12 21:38:46 +01:00
};
2025-04-14 13:50:13 +01:00
2025-05-22 17:59:48 +01:00
function ArtefactCard({ artefact }: { artefact: ExtendedArtefact }) {
2025-05-19 13:32:20 +01:00
return (
<div
className="flex flex-col bg-white shadow-md rounded-md overflow-hidden cursor-pointer hover:scale-105 transition-transform"
onClick={() => setSelectedArtefact(artefact)}
>
<Image src={artefact.image} alt={artefact.name} width={500} height={300} className="w-full h-56 object-cover" />
<div className="p-4">
<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.earthquakeID}</p>
<p className="text-black font-bold text-md mt-2">
{currencyTickers[selectedCurrency]}
{convertPrice(artefact.price, selectedCurrency)}
</p>
</div>
</div>
);
}
2025-05-31 21:58:05 +01:00
2025-05-22 17:59:48 +01:00
function Modal({ artefact }: { artefact: ExtendedArtefact }) {
2025-05-13 10:00:09 +01:00
if (!artefact) return null;
2025-05-19 13:32:20 +01:00
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) setSelectedArtefact(null);
2025-05-12 21:38:46 +01:00
};
2025-05-31 21:58:05 +01:00
const inCart = cart.some((a) => a.id === artefact.id);
2025-05-12 21:38:46 +01:00
return (
<div
className="fixed inset-0 bg-neutral-900 bg-opacity-50 flex justify-center items-center z-50"
onClick={handleOverlayClick}
>
<div className="bg-white rounded-xl shadow-2xl max-w-lg w-full p-6">
2025-05-13 10:00:09 +01:00
<h3 className="text-2xl font-bold mb-4">{artefact.name}</h3>
2025-05-12 21:38:46 +01:00
<Image
2025-05-13 10:00:09 +01:00
src={artefact.image}
alt={artefact.name}
2025-05-19 13:32:20 +01:00
width={500}
height={300}
2025-05-12 21:38:46 +01:00
className="w-full h-64 object-cover rounded-md"
/>
<p className="text-xl font-bold">
{currencyTickers[selectedCurrency]}
2025-05-13 10:00:09 +01:00
{convertPrice(artefact.price, selectedCurrency)}
2025-05-12 21:38:46 +01:00
</p>
2025-05-13 10:00:09 +01:00
<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 mb-2">{artefact.earthquakeID}</p>
<p className="text-neutral-500 mb-2">{artefact.observatory}</p>
<p className="text-neutral-500 mb-2">{artefact.dateReleased}</p>
2025-05-31 21:58:05 +01:00
<div className="flex flex-col sm:flex-row justify-end gap-4 mt-4 mr-2">
<button
onClick={() => {
if (!inCart) setCart((cart) => [...cart, artefact]);
}}
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"
}
`}
>
{inCart ? "In Cart" : "Add to Cart"}
</button>
2025-05-12 21:38:46 +01:00
<button
2025-05-19 13:32:20 +01:00
onClick={() => {
2025-05-31 21:58:05 +01:00
setArtefactToBuy(artefact);
setShowPaymentModal(true);
setCartCheckout(false);
setSelectedArtefact(null);
2025-05-19 13:32:20 +01:00
}}
2025-05-31 21:58:05 +01:00
className="px-8 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
2025-05-12 21:38:46 +01:00
>
2025-05-31 21:58:05 +01:00
Buy Now
2025-05-12 21:38:46 +01:00
</button>
</div>
</div>
</div>
);
}
2025-04-13 22:38:33 +01:00
2025-05-31 21:58:05 +01:00
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[];
}) {
2025-05-19 13:32:20 +01:00
const [cardNumber, setCardNumber] = useState("");
const [expiry, setExpiry] = useState("");
const [cvc, setCvc] = useState("");
const [name, setName] = useState("");
2025-05-31 21:58:05 +01:00
const [email, setEmail] = useState(user?.email || "");
2025-05-19 13:32:20 +01:00
const [remember, setRemember] = useState(false);
const [error, setError] = useState("");
2025-05-31 21:58:05 +01:00
const artefactsToBuy = artefact ? [artefact] : cartItems || [];
const total = artefactsToBuy.reduce((sum, art) => sum + art.price, 0);
2025-05-19 13:32:20 +01:00
function validateEmail(email: string) {
return (
email.includes("@") &&
(email.endsWith(".com") || email.endsWith(".co.uk") || email.endsWith(".org") || email.endsWith(".org.uk"))
);
}
function validateCardNumber(number: string) {
return /^\d{12,19}$/.test(number.replace(/\s/g, "")); // 12-19 digits
}
function validateCVC(number: string) {
return /^\d{3,4}$/.test(number);
}
function validateExpiry(exp: string) {
return /^\d{2}\/\d{2}$/.test(exp);
}
function handlePay() {
setError("");
2025-05-31 21:58:05 +01:00
const paymentEmail = user?.email || email;
if (!validateEmail(paymentEmail)) {
setError("Please enter a valid email");
2025-05-19 13:32:20 +01:00
return;
}
if (!validateCardNumber(cardNumber)) {
setError("Card number must be 12-19 digits.");
return;
}
if (!validateExpiry(expiry)) {
setError("Expiry must be in MM/YY format.");
return;
}
if (!validateCVC(cvc)) {
setError("CVC must be 3 or 4 digits.");
return;
}
2025-05-31 21:58:05 +01:00
// remove all artefacts that were bought (works for both cart and single)
setHiddenArtefactIds((ids) => [...ids, ...artefactsToBuy.map((a) => a.id)]);
2025-05-20 13:53:25 +01:00
// todo create receiving api route
// todo handle sending to api route
2025-05-19 13:32:20 +01:00
const genOrder = () => "#" + Math.random().toString(36).substring(2, 10).toUpperCase();
setOrderNumber(genOrder());
onClose();
setShowThankYouModal(true);
2025-05-31 21:58:05 +01:00
setCart((c) => c.filter((a) => !artefactsToBuy.map((x) => x.id).includes(a.id)));
2025-05-19 13:32:20 +01:00
}
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
2025-05-12 21:38:46 +01:00
return (
<div
2025-05-31 21:58:05 +01:00
className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-[12000]"
2025-05-19 13:32:20 +01:00
onClick={handleOverlayClick}
2025-05-12 21:38:46 +01:00
>
2025-05-19 13:32:20 +01:00
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
2025-05-31 21:58:05 +01:00
<h2 className="text-2xl font-bold mb-4">
Checkout {artefact ? artefact.name : artefactsToBuy.length + " item(s)"}
{!artefact && <span className="ml-1">({artefactsToBuy.map((x) => x.name).join(", ")})</span>}
</h2>
2025-05-19 13:32:20 +01:00
<form
onSubmit={(e) => {
e.preventDefault();
handlePay();
}}
>
2025-05-31 21:58:05 +01:00
{/* Email autofill */}
<input
className="w-full mb-2 px-3 py-2 border rounded"
placeholder="Email Address"
value={user?.email ? user.email : email}
onChange={(e) => setEmail(e.target.value)}
type="email"
required
autoFocus
disabled={!!user?.email}
/>
{user?.email && (
<p className="text-sm text-gray-500 mb-2">
Signed in as <span className="font-bold">{user.email}</span>
</p>
)}
2025-05-19 13:32:20 +01:00
<input
className="w-full mb-2 px-3 py-2 border rounded"
placeholder="Cardholder Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
className="w-full mb-2 px-3 py-2 border rounded"
placeholder="Card Number"
value={cardNumber}
onChange={(e) => setCardNumber(e.target.value.replace(/\D/g, ""))}
maxLength={19}
required
inputMode="numeric"
pattern="\d*"
/>
<div className="flex gap-2">
<input
className="w-1/2 mb-2 px-3 py-2 border rounded"
placeholder="MM/YY"
value={expiry}
onChange={(e) => setExpiry(e.target.value.replace(/[^0-9/]/g, ""))}
maxLength={5}
required
inputMode="numeric"
/>
<input
className="w-1/2 mb-2 px-3 py-2 border rounded"
placeholder="CVC"
value={cvc}
onChange={(e) => setCvc(e.target.value.replace(/\D/g, ""))}
maxLength={4}
required
inputMode="numeric"
/>
</div>
<label className="inline-flex items-center mb-4">
<input type="checkbox" checked={remember} onChange={() => setRemember((r) => !r)} className="mr-2" />
Remember me
</label>
{error && <p className="text-red-600 mb-2">{error}</p>}
2025-05-31 21:58:05 +01:00
<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>
2025-05-19 13:32:20 +01:00
<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">
Cancel
</button>
<button type="submit" className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Pay
</button>
</div>
</form>
2025-05-12 21:38:46 +01:00
</div>
</div>
);
}
2025-04-29 16:52:03 +01:00
2025-05-19 13:32:20 +01:00
function ThankYouModal({ orderNumber, onClose }: { orderNumber: string; onClose: () => void }) {
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div
className="fixed inset-0 bg-neutral-900 bg-opacity-60 flex justify-center items-center z-50"
onClick={handleOverlayClick}
>
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-8 text-center">
<h2 className="text-3xl font-bold mb-4">Thank you for your purchase!</h2>
<p className="mb-4">Your order number is:</p>
<p className="text-2xl font-mono font-bold mb-6">{orderNumber}</p>
<button className="px-8 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" onClick={onClose}>
Close
</button>
</div>
</div>
);
}
2025-05-12 21:38:46 +01:00
return (
<div
2025-05-27 13:48:32 +01:00
className="min-h-screen bg-blue-50 relative flex flex-col"
2025-05-12 21:38:46 +01:00
style={{
2025-05-27 16:43:22 +01:00
backgroundImage: "url('/EarthHighRes.jpg')",
2025-05-12 21:38:46 +01:00
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
2025-05-27 13:48:32 +01:00
<div className="absolute inset-0 bg-black bg-opacity-0 z-0"></div>
2025-05-31 21:58:05 +01:00
{/* --- 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>
2025-05-12 21:38:46 +01:00
<div className="relative z-10 flex flex-col items-center w-full px-2 py-12">
2025-05-27 16:43:22 +01:00
<h1 className="text-4xl md:text-4xl font-bold text-center text-blue-300 mb-2 tracking-tight drop-shadow-lg">
2025-05-13 10:00:09 +01:00
Artefact Shop
2025-05-12 21:38:46 +01:00
</h1>
<p className="text-lg md:text-xl text-center text-white mb-10 drop-shadow-md max-w-2xl">
2025-05-30 14:05:56 +01:00
Discover extraordinary artefacts and collectibles from major seismic events from around the world - Previously studied
by our scientists, now available for purchase.
2025-05-12 21:38:46 +01:00
</p>
<div className="w-full max-w-7xl grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10 p-2">
2025-05-30 14:05:56 +01:00
{currentArtefacts
.filter((x) => !hiddenArtefactIds.includes(x.id))
.map((artefact) => (
<ArtefactCard key={artefact.id} artefact={artefact} />
))}
2025-05-12 21:38:46 +01:00
</div>
<footer className="mt-10 bg-white bg-opacity-90 border-neutral-300 py-3 text-center flex justify-center items-center w-100 max-w-7xl rounded-lg">
<button
onClick={handlePreviousPage}
disabled={currentPage === 1}
className={`mx-2 px-4 py-1 bg-blue-500 text-white rounded-md font-bold shadow-md ${
currentPage === 1 ? "opacity-50 cursor-not-allowed" : "hover:bg-blue-600"
}`}
>
&larr; Previous
</button>
<p className="mx-3 text-lg font-bold">{currentPage}</p>
<button
onClick={handleNextPage}
2025-05-13 10:00:09 +01:00
disabled={indexOfLastArtefact >= artefacts.length}
2025-05-12 21:38:46 +01:00
className={`mx-2 px-4 py-1 bg-blue-500 text-white rounded-md font-bold shadow-md ${
2025-05-13 10:00:09 +01:00
indexOfLastArtefact >= artefacts.length ? "opacity-50 cursor-not-allowed" : "hover:bg-blue-600"
2025-05-12 21:38:46 +01:00
}`}
>
Next &rarr;
</button>
</footer>
</div>
2025-05-13 10:00:09 +01:00
{selectedArtefact && <Modal artefact={selectedArtefact} />}
2025-05-31 21:58:05 +01:00
{showCartModal && <CartModal />}
{showPaymentModal && (cartCheckout || artefactToBuy) && (
2025-05-19 13:32:20 +01:00
<PaymentModal
2025-05-31 21:58:05 +01:00
artefact={cartCheckout ? undefined : artefactToBuy!}
cartItems={cartCheckout ? cart : undefined}
2025-05-19 13:32:20 +01:00
onClose={() => {
setShowPaymentModal(false);
setArtefactToBuy(null);
2025-05-31 21:58:05 +01:00
setCartCheckout(false);
2025-05-19 13:32:20 +01:00
}}
/>
)}
{showThankYouModal && orderNumber && (
<ThankYouModal orderNumber={orderNumber} onClose={() => setShowThankYouModal(false)} />
)}
2025-05-31 21:58:05 +01:00
{!selectedArtefact && !showPaymentModal && !showThankYouModal && !showCartModal && (
2025-05-30 14:05:56 +01:00
<div className="relative z-50">
<BottomFooter />
</div>
)}
</div>
);
}