Initial Falah Mobile rebuild — all 7 blocks complete
- Auth system (login/register/profile) - Nur AI chat with persona system - Souq marketplace with FLH economy - Forum community with Shariah moderation - Wallet & FLH top-up - Premium/Pro tier upgrade with Polar.sh - Halal Monitor with map & bookmarks - Home dashboard with daily verse & streaks - Health endpoints Next.js 16.2.7, Prisma v5 SQLite, JWT auth, Tailwind CSS v4
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, FormEvent } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Wallet,
|
||||
Zap,
|
||||
Crown,
|
||||
TrendingUp,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
Clock,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
|
||||
interface CashoutEntry {
|
||||
id: string;
|
||||
amountFlh: number;
|
||||
fiatAmount: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const TOP_UP_OPTIONS = [
|
||||
{ flh: 500, usd: 4.99, bonus: "" },
|
||||
{ flh: 1100, usd: 9.99, bonus: "+10% bonus" },
|
||||
{ flh: 3000, usd: 24.99, bonus: "+20% bonus" },
|
||||
];
|
||||
|
||||
const EARNING_TIPS = [
|
||||
"Post in the forum — earn 50 FLH per approved post",
|
||||
"List items on Souq marketplace",
|
||||
"Complete daily AI coaching sessions",
|
||||
"Refer friends — earn 200 FLH per referral",
|
||||
"Participate in community challenges",
|
||||
];
|
||||
|
||||
export default function WalletPage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [cashoutHistory, setCashoutHistory] = useState<CashoutEntry[]>([]);
|
||||
const [cashoutAmount, setCashoutAmount] = useState("");
|
||||
const [cashoutLoading, setCashoutLoading] = useState(false);
|
||||
const [cashoutMessage, setCashoutMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [topupLoading, setTopupLoading] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) router.push("/login");
|
||||
}, [loading, token, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchWallet();
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const fetchWallet = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wallet", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBalance(data.balance);
|
||||
setCashoutHistory(data.cashoutHistory || []);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleTopUp = async (amount: number) => {
|
||||
if (!token) return;
|
||||
setTopupLoading(amount);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wallet/top-up/create-checkout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ amount }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Top-up failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
router.push(data.url);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Top-up failed";
|
||||
setCashoutMessage({ type: "error", text: message });
|
||||
setTimeout(() => setCashoutMessage(null), 3000);
|
||||
} finally {
|
||||
setTopupLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCashout = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
const amountNum = parseInt(cashoutAmount, 10);
|
||||
if (isNaN(amountNum) || amountNum < 1000) {
|
||||
setCashoutMessage({
|
||||
type: "error",
|
||||
text: "Minimum cashout is 1,000 FLH",
|
||||
});
|
||||
setTimeout(() => setCashoutMessage(null), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (amountNum > balance) {
|
||||
setCashoutMessage({
|
||||
type: "error",
|
||||
text: "Insufficient balance",
|
||||
});
|
||||
setTimeout(() => setCashoutMessage(null), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
setCashoutLoading(true);
|
||||
setCashoutMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wallet", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ amountFlh: amountNum }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Cashout failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setCashoutMessage({
|
||||
type: "success",
|
||||
text: `Cashout request for ${data.cashout.amountFlh} FLH submitted!`,
|
||||
});
|
||||
setCashoutAmount("");
|
||||
await fetchWallet();
|
||||
setTimeout(() => setCashoutMessage(null), 4000);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Cashout failed";
|
||||
setCashoutMessage({ type: "error", text: message });
|
||||
setTimeout(() => setCashoutMessage(null), 3000);
|
||||
} finally {
|
||||
setCashoutLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isPremium = user?.isPremium || user?.isPro;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
<div className="px-4 pt-6 pb-2">
|
||||
<h1 className="text-xl font-bold text-white">Wallet</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 space-y-5 animate-fade-in">
|
||||
{/* Balance Card */}
|
||||
<div className="bg-gradient-to-br from-[#D4AF37]/20 to-[#0a0a0f] border border-[#D4AF37]/30 rounded-2xl p-6 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-[#D4AF37]/20 flex items-center justify-center mx-auto mb-3">
|
||||
<Wallet size={24} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
|
||||
FLH Balance
|
||||
</p>
|
||||
<p className="text-4xl font-bold text-[#D4AF37]">
|
||||
{balance.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
≈ ${(balance / 1000).toFixed(2)} USD
|
||||
</p>
|
||||
|
||||
{!isPremium && (
|
||||
<div className="mt-4 bg-[#D4AF37]/10 border border-[#D4AF37]/20 rounded-xl px-4 py-3">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Crown size={14} className="text-[#D4AF37]" />
|
||||
<p className="text-xs text-[#D4AF37] font-medium">
|
||||
Premium members earn 2x FLH!
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push("/upgrade")}
|
||||
className="mt-2 text-[11px] text-[#D4AF37] underline underline-offset-2"
|
||||
>
|
||||
Upgrade now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{cashoutMessage && (
|
||||
<div
|
||||
className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
|
||||
cashoutMessage.type === "success"
|
||||
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
|
||||
: "bg-red-900/20 border border-red-800/40 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{cashoutMessage.type === "success" ? (
|
||||
<Check size={16} />
|
||||
) : (
|
||||
<X size={16} />
|
||||
)}
|
||||
{cashoutMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-Up Section */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Zap size={16} className="text-[#D4AF37]" />
|
||||
Top Up FLH
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{TOP_UP_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.flh}
|
||||
onClick={() => handleTopUp(opt.flh)}
|
||||
disabled={topupLoading === opt.flh}
|
||||
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 text-center active:scale-95 transition hover:border-[#D4AF37]/30 disabled:opacity-60"
|
||||
>
|
||||
{topupLoading === opt.flh ? (
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-[#D4AF37]" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg font-bold text-white">
|
||||
{opt.flh.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
${opt.usd.toFixed(2)}
|
||||
</p>
|
||||
{opt.bonus && (
|
||||
<p className="text-[10px] text-emerald-400 mt-1 font-medium">
|
||||
{opt.bonus}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cashout Form */}
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
||||
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<ArrowUpRight size={16} className="text-[#D4AF37]" />
|
||||
Cash Out FLH
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Convert your FLH to USD. Minimum 1,000 FLH (1,000 FLH = $1.00).
|
||||
</p>
|
||||
<form onSubmit={handleCashout} className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="number"
|
||||
min={1000}
|
||||
step={100}
|
||||
value={cashoutAmount}
|
||||
onChange={(e) => setCashoutAmount(e.target.value)}
|
||||
placeholder="Amount FLH"
|
||||
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={cashoutLoading}
|
||||
className="px-5 py-3 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm disabled:opacity-50 active:opacity-80 transition flex items-center gap-2"
|
||||
>
|
||||
{cashoutLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
"Cash Out"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{cashoutAmount && parseInt(cashoutAmount) >= 1000 && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
You will receive ~$
|
||||
{(parseInt(cashoutAmount) / 1000).toFixed(2)} USD
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transaction History */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Clock size={16} className="text-gray-500" />
|
||||
Cashout History
|
||||
</h2>
|
||||
{cashoutHistory.length === 0 ? (
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
|
||||
<TrendingUp size={20} className="mx-auto text-gray-700 mb-2" />
|
||||
<p className="text-sm text-gray-500">No cashouts yet</p>
|
||||
<p className="text-xs text-gray-700 mt-1">
|
||||
Your cashout requests will appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{cashoutHistory.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
entry.status === "approved"
|
||||
? "bg-emerald-900/20"
|
||||
: entry.status === "rejected"
|
||||
? "bg-red-900/20"
|
||||
: "bg-amber-900/20"
|
||||
}`}
|
||||
>
|
||||
{entry.status === "approved" ? (
|
||||
<ArrowDownLeft
|
||||
size={14}
|
||||
className="text-emerald-400"
|
||||
/>
|
||||
) : entry.status === "rejected" ? (
|
||||
<X size={14} className="text-red-400" />
|
||||
) : (
|
||||
<Clock size={14} className="text-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{entry.amountFlh.toLocaleString()} FLH
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
${entry.fiatAmount.toFixed(2)} —{" "}
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium capitalize ${
|
||||
entry.status === "approved"
|
||||
? "text-emerald-400"
|
||||
: entry.status === "rejected"
|
||||
? "text-red-400"
|
||||
: "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{entry.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Earning Tips */}
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
|
||||
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-[#D4AF37]" />
|
||||
Earn More FLH
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{EARNING_TIPS.map((tip, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-400">
|
||||
<Info size={12} className="text-[#D4AF37] mt-0.5 shrink-0" />
|
||||
<span>{tip}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user