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:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
“{gift.message}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
interface DeviceOrientationEventiOS extends DeviceOrientationEvent {
|
||||
webkitCompassHeading?: number;
|
||||
webkitCompassAccuracy?: number;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module 'pdfkit';
|
||||
Reference in New Issue
Block a user