diff --git a/server/prisma/migrations/20260912000000_add_freefire_mvp_models/migration.sql b/server/prisma/migrations/20260912000000_add_freefire_mvp_models/migration.sql new file mode 100644 index 0000000..064ad36 --- /dev/null +++ b/server/prisma/migrations/20260912000000_add_freefire_mvp_models/migration.sql @@ -0,0 +1,105 @@ +-- CreateEnum +CREATE TYPE "FreeFireTeammateRequestStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'CANCELLED', 'EXPIRED'); + +-- CreateTable +CREATE TABLE "FreeFireProfile" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "freeFireUid" TEXT, + "freeFireUsername" TEXT, + "preferredMode" TEXT NOT NULL DEFAULT 'BR_SQUAD', + "rank" TEXT NOT NULL DEFAULT 'HEROIC', + "playstyle" TEXT NOT NULL DEFAULT 'BALANCED', + "language" TEXT NOT NULL DEFAULT 'ENGLISH', + "micPreference" BOOLEAN NOT NULL DEFAULT true, + "availability" TEXT NOT NULL DEFAULT 'EVENING', + "teammatePreference" TEXT, + "isVerified" BOOLEAN NOT NULL DEFAULT false, + "reputationScore" DOUBLE PRECISION NOT NULL DEFAULT 5.0, + "completedSessions" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FreeFireProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FreeFireTeammateRequest" ( + "id" TEXT NOT NULL, + "senderId" TEXT NOT NULL, + "receiverId" TEXT NOT NULL, + "status" "FreeFireTeammateRequestStatus" NOT NULL DEFAULT 'PENDING', + "message" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FreeFireTeammateRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FreeFireSessionRating" ( + "id" TEXT NOT NULL, + "raterId" TEXT NOT NULL, + "ratedUserId" TEXT NOT NULL, + "rating" INTEGER NOT NULL, + "feedback" TEXT, + "communicationRating" INTEGER, + "reliabilityRating" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FreeFireSessionRating_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "FreeFireProfile_userId_key" ON "FreeFireProfile"("userId"); + +-- CreateIndex +CREATE INDEX "FreeFireProfile_userId_idx" ON "FreeFireProfile"("userId"); + +-- CreateIndex +CREATE INDEX "FreeFireProfile_rank_idx" ON "FreeFireProfile"("rank"); + +-- CreateIndex +CREATE INDEX "FreeFireProfile_playstyle_idx" ON "FreeFireProfile"("playstyle"); + +-- CreateIndex +CREATE INDEX "FreeFireProfile_availability_idx" ON "FreeFireProfile"("availability"); + +-- CreateIndex +CREATE INDEX "FreeFireProfile_reputationScore_idx" ON "FreeFireProfile"("reputationScore"); + +-- CreateIndex +CREATE INDEX "FreeFireTeammateRequest_senderId_idx" ON "FreeFireTeammateRequest"("senderId"); + +-- CreateIndex +CREATE INDEX "FreeFireTeammateRequest_receiverId_idx" ON "FreeFireTeammateRequest"("receiverId"); + +-- CreateIndex +CREATE INDEX "FreeFireTeammateRequest_status_idx" ON "FreeFireTeammateRequest"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "FreeFireTeammateRequest_senderId_receiverId_status_key" ON "FreeFireTeammateRequest"("senderId", "receiverId", "status"); + +-- CreateIndex +CREATE INDEX "FreeFireSessionRating_raterId_idx" ON "FreeFireSessionRating"("raterId"); + +-- CreateIndex +CREATE INDEX "FreeFireSessionRating_ratedUserId_idx" ON "FreeFireSessionRating"("ratedUserId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FreeFireSessionRating_raterId_ratedUserId_key" ON "FreeFireSessionRating"("raterId", "ratedUserId"); + +-- AddForeignKey +ALTER TABLE "FreeFireProfile" ADD CONSTRAINT "FreeFireProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FreeFireTeammateRequest" ADD CONSTRAINT "FreeFireTeammateRequest_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FreeFireTeammateRequest" ADD CONSTRAINT "FreeFireTeammateRequest_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FreeFireSessionRating" ADD CONSTRAINT "FreeFireSessionRating_raterId_fkey" FOREIGN KEY ("raterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FreeFireSessionRating" ADD CONSTRAINT "FreeFireSessionRating_ratedUserId_fkey" FOREIGN KEY ("ratedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b7e5631..f2c0317 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -90,6 +90,11 @@ model User { blockedBy UserBlock[] @relation("BlockedBy") videoClips VideoClip[] montageProjects MontageProject[] + freeFireProfile FreeFireProfile? + freeFireRequestsSent FreeFireTeammateRequest[] @relation("FreeFireRequestsSent") + freeFireRequestsReceived FreeFireTeammateRequest[] @relation("FreeFireRequestsReceived") + freeFireRatingsGiven FreeFireSessionRating[] @relation("FreeFireRatingsGiven") + freeFireRatingsReceived FreeFireSessionRating[] @relation("FreeFireRatingsReceived") profileViews ProfileView[] postViews PostView[] chatMessageReactions ChatMessageReaction[] @@ -1484,3 +1489,73 @@ model MontageProject { @@index([status]) @@index([createdAt]) } + +// ─── Free Fire MVP: Teammate Discovery & Trust ────────────────────────────── + +enum FreeFireTeammateRequestStatus { + PENDING + ACCEPTED + DECLINED + CANCELLED + EXPIRED +} + +model FreeFireProfile { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + freeFireUid String? // Self-reported UID + freeFireUsername String? // Self-reported IGN + preferredMode String @default("BR_SQUAD") // BR_SQUAD, CS_SQUAD, LONE_WOLF, DUO + rank String @default("HEROIC") // BRONZE, SILVER, GOLD, PLATINUM, DIAMOND, HEROIC, GRANDMASTER + playstyle String @default("BALANCED") // AGGRESSIVE, BALANCED, DEFENSIVE, SUPPORT, STRATEGIC + language String @default("ENGLISH") + micPreference Boolean @default(true) + availability String @default("EVENING") // NOW, TODAY, EVENING, WEEKENDS + teammatePreference String? // Description of desired partner + isVerified Boolean @default(false) // Always false for self-reported Free Fire stats per policy + reputationScore Float @default(5.0) + completedSessions Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([rank]) + @@index([playstyle]) + @@index([availability]) + @@index([reputationScore]) +} + +model FreeFireTeammateRequest { + id String @id @default(uuid()) + senderId String + sender User @relation("FreeFireRequestsSent", fields: [senderId], references: [id], onDelete: Cascade) + receiverId String + receiver User @relation("FreeFireRequestsReceived", fields: [receiverId], references: [id], onDelete: Cascade) + status FreeFireTeammateRequestStatus @default(PENDING) + message String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([senderId, receiverId, status]) + @@index([senderId]) + @@index([receiverId]) + @@index([status]) +} + +model FreeFireSessionRating { + id String @id @default(uuid()) + raterId String + rater User @relation("FreeFireRatingsGiven", fields: [raterId], references: [id], onDelete: Cascade) + ratedUserId String + ratedUser User @relation("FreeFireRatingsReceived", fields: [ratedUserId], references: [id], onDelete: Cascade) + rating Int // 1 to 5 stars + feedback String? + communicationRating Int? // 1 to 5 stars + reliabilityRating Int? // 1 to 5 stars + createdAt DateTime @default(now()) + + @@unique([raterId, ratedUserId]) + @@index([raterId]) + @@index([ratedUserId]) +} diff --git a/server/src/controllers/freefire.controller.ts b/server/src/controllers/freefire.controller.ts new file mode 100644 index 0000000..d8773c9 --- /dev/null +++ b/server/src/controllers/freefire.controller.ts @@ -0,0 +1,69 @@ +import { Response } from 'express'; +import { AuthRequest } from '../types'; +import { freeFireService } from '../services/freefire.service'; +import { asyncHandler } from '../utils/asyncHandler'; +import { sendSuccess } from '../utils/response'; + +export class FreeFireController { + getProfile = asyncHandler(async (req: AuthRequest, res: Response) => { + const userId = req.user!.userId; + const profile = await freeFireService.getProfile(userId); + sendSuccess(res, profile); + }); + + updateProfile = asyncHandler(async (req: AuthRequest, res: Response) => { + const userId = req.user!.userId; + const profile = await freeFireService.updateProfile(userId, req.body); + sendSuccess(res, profile); + }); + + discoverTeammates = asyncHandler(async (req: AuthRequest, res: Response) => { + const userId = req.user!.userId; + const { rank, playstyle, language, availability, limit } = req.query; + + const candidates = await freeFireService.discoverTeammates(userId, { + rank: rank as string, + playstyle: playstyle as string, + language: language as string, + availability: availability as string, + limit: limit ? parseInt(limit as string) : 20, + }); + + sendSuccess(res, candidates); + }); + + sendRequest = asyncHandler(async (req: AuthRequest, res: Response) => { + const senderId = req.user!.userId; + const { receiverId, message } = req.body; + + const request = await freeFireService.sendTeammateRequest(senderId, receiverId, message); + sendSuccess(res, request, 'Teammate request sent successfully'); + }); + + updateRequestStatus = asyncHandler(async (req: AuthRequest, res: Response) => { + const userId = req.user!.userId; + const { id } = req.params; + const { status } = req.body; + + const updated = await freeFireService.updateTeammateRequestStatus(id, userId, status); + sendSuccess(res, updated, `Request status updated to ${status}`); + }); + + rateTeammate = asyncHandler(async (req: AuthRequest, res: Response) => { + const raterId = req.user!.userId; + const { ratedUserId, rating, feedback, communicationRating, reliabilityRating } = req.body; + + const result = await freeFireService.rateTeammate( + raterId, + ratedUserId, + parseInt(rating), + feedback, + communicationRating ? parseInt(communicationRating) : undefined, + reliabilityRating ? parseInt(reliabilityRating) : undefined + ); + + sendSuccess(res, result, 'Teammate rating submitted successfully'); + }); +} + +export const freeFireController = new FreeFireController(); diff --git a/server/src/index.ts b/server/src/index.ts index e97fe98..8063e84 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -43,6 +43,7 @@ import cryptoRoutes from './routes/crypto.routes'; import steamRoutes from './routes/steam.routes'; import gameStatsRoutes from './routes/game-stats.routes'; import clashOfClansRoutes from './routes/clashofclans.routes'; +import freeFireRoutes from './routes/freefire.routes'; import challengeRoutes from './routes/challenge.routes'; import videoRoutes from './routes/video.routes'; import { setSocketIo } from './socket-emitter'; @@ -218,6 +219,7 @@ app.use('/api/compare', compareRoutes); app.use('/api/game-sync', gameSyncRoutes); app.use('/api/game-stats', gameStatsRoutes); app.use('/api/game', gameModularRoutes); +app.use('/api/freefire', freeFireRoutes); app.use('/api/challenges', challengeRoutes); app.use('/api/video', videoRoutes); diff --git a/server/src/routes/freefire.routes.ts b/server/src/routes/freefire.routes.ts new file mode 100644 index 0000000..0eb4fb1 --- /dev/null +++ b/server/src/routes/freefire.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { freeFireController } from '../controllers/freefire.controller'; +import { authenticate } from '../middleware/auth'; + +const router = Router(); + +router.get('/profile', authenticate, freeFireController.getProfile.bind(freeFireController)); +router.post('/profile', authenticate, freeFireController.updateProfile.bind(freeFireController)); +router.get('/teammates', authenticate, freeFireController.discoverTeammates.bind(freeFireController)); +router.post('/request', authenticate, freeFireController.sendRequest.bind(freeFireController)); +router.patch('/request/:id', authenticate, freeFireController.updateRequestStatus.bind(freeFireController)); +router.post('/rate', authenticate, freeFireController.rateTeammate.bind(freeFireController)); + +export default router; diff --git a/server/src/services/feed.service.ts b/server/src/services/feed.service.ts index fba74ed..b3d97fd 100644 --- a/server/src/services/feed.service.ts +++ b/server/src/services/feed.service.ts @@ -102,7 +102,7 @@ export class FeedService { this.ensureOfficialWelcomePostExists().catch(() => {}); } - let whereClause: any = { isPublished: true }; + const whereClause: any = { isPublished: true }; if (userId) { const following = await prisma.follow.findMany({ where: { followerId: userId }, diff --git a/server/src/services/freefire.service.test.ts b/server/src/services/freefire.service.test.ts new file mode 100644 index 0000000..fe29895 --- /dev/null +++ b/server/src/services/freefire.service.test.ts @@ -0,0 +1,52 @@ +import { freeFireService } from './freefire.service'; + +describe('FreeFireService - Compatibility Scoring Logic', () => { + it('should calculate high compatibility when language, playstyle, mic, and availability match', () => { + const userA = { + language: 'ENGLISH', + playstyle: 'AGGRESSIVE', + micPreference: true, + availability: 'EVENING', + }; + + const userB = { + language: 'ENGLISH', + playstyle: 'AGGRESSIVE', + micPreference: true, + availability: 'EVENING', + reputationScore: 4.8, + }; + + const match = freeFireService.calculateCompatibility(userA, userB); + + expect(match.score).toBe(100); // 50 + 20 (lang) + 15 (playstyle) + 10 (mic) + 15 (avail) + 10 (rep) capped at 100 + expect(match.reasons).toContain('Speaks ENGLISH'); + expect(match.reasons).toContain('Matching AGGRESSIVE playstyle'); + expect(match.reasons).toContain('Uses Microphone'); + expect(match.reasons).toContain('Available EVENING'); + }); + + it('should handle partial compatibility gracefully', () => { + const userA = { + language: 'ENGLISH', + playstyle: 'DEFENSIVE', + micPreference: false, + availability: 'WEEKENDS', + }; + + const userB = { + language: 'ENGLISH', + playstyle: 'AGGRESSIVE', + micPreference: false, + availability: 'EVENING', + reputationScore: 3.5, + }; + + const match = freeFireService.calculateCompatibility(userA, userB); + + expect(match.score).toBe(80); // 50 + 20 (lang) + 10 (mic) = 80 + expect(match.reasons).toContain('Speaks ENGLISH'); + expect(match.reasons).toContain('No-Mic Preferred'); + expect(match.reasons).not.toContain('Matching AGGRESSIVE playstyle'); + }); +}); diff --git a/server/src/services/freefire.service.ts b/server/src/services/freefire.service.ts new file mode 100644 index 0000000..ef400cc --- /dev/null +++ b/server/src/services/freefire.service.ts @@ -0,0 +1,303 @@ +import prisma from '../config/database'; +import { AppError } from '../utils/errors'; + +export interface FreeFireProfileInput { + freeFireUid?: string; + freeFireUsername?: string; + preferredMode?: string; + rank?: string; + playstyle?: string; + language?: string; + micPreference?: boolean; + availability?: string; + teammatePreference?: string; +} + +export interface TeammateFilterQuery { + rank?: string; + playstyle?: string; + language?: string; + availability?: string; + micPreference?: boolean; + limit?: number; +} + +export class FreeFireService { + /** + * Get or create user Free Fire profile + */ + async getProfile(userId: string) { + let profile = await (prisma as any).freeFireProfile.findUnique({ + where: { userId }, + include: { + user: { + select: { + id: true, + email: true, + profile: { + select: { + username: true, + displayName: true, + avatar: true, + }, + }, + }, + }, + }, + }); + + if (!profile) { + profile = await (prisma as any).freeFireProfile.create({ + data: { + userId, + isVerified: false, // Always false per Anti-Fabrication Policy + }, + include: { + user: { + select: { + id: true, + email: true, + profile: { + select: { + username: true, + displayName: true, + avatar: true, + }, + }, + }, + }, + }, + }); + } + + return profile; + } + + /** + * Upsert Free Fire profile information + */ + async updateProfile(userId: string, data: FreeFireProfileInput) { + return (prisma as any).freeFireProfile.upsert({ + where: { userId }, + create: { + userId, + freeFireUid: data.freeFireUid, + freeFireUsername: data.freeFireUsername, + preferredMode: data.preferredMode || 'BR_SQUAD', + rank: data.rank || 'HEROIC', + playstyle: data.playstyle || 'BALANCED', + language: data.language || 'ENGLISH', + micPreference: data.micPreference !== undefined ? data.micPreference : true, + availability: data.availability || 'EVENING', + teammatePreference: data.teammatePreference, + isVerified: false, // Enforce non-verified status + }, + update: { + freeFireUid: data.freeFireUid, + freeFireUsername: data.freeFireUsername, + preferredMode: data.preferredMode, + rank: data.rank, + playstyle: data.playstyle, + language: data.language, + micPreference: data.micPreference, + availability: data.availability, + teammatePreference: data.teammatePreference, + isVerified: false, // Ensure users cannot self-set verified + }, + }); + } + + /** + * Discover compatible Free Fire teammates with explainable scoring + */ + async discoverTeammates(currentUserId: string, filters: TeammateFilterQuery) { + const currentUserProfile = await this.getProfile(currentUserId); + + const candidates = await (prisma as any).freeFireProfile.findMany({ + where: { + userId: { not: currentUserId }, + ...(filters.rank ? { rank: filters.rank } : {}), + ...(filters.playstyle ? { playstyle: filters.playstyle } : {}), + ...(filters.language ? { language: filters.language } : {}), + ...(filters.availability ? { availability: filters.availability } : {}), + }, + take: filters.limit || 20, + include: { + user: { + select: { + id: true, + profile: { + select: { + username: true, + displayName: true, + avatar: true, + }, + }, + }, + }, + }, + }); + + return candidates.map((candidate: any) => { + const matchDetails = this.calculateCompatibility(currentUserProfile, candidate); + return { + profile: candidate, + compatibility: matchDetails.score, + reasons: matchDetails.reasons, + isSelfReported: !candidate.isVerified, // Explicit trust transparency flag + }; + }).sort((a: any, b: any) => b.compatibility - a.compatibility); + } + + /** + * Transparent compatibility scoring algorithm + */ + calculateCompatibility(userA: any, userB: any) { + let score = 50; // Base score + const reasons: string[] = []; + + // Language match (+20) + if (userA.language && userB.language && userA.language.toUpperCase() === userB.language.toUpperCase()) { + score += 20; + reasons.push(`Speaks ${userB.language}`); + } + + // Playstyle match (+15) + if (userA.playstyle && userB.playstyle && userA.playstyle.toUpperCase() === userB.playstyle.toUpperCase()) { + score += 15; + reasons.push(`Matching ${userB.playstyle} playstyle`); + } + + // Mic preference (+10) + if (userA.micPreference === userB.micPreference) { + score += 10; + reasons.push(userB.micPreference ? 'Uses Microphone' : 'No-Mic Preferred'); + } + + // Availability overlap (+15) + if (userA.availability && userB.availability && userA.availability.toUpperCase() === userB.availability.toUpperCase()) { + score += 15; + reasons.push(`Available ${userB.availability}`); + } + + // High Reputation Bonus (+10) + if (userB.reputationScore >= 4.5) { + score += 10; + reasons.push(`High Trust Rating (${userB.reputationScore.toFixed(1)}⭐)`); + } + + return { + score: Math.min(score, 100), + reasons: reasons.length > 0 ? reasons : ['General Free Fire Player'], + }; + } + + /** + * Send teammate request + */ + async sendTeammateRequest(senderId: string, receiverId: string, message?: string) { + if (senderId === receiverId) { + throw new AppError('You cannot send a teammate request to yourself', 400); + } + + const existing = await (prisma as any).freeFireTeammateRequest.findFirst({ + where: { + senderId, + receiverId, + status: 'PENDING', + }, + }); + + if (existing) { + throw new AppError('A pending request already exists for this player', 400); + } + + return (prisma as any).freeFireTeammateRequest.create({ + data: { + senderId, + receiverId, + message, + status: 'PENDING', + }, + }); + } + + /** + * Update teammate request status (accept, decline, cancel) + */ + async updateTeammateRequestStatus(requestId: string, userId: string, status: 'ACCEPTED' | 'DECLINED' | 'CANCELLED') { + const request = await (prisma as any).freeFireTeammateRequest.findUnique({ + where: { id: requestId }, + }); + + if (!request) { + throw new AppError('Teammate request not found', 404); + } + + if (request.receiverId !== userId && request.senderId !== userId) { + throw new AppError('Unauthorized to update this request', 403); + } + + return (prisma as any).freeFireTeammateRequest.update({ + where: { id: requestId }, + data: { status }, + }); + } + + /** + * Rate a teammate post-session and update reputation + */ + async rateTeammate(raterId: string, ratedUserId: string, rating: number, feedback?: string, commsRating?: number, reliabilityRating?: number) { + if (raterId === ratedUserId) { + throw new AppError('Self-rating is strictly prohibited', 400); + } + + if (rating < 1 || rating > 5) { + throw new AppError('Rating must be between 1 and 5 stars', 400); + } + + // Upsert rating + const sessionRating = await (prisma as any).freeFireSessionRating.upsert({ + where: { + raterId_ratedUserId: { + raterId, + ratedUserId, + }, + }, + create: { + raterId, + ratedUserId, + rating, + feedback, + communicationRating: commsRating, + reliabilityRating, + }, + update: { + rating, + feedback, + communicationRating: commsRating, + reliabilityRating, + }, + }); + + // Recalculate reputation score + const allRatings = await (prisma as any).freeFireSessionRating.findMany({ + where: { ratedUserId }, + select: { rating: true }, + }); + + const avgRating = allRatings.reduce((acc: number, r: any) => acc + r.rating, 0) / allRatings.length; + + await (prisma as any).freeFireProfile.update({ + where: { userId: ratedUserId }, + data: { + reputationScore: parseFloat(avgRating.toFixed(2)), + completedSessions: { increment: 1 }, + }, + }); + + return sessionRating; + } +} + +export const freeFireService = new FreeFireService(); diff --git a/web/src/app/freefire/page.tsx b/web/src/app/freefire/page.tsx new file mode 100644 index 0000000..d9b03a9 --- /dev/null +++ b/web/src/app/freefire/page.tsx @@ -0,0 +1,638 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { + Flame, + Users, + ShieldAlert, + Mic, + MicOff, + Star, + CheckCircle2, + Clock, + Sparkles, + Filter, + Send, + UserCheck, + Zap, + Gamepad2, + ThumbsUp, +} from 'lucide-react'; +import { getInitials } from '@/lib/utils'; +import toast from 'react-hot-toast'; + +const RANKS = ['BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND', 'HEROIC', 'GRANDMASTER']; +const PLAYSTYLES = ['AGGRESSIVE', 'BALANCED', 'DEFENSIVE', 'SUPPORT', 'STRATEGIC']; +const MODES = ['BR_SQUAD', 'CS_SQUAD', 'LONE_WOLF', 'DUO']; +const LANGUAGES = ['ENGLISH', 'HINDI', 'SPANISH', 'PORTUGUESE', 'INDONESIAN', 'OTHER']; +const AVAILABILITIES = ['NOW', 'TODAY', 'EVENING', 'WEEKENDS']; + +export default function FreeFireTeammatesPage() { + const queryClient = useQueryClient(); + + // Filters state + const [rankFilter, setRankFilter] = useState('ALL'); + const [playstyleFilter, setPlaystyleFilter] = useState('ALL'); + const [availabilityFilter, setAvailabilityFilter] = useState('ALL'); + + // Modals state + const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); + const [selectedCandidate, setSelectedCandidate] = useState(null); + const [inviteMessage, setInviteMessage] = useState(''); + const [ratingTarget, setRatingTarget] = useState(null); + const [starRating, setStarRating] = useState(5); + const [ratingFeedback, setRatingFeedback] = useState(''); + + // Fetch current user's Free Fire Profile + const { data: myProfile, isLoading: isProfileLoading } = useQuery({ + queryKey: ['freefire-profile'], + queryFn: () => api.get('/freefire/profile').then((r) => r.data.data), + }); + + // Profile Form State + const [profileForm, setProfileForm] = useState({ + freeFireUid: myProfile?.freeFireUid || '', + freeFireUsername: myProfile?.freeFireUsername || '', + preferredMode: myProfile?.preferredMode || 'BR_SQUAD', + rank: myProfile?.rank || 'HEROIC', + playstyle: myProfile?.playstyle || 'BALANCED', + language: myProfile?.language || 'ENGLISH', + micPreference: myProfile?.micPreference ?? true, + availability: myProfile?.availability || 'EVENING', + teammatePreference: myProfile?.teammatePreference || '', + }); + + // Update profile mutation + const updateProfileMutation = useMutation({ + mutationFn: (data: typeof profileForm) => api.post('/freefire/profile', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['freefire-profile'] }); + queryClient.invalidateQueries({ queryKey: ['freefire-teammates'] }); + setIsProfileModalOpen(false); + toast.success('Free Fire profile updated successfully!'); + }, + onError: (err: any) => { + toast.error(err.response?.data?.message || 'Failed to update profile'); + }, + }); + + // Fetch teammates candidates + const { data: teammates = [], isLoading: isTeammatesLoading } = useQuery({ + queryKey: ['freefire-teammates', rankFilter, playstyleFilter, availabilityFilter], + queryFn: () => { + const params = new URLSearchParams(); + if (rankFilter !== 'ALL') params.append('rank', rankFilter); + if (playstyleFilter !== 'ALL') params.append('playstyle', playstyleFilter); + if (availabilityFilter !== 'ALL') params.append('availability', availabilityFilter); + return api.get(`/freefire/teammates?${params.toString()}`).then((r) => r.data.data); + }, + }); + + // Send request mutation + const sendRequestMutation = useMutation({ + mutationFn: ({ receiverId, message }: { receiverId: string; message: string }) => + api.post('/freefire/request', { receiverId, message }), + onSuccess: () => { + setSelectedCandidate(null); + setInviteMessage(''); + toast.success('Teammate invite sent successfully!'); + }, + onError: (err: any) => { + toast.error(err.response?.data?.message || 'Failed to send invite'); + }, + }); + + // Rate teammate mutation + const rateTeammateMutation = useMutation({ + mutationFn: ({ ratedUserId, rating, feedback }: { ratedUserId: string; rating: number; feedback: string }) => + api.post('/freefire/rate', { ratedUserId, rating, feedback }), + onSuccess: () => { + setRatingTarget(null); + setStarRating(5); + setRatingFeedback(''); + queryClient.invalidateQueries({ queryKey: ['freefire-teammates'] }); + toast.success('Rating submitted successfully!'); + }, + onError: (err: any) => { + toast.error(err.response?.data?.message || 'Failed to submit rating'); + }, + }); + + return ( +
+ {/* Top Banner */} +
+
+
+ +
+
+
+

+ FREE FIRE TEAMMATE FINDER +

+ MVP +
+

+ Find compatible & trustworthy squad partners based on preferences, mic use, availability, and trust ratings. +

+
+
+ + +
+ + {/* Trust & Transparency Policy Callout */} +
+ +
+ Anti-Fabrication & Privacy Guarantee: GamerZ Hub does not invent fake statistics or claim unverified Free Fire server data. All UIDs, ranks, and handles are Self-Reported and validated through community reputation ratings. +
+
+ + {/* Filter Bar */} + +
+
+ Filters +
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+ + {/* Candidate Teammate Grid */} +
+
+

+ Compatible Squad Candidates +

+ {teammates.length} Teammates Found +
+ + {isTeammatesLoading ? ( +
+
+

Calculating compatibility matrix...

+
+ ) : teammates.length === 0 ? ( + + +

No Compatible Teammates Found

+

+ Try broadening your rank or playstyle filters, or update your own Free Fire profile to get better matches! +

+ +
+ ) : ( +
+ {teammates.map((item: any) => { + const profile = item.profile; + const user = profile.user; + const displayName = user.profile?.displayName || user.profile?.username || 'Free Fire Gamer'; + + return ( + + + {/* Header: User Info & Compatibility Badge */} +
+
+ + + + {getInitials(displayName)} + + +
+

+ {displayName} +

+
+ IGN: {profile.freeFireUsername || 'Not Set'} + + Self-Reported + +
+
+
+ + = 80 + ? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/40' + : 'bg-amber-500/20 text-amber-400 border border-amber-500/40' + }`} + > + {item.compatibility}% Match + +
+ + {/* Stats & Attributes */} +
+
+

Rank

+

{profile.rank}

+
+
+

Playstyle

+

{profile.playstyle}

+
+
+

Language

+

{profile.language}

+
+
+

Mic

+

+ {profile.micPreference ? ( + <> Yes + ) : ( + <> No + )} +

+
+
+ + {/* Trust Rating & Availability */} +
+
+ + {profile.reputationScore.toFixed(1)} + ({profile.completedSessions} games) +
+
+ Available {profile.availability} +
+
+ + {/* Explainable Reasons */} +
+ {item.reasons.map((reason: string, idx: number) => ( + + ✓ {reason} + + ))} +
+
+ + {/* Actions */} +
+ + +
+
+ ); + })} +
+ )} +
+ + {/* Edit Free Fire Profile Modal */} + + + + + Free Fire Gamer Profile + + + Configure your self-reported Free Fire identity to help the algorithm pair you with ideal teammates. + + + +
+
+
+ + setProfileForm({ ...profileForm, freeFireUid: e.target.value })} + placeholder="e.g. 567890123" + className="bg-white/5 border-white/10 text-xs h-9" + /> +
+
+ + setProfileForm({ ...profileForm, freeFireUsername: e.target.value })} + placeholder="e.g. Viper#123" + className="bg-white/5 border-white/10 text-xs h-9" + /> +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +