456 lines
15 KiB
TypeScript
456 lines
15 KiB
TypeScript
"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>;
|
||
};
|
||
|
||
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,
|
||
reloadUser,
|
||
}: Props) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
|
||
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 | "">("");
|
||
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,
|
||
),
|
||
)
|
||
.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
|
||
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>
|
||
|
||
<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
|
||
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>{formatName(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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|