cfff74e2db
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
436 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|