390 lines
12 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|