Files
falah-mobile/src/app/wallet/page.tsx
T
root cfff74e2db feat: Souq native integration + auth simplification + CE-wide enhancements
Souq Marketplace:
- Native souq pages: browse+filters, create listing, detail+seller profile, orders+chat
- New API: reviews (POST), sellers/[id], upload (multipart), polar-checkout webhook
- Listings API enhanced: minPrice, maxPrice, sortBy, deliveryDays filters
- Seller workflow: start order, mark delivered, upload files, confirm delivery
- Wallet: 5 Polar.sh FLH tiers, production checkout, mock fallback, success banner
- Fix: order redirect /souq/history → /souq/orders

Auth System:
- Unified auth page, removed OAuth provider routing (2 files deleted)
- Deleted oauth.ts (319 lines) — simplified auth chain
- Streamlined login/register API routes

Prisma Schema (+179 lines):
- New models: Listing, Purchase, Review, Group/GroupMember
- Gamification: DailyStreak, XpTransaction, Achievement, UserLevel
- Learning: LearnCourse/Module/Enrollment/Certificate
- Notifications, Referrals, Dhikr, PremiumBenefits

Deleted legacy code:
- src/app/api/marketplace/* (3 files) — replaced by /api/souq/*
- src/app/api/files/[listingId]/route.ts — replaced by /api/souq/upload
- src/app/api/seller/[sellerId]/route.ts — replaced by /api/souq/sellers/[id]

Infrastructure:
- Dockerfile: standalone output, Prisma runtime migration
- docker-compose.yml: Polar env vars, healthcheck fix
- docker-compose.staging.yml: staging on port 4014
- .gitea/workflows/ci.yml: CI pipeline
2026-06-24 07:02:03 +02:00

436 lines
15 KiB
TypeScript

"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";
import ErrorFeedback from "@/components/ErrorFeedback";
interface CashoutEntry {
id: string;
amountFlh: number;
fiatAmount: number;
status: string;
createdAt: string;
}
const TOP_UP_OPTIONS = [
{ flh: 500, usd: 0.99, bonus: "", bestValue: false },
{ flh: 1000, usd: 0.99, bonus: "", bestValue: true },
{ flh: 5000, usd: 4.99, bonus: "+500 bonus", bestValue: false },
{ flh: 10000, usd: 9.99, bonus: "+2,000 bonus", bestValue: false },
{ flh: 50000, usd: 49.99, bonus: "+15,000 bonus", bestValue: false },
];
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);
const [topupSuccess, setTopupSuccess] = useState(false);
useEffect(() => {
if (!loading && !token) router.push("/auth");
}, [loading, token, router]);
useEffect(() => {
if (token) {
fetchWallet();
}
}, [token]);
// Check for ?topup=success from Polar.sh or mock redirect
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("topup") === "success") {
setTopupSuccess(true);
fetchWallet();
// Clean URL param without reload
const url = new URL(window.location.href);
url.searchParams.delete("topup");
window.history.replaceState({}, "", url.toString());
const timer = setTimeout(() => setTopupSuccess(false), 6000);
return () => clearTimeout(timer);
}
}, []);
const fetchWallet = async () => {
try {
const res = await fetch("/mobile/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("/mobile/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("/mobile/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-xs text-[#D4AF37] underline underline-offset-2 py-1.5 px-2 -mx-2 rounded-lg"
>
Upgrade now
</button>
</div>
)}
</div>
{/* Messages */}
{cashoutMessage && cashoutMessage.type === "success" && (
<div className="flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
{cashoutMessage.text}
</div>
)}
{cashoutMessage && cashoutMessage.type === "error" && (
<ErrorFeedback error={cashoutMessage.text} kind="default" onRetry={() => setCashoutMessage(null)} context="wallet" />
)}
{/* Top-up Success Banner */}
{topupSuccess && (
<div className="flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
Top-up successful! FLH has been added to your balance.
</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-4">
{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 relative"
>
{opt.bestValue && (
<span className="absolute -top-2.5 inset-x-0 mx-auto w-fit px-2 py-0.5 bg-[#D4AF37] text-[#0a0a0f] text-[10px] font-bold rounded-full uppercase tracking-wider">
Best Value
</span>
)}
{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-[11px] text-emerald-400 mt-1.5 font-semibold">
{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-4 text-white placeholder-gray-600 focus:outline-none focus:border-[#D4AF37]/50 focus:ring-1 focus:ring-[#D4AF37]/30 transition min-h-[48px]"
/>
</div>
<button
type="submit"
disabled={cashoutLoading}
className="px-5 py-4 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm disabled:opacity-50 active:opacity-80 transition flex items-center gap-2 min-h-[48px]"
>
{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-xs 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-3 text-sm text-gray-400">
<Info size={16} className="text-[#D4AF37] mt-0.5 shrink-0" />
<span>{tip}</span>
</li>
))}
</ul>
</div>
<div className="h-4" />
</div>
</div>
);
}