Block 8 complete: streaks, notifications, referrals, premium identity, gamification
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// GET /api/gamification/achievements — get all achievements with unlock status
|
||||
export async function GET(request: Request) {
|
||||
const jwt = await requireAuth(request);
|
||||
if (!jwt) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = jwt.userId;
|
||||
|
||||
const achievements = await prisma.achievement.findMany({
|
||||
where: { userId },
|
||||
orderBy: { unlockedAt: "desc" },
|
||||
});
|
||||
|
||||
// Define all possible achievement types so the frontend can show locked ones too
|
||||
const allAchievementTypes = [
|
||||
{ type: "first_chat", title: "First Words", description: "Send your first message to Nur AI", icon: "💬" },
|
||||
{ type: "streak_7", title: "Week Warrior", description: "Maintain a 7-day streak", icon: "🔥" },
|
||||
{ type: "streak_30", title: "Monthly Master", description: "Maintain a 30-day streak", icon: "🏆" },
|
||||
{ type: "level_5", title: "Rising Star", description: "Reach level 5", icon: "⭐" },
|
||||
{ type: "first_purchase", title: "First Shopper", description: "Make your first marketplace purchase", icon: "🛍️" },
|
||||
{ type: "first_post", title: "Forum Newbie", description: "Make your first forum post", icon: "📝" },
|
||||
];
|
||||
|
||||
const unlocked = new Set(achievements.map((a) => a.type));
|
||||
|
||||
const allAchievements = allAchievementTypes.map((def) => {
|
||||
const unlockedAchievement = achievements.find((a) => a.type === def.type);
|
||||
return {
|
||||
...def,
|
||||
unlocked: unlocked.has(def.type),
|
||||
unlockedAt: unlockedAchievement?.unlockedAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
achievements: allAchievements,
|
||||
unlockedCount: achievements.length,
|
||||
totalCount: allAchievementTypes.length,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const STREAK_REWARDS: Record<number, number> = {
|
||||
1: 100,
|
||||
3: 300,
|
||||
7: 500,
|
||||
14: 1000,
|
||||
30: 2500,
|
||||
};
|
||||
|
||||
function getStreakReward(streak: number): number {
|
||||
const milestones = Object.keys(STREAK_REWARDS)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a);
|
||||
for (const m of milestones) {
|
||||
if (streak >= m) return STREAK_REWARDS[m];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getXpForStreakClaim(streak: number): number {
|
||||
return Math.min(10 + streak * 2, 100);
|
||||
}
|
||||
|
||||
function isYesterday(d: Date): boolean {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return (
|
||||
d.getFullYear() === yesterday.getFullYear() &&
|
||||
d.getMonth() === yesterday.getMonth() &&
|
||||
d.getDate() === yesterday.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
const now = new Date();
|
||||
return (
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
// GET /api/gamification/streak — get current streak info
|
||||
export async function GET(request: Request) {
|
||||
const jwt = await requireAuth(request);
|
||||
if (!jwt) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let streak = await prisma.dailyStreak.findUnique({
|
||||
where: { userId: jwt.userId },
|
||||
});
|
||||
|
||||
if (!streak) {
|
||||
streak = await prisma.dailyStreak.create({
|
||||
data: { userId: jwt.userId },
|
||||
});
|
||||
}
|
||||
|
||||
const canClaim =
|
||||
!streak.lastClaimDate || !isToday(streak.lastClaimDate);
|
||||
|
||||
const todayReward = getStreakReward(streak.currentStreak + (canClaim ? 1 : 0));
|
||||
|
||||
return NextResponse.json({
|
||||
currentStreak: streak.currentStreak,
|
||||
longestStreak: streak.longestStreak,
|
||||
lastClaimDate: streak.lastClaimDate,
|
||||
canClaim,
|
||||
streakFreeze: streak.streakFreeze,
|
||||
todayReward,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/gamification/streak — claim daily streak reward
|
||||
export async function POST(request: Request) {
|
||||
const jwt = await requireAuth(request);
|
||||
if (!jwt) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = jwt.userId;
|
||||
|
||||
// Get or create streak record
|
||||
let streak = await prisma.dailyStreak.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!streak) {
|
||||
streak = await prisma.dailyStreak.create({
|
||||
data: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
// Already claimed today
|
||||
if (streak.lastClaimDate && isToday(streak.lastClaimDate)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Already claimed today" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
let newStreak: number;
|
||||
let newLongest: number;
|
||||
|
||||
if (!streak.lastClaimDate) {
|
||||
// First claim ever
|
||||
newStreak = 1;
|
||||
newLongest = 1;
|
||||
} else if (isYesterday(streak.lastClaimDate)) {
|
||||
// Consecutive day
|
||||
newStreak = streak.currentStreak + 1;
|
||||
newLongest = Math.max(newStreak, streak.longestStreak);
|
||||
} else {
|
||||
// Gap > 1 day — check streak freeze
|
||||
if (streak.streakFreeze > 0) {
|
||||
// Use a freeze — keep streak intact
|
||||
newStreak = streak.currentStreak + 1;
|
||||
newLongest = Math.max(newStreak, streak.longestStreak);
|
||||
// Decrement freeze
|
||||
await prisma.dailyStreak.update({
|
||||
where: { userId },
|
||||
data: { streakFreeze: { decrement: 1 } },
|
||||
});
|
||||
} else {
|
||||
// Reset streak
|
||||
newStreak = 1;
|
||||
newLongest = streak.longestStreak; // keep longest
|
||||
}
|
||||
}
|
||||
|
||||
const reward = getStreakReward(newStreak);
|
||||
const xpGain = getXpForStreakClaim(newStreak);
|
||||
|
||||
// Update streak
|
||||
await prisma.dailyStreak.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
currentStreak: newStreak,
|
||||
longestStreak: newLongest,
|
||||
lastClaimDate: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Award FLH
|
||||
if (reward > 0) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { flhBalance: { increment: reward } },
|
||||
});
|
||||
}
|
||||
|
||||
// Award XP + create transaction
|
||||
const xpTx = await prisma.xpTransaction.create({
|
||||
data: {
|
||||
userId,
|
||||
amount: xpGain,
|
||||
reason: "daily_login",
|
||||
},
|
||||
});
|
||||
|
||||
// Update or create user level
|
||||
let userLevel = await prisma.userLevel.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!userLevel) {
|
||||
userLevel = await prisma.userLevel.create({
|
||||
data: { userId, xp: xpGain },
|
||||
});
|
||||
} else {
|
||||
userLevel = await prisma.userLevel.update({
|
||||
where: { userId },
|
||||
data: { xp: { increment: xpGain } },
|
||||
});
|
||||
}
|
||||
|
||||
// Check level up
|
||||
let newLevel = userLevel.level;
|
||||
let newXp = userLevel.xp;
|
||||
let newXpToNext = userLevel.xpToNext;
|
||||
let leveledUp = false;
|
||||
|
||||
while (newXp >= newXpToNext) {
|
||||
newXp -= newXpToNext;
|
||||
newLevel++;
|
||||
newXpToNext = Math.floor(newXpToNext * 1.5);
|
||||
leveledUp = true;
|
||||
}
|
||||
|
||||
if (leveledUp) {
|
||||
await prisma.userLevel.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
level: newLevel,
|
||||
xp: newXp,
|
||||
xpToNext: newXpToNext,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check achievements
|
||||
const achievements: string[] = [];
|
||||
|
||||
// Streak 7 days
|
||||
if (newStreak >= 7) {
|
||||
const exists = await prisma.achievement.findUnique({
|
||||
where: { userId_type: { userId, type: "streak_7" } },
|
||||
});
|
||||
if (!exists) {
|
||||
await prisma.achievement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "streak_7",
|
||||
title: "Week Warrior",
|
||||
description: "Maintained a 7-day streak",
|
||||
icon: "🔥",
|
||||
},
|
||||
});
|
||||
achievements.push("streak_7");
|
||||
}
|
||||
}
|
||||
|
||||
// Streak 30 days
|
||||
if (newStreak >= 30) {
|
||||
const exists = await prisma.achievement.findUnique({
|
||||
where: { userId_type: { userId, type: "streak_30" } },
|
||||
});
|
||||
if (!exists) {
|
||||
await prisma.achievement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "streak_30",
|
||||
title: "Monthly Master",
|
||||
description: "Maintained a 30-day streak",
|
||||
icon: "🏆",
|
||||
},
|
||||
});
|
||||
achievements.push("streak_30");
|
||||
}
|
||||
}
|
||||
|
||||
// Level 5
|
||||
if (newLevel >= 5 && leveledUp) {
|
||||
const exists = await prisma.achievement.findUnique({
|
||||
where: { userId_type: { userId, type: "level_5" } },
|
||||
});
|
||||
if (!exists) {
|
||||
await prisma.achievement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "level_5",
|
||||
title: "Rising Star",
|
||||
description: "Reached level 5",
|
||||
icon: "⭐",
|
||||
},
|
||||
});
|
||||
achievements.push("level_5");
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
currentStreak: newStreak,
|
||||
longestStreak: newLongest,
|
||||
lastClaimDate: new Date(),
|
||||
reward,
|
||||
xpGain,
|
||||
newLevel: leveledUp ? newLevel : undefined,
|
||||
achievements,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// GET /api/gamification/xp — get user XP, level, and recent transactions
|
||||
export async function GET(request: Request) {
|
||||
const jwt = await requireAuth(request);
|
||||
if (!jwt) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = jwt.userId;
|
||||
|
||||
// Get or create user level
|
||||
let userLevel = await prisma.userLevel.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!userLevel) {
|
||||
userLevel = await prisma.userLevel.create({
|
||||
data: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
// Get recent transactions
|
||||
const recentTransactions = await prisma.xpTransaction.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
level: userLevel.level,
|
||||
xp: userLevel.xp,
|
||||
xpToNext: userLevel.xpToNext,
|
||||
totalXp: userLevel.xp + (userLevel.level - 1) * calculateXpForLevel(userLevel.level),
|
||||
recentTransactions: recentTransactions.map((tx) => ({
|
||||
id: tx.id,
|
||||
amount: tx.amount,
|
||||
reason: tx.reason,
|
||||
createdAt: tx.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function calculateXpForLevel(level: number): number {
|
||||
// Base XP per level: 100 * 1.5^(level-1) cumulative
|
||||
let total = 0;
|
||||
let xpToNext = 100;
|
||||
for (let i = 1; i < level; i++) {
|
||||
total += xpToNext;
|
||||
xpToNext = Math.floor(xpToNext * 1.5);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export async function POST(request: NextRequest) {
|
||||
where: { id: listingId },
|
||||
include: {
|
||||
seller: {
|
||||
select: { id: true, isPro: true },
|
||||
select: { id: true, isPremium: true, isPro: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -64,10 +64,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Calculate platform fee
|
||||
// Pro sellers pay 0% platform fee, free/premium pay 1.5%
|
||||
const sellerTier = getUserTier(false, listing.seller.isPro);
|
||||
const sellerTier = getUserTier(listing.seller.isPremium, listing.seller.isPro);
|
||||
const platformFeeRate = sellerTier === "pro" ? 0 : 0.015;
|
||||
const platformFee = Math.round(listing.priceFlh * platformFeeRate);
|
||||
const sellerPayout = listing.priceFlh - platformFee;
|
||||
|
||||
// FLH Earning Multiplier: Premium/Pro sellers earn 2x their payout
|
||||
const sellerMultiplier = listing.seller.isPremium || listing.seller.isPro ? 2 : 1;
|
||||
const basePayout = listing.priceFlh - platformFee;
|
||||
const sellerPayout = basePayout * sellerMultiplier;
|
||||
|
||||
// Auto-confirm in 7 days
|
||||
const autoConfirmAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
@@ -99,7 +103,7 @@ export async function POST(request: NextRequest) {
|
||||
where: { id: buyer.id },
|
||||
data: { flhBalance: { decrement: listing.priceFlh } },
|
||||
}),
|
||||
// Add payout to seller
|
||||
// Add payout to seller (with multiplier applied)
|
||||
prisma.user.update({
|
||||
where: { id: listing.sellerId },
|
||||
data: { flhBalance: { increment: sellerPayout } },
|
||||
@@ -111,10 +115,14 @@ export async function POST(request: NextRequest) {
|
||||
}),
|
||||
]);
|
||||
|
||||
const multiplierNote = sellerMultiplier > 1
|
||||
? ` Seller earned ${sellerMultiplier}x FLH multiplier (Premium/Pro bonus).`
|
||||
: "";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
purchase,
|
||||
message: `Successfully purchased "${listing.title}" for ${listing.priceFlh} FLH. Platform fee: ${platformFee} FLH. Seller receives: ${sellerPayout} FLH.`,
|
||||
message: `Successfully purchased "${listing.title}" for ${listing.priceFlh} FLH. Platform fee: ${platformFee} FLH. Seller receives: ${sellerPayout} FLH.${multiplierNote}`,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const jwtPayload = await requireAuth(req);
|
||||
if (!jwtPayload) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const unreadOnly = searchParams.get("unread") === "true";
|
||||
|
||||
const where: any = { userId: jwtPayload.userId };
|
||||
if (unreadOnly) where.read = false;
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return NextResponse.json({ notifications });
|
||||
} catch (error) {
|
||||
console.error("Notifications GET error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch notifications" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const jwtPayload = await requireAuth(req);
|
||||
if (!jwtPayload) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
|
||||
if (body.all === true) {
|
||||
await prisma.notification.updateMany({
|
||||
where: { userId: jwtPayload.userId, read: false },
|
||||
data: { read: true },
|
||||
});
|
||||
return NextResponse.json({ success: true, markedAll: true });
|
||||
}
|
||||
|
||||
if (body.id) {
|
||||
const notification = await prisma.notification.findFirst({
|
||||
where: { id: body.id, userId: jwtPayload.userId },
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
return NextResponse.json(
|
||||
{ error: "Notification not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.notification.update({
|
||||
where: { id: body.id },
|
||||
data: { read: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Provide 'id' or { all: true }" },
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Notifications PATCH error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update notification" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const jwtPayload = await requireAuth(req);
|
||||
if (!jwtPayload) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const count = await prisma.notification.count({
|
||||
where: { userId: jwtPayload.userId, read: false },
|
||||
});
|
||||
|
||||
return NextResponse.json({ count });
|
||||
} catch (error) {
|
||||
console.error("Unread count error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to get unread count" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const benefits = await prisma.premiumBenefit.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
const grouped = {
|
||||
premium: benefits.filter((b) => b.tier === "premium"),
|
||||
pro: benefits.filter((b) => b.tier === "pro"),
|
||||
};
|
||||
|
||||
return NextResponse.json({ benefits: grouped });
|
||||
} catch (error) {
|
||||
console.error("GET /api/premium/benefits error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const isPremium = auth.isPremium || auth.isPro;
|
||||
const multiplier = isPremium ? 2 : 1;
|
||||
const reason = isPremium
|
||||
? "Premium/Pro members earn 2x FLH on marketplace sales"
|
||||
: "Free members earn 1x FLH. Upgrade to Premium for 2x!";
|
||||
|
||||
return NextResponse.json({ multiplier, reason });
|
||||
} catch (error) {
|
||||
console.error("GET /api/premium/flh-multiplier error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Decode a base62 referral code back to a userId prefix for lookup
|
||||
function decodeBase62Lookup(code: string): number {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
let hash = 0;
|
||||
for (const ch of code) {
|
||||
const idx = chars.indexOf(ch);
|
||||
if (idx === -1) return -1;
|
||||
hash = hash * 62 + idx;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { code } = body;
|
||||
|
||||
if (!code || typeof code !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Referral code is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// In a real system, you'd look up the referrer by their stored referral code.
|
||||
// For simplicity, we encode the userId in the code. We need to find the user
|
||||
// whose userId produces this code. Since encodeBase62 uses a hash, we
|
||||
// can't reverse it perfectly. Instead, we iterate active users.
|
||||
// A production approach: store referral codes in a dedicated table or column.
|
||||
// For now, we'll iterate through users and find a match.
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Recreate the encode logic to find the matching user
|
||||
function encodeForMatch(id: string): string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
let hash = 0;
|
||||
for (let i = 0; i < Math.min(id.length, 10); i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
let result = "";
|
||||
let h = hash;
|
||||
while (result.length < 6) {
|
||||
result = chars[h % 62] + result;
|
||||
h = Math.floor(h / 62);
|
||||
if (h === 0 && result.length < 6) {
|
||||
h = Math.floor(Math.random() * 62 ** (6 - result.length));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let referrerId: string | null = null;
|
||||
for (const u of users) {
|
||||
if (encodeForMatch(u.id) === code) {
|
||||
referrerId = u.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!referrerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid referral code" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Save the referral code in a cookie/localStorage so when user registers,
|
||||
// the newly created user can be linked. For now, we just create a placeholder
|
||||
// "pending" referral. The actual linking happens when the user registers
|
||||
// and passes the referral code again (to be paired via the referred user's ID).
|
||||
// Since we don't have a "referredId" yet, we store a temporary token.
|
||||
const tempToken = `pending_${referrerId}_${Date.now()}`;
|
||||
|
||||
// Check if this referrer already has a pending referral with this temp token
|
||||
const existing = await prisma.referral.findFirst({
|
||||
where: { referrerId, status: "pending" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
// Store in a lightweight way — we'll use the Referral model with a placeholder referredId
|
||||
// The referredId will be updated when registration completes.
|
||||
// For production, a ReferralClaimToken model would be better.
|
||||
const referral = await prisma.referral.create({
|
||||
data: {
|
||||
referrerId,
|
||||
referredId: `pending_${tempToken}`, // placeholder, will be updated on registration
|
||||
status: "pending",
|
||||
rewardFlh: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Referral code applied! You'll get 1000 FLH when you sign up.",
|
||||
referralId: referral.id,
|
||||
tempToken,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Referral claim error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to claim referral" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
// Encode a userId (UUID/CUID) into a short base62 referral code
|
||||
function encodeBase62(id: string): string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
// Convert first 10 chars of the id to a numeric hash for a short code
|
||||
let hash = 0;
|
||||
for (let i = 0; i < Math.min(id.length, 10); i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0; // unsigned 32-bit
|
||||
}
|
||||
let code = "";
|
||||
while (code.length < 6) {
|
||||
code = chars[hash % 62] + code;
|
||||
hash = Math.floor(hash / 62);
|
||||
if (hash === 0 && code.length < 6) {
|
||||
// Pad with random chars
|
||||
hash = Math.floor(Math.random() * 62 ** (6 - code.length));
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const jwtPayload = await requireAuth(req);
|
||||
if (!jwtPayload) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const referralCode = encodeBase62(jwtPayload.userId);
|
||||
|
||||
return NextResponse.json({
|
||||
referralCode,
|
||||
shareLink: `${process.env.NEXT_PUBLIC_APP_URL || "https://falah.app"}/join?ref=${referralCode}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Referral code error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate referral code" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
// Same encodeBase62 used in the code route
|
||||
function encodeBase62(id: string): string {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
let hash = 0;
|
||||
for (let i = 0; i < Math.min(id.length, 10); i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
let code = "";
|
||||
while (code.length < 6) {
|
||||
code = chars[hash % 62] + code;
|
||||
hash = Math.floor(hash / 62);
|
||||
if (hash === 0 && code.length < 6) {
|
||||
hash = Math.floor(Math.random() * 62 ** (6 - code.length));
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const jwtPayload = await requireAuth(req);
|
||||
if (!jwtPayload) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = jwtPayload.userId;
|
||||
|
||||
const [totalReferrals, upgradedCount, earnings] = await Promise.all([
|
||||
prisma.referral.count({
|
||||
where: { referrerId: userId },
|
||||
}),
|
||||
prisma.referral.count({
|
||||
where: { referrerId: userId, status: "upgraded" },
|
||||
}),
|
||||
prisma.referral.aggregate({
|
||||
where: { referrerId: userId },
|
||||
_sum: { rewardFlh: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const referralCode = encodeBase62(userId);
|
||||
|
||||
return NextResponse.json({
|
||||
totalReferrals,
|
||||
upgradedCount,
|
||||
totalEarned: earnings._sum.rewardFlh || 0,
|
||||
referralCode,
|
||||
shareLink: `${process.env.NEXT_PUBLIC_APP_URL || "https://falah.app"}/join?ref=${referralCode}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Referral stats error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to get referral stats" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -19,6 +19,7 @@ import {
|
||||
MessageCircle,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import PremiumBadge from "@/components/PremiumBadge";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@@ -397,14 +398,20 @@ export default function ForumPage() {
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-gray-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-500">by</span>
|
||||
<span className="font-medium text-gray-400">
|
||||
<span className={`font-medium ${
|
||||
thread.author.isPro
|
||||
? "text-purple-400"
|
||||
: thread.author.isPremium
|
||||
? "text-[#D4AF37]"
|
||||
: "text-gray-400"
|
||||
}`}>
|
||||
{thread.author.name}
|
||||
</span>
|
||||
{thread.author.isPro && (
|
||||
<Crown size={9} className="text-purple-400" />
|
||||
<PremiumBadge tier="pro" size="sm" />
|
||||
)}
|
||||
{thread.author.isPremium && !thread.author.isPro && (
|
||||
<Star size={9} className="text-[#D4AF37]" />
|
||||
<PremiumBadge tier="premium" size="sm" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+7
-1
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/lib/AuthContext";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import NotificationBell from "@/components/NotificationBell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Falah — Islamic Lifestyle",
|
||||
@@ -27,7 +28,12 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body>
|
||||
<AuthProvider>
|
||||
<main>{children}</main>
|
||||
<div className="relative">
|
||||
<div className="absolute top-4 right-4 z-40">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</AuthProvider>
|
||||
</body>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
CheckCircle2,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import PremiumBadge from "@/components/PremiumBadge";
|
||||
|
||||
/* ── Types ── */
|
||||
interface ChatMessage {
|
||||
@@ -319,7 +320,7 @@ function NurChat() {
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => (
|
||||
<MessageBubble key={msg.id || idx} message={msg} />
|
||||
<MessageBubble key={msg.id || idx} message={msg} isPremium={isPremium} isPro={isPro} />
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
@@ -451,11 +452,9 @@ function PersonaSelector({
|
||||
{persona.description}
|
||||
</span>
|
||||
{!accessible && (
|
||||
<div className="flex items-center gap-1 bg-amber-900/30 border border-amber-700/30 rounded-full px-2 py-0.5 mt-1">
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<Lock size={8} className="text-amber-400" />
|
||||
<span className="text-[8px] font-medium text-amber-400">
|
||||
Premium
|
||||
</span>
|
||||
<PremiumBadge tier="premium" size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{isActive && (
|
||||
@@ -470,8 +469,9 @@ function PersonaSelector({
|
||||
}
|
||||
|
||||
/* ── Message Bubble ── */
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
function MessageBubble({ message, isPremium, isPro }: { message: ChatMessage; isPremium?: boolean; isPro?: boolean }) {
|
||||
const isUser = message.role === "user";
|
||||
const premiumGlow = !isUser && (isPremium || isPro) && "shadow-[0_0_12px_rgba(212,175,55,0.15)] border-[#D4AF37]/20";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -488,7 +488,7 @@ function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap ${
|
||||
isUser
|
||||
? "bg-[#D4AF37] text-[#0a0a0f] rounded-tr-sm"
|
||||
: "bg-[#1a1a24] border border-gray-700/40 text-gray-200 rounded-tl-sm"
|
||||
: `bg-[#1a1a24] border border-gray-700/40 text-gray-200 rounded-tl-sm ${premiumGlow || ""}`
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
|
||||
+165
-18
@@ -7,47 +7,125 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Bot, ShoppingBag, MessageCircle, Wallet,
|
||||
Flame, Sparkles, Star, ChevronRight,
|
||||
BookOpen, MapPin, Crown, Zap,
|
||||
BookOpen, MapPin, Crown, Zap, TrendingUp,
|
||||
Gift, Trophy,
|
||||
} from "lucide-react";
|
||||
import { DAILY_VERSE, generateAIResponse } from "@/lib/ai";
|
||||
import PremiumBadge from "@/components/PremiumBadge";
|
||||
|
||||
export default function HomePage() {
|
||||
const { user, token, loading } = useAuth();
|
||||
const { user, token, loading, streak, xp, refreshStreak, refreshXp, refreshUser } = useAuth();
|
||||
const router = useRouter();
|
||||
const [streak, setStreak] = useState(0);
|
||||
const [verse, setVerse] = useState(DAILY_VERSE[Math.floor(Math.random() * DAILY_VERSE.length)]);
|
||||
const [verse] = useState(DAILY_VERSE[Math.floor(Math.random() * DAILY_VERSE.length)]);
|
||||
const [claiming, setClaiming] = useState(false);
|
||||
const [claimResult, setClaimResult] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !token) router.push("/login");
|
||||
}, [loading, token, router]);
|
||||
|
||||
// Refresh streak/XP data every 60 seconds
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const interval = setInterval(() => {
|
||||
refreshStreak();
|
||||
refreshXp();
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [token, refreshStreak, refreshXp]);
|
||||
|
||||
const handleClaimReward = async () => {
|
||||
if (!token || claiming) return;
|
||||
setClaiming(true);
|
||||
setClaimResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/gamification/streak", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setClaimResult(
|
||||
`Claimed! +${data.reward} FLH, +${data.xpGain} XP${
|
||||
data.newLevel ? ` — Level Up! You're now level ${data.newLevel}! 🎉` : ""
|
||||
}`
|
||||
);
|
||||
refreshStreak();
|
||||
refreshXp();
|
||||
refreshUser(); // update FLH balance
|
||||
} else if (res.status === 409) {
|
||||
setClaimResult("Already claimed today!");
|
||||
} else {
|
||||
const err = await res.json();
|
||||
setClaimResult(err.error || "Failed to claim");
|
||||
}
|
||||
} catch {
|
||||
setClaimResult("Network error — try again");
|
||||
} finally {
|
||||
setClaiming(false);
|
||||
setTimeout(() => setClaimResult(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (!user) return null;
|
||||
|
||||
const isPremium = user.isPremium || user.isPro;
|
||||
const isPro = user.isPro;
|
||||
const currentStreak = streak?.currentStreak ?? 0;
|
||||
const canClaim = streak?.canClaim ?? false;
|
||||
const todayReward = streak?.todayReward ?? 0;
|
||||
const level = xp?.level ?? 1;
|
||||
const currentXp = xp?.xp ?? 0;
|
||||
const xpToNext = xp?.xpToNext ?? 100;
|
||||
const xpPercent = xpToNext > 0 ? Math.min(Math.round((currentXp / xpToNext) * 100), 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[#0a0a0f] pb-24">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
Assalamualaikum, {user.name.split(" ")[0]} ☪️
|
||||
{/* Level Badge */}
|
||||
<div className="flex items-center gap-1 bg-gradient-to-r from-purple-900/40 to-purple-800/20 border border-purple-700/30 rounded-full px-2.5 py-0.5">
|
||||
<Trophy size={12} className="text-purple-400" />
|
||||
<span className="text-xs font-bold text-purple-300">Lv.{level}</span>
|
||||
</div>
|
||||
{user.isPro && <PremiumBadge tier="pro" size="sm" />}
|
||||
{user.isPremium && !user.isPro && <PremiumBadge tier="premium" size="sm" />}
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{new Date().toLocaleDateString("en-MY", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
{/* Streak */}
|
||||
{streak > 0 && (
|
||||
<div className="flex items-center gap-1.5 bg-gradient-to-r from-orange-900/40 to-orange-800/20 border border-orange-700/30 rounded-xl px-3 py-2">
|
||||
<Flame size={16} className="text-orange-400 flame" />
|
||||
<span className="text-sm font-bold text-orange-300">{streak}</span>
|
||||
<span className="text-[10px] text-orange-400/70">day</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-1.5 rounded-xl px-3 py-2 ${
|
||||
currentStreak > 0
|
||||
? "bg-gradient-to-r from-orange-900/40 to-orange-800/20 border border-orange-700/30"
|
||||
: "bg-gray-900/40 border border-gray-800/30"
|
||||
}`}>
|
||||
<Flame size={16} className={currentStreak > 0 ? "text-orange-400 flame" : "text-gray-600"} />
|
||||
<span className={`text-sm font-bold ${currentStreak > 0 ? "text-orange-300" : "text-gray-500"}`}>
|
||||
{currentStreak}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500">day</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* XP Progress Bar */}
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-gray-500">XP Progress</span>
|
||||
<span className="text-[10px] text-gray-500">{currentXp} / {xpToNext} XP</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-amber-400 rounded-full transition-all duration-500"
|
||||
style={{ width: `${xpPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Premium banner */}
|
||||
@@ -74,6 +152,15 @@ export default function HomePage() {
|
||||
<span className="text-sm font-medium text-purple-300">Pro Member — Unlimited Everything</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FLH Earning Rate - shown for premium/pro */}
|
||||
{(isPremium || isPro) && (
|
||||
<div className="mt-2 flex items-center gap-2 bg-gradient-to-r from-[#D4AF37]/10 to-purple-900/10 border border-[#D4AF37]/20 rounded-2xl px-4 py-2">
|
||||
<TrendingUp size={14} className="text-[#D4AF37]" />
|
||||
<span className="text-xs font-semibold text-[#D4AF37]">2x Earning Active</span>
|
||||
<span className="text-[10px] text-gray-500">— Earn double FLH on marketplace sales</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Verse */}
|
||||
@@ -91,6 +178,62 @@ export default function HomePage() {
|
||||
<p className="text-[10px] text-gray-600 text-center mt-1">— {verse.reference}</p>
|
||||
</div>
|
||||
|
||||
{/* Streak Reward Card */}
|
||||
<div className="mx-4 mb-5">
|
||||
<div className={`bg-gradient-to-br ${
|
||||
canClaim
|
||||
? "from-orange-900/20 to-amber-900/10 border-orange-700/30"
|
||||
: "from-gray-900/30 to-gray-800/10 border-gray-800/30"
|
||||
} border rounded-2xl p-4`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-9 h-9 rounded-xl ${canClaim ? "bg-orange-900/40" : "bg-gray-800/40"} flex items-center justify-center`}>
|
||||
<Gift size={18} className={canClaim ? "text-orange-400" : "text-gray-600"} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Daily Reward</h3>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
{canClaim
|
||||
? `Claim your streak reward — +${todayReward} FLH`
|
||||
: "Reward claimed — come back tomorrow!"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-orange-400">{todayReward.toLocaleString()}</p>
|
||||
<p className="text-[9px] text-gray-600">FLH reward</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{canClaim ? (
|
||||
<button
|
||||
onClick={handleClaimReward}
|
||||
disabled={claiming}
|
||||
className="w-full py-2.5 bg-gradient-to-r from-orange-600 to-amber-600 hover:from-orange-500 hover:to-amber-500 disabled:opacity-50 text-white text-sm font-semibold rounded-xl active:scale-[0.98] transition-all"
|
||||
>
|
||||
{claiming ? "Claiming..." : "🔥 Claim Streak Reward"}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full py-2.5 bg-gray-800/50 text-gray-500 text-sm font-medium rounded-xl text-center">
|
||||
✅ Claimed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claim result toast */}
|
||||
{claimResult && (
|
||||
<div className="mt-2 text-xs text-center font-medium text-orange-300 animate-pulse">
|
||||
{claimResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* XP hint */}
|
||||
<p className="text-[10px] text-gray-700 text-center mt-2">
|
||||
+{Math.min(10 + currentStreak * 2, 100)} XP also awarded on claim
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="px-4 mb-5">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Quick Actions</h2>
|
||||
@@ -127,16 +270,20 @@ export default function HomePage() {
|
||||
|
||||
{/* Premium upsell at bottom for free users */}
|
||||
{!isPremium && (
|
||||
<div className="mx-4 mt-5 bg-gradient-to-r from-[#D4AF37]/10 to-purple-900/10 border border-[#D4AF37]/20 rounded-2xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mx-4 mt-5 bg-gradient-to-r from-[#D4AF37]/10 to-purple-900/10 border border-[#D4AF37]/20 rounded-2xl p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-24 h-24 bg-[#D4AF37]/5 rounded-full -translate-y-1/2 translate-x-1/2 blur-2xl" />
|
||||
<div className="flex items-start gap-3 relative">
|
||||
<div className="w-10 h-10 rounded-2xl bg-[#D4AF37]/15 flex items-center justify-center shrink-0">
|
||||
<Star size={20} className="text-[#D4AF37]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-white text-sm">Unlock Your Full Potential</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">Premium gives you unlimited AI, halal monitor, and marketplace access.</p>
|
||||
<h3 className="font-semibold text-white text-sm flex items-center gap-2">
|
||||
Unlock Your Full Potential
|
||||
<PremiumBadge tier="free" size="sm" />
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">Premium gives you unlimited AI, halal monitor, <strong className="text-gray-400">2x FLH earning</strong>, and marketplace access.</p>
|
||||
<Link href="/upgrade"
|
||||
className="inline-block mt-3 text-xs font-semibold text-[#D4AF37] border border-[#D4AF37]/40 rounded-full px-4 py-1.5 active:bg-[#D4AF37]/10 transition">
|
||||
className="inline-block mt-3 text-xs font-semibold text-[#D4AF37] border border-[#D4AF37]/40 rounded-full px-4 py-1.5 active:bg-[#D4AF37]/10 transition hover:shadow-[0_0_12px_rgba(212,175,55,0.3)]">
|
||||
See Plans
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, FormEvent } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getUserTier } from "@/lib/tiers";
|
||||
import { PERSONAS } from "@/lib/personas";
|
||||
import {
|
||||
@@ -14,7 +15,10 @@ import {
|
||||
Save,
|
||||
Sparkles,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import PremiumBadge from "@/components/PremiumBadge";
|
||||
|
||||
const VALID_MADHABS = ["Hanafi", "Maliki", "Shafi'i", "Hanbali"];
|
||||
|
||||
@@ -145,6 +149,7 @@ export default function ProfilePage() {
|
||||
? "Premium"
|
||||
: "Pro"}
|
||||
</span>
|
||||
<PremiumBadge tier={tier} size="md" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap size={14} className="text-gray-600" />
|
||||
@@ -156,6 +161,57 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Premium/Pro Banner with Gradient */}
|
||||
{(tier === "premium" || tier === "pro") && (
|
||||
<div className={`mt-3 rounded-xl p-4 relative overflow-hidden ${
|
||||
tier === "pro"
|
||||
? "bg-gradient-to-r from-purple-900/40 via-purple-900/20 to-gray-900/60 border border-purple-500/30"
|
||||
: "bg-gradient-to-r from-[#D4AF37]/20 via-yellow-900/15 to-gray-900/60 border border-[#D4AF37]/30"
|
||||
}`}>
|
||||
<div className={`absolute top-0 right-0 w-32 h-32 rounded-full blur-3xl ${
|
||||
tier === "pro" ? "bg-purple-500/10" : "bg-[#D4AF37]/10"
|
||||
}`} />
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||
tier === "pro" ? "bg-purple-900/40" : "bg-[#D4AF37]/20"
|
||||
}`}>
|
||||
{tier === "pro" ? (
|
||||
<Crown size={20} className="text-purple-400" />
|
||||
) : (
|
||||
<Sparkles size={20} className="text-[#D4AF37]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-bold ${
|
||||
tier === "pro" ? "text-purple-300" : "text-[#D4AF37]"
|
||||
}`}>
|
||||
{tier === "pro" ? "Pro Member" : "Premium Member"}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">
|
||||
{tier === "pro"
|
||||
? "Unlimited everything + 0% marketplace fees"
|
||||
: "Enhanced AI + marketplace access"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FLH Earning Multiplier */}
|
||||
{(tier === "premium" || tier === "pro") && (
|
||||
<div className="mt-3 flex items-center justify-between bg-gradient-to-r from-[#D4AF37]/10 to-[#D4AF37]/5 border border-[#D4AF37]/20 rounded-xl px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp size={16} className="text-[#D4AF37]" />
|
||||
<span className="text-sm font-medium text-gray-300">
|
||||
FLH Earning Multiplier
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-[#D4AF37]">
|
||||
2x
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FLH Balance */}
|
||||
<div className="mt-3 flex items-center justify-between bg-[#1a1a24] rounded-xl px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -322,6 +378,39 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subscription Management */}
|
||||
{(tier === "premium" || tier === "pro") && (
|
||||
<div className="mt-4 pt-3 border-t border-gray-800/60">
|
||||
<Link
|
||||
href="/upgrade"
|
||||
className="flex items-center justify-between py-2 px-3 rounded-xl bg-[#D4AF37]/10 border border-[#D4AF37]/20 active:bg-[#D4AF37]/20 transition"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink size={14} className="text-[#D4AF37]" />
|
||||
<span className="text-xs font-medium text-[#D4AF37]">
|
||||
Manage Subscription
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight size={14} className="text-[#D4AF37]" />
|
||||
</Link>
|
||||
{user.trialEndsAt && (
|
||||
<div className="mt-2 flex items-center gap-1.5 px-3 py-1.5">
|
||||
<span className="text-[10px] text-gray-600">
|
||||
Trial ends{" "}
|
||||
{new Date(user.trialEndsAt).toLocaleDateString("en-MY", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-700">·</span>
|
||||
<span className="text-[10px] text-amber-400 font-medium">
|
||||
{Math.max(0, Math.ceil((new Date(user.trialEndsAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))} days remaining
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
|
||||
+15
-3
@@ -22,7 +22,9 @@ import {
|
||||
BookOpen,
|
||||
Grid3X3,
|
||||
ChevronDown,
|
||||
Percent,
|
||||
} from "lucide-react";
|
||||
import PremiumBadge from "@/components/PremiumBadge";
|
||||
|
||||
const CATEGORIES = ["All", "E-Books", "Courses", "Design", "Audio", "Video", "Software", "Other"];
|
||||
|
||||
@@ -663,7 +665,11 @@ function ListingCard({
|
||||
const canAfford = userBalance >= listing.priceFlh;
|
||||
|
||||
return (
|
||||
<div className="bg-[#111118] border border-gray-800/60 rounded-2xl overflow-hidden active:scale-[0.98] transition-transform">
|
||||
<div className={`bg-[#111118] border rounded-2xl overflow-hidden active:scale-[0.98] transition-transform ${
|
||||
listing.featured
|
||||
? "border-[#D4AF37]/40 shadow-[0_0_12px_rgba(212,175,55,0.15)]"
|
||||
: "border-gray-800/60"
|
||||
}`}>
|
||||
{/* Image placeholder */}
|
||||
<div className="h-28 bg-gradient-to-br from-gray-800/60 to-gray-900/60 flex items-center justify-center relative">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#D4AF37]/10 flex items-center justify-center">
|
||||
@@ -693,10 +699,16 @@ function ListingCard({
|
||||
{listing.seller.name}
|
||||
</span>
|
||||
{listing.seller.isPro && (
|
||||
<Crown size={8} className="text-purple-400 shrink-0" />
|
||||
<PremiumBadge tier="pro" size="sm" />
|
||||
)}
|
||||
{listing.seller.isPremium && !listing.seller.isPro && (
|
||||
<Star size={8} className="text-[#D4AF37] shrink-0" />
|
||||
<PremiumBadge tier="premium" size="sm" />
|
||||
)}
|
||||
{listing.seller.isPro && (
|
||||
<span className="flex items-center gap-0.5 text-[8px] text-emerald-400 font-semibold ml-0.5">
|
||||
<Percent size={7} />
|
||||
0% fee
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAuth } from "@/lib/AuthContext";
|
||||
import { Bell } from "lucide-react";
|
||||
import NotificationPanel from "./NotificationPanel";
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { user, token } = useAuth();
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch("/api/notifications/unread-count", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUnreadCount(data.count);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnreadCount();
|
||||
// Poll every 30 seconds
|
||||
const interval = setInterval(fetchUnreadCount, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [token]);
|
||||
|
||||
// Close panel on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
};
|
||||
if (panelOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}, [panelOpen]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="relative">
|
||||
<button
|
||||
onClick={() => setPanelOpen(!panelOpen)}
|
||||
className="relative p-2 rounded-full hover:bg-gray-800/60 transition-colors"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={20} className="text-gray-300" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center w-4.5 h-4.5 text-[10px] font-bold bg-red-500 text-white rounded-full min-w-[18px] min-h-[18px] leading-none">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{panelOpen && (
|
||||
<NotificationPanel
|
||||
token={token}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
onCountChange={setUnreadCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Zap,
|
||||
BookOpen,
|
||||
Crown,
|
||||
ShoppingBag,
|
||||
MessageCircle,
|
||||
Trophy,
|
||||
Users,
|
||||
X,
|
||||
CheckCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
interface NotificationItem {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
data: string | null;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface NotificationPanelProps {
|
||||
token: string | null;
|
||||
onClose: () => void;
|
||||
onCountChange: (count: number) => void;
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
|
||||
streak_reminder: { icon: Zap, color: "text-yellow-400" },
|
||||
daily_verse: { icon: BookOpen, color: "text-emerald-400" },
|
||||
premium_expiring: { icon: Crown, color: "text-amber-400" },
|
||||
new_listing: { icon: ShoppingBag, color: "text-blue-400" },
|
||||
forum_reply: { icon: MessageCircle, color: "text-purple-400" },
|
||||
achievement: { icon: Trophy, color: "text-yellow-500" },
|
||||
referral_bonus: { icon: Users, color: "text-green-400" },
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffMs = now - then;
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
export default function NotificationPanel({
|
||||
token,
|
||||
onClose,
|
||||
onCountChange,
|
||||
}: NotificationPanelProps) {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch("/api/notifications", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNotifications(data.notifications);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const markAsRead = async (id: string) => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch("/api/notifications", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
|
||||
);
|
||||
await refreshCount();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch("/api/notifications", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ all: true }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => ({ ...n, read: true }))
|
||||
);
|
||||
onCountChange(0);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCount = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch("/api/notifications/unread-count", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
onCountChange(data.count);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<div className="absolute top-full right-0 mt-2 w-80 sm:w-96 max-h-[70vh] bg-[#12121a] border border-gray-800/60 rounded-xl shadow-2xl shadow-black/50 z-50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800/60">
|
||||
<h3 className="text-sm font-semibold text-gray-200">Notifications</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-full hover:bg-gray-800/60 transition-colors text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="overflow-y-auto max-h-[calc(70vh-52px)]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-[#D4AF37] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<Bell size={32} className="mb-2 opacity-50" />
|
||||
<p className="text-sm">No notifications yet</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notif) => {
|
||||
const iconConfig = typeIcons[notif.type] || { icon: Bell, color: "text-gray-400" };
|
||||
const IconComponent = iconConfig.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={notif.id}
|
||||
onClick={() => !notif.read && markAsRead(notif.id)}
|
||||
className={`w-full text-left px-4 py-3 transition-colors border-b border-gray-800/40 last:border-b-0 hover:bg-gray-800/40 ${
|
||||
!notif.read ? "bg-gray-800/20" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-0.5 ${iconConfig.color}`}>
|
||||
<IconComponent size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
className={`text-sm ${
|
||||
!notif.read ? "font-semibold text-gray-100" : "text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{notif.title}
|
||||
</p>
|
||||
<span className="text-[10px] text-gray-500 whitespace-nowrap mt-0.5">
|
||||
{timeAgo(notif.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{notif.body && (
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
|
||||
{notif.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!notif.read && (
|
||||
<div className="w-2 h-2 bg-[#D4AF37] rounded-full mt-2 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { Crown, Sparkles } from "lucide-react";
|
||||
|
||||
interface PremiumBadgeProps {
|
||||
tier: "premium" | "pro" | "free";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export default function PremiumBadge({ tier, size = "sm" }: PremiumBadgeProps) {
|
||||
const sizeMap = {
|
||||
sm: { px: "px-1.5 py-0.5", text: "text-[9px]", icon: 8, gap: "gap-0.5" },
|
||||
md: { px: "px-2 py-0.5", text: "text-[10px]", icon: 10, gap: "gap-1" },
|
||||
lg: { px: "px-2.5 py-1", text: "text-xs", icon: 12, gap: "gap-1" },
|
||||
};
|
||||
|
||||
const s = sizeMap[size];
|
||||
|
||||
if (tier === "premium") {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center ${s.gap} ${s.px} rounded-full bg-gradient-to-r from-[#D4AF37]/20 to-yellow-900/20 border border-[#D4AF37]/50 ${s.text} font-semibold text-[#D4AF37] shadow-[0_0_6px_rgba(212,175,55,0.3)]`}
|
||||
>
|
||||
<Crown size={s.icon} className="text-[#D4AF37]" />
|
||||
Premium
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (tier === "pro") {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center ${s.gap} ${s.px} rounded-full bg-gradient-to-r from-purple-900/30 to-violet-900/20 border border-purple-500/50 ${s.text} font-semibold text-purple-300 shadow-[0_0_8px_rgba(168,85,247,0.35)]`}
|
||||
>
|
||||
<Crown size={s.icon} className="text-purple-400" />
|
||||
Pro
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Free tier
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center ${s.gap} ${s.px} rounded-full bg-gray-800/40 border border-gray-700/50 ${s.text} font-medium text-gray-500`}
|
||||
>
|
||||
<Sparkles size={s.icon} className="text-gray-600" />
|
||||
Free
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+63
-1
@@ -18,6 +18,22 @@ interface User {
|
||||
trialEndsAt?: string;
|
||||
}
|
||||
|
||||
interface StreakData {
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
lastClaimDate: string | null;
|
||||
canClaim: boolean;
|
||||
streakFreeze: number;
|
||||
todayReward: number;
|
||||
}
|
||||
|
||||
interface XpData {
|
||||
level: number;
|
||||
xp: number;
|
||||
xpToNext: number;
|
||||
totalXp: number;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
@@ -26,6 +42,10 @@ interface AuthContextType {
|
||||
register: (email: string, name: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
streak: StreakData | null;
|
||||
xp: XpData | null;
|
||||
refreshStreak: () => Promise<void>;
|
||||
refreshXp: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
@@ -34,6 +54,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [streak, setStreak] = useState<StreakData | null>(null);
|
||||
const [xp, setXp] = useState<XpData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("flh_token");
|
||||
@@ -100,6 +122,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
localStorage.removeItem("flh_token");
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setStreak(null);
|
||||
setXp(null);
|
||||
}, []);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
@@ -107,8 +131,46 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
await fetchUser(token);
|
||||
}, [token]);
|
||||
|
||||
const refreshStreak = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch("/api/gamification/streak", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStreak(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const refreshXp = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch("/api/gamification/xp", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setXp(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Fetch streak and XP when token becomes available
|
||||
useEffect(() => {
|
||||
if (token && user) {
|
||||
refreshStreak();
|
||||
refreshXp();
|
||||
}
|
||||
}, [token, user, refreshStreak, refreshXp]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, login, register, logout, refreshUser }}>
|
||||
<AuthContext.Provider value={{ user, token, loading, login, register, logout, refreshUser, streak, xp, refreshStreak, refreshXp }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user