Functional cart and balance display

This commit is contained in:
ZareMate 2026-01-22 18:54:51 +01:00
parent bcc5164abb
commit ed5afb1dec
16 changed files with 624 additions and 107 deletions

View File

@ -33,6 +33,8 @@ export default tseslint.config(
"error", "error",
{ checksVoidReturn: { attributes: false } }, { checksVoidReturn: { attributes: false } },
], ],
// ignore
"@next/next/no-img-element": "off",
}, },
}, },
{ {

File diff suppressed because one or more lines are too long

View File

@ -181,7 +181,6 @@ exports.Prisma.SellableScalarFieldEnum = {
}; };
exports.Prisma.CartScalarFieldEnum = { exports.Prisma.CartScalarFieldEnum = {
id: 'id',
userId: 'userId' userId: 'userId'
}; };
@ -254,7 +253,6 @@ exports.Prisma.SellableOrderByRelevanceFieldEnum = {
}; };
exports.Prisma.CartOrderByRelevanceFieldEnum = { exports.Prisma.CartOrderByRelevanceFieldEnum = {
id: 'id',
userId: 'userId' userId: 'userId'
}; };

View File

@ -8745,34 +8745,28 @@ export namespace Prisma {
} }
export type CartMinAggregateOutputType = { export type CartMinAggregateOutputType = {
id: string | null
userId: string | null userId: string | null
} }
export type CartMaxAggregateOutputType = { export type CartMaxAggregateOutputType = {
id: string | null
userId: string | null userId: string | null
} }
export type CartCountAggregateOutputType = { export type CartCountAggregateOutputType = {
id: number
userId: number userId: number
_all: number _all: number
} }
export type CartMinAggregateInputType = { export type CartMinAggregateInputType = {
id?: true
userId?: true userId?: true
} }
export type CartMaxAggregateInputType = { export type CartMaxAggregateInputType = {
id?: true
userId?: true userId?: true
} }
export type CartCountAggregateInputType = { export type CartCountAggregateInputType = {
id?: true
userId?: true userId?: true
_all?: true _all?: true
} }
@ -8850,7 +8844,6 @@ export namespace Prisma {
} }
export type CartGroupByOutputType = { export type CartGroupByOutputType = {
id: string
userId: string userId: string
_count: CartCountAggregateOutputType | null _count: CartCountAggregateOutputType | null
_min: CartMinAggregateOutputType | null _min: CartMinAggregateOutputType | null
@ -8872,7 +8865,6 @@ export namespace Prisma {
export type CartSelect<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetSelect<{ export type CartSelect<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetSelect<{
id?: boolean
userId?: boolean userId?: boolean
user?: boolean | UserDefaultArgs<ExtArgs> user?: boolean | UserDefaultArgs<ExtArgs>
cartItems?: boolean | Cart$cartItemsArgs<ExtArgs> cartItems?: boolean | Cart$cartItemsArgs<ExtArgs>
@ -8882,11 +8874,10 @@ export namespace Prisma {
export type CartSelectScalar = { export type CartSelectScalar = {
id?: boolean
userId?: boolean userId?: boolean
} }
export type CartOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"id" | "userId", ExtArgs["result"]["cart"]> export type CartOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"userId", ExtArgs["result"]["cart"]>
export type CartInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = { export type CartInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = {
user?: boolean | UserDefaultArgs<ExtArgs> user?: boolean | UserDefaultArgs<ExtArgs>
cartItems?: boolean | Cart$cartItemsArgs<ExtArgs> cartItems?: boolean | Cart$cartItemsArgs<ExtArgs>
@ -8900,7 +8891,6 @@ export namespace Prisma {
cartItems: Prisma.$CartItemPayload<ExtArgs>[] cartItems: Prisma.$CartItemPayload<ExtArgs>[]
} }
scalars: $Extensions.GetPayloadResult<{ scalars: $Extensions.GetPayloadResult<{
id: string
userId: string userId: string
}, ExtArgs["result"]["cart"]> }, ExtArgs["result"]["cart"]>
composites: {} composites: {}
@ -8985,8 +8975,8 @@ export namespace Prisma {
* // Get first 10 Carts * // Get first 10 Carts
* const carts = await prisma.cart.findMany({ take: 10 }) * const carts = await prisma.cart.findMany({ take: 10 })
* *
* // Only select the `id` * // Only select the `userId`
* const cartWithIdOnly = await prisma.cart.findMany({ select: { id: true } }) * const cartWithUserIdOnly = await prisma.cart.findMany({ select: { userId: true } })
* *
*/ */
findMany<T extends CartFindManyArgs>(args?: SelectSubset<T, CartFindManyArgs<ExtArgs>>): Prisma.PrismaPromise<$Result.GetResult<Prisma.$CartPayload<ExtArgs>, T, "findMany", GlobalOmitOptions>> findMany<T extends CartFindManyArgs>(args?: SelectSubset<T, CartFindManyArgs<ExtArgs>>): Prisma.PrismaPromise<$Result.GetResult<Prisma.$CartPayload<ExtArgs>, T, "findMany", GlobalOmitOptions>>
@ -9273,7 +9263,6 @@ export namespace Prisma {
* Fields of the Cart model * Fields of the Cart model
*/ */
interface CartFieldRefs { interface CartFieldRefs {
readonly id: FieldRef<"Cart", 'String'>
readonly userId: FieldRef<"Cart", 'String'> readonly userId: FieldRef<"Cart", 'String'>
} }
@ -11607,7 +11596,6 @@ export namespace Prisma {
export const CartScalarFieldEnum: { export const CartScalarFieldEnum: {
id: 'id',
userId: 'userId' userId: 'userId'
}; };
@ -11716,7 +11704,6 @@ export namespace Prisma {
export const CartOrderByRelevanceFieldEnum: { export const CartOrderByRelevanceFieldEnum: {
id: 'id',
userId: 'userId' userId: 'userId'
}; };
@ -12230,14 +12217,12 @@ export namespace Prisma {
AND?: CartWhereInput | CartWhereInput[] AND?: CartWhereInput | CartWhereInput[]
OR?: CartWhereInput[] OR?: CartWhereInput[]
NOT?: CartWhereInput | CartWhereInput[] NOT?: CartWhereInput | CartWhereInput[]
id?: StringFilter<"Cart"> | string
userId?: StringFilter<"Cart"> | string userId?: StringFilter<"Cart"> | string
user?: XOR<UserScalarRelationFilter, UserWhereInput> user?: XOR<UserScalarRelationFilter, UserWhereInput>
cartItems?: CartItemListRelationFilter cartItems?: CartItemListRelationFilter
} }
export type CartOrderByWithRelationInput = { export type CartOrderByWithRelationInput = {
id?: SortOrder
userId?: SortOrder userId?: SortOrder
user?: UserOrderByWithRelationInput user?: UserOrderByWithRelationInput
cartItems?: CartItemOrderByRelationAggregateInput cartItems?: CartItemOrderByRelationAggregateInput
@ -12245,17 +12230,15 @@ export namespace Prisma {
} }
export type CartWhereUniqueInput = Prisma.AtLeast<{ export type CartWhereUniqueInput = Prisma.AtLeast<{
id?: string userId?: string
AND?: CartWhereInput | CartWhereInput[] AND?: CartWhereInput | CartWhereInput[]
OR?: CartWhereInput[] OR?: CartWhereInput[]
NOT?: CartWhereInput | CartWhereInput[] NOT?: CartWhereInput | CartWhereInput[]
userId?: StringFilter<"Cart"> | string
user?: XOR<UserScalarRelationFilter, UserWhereInput> user?: XOR<UserScalarRelationFilter, UserWhereInput>
cartItems?: CartItemListRelationFilter cartItems?: CartItemListRelationFilter
}, "id"> }, "userId">
export type CartOrderByWithAggregationInput = { export type CartOrderByWithAggregationInput = {
id?: SortOrder
userId?: SortOrder userId?: SortOrder
_count?: CartCountOrderByAggregateInput _count?: CartCountOrderByAggregateInput
_max?: CartMaxOrderByAggregateInput _max?: CartMaxOrderByAggregateInput
@ -12266,7 +12249,6 @@ export namespace Prisma {
AND?: CartScalarWhereWithAggregatesInput | CartScalarWhereWithAggregatesInput[] AND?: CartScalarWhereWithAggregatesInput | CartScalarWhereWithAggregatesInput[]
OR?: CartScalarWhereWithAggregatesInput[] OR?: CartScalarWhereWithAggregatesInput[]
NOT?: CartScalarWhereWithAggregatesInput | CartScalarWhereWithAggregatesInput[] NOT?: CartScalarWhereWithAggregatesInput | CartScalarWhereWithAggregatesInput[]
id?: StringWithAggregatesFilter<"Cart"> | string
userId?: StringWithAggregatesFilter<"Cart"> | string userId?: StringWithAggregatesFilter<"Cart"> | string
} }
@ -12811,40 +12793,34 @@ export namespace Prisma {
} }
export type CartCreateInput = { export type CartCreateInput = {
id: string
user: UserCreateNestedOneWithoutCartsInput user: UserCreateNestedOneWithoutCartsInput
cartItems?: CartItemCreateNestedManyWithoutCartInput cartItems?: CartItemCreateNestedManyWithoutCartInput
} }
export type CartUncheckedCreateInput = { export type CartUncheckedCreateInput = {
id: string
userId: string userId: string
cartItems?: CartItemUncheckedCreateNestedManyWithoutCartInput cartItems?: CartItemUncheckedCreateNestedManyWithoutCartInput
} }
export type CartUpdateInput = { export type CartUpdateInput = {
id?: StringFieldUpdateOperationsInput | string
user?: UserUpdateOneRequiredWithoutCartsNestedInput user?: UserUpdateOneRequiredWithoutCartsNestedInput
cartItems?: CartItemUpdateManyWithoutCartNestedInput cartItems?: CartItemUpdateManyWithoutCartNestedInput
} }
export type CartUncheckedUpdateInput = { export type CartUncheckedUpdateInput = {
id?: StringFieldUpdateOperationsInput | string
userId?: StringFieldUpdateOperationsInput | string userId?: StringFieldUpdateOperationsInput | string
cartItems?: CartItemUncheckedUpdateManyWithoutCartNestedInput cartItems?: CartItemUncheckedUpdateManyWithoutCartNestedInput
} }
export type CartCreateManyInput = { export type CartCreateManyInput = {
id: string
userId: string userId: string
} }
export type CartUpdateManyMutationInput = { export type CartUpdateManyMutationInput = {
id?: StringFieldUpdateOperationsInput | string
} }
export type CartUncheckedUpdateManyInput = { export type CartUncheckedUpdateManyInput = {
id?: StringFieldUpdateOperationsInput | string
userId?: StringFieldUpdateOperationsInput | string userId?: StringFieldUpdateOperationsInput | string
} }
@ -13523,17 +13499,14 @@ export namespace Prisma {
} }
export type CartCountOrderByAggregateInput = { export type CartCountOrderByAggregateInput = {
id?: SortOrder
userId?: SortOrder userId?: SortOrder
} }
export type CartMaxOrderByAggregateInput = { export type CartMaxOrderByAggregateInput = {
id?: SortOrder
userId?: SortOrder userId?: SortOrder
} }
export type CartMinOrderByAggregateInput = { export type CartMinOrderByAggregateInput = {
id?: SortOrder
userId?: SortOrder userId?: SortOrder
} }
@ -14651,12 +14624,10 @@ export namespace Prisma {
} }
export type CartCreateWithoutUserInput = { export type CartCreateWithoutUserInput = {
id: string
cartItems?: CartItemCreateNestedManyWithoutCartInput cartItems?: CartItemCreateNestedManyWithoutCartInput
} }
export type CartUncheckedCreateWithoutUserInput = { export type CartUncheckedCreateWithoutUserInput = {
id: string
cartItems?: CartItemUncheckedCreateNestedManyWithoutCartInput cartItems?: CartItemUncheckedCreateNestedManyWithoutCartInput
} }
@ -14796,7 +14767,6 @@ export namespace Prisma {
AND?: CartScalarWhereInput | CartScalarWhereInput[] AND?: CartScalarWhereInput | CartScalarWhereInput[]
OR?: CartScalarWhereInput[] OR?: CartScalarWhereInput[]
NOT?: CartScalarWhereInput | CartScalarWhereInput[] NOT?: CartScalarWhereInput | CartScalarWhereInput[]
id?: StringFilter<"Cart"> | string
userId?: StringFilter<"Cart"> | string userId?: StringFilter<"Cart"> | string
} }
@ -15318,12 +15288,10 @@ export namespace Prisma {
} }
export type CartCreateWithoutCartItemsInput = { export type CartCreateWithoutCartItemsInput = {
id: string
user: UserCreateNestedOneWithoutCartsInput user: UserCreateNestedOneWithoutCartsInput
} }
export type CartUncheckedCreateWithoutCartItemsInput = { export type CartUncheckedCreateWithoutCartItemsInput = {
id: string
userId: string userId: string
} }
@ -15367,12 +15335,10 @@ export namespace Prisma {
} }
export type CartUpdateWithoutCartItemsInput = { export type CartUpdateWithoutCartItemsInput = {
id?: StringFieldUpdateOperationsInput | string
user?: UserUpdateOneRequiredWithoutCartsNestedInput user?: UserUpdateOneRequiredWithoutCartsNestedInput
} }
export type CartUncheckedUpdateWithoutCartItemsInput = { export type CartUncheckedUpdateWithoutCartItemsInput = {
id?: StringFieldUpdateOperationsInput | string
userId?: StringFieldUpdateOperationsInput | string userId?: StringFieldUpdateOperationsInput | string
} }
@ -15500,7 +15466,7 @@ export namespace Prisma {
} }
export type CartCreateManyUserInput = { export type CartCreateManyUserInput = {
id: string
} }
export type AdressCreateManyUserInput = { export type AdressCreateManyUserInput = {
@ -15591,17 +15557,15 @@ export namespace Prisma {
} }
export type CartUpdateWithoutUserInput = { export type CartUpdateWithoutUserInput = {
id?: StringFieldUpdateOperationsInput | string
cartItems?: CartItemUpdateManyWithoutCartNestedInput cartItems?: CartItemUpdateManyWithoutCartNestedInput
} }
export type CartUncheckedUpdateWithoutUserInput = { export type CartUncheckedUpdateWithoutUserInput = {
id?: StringFieldUpdateOperationsInput | string
cartItems?: CartItemUncheckedUpdateManyWithoutCartNestedInput cartItems?: CartItemUncheckedUpdateManyWithoutCartNestedInput
} }
export type CartUncheckedUpdateManyWithoutUserInput = { export type CartUncheckedUpdateManyWithoutUserInput = {
id?: StringFieldUpdateOperationsInput | string
} }
export type AdressUpdateWithoutUserInput = { export type AdressUpdateWithoutUserInput = {

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{ {
"name": "prisma-client-1d666464292a599bad3179d365e160013075f010f27e15f565fd192ad5ef967d", "name": "prisma-client-a0e1bef334afa5b609bdf0c15431f28835b869da62270d0786ce071578cdedb4",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "default.js", "browser": "default.js",

View File

@ -107,8 +107,7 @@ model Sellable {
////////////////////// //////////////////////
model Cart { model Cart {
id String @id userId String @id
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
cartItems CartItem[] cartItems CartItem[]
@ -119,7 +118,7 @@ model CartItem {
quantity Int quantity Int
cartId String cartId String
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade, onUpdate: Cascade) cart Cart @relation(fields: [cartId], references: [userId], onDelete: Cascade, onUpdate: Cascade)
sellable Sellable @relation(fields: [itemId], references: [id], onDelete: Cascade, onUpdate: Cascade) sellable Sellable @relation(fields: [itemId], references: [id], onDelete: Cascade, onUpdate: Cascade)
} }

File diff suppressed because one or more lines are too long

View File

@ -107,8 +107,7 @@ model Sellable {
////////////////////// //////////////////////
model Cart { model Cart {
id String @id userId String @id
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
cartItems CartItem[] cartItems CartItem[]
@ -119,7 +118,7 @@ model CartItem {
quantity Int quantity Int
cartId String cartId String
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade, onUpdate: Cascade) cart Cart @relation(fields: [cartId], references: [userId], onDelete: Cascade, onUpdate: Cascade)
sellable Sellable @relation(fields: [itemId], references: [id], onDelete: Cascade, onUpdate: Cascade) sellable Sellable @relation(fields: [itemId], references: [id], onDelete: Cascade, onUpdate: Cascade)
} }

0
src/app/api/buy/route.ts Normal file
View File

83
src/app/api/cart/route.ts Normal file
View File

@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { auth } from "~/server/auth";
import { db } from "~/server/db";
export async function PATCH(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
type CartItem = {
id: string;
quantity: number;
};
const userId = session.user.id;
const body = (await request.json()) as CartItem;
const { id: itemId, quantity } = body;
if (!itemId || !quantity || quantity == 0) {
return NextResponse.json(
{ error: "Invalid request data" },
{ status: 400 },
);
}
try {
// Find or create the user's cart
let cart = await db.cart.findFirst({
where: { userId },
include: { cartItems: true },
});
// Use nullish coalescing assignment
cart ??= await db.cart.create({
data: { userId },
include: { cartItems: true },
});
// Check if the item already exists in the cart
const existingCartItem = await db.cartItem.findUnique({
where: { itemId },
});
if (existingCartItem) {
if (quantity <= 0 && quantity * -1 >= existingCartItem.quantity) {
await db.cartItem.delete({
where: { itemId },
});
} else {
// Update quantity
await db.cartItem.update({
where: { itemId },
data: { quantity: existingCartItem.quantity + quantity },
});
}
} else {
// Add new item
await db.cartItem.create({
data: {
itemId,
quantity,
cartId: userId,
},
});
}
// Fetch the updated cart
const updatedCart = await db.cart.findUnique({
where: { userId: userId },
include: { cartItems: { include: { sellable: true } } },
});
return NextResponse.json(updatedCart);
} catch (error) {
console.error(error);
return NextResponse.json(
{ error: "Failed to add item to cart" },
{ status: 500 },
);
}
}

View File

@ -16,6 +16,9 @@ export async function GET() {
item: { select: { stock: true } }, item: { select: { stock: true } },
}, },
}); });
// and cache no store header
return NextResponse.json(sellables); return NextResponse.json(sellables, {
headers: { "Cache-Control": "no-store" },
});
} }

View File

@ -45,6 +45,12 @@ export async function GET() {
shops: { shops: {
select: { id: true, label: true, sellables: true }, select: { id: true, label: true, sellables: true },
}, },
carts: {
select: {
cartItems: { select: { itemId: true, quantity: true } },
},
},
balance: true,
}, },
}); });

View File

@ -1,21 +1,66 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import Items from "~/components/sellable_items"; import Items from "~/components/sellable_items";
import Search from "~/components/search"; import Search from "~/components/search";
import CartButton from "~/components/cart";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
type UserResponse = {
id: string;
name: string | null;
carts: {
cartItems: {
itemId: string;
quantity: number;
}[];
}[];
balance: number;
};
type Props = { type Props = {
session: Session | null; session: Session | null;
}; };
export default function HomeClient({ session }: Props) { export default function HomeClient({ session }: Props) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [userData, setUserData] = useState<UserResponse | null>(null);
const [loading, setLoading] = useState(true);
// Fetch /api/user once and store globally here
const loadUser = useCallback(async () => {
try {
setLoading(true);
const res = await fetch("/api/user");
if (!res.ok) throw new Error("Failed to fetch user");
const data = (await res.json()) as UserResponse;
setUserData(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadUser();
}, [loadUser]);
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center bg-linear-to-b from-[#2e026d] to-[#7f3b3b] text-white"> <main className="flex min-h-screen flex-col items-center justify-center bg-linear-to-b from-[#2e026d] to-[#7f3b3b] text-white">
<div className="absolute top-4 right-4 flex items-center gap-4"> <div className="absolute top-4 right-4 flex items-center gap-4">
{userData && (
<div className="rounded-full bg-white/10 px-4 py-2 font-semibold">
${userData.balance ?? 0}
</div>
)}
<CartButton
userData={userData}
reloadUser={loadUser}
loading={loading}
/>
<Search query={query} setQuery={setQuery} /> <Search query={query} setQuery={setQuery} />
{session?.user ? ( {session?.user ? (
@ -40,8 +85,7 @@ export default function HomeClient({ session }: Props) {
Suchodupin <span className="text-[hsl(280,100%,70%)]">MC</span> Shop Suchodupin <span className="text-[hsl(280,100%,70%)]">MC</span> Shop
</h1> </h1>
{/* ✅ query flows down */} <Items query={query} reloadUser={loadUser} />
<Items query={query} />
</div> </div>
</main> </main>
); );

292
src/components/cart.tsx Normal file
View File

@ -0,0 +1,292 @@
"use client";
import { useEffect, useState, useCallback } from "react";
type UserResponse = {
id: string;
name: string | null;
carts: {
cartItems: {
itemId: string;
quantity: number;
}[];
}[];
};
type CartViewItem = {
id: string;
name: string;
actualQuantity: number;
itemAmount: number;
stock: 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;
export default function CartButton({
userData,
reloadUser,
loading,
}: CartButtonProps) {
const [cartItems, setCartItems] = useState<CartViewItem[]>([]);
const [totalQuantity, setTotalQuantity] = useState(0);
const [isOpen, setIsOpen] = useState(false);
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/items");
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,
});
}
}
setCartItems(collected);
setTotalQuantity(total);
};
void fetchItems();
}, [userData]);
// 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.some(
(item) => item.stock < item.actualQuantity,
);
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)}
>
🛒 Cart
{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">
Quantity: {item.actualQuantity} / Stock: {item.stock}
</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>
)}
<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={() => alert("Dummy buy action")}
disabled={buyDisabled}
>
Buy
</button>
</div>
</div>
)}
</>
);
}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
type ApiItem = { type ApiItem = {
id: string; id: string;
@ -30,57 +30,190 @@ const getImage = (name: string) => {
return `/textures/${mod}/${item}.png`; return `/textures/${mod}/${item}.png`;
}; };
const Items = ({ query }: { query: string }) => { type ItemsProps = {
query: string;
reloadUser: () => Promise<void>;
};
const Items = ({ query, reloadUser }: ItemsProps) => {
const [items, setItems] = useState<ApiItem[]>([]); const [items, setItems] = useState<ApiItem[]>([]);
const [dragging, setDragging] = useState<{
itemId: string;
quantity: number;
max: number;
rect: DOMRect;
} | null>(null);
const [sliderActive, setSliderActive] = useState<string | null>(null);
const [holdTimeout, setHoldTimeout] = useState<NodeJS.Timeout | null>(null);
const loadItems = useCallback(async () => {
try {
const res = await fetch("/api/items", { cache: "no-store" });
const data = (await res.json()) as ApiItem[];
setItems(data.filter((item) => item.enabled));
} catch (err) {
console.error("Failed to load items", err);
}
}, []);
useEffect(() => { useEffect(() => {
const loadItems = async () => { void loadItems();
try { const interval = setInterval(() => void loadItems(), 60_000);
const res = await fetch("/api/items"); return () => clearInterval(interval);
const data = (await res.json()) as ApiItem[]; }, [loadItems]);
setItems(data.filter((item) => item.enabled)); const buyItem = useCallback(
async (id: string, quantity: number) => {
try {
const res = await fetch("/api/cart", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, quantity }),
});
if (!res.ok) throw new Error("Buy failed");
await reloadUser(); // all components now update
} catch (err) { } catch (err) {
console.error("Failed to load items", err); console.error(err);
} }
},
[reloadUser], // <- include reloadUser as dependency
);
// Handle hold to activate slider
const handleMouseDown = (e: React.MouseEvent, item: ApiItem) => {
e.preventDefault();
const button = e.currentTarget.getBoundingClientRect();
const timeout = setTimeout(() => {
setSliderActive(item.id);
setDragging({
itemId: item.id,
quantity: 1,
max: item.item.stock / item.amount,
rect: button,
});
}, 300); // 1 second threshold
setHoldTimeout(timeout);
};
const handleMouseUp = (e: React.MouseEvent, item: ApiItem) => {
e.preventDefault();
if (holdTimeout) {
clearTimeout(holdTimeout);
setHoldTimeout(null);
}
if (!sliderActive) {
void buyItem(item.id, 1); // quick click
}
};
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) {
setDragging(null);
setSliderActive(null);
await buyItem(dragging.itemId, dragging.quantity);
}
}, [dragging, buyItem]);
useEffect(() => {
if (!dragging) return;
const onMouseMove = handleMouseMove;
const onMouseUp = () => {
void handleMouseRelease();
}; };
void loadItems(); window.addEventListener("mousemove", onMouseMove);
}, []); window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [dragging, handleMouseMove, handleMouseRelease]);
const filteredItems = items.filter((item) => const filteredItems = items.filter((item) =>
formatName(item.item_name).toLowerCase().includes(query.toLowerCase()), formatName(item.item_name).toLowerCase().includes(query.toLowerCase()),
); );
return ( return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="relative">
{filteredItems.map((item) => ( {/* Blur and darken overlay */}
<div
key={item.id}
className="flex gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
>
<img
src={getImage(item.item_name)}
alt={item.item_name}
className="item-img h-12 w-12"
/>
<div className="w-70"> <div className="relative z-10 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<h2 className="text-xl font-bold">{formatName(item.item_name)}</h2> {filteredItems.map((item) => (
<p className="text-sm">{item.item.stock} available</p> <div
<p className="text-xs text-white/70">Shop: {item.shop.label}</p> key={item.id}
</div> className="relative flex gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
>
<img
src={getImage(item.item_name)}
alt={item.item_name}
className="item-img h-12 w-12"
/>
<div className="flex flex-col items-end"> <div className="w-70">
<button className="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"> <h2 className="text-xl font-bold">
Buy {formatName(item.item_name)}
</button> </h2>
<h3 className="w-fit self-center text-xl font-bold"> <p className="text-sm">{item.item.stock} available</p>
{item.price}$/{item.amount} <p className="text-xs text-white/70">Shop: {item.shop.label}</p>
</h3> </div>
<div className="relative flex flex-col items-end">
<button
onMouseDown={(e) => handleMouseDown(e, item)}
onMouseUp={(e) => handleMouseUp(e, item)}
className="relative z-10 rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
>
Buy
</button>
<h3 className="mt-2 w-fit self-center text-xl font-bold">
{item.price}$/{item.amount}
</h3>
{/* Slider on 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-10 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-blue-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.amount}
</span>
</div>
</div>
</div>
)}
</div>
</div> </div>
</div> ))}
))} </div>
</div> </div>
); );
}; };