feat(transfer): implement transfer functionality with user selection and balance validation
This commit is contained in:
parent
992c9a2e63
commit
5e2eaff7c4
75
src/app/api/transfer/route.ts
Normal file
75
src/app/api/transfer/route.ts
Normal 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 });
|
||||
}
|
||||
19
src/app/api/users/route.ts
Normal file
19
src/app/api/users/route.ts
Normal 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);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import CartButton from "~/components/cart";
|
||||
import type { Session } from "next-auth";
|
||||
import AccountButton from "./account";
|
||||
import SellableItemsButton from "./new_items";
|
||||
import TransferButton from "./transfer";
|
||||
|
||||
type UserResponse = {
|
||||
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">
|
||||
<div className="absolute top-4 right-4 flex items-center gap-4">
|
||||
{userData && (
|
||||
<div className="rounded-full bg-white/10 px-4 py-2 font-semibold">
|
||||
${userData.balance ?? 0}
|
||||
</div>
|
||||
<TransferButton
|
||||
balance={userData.balance ?? 0}
|
||||
reloadUser={loadUser}
|
||||
/>
|
||||
)}
|
||||
{session?.user && (
|
||||
<>
|
||||
|
||||
149
src/components/transfer.tsx
Normal file
149
src/components/transfer.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user