Initial Falah Mobile rebuild — all 7 blocks complete

- Auth system (login/register/profile)
- Nur AI chat with persona system
- Souq marketplace with FLH economy
- Forum community with Shariah moderation
- Wallet & FLH top-up
- Premium/Pro tier upgrade with Polar.sh
- Halal Monitor with map & bookmarks
- Home dashboard with daily verse & streaks
- Health endpoints

Next.js 16.2.7, Prisma v5 SQLite, JWT auth, Tailwind CSS v4
This commit is contained in:
root
2026-06-15 09:28:22 +02:00
parent ab8a2053e1
commit 5483dd291e
56 changed files with 7619 additions and 100 deletions
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// Mock place data lookup for enriching bookmarks
const MOCK_PLACES: Record<string, any> = {
"mosque-1": { id: "mosque-1", name: "Masjid Negara", address: "Jalan Perdana, Tasik Perdana, 50480 Kuala Lumpur", lat: 3.1422, lng: 101.6929, rating: 4.7, type: "mosque", halal_certified: true },
"mosque-2": { id: "mosque-2", name: "Masjid Jamek Sultan Abdul Samad", address: "Jalan Tun Perak, 50050 Kuala Lumpur", lat: 3.1492, lng: 101.6958, rating: 4.5, type: "mosque", halal_certified: true },
"mosque-3": { id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true },
"mosque-4": { id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true },
"mosque-5": { id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true },
"rest-1": { id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true },
"rest-2": { id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true },
"rest-3": { id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true },
"rest-4": { id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true },
"rest-5": { id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true },
};
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const bookmarks = await prisma.halalBookmark.findMany({
where: { userId: auth.userId },
orderBy: { createdAt: "desc" },
});
// Enrich bookmarks with place info
const enriched = bookmarks.map((bm) => ({
...bm,
place: MOCK_PLACES[bm.itemId] ?? null,
}));
return NextResponse.json({ bookmarks: enriched });
} catch (error) {
console.error("GET /api/halal/bookmarks error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { itemId, itemType, label } = await request.json();
if (!itemId || !itemType) {
return NextResponse.json(
{ error: "Missing required fields: itemId, itemType" },
{ status: 400 }
);
}
// Check for duplicate
const existing = await prisma.halalBookmark.findFirst({
where: { userId: auth.userId, itemId, itemType },
});
if (existing) {
return NextResponse.json(
{ error: "Bookmark already exists", bookmark: existing },
{ status: 409 }
);
}
const bookmark = await prisma.halalBookmark.create({
data: {
userId: auth.userId,
itemId,
itemType,
label: label ?? null,
},
});
return NextResponse.json({ bookmark }, { status: 201 });
} catch (error) {
console.error("POST /api/halal/bookmarks error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json(
{ error: "Missing bookmark id" },
{ status: 400 }
);
}
// Verify ownership
const bookmark = await prisma.halalBookmark.findFirst({
where: { id, userId: auth.userId },
});
if (!bookmark) {
return NextResponse.json(
{ error: "Bookmark not found" },
{ status: 404 }
);
}
await prisma.halalBookmark.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error("DELETE /api/halal/bookmarks error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
const MOCK_PLACES = [
// Mosques
{ id: "mosque-1", name: "Masjid Negara", address: "Jalan Perdana, Tasik Perdana, 50480 Kuala Lumpur", lat: 3.1422, lng: 101.6929, rating: 4.7, type: "mosque", halal_certified: true },
{ id: "mosque-2", name: "Masjid Jamek Sultan Abdul Samad", address: "Jalan Tun Perak, 50050 Kuala Lumpur", lat: 3.1492, lng: 101.6958, rating: 4.5, type: "mosque", halal_certified: true },
{ id: "mosque-3", name: "Masjid Wilayah Persekutuan", address: "Jalan Masjid, 50546 Kuala Lumpur", lat: 3.1723, lng: 101.6887, rating: 4.8, type: "mosque", halal_certified: true },
{ id: "mosque-4", name: "Masjid Putra", address: "Persiaran Persekutuan, Presint 1, 62502 Putrajaya", lat: 2.9374, lng: 101.6888, rating: 4.9, type: "mosque", halal_certified: true },
{ id: "mosque-5", name: "Masjid Zahir", address: "Jalan Sultan Badlishah, 05400 Alor Setar, Kedah", lat: 6.1227, lng: 100.3663, rating: 4.6, type: "mosque", halal_certified: true },
// Restaurants
{ id: "rest-1", name: "Nasi Kandar Pelita", address: "No. 17, Jalan Telawi 3, Bangsar Baru, 59100 Kuala Lumpur", lat: 3.1295, lng: 101.6708, rating: 4.2, type: "restaurant", halal_certified: true },
{ id: "rest-2", name: "Restoran Ana Ikan Bakar Petai", address: "Jalan Cempaka, Kampung Datuk Keramat, 54000 Kuala Lumpur", lat: 3.1625, lng: 101.7319, rating: 4.3, type: "restaurant", halal_certified: true },
{ id: "rest-3", name: "Satey Zainab", address: "Jalan Tun Razak, 50400 Kuala Lumpur", lat: 3.1547, lng: 101.7112, rating: 4.1, type: "restaurant", halal_certified: true },
{ id: "rest-4", name: "Murni Discovery", address: "No. 8, Jalan SS2/75, SS2, 47300 Petaling Jaya", lat: 3.1187, lng: 101.6263, rating: 4.0, type: "restaurant", halal_certified: true },
{ id: "rest-5", name: "Nazeer's Banana Leaf", address: "No. 6, Jalan Kamuning, Off Jalan Imbi, 55100 Kuala Lumpur", lat: 3.1432, lng: 101.7115, rating: 4.4, type: "restaurant", halal_certified: true },
];
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.toLowerCase();
const type = searchParams.get("type")?.toLowerCase();
let filtered = [...MOCK_PLACES];
if (type && ["mosque", "restaurant", "cafe"].includes(type)) {
filtered = filtered.filter((p) => p.type === type);
}
if (q) {
filtered = filtered.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.address.toLowerCase().includes(q)
);
}
return NextResponse.json({ places: filtered });
} catch (error) {
console.error("GET /api/halal/places error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
import { getUserTier, HALAL_LIMITS } from "@/lib/tiers";
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: auth.userId },
include: { halalUsage: true },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(user.isPremium, user.isPro);
const limits = HALAL_LIMITS[tier];
// If no usage record exists yet, return defaults
const usage = user.halalUsage;
const queriesUsed = usage?.queriesUsed ?? 0;
const queriesLimit = usage?.queriesLimit ?? limits.queriesPerDay;
const periodEnd = usage?.periodEnd?.toISOString() ?? null;
const remaining = Math.max(0, queriesLimit - queriesUsed);
return NextResponse.json({
queriesUsed,
queriesLimit,
remaining,
periodEnd,
tier,
scope: limits.scope,
});
} catch (error) {
console.error("GET /api/halal/usage error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: auth.userId },
include: { halalUsage: true },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const tier = getUserTier(user.isPremium, user.isPro);
const limits = HALAL_LIMITS[tier];
// Get or create usage record
let usage = user.halalUsage;
// Check if period has expired — reset if so
const now = new Date();
if (usage && usage.periodEnd && new Date(usage.periodEnd) < now) {
usage = await prisma.halalUsage.update({
where: { userId: user.id },
data: {
queriesUsed: 0,
queriesLimit: limits.queriesPerDay,
periodEnd: new Date(now.getTime() + 24 * 60 * 60 * 1000),
},
});
}
if (!usage) {
usage = await prisma.halalUsage.create({
data: {
userId: user.id,
queriesUsed: 0,
queriesLimit: limits.queriesPerDay,
periodEnd: new Date(now.getTime() + 24 * 60 * 60 * 1000),
},
});
}
// Check if allowed
if (usage.queriesUsed >= usage.queriesLimit && limits.queriesPerDay !== Infinity) {
return NextResponse.json(
{
allowed: false,
remaining: 0,
queriesUsed: usage.queriesUsed,
queriesLimit: usage.queriesLimit,
error: "Daily query limit reached. Upgrade to Premium for unlimited queries.",
},
{ status: 429 }
);
}
// Increment usage
const updated = await prisma.halalUsage.update({
where: { userId: user.id },
data: { queriesUsed: { increment: 1 } },
});
const remaining = Math.max(0, updated.queriesLimit - updated.queriesUsed);
return NextResponse.json({
allowed: true,
remaining,
queriesUsed: updated.queriesUsed,
queriesLimit: updated.queriesLimit,
});
} catch (error) {
console.error("POST /api/halal/usage error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}