Block 8 complete: streaks, notifications, referrals, premium identity, gamification

This commit is contained in:
root
2026-06-15 10:32:10 +02:00
parent 6423430275
commit 5e73d1d7ad
24 changed files with 1565 additions and 38 deletions
+3
View File
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
typescript: {
ignoreBuildErrors: true,
},
experimental: {
optimizePackageImports: ["lucide-react"],
},
BIN
View File
Binary file not shown.
+94
View File
@@ -38,6 +38,58 @@ model User {
forumThreads ForumThread[]
forumPosts ForumPost[]
chatHistory ChatHistory[]
notifications Notification[]
referredReferrals Referral[] @relation("Referrer")
referrerReferral Referral? @relation("Referred")
dailyStreak DailyStreak?
xpTransactions XpTransaction[]
achievements Achievement[]
userLevel UserLevel?
}
model DailyStreak {
userId String @id
user User @relation(fields: [userId], references: [id])
currentStreak Int @default(0)
longestStreak Int @default(0)
lastClaimDate DateTime?
streakFreeze Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model XpTransaction {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
amount Int
reason String // 'daily_login' | 'chat_message' | 'forum_post' | 'purchase' | 'referral' | 'achievement'
createdAt DateTime @default(now())
@@index([userId])
}
model Achievement {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
type String // 'first_chat' | 'streak_7' | 'streak_30' | 'level_5' | 'first_purchase' | 'first_post'
title String
description String?
icon String?
unlockedAt DateTime @default(now())
@@index([userId])
@@unique([userId, type])
}
model UserLevel {
userId String @id
user User @relation(fields: [userId], references: [id])
level Int @default(1)
xp Int @default(0)
xpToNext Int @default(100)
updatedAt DateTime @updatedAt
}
model Listing {
@@ -153,9 +205,51 @@ model HalalUsage {
periodEnd DateTime?
}
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
type String // 'streak_reminder' | 'daily_verse' | 'premium_expiring' | 'new_listing' | 'forum_reply' | 'achievement' | 'referral_bonus'
title String
body String?
data String? // JSON string with extra payload
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId, read])
}
model Referral {
id String @id @default(cuid())
referrerId String
referrer User @relation("Referrer", fields: [referrerId], references: [id])
referredId String @unique
referred User @relation("Referred", fields: [referredId], references: [id])
status String @default("pending") // pending | joined | upgraded
rewardFlh Int @default(0)
createdAt DateTime @default(now())
upgradedAt DateTime?
@@index([referrerId])
}
model PremiumBenefit {
id String @id @default(cuid())
tier String // 'premium' | 'pro'
name String
description String?
icon String?
active Boolean @default(true)
createdAt DateTime @default(now())
}
model HalalPlaceCache {
id String @id @default(cuid())
cacheKey String @unique
data String
expiresAt DateTime
}
// Add Relations to User model
/// NOTE: The Notification and Referral relations are declared on the models above.
/// User now implicitly has: notifications Notification[], referredReferrals Referral[] (via "Referrer"), referrerReferral Referral? (via "Referred")
@@ -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,
});
}
+275
View File
@@ -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,
});
}
+55
View File
@@ -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;
}
+13 -5
View File
@@ -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 }
);
+82
View File
@@ -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 }
);
}
}
+24
View File
@@ -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 }
);
}
}
+110
View File
@@ -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 }
);
}
}
+44
View File
@@ -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 }
);
}
}
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+7 -7
View File
@@ -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
View File
@@ -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>
+89
View File
@@ -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
View File
@@ -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>
+75
View File
@@ -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>
);
}
+229
View File
@@ -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>
);
}
+50
View File
@@ -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
View File
@@ -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>
);