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.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
This commit is contained in:
167
components/houses/HouseCard.tsx
Normal file
167
components/houses/HouseCard.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(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 (
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl font-bold mb-2 break-words" style={{ color: "var(--foreground)" }}>
|
||||
{house.name}
|
||||
</h3>
|
||||
{house.description && (
|
||||
<p className="text-sm mb-2 break-words" style={{ color: "var(--muted-foreground)" }}>
|
||||
{house.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4 text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
<span>Créée par {house.creator.username}</span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span>{memberCount} membre{memberCount > 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert variant="success" className="mb-4">
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{session?.user?.id && !isMember && (
|
||||
<Button
|
||||
onClick={handleRequestToJoin}
|
||||
disabled={isPending}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isPending ? "Envoi..." : "Demander à rejoindre"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMember && (
|
||||
<div className="text-xs mb-4" style={{ color: "var(--success)" }}>
|
||||
✓ Vous êtes membre
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members List */}
|
||||
{house.memberships && house.memberships.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border)" }}>
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||
Membres ({house.memberships.length})
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{house.memberships.map((membership) => (
|
||||
<div
|
||||
key={membership.id}
|
||||
className="flex items-center gap-2 p-2 rounded"
|
||||
style={{ backgroundColor: "var(--card-hover)" }}
|
||||
title={`${membership.user.username} (${membership.role})${membership.user.score !== undefined ? ` - ${membership.user.score} pts` : ""}`}
|
||||
>
|
||||
<Avatar
|
||||
src={membership.user.avatar}
|
||||
username={membership.user.username}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
borderClassName="border-pixel-gold/30"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-semibold truncate" style={{ color: "var(--foreground)" }}>
|
||||
{membership.user.username}
|
||||
</span>
|
||||
{membership.role === "OWNER" && (
|
||||
<span className="text-[10px] uppercase" style={{ color: "var(--accent)" }}>
|
||||
👑
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{membership.user.score !== undefined && membership.user.level !== undefined && (
|
||||
<div className="text-[10px]" style={{ color: "var(--muted-foreground)" }}>
|
||||
{membership.user.score} pts • Lv.{membership.user.level}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
90
components/houses/HouseForm.tsx
Normal file
90
components/houses/HouseForm.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
|
||||
<Input
|
||||
label="Nom de la maison"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={50}
|
||||
disabled={isPending}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description (optionnelle)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={500}
|
||||
disabled={isPending}
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button type="submit" disabled={isPending} variant="primary" className="w-full sm:w-auto">
|
||||
{isPending ? "Enregistrement..." : house ? "Modifier" : "Créer"}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isPending}
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
327
components/houses/HouseManagement.tsx
Normal file
327
components/houses/HouseManagement.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Card from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import SectionTitle from "@/components/ui/SectionTitle";
|
||||
import HouseForm from "./HouseForm";
|
||||
import RequestList from "./RequestList";
|
||||
import Alert from "@/components/ui/Alert";
|
||||
import { deleteHouse, leaveHouse } from "@/actions/houses/update";
|
||||
import { inviteUser } from "@/actions/houses/invitations";
|
||||
|
||||
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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
interface HouseManagementProps {
|
||||
house: House | null;
|
||||
users?: User[];
|
||||
requests?: Array<{
|
||||
id: string;
|
||||
requester: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
interface Request {
|
||||
id: string;
|
||||
requester: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function HouseManagement({
|
||||
house,
|
||||
users = [],
|
||||
requests: initialRequests = [],
|
||||
onUpdate,
|
||||
}: HouseManagementProps) {
|
||||
const { data: session } = useSession();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showInviteForm, setShowInviteForm] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState("");
|
||||
const [requests, setRequests] = useState<Request[]>(initialRequests);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const userRole = house?.memberships?.find(
|
||||
(m) => m.user.id === session?.user?.id
|
||||
)?.role;
|
||||
const isOwner = userRole === "OWNER";
|
||||
const isAdmin = userRole === "ADMIN" || isOwner;
|
||||
const pendingRequests = requests.filter((r) => r.status === "PENDING");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRequests = async () => {
|
||||
if (!house || !isAdmin) return;
|
||||
try {
|
||||
const response = await fetch(`/api/houses/${house.id}/requests?status=PENDING`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRequests(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching requests:", error);
|
||||
}
|
||||
};
|
||||
fetchRequests();
|
||||
}, [house?.id, isAdmin]);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!house || !confirm("Êtes-vous sûr de vouloir supprimer cette maison ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await deleteHouse(house.id);
|
||||
if (result.success) {
|
||||
onUpdate?.();
|
||||
} else {
|
||||
setError(result.error || "Erreur lors de la suppression");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleLeave = () => {
|
||||
if (!house || !confirm("Êtes-vous sûr de vouloir quitter cette maison ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await leaveHouse(house.id);
|
||||
if (result.success) {
|
||||
onUpdate?.();
|
||||
} else {
|
||||
setError(result.error || "Erreur lors de la sortie");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!house || !selectedUserId) return;
|
||||
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await inviteUser(house.id, selectedUserId);
|
||||
if (result.success) {
|
||||
setSuccess("Invitation envoyée");
|
||||
setShowInviteForm(false);
|
||||
setSelectedUserId("");
|
||||
onUpdate?.();
|
||||
} else {
|
||||
setError(result.error || "Erreur lors de l'envoi de l'invitation");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const availableUsers = users.filter(
|
||||
(u) =>
|
||||
u.id !== session?.user?.id &&
|
||||
!house?.memberships?.some((m) => m.user.id === u.id)
|
||||
);
|
||||
|
||||
if (!house) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<SectionTitle>Ma Maison</SectionTitle>
|
||||
<p className="text-sm mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||
Vous n'êtes membre d'aucune maison pour le moment.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<SectionTitle>{house.name}</SectionTitle>
|
||||
{house.description && (
|
||||
<p className="text-sm mt-2 break-words" style={{ color: "var(--muted-foreground)" }}>
|
||||
{house.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:flex-nowrap">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
{isEditing ? "Annuler" : "Modifier"}
|
||||
</Button>
|
||||
{isOwner && (
|
||||
<Button onClick={handleDelete} variant="danger" size="sm" className="flex-1 sm:flex-none">
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isOwner && (
|
||||
<Button onClick={handleLeave} variant="danger" size="sm" className="flex-1 sm:flex-none">
|
||||
Quitter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
||||
{success && <Alert variant="success" className="mb-4">{success}</Alert>}
|
||||
|
||||
{isEditing ? (
|
||||
<HouseForm
|
||||
house={house}
|
||||
onSuccess={() => {
|
||||
setIsEditing(false);
|
||||
onUpdate?.();
|
||||
}}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<h3 className="font-bold mb-3" style={{ color: "var(--foreground)" }}>
|
||||
Membres ({house.memberships?.length ?? 0})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{(house.memberships || []).map((membership) => (
|
||||
<div
|
||||
key={membership.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-2 rounded"
|
||||
style={{ backgroundColor: "var(--card-hover)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{membership.user.avatar && (
|
||||
<img
|
||||
src={membership.user.avatar}
|
||||
alt={membership.user.username}
|
||||
className="w-8 h-8 rounded-full flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold block sm:inline" style={{ color: "var(--foreground)" }}>
|
||||
{membership.user.username}
|
||||
</span>
|
||||
<span className="text-xs block sm:inline sm:ml-2" style={{ color: "var(--muted-foreground)" }}>
|
||||
({membership.user.score} pts - Niveau {membership.user.level})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs uppercase flex-shrink-0" style={{ color: "var(--accent)" }}>
|
||||
{membership.role}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="mt-4">
|
||||
{showInviteForm ? (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
className="w-full p-2 rounded border"
|
||||
style={{
|
||||
backgroundColor: "var(--input)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<option value="">Sélectionner un utilisateur</option>
|
||||
{availableUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleInvite}
|
||||
disabled={!selectedUserId || isPending}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{isPending ? "Envoi..." : "Inviter"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowInviteForm(false);
|
||||
setSelectedUserId("");
|
||||
}}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setShowInviteForm(true)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Inviter un utilisateur
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{isAdmin && pendingRequests.length > 0 && (
|
||||
<Card className="p-4 sm:p-6">
|
||||
<SectionTitle>Demandes d'adhésion</SectionTitle>
|
||||
<RequestList requests={pendingRequests} onUpdate={onUpdate} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
236
components/houses/HousesSection.tsx
Normal file
236
components/houses/HousesSection.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Card from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import SectionTitle from "@/components/ui/SectionTitle";
|
||||
import BackgroundSection from "@/components/ui/BackgroundSection";
|
||||
import HouseCard from "./HouseCard";
|
||||
import HouseForm from "./HouseForm";
|
||||
import HouseManagement from "./HouseManagement";
|
||||
import InvitationList from "./InvitationList";
|
||||
import Input from "@/components/ui/Input";
|
||||
|
||||
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 User {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
interface HousesSectionProps {
|
||||
initialHouses?: House[];
|
||||
initialMyHouse?: House | null;
|
||||
initialUsers?: User[];
|
||||
initialInvitations?: Array<{
|
||||
id: string;
|
||||
house: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
inviter: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
backgroundImage: string;
|
||||
}
|
||||
|
||||
export default function HousesSection({
|
||||
initialHouses = [],
|
||||
initialMyHouse = null,
|
||||
initialUsers = [],
|
||||
initialInvitations = [],
|
||||
backgroundImage,
|
||||
}: HousesSectionProps) {
|
||||
const { data: session } = useSession();
|
||||
const [houses, setHouses] = useState<House[]>(initialHouses);
|
||||
const [myHouse, setMyHouse] = useState<House | null>(initialMyHouse);
|
||||
const [invitations, setInvitations] = useState(initialInvitations);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const fetchHouses = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (searchTerm) {
|
||||
params.append("search", searchTerm);
|
||||
}
|
||||
params.append("include", "members,creator");
|
||||
|
||||
const response = await fetch(`/api/houses?${params}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHouses(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching houses:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMyHouse = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/houses/my-house");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMyHouse(data);
|
||||
} else if (response.status === 404) {
|
||||
setMyHouse(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching my house:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInvitations = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/invitations?status=PENDING");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setInvitations(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching invitations:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm) {
|
||||
const timeout = setTimeout(() => {
|
||||
fetchHouses();
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
fetchHouses();
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
const handleUpdate = () => {
|
||||
fetchMyHouse();
|
||||
fetchHouses();
|
||||
fetchInvitations();
|
||||
};
|
||||
|
||||
const filteredHouses = houses.filter((house) => {
|
||||
if (!myHouse) return true;
|
||||
return house.id !== myHouse.id;
|
||||
});
|
||||
|
||||
return (
|
||||
<BackgroundSection backgroundImage={backgroundImage}>
|
||||
{/* Title Section */}
|
||||
<SectionTitle
|
||||
variant="gradient"
|
||||
size="lg"
|
||||
subtitle="Rejoignez une maison ou créez la vôtre"
|
||||
className="mb-12 overflow-hidden"
|
||||
>
|
||||
MAISONS
|
||||
</SectionTitle>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{session?.user && (
|
||||
<>
|
||||
{invitations.length > 0 && (
|
||||
<Card className="p-4 sm:p-6">
|
||||
<SectionTitle>Mes Invitations</SectionTitle>
|
||||
<InvitationList invitations={invitations} onUpdate={handleUpdate} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-4 sm:p-6">
|
||||
<SectionTitle>Ma Maison</SectionTitle>
|
||||
{myHouse ? (
|
||||
<HouseManagement
|
||||
house={myHouse}
|
||||
users={initialUsers}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{showCreateForm ? (
|
||||
<HouseForm
|
||||
onSuccess={() => {
|
||||
setShowCreateForm(false);
|
||||
handleUpdate();
|
||||
}}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm mb-4 break-words" style={{ color: "var(--muted-foreground)" }}>
|
||||
Vous n'êtes membre d'aucune maison. Créez-en une ou demandez à rejoindre une maison existante.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Créer une maison
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card className="p-4 sm:p-6">
|
||||
<SectionTitle>Toutes les Maisons</SectionTitle>
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
placeholder="Rechercher une maison..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredHouses.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||
Aucune maison trouvée
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filteredHouses.map((house) => (
|
||||
<HouseCard
|
||||
key={house.id}
|
||||
house={house}
|
||||
onRequestSent={handleUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</BackgroundSection>
|
||||
);
|
||||
}
|
||||
|
||||
123
components/houses/InvitationList.tsx
Normal file
123
components/houses/InvitationList.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Card from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import {
|
||||
acceptInvitation,
|
||||
rejectInvitation,
|
||||
} from "@/actions/houses/invitations";
|
||||
import Alert from "@/components/ui/Alert";
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
house: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
inviter: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface InvitationListProps {
|
||||
invitations: Invitation[];
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export default function InvitationList({
|
||||
invitations,
|
||||
onUpdate,
|
||||
}: InvitationListProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAccept = (invitationId: string) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await acceptInvitation(invitationId);
|
||||
if (result.success) {
|
||||
onUpdate?.();
|
||||
} else {
|
||||
setError(result.error || "Erreur lors de l'acceptation");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = (invitationId: string) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await rejectInvitation(invitationId);
|
||||
if (result.success) {
|
||||
onUpdate?.();
|
||||
} else {
|
||||
setError(result.error || "Erreur lors du refus");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (invitations.length === 0) {
|
||||
return (
|
||||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||
Aucune invitation en attente
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
{invitations.map((invitation) => (
|
||||
<Card key={invitation.id} className="p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold mb-1 break-words" style={{ color: "var(--foreground)" }}>
|
||||
Invitation de {invitation.inviter.username}
|
||||
</h4>
|
||||
<p className="text-sm mb-2 break-words" style={{ color: "var(--muted-foreground)" }}>
|
||||
Pour rejoindre la maison <strong>{invitation.house.name}</strong>
|
||||
</p>
|
||||
</div>
|
||||
{invitation.status === "PENDING" && (
|
||||
<div className="flex gap-2 sm:flex-nowrap">
|
||||
<Button
|
||||
onClick={() => handleAccept(invitation.id)}
|
||||
disabled={isPending}
|
||||
variant="success"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
Accepter
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleReject(invitation.id)}
|
||||
disabled={isPending}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
Refuser
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{invitation.status === "ACCEPTED" && (
|
||||
<span className="text-xs flex-shrink-0" style={{ color: "var(--success)" }}>
|
||||
✓ Acceptée
|
||||
</span>
|
||||
)}
|
||||
{invitation.status === "REJECTED" && (
|
||||
<span className="text-xs flex-shrink-0" style={{ color: "var(--destructive)" }}>
|
||||
✗ Refusée
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
119
components/houses/RequestList.tsx
Normal file
119
components/houses/RequestList.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Card from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import {
|
||||
acceptRequest,
|
||||
rejectRequest,
|
||||
} from "@/actions/houses/requests";
|
||||
import Alert from "@/components/ui/Alert";
|
||||
|
||||
interface Request {
|
||||
id: string;
|
||||
requester: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface RequestListProps {
|
||||
requests: Request[];
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export default function RequestList({
|
||||
requests,
|
||||
onUpdate,
|
||||
}: RequestListProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAccept = (requestId: string) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await acceptRequest(requestId);
|
||||
if (result.success) {
|
||||
onUpdate?.();
|
||||
} else {
|
||||
setError(result.error || "Erreur lors de l'acceptation");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = (requestId: string) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await rejectRequest(requestId);
|
||||
if (result.success) {
|
||||
onUpdate?.();
|
||||
} else {
|
||||
setError(result.error || "Erreur lors du refus");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||
Aucune demande en attente
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
{requests.map((request) => (
|
||||
<Card key={request.id} className="p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold mb-1 break-words" style={{ color: "var(--foreground)" }}>
|
||||
{request.requester.username}
|
||||
</h4>
|
||||
<p className="text-sm break-words" style={{ color: "var(--muted-foreground)" }}>
|
||||
souhaite rejoindre votre maison
|
||||
</p>
|
||||
</div>
|
||||
{request.status === "PENDING" && (
|
||||
<div className="flex gap-2 sm:flex-nowrap">
|
||||
<Button
|
||||
onClick={() => handleAccept(request.id)}
|
||||
disabled={isPending}
|
||||
variant="success"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
Accepter
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleReject(request.id)}
|
||||
disabled={isPending}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
Refuser
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{request.status === "ACCEPTED" && (
|
||||
<span className="text-xs flex-shrink-0" style={{ color: "var(--success)" }}>
|
||||
✓ Acceptée
|
||||
</span>
|
||||
)}
|
||||
{request.status === "REJECTED" && (
|
||||
<span className="text-xs flex-shrink-0" style={{ color: "var(--destructive)" }}>
|
||||
✗ Refusée
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user