functional account and item management

This commit is contained in:
ZareMate 2026-01-23 12:13:27 +01:00
parent ed5afb1dec
commit 9e52f569f8
10 changed files with 877 additions and 48 deletions

1
public/icons/account.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M234-276q51-39 114-61.5T480-360q69 0 132 22.5T726-276q35-41 54.5-93T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 59 19.5 111t54.5 93Zm246-164q-59 0-99.5-40.5T340-580q0-59 40.5-99.5T480-720q59 0 99.5 40.5T620-580q0 59-40.5 99.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q53 0 100-15.5t86-44.5q-39-29-86-44.5T480-280q-53 0-100 15.5T294-220q39 29 86 44.5T480-160Zm0-360q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm0-60Zm0 360Z"/></svg>

After

Width:  |  Height:  |  Size: 751 B

1
public/icons/cart.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M280-80q-33 0-56.5-23.5T200-160q0-33 23.5-56.5T280-240q33 0 56.5 23.5T360-160q0 33-23.5 56.5T280-80Zm400 0q-33 0-56.5-23.5T600-160q0-33 23.5-56.5T680-240q33 0 56.5 23.5T760-160q0 33-23.5 56.5T680-80ZM246-720l96 200h280l110-200H246Zm-38-80h590q23 0 35 20.5t1 41.5L692-482q-11 20-29.5 31T622-440H324l-44 80h480v80H280q-45 0-68-39.5t-2-78.5l54-98-144-304H40v-80h130l38 80Zm134 280h280-280Z"/></svg>

After

Width:  |  Height:  |  Size: 511 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M680-40v-120H560v-80h120v-120h80v120h120v80H760v120h-80ZM200-200v-560 560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v353q-18-11-38-18t-42-11v-324H200v560h280q0 21 3 41t10 39H200Zm120-160q17 0 28.5-11.5T360-320q0-17-11.5-28.5T320-360q-17 0-28.5 11.5T280-320q0 17 11.5 28.5T320-280Zm0-160q17 0 28.5-11.5T360-480q0-17-11.5-28.5T320-520q-17 0-28.5 11.5T280-480q0 17 11.5 28.5T320-440Zm0-160q17 0 28.5-11.5T360-640q0-17-11.5-28.5T320-680q-17 0-28.5 11.5T280-640q0 17 11.5 28.5T320-600Zm120 160h240v-80H440v80Zm0-160h240v-80H440v80Zm0 320h54q8-23 20-43t28-37H440v80Z"/></svg>

After

Width:  |  Height:  |  Size: 727 B

View File

