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:
root
2026-06-15 09:28:22 +02:00
parent ab8a2053e1
commit 5483dd291e
56 changed files with 7619 additions and 100 deletions
+369
View File
@@ -0,0 +1,369 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useSearchParams } from "next/navigation";
import { Crown, Zap, Check, ChevronLeft, Loader2 } from "lucide-react";
const TIERS = [
{
id: "free",
name: "Free",
price: "$0",
period: "forever",
icon: Zap,
color: "text-gray-400",
bgColor: "bg-gray-900/50",
borderColor: "border-gray-700/50",
features: [
"10 AI messages per day",
"NurBuddy persona only",
"Basic halal monitor (10 queries/day)",
"Browse marketplace",
"Standard FLH earning rate",
],
cta: "Current Plan",
disabled: true,
},
{
id: "premium",
name: "Premium",
usd: 4.99,
price: "$4.99",
period: "per month",
priceId: "price_premium_monthly",
icon: Crown,
color: "text-[#D4AF37]",
bgColor: "bg-gradient-to-br from-[#D4AF37]/20 to-[#0a0a0f]",
borderColor: "border-[#D4AF37]/50",
features: [
"100 AI messages per day",
"All 4 coach personas",
"Unlimited halal monitor queries",
"Sell on marketplace (up to 20 items)",
"2x FLH earning rate",
"Forum posting access",
"Priority support",
],
cta: "Go Premium",
badge: "MOST POPULAR",
highlighted: true,
},
{
id: "pro",
name: "Pro",
usd: 9.99,
price: "$9.99",
period: "per month",
priceId: "price_pro_monthly",
icon: Crown,
color: "text-purple-400",
bgColor: "bg-purple-900/20",
borderColor: "border-purple-500/30",
features: [
"Unlimited AI messages",
"All 6 coach personas",
"Unlimited halal monitor queries",
"Sell on marketplace (unlimited items)",
"3x FLH earning rate",
"Forum posting access",
"Priority support",
"Early access to new features",
"Featured listings on marketplace",
],
cta: "Go Pro",
badge: "",
highlighted: false,
},
];
function UpgradeContent() {
const { user, token, loading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const [checkoutLoading, setCheckoutLoading] = useState<string | null>(null);
const [message, setMessage] = useState<{
type: "success" | "error" | "info";
text: string;
} | null>(null);
const isPremium = user?.isPremium || user?.isPro;
const isPro = user?.isPro;
useEffect(() => {
if (!loading && !token) router.push("/login");
}, [loading, token, router]);
useEffect(() => {
const upgrade = searchParams.get("upgrade");
const canceled = searchParams.get("canceled");
const tier = searchParams.get("tier");
if (upgrade === "success") {
setMessage({
type: "success",
text: tier
? `Welcome to ${tier === "pro" ? "Pro" : "Premium"}! 🎉`
: "Upgrade successful! Welcome aboard 🎉",
});
// Clean URL
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
// Refresh user data
if (token) {
fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((d) => {
if (d.user) {
// The AuthContext refreshUser will pick this up
window.location.reload();
}
})
.catch(() => {});
}
}
if (canceled === "true") {
setMessage({
type: "info",
text: "Checkout was canceled. No charges were made.",
});
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
}, [searchParams, token]);
const handleUpgrade = async (tierId: string, priceId: string) => {
if (!token) return;
setCheckoutLoading(tierId);
setMessage(null);
try {
const res = await fetch("/api/upgrade/create-checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ priceId }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Upgrade failed");
}
const data = await res.json();
if (data.url) {
router.push(data.url);
}
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Upgrade failed";
setMessage({ type: "error", text: message });
setTimeout(() => setMessage(null), 4000);
} finally {
setCheckoutLoading(null);
}
};
if (loading) {
return (
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<Loader2 size={24} className="animate-spin text-[#D4AF37]" />
</div>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-2 flex items-center gap-3">
<button
onClick={() => router.back()}
className="w-9 h-9 rounded-full bg-[#111118] border border-gray-800/60 flex items-center justify-center active:scale-90 transition"
>
<ChevronLeft size={18} className="text-gray-400" />
</button>
<div>
<h1 className="text-lg font-bold text-white">Choose your plan</h1>
<p className="text-xs text-gray-500">Cancel anytime</p>
</div>
</div>
{/* Trial Banner */}
<div className="mx-4 mt-2 mb-5 bg-gradient-to-r from-[#D4AF37]/10 to-purple-900/10 border border-[#D4AF37]/20 rounded-2xl p-3">
<div className="flex items-center gap-2">
<SparklesIcon />
<p className="text-sm text-[#D4AF37] font-medium">
New accounts include a 7-day Premium trial free
</p>
</div>
</div>
{/* Messages */}
{message && (
<div className="mx-4 mb-4">
<div
className={`p-3 rounded-xl text-sm text-center ${
message.type === "success"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: message.type === "error"
? "bg-red-900/20 border border-red-800/40 text-red-400"
: "bg-blue-900/20 border border-blue-800/40 text-blue-400"
}`}
>
{message.text}
</div>
</div>
)}
{/* Tier Cards */}
<div className="px-4 space-y-4">
{TIERS.map((tier) => {
const Icon = tier.icon;
const isActive =
tier.id === "pro"
? isPro
: tier.id === "premium"
? isPremium
: true;
const isDisabled = tier.disabled || isActive;
return (
<div
key={tier.id}
className={`relative rounded-2xl p-5 ${tier.bgColor} ${tier.borderColor} border ${
tier.highlighted ? "ring-2 ring-[#D4AF37]/30" : ""
}`}
>
{/* Badge */}
{tier.badge && (
<div className="absolute -top-2.5 right-4 bg-[#D4AF37] text-[#0a0a0f] text-[10px] font-bold uppercase tracking-wider px-3 py-1 rounded-full">
{tier.badge}
</div>
)}
{/* Tier Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Icon size={20} className={tier.color} />
<span className={`text-lg font-bold text-white`}>
{tier.name}
</span>
</div>
<div className="text-right">
<p className="text-xl font-bold text-white">{tier.price}</p>
{tier.period && (
<p className="text-[11px] text-gray-500">{tier.period}</p>
)}
</div>
</div>
{/* Features */}
<ul className="space-y-2 mb-4">
{tier.features.map((feature, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-gray-400"
>
<Check
size={14}
className={`mt-0.5 shrink-0 ${
tier.id === "free"
? "text-gray-600"
: tier.id === "pro"
? "text-purple-400"
: "text-[#D4AF37]"
}`}
/>
<span>{feature}</span>
</li>
))}
</ul>
{/* CTA Button */}
{tier.id !== "free" && (
<button
onClick={() =>
!isActive && handleUpgrade(tier.id, tier.priceId!)
}
disabled={isDisabled || checkoutLoading === tier.id}
className={`w-full py-3 rounded-xl font-semibold text-sm active:opacity-80 disabled:opacity-50 transition flex items-center justify-center gap-2 ${
tier.id === "premium"
? "bg-[#D4AF37] text-[#0a0a0f]"
: "bg-purple-600 text-white"
}`}
>
{checkoutLoading === tier.id ? (
<>
<Loader2 size={16} className="animate-spin" />
Processing...
</>
) : isActive ? (
"Current Plan"
) : (
tier.cta
)}
</button>
)}
{tier.id === "free" && (
<div className="w-full py-3 rounded-xl bg-gray-900/50 border border-gray-700/30 text-gray-500 font-semibold text-sm text-center">
{tier.cta}
</div>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="px-4 mt-6 text-center">
<p className="text-xs text-gray-600">
Secure payments via Polar. Cancel anytime from your account settings.
</p>
</div>
<div className="h-8" />
</div>
);
}
function SparklesIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#D4AF37] shrink-0"
>
<path d="M12 3l1.5 5.5L19 10l-5.5 1.5L12 17l-1.5-5.5L5 10l5.5-1.5z" />
<path d="M19 17l-1.5-2.5L15 15l1.5 2.5L15 20l2.5-1.5L19 20l-1.5-2.5z" />
</svg>
);
}
export default function UpgradePage() {
return (
<Suspense
fallback={
<div className="min-h-dvh bg-[#0a0a0f] flex items-center justify-center">
<Loader2 size={24} className="animate-spin text-[#D4AF37]" />
</div>
}
>
<UpgradeContent />
</Suspense>
);
}