diff --git a/public/icons/account.svg b/public/icons/account.svg new file mode 100644 index 00000000..7829edb6 --- /dev/null +++ b/public/icons/account.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/cart.svg b/public/icons/cart.svg new file mode 100644 index 00000000..303c257e --- /dev/null +++ b/public/icons/cart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/list_add.svg b/public/icons/list_add.svg new file mode 100644 index 00000000..7bc4a55b --- /dev/null +++ b/public/icons/list_add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/api/items/route.ts b/src/app/api/items/route.ts index 0af5d0e4..f93eb33b 100644 --- a/src/app/api/items/route.ts +++ b/src/app/api/items/route.ts @@ -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 diff --git a/src/app/api/sellable/route.ts b/src/app/api/sellable/route.ts new file mode 100644 index 00000000..47e50b4c --- /dev/null +++ b/src/app/api/sellable/route.ts @@ -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" }, + }, + ); +} diff --git a/src/components/HomeClient.tsx b/src/components/HomeClient.tsx index 9114955a..4658d141 100644 --- a/src/components/HomeClient.tsx +++ b/src/components/HomeClient.tsx @@ -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(null); + const [sellableData, setSellableData] = useState( + 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 (
@@ -55,11 +93,21 @@ export default function HomeClient({ session }: Props) { ${userData.balance ?? 0} )} - + {session?.user && ( + <> + + + + + )} @@ -84,8 +132,13 @@ export default function HomeClient({ session }: Props) {

Suchodupin MC Shop

- - + {sellableData && ( + + )}
); diff --git a/src/components/account.tsx b/src/components/account.tsx new file mode 100644 index 00000000..de8e9e97 --- /dev/null +++ b/src/components/account.tsx @@ -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({ + adresses: [], + shops: [], + }); + + const [newAddress, setNewAddress] = useState(""); + const [shopId, setShopId] = useState(""); + const [shopLabel, setShopLabel] = useState(""); + + /* ─────────────── LOAD USER ON OPEN ─────────────── */ + + const loadUser = async (): Promise => { + 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 => { + 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 */} + + + {/* MODAL */} + {isOpen && ( +
+
setIsOpen(false)} + /> + +
+

Account Settings

+ + {/* ───────── ADDRESSES ───────── */} +
+

Addresses

+ +
    + {userData.adresses.map((adress) => ( +
  • + {adress} + +
  • + ))} +
+ +
+ setNewAddress(e.target.value)} + placeholder="PKP Centralny: Poczta - zaremate" + className="flex-1 rounded bg-white/10 px-3 py-2 text-sm" + /> + +
+
+ + {/* ───────── SHOPS ───────── */} +
+

Shops

+ +
    + {userData.shops.map((shop) => ( +
  • + + #{shop.id} — {shop.label} + + +
  • + ))} +
+ +
+ + 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" + /> + setShopLabel(e.target.value)} + placeholder="Label" + className="rounded bg-white/10 px-3 py-2 text-sm" + /> +
+ + +
+
+
+ )} + + ); +} diff --git a/src/components/cart.tsx b/src/components/cart.tsx index a81ab130..22cf7d33 100644 --- a/src/components/cart.tsx +++ b/src/components/cart.tsx @@ -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 + Cart Icon {totalQuantity > 0 && ( {totalQuantity} diff --git a/src/components/new_items.tsx b/src/components/new_items.tsx new file mode 100644 index 00000000..e70350c0 --- /dev/null +++ b/src/components/new_items.tsx @@ -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; + reloadUser: () => Promise; +}; + +export default function SellableItemsButton({ + loading, + reloadSellable, + reloadUser, +}: Props) { + const [isOpen, setIsOpen] = useState(false); + + const [items, setItems] = useState([]); + const [sellables, setSellables] = useState([]); + const [userShops, setUserShops] = useState([]); + + const [selectedIndex, setSelectedIndex] = useState(null); + const [price, setPrice] = useState(""); + const [amount, setAmount] = useState(""); + + const [editingKey, setEditingKey] = useState(null); + const [editPrice, setEditPrice] = useState(""); + const [editAmount, setEditAmount] = useState(""); + + // 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 => { + 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>( + (acc, s) => { + if (!userShopIds.has(s.shopId)) return acc; + acc[s.shopId] ??= []; + acc[s.shopId].push(s); + return acc; + }, + {}, + ); + + /* ─────────────── UI ─────────────── */ + return ( + <> + {/* BUTTON */} + + + {/* MODAL */} + {isOpen && ( +
+
setIsOpen(false)} + /> + +
+

Store Items

+ + {/* ───────── CURRENTLY SOLD ───────── */} +
+

+ Currently Sold (Your Shops) +

+ + {Object.keys(sellablesByShop).length === 0 && ( +

+ No items are currently sold. +

+ )} + +
+ {Object.entries(sellablesByShop).map(([shopId, items]) => ( +
+

+ {items[0]?.shop.label ?? `Shop ${shopId}`} +

+ +
    + {items.map((s) => { + const key = sellableKey(s); + const isEditing = editingKey === key; + + return ( +
  • + {s.item_name} + + {isEditing ? ( + <> + + 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$)" + /> + + setEditAmount( + e.target.value === "" + ? "" + : Number(e.target.value), + ) + } + className="w-20 rounded bg-white/10 px-2 py-1 text-xs" + /> + + + + + ) : ( + <> + + {s.amount} × {formatPrice(s.price)} + + + + + + + )} +
  • + ); + })} +
+
+ ))} +
+
+ + {/* ───────── ADD NEW ───────── */} +
+

Add Item to Store

+ +
    + {availableItems.map((item, index) => ( +
  • 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" + }`} + > +
    + {item.item_name} + + {item.shop.label} + +
    +
    + Stock: {item.stock} +
    +
  • + ))} +
+ +
+ + setPrice( + e.target.value === "" ? "" : Number(e.target.value), + ) + } + placeholder="Price (0.00$)" + className="rounded bg-white/10 px-3 py-2 text-sm" + /> + + setAmount( + e.target.value === "" ? "" : Number(e.target.value), + ) + } + placeholder="Amount" + className="rounded bg-white/10 px-3 py-2 text-sm" + /> +
+ + +
+
+
+ )} + + ); +} diff --git a/src/components/sellable_items.tsx b/src/components/sellable_items.tsx index b5a58fce..0350f3e1 100644 --- a/src/components/sellable_items.tsx +++ b/src/components/sellable_items.tsx @@ -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; }; -const Items = ({ query, reloadUser }: ItemsProps) => { - const [items, setItems] = useState([]); +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(null); const [holdTimeout, setHoldTimeout] = useState(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 (
- {/* Blur and darken overlay */} -
{filteredItems.map((item) => (
{ {item.price}$/{item.amount} - {/* Slider on button */} {sliderActive === item.id && dragging && (