@ -1,19 +1,13 @@
// make api route to get all sellables // make api route to get all sellables
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "~/server/auth";
import { db } from "~/server/db"; import { db } from "~/server/db";
export async function GET() { export async function GET() {
const session = await auth(); const sellables = await db.item.findMany({
select: {
if (!session) { shop: { select: { label: true, id: true } },
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); item_name: true,
} stock: true,
const sellables = await db.sellable.findMany({
include: {
shop: { select: { label: true } },
item: { select: { stock: true } },
}, },
}); });
// and cache no store header // and cache no store header

View File

@ -0,0 +1,112 @@
// make api route to get all sellables
import { NextResponse } from "next/server";
import { db } from "~/server/db";
export async function GET() {
const sellables = await db.sellable.findMany({
include: {
shop: { select: { label: true } },
item: { select: { stock: true } },
},
});
// and cache no store header
return NextResponse.json(sellables, {
headers: { "Cache-Control": "no-store" },
});
}
type Sellable = {
shopId: number;
itemId: string;
itemName: string;
price: number;
amount: number;
};
export async function POST(request: Request) {
const { shopId, itemId, price, amount } = (await request.json()) as Sellable;
const sellable = await db.sellable.create({
data: {
shopId,
item_name: itemId,
price,
amount,
},
});
return NextResponse.json(sellable, {
headers: { "Cache-Control": "no-store" },
});
}
export async function PATCH(request: Request) {
const { shopId, itemId, price, amount } = (await request.json()) as Sellable;
if (!shopId || !itemId) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
const item = await db.sellable.findFirst({
where: {
shopId,
item_name: itemId,
},
select: {
id: true,
},
});
if (!item) {
return NextResponse.json({ error: "Item not found" }, { status: 404 });
}
const updated = await db.sellable.update({
where: {
id: item.id,
},
data: {
price,
amount,
},
});
return NextResponse.json(updated, {
headers: { "Cache-Control": "no-store" },
});
}
export async function DELETE(request: Request) {
const { shopId, itemId } = (await request.json()) as Sellable;
if (!shopId || !itemId) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
const item = await db.sellable.findFirst({
where: {
shopId,
item_name: itemId,
},
select: {
id: true,
},
});
if (!item) {
return NextResponse.json({ error: "Item not found" }, { status: 404 });
}
await db.sellable.delete({
where: {
id: item.id,
},
});
return NextResponse.json(
{ success: true },
{
headers: { "Cache-Control": "no-store" },
},
);
}

View File

@ -6,6 +6,8 @@ import Items from "~/components/sellable_items";
import Search from "~/components/search"; import Search from "~/components/search";
import CartButton from "~/components/cart"; import CartButton from "~/components/cart";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import AccountButton from "./account";
import SellableItemsButton from "./new_items";
type UserResponse = { type UserResponse = {
id: string; id: string;
@ -19,6 +21,20 @@ type UserResponse = {
balance: number; balance: number;
}; };
type SellableResponse = {
id: string;
item_name: string;
amount: number;
price: number;
enabled: boolean;
shop: {
label: string;
};
item: {
stock: number;
};
};
type Props = { type Props = {
session: Session | null; session: Session | null;
}; };
@ -26,6 +42,9 @@ type Props = {
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 [userData, setUserData] = useState<UserResponse | null>(null);
const [sellableData, setSellableData] = useState<SellableResponse | null>(
null,
);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Fetch /api/user once and store globally here // Fetch /api/user once and store globally here
@ -44,8 +63,27 @@ export default function HomeClient({ session }: Props) {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!session) return;
void loadUser(); void loadUser();
}, [loadUser]); }, [loadUser, session]);
const loadSellable = useCallback(async () => {
try {
setLoading(true);
const res = await fetch("/api/sellable");
if (!res.ok) throw new Error("Failed to fetch sellable");
const data = (await res.json()) as SellableResponse;
setSellableData(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadSellable();
}, [loadSellable]);
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">
@ -55,11 +93,21 @@ export default function HomeClient({ session }: Props) {
${userData.balance ?? 0} ${userData.balance ?? 0}
</div> </div>
)} )}
{session?.user && (
<>
<CartButton <CartButton
userData={userData} userData={userData}
reloadUser={loadUser} reloadUser={loadUser}
loading={loading} loading={loading}
/> />
<AccountButton loading={loading} />
<SellableItemsButton
loading={loading}
reloadSellable={loadSellable}
reloadUser={loadUser}
/>
</>
)}
<Search query={query} setQuery={setQuery} /> <Search query={query} setQuery={setQuery} />
@ -84,8 +132,13 @@ export default function HomeClient({ session }: Props) {
<h1 className="text-5xl font-extrabold tracking-tight"> <h1 className="text-5xl font-extrabold tracking-tight">
Suchodupin <span className="text-[hsl(280,100%,70%)]">MC</span> Shop Suchodupin <span className="text-[hsl(280,100%,70%)]">MC</span> Shop
</h1> </h1>
{sellableData && (
<Items query={query} reloadUser={loadUser} /> <Items
query={query}
sellableData={sellableData}
reloadUser={loadUser}
/>
)}
</div> </div>
</main> </main>
); );

264
src/components/account.tsx Normal file
View File

