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);