456 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)}
</>
);
}