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:
@@ -0,0 +1,4 @@
|
||||
# Exclude source dev.db — live DB is on persistent volume at /app/data/dev.db
|
||||
prisma/dev.db*
|
||||
prisma/*.db-wal
|
||||
prisma/*.db-shm
|
||||
+18
-6
@@ -1,24 +1,36 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
RUN apk add --no-cache openssl ca-certificates
|
||||
RUN apk add --no-cache openssl ca-certificates curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone build (pre-built outside Docker)
|
||||
COPY .next/standalone ./
|
||||
COPY .next/standalone/falah-mobile ./
|
||||
COPY .next/static ./.next/static
|
||||
COPY public ./public
|
||||
COPY prisma ./prisma
|
||||
COPY node_modules/.prisma ./node_modules/.prisma
|
||||
COPY prisma/schema.prisma ./prisma/schema.prisma
|
||||
|
||||
RUN rm -f .env && mkdir -p /app/data && chown -R nextjs:nodejs /app /app/prisma/dev.db
|
||||
# Fix Turbopack's hashed Prisma client path
|
||||
# Turbopack resolves @prisma/client to @prisma/client-<hash> but the
|
||||
# build-time symlink points to a path that doesn't exist in Docker.
|
||||
# We copy the existing @prisma/client to the hash name so Node.js
|
||||
# can resolve it when the Turbopack runtime requires it.
|
||||
RUN if [ -d ".next/node_modules/@prisma" ]; then \
|
||||
for d in .next/node_modules/@prisma/client-*; do \
|
||||
name=$(basename "$d"); \
|
||||
rm -f "node_modules/@prisma/$name" 2>/dev/null; \
|
||||
cp -r node_modules/@prisma/client "node_modules/@prisma/$name"; \
|
||||
done; \
|
||||
fi
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV DATABASE_URL="file:./dev.db"
|
||||
ENV DATABASE_URL="file:/app/data/dev.db"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
+6
-1
@@ -7,8 +7,13 @@ services:
|
||||
- "4013:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOSTNAME=0.0.0.0
|
||||
- NEXT_PUBLIC_APP_URL=https://falahos.my/mobile
|
||||
- JWT_SECRET=${JWT_SECRET:-flh-dev-jwt-secret-change-in-prod}
|
||||
- DATABASE_URL=file:./dev.db
|
||||
- DATABASE_URL=file:/app/data/dev.db
|
||||
- LLM_API_KEY=${LLM_API_KEY:-}
|
||||
- LLM_BASE_URL=${LLM_BASE_URL:-}
|
||||
- LLM_MODEL=${LLM_MODEL:-qwen3.6-plus}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function GroupDetailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) {
|
||||
router.push("/login");
|
||||
router.push("/auth");
|
||||
return;
|
||||
}
|
||||
}, [loading, token, router]);
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -39,7 +39,7 @@ interface AuthContextType {
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, name: string, password: string) => Promise<void>;
|
||||
register: (email: string, name: string, password: string, referralCode?: string) => Promise<void>;
|
||||
oauthLogin: (provider: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
@@ -113,11 +113,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(data.user);
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (email: string, name: string, password: string) => {
|
||||
const register = useCallback(async (email: string, name: string, password: string, referralCode?: string) => {
|
||||
const res = await fetch("/mobile/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, name, password }),
|
||||
body: JSON.stringify({ email, name, password, referralCode }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
|
||||
+240
-24
@@ -1,10 +1,18 @@
|
||||
/**
|
||||
* AI Reply Generator — Persona-specific responses
|
||||
* Uses conversation history for context and generates on-brand replies.
|
||||
* Tries OpenAI-compatible LLM first (when configured), falls back to
|
||||
* keyword-based rules when no API key is available or the API call fails.
|
||||
*
|
||||
* Environment variables:
|
||||
* LLM_API_KEY — OpenAI-compatible API key (optional)
|
||||
* LLM_BASE_URL — API base URL (default: https://api.openai.com/v1)
|
||||
* LLM_MODEL — Model name (default: gpt-4o-mini)
|
||||
*/
|
||||
|
||||
type Message = { role: string; content: string };
|
||||
|
||||
/* ── Persona intro responses & descriptions ── */
|
||||
|
||||
const INTRO_RESPONSES: Record<string, string> = {
|
||||
nurbuddy:
|
||||
"Assalamualaikum! 🌟 I'm NurBuddy, your friendly Islamic lifestyle companion. How can I help you today? Feel free to ask about daily duas, prayer times, or just have a chat about faith and life.",
|
||||
@@ -20,6 +28,116 @@ const INTRO_RESPONSES: Record<string, string> = {
|
||||
"Assalamualaikum. I am Daie, and my mission is dawah — calling to the beauty of Islam with wisdom and gentle persuasion. Whether you're curious about Islam or seeking comparative insights, I'm here to help.",
|
||||
};
|
||||
|
||||
/* ── LLM Integration ── */
|
||||
|
||||
/** Read LLM config from environment variables. */
|
||||
function getLLMConfig(): {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
} {
|
||||
return {
|
||||
apiKey: process.env.LLM_API_KEY || "",
|
||||
baseUrl: (process.env.LLM_BASE_URL || "https://api.openai.com/v1").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
),
|
||||
model: process.env.LLM_MODEL || "gpt-4o-mini",
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the system prompt for a given persona by combining its intro and description. */
|
||||
function buildSystemPrompt(personaId: string): string {
|
||||
const intro =
|
||||
INTRO_RESPONSES[personaId] || INTRO_RESPONSES.nurbuddy;
|
||||
|
||||
// Extended persona descriptions that define tone, style, and scope
|
||||
const descriptions: Record<string, string> = {
|
||||
nurbuddy:
|
||||
"You are NurBuddy, a friendly and approachable Islamic lifestyle companion. Your tone is warm, encouraging, and supportive. You help users with daily duas, prayer times, and general advice about faith and life. Use emojis occasionally and keep the tone cheerful and uplifting.",
|
||||
alim:
|
||||
"You are Alim, a dedicated student of Islamic knowledge. You draw upon the Qur'an and authentic hadith to provide scholarly insights. Your tone is knowledgeable, precise, and respectful. Cite sources and evidence when possible. When uncertain, acknowledge differing scholarly opinions.",
|
||||
hakim:
|
||||
"You are Hakim, a wisdom counselor who draws from the wellspring of Islamic tradition and timeless parables. Your tone is reflective, poetic, and profound. Use metaphors, stories, and analogies to convey deeper truths. Encourage the seeker to reflect inward.",
|
||||
murabbi:
|
||||
"You are Murabbi, a spiritual mentor guiding souls toward reflection and growth. Your tone is gentle, introspective, and nurturing. Help users connect with their faith on a deeper level. Ask reflective questions and encourage self-accountability (muhasabah).",
|
||||
mufti:
|
||||
"You are Mufti, a scholar who provides detailed rulings (fatawa) based on the Qur'an, Sunnah, and the established schools of Islamic jurisprudence (madhahib). Your tone is formal, thorough, and evidence-based. Structure responses with: evidence, analysis, and conclusion. Always include 'Wallahu a'lam (Allah knows best)' at the end.",
|
||||
daie:
|
||||
"You are Daie, a caller to Islam (dawah). Your mission is to present the beauty of Islam with wisdom, gentleness, and persuasive reasoning. Your tone is welcoming, informative, and respectful of other faiths. Focus on common ground while explaining Islamic teachings with clarity and compassion.",
|
||||
};
|
||||
|
||||
const desc = descriptions[personaId] || descriptions.nurbuddy;
|
||||
return `${intro}\n\n${desc}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an OpenAI-compatible LLM chat completion endpoint.
|
||||
* Returns the response text on success, or `null` if the call fails
|
||||
* (no API key, network error, non-2xx status, malformed response).
|
||||
*/
|
||||
async function callLLM(
|
||||
personaId: string,
|
||||
userMessage: string,
|
||||
history: Message[],
|
||||
): Promise<string | null> {
|
||||
const { apiKey, baseUrl, model } = getLLMConfig();
|
||||
|
||||
// No key configured — skip LLM entirely
|
||||
if (!apiKey) return null;
|
||||
|
||||
try {
|
||||
const systemPrompt = buildSystemPrompt(personaId);
|
||||
|
||||
// Build messages array: system prompt + last 10 history entries + current user message
|
||||
const messages: { role: string; content: string }[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...history.slice(-10).map((m) => ({
|
||||
role: m.role === "assistant" ? "assistant" : "user",
|
||||
content: m.content,
|
||||
})),
|
||||
{ role: "user", content: userMessage },
|
||||
];
|
||||
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[ai-server] LLM API error [${response.status}]: ${response.statusText}`,
|
||||
);
|
||||
return null; // Fall back to keyword rules
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Validate response structure
|
||||
const content = data?.choices?.[0]?.message?.content;
|
||||
if (!content || typeof content !== "string") {
|
||||
console.error("[ai-server] LLM returned unexpected response format");
|
||||
return null;
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
console.error("[ai-server] LLM call failed:", error);
|
||||
return null; // Fall back to keyword rules
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Utility helpers ── */
|
||||
|
||||
function getRandomItem<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
@@ -27,7 +145,7 @@ function getRandomItem<T>(arr: T[]): T {
|
||||
function getLastMessages(
|
||||
history: Message[],
|
||||
userMessage: string,
|
||||
count: number = 4
|
||||
count: number = 4,
|
||||
): string {
|
||||
const recent = history.slice(-count);
|
||||
if (recent.length === 0) return `User asks: "${userMessage}"`;
|
||||
@@ -37,18 +155,32 @@ function getLastMessages(
|
||||
return `${ctx}\nUser: "${userMessage}"`;
|
||||
}
|
||||
|
||||
/* ── Persona response generators ── */
|
||||
/* ── Persona response generators (keyword-based fallbacks) ── */
|
||||
|
||||
function nurbuddyReply(userMessage: string, context: string): string {
|
||||
const lower = userMessage.toLowerCase();
|
||||
|
||||
if (lower.includes("assalamu") || lower.includes("salam") || lower.includes("hello") || lower.includes("hi"))
|
||||
if (
|
||||
lower.includes("assalamu") ||
|
||||
lower.includes("salam") ||
|
||||
lower.includes("hello") ||
|
||||
lower.includes("hi")
|
||||
)
|
||||
return "Waalaikumsalam warahmatullahi wabarakatuh! 😊 So happy to chat with you today. What's on your mind?";
|
||||
|
||||
if (lower.includes("dua") || lower.includes("prayer") || lower.includes("supplication"))
|
||||
if (
|
||||
lower.includes("dua") ||
|
||||
lower.includes("prayer") ||
|
||||
lower.includes("supplication")
|
||||
)
|
||||
return "Alhamdulillah, making dua is a beautiful act of worship! Here's a simple yet powerful dua for you:\n\n**رَبَّنَا آتِنَا فِي الدُّنْيَا حَسَنَةً وَفِي الْآخِرَةِ حَسَنَةً وَقِنَا عَذَابَ النَّارِ**\n\n\"Our Lord, give us good in this world and good in the Hereafter, and protect us from the punishment of the Fire.\" (Quran 2:201)\n\nSay it with sincerity and watch how Allah transforms your life! 🌿";
|
||||
|
||||
if (lower.includes("stress") || lower.includes("anxious") || lower.includes("worried") || lower.includes("sad"))
|
||||
if (
|
||||
lower.includes("stress") ||
|
||||
lower.includes("anxious") ||
|
||||
lower.includes("worried") ||
|
||||
lower.includes("sad")
|
||||
)
|
||||
return "I hear you, and it's okay to feel this way. Remember what Allah says: **\"Verily, with hardship comes ease\"** (Quran 94:6). \n\nHere are a few things that might help:\n• Take a deep breath and recite *SubhanAllah* 33 times\n• Go for wudu (ablution) — it's surprisingly calming!\n• Talk to someone you trust\n\nYou're not alone, and this too shall pass, insha'Allah. 💛";
|
||||
|
||||
if (lower.includes("thank"))
|
||||
@@ -71,7 +203,11 @@ function alimReply(userMessage: string, context: string): string {
|
||||
if (lower.includes("zakat") || lower.includes("charity"))
|
||||
return "Zakat is one of the five pillars of Islam, mentioned alongside prayer in numerous Qur'anic verses. Allah says: **\"And establish prayer and give zakah, and whatever good you put forward for yourselves — you will find it with Allah\"** (Quran 2:110).\n\nThe general ruling is that 2.5% of one's accumulated wealth (above the nisab threshold) should be given annually to the eight categories specified in Surah At-Tawbah (9:60). The nisab is equivalent to the value of 85 grams of gold or 595 grams of silver.\n\nWould you like me to elaborate on any specific aspect of zakat calculation or distribution?";
|
||||
|
||||
if (lower.includes("halal") || lower.includes("haram") || lower.includes("permissible"))
|
||||
if (
|
||||
lower.includes("halal") ||
|
||||
lower.includes("haram") ||
|
||||
lower.includes("permissible")
|
||||
)
|
||||
return "The general principle in Islamic jurisprudence is that all things are permissible (halal) unless explicitly prohibited (haram) in the Qur'an or authentic Sunnah. Allah says: **\"O mankind, eat from whatever is on earth [that is] lawful and good\"** (Quran 2:168).\n\nThe categories of haram include: intoxicants, pork and its by-products, carrion, blood, and anything dedicated to other than Allah. For matters where clear texts are absent, scholars use ijtihad (independent reasoning) based on the maqasid (objectives) of Islamic law.\n\nCould you specify which area you're asking about so I can provide a more targeted answer with proper evidence?";
|
||||
|
||||
return `In the name of Allah, the Most Gracious, the Most Merciful.\n\nThank you for your inquiry. Let me draw upon the sources of Islamic knowledge to address this:\n\n${getRandomItem([
|
||||
@@ -85,13 +221,28 @@ function alimReply(userMessage: string, context: string): string {
|
||||
function hakimReply(userMessage: string, context: string): string {
|
||||
const lower = userMessage.toLowerCase();
|
||||
|
||||
if (lower.includes("decision") || lower.includes("choice") || lower.includes("confused") || lower.includes("dilemma"))
|
||||
if (
|
||||
lower.includes("decision") ||
|
||||
lower.includes("choice") ||
|
||||
lower.includes("confused") ||
|
||||
lower.includes("dilemma")
|
||||
)
|
||||
return "A difficult decision, you say? Let me share a parable:\n\nA traveler once came to a crossroads. One path was smooth but led to a barren land; the other was rocky but led to a lush garden. He asked a wise man which to take. The wise man replied: **\"Judge the path not by its comfort, but by its destination.\"**\n\nIn making your choice, consider:\n1️⃣ **Istikhara** — Pray the Salat al-Istikhara and ask Allah for guidance\n2️⃣ **Consultation (Shura)** — Speak with trusted, knowledgeable people\n3️⃣ **Consequences** — Weigh the outcomes against your values and deen\n\nWhat does your heart lean toward after seeking Allah's guidance?";
|
||||
|
||||
if (lower.includes("forgive") || lower.includes("anger") || lower.includes("hurt") || lower.includes("grudge"))
|
||||
if (
|
||||
lower.includes("forgive") ||
|
||||
lower.includes("anger") ||
|
||||
lower.includes("hurt") ||
|
||||
lower.includes("grudge")
|
||||
)
|
||||
return "Ah, the weight of carrying a grudge — it is like drinking poison and expecting the other person to suffer.\n\nAllah reminds us: **\"And let them pardon and overlook. Would you not like that Allah should forgive you? And Allah is Forgiving and Merciful\"** (Quran 24:22).\n\nThe Prophet ﷺ, despite being wronged in countless ways, was the most forgiving of people. When he conquered Mecca, he asked the Quraysh: \"What do you think I will do to you?\" They replied: \"You are a noble brother, son of a noble brother.\" He said: **\"Go, for you are free.\"**\n\nForgiveness is not weakness — it is the strength of a soul that trusts Allah's justice. 🕊️";
|
||||
|
||||
if (lower.includes("patience") || lower.includes("sabr") || lower.includes("waiting") || lower.includes("trial"))
|
||||
if (
|
||||
lower.includes("patience") ||
|
||||
lower.includes("sabr") ||
|
||||
lower.includes("waiting") ||
|
||||
lower.includes("trial")
|
||||
)
|
||||
return "Patience (sabr) is indeed a light. The trials you face are not punishments — they are opportunities for elevation. \n\nThere's a beautiful story of Prophet Ayyub (Job, peace be upon him), who lost his wealth, children, and health, yet never stopped praising Allah. When asked why, he said: **\"Allah gave, and Allah has taken. Blessed be Allah in both.\"**\n\nAnd what was his reward? Allah says: **\"And We gave him back his family and the like thereof with them — a mercy from Us and a reminder for those who worship\"** (Quran 38:43).\n\nYour patience is not in vain. Every moment of endurance is recorded, and the reward is immense. 🌱";
|
||||
|
||||
return `A profound question. Let me reflect with you:\n\n${getRandomItem([
|
||||
@@ -105,13 +256,28 @@ function hakimReply(userMessage: string, context: string): string {
|
||||
function murabbiReply(userMessage: string, context: string): string {
|
||||
const lower = userMessage.toLowerCase();
|
||||
|
||||
if (lower.includes("iman") || lower.includes("faith") || lower.includes("spiritual") || lower.includes("connection"))
|
||||
if (
|
||||
lower.includes("iman") ||
|
||||
lower.includes("faith") ||
|
||||
lower.includes("spiritual") ||
|
||||
lower.includes("connection")
|
||||
)
|
||||
return "Masha'Allah, your concern for your iman is itself a sign of its presence. The fact that you're asking tells me your heart is alive. 🌙\n\nLet me ask you some reflective questions:\n• When did you last feel truly connected in your salah?\n• What aspect of Allah's names (Asma'ul Husna) resonates most with you right now?\n• If your iman were a garden, what would it need — water, sunlight, or perhaps the removal of weeds?\n\nTake a moment to sit with these questions. Remember, iman fluctuates — that's natural. The Prophet ﷺ said: **\"Renew your faith!\"** They asked: \"How do we renew it?\" He said: **\"Say La ilaha illallah\"** (Ahmad).";
|
||||
|
||||
if (lower.includes("repent") || lower.includes("taubah") || lower.includes("sin") || lower.includes("regret"))
|
||||
if (
|
||||
lower.includes("repent") ||
|
||||
lower.includes("taubah") ||
|
||||
lower.includes("sin") ||
|
||||
lower.includes("regret")
|
||||
)
|
||||
return "Dear soul, let me tell you something beautiful: the fact that you feel remorse is itself a gift from Allah. It means your heart is not sealed, and the door of repentance is wide open for you.\n\nAllah says: **\"Say, O My servants who have transgressed against themselves, do not despair of the mercy of Allah. Indeed, Allah forgives all sins. Indeed, it is He who is the Forgiving, the Merciful\"** (Quran 39:53).\n\nNotice He says \"O My servants\" — even when we sin, He still claims us as His own. That's the depth of His love. ❤️\n\nFor sincere repentance (tawbah an-nasuh), reflect on these three steps:\n1️⃣ Stop the sin immediately\n2️⃣ Feel genuine regret\n3️⃣ Resolve never to return to it\n\nAnd if your sin involves another person's rights, make amends with them too.\n\nYou are not defined by your worst moment. You are defined by your return to Him.";
|
||||
|
||||
if (lower.includes("purpose") || lower.includes("meaning") || lower.includes("why") || lower.includes("life"))
|
||||
if (
|
||||
lower.includes("purpose") ||
|
||||
lower.includes("meaning") ||
|
||||
lower.includes("why") ||
|
||||
lower.includes("life")
|
||||
)
|
||||
return "The question of purpose is perhaps the most profound question a soul can ask. And you're asking it — that's beautiful.\n\nAllah gives us the answer directly: **\"And I did not create the jinn and mankind except to worship Me\"** (Quran 51:56).\n\nBut 'worship' here (ibadah) means far more than rituals. It means living a life of consciousness of Allah in every moment — your work, your relationships, your kindness, even your sleep can be worship with the right intention.\n\nLet me ask you: What makes you feel most alive and most at peace? Often, the answer to that question is connected to your purpose. When your natural talents meet the world's needs, with Allah at the center — that's your purpose in action. 🌟";
|
||||
|
||||
return `Thank you for trusting me with your reflections. Let me offer you some spiritual nourishment:\n\n${getRandomItem([
|
||||
@@ -125,13 +291,29 @@ function murabbiReply(userMessage: string, context: string): string {
|
||||
function muftiReply(userMessage: string, context: string): string {
|
||||
const lower = userMessage.toLowerCase();
|
||||
|
||||
if (lower.includes("interest") || lower.includes("riba") || lower.includes("bank") || lower.includes("loan"))
|
||||
if (
|
||||
lower.includes("interest") ||
|
||||
lower.includes("riba") ||
|
||||
lower.includes("bank") ||
|
||||
lower.includes("loan")
|
||||
)
|
||||
return "**Issue:** Riba (interest) in financial transactions\n**Ruling:** Riba is categorically prohibited in Islam.\n\n**Evidence:**\n1. Allah says: **\"Those who consume interest will stand on the Day of Resurrection like one driven mad by Satan's touch. That is because they say, 'Trade is like interest.' But Allah has permitted trade and forbidden interest\"** (Quran 2:275).\n\n2. The Prophet ﷺ cursed the one who consumes riba, the one who pays it, the one who writes it, and the one who witnesses it, saying: **\"They are all the same\"** (Sahih Muslim).\n\n**Application:** All forms of conventional interest-based loans, credit cards with interest, and savings accounts that pay interest fall under this prohibition. Muslims should seek Shariah-compliant alternatives such as:\n• Murabahah (cost-plus financing)\n• Musharakah (profit-sharing)\n• Ijara (leasing)\n\n**Note:** In cases of dire necessity (darura), scholars may permit exceptions under strict conditions. Please consult a local scholar for your specific situation.";
|
||||
|
||||
if (lower.includes("prayer") || lower.includes("salah") || lower.includes("fajr") || lower.includes("qada") || lower.includes("missed"))
|
||||
if (
|
||||
lower.includes("prayer") ||
|
||||
lower.includes("salah") ||
|
||||
lower.includes("fajr") ||
|
||||
lower.includes("qada") ||
|
||||
lower.includes("missed")
|
||||
)
|
||||
return "**Issue:** Making up missed prayers (Qada' al-Salah)\n\n**Evidence:** The majority of scholars (Hanafi, Shafi'i, Maliki) hold that making up missed obligatory prayers is compulsory (wajib). The Prophet ﷺ said: **\"Whoever forgets a prayer, let him pray it when he remembers it. There is no expiation for it except that\"** (Sahih al-Bukhari).\n\n**Ruling:**\n- **Intentionally missed prayers:** The predominant view (Hanbali, some Malikis) is that one must repent and make them up. Some scholars (particularly the Hanbali school) hold that intentional abandonment of prayer is major kufr — thus one must first repent and renew their shahadah.\n- **Unintentionally missed** (forgetfulness, sleep): Make up as soon as remembered.\n\n**Recommended sequence:** Make up missed prayers alongside your current ones, prioritizing the current prayer time but making up the missed ones as soon as possible.\n\nWallahu a'lam (Allah knows best). I recommend consulting a local scholar of your madhab for personalized guidance.";
|
||||
|
||||
if (lower.includes("marriage") || lower.includes("nikah") || lower.includes("divorce") || lower.includes("talaq"))
|
||||
if (
|
||||
lower.includes("marriage") ||
|
||||
lower.includes("nikah") ||
|
||||
lower.includes("divorce") ||
|
||||
lower.includes("talaq")
|
||||
)
|
||||
return "**Issue:** Rules of marriage and divorce in Islam\n\nMarriage (nikah) is a sacred contract (mithaq ghalidh) in Islam. Allah describes it as: **\"And He placed between you affection and mercy\"** (Quran 30:21).\n\n**Essential conditions for a valid nikah:**\n1. Mutual consent of both parties (the bride's consent, through her wali/guardian)\n2. Mahr (dower) — a gift from the husband to the wife\n3. Two upright Muslim witnesses\n4. The marriage contract (ijab and qabul)\n\n**Regarding divorce (talaq):**\n- It is the most disliked permissible act (abghad al-halal ila Allah)\n- A triple talaq uttered in one sitting: the majority view is that this counts as three divorces and the marriage is irrevocably dissolved (the Hanbali position differs, counting it as one)\n- After the first or second talaq, there is a waiting period (iddah) of three menstrual cycles, during which reconciliation is encouraged\n\nPlease provide more specifics about your situation for a detailed ruling. Wallahu a'lam.";
|
||||
|
||||
return `**Request for a Ruling (Ifta')**\n\nThank you for your question. I will address it based on the primary sources of Islamic law and the established methodologies of the four Sunni schools of thought.\n\n**Context:** ${userMessage}\n\n**Analysis:**\n${getRandomItem([
|
||||
@@ -144,13 +326,29 @@ function muftiReply(userMessage: string, context: string): string {
|
||||
function daieReply(userMessage: string, context: string): string {
|
||||
const lower = userMessage.toLowerCase();
|
||||
|
||||
if (lower.includes("jesus") || lower.includes("isa") || lower.includes("christian") || lower.includes("bible"))
|
||||
if (
|
||||
lower.includes("jesus") ||
|
||||
lower.includes("isa") ||
|
||||
lower.includes("christian") ||
|
||||
lower.includes("bible")
|
||||
)
|
||||
return "This is a beautiful area of discussion! 🌿\n\nIn Islam, Prophet Isa (Jesus, peace be upon him) holds a very special position. We love and honor him as one of the greatest messengers of Allah. Here's what Muslims believe:\n\n• He was born miraculously to Maryam (Mary) without a father — **\"And We made the son of Mary and his mother a sign\"** (Quran 23:50)\n• He spoke as a baby in the cradle, defending his mother's honor\n• He performed miracles — healing the blind and leper, raising the dead — all by Allah's permission\n• He preached the worship of One God (Tawheed)\n• He was not crucified but was raised to the heavens (Quran 4:157-158)\n• He will return before the Day of Judgment\n\nThe key difference is that Islam upholds the absolute oneness of God (Tawheed) and considers it the highest sin to associate partners with Allah. Muslims believe Isa was a human prophet, not divine.\n\nWould you like to explore more similarities or differences between Islamic and Christian teachings? ☪️✝️";
|
||||
|
||||
if (lower.includes("hindu") || lower.includes("buddhist") || lower.includes("other religion") || lower.includes("jewish") || lower.includes("judaism"))
|
||||
if (
|
||||
lower.includes("hindu") ||
|
||||
lower.includes("buddhist") ||
|
||||
lower.includes("other religion") ||
|
||||
lower.includes("jewish") ||
|
||||
lower.includes("judaism")
|
||||
)
|
||||
return "Assalamualaikum. Let me approach this with the wisdom that dawah requires — gentleness and respect. Allah commands: **\"Invite to the way of your Lord with wisdom and good instruction, and argue with them in a way that is best\"** (Quran 16:125).\n\nEvery faith tradition contains elements of truth, and Islam affirms that Allah sent messengers to every nation. The Qur'an says: **\"And We certainly sent into every nation a messenger, [saying], 'Worship Allah and avoid false gods'\"** (Quran 16:36).\n\nWhat makes Islam unique is its:\n• **Finality of revelation** — The Qur'an, preserved in its original language for over 1,400 years\n• **Comprehensive guidance** — Covering spirituality, law, economics, and governance\n• **Universal message** — For all of humanity, not just one people or time\n\nI'd be happy to discuss specific beliefs or answer questions you have. The goal is understanding, not winning arguments. 🤝";
|
||||
|
||||
if (lower.includes("convert") || lower.includes("revert") || lower.includes("become muslim") || lower.includes("shahadah"))
|
||||
if (
|
||||
lower.includes("convert") ||
|
||||
lower.includes("revert") ||
|
||||
lower.includes("become muslim") ||
|
||||
lower.includes("shahadah")
|
||||
)
|
||||
return "Masha'Allah! Your interest in Islam fills my heart with joy. 🌟\n\nLet me share what embracing Islam involves:\n\n**The Shahadah (Declaration of Faith):**\n**\"Ashhadu an la ilaha illallah, wa ashhadu anna Muhammadan rasulullah\"**\n(I bear witness that there is no god worthy of worship except Allah, and I bear witness that Muhammad is the Messenger of Allah)\n\nThat's it — utter these words with sincerity and understanding, and you become a Muslim. Everything else (prayer, fasting, etc.) builds on this foundation.\n\n**What changes?**\n• Your past sins are forgiven — you start fresh like a newborn\n• Your relationship with God becomes direct — no intermediaries needed\n• You join the global family of 1.8 billion Muslims\n\nI encourage you to:\n1️⃣ Visit a local mosque — the community will welcome you warmly\n2️⃣ Get a copy of the Qur'an with translation\n3️⃣ Learn how to pray (salat) at your own pace\n\nWould you like to know more about what life as a Muslim looks like day-to-day? 🕌";
|
||||
|
||||
return `Assalamualaikum! Thank you for your question. Let me respond with the spirit of dawah — inviting through wisdom and beauty.\n\n${getRandomItem([
|
||||
@@ -161,15 +359,33 @@ function daieReply(userMessage: string, context: string): string {
|
||||
])}\n\nIs there a particular aspect of Islam you'd like to explore further? I'm here to help with patience and clarity. ☪️`;
|
||||
}
|
||||
|
||||
export function generateAIReply(
|
||||
/* ── Main entry point ── */
|
||||
|
||||
/**
|
||||
* Generate an AI reply for the given persona and conversation context.
|
||||
*
|
||||
* Tries the LLM first if LLM_API_KEY is set. Falls back to the existing
|
||||
* keyword-based persona reply functions when the LLM is unavailable or
|
||||
* the API call fails.
|
||||
*
|
||||
* @param personaId - One of: nurbuddy, alim, hakim, murabbi, mufti, daie
|
||||
* @param userMessage - The user's current message text
|
||||
* @param history - Array of previous messages for conversational context
|
||||
* @returns The generated reply text
|
||||
*/
|
||||
export async function generateAIReply(
|
||||
personaId: string,
|
||||
userMessage: string,
|
||||
history: { role: string; content: string }[]
|
||||
): string {
|
||||
history: { role: string; content: string }[],
|
||||
): Promise<string> {
|
||||
// 1. Attempt LLM-first response (only if API key is configured)
|
||||
const llmReply = await callLLM(personaId, userMessage, history);
|
||||
if (llmReply !== null) return llmReply;
|
||||
|
||||
// 2. LLM unavailable or failed — fall back to keyword-based rules
|
||||
const context = getLastMessages(history, userMessage);
|
||||
|
||||
// If this is the first message or history is empty, return intro
|
||||
if (history.length === 0 || history.every((m) => m.role === "user")) {
|
||||
if (history.length === 0) {
|
||||
return INTRO_RESPONSES[personaId] || INTRO_RESPONSES.nurbuddy;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Deterministic base62 referral code from a user ID.
|
||||
* Uses the first 10 chars of the ID as a hash, producing a 6-character code.
|
||||
* When the hash is exhausted early, the position index is used as padding,
|
||||
* making the output deterministic for the same input.
|
||||
*/
|
||||
export function encodeReferralCode(userId: string): string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
let hash = 0;
|
||||
for (let i = 0; i < Math.min(userId.length, 10); i++) {
|
||||
hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
|
||||
let code = "";
|
||||
const originalHash = hash;
|
||||
while (code.length < 6) {
|
||||
code = chars[hash % 62] + code;
|
||||
hash = Math.floor(hash / 62);
|
||||
if (hash === 0 && code.length < 6) {
|
||||
// Deterministic padding: iteratively hash the original value
|
||||
hash = (originalHash * (code.length + 1) * 7) >>> 0;
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by their referral code by iterating all users and
|
||||
* re-encoding their IDs until a match is found.
|
||||
*/
|
||||
export async function findReferrerByCode(
|
||||
users: { id: string }[],
|
||||
code: string
|
||||
): Promise<string | null> {
|
||||
for (const u of users) {
|
||||
if (encodeReferralCode(u.id) === code) {
|
||||
return u.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
export const DAILY_LIMITS = {
|
||||
free: { msgs: 10, personas: ["nurbuddy"] },
|
||||
premium: { msgs: 100, personas: ["nurbuddy", "alim", "hakim", "murabbi"] },
|
||||
premium: { msgs: 100, personas: ["nurbuddy", "alim", "hakim", "murabbi", "mufti", "daie"] },
|
||||
pro: { msgs: Infinity, personas: ["nurbuddy", "alim", "hakim", "murabbi", "mufti", "daie"] },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user