Referral page, bug fixes, seed data, persona tiers

- New /refer page with share/copy/stats
- Fixed modal z-index conflicts (z-50 → z-[60]) across forum, groups, souq, halal-monitor
- Seed route: forum categories, marketplace listings, private groups
- Mufti/Daie personas now available in Premium tier
- Fixed referral share link: falahos.my/mobile/auth?ref=CODE
- Fixed client-side persona access check for premium
This commit is contained in:
root
2026-06-18 09:38:05 +02:00
parent b39bce91e4
commit ed34b186f9
29 changed files with 996 additions and 188 deletions
@@ -88,7 +88,11 @@ async function handleCallback(
});
// Build the redirect URI (must match the one used in the authorization request)
const baseUrl = `${url.protocol}//${url.host}`;
const forwardedProto = req.headers.get("x-forwarded-proto") || "https";
const rawHost = req.headers.get("host") || "";
const isDockerHost = /^[a-f0-9]{12,}$/i.test(rawHost.split(":")[0]);
const forwardedHost = req.headers.get("x-forwarded-host") || (isDockerHost ? "falahos.my" : rawHost);
const baseUrl = `${forwardedProto}://${forwardedHost}`;
const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`;
// Exchange code for user info
+7 -2
View File
@@ -35,8 +35,13 @@ export async function GET(
}
// Build the redirect URI — the callback URL for this provider
const url = new URL(_req.url);
const baseUrl = `${url.protocol}//${url.host}`;
// Use forwarded headers (Traefik/Cloudflare) or the original Host header
// to ensure the public domain is used, not the Docker container hostname.
const forwardedProto = _req.headers.get("x-forwarded-proto") || "https";
const rawHost = _req.headers.get("host") || "";
const isDockerHost = /^[a-f0-9]{12,}$/i.test(rawHost.split(":")[0]);
const forwardedHost = _req.headers.get("x-forwarded-host") || (isDockerHost ? "falahos.my" : rawHost);
const baseUrl = `${forwardedProto}://${forwardedHost}`;
const redirectUri = `${baseUrl}/mobile/api/auth/${provider}/callback`;
// Generate state for CSRF protection
+57 -2
View File
@@ -2,10 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { signJWT } from "@/lib/auth";
import { findReferrerByCode } from "@/lib/referral";
const REFERRER_REWARD_FLH = 200;
const REFERRED_BONUS_FLH = 1000;
export async function POST(req: NextRequest) {
try {
const { email, name, password } = await req.json();
const { email, name, password, referralCode } = await req.json();
if (!email || !name || !password) {
return NextResponse.json(
@@ -24,17 +28,66 @@ export async function POST(req: NextRequest) {
const passwordHash = await bcrypt.hash(password, 12);
// Look up referrer if a referral code was provided
let referrerId: string | null = null;
if (referralCode && typeof referralCode === "string") {
const users = await prisma.user.findMany({
select: { id: true },
});
referrerId = await findReferrerByCode(users, referralCode);
}
// Calculate starting balance
let startingBalance = 5000; // default sign-up bonus
if (referrerId) {
startingBalance += REFERRED_BONUS_FLH; // +1,000 FLH for using a referral code
}
const user = await prisma.user.create({
data: {
email,
name,
passwordHash,
provider: "email",
flhBalance: 5000,
flhBalance: startingBalance,
trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// If referral code was valid, create the referral record and credit the referrer
if (referrerId) {
await prisma.$transaction([
// Create the referral record
prisma.referral.create({
data: {
referrerId,
referredId: user.id,
status: "joined",
rewardFlh: REFERRER_REWARD_FLH,
},
}),
// Credit the referrer
prisma.user.update({
where: { id: referrerId },
data: { flhBalance: { increment: REFERRER_REWARD_FLH } },
}),
// Notify the referrer
prisma.notification.create({
data: {
userId: referrerId,
type: "referral_bonus",
title: "Referral Bonus Earned!",
body: `${name} joined Falah using your referral code — you earned ${REFERRER_REWARD_FLH} FLH!`,
data: JSON.stringify({
referredUserId: user.id,
referredName: name,
rewardFlh: REFERRER_REWARD_FLH,
}),
},
}),
]);
}
const token = await signJWT({
userId: user.id,
email: user.email,
@@ -53,6 +106,8 @@ export async function POST(req: NextRequest) {
flhBalance: user.flhBalance,
dailyMsgCount: user.dailyMsgCount,
},
referralApplied: referrerId ? true : false,
referralReward: referrerId ? REFERRED_BONUS_FLH : 0,
});
} catch (error) {
console.error("Register error:", error);
+2 -2
View File
@@ -108,8 +108,8 @@ export async function POST(request: Request) {
}
}
// Generate AI response
const aiContent = generateAIReply(personaId, lastUserMessage, historyContext);
// Generate AI response (tries LLM first, falls back to keyword rules)
const aiContent = await generateAIReply(personaId, lastUserMessage, historyContext);
// Save AI response to ChatHistory
const aiMessage = await prisma.chatHistory.create({
+4 -51
View File
@@ -1,17 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Decode a base62 referral code back to a userId prefix for lookup
function decodeBase62Lookup(code: string): number {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let hash = 0;
for (const ch of code) {
const idx = chars.indexOf(ch);
if (idx === -1) return -1;
hash = hash * 62 + idx;
}
return hash >>> 0;
}
import { findReferrerByCode } from "@/lib/referral";
export async function POST(req: NextRequest) {
try {
@@ -25,42 +14,12 @@ export async function POST(req: NextRequest) {
);
}
// In a real system, you'd look up the referrer by their stored referral code.
// For simplicity, we encode the userId in the code. We need to find the user
// whose userId produces this code. Since encodeBase62 uses a hash, we
// can't reverse it perfectly. Instead, we iterate active users.
// A production approach: store referral codes in a dedicated table or column.
// For now, we'll iterate through users and find a match.
// Look up the referrer by matching the referral code against all users
const users = await prisma.user.findMany({
select: { id: true },
});
// Recreate the encode logic to find the matching user
function encodeForMatch(id: string): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let hash = 0;
for (let i = 0; i < Math.min(id.length, 10); i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
let result = "";
let h = hash;
while (result.length < 6) {
result = chars[h % 62] + result;
h = Math.floor(h / 62);
if (h === 0 && result.length < 6) {
h = Math.floor(Math.random() * 62 ** (6 - result.length));
}
}
return result;
}
let referrerId: string | null = null;
for (const u of users) {
if (encodeForMatch(u.id) === code) {
referrerId = u.id;
break;
}
}
const referrerId = await findReferrerByCode(users, code);
if (!referrerId) {
return NextResponse.json(
@@ -76,12 +35,6 @@ export async function POST(req: NextRequest) {
// Since we don't have a "referredId" yet, we store a temporary token.
const tempToken = `pending_${referrerId}_${Date.now()}`;
// Check if this referrer already has a pending referral with this temp token
const existing = await prisma.referral.findFirst({
where: { referrerId, status: "pending" },
orderBy: { createdAt: "desc" },
});
// Store in a lightweight way — we'll use the Referral model with a placeholder referredId
// The referredId will be updated when registration completes.
// For production, a ReferralClaimToken model would be better.
@@ -96,7 +49,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({
success: true,
message: "Referral code applied! You'll get 1000 FLH when you sign up.",
message: "Referral code applied! You'll get 1,000 FLH extra when you sign up, and your referrer earns 200 FLH.",
referralId: referral.id,
tempToken,
});
+3 -22
View File
@@ -1,25 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth";
// Encode a userId (UUID/CUID) into a short base62 referral code
function encodeBase62(id: string): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// Convert first 10 chars of the id to a numeric hash for a short code
let hash = 0;
for (let i = 0; i < Math.min(id.length, 10); i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0; // unsigned 32-bit
}
let code = "";
while (code.length < 6) {
code = chars[hash % 62] + code;
hash = Math.floor(hash / 62);
if (hash === 0 && code.length < 6) {
// Pad with random chars
hash = Math.floor(Math.random() * 62 ** (6 - code.length));
}
}
return code;
}
import { encodeReferralCode } from "@/lib/referral";
export async function GET(req: NextRequest) {
const jwtPayload = await requireAuth(req);
@@ -28,11 +9,11 @@ export async function GET(req: NextRequest) {
}
try {
const referralCode = encodeBase62(jwtPayload.userId);
const referralCode = encodeReferralCode(jwtPayload.userId);
return NextResponse.json({
referralCode,
shareLink: `${process.env.NEXT_PUBLIC_APP_URL || "https://falah.app"}/join?ref=${referralCode}`,
shareLink: `${process.env.NEXT_PUBLIC_APP_URL || "https://falahos.my/mobile"}/auth?ref=${referralCode}`,
});
} catch (error) {
console.error("Referral code error:", error);
+3 -20
View File
@@ -1,24 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// Same encodeBase62 used in the code route
function encodeBase62(id: string): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let hash = 0;
for (let i = 0; i < Math.min(id.length, 10); i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
let code = "";
while (code.length < 6) {
code = chars[hash % 62] + code;
hash = Math.floor(hash / 62);
if (hash === 0 && code.length < 6) {
hash = Math.floor(Math.random() * 62 ** (6 - code.length));
}
}
return code;
}
import { encodeReferralCode } from "@/lib/referral";
export async function GET(req: NextRequest) {
const jwtPayload = await requireAuth(req);
@@ -42,14 +25,14 @@ export async function GET(req: NextRequest) {
}),
]);
const referralCode = encodeBase62(userId);
const referralCode = encodeReferralCode(userId);
return NextResponse.json({
totalReferrals,
upgradedCount,
totalEarned: earnings._sum.rewardFlh || 0,
referralCode,
shareLink: `${process.env.NEXT_PUBLIC_APP_URL || "https://falah.app"}/join?ref=${referralCode}`,
shareLink: `${process.env.NEXT_PUBLIC_APP_URL || "https://falahos.my/mobile"}/auth?ref=${referralCode}`,
});
} catch (error) {
console.error("Referral stats error:", error);
+192 -21
View File
@@ -4,39 +4,210 @@ import { prisma } from "@/lib/prisma";
export async function POST() {
try {
const existing = await prisma.user.findUnique({
where: { email: "demo@falah.app" },
});
if (existing) {
return NextResponse.json(
{ message: "Demo user already exists", userId: existing.id },
{ status: 200 }
);
}
const demoPasswordHash = await bcrypt.hash("password123", 12);
const demoUser = await prisma.user.create({
data: {
email: "demo@falah.app",
// ── 1. Seed Demo Users ──────────────────────────────────────────
const users = [
{
email: "demo@falahos.my",
password: "password123",
name: "Demo User",
passwordHash: demoPasswordHash,
flhBalance: 10000,
isPremium: true,
isPro: false,
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
preferredName: "Demo",
coachingGoals: "Learn more about Islam and improve my daily prayers",
},
{
email: "premium@falahos.my",
password: "Premium123!",
name: "Premium Demo",
flhBalance: 50000,
isPremium: true,
isPro: false,
preferredName: "Premium",
coachingGoals: "Advanced Islamic studies and community leadership",
},
];
const userResults = [];
for (const user of users) {
const { password, ...userData } = user;
const passwordHash = await bcrypt.hash(password, 12);
const upserted = await prisma.user.upsert({
where: { email: user.email },
update: { ...userData, passwordHash },
create: { ...userData, passwordHash },
});
userResults.push({ email: upserted.email, id: upserted.id });
}
// Find demo user for sample data
const demoUser = await prisma.user.findUnique({ where: { email: "demo@falahos.my" } });
const premiumUser = await prisma.user.findUnique({ where: { email: "premium@falahos.my" } });
const demoId = demoUser!.id;
const premiumId = premiumUser!.id;
// ── 2. Seed Forum Categories ─────────────────────────────────────
const categoriesData = [
{ name: "General Discussion", description: "General Islamic discussions and community topics", icon: "💬", order: 1 },
{ name: "Quran & Tafsir", description: "Quranic studies, recitation, and interpretation", icon: "📖", order: 2 },
{ name: "Fiqh & Hadith", description: "Islamic jurisprudence and prophetic traditions", icon: "📜", order: 3 },
{ name: "Family & Marriage", description: "Family life, marriage, and parenting in Islam", icon: "👨‍👩‍👧‍👦", order: 4 },
{ name: "Halal Lifestyle", description: "Halal food, travel, fashion, and daily living", icon: "🌿", order: 5 },
{ name: "Community Events", description: "Local events, study circles, and gatherings", icon: "📅", order: 6 },
];
const categoriesCreated: string[] = [];
for (const cat of categoriesData) {
const created = await prisma.forumCategory.upsert({
where: { name: cat.name },
update: cat,
create: cat,
});
categoriesCreated.push(created.name);
}
// ── 3. Seed Sample Forum Threads ─────────────────────────────────
const generalCat = await prisma.forumCategory.findUnique({ where: { name: "General Discussion" } });
const quranCat = await prisma.forumCategory.findUnique({ where: { name: "Quran & Tafsir" } });
if (generalCat) {
await prisma.forumThread.upsert({
where: { id: "seed-thread-welcome" },
update: {},
create: {
id: "seed-thread-welcome",
title: "Welcome to Falah Community! 👋",
content: "Assalamu alaikum everyone! This is our community space where we can discuss all things related to Islam, seek knowledge, and support each other. Feel free to introduce yourself!",
categoryId: generalCat.id,
authorId: premiumId,
pinned: true,
shariahStatus: "approved",
},
});
}
if (quranCat) {
await prisma.forumThread.upsert({
where: { id: "seed-thread-quran" },
update: {},
create: {
id: "seed-thread-quran",
title: "Daily Quran Reflection — Surah Al-Fatiha",
content: "Let's reflect on Surah Al-Fatiha together. What does 'Guide us to the straight path' mean to you in your daily life? Share your reflections below.",
categoryId: quranCat.id,
authorId: demoId,
pinned: false,
shariahStatus: "approved",
},
});
}
// ── 4. Seed Sample Group (Private Forum) ─────────────────────────
const group = await prisma.group.upsert({
where: { id: "seed-group-study-circle" },
update: {},
create: {
id: "seed-group-study-circle",
name: "Quran Study Circle",
description: "A private group for dedicated Quran study and memorization. Members meet weekly to review progress.",
ownerId: premiumId,
inviteCode: "QURAN2026",
members: {
create: [
{ userId: premiumId, role: "owner" },
{ userId: demoId, role: "member" },
],
},
},
include: { _count: { select: { members: true, threads: true } } },
});
// Create a thread in the group
const groupCat = generalCat || quranCat;
if (groupCat) {
await prisma.forumThread.upsert({
where: { id: "seed-thread-group-intro" },
update: {},
create: {
id: "seed-thread-group-intro",
title: "Welcome to Quran Study Circle 📖",
content: "Assalamu alaikum group members! This is our private space for discussing our weekly Quran study sessions. Our first session will cover Surah Al-Kahf — please review the tafsir beforehand.",
categoryId: groupCat.id,
authorId: premiumId,
groupId: group.id,
pinned: true,
shariahStatus: "approved",
},
});
}
// ── 5. Seed Sample Marketplace Listings ──────────────────────────
const listingsData = [
{
id: "seed-listing-tafsir",
title: "Tafsir Ibn Kathir (Abridged) — 10 Volumes",
description: "Complete set of Tafsir Ibn Kathir in English. Excellent condition, hardly used. Perfect for anyone looking to deepen their understanding of the Quran.",
category: "Books",
priceFlh: 2500,
sellerId: premiumId,
featured: true,
},
{
id: "seed-listing-prayer-mat",
title: "Premium Turkish Prayer Mat — Green",
description: "High-quality woven Turkish prayer mat with mosque design. Soft texture, perfect size. Ideal gift for loved ones.",
category: "Essentials",
priceFlh: 800,
sellerId: demoId,
featured: false,
},
{
id: "seed-listing-miswak",
title: "Natural Miswak Sticks (Pack of 10)",
description: "Fresh, high-quality miswak sticks from the Arak tree. Naturally whitens teeth, freshens breath, and follows the Sunnah. Each pack contains 10 sticks.",
category: "Wellness",
priceFlh: 150,
sellerId: demoId,
featured: false,
},
{
id: "seed-listing-hijab",
title: "Handcrafted Silk Hijabs — Assorted Colors",
description: "Beautiful handcrafted silk hijabs in assorted colors. Lightweight, breathable fabric. Perfect for daily wear or special occasions.",
category: "Fashion",
priceFlh: 350,
sellerId: premiumId,
featured: false,
},
];
const listingsCreated: string[] = [];
for (const listing of listingsData) {
await prisma.listing.upsert({
where: { id: listing.id },
update: { ...listing },
create: listing,
});
listingsCreated.push(listing.title);
}
return NextResponse.json(
{
message: "Demo user created successfully",
userId: demoUser.id,
credentials: { email: "demo@falah.app", password: "password123" },
message: "Seed completed successfully",
users: userResults,
categories: categoriesCreated,
group: { name: group.name, members: group._count.members },
listings: listingsCreated,
credentials: [
{ email: "demo@falahos.my", password: "password123" },
{ email: "premium@falahos.my", password: "Premium123!" },
],
},
{ status: 201 }
{ status: 200 }
);
} catch (error) {
console.error("Seed error:", error);
+31 -5
View File
@@ -1,8 +1,8 @@
"use client";
import { useState, FormEvent } from "react";
import { Suspense, useState, FormEvent, useEffect } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { Mail, ArrowLeft } from "lucide-react";
type AuthMode = "login" | "register";
@@ -41,9 +41,16 @@ function GitHubLogo({ className }: { className?: string }) {
);
}
export default function AuthPage() {
const { login, register, oauthLogin, loading: authLoading } = useAuth();
function AuthPageInner() {
const { user, login, register, oauthLogin, loading: authLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const refCode = searchParams.get("ref");
// Redirect to main page if already logged in (e.g. after OAuth popup completes)
useEffect(() => {
if (user) router.push("/");
}, [user, router]);
const [pageState, setPageState] = useState<PageState>("select");
const [mode, setMode] = useState<AuthMode>("register");
@@ -90,7 +97,7 @@ export default function AuthPage() {
if (mode === "login") {
await login(email.trim(), password);
} else {
await register(email.trim(), name.trim(), password);
await register(email.trim(), name.trim(), password, refCode || undefined);
}
router.push("/");
} catch (err: unknown) {
@@ -339,6 +346,13 @@ export default function AuthPage() {
<p className="text-xs text-gray-500">
Get <span className="text-[#D4AF37] font-semibold">5,000 FLH</span>{" "}
free on sign up + 7-day premium trial
{refCode && (
<>
{" "}+{" "}
<span className="text-emerald-400 font-semibold">1,000 FLH</span>{" "}
referral bonus
</>
)}
</p>
</div>
)}
@@ -356,3 +370,15 @@ export default function AuthPage() {
</div>
);
}
export default function AuthPage() {
return (
<Suspense fallback={
<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>
}>
<AuthPageInner />
</Suspense>
);
}
+3 -3
View File
@@ -77,7 +77,7 @@ export default function ThreadDetailPage() {
const fetchThread = useCallback(async () => {
try {
const res = await fetch(`/api/forum/threads`);
const res = await fetch(`/mobile/api/forum/threads`);
const data = await res.json();
if (res.ok) {
const found = data.threads.find((t: ThreadDetail) => t.id === threadId);
@@ -97,7 +97,7 @@ export default function ThreadDetailPage() {
setLoadingData(true);
setError(null);
try {
const res = await fetch(`/api/forum/posts?threadId=${threadId}`);
const res = await fetch(`/mobile/api/forum/posts?threadId=${threadId}`);
const data = await res.json();
if (res.ok) {
setPosts(data.posts);
@@ -113,7 +113,7 @@ export default function ThreadDetailPage() {
useEffect(() => {
if (!loading && !token) {
router.push("/login");
router.push("/auth");
return;
}
}, [loading, token, router]);
+2 -2
View File
@@ -163,7 +163,7 @@ export default function ForumPage() {
useEffect(() => {
if (!loading && !token) {
router.push("/login");
router.push("/auth");
return;
}
}, [loading, token, router]);
@@ -514,7 +514,7 @@ export default function ForumPage() {
{/* Create thread modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowCreateModal(false)}
+1 -1
View File
@@ -124,7 +124,7 @@ export default function GroupDetailPage() {
useEffect(() => {
if (!loading && !token) {
router.push("/login");
router.push("/auth");
return;
}
}, [loading, token, router]);
+3 -3
View File
@@ -94,7 +94,7 @@ export default function GroupsPage() {
useEffect(() => {
if (!loading && !token) {
router.push("/login");
router.push("/auth");
return;
}
}, [loading, token, router]);
@@ -362,7 +362,7 @@ export default function GroupsPage() {
{/* Create group modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowCreateModal(false)}
@@ -447,7 +447,7 @@ export default function GroupsPage() {
{/* Join group modal */}
{showJoinModal && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => {
+4 -4
View File
@@ -76,7 +76,7 @@ export default function HalalMonitorPage() {
// ── Redirect if not logged in ──────────────────────────────────────────
useEffect(() => {
if (!loading && !token) router.push("/login");
if (!loading && !token) router.push("/auth");
}, [loading, token, router]);
// ── Fetch places ───────────────────────────────────────────────────────
@@ -86,7 +86,7 @@ export default function HalalMonitorPage() {
if (q) params.set("q", q);
if (type && type !== "all") params.set("type", type);
const res = await fetch(`/api/halal/places?${params.toString()}`);
const res = await fetch(`/mobile/api/halal/places?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setPlaces(data.places);
@@ -192,7 +192,7 @@ export default function HalalMonitorPage() {
if (!token) return;
const existing = bookmarks.find((b) => b.itemId === place.id);
if (existing) {
const res = await fetch(`/api/halal/bookmarks?id=${existing.id}`, {
const res = await fetch(`/mobile/api/halal/bookmarks?id=${existing.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
@@ -515,7 +515,7 @@ export default function HalalMonitorPage() {
{/* ── Detail Modal ────────────────────────────────────────────────── */}
{selectedPlace && (
<div
className="fixed inset-0 z-50 bg-black/70 flex items-end sm:items-center justify-center"
className="fixed inset-0 z-[60] bg-black/70 flex items-end sm:items-center justify-center"
onClick={() => setSelectedPlace(null)}
>
<div
+3 -3
View File
@@ -63,7 +63,7 @@ function NurChat() {
/* Redirect if not logged in */
useEffect(() => {
if (!loading && !token) {
router.push("/login");
router.push("/auth");
}
}, [loading, token, router]);
@@ -74,7 +74,7 @@ function NurChat() {
const loadData = async () => {
try {
const [historyRes, dailyRes] = await Promise.all([
fetch(`/api/chat/history/${user.id}`, {
fetch(`/mobile/api/chat/history/${user.id}`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch("/mobile/api/chat/daily", {
@@ -406,7 +406,7 @@ function PersonaSelector({
const canAccess = (personaId: string) => {
if (personaId === "nurbuddy") return true;
if (isPro) return true;
if (isPremium && ["alim", "hakim", "murabbi"].includes(personaId)) return true;
if (isPremium && ["alim", "hakim", "murabbi", "mufti", "daie"].includes(personaId)) return true;
return false;
};
+1 -1
View File
@@ -21,7 +21,7 @@ export default function HomePage() {
const [claimResult, setClaimResult] = useState<string | null>(null);
useEffect(() => {
if (!loading && !token) router.push("/login");
if (!loading && !token) router.push("/auth");
}, [loading, token, router]);
// Refresh streak/XP data every 60 seconds
+15 -2
View File
@@ -17,6 +17,7 @@ import {
Shield,
TrendingUp,
ExternalLink,
Gift,
} from "lucide-react";
import PremiumBadge from "@/components/PremiumBadge";
@@ -36,7 +37,7 @@ export default function ProfilePage() {
const [saveMessage, setSaveMessage] = useState("");
useEffect(() => {
if (!loading && !token) router.push("/login");
if (!loading && !token) router.push("/auth");
}, [loading, token, router]);
useEffect(() => {
@@ -101,7 +102,7 @@ export default function ProfilePage() {
const handleLogout = () => {
logout();
router.push("/login");
router.push("/auth");
};
if (loading) {
@@ -413,6 +414,18 @@ export default function ProfilePage() {
)}
</div>
{/* Refer & Earn */}
<Link
href="/refer"
className="w-full flex items-center justify-between py-3.5 px-5 rounded-xl bg-[#D4AF37]/10 border border-[#D4AF37]/20 text-[#D4AF37] active:bg-[#D4AF37]/20 transition min-h-[48px]"
>
<div className="flex items-center gap-2">
<Gift size={16} />
<span className="text-sm font-medium">Refer & Earn FLH</span>
</div>
<ChevronRight size={16} />
</Link>
{/* Logout */}
<button
onClick={handleLogout}
+339
View File
@@ -0,0 +1,339 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
Users,
Gift,
Share2,
Copy,
Check,
ExternalLink,
TrendingUp,
Crown,
} from "lucide-react";
interface ReferralStats {
totalReferrals: number;
upgradedCount: number;
totalEarned: number;
referralCode: string;
shareLink: string;
}
interface ToastState {
type: "success" | "error";
text: string;
}
export default function ReferPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [stats, setStats] = useState<ReferralStats | null>(null);
const [dataLoading, setDataLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<ToastState | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!loading && !token) router.push("/auth");
}, [loading, token, router]);
useEffect(() => {
if (token) {
fetchStats();
}
}, [token]);
const fetchStats = async () => {
setDataLoading(true);
setError(null);
try {
const res = await fetch("/mobile/api/referrals/stats", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || "Failed to load referral stats");
}
const data: ReferralStats = await res.json();
setStats(data);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Something went wrong";
setError(message);
} finally {
setDataLoading(false);
}
};
const getShareLink = (): string => {
const code = stats?.referralCode || "";
const base =
(typeof window !== "undefined" &&
(window as any).NEXT_PUBLIC_APP_URL) ||
process.env.NEXT_PUBLIC_APP_URL ||
"https://falahos.my/mobile";
return `${base}/auth?ref=${code}`;
};
const handleShare = async () => {
const link = getShareLink();
const shareData = {
title: "Join me on Falah",
text: "Assalamu alaikum! Join me on Falah — the Islamic lifestyle app for daily AI coaching, Quran, and community. Use my referral link to get started!",
url: link,
};
if (typeof navigator !== "undefined" && navigator.share) {
try {
await navigator.share(shareData);
showToast("success", "Shared successfully!");
} catch {
// User cancelled or share failed — don't show error
}
} else {
// Fallback: copy link instead
handleCopyLink();
}
};
const handleCopyLink = async () => {
const link = getShareLink();
try {
await navigator.clipboard.writeText(link);
setCopied(true);
showToast("success", "Referral link copied!");
setTimeout(() => setCopied(false), 2000);
} catch {
showToast("error", "Failed to copy link");
}
};
const showToast = (type: "success" | "error", text: string) => {
setToast({ type, text });
setTimeout(() => setToast(null), 3000);
};
// Loading state
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>
);
}
// Not authenticated
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-2">
<h1 className="text-xl font-bold text-white">Refer & Earn</h1>
</div>
<div className="px-4 space-y-5 animate-fade-in">
{/* Toast Notification */}
{toast && (
<div
className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
toast.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"
}`}
>
{toast.type === "success" ? (
<Check size={16} />
) : (
<ExternalLink size={16} />
)}
{toast.text}
</div>
)}
{/* Error State */}
{error && !dataLoading && (
<div className="bg-red-900/20 border border-red-800/40 rounded-2xl p-5 text-center">
<p className="text-sm text-red-400 mb-3">{error}</p>
<button
onClick={fetchStats}
className="text-sm text-[#D4AF37] underline underline-offset-2"
>
Try again
</button>
</div>
)}
{/* Data Loading */}
{dataLoading && !error && (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-10 text-center">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin mx-auto mb-3" />
<p className="text-sm text-gray-500">Loading referral stats...</p>
</div>
)}
{/* Referral Code Card */}
{stats && !dataLoading && (
<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">
<Gift size={24} className="text-[#D4AF37]" />
</div>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
Your Referral Code
</p>
<p className="text-3xl font-mono font-bold tracking-widest text-[#D4AF37] select-all">
{stats.referralCode}
</p>
<p className="text-xs text-gray-500 mt-2">
Share this code or link to earn rewards
</p>
</div>
)}
{/* Action Buttons */}
{stats && !dataLoading && (
<div className="grid grid-cols-2 gap-4">
{/* Share Button */}
<button
onClick={handleShare}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center active:scale-95 transition hover:border-[#D4AF37]/30 min-h-[80px]"
>
<Share2 size={20} className="mx-auto text-[#D4AF37] mb-2" />
<p className="text-sm font-medium text-white">Share</p>
<p className="text-xs text-gray-500 mt-0.5">Invite friends</p>
</button>
{/* Copy Link Button */}
<button
onClick={handleCopyLink}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center active:scale-95 transition hover:border-[#D4AF37]/30 min-h-[80px]"
>
{copied ? (
<Check size={20} className="mx-auto text-emerald-400 mb-2" />
) : (
<Copy size={20} className="mx-auto text-[#D4AF37] mb-2" />
)}
<p className="text-sm font-medium text-white">
{copied ? "Copied!" : "Copy Link"}
</p>
<p className="text-xs text-gray-500 mt-0.5">
{copied ? "Link copied to clipboard" : "Share your link"}
</p>
</button>
</div>
)}
{/* Stats Cards */}
{stats && !dataLoading && (
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<TrendingUp size={16} className="text-[#D4AF37]" />
Your Referral Stats
</h2>
<div className="grid grid-cols-3 gap-3">
{/* Total Referrals */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 text-center">
<Users size={18} className="mx-auto text-[#D4AF37] mb-2" />
<p className="text-xl font-bold text-white">
{stats.totalReferrals}
</p>
<p className="text-xs text-gray-500 mt-1">Referrals</p>
</div>
{/* FLH Earned */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 text-center">
<Gift size={18} className="mx-auto text-[#D4AF37] mb-2" />
<p className="text-xl font-bold text-[#D4AF37]">
{stats.totalEarned.toLocaleString()}
</p>
<p className="text-xs text-gray-500 mt-1">FLH Earned</p>
</div>
{/* Upgraded Count */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 text-center">
<Crown size={18} className="mx-auto text-[#D4AF37] mb-2" />
<p className="text-xl font-bold text-white">
{stats.upgradedCount}
</p>
<p className="text-xs text-gray-500 mt-1">Upgraded</p>
</div>
</div>
</div>
)}
{/* How It Works */}
{stats && !dataLoading && (
<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">
<Gift size={16} className="text-[#D4AF37]" />
How It Works
</h3>
<ul className="space-y-3">
<li className="flex items-start gap-3 text-sm text-gray-400">
<span className="w-5 h-5 rounded-full bg-[#D4AF37]/20 text-[#D4AF37] text-xs font-bold flex items-center justify-center shrink-0 mt-0.5">
1
</span>
<span>
Share your referral code or link with friends and family
</span>
</li>
<li className="flex items-start gap-3 text-sm text-gray-400">
<span className="w-5 h-5 rounded-full bg-[#D4AF37]/20 text-[#D4AF37] text-xs font-bold flex items-center justify-center shrink-0 mt-0.5">
2
</span>
<span>
They sign up using your link you earn{" "}
<span className="text-[#D4AF37] font-medium">200 FLH</span>{" "}
per referral
</span>
</li>
<li className="flex items-start gap-3 text-sm text-gray-400">
<span className="w-5 h-5 rounded-full bg-[#D4AF37]/20 text-[#D4AF37] text-xs font-bold flex items-center justify-center shrink-0 mt-0.5">
3
</span>
<span>
When they upgrade to Premium, you earn a{" "}
<span className="text-[#D4AF37] font-medium">bonus</span> on
top!
</span>
</li>
</ul>
</div>
)}
{/* Share Link Card (visible at bottom) */}
{stats && !dataLoading && (
<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">
<ExternalLink size={16} className="text-[#D4AF37]" />
Your Share Link
</h3>
<div className="flex items-center gap-3">
<div className="flex-1 bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 min-h-[48px] flex items-center">
<code className="text-sm text-gray-400 truncate select-all">
{getShareLink()}
</code>
</div>
<button
onClick={handleCopyLink}
className="w-12 h-12 rounded-xl bg-[#D4AF37]/10 border border-[#D4AF37]/30 flex items-center justify-center active:scale-95 transition hover:bg-[#D4AF37]/20 shrink-0 min-h-[48px] min-w-[48px]"
>
{copied ? (
<Check size={18} className="text-emerald-400" />
) : (
<Copy size={18} className="text-[#D4AF37]" />
)}
</button>
</div>
</div>
)}
<div className="h-4" />
</div>
</div>
);
}
+2 -2
View File
@@ -377,7 +377,7 @@ export default function SouqPage() {
{/* Create listing modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowCreateModal(false)}
@@ -513,7 +513,7 @@ export default function SouqPage() {
{/* Purchase confirmation dialog */}
{showConfirmDialog && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => {
+1 -1
View File
@@ -92,7 +92,7 @@ function UpgradeContent() {
const isPro = user?.isPro;
useEffect(() => {
if (!loading && !token) router.push("/login");
if (!loading && !token) router.push("/auth");
}, [loading, token, router]);
useEffect(() => {
+1 -1
View File
@@ -55,7 +55,7 @@ export default function WalletPage() {
const [topupLoading, setTopupLoading] = useState<number | null>(null);
useEffect(() => {
if (!loading && !token) router.push("/login");
if (!loading && !token) router.push("/auth");
}, [loading, token, router]);
useEffect(() => {