@ -0,0 +1,264 @@
"use client";
import { useState } from "react";
type Shop = {
id: number;
label: string;
};
type UserData = {
adresses: string[];
shops: Shop[];
};
type Props = {
loading: boolean;
};
export default function AccountButton({ loading }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [userData, setUserData] = useState<UserData>({
adresses: [],
shops: [],
});
const [newAddress, setNewAddress] = useState("");
const [shopId, setShopId] = useState<number | "">("");
const [shopLabel, setShopLabel] = useState("");
/* ─────────────── LOAD USER ON OPEN ─────────────── */
const loadUser = async (): Promise<boolean> => {
try {
const res = await fetch("/api/user");
if (!res.ok) {
console.error("Failed to load user data");
return false;
}
const data = await res.json();
setUserData({
adresses: data.adresses ?? [],
shops: data.shops ?? [],
});
return true;
} catch (err) {
console.error("Error loading user data", err);
return false;
}
};
const openModal = async () => {
const ok = await loadUser();
if (ok) setIsOpen(true);
};
/* ─────────────── SAVE ─────────────── */
const saveAll = async (next: UserData): Promise<Response | null> => {
try {
return await fetch("/api/user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
});
} catch (err) {
console.error("Save failed", err);
return null;
}
};
/* ─────────────── ADD / REMOVE ADDRESSES ─────────────── */
const addAddress = async () => {
const value = newAddress.trim();
if (!value) return;
const next = {
...userData,
adresses: [...userData.adresses, value],
};
const res = await saveAll(next);
if (!res || !res.ok) {
console.error("Failed to add address");
return;
}
setUserData(next);
setNewAddress("");
};
const removeAddress = async (adress: string) => {
const next = {
...userData,
adresses: userData.adresses.filter((a) => a !== adress),
};
const res = await saveAll(next);
if (!res || !res.ok) {
console.error("Failed to remove address");
return;
}
setUserData(next);
};
/* ─────────────── ADD / REMOVE SHOPS ─────────────── */
const addShop = async () => {
if (shopId === "" || !Number.isInteger(shopId)) return;
if (!shopLabel.trim()) return;
const next = {
...userData,
shops: [...userData.shops, { id: shopId, label: shopLabel.trim() }],
};
const res = await saveAll(next);
if (!res || !res.ok) {
console.error("Failed to add shop");
return;
}
setUserData(next);
setShopId("");
setShopLabel("");
};
const removeShop = async (id: number) => {
const next = {
...userData,
shops: userData.shops.filter((s) => s.id !== id),
};
const res = await saveAll(next);
if (!res || !res.ok) {
console.error("Failed to remove shop");
return;
}
setUserData(next);
};
/* ─────────────── UI ─────────────── */
return (
<>
{/* BUTTON */}
<button
className="relative rounded-full bg-white/10 px-4 py-2 font-semibold hover:bg-white/20"
disabled={loading}
onClick={openModal}
>
<img src="/icons/account.svg" alt="User Icon" className="h-6 w-6" />
</button>
{/* MODAL */}
{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-lg rounded-xl bg-neutral-900 p-6 text-white shadow-xl">
<h2 className="mb-6 text-xl font-bold">Account Settings</h2>
{/* ───────── ADDRESSES ───────── */}
<section className="mb-6">
<h3 className="mb-2 text-lg font-semibold">Addresses</h3>
<ul className="mb-3 space-y-2">
{userData.adresses.map((adress) => (
<li
key={adress}
className="flex items-center justify-between rounded bg-white/5 px-3 py-2"
>
<span className="text-sm break-all">{adress}</span>
<button
onClick={() => removeAddress(adress)}
className="rounded bg-red-500 px-2 py-1 text-xs font-semibold hover:bg-red-600"
>
Remove
</button>
</li>
))}
</ul>
<div className="flex gap-2">
<input
value={newAddress}
onChange={(e) => setNewAddress(e.target.value)}
placeholder="PKP Centralny: Poczta - zaremate"
className="flex-1 rounded bg-white/10 px-3 py-2 text-sm"
/>
<button
onClick={addAddress}
className="rounded bg-green-500 px-3 py-2 text-sm font-bold hover:bg-green-600"
>
Add
</button>
</div>
</section>
{/* ───────── SHOPS ───────── */}
<section>
<h3 className="mb-2 text-lg font-semibold">Shops</h3>
<ul className="mb-3 space-y-2">
{userData.shops.map((shop) => (
<li
key={shop.id}
className="flex items-center justify-between rounded bg-white/5 px-3 py-2"
>
<span className="text-sm">
#{shop.id} {shop.label}
</span>
<button
onClick={() => removeShop(shop.id)}
className="rounded bg-red-500 px-2 py-1 text-xs font-semibold hover:bg-red-600"
>
Remove
</button>
</li>
))}
</ul>
<div className="grid grid-cols-2 gap-2">
<input
type="number"
step={1}
value={shopId}
onChange={(e) =>
setShopId(
e.target.value === "" ? "" : Number(e.target.value),
)
}
placeholder="Shop ID (e.g. 12)"
className="rounded bg-white/10 px-3 py-2 text-sm"
/>
<input
value={shopLabel}
onChange={(e) => setShopLabel(e.target.value)}
placeholder="Label"
className="rounded bg-white/10 px-3 py-2 text-sm"
/>
</div>
<button
onClick={addShop}
className="mt-3 w-full rounded bg-green-500 py-2 font-bold hover:bg-green-600"
>
Add Shop
</button>
</section>
</div>
</div>
)}
</>
);
}

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { IMAGES_MANIFEST } from "next/dist/shared/lib/constants";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
type UserResponse = { type UserResponse = {
@ -80,7 +81,7 @@ export default function CartButton({
if (!userData) return; if (!userData) return;
const fetchItems = async () => { const fetchItems = async () => {
const res = await fetch("/api/items"); const res = await fetch("/api/sellable");
const items = (await res.json()) as ApiItem[]; const items = (await res.json()) as ApiItem[];
const itemMap = new Map(items.map((i) => [i.id, i])); const itemMap = new Map(items.map((i) => [i.id, i]));
@ -196,7 +197,7 @@ export default function CartButton({
disabled={loading} disabled={loading}
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
🛒 Cart <img src="/icons/cart.svg" alt="Cart Icon" className="h-6 w-6" />
{totalQuantity > 0 && ( {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"> <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} {totalQuantity}

View File

@ -0,0 +1,421 @@
"use client";
import { useState } from "react";
type Shop = {
id: number;
label: string;
};
type UserData = {
shops: Shop[];
};
type ItemFromApi = {
item_name: string;
stock: number;
shop: Shop;
};
type SellableFromApi = {
shopId: number;
item_name: string;
price: number;
amount: number;
shop: {
label: string;
};
};
type Props = {
loading: boolean;
reloadSellable: () => Promise<void>;
reloadUser: () => Promise<void>;
};
export default function SellableItemsButton({
loading,
reloadSellable,
reloadUser,
}: Props) {
const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState<ItemFromApi[]>([]);
const [sellables, setSellables] = useState<SellableFromApi[]>([]);
const [userShops, setUserShops] = useState<Shop[]>([]);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [price, setPrice] = useState<number | "">("");
const [amount, setAmount] = useState<number | "">("");
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editPrice, setEditPrice] = useState<number | "">("");
const [editAmount, setEditAmount] = useState<number | "">("");
// Format price as 0.00$
const formatPrice = (p: number) => `${p.toFixed(2)}$`;
const sellableKey = (s: SellableFromApi) => `${s.shopId}:${s.item_name}`;
/* ─────────────── LOAD ALL DATA ─────────────── */
const loadAll = async (): Promise<boolean> => {
try {
const [userRes, itemsRes, sellablesRes] = await Promise.all([
fetch("/api/user", { cache: "no-store" }),
fetch("/api/items", { cache: "no-store" }),
fetch("/api/sellable", { cache: "no-store" }),
]);
if (!userRes.ok || !itemsRes.ok || !sellablesRes.ok) {
console.error("Failed to load data");
return false;
}
const userData: UserData = (await userRes.json()) as UserData;
const itemsData = (await itemsRes.json()) as ItemFromApi[];
const sellablesData = (await sellablesRes.json()) as SellableFromApi[];
setUserShops(userData.shops ?? []);
setItems(itemsData);
setSellables(sellablesData);
return true;
} catch (err) {
console.error("Error loading data", err);
return false;
}
};
const openModal = async () => {
const ok = await loadAll();
if (ok) setIsOpen(true);
};
/* ─────────────── ADD SELLABLE ─────────────── */
const addSellable = async () => {
if (selectedIndex === null || price === "" || amount === "") return;
const item = items[selectedIndex];
if (!item) return;
const res = await fetch("/api/sellable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
shopId: item.shop.id,
itemId: item.item_name,
price,
amount,
}),
});
if (!res.ok) {
console.error("Failed to add sellable");
return;
}
// Update state locally so item disappears immediately
setSellables((prev) => [
...prev,
{
shopId: item.shop.id,
item_name: item.item_name,
price,
amount,
shop: { label: item.shop.label },
},
]);
setSelectedIndex(null);
setPrice("");
setAmount("");
void reloadSellable();
void reloadUser();
};
/* ─────────────── REMOVE SELLABLE ─────────────── */
const removeSellable = async (shopId: number, itemId: string) => {
const res = await fetch("/api/sellable", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ shopId, itemId }),
});
if (!res.ok) {
console.error("Failed to remove sellable");
return;
}
// Remove locally
setSellables((prev) =>
prev.filter((s) => !(s.shopId === shopId && s.item_name === itemId)),
);
void reloadSellable();
void reloadUser();
};
/* ─────────────── EDIT SELLABLE ─────────────── */
const saveEdit = async (shopId: number, itemId: string) => {
if (editPrice === "" || editAmount === "") return;
const res = await fetch("/api/sellable", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
shopId,
itemId,
price: editPrice,
amount: editAmount,
}),
});
if (!res.ok) {
console.error("Failed to update sellable");
return;
}
// Update locally
setSellables((prev) =>
prev.map((s) =>
s.shopId === shopId && s.item_name === itemId
? { ...s, price: editPrice, amount: editAmount }
: s,
),
);
setEditingKey(null);
void reloadSellable();
void reloadUser();
};
/* ─────────────── FILTERING ─────────────── */
const userShopIds = new Set(userShops.map((s) => s.id));
// Items available to add (user's shops, not already sold)
const availableItems = items
.filter((item) => userShopIds.has(item.shop.id))
.filter(
(item) =>
!sellables.some(
(s) => s.shopId === item.shop.id && s.item_name === item.item_name,
),
);
// Sellables grouped by user's shops
const sellablesByShop = sellables.reduce<Record<number, SellableFromApi[]>>(
(acc, s) => {
if (!userShopIds.has(s.shopId)) return acc;
acc[s.shopId] ??= [];
acc[s.shopId].push(s);
return acc;
},
{},
);
/* ─────────────── UI ─────────────── */
return (
<>
{/* BUTTON */}
<button
className="relative rounded-full bg-white/10 px-4 py-2 font-semibold hover:bg-white/20"
disabled={loading}
onClick={openModal}
>
<img src="/icons/list_add.svg" alt="Shop Icon" className="h-6 w-6" />
</button>
{/* MODAL */}
{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-2xl rounded-xl bg-neutral-900 p-6 text-white shadow-xl">
<h2 className="mb-6 text-xl font-bold">Store Items</h2>
{/* ───────── CURRENTLY SOLD ───────── */}
<section className="mb-8">
<h3 className="mb-3 text-lg font-semibold">
Currently Sold (Your Shops)
</h3>
{Object.keys(sellablesByShop).length === 0 && (
<p className="text-sm text-neutral-400">
No items are currently sold.
</p>
)}
<div className="space-y-4">
{Object.entries(sellablesByShop).map(([shopId, items]) => (
<div key={shopId} className="rounded-lg bg-white/5 p-3">
<h4 className="mb-2 font-semibold">
{items[0]?.shop.label ?? `Shop ${shopId}`}
</h4>
<ul className="space-y-1 text-sm">
{items.map((s) => {
const key = sellableKey(s);
const isEditing = editingKey === key;
return (
<li
key={key}
className="flex items-center justify-between gap-2 rounded bg-black/20 px-2 py-1"
>
<span className="flex-1">{s.item_name}</span>
{isEditing ? (
<>
<input
type="number"
step={0.01}
value={editPrice}
onChange={(e) =>
setEditPrice(
e.target.value === ""
? ""
: Number(e.target.value),
)
}
className="w-20 rounded bg-white/10 px-2 py-1 text-xs"
placeholder="Price (0.00$)"
/>
<input
type="number"
value={editAmount}
min={1}
onChange={(e) =>
setEditAmount(
e.target.value === ""
? ""
: Number(e.target.value),
)
}
className="w-20 rounded bg-white/10 px-2 py-1 text-xs"
/>
<button
onClick={() =>
saveEdit(s.shopId, s.item_name)
}
className="text-xs font-bold text-green-400"
>
Save
</button>
<button
onClick={() => setEditingKey(null)}
className="text-xs text-neutral-400"
>
Cancel
</button>
</>
) : (
<>
<span className="text-xs text-neutral-400">
{s.amount} × {formatPrice(s.price)}
</span>
<button
onClick={() => {
setEditingKey(key);
setEditPrice(s.price);
setEditAmount(s.amount);
}}
className="text-xs text-blue-400"
>
Edit
</button>
<button
onClick={() =>
removeSellable(s.shopId, s.item_name)
}
className="text-xs text-red-400"
>
Remove
</button>
</>
)}
</li>
);
})}
</ul>
</div>
))}
</div>
</section>
{/* ───────── ADD NEW ───────── */}
<section>
<h3 className="mb-3 text-lg font-semibold">Add Item to Store</h3>
<ul className="mb-4 max-h-56 space-y-2 overflow-y-auto">
{availableItems.map((item, index) => (
<li
key={`${item.shop.id}-${item.item_name}`}
onClick={() => setSelectedIndex(index)}
className={`cursor-pointer rounded px-3 py-2 ${
selectedIndex === index
? "bg-green-500/20 ring-1 ring-green-500"
: "bg-white/5 hover:bg-white/10"
}`}
>
<div className="flex justify-between text-sm">
<span>{item.item_name}</span>
<span className="text-neutral-400">
{item.shop.label}
</span>
</div>
<div className="text-xs text-neutral-400">
Stock: {item.stock}
</div>
</li>
))}
</ul>
<div className="grid grid-cols-2 gap-3">
<input
type="number"
step={0.01}
min={0}
value={price}
onChange={(e) =>
setPrice(
e.target.value === "" ? "" : Number(e.target.value),
)
}
placeholder="Price (0.00$)"
className="rounded bg-white/10 px-3 py-2 text-sm"
/>
<input
type="number"
min={1}
value={amount}
onChange={(e) =>
setAmount(
e.target.value === "" ? "" : Number(e.target.value),
)
}
placeholder="Amount"
className="rounded bg-white/10 px-3 py-2 text-sm"
/>
</div>
<button
onClick={addSellable}
disabled={selectedIndex === null}
className="mt-4 w-full rounded bg-green-500 py-3 font-bold text-black hover:bg-green-600 disabled:bg-gray-400"
>
Add to Store
</button>
</section>
</div>
</div>
)}
</>
);
}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useState, useCallback, useEffect } from "react";
type ApiItem = { type ApiItem = {
id: string; id: string;
@ -32,11 +32,12 @@ const getImage = (name: string) => {
type ItemsProps = { type ItemsProps = {
query: string; query: string;
sellableData: ApiItem[];
reloadUser: () => Promise<void>; reloadUser: () => Promise<void>;
}; };
const Items = ({ query, reloadUser }: ItemsProps) => { const Items = ({ query, sellableData, reloadUser }: ItemsProps) => {
const [items, setItems] = useState<ApiItem[]>([]); const items = sellableData || []; // <-- fallback to empty array
const [dragging, setDragging] = useState<{ const [dragging, setDragging] = useState<{
itemId: string; itemId: string;
quantity: number; quantity: number;
@ -46,22 +47,6 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
const [sliderActive, setSliderActive] = useState<string | null>(null); const [sliderActive, setSliderActive] = useState<string | null>(null);
const [holdTimeout, setHoldTimeout] = useState<NodeJS.Timeout | 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(() => {
void loadItems();
const interval = setInterval(() => void loadItems(), 60_000);
return () => clearInterval(interval);
}, [loadItems]);
const buyItem = useCallback( const buyItem = useCallback(
async (id: string, quantity: number) => { async (id: string, quantity: number) => {
try { try {
@ -72,15 +57,14 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
}); });
if (!res.ok) throw new Error("Buy failed"); if (!res.ok) throw new Error("Buy failed");
await reloadUser(); // all components now update await reloadUser();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
}, },
[reloadUser], // <- include reloadUser as dependency [reloadUser],
); );
// Handle hold to activate slider
const handleMouseDown = (e: React.MouseEvent, item: ApiItem) => { const handleMouseDown = (e: React.MouseEvent, item: ApiItem) => {
e.preventDefault(); e.preventDefault();
@ -94,7 +78,7 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
max: item.item.stock / item.amount, max: item.item.stock / item.amount,
rect: button, rect: button,
}); });
}, 300); // 1 second threshold }, 300);
setHoldTimeout(timeout); setHoldTimeout(timeout);
}; };
@ -108,7 +92,7 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
} }
if (!sliderActive) { if (!sliderActive) {
void buyItem(item.id, 1); // quick click void buyItem(item.id, 1);
} }
}; };
@ -156,8 +140,6 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
return ( return (
<div className="relative"> <div className="relative">
{/* Blur and darken overlay */}
<div className="relative z-10 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="relative z-10 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<div <div
@ -191,7 +173,6 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
{item.price}$/{item.amount} {item.price}$/{item.amount}
</h3> </h3>
{/* Slider on button */}
{sliderActive === item.id && dragging && ( {sliderActive === item.id && dragging && (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center"> <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-full w-full rounded">