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 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
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