feat(transfer): implement transfer functionality with user selection and balance validation

This commit is contained in:
ZareMate 2026-03-26 23:31:57 +01:00
parent 992c9a2e63
commit 5e2eaff7c4
4 changed files with 248 additions and 3 deletions

View File

@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { auth } from "~/server/auth";
import { db } from "~/server/db";
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { toUserId, amount } = (await request.json()) as {
toUserId: string;
amount: number;
};
if (!toUserId || typeof toUserId !== "string") {
return NextResponse.json({ error: "Invalid recipient" }, { status: 400 });
}
if (typeof amount !== "number" || isNaN(amount) || amount <= 0) {
return NextResponse.json(
{ error: "Amount must be a positive number" },
{ status: 400 },
);
}
const fromUserId = session.user.id;
if (fromUserId === toUserId) {
return NextResponse.json(
{ error: "Cannot transfer to yourself" },
{ status: 400 },
);
}
const [sender, recipient] = await Promise.all([
db.user.findUnique({
where: { id: fromUserId },
select: { id: true, balance: true },
}),
db.user.findUnique({
where: { id: toUserId },
select: { id: true, name: true },
}),
]);
if (!sender) {
return NextResponse.json({ error: "Sender not found" }, { status: 404 });
}
if (!recipient) {
return NextResponse.json({ error: "Recipient not found" }, { status: 404 });
}
if (sender.balance < amount) {
return NextResponse.json(
{ error: "Insufficient balance" },
{ status: 400 },
);
}
await db.$transaction([
db.user.update({
where: { id: fromUserId },
data: { balance: { decrement: amount } },
}),
db.user.update({
where: { id: toUserId },
data: { balance: { increment: amount } },
}),
]);
return NextResponse.json({ success: true, amount, to: recipient.name });
}

View File

@ -0,0 +1,19 @@
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?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const users = await db.user.findMany({
where: { id: { not: session.user.id } },
select: { id: true, name: true },
orderBy: { name: "asc" },
});
return NextResponse.json(users);
}

View File

@ -8,6 +8,7 @@ import CartButton from "~/components/cart";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import AccountButton from "./account"; import AccountButton from "./account";
import SellableItemsButton from "./new_items"; import SellableItemsButton from "./new_items";
import TransferButton from "./transfer";
type UserResponse = { type UserResponse = {
id: string; id: string;
@ -102,9 +103,10 @@ export default function HomeClient({ session }: Props) {
<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">
<div className="absolute top-4 right-4 flex items-center gap-4"> <div className="absolute top-4 right-4 flex items-center gap-4">
{userData && ( {userData && (
<div className="rounded-full bg-white/10 px-4 py-2 font-semibold"> <TransferButton
${userData.balance ?? 0} balance={userData.balance ?? 0}
</div> reloadUser={loadUser}
/>
)} )}
{session?.user && ( {session?.user && (
<> <>

149
src/components/transfer.tsx Normal file
View File

@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
type User = {
id: string;
name: string | null;
};
type Props = {
balance: number;
reloadUser: () => Promise<void>;
};
export default function TransferButton({ balance, reloadUser }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [toUserId, setToUserId] = useState("");
const [amount, setAmount] = useState("");
const [error, setError] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const openModal = async () => {
setError(null);
setAmount("");
setToUserId("");
try {
const res = await fetch("/api/users");
if (!res.ok) throw new Error("Failed to load users");
const data = (await res.json()) as User[];
setUsers(data);
if (data[0]) setToUserId(data[0].id);
setIsOpen(true);
} catch {
setError("Could not load users");
}
};
const send = async () => {
setError(null);
const parsed = parseFloat(amount);
if (!toUserId) return setError("Select a recipient");
if (isNaN(parsed) || parsed <= 0) return setError("Enter a valid amount");
if (parsed > balance) return setError("Insufficient balance");
setSending(true);
try {
const res = await fetch("/api/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ toUserId, amount: parsed }),
});
const data = (await res.json()) as { error?: string; to?: string };
if (!res.ok) {
setError(data.error ?? "Transfer failed");
return;
}
setIsOpen(false);
setAmount("");
await reloadUser();
} catch {
setError("Transfer failed");
} finally {
setSending(false);
}
};
return (
<>
<button
onClick={openModal}
className="rounded-full bg-white/10 px-4 py-2 font-semibold transition hover:bg-white/20"
>
${balance}
</button>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* backdrop */}
<div
className="absolute inset-0 bg-black/60"
onClick={() => setIsOpen(false)}
/>
<div className="relative z-10 w-full max-w-sm rounded-xl bg-neutral-900 p-6 text-white shadow-xl">
<h2 className="mb-5 text-xl font-bold">Transfer Balance</h2>
<div className="mb-4">
<label className="mb-1 block text-sm font-semibold text-white/70">
Recipient
</label>
<select
value={toUserId}
onChange={(e) => setToUserId(e.target.value)}
className="w-full rounded bg-white/10 px-3 py-2 text-sm text-white focus:outline-none"
>
{users.map((u) => (
<option key={u.id} value={u.id} className="bg-neutral-800">
{u.name ?? u.id}
</option>
))}
</select>
</div>
<div className="mb-5">
<label className="mb-1 block text-sm font-semibold text-white/70">
Amount{" "}
<span className="text-white/40">
(your balance: ${balance})
</span>
</label>
<input
type="number"
min={1}
step={1}
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0"
className="w-full rounded bg-white/10 px-3 py-2 text-sm focus:outline-none"
/>
</div>
{error && (
<p className="mb-3 rounded bg-red-500/20 px-3 py-2 text-sm text-red-300">
{error}
</p>
)}
<div className="flex gap-2">
<button
onClick={send}
disabled={sending}
className="flex-1 rounded bg-purple-600 py-2 font-bold transition hover:bg-purple-700 disabled:opacity-50"
>
{sending ? "Sending…" : "Send"}
</button>
<button
onClick={() => setIsOpen(false)}
className="rounded bg-white/10 px-4 py-2 font-semibold transition hover:bg-white/20"
>
Cancel
</button>
</div>
</div>
</div>
)}
</>
);
}