functional account and item management
This commit is contained in:
parent
ed5afb1dec
commit
9e52f569f8
1
public/icons/account.svg
Normal file
1
public/icons/account.svg
Normal 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
1
public/icons/cart.svg
Normal 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 |
1
public/icons/list_add.svg
Normal file
1
public/icons/list_add.svg
Normal 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 |
@ -1,19 +1,13 @@
|
||||
// make api route to get all sellables
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "~/server/auth";
|
||||
import { db } from "~/server/db";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const sellables = await db.sellable.findMany({
|
||||
include: {
|
||||
shop: { select: { label: true } },
|
||||
item: { select: { stock: true } },
|
||||
const sellables = await db.item.findMany({
|
||||
select: {
|
||||
shop: { select: { label: true, id: true } },
|
||||
item_name: true,
|
||||
stock: true,
|
||||
},
|
||||
});
|
||||
// and cache no store header
|
||||
|
||||
112
src/app/api/sellable/route.ts
Normal file
112
src/app/api/sellable/route.ts
Normal 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,8 @@ import Items from "~/components/sellable_items";
|
||||
import Search from "~/components/search";
|
||||
import CartButton from "~/components/cart";
|
||||
import type { Session } from "next-auth";
|
||||
import AccountButton from "./account";
|
||||
import SellableItemsButton from "./new_items";
|
||||
|
||||
type UserResponse = {
|
||||
id: string;
|
||||
@ -19,6 +21,20 @@ type UserResponse = {
|
||||
balance: number;
|
||||
};
|
||||
|
||||
type SellableResponse = {
|
||||
id: string;
|
||||
item_name: string;
|
||||
amount: number;
|
||||
price: number;
|
||||
enabled: boolean;
|
||||
shop: {
|
||||
label: string;
|
||||
};
|
||||
item: {
|
||||
stock: number;
|
||||
};
|
||||
};
|
||||
|
||||
type Props = {
|
||||
session: Session | null;
|
||||
};
|
||||
@ -26,6 +42,9 @@ type Props = {
|
||||
export default function HomeClient({ session }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [userData, setUserData] = useState<UserResponse | null>(null);
|
||||
const [sellableData, setSellableData] = useState<SellableResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch /api/user once and store globally here
|
||||
@ -44,8 +63,27 @@ export default function HomeClient({ session }: Props) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
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 (
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
<CartButton
|
||||
userData={userData}
|
||||
reloadUser={loadUser}
|
||||
loading={loading}
|
||||
/>
|
||||
{session?.user && (
|
||||
<>
|
||||
<CartButton
|
||||
userData={userData}
|
||||
reloadUser={loadUser}
|
||||
loading={loading}
|
||||
/>
|
||||
<AccountButton loading={loading} />
|
||||
<SellableItemsButton
|
||||
loading={loading}
|
||||
reloadSellable={loadSellable}
|
||||
reloadUser={loadUser}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Search query={query} setQuery={setQuery} />
|
||||
|
||||
@ -84,8 +132,13 @@ export default function HomeClient({ session }: Props) {
|
||||
<h1 className="text-5xl font-extrabold tracking-tight">
|
||||
Suchodupin <span className="text-[hsl(280,100%,70%)]">MC</span> Shop
|
||||
</h1>
|
||||
|
||||
<Items query={query} reloadUser={loadUser} />
|
||||
{sellableData && (
|
||||
<Items
|
||||
query={query}
|
||||
sellableData={sellableData}
|
||||
reloadUser={loadUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
264
src/components/account.tsx
Normal file
264
src/components/account.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IMAGES_MANIFEST } from "next/dist/shared/lib/constants";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
type UserResponse = {
|
||||
@ -80,7 +81,7 @@ export default function CartButton({
|
||||
if (!userData) return;
|
||||
|
||||
const fetchItems = async () => {
|
||||
const res = await fetch("/api/items");
|
||||
const res = await fetch("/api/sellable");
|
||||
const items = (await res.json()) as ApiItem[];
|
||||
const itemMap = new Map(items.map((i) => [i.id, i]));
|
||||
|
||||
@ -196,7 +197,7 @@ export default function CartButton({
|
||||
disabled={loading}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
🛒 Cart
|
||||
<img src="/icons/cart.svg" alt="Cart Icon" className="h-6 w-6" />
|
||||
{totalQuantity > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white">
|
||||
{totalQuantity}
|
||||
|
||||
421
src/components/new_items.tsx
Normal file
421
src/components/new_items.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
type ApiItem = {
|
||||
id: string;
|
||||
@ -32,11 +32,12 @@ const getImage = (name: string) => {
|
||||
|
||||
type ItemsProps = {
|
||||
query: string;
|
||||
sellableData: ApiItem[];
|
||||
reloadUser: () => Promise<void>;
|
||||
};
|
||||
|
||||
const Items = ({ query, reloadUser }: ItemsProps) => {
|
||||
const [items, setItems] = useState<ApiItem[]>([]);
|
||||
const Items = ({ query, sellableData, reloadUser }: ItemsProps) => {
|
||||
const items = sellableData || []; // <-- fallback to empty array
|
||||
const [dragging, setDragging] = useState<{
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
@ -46,22 +47,6 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
|
||||
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(() => {
|
||||
void loadItems();
|
||||
const interval = setInterval(() => void loadItems(), 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadItems]);
|
||||
|
||||
const buyItem = useCallback(
|
||||
async (id: string, quantity: number) => {
|
||||
try {
|
||||
@ -72,15 +57,14 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
|
||||
});
|
||||
if (!res.ok) throw new Error("Buy failed");
|
||||
|
||||
await reloadUser(); // all components now update
|
||||
await reloadUser();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
},
|
||||
[reloadUser], // <- include reloadUser as dependency
|
||||
[reloadUser],
|
||||
);
|
||||
|
||||
// Handle hold to activate slider
|
||||
const handleMouseDown = (e: React.MouseEvent, item: ApiItem) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -94,7 +78,7 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
|
||||
max: item.item.stock / item.amount,
|
||||
rect: button,
|
||||
});
|
||||
}, 300); // 1 second threshold
|
||||
}, 300);
|
||||
|
||||
setHoldTimeout(timeout);
|
||||
};
|
||||
@ -108,7 +92,7 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
|
||||
}
|
||||
|
||||
if (!sliderActive) {
|
||||
void buyItem(item.id, 1); // quick click
|
||||
void buyItem(item.id, 1);
|
||||
}
|
||||
};
|
||||
|
||||
@ -156,8 +140,6 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{filteredItems.map((item) => (
|
||||
<div
|
||||
@ -191,7 +173,6 @@ const Items = ({ query, reloadUser }: ItemsProps) => {
|
||||
{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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user