better item primary key and cart updates, balance updating WIP
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "~/server/auth";
|
||||
import { db } from "~/server/db";
|
||||
|
||||
type Order = {
|
||||
cart: { id: string; quantity: number }[];
|
||||
address: string;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { address, cart } = (await request.json()) as Order;
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
try {
|
||||
if (!cart || cart.length === 0) {
|
||||
return NextResponse.json({ error: "Cart is empty" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch all cart items with related sellable and item to get shopId
|
||||
const cartItemsWithShop = await db.cartItem.findMany({
|
||||
where: { itemId: { in: cart.map((c) => c.id) } },
|
||||
include: {
|
||||
sellable: {
|
||||
include: {
|
||||
item: true, // include item to get shopId
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (cartItemsWithShop.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No valid items in cart" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Group items by shopId
|
||||
const itemsByShop: Record<number, { id: string; quantity: number }[]> = {};
|
||||
|
||||
for (const ci of cartItemsWithShop) {
|
||||
const shopId = ci.sellable.item.shopId; // get shopId from item
|
||||
itemsByShop[shopId] ??= []; // initialize if undefined or null
|
||||
itemsByShop[shopId].push({
|
||||
id: ci.sellable.item.item_name, // API expects item_name as id
|
||||
quantity: ci.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(itemsByShop);
|
||||
|
||||
// Send requests per shop
|
||||
for (const [shopId, items] of Object.entries(itemsByShop)) {
|
||||
const body = JSON.stringify({
|
||||
shopId: Number(shopId),
|
||||
address,
|
||||
items,
|
||||
});
|
||||
|
||||
console.log("Sending to shop:", shopId, body);
|
||||
|
||||
const response = await fetch(process.env.ITEM_SEND_API!, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to send items to shop ${shopId}: ${errorText}` },
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
// Delete successfully sent items from cart
|
||||
const itemIds = items.map((i) => i.id);
|
||||
await db.cartItem.deleteMany({
|
||||
where: {
|
||||
cartId: userId,
|
||||
sellable: { item_name: { in: itemIds } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type UserResponse = {
|
||||
}[];
|
||||
}[];
|
||||
balance: number;
|
||||
adresses: string[];
|
||||
};
|
||||
|
||||
type SellableResponse = {
|
||||
@@ -62,11 +63,6 @@ export default function HomeClient({ session }: Props) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
void loadUser();
|
||||
}, [loadUser, session]);
|
||||
|
||||
const loadSellable = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -82,8 +78,27 @@ export default function HomeClient({ session }: Props) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
|
||||
// Load immediately
|
||||
void loadUser();
|
||||
void loadSellable();
|
||||
}, [loadSellable]);
|
||||
|
||||
// Set intervals to reload every 30s (30000ms)
|
||||
const userInterval = setInterval(() => {
|
||||
void loadUser();
|
||||
}, 30000);
|
||||
|
||||
const sellableInterval = setInterval(() => {
|
||||
void loadSellable();
|
||||
}, 30000);
|
||||
|
||||
// Clear intervals on unmount
|
||||
return () => {
|
||||
clearInterval(userInterval);
|
||||
clearInterval(sellableInterval);
|
||||
};
|
||||
}, [session, loadUser, loadSellable]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-linear-to-b from-[#2e026d] to-[#7f3b3b] text-white">
|
||||
|
||||
+109
-13
@@ -1,17 +1,22 @@
|
||||
"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: {
|
||||
cartItems: {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
}[];
|
||||
}[];
|
||||
carts: Cart[];
|
||||
adresses: string[];
|
||||
balance: number;
|
||||
};
|
||||
|
||||
type CartViewItem = {
|
||||
@@ -20,6 +25,7 @@ type CartViewItem = {
|
||||
actualQuantity: number;
|
||||
itemAmount: number;
|
||||
stock: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
type ApiItem = {
|
||||
@@ -64,6 +70,16 @@ type DraggingState = {
|
||||
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,
|
||||
@@ -72,7 +88,9 @@ export default function CartButton({
|
||||
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);
|
||||
@@ -100,10 +118,13 @@ export default function CartButton({
|
||||
actualQuantity,
|
||||
itemAmount: item.amount,
|
||||
stock: item.item.stock,
|
||||
price: item.price,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedAddress) {
|
||||
setSelectedAddress(userData.adresses?.[0] ?? "");
|
||||
}
|
||||
setCartItems(collected);
|
||||
setTotalQuantity(total);
|
||||
};
|
||||
@@ -111,6 +132,21 @@ export default function CartButton({
|
||||
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 {
|
||||
@@ -185,9 +221,21 @@ export default function CartButton({
|
||||
};
|
||||
}, [dragging, handleMouseMove, handleMouseRelease]);
|
||||
|
||||
const buyDisabled = cartItems.some(
|
||||
(item) => item.stock < item.actualQuantity,
|
||||
);
|
||||
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 (
|
||||
<>
|
||||
@@ -239,7 +287,11 @@ export default function CartButton({
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Quantity: {item.actualQuantity} / Stock: {item.stock}
|
||||
Qty: {item.actualQuantity} · Price: $
|
||||
{formatPrice(item.price)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-green-400">
|
||||
Subtotal: ${formatPrice(getItemSubtotal(item))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,13 +330,57 @@ export default function CartButton({
|
||||
</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={() => alert("Dummy buy action")}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -33,6 +33,20 @@ type Props = {
|
||||
reloadUser: () => Promise<void>;
|
||||
};
|
||||
|
||||
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 getModId = (name: string) => {
|
||||
const [modId] = name.split(":");
|
||||
return modId ?? "";
|
||||
};
|
||||
|
||||
export default function SellableItemsButton({
|
||||
loading,
|
||||
reloadSellable,
|
||||
@@ -43,6 +57,7 @@ export default function SellableItemsButton({
|
||||
const [items, setItems] = useState<ItemFromApi[]>([]);
|
||||
const [sellables, setSellables] = useState<SellableFromApi[]>([]);
|
||||
const [userShops, setUserShops] = useState<Shop[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [price, setPrice] = useState<number | "">("");
|
||||
@@ -201,6 +216,14 @@ export default function SellableItemsButton({
|
||||
!sellables.some(
|
||||
(s) => s.shopId === item.shop.id && s.item_name === item.item_name,
|
||||
),
|
||||
)
|
||||
.filter(
|
||||
(item) =>
|
||||
formatName(item.item_name)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()) ||
|
||||
getModId(item.item_name).toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.item_name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
// Sellables grouped by user's shops
|
||||
@@ -353,6 +376,17 @@ export default function SellableItemsButton({
|
||||
<section>
|
||||
<h3 className="mb-3 text-lg font-semibold">Add Item to Store</h3>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setSelectedIndex(null); // reset selection when filtering
|
||||
}}
|
||||
placeholder="Search item by name..."
|
||||
className="mb-3 w-full rounded bg-white/10 px-3 py-2 text-sm placeholder:text-neutral-400"
|
||||
/>
|
||||
|
||||
<ul className="mb-4 max-h-56 space-y-2 overflow-y-auto">
|
||||
{availableItems.map((item, index) => (
|
||||
<li
|
||||
@@ -365,7 +399,7 @@ export default function SellableItemsButton({
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{item.item_name}</span>
|
||||
<span>{formatName(item.item_name)}</span>
|
||||
<span className="text-neutral-400">
|
||||
{item.shop.label}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user