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