Functional cart and balance display
This commit is contained in:
parent
bcc5164abb
commit
ed5afb1dec
@ -33,6 +33,8 @@ export default tseslint.config(
|
||||
"error",
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
// ignore
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -181,7 +181,6 @@ exports.Prisma.SellableScalarFieldEnum = {
|
||||
};
|
||||
|
||||
exports.Prisma.CartScalarFieldEnum = {
|
||||
id: 'id',
|
||||
userId: 'userId'
|
||||
};
|
||||
|
||||
@ -254,7 +253,6 @@ exports.Prisma.SellableOrderByRelevanceFieldEnum = {
|
||||
};
|
||||
|
||||
exports.Prisma.CartOrderByRelevanceFieldEnum = {
|
||||
id: 'id',
|
||||
userId: 'userId'
|
||||
};
|
||||
|
||||
|
||||
52
generated/prisma/index.d.ts
vendored
52
generated/prisma/index.d.ts
vendored
@ -8745,34 +8745,28 @@ export namespace Prisma {
|
||||
}
|
||||
|
||||
export type CartMinAggregateOutputType = {
|
||||
id: string | null
|
||||
userId: string | null
|
||||
}
|
||||
|
||||
export type CartMaxAggregateOutputType = {
|
||||
id: string | null
|
||||
userId: string | null
|
||||
}
|
||||
|
||||
export type CartCountAggregateOutputType = {
|
||||
id: number
|
||||
userId: number
|
||||
_all: number
|
||||
}
|
||||
|
||||
|
||||
export type CartMinAggregateInputType = {
|
||||
id?: true
|
||||
userId?: true
|
||||
}
|
||||
|
||||
export type CartMaxAggregateInputType = {
|
||||
id?: true
|
||||
userId?: true
|
||||
}
|
||||
|
||||
export type CartCountAggregateInputType = {
|
||||
id?: true
|
||||
userId?: true
|
||||
_all?: true
|
||||
}
|
||||
@ -8850,7 +8844,6 @@ export namespace Prisma {
|
||||
}
|
||||
|
||||
export type CartGroupByOutputType = {
|
||||
id: string
|
||||
userId: string
|
||||
_count: CartCountAggregateOutputType | null
|
||||
_min: CartMinAggregateOutputType | null
|
||||
@ -8872,7 +8865,6 @@ export namespace Prisma {
|
||||
|
||||
|
||||
export type CartSelect<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetSelect<{
|
||||
id?: boolean
|
||||
userId?: boolean
|
||||
user?: boolean | UserDefaultArgs<ExtArgs>
|
||||
cartItems?: boolean | Cart$cartItemsArgs<ExtArgs>
|
||||
@ -8882,11 +8874,10 @@ export namespace Prisma {
|
||||
|
||||
|
||||
export type CartSelectScalar = {
|
||||
id?: 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> = {
|
||||
user?: boolean | UserDefaultArgs<ExtArgs>
|
||||
cartItems?: boolean | Cart$cartItemsArgs<ExtArgs>
|
||||
@ -8900,7 +8891,6 @@ export namespace Prisma {
|
||||
cartItems: Prisma.$CartItemPayload<ExtArgs>[]
|
||||
}
|
||||
scalars: $Extensions.GetPayloadResult<{
|
||||
id: string
|
||||
userId: string
|
||||
}, ExtArgs["result"]["cart"]>
|
||||
composites: {}
|
||||
@ -8985,8 +8975,8 @@ export namespace Prisma {
|
||||
* // Get first 10 Carts
|
||||
* const carts = await prisma.cart.findMany({ take: 10 })
|
||||
*
|
||||
* // Only select the `id`
|
||||
* const cartWithIdOnly = await prisma.cart.findMany({ select: { id: true } })
|
||||
* // Only select the `userId`
|
||||
* 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>>
|
||||
@ -9273,7 +9263,6 @@ export namespace Prisma {
|
||||
* Fields of the Cart model
|
||||
*/
|
||||
interface CartFieldRefs {
|
||||
readonly id: FieldRef<"Cart", 'String'>
|
||||
readonly userId: FieldRef<"Cart", 'String'>
|
||||
}
|
||||
|
||||
@ -11607,7 +11596,6 @@ export namespace Prisma {
|
||||
|
||||
|
||||
export const CartScalarFieldEnum: {
|
||||
id: 'id',
|
||||
userId: 'userId'
|
||||
};
|
||||
|
||||
@ -11716,7 +11704,6 @@ export namespace Prisma {
|
||||
|
||||
|
||||
export const CartOrderByRelevanceFieldEnum: {
|
||||
id: 'id',
|
||||
userId: 'userId'
|
||||
};
|
||||
|
||||
@ -12230,14 +12217,12 @@ export namespace Prisma {
|
||||
AND?: CartWhereInput | CartWhereInput[]
|
||||
OR?: CartWhereInput[]
|
||||
NOT?: CartWhereInput | CartWhereInput[]
|
||||
id?: StringFilter<"Cart"> | string
|
||||
userId?: StringFilter<"Cart"> | string
|
||||
user?: XOR<UserScalarRelationFilter, UserWhereInput>
|
||||
cartItems?: CartItemListRelationFilter
|
||||
}
|
||||
|
||||
export type CartOrderByWithRelationInput = {
|
||||
id?: SortOrder
|
||||
userId?: SortOrder
|
||||
user?: UserOrderByWithRelationInput
|
||||
cartItems?: CartItemOrderByRelationAggregateInput
|
||||
@ -12245,17 +12230,15 @@ export namespace Prisma {
|
||||
}
|
||||
|
||||
export type CartWhereUniqueInput = Prisma.AtLeast<{
|
||||
id?: string
|
||||
userId?: string
|
||||
AND?: CartWhereInput | CartWhereInput[]
|
||||
OR?: CartWhereInput[]
|
||||
NOT?: CartWhereInput | CartWhereInput[]
|
||||
userId?: StringFilter<"Cart"> | string
|
||||
user?: XOR<UserScalarRelationFilter, UserWhereInput>
|
||||
cartItems?: CartItemListRelationFilter
|
||||
}, "id">
|
||||
}, "userId">
|
||||
|
||||
export type CartOrderByWithAggregationInput = {
|
||||
id?: SortOrder
|
||||
userId?: SortOrder
|
||||
_count?: CartCountOrderByAggregateInput
|
||||
_max?: CartMaxOrderByAggregateInput
|
||||
@ -12266,7 +12249,6 @@ export namespace Prisma {
|
||||
AND?: CartScalarWhereWithAggregatesInput | CartScalarWhereWithAggregatesInput[]
|
||||
OR?: CartScalarWhereWithAggregatesInput[]
|
||||
NOT?: CartScalarWhereWithAggregatesInput | CartScalarWhereWithAggregatesInput[]
|
||||
id?: StringWithAggregatesFilter<"Cart"> | string
|
||||
userId?: StringWithAggregatesFilter<"Cart"> | string
|
||||
}
|
||||
|
||||
@ -12811,40 +12793,34 @@ export namespace Prisma {
|
||||
}
|
||||
|
||||
export type CartCreateInput = {
|
||||
id: string
|
||||
user: UserCreateNestedOneWithoutCartsInput
|
||||
cartItems?: CartItemCreateNestedManyWithoutCartInput
|
||||
}
|
||||
|
||||
export type CartUncheckedCreateInput = {
|
||||
id: string
|
||||
userId: string
|
||||
cartItems?: CartItemUncheckedCreateNestedManyWithoutCartInput
|
||||
}
|
||||
|
||||
export type CartUpdateInput = {
|
||||
id?: StringFieldUpdateOperationsInput | string
|
||||
user?: UserUpdateOneRequiredWithoutCartsNestedInput
|
||||
cartItems?: CartItemUpdateManyWithoutCartNestedInput
|
||||
}
|
||||
|
||||
export type CartUncheckedUpdateInput = {
|
||||
id?: StringFieldUpdateOperationsInput | string
|
||||
userId?: StringFieldUpdateOperationsInput | string
|
||||
cartItems?: CartItemUncheckedUpdateManyWithoutCartNestedInput
|
||||
}
|
||||
|
||||
export type CartCreateManyInput = {
|
||||
id: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
export type CartUpdateManyMutationInput = {
|
||||
id?: StringFieldUpdateOperationsInput | string
|
||||
|
||||
}
|
||||
|
||||
export type CartUncheckedUpdateManyInput = {
|
||||
id?: StringFieldUpdateOperationsInput | string
|
||||
userId?: StringFieldUpdateOperationsInput | string
|
||||
}
|
||||
|
||||
@ -13523,17 +13499,14 @@ export namespace Prisma {
|
||||
}
|
||||
|
||||
export type CartCountOrderByAggregateInput = {
|
||||
id?: SortOrder
|
||||
userId?: SortOrder
|
||||
}
|
||||
|
||||
export type CartMaxOrderByAggregateInput = {
|
||||
id?: SortOrder
|
||||
userId?: SortOrder
|
||||
}
|
||||
|
||||
export type CartMinOrderByAggregateInput = {
|
||||
id?: SortOrder
|
||||
userId?: SortOrder
|
||||
}
|
||||
|
||||
@ -14651,12 +14624,10 @@ export namespace Prisma {
|
||||
}
|
||||
|
||||
export type CartCreateWithoutUserInput = {
|
||||
id: string
|
||||
cartItems?: CartItemCreateNestedManyWithoutCartInput
|
||||
}
|
||||
|
||||
export type CartUncheckedCreateWithoutUserInput = {
|
||||
id: string
|
||||
cartItems?: CartItemUncheckedCreateNestedManyWithoutCartInput
|
||||
}
|
||||
|
||||
@ -14796,7 +14767,6 @@ export namespace Prisma {
|
||||
AND?: CartScalarWhereInput | CartScalarWhereInput[]
|
||||
OR?: CartScalarWhereInput[]
|
||||
NOT?: CartScalarWhereInput | CartScalarWhereInput[]
|
||||
id?: StringFilter<"Cart"> | string
|
||||
userId?: StringFilter<"Cart"> | string
|
||||
}
|
||||
|
||||
@ -15318,12 +15288,10 @@ export namespace Prisma {
|
||||
}
|
||||
|
||||
export type CartCreateWithoutCartItemsInput = {
|
||||
id: string
|
||||
user: UserCreateNestedOneWithoutCartsInput
|
||||
}
|
||||
|
||||
export type CartUncheckedCreateWithoutCartItemsInput = {
|
||||
id: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
@ -15367,12 +15335,10 @@ export namespace Prisma {
|
||||
}
|
||||
|
||||
export type CartUpdateWithoutCartItemsInput = {
|
||||
id?: StringFieldUpdateOperationsInput | string
|
||||
user?: UserUpdateOneRequiredWithoutCartsNestedInput
|
||||
}
|
||||
|
||||
export type CartUncheckedUpdateWithoutCartItemsInput = {
|
||||
id?: StringFieldUpdateOperationsInput | string
|
||||
userId?: StringFieldUpdateOperationsInput | string
|
||||
}
|
||||
|
||||
@ -15500,7 +15466,7 @@ export namespace Prisma {
|
||||
}
|
||||
|
||||
export type CartCreateManyUserInput = {
|
||||
id: string
|
||||
|
||||
}
|
||||
|
||||
export type AdressCreateManyUserInput = {
|
||||
@ -15591,17 +15557,15 @@ export namespace Prisma {
|
||||
}
|
||||
|
||||
export type CartUpdateWithoutUserInput = {
|
||||
id?: StringFieldUpdateOperationsInput | string
|
||||
cartItems?: CartItemUpdateManyWithoutCartNestedInput
|
||||
}
|
||||
|
||||
export type CartUncheckedUpdateWithoutUserInput = {
|
||||
id?: StringFieldUpdateOperationsInput | string
|
||||
cartItems?: CartItemUncheckedUpdateManyWithoutCartNestedInput
|
||||
}
|
||||
|
||||
export type CartUncheckedUpdateManyWithoutUserInput = {
|
||||
id?: StringFieldUpdateOperationsInput | string
|
||||
|
||||
}
|
||||
|
||||
export type AdressUpdateWithoutUserInput = {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-1d666464292a599bad3179d365e160013075f010f27e15f565fd192ad5ef967d",
|
||||
"name": "prisma-client-a0e1bef334afa5b609bdf0c15431f28835b869da62270d0786ce071578cdedb4",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "default.js",
|
||||
|
||||
@ -107,8 +107,7 @@ model Sellable {
|
||||
//////////////////////
|
||||
|
||||
model Cart {
|
||||
id String @id
|
||||
userId String
|
||||
userId String @id
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
cartItems CartItem[]
|
||||
@ -119,7 +118,7 @@ model CartItem {
|
||||
quantity Int
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -107,8 +107,7 @@ model Sellable {
|
||||
//////////////////////
|
||||
|
||||
model Cart {
|
||||
id String @id
|
||||
userId String
|
||||
userId String @id
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
cartItems CartItem[]
|
||||
@ -119,7 +118,7 @@ model CartItem {
|
||||
quantity Int
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
0
src/app/api/buy/route.ts
Normal file
0
src/app/api/buy/route.ts
Normal file
83
src/app/api/cart/route.ts
Normal file
83
src/app/api/cart/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,9 @@ export async function GET() {
|
||||
item: { select: { stock: true } },
|
||||
},
|
||||
});
|
||||
// and cache no store header
|
||||
|
||||
return NextResponse.json(sellables);
|
||||
return NextResponse.json(sellables, {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
@ -45,6 +45,12 @@ export async function GET() {
|
||||
shops: {
|
||||
select: { id: true, label: true, sellables: true },
|
||||
},
|
||||
carts: {
|
||||
select: {
|
||||
cartItems: { select: { itemId: true, quantity: true } },
|
||||
},
|
||||
},
|
||||
balance: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,21 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import Items from "~/components/sellable_items";
|
||||
import Search from "~/components/search";
|
||||
import CartButton from "~/components/cart";
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
type UserResponse = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
carts: {
|
||||
cartItems: {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
}[];
|
||||
}[];
|
||||
balance: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
session: Session | null;
|
||||
};
|
||||
|
||||
export default function HomeClient({ session }: Props) {
|
||||
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 (
|
||||
<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">
|
||||
{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} />
|
||||
|
||||
{session?.user ? (
|
||||
@ -40,8 +85,7 @@ export default function HomeClient({ session }: Props) {
|
||||
Suchodupin <span className="text-[hsl(280,100%,70%)]">MC</span> Shop
|
||||
</h1>
|
||||
|
||||
{/* ✅ query flows down */}
|
||||
<Items query={query} />
|
||||
<Items query={query} reloadUser={loadUser} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
292
src/components/cart.tsx
Normal file
292
src/components/cart.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
type ApiItem = {
|
||||
id: string;
|
||||
@ -30,57 +30,190 @@ const getImage = (name: string) => {
|
||||
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 [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(() => {
|
||||
const loadItems = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/items");
|
||||
const data = (await res.json()) as ApiItem[];
|
||||
void loadItems();
|
||||
const interval = setInterval(() => void loadItems(), 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [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) {
|
||||
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) =>
|
||||
formatName(item.item_name).toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{filteredItems.map((item) => (
|
||||
<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="relative">
|
||||
{/* Blur and darken overlay */}
|
||||
|
||||
<div className="w-70">
|
||||
<h2 className="text-xl font-bold">{formatName(item.item_name)}</h2>
|
||||
<p className="text-sm">{item.item.stock} available</p>
|
||||
<p className="text-xs text-white/70">Shop: {item.shop.label}</p>
|
||||
</div>
|
||||
<div className="relative z-10 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
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">
|
||||
<button className="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700">
|
||||
Buy
|
||||
</button>
|
||||
<h3 className="w-fit self-center text-xl font-bold">
|
||||
{item.price}$/{item.amount}
|
||||
</h3>
|
||||
<div className="w-70">
|
||||
<h2 className="text-xl font-bold">
|
||||
{formatName(item.item_name)}
|
||||
</h2>
|
||||
<p className="text-sm">{item.item.stock} available</p>
|
||||
<p className="text-xs text-white/70">Shop: {item.shop.label}</p>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user