Compare commits
60 Commits
1e865330a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c47bf916c | ||
|
|
9bcafe54d3 | ||
|
|
14c767cfc0 | ||
|
|
82069c74bc | ||
|
|
a062f5573b | ||
|
|
6e7c5d3eaf | ||
|
|
5dc178543e | ||
|
|
881b8149e5 | ||
|
|
d6a1e21e9f | ||
|
|
0b56d625ec | ||
|
|
f5dab3cb95 | ||
|
|
1b82bd9ee6 | ||
|
|
12bc44e3ac | ||
|
|
4a415f79e0 | ||
|
|
a62e61a314 | ||
|
|
91460930a4 | ||
|
|
fdedc1cf65 | ||
|
|
4fcf34c9aa | ||
|
|
85ee812ab1 | ||
|
|
cb02b494f4 | ||
|
|
2c7a346cde | ||
|
|
5875813f2f | ||
|
|
20c3043572 | ||
|
|
8ad09ab9c8 | ||
|
|
1f59cc7f9d | ||
|
|
67b3d9e2a9 | ||
|
|
ba3b2c17b9 | ||
|
|
7c0b3bc848 | ||
|
|
5eddf36121 | ||
|
|
ec965cd59d | ||
|
|
79c21955e0 | ||
|
|
16e4b63ffd | ||
|
|
3dd82c2bd4 | ||
|
|
f45cc1839e | ||
|
|
ffbf3cd42f | ||
|
|
a9a4120874 | ||
|
|
c7595c4173 | ||
|
|
bfaf30ee26 | ||
|
|
633245c1f1 | ||
|
|
bee8999362 | ||
|
|
d3a4fa7cf5 | ||
|
|
83446759fe | ||
|
|
b790ee21f2 | ||
|
|
321da3176e | ||
|
|
e4b0907801 | ||
|
|
9b9cc3885a | ||
|
|
d45475fb5a | ||
|
|
042b3128d4 | ||
|
|
5e179fb97a | ||
|
|
d9555a5d49 | ||
|
|
83aa54ff44 | ||
|
|
d08f6f6a9c | ||
|
|
699de28868 | ||
|
|
bbb0fbb9a1 | ||
|
|
f2bb02406e | ||
|
|
177b34d70f | ||
|
|
5e810202bb | ||
|
|
f093977b34 | ||
|
|
9518eef3d4 | ||
|
|
dcba162663 |
30
.env
30
.env
@@ -5,8 +5,28 @@
|
||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||
|
||||
DATABASE_URL="file:./data/dev.db"
|
||||
AUTH_SECRET="your-secret-key-change-this-in-production"
|
||||
AUTH_URL="http://localhost:3000"
|
||||
PRISMA_DATA_PATH="/Users/julien.froidefond/Sites/DAIS/got-mc/data"
|
||||
UPLOADS_PATH="/Users/julien.froidefond/Sites/DAIS/got-mc/public/uploads"
|
||||
# DATABASE_URL="file:./data/dev.db"
|
||||
# AUTH_SECRET="your-secret-key-change-this-in-production"
|
||||
# AUTH_URL="http://localhost:3000"
|
||||
# PRISMA_DATA_PATH="/Users/julien.froidefond/Sites/DAIS/public/got-gaming/data"
|
||||
# UPLOADS_PATH="/Users/julien.froidefond/Sites/DAIS/public/got-gaming/public/uploads"
|
||||
|
||||
# NextAuth Configuration
|
||||
NEXTAUTH_SECRET=change-this-secret-in-production
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# PostgreSQL Configuration
|
||||
POSTGRES_USER=gotgaming
|
||||
POSTGRES_PASSWORD=change-this-in-production
|
||||
POSTGRES_DB=gotgaming
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5433
|
||||
|
||||
# Database URL (construite automatiquement si non définie)
|
||||
# Si vous définissez cette variable, elle sera utilisée telle quelle
|
||||
# Sinon, elle sera construite à partir des variables POSTGRES_* ci-dessus
|
||||
DATABASE_URL=postgresql://gotgaming:change-this-in-production@localhost:5433/gotgaming?schema=public
|
||||
|
||||
# Docker Volumes (optionnel)
|
||||
POSTGRES_DATA_PATH=./data/postgres
|
||||
UPLOADS_PATH=./public/uploads
|
||||
|
||||
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# NextAuth Configuration
|
||||
NEXTAUTH_SECRET=change-this-secret-in-production
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# PostgreSQL Configuration
|
||||
POSTGRES_USER=gotgaming
|
||||
POSTGRES_PASSWORD=change-this-in-production
|
||||
POSTGRES_DB=gotgaming
|
||||
POSTGRES_HOST=got-postgres
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Database URL (construite automatiquement si non définie)
|
||||
# Si vous définissez cette variable, elle sera utilisée telle quelle
|
||||
# Sinon, elle sera construite à partir des variables POSTGRES_* ci-dessus
|
||||
# DATABASE_URL=postgresql://gotgaming:change-this-in-production@got-postgres:5432/gotgaming?schema=public
|
||||
|
||||
# Docker Volumes (optionnel)
|
||||
POSTGRES_DATA_PATH=./data/postgres
|
||||
UPLOADS_PATH=./public/uploads
|
||||
@@ -20,5 +20,7 @@ jobs:
|
||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
|
||||
UPLOADS_PATH: ${{ vars.UPLOADS_PATH }}
|
||||
POSTGRES_DATA_PATH: ${{ vars.POSTGRES_DATA_PATH }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
run: |
|
||||
docker compose up -d --build
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -25,6 +25,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
@@ -41,3 +42,9 @@ dev.db*
|
||||
|
||||
# prisma
|
||||
/app/generated/prisma
|
||||
prisma/generated/
|
||||
|
||||
# database data
|
||||
data/postgres/
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
|
||||
40
Dockerfile
40
Dockerfile
@@ -19,7 +19,9 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV DATABASE_URL="file:/tmp/build.db"
|
||||
# ARG pour DATABASE_URL au build (valeur factice par défaut, car prisma generate n'a pas besoin de vraie DB)
|
||||
ARG DATABASE_URL_BUILD="postgresql://user:pass@localhost:5432/db"
|
||||
ENV DATABASE_URL=$DATABASE_URL_BUILD
|
||||
RUN pnpm prisma generate
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
@@ -45,28 +47,41 @@ COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=builder /app/next.config.js ./next.config.js
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||
|
||||
ENV DATABASE_URL="file:/tmp/build.db"
|
||||
# Copier le répertoire prisma complet (schema + migrations)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
# Copier prisma.config.ts (nécessaire pour Prisma 7)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.ts
|
||||
|
||||
# Installer seulement les dépendances de production puis générer Prisma Client
|
||||
# ARG pour DATABASE_URL au build (valeur factice par défaut, car prisma generate n'a pas besoin de vraie DB)
|
||||
# Au runtime, DATABASE_URL sera définie par docker-compose.yml (voir ligne 41)
|
||||
ARG DATABASE_URL_BUILD="postgresql://user:pass@localhost:5432/db"
|
||||
ENV DATABASE_URL=$DATABASE_URL_BUILD
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --prod && \
|
||||
pnpm dlx prisma generate
|
||||
# Ne pas définir ENV DATABASE_URL ici - elle sera définie par docker-compose.yml au runtime
|
||||
|
||||
ENV DATABASE_URL="file:/app/data/dev.db"
|
||||
|
||||
# Create data directory for SQLite database and uploads directories
|
||||
RUN mkdir -p /app/data /app/public/uploads /app/public/uploads/backgrounds && \
|
||||
chown -R nextjs:nodejs /app/data /app/public/uploads
|
||||
# Create uploads directories
|
||||
RUN mkdir -p /app/public/uploads /app/public/uploads/backgrounds && \
|
||||
chown -R nextjs:nodejs /app/public/uploads
|
||||
|
||||
RUN echo '#!/bin/sh' > /app/entrypoint.sh && \
|
||||
echo 'set -e' >> /app/entrypoint.sh && \
|
||||
echo 'mkdir -p /app/data' >> /app/entrypoint.sh && \
|
||||
echo 'mkdir -p /app/public/uploads' >> /app/entrypoint.sh && \
|
||||
echo 'mkdir -p /app/public/uploads/backgrounds' >> /app/entrypoint.sh && \
|
||||
echo 'pnpm dlx prisma migrate deploy || true' >> /app/entrypoint.sh && \
|
||||
echo 'if [ -z "$DATABASE_URL" ]; then' >> /app/entrypoint.sh && \
|
||||
echo ' echo "ERROR: DATABASE_URL is not set"' >> /app/entrypoint.sh && \
|
||||
echo ' exit 1' >> /app/entrypoint.sh && \
|
||||
echo 'fi' >> /app/entrypoint.sh && \
|
||||
echo 'export DATABASE_URL' >> /app/entrypoint.sh && \
|
||||
echo 'cd /app' >> /app/entrypoint.sh && \
|
||||
echo 'echo "Applying migrations..."' >> /app/entrypoint.sh && \
|
||||
echo 'if ! pnpm dlx prisma migrate deploy; then' >> /app/entrypoint.sh && \
|
||||
echo ' echo "Migration failed. Attempting to resolve failed migration..."' >> /app/entrypoint.sh && \
|
||||
echo ' pnpm dlx prisma migrate resolve --applied 20251217101717_init_postgres 2>/dev/null || true' >> /app/entrypoint.sh && \
|
||||
echo ' pnpm dlx prisma migrate deploy || echo "WARNING: Some migrations may need manual resolution"' >> /app/entrypoint.sh && \
|
||||
echo 'fi' >> /app/entrypoint.sh && \
|
||||
echo 'exec pnpm start' >> /app/entrypoint.sh && \
|
||||
chmod +x /app/entrypoint.sh && \
|
||||
chown nextjs:nodejs /app/entrypoint.sh
|
||||
@@ -76,6 +91,5 @@ USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV DATABASE_URL="file:/app/data/dev.db"
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
@@ -24,26 +24,48 @@ docker-compose logs -f
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
Créez un fichier `.env` à la racine du projet avec les variables suivantes :
|
||||
Créez un fichier `.env` à la racine du projet à partir du template `.env.example` :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Puis modifiez les valeurs dans `.env` selon votre configuration :
|
||||
|
||||
```env
|
||||
# NextAuth Configuration
|
||||
NEXTAUTH_SECRET=your-secret-key-here
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
DATABASE_URL=file:./prisma/dev.db
|
||||
|
||||
# PostgreSQL Configuration
|
||||
POSTGRES_USER=gotgaming
|
||||
POSTGRES_PASSWORD=change-this-in-production
|
||||
POSTGRES_DB=gotgaming
|
||||
|
||||
# Database URL (optionnel - construite automatiquement si non définie)
|
||||
# DATABASE_URL=postgresql://gotgaming:change-this-in-production@got-postgres:5432/gotgaming?schema=public
|
||||
|
||||
# Docker Volumes (optionnel)
|
||||
POSTGRES_DATA_PATH=./data/postgres
|
||||
UPLOADS_PATH=./public/uploads
|
||||
```
|
||||
|
||||
**Important** :
|
||||
- Le fichier `.env` est ignoré par Git (ne pas commiter vos secrets)
|
||||
- Si vous changez `POSTGRES_PASSWORD` après la première initialisation, vous devrez soit réinitialiser la base, soit changer le mot de passe manuellement dans PostgreSQL
|
||||
|
||||
## Volumes persistants
|
||||
|
||||
### Base de données
|
||||
### Base de données PostgreSQL
|
||||
|
||||
La base de données SQLite est persistée via un volume Docker. Par défaut, elle est stockée dans `/Volumes/EXTERNAL_USB/sites/got-gaming/data`, mais vous pouvez la personnaliser avec la variable d'environnement `PRISMA_DATA_PATH`.
|
||||
La base de données PostgreSQL est persistée via un volume Docker. Par défaut, elle est stockée dans `./data/postgres`, mais vous pouvez la personnaliser avec la variable d'environnement `POSTGRES_DATA_PATH`.
|
||||
|
||||
Les migrations Prisma sont appliquées automatiquement au démarrage du conteneur.
|
||||
|
||||
Pour appliquer manuellement les migrations :
|
||||
|
||||
```bash
|
||||
docker-compose exec got-app node node_modules/.bin/prisma migrate deploy
|
||||
docker-compose exec got-app pnpm dlx prisma migrate deploy
|
||||
```
|
||||
|
||||
### Images uploadées
|
||||
|
||||
@@ -41,5 +41,5 @@ pnpm start
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Prisma (SQLite)
|
||||
- Prisma (PostgreSQL)
|
||||
- NextAuth.js
|
||||
|
||||
270
actions/admin/challenges.ts
Normal file
270
actions/admin/challenges.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { challengeService } from "@/services/challenges/challenge.service";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
import { ValidationError, NotFoundError } from "@/services/errors";
|
||||
|
||||
async function checkAdminAccess() {
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== Role.ADMIN) {
|
||||
throw new Error("Accès refusé - Admin uniquement");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function validateChallenge(
|
||||
challengeId: string,
|
||||
winnerId: string,
|
||||
adminComment?: string
|
||||
) {
|
||||
try {
|
||||
const session = await checkAdminAccess();
|
||||
|
||||
const challenge = await challengeService.validateChallenge(
|
||||
challengeId,
|
||||
session.user.id,
|
||||
winnerId,
|
||||
adminComment
|
||||
);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/challenges");
|
||||
revalidatePath("/leaderboard");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Défi validé avec succès",
|
||||
data: challenge,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Validate challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("Accès refusé")) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de la validation du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectChallenge(
|
||||
challengeId: string,
|
||||
adminComment?: string
|
||||
) {
|
||||
try {
|
||||
const session = await checkAdminAccess();
|
||||
|
||||
const challenge = await challengeService.rejectChallenge(
|
||||
challengeId,
|
||||
session.user.id,
|
||||
adminComment
|
||||
);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/challenges");
|
||||
|
||||
return { success: true, message: "Défi rejeté", data: challenge };
|
||||
} catch (error) {
|
||||
console.error("Reject challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("Accès refusé")) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors du rejet du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateChallenge(
|
||||
challengeId: string,
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
pointsReward?: number;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
await checkAdminAccess();
|
||||
|
||||
const challenge = await challengeService.updateChallenge(challengeId, {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
pointsReward: data.pointsReward,
|
||||
});
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/challenges");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Défi mis à jour avec succès",
|
||||
data: challenge,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Update challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("Accès refusé")) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de la mise à jour du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteChallenge(challengeId: string) {
|
||||
try {
|
||||
await checkAdminAccess();
|
||||
|
||||
await challengeService.deleteChallenge(challengeId);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/challenges");
|
||||
|
||||
return { success: true, message: "Défi supprimé avec succès" };
|
||||
} catch (error) {
|
||||
console.error("Delete challenge error:", error);
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("Accès refusé")) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de la suppression du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminCancelChallenge(challengeId: string) {
|
||||
try {
|
||||
await checkAdminAccess();
|
||||
|
||||
const challenge = await challengeService.adminCancelChallenge(challengeId);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/challenges");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Défi annulé avec succès",
|
||||
data: challenge,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Admin cancel challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("Accès refusé")) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de l'annulation du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function reactivateChallenge(challengeId: string) {
|
||||
try {
|
||||
await checkAdminAccess();
|
||||
|
||||
const challenge = await challengeService.reactivateChallenge(challengeId);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/challenges");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Défi réactivé avec succès",
|
||||
data: challenge,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Reactivate challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("Accès refusé")) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de la réactivation du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminAcceptChallenge(challengeId: string) {
|
||||
try {
|
||||
await checkAdminAccess();
|
||||
|
||||
const challenge = await challengeService.adminAcceptChallenge(challengeId);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/challenges");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Défi accepté avec succès",
|
||||
data: challenge,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Admin accept challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message.includes("Accès refusé")) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de l'acceptation du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,79 @@
|
||||
'use server'
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { eventService } from '@/services/events/event.service'
|
||||
import { Role, EventType } from '@/prisma/generated/prisma/client'
|
||||
import { ValidationError, NotFoundError } from '@/services/errors'
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { eventService } from "@/services/events/event.service";
|
||||
import { Role, EventType } from "@/prisma/generated/prisma/client";
|
||||
import { ValidationError, NotFoundError } from "@/services/errors";
|
||||
|
||||
function checkAdminAccess() {
|
||||
return async () => {
|
||||
const session = await auth()
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== Role.ADMIN) {
|
||||
throw new Error('Accès refusé')
|
||||
throw new Error("Accès refusé");
|
||||
}
|
||||
return session
|
||||
}
|
||||
return session;
|
||||
};
|
||||
}
|
||||
|
||||
export async function createEvent(data: {
|
||||
date: string
|
||||
name: string
|
||||
description?: string | null
|
||||
type: string
|
||||
room?: string | null
|
||||
time?: string | null
|
||||
maxPlaces?: number | null
|
||||
date: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
type: string;
|
||||
room?: string | null;
|
||||
time?: string | null;
|
||||
maxPlaces?: number | null;
|
||||
}) {
|
||||
try {
|
||||
await checkAdminAccess()()
|
||||
await checkAdminAccess()();
|
||||
|
||||
const event = await eventService.validateAndCreateEvent({
|
||||
date: data.date,
|
||||
name: data.name,
|
||||
description: data.description ?? '',
|
||||
description: data.description ?? "",
|
||||
type: data.type as EventType,
|
||||
room: data.room ?? undefined,
|
||||
time: data.time ?? undefined,
|
||||
maxPlaces: data.maxPlaces ?? undefined,
|
||||
})
|
||||
});
|
||||
|
||||
revalidatePath('/admin')
|
||||
revalidatePath('/events')
|
||||
revalidatePath('/')
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/events");
|
||||
revalidatePath("/");
|
||||
|
||||
return { success: true, data: event }
|
||||
return { success: true, data: event };
|
||||
} catch (error) {
|
||||
console.error('Error creating event:', error)
|
||||
console.error("Error creating event:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Accès refusé') {
|
||||
return { success: false, error: 'Accès refusé' }
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Erreur lors de la création de l\'événement' }
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de la création de l'événement",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEvent(eventId: string, data: {
|
||||
date?: string
|
||||
name?: string
|
||||
description?: string | null
|
||||
type?: string
|
||||
room?: string | null
|
||||
time?: string | null
|
||||
maxPlaces?: number | null
|
||||
}) {
|
||||
export async function updateEvent(
|
||||
eventId: string,
|
||||
data: {
|
||||
date?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
type?: string;
|
||||
room?: string | null;
|
||||
time?: string | null;
|
||||
maxPlaces?: number | null;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
await checkAdminAccess()()
|
||||
await checkAdminAccess()();
|
||||
|
||||
const event = await eventService.validateAndUpdateEvent(eventId, {
|
||||
date: data.date,
|
||||
@@ -77,55 +83,60 @@ export async function updateEvent(eventId: string, data: {
|
||||
room: data.room ?? undefined,
|
||||
time: data.time ?? undefined,
|
||||
maxPlaces: data.maxPlaces ?? undefined,
|
||||
})
|
||||
});
|
||||
|
||||
revalidatePath('/admin')
|
||||
revalidatePath('/events')
|
||||
revalidatePath('/')
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/events");
|
||||
revalidatePath("/");
|
||||
|
||||
return { success: true, data: event }
|
||||
return { success: true, data: event };
|
||||
} catch (error) {
|
||||
console.error('Error updating event:', error)
|
||||
console.error("Error updating event:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Accès refusé') {
|
||||
return { success: false, error: 'Accès refusé' }
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Erreur lors de la mise à jour de l\'événement' }
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de la mise à jour de l'événement",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEvent(eventId: string) {
|
||||
try {
|
||||
await checkAdminAccess()()
|
||||
await checkAdminAccess()();
|
||||
|
||||
const existingEvent = await eventService.getEventById(eventId)
|
||||
const existingEvent = await eventService.getEventById(eventId);
|
||||
|
||||
if (!existingEvent) {
|
||||
return { success: false, error: 'Événement non trouvé' }
|
||||
return { success: false, error: "Événement non trouvé" };
|
||||
}
|
||||
|
||||
await eventService.deleteEvent(eventId)
|
||||
await eventService.deleteEvent(eventId);
|
||||
|
||||
revalidatePath('/admin')
|
||||
revalidatePath('/events')
|
||||
revalidatePath('/')
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/events");
|
||||
revalidatePath("/");
|
||||
|
||||
return { success: true }
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error)
|
||||
console.error("Error deleting event:", error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Accès refusé') {
|
||||
return { success: false, error: 'Accès refusé' }
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Erreur lors de la suppression de l\'événement' }
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de la suppression de l'événement",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
127
actions/admin/feedback.ts
Normal file
127
actions/admin/feedback.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/services/database";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
import { NotFoundError } from "@/services/errors";
|
||||
|
||||
function checkAdminAccess() {
|
||||
return async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== Role.ADMIN) {
|
||||
throw new Error("Accès refusé");
|
||||
}
|
||||
return session;
|
||||
};
|
||||
}
|
||||
|
||||
export async function addFeedbackBonusPoints(
|
||||
userId: string,
|
||||
points: number
|
||||
) {
|
||||
try {
|
||||
await checkAdminAccess()();
|
||||
|
||||
// Vérifier que l'utilisateur existe
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, score: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError("Utilisateur");
|
||||
}
|
||||
|
||||
// Ajouter les points
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
score: {
|
||||
increment: points,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
score: true,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/leaderboard");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${points} points ajoutés avec succès`,
|
||||
data: updatedUser,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error adding bonus points:", error);
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de l'ajout des points",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function markFeedbackAsRead(feedbackId: string, isRead: boolean) {
|
||||
try {
|
||||
await checkAdminAccess()();
|
||||
|
||||
// Vérifier que le feedback existe
|
||||
const feedback = await prisma.eventFeedback.findUnique({
|
||||
where: { id: feedbackId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!feedback) {
|
||||
throw new NotFoundError("Feedback");
|
||||
}
|
||||
|
||||
// Mettre à jour le statut
|
||||
const updatedFeedback = await prisma.eventFeedback.update({
|
||||
where: { id: feedbackId },
|
||||
data: {
|
||||
isRead,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isRead: true,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: isRead
|
||||
? "Feedback marqué comme lu"
|
||||
: "Feedback marqué comme non lu",
|
||||
data: updatedFeedback,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error marking feedback as read:", error);
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de la mise à jour du feedback",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
139
actions/admin/houses.ts
Normal file
139
actions/admin/houses.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { houseService } from "@/services/houses/house.service";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
} from "@/services/errors";
|
||||
|
||||
function checkAdminAccess() {
|
||||
return async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== Role.ADMIN) {
|
||||
throw new Error("Accès refusé");
|
||||
}
|
||||
return session;
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateHouse(
|
||||
houseId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
await checkAdminAccess()();
|
||||
|
||||
// L'admin peut modifier n'importe quelle maison sans vérifier les permissions normales
|
||||
// On utilise directement le service mais on bypass les vérifications de propriétaire/admin
|
||||
const house = await houseService.getHouseById(houseId);
|
||||
if (!house) {
|
||||
return { success: false, error: "Maison non trouvée" };
|
||||
}
|
||||
|
||||
// Utiliser le service avec le creatorId pour bypass les vérifications
|
||||
const updatedHouse = await houseService.updateHouse(
|
||||
houseId,
|
||||
house.creatorId, // Utiliser le creatorId pour bypass
|
||||
data
|
||||
);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/houses");
|
||||
|
||||
return { success: true, data: updatedHouse };
|
||||
} catch (error) {
|
||||
console.error("Error updating house:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof ConflictError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de la mise à jour de la maison",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHouse(houseId: string) {
|
||||
try {
|
||||
await checkAdminAccess()();
|
||||
|
||||
const house = await houseService.getHouseById(houseId);
|
||||
if (!house) {
|
||||
return { success: false, error: "Maison non trouvée" };
|
||||
}
|
||||
|
||||
// L'admin peut supprimer n'importe quelle maison
|
||||
// On utilise le creatorId pour bypass les vérifications
|
||||
await houseService.deleteHouse(houseId, house.creatorId);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/houses");
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting house:", error);
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof ForbiddenError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de la suppression de la maison",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeMember(houseId: string, memberId: string) {
|
||||
try {
|
||||
await checkAdminAccess()();
|
||||
|
||||
// L'admin peut retirer n'importe quel membre (sauf le propriétaire)
|
||||
await houseService.removeMemberAsAdmin(houseId, memberId);
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/houses");
|
||||
|
||||
return { success: true, message: "Membre retiré de la maison" };
|
||||
} catch (error) {
|
||||
console.error("Error removing member:", error);
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof ForbiddenError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors du retrait du membre",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,69 @@
|
||||
'use server'
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { sitePreferencesService } from '@/services/preferences/site-preferences.service'
|
||||
import { Role } from '@/prisma/generated/prisma/client'
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
|
||||
function checkAdminAccess() {
|
||||
return async () => {
|
||||
const session = await auth()
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== Role.ADMIN) {
|
||||
throw new Error('Accès refusé')
|
||||
throw new Error("Accès refusé");
|
||||
}
|
||||
return session
|
||||
}
|
||||
return session;
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateSitePreferences(data: {
|
||||
homeBackground?: string | null
|
||||
eventsBackground?: string | null
|
||||
leaderboardBackground?: string | null
|
||||
homeBackground?: string | null;
|
||||
eventsBackground?: string | null;
|
||||
leaderboardBackground?: string | null;
|
||||
challengesBackground?: string | null;
|
||||
profileBackground?: string | null;
|
||||
houseBackground?: string | null;
|
||||
eventRegistrationPoints?: number;
|
||||
eventFeedbackPoints?: number;
|
||||
houseJoinPoints?: number;
|
||||
houseLeavePoints?: number;
|
||||
houseCreatePoints?: number;
|
||||
}) {
|
||||
try {
|
||||
await checkAdminAccess()()
|
||||
await checkAdminAccess()();
|
||||
|
||||
const preferences = await sitePreferencesService.updateSitePreferences({
|
||||
homeBackground: data.homeBackground,
|
||||
eventsBackground: data.eventsBackground,
|
||||
leaderboardBackground: data.leaderboardBackground,
|
||||
})
|
||||
challengesBackground: data.challengesBackground,
|
||||
profileBackground: data.profileBackground,
|
||||
houseBackground: data.houseBackground,
|
||||
eventRegistrationPoints: data.eventRegistrationPoints,
|
||||
eventFeedbackPoints: data.eventFeedbackPoints,
|
||||
houseJoinPoints: data.houseJoinPoints,
|
||||
houseLeavePoints: data.houseLeavePoints,
|
||||
houseCreatePoints: data.houseCreatePoints,
|
||||
});
|
||||
|
||||
revalidatePath('/admin')
|
||||
revalidatePath('/')
|
||||
revalidatePath('/events')
|
||||
revalidatePath('/leaderboard')
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/events");
|
||||
revalidatePath("/leaderboard");
|
||||
revalidatePath("/challenges");
|
||||
revalidatePath("/profile");
|
||||
revalidatePath("/houses");
|
||||
|
||||
return { success: true, data: preferences }
|
||||
return { success: true, data: preferences };
|
||||
} catch (error) {
|
||||
console.error('Error updating admin preferences:', error)
|
||||
console.error("Error updating admin preferences:", error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Accès refusé') {
|
||||
return { success: false, error: 'Accès refusé' }
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Erreur lors de la mise à jour des préférences' }
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de la mise à jour des préférences",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,55 @@
|
||||
'use server'
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { userService } from '@/services/users/user.service'
|
||||
import { userStatsService } from '@/services/users/user-stats.service'
|
||||
import { Role } from '@/prisma/generated/prisma/client'
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import { userStatsService } from "@/services/users/user-stats.service";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} from '@/services/errors'
|
||||
} from "@/services/errors";
|
||||
|
||||
function checkAdminAccess() {
|
||||
return async () => {
|
||||
const session = await auth()
|
||||
const session = await auth();
|
||||
if (!session?.user || session.user.role !== Role.ADMIN) {
|
||||
throw new Error('Accès refusé')
|
||||
throw new Error("Accès refusé");
|
||||
}
|
||||
return session
|
||||
}
|
||||
return session;
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateUser(userId: string, data: {
|
||||
username?: string
|
||||
avatar?: string | null
|
||||
hpDelta?: number
|
||||
xpDelta?: number
|
||||
score?: number
|
||||
level?: number
|
||||
role?: string
|
||||
}) {
|
||||
export async function updateUser(
|
||||
userId: string,
|
||||
data: {
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
hpDelta?: number;
|
||||
xpDelta?: number;
|
||||
score?: number;
|
||||
level?: number;
|
||||
role?: string;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
await checkAdminAccess()()
|
||||
await checkAdminAccess()();
|
||||
|
||||
// Valider username si fourni
|
||||
if (data.username !== undefined) {
|
||||
try {
|
||||
await userService.validateAndUpdateUserProfile(userId, { username: data.username })
|
||||
await userService.validateAndUpdateUserProfile(userId, {
|
||||
username: data.username,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError || error instanceof ConflictError) {
|
||||
return { success: false, error: error.message }
|
||||
if (
|
||||
error instanceof ValidationError ||
|
||||
error instanceof ConflictError
|
||||
) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,47 +78,52 @@ export async function updateUser(userId: string, data: {
|
||||
maxXp: true,
|
||||
avatar: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath('/admin')
|
||||
revalidatePath('/leaderboard')
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/leaderboard");
|
||||
|
||||
return { success: true, data: updatedUser }
|
||||
return { success: true, data: updatedUser };
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error)
|
||||
console.error("Error updating user:", error);
|
||||
|
||||
if (error instanceof Error && error.message === 'Accès refusé') {
|
||||
return { success: false, error: 'Accès refusé' }
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Erreur lors de la mise à jour de l\'utilisateur' }
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de la mise à jour de l'utilisateur",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
try {
|
||||
const session = await checkAdminAccess()()
|
||||
const session = await checkAdminAccess()();
|
||||
|
||||
await userService.validateAndDeleteUser(userId, session.user.id)
|
||||
await userService.validateAndDeleteUser(userId, session.user.id);
|
||||
|
||||
revalidatePath('/admin')
|
||||
revalidatePath('/leaderboard')
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/leaderboard");
|
||||
|
||||
return { success: true }
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error)
|
||||
console.error("Error deleting user:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Accès refusé') {
|
||||
return { success: false, error: 'Accès refusé' }
|
||||
if (error instanceof Error && error.message === "Accès refusé") {
|
||||
return { success: false, error: "Accès refusé" };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Erreur lors de la suppression de l\'utilisateur' }
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de la suppression de l'utilisateur",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
131
actions/challenges/create.ts
Normal file
131
actions/challenges/create.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { challengeService } from "@/services/challenges/challenge.service";
|
||||
import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} from "@/services/errors";
|
||||
|
||||
export async function createChallenge(data: {
|
||||
challengedId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pointsReward?: number;
|
||||
}) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Vous devez être connecté pour créer un défi",
|
||||
};
|
||||
}
|
||||
|
||||
const challenge = await challengeService.createChallenge({
|
||||
challengerId: session.user.id,
|
||||
challengedId: data.challengedId,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
pointsReward: data.pointsReward || 100,
|
||||
});
|
||||
|
||||
revalidatePath("/challenges");
|
||||
revalidatePath("/profile");
|
||||
|
||||
return { success: true, message: "Défi créé avec succès", data: challenge };
|
||||
} catch (error) {
|
||||
console.error("Create challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError || error instanceof ConflictError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de la création du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptChallenge(challengeId: string) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Vous devez être connecté pour accepter un défi",
|
||||
};
|
||||
}
|
||||
|
||||
const challenge = await challengeService.acceptChallenge(
|
||||
challengeId,
|
||||
session.user.id
|
||||
);
|
||||
|
||||
revalidatePath("/challenges");
|
||||
revalidatePath("/profile");
|
||||
|
||||
return { success: true, message: "Défi accepté", data: challenge };
|
||||
} catch (error) {
|
||||
console.error("Accept challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de l'acceptation du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelChallenge(challengeId: string) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Vous devez être connecté pour annuler un défi",
|
||||
};
|
||||
}
|
||||
|
||||
const challenge = await challengeService.cancelChallenge(
|
||||
challengeId,
|
||||
session.user.id
|
||||
);
|
||||
|
||||
revalidatePath("/challenges");
|
||||
revalidatePath("/profile");
|
||||
|
||||
return { success: true, message: "Défi annulé", data: challenge };
|
||||
} catch (error) {
|
||||
console.error("Cancel challenge error:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de l'annulation du défi",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
'use server'
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { eventFeedbackService } from '@/services/events/event-feedback.service'
|
||||
import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
} from '@/services/errors'
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { eventFeedbackService } from "@/services/events/event-feedback.service";
|
||||
import { ValidationError, NotFoundError } from "@/services/errors";
|
||||
|
||||
export async function createFeedback(eventId: string, data: {
|
||||
rating: number
|
||||
comment?: string | null
|
||||
}) {
|
||||
export async function createFeedback(
|
||||
eventId: string,
|
||||
data: {
|
||||
rating: number;
|
||||
comment?: string | null;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' }
|
||||
return { success: false, error: "Non authentifié" };
|
||||
}
|
||||
|
||||
const feedback = await eventFeedbackService.validateAndCreateFeedback(
|
||||
session.user.id,
|
||||
eventId,
|
||||
{ rating: data.rating, comment: data.comment }
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath(`/feedback/${eventId}`)
|
||||
revalidatePath('/events')
|
||||
revalidatePath(`/feedback/${eventId}`);
|
||||
revalidatePath("/events");
|
||||
|
||||
return { success: true, data: feedback }
|
||||
return { success: true, data: feedback };
|
||||
} catch (error) {
|
||||
console.error('Error saving feedback:', error)
|
||||
console.error("Error saving feedback:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Erreur lors de l\'enregistrement du feedback' }
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de l'enregistrement du feedback",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +1,77 @@
|
||||
'use server'
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { eventRegistrationService } from '@/services/events/event-registration.service'
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { eventRegistrationService } from "@/services/events/event-registration.service";
|
||||
import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} from '@/services/errors'
|
||||
} from "@/services/errors";
|
||||
|
||||
export async function registerForEvent(eventId: string) {
|
||||
try {
|
||||
const session = await auth()
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Vous devez être connecté pour vous inscrire' }
|
||||
return {
|
||||
success: false,
|
||||
error: "Vous devez être connecté pour vous inscrire",
|
||||
};
|
||||
}
|
||||
|
||||
const registration = await eventRegistrationService.validateAndRegisterUser(
|
||||
session.user.id,
|
||||
eventId
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath('/events')
|
||||
revalidatePath('/')
|
||||
revalidatePath("/events");
|
||||
revalidatePath("/");
|
||||
|
||||
return { success: true, message: 'Inscription réussie', data: registration }
|
||||
return {
|
||||
success: true,
|
||||
message: "Inscription réussie",
|
||||
data: registration,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
console.error("Registration error:", error);
|
||||
|
||||
if (error instanceof ValidationError || error instanceof ConflictError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Une erreur est survenue lors de l\'inscription' }
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de l'inscription",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterFromEvent(eventId: string) {
|
||||
try {
|
||||
const session = await auth()
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Vous devez être connecté' }
|
||||
return { success: false, error: "Vous devez être connecté" };
|
||||
}
|
||||
|
||||
await eventRegistrationService.unregisterUserFromEvent(
|
||||
session.user.id,
|
||||
eventId
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath('/events')
|
||||
revalidatePath('/')
|
||||
revalidatePath("/events");
|
||||
revalidatePath("/");
|
||||
|
||||
return { success: true, message: 'Inscription annulée' }
|
||||
return { success: true, message: "Inscription annulée" };
|
||||
} catch (error) {
|
||||
console.error('Unregistration error:', error)
|
||||
return { success: false, error: 'Une erreur est survenue lors de l\'annulation' }
|
||||
console.error("Unregistration error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors de l'annulation",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
actions/houses/create.ts
Normal file
48
actions/houses/create.ts
Normal file
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
173
actions/houses/invitations.ts
Normal file
173
actions/houses/invitations.ts
Normal file
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
163
actions/houses/requests.ts
Normal file
163
actions/houses/requests.ts
Normal file
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
149
actions/houses/update.ts
Normal file
149
actions/houses/update.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
"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 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeMember(houseId: string, memberId: string) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Vous devez être connecté",
|
||||
};
|
||||
}
|
||||
|
||||
await houseService.removeMember(houseId, memberId, session.user.id);
|
||||
|
||||
revalidatePath("/houses");
|
||||
revalidatePath("/profile");
|
||||
|
||||
return { success: true, message: "Membre retiré de la maison" };
|
||||
} catch (error) {
|
||||
console.error("Remove member error:", error);
|
||||
|
||||
if (
|
||||
error instanceof ForbiddenError ||
|
||||
error instanceof NotFoundError
|
||||
) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "Une erreur est survenue lors du retrait du membre",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
'use server'
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { userService } from '@/services/users/user.service'
|
||||
import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
} from '@/services/errors'
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import { ValidationError, NotFoundError } from "@/services/errors";
|
||||
|
||||
export async function updatePassword(data: {
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}) {
|
||||
try {
|
||||
const session = await auth()
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return { success: false, error: 'Non authentifié' }
|
||||
return { success: false, error: "Non authentifié" };
|
||||
}
|
||||
|
||||
await userService.validateAndUpdatePassword(
|
||||
@@ -25,22 +22,24 @@ export async function updatePassword(data: {
|
||||
data.currentPassword,
|
||||
data.newPassword,
|
||||
data.confirmPassword
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath('/profile')
|
||||
revalidatePath("/profile");
|
||||
|
||||
return { success: true, message: 'Mot de passe modifié avec succès' }
|
||||
return { success: true, message: "Mot de passe modifié avec succès" };
|
||||
} catch (error) {
|
||||
console.error('Error updating password:', error)
|
||||
console.error("Error updating password:", error);
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Erreur lors de la modification du mot de passe' }
|
||||
return {
|
||||
success: false,
|
||||
error: "Erreur lors de la modification du mot de passe",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
'use server'
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { userService } from '@/services/users/user.service'
|
||||
import { CharacterClass } from '@/prisma/generated/prisma/client'
|
||||
import {
|
||||
ValidationError,
|
||||
ConflictError,
|
||||
} from '@/services/errors'
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import { CharacterClass } from "@/prisma/generated/prisma/client";
|
||||
import { ValidationError, ConflictError } from "@/services/errors";
|
||||
|
||||
export async function updateProfile(data: {
|
||||
username?: string
|
||||
avatar?: string | null
|
||||
bio?: string | null
|
||||
characterClass?: string | null
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
bio?: string | null;
|
||||
characterClass?: string | null;
|
||||
}) {
|
||||
try {
|
||||
const session = await auth()
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return { success: false, error: 'Non authentifié' }
|
||||
return { success: false, error: "Non authentifié" };
|
||||
}
|
||||
|
||||
const updatedUser = await userService.validateAndUpdateUserProfile(
|
||||
@@ -28,7 +25,9 @@ export async function updateProfile(data: {
|
||||
username: data.username,
|
||||
avatar: data.avatar,
|
||||
bio: data.bio,
|
||||
characterClass: data.characterClass ? (data.characterClass as CharacterClass) : null,
|
||||
characterClass: data.characterClass
|
||||
? (data.characterClass as CharacterClass)
|
||||
: null,
|
||||
},
|
||||
{
|
||||
id: true,
|
||||
@@ -44,20 +43,19 @@ export async function updateProfile(data: {
|
||||
level: true,
|
||||
score: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath('/profile')
|
||||
revalidatePath('/')
|
||||
revalidatePath("/profile");
|
||||
revalidatePath("/");
|
||||
|
||||
return { success: true, data: updatedUser }
|
||||
return { success: true, data: updatedUser };
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error)
|
||||
console.error("Error updating profile:", error);
|
||||
|
||||
if (error instanceof ValidationError || error instanceof ConflictError) {
|
||||
return { success: false, error: error.message }
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Erreur lors de la mise à jour du profil' }
|
||||
return { success: false, error: "Erreur lors de la mise à jour du profil" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
app/admin/challenges/page.tsx
Normal file
26
app/admin/challenges/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import ChallengeManagement from "@/components/admin/ChallengeManagement";
|
||||
import { Card } from "@/components/ui";
|
||||
import { challengeService } from "@/services/challenges/challenge.service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminChallengesPage() {
|
||||
const challenges = await challengeService.getAllChallenges();
|
||||
|
||||
// Sérialiser les dates pour le client
|
||||
const serializedChallenges = challenges.map((challenge) => ({
|
||||
...challenge,
|
||||
createdAt: challenge.createdAt.toISOString(),
|
||||
acceptedAt: challenge.acceptedAt?.toISOString() ?? null,
|
||||
completedAt: challenge.completedAt?.toISOString() ?? null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Défis
|
||||
</h2>
|
||||
<ChallengeManagement initialChallenges={serializedChallenges} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
34
app/admin/events/page.tsx
Normal file
34
app/admin/events/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import EventManagement from "@/components/admin/EventManagement";
|
||||
import { Card } from "@/components/ui";
|
||||
import { eventService } from "@/services/events/event.service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminEventsPage() {
|
||||
const events = await eventService.getEventsWithStatus();
|
||||
|
||||
// Transformer les données pour la sérialisation
|
||||
const serializedEvents = events.map((event) => ({
|
||||
id: event.id,
|
||||
date: event.date.toISOString(),
|
||||
name: event.name,
|
||||
description: event.description,
|
||||
type: event.type,
|
||||
status: event.status,
|
||||
room: event.room,
|
||||
time: event.time,
|
||||
maxPlaces: event.maxPlaces,
|
||||
createdAt: event.createdAt.toISOString(),
|
||||
updatedAt: event.updatedAt.toISOString(),
|
||||
registrationsCount: event.registrationsCount,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Événements
|
||||
</h2>
|
||||
<EventManagement initialEvents={serializedEvents} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
77
app/admin/feedbacks/page.tsx
Normal file
77
app/admin/feedbacks/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import FeedbackManagement from "@/components/admin/FeedbackManagement";
|
||||
import { Card } from "@/components/ui";
|
||||
import { eventFeedbackService } from "@/services/events/event-feedback.service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminFeedbacksPage() {
|
||||
const [feedbacksRaw, statistics] = await Promise.all([
|
||||
eventFeedbackService.getAllFeedbacks(),
|
||||
eventFeedbackService.getFeedbackStatistics(),
|
||||
]);
|
||||
|
||||
// Type assertion car getAllFeedbacks inclut event et user par défaut
|
||||
const feedbacks = feedbacksRaw as unknown as Array<{
|
||||
id: string;
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: Date;
|
||||
event: {
|
||||
id: string;
|
||||
name: string;
|
||||
date: Date;
|
||||
type: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
score: number;
|
||||
};
|
||||
}>;
|
||||
|
||||
// Sérialiser les dates pour le client
|
||||
const serializedFeedbacks = feedbacks.map((feedback) => ({
|
||||
id: feedback.id,
|
||||
rating: feedback.rating,
|
||||
comment: feedback.comment,
|
||||
isRead: feedback.isRead,
|
||||
createdAt: feedback.createdAt.toISOString(),
|
||||
event: {
|
||||
id: feedback.event.id,
|
||||
name: feedback.event.name,
|
||||
date: feedback.event.date.toISOString(),
|
||||
type: feedback.event.type,
|
||||
},
|
||||
user: {
|
||||
id: feedback.user.id,
|
||||
username: feedback.user.username,
|
||||
email: feedback.user.email,
|
||||
avatar: feedback.user.avatar,
|
||||
score: feedback.user.score,
|
||||
},
|
||||
}));
|
||||
|
||||
const serializedStatistics = statistics.map((stat) => ({
|
||||
eventId: stat.eventId,
|
||||
eventName: stat.eventName,
|
||||
eventDate: stat.eventDate?.toISOString() ?? null,
|
||||
eventType: stat.eventType,
|
||||
averageRating: stat.averageRating,
|
||||
feedbackCount: stat.feedbackCount,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Feedbacks
|
||||
</h2>
|
||||
<FeedbackManagement
|
||||
initialFeedbacks={serializedFeedbacks}
|
||||
initialStatistics={serializedStatistics}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
90
app/admin/houses/page.tsx
Normal file
90
app/admin/houses/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import HouseManagement from "@/components/admin/HouseManagement";
|
||||
import { Card } from "@/components/ui";
|
||||
import { houseService } from "@/services/houses/house.service";
|
||||
import { Prisma } from "@/prisma/generated/prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminHousesPage() {
|
||||
type HouseWithIncludes = Prisma.HouseGetPayload<{
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true;
|
||||
username: true;
|
||||
avatar: true;
|
||||
};
|
||||
};
|
||||
memberships: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true;
|
||||
username: true;
|
||||
avatar: true;
|
||||
score: true;
|
||||
level: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const houses = (await houseService.getAllHouses({
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
memberships: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
score: true,
|
||||
level: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ role: "asc" }, { joinedAt: "asc" }],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
})) as unknown as HouseWithIncludes[];
|
||||
|
||||
// Transformer les données pour la sérialisation
|
||||
const serializedHouses = houses.map((house) => ({
|
||||
id: house.id,
|
||||
name: house.name,
|
||||
description: house.description,
|
||||
creatorId: house.creatorId,
|
||||
creator: house.creator,
|
||||
createdAt: house.createdAt.toISOString(),
|
||||
updatedAt: house.updatedAt.toISOString(),
|
||||
membersCount: house.memberships?.length || 0,
|
||||
memberships:
|
||||
house.memberships?.map((membership) => ({
|
||||
id: membership.id,
|
||||
role: membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
user: membership.user,
|
||||
})) || [],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Maisons
|
||||
</h2>
|
||||
<HouseManagement initialHouses={serializedHouses} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
52
app/admin/layout.tsx
Normal file
52
app/admin/layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
|
||||
import AdminNavigation from "@/components/admin/AdminNavigation";
|
||||
import { SectionTitle } from "@/components/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.user.role !== Role.ADMIN) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
{/* Background Image */}
|
||||
<div
|
||||
className="fixed inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url('/got-light.jpg')`,
|
||||
}}
|
||||
>
|
||||
{/* Dark overlay for readability */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
|
||||
</div>
|
||||
<NavigationWrapper />
|
||||
<section className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16">
|
||||
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16">
|
||||
<SectionTitle variant="gradient" size="md" className="mb-16 text-center">
|
||||
ADMIN
|
||||
</SectionTitle>
|
||||
|
||||
<AdminNavigation />
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
|
||||
import AdminPanel from "@/components/admin/AdminPanel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (session.user.role !== Role.ADMIN) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
// Récupérer les préférences globales du site (ou créer si elles n'existent pas)
|
||||
const sitePreferences =
|
||||
await sitePreferencesService.getOrCreateSitePreferences();
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
{/* Background Image */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url('/got-light.jpg')`,
|
||||
}}
|
||||
>
|
||||
{/* Dark overlay for readability */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/60 to-black/80"></div>
|
||||
</div>
|
||||
<NavigationWrapper />
|
||||
<AdminPanel initialPreferences={sitePreferences} />
|
||||
</main>
|
||||
);
|
||||
redirect("/admin/preferences");
|
||||
}
|
||||
|
||||
30
app/admin/preferences/page.tsx
Normal file
30
app/admin/preferences/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { sitePreferencesService } from "@/services/preferences/site-preferences.service";
|
||||
import BackgroundPreferences from "@/components/admin/BackgroundPreferences";
|
||||
import EventPointsPreferences from "@/components/admin/EventPointsPreferences";
|
||||
import EventFeedbackPointsPreferences from "@/components/admin/EventFeedbackPointsPreferences";
|
||||
import HousePointsPreferences from "@/components/admin/HousePointsPreferences";
|
||||
import { Card } from "@/components/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminPreferencesPage() {
|
||||
const sitePreferences =
|
||||
await sitePreferencesService.getOrCreateSitePreferences();
|
||||
|
||||
return (
|
||||
<Card variant="dark" className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<h2 className="text-xl sm:text-2xl font-gaming font-bold text-pixel-gold break-words">
|
||||
Préférences UI Globales
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<BackgroundPreferences initialPreferences={sitePreferences} />
|
||||
<EventPointsPreferences initialPreferences={sitePreferences} />
|
||||
<EventFeedbackPointsPreferences initialPreferences={sitePreferences} />
|
||||
<HousePointsPreferences initialPreferences={sitePreferences} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
42
app/admin/users/page.tsx
Normal file
42
app/admin/users/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import UserManagement from "@/components/admin/UserManagement";
|
||||
import { Card } from "@/components/ui";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const users = await userService.getAllUsers({
|
||||
orderBy: {
|
||||
score: "desc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
score: true,
|
||||
level: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
avatar: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Sérialiser les dates pour le client
|
||||
const serializedUsers = users.map((user) => ({
|
||||
...user,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Utilisateurs
|
||||
</h2>
|
||||
<UserManagement initialUsers={serializedUsers} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
25
app/api/admin/challenges/route.ts
Normal file
25
app/api/admin/challenges/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { challengeService } from "@/services/challenges/challenge.service";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || session.user.role !== Role.ADMIN) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Récupérer tous les défis pour l'admin (PENDING, ACCEPTED, CANCELLED, COMPLETED, REJECTED)
|
||||
const challenges = await challengeService.getAllChallenges();
|
||||
|
||||
return NextResponse.json(challenges);
|
||||
} catch (error) {
|
||||
console.error("Error fetching challenges:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la récupération des défis" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
app/api/admin/events/[id]/registrations/route.ts
Normal file
31
app/api/admin/events/[id]/registrations/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { eventRegistrationService } from "@/services/events/event-registration.service";
|
||||
import { Role } from "@/prisma/generated/prisma/client";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || session.user.role !== Role.ADMIN) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id: eventId } = await params;
|
||||
const registrations = await eventRegistrationService.getEventRegistrations(
|
||||
eventId
|
||||
);
|
||||
|
||||
return NextResponse.json(registrations);
|
||||
} catch (error) {
|
||||
console.error("Error fetching event registrations:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la récupération des inscrits" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,3 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
99
app/api/admin/houses/route.ts
Normal file
99
app/api/admin/houses/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { houseService } from "@/services/houses/house.service";
|
||||
import { Role, Prisma } from "@/prisma/generated/prisma/client";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || session.user.role !== Role.ADMIN) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Récupérer toutes les maisons avec leurs membres
|
||||
type HouseWithIncludes = Prisma.HouseGetPayload<{
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true;
|
||||
username: true;
|
||||
avatar: true;
|
||||
};
|
||||
};
|
||||
memberships: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true;
|
||||
username: true;
|
||||
avatar: true;
|
||||
score: true;
|
||||
level: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const houses = (await houseService.getAllHouses({
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
},
|
||||
memberships: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
score: true,
|
||||
level: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ role: "asc" }, // OWNER, ADMIN, MEMBER
|
||||
{ joinedAt: "asc" },
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
})) as unknown as HouseWithIncludes[];
|
||||
|
||||
// Transformer les données pour la sérialisation
|
||||
const housesWithData = houses.map((house) => ({
|
||||
id: house.id,
|
||||
name: house.name,
|
||||
description: house.description,
|
||||
creatorId: house.creatorId,
|
||||
creator: house.creator,
|
||||
createdAt: house.createdAt.toISOString(),
|
||||
updatedAt: house.updatedAt.toISOString(),
|
||||
membersCount: house.memberships?.length || 0,
|
||||
memberships:
|
||||
house.memberships?.map((membership) => ({
|
||||
id: membership.id,
|
||||
role: membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
user: membership.user,
|
||||
})) || [],
|
||||
}));
|
||||
|
||||
return NextResponse.json(housesWithData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching houses:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la récupération des maisons" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ export async function GET() {
|
||||
}
|
||||
|
||||
// Récupérer les préférences globales du site (ou créer si elles n'existent pas)
|
||||
const sitePreferences = await sitePreferencesService.getOrCreateSitePreferences();
|
||||
const sitePreferences =
|
||||
await sitePreferencesService.getOrCreateSitePreferences();
|
||||
|
||||
return NextResponse.json(sitePreferences);
|
||||
} catch (error) {
|
||||
|
||||
23
app/api/challenges/active-count/route.ts
Normal file
23
app/api/challenges/active-count/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { challengeService } from "@/services/challenges/challenge.service";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ count: 0 });
|
||||
}
|
||||
|
||||
const count = await challengeService.getActiveChallengesCount(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
return NextResponse.json({ count });
|
||||
} catch (error) {
|
||||
console.error("Error fetching active challenges count:", error);
|
||||
return NextResponse.json({ count: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
31
app/api/challenges/route.ts
Normal file
31
app/api/challenges/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { challengeService } from "@/services/challenges/challenge.service";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Vous devez être connecté" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer tous les défis de l'utilisateur
|
||||
const challenges = await challengeService.getUserChallenges(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
return NextResponse.json(challenges);
|
||||
} catch (error) {
|
||||
console.error("Error fetching challenges:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la récupération des défis" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { eventRegistrationService } from "@/services/events/event-registration.service";
|
||||
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { eventFeedbackService } from "@/services/events/event-feedback.service";
|
||||
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ eventId: string }> }
|
||||
|
||||
51
app/api/houses/[houseId]/invitations/route.ts
Normal file
51
app/api/houses/[houseId]/invitations/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
48
app/api/houses/[houseId]/requests/route.ts
Normal file
48
app/api/houses/[houseId]/requests/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
59
app/api/houses/[houseId]/route.ts
Normal file
59
app/api/houses/[houseId]/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
48
app/api/houses/my-house/route.ts
Normal file
48
app/api/houses/my-house/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
87
app/api/houses/route.ts
Normal file
87
app/api/houses/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
23
app/api/invitations/pending-count/route.ts
Normal file
23
app/api/invitations/pending-count/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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({ count: 0 });
|
||||
}
|
||||
|
||||
// Compter les invitations ET les demandes d'adhésion en attente
|
||||
const count = await houseService.getPendingHouseActionsCount(
|
||||
session.user.id
|
||||
);
|
||||
|
||||
return NextResponse.json({ count });
|
||||
} catch (error) {
|
||||
console.error("Error fetching pending house actions count:", error);
|
||||
return NextResponse.json({ count: 0 });
|
||||
}
|
||||
}
|
||||
36
app/api/invitations/route.ts
Normal file
36
app/api/invitations/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
17
app/api/leaderboard/houses/route.ts
Normal file
17
app/api/leaderboard/houses/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ export async function GET() {
|
||||
homeBackground: null,
|
||||
eventsBackground: null,
|
||||
leaderboardBackground: null,
|
||||
challengesBackground: null,
|
||||
profileBackground: null,
|
||||
houseBackground: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +22,9 @@ export async function GET() {
|
||||
homeBackground: sitePreferences.homeBackground,
|
||||
eventsBackground: sitePreferences.eventsBackground,
|
||||
leaderboardBackground: sitePreferences.leaderboardBackground,
|
||||
challengesBackground: sitePreferences.challengesBackground,
|
||||
profileBackground: sitePreferences.profileBackground,
|
||||
houseBackground: sitePreferences.houseBackground,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching preferences:", error);
|
||||
@@ -27,6 +33,9 @@ export async function GET() {
|
||||
homeBackground: null,
|
||||
eventsBackground: null,
|
||||
leaderboardBackground: null,
|
||||
challengesBackground: null,
|
||||
profileBackground: null,
|
||||
houseBackground: null,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
|
||||
@@ -42,4 +42,3 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,7 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
console.error("Error completing registration:", error);
|
||||
|
||||
if (
|
||||
error instanceof ValidationError ||
|
||||
error instanceof ConflictError
|
||||
) {
|
||||
if (error instanceof ValidationError || error instanceof ConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
|
||||
43
app/api/users/route.ts
Normal file
43
app/api/users/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Vous devez être connecté" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer tous les utilisateurs (pour sélectionner qui défier)
|
||||
const users = await userService.getAllUsers({
|
||||
orderBy: {
|
||||
username: "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
score: true,
|
||||
level: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Filtrer l'utilisateur actuel
|
||||
const otherUsers = users.filter((user) => user.id !== session.user.id);
|
||||
|
||||
return NextResponse.json(otherUsers);
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la récupération des utilisateurs" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
app/challenges/page.tsx
Normal file
55
app/challenges/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getBackgroundImage } from "@/lib/preferences";
|
||||
import NavigationWrapper from "@/components/navigation/NavigationWrapper";
|
||||
import ChallengesSection from "@/components/challenges/ChallengesSection";
|
||||
import { challengeService } from "@/services/challenges/challenge.service";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ChallengesPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const [challengesRaw, users, backgroundImage] = await Promise.all([
|
||||
challengeService.getUserChallenges(session.user.id),
|
||||
userService
|
||||
.getAllUsers({
|
||||
orderBy: {
|
||||
username: "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
score: true,
|
||||
level: true,
|
||||
},
|
||||
})
|
||||
.then((users) => users.filter((user) => user.id !== session.user.id)),
|
||||
getBackgroundImage("challenges", "/got-2.jpg"),
|
||||
]);
|
||||
|
||||
// Convertir les dates Date en string pour correspondre au type attendu par le composant
|
||||
const challenges = challengesRaw.map((challenge) => ({
|
||||
...challenge,
|
||||
createdAt: challenge.createdAt.toISOString(),
|
||||
acceptedAt: challenge.acceptedAt?.toISOString() ?? null,
|
||||
completedAt: challenge.completedAt?.toISOString() ?? null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
<NavigationWrapper />
|
||||
<ChallengesSection
|
||||
initialChallenges={challenges}
|
||||
initialUsers={users}
|
||||
backgroundImage={backgroundImage}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,18 @@ import { auth } from "@/lib/auth";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EventsPage() {
|
||||
const events = await eventService.getAllEvents({
|
||||
orderBy: { date: "desc" },
|
||||
});
|
||||
// Paralléliser les appels indépendants
|
||||
const session = await auth();
|
||||
|
||||
const [events, backgroundImage, allRegistrations] = await Promise.all([
|
||||
eventService.getAllEvents({
|
||||
orderBy: { date: "desc" },
|
||||
}),
|
||||
getBackgroundImage("events", "/got-2.jpg"),
|
||||
session?.user?.id
|
||||
? eventRegistrationService.getUserRegistrations(session.user.id)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
// Sérialiser les dates pour le client
|
||||
const serializedEvents = events.map((event) => ({
|
||||
@@ -20,21 +29,11 @@ export default async function EventsPage() {
|
||||
updatedAt: event.updatedAt.toISOString(),
|
||||
}));
|
||||
|
||||
const backgroundImage = await getBackgroundImage("events", "/got-2.jpg");
|
||||
|
||||
// Récupérer les inscriptions côté serveur pour éviter le clignotement
|
||||
const session = await auth();
|
||||
// Construire le map des inscriptions
|
||||
const initialRegistrations: Record<string, boolean> = {};
|
||||
|
||||
if (session?.user?.id) {
|
||||
// Récupérer toutes les inscriptions (passées et à venir) pour permettre le feedback
|
||||
const allRegistrations =
|
||||
await eventRegistrationService.getUserRegistrations(session.user.id);
|
||||
|
||||
allRegistrations.forEach((reg) => {
|
||||
initialRegistrations[reg.eventId] = true;
|
||||
});
|
||||
}
|
||||
allRegistrations.forEach((reg) => {
|
||||
initialRegistrations[reg.eventId] = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
|
||||
@@ -128,6 +128,9 @@ export default function FeedbackPageClient({
|
||||
});
|
||||
}
|
||||
|
||||
// Rafraîchir le score dans le header
|
||||
window.dispatchEvent(new Event("refreshUserScore"));
|
||||
|
||||
// Rediriger après 2 secondes
|
||||
setTimeout(() => {
|
||||
router.push("/events");
|
||||
|
||||
207
app/houses/page.tsx
Normal file
207
app/houses/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
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 { prisma } from "@/services/database";
|
||||
import type {
|
||||
House,
|
||||
HouseMembership,
|
||||
HouseInvitation,
|
||||
} from "@/prisma/generated/prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Types pour les données sérialisées
|
||||
type HouseWithRelations = House & {
|
||||
creator?: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
} | null;
|
||||
creatorId?: string;
|
||||
memberships?: Array<
|
||||
HouseMembership & {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
score: number | null;
|
||||
level: number | null;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
type InvitationWithRelations = HouseInvitation & {
|
||||
house: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
inviter: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
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 sans maison pour les invitations
|
||||
prisma.user.findMany({
|
||||
where: {
|
||||
houseMemberships: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
},
|
||||
orderBy: {
|
||||
username: "asc",
|
||||
},
|
||||
}),
|
||||
getBackgroundImage("houses", "/got-2.jpg"),
|
||||
]);
|
||||
|
||||
// Sérialiser les données pour le client
|
||||
const houses = (housesData as HouseWithRelations[]).map(
|
||||
(house: HouseWithRelations) => ({
|
||||
id: house.id,
|
||||
name: house.name,
|
||||
description: house.description,
|
||||
creator: house.creator || {
|
||||
id: house.creatorId || "",
|
||||
username: "Unknown",
|
||||
avatar: null,
|
||||
},
|
||||
memberships: (house.memberships || []).map((m) => ({
|
||||
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 HouseWithRelations).creator || {
|
||||
id: (myHouseData as HouseWithRelations).creatorId || "",
|
||||
username: "Unknown",
|
||||
avatar: null,
|
||||
},
|
||||
memberships: (
|
||||
(myHouseData as HouseWithRelations).memberships || []
|
||||
).map((m) => ({
|
||||
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 as InvitationWithRelations[]).map(
|
||||
(inv: InvitationWithRelations) => ({
|
||||
id: inv.id,
|
||||
house: {
|
||||
id: inv.house.id,
|
||||
name: inv.house.name,
|
||||
},
|
||||
inviter: inv.inviter,
|
||||
status: inv.status,
|
||||
createdAt: inv.createdAt.toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
<NavigationWrapper />
|
||||
<HousesSection
|
||||
initialHouses={houses}
|
||||
initialMyHouse={myHouse}
|
||||
initialUsers={users}
|
||||
initialInvitations={invitations}
|
||||
backgroundImage={backgroundImage}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -6,18 +6,19 @@ import { getBackgroundImage } from "@/lib/preferences";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LeaderboardPage() {
|
||||
const leaderboard = await userStatsService.getLeaderboard(10);
|
||||
|
||||
const backgroundImage = await getBackgroundImage(
|
||||
"leaderboard",
|
||||
"/leaderboard-bg.jpg"
|
||||
);
|
||||
// Paralléliser les appels DB
|
||||
const [leaderboard, houseLeaderboard, backgroundImage] = await Promise.all([
|
||||
userStatsService.getLeaderboard(10),
|
||||
userStatsService.getHouseLeaderboard(10),
|
||||
getBackgroundImage("leaderboard", "/leaderboard-bg.jpg"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
<NavigationWrapper />
|
||||
<LeaderboardSection
|
||||
leaderboard={leaderboard}
|
||||
houseLeaderboard={houseLeaderboard}
|
||||
backgroundImage={backgroundImage}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function LoginPage() {
|
||||
<Card variant="dark" className="p-8">
|
||||
<SectionTitle
|
||||
variant="gradient"
|
||||
size="lg"
|
||||
size="md"
|
||||
className="mb-2 text-center"
|
||||
>
|
||||
CONNEXION
|
||||
|
||||
11
app/page.tsx
11
app/page.tsx
@@ -7,7 +7,11 @@ import { getBackgroundImage } from "@/lib/preferences";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const events = await eventService.getUpcomingEvents(3);
|
||||
// Paralléliser les appels DB
|
||||
const [events, backgroundImage] = await Promise.all([
|
||||
eventService.getUpcomingEvents(3),
|
||||
getBackgroundImage("home", "/got-2.jpg"),
|
||||
]);
|
||||
|
||||
// Convert Date objects to strings for serialization
|
||||
const serializedEvents = events.map((event) => ({
|
||||
@@ -15,11 +19,8 @@ export default async function Home() {
|
||||
date: event.date.toISOString(),
|
||||
}));
|
||||
|
||||
// Récupérer l'image de fond côté serveur
|
||||
const backgroundImage = await getBackgroundImage("home", "/got-2.jpg");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black relative">
|
||||
<main className="min-h-screen relative" style={{ backgroundColor: "var(--background)" }}>
|
||||
<NavigationWrapper />
|
||||
<HeroSection backgroundImage={backgroundImage} />
|
||||
<EventsSection events={serializedEvents} />
|
||||
|
||||
@@ -12,31 +12,30 @@ export default async function ProfilePage() {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const user = await userService.getUserById(session.user.id, {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
bio: true,
|
||||
characterClass: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
score: true,
|
||||
createdAt: true,
|
||||
});
|
||||
// Paralléliser les appels DB
|
||||
const [user, backgroundImage] = await Promise.all([
|
||||
userService.getUserById(session.user.id, {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
bio: true,
|
||||
characterClass: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
score: true,
|
||||
createdAt: true,
|
||||
}),
|
||||
getBackgroundImage("profile", "/got-background.jpg"),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const backgroundImage = await getBackgroundImage(
|
||||
"home",
|
||||
"/got-background.jpg"
|
||||
);
|
||||
|
||||
// Convert Date to string for the component
|
||||
const userProfile = {
|
||||
...user,
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function RegisterPage() {
|
||||
<Navigation />
|
||||
<BackgroundSection backgroundImage="/got-2.jpg" className="pt-24">
|
||||
{/* Register Form */}
|
||||
<div className="w-full max-w-md mx-auto px-8">
|
||||
<div className="w-full max-w-4xl mx-auto px-8">
|
||||
<Card variant="dark" className="p-8">
|
||||
<SectionTitle
|
||||
variant="gradient"
|
||||
@@ -397,7 +397,7 @@ export default function RegisterPage() {
|
||||
<label className="block text-sm font-semibold text-gray-300 mb-3 uppercase tracking-wider">
|
||||
Classe de Personnage (optionnel)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{CHARACTER_CLASSES.map((cls) => (
|
||||
<button
|
||||
key={cls.value}
|
||||
@@ -411,16 +411,16 @@ export default function RegisterPage() {
|
||||
: cls.value,
|
||||
})
|
||||
}
|
||||
className={`p-3 border-2 rounded-lg text-left transition-all ${
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all ${
|
||||
formData.characterClass === cls.value
|
||||
? "border-pixel-gold bg-pixel-gold/20"
|
||||
: "border-pixel-gold/30 bg-black/40 hover:border-pixel-gold/50"
|
||||
? "border-pixel-gold bg-pixel-gold/20 shadow-lg shadow-pixel-gold/30"
|
||||
: "border-pixel-gold/30 bg-black/40 hover:border-pixel-gold/50 hover:bg-black/60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{cls.icon}</span>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-2xl">{cls.icon}</span>
|
||||
<span
|
||||
className={`font-bold text-xs uppercase tracking-wider ${
|
||||
className={`font-bold text-sm uppercase tracking-wider ${
|
||||
formData.characterClass === cls.value
|
||||
? "text-pixel-gold"
|
||||
: "text-white"
|
||||
@@ -429,6 +429,9 @@ export default function RegisterPage() {
|
||||
{cls.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 leading-tight">
|
||||
{cls.desc}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
Card,
|
||||
Badge,
|
||||
Alert,
|
||||
@@ -22,6 +23,7 @@ export default function StyleGuidePage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [textareaValue, setTextareaValue] = useState("");
|
||||
const [selectValue, setSelectValue] = useState("");
|
||||
const [rating, setRating] = useState(0);
|
||||
|
||||
return (
|
||||
@@ -29,7 +31,7 @@ export default function StyleGuidePage() {
|
||||
<Navigation />
|
||||
<BackgroundSection backgroundImage="/got-2.jpg" className="pt-24 pb-16">
|
||||
<div className="w-full max-w-6xl mx-auto px-8">
|
||||
<SectionTitle variant="gradient" size="xl" className="mb-12">
|
||||
<SectionTitle variant="gradient" size="xl" className="mb-16">
|
||||
STYLE GUIDE
|
||||
</SectionTitle>
|
||||
<p className="text-gray-400 text-center mb-12 max-w-3xl mx-auto">
|
||||
@@ -39,9 +41,7 @@ export default function StyleGuidePage() {
|
||||
|
||||
{/* Buttons */}
|
||||
<Card variant="dark" className="p-6 mb-8">
|
||||
<h2 className="text-2xl font-bold text-pixel-gold mb-6">
|
||||
Buttons
|
||||
</h2>
|
||||
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Buttons</h2>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg text-gray-300 mb-3">Variantes</h3>
|
||||
@@ -103,15 +103,8 @@ export default function StyleGuidePage() {
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<Input
|
||||
label="Number Input"
|
||||
type="number"
|
||||
placeholder="123"
|
||||
/>
|
||||
<Input
|
||||
label="Date Input"
|
||||
type="date"
|
||||
/>
|
||||
<Input label="Number Input" type="number" placeholder="123" />
|
||||
<Input label="Date Input" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -179,6 +172,74 @@ export default function StyleGuidePage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Select */}
|
||||
<Card variant="dark" className="p-6 mb-8">
|
||||
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Select</h2>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg text-gray-300 mb-3">Basique</h3>
|
||||
<div className="max-w-md">
|
||||
<Select
|
||||
label="Sélectionner une option"
|
||||
value={selectValue}
|
||||
onChange={(e) => setSelectValue(e.target.value)}
|
||||
>
|
||||
<option value="">Choisir...</option>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
<option value="option3">Option 3</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg text-gray-300 mb-3">Sans label</h3>
|
||||
<div className="max-w-md">
|
||||
<Select
|
||||
value={selectValue}
|
||||
onChange={(e) => setSelectValue(e.target.value)}
|
||||
>
|
||||
<option value="">Choisir...</option>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
<option value="option3">Option 3</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg text-gray-300 mb-3">Avec erreur</h3>
|
||||
<div className="max-w-md">
|
||||
<Select
|
||||
label="Select avec erreur"
|
||||
value={selectValue}
|
||||
onChange={(e) => setSelectValue(e.target.value)}
|
||||
error="Veuillez sélectionner une option"
|
||||
>
|
||||
<option value="">Choisir...</option>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
<option value="option3">Option 3</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg text-gray-300 mb-3">Disabled</h3>
|
||||
<div className="max-w-md">
|
||||
<Select
|
||||
label="Select désactivé"
|
||||
value={selectValue}
|
||||
onChange={(e) => setSelectValue(e.target.value)}
|
||||
disabled
|
||||
>
|
||||
<option value="">Choisir...</option>
|
||||
<option value="option1">Option 1</option>
|
||||
<option value="option2">Option 2</option>
|
||||
<option value="option3">Option 3</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Badges */}
|
||||
<Card variant="dark" className="p-6 mb-8">
|
||||
<h2 className="text-2xl font-bold text-pixel-gold mb-6">Badges</h2>
|
||||
@@ -196,6 +257,9 @@ export default function StyleGuidePage() {
|
||||
<div>
|
||||
<h3 className="text-lg text-gray-300 mb-3">Tailles</h3>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Badge variant="default" size="xs">
|
||||
Extra Small
|
||||
</Badge>
|
||||
<Badge variant="default" size="sm">
|
||||
Small
|
||||
</Badge>
|
||||
@@ -320,11 +384,7 @@ export default function StyleGuidePage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg text-gray-300 mb-3">Interactif</h3>
|
||||
<StarRating
|
||||
value={rating}
|
||||
onChange={setRating}
|
||||
showValue
|
||||
/>
|
||||
<StarRating value={rating} onChange={setRating} showValue />
|
||||
<p className="text-gray-400 text-sm mt-2">
|
||||
Note sélectionnée : {rating}/5
|
||||
</p>
|
||||
@@ -356,21 +416,9 @@ export default function StyleGuidePage() {
|
||||
<div>
|
||||
<h3 className="text-lg text-gray-300 mb-3">Tailles</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<Avatar
|
||||
src="/avatar-1.jpg"
|
||||
username="User"
|
||||
size="sm"
|
||||
/>
|
||||
<Avatar
|
||||
src="/avatar-2.jpg"
|
||||
username="User"
|
||||
size="md"
|
||||
/>
|
||||
<Avatar
|
||||
src="/avatar-3.jpg"
|
||||
username="User"
|
||||
size="lg"
|
||||
/>
|
||||
<Avatar src="/avatar-1.jpg" username="User" size="sm" />
|
||||
<Avatar src="/avatar-2.jpg" username="User" size="md" />
|
||||
<Avatar src="/avatar-3.jpg" username="User" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -492,4 +540,3 @@ export default function StyleGuidePage() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
41
components/admin/AdminNavigation.tsx
Normal file
41
components/admin/AdminNavigation.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
const adminSections = [
|
||||
{ id: "preferences", label: "Préférences UI", path: "/admin/preferences" },
|
||||
{ id: "users", label: "Utilisateurs", path: "/admin/users" },
|
||||
{ id: "events", label: "Événements", path: "/admin/events" },
|
||||
{ id: "feedbacks", label: "Feedbacks", path: "/admin/feedbacks" },
|
||||
{ id: "challenges", label: "Défis", path: "/admin/challenges" },
|
||||
{ id: "houses", label: "Maisons", path: "/admin/houses" },
|
||||
];
|
||||
|
||||
export default function AdminNavigation() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 mb-8 justify-center flex-wrap">
|
||||
{adminSections.map((section) => {
|
||||
const isActive = pathname === section.path ||
|
||||
(section.path === "/admin/preferences" && pathname === "/admin");
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={section.id}
|
||||
as={Link}
|
||||
href={section.path}
|
||||
variant={isActive ? "primary" : "secondary"}
|
||||
size="md"
|
||||
className={isActive ? "bg-pixel-gold/10" : ""}
|
||||
>
|
||||
{section.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import UserManagement from "@/components/admin/UserManagement";
|
||||
import EventManagement from "@/components/admin/EventManagement";
|
||||
import FeedbackManagement from "@/components/admin/FeedbackManagement";
|
||||
import BackgroundPreferences from "@/components/admin/BackgroundPreferences";
|
||||
import { Button, Card, SectionTitle } from "@/components/ui";
|
||||
|
||||
interface SitePreferences {
|
||||
id: string;
|
||||
homeBackground: string | null;
|
||||
eventsBackground: string | null;
|
||||
leaderboardBackground: string | null;
|
||||
}
|
||||
|
||||
interface AdminPanelProps {
|
||||
initialPreferences: SitePreferences;
|
||||
}
|
||||
|
||||
type AdminSection = "preferences" | "users" | "events" | "feedbacks";
|
||||
|
||||
export default function AdminPanel({ initialPreferences }: AdminPanelProps) {
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<AdminSection>("preferences");
|
||||
|
||||
return (
|
||||
<section className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16">
|
||||
<div className="relative z-10 w-full max-w-6xl mx-auto px-8 py-16">
|
||||
<SectionTitle variant="gradient" size="md" className="mb-8 text-center">
|
||||
ADMIN
|
||||
</SectionTitle>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-4 mb-8 justify-center flex-wrap">
|
||||
<Button
|
||||
onClick={() => setActiveSection("preferences")}
|
||||
variant={activeSection === "preferences" ? "primary" : "secondary"}
|
||||
size="md"
|
||||
className={
|
||||
activeSection === "preferences" ? "bg-pixel-gold/10" : ""
|
||||
}
|
||||
>
|
||||
Préférences UI
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveSection("users")}
|
||||
variant={activeSection === "users" ? "primary" : "secondary"}
|
||||
size="md"
|
||||
className={activeSection === "users" ? "bg-pixel-gold/10" : ""}
|
||||
>
|
||||
Utilisateurs
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveSection("events")}
|
||||
variant={activeSection === "events" ? "primary" : "secondary"}
|
||||
size="md"
|
||||
className={activeSection === "events" ? "bg-pixel-gold/10" : ""}
|
||||
>
|
||||
Événements
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveSection("feedbacks")}
|
||||
variant={activeSection === "feedbacks" ? "primary" : "secondary"}
|
||||
size="md"
|
||||
className={activeSection === "feedbacks" ? "bg-pixel-gold/10" : ""}
|
||||
>
|
||||
Feedbacks
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeSection === "preferences" && (
|
||||
<Card variant="dark" className="p-4 sm:p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<h2 className="text-xl sm:text-2xl font-gaming font-bold text-pixel-gold break-words">
|
||||
Préférences UI Globales
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<BackgroundPreferences initialPreferences={initialPreferences} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === "users" && (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Utilisateurs
|
||||
</h2>
|
||||
<UserManagement />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === "events" && (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Événements
|
||||
</h2>
|
||||
<EventManagement />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeSection === "feedbacks" && (
|
||||
<Card variant="dark" className="p-6">
|
||||
<h2 className="text-2xl font-gaming font-bold mb-6 text-pixel-gold">
|
||||
Gestion des Feedbacks
|
||||
</h2>
|
||||
<FeedbackManagement />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,10 @@ interface SitePreferences {
|
||||
homeBackground: string | null;
|
||||
eventsBackground: string | null;
|
||||
leaderboardBackground: string | null;
|
||||
challengesBackground: string | null;
|
||||
profileBackground: string | null;
|
||||
houseBackground: string | null;
|
||||
eventRegistrationPoints?: number;
|
||||
}
|
||||
|
||||
interface BackgroundPreferencesProps {
|
||||
@@ -20,6 +24,9 @@ const DEFAULT_IMAGES = {
|
||||
home: "/got-2.jpg",
|
||||
events: "/got-2.jpg",
|
||||
leaderboard: "/leaderboard-bg.jpg",
|
||||
challenges: "/got-2.jpg",
|
||||
profile: "/got-background.jpg",
|
||||
houses: "/got-2.jpg",
|
||||
};
|
||||
|
||||
export default function BackgroundPreferences({
|
||||
@@ -57,6 +64,18 @@ export default function BackgroundPreferences({
|
||||
initialPreferences.leaderboardBackground,
|
||||
DEFAULT_IMAGES.leaderboard
|
||||
),
|
||||
challengesBackground: getFormValue(
|
||||
initialPreferences.challengesBackground,
|
||||
DEFAULT_IMAGES.challenges
|
||||
),
|
||||
profileBackground: getFormValue(
|
||||
initialPreferences.profileBackground,
|
||||
DEFAULT_IMAGES.profile
|
||||
),
|
||||
houseBackground: getFormValue(
|
||||
initialPreferences.houseBackground,
|
||||
DEFAULT_IMAGES.houses
|
||||
),
|
||||
}),
|
||||
[initialPreferences]
|
||||
);
|
||||
@@ -90,6 +109,18 @@ export default function BackgroundPreferences({
|
||||
formData.leaderboardBackground,
|
||||
DEFAULT_IMAGES.leaderboard
|
||||
),
|
||||
challengesBackground: getApiValue(
|
||||
formData.challengesBackground,
|
||||
DEFAULT_IMAGES.challenges
|
||||
),
|
||||
profileBackground: getApiValue(
|
||||
formData.profileBackground,
|
||||
DEFAULT_IMAGES.profile
|
||||
),
|
||||
houseBackground: getApiValue(
|
||||
formData.houseBackground,
|
||||
DEFAULT_IMAGES.houses
|
||||
),
|
||||
};
|
||||
|
||||
const result = await updateSitePreferences(apiData);
|
||||
@@ -110,6 +141,18 @@ export default function BackgroundPreferences({
|
||||
result.data.leaderboardBackground,
|
||||
DEFAULT_IMAGES.leaderboard
|
||||
),
|
||||
challengesBackground: getFormValue(
|
||||
result.data.challengesBackground,
|
||||
DEFAULT_IMAGES.challenges
|
||||
),
|
||||
profileBackground: getFormValue(
|
||||
result.data.profileBackground,
|
||||
DEFAULT_IMAGES.profile
|
||||
),
|
||||
houseBackground: getFormValue(
|
||||
result.data.houseBackground,
|
||||
DEFAULT_IMAGES.houses
|
||||
),
|
||||
});
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
@@ -138,6 +181,18 @@ export default function BackgroundPreferences({
|
||||
preferences.leaderboardBackground,
|
||||
DEFAULT_IMAGES.leaderboard
|
||||
),
|
||||
challengesBackground: getFormValue(
|
||||
preferences.challengesBackground,
|
||||
DEFAULT_IMAGES.challenges
|
||||
),
|
||||
profileBackground: getFormValue(
|
||||
preferences.profileBackground,
|
||||
DEFAULT_IMAGES.profile
|
||||
),
|
||||
houseBackground: getFormValue(
|
||||
preferences.houseBackground,
|
||||
DEFAULT_IMAGES.houses
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -197,6 +252,36 @@ export default function BackgroundPreferences({
|
||||
}
|
||||
label="Background Leaderboard"
|
||||
/>
|
||||
<ImageSelector
|
||||
value={formData.challengesBackground}
|
||||
onChange={(url) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
challengesBackground: url,
|
||||
})
|
||||
}
|
||||
label="Background Challenges"
|
||||
/>
|
||||
<ImageSelector
|
||||
value={formData.profileBackground}
|
||||
onChange={(url) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
profileBackground: url,
|
||||
})
|
||||
}
|
||||
label="Background Profile"
|
||||
/>
|
||||
<ImageSelector
|
||||
value={formData.houseBackground}
|
||||
onChange={(url) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
houseBackground: url,
|
||||
})
|
||||
}
|
||||
label="Background Houses"
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-4">
|
||||
<Button onClick={handleSave} variant="success" size="md">
|
||||
Enregistrer
|
||||
@@ -376,6 +461,174 @@ export default function BackgroundPreferences({
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
|
||||
Challenges:
|
||||
</span>
|
||||
{(() => {
|
||||
const currentImage =
|
||||
preferences?.challengesBackground &&
|
||||
preferences.challengesBackground.trim() !== ""
|
||||
? preferences.challengesBackground
|
||||
: DEFAULT_IMAGES.challenges;
|
||||
const isDefault =
|
||||
!preferences?.challengesBackground ||
|
||||
preferences.challengesBackground.trim() === "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
||||
<div className="relative w-16 h-10 sm:w-20 sm:h-12 rounded border border-pixel-gold/30 overflow-hidden bg-black/60 flex-shrink-0">
|
||||
<img
|
||||
src={currentImage}
|
||||
alt="Challenges background"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const currentSrc = target.src;
|
||||
const fallbackSrc = "/got-2.jpg";
|
||||
if (!currentSrc.includes(fallbackSrc)) {
|
||||
target.src = fallbackSrc;
|
||||
} else {
|
||||
target.style.display = "none";
|
||||
const fallbackDiv =
|
||||
target.nextElementSibling as HTMLElement;
|
||||
if (fallbackDiv) {
|
||||
fallbackDiv.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-gray-500 text-xs hidden">
|
||||
No image
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-xs text-gray-400 truncate min-w-0">
|
||||
{isDefault ? "Par défaut: " : ""}
|
||||
{currentImage}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<span className="text-[10px] text-gray-500 italic">
|
||||
(Image par défaut)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
|
||||
Profile:
|
||||
</span>
|
||||
{(() => {
|
||||
const currentImage =
|
||||
preferences?.profileBackground &&
|
||||
preferences.profileBackground.trim() !== ""
|
||||
? preferences.profileBackground
|
||||
: DEFAULT_IMAGES.profile;
|
||||
const isDefault =
|
||||
!preferences?.profileBackground ||
|
||||
preferences.profileBackground.trim() === "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
||||
<div className="relative w-16 h-10 sm:w-20 sm:h-12 rounded border border-pixel-gold/30 overflow-hidden bg-black/60 flex-shrink-0">
|
||||
<img
|
||||
src={currentImage}
|
||||
alt="Profile background"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const currentSrc = target.src;
|
||||
const fallbackSrc = "/got-background.jpg";
|
||||
if (!currentSrc.includes(fallbackSrc)) {
|
||||
target.src = fallbackSrc;
|
||||
} else {
|
||||
target.style.display = "none";
|
||||
const fallbackDiv =
|
||||
target.nextElementSibling as HTMLElement;
|
||||
if (fallbackDiv) {
|
||||
fallbackDiv.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-gray-500 text-xs hidden">
|
||||
No image
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-xs text-gray-400 truncate min-w-0">
|
||||
{isDefault ? "Par défaut: " : ""}
|
||||
{currentImage}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<span className="text-[10px] text-gray-500 italic">
|
||||
(Image par défaut)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[120px] flex-shrink-0">
|
||||
Houses:
|
||||
</span>
|
||||
{(() => {
|
||||
const currentImage =
|
||||
preferences?.houseBackground &&
|
||||
preferences.houseBackground.trim() !== ""
|
||||
? preferences.houseBackground
|
||||
: DEFAULT_IMAGES.houses;
|
||||
const isDefault =
|
||||
!preferences?.houseBackground ||
|
||||
preferences.houseBackground.trim() === "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
|
||||
<div className="relative w-16 h-10 sm:w-20 sm:h-12 rounded border border-pixel-gold/30 overflow-hidden bg-black/60 flex-shrink-0">
|
||||
<img
|
||||
src={currentImage}
|
||||
alt="Houses background"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const currentSrc = target.src;
|
||||
const fallbackSrc = "/got-2.jpg";
|
||||
if (!currentSrc.includes(fallbackSrc)) {
|
||||
target.src = fallbackSrc;
|
||||
} else {
|
||||
target.style.display = "none";
|
||||
const fallbackDiv =
|
||||
target.nextElementSibling as HTMLElement;
|
||||
if (fallbackDiv) {
|
||||
fallbackDiv.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-gray-500 text-xs hidden">
|
||||
No image
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-xs text-gray-400 truncate min-w-0">
|
||||
{isDefault ? "Par défaut: " : ""}
|
||||
{currentImage}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<span className="text-[10px] text-gray-500 italic">
|
||||
(Image par défaut)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
696
components/admin/ChallengeManagement.tsx
Normal file
696
components/admin/ChallengeManagement.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
validateChallenge,
|
||||
rejectChallenge,
|
||||
updateChallenge,
|
||||
deleteChallenge,
|
||||
adminCancelChallenge,
|
||||
reactivateChallenge,
|
||||
adminAcceptChallenge,
|
||||
} from "@/actions/admin/challenges";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Textarea,
|
||||
Alert,
|
||||
Modal,
|
||||
CloseButton,
|
||||
} from "@/components/ui";
|
||||
import { Avatar } from "@/components/ui";
|
||||
|
||||
interface Challenge {
|
||||
id: string;
|
||||
challenger: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
challenged: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
title: string;
|
||||
description: string;
|
||||
pointsReward: number;
|
||||
status: string;
|
||||
adminComment: string | null;
|
||||
createdAt: string;
|
||||
acceptedAt: string | null;
|
||||
}
|
||||
|
||||
interface ChallengeManagementProps {
|
||||
initialChallenges: Challenge[];
|
||||
}
|
||||
|
||||
export default function ChallengeManagement({ initialChallenges }: ChallengeManagementProps) {
|
||||
const [challenges, setChallenges] = useState<Challenge[]>(initialChallenges);
|
||||
const [selectedChallenge, setSelectedChallenge] = useState<Challenge | null>(
|
||||
null
|
||||
);
|
||||
const [editingChallenge, setEditingChallenge] = useState<Challenge | null>(
|
||||
null
|
||||
);
|
||||
const [winnerId, setWinnerId] = useState<string>("");
|
||||
const [adminComment, setAdminComment] = useState("");
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editPointsReward, setEditPointsReward] = useState<number>(0);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const fetchChallenges = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/challenges");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setChallenges(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching challenges:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!selectedChallenge || !winnerId) {
|
||||
setErrorMessage("Veuillez sélectionner un gagnant");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await validateChallenge(
|
||||
selectedChallenge.id,
|
||||
winnerId,
|
||||
adminComment || undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage(
|
||||
"Défi validé avec succès ! Les points ont été attribués."
|
||||
);
|
||||
setSelectedChallenge(null);
|
||||
setWinnerId("");
|
||||
setAdminComment("");
|
||||
fetchChallenges();
|
||||
// Rafraîchir le badge des défis
|
||||
window.dispatchEvent(new Event("refreshChallenges"));
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de la validation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!selectedChallenge) return;
|
||||
|
||||
if (!confirm("Êtes-vous sûr de vouloir rejeter ce défi ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await rejectChallenge(
|
||||
selectedChallenge.id,
|
||||
adminComment || undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi rejeté");
|
||||
setSelectedChallenge(null);
|
||||
setAdminComment("");
|
||||
fetchChallenges();
|
||||
// Rafraîchir le badge des défis
|
||||
window.dispatchEvent(new Event("refreshChallenges"));
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors du rejet");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (challenge: Challenge) => {
|
||||
setEditingChallenge(challenge);
|
||||
setEditTitle(challenge.title);
|
||||
setEditDescription(challenge.description);
|
||||
setEditPointsReward(challenge.pointsReward);
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editingChallenge) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateChallenge(editingChallenge.id, {
|
||||
title: editTitle,
|
||||
description: editDescription,
|
||||
pointsReward: editPointsReward,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi mis à jour avec succès");
|
||||
setEditingChallenge(null);
|
||||
setEditTitle("");
|
||||
setEditDescription("");
|
||||
setEditPointsReward(0);
|
||||
fetchChallenges();
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de la mise à jour");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (challengeId: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
"Êtes-vous sûr de vouloir supprimer ce défi ? Cette action est irréversible."
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await deleteChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi supprimé avec succès");
|
||||
fetchChallenges();
|
||||
// Rafraîchir le badge des défis
|
||||
window.dispatchEvent(new Event("refreshChallenges"));
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de la suppression");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = async (challengeId: string) => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir annuler ce défi ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await adminCancelChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi annulé avec succès");
|
||||
fetchChallenges();
|
||||
// Rafraîchir le badge des défis
|
||||
window.dispatchEvent(new Event("refreshChallenges"));
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de l'annulation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReactivate = async (challengeId: string) => {
|
||||
if (!confirm("Êtes-vous sûr de vouloir réactiver ce défi ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await reactivateChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi réactivé avec succès");
|
||||
fetchChallenges();
|
||||
// Rafraîchir le badge des défis
|
||||
window.dispatchEvent(new Event("refreshChallenges"));
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de la réactivation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdminAccept = async (challengeId: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
"Êtes-vous sûr de vouloir accepter ce défi à la place de l'utilisateur ?"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await adminAcceptChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi accepté avec succès");
|
||||
fetchChallenges();
|
||||
// Rafraîchir le badge des défis
|
||||
window.dispatchEvent(new Event("refreshChallenges"));
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de l'acceptation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (challenges.length === 0) {
|
||||
return <div className="text-center text-gray-400 py-8">Aucun défi</div>;
|
||||
}
|
||||
|
||||
const acceptedChallenges = challenges.filter((c) => c.status === "ACCEPTED");
|
||||
const pendingChallenges = challenges.filter((c) => c.status === "PENDING");
|
||||
const cancelledChallenges = challenges.filter(
|
||||
(c) => c.status === "CANCELLED"
|
||||
);
|
||||
const completedChallenges = challenges.filter(
|
||||
(c) => c.status === "COMPLETED"
|
||||
);
|
||||
const rejectedChallenges = challenges.filter((c) => c.status === "REJECTED");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{successMessage && (
|
||||
<Alert variant="success" className="mb-4">
|
||||
{successMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="text-sm text-gray-400 mb-4">
|
||||
{acceptedChallenges.length > 0 && (
|
||||
<span>
|
||||
{acceptedChallenges.length} défi
|
||||
{acceptedChallenges.length > 1 ? "s" : ""} en attente de désignation
|
||||
du gagnant
|
||||
</span>
|
||||
)}
|
||||
{pendingChallenges.length > 0 && (
|
||||
<span className={acceptedChallenges.length > 0 ? "ml-2" : ""}>
|
||||
• {pendingChallenges.length} défi
|
||||
{pendingChallenges.length > 1 ? "s" : ""} en attente
|
||||
d'acceptation
|
||||
</span>
|
||||
)}
|
||||
{cancelledChallenges.length > 0 && (
|
||||
<span className="ml-2">
|
||||
• {cancelledChallenges.length} défi
|
||||
{cancelledChallenges.length > 1 ? "s" : ""} annulé
|
||||
{cancelledChallenges.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{completedChallenges.length > 0 && (
|
||||
<span className="ml-2">
|
||||
• {completedChallenges.length} défi
|
||||
{completedChallenges.length > 1 ? "s" : ""} complété
|
||||
{completedChallenges.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{rejectedChallenges.length > 0 && (
|
||||
<span className="ml-2">
|
||||
• {rejectedChallenges.length} défi
|
||||
{rejectedChallenges.length > 1 ? "s" : ""} rejeté
|
||||
{rejectedChallenges.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{challenges.map((challenge) => (
|
||||
<Card key={challenge.id} variant="dark" className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-pixel-gold mb-2">
|
||||
{challenge.title}
|
||||
</h3>
|
||||
<p className="text-gray-300 mb-4">{challenge.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={challenge.challenger.avatar}
|
||||
username={challenge.challenger.username}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{challenge.challenger.username}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">VS</span>
|
||||
<Avatar
|
||||
src={challenge.challenged.avatar}
|
||||
username={challenge.challenged.username}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{challenge.challenged.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
Récompense:{" "}
|
||||
<span className="text-pixel-gold font-bold">
|
||||
{challenge.pointsReward} points
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs mt-2">
|
||||
<span
|
||||
className={`px-2 py-1 rounded ${
|
||||
challenge.status === "ACCEPTED"
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: challenge.status === "COMPLETED"
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: challenge.status === "CANCELLED"
|
||||
? "bg-gray-500/20 text-gray-400"
|
||||
: challenge.status === "REJECTED"
|
||||
? "bg-red-500/20 text-red-400"
|
||||
: "bg-yellow-500/20 text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{challenge.status === "PENDING"
|
||||
? "En attente d'acceptation"
|
||||
: challenge.status === "ACCEPTED"
|
||||
? "En cours - En attente de désignation du gagnant"
|
||||
: challenge.status === "COMPLETED"
|
||||
? "Complété"
|
||||
: challenge.status === "CANCELLED"
|
||||
? "Annulé"
|
||||
: challenge.status === "REJECTED"
|
||||
? "Rejeté"
|
||||
: challenge.status}
|
||||
</span>
|
||||
</div>
|
||||
{challenge.acceptedAt && (
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Accepté le:{" "}
|
||||
{new Date(challenge.acceptedAt).toLocaleDateString("fr-FR")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={() => handleEdit(challenge)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
{challenge.status === "PENDING" && (
|
||||
<Button
|
||||
onClick={() => handleAdminAccept(challenge.id)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
Accepter le défi
|
||||
</Button>
|
||||
)}
|
||||
{challenge.status === "ACCEPTED" && (
|
||||
<Button
|
||||
onClick={() => setSelectedChallenge(challenge)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Désigner le gagnant
|
||||
</Button>
|
||||
)}
|
||||
{challenge.status !== "CANCELLED" &&
|
||||
challenge.status !== "COMPLETED" && (
|
||||
<Button
|
||||
onClick={() => handleCancel(challenge.id)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
{challenge.status === "CANCELLED" && (
|
||||
<Button
|
||||
onClick={() => handleReactivate(challenge.id)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
Réactiver
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleDelete(challenge.id)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
style={{
|
||||
color: "var(--destructive)",
|
||||
borderColor: "var(--destructive)",
|
||||
}}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Modal de validation */}
|
||||
{selectedChallenge && (
|
||||
<Modal
|
||||
isOpen={!!selectedChallenge}
|
||||
onClose={() => {
|
||||
setSelectedChallenge(null);
|
||||
setWinnerId("");
|
||||
setAdminComment("");
|
||||
}}
|
||||
size="lg"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-bold text-pixel-gold">
|
||||
Désigner le gagnant
|
||||
</h2>
|
||||
<CloseButton
|
||||
onClick={() => {
|
||||
setSelectedChallenge(null);
|
||||
setWinnerId("");
|
||||
setAdminComment("");
|
||||
}}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-gray-300 mb-2">
|
||||
{selectedChallenge.title}
|
||||
</h3>
|
||||
<p className="text-gray-400 mb-4">
|
||||
{selectedChallenge.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={selectedChallenge.challenger.avatar}
|
||||
username={selectedChallenge.challenger.username}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenger.username}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500">VS</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={selectedChallenge.challenged.avatar}
|
||||
username={selectedChallenge.challenged.username}
|
||||
size="md"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenged.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Sélectionner le gagnant
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="winner"
|
||||
value={selectedChallenge.challenger.id}
|
||||
checked={winnerId === selectedChallenge.challenger.id}
|
||||
onChange={(e) => setWinnerId(e.target.value)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenger.username}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="winner"
|
||||
value={selectedChallenge.challenged.id}
|
||||
checked={winnerId === selectedChallenge.challenged.id}
|
||||
onChange={(e) => setWinnerId(e.target.value)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-gray-300">
|
||||
{selectedChallenge.challenged.username}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Commentaire (optionnel)
|
||||
</label>
|
||||
<textarea
|
||||
value={adminComment}
|
||||
onChange={(e) => setAdminComment(e.target.value)}
|
||||
className="w-full p-2 bg-black/60 border border-pixel-gold/30 rounded text-gray-300"
|
||||
rows={3}
|
||||
placeholder="Commentaire pour les joueurs..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
variant="primary"
|
||||
disabled={!winnerId || isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? "Enregistrement..." : "Confirmer le gagnant"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReject}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? "Rejet..." : "Rejeter le défi"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedChallenge(null);
|
||||
setWinnerId("");
|
||||
setAdminComment("");
|
||||
}}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Modal d'édition */}
|
||||
{editingChallenge && (
|
||||
<Modal
|
||||
isOpen={!!editingChallenge}
|
||||
onClose={() => {
|
||||
setEditingChallenge(null);
|
||||
setEditTitle("");
|
||||
setEditDescription("");
|
||||
setEditPointsReward(0);
|
||||
}}
|
||||
size="lg"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-bold text-pixel-gold">
|
||||
Modifier le défi
|
||||
</h2>
|
||||
<CloseButton
|
||||
onClick={() => {
|
||||
setEditingChallenge(null);
|
||||
setEditTitle("");
|
||||
setEditDescription("");
|
||||
setEditPointsReward(0);
|
||||
}}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id="edit-title"
|
||||
label="Titre"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
required
|
||||
placeholder="Titre du défi"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
label="Description"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
required
|
||||
rows={4}
|
||||
placeholder="Description du défi"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="edit-points"
|
||||
label="Récompense (points)"
|
||||
type="number"
|
||||
min="1"
|
||||
value={editPointsReward}
|
||||
onChange={(e) =>
|
||||
setEditPointsReward(parseInt(e.target.value) || 0)
|
||||
}
|
||||
required
|
||||
placeholder="100"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
variant="primary"
|
||||
disabled={
|
||||
isPending ||
|
||||
!editTitle ||
|
||||
!editDescription ||
|
||||
editPointsReward <= 0
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? "Mise à jour..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingChallenge(null);
|
||||
setEditTitle("");
|
||||
setEditDescription("");
|
||||
setEditPointsReward(0);
|
||||
}}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
components/admin/EventFeedbackPointsPreferences.tsx
Normal file
166
components/admin/EventFeedbackPointsPreferences.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { updateSitePreferences } from "@/actions/admin/preferences";
|
||||
import { Button, Card, Input } from "@/components/ui";
|
||||
|
||||
interface SitePreferences {
|
||||
id: string;
|
||||
eventFeedbackPoints: number;
|
||||
}
|
||||
|
||||
interface EventFeedbackPointsPreferencesProps {
|
||||
initialPreferences: SitePreferences;
|
||||
}
|
||||
|
||||
export default function EventFeedbackPointsPreferences({
|
||||
initialPreferences,
|
||||
}: EventFeedbackPointsPreferencesProps) {
|
||||
const [preferences, setPreferences] = useState<SitePreferences | null>(
|
||||
initialPreferences
|
||||
);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
eventFeedbackPoints: initialPreferences.eventFeedbackPoints.toString(),
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Synchroniser les préférences quand initialPreferences change
|
||||
useEffect(() => {
|
||||
setPreferences(initialPreferences);
|
||||
setFormData({
|
||||
eventFeedbackPoints: initialPreferences.eventFeedbackPoints.toString(),
|
||||
});
|
||||
}, [initialPreferences]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const points = parseInt(formData.eventFeedbackPoints, 10);
|
||||
|
||||
if (isNaN(points) || points < 0) {
|
||||
alert("Le nombre de points doit être un nombre positif");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await updateSitePreferences({
|
||||
eventFeedbackPoints: points,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setPreferences(result.data);
|
||||
setFormData({
|
||||
eventFeedbackPoints: result.data.eventFeedbackPoints.toString(),
|
||||
});
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
console.error("Error updating preferences:", result.error);
|
||||
alert(result.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating preferences:", error);
|
||||
alert("Erreur lors de la mise à jour");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
if (preferences) {
|
||||
setFormData({
|
||||
eventFeedbackPoints: preferences.eventFeedbackPoints.toString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="default" className="p-3 sm:p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3 mb-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
||||
Points de feedback sur les événements
|
||||
</h3>
|
||||
<p className="text-gray-400 text-xs sm:text-sm">
|
||||
Nombre de points attribués lorsqu'un utilisateur donne un feedback à un événement (première fois uniquement)
|
||||
</p>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="eventFeedbackPoints"
|
||||
className="block text-sm font-medium text-pixel-gold mb-2"
|
||||
>
|
||||
Points de feedback
|
||||
</label>
|
||||
<Input
|
||||
id="eventFeedbackPoints"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.eventFeedbackPoints}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
eventFeedbackPoints: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="100"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Les utilisateurs gagneront ce nombre de points lors de leur premier feedback sur un événement
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-4">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[200px] flex-shrink-0">
|
||||
Points actuels:
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg sm:text-xl font-bold text-white">
|
||||
{preferences?.eventFeedbackPoints ?? 100}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-gray-400">points</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { calculateEventStatus } from "@/lib/eventStatus";
|
||||
import { createEvent, updateEvent, deleteEvent } from "@/actions/admin/events";
|
||||
import { Input, Textarea, Button, Card, Badge } from "@/components/ui";
|
||||
import {
|
||||
Input,
|
||||
Textarea,
|
||||
Button,
|
||||
Card,
|
||||
Badge,
|
||||
Modal,
|
||||
CloseButton,
|
||||
Avatar,
|
||||
} from "@/components/ui";
|
||||
import { updateUser } from "@/actions/admin/users";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
@@ -20,6 +30,24 @@ interface Event {
|
||||
registrationsCount?: number;
|
||||
}
|
||||
|
||||
interface EventRegistration {
|
||||
id: string;
|
||||
userId: string;
|
||||
eventId: string;
|
||||
createdAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
score: number;
|
||||
level: number;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
xp: number;
|
||||
maxXp: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface EventFormData {
|
||||
date: string;
|
||||
name: string;
|
||||
@@ -64,12 +92,23 @@ const getStatusLabel = (status: Event["status"]) => {
|
||||
}
|
||||
};
|
||||
|
||||
export default function EventManagement() {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
interface EventManagementProps {
|
||||
initialEvents: Event[];
|
||||
}
|
||||
|
||||
export default function EventManagement({ initialEvents }: EventManagementProps) {
|
||||
const [events, setEvents] = useState<Event[]>(initialEvents);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [viewingRegistrations, setViewingRegistrations] =
|
||||
useState<Event | null>(null);
|
||||
const [registrations, setRegistrations] = useState<EventRegistration[]>([]);
|
||||
const [loadingRegistrations, setLoadingRegistrations] = useState(false);
|
||||
const [editingScores, setEditingScores] = useState<Record<string, number>>(
|
||||
{}
|
||||
);
|
||||
const [savingScore, setSavingScore] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<EventFormData>({
|
||||
date: "",
|
||||
name: "",
|
||||
@@ -80,10 +119,6 @@ export default function EventManagement() {
|
||||
maxPlaces: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/events");
|
||||
@@ -93,8 +128,6 @@ export default function EventManagement() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching events:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,8 +148,10 @@ export default function EventManagement() {
|
||||
const handleEdit = (event: Event) => {
|
||||
setEditingEvent(event);
|
||||
setIsCreating(false);
|
||||
// Convertir la date ISO en format YYYY-MM-DD pour l'input date
|
||||
const dateValue = event.date ? new Date(event.date).toISOString().split('T')[0] : "";
|
||||
setFormData({
|
||||
date: event.date,
|
||||
date: dateValue,
|
||||
name: event.name,
|
||||
description: event.description,
|
||||
type: event.type,
|
||||
@@ -199,9 +234,74 @@ export default function EventManagement() {
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-400 py-8">Chargement...</div>;
|
||||
}
|
||||
const handleViewRegistrations = async (event: Event) => {
|
||||
setViewingRegistrations(event);
|
||||
setLoadingRegistrations(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/events/${event.id}/registrations`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRegistrations(data);
|
||||
// Initialiser les scores d'édition avec les scores actuels
|
||||
const scoresMap: Record<string, number> = {};
|
||||
data.forEach((reg: EventRegistration) => {
|
||||
scoresMap[reg.user.id] = reg.user.score;
|
||||
});
|
||||
setEditingScores(scoresMap);
|
||||
} else {
|
||||
alert("Erreur lors de la récupération des inscrits");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching registrations:", error);
|
||||
alert("Erreur lors de la récupération des inscrits");
|
||||
} finally {
|
||||
setLoadingRegistrations(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseRegistrations = () => {
|
||||
setViewingRegistrations(null);
|
||||
setRegistrations([]);
|
||||
setEditingScores({});
|
||||
};
|
||||
|
||||
const handleScoreChange = (userId: string, newScore: number) => {
|
||||
setEditingScores({
|
||||
...editingScores,
|
||||
[userId]: newScore,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveScore = async (userId: string) => {
|
||||
const newScore = editingScores[userId];
|
||||
if (newScore === undefined) return;
|
||||
|
||||
setSavingScore(userId);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await updateUser(userId, { score: newScore });
|
||||
if (result.success) {
|
||||
// Mettre à jour le score dans la liste locale
|
||||
setRegistrations((prev) =>
|
||||
prev.map((reg) =>
|
||||
reg.user.id === userId
|
||||
? { ...reg, user: { ...reg.user, score: newScore } }
|
||||
: reg
|
||||
)
|
||||
);
|
||||
} else {
|
||||
alert(result.error || "Erreur lors de la mise à jour du score");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating score:", error);
|
||||
alert("Erreur lors de la mise à jour du score");
|
||||
} finally {
|
||||
setSavingScore(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -221,116 +321,126 @@ export default function EventManagement() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de création/édition */}
|
||||
{(isCreating || editingEvent) && (
|
||||
<Card variant="default" className="p-3 sm:p-4 mb-4">
|
||||
<h4 className="text-pixel-gold font-bold mb-4 text-base sm:text-lg break-words">
|
||||
{isCreating ? "Créer un événement" : "Modifier l'événement"}
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="date"
|
||||
label="Date"
|
||||
value={formData.date}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, date: e.target.value })
|
||||
}
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label="Nom"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="Nom de l'événement"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
placeholder="Description de l'événement"
|
||||
rows={4}
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm text-gray-300 mb-1">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
<Modal
|
||||
isOpen={isCreating || !!editingEvent}
|
||||
onClose={handleCancel}
|
||||
size="lg"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
||||
{isCreating ? "Créer un événement" : "Modifier l'événement"}
|
||||
</h4>
|
||||
<CloseButton onClick={handleCancel} size="lg" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="date"
|
||||
label="Date"
|
||||
value={formData.date}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, date: e.target.value })
|
||||
}
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label="Nom"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="Nom de l'événement"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
placeholder="Description de l'événement"
|
||||
rows={4}
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm text-gray-300 mb-1">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
type: e.target.value as Event["type"],
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm"
|
||||
>
|
||||
{eventTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getEventTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
label="Salle"
|
||||
value={formData.room || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, room: e.target.value })
|
||||
}
|
||||
placeholder="Ex: Nautilus"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label="Heure"
|
||||
value={formData.time || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, time: e.target.value })
|
||||
}
|
||||
placeholder="Ex: 11h-12h"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="Places max"
|
||||
value={formData.maxPlaces || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
type: e.target.value as Event["type"],
|
||||
maxPlaces: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm"
|
||||
placeholder="Ex: 25"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={saving}
|
||||
>
|
||||
{eventTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getEventTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{saving ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button onClick={handleCancel} variant="secondary" size="md">
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
label="Salle"
|
||||
value={formData.room || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, room: e.target.value })
|
||||
}
|
||||
placeholder="Ex: Nautilus"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label="Heure"
|
||||
value={formData.time || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, time: e.target.value })
|
||||
}
|
||||
placeholder="Ex: 11h-12h"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
label="Places max"
|
||||
value={formData.maxPlaces || ""}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
maxPlaces: e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Ex: 25"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button onClick={handleCancel} variant="secondary" size="md">
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{events.length === 0 ? (
|
||||
@@ -392,7 +502,15 @@ export default function EventManagement() {
|
||||
</div>
|
||||
</div>
|
||||
{!isCreating && !editingEvent && (
|
||||
<div className="flex gap-2 sm:ml-4 flex-shrink-0">
|
||||
<div className="flex gap-2 sm:ml-4 flex-shrink-0 flex-wrap">
|
||||
<Button
|
||||
onClick={() => handleViewRegistrations(event)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Inscrits ({event.registrationsCount || 0})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleEdit(event)}
|
||||
variant="primary"
|
||||
@@ -417,6 +535,116 @@ export default function EventManagement() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal des inscrits */}
|
||||
{viewingRegistrations && (
|
||||
<Modal
|
||||
isOpen={!!viewingRegistrations}
|
||||
onClose={handleCloseRegistrations}
|
||||
size="lg"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
||||
Inscrits à "{viewingRegistrations.name}"
|
||||
</h4>
|
||||
<CloseButton onClick={handleCloseRegistrations} size="lg" />
|
||||
</div>
|
||||
|
||||
{loadingRegistrations ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Chargement...
|
||||
</div>
|
||||
) : registrations.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Aucun inscrit pour cet événement
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{registrations.map((registration) => {
|
||||
const user = registration.user;
|
||||
const currentScore = editingScores[user.id] ?? user.score;
|
||||
const isSaving = savingScore === user.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={registration.id}
|
||||
variant="default"
|
||||
className="p-3 sm:p-4"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
username={user.username}
|
||||
size="md"
|
||||
borderClassName="border-2 border-pixel-gold/50"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h5 className="text-pixel-gold font-bold text-sm sm:text-base break-words">
|
||||
{user.username}
|
||||
</h5>
|
||||
<p className="text-gray-400 text-xs sm:text-sm">
|
||||
Niveau {user.level} • HP: {user.hp}/{user.maxHp} •
|
||||
XP: {user.xp}/{user.maxXp}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs sm:text-sm text-gray-300 whitespace-nowrap">
|
||||
Score:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={currentScore}
|
||||
onChange={(e) =>
|
||||
handleScoreChange(
|
||||
user.id,
|
||||
parseInt(e.target.value) || 0
|
||||
)
|
||||
}
|
||||
disabled={isSaving}
|
||||
className="w-24 px-2 sm:px-3 py-1 bg-black/60 border border-pixel-gold/30 rounded text-white text-xs sm:text-sm text-center disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleScoreChange(user.id, currentScore - 100)
|
||||
}
|
||||
disabled={isSaving}
|
||||
className="px-2 sm:px-3 py-1 border border-red-500/50 bg-red-900/20 text-red-400 text-[10px] sm:text-xs rounded hover:bg-red-900/30 transition flex-shrink-0 disabled:opacity-50"
|
||||
>
|
||||
-100
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleScoreChange(user.id, currentScore + 100)
|
||||
}
|
||||
disabled={isSaving}
|
||||
className="px-2 sm:px-3 py-1 border border-green-500/50 bg-green-900/20 text-green-400 text-[10px] sm:text-xs rounded hover:bg-green-900/30 transition flex-shrink-0 disabled:opacity-50"
|
||||
>
|
||||
+100
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSaveScore(user.id)}
|
||||
disabled={isSaving || currentScore === user.score}
|
||||
className="px-2 sm:px-3 py-1 border border-pixel-gold/50 bg-pixel-gold/20 text-pixel-gold text-[10px] sm:text-xs rounded hover:bg-pixel-gold/30 transition flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSaving ? "..." : "Sauver"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
166
components/admin/EventPointsPreferences.tsx
Normal file
166
components/admin/EventPointsPreferences.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { updateSitePreferences } from "@/actions/admin/preferences";
|
||||
import { Button, Card, Input } from "@/components/ui";
|
||||
|
||||
interface SitePreferences {
|
||||
id: string;
|
||||
eventRegistrationPoints: number;
|
||||
}
|
||||
|
||||
interface EventPointsPreferencesProps {
|
||||
initialPreferences: SitePreferences;
|
||||
}
|
||||
|
||||
export default function EventPointsPreferences({
|
||||
initialPreferences,
|
||||
}: EventPointsPreferencesProps) {
|
||||
const [preferences, setPreferences] = useState<SitePreferences | null>(
|
||||
initialPreferences
|
||||
);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
eventRegistrationPoints: initialPreferences.eventRegistrationPoints.toString(),
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Synchroniser les préférences quand initialPreferences change
|
||||
useEffect(() => {
|
||||
setPreferences(initialPreferences);
|
||||
setFormData({
|
||||
eventRegistrationPoints: initialPreferences.eventRegistrationPoints.toString(),
|
||||
});
|
||||
}, [initialPreferences]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const points = parseInt(formData.eventRegistrationPoints, 10);
|
||||
|
||||
if (isNaN(points) || points < 0) {
|
||||
alert("Le nombre de points doit être un nombre positif");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await updateSitePreferences({
|
||||
eventRegistrationPoints: points,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setPreferences(result.data);
|
||||
setFormData({
|
||||
eventRegistrationPoints: result.data.eventRegistrationPoints.toString(),
|
||||
});
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
console.error("Error updating preferences:", result.error);
|
||||
alert(result.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating preferences:", error);
|
||||
alert("Erreur lors de la mise à jour");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
if (preferences) {
|
||||
setFormData({
|
||||
eventRegistrationPoints: preferences.eventRegistrationPoints.toString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="default" className="p-3 sm:p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3 mb-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
||||
Points d'inscription aux événements
|
||||
</h3>
|
||||
<p className="text-gray-400 text-xs sm:text-sm">
|
||||
Nombre de points attribués lorsqu'un utilisateur s'inscrit à un événement
|
||||
</p>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="eventRegistrationPoints"
|
||||
className="block text-sm font-medium text-pixel-gold mb-2"
|
||||
>
|
||||
Points d'inscription
|
||||
</label>
|
||||
<Input
|
||||
id="eventRegistrationPoints"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.eventRegistrationPoints}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
eventRegistrationPoints: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="100"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Les utilisateurs gagneront ce nombre de points lors de leur inscription à un événement
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-4">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[200px] flex-shrink-0">
|
||||
Points actuels:
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg sm:text-xl font-bold text-white">
|
||||
{preferences?.eventRegistrationPoints ?? 100}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-gray-400">points</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
addFeedbackBonusPoints,
|
||||
markFeedbackAsRead,
|
||||
} from "@/actions/admin/feedback";
|
||||
import { Button } from "@/components/ui";
|
||||
import Avatar from "@/components/ui/Avatar";
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
event: {
|
||||
id: string;
|
||||
@@ -17,6 +24,8 @@ interface Feedback {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
score: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,16 +38,23 @@ interface EventStatistics {
|
||||
feedbackCount: number;
|
||||
}
|
||||
|
||||
export default function FeedbackManagement() {
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||
const [statistics, setStatistics] = useState<EventStatistics[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
interface FeedbackManagementProps {
|
||||
initialFeedbacks: Feedback[];
|
||||
initialStatistics: EventStatistics[];
|
||||
}
|
||||
|
||||
export default function FeedbackManagement({
|
||||
initialFeedbacks,
|
||||
initialStatistics,
|
||||
}: FeedbackManagementProps) {
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>(initialFeedbacks);
|
||||
const [statistics, setStatistics] = useState<EventStatistics[]>(initialStatistics);
|
||||
const [error, setError] = useState("");
|
||||
const [selectedEvent, setSelectedEvent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedbacks();
|
||||
}, []);
|
||||
const [addingPoints, setAddingPoints] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const [markingRead, setMarkingRead] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchFeedbacks = async () => {
|
||||
try {
|
||||
@@ -52,8 +68,6 @@ export default function FeedbackManagement() {
|
||||
setStatistics(data.statistics || []);
|
||||
} catch {
|
||||
setError("Erreur lors du chargement des feedbacks");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,17 +106,59 @@ export default function FeedbackManagement() {
|
||||
);
|
||||
};
|
||||
|
||||
const filteredFeedbacks = selectedEvent
|
||||
? feedbacks.filter((f) => f.event.id === selectedEvent)
|
||||
: feedbacks;
|
||||
const handleAddPoints = async (userId: string, points: number) => {
|
||||
const key = `${userId}-${points}`;
|
||||
setAddingPoints((prev) => ({ ...prev, [key]: true }));
|
||||
setError("");
|
||||
|
||||
if (loading) {
|
||||
try {
|
||||
const result = await addFeedbackBonusPoints(userId, points);
|
||||
if (result.success) {
|
||||
// Rafraîchir les données pour voir les nouveaux scores
|
||||
await fetchFeedbacks();
|
||||
// Rafraîchir le score dans le header si l'utilisateur est connecté
|
||||
window.dispatchEvent(new Event("refreshUserScore"));
|
||||
} else {
|
||||
setError(result.error || "Erreur lors de l'ajout des points");
|
||||
}
|
||||
} catch {
|
||||
setError("Erreur lors de l'ajout des points");
|
||||
} finally {
|
||||
setAddingPoints((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (feedbackId: string, isRead: boolean) => {
|
||||
setMarkingRead((prev) => ({ ...prev, [feedbackId]: true }));
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const result = await markFeedbackAsRead(feedbackId, isRead);
|
||||
if (result.success) {
|
||||
// Rafraîchir les données pour voir le nouveau statut
|
||||
await fetchFeedbacks();
|
||||
} else {
|
||||
setError(result.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
} catch {
|
||||
setError("Erreur lors de la mise à jour");
|
||||
} finally {
|
||||
setMarkingRead((prev) => ({ ...prev, [feedbackId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFeedbacks = (selectedEvent
|
||||
? feedbacks.filter((f) => f.event.id === selectedEvent)
|
||||
: feedbacks
|
||||
).sort((a, b) => {
|
||||
// Trier : non lus en premier, puis par date décroissante
|
||||
if (a.isRead !== b.isRead) {
|
||||
return a.isRead ? 1 : -1;
|
||||
}
|
||||
return (
|
||||
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg p-4 sm:p-8">
|
||||
<p className="text-gray-400 text-center text-sm">Chargement...</p>
|
||||
</div>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
@@ -184,20 +240,45 @@ export default function FeedbackManagement() {
|
||||
{filteredFeedbacks.map((feedback) => (
|
||||
<div
|
||||
key={feedback.id}
|
||||
className="bg-black/40 border border-pixel-gold/20 rounded p-3 sm:p-4"
|
||||
className={`bg-black/40 border rounded p-3 sm:p-4 ${
|
||||
feedback.isRead
|
||||
? "border-pixel-gold/20 opacity-75"
|
||||
: "border-pixel-gold/50 bg-pixel-gold/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3 mb-2">
|
||||
<h4 className="text-white font-semibold text-sm sm:text-base break-words">
|
||||
{feedback.user.username}
|
||||
</h4>
|
||||
<span className="text-gray-500 text-[10px] sm:text-xs break-all">
|
||||
{feedback.user.email}
|
||||
</span>
|
||||
{/* En-tête utilisateur avec avatar */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-3">
|
||||
<Avatar
|
||||
src={feedback.user.avatar}
|
||||
username={feedback.user.username}
|
||||
size="md"
|
||||
borderClassName="border-pixel-gold/30"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 mb-1">
|
||||
<h4 className="text-white font-semibold text-sm sm:text-base break-words">
|
||||
{feedback.user.username}
|
||||
</h4>
|
||||
<span className="text-pixel-gold font-bold text-xs sm:text-sm">
|
||||
{feedback.user.score.toLocaleString("fr-FR")} pts
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-[10px] sm:text-xs break-all">
|
||||
{feedback.user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-pixel-gold text-xs sm:text-sm font-semibold mb-2 break-words">
|
||||
{feedback.event.name}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="text-pixel-gold text-xs sm:text-sm font-semibold break-words">
|
||||
{feedback.event.name}
|
||||
</div>
|
||||
{!feedback.isRead && (
|
||||
<span className="bg-pixel-gold/20 text-pixel-gold text-[10px] px-1.5 py-0.5 rounded uppercase font-semibold">
|
||||
Non lu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-500 text-[10px] sm:text-xs mb-2">
|
||||
{new Date(feedback.createdAt).toLocaleDateString(
|
||||
@@ -212,8 +293,23 @@ export default function FeedbackManagement() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
{renderStars(feedback.rating)}
|
||||
<Button
|
||||
variant={feedback.isRead ? "secondary" : "success"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleMarkAsRead(feedback.id, !feedback.isRead)
|
||||
}
|
||||
disabled={markingRead[feedback.id]}
|
||||
className="text-xs whitespace-nowrap"
|
||||
>
|
||||
{markingRead[feedback.id]
|
||||
? "..."
|
||||
: feedback.isRead
|
||||
? "Marquer non lu"
|
||||
: "Marquer lu"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{feedback.comment && (
|
||||
@@ -223,6 +319,39 @@ export default function FeedbackManagement() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Boutons pour ajouter des points bonus */}
|
||||
<div className="mt-3 pt-3 border-t border-pixel-gold/20 flex flex-wrap gap-2">
|
||||
<span className="text-gray-400 text-xs sm:text-sm mr-2">
|
||||
Points bonus:
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleAddPoints(feedback.user.id, 10)}
|
||||
disabled={addingPoints[`${feedback.user.id}-10`]}
|
||||
className="text-xs"
|
||||
>
|
||||
{addingPoints[`${feedback.user.id}-10`] ? "..." : "+10"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleAddPoints(feedback.user.id, 100)}
|
||||
disabled={addingPoints[`${feedback.user.id}-100`]}
|
||||
className="text-xs"
|
||||
>
|
||||
{addingPoints[`${feedback.user.id}-100`] ? "..." : "+100"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleAddPoints(feedback.user.id, 1000)}
|
||||
disabled={addingPoints[`${feedback.user.id}-1000`]}
|
||||
className="text-xs"
|
||||
>
|
||||
{addingPoints[`${feedback.user.id}-1000`] ? "..." : "+1000"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
447
components/admin/HouseManagement.tsx
Normal file
447
components/admin/HouseManagement.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
Input,
|
||||
Textarea,
|
||||
Button,
|
||||
Card,
|
||||
Badge,
|
||||
Modal,
|
||||
CloseButton,
|
||||
Avatar,
|
||||
} from "@/components/ui";
|
||||
import { updateHouse, deleteHouse, removeMember } from "@/actions/admin/houses";
|
||||
|
||||
interface House {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
creatorId: string;
|
||||
creator: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
membersCount: number;
|
||||
memberships: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
score: number;
|
||||
level: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface HouseFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
case "OWNER":
|
||||
return "👑 Propriétaire";
|
||||
case "ADMIN":
|
||||
return "⚡ Admin";
|
||||
case "MEMBER":
|
||||
return "👤 Membre";
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
switch (role) {
|
||||
case "OWNER":
|
||||
return "var(--accent)";
|
||||
case "ADMIN":
|
||||
return "var(--primary)";
|
||||
case "MEMBER":
|
||||
return "var(--muted-foreground)";
|
||||
default:
|
||||
return "var(--gray)";
|
||||
}
|
||||
};
|
||||
|
||||
interface HouseManagementProps {
|
||||
initialHouses: House[];
|
||||
}
|
||||
|
||||
export default function HouseManagement({ initialHouses }: HouseManagementProps) {
|
||||
const [houses, setHouses] = useState<House[]>(initialHouses);
|
||||
const [editingHouse, setEditingHouse] = useState<House | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingHouseId, setDeletingHouseId] = useState<string | null>(null);
|
||||
const [viewingMembers, setViewingMembers] = useState<House | null>(null);
|
||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<HouseFormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const fetchHouses = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/houses");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHouses(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching houses:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (house: House) => {
|
||||
setEditingHouse(house);
|
||||
setFormData({
|
||||
name: house.name,
|
||||
description: house.description || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingHouse) return;
|
||||
|
||||
setSaving(true);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await updateHouse(editingHouse.id, {
|
||||
name: formData.name,
|
||||
description: formData.description || null,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await fetchHouses();
|
||||
setEditingHouse(null);
|
||||
setFormData({ name: "", description: "" });
|
||||
} else {
|
||||
alert(result.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating house:", error);
|
||||
alert("Erreur lors de la mise à jour");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (houseId: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
"Êtes-vous sûr de vouloir supprimer cette maison ? Cette action est irréversible et supprimera tous les membres."
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingHouseId(houseId);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteHouse(houseId);
|
||||
|
||||
if (result.success) {
|
||||
await fetchHouses();
|
||||
} else {
|
||||
alert(result.error || "Erreur lors de la suppression");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting house:", error);
|
||||
alert("Erreur lors de la suppression");
|
||||
} finally {
|
||||
setDeletingHouseId(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingHouse(null);
|
||||
setFormData({ name: "", description: "" });
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (houseId: string, memberId: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
"Êtes-vous sûr de vouloir retirer ce membre de la maison ? Cette action lui retirera des points."
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRemovingMemberId(memberId);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await removeMember(houseId, memberId);
|
||||
|
||||
if (result.success) {
|
||||
// Récupérer les maisons mises à jour
|
||||
const response = await fetch("/api/admin/houses");
|
||||
if (response.ok) {
|
||||
const updatedHouses = await response.json();
|
||||
setHouses(updatedHouses);
|
||||
// Mettre à jour la modal si elle est ouverte
|
||||
if (viewingMembers) {
|
||||
const updatedHouse = updatedHouses.find((h: House) => h.id === houseId);
|
||||
if (updatedHouse) {
|
||||
setViewingMembers(updatedHouse);
|
||||
} else {
|
||||
// Si la maison n'existe plus, fermer la modal
|
||||
setViewingMembers(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert(result.error || "Erreur lors du retrait du membre");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing member:", error);
|
||||
alert("Erreur lors du retrait du membre");
|
||||
} finally {
|
||||
setRemovingMemberId(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString("en-US");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 mb-4">
|
||||
<h3 className="text-lg sm:text-xl font-gaming font-bold text-pixel-gold break-words">
|
||||
Maisons ({houses.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Modal d'édition */}
|
||||
{editingHouse && (
|
||||
<Modal isOpen={!!editingHouse} onClose={handleCancel} size="lg">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
||||
Modifier la maison
|
||||
</h4>
|
||||
<CloseButton onClick={handleCancel} size="lg" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
label="Nom de la maison"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="Nom de la maison"
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
placeholder="Description de la maison"
|
||||
rows={4}
|
||||
className="text-xs sm:text-sm px-3 py-2"
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button onClick={handleCancel} variant="secondary" size="md">
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Modal des membres */}
|
||||
{viewingMembers && (
|
||||
<Modal
|
||||
isOpen={!!viewingMembers}
|
||||
onClose={() => setViewingMembers(null)}
|
||||
size="lg"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
||||
Membres de "{viewingMembers.name}"
|
||||
</h4>
|
||||
<CloseButton onClick={() => setViewingMembers(null)} size="lg" />
|
||||
</div>
|
||||
|
||||
{viewingMembers.memberships.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Aucun membre dans cette maison
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{viewingMembers.memberships.map((membership) => {
|
||||
const roleColor = getRoleColor(membership.role);
|
||||
return (
|
||||
<Card
|
||||
key={membership.id}
|
||||
variant="default"
|
||||
className="p-3 sm:p-4"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Avatar
|
||||
src={membership.user.avatar}
|
||||
username={membership.user.username}
|
||||
size="md"
|
||||
borderClassName="border-2"
|
||||
style={{
|
||||
borderColor: roleColor,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h5 className="text-pixel-gold font-bold text-sm sm:text-base break-words">
|
||||
{membership.user.username}
|
||||
</h5>
|
||||
<p className="text-gray-400 text-xs sm:text-sm">
|
||||
Niveau {membership.user.level} • Score:{" "}
|
||||
{formatNumber(membership.user.score)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge
|
||||
variant="default"
|
||||
size="sm"
|
||||
style={{
|
||||
color: roleColor,
|
||||
backgroundColor: `color-mix(in srgb, ${roleColor} 15%, transparent)`,
|
||||
borderColor: `color-mix(in srgb, ${roleColor} 30%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{getRoleLabel(membership.role)}
|
||||
</Badge>
|
||||
{membership.role !== "OWNER" && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleRemoveMember(
|
||||
viewingMembers.id,
|
||||
membership.user.id
|
||||
)
|
||||
}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={removingMemberId === membership.user.id}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{removingMemberId === membership.user.id
|
||||
? "..."
|
||||
: "Retirer"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{houses.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Aucune maison trouvée
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{houses.map((house) => {
|
||||
return (
|
||||
<Card key={house.id} variant="default" className="p-3 sm: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">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-2">
|
||||
<h4 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
||||
{house.name}
|
||||
</h4>
|
||||
<Badge variant="info" size="sm">
|
||||
{house.membersCount} membre
|
||||
{house.membersCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
{house.description && (
|
||||
<p className="text-gray-400 text-xs sm:text-sm mb-2 break-words">
|
||||
{house.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={house.creator.avatar}
|
||||
username={house.creator.username}
|
||||
size="sm"
|
||||
borderClassName="border border-pixel-gold/50"
|
||||
/>
|
||||
<p className="text-gray-500 text-[10px] sm:text-xs">
|
||||
Créée par {house.creator.username}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-gray-500 text-[10px] sm:text-xs whitespace-nowrap">
|
||||
Créée le{" "}
|
||||
{new Date(house.createdAt).toLocaleDateString("fr-FR")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!editingHouse && (
|
||||
<div className="flex gap-2 sm:ml-4 flex-shrink-0 flex-wrap">
|
||||
<Button
|
||||
onClick={() => setViewingMembers(house)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Membres ({house.membersCount})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleEdit(house)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(house.id)}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={deletingHouseId === house.id}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{deletingHouseId === house.id ? "..." : "Supprimer"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
269
components/admin/HousePointsPreferences.tsx
Normal file
269
components/admin/HousePointsPreferences.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { updateSitePreferences } from "@/actions/admin/preferences";
|
||||
import { Button, Card, Input } from "@/components/ui";
|
||||
|
||||
interface SitePreferences {
|
||||
id: string;
|
||||
houseJoinPoints: number;
|
||||
houseLeavePoints: number;
|
||||
houseCreatePoints: number;
|
||||
}
|
||||
|
||||
interface HousePointsPreferencesProps {
|
||||
initialPreferences: SitePreferences;
|
||||
}
|
||||
|
||||
export default function HousePointsPreferences({
|
||||
initialPreferences,
|
||||
}: HousePointsPreferencesProps) {
|
||||
const [preferences, setPreferences] = useState<SitePreferences | null>(
|
||||
initialPreferences
|
||||
);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
houseJoinPoints: initialPreferences.houseJoinPoints.toString(),
|
||||
houseLeavePoints: initialPreferences.houseLeavePoints.toString(),
|
||||
houseCreatePoints: initialPreferences.houseCreatePoints.toString(),
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Synchroniser les préférences quand initialPreferences change
|
||||
useEffect(() => {
|
||||
setPreferences(initialPreferences);
|
||||
setFormData({
|
||||
houseJoinPoints: initialPreferences.houseJoinPoints.toString(),
|
||||
houseLeavePoints: initialPreferences.houseLeavePoints.toString(),
|
||||
houseCreatePoints: initialPreferences.houseCreatePoints.toString(),
|
||||
});
|
||||
}, [initialPreferences]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const joinPoints = parseInt(formData.houseJoinPoints, 10);
|
||||
const leavePoints = parseInt(formData.houseLeavePoints, 10);
|
||||
const createPoints = parseInt(formData.houseCreatePoints, 10);
|
||||
|
||||
if (isNaN(joinPoints) || joinPoints < 0) {
|
||||
alert("Le nombre de points pour rejoindre une maison doit être un nombre positif");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN(leavePoints) || leavePoints < 0) {
|
||||
alert("Le nombre de points pour quitter une maison doit être un nombre positif");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN(createPoints) || createPoints < 0) {
|
||||
alert("Le nombre de points pour créer une maison doit être un nombre positif");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await updateSitePreferences({
|
||||
houseJoinPoints: joinPoints,
|
||||
houseLeavePoints: leavePoints,
|
||||
houseCreatePoints: createPoints,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setPreferences(result.data);
|
||||
setFormData({
|
||||
houseJoinPoints: result.data.houseJoinPoints.toString(),
|
||||
houseLeavePoints: result.data.houseLeavePoints.toString(),
|
||||
houseCreatePoints: result.data.houseCreatePoints.toString(),
|
||||
});
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
console.error("Error updating preferences:", result.error);
|
||||
alert(result.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating preferences:", error);
|
||||
alert("Erreur lors de la mise à jour");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
if (preferences) {
|
||||
setFormData({
|
||||
houseJoinPoints: preferences.houseJoinPoints.toString(),
|
||||
houseLeavePoints: preferences.houseLeavePoints.toString(),
|
||||
houseCreatePoints: preferences.houseCreatePoints.toString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="default" className="p-3 sm:p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3 mb-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-pixel-gold font-bold text-base sm:text-lg break-words">
|
||||
Points des Maisons
|
||||
</h3>
|
||||
<p className="text-gray-400 text-xs sm:text-sm">
|
||||
Nombre de points attribués ou retirés pour les actions liées aux maisons
|
||||
</p>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="houseJoinPoints"
|
||||
className="block text-sm font-medium text-pixel-gold mb-2"
|
||||
>
|
||||
Points pour rejoindre une maison
|
||||
</label>
|
||||
<Input
|
||||
id="houseJoinPoints"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.houseJoinPoints}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
houseJoinPoints: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="100"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Les utilisateurs gagneront ce nombre de points lorsqu'ils rejoignent une maison
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="houseLeavePoints"
|
||||
className="block text-sm font-medium text-pixel-gold mb-2"
|
||||
>
|
||||
Points retirés en quittant une maison
|
||||
</label>
|
||||
<Input
|
||||
id="houseLeavePoints"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.houseLeavePoints}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
houseLeavePoints: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="100"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Les utilisateurs perdront ce nombre de points lorsqu'ils quittent une maison
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="houseCreatePoints"
|
||||
className="block text-sm font-medium text-pixel-gold mb-2"
|
||||
>
|
||||
Points pour créer une maison
|
||||
</label>
|
||||
<Input
|
||||
id="houseCreatePoints"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.houseCreatePoints}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
houseCreatePoints: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="100"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Les utilisateurs gagneront ce nombre de points lorsqu'ils créent une maison
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-4">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="success"
|
||||
size="md"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[200px] flex-shrink-0">
|
||||
Points pour rejoindre:
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg sm:text-xl font-bold text-white">
|
||||
{preferences?.houseJoinPoints ?? 100}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-gray-400">points</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[200px] flex-shrink-0">
|
||||
Points retirés en quittant:
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg sm:text-xl font-bold text-white">
|
||||
{preferences?.houseLeavePoints ?? 100}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-gray-400">points</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
|
||||
<span className="text-pixel-gold font-bold text-sm sm:text-base min-w-0 sm:min-w-[200px] flex-shrink-0">
|
||||
Points pour créer:
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg sm:text-xl font-bold text-white">
|
||||
{preferences?.houseCreatePoints ?? 100}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-gray-400">points</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
183
components/challenges/ChallengeCard.tsx
Normal file
183
components/challenges/ChallengeCard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { Card, Button, Avatar, Badge } from "@/components/ui";
|
||||
|
||||
interface ChallengeCardProps {
|
||||
challenge: {
|
||||
id: string;
|
||||
challenger: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
challenged: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
title: string;
|
||||
description: string;
|
||||
pointsReward: number;
|
||||
status: string;
|
||||
adminComment: string | null;
|
||||
winner?: {
|
||||
id: string;
|
||||
username: string;
|
||||
} | null;
|
||||
createdAt: string;
|
||||
acceptedAt: string | null;
|
||||
completedAt: string | null;
|
||||
};
|
||||
currentUserId?: string;
|
||||
onAccept?: (challengeId: string) => void;
|
||||
onCancel?: (challengeId: string) => void;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "En attente d'acceptation";
|
||||
case "ACCEPTED":
|
||||
return "En cours - En attente de désignation du gagnant";
|
||||
case "COMPLETED":
|
||||
return "Complété";
|
||||
case "REJECTED":
|
||||
return "Rejeté";
|
||||
case "CANCELLED":
|
||||
return "Annulé";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusVariant = (
|
||||
status: string
|
||||
): "default" | "success" | "warning" | "danger" | "info" => {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "warning";
|
||||
case "ACCEPTED":
|
||||
return "info";
|
||||
case "COMPLETED":
|
||||
return "success";
|
||||
case "REJECTED":
|
||||
return "danger";
|
||||
case "CANCELLED":
|
||||
return "default";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
export default function ChallengeCard({
|
||||
challenge,
|
||||
currentUserId,
|
||||
onAccept,
|
||||
onCancel,
|
||||
isPending = false,
|
||||
}: ChallengeCardProps) {
|
||||
const isChallenger = challenge.challenger.id === currentUserId;
|
||||
const isChallenged = challenge.challenged.id === currentUserId;
|
||||
const canAccept = challenge.status === "PENDING" && isChallenged;
|
||||
const canCancel =
|
||||
(challenge.status === "PENDING" || challenge.status === "ACCEPTED") &&
|
||||
(isChallenger || isChallenged);
|
||||
|
||||
return (
|
||||
<Card variant="dark" className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<h3 className="text-lg font-bold text-pixel-gold">
|
||||
{challenge.title}
|
||||
</h3>
|
||||
<Badge variant={getStatusVariant(challenge.status)} size="xs">
|
||||
{getStatusLabel(challenge.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4">{challenge.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={challenge.challenger.avatar}
|
||||
username={challenge.challenger.username}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{challenge.challenger.username}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500">VS</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
src={challenge.challenged.avatar}
|
||||
username={challenge.challenged.username}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">
|
||||
{challenge.challenged.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
Récompense:{" "}
|
||||
<span className="text-pixel-gold font-bold">
|
||||
{challenge.pointsReward} points
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{challenge.winner && (
|
||||
<div className="text-sm text-green-400 mt-2">
|
||||
🏆 Gagnant: {challenge.winner.username}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{challenge.adminComment && (
|
||||
<div className="text-xs text-gray-500 mt-2 italic">
|
||||
Admin: {challenge.adminComment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Créé le: {new Date(challenge.createdAt).toLocaleDateString("fr-FR")}
|
||||
{challenge.acceptedAt &&
|
||||
` • Accepté le: ${new Date(challenge.acceptedAt).toLocaleDateString("fr-FR")}`}
|
||||
{challenge.completedAt &&
|
||||
` • Complété le: ${new Date(challenge.completedAt).toLocaleDateString("fr-FR")}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{canAccept && onAccept && (
|
||||
<Button
|
||||
onClick={() => onAccept(challenge.id)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
Accepter
|
||||
</Button>
|
||||
)}
|
||||
{canCancel && onCancel && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (confirm("Êtes-vous sûr de vouloir annuler ce défi ?")) {
|
||||
onCancel(challenge.id);
|
||||
}
|
||||
}}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
140
components/challenges/ChallengeForm.tsx
Normal file
140
components/challenges/ChallengeForm.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, Input, Textarea, Button, Select } from "@/components/ui";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
score: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface ChallengeFormProps {
|
||||
users: User[];
|
||||
onSubmit: (data: {
|
||||
challengedId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pointsReward: number;
|
||||
}) => void;
|
||||
onCancel?: () => void;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
export default function ChallengeForm({
|
||||
users,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isPending = false,
|
||||
}: ChallengeFormProps) {
|
||||
const [challengedId, setChallengedId] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [pointsReward, setPointsReward] = useState(100);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!challengedId || !title || !description) {
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
challengedId,
|
||||
title,
|
||||
description,
|
||||
pointsReward,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setChallengedId("");
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setPointsReward(100);
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="dark" className="p-6 mb-8">
|
||||
<h2 className="text-xl font-bold text-pixel-gold mb-4">
|
||||
Créer un nouveau défi
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Select
|
||||
label="Défier qui ?"
|
||||
value={challengedId}
|
||||
onChange={(e) => setChallengedId(e.target.value)}
|
||||
>
|
||||
<option value="">Sélectionner un joueur</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.username} (Lv.{user.level} - {user.score} pts)
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Titre du défi
|
||||
</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Ex: Qui participera à plus d'événements ce mois ?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Décrivez les règles du défi..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-pixel-gold mb-2">
|
||||
Points à gagner (défaut: 100)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={pointsReward}
|
||||
onChange={(e) =>
|
||||
setPointsReward(parseInt(e.target.value) || 100)
|
||||
}
|
||||
min={1}
|
||||
max={1000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isPending || !challengedId || !title || !description}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? "Création..." : "Créer le défi"}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
variant="secondary"
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
319
components/challenges/ChallengesSection.tsx
Normal file
319
components/challenges/ChallengesSection.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import {
|
||||
createChallenge,
|
||||
acceptChallenge,
|
||||
cancelChallenge,
|
||||
} from "@/actions/challenges/create";
|
||||
import { Button, Card, SectionTitle, Alert } from "@/components/ui";
|
||||
import ChallengeCard from "./ChallengeCard";
|
||||
import ChallengeForm from "./ChallengeForm";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
score: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface Challenge {
|
||||
id: string;
|
||||
challenger: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
challenged: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
title: string;
|
||||
description: string;
|
||||
pointsReward: number;
|
||||
status: string;
|
||||
adminComment: string | null;
|
||||
winner?: {
|
||||
id: string;
|
||||
username: string;
|
||||
} | null;
|
||||
createdAt: string;
|
||||
acceptedAt: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
interface ChallengesSectionProps {
|
||||
initialChallenges: Challenge[];
|
||||
initialUsers: User[];
|
||||
backgroundImage: string;
|
||||
}
|
||||
|
||||
export default function ChallengesSection({
|
||||
initialChallenges,
|
||||
initialUsers,
|
||||
backgroundImage,
|
||||
}: ChallengesSectionProps) {
|
||||
const { data: session } = useSession();
|
||||
const [challenges, setChallenges] = useState<Challenge[]>(initialChallenges);
|
||||
const [users] = useState<User[]>(initialUsers);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showExamples, setShowExamples] = useState(false);
|
||||
|
||||
const fetchChallenges = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/challenges");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setChallenges(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching challenges:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateChallenge = (data: {
|
||||
challengedId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pointsReward: number;
|
||||
}) => {
|
||||
startTransition(async () => {
|
||||
const result = await createChallenge(data);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi créé avec succès !");
|
||||
setShowCreateForm(false);
|
||||
fetchChallenges();
|
||||
// Rafraîchir le badge des défis
|
||||
window.dispatchEvent(new Event("refreshChallenges"));
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de la création du défi");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAcceptChallenge = (challengeId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await acceptChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage(
|
||||
"Défi accepté ! En attente de désignation du gagnant."
|
||||
);
|
||||
fetchChallenges();
|
||||
// Rafraîchir le badge des défis
|
||||
window.dispatchEvent(new Event("refreshChallenges"));
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de l'acceptation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelChallenge = (challengeId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await cancelChallenge(challengeId);
|
||||
|
||||
if (result.success) {
|
||||
setSuccessMessage("Défi annulé");
|
||||
fetchChallenges();
|
||||
// Rafraîchir le badge des défis
|
||||
window.dispatchEvent(new Event("refreshChallenges"));
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
} else {
|
||||
setErrorMessage(result.error || "Erreur lors de l'annulation");
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative w-full min-h-screen flex flex-col items-center overflow-hidden pt-24 pb-16">
|
||||
{/* Background Image */}
|
||||
<div
|
||||
className="fixed inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url('${backgroundImage}')`,
|
||||
}}
|
||||
>
|
||||
{/* Dark overlay for readability */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-b"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom,
|
||||
color-mix(in srgb, var(--background) 70%, transparent),
|
||||
color-mix(in srgb, var(--background) 60%, transparent),
|
||||
color-mix(in srgb, var(--background) 80%, transparent)
|
||||
)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-6xl mx-auto px-4 sm:px-8 py-16">
|
||||
<SectionTitle
|
||||
variant="gradient"
|
||||
size="xl"
|
||||
subtitle="Défiez vos collègues et gagnez des points"
|
||||
className="mb-16"
|
||||
>
|
||||
DÉFIS ENTRE JOUEURS
|
||||
</SectionTitle>
|
||||
<p className="text-gray-400 text-sm max-w-2xl mx-auto text-center mb-16">
|
||||
Créez des défis personnalisés, acceptez ceux de vos collègues et
|
||||
remportez des récompenses en points pour monter dans le classement
|
||||
</p>
|
||||
|
||||
{successMessage && (
|
||||
<Alert variant="success" className="mb-4">
|
||||
{successMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex justify-center">
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
variant="primary"
|
||||
size="md"
|
||||
>
|
||||
{showCreateForm ? "Annuler" : "Créer un défi"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreateForm && (
|
||||
<ChallengeForm
|
||||
users={users}
|
||||
onSubmit={handleCreateChallenge}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
isPending={isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Challenges List */}
|
||||
{challenges.length === 0 ? (
|
||||
<Card variant="dark" className="p-6 text-center">
|
||||
<p className="text-gray-400">
|
||||
Vous n'avez aucun défi pour le moment.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{challenges.map((challenge) => (
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
challenge={challenge}
|
||||
currentUserId={session?.user?.id}
|
||||
onAccept={handleAcceptChallenge}
|
||||
onCancel={handleCancelChallenge}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Examples Section */}
|
||||
<Card variant="dark" className="mt-8">
|
||||
<button
|
||||
onClick={() => setShowExamples(!showExamples)}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<h3 className="text-lg font-bold text-pixel-gold">
|
||||
💡 Exemples de défis
|
||||
</h3>
|
||||
<span className="text-pixel-gold text-xl">
|
||||
{showExamples ? "−" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
{showExamples && (
|
||||
<div className="px-4 pb-4 space-y-4 border-t border-[var(--border)] pt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
|
||||
<h4 className="font-bold text-pixel-gold mb-2">
|
||||
Qui participera à plus d'événements ce mois ?
|
||||
</h4>
|
||||
<p className="text-sm text-gray-300">
|
||||
Le joueur qui participe au plus grand nombre
|
||||
d'événements organisés ce mois remporte le défi. Les
|
||||
événements doivent être validés par un admin pour compter.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
Points suggérés: 150
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
|
||||
<h4 className="font-bold text-pixel-gold mb-2">
|
||||
Premier à atteindre le niveau 10
|
||||
</h4>
|
||||
<p className="text-sm text-gray-300">
|
||||
Le premier joueur à atteindre le niveau 10 remporte le défi.
|
||||
Le niveau est calculé automatiquement selon le score total.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
Points suggérés: 200
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
|
||||
<h4 className="font-bold text-pixel-gold mb-2">
|
||||
Meilleur feedback sur un événement
|
||||
</h4>
|
||||
<p className="text-sm text-gray-300">
|
||||
Le joueur qui donne le feedback le plus détaillé et
|
||||
constructif sur un événement remporte le défi. L'admin
|
||||
désignera le gagnant selon la qualité du feedback.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
Points suggérés: 100
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
|
||||
<h4 className="font-bold text-pixel-gold mb-2">
|
||||
Plus grand nombre de points gagnés cette semaine
|
||||
</h4>
|
||||
<p className="text-sm text-gray-300">
|
||||
Le joueur qui accumule le plus de points cette semaine
|
||||
remporte le défi. Seuls les points gagnés après
|
||||
l'acceptation du défi comptent.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
Points suggérés: 250
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-black/40 rounded border border-[var(--border)]">
|
||||
<h4 className="font-bold text-pixel-gold mb-2">
|
||||
Défi créatif : meilleure bio de profil
|
||||
</h4>
|
||||
<p className="text-sm text-gray-300">
|
||||
Le joueur avec la bio de profil la plus créative et
|
||||
originale remporte le défi. L'admin désignera le
|
||||
gagnant selon l'originalité et la qualité de la bio.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
Points suggérés: 120
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -115,6 +115,8 @@ export default function EventsPageSection({
|
||||
|
||||
// Ref pour tracker si on a déjà utilisé les données initiales
|
||||
const hasUsedInitialData = useRef(hasInitialData);
|
||||
// Ref pour tracker si on a déjà fait les appels API
|
||||
const hasFetchedRegistrations = useRef(false);
|
||||
|
||||
// Séparer et trier les événements (du plus récent au plus ancien)
|
||||
// Le statut est calculé automatiquement en fonction de la date
|
||||
@@ -179,11 +181,24 @@ export default function EventsPageSection({
|
||||
return;
|
||||
}
|
||||
|
||||
// Si on a déjà fait les appels API, ne pas refaire
|
||||
if (hasFetchedRegistrations.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si pas de session, ne rien faire (on garde les données vides)
|
||||
if (!session?.user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si pas d'événements, ne rien faire
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Marquer qu'on va faire les appels
|
||||
hasFetchedRegistrations.current = true;
|
||||
|
||||
// Charger les inscriptions depuis l'API seulement si on n'a pas de données initiales
|
||||
// On charge pour tous les événements (passés et à venir) pour permettre le feedback
|
||||
const checkRegistrations = async () => {
|
||||
@@ -206,7 +221,8 @@ export default function EventsPageSection({
|
||||
};
|
||||
|
||||
checkRegistrations();
|
||||
}, [session?.user?.id, events]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.user?.id]);
|
||||
|
||||
// Fonctions pour le calendrier
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
@@ -521,7 +537,7 @@ export default function EventsPageSection({
|
||||
)}
|
||||
{getEventStatus(event) === "LIVE" && (
|
||||
<Button variant="danger" size="md" className="w-full animate-pulse">
|
||||
Rejoindre en direct
|
||||
Connectez-vous à Teams, c'est maintenant !
|
||||
</Button>
|
||||
)}
|
||||
{getEventStatus(event) === "PAST" && (
|
||||
@@ -564,6 +580,8 @@ export default function EventsPageSection({
|
||||
...prev,
|
||||
[eventId]: true,
|
||||
}));
|
||||
// Rafraîchir le score dans le header
|
||||
window.dispatchEvent(new Event("refreshUserScore"));
|
||||
} else {
|
||||
setError(result.error || "Une erreur est survenue");
|
||||
}
|
||||
@@ -583,6 +601,8 @@ export default function EventsPageSection({
|
||||
...prev,
|
||||
[eventId]: false,
|
||||
}));
|
||||
// Rafraîchir le score dans le header
|
||||
window.dispatchEvent(new Event("refreshUserScore"));
|
||||
} else {
|
||||
setError(result.error || "Une erreur est survenue");
|
||||
}
|
||||
@@ -812,7 +832,7 @@ export default function EventsPageSection({
|
||||
size="lg"
|
||||
className="w-full animate-pulse"
|
||||
>
|
||||
Rejoindre en direct
|
||||
Connectez-vous à Teams, c'est maintenant !
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,34 +9,61 @@ interface EventsSectionProps {
|
||||
}
|
||||
|
||||
export default function EventsSection({ events }: EventsSectionProps) {
|
||||
if (events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<section className="w-full bg-gray-950 border-t border-pixel-gold/30 py-16">
|
||||
<section
|
||||
className="w-full py-16 border-t relative z-10"
|
||||
style={{
|
||||
backgroundColor: "var(--card-column)",
|
||||
borderColor: "color-mix(in srgb, var(--pixel-gold) 50%, transparent)",
|
||||
borderTopWidth: "2px",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
<div className="flex flex-col md:flex-row items-center justify-around gap-8">
|
||||
{events.map((event, index) => (
|
||||
<div key={index} className="flex flex-col items-center">
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<span className="text-pixel-gold text-xs uppercase tracking-widest mb-2">
|
||||
Événement
|
||||
</span>
|
||||
<div className="w-16 h-px bg-pixel-gold"></div>
|
||||
{events.length === 0 ? (
|
||||
<div className="text-center">
|
||||
<p
|
||||
className="text-base"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Aucun événement à venir pour le moment
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col md:flex-row items-center justify-around gap-8">
|
||||
{events.map((event, index) => (
|
||||
<div key={index} className="flex flex-col items-center">
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<span
|
||||
className="text-xs uppercase tracking-widest mb-2"
|
||||
style={{ color: "var(--pixel-gold)" }}
|
||||
>
|
||||
Événement
|
||||
</span>
|
||||
<div
|
||||
className="w-16 h-px"
|
||||
style={{ backgroundColor: "var(--pixel-gold)" }}
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
className="text-lg font-bold mb-2 uppercase tracking-wide"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{new Date(event.date).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="text-base text-center"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{event.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-white text-lg font-bold mb-2 uppercase tracking-wide">
|
||||
{new Date(event.date).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<div className="text-white text-base text-center">
|
||||
{event.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -68,18 +68,22 @@ export default function FeedbackModal({
|
||||
if (!eventId) return;
|
||||
|
||||
try {
|
||||
// Récupérer l'événement
|
||||
const eventResponse = await fetch(`/api/events/${eventId}`);
|
||||
// Paralléliser les appels API
|
||||
const [eventResponse, feedbackResponse] = await Promise.all([
|
||||
fetch(`/api/events/${eventId}`),
|
||||
fetch(`/api/feedback/${eventId}`),
|
||||
]);
|
||||
|
||||
if (!eventResponse.ok) {
|
||||
setError("Événement introuvable");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventData = await eventResponse.json();
|
||||
setEvent(eventData);
|
||||
|
||||
// Récupérer le feedback existant si disponible
|
||||
const feedbackResponse = await fetch(`/api/feedback/${eventId}`);
|
||||
// Traiter le feedback
|
||||
if (feedbackResponse.ok) {
|
||||
const feedbackData = await feedbackResponse.json();
|
||||
if (feedbackData.feedback) {
|
||||
@@ -151,6 +155,9 @@ export default function FeedbackModal({
|
||||
});
|
||||
}
|
||||
|
||||
// Rafraîchir le score dans le header
|
||||
window.dispatchEvent(new Event("refreshUserScore"));
|
||||
|
||||
// Fermer la modale après 1.5 secondes
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
|
||||
169
components/houses/HouseCard.tsx
Normal file
169
components/houses/HouseCard.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"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) {
|
||||
// Rafraîchir le badge d'invitations/demandes dans le header
|
||||
window.dispatchEvent(new Event("refreshInvitations"));
|
||||
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-lg sm: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>
|
||||
);
|
||||
}
|
||||
|
||||
94
components/houses/HouseForm.tsx
Normal file
94
components/houses/HouseForm.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"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) {
|
||||
// Rafraîchir le score dans le header si on crée une maison (pas si on met à jour)
|
||||
if (!house) {
|
||||
window.dispatchEvent(new Event("refreshUserScore"));
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
628
components/houses/HouseManagement.tsx
Normal file
628
components/houses/HouseManagement.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Card from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import HouseForm from "./HouseForm";
|
||||
import RequestList from "./RequestList";
|
||||
import Alert from "@/components/ui/Alert";
|
||||
import { deleteHouse, leaveHouse, removeMember } from "@/actions/houses/update";
|
||||
import { inviteUser, cancelInvitation } 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 HouseInvitation {
|
||||
id: string;
|
||||
invitee: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
inviter: {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
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 [invitations, setInvitations] = useState<HouseInvitation[]>([]);
|
||||
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, isAdmin]);
|
||||
|
||||
const fetchInvitations = useCallback(async () => {
|
||||
if (!house || !isAdmin) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/houses/${house.id}/invitations?status=PENDING`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setInvitations(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching invitations:", error);
|
||||
}
|
||||
}, [house, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
// Utiliser un timeout pour éviter l'appel synchrone de setState dans l'effect
|
||||
const timeout = setTimeout(() => {
|
||||
fetchInvitations();
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [fetchInvitations]);
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
fetchInvitations();
|
||||
onUpdate?.();
|
||||
}, [fetchInvitations, onUpdate]);
|
||||
|
||||
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) {
|
||||
// Rafraîchir le score dans le header (le créateur perd des points)
|
||||
window.dispatchEvent(new Event("refreshUserScore"));
|
||||
handleUpdate();
|
||||
} 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) {
|
||||
window.dispatchEvent(new Event("refreshUserScore"));
|
||||
handleUpdate();
|
||||
} 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) {
|
||||
// Rafraîchir le badge d'invitations/demandes dans le header (pour l'invité)
|
||||
window.dispatchEvent(new Event("refreshInvitations"));
|
||||
setSuccess("Invitation envoyée");
|
||||
setShowInviteForm(false);
|
||||
setSelectedUserId("");
|
||||
// Rafraîchir la liste des invitations
|
||||
await fetchInvitations();
|
||||
handleUpdate();
|
||||
} 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">
|
||||
<h2
|
||||
className="text-lg sm:text-xl font-bold mb-4"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
Ma Maison
|
||||
</h2>
|
||||
<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"
|
||||
style={{
|
||||
borderColor: `color-mix(in srgb, var(--accent) 40%, var(--border))`,
|
||||
borderWidth: "2px",
|
||||
boxShadow: `0 0 20px color-mix(in srgb, var(--accent) 10%, transparent)`,
|
||||
}}
|
||||
>
|
||||
<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 sm:text-2xl font-bold mb-2 break-words"
|
||||
style={{
|
||||
color: "var(--accent)",
|
||||
textShadow: `0 0 10px color-mix(in srgb, var(--accent) 30%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{house.name}
|
||||
</h3>
|
||||
{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);
|
||||
handleUpdate();
|
||||
}}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<h4
|
||||
className="text-sm font-semibold uppercase tracking-wider mb-3"
|
||||
style={{
|
||||
color: "var(--primary)",
|
||||
borderBottom: `2px solid color-mix(in srgb, var(--primary) 30%, transparent)`,
|
||||
paddingBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
Membres ({house.memberships?.length ?? 0})
|
||||
{isAdmin && invitations.length > 0 && (
|
||||
<span className="ml-2 text-xs normal-case" style={{ color: "var(--muted-foreground)" }}>
|
||||
• {invitations.length} invitation{invitations.length > 1 ? "s" : ""} en cours
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{(house.memberships || []).map((membership) => {
|
||||
const isCurrentUser = membership.user.id === session?.user?.id;
|
||||
const roleColor =
|
||||
membership.role === "OWNER"
|
||||
? "var(--accent)"
|
||||
: membership.role === "ADMIN"
|
||||
? "var(--primary)"
|
||||
: "var(--muted-foreground)";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={membership.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 rounded"
|
||||
style={{
|
||||
backgroundColor: isCurrentUser
|
||||
? "color-mix(in srgb, var(--primary) 10%, var(--card-hover))"
|
||||
: "var(--card-hover)",
|
||||
borderLeft: `3px solid ${roleColor}`,
|
||||
borderColor: isCurrentUser
|
||||
? "var(--primary)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<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 border-2"
|
||||
style={{ borderColor: roleColor }}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
className="font-semibold block sm:inline"
|
||||
style={{
|
||||
color: isCurrentUser
|
||||
? "var(--primary)"
|
||||
: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{membership.user.username}
|
||||
{isCurrentUser && " (Vous)"}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs block sm:inline sm:ml-2"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
<span style={{ color: "var(--success)" }}>
|
||||
{membership.user.score} pts
|
||||
</span>
|
||||
{" • "}
|
||||
<span style={{ color: "var(--blue)" }}>
|
||||
Niveau {membership.user.level}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
className="text-xs uppercase px-2 py-1 rounded font-bold"
|
||||
style={{
|
||||
color: roleColor,
|
||||
backgroundColor: `color-mix(in srgb, ${roleColor} 15%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${roleColor} 30%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{membership.role === "OWNER" && "👑 "}
|
||||
{membership.role}
|
||||
</span>
|
||||
{isAdmin &&
|
||||
!isCurrentUser &&
|
||||
(isOwner || membership.role === "MEMBER") &&
|
||||
membership.role !== "OWNER" && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Êtes-vous sûr de vouloir retirer ${membership.user.username} de la maison ?`
|
||||
)
|
||||
) {
|
||||
startTransition(async () => {
|
||||
const result = await removeMember(
|
||||
house.id,
|
||||
membership.user.id
|
||||
);
|
||||
if (result.success) {
|
||||
// Rafraîchir le score dans le header (le membre retiré perd des points)
|
||||
window.dispatchEvent(
|
||||
new Event("refreshUserScore")
|
||||
);
|
||||
handleUpdate();
|
||||
} else {
|
||||
setError(
|
||||
result.error ||
|
||||
"Erreur lors du retrait du membre"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={isPending}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
Retirer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isAdmin && invitations.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h5
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-2"
|
||||
style={{
|
||||
color: "var(--primary)",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
Invitations en cours
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{invitations
|
||||
.filter((inv) => inv.status === "PENDING")
|
||||
.map((invitation) => (
|
||||
<div
|
||||
key={invitation.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 rounded"
|
||||
style={{
|
||||
backgroundColor: "var(--card-hover)",
|
||||
borderLeft: `3px solid var(--primary)`,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{invitation.invitee.avatar && (
|
||||
<img
|
||||
src={invitation.invitee.avatar}
|
||||
alt={invitation.invitee.username}
|
||||
className="w-8 h-8 rounded-full flex-shrink-0 border-2"
|
||||
style={{ borderColor: "var(--primary)" }}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
className="font-semibold block sm:inline"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{invitation.invitee.username}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs block sm:inline sm:ml-2"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Invité par {invitation.inviter.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
className="text-xs uppercase px-2 py-1 rounded font-bold"
|
||||
style={{
|
||||
color: "var(--primary)",
|
||||
backgroundColor: `color-mix(in srgb, var(--primary) 15%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, var(--primary) 30%, transparent)`,
|
||||
}}
|
||||
>
|
||||
En attente
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Êtes-vous sûr de vouloir annuler l'invitation pour ${invitation.invitee.username} ?`
|
||||
)
|
||||
) {
|
||||
startTransition(async () => {
|
||||
const result = await cancelInvitation(
|
||||
invitation.id
|
||||
);
|
||||
if (result.success) {
|
||||
window.dispatchEvent(
|
||||
new Event("refreshInvitations")
|
||||
);
|
||||
handleUpdate();
|
||||
} else {
|
||||
setError(
|
||||
result.error ||
|
||||
"Erreur lors de l'annulation"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={isPending}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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">
|
||||
<h2
|
||||
className="text-lg sm:text-xl font-bold mb-4"
|
||||
style={{
|
||||
color: "var(--purple)",
|
||||
borderBottom: `2px solid color-mix(in srgb, var(--purple) 30%, transparent)`,
|
||||
paddingBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
Demandes d'adhésion
|
||||
</h2>
|
||||
<RequestList requests={pendingRequests} onUpdate={handleUpdate} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
components/houses/HousesSection.tsx
Normal file
265
components/houses/HousesSection.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } 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 = useCallback(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);
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
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 {
|
||||
// Utiliser un timeout pour éviter setState synchrone dans effect
|
||||
const timeout = setTimeout(() => {
|
||||
fetchHouses();
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [searchTerm, fetchHouses]);
|
||||
|
||||
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="xl"
|
||||
subtitle="Rejoignez une maison ou créez la vôtre"
|
||||
className="mb-16"
|
||||
>
|
||||
MAISONS
|
||||
</SectionTitle>
|
||||
<p className="text-gray-400 text-sm max-w-2xl mx-auto text-center mb-16">
|
||||
Formez des équipes, créez votre propre maison et rivalisez avec les
|
||||
autres maisons pour dominer le classement collectif
|
||||
</p>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{session?.user && (
|
||||
<>
|
||||
{invitations.length > 0 && (
|
||||
<Card className="p-4 sm:p-6">
|
||||
<h2
|
||||
className="text-lg sm:text-xl font-bold mb-4"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
Mes Invitations
|
||||
</h2>
|
||||
<InvitationList
|
||||
invitations={invitations}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-4 sm:p-6">
|
||||
<h2
|
||||
className="text-lg sm:text-xl font-bold mb-4"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
Ma Maison
|
||||
</h2>
|
||||
{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">
|
||||
<h2
|
||||
className="text-lg sm:text-xl font-bold mb-4"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
Toutes les Maisons
|
||||
</h2>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
129
components/houses/InvitationList.tsx
Normal file
129
components/houses/InvitationList.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"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) {
|
||||
// Rafraîchir le score dans le header (l'utilisateur reçoit des points)
|
||||
window.dispatchEvent(new Event("refreshUserScore"));
|
||||
// Rafraîchir le badge d'invitations dans le header
|
||||
window.dispatchEvent(new Event("refreshInvitations"));
|
||||
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) {
|
||||
// Rafraîchir le badge d'invitations dans le header
|
||||
window.dispatchEvent(new Event("refreshInvitations"));
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
125
components/houses/RequestList.tsx
Normal file
125
components/houses/RequestList.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"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) {
|
||||
// Rafraîchir le score dans le header (le requester reçoit des points)
|
||||
window.dispatchEvent(new Event("refreshUserScore"));
|
||||
// Rafraîchir le badge d'invitations/demandes dans le header (le requester n'a plus de demande en attente)
|
||||
window.dispatchEvent(new Event("refreshInvitations"));
|
||||
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) {
|
||||
// Rafraîchir le badge d'invitations/demandes dans le header (le requester n'a plus de demande en attente)
|
||||
window.dispatchEvent(new Event("refreshInvitations"));
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
className="w-full py-6 px-4 sm:px-8 border-t"
|
||||
className="w-full py-6 px-4 sm:px-8 border-t relative z-10"
|
||||
style={{
|
||||
backgroundColor: "var(--background)",
|
||||
borderColor: "color-mix(in srgb, var(--gray-800) 30%, transparent)",
|
||||
@@ -37,4 +37,3 @@ export default function Footer() {
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Avatar } from "@/components/ui";
|
||||
import { getCharacterClassIcon, getCharacterClassName, type CharacterClass } from "@/lib/character-classes";
|
||||
import {
|
||||
getCharacterClassIcon,
|
||||
getCharacterClassName,
|
||||
type CharacterClass,
|
||||
} from "@/lib/character-classes";
|
||||
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
@@ -99,7 +103,8 @@ export default function Leaderboard() {
|
||||
</span>
|
||||
{entry.characterClass && (
|
||||
<span className="text-xs text-gray-400 uppercase tracking-wider">
|
||||
[{getCharacterClassIcon(entry.characterClass)} {getCharacterClassName(entry.characterClass)}]
|
||||
[{getCharacterClassIcon(entry.characterClass)}{" "}
|
||||
{getCharacterClassName(entry.characterClass)}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,8 +26,29 @@ interface LeaderboardEntry {
|
||||
characterClass?: CharacterClass | null;
|
||||
}
|
||||
|
||||
interface HouseMember {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
score: number;
|
||||
level: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface HouseLeaderboardEntry {
|
||||
rank: number;
|
||||
houseId: string;
|
||||
houseName: string;
|
||||
totalScore: number;
|
||||
memberCount: number;
|
||||
averageScore: number;
|
||||
description: string | null;
|
||||
members: HouseMember[];
|
||||
}
|
||||
|
||||
interface LeaderboardSectionProps {
|
||||
leaderboard: LeaderboardEntry[];
|
||||
houseLeaderboard: HouseLeaderboardEntry[];
|
||||
backgroundImage: string;
|
||||
}
|
||||
|
||||
@@ -38,25 +59,34 @@ const formatScore = (score: number): string => {
|
||||
|
||||
export default function LeaderboardSection({
|
||||
leaderboard,
|
||||
houseLeaderboard,
|
||||
backgroundImage,
|
||||
}: LeaderboardSectionProps) {
|
||||
const [selectedEntry, setSelectedEntry] = useState<LeaderboardEntry | null>(
|
||||
null
|
||||
);
|
||||
const [selectedHouse, setSelectedHouse] = useState<HouseLeaderboardEntry | null>(
|
||||
null
|
||||
);
|
||||
|
||||
return (
|
||||
<BackgroundSection backgroundImage={backgroundImage}>
|
||||
{/* Title Section */}
|
||||
<SectionTitle
|
||||
variant="gradient"
|
||||
size="lg"
|
||||
size="xl"
|
||||
subtitle="Top Players"
|
||||
className="mb-12 overflow-hidden"
|
||||
className="mb-16"
|
||||
>
|
||||
LEADERBOARD
|
||||
</SectionTitle>
|
||||
<p className="text-gray-400 text-sm max-w-2xl mx-auto text-center mb-16">
|
||||
Consultez le classement des meilleurs joueurs et des maisons les plus
|
||||
performantes. Montez dans les rangs en participant aux événements et en
|
||||
relevant des défis
|
||||
</p>
|
||||
|
||||
{/* Leaderboard Table */}
|
||||
{/* Players Leaderboard Table */}
|
||||
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg backdrop-blur-sm overflow-x-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-900/80 border-b border-pixel-gold/30 grid grid-cols-12 gap-2 sm:gap-4 p-2 sm:p-4 font-bold text-[10px] sm:text-xs uppercase tracking-widest text-gray-300">
|
||||
@@ -143,6 +173,90 @@ export default function LeaderboardSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* House Leaderboard Table */}
|
||||
<div className="mt-12">
|
||||
<SectionTitle
|
||||
variant="gradient"
|
||||
size="md"
|
||||
subtitle="Top Houses"
|
||||
className="mb-8 overflow-hidden"
|
||||
>
|
||||
MAISONS
|
||||
</SectionTitle>
|
||||
|
||||
<div className="bg-black/60 border border-pixel-gold/30 rounded-lg backdrop-blur-sm overflow-x-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-900/80 border-b border-pixel-gold/30 grid grid-cols-12 gap-2 sm:gap-4 p-2 sm:p-4 font-bold text-[10px] sm:text-xs uppercase tracking-widest text-gray-300">
|
||||
<div className="col-span-2 sm:col-span-1 text-center">Rank</div>
|
||||
<div className="col-span-5 sm:col-span-6">Maison</div>
|
||||
<div className="col-span-3 text-right">Score Total</div>
|
||||
<div className="col-span-2 text-right">Membres</div>
|
||||
</div>
|
||||
|
||||
{/* Entries */}
|
||||
<div className="divide-y divide-pixel-gold/10 overflow-visible">
|
||||
{houseLeaderboard.map((house) => (
|
||||
<div
|
||||
key={house.houseId}
|
||||
className={`grid grid-cols-12 gap-2 sm:gap-4 p-2 sm:p-4 hover:bg-gray-900/50 transition relative cursor-pointer ${
|
||||
house.rank <= 3
|
||||
? "bg-gradient-to-r from-pixel-gold/10 via-pixel-gold/5 to-transparent"
|
||||
: "bg-black/40"
|
||||
}`}
|
||||
onClick={() => setSelectedHouse(house)}
|
||||
>
|
||||
{/* Rank */}
|
||||
<div className="col-span-2 sm:col-span-1 flex items-center justify-center">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-8 h-8 sm:w-10 sm:h-10 rounded-full font-bold text-xs sm:text-sm ${
|
||||
house.rank === 1
|
||||
? "bg-gradient-to-br from-pixel-gold to-orange-500 text-black shadow-lg shadow-pixel-gold/50"
|
||||
: house.rank === 2
|
||||
? "bg-gradient-to-br from-gray-400 to-gray-500 text-black"
|
||||
: house.rank === 3
|
||||
? "bg-gradient-to-br from-orange-700 to-orange-800 text-white"
|
||||
: "bg-gray-900 text-gray-400 border border-gray-800"
|
||||
}`}
|
||||
>
|
||||
{house.rank}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* House Name */}
|
||||
<div className="col-span-5 sm:col-span-6 flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
<div className="flex items-center gap-1 sm:gap-2 min-w-0">
|
||||
<span
|
||||
className={`font-bold text-xs sm:text-sm break-words ${
|
||||
house.rank <= 3 ? "text-pixel-gold" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{house.houseName}
|
||||
</span>
|
||||
{house.rank <= 3 && (
|
||||
<span className="text-pixel-gold text-xs">✦</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Score */}
|
||||
<div className="col-span-3 flex items-center justify-end">
|
||||
<span className="font-mono text-gray-300 text-xs sm:text-sm">
|
||||
{formatScore(house.totalScore)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Member Count */}
|
||||
<div className="col-span-2 flex items-center justify-end">
|
||||
<span className="font-bold text-gray-400 text-xs sm:text-sm">
|
||||
{house.memberCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-gray-500 text-sm">
|
||||
@@ -151,6 +265,112 @@ export default function LeaderboardSection({
|
||||
<p className="text-gray-600 text-xs mt-2">Rankings update every hour</p>
|
||||
</div>
|
||||
|
||||
{/* House Modal */}
|
||||
{selectedHouse && (
|
||||
<Modal
|
||||
isOpen={!!selectedHouse}
|
||||
onClose={() => setSelectedHouse(null)}
|
||||
size="md"
|
||||
>
|
||||
<div className="p-4 sm:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl sm:text-3xl font-bold text-pixel-gold uppercase tracking-wider break-words">
|
||||
{selectedHouse.houseName}
|
||||
</h2>
|
||||
<CloseButton onClick={() => setSelectedHouse(null)} size="md" />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<Card variant="default" className="p-4">
|
||||
<div className="text-xs text-gray-400 uppercase tracking-widest mb-1">
|
||||
Rank
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-pixel-gold">
|
||||
#{selectedHouse.rank}
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="default" className="p-4">
|
||||
<div className="text-xs text-gray-400 uppercase tracking-widest mb-1">
|
||||
Score Total
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-pixel-gold">
|
||||
{formatScore(selectedHouse.totalScore)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="default" className="p-4">
|
||||
<div className="text-xs text-gray-400 uppercase tracking-widest mb-1">
|
||||
Membres
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-pixel-gold">
|
||||
{selectedHouse.memberCount}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Members List */}
|
||||
<div className="border-t border-pixel-gold/30 pt-6 mb-6">
|
||||
<div className="text-xs text-pixel-gold uppercase tracking-widest mb-4 font-bold">
|
||||
Membres ({selectedHouse.memberCount})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{selectedHouse.members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between p-3 rounded"
|
||||
style={{ backgroundColor: "var(--card-hover)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
src={member.avatar}
|
||||
username={member.username}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
borderClassName="border-pixel-gold/30"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm" style={{ color: "var(--foreground)" }}>
|
||||
{member.username}
|
||||
</span>
|
||||
<span className="text-xs uppercase" style={{ color: "var(--accent)" }}>
|
||||
{member.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
Niveau {member.level}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm font-bold" style={{ color: "var(--foreground)" }}>
|
||||
{formatScore(member.score)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
points
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{selectedHouse.description && (
|
||||
<div className="border-t border-pixel-gold/30 pt-6">
|
||||
<div className="text-xs text-pixel-gold uppercase tracking-widest mb-3 font-bold">
|
||||
Description
|
||||
</div>
|
||||
<p className="text-gray-200 leading-relaxed whitespace-pre-wrap break-words">
|
||||
{selectedHouse.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Character Modal */}
|
||||
{selectedEntry && (
|
||||
<Modal
|
||||
|
||||
72
components/navigation/ChallengeBadge.tsx
Normal file
72
components/navigation/ChallengeBadge.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ChallengeBadgeProps {
|
||||
initialCount?: number;
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export default function ChallengeBadge({
|
||||
initialCount = 0,
|
||||
onNavigate,
|
||||
}: ChallengeBadgeProps) {
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
// Utiliser le count initial (déjà récupéré côté serveur)
|
||||
useEffect(() => {
|
||||
setCount(initialCount);
|
||||
}, [initialCount]);
|
||||
|
||||
// Écouter les événements de refresh des défis (déclenché après acceptation/annulation)
|
||||
useEffect(() => {
|
||||
const handleRefreshChallenges = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/challenges/active-count");
|
||||
const data = await response.json();
|
||||
setCount(data.count || 0);
|
||||
} catch (error) {
|
||||
console.error("Error fetching active challenges count:", error);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("refreshChallenges", handleRefreshChallenges);
|
||||
return () => {
|
||||
window.removeEventListener("refreshChallenges", handleRefreshChallenges);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/challenges"
|
||||
onClick={onNavigate}
|
||||
className={`inline-flex items-center gap-1.5 transition text-xs font-normal uppercase tracking-widest ${
|
||||
onNavigate ? "py-2" : ""
|
||||
}`}
|
||||
style={{ color: "var(--foreground)" }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.color = "var(--accent-color)")
|
||||
}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--foreground)")}
|
||||
title={
|
||||
count > 0
|
||||
? `${count} défi${count > 1 ? "s" : ""} actif${count > 1 ? "s" : ""}`
|
||||
: "Défis"
|
||||
}
|
||||
>
|
||||
<span>DÉFIS</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="flex h-5 w-5 min-w-[20px] items-center justify-center rounded-full text-[10px] font-bold leading-none"
|
||||
style={{
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--background)",
|
||||
}}
|
||||
>
|
||||
{count > 9 ? "9+" : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
73
components/navigation/InvitationBadge.tsx
Normal file
73
components/navigation/InvitationBadge.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface InvitationBadgeProps {
|
||||
initialCount?: number;
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export default function InvitationBadge({
|
||||
initialCount = 0,
|
||||
onNavigate,
|
||||
}: InvitationBadgeProps) {
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
// Utiliser le count initial (déjà récupéré côté serveur)
|
||||
useEffect(() => {
|
||||
setCount(initialCount);
|
||||
}, [initialCount]);
|
||||
|
||||
// Écouter les événements de refresh des invitations (déclenché après acceptation/refus)
|
||||
useEffect(() => {
|
||||
const handleRefreshInvitations = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/invitations/pending-count");
|
||||
const data = await response.json();
|
||||
setCount(data.count || 0);
|
||||
} catch (error) {
|
||||
console.error("Error fetching pending invitations count:", error);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("refreshInvitations", handleRefreshInvitations);
|
||||
return () => {
|
||||
window.removeEventListener("refreshInvitations", handleRefreshInvitations);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/houses"
|
||||
onClick={onNavigate}
|
||||
className={`inline-flex items-center gap-1.5 transition text-xs font-normal uppercase tracking-widest ${
|
||||
onNavigate ? "py-2" : ""
|
||||
}`}
|
||||
style={{ color: "var(--foreground)" }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.color = "var(--accent-color)")
|
||||
}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--foreground)")}
|
||||
title={
|
||||
count > 0
|
||||
? `${count} action${count > 1 ? "s" : ""} en attente (invitations et demandes)`
|
||||
: "Maisons"
|
||||
}
|
||||
>
|
||||
<span>MAISONS</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="flex h-5 w-5 min-w-[20px] items-center justify-center rounded-full text-[10px] font-bold leading-none"
|
||||
style={{
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--background)",
|
||||
}}
|
||||
>
|
||||
{count > 9 ? "9+" : count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import PlayerStats from "@/components/profile/PlayerStats";
|
||||
import { Button, ThemeToggle } from "@/components/ui";
|
||||
import ChallengeBadge from "./ChallengeBadge";
|
||||
import InvitationBadge from "./InvitationBadge";
|
||||
|
||||
interface UserData {
|
||||
username: string;
|
||||
@@ -15,16 +17,21 @@ interface UserData {
|
||||
xp: number;
|
||||
maxXp: number;
|
||||
level: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface NavigationProps {
|
||||
initialUserData?: UserData | null;
|
||||
initialIsAdmin?: boolean;
|
||||
initialActiveChallengesCount?: number;
|
||||
initialPendingInvitationsCount?: number;
|
||||
}
|
||||
|
||||
export default function Navigation({
|
||||
initialUserData,
|
||||
initialIsAdmin,
|
||||
initialActiveChallengesCount = 0,
|
||||
initialPendingInvitationsCount = 0,
|
||||
}: NavigationProps) {
|
||||
const { data: session } = useSession();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -113,6 +120,12 @@ export default function Navigation({
|
||||
>
|
||||
LEADERBOARD
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<InvitationBadge initialCount={initialPendingInvitationsCount} />
|
||||
<ChallengeBadge initialCount={initialActiveChallengesCount} />
|
||||
</>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
@@ -271,6 +284,18 @@ export default function Navigation({
|
||||
>
|
||||
LEADERBOARD
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<InvitationBadge
|
||||
initialCount={initialPendingInvitationsCount}
|
||||
onNavigate={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
<ChallengeBadge
|
||||
initialCount={initialActiveChallengesCount}
|
||||
onNavigate={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { userService } from "@/services/users/user.service";
|
||||
import { challengeService } from "@/services/challenges/challenge.service";
|
||||
import { houseService } from "@/services/houses/house.service";
|
||||
import Navigation from "./Navigation";
|
||||
|
||||
interface UserData {
|
||||
@@ -10,6 +12,7 @@ interface UserData {
|
||||
xp: number;
|
||||
maxXp: number;
|
||||
level: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export default async function NavigationWrapper() {
|
||||
@@ -17,22 +20,40 @@ export default async function NavigationWrapper() {
|
||||
|
||||
let userData: UserData | null = null;
|
||||
const isAdmin = session?.user?.role === "ADMIN";
|
||||
let activeChallengesCount = 0;
|
||||
let pendingHouseActionsCount = 0;
|
||||
|
||||
if (session?.user?.id) {
|
||||
const user = await userService.getUserById(session.user.id, {
|
||||
username: true,
|
||||
avatar: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
});
|
||||
// Paralléliser les appels DB
|
||||
const [user, challengesCount, houseActionsCount] = await Promise.all([
|
||||
userService.getUserById(session.user.id, {
|
||||
username: true,
|
||||
avatar: true,
|
||||
hp: true,
|
||||
maxHp: true,
|
||||
xp: true,
|
||||
maxXp: true,
|
||||
level: true,
|
||||
score: true,
|
||||
}),
|
||||
challengeService.getActiveChallengesCount(session.user.id),
|
||||
houseService.getPendingHouseActionsCount(session.user.id),
|
||||
]);
|
||||
|
||||
if (user) {
|
||||
userData = user;
|
||||
}
|
||||
|
||||
activeChallengesCount = challengesCount;
|
||||
pendingHouseActionsCount = houseActionsCount;
|
||||
}
|
||||
|
||||
return <Navigation initialUserData={userData} initialIsAdmin={isAdmin} />;
|
||||
return (
|
||||
<Navigation
|
||||
initialUserData={userData}
|
||||
initialIsAdmin={isAdmin}
|
||||
initialActiveChallengesCount={activeChallengesCount}
|
||||
initialPendingInvitationsCount={pendingHouseActionsCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { Avatar } from "@/components/ui";
|
||||
@@ -13,6 +13,7 @@ interface UserData {
|
||||
xp: number;
|
||||
maxXp: number;
|
||||
level: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface PlayerStatsProps {
|
||||
@@ -32,6 +33,7 @@ const defaultUserData: UserData = {
|
||||
xp: 0,
|
||||
maxXp: 5000,
|
||||
level: 1,
|
||||
score: 0,
|
||||
};
|
||||
|
||||
export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
|
||||
@@ -40,6 +42,31 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
|
||||
initialUserData || defaultUserData
|
||||
);
|
||||
|
||||
const refreshUserData = useCallback(async () => {
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/users/${session.user.id}`);
|
||||
const data = await res.json();
|
||||
if (data) {
|
||||
requestAnimationFrame(() => {
|
||||
setUserData({
|
||||
username: data.username || "Guest",
|
||||
avatar: data.avatar,
|
||||
hp: data.hp || 1000,
|
||||
maxHp: data.maxHp || 1000,
|
||||
xp: data.xp || 0,
|
||||
maxXp: data.maxXp || 5000,
|
||||
level: data.level || 1,
|
||||
score: data.score || 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error refreshing user data:", error);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
// Si on a déjà des données initiales, ne rien faire (déjà initialisé dans useState)
|
||||
if (initialUserData) {
|
||||
@@ -62,6 +89,7 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
|
||||
xp: data.xp || 0,
|
||||
maxXp: data.maxXp || 5000,
|
||||
level: data.level || 1,
|
||||
score: data.score || 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -77,6 +105,7 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
|
||||
xp: 0,
|
||||
maxXp: 5000,
|
||||
level: 1,
|
||||
score: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -88,51 +117,19 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
|
||||
}
|
||||
}, [session, initialUserData]);
|
||||
|
||||
const { username, avatar, hp, maxHp, xp, maxXp, level } = userData;
|
||||
|
||||
// Calculer les pourcentages cibles
|
||||
const targetHpPercentage = (hp / maxHp) * 100;
|
||||
const targetXpPercentage = (xp / maxXp) * 100;
|
||||
|
||||
// Initialiser les pourcentages à 0 si on a des données initiales (pour l'animation)
|
||||
// Sinon utiliser directement les valeurs calculées
|
||||
const [hpPercentage, setHpPercentage] = useState(
|
||||
initialUserData ? 0 : targetHpPercentage
|
||||
);
|
||||
const [xpPercentage, setXpPercentage] = useState(
|
||||
initialUserData ? 0 : targetXpPercentage
|
||||
);
|
||||
|
||||
// Écouter les événements de refresh du score
|
||||
useEffect(() => {
|
||||
// Si on a des données initiales, animer depuis 0 vers la valeur cible
|
||||
if (initialUserData) {
|
||||
const hpTimer = setTimeout(() => {
|
||||
setHpPercentage(targetHpPercentage);
|
||||
}, 100);
|
||||
const handleRefreshScore = () => {
|
||||
refreshUserData();
|
||||
};
|
||||
|
||||
const xpTimer = setTimeout(() => {
|
||||
setXpPercentage(targetXpPercentage);
|
||||
}, 200);
|
||||
window.addEventListener("refreshUserScore", handleRefreshScore);
|
||||
return () => {
|
||||
window.removeEventListener("refreshUserScore", handleRefreshScore);
|
||||
};
|
||||
}, [refreshUserData]);
|
||||
|
||||
return () => {
|
||||
clearTimeout(hpTimer);
|
||||
clearTimeout(xpTimer);
|
||||
};
|
||||
}
|
||||
// Sinon, mettre à jour directement (pour les pages Client Components)
|
||||
// Utiliser requestAnimationFrame pour éviter les cascades de rendu
|
||||
requestAnimationFrame(() => {
|
||||
setHpPercentage(targetHpPercentage);
|
||||
setXpPercentage(targetXpPercentage);
|
||||
});
|
||||
}, [targetHpPercentage, targetXpPercentage, initialUserData]);
|
||||
|
||||
const hpColor =
|
||||
hpPercentage > 60
|
||||
? "from-green-600 to-green-700"
|
||||
: hpPercentage > 30
|
||||
? "from-yellow-600 to-orange-700"
|
||||
: "from-red-700 to-red-900";
|
||||
const { username, avatar, level, score } = userData;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -150,7 +147,7 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
|
||||
</Link>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-col gap-1.5 min-w-[180px] sm:min-w-[200px]">
|
||||
<div className="flex flex-col gap-1.5 min-w-[140px] sm:min-w-[160px]">
|
||||
{/* Username & Level */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
@@ -166,57 +163,16 @@ export default function PlayerStats({ initialUserData }: PlayerStatsProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bars side by side */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* HP Bar */}
|
||||
<div className="relative h-2 flex-1 bg-gray-900 border border-gray-700 rounded overflow-hidden">
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-r ${hpColor} transition-all duration-1000 ease-out`}
|
||||
style={{ width: `${hpPercentage}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer"></div>
|
||||
</div>
|
||||
{hpPercentage < 30 && (
|
||||
<div className="absolute inset-0 border border-red-500 rounded animate-pulse"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* XP Bar */}
|
||||
<div className="relative h-2 flex-1 bg-gray-900 border border-pixel-gold/30 rounded overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-pixel-gold/80 via-pixel-gold/70 to-pixel-gold/80 transition-all duration-1000 ease-out"
|
||||
style={{ width: `${xpPercentage}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Score Display */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-400 font-pixel text-xs uppercase">
|
||||
Score
|
||||
</div>
|
||||
{/* Labels */}
|
||||
<div className="flex items-center gap-2 text-[8px] font-pixel text-gray-400">
|
||||
<div className="flex-1 text-left">
|
||||
HP {hp} / {maxHp}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
XP {formatNumber(xp)} / {formatNumber(maxXp)}
|
||||
</div>
|
||||
<div className="text-pixel-gold font-gaming font-bold text-sm">
|
||||
{formatNumber(score)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,12 +171,17 @@ export default function ProfileForm({
|
||||
{/* Title Section */}
|
||||
<SectionTitle
|
||||
variant="gradient"
|
||||
size="lg"
|
||||
size="xl"
|
||||
subtitle="Gérez votre profil"
|
||||
className="mb-12"
|
||||
className="mb-16"
|
||||
>
|
||||
PROFIL
|
||||
</SectionTitle>
|
||||
<p className="text-gray-400 text-sm max-w-2xl mx-auto text-center mb-16">
|
||||
Personnalisez votre avatar, votre bio, votre classe de personnage et
|
||||
consultez vos statistiques. Gérez vos préférences et votre mot de
|
||||
passe
|
||||
</p>
|
||||
|
||||
{/* Profile Card */}
|
||||
<Card variant="default" className="overflow-hidden">
|
||||
|
||||
@@ -27,7 +27,8 @@ export default function Alert({
|
||||
color: "var(--success)",
|
||||
},
|
||||
error: {
|
||||
backgroundColor: "color-mix(in srgb, var(--destructive) 20%, transparent)",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--destructive) 20%, transparent)",
|
||||
borderColor: "color-mix(in srgb, var(--destructive) 50%, transparent)",
|
||||
color: "var(--destructive)",
|
||||
},
|
||||
@@ -53,4 +54,3 @@ export default function Alert({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ interface AvatarProps {
|
||||
className?: string;
|
||||
borderClassName?: string;
|
||||
fallbackText?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
@@ -28,6 +29,7 @@ export default function Avatar({
|
||||
className = "",
|
||||
borderClassName = "",
|
||||
fallbackText,
|
||||
style,
|
||||
}: AvatarProps) {
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const prevSrcRef = useRef<string | null | undefined>(undefined);
|
||||
@@ -53,6 +55,7 @@ export default function Avatar({
|
||||
style={{
|
||||
backgroundColor: "var(--card)",
|
||||
borderColor: "var(--border)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{displaySrc ? (
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function BackgroundSection({
|
||||
>
|
||||
{/* Background Image */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
className="fixed inset-0 bg-cover bg-center bg-no-repeat z-0"
|
||||
style={{
|
||||
backgroundImage: `url('${backgroundImage}')`,
|
||||
}}
|
||||
@@ -49,4 +49,3 @@ export default function BackgroundSection({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HTMLAttributes, ReactNode } from "react";
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
children: ReactNode;
|
||||
variant?: "default" | "success" | "warning" | "danger" | "info";
|
||||
size?: "sm" | "md";
|
||||
size?: "xs" | "sm" | "md";
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
@@ -17,6 +17,7 @@ const variantClasses = {
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "px-1.5 py-0.5 text-[9px] sm:text-[10px]",
|
||||
sm: "px-2 py-1 text-[10px] sm:text-xs",
|
||||
md: "px-3 py-1 text-xs",
|
||||
};
|
||||
@@ -30,7 +31,8 @@ export default function Badge({
|
||||
}: BadgeProps) {
|
||||
const variantStyles = {
|
||||
default: {
|
||||
backgroundColor: "color-mix(in srgb, var(--accent-color) 20%, transparent)",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--accent-color) 20%, transparent)",
|
||||
borderColor: "color-mix(in srgb, var(--accent-color) 50%, transparent)",
|
||||
color: "var(--accent-color)",
|
||||
},
|
||||
@@ -45,7 +47,8 @@ export default function Badge({
|
||||
color: "var(--yellow)",
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: "color-mix(in srgb, var(--destructive) 20%, transparent)",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--destructive) 20%, transparent)",
|
||||
borderColor: "color-mix(in srgb, var(--destructive) 50%, transparent)",
|
||||
color: "var(--destructive)",
|
||||
},
|
||||
@@ -66,4 +69,3 @@ export default function Badge({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { ButtonHTMLAttributes, ReactNode, ElementType } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "success" | "danger" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
children: ReactNode;
|
||||
as?: ElementType;
|
||||
}
|
||||
} & (
|
||||
| { as?: Exclude<ElementType, typeof Link> }
|
||||
| { as: typeof Link; href: string }
|
||||
);
|
||||
|
||||
const variantClasses = {
|
||||
primary: "btn-primary border transition-colors",
|
||||
|
||||
@@ -39,4 +39,3 @@ export default function Card({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,4 +26,3 @@ export default function CloseButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user