390 lines
12 KiB
TypeScript

"use client";
import type { CartItem } from "generated/prisma";
import { IMAGES_MANIFEST } from "next/dist/shared/lib/constants";
import { useEffect, useState, useCallback } from "react";
type Cart = {
cartItems: {
itemId: string;
quantity: number;
}[];
};
type UserResponse = {
id: string;
name: string | null;
carts: Cart[];
adresses: string[];
balance: number;
};
type CartViewItem = {
id: string;
name: string;
actualQuantity: number;
itemAmount: number;
stock: number;
price: number;
};
type ApiItem = {
id: string;
item_name: string;
amount: number;
price: number;
enabled: boolean;
shop: {
label: string;
};
item: {
stock: number;
};
};
const formatName = (name: string) => {
const parts = name.split(":");
if (!parts[1]) return "";
return parts[1]
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
};
const getImage = (name: string) => {
const [mod, item] = name.split(":");
return `/textures/${mod}/${item}.png`;
};
type CartButtonProps = {
userData: UserResponse | null;
reloadUser: () => Promise<void>;
loading: boolean;
};
// Add at top of CartButton component
type DraggingState = {
itemId: string;
quantity: number;
max: number;
rect: DOMRect;
} | null;
async function buyItems(cart: CartViewItem[], address: string) {
await fetch("/api/buy", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ cart, address }),
});
}
export default function CartButton({
userData,
reloadUser,
loading,
}: CartButtonProps) {
const [cartItems, setCartItems] = useState<CartViewItem[]>([]);
const [totalQuantity, setTotalQuantity] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [selectedAddress, setSelectedAddress] = useState<string>(
userData?.adresses?.[0] ?? "",
);
const [dragging, setDragging] = useState<DraggingState>(null);
const [sliderActive, setSliderActive] = useState<string | null>(null);
const [holdTimeout, setHoldTimeout] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!userData) return;
const fetchItems = async () => {
const res = await fetch("/api/sellable");
const items = (await res.json()) as ApiItem[];
const itemMap = new Map(items.map((i) => [i.id, i]));
let total = 0;
const collected: CartViewItem[] = [];
for (const cart of userData.carts ?? []) {
for (const cartItem of cart.cartItems) {
const item = itemMap.get(cartItem.itemId);
if (!item) continue;
const actualQuantity = cartItem.quantity * item.amount;
total += actualQuantity;
collected.push({
id: item.id,
name: item.item_name,
actualQuantity,
itemAmount: item.amount,
stock: item.item.stock,
price: item.price,
});
}
}
if (!selectedAddress) {
setSelectedAddress(userData.adresses?.[0] ?? "");
}
setCartItems(collected);
setTotalQuantity(total);
};
void fetchItems();
}, [userData]);
const getItemSubtotal = (item: CartViewItem) => {
return (item.price * item.actualQuantity) / item.itemAmount;
};
const cartTotalPrice = cartItems.reduce(
(sum, item) => sum + getItemSubtotal(item),
0,
);
const formatPrice = (value: number) =>
value.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
// Remove item function (negative quantity)
const removeItem = async (id: string, quantity: number) => {
try {
await fetch("/api/cart", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, quantity: -quantity }),
});
await reloadUser();
} catch (err) {
console.error("Failed to remove item", err);
}
};
// Drag/hold logic
const handleMouseDown = (e: React.MouseEvent, item: CartViewItem) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const timeout = setTimeout(() => {
setSliderActive(item.id);
setDragging({
itemId: item.id,
quantity: 1,
max: item.actualQuantity / item.itemAmount,
rect,
});
}, 300); // hold threshold
setHoldTimeout(timeout);
};
const handleMouseUp = (e: React.MouseEvent, item: CartViewItem) => {
e.preventDefault();
if (holdTimeout) {
clearTimeout(holdTimeout);
setHoldTimeout(null);
}
if (!sliderActive) {
void removeItem(item.id, 1); // quick click removes 1
}
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!dragging) return;
const { rect, max } = dragging;
let relativeX = e.clientX - rect.left;
relativeX = Math.max(0, Math.min(relativeX, rect.width));
const quantity = Math.max(1, Math.round((relativeX / rect.width) * max));
setDragging({ ...dragging, quantity });
},
[dragging],
);
const handleMouseRelease = useCallback(async () => {
if (dragging) {
const { itemId, quantity } = dragging;
setDragging(null);
setSliderActive(null);
await removeItem(itemId, quantity);
}
}, [dragging]);
useEffect(() => {
if (!dragging) return;
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseRelease);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseRelease);
};
}, [dragging, handleMouseMove, handleMouseRelease]);
const buyDisabled =
cartItems.length === 0 ||
cartItems.some((item) => item.stock < item.actualQuantity) ||
!userData?.balance ||
cartTotalPrice > userData.balance;
const buyDisabledReason = buyDisabled
? cartItems.some((item) => item.stock === 0)
? "Out of stock"
: cartItems.some((item) => item.stock < item.actualQuantity)
? "Not enough stock"
: !userData?.balance || cartTotalPrice > userData.balance
? "Insufficient balance"
: "No items in cart"
: null;
return (
<>
{/* CART BUTTON */}
<button
className="relative rounded-full bg-white/10 px-4 py-2 font-semibold transition hover:bg-white/20"
disabled={loading}
onClick={() => setIsOpen(true)}
>
<img src="/icons/cart.svg" alt="Cart Icon" className="h-6 w-6" />
{totalQuantity > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white">
{totalQuantity}
</span>
)}
</button>
{/* OVERLAY */}
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
/>
<div className="relative z-10 w-full max-w-md rounded-xl bg-neutral-900 p-6 text-white shadow-xl">
<h2 className="mb-4 text-xl font-bold">Your Cart</h2>
{cartItems.length === 0 ? (
<p className="text-neutral-400">Cart is empty</p>
) : (
<ul className="space-y-3">
{cartItems.map((item) => (
<li
className="flex items-center justify-between rounded-lg bg-white/5 px-3 py-2"
key={item.id}
>
<div className="flex items-center gap-3">
<img
src={getImage(item.name)}
alt={item.name}
className="h-8 w-8 rounded-full"
/>
<div>
<p className="flex items-center gap-1 font-medium">
{formatName(item.name)}
{item.actualQuantity > item.stock && (
<span className="font-bold text-red-500">!</span>
)}
</p>
<p className="text-sm text-neutral-400">
Qty: {item.actualQuantity} · Price: $
{formatPrice(item.price)}
</p>
<p className="text-sm font-semibold text-green-400">
Subtotal: ${formatPrice(getItemSubtotal(item))}
</p>
</div>
</div>
{/* Remove button with hold/slider */}
<div className="relative flex flex-col items-end">
<button
onMouseDown={(e) => handleMouseDown(e, item)}
onMouseUp={(e) => handleMouseUp(e, item)}
className="rounded bg-red-500 px-3 py-1 text-sm font-semibold hover:bg-red-600"
>
Remove
</button>
{sliderActive === item.id && dragging && (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center">
<div className="relative h-full w-full rounded">
<div className="relative h-7 w-full rounded bg-white/20">
<div className="absolute top-0 left-0 h-full w-full rounded bg-gray-400" />
<div
className="absolute top-0 left-0 h-full rounded bg-red-500 transition-transform duration-300"
style={{
width: `${(dragging.quantity / dragging.max) * 100}%`,
}}
/>
<span className="text-md absolute top-0 right-2 font-bold text-white">
x{dragging.quantity * item.itemAmount}
</span>
</div>
</div>
</div>
)}
</div>
</li>
))}
</ul>
)}
<div className="mt-4 flex items-center justify-between rounded-lg bg-white/10 px-4 py-3">
<span className="text-lg font-semibold">Total</span>
<span className="text-lg font-bold text-green-400">
${formatPrice(cartTotalPrice)}
</span>
</div>
{/* ADDRESS SELECT */}
{userData?.adresses && userData.adresses.length > 0 && (
<div className="mt-4">
<label
htmlFor="address"
className="mb-2 block text-sm font-medium text-neutral-300"
>
Select Delivery Address
</label>
<select
id="address"
className="w-full rounded-lg border border-neutral-700 bg-neutral-800 p-2 text-white focus:border-green-500 focus:ring-1 focus:ring-green-500"
value={selectedAddress}
onChange={(e) => setSelectedAddress(e.target.value)}
>
{userData.adresses.map((addr, idx) => (
<option key={idx} value={addr}>
{addr}
</option>
))}
</select>
</div>
)}
<button
className={`mt-6 w-full rounded-lg py-3 font-bold text-black ${buyDisabled ? "cursor-not-allowed bg-gray-400" : "bg-green-500 hover:bg-green-600"}`}
onClick={() => {
if (
!selectedAddress ||
selectedAddress === "" ||
cartItems.length === 0
)
return;
void buyItems(cartItems, selectedAddress);
}}
disabled={buyDisabled}
>
Buy
</button>
{buyDisabled && buyDisabledReason && (
<p className="mt-3 text-center text-sm font-medium text-red-400">
{buyDisabledReason}
</p>
)}
</div>
</div>
)}
</>
);
}