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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user