From 85ee812ab1b03e70c34d4a832461e8caeff13c5c Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Wed, 17 Dec 2025 13:35:18 +0100 Subject: [PATCH] Add house leaderboard feature: Integrate house leaderboard functionality in LeaderboardPage and LeaderboardSection components. Update userStatsService to fetch house leaderboard data, and enhance UI to display house rankings, scores, and member details. Update Prisma schema to include house-related models and relationships, and seed database with initial house data. --- actions/houses/create.ts | 48 + actions/houses/invitations.ts | 173 +++ actions/houses/requests.ts | 163 +++ actions/houses/update.ts | 114 ++ app/api/houses/[houseId]/invitations/route.ts | 51 + app/api/houses/[houseId]/requests/route.ts | 48 + app/api/houses/[houseId]/route.ts | 59 + app/api/houses/my-house/route.ts | 48 + app/api/houses/route.ts | 87 ++ app/api/invitations/route.ts | 36 + app/api/leaderboard/houses/route.ts | 17 + app/houses/page.tsx | 146 +++ app/leaderboard/page.tsx | 4 +- components/houses/HouseCard.tsx | 167 +++ components/houses/HouseForm.tsx | 90 ++ components/houses/HouseManagement.tsx | 327 +++++ components/houses/HousesSection.tsx | 236 ++++ components/houses/InvitationList.tsx | 123 ++ components/houses/RequestList.tsx | 119 ++ components/leaderboard/LeaderboardSection.tsx | 217 +++- components/navigation/Navigation.tsx | 41 +- prisma/generated/prisma/browser.ts | 20 + prisma/generated/prisma/client.ts | 20 + prisma/generated/prisma/commonInputTypes.ts | 102 ++ prisma/generated/prisma/enums.ts | 29 + prisma/generated/prisma/internal/class.ts | 44 +- .../prisma/internal/prismaNamespace.ts | 398 +++++- .../prisma/internal/prismaNamespaceBrowser.ts | 54 +- prisma/generated/prisma/models.ts | 4 + prisma/generated/prisma/models/User.ts | 1110 +++++++++++++++++ .../migration.sql | 120 ++ prisma/schema.prisma | 89 ++ prisma/seed.ts | 145 ++- services/errors.ts | 7 + services/houses/house.service.ts | 893 +++++++++++++ services/users/user-stats.service.ts | 86 ++ 36 files changed, 5422 insertions(+), 13 deletions(-) create mode 100644 actions/houses/create.ts create mode 100644 actions/houses/invitations.ts create mode 100644 actions/houses/requests.ts create mode 100644 actions/houses/update.ts create mode 100644 app/api/houses/[houseId]/invitations/route.ts create mode 100644 app/api/houses/[houseId]/requests/route.ts create mode 100644 app/api/houses/[houseId]/route.ts create mode 100644 app/api/houses/my-house/route.ts create mode 100644 app/api/houses/route.ts create mode 100644 app/api/invitations/route.ts create mode 100644 app/api/leaderboard/houses/route.ts create mode 100644 app/houses/page.tsx create mode 100644 components/houses/HouseCard.tsx create mode 100644 components/houses/HouseForm.tsx create mode 100644 components/houses/HouseManagement.tsx create mode 100644 components/houses/HousesSection.tsx create mode 100644 components/houses/InvitationList.tsx create mode 100644 components/houses/RequestList.tsx create mode 100644 prisma/migrations/20251217131946_add_houses_system/migration.sql create mode 100644 services/houses/house.service.ts diff --git a/actions/houses/create.ts b/actions/houses/create.ts new file mode 100644 index 0000000..10d0bfd --- /dev/null +++ b/actions/houses/create.ts @@ -0,0 +1,48 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; +import { + ValidationError, + ConflictError, +} from "@/services/errors"; + +export async function createHouse(data: { + name: string; + description?: string | null; +}) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté pour créer une maison", + }; + } + + const house = await houseService.createHouse({ + name: data.name, + description: data.description, + creatorId: session.user.id, + }); + + revalidatePath("/houses"); + revalidatePath("/profile"); + + return { success: true, message: "Maison créée avec succès", data: house }; + } catch (error) { + console.error("Create house error:", error); + + if (error instanceof ValidationError || error instanceof ConflictError) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors de la création de la maison", + }; + } +} + diff --git a/actions/houses/invitations.ts b/actions/houses/invitations.ts new file mode 100644 index 0000000..e38bdb0 --- /dev/null +++ b/actions/houses/invitations.ts @@ -0,0 +1,173 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; +import { + ValidationError, + ConflictError, + ForbiddenError, + NotFoundError, +} from "@/services/errors"; + +export async function inviteUser(houseId: string, inviteeId: string) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + const invitation = await houseService.inviteUser({ + houseId, + inviterId: session.user.id, + inviteeId, + }); + + revalidatePath("/houses"); + revalidatePath(`/houses/${houseId}`); + + return { + success: true, + message: "Invitation envoyée", + data: invitation, + }; + } catch (error) { + console.error("Invite user error:", error); + + if ( + error instanceof ValidationError || + error instanceof ConflictError || + error instanceof ForbiddenError + ) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors de l'envoi de l'invitation", + }; + } +} + +export async function acceptInvitation(invitationId: string) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + const membership = await houseService.acceptInvitation( + invitationId, + session.user.id + ); + + revalidatePath("/houses"); + revalidatePath("/profile"); + revalidatePath("/invitations"); + + return { + success: true, + message: "Invitation acceptée", + data: membership, + }; + } catch (error) { + console.error("Accept invitation error:", error); + + if ( + error instanceof ValidationError || + error instanceof ConflictError || + error instanceof ForbiddenError || + error instanceof NotFoundError + ) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors de l'acceptation de l'invitation", + }; + } +} + +export async function rejectInvitation(invitationId: string) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + await houseService.rejectInvitation(invitationId, session.user.id); + + revalidatePath("/houses"); + revalidatePath("/invitations"); + + return { success: true, message: "Invitation refusée" }; + } catch (error) { + console.error("Reject invitation error:", error); + + if ( + error instanceof ConflictError || + error instanceof ForbiddenError || + error instanceof NotFoundError + ) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors du refus de l'invitation", + }; + } +} + +export async function cancelInvitation(invitationId: string) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + // Récupérer l'invitation pour obtenir le houseId avant de l'annuler + const invitation = await houseService.getInvitationById(invitationId); + + await houseService.cancelInvitation(invitationId, session.user.id); + + revalidatePath("/houses"); + if (invitation?.houseId) { + revalidatePath(`/houses/${invitation.houseId}`); + } + + return { success: true, message: "Invitation annulée" }; + } catch (error) { + console.error("Cancel invitation error:", error); + + if ( + error instanceof ConflictError || + error instanceof ForbiddenError || + error instanceof NotFoundError + ) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors de l'annulation de l'invitation", + }; + } +} diff --git a/actions/houses/requests.ts b/actions/houses/requests.ts new file mode 100644 index 0000000..ff4eb1f --- /dev/null +++ b/actions/houses/requests.ts @@ -0,0 +1,163 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; +import { + ValidationError, + ConflictError, + ForbiddenError, + NotFoundError, +} from "@/services/errors"; + +export async function requestToJoin(houseId: string) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + const request = await houseService.requestToJoin({ + houseId, + requesterId: session.user.id, + }); + + revalidatePath("/houses"); + revalidatePath(`/houses/${houseId}`); + + return { + success: true, + message: "Demande envoyée", + data: request, + }; + } catch (error) { + console.error("Request to join error:", error); + + if ( + error instanceof ValidationError || + error instanceof ConflictError + ) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors de l'envoi de la demande", + }; + } +} + +export async function acceptRequest(requestId: string) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + const membership = await houseService.acceptRequest( + requestId, + session.user.id + ); + + revalidatePath("/houses"); + revalidatePath("/profile"); + + return { + success: true, + message: "Demande acceptée", + data: membership, + }; + } catch (error) { + console.error("Accept request error:", error); + + if ( + error instanceof ConflictError || + error instanceof ForbiddenError || + error instanceof NotFoundError + ) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors de l'acceptation de la demande", + }; + } +} + +export async function rejectRequest(requestId: string) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + await houseService.rejectRequest(requestId, session.user.id); + + revalidatePath("/houses"); + + return { success: true, message: "Demande refusée" }; + } catch (error) { + console.error("Reject request error:", error); + + if ( + error instanceof ConflictError || + error instanceof ForbiddenError || + error instanceof NotFoundError + ) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors du refus de la demande", + }; + } +} + +export async function cancelRequest(requestId: string) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + await houseService.cancelRequest(requestId, session.user.id); + + revalidatePath("/houses"); + + return { success: true, message: "Demande annulée" }; + } catch (error) { + console.error("Cancel request error:", error); + + if ( + error instanceof ConflictError || + error instanceof ForbiddenError || + error instanceof NotFoundError + ) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors de l'annulation de la demande", + }; + } +} + diff --git a/actions/houses/update.ts b/actions/houses/update.ts new file mode 100644 index 0000000..f1d3f27 --- /dev/null +++ b/actions/houses/update.ts @@ -0,0 +1,114 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; +import { + ValidationError, + ConflictError, + ForbiddenError, +} from "@/services/errors"; + +export async function updateHouse( + houseId: string, + data: { + name?: string; + description?: string | null; + } +) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + const house = await houseService.updateHouse(houseId, session.user.id, data); + + revalidatePath("/houses"); + revalidatePath(`/houses/${houseId}`); + + return { success: true, message: "Maison mise à jour", data: house }; + } catch (error) { + console.error("Update house error:", error); + + if ( + error instanceof ValidationError || + error instanceof ConflictError || + error instanceof ForbiddenError + ) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors de la mise à jour de la maison", + }; + } +} + +export async function deleteHouse(houseId: string) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + await houseService.deleteHouse(houseId, session.user.id); + + revalidatePath("/houses"); + revalidatePath("/profile"); + + return { success: true, message: "Maison supprimée" }; + } catch (error) { + console.error("Delete house error:", error); + + if (error instanceof ForbiddenError) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors de la suppression de la maison", + }; + } +} + +export async function leaveHouse(houseId: string) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return { + success: false, + error: "Vous devez être connecté", + }; + } + + await houseService.leaveHouse(houseId, session.user.id); + + revalidatePath("/houses"); + revalidatePath("/profile"); + + return { success: true, message: "Vous avez quitté la maison" }; + } catch (error) { + console.error("Leave house error:", error); + + if (error instanceof ForbiddenError) { + return { success: false, error: error.message }; + } + + return { + success: false, + error: "Une erreur est survenue lors de la sortie de la maison", + }; + } +} + diff --git a/app/api/houses/[houseId]/invitations/route.ts b/app/api/houses/[houseId]/invitations/route.ts new file mode 100644 index 0000000..cfd3a71 --- /dev/null +++ b/app/api/houses/[houseId]/invitations/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ houseId: string }> } +) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Vous devez être connecté" }, + { status: 401 } + ); + } + + const { houseId } = await params; + + // Vérifier que l'utilisateur est membre de la maison + const isMember = await houseService.isUserMemberOfHouse( + session.user.id, + houseId + ); + + if (!isMember) { + return NextResponse.json( + { error: "Vous devez être membre de cette maison" }, + { status: 403 } + ); + } + + const { searchParams } = new URL(request.url); + const status = searchParams.get("status") as "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" | null; + + const invitations = await houseService.getHouseInvitations( + houseId, + status || undefined + ); + + return NextResponse.json(invitations); + } catch (error) { + console.error("Error fetching house invitations:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des invitations" }, + { status: 500 } + ); + } +} + diff --git a/app/api/houses/[houseId]/requests/route.ts b/app/api/houses/[houseId]/requests/route.ts new file mode 100644 index 0000000..859cb4e --- /dev/null +++ b/app/api/houses/[houseId]/requests/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ houseId: string }> } +) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Vous devez être connecté" }, + { status: 401 } + ); + } + + const { houseId } = await params; + + // Vérifier que l'utilisateur est propriétaire ou admin + const isAuthorized = await houseService.isUserOwnerOrAdmin( + session.user.id, + houseId + ); + + if (!isAuthorized) { + return NextResponse.json( + { error: "Vous n'avez pas les permissions pour voir les demandes" }, + { status: 403 } + ); + } + + const { searchParams } = new URL(request.url); + const status = searchParams.get("status") as "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" | null; + + const requests = await houseService.getHouseRequests(houseId, status || undefined); + + return NextResponse.json(requests); + } catch (error) { + console.error("Error fetching house requests:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des demandes" }, + { status: 500 } + ); + } +} + diff --git a/app/api/houses/[houseId]/route.ts b/app/api/houses/[houseId]/route.ts new file mode 100644 index 0000000..a1458ca --- /dev/null +++ b/app/api/houses/[houseId]/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ houseId: string }> } +) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Vous devez être connecté" }, + { status: 401 } + ); + } + + const { houseId } = await params; + const house = await houseService.getHouseById(houseId, { + memberships: { + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + score: true, + level: true, + }, + }, + }, + }, + creator: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + }); + + if (!house) { + return NextResponse.json( + { error: "Maison non trouvée" }, + { status: 404 } + ); + } + + return NextResponse.json(house); + } catch (error) { + console.error("Error fetching house:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération de la maison" }, + { status: 500 } + ); + } +} + diff --git a/app/api/houses/my-house/route.ts b/app/api/houses/my-house/route.ts new file mode 100644 index 0000000..533695e --- /dev/null +++ b/app/api/houses/my-house/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; + +export async function GET() { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Vous devez être connecté" }, + { status: 401 } + ); + } + + const house = await houseService.getUserHouse(session.user.id, { + memberships: { + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + score: true, + level: true, + }, + }, + }, + }, + creator: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + }); + + return NextResponse.json(house); + } catch (error) { + console.error("Error fetching user house:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération de votre maison" }, + { status: 500 } + ); + } +} + diff --git a/app/api/houses/route.ts b/app/api/houses/route.ts new file mode 100644 index 0000000..ca762e4 --- /dev/null +++ b/app/api/houses/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; + +export async function GET(request: Request) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Vous devez être connecté" }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const search = searchParams.get("search"); + const include = searchParams.get("include")?.split(",") || []; + + const includeOptions: { + memberships?: { + include: { + user: { + select: { + id: boolean; + username: boolean; + avatar: boolean; + score?: boolean; + level?: boolean; + }; + }; + }; + }; + creator?: { + select: { + id: boolean; + username: boolean; + avatar: boolean; + }; + }; + } = {}; + if (include.includes("members")) { + includeOptions.memberships = { + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + score: true, + level: true, + }, + }, + }, + }; + } + if (include.includes("creator")) { + includeOptions.creator = { + select: { + id: true, + username: true, + avatar: true, + }, + }; + } + + let houses; + if (search) { + houses = await houseService.searchHouses(search, { + include: includeOptions, + }); + } else { + houses = await houseService.getAllHouses({ + include: includeOptions, + }); + } + + return NextResponse.json(houses); + } catch (error) { + console.error("Error fetching houses:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des maisons" }, + { status: 500 } + ); + } +} + diff --git a/app/api/invitations/route.ts b/app/api/invitations/route.ts new file mode 100644 index 0000000..48958fb --- /dev/null +++ b/app/api/invitations/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { houseService } from "@/services/houses/house.service"; + +export async function GET(request: Request) { + try { + const session = await auth(); + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Vous devez être connecté" }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const statusParam = searchParams.get("status"); + const status = statusParam && ["PENDING", "ACCEPTED", "REJECTED", "CANCELLED"].includes(statusParam) + ? (statusParam as "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED") + : undefined; + + const invitations = await houseService.getUserInvitations( + session.user.id, + status + ); + + return NextResponse.json(invitations); + } catch (error) { + console.error("Error fetching invitations:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des invitations" }, + { status: 500 } + ); + } +} + diff --git a/app/api/leaderboard/houses/route.ts b/app/api/leaderboard/houses/route.ts new file mode 100644 index 0000000..71b1115 --- /dev/null +++ b/app/api/leaderboard/houses/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { userStatsService } from "@/services/users/user-stats.service"; + +export async function GET() { + try { + const leaderboard = await userStatsService.getHouseLeaderboard(10); + + return NextResponse.json(leaderboard); + } catch (error) { + console.error("Error fetching house leaderboard:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération du leaderboard des maisons" }, + { status: 500 } + ); + } +} + diff --git a/app/houses/page.tsx b/app/houses/page.tsx new file mode 100644 index 0000000..bb70d27 --- /dev/null +++ b/app/houses/page.tsx @@ -0,0 +1,146 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; +import { getBackgroundImage } from "@/lib/preferences"; +import NavigationWrapper from "@/components/navigation/NavigationWrapper"; +import HousesSection from "@/components/houses/HousesSection"; +import { houseService } from "@/services/houses/house.service"; +import { userService } from "@/services/users/user.service"; + +export const dynamic = "force-dynamic"; + +export default async function HousesPage() { + const session = await auth(); + + if (!session?.user?.id) { + redirect("/login"); + } + + const [housesData, myHouseData, invitationsData, users, backgroundImage] = await Promise.all([ + // Récupérer les maisons + houseService.getAllHouses({ + include: { + memberships: { + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + score: true, + level: true, + }, + }, + }, + orderBy: [ + { role: "asc" }, // OWNER, ADMIN, MEMBER + { user: { score: "desc" } }, // Puis par score décroissant + ], + }, + creator: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + }, + }), + // Récupérer la maison de l'utilisateur + houseService.getUserHouse(session.user.id, { + memberships: { + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + score: true, + level: true, + }, + }, + }, + }, + creator: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + }), + // Récupérer les invitations de l'utilisateur + houseService.getUserInvitations(session.user.id, "PENDING"), + // Récupérer tous les utilisateurs pour les invitations + userService.getAllUsers({ + select: { + id: true, + username: true, + avatar: true, + }, + }), + getBackgroundImage("challenges", "/got-2.jpg"), + ]); + + // Sérialiser les données pour le client + const houses = (housesData as any[]).map((house: any) => ({ + id: house.id, + name: house.name, + description: house.description, + creator: house.creator || { id: house.creatorId, username: "Unknown", avatar: null }, + memberships: (house.memberships || []).map((m: any) => ({ + id: m.id, + role: m.role, + user: { + id: m.user.id, + username: m.user.username, + avatar: m.user.avatar, + score: m.user.score ?? 0, + level: m.user.level ?? 1, + }, + })), + })); + + const myHouse = myHouseData + ? { + id: myHouseData.id, + name: myHouseData.name, + description: myHouseData.description, + creator: (myHouseData as any).creator || { id: (myHouseData as any).creatorId, username: "Unknown", avatar: null }, + memberships: ((myHouseData as any).memberships || []).map((m: any) => ({ + id: m.id, + role: m.role, + user: { + id: m.user.id, + username: m.user.username, + avatar: m.user.avatar, + score: m.user.score ?? 0, + level: m.user.level ?? 1, + }, + })), + } + : null; + + const invitations = invitationsData.map((inv: any) => ({ + id: inv.id, + house: { + id: inv.house.id, + name: inv.house.name, + }, + inviter: inv.inviter, + status: inv.status, + createdAt: inv.createdAt.toISOString(), + })); + + return ( +
+ + +
+ ); +} diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 49bb417..4b649c2 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -7,8 +7,9 @@ export const dynamic = "force-dynamic"; export default async function LeaderboardPage() { // Paralléliser les appels DB - const [leaderboard, backgroundImage] = await Promise.all([ + const [leaderboard, houseLeaderboard, backgroundImage] = await Promise.all([ userStatsService.getLeaderboard(10), + userStatsService.getHouseLeaderboard(10), getBackgroundImage("leaderboard", "/leaderboard-bg.jpg"), ]); @@ -17,6 +18,7 @@ export default async function LeaderboardPage() { diff --git a/components/houses/HouseCard.tsx b/components/houses/HouseCard.tsx new file mode 100644 index 0000000..48d8cb3 --- /dev/null +++ b/components/houses/HouseCard.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState } from "react"; +import { useSession } from "next-auth/react"; +import Card from "@/components/ui/Card"; +import Button from "@/components/ui/Button"; +import Avatar from "@/components/ui/Avatar"; +import { requestToJoin } from "@/actions/houses/requests"; +import { useTransition } from "react"; +import Alert from "@/components/ui/Alert"; + +interface House { + id: string; + name: string; + description: string | null; + creator: { + id: string; + username: string; + avatar: string | null; + }; + memberships?: Array<{ + id: string; + role: string; + user: { + id: string; + username: string; + avatar: string | null; + score?: number; + level?: number; + }; + }>; + _count?: { + memberships: number; + }; +} + +interface HouseCardProps { + house: House; + onRequestSent?: () => void; +} + +export default function HouseCard({ house, onRequestSent }: HouseCardProps) { + const { data: session } = useSession(); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const isMember = house.memberships?.some( + (m) => m.user.id === session?.user?.id + ); + const memberCount = house._count?.memberships || house.memberships?.length || 0; + + const handleRequestToJoin = () => { + if (!session?.user?.id) return; + + setError(null); + setSuccess(null); + + startTransition(async () => { + const result = await requestToJoin(house.id); + + if (result.success) { + setSuccess("Demande envoyée avec succès"); + onRequestSent?.(); + } else { + setError(result.error || "Erreur lors de l'envoi de la demande"); + } + }); + }; + + return ( + +
+
+

+ {house.name} +

+ {house.description && ( +

+ {house.description} +

+ )} +
+ Créée par {house.creator.username} + + {memberCount} membre{memberCount > 1 ? "s" : ""} +
+
+
+ + {error && ( + + {error} + + )} + + {success && ( + + {success} + + )} + + {session?.user?.id && !isMember && ( + + )} + + {isMember && ( +
+ ✓ Vous êtes membre +
+ )} + + {/* Members List */} + {house.memberships && house.memberships.length > 0 && ( +
+

+ Membres ({house.memberships.length}) +

+
+ {house.memberships.map((membership) => ( +
+ +
+
+ + {membership.user.username} + + {membership.role === "OWNER" && ( + + 👑 + + )} +
+ {membership.user.score !== undefined && membership.user.level !== undefined && ( +
+ {membership.user.score} pts • Lv.{membership.user.level} +
+ )} +
+
+ ))} +
+
+ )} +
+ ); +} + diff --git a/components/houses/HouseForm.tsx b/components/houses/HouseForm.tsx new file mode 100644 index 0000000..785780e --- /dev/null +++ b/components/houses/HouseForm.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Button from "@/components/ui/Button"; +import Input from "@/components/ui/Input"; +import Textarea from "@/components/ui/Textarea"; +import Alert from "@/components/ui/Alert"; +import { createHouse } from "@/actions/houses/create"; +import { updateHouse } from "@/actions/houses/update"; + +interface HouseFormProps { + house?: { + id: string; + name: string; + description: string | null; + }; + onSuccess?: () => void; + onCancel?: () => void; +} + +export default function HouseForm({ + house, + onSuccess, + onCancel, +}: HouseFormProps) { + const [name, setName] = useState(house?.name || ""); + const [description, setDescription] = useState(house?.description || ""); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + startTransition(async () => { + const result = house + ? await updateHouse(house.id, { name, description: description || null }) + : await createHouse({ name, description: description || null }); + + if (result.success) { + onSuccess?.(); + } else { + setError(result.error || "Une erreur est survenue"); + } + }); + }; + + return ( +
+ {error && {error}} + + setName(e.target.value)} + required + minLength={3} + maxLength={50} + disabled={isPending} + /> + +