pivot: migrate V1 mobile from Docker to Netlify serverless

- Switch Prisma schema from SQLite to PostgreSQL
- Add netlify.toml for Netlify deployment config
- Add @netlify/plugin-nextjs for serverless Next.js
- Remove Docker build files (Dockerfile, docker-compose, start.sh)
- Remove auto-healer and scripts directories
- Update next.config.ts (remove standalone output)
- Database: falah_mobile_v1 on Contabo Postgres
This commit is contained in:
2026-06-28 23:02:48 +02:00
parent 5b08f8b041
commit a1c5fecd83
117 changed files with 8619 additions and 2837 deletions
+1
View File
@@ -64,6 +64,7 @@ export async function POST(req: NextRequest) {
isPremium: localUser.isPremium,
isPro: localUser.isPro,
flhBalance: localUser.flhBalance,
flhPurchased: (localUser as any).flhPurchased ?? 0,
dailyMsgCount: localUser.dailyMsgCount,
},
});
+1
View File
@@ -24,6 +24,7 @@ export async function GET(req: NextRequest) {
isPremium: user.isPremium,
isPro: user.isPro,
flhBalance: user.flhBalance,
flhPurchased: (user as any).flhPurchased ?? 0,
dailyMsgCount: user.dailyMsgCount,
experienceLevel: user.experienceLevel,
madhab: user.madhab,
+1
View File
@@ -64,6 +64,7 @@ export async function POST(req: NextRequest) {
isPremium: localUser.isPremium,
isPro: localUser.isPro,
flhBalance: localUser.flhBalance,
flhPurchased: (localUser as any).flhPurchased ?? 0,
dailyMsgCount: localUser.dailyMsgCount,
},
});
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
// Accept a received hibah by its ID
// POST /api/flh/hibah/accept?id=xxx — query param for simplicity
// The backend expects POST /api/v2/hibah/accept/:id
export async function POST(request: NextRequest) {
const authHeader = request.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const body = await request.json();
const hibahId = body.hibahId;
if (!hibahId) {
return NextResponse.json({ error: "hibahId is required" }, { status: 400 });
}
const ummahRes = await fetch(
`${COMMUNITY_URL}/api/v2/hibah/accept/${encodeURIComponent(hibahId)}`,
{
method: "POST",
headers: { "Content-Type": "application/json", Authorization: authHeader },
}
);
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[hibah-accept] Proxy error:", error);
return NextResponse.json({ error: "Failed to accept hibah" }, { status: 502 });
}
}
+10
View File
@@ -0,0 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(req: NextRequest) {
const auth = req.headers.get("authorization");
if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const r = await fetch(`${COMMUNITY_URL}/api/v2/hibah/received`, { headers:{Authorization:auth} });
return NextResponse.json(await r.json(), { status: r.status });
} catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); }
}
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const authHeader = request.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const { id } = await params;
const ummahRes = await fetch(
`${COMMUNITY_URL}/api/v2/hibah/reject/${id}`,
{
method: "POST",
headers: { "Content-Type": "application/json", Authorization: authHeader },
}
);
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[hibah-reject] Proxy error:", error);
return NextResponse.json({ error: "Failed to reject hibah" }, { status: 502 });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function POST(req: NextRequest) {
const auth = req.headers.get("authorization");
if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const body = await req.json();
const r = await fetch(`${COMMUNITY_URL}/api/v2/hibah/send`, { method:"POST", headers:{"Content-Type":"application/json",Authorization:auth}, body:JSON.stringify(body) });
return NextResponse.json(await r.json(), { status: r.status });
} catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); }
}
+10
View File
@@ -0,0 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(req: NextRequest) {
const auth = req.headers.get("authorization");
if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const r = await fetch(`${COMMUNITY_URL}/api/v2/hibah/sent`, { headers:{Authorization:auth} });
return NextResponse.json(await r.json(), { status: r.status });
} catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); }
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(request: NextRequest) {
const authHeader = request.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/hibah/summary`, {
headers: { Authorization: authHeader },
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[hibah-summary] Proxy error:", error);
return NextResponse.json({ error: "Failed to fetch hibah summary" }, { status: 502 });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET() {
try {
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/causes`);
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[sadaqah-causes] Proxy error:", error);
return NextResponse.json({ error: "Failed to fetch causes" }, { status: 502 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/history`, {
headers: { Authorization: authHeader },
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[sadaqah-history] Proxy error:", error);
return NextResponse.json({ error: "Failed to fetch history" }, { status: 502 });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET() {
try {
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/pool`);
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[sadaqah-pool] Proxy error:", error);
return NextResponse.json({ error: "Failed to fetch sadaqah pool" }, { status: 502 });
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const body = await req.json();
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/give`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: authHeader },
body: JSON.stringify(body),
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[sadaqah-send] Proxy error:", error);
return NextResponse.json({ error: "Failed to send sadaqah" }, { status: 502 });
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const body = await req.json();
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: authHeader },
body: JSON.stringify(body),
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[sadaqah-subscribe] Proxy error:", error);
return NextResponse.json({ error: "Failed to create subscription" }, { status: 502 });
}
}
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/subscriptions`, {
headers: { Authorization: authHeader },
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[sadaqah-subscriptions] Proxy error:", error);
return NextResponse.json({ error: "Failed to fetch subscriptions" }, { status: 502 });
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const body = await req.json();
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/sadaqah/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: authHeader },
body: JSON.stringify(body),
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[sadaqah-unsubscribe] Proxy error:", error);
return NextResponse.json({ error: "Failed to cancel subscription" }, { status: 502 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(request: NextRequest) {
const authHeader = request.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/flh/summary`, {
headers: { Authorization: authHeader },
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[flh-summary] Proxy error:", error);
return NextResponse.json({ error: "Failed to fetch summary" }, { status: 502 });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function POST(req: NextRequest) {
const auth = req.headers.get("authorization");
if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const body = await req.json();
const r = await fetch(`${COMMUNITY_URL}/api/v2/waqf/contribute`, { method:"POST", headers:{"Content-Type":"application/json",Authorization:auth}, body:JSON.stringify(body) });
return NextResponse.json(await r.json(), { status: r.status });
} catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); }
}
+11
View File
@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function POST(req: NextRequest) {
const auth = req.headers.get("authorization");
if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const body = await req.json();
const r = await fetch(`${COMMUNITY_URL}/api/v2/waqf/pools`, { method:"POST", headers:{"Content-Type":"application/json",Authorization:auth}, body:JSON.stringify(body) });
return NextResponse.json(await r.json(), { status: r.status });
} catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); }
}
@@ -0,0 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(req: NextRequest) {
const auth = req.headers.get("authorization");
if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const r = await fetch(`${COMMUNITY_URL}/api/v2/waqf/my-contributions`, { headers:{Authorization:auth} });
return NextResponse.json(await r.json(), { status: r.status });
} catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); }
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET() {
try {
const r = await fetch(`${COMMUNITY_URL}/api/v2/waqf/pools`);
return NextResponse.json(await r.json(), { status: r.status });
} catch (e) { return NextResponse.json({ error:"Failed" }, { status:502 }); }
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const country = searchParams.get("country");
try {
const url = country
? `${COMMUNITY_URL}/api/v2/zakat/agents?country=${encodeURIComponent(country)}`
: `${COMMUNITY_URL}/api/v2/zakat/agents`;
const res = await fetch(url);
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (error) {
console.error("[zakat/agents] Proxy error:", error);
return NextResponse.json(
{ error: "Failed to fetch zakat agents" },
{ status: 502 }
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function POST(request: NextRequest) {
const authHeader = request.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const body = await request.json();
const { amount } = body;
// If amount is 0 or not provided, return status summary (remaining obligation, nisab, etc.)
if (!amount || typeof amount !== "number" || amount < 0) {
return NextResponse.json(
{ error: "Amount is required and must be non-negative" },
{ status: 400 }
);
}
if (amount === 0) {
// Return zakat status summary
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/status`, {
method: "GET",
headers: {
Authorization: authHeader,
},
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
}
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/calculate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
body: JSON.stringify({ amount }),
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[zakat-calculate] Proxy error:", error);
return NextResponse.json(
{ error: "Failed to calculate zakat" },
{ status: 502 }
);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/history`, {
headers: { Authorization: authHeader },
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[zakat-history] Proxy error:", error);
return NextResponse.json(
{ error: "Failed to fetch zakat history" },
{ status: 502 }
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const body = await req.json();
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/pay`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
body: JSON.stringify(body),
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[zakat-pay] Proxy error:", error);
return NextResponse.json(
{ error: "Failed to process zakat payment" },
{ status: 502 }
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET() {
try {
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/pool`);
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[zakat-pool] Proxy error:", error);
return NextResponse.json({ error: "Failed to fetch zakat pool" }, { status: 502 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
const COMMUNITY_URL = process.env.COMMUNITY_URL || process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const ummahRes = await fetch(`${COMMUNITY_URL}/api/v2/zakat/history`, {
headers: { Authorization: authHeader },
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[zakat-status] Proxy error:", error);
return NextResponse.json(
{ error: "Failed to fetch zakat status" },
{ status: 502 }
);
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
const radius = radiusParam ? parseInt(radiusParam, 10) : 5000;
let places;
let places: any[] = [];
let searchLocation: string | null = null;
// Priority 1: City search (with optional country)
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(_req: NextRequest) {
const start = Date.now();
try {
// Enforce 3-second max runtime
if (Date.now() - start >= 3000) {
return NextResponse.json(
{ status: "error", reads: false, writes: false, error: "Timeout" },
{ status: 504 }
);
}
// 1. Test read: count users (SELECT COUNT(*) FROM User)
let userCount: number;
try {
userCount = await prisma.user.count();
} catch (readErr) {
const message = readErr instanceof Error ? readErr.message : "Read query failed";
return NextResponse.json(
{ status: "error", reads: false, writes: false, error: message },
{ status: 503 }
);
}
// 2. Test write: insert a lightweight temp record and delete it immediately
try {
const testId = `health-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const testEmail = `${testId}@health.internal`;
await prisma.$executeRawUnsafe(
`INSERT INTO User (id, email, name, flhBalance) VALUES (?, ?, ?, ?)`,
testId,
testEmail,
"__health_check__",
0
);
// Clean up — this record must not persist
await prisma.$executeRawUnsafe(`DELETE FROM User WHERE id = ?`, testId);
} catch (writeErr) {
const message = writeErr instanceof Error ? writeErr.message : "Write query failed";
return NextResponse.json(
{ status: "error", reads: false, writes: false, error: message },
{ status: 503 }
);
}
// 3. All checks passed
return NextResponse.json({
status: "ok",
reads: true,
writes: true,
count: userCount,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ status: "error", reads: false, writes: false, error: message },
{ status: 500 }
);
}
}
+5 -5
View File
@@ -145,7 +145,7 @@ export async function POST(request: Request) {
if (existingCert) {
// Already issued — still update enrollment but don't create another
const updatedEnrollment = await tx.learnEnrollment.update({
where: { id: enrollment.id },
where: { id: enrollment!.id },
data: {
progress,
completed: true,
@@ -156,8 +156,8 @@ export async function POST(request: Request) {
}
// Update enrollment (progress + completion) and create certificate atomically
const updatedEnrollment = await tx.learnEnrollment.update({
where: { id: enrollment.id },
const updatedEnrollment2 = await tx.learnEnrollment.update({
where: { id: enrollment!.id },
data: {
progress,
completed: true,
@@ -179,7 +179,7 @@ export async function POST(request: Request) {
});
return {
enrollment: updatedEnrollment,
enrollment: updatedEnrollment2,
certificate: {
serialNumber,
verifyUrl: certificateData.verifyUrl,
@@ -202,7 +202,7 @@ export async function POST(request: Request) {
return NextResponse.json({
progress,
completed: enrollment.completed,
completed: enrollment!.completed,
...(certificate ? { certificate } : {}),
});
} catch (error) {
+1 -1
View File
@@ -212,7 +212,7 @@ export async function POST(req: NextRequest) {
group: { name: group.name, members: group._count.members },
listings: listingsCreated,
credentials: [
{ email: "demo@falahos.my", password: "password123" },
{ email: "demo@falahos.my", password: "demo123" },
{ email: "premium@falahos.my", password: "Premium123!" },
],
},
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// GET /api/souq/listings/[id] — single listing with seller info + reviews
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const listing = await prisma.listing.findUnique({
where: { id },
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
reviews: {
include: {
reviewer: {
select: { id: true, name: true, avatar: true },
},
},
orderBy: { createdAt: "desc" },
},
_count: {
select: { purchases: true },
},
},
});
if (!listing) {
return NextResponse.json(
{ error: "Listing not found" },
{ status: 404 }
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = listing as any;
return NextResponse.json({
listing: {
...data,
packages: data.packages ? JSON.parse(data.packages) : null,
tags: data.tags ? JSON.parse(data.tags) : null,
images: data.images ? JSON.parse(data.images) : null,
},
});
} catch (error) {
console.error("GET /api/souq/listings/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+182
View File
@@ -0,0 +1,182 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const category = searchParams.get("category");
const search = searchParams.get("search");
const featured = searchParams.get("featured");
const sellerId = searchParams.get("sellerId");
const minPrice = searchParams.get("minPrice");
const maxPrice = searchParams.get("maxPrice");
const sortBy = searchParams.get("sortBy");
const deliveryDays = searchParams.get("deliveryDays");
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "20", 10);
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
// Only return active listings by default
where.status = "active";
if (category) {
where.category = category;
}
if (search) {
where.OR = [
{ title: { contains: search } },
{ description: { contains: search } },
];
}
if (featured === "true") {
where.featured = true;
}
if (sellerId) {
where.sellerId = sellerId;
}
// Price range filter
if (minPrice || maxPrice) {
const priceFilter: Record<string, number> = {};
if (minPrice) priceFilter.gte = parseInt(minPrice, 10);
if (maxPrice) priceFilter.lte = parseInt(maxPrice, 10);
where.priceFlh = priceFilter;
}
// Delivery days filter
if (deliveryDays) {
where.deliveryDays = { lte: parseInt(deliveryDays, 10) };
}
// Build orderBy based on sortBy
let orderBy: Record<string, unknown>[];
switch (sortBy) {
case "price_asc":
orderBy = [{ priceFlh: "asc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "price_desc":
orderBy = [{ priceFlh: "desc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "rating":
orderBy = [{ rating: "desc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "popular":
orderBy = [{ salesCount: "desc" }, { featured: "desc" }, { createdAt: "desc" }];
break;
case "newest":
orderBy = [{ createdAt: "desc" }, { featured: "desc" }];
break;
default:
orderBy = [{ featured: "desc" }, { createdAt: "desc" }];
break;
}
const [listings, total] = await Promise.all([
prisma.listing.findMany({
where,
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
orderBy,
skip,
take: limit,
}),
prisma.listing.count({ where }),
]);
return NextResponse.json({
listings,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error("GET /api/souq/listings 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 } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const {
title,
description,
category,
subcategory,
priceFlh,
packages,
tags,
images,
deliveryDays,
fileType,
} = await request.json();
if (!title || !description || !category || !priceFlh) {
return NextResponse.json(
{ error: "Missing required fields: title, description, category, priceFlh" },
{ status: 400 }
);
}
if (typeof priceFlh !== "number" || priceFlh < 0) {
return NextResponse.json(
{ error: "priceFlh must be a non-negative number" },
{ status: 400 }
);
}
const listing = await prisma.listing.create({
data: {
title,
description,
category,
subcategory: subcategory || null,
priceFlh,
packages: packages ? JSON.stringify(packages) : null,
tags: tags ? JSON.stringify(tags) : null,
images: images ? JSON.stringify(images) : null,
deliveryDays: deliveryDays || null,
fileType: fileType || null,
sellerId: user.id,
status: "active",
},
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
return NextResponse.json({ listing }, { status: 201 });
} catch (error) {
console.error("POST /api/souq/listings error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,250 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/souq/orders/[id] — order detail
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const order = await prisma.purchase.findUnique({
where: { id },
include: {
listing: {
select: { id: true, title: true, category: true, subcategory: true, priceFlh: true, description: true, deliveryDays: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
if (!order) {
return NextResponse.json(
{ error: "Order not found" },
{ status: 404 }
);
}
// Verify user is either the buyer or seller
if (order.buyerId !== auth.userId && order.sellerId !== auth.userId) {
return NextResponse.json(
{ error: "Forbidden: you are not a party to this order" },
{ status: 403 }
);
}
// Check if the current user (buyer) has already reviewed this order
let hasReviewed = false;
if (order.status === "completed" && order.buyerId === auth.userId) {
const existingReview = await prisma.review.findFirst({
where: { purchaseId: order.id, reviewerId: auth.userId },
select: { id: true },
});
hasReviewed = !!existingReview;
}
// Parse JSON fields
const data = order as any;
return NextResponse.json({
order: {
...data,
messages: data.messages ? JSON.parse(data.messages) : [],
deliveryFiles: data.deliveryFiles ? JSON.parse(data.deliveryFiles) : [],
},
hasReviewed,
});
} catch (error) {
console.error("GET /api/souq/orders/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// PATCH /api/souq/orders/[id] — update order status or add messages
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const order = await prisma.purchase.findUnique({
where: { id },
});
if (!order) {
return NextResponse.json(
{ error: "Order not found" },
{ status: 404 }
);
}
// Verify user is either the buyer or seller
if (order.buyerId !== auth.userId && order.sellerId !== auth.userId) {
return NextResponse.json(
{ error: "Forbidden: you are not a party to this order" },
{ status: 403 }
);
}
const body = await request.json();
const { status: newStatus, message, deliveryFiles } = body;
const updateData: Record<string, unknown> = {};
// Update status if provided
if (newStatus) {
const validStatuses = [
"pending",
"paid",
"in_progress",
"delivered",
"completed",
"disputed",
"cancelled",
];
if (!validStatuses.includes(newStatus)) {
return NextResponse.json(
{
error: `Invalid status. Must be one of: ${validStatuses.join(", ")}`,
},
{ status: 400 }
);
}
// Validate status transitions
const validTransitions: Record<string, string[]> = {
paid: ["in_progress", "cancelled"],
in_progress: ["delivered", "disputed"],
delivered: ["completed", "disputed"],
completed: [],
disputed: ["completed", "cancelled"],
cancelled: [],
pending: ["paid", "cancelled"],
};
const allowed = validTransitions[order.status] || [];
if (!allowed.includes(newStatus)) {
return NextResponse.json(
{
error: `Cannot transition from "${order.status}" to "${newStatus}"`,
},
{ status: 400 }
);
}
// Only seller can mark as in_progress or delivered
if (
(newStatus === "in_progress" || newStatus === "delivered") &&
auth.userId !== order.sellerId
) {
return NextResponse.json(
{ error: "Only the seller can update to this status" },
{ status: 403 }
);
}
// Only buyer can mark as completed
if (newStatus === "completed" && auth.userId !== order.buyerId) {
return NextResponse.json(
{ error: "Only the buyer can mark an order as completed" },
{ status: 403 }
);
}
updateData.status = newStatus;
}
// Add a message if provided
if (message) {
if (typeof message !== "string" || message.trim().length === 0) {
return NextResponse.json(
{ error: "Message must be a non-empty string" },
{ status: 400 }
);
}
const existingMessages = order.messages
? (JSON.parse(order.messages) as Array<Record<string, unknown>>)
: [];
const newMessage = {
from: auth.userId,
text: message.trim(),
timestamp: new Date().toISOString(),
};
existingMessages.push(newMessage);
updateData.messages = JSON.stringify(existingMessages);
}
// Update delivery files if provided
if (deliveryFiles) {
if (!Array.isArray(deliveryFiles)) {
return NextResponse.json(
{ error: "deliveryFiles must be an array" },
{ status: 400 }
);
}
updateData.deliveryFiles = JSON.stringify(deliveryFiles);
}
if (Object.keys(updateData).length === 0) {
return NextResponse.json(
{ error: "No fields to update. Provide status, message, or deliveryFiles." },
{ status: 400 }
);
}
const updated = await prisma.purchase.update({
where: { id },
data: updateData,
include: {
listing: {
select: { id: true, title: true, category: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
const data = updated as any;
return NextResponse.json({
order: {
...data,
messages: data.messages ? JSON.parse(data.messages) : [],
deliveryFiles: data.deliveryFiles ? JSON.parse(data.deliveryFiles) : [],
},
});
} catch (error) {
console.error("PATCH /api/souq/orders/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+213
View File
@@ -0,0 +1,213 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// GET /api/souq/orders — my orders (as buyer or seller)
export async function GET(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const role = searchParams.get("role"); // "buyer" | "seller" | null (both)
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "20", 10);
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (role === "buyer") {
where.buyerId = auth.userId;
} else if (role === "seller") {
where.sellerId = auth.userId;
} else {
where.OR = [
{ buyerId: auth.userId },
{ sellerId: auth.userId },
];
}
const [orders, total] = await Promise.all([
prisma.purchase.findMany({
where,
include: {
listing: {
select: { id: true, title: true, category: true, priceFlh: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
prisma.purchase.count({ where }),
]);
// Parse messages JSON for each order
const parsed = orders.map((order) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const o = order as any;
return {
...o,
messages: o.messages ? JSON.parse(o.messages) : null,
deliveryFiles: o.deliveryFiles ? JSON.parse(o.deliveryFiles) : null,
};
});
return NextResponse.json({
orders: parsed,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
console.error("GET /api/souq/orders error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
// POST /api/souq/orders — place a new order
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 } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const { listingId, packageName } = await request.json();
if (!listingId) {
return NextResponse.json(
{ error: "Missing required field: listingId" },
{ status: 400 }
);
}
// Validate listing exists
const listing = await prisma.listing.findUnique({
where: { id: listingId },
});
if (!listing) {
return NextResponse.json(
{ error: "Listing not found" },
{ status: 404 }
);
}
if (listing.status !== "active") {
return NextResponse.json(
{ error: "Listing is not available for purchase" },
{ status: 400 }
);
}
// Prevent buying own listing
if (listing.sellerId === user.id) {
return NextResponse.json(
{ error: "You cannot purchase your own listing" },
{ status: 400 }
);
}
// Determine amount: if packageName provided, look it up in packages JSON
let amountFlh = listing.priceFlh;
if (packageName) {
if (!listing.packages) {
return NextResponse.json(
{ error: "This listing has no packages" },
{ status: 400 }
);
}
const packages = JSON.parse(listing.packages) as Array<{
name: string;
price?: number;
}>;
const selectedPackage = packages.find((p) => p.name === packageName);
if (!selectedPackage) {
return NextResponse.json(
{ error: `Package "${packageName}" not found` },
{ status: 400 }
);
}
amountFlh = selectedPackage.price ?? listing.priceFlh;
}
// Check buyer balance
if (user.flhBalance < amountFlh) {
return NextResponse.json(
{ error: "Insufficient FLH balance" },
{ status: 400 }
);
}
// Calculate fees
const platformFee = Math.round(amountFlh * 0.015);
const sellerPayout = amountFlh - platformFee;
// Create purchase and deduct balance in a transaction
const [purchase] = await prisma.$transaction([
prisma.purchase.create({
data: {
listingId,
buyerId: user.id,
sellerId: listing.sellerId,
packageName: packageName || null,
amountFlh,
platformFee,
sellerPayout,
status: "paid",
},
include: {
listing: {
select: { id: true, title: true, category: true, priceFlh: true },
},
buyer: {
select: { id: true, name: true, email: true, avatar: true },
},
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
}),
prisma.user.update({
where: { id: user.id },
data: { flhBalance: { decrement: amountFlh } },
}),
// Increment sales count on the listing
prisma.listing.update({
where: { id: listingId },
data: { salesCount: { increment: 1 } },
}),
]);
return NextResponse.json({ order: purchase }, { status: 201 });
} catch (error) {
console.error("POST /api/souq/orders error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth";
// POST /api/souq/reviews — create a review
export async function POST(request: NextRequest) {
try {
const auth = await requireAuth(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { listingId, orderId, rating, title, text } = await request.json();
// ── Validate fields ──
if (!listingId || !orderId) {
return NextResponse.json(
{ error: "Missing required fields: listingId, orderId" },
{ status: 400 }
);
}
if (!rating || typeof rating !== "number" || rating < 1 || rating > 5 || !Number.isInteger(rating)) {
return NextResponse.json(
{ error: "Rating must be an integer between 1 and 5" },
{ status: 400 }
);
}
// ── Ensure the order exists and belongs to this user ──
const order = await prisma.purchase.findUnique({
where: { id: orderId },
include: { listing: { select: { id: true, sellerId: true } } },
});
if (!order) {
return NextResponse.json({ error: "Order not found" }, { status: 404 });
}
if (order.buyerId !== auth.userId) {
return NextResponse.json(
{ error: "Only the buyer can review this order" },
{ status: 403 }
);
}
if (order.status !== "completed") {
return NextResponse.json(
{ error: "Can only review completed orders" },
{ status: 400 }
);
}
if (order.listingId !== listingId) {
return NextResponse.json(
{ error: "Listing does not match this order" },
{ status: 400 }
);
}
// ── Check if user already reviewed this purchase ──
const existing = await prisma.review.findFirst({
where: { purchaseId: orderId, reviewerId: auth.userId },
});
if (existing) {
return NextResponse.json(
{ error: "You have already reviewed this order" },
{ status: 409 }
);
}
// ── Create the review and update listing stats in a transaction ──
const [review] = await prisma.$transaction(async (tx) => {
const created = await tx.review.create({
data: {
listingId,
purchaseId: orderId,
reviewerId: auth.userId,
sellerId: order.listing.sellerId,
rating,
title: title || null,
text: text ?? "",
},
});
// Recalculate listing average rating and count
const agg = await tx.review.aggregate({
where: { listingId },
_avg: { rating: true },
_count: { rating: true },
});
await tx.listing.update({
where: { id: listingId },
data: {
rating: agg._avg.rating ?? 0,
reviewCount: agg._count.rating,
},
});
return [created];
});
return NextResponse.json({ review }, { status: 201 });
} catch (error) {
console.error("POST /api/souq/reviews error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// GET /api/souq/sellers/[id] — seller profile data
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Get user info
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
avatar: true,
createdAt: true,
},
});
if (!user) {
return NextResponse.json(
{ error: "Seller not found" },
{ status: 404 }
);
}
// Get review stats
const reviewAgg = await prisma.review.aggregate({
where: { sellerId: id },
_avg: { rating: true },
_count: true,
});
// Get sales count
const salesCount = await prisma.purchase.count({
where: { sellerId: id },
});
// Get active listings
const listings = await prisma.listing.findMany({
where: { sellerId: id, status: "active" },
orderBy: { createdAt: "desc" },
include: {
seller: {
select: { id: true, name: true, email: true, avatar: true },
},
},
});
// Parse JSON fields
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsedListings = listings.map((l: any) => ({
...l,
packages: l.packages ? JSON.parse(l.packages) : null,
tags: l.tags ? JSON.parse(l.tags) : null,
images: l.images ? JSON.parse(l.images) : null,
}));
return NextResponse.json({
seller: {
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
memberSince: user.createdAt,
listingsCount: parsedListings.length,
averageRating: reviewAgg._avg.rating ?? 0,
reviewCount: reviewAgg._count,
salesCount,
listings: parsedListings,
},
});
} catch (error) {
console.error("GET /api/souq/sellers/[id] error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+11 -3
View File
@@ -23,7 +23,8 @@ export async function GET(req: NextRequest) {
}
return NextResponse.json({
balance: user.flhBalance,
flhBalance: user.flhBalance,
flhPurchased: user.flhPurchased,
cashoutHistory: user.cashouts.map((c) => ({
id: c.id,
amountFlh: c.amountFlh,
@@ -59,7 +60,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (user.flhBalance < amountFlh) {
if (user.flhPurchased < amountFlh) {
return NextResponse.json(
{ error: "Insufficient FLH balance" },
{ status: 400 }
@@ -81,7 +82,12 @@ export async function POST(req: NextRequest) {
// Deduct balance immediately
await prisma.user.update({
where: { id: user.id },
data: { flhBalance: { decrement: amountFlh } },
data: { flhPurchased: { decrement: amountFlh } },
});
// Fetch updated user to return both balances
const updatedUser = await prisma.user.findUnique({
where: { id: user.id },
});
return NextResponse.json({
@@ -93,6 +99,8 @@ export async function POST(req: NextRequest) {
status: cashout.status,
createdAt: cashout.createdAt,
},
flhBalance: updatedUser?.flhBalance ?? 0,
flhPurchased: updatedUser?.flhPurchased ?? 0,
});
} catch (error) {
console.error("Cashout error:", error);
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
const UMMAHID_URL = process.env.UMMAHID_URL || "http://ummahid:3000";
export async function GET(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const ummahRes = await fetch(`${UMMAHID_URL}/api/flh/wallet/ton-address`, {
headers: { Authorization: authHeader },
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[ton-address] Proxy error:", error);
return NextResponse.json({ error: "Failed to fetch TON address" }, { status: 502 });
}
}
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
return NextResponse.json({ error: "Authorization required" }, { status: 401 });
}
try {
const body = await req.json();
const ummahRes = await fetch(`${UMMAHID_URL}/api/flh/wallet/ton-address`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
body: JSON.stringify(body),
});
const data = await ummahRes.json();
return NextResponse.json(data, { status: ummahRes.status });
} catch (error) {
console.error("[ton-address] Proxy error:", error);
return NextResponse.json({ error: "Failed to save TON address" }, { status: 502 });
}
}
+7
View File
@@ -57,6 +57,13 @@ export async function POST(req: NextRequest) {
);
}
} else {
if (process.env.NODE_ENV === "production") {
console.error("POLAR_WEBHOOK_SECRET missing in production — refusing unverified webhook");
return NextResponse.json(
{ error: "Webhook secret not configured" },
{ status: 500 }
);
}
console.warn("POLAR_WEBHOOK_SECRET not set — skipping signature verification (dev mode)");
}
+502
View File
@@ -0,0 +1,502 @@
"use client";
import { useState, useEffect, FormEvent } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
Heart,
Send,
Clock,
Check,
Loader2,
TrendingUp,
User,
ArrowUpRight,
ArrowDownLeft,
X,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface SentHibah {
id: string;
toUserId?: string;
toEmail?: string;
amount: number;
message?: string;
status: string;
createdAt: string;
}
interface ReceivedHibah {
id: string;
fromUserId?: string;
fromEmail?: string;
fromName?: string;
amount: number;
message?: string;
status: string;
createdAt: string;
}
export default function HibahPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
// Send form
const [toEmail, setToEmail] = useState("");
const [sendAmount, setSendAmount] = useState("");
const [sendMessage, setSendMessage] = useState("");
const [sendLoading, setSendLoading] = useState(false);
const [sendMessageState, setSendMessageState] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// Sent
const [sent, setSent] = useState<SentHibah[]>([]);
const [sentLoading, setSentLoading] = useState(true);
const [sentError, setSentError] = useState<string | null>(null);
// Received
const [received, setReceived] = useState<ReceivedHibah[]>([]);
const [receivedLoading, setReceivedLoading] = useState(true);
const [receivedError, setReceivedError] = useState<string | null>(null);
// Accept/Reject
const [actionLoadingId, setActionLoadingId] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !token) router.push("/auth");
}, [authLoading, token, router]);
useEffect(() => {
if (token) {
fetchSent();
fetchReceived();
}
}, [token]);
const fetchSent = async () => {
setSentLoading(true);
setSentError(null);
try {
const res = await fetch("/mobile/api/flh/hibah/sent", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Failed to fetch sent hibah");
const data = await res.json();
setSent(Array.isArray(data) ? data : data.sent ?? []);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setSentError(msg);
} finally {
setSentLoading(false);
}
};
const fetchReceived = async () => {
setReceivedLoading(true);
setReceivedError(null);
try {
const res = await fetch("/mobile/api/flh/hibah/received", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Failed to fetch received hibah");
const data = await res.json();
setReceived(Array.isArray(data) ? data : data.received ?? []);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setReceivedError(msg);
} finally {
setReceivedLoading(false);
}
};
const handleSend = async (e: FormEvent) => {
e.preventDefault();
const amount = parseFloat(sendAmount);
if (isNaN(amount) || amount <= 0) {
setSendMessageState({
type: "error",
text: "Please enter a valid amount",
});
return;
}
if (!toEmail.trim()) {
setSendMessageState({
type: "error",
text: "Please enter recipient email",
});
return;
}
setSendLoading(true);
setSendMessageState(null);
try {
const res = await fetch("/mobile/api/flh/hibah/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
toEmail: toEmail.trim(),
amount,
message: sendMessage.trim() || undefined,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to send hibah");
}
setSendMessageState({
type: "success",
text: `Hibah of ${amount} FLH sent to ${toEmail}!`,
});
setToEmail("");
setSendAmount("");
setSendMessage("");
fetchSent();
setTimeout(() => setSendMessageState(null), 4000);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to send";
setSendMessageState({ type: "error", text: msg });
setTimeout(() => setSendMessageState(null), 4000);
} finally {
setSendLoading(false);
}
};
const handleAccept = async (hibahId: string) => {
setActionLoadingId(hibahId);
try {
const res = await fetch("/mobile/api/flh/hibah/accept", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ hibahId }),
});
if (!res.ok) throw new Error("Failed to accept");
fetchReceived();
} catch {
// ignore
} finally {
setActionLoadingId(null);
}
};
const handleReject = async (hibahId: string) => {
setActionLoadingId(hibahId);
try {
const res = await fetch(`/mobile/api/flh/hibah/reject/${hibahId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Failed to reject");
fetchReceived();
} catch {
// ignore
} finally {
setActionLoadingId(null);
}
};
if (authLoading) {
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>
);
}
if (!user) return null;
const statusIcon = (status: string) => {
switch (status) {
case "accepted":
return <Check size={14} className="text-emerald-400" />;
case "rejected":
return <X size={14} className="text-red-400" />;
default:
return <Clock size={14} className="text-amber-400" />;
}
};
const statusColor = (status: string) => {
switch (status) {
case "accepted":
return "text-emerald-400";
case "rejected":
return "text-red-400";
default:
return "text-amber-400";
}
};
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-2 flex items-center gap-3">
<Link
href="/flh"
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:scale-90 transition"
>
<ArrowLeft size={16} className="text-gray-500" />
</Link>
<div>
<h1 className="text-xl font-bold text-white">Hibah</h1>
<p className="text-xs text-gray-500">Give gifts to loved ones</p>
</div>
</div>
<div className="px-4 space-y-5 animate-fade-in">
{/* a) Send Hibah */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Send size={16} className="text-rose-400" />
Send Hibah
</h2>
<form onSubmit={handleSend} className="space-y-3">
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Recipient Email
</label>
<input
type="email"
value={toEmail}
onChange={(e) => setToEmail(e.target.value)}
placeholder="friend@example.com"
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white placeholder-gray-600 focus:outline-none focus:border-rose-800/50 focus:ring-1 focus:ring-rose-800/30 transition"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Amount (FLH)
</label>
<input
type="number"
min={1}
step={1}
value={sendAmount}
onChange={(e) => setSendAmount(e.target.value)}
placeholder="Enter amount"
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white placeholder-gray-600 focus:outline-none focus:border-rose-800/50 focus:ring-1 focus:ring-rose-800/30 transition"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Message (optional)
</label>
<textarea
value={sendMessage}
onChange={(e) => setSendMessage(e.target.value)}
placeholder="A kind note..."
rows={2}
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 text-white placeholder-gray-600 focus:outline-none focus:border-rose-800/50 focus:ring-1 focus:ring-rose-800/30 transition resize-none"
/>
</div>
<button
type="submit"
disabled={sendLoading}
className="w-full px-4 py-4 rounded-xl bg-rose-600 text-white font-semibold text-sm disabled:opacity-50 active:opacity-80 transition flex items-center justify-center gap-2"
>
{sendLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Heart size={16} />
Send Hibah
</>
)}
</button>
</form>
{sendMessageState && sendMessageState.type === "success" && (
<div className="mt-3 flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
{sendMessageState.text}
</div>
)}
{sendMessageState && sendMessageState.type === "error" && (
<ErrorFeedback
error={sendMessageState.text}
kind="default"
onRetry={() => setSendMessageState(null)}
context="flh"
/>
)}
</div>
{/* b) Sent Gifts */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<ArrowUpRight size={16} className="text-gray-500" />
Sent Gifts
</h2>
{sentLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : sentError ? (
<ErrorFeedback
error={sentError}
kind="default"
onRetry={fetchSent}
context="flh"
/>
) : sent.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<ArrowUpRight size={20} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No gifts sent yet</p>
<p className="text-xs text-gray-700 mt-1">
Your outgoing hibah will appear here
</p>
</div>
) : (
<div className="space-y-2">
{sent.map((gift) => (
<div
key={gift.id}
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-rose-900/20 flex items-center justify-center">
<ArrowUpRight size={14} className="text-rose-400" />
</div>
<div>
<p className="text-sm font-medium text-white">
{gift.amount.toLocaleString()} FLH
</p>
<p className="text-xs text-gray-500">
To: {gift.toEmail ?? gift.toUserId ?? "Unknown"} {" "}
{new Date(gift.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{statusIcon(gift.status)}
<span
className={`text-xs font-medium capitalize ${statusColor(gift.status)}`}
>
{gift.status}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* c) Received Gifts */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<ArrowDownLeft size={16} className="text-gray-500" />
Received Gifts
</h2>
{receivedLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : receivedError ? (
<ErrorFeedback
error={receivedError}
kind="default"
onRetry={fetchReceived}
context="flh"
/>
) : received.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<ArrowDownLeft
size={20}
className="mx-auto text-gray-700 mb-2"
/>
<p className="text-sm text-gray-500">No gifts received yet</p>
<p className="text-xs text-gray-700 mt-1">
When someone sends you a hibah, it will appear here
</p>
</div>
) : (
<div className="space-y-2">
{received.map((gift) => (
<div
key={gift.id}
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-900/20 flex items-center justify-center">
<ArrowDownLeft
size={14}
className="text-emerald-400"
/>
</div>
<div>
<p className="text-sm font-medium text-white">
{gift.amount.toLocaleString()} FLH
</p>
<p className="text-xs text-gray-500">
From: {gift.fromName ?? gift.fromEmail ?? "Unknown"}{" "}
{" "}
{new Date(gift.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{statusIcon(gift.status)}
<span
className={`text-xs font-medium capitalize ${statusColor(gift.status)}`}
>
{gift.status}
</span>
</div>
</div>
{gift.message && (
<p className="text-xs text-gray-500 ml-11 mb-2 italic">
&ldquo;{gift.message}&rdquo;
</p>
)}
{gift.status === "pending" && (
<div className="flex gap-2 ml-11">
<button
onClick={() => handleAccept(gift.id)}
disabled={actionLoadingId === gift.id}
className="flex-1 px-3 py-2 rounded-lg bg-emerald-600 text-white text-xs font-medium active:scale-95 transition disabled:opacity-50 flex items-center justify-center gap-1"
>
{actionLoadingId === gift.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
<>
<Check size={12} />
Accept
</>
)}
</button>
<button
onClick={() => handleReject(gift.id)}
disabled={actionLoadingId === gift.id}
className="flex-1 px-3 py-2 rounded-lg bg-red-900/20 border border-red-800/30 text-red-400 text-xs font-medium active:scale-95 transition disabled:opacity-50 flex items-center justify-center gap-1"
>
{actionLoadingId === gift.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
<>
<X size={12} />
Reject
</>
)}
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
<div className="h-4" />
</div>
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
Heart,
Gift,
Hand,
Building2,
Calculator,
Info,
ArrowRight,
Sparkles,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface SummaryData {
zakatTotal: number;
sadaqahTotal: number;
hibahTotal: number;
waqfTotal: number;
}
const PILLARS = [
{
slug: "zakat",
name: "Zakat",
desc: "Obligatory charity — purify your wealth",
icon: Calculator,
color: "text-amber-400",
bg: "bg-amber-900/20",
},
{
slug: "sadaqah",
name: "Sadaqah",
desc: "Voluntary charity for the sake of Allah",
icon: Gift,
color: "text-emerald-400",
bg: "bg-emerald-900/20",
},
{
slug: "hibah",
name: "Hibah",
desc: "Give gifts to loved ones",
icon: Heart,
color: "text-rose-400",
bg: "bg-rose-900/20",
},
{
slug: "waqf",
name: "Waqf",
desc: "Endowment for perpetual rewards",
icon: Building2,
color: "text-sky-400",
bg: "bg-sky-900/20",
},
];
export default function FlhDashboard() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
const [summary, setSummary] = useState<SummaryData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !token) router.push("/auth");
}, [authLoading, token, router]);
useEffect(() => {
if (token) fetchSummary();
}, [token]);
const fetchSummary = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/mobile/api/flh/summary", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to load summary");
}
const data = await res.json();
setSummary(data);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setError(msg);
} finally {
setLoading(false);
}
};
const getPillarTotal = (slug: string): number => {
if (!summary) return 0;
switch (slug) {
case "zakat":
return summary.zakatTotal ?? 0;
case "sadaqah":
return summary.sadaqahTotal ?? 0;
case "hibah":
return summary.hibahTotal ?? 0;
case "waqf":
return summary.waqfTotal ?? 0;
default:
return 0;
}
};
if (authLoading) {
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>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
<div className="px-4 pt-6 pb-2">
<h1 className="text-xl font-bold text-white">Islamic Finance</h1>
<p className="text-sm text-gray-500 mt-1">
Zakat · Sadaqah · Hibah · Waqf
</p>
</div>
<div className="px-4 space-y-5 animate-fade-in">
{error && (
<ErrorFeedback
error={error}
kind="default"
onRetry={fetchSummary}
context="flh"
/>
)}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : (
<>
{/* Pillar Cards Grid */}
<div className="grid grid-cols-2 gap-3">
{PILLARS.map((pillar) => (
<Link
key={pillar.slug}
href={`/flh/${pillar.slug}`}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4 active:scale-[0.97] transition-all hover:border-gray-700/60 flex flex-col"
>
<div
className={`w-10 h-10 rounded-xl ${pillar.bg} flex items-center justify-center mb-3`}
>
<pillar.icon size={20} className={pillar.color} />
</div>
<h3 className="text-sm font-semibold text-white mb-1">
{pillar.name}
</h3>
<p className="text-xs text-gray-500 leading-relaxed mb-3 flex-1">
{pillar.desc}
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600">Total</span>
<span className="text-xs font-semibold text-[#D4AF37]">
{getPillarTotal(pillar.slug).toLocaleString()} FLH
</span>
</div>
</Link>
))}
</div>
{/* Quick Actions */}
<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">
<Sparkles size={16} className="text-[#D4AF37]" />
Quick Actions
</h3>
<Link
href="/flh/zakat"
className="flex items-center justify-between bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-3 active:scale-[0.98] transition"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-amber-900/20 flex items-center justify-center">
<Calculator size={16} className="text-amber-400" />
</div>
<span className="text-sm text-gray-300 font-medium">
Calculate Zakat
</span>
</div>
<ArrowRight size={16} className="text-gray-600" />
</Link>
</div>
{/* Educational Snippet */}
<div className="bg-gradient-to-br from-[#D4AF37]/20 to-[#0a0a0f] border border-[#D4AF37]/30 rounded-2xl p-5">
<div className="flex items-start gap-3">
<Info size={18} className="text-[#D4AF37] mt-0.5 shrink-0" />
<div>
<h3 className="text-sm font-semibold text-[#D4AF37] mb-1">
Islamic Finance in Islam
</h3>
<p className="text-xs text-gray-400 leading-relaxed">
Islamic finance is guided by Shariah principles that promote
justice, transparency, and social welfare. Zakat purifies
wealth, Sadaqah spreads blessings, Hibah strengthens family
bonds, and Waqf creates perpetual charity. Each act of
giving brings barakah and draws us closer to Allah.
</p>
</div>
</div>
</div>
<div className="h-4" />
</>
)}
</div>
</div>
);
}
+548
View File
@@ -0,0 +1,548 @@
"use client";
import { useState, useEffect, FormEvent } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
Gift,
Clock,
Check,
Loader2,
TrendingUp,
Heart,
Repeat,
X,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface Cause {
id: string;
name: string;
description: string;
collected: number;
target: number;
}
interface Subscription {
id: string;
causeName?: string;
amount: number;
interval: string;
active: boolean;
createdAt: string;
}
interface SadaqahPayment {
id: string;
amount: number;
causeName?: string;
createdAt: string;
}
export default function SadaqahPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
// Causes
const [causes, setCauses] = useState<Cause[]>([]);
const [causesLoading, setCausesLoading] = useState(true);
const [causesError, setCausesError] = useState<string | null>(null);
// Give Sadaqah
const [giveAmount, setGiveAmount] = useState("");
const [selectedCause, setSelectedCause] = useState("");
const [isRecurring, setIsRecurring] = useState(false);
const [recurringInterval, setRecurringInterval] = useState<
"weekly" | "monthly" | "yearly"
>("monthly");
const [giveLoading, setGiveLoading] = useState(false);
const [giveMessage, setGiveMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// Subscriptions
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [subsLoading, setSubsLoading] = useState(true);
const [subsError, setSubsError] = useState<string | null>(null);
const [cancelSubId, setCancelSubId] = useState<string | null>(null);
// History
const [history, setHistory] = useState<SadaqahPayment[]>([]);
const [historyLoading, setHistoryLoading] = useState(true);
const [historyError, setHistoryError] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !token) router.push("/auth");
}, [authLoading, token, router]);
useEffect(() => {
if (token) {
fetchCauses();
fetchSubscriptions();
fetchHistory();
}
}, [token]);
const fetchCauses = async () => {
setCausesLoading(true);
setCausesError(null);
try {
const res = await fetch("/mobile/api/flh/sadaqah/causes");
if (!res.ok) throw new Error("Failed to fetch causes");
const data = await res.json();
setCauses(Array.isArray(data) ? data : data.causes ?? []);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setCausesError(msg);
} finally {
setCausesLoading(false);
}
};
const fetchSubscriptions = async () => {
setSubsLoading(true);
setSubsError(null);
try {
const res = await fetch("/mobile/api/flh/sadaqah/subscriptions", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Failed to fetch subscriptions");
const data = await res.json();
setSubscriptions(Array.isArray(data) ? data : data.subscriptions ?? []);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setSubsError(msg);
} finally {
setSubsLoading(false);
}
};
const fetchHistory = async () => {
setHistoryLoading(true);
setHistoryError(null);
try {
const res = await fetch("/mobile/api/flh/sadaqah/history", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Failed to fetch history");
const data = await res.json();
setHistory(Array.isArray(data) ? data : data.payments ?? []);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setHistoryError(msg);
} finally {
setHistoryLoading(false);
}
};
const handleGive = async (e: FormEvent) => {
e.preventDefault();
const amount = parseFloat(giveAmount);
if (isNaN(amount) || amount <= 0) {
setGiveMessage({ type: "error", text: "Please enter a valid amount" });
return;
}
setGiveLoading(true);
setGiveMessage(null);
try {
if (isRecurring) {
const res = await fetch("/mobile/api/flh/sadaqah/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
amount,
causeId: selectedCause || undefined,
interval: recurringInterval,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Subscription failed");
}
setGiveMessage({
type: "success",
text: `Recurring sadaqah of ${amount} FLH ${recurringInterval} set up!`,
});
fetchSubscriptions();
} else {
const res = await fetch("/mobile/api/flh/sadaqah/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
amount,
causeId: selectedCause || undefined,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Sadaqah failed");
}
setGiveMessage({
type: "success",
text: `Sadaqah of ${amount} FLH given! May Allah accept it.`,
});
fetchHistory();
}
setGiveAmount("");
setTimeout(() => setGiveMessage(null), 4000);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Transaction failed";
setGiveMessage({ type: "error", text: msg });
setTimeout(() => setGiveMessage(null), 4000);
} finally {
setGiveLoading(false);
}
};
const handleCancelSubscription = async (subscriptionId: string) => {
setCancelSubId(subscriptionId);
try {
const res = await fetch("/mobile/api/flh/sadaqah/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ subscriptionId }),
});
if (!res.ok) throw new Error("Failed to cancel");
fetchSubscriptions();
} catch {
// ignore
} finally {
setCancelSubId(null);
}
};
if (authLoading) {
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>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-2 flex items-center gap-3">
<Link
href="/flh"
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:scale-90 transition"
>
<ArrowLeft size={16} className="text-gray-500" />
</Link>
<div>
<h1 className="text-xl font-bold text-white">Sadaqah</h1>
<p className="text-xs text-gray-500">Voluntary charity</p>
</div>
</div>
<div className="px-4 space-y-5 animate-fade-in">
{/* a) Causes Grid */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Heart size={16} className="text-emerald-400" />
Causes
</h2>
{causesLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : causesError ? (
<ErrorFeedback
error={causesError}
kind="default"
onRetry={fetchCauses}
context="flh"
/>
) : causes.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<Gift size={20} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No causes available</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{causes.map((cause) => {
const progress =
cause.target > 0
? Math.min(100, (cause.collected / cause.target) * 100)
: 0;
return (
<div
key={cause.id}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4"
>
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-sm font-semibold text-white">
{cause.name}
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{cause.description}
</p>
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-500 mb-2">
<span>
{cause.collected.toLocaleString()} /{" "}
{cause.target.toLocaleString()} FLH
</span>
<span>{progress.toFixed(0)}%</span>
</div>
<div className="w-full h-2 bg-gray-800/60 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
{/* b) Give Sadaqah */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Gift size={16} className="text-emerald-400" />
Give Sadaqah
</h2>
<form onSubmit={handleGive} className="space-y-3">
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Amount (FLH)
</label>
<input
type="number"
min={1}
step={1}
value={giveAmount}
onChange={(e) => setGiveAmount(e.target.value)}
placeholder="Enter amount"
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white placeholder-gray-600 focus:outline-none focus:border-emerald-800/50 focus:ring-1 focus:ring-emerald-800/30 transition"
/>
</div>
{causes.length > 0 && (
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Cause (optional)
</label>
<select
value={selectedCause}
onChange={(e) => setSelectedCause(e.target.value)}
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white focus:outline-none focus:border-emerald-800/50 transition"
>
<option value="">General Sadaqah</option>
{causes.map((cause) => (
<option key={cause.id} value={cause.id}>
{cause.name}
</option>
))}
</select>
</div>
)}
{/* Recurring Toggle */}
<div className="flex items-center justify-between">
<label className="text-xs text-gray-500 flex items-center gap-2">
<Repeat size={14} />
Make recurring
</label>
<button
type="button"
onClick={() => setIsRecurring(!isRecurring)}
className={`relative w-10 h-5 rounded-full transition-colors ${
isRecurring ? "bg-emerald-600" : "bg-gray-700"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
isRecurring ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
</div>
{isRecurring && (
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Interval
</label>
<select
value={recurringInterval}
onChange={(e) =>
setRecurringInterval(
e.target.value as "weekly" | "monthly" | "yearly"
)
}
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white focus:outline-none focus:border-emerald-800/50 transition"
>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
)}
<button
type="submit"
disabled={giveLoading}
className="w-full px-4 py-4 rounded-xl bg-emerald-600 text-white font-semibold text-sm disabled:opacity-50 active:opacity-80 transition flex items-center justify-center gap-2"
>
{giveLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Gift size={16} />
{isRecurring ? "Subscribe" : "Give Sadaqah"}
</>
)}
</button>
</form>
{giveMessage && giveMessage.type === "success" && (
<div className="mt-3 flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
{giveMessage.text}
</div>
)}
{giveMessage && giveMessage.type === "error" && (
<ErrorFeedback
error={giveMessage.text}
kind="default"
onRetry={() => setGiveMessage(null)}
context="flh"
/>
)}
</div>
{/* c) Recurring Subscriptions */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Repeat size={16} className="text-gray-500" />
Recurring Subscriptions
</h2>
{subsLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : subsError ? (
<ErrorFeedback
error={subsError}
kind="default"
onRetry={fetchSubscriptions}
context="flh"
/>
) : subscriptions.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<Repeat size={20} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No active subscriptions</p>
<p className="text-xs text-gray-700 mt-1">
Set up recurring sadaqah for continuous rewards
</p>
</div>
) : (
<div className="space-y-2">
{subscriptions
.filter((s) => s.active)
.map((sub) => (
<div
key={sub.id}
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-900/20 flex items-center justify-center">
<Repeat size={14} className="text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium text-white">
{sub.amount.toLocaleString()} FLH / {sub.interval}
</p>
<p className="text-xs text-gray-500">
{sub.causeName ?? "General Sadaqah"}
</p>
</div>
</div>
<button
onClick={() => handleCancelSubscription(sub.id)}
disabled={cancelSubId === sub.id}
className="px-3 py-1.5 rounded-lg bg-red-900/20 border border-red-800/30 text-red-400 text-xs font-medium active:scale-95 transition disabled:opacity-50"
>
{cancelSubId === sub.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
"Cancel"
)}
</button>
</div>
))}
</div>
)}
</div>
{/* d) History */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Clock size={16} className="text-gray-500" />
History
</h2>
{historyLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : historyError ? (
<ErrorFeedback
error={historyError}
kind="default"
onRetry={fetchHistory}
context="flh"
/>
) : history.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<TrendingUp size={20} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No sadaqah yet</p>
<p className="text-xs text-gray-700 mt-1">
Your sadaqah payments will appear here
</p>
</div>
) : (
<div className="space-y-2">
{history.map((payment) => (
<div
key={payment.id}
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-emerald-900/20 flex items-center justify-center">
<Gift size={14} className="text-emerald-400" />
</div>
<div>
<p className="text-sm font-medium text-white">
{payment.amount.toLocaleString()} FLH
</p>
<p className="text-xs text-gray-500">
{payment.causeName ?? "General Sadaqah"} {" "}
{new Date(payment.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="h-4" />
</div>
</div>
);
}
+383
View File
@@ -0,0 +1,383 @@
"use client";
import { useState, useEffect, FormEvent } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
Building2,
Clock,
Check,
Loader2,
TrendingUp,
Hand,
Landmark,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface WaqfPool {
id: string;
name: string;
beneficiary: string;
totalPrincipal: number;
availableYield: number;
}
interface Contribution {
id: string;
poolId: string;
poolName?: string;
amount: number;
createdAt: string;
}
export default function WaqfPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
// Pools
const [pools, setPools] = useState<WaqfPool[]>([]);
const [poolsLoading, setPoolsLoading] = useState(true);
const [poolsError, setPoolsError] = useState<string | null>(null);
// My Contributions
const [contributions, setContributions] = useState<Contribution[]>([]);
const [contribLoading, setContribLoading] = useState(true);
const [contribError, setContribError] = useState<string | null>(null);
// Contribute
const [selectedPoolId, setSelectedPoolId] = useState("");
const [contributeAmount, setContributeAmount] = useState("");
const [contributeLoading, setContributeLoading] = useState(false);
const [contributeMessage, setContributeMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
useEffect(() => {
if (!authLoading && !token) router.push("/auth");
}, [authLoading, token, router]);
useEffect(() => {
if (token) {
fetchPools();
fetchContributions();
}
}, [token]);
const fetchPools = async () => {
setPoolsLoading(true);
setPoolsError(null);
try {
const res = await fetch("/mobile/api/flh/waqf/pools");
if (!res.ok) throw new Error("Failed to fetch waqf pools");
const data = await res.json();
setPools(Array.isArray(data) ? data : data.pools ?? []);
if (Array.isArray(data) && data.length > 0) {
setSelectedPoolId(data[0].id);
} else if (data.pools?.length > 0) {
setSelectedPoolId(data.pools[0].id);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setPoolsError(msg);
} finally {
setPoolsLoading(false);
}
};
const fetchContributions = async () => {
setContribLoading(true);
setContribError(null);
try {
const res = await fetch("/mobile/api/flh/waqf/my-contributions", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Failed to fetch contributions");
const data = await res.json();
setContributions(
Array.isArray(data) ? data : data.contributions ?? []
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setContribError(msg);
} finally {
setContribLoading(false);
}
};
const handleContribute = async (e: FormEvent) => {
e.preventDefault();
const amount = parseFloat(contributeAmount);
if (isNaN(amount) || amount <= 0) {
setContributeMessage({
type: "error",
text: "Please enter a valid amount",
});
return;
}
if (!selectedPoolId) {
setContributeMessage({
type: "error",
text: "Please select a pool",
});
return;
}
setContributeLoading(true);
setContributeMessage(null);
try {
const res = await fetch("/mobile/api/flh/waqf/contribute", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
poolId: selectedPoolId,
amount,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Contribution failed");
}
setContributeMessage({
type: "success",
text: `Contributed ${amount} FLH to waqf pool!`,
});
setContributeAmount("");
fetchContributions();
setTimeout(() => setContributeMessage(null), 4000);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Contribution failed";
setContributeMessage({ type: "error", text: msg });
setTimeout(() => setContributeMessage(null), 4000);
} finally {
setContributeLoading(false);
}
};
if (authLoading) {
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>
);
}
if (!user) return null;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-2 flex items-center gap-3">
<Link
href="/flh"
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:scale-90 transition"
>
<ArrowLeft size={16} className="text-gray-500" />
</Link>
<div>
<h1 className="text-xl font-bold text-white">Waqf</h1>
<p className="text-xs text-gray-500">Endowment for perpetual rewards</p>
</div>
</div>
<div className="px-4 space-y-5 animate-fade-in">
{/* a) Pools Grid */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Building2 size={16} className="text-sky-400" />
Waqf Pools
</h2>
{poolsLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : poolsError ? (
<ErrorFeedback
error={poolsError}
kind="default"
onRetry={fetchPools}
context="flh"
/>
) : pools.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<Building2 size={20} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No waqf pools available</p>
<p className="text-xs text-gray-700 mt-1">
Waqf pools will appear here when available
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{pools.map((pool) => (
<div
key={pool.id}
className="bg-[#111118] border border-gray-800/60 rounded-2xl p-4"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-sky-900/20 flex items-center justify-center">
<Landmark size={18} className="text-sky-400" />
</div>
<div>
<h3 className="text-sm font-semibold text-white">
{pool.name}
</h3>
<p className="text-xs text-gray-500">
{pool.beneficiary}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="bg-[#1a1a24] rounded-xl px-3 py-2 text-center">
<p className="text-xs text-gray-500">Total Principal</p>
<p className="text-sm font-semibold text-white">
{pool.totalPrincipal.toLocaleString()} FLH
</p>
</div>
<div className="bg-[#1a1a24] rounded-xl px-3 py-2 text-center">
<p className="text-xs text-gray-500">Available Yield</p>
<p className="text-sm font-semibold text-emerald-400">
{pool.availableYield.toLocaleString()} FLH
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* b) My Contributions */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Hand size={16} className="text-gray-500" />
My Contributions
</h2>
{contribLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : contribError ? (
<ErrorFeedback
error={contribError}
kind="default"
onRetry={fetchContributions}
context="flh"
/>
) : contributions.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<TrendingUp size={20} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">
No contributions yet
</p>
<p className="text-xs text-gray-700 mt-1">
Your waqf contributions will appear here
</p>
</div>
) : (
<div className="space-y-2">
{contributions.map((c) => (
<div
key={c.id}
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-sky-900/20 flex items-center justify-center">
<Landmark size={14} className="text-sky-400" />
</div>
<div>
<p className="text-sm font-medium text-white">
{c.amount.toLocaleString()} FLH
</p>
<p className="text-xs text-gray-500">
{c.poolName ?? "Waqf Pool"} {" "}
{new Date(c.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* c) Contribute Form */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Building2 size={16} className="text-sky-400" />
Contribute to Waqf
</h2>
<form onSubmit={handleContribute} className="space-y-3">
{pools.length > 0 && (
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Select Pool
</label>
<select
value={selectedPoolId}
onChange={(e) => setSelectedPoolId(e.target.value)}
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white focus:outline-none focus:border-sky-800/50 transition"
>
{pools.map((pool) => (
<option key={pool.id} value={pool.id}>
{pool.name}
</option>
))}
</select>
</div>
)}
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Amount (FLH)
</label>
<input
type="number"
min={1}
step={1}
value={contributeAmount}
onChange={(e) => setContributeAmount(e.target.value)}
placeholder="Enter amount"
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white placeholder-gray-600 focus:outline-none focus:border-sky-800/50 focus:ring-1 focus:ring-sky-800/30 transition"
/>
</div>
<button
type="submit"
disabled={contributeLoading}
className="w-full px-4 py-4 rounded-xl bg-sky-600 text-white font-semibold text-sm disabled:opacity-50 active:opacity-80 transition flex items-center justify-center gap-2"
>
{contributeLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Hand size={16} />
Contribute
</>
)}
</button>
</form>
{contributeMessage && contributeMessage.type === "success" && (
<div className="mt-3 flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
{contributeMessage.text}
</div>
)}
{contributeMessage && contributeMessage.type === "error" && (
<ErrorFeedback
error={contributeMessage.text}
kind="default"
onRetry={() => setContributeMessage(null)}
context="flh"
/>
)}
</div>
<div className="h-4" />
</div>
</div>
);
}
+484
View File
@@ -0,0 +1,484 @@
"use client";
import { useState, useEffect, FormEvent } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
Calculator,
Hand,
Clock,
Check,
Loader2,
Info,
TrendingUp,
Landmark,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface ZakatStatus {
totalPaid: number;
remainingObligation: number;
nisabThreshold: number;
}
interface ZakatAgent {
id: string;
name: string;
country: string;
}
interface ZakatPayment {
id: string;
amount: number;
agentName?: string;
status: string;
createdAt: string;
}
export default function ZakatPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
// Status
const [status, setStatus] = useState<ZakatStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const [statusError, setStatusError] = useState<string | null>(null);
// Calculator
const [savings, setSavings] = useState("");
const [calcResult, setCalcResult] = useState<number | null>(null);
const [calcNisab, setCalcNisab] = useState<number | null>(null);
const [calcLoading, setCalcLoading] = useState(false);
const [calcError, setCalcError] = useState<string | null>(null);
// Pay
const [payAmount, setPayAmount] = useState("");
const [agents, setAgents] = useState<ZakatAgent[]>([]);
const [selectedAgent, setSelectedAgent] = useState("");
const [payLoading, setPayLoading] = useState(false);
const [payMessage, setPayMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// History
const [history, setHistory] = useState<ZakatPayment[]>([]);
const [historyLoading, setHistoryLoading] = useState(true);
const [historyError, setHistoryError] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !token) router.push("/auth");
}, [authLoading, token, router]);
useEffect(() => {
if (token) {
fetchStatus();
fetchHistory();
fetchAgents();
}
}, [token]);
const fetchStatus = async () => {
setStatusLoading(true);
setStatusError(null);
try {
const res = await fetch("/mobile/api/flh/zakat/calculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ amount: 0 }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to fetch status");
}
const data = await res.json();
setStatus({
totalPaid: data.totalPaid ?? 0,
remainingObligation: data.remainingObligation ?? 0,
nisabThreshold: data.nisabThreshold ?? 2613,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setStatusError(msg);
// Set defaults so the UI works
setStatus({ totalPaid: 0, remainingObligation: 0, nisabThreshold: 2613 });
} finally {
setStatusLoading(false);
}
};
const fetchAgents = async () => {
try {
const res = await fetch("/mobile/api/flh/zakat/agents?country=MY");
if (res.ok) {
const data = await res.json();
setAgents(Array.isArray(data) ? data : data.agents ?? []);
}
} catch {
// silently fail
}
};
const fetchHistory = async () => {
setHistoryLoading(true);
setHistoryError(null);
try {
const res = await fetch("/mobile/api/flh/zakat/history", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to fetch history");
}
const data = await res.json();
setHistory(Array.isArray(data) ? data : data.payments ?? []);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setHistoryError(msg);
} finally {
setHistoryLoading(false);
}
};
const handleCalculate = async (e: FormEvent) => {
e.preventDefault();
const amount = parseFloat(savings);
if (isNaN(amount) || amount <= 0) {
setCalcError("Please enter a valid savings amount");
return;
}
setCalcLoading(true);
setCalcError(null);
try {
const res = await fetch("/mobile/api/flh/zakat/calculate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ amount }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Calculation failed");
}
const data = await res.json();
setCalcResult(data.zakatAmount ?? amount * 0.025);
setCalcNisab(data.nisabThreshold ?? null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Calculation failed";
setCalcError(msg);
} finally {
setCalcLoading(false);
}
};
const handlePay = async (e: FormEvent) => {
e.preventDefault();
const amount = parseFloat(payAmount);
if (isNaN(amount) || amount <= 0) {
setPayMessage({ type: "error", text: "Please enter a valid amount" });
return;
}
setPayLoading(true);
setPayMessage(null);
try {
const res = await fetch("/mobile/api/flh/zakat/pay", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
amount,
agentId: selectedAgent || undefined,
idempotencyKey: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Payment failed");
}
setPayMessage({ type: "success", text: "Zakat payment successful!" });
setPayAmount("");
fetchHistory();
fetchStatus();
setTimeout(() => setPayMessage(null), 4000);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Payment failed";
setPayMessage({ type: "error", text: msg });
setTimeout(() => setPayMessage(null), 4000);
} finally {
setPayLoading(false);
}
};
if (authLoading) {
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>
);
}
if (!user) return null;
const nisabDisplay = calcNisab ?? status?.nisabThreshold ?? 2613;
return (
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
{/* Header */}
<div className="px-4 pt-6 pb-2 flex items-center gap-3">
<Link
href="/flh"
className="w-8 h-8 rounded-full bg-gray-800/60 flex items-center justify-center active:scale-90 transition"
>
<ArrowLeft size={16} className="text-gray-500" />
</Link>
<div>
<h1 className="text-xl font-bold text-white">Zakat</h1>
<p className="text-xs text-gray-500">Purify your wealth</p>
</div>
</div>
<div className="px-4 space-y-5 animate-fade-in">
{/* a) Overview Card */}
<div className="bg-gradient-to-br from-amber-900/20 to-[#0a0a0f] border border-amber-800/30 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-amber-300 mb-3 flex items-center gap-2">
<Info size={16} />
Your Zakat Summary
</h2>
{statusLoading ? (
<div className="flex items-center justify-center py-4">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : statusError ? (
<ErrorFeedback
error={statusError}
kind="default"
onRetry={fetchStatus}
context="flh"
/>
) : (
<div className="grid grid-cols-2 gap-3">
<div className="bg-[#111118] border border-gray-800/60 rounded-xl p-3 text-center">
<p className="text-xs text-gray-500 mb-1">Paid This Year</p>
<p className="text-lg font-bold text-emerald-400">
{(status?.totalPaid ?? 0).toLocaleString()} FLH
</p>
</div>
<div className="bg-[#111118] border border-gray-800/60 rounded-xl p-3 text-center">
<p className="text-xs text-gray-500 mb-1">Remaining</p>
<p className="text-lg font-bold text-amber-400">
{(status?.remainingObligation ?? 0).toLocaleString()} FLH
</p>
</div>
</div>
)}
</div>
{/* b) Zakat Calculator */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Calculator size={16} className="text-amber-400" />
Zakat Calculator
</h2>
<form onSubmit={handleCalculate} className="space-y-3">
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Total Savings (FLH)
</label>
<input
type="number"
min={0}
step={1}
value={savings}
onChange={(e) => setSavings(e.target.value)}
placeholder="e.g. 100000"
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white placeholder-gray-600 focus:outline-none focus:border-amber-800/50 focus:ring-1 focus:ring-amber-800/30 transition"
/>
</div>
<button
type="submit"
disabled={calcLoading}
className="w-full px-4 py-4 rounded-xl bg-amber-600 text-white font-semibold text-sm disabled:opacity-50 active:opacity-80 transition flex items-center justify-center gap-2"
>
{calcLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Calculator size={16} />
Calculate
</>
)}
</button>
</form>
{calcError && (
<ErrorFeedback
error={calcError}
kind="default"
onRetry={() => setCalcError(null)}
context="flh"
/>
)}
{calcResult !== null && (
<div className="mt-4 bg-amber-900/10 border border-amber-800/30 rounded-xl p-4">
<p className="text-xs text-amber-300/70 mb-1">
Your Zakat Obligation (2.5%)
</p>
<p className="text-2xl font-bold text-amber-400">
{calcResult.toLocaleString()} FLH
</p>
<p className="text-xs text-gray-500 mt-2">
Nisab threshold: {nisabDisplay.toLocaleString()} FLH zakat is
due if your savings exceed this amount and have been held for
one lunar year.
</p>
</div>
)}
</div>
{/* c) Pay Zakat */}
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Hand size={16} className="text-amber-400" />
Pay Zakat
</h2>
<form onSubmit={handlePay} className="space-y-3">
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Amount (FLH)
</label>
<input
type="number"
min={1}
step={1}
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
placeholder="Enter amount"
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white placeholder-gray-600 focus:outline-none focus:border-amber-800/50 focus:ring-1 focus:ring-amber-800/30 transition"
/>
</div>
{agents.length > 0 && (
<div>
<label className="text-xs text-gray-500 mb-1.5 block">
Zakat Agent (optional)
</label>
<select
value={selectedAgent}
onChange={(e) => setSelectedAgent(e.target.value)}
className="w-full bg-[#1a1a24] border border-gray-800/60 rounded-xl px-4 py-4 text-white focus:outline-none focus:border-amber-800/50 transition"
>
<option value="">General Zakat Fund</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</div>
)}
<button
type="submit"
disabled={payLoading}
className="w-full px-4 py-4 rounded-xl bg-[#D4AF37] text-[#0a0a0f] font-semibold text-sm disabled:opacity-50 active:opacity-80 transition flex items-center justify-center gap-2"
>
{payLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Hand size={16} />
Pay Zakat
</>
)}
</button>
</form>
{payMessage && payMessage.type === "success" && (
<div className="mt-3 flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
{payMessage.text}
</div>
)}
{payMessage && payMessage.type === "error" && (
<ErrorFeedback
error={payMessage.text}
kind="default"
onRetry={() => setPayMessage(null)}
context="flh"
/>
)}
</div>
{/* d) History */}
<div>
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Clock size={16} className="text-gray-500" />
Zakat History
</h2>
{historyLoading ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-6 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-[#D4AF37]/30 border-t-[#D4AF37] animate-spin" />
</div>
) : historyError ? (
<ErrorFeedback
error={historyError}
kind="default"
onRetry={fetchHistory}
context="flh"
/>
) : history.length === 0 ? (
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl p-5 text-center">
<TrendingUp size={20} className="mx-auto text-gray-700 mb-2" />
<p className="text-sm text-gray-500">No zakat payments yet</p>
<p className="text-xs text-gray-700 mt-1">
Your zakat payments will appear here
</p>
</div>
) : (
<div className="space-y-2">
{history.map((payment) => (
<div
key={payment.id}
className="bg-[#111118] border border-gray-800/60 rounded-xl px-4 py-3 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-amber-900/20 flex items-center justify-center">
<Landmark size={14} className="text-amber-400" />
</div>
<div>
<p className="text-sm font-medium text-white">
{payment.amount.toLocaleString()} FLH
</p>
<p className="text-xs text-gray-500">
{payment.agentName ?? "General Zakat Fund"} {" "}
{new Date(payment.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<span
className={`text-xs font-medium capitalize ${
payment.status === "completed"
? "text-emerald-400"
: payment.status === "pending"
? "text-amber-400"
: "text-gray-500"
}`}
>
{payment.status}
</span>
</div>
))}
</div>
)}
</div>
<div className="h-4" />
</div>
</div>
);
}
+1 -1
View File
@@ -74,7 +74,7 @@ interface QuizData {
function parseQuizData(raw: Record<string, unknown> | null): QuizData | null {
if (!raw) return null;
const q = raw as QuizData;
const q = raw as unknown as QuizData;
if (!Array.isArray(q.questions) || q.questions.length === 0) return null;
// Validate each question has required fields
const valid = q.questions.every(
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
export const dynamic = "force-static";
const manifest = {
name: "Falah — Islamic Lifestyle",
short_name: "Falah",
description: "Nur AI coaching, Halal marketplace, community & wallet",
start_url: "/mobile",
scope: "/mobile",
display: "standalone",
orientation: "portrait",
background_color: "#0a0a0f",
theme_color: "#0a0a0f",
categories: ["lifestyle", "education", "finance"],
lang: "en",
dir: "ltr",
id: "/mobile/",
icons: [
{ src: "/mobile/icons/icon-48x48.png", sizes: "48x48", type: "image/png" },
{ src: "/mobile/icons/icon-72x72.png", sizes: "72x72", type: "image/png" },
{ src: "/mobile/icons/icon-96x96.png", sizes: "96x96", type: "image/png" },
{ src: "/mobile/icons/icon-128x128.png", sizes: "128x128", type: "image/png" },
{ src: "/mobile/icons/icon-144x144.png", sizes: "144x144", type: "image/png" },
{ src: "/mobile/icons/icon-152x152.png", sizes: "152x152", type: "image/png" },
{ src: "/mobile/icons/icon-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "/mobile/icons/icon-384x384.png", sizes: "384x384", type: "image/png" },
{ src: "/mobile/icons/icon-512x512.png", sizes: "512x512", type: "image/png" },
{
src: "/mobile/icons/icon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/mobile/icons/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
screenshots: [],
shortcuts: [
{
name: "Nur AI Coach",
short_name: "Nur AI",
description: "Open AI coaching assistant",
url: "/mobile/nur",
},
{
name: "Halal Market",
short_name: "Market",
description: "Browse halal marketplace",
url: "/mobile/market",
},
{
name: "Wallet",
short_name: "Wallet",
description: "Falah wallet & payments",
url: "/mobile/wallet",
},
],
};
export async function GET() {
return NextResponse.json(manifest);
}
+3 -2
View File
@@ -254,9 +254,10 @@ export default function QiblaPage() {
// alpha = compass heading (0-360, 0 = North)
// webkitCompassHeading for iOS
let heading: number | null = null;
const iosEvent = event as DeviceOrientationEventiOS;
if (event.webkitCompassHeading !== undefined && event.webkitCompassHeading !== null) {
heading = event.webkitCompassHeading;
if (iosEvent.webkitCompassHeading !== undefined && iosEvent.webkitCompassHeading !== null) {
heading = iosEvent.webkitCompassHeading;
} else if (event.alpha !== null) {
// alpha is heading from North when absolute is true
heading = event.alpha;
+12 -1
View File
@@ -18,10 +18,20 @@ import {
X,
Home,
GraduationCap,
Heart,
} from "lucide-react";
interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ size?: number }>;
color: string;
bg: string;
external?: boolean;
}
// All destinations except Home (Home stays in center nav)
const overflowItems = [
const overflowItems: NavItem[] = [
{ href: "/nur", label: "Nur AI", icon: Bot, color: "text-amber-400", bg: "bg-amber-900/20" },
{ href: "/prayer", label: "Prayer", icon: Clock, color: "text-emerald-400", bg: "bg-emerald-900/20" },
{ href: "/forum", label: "Forum", icon: MessageCircle, color: "text-sky-400", bg: "bg-sky-900/20" },
@@ -31,6 +41,7 @@ const overflowItems = [
{ href: "/qibla", label: "Qibla", icon: Compass, color: "text-amber-400", bg: "bg-amber-900/20" },
{ href: "/souq", label: "Souq", icon: ShoppingBag, color: "text-[#D4AF37]", bg: "bg-[#D4AF37]/15" },
{ href: "/halal-monitor", label: "Halal Monitor", icon: MapPin, color: "text-emerald-400", bg: "bg-emerald-900/20" },
{ href: "/flh", label: "Islamic Finance", icon: Heart, color: "text-rose-400", bg: "bg-rose-900/20" },
{ href: "/learn", label: "Learn", icon: GraduationCap, color: "text-[#D4AF37]", bg: "bg-[#D4AF37]/15" },
{ href: "/upgrade", label: "Upgrade", icon: Sparkles, color: "text-[#D4AF37]", bg: "bg-[#D4AF37]/15" },
];
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { useState } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import {
Sparkles,
Headphones,
Award,
Book,
Check,
Zap,
} from "lucide-react";
/* ------------------------------------------------------------------ */
/* Pla n s */
/* ------------------------------------------------------------------ */
interface Plan {
id: "app-learn-pass" | "app-learn-pass-annual";
label: string;
price: string;
period: string;
annual?: boolean;
badge?: string;
}
const PLANS: Plan[] = [
{
id: "app-learn-pass",
label: "Monthly",
price: "$9.99",
period: "/month",
annual: false,
},
{
id: "app-learn-pass-annual",
label: "Annual",
price: "$79.99",
period: "/year",
annual: true,
badge: "2 months free",
},
];
const FEATURES = [
"Unlimited course access",
"Audio lessons",
"Certificates of completion",
"Progress tracking",
"New courses added regularly",
];
/* ------------------------------------------------------------------ */
/* Comp o n e n t */
/* ------------------------------------------------------------------ */
export default function LearnPassCard() {
const { user } = useAuth();
const router = useRouter();
const [selected, setSelected] = useState<"app-learn-pass" | "app-learn-pass-annual">(
"app-learn-pass-annual"
);
const [paymentMethod, setPaymentMethod] = useState<"flh" | "card">("flh");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const activePlan = PLANS.find((p) => p.id === selected)!;
const savingsNote =
activePlan.annual
? `Save $39.89/year vs monthly`
: null;
const handleSubscribe = async () => {
if (!user?.email) {
setError("Please log in first");
return;
}
setLoading(true);
setError("");
try {
const res = await fetch("/mobile/api/learn/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app_id: selected,
customer_email: user.email,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Checkout failed");
}
if (data.checkout_url) {
router.push(data.checkout_url);
} else if (data.mock) {
// Mock mode — redirect back to learn page
router.push("/mobile/learn");
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setError(msg);
} finally {
setLoading(false);
}
};
const handleFlhPurchase = async () => {
if (!user?.email) {
setError("Please log in first");
return;
}
setLoading(true);
setError("");
try {
const res = await fetch("/mobile/api/learn/pass/purchase", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app_id: selected,
customer_email: user.email,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Purchase failed");
}
// Successful FLH purchase — redirect to learn page
router.push("/mobile/learn");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Something went wrong";
setError(msg);
} finally {
setLoading(false);
}
};
if (user?.isPremium) {
return (
<div className="mt-8 mx-0">
<div className="bg-gradient-to-br from-emerald-900/20 to-[#111118] border border-emerald-800/30 rounded-2xl p-5 relative overflow-hidden">
<div className="absolute -top-10 -right-10 w-32 h-32 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-emerald-900/40 flex items-center justify-center">
<Zap size={20} className="text-emerald-400" />
</div>
<div>
<p className="text-sm font-semibold text-white">
Learn Pass Active
</p>
<p className="text-[11px] text-emerald-400/80">
You have unlimited access to all courses
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="mt-8 mx-0">
<div className="bg-gradient-to-br from-[#C9A84C]/10 to-[#C9A84C]/5 border border-[#C9A84C]/20 rounded-2xl p-5 relative overflow-hidden">
{/* Decorative glow */}
<div className="absolute -top-10 -right-10 w-32 h-32 bg-[#C9A84C]/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -bottom-8 -left-8 w-28 h-28 bg-[#00C48C]/5 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10">
{/* Header */}
<div className="flex items-center gap-2 mb-1">
<Sparkles size={18} className="text-[#C9A84C]" />
<h3 className="text-base font-bold text-white">Learn Pass</h3>
</div>
<p className="text-xs text-gray-400 mb-5 leading-relaxed">
Unlimited access to all courses, audio lessons, and certificates.
</p>
{/* Toggle: Monthly / Annual */}
<div className="flex bg-[#0a0a0f] border border-gray-800/60 rounded-xl p-1 mb-5">
{PLANS.map((plan) => (
<button
key={plan.id}
onClick={() => setSelected(plan.id)}
className={`flex-1 relative py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] ${
selected === plan.id
? "bg-[#C9A84C] text-[#07090C] shadow-md"
: "text-gray-400 hover:text-white"
}`}
>
{plan.label}
{plan.badge && (
<span
className={`absolute -top-2 right-1/2 translate-x-1/2 text-[9px] font-bold px-1.5 py-0.5 rounded-full whitespace-nowrap ${
selected === plan.id
? "bg-[#07090C] text-[#C9A84C]"
: "bg-[#C9A84C]/20 text-[#C9A84C]"
}`}
>
{plan.badge}
</span>
)}
</button>
))}
</div>
{/* Pricing */}
<div className="text-center mb-4">
<span className="text-3xl font-bold text-white">
{activePlan.price}
</span>
<span className="text-sm text-gray-500 ml-1">
{activePlan.period}
</span>
{savingsNote && (
<p className="text-[11px] text-emerald-400/80 mt-1">
{savingsNote}
</p>
)}
</div>
{/* Features */}
<ul className="space-y-2 mb-5">
{FEATURES.map((f) => (
<li key={f} className="flex items-center gap-2 text-xs text-gray-300">
<Check size={14} className="text-[#C9A84C] shrink-0" />
{f}
</li>
))}
</ul>
{/* Payment method toggle */}
<div className="flex bg-[#0a0a0f] border border-gray-800/60 rounded-xl p-1 mb-4">
<button
onClick={() => setPaymentMethod("flh")}
className={`flex-1 py-2.5 rounded-lg text-xs font-medium transition-all min-h-[40px] ${
paymentMethod === "flh"
? "bg-[#00C48C] text-[#07090C] shadow-md"
: "text-gray-400 hover:text-white"
}`}
>
Pay with FLH
</button>
<button
onClick={() => setPaymentMethod("card")}
className={`flex-1 py-2.5 rounded-lg text-xs font-medium transition-all min-h-[40px] ${
paymentMethod === "card"
? "bg-[#C9A84C] text-[#07090C] shadow-md"
: "text-gray-400 hover:text-white"
}`}
>
Pay with Card
</button>
</div>
{/* Action button */}
{paymentMethod === "flh" ? (
<button
onClick={handleFlhPurchase}
disabled={loading}
className="w-full inline-flex items-center justify-center gap-2 bg-[#00C48C] text-[#07090C] text-sm font-semibold px-5 py-3 rounded-xl hover:bg-[#00C48C]/90 active:scale-[0.97] transition disabled:opacity-50 disabled:cursor-not-allowed min-h-[48px]"
>
{loading ? (
<span className="w-5 h-5 border-2 border-[#07090C]/30 border-t-[#07090C] animate-spin rounded-full" />
) : (
<>
<Sparkles size={16} />
Pay with FLH {activePlan.price}{activePlan.period}
</>
)}
</button>
) : (
<button
onClick={handleSubscribe}
disabled={loading}
className="w-full inline-flex items-center justify-center gap-2 bg-[#C9A84C] text-[#07090C] text-sm font-semibold px-5 py-3 rounded-xl hover:bg-[#C9A84C]/90 active:scale-[0.97] transition disabled:opacity-50 disabled:cursor-not-allowed min-h-[48px]"
>
{loading ? (
<span className="w-5 h-5 border-2 border-[#07090C]/30 border-t-[#07090C] animate-spin rounded-full" />
) : (
<>
<Sparkles size={16} />
Subscribe {activePlan.price}{activePlan.period}
</>
)}
</button>
)}
{/* Error */}
{error && (
<p className="mt-3 text-xs text-red-400 text-center">{error}</p>
)}
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -15,12 +15,12 @@ export default function PWARegister() {
// Listen for beforeinstallprompt (Android Chrome)
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
(window as Record<string, unknown>).__deferredPrompt = e;
(window as unknown as Record<string, unknown>).__deferredPrompt = e;
});
window.addEventListener("appinstalled", () => {
console.log("[PWA] App installed");
(window as Record<string, unknown>).__deferredPrompt = null;
(window as unknown as Record<string, unknown>).__deferredPrompt = null;
});
}
}, []);
+579
View File
@@ -0,0 +1,579 @@
// ---------------------------------------------------------------------------
// Ummahid API Client Library
// ---------------------------------------------------------------------------
// Provides typesafe HTTP methods for Souq, Learn, Forum, and FLH resources.
// Use these functions in Next.js API routes to call the Ummah ID backend
// instead of making direct Prisma queries.
//
// Usage (named imports — used by Souq routes):
// import { getSouqListings } from "@/lib/ummahid-client";
//
// Usage (aggregate object — used by Learn & Forum routes):
// import { ummahidClient } from "@/lib/ummahid-client";
// ummahidClient.getLearnCourses(params, token);
// ---------------------------------------------------------------------------
import { NextRequest } from "next/server";
const UMMAHID_BASE = process.env.UMMAHID_URL || "http://falah_ummahid:3000";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Extract a Bearer token from an incoming NextRequest.
*/
async function getToken(request: NextRequest): Promise<string | null> {
const auth = request.headers.get("authorization");
return auth?.startsWith("Bearer ") ? auth.slice(7) : null;
}
/**
* Generic fetch wrapper against the Ummah ID API.
*/
async function apiCall(
url: string,
options?: {
method?: string;
body?: unknown;
token?: string | null;
},
): Promise<Response> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options?.token) {
headers["Authorization"] = `Bearer ${options.token}`;
}
return fetch(`${UMMAHID_BASE}${url}`, {
method: options?.method || "GET",
headers,
body: options?.body ? JSON.stringify(options.body) : undefined,
});
}
// ---------------------------------------------------------------------------
// S O U Q (Marketplace)
// ---------------------------------------------------------------------------
/**
* GET /api/souq/categories
*/
async function getSouqCategories(token?: string | null): Promise<Response> {
return apiCall("/api/souq/categories", { token });
}
/**
* GET /api/souq/listings
*/
async function getSouqListings(
params?: Record<string, unknown>,
token?: string | null,
): Promise<Response> {
const qs = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
qs.set(key, String(value));
}
}
}
const q = qs.toString();
return apiCall(`/api/souq/listings${q ? `?${q}` : ""}`, { token });
}
/**
* GET /api/souq/listings/:id
*/
async function getSouqListing(
id: string,
token?: string | null,
): Promise<Response> {
return apiCall(`/api/souq/listings/${encodeURIComponent(id)}`, { token });
}
/**
* POST /api/souq/listings
*/
async function createSouqListing(
data: unknown,
token: string,
): Promise<Response> {
return apiCall("/api/souq/listings", { method: "POST", body: data, token });
}
/**
* GET /api/souq/orders
*/
async function getSouqOrders(
params?: Record<string, unknown>,
token?: string,
): Promise<Response> {
const qs = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
qs.set(key, String(value));
}
}
}
const q = qs.toString();
return apiCall(`/api/souq/orders${q ? `?${q}` : ""}`, { token });
}
/**
* POST /api/souq/orders
*/
async function createSouqOrder(
data: unknown,
token: string,
): Promise<Response> {
return apiCall("/api/souq/orders", { method: "POST", body: data, token });
}
/**
* GET /api/souq/orders/:id
*/
async function getSouqOrder(id: string, token: string): Promise<Response> {
return apiCall(`/api/souq/orders/${encodeURIComponent(id)}`, { token });
}
/**
* PATCH /api/souq/orders/:id
*/
async function updateSouqOrder(
id: string,
data: unknown,
token: string,
): Promise<Response> {
return apiCall(`/api/souq/orders/${encodeURIComponent(id)}`, {
method: "PATCH",
body: data,
token,
});
}
/**
* POST /api/souq/reviews
*/
async function createSouqReview(
data: unknown,
token: string,
): Promise<Response> {
return apiCall("/api/souq/reviews", { method: "POST", body: data, token });
}
/**
* GET /api/souq/sellers/:id
*/
async function getSouqSeller(
id: string,
token?: string | null,
): Promise<Response> {
return apiCall(`/api/souq/sellers/${encodeURIComponent(id)}`, { token });
}
// ---------------------------------------------------------------------------
// L E A R N (Courses & Certification)
// ---------------------------------------------------------------------------
/**
* GET /api/learn/categories
*/
async function getLearnCategories(token?: string): Promise<Response> {
return apiCall("/api/learn/categories", { token });
}
/**
* GET /api/learn/courses
*/
async function getLearnCourses(
params?: Record<string, unknown>,
token?: string,
): Promise<Response> {
const qs = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
qs.set(key, String(value));
}
}
}
const q = qs.toString();
return apiCall(`/api/learn/courses${q ? `?${q}` : ""}`, { token });
}
/**
* GET /api/learn/courses/:slug
*/
async function getLearnCourse(
slug: string,
token?: string,
): Promise<Response> {
return apiCall(`/api/learn/courses/${encodeURIComponent(slug)}`, { token });
}
/**
* POST /api/learn/progress
*/
async function updateLearnProgress(
data: unknown,
token: string,
): Promise<Response> {
return apiCall("/api/learn/progress", { method: "POST", body: data, token });
}
/**
* GET /api/learn/pass/status
*/
async function getLearnPassStatus(token: string): Promise<Response> {
return apiCall("/api/learn/pass/status", { token });
}
/**
* POST /api/learn/pass/purchase
*/
async function purchaseLearnPass(
data: unknown,
token: string,
): Promise<Response> {
return apiCall("/api/learn/pass/purchase", {
method: "POST",
body: data,
token,
});
}
/**
* GET /api/learn/certificates
*/
async function getLearnCertificates(token: string): Promise<Response> {
return apiCall("/api/learn/certificates", { token });
}
/**
* GET /api/learn/certificates/:serial
*/
async function getLearnCertificate(
serial: string,
token?: string,
): Promise<Response> {
return apiCall(
`/api/learn/certificates/${encodeURIComponent(serial)}`,
{ token },
);
}
/**
* GET /api/learn/certificates/verify/:serial
*/
async function verifyLearnCertificate(serial: string): Promise<Response> {
return apiCall(
`/api/learn/certificates/verify/${encodeURIComponent(serial)}`,
);
}
/**
* GET /api/learn/tts
*/
async function getLearnTts(
params?: Record<string, unknown>,
token?: string,
): Promise<Response> {
const qs = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
qs.set(key, String(value));
}
}
}
const q = qs.toString();
return apiCall(`/api/learn/tts${q ? `?${q}` : ""}`, { token });
}
/**
* POST /api/learn/tts — trigger TTS generation for a module
*/
async function updateLearnTts(
data: unknown,
token: string,
): Promise<Response> {
return apiCall("/api/learn/tts", { method: "POST", body: data, token });
}
/**
* POST /api/learn/sync
*/
async function syncLearnCourses(
data: unknown,
syncToken?: string,
): Promise<Response> {
return apiCall("/api/learn/sync", {
method: "POST",
body: data,
token: syncToken,
});
}
// ---------------------------------------------------------------------------
// F O R U M (Discussions & Groups)
// ---------------------------------------------------------------------------
/**
* GET /api/forum/categories
*/
async function getForumCategories(token?: string): Promise<Response> {
return apiCall("/api/forum/categories", { token });
}
/**
* GET /api/forum/threads
*/
async function getForumThreads(
params?: Record<string, unknown>,
token?: string,
): Promise<Response> {
const qs = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
qs.set(key, String(value));
}
}
}
const q = qs.toString();
return apiCall(`/api/forum/threads${q ? `?${q}` : ""}`, { token });
}
/**
* POST /api/forum/threads
*/
async function createForumThread(
data: unknown,
token: string,
): Promise<Response> {
return apiCall("/api/forum/threads", { method: "POST", body: data, token });
}
/**
* GET /api/forum/posts
*/
async function getForumPosts(
params?: Record<string, unknown>,
token?: string,
): Promise<Response> {
const qs = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
qs.set(key, String(value));
}
}
}
const q = qs.toString();
return apiCall(`/api/forum/posts${q ? `?${q}` : ""}`, { token });
}
/**
* POST /api/forum/posts
*/
async function createForumPost(
data: unknown,
token: string,
): Promise<Response> {
return apiCall("/api/forum/posts", { method: "POST", body: data, token });
}
/**
* POST /api/forum/groups
*/
async function createForumGroup(
data: unknown,
token: string,
): Promise<Response> {
return apiCall("/api/forum/groups", { method: "POST", body: data, token });
}
/**
* POST /api/forum/groups/join
*/
async function joinForumGroup(
inviteCode: string,
token: string,
): Promise<Response> {
return apiCall("/api/forum/groups/join", {
method: "POST",
body: { inviteCode },
token,
});
}
// ---------------------------------------------------------------------------
// F L H (Financial Liberation Hub)
// ---------------------------------------------------------------------------
/**
* GET /api/flh/balance
*/
async function getFlhBalance(token: string): Promise<Response> {
return apiCall("/api/flh/balance", { token });
}
/**
* GET /api/flh/zakat/status
*/
async function getZakatStatus(token: string): Promise<Response> {
return apiCall("/api/flh/zakat/status", { token });
}
/**
* POST /api/flh/zakat/pay
*/
async function payZakat(data: unknown, token: string): Promise<Response> {
return apiCall("/api/flh/zakat/pay", { method: "POST", body: data, token });
}
/**
* GET /api/flh/sadaqah/causes
*/
async function getSadaqahCauses(token?: string): Promise<Response> {
return apiCall("/api/flh/sadaqah/causes", { token });
}
/**
* POST /api/flh/sadaqah/send
*/
async function sendSadaqah(data: unknown, token: string): Promise<Response> {
return apiCall("/api/flh/sadaqah/send", {
method: "POST",
body: data,
token,
});
}
/**
* POST /api/flh/hibah/send
*/
async function sendHibah(data: unknown, token: string): Promise<Response> {
return apiCall("/api/flh/hibah/send", { method: "POST", body: data, token });
}
/**
* POST /api/flh/waqf/create
*/
async function createWaqf(data: unknown, token: string): Promise<Response> {
return apiCall("/api/flh/waqf/create", { method: "POST", body: data, token });
}
/**
* POST /api/flh/waqf/contribute
*/
async function contributeWaqf(
data: unknown,
token: string,
): Promise<Response> {
return apiCall("/api/flh/waqf/contribute", {
method: "POST",
body: data,
token,
});
}
// ---------------------------------------------------------------------------
// Aggregated client object (used by Learn & Forum routes)
// ---------------------------------------------------------------------------
const ummahidClient = {
// Souq
getSouqCategories,
getSouqListings,
getSouqListing,
createSouqListing,
getSouqOrders,
createSouqOrder,
getSouqOrder,
updateSouqOrder,
createSouqReview,
getSouqSeller,
// Learn
getLearnCategories,
getLearnCourses,
getLearnCourse,
updateLearnProgress,
getLearnPassStatus,
purchaseLearnPass,
getLearnCertificates,
getLearnCertificate,
verifyLearnCertificate,
getLearnTts,
updateLearnTts,
syncLearnCourses,
// Forum
getForumCategories,
getForumThreads,
createForumThread,
getForumPosts,
createForumPost,
createForumGroup,
joinForumGroup,
// FLH
getFlhBalance,
getZakatStatus,
payZakat,
getSadaqahCauses,
sendSadaqah,
sendHibah,
createWaqf,
contributeWaqf,
};
export {
// helpers
getToken,
apiCall,
// aggregated client
ummahidClient,
// Souq
getSouqCategories,
getSouqListings,
getSouqListing,
createSouqListing,
getSouqOrders,
createSouqOrder,
getSouqOrder,
updateSouqOrder,
createSouqReview,
getSouqSeller,
// Learn
getLearnCategories,
getLearnCourses,
getLearnCourse,
updateLearnProgress,
getLearnPassStatus,
purchaseLearnPass,
getLearnCertificates,
getLearnCertificate,
verifyLearnCertificate,
getLearnTts,
updateLearnTts,
syncLearnCourses,
// Forum
getForumCategories,
getForumThreads,
createForumThread,
getForumPosts,
createForumPost,
createForumGroup,
joinForumGroup,
// FLH
getFlhBalance,
getZakatStatus,
payZakat,
getSadaqahCauses,
sendSadaqah,
sendHibah,
createWaqf,
contributeWaqf,
};
+4
View File
@@ -0,0 +1,4 @@
interface DeviceOrientationEventiOS extends DeviceOrientationEvent {
webkitCompassHeading?: number;
webkitCompassAccuracy?: number;
}
+1
View File
@@ -0,0 +1 @@
declare module 'pdfkit';