feat: implement Moving Motivators feature with session management, real-time event handling, and UI components for enhanced user experience

This commit is contained in:
Julien Froidefond
2025-11-28 08:40:39 +01:00
parent a5c17e23f6
commit 448cf61e66
26 changed files with 3191 additions and 183 deletions

BIN
dev.db

Binary file not shown.

View File

@@ -16,6 +16,9 @@
"lint": "eslint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/adapter-better-sqlite3": "^7.0.1",
"@prisma/client": "^7.0.1",

56
pnpm-lock.yaml generated
View File

@@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.0)
'@hello-pangea/dnd':
specifier: ^18.0.1
version: 18.0.1(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -179,6 +188,28 @@ packages:
'@chevrotain/utils@10.5.0':
resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@electric-sql/pglite-socket@0.0.6':
resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==}
hasBin: true
@@ -2612,6 +2643,31 @@ snapshots:
'@chevrotain/utils@10.5.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.0)':
dependencies:
react: 19.2.0
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.0)
'@dnd-kit/utilities': 3.2.2(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@dnd-kit/utilities': 3.2.2(react@19.2.0)
react: 19.2.0
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.0)':
dependencies:
react: 19.2.0
tslib: 2.8.1
'@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)':
dependencies:
'@electric-sql/pglite': 0.3.2

View File

@@ -0,0 +1,67 @@
-- CreateTable
CREATE TABLE "MovingMotivatorsSession" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"participant" TEXT NOT NULL,
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "MovingMotivatorsSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "MotivatorCard" (
"id" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"orderIndex" INTEGER NOT NULL,
"influence" INTEGER NOT NULL DEFAULT 0,
"sessionId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "MotivatorCard_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MovingMotivatorsSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "MMSessionShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'EDITOR',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MMSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MovingMotivatorsSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "MMSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "MMSessionEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MMSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MovingMotivatorsSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "MMSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "MovingMotivatorsSession_userId_idx" ON "MovingMotivatorsSession"("userId");
-- CreateIndex
CREATE INDEX "MotivatorCard_sessionId_idx" ON "MotivatorCard"("sessionId");
-- CreateIndex
CREATE UNIQUE INDEX "MotivatorCard_sessionId_type_key" ON "MotivatorCard"("sessionId", "type");
-- CreateIndex
CREATE INDEX "MMSessionShare_sessionId_idx" ON "MMSessionShare"("sessionId");
-- CreateIndex
CREATE INDEX "MMSessionShare_userId_idx" ON "MMSessionShare"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "MMSessionShare_sessionId_userId_key" ON "MMSessionShare"("sessionId", "userId");
-- CreateIndex
CREATE INDEX "MMSessionEvent_sessionId_createdAt_idx" ON "MMSessionEvent"("sessionId", "createdAt");

View File

@@ -17,6 +17,10 @@ model User {
sessions Session[]
sharedSessions SessionShare[]
sessionEvents SessionEvent[]
// Moving Motivators relations
motivatorSessions MovingMotivatorsSession[]
sharedMotivatorSessions MMSessionShare[]
motivatorSessionEvents MMSessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -122,3 +126,77 @@ model SessionEvent {
@@index([sessionId, createdAt])
}
// ============================================
// Moving Motivators Workshop
// ============================================
enum MotivatorType {
STATUS // Statut
POWER // Pouvoir
ORDER // Ordre
ACCEPTANCE // Acceptation
HONOR // Honneur
MASTERY // Maîtrise
SOCIAL // Relations sociales
FREEDOM // Liberté
CURIOSITY // Curiosité
PURPOSE // But
}
model MovingMotivatorsSession {
id String @id @default(cuid())
title String
participant String // Nom du participant
date DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
cards MotivatorCard[]
shares MMSessionShare[]
events MMSessionEvent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
model MotivatorCard {
id String @id @default(cuid())
type MotivatorType
orderIndex Int // Position horizontale (1-10, importance)
influence Int @default(0) // Position verticale (-3 à +3)
sessionId String
session MovingMotivatorsSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sessionId, type]) // Une seule carte par type par session
@@index([sessionId])
}
model MMSessionShare {
id String @id @default(cuid())
sessionId String
session MovingMotivatorsSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role ShareRole @default(EDITOR)
createdAt DateTime @default(now())
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
model MMSessionEvent {
id String @id @default(cuid())
sessionId String
session MovingMotivatorsSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // CARD_MOVED, CARD_INFLUENCE_CHANGED, etc.
payload String // JSON payload
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
}

View File

@@ -0,0 +1,218 @@
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import * as motivatorsService from '@/services/moving-motivators';
// ============================================
// Session Actions
// ============================================
export async function createMotivatorSession(data: { title: string; participant: string }) {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const motivatorSession = await motivatorsService.createMotivatorSession(
session.user.id,
data
);
revalidatePath('/motivators');
return { success: true, data: motivatorSession };
} catch (error) {
console.error('Error creating motivator session:', error);
return { success: false, error: 'Erreur lors de la création' };
}
}
export async function updateMotivatorSession(
sessionId: string,
data: { title?: string; participant?: string }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await motivatorsService.updateMotivatorSession(sessionId, authSession.user.id, data);
// Emit event for real-time sync
await motivatorsService.createMotivatorSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
data
);
revalidatePath(`/motivators/${sessionId}`);
revalidatePath('/motivators');
return { success: true };
} catch (error) {
console.error('Error updating motivator session:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function deleteMotivatorSession(sessionId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await motivatorsService.deleteMotivatorSession(sessionId, authSession.user.id);
revalidatePath('/motivators');
return { success: true };
} catch (error) {
console.error('Error deleting motivator session:', error);
return { success: false, error: 'Erreur lors de la suppression' };
}
}
// ============================================
// Card Actions
// ============================================
export async function updateMotivatorCard(
cardId: string,
sessionId: string,
data: { orderIndex?: number; influence?: number }
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await motivatorsService.canEditMotivatorSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
const card = await motivatorsService.updateMotivatorCard(cardId, data);
// Emit event for real-time sync
if (data.influence !== undefined) {
await motivatorsService.createMotivatorSessionEvent(
sessionId,
authSession.user.id,
'CARD_INFLUENCE_CHANGED',
{ cardId, influence: data.influence, type: card.type }
);
} else if (data.orderIndex !== undefined) {
await motivatorsService.createMotivatorSessionEvent(
sessionId,
authSession.user.id,
'CARD_MOVED',
{ cardId, orderIndex: data.orderIndex, type: card.type }
);
}
revalidatePath(`/motivators/${sessionId}`);
return { success: true, data: card };
} catch (error) {
console.error('Error updating motivator card:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
export async function reorderMotivatorCards(sessionId: string, cardIds: string[]) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Check edit permission
const canEdit = await motivatorsService.canEditMotivatorSession(
sessionId,
authSession.user.id
);
if (!canEdit) {
return { success: false, error: 'Permission refusée' };
}
try {
await motivatorsService.reorderMotivatorCards(sessionId, cardIds);
// Emit event for real-time sync
await motivatorsService.createMotivatorSessionEvent(
sessionId,
authSession.user.id,
'CARDS_REORDERED',
{ cardIds }
);
revalidatePath(`/motivators/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error reordering motivator cards:', error);
return { success: false, error: 'Erreur lors du réordonnancement' };
}
}
export async function updateCardInfluence(
cardId: string,
sessionId: string,
influence: number
) {
return updateMotivatorCard(cardId, sessionId, { influence });
}
// ============================================
// Sharing Actions
// ============================================
export async function shareMotivatorSession(
sessionId: string,
targetEmail: string,
role: 'VIEWER' | 'EDITOR' = 'EDITOR'
) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
const share = await motivatorsService.shareMotivatorSession(
sessionId,
authSession.user.id,
targetEmail,
role
);
revalidatePath(`/motivators/${sessionId}`);
return { success: true, data: share };
} catch (error) {
console.error('Error sharing motivator session:', error);
const message =
error instanceof Error ? error.message : 'Erreur lors du partage';
return { success: false, error: message };
}
}
export async function removeMotivatorShare(sessionId: string, shareUserId: string) {
const authSession = await auth();
if (!authSession?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
try {
await motivatorsService.removeMotivatorShare(
sessionId,
authSession.user.id,
shareUserId
);
revalidatePath(`/motivators/${sessionId}`);
return { success: true };
} catch (error) {
console.error('Error removing motivator share:', error);
return { success: false, error: 'Erreur lors de la suppression du partage' };
}
}

View File

@@ -0,0 +1,118 @@
import { auth } from '@/lib/auth';
import {
canAccessMotivatorSession,
getMotivatorSessionEvents,
} from '@/services/moving-motivators';
export const dynamic = 'force-dynamic';
// Store active connections per session
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: sessionId } = await params;
const session = await auth();
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
// Check access
const hasAccess = await canAccessMotivatorSession(sessionId, session.user.id);
if (!hasAccess) {
return new Response('Forbidden', { status: 403 });
}
const userId = session.user.id;
let lastEventTime = new Date();
let controller: ReadableStreamDefaultController;
const stream = new ReadableStream({
start(ctrl) {
controller = ctrl;
// Register connection
if (!connections.has(sessionId)) {
connections.set(sessionId, new Set());
}
connections.get(sessionId)!.add(controller);
// Send initial ping
const encoder = new TextEncoder();
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
);
},
cancel() {
// Remove connection on close
connections.get(sessionId)?.delete(controller);
if (connections.get(sessionId)?.size === 0) {
connections.delete(sessionId);
}
},
});
// Poll for new events (simple approach, works with any DB)
const pollInterval = setInterval(async () => {
try {
const events = await getMotivatorSessionEvents(sessionId, lastEventTime);
if (events.length > 0) {
const encoder = new TextEncoder();
for (const event of events) {
// Don't send events to the user who created them
if (event.userId !== userId) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: event.type,
payload: JSON.parse(event.payload),
userId: event.userId,
user: event.user,
timestamp: event.createdAt,
})}\n\n`
)
);
}
lastEventTime = event.createdAt;
}
}
} catch {
// Connection might be closed
clearInterval(pollInterval);
}
}, 1000); // Poll every second
// Cleanup on abort
request.signal.addEventListener('abort', () => {
clearInterval(pollInterval);
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
// Helper to broadcast to all connections (called from actions)
export function broadcastToMotivatorSession(sessionId: string, event: object) {
const sessionConnections = connections.get(sessionId);
if (!sessionConnections) return;
const encoder = new TextEncoder();
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
for (const controller of sessionConnections) {
try {
controller.enqueue(message);
} catch {
// Connection closed, will be cleaned up
}
}
}

View File

@@ -0,0 +1,110 @@
'use client';
import { useState, useTransition, useRef, useEffect } from 'react';
import { updateMotivatorSession } from '@/actions/moving-motivators';
interface EditableMotivatorTitleProps {
sessionId: string;
initialTitle: string;
isOwner: boolean;
}
export function EditableMotivatorTitle({
sessionId,
initialTitle,
isOwner,
}: EditableMotivatorTitleProps) {
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState(initialTitle);
const [isPending, startTransition] = useTransition();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
// Update local state when prop changes (e.g., from SSE)
useEffect(() => {
if (!isEditing) {
setTitle(initialTitle);
}
}, [initialTitle, isEditing]);
const handleSave = () => {
if (!title.trim()) {
setTitle(initialTitle);
setIsEditing(false);
return;
}
if (title.trim() === initialTitle) {
setIsEditing(false);
return;
}
startTransition(async () => {
const result = await updateMotivatorSession(sessionId, { title: title.trim() });
if (!result.success) {
setTitle(initialTitle);
console.error(result.error);
}
setIsEditing(false);
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
setTitle(initialTitle);
setIsEditing(false);
}
};
if (!isOwner) {
return <h1 className="text-3xl font-bold text-foreground">{title}</h1>;
}
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
disabled={isPending}
className="w-full max-w-md rounded-lg border border-border bg-input px-3 py-1.5 text-3xl font-bold text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:opacity-50"
/>
);
}
return (
<button
onClick={() => setIsEditing(true)}
className="group flex items-center gap-2 text-left"
title="Cliquez pour modifier"
>
<h1 className="text-3xl font-bold text-foreground">{title}</h1>
<svg
className="h-5 w-5 text-muted opacity-0 transition-opacity group-hover:opacity-100"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
);
}

View File

@@ -0,0 +1,88 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getMotivatorSessionById } from '@/services/moving-motivators';
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
import { Badge } from '@/components/ui';
import { EditableMotivatorTitle } from './EditableTitle';
interface MotivatorSessionPageProps {
params: Promise<{ id: string }>;
}
export default async function MotivatorSessionPage({ params }: MotivatorSessionPageProps) {
const { id } = await params;
const authSession = await auth();
if (!authSession?.user?.id) {
return null;
}
const session = await getMotivatorSessionById(id, authSession.user.id);
if (!session) {
notFound();
}
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href="/motivators" className="hover:text-foreground">
Moving Motivators
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
{!session.isOwner && (
<Badge variant="accent" className="ml-2">
Partagé par {session.user.name || session.user.email}
</Badge>
)}
</div>
<div className="flex items-start justify-between">
<div>
<EditableMotivatorTitle
sessionId={session.id}
initialTitle={session.title}
isOwner={session.isOwner}
/>
<p className="mt-1 text-lg text-muted">
👤 {session.participant}
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">
{session.cards.filter((c) => c.influence !== 0).length} / 10 évalués
</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Live Wrapper + Board */}
<MotivatorLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
>
<MotivatorBoard
sessionId={session.id}
cards={session.cards}
canEdit={session.canEdit}
/>
</MotivatorLiveWrapper>
</main>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Input } from '@/components/ui';
import { createMotivatorSession } from '@/actions/moving-motivators';
export default function NewMotivatorSessionPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const participant = formData.get('participant') as string;
if (!title || !participant) {
setError('Veuillez remplir tous les champs');
setLoading(false);
return;
}
const result = await createMotivatorSession({ title, participant });
if (!result.success) {
setError(result.error || 'Une erreur est survenue');
setLoading(false);
return;
}
router.push(`/motivators/${result.data?.id}`);
}
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>🎯</span>
Nouvelle Session Moving Motivators
</CardTitle>
<CardDescription>
Créez une session pour explorer les motivations intrinsèques d&apos;un collaborateur
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre de la session"
name="title"
placeholder="Ex: Entretien motivation Q1 2025"
required
/>
<Input
label="Nom du participant"
name="participant"
placeholder="Ex: Jean Dupont"
required
/>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>Classez les 10 cartes de motivation par ordre d&apos;importance</li>
<li>Évaluez l&apos;influence positive ou négative de chaque motivation</li>
<li>Découvrez le récapitulatif des motivations clés</li>
</ol>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer la session
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

135
src/app/motivators/page.tsx Normal file
View File

@@ -0,0 +1,135 @@
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { Card, CardContent, Badge, Button } from '@/components/ui';
export default async function MotivatorsPage() {
const session = await auth();
if (!session?.user?.id) {
return null;
}
const sessions = await getMotivatorSessionsByUserId(session.user.id);
// Separate owned vs shared sessions
const ownedSessions = sessions.filter((s) => s.isOwner);
const sharedSessions = sessions.filter((s) => !s.isOwner);
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Moving Motivators</h1>
<p className="mt-1 text-muted">
Découvrez ce qui motive vraiment vos collaborateurs
</p>
</div>
<Link href="/motivators/new">
<Button>
<span>🎯</span>
Nouvelle Session
</Button>
</Link>
</div>
{/* Sessions Grid */}
{sessions.length === 0 ? (
<Card className="p-12 text-center">
<div className="text-5xl mb-4">🎯</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
Aucune session pour le moment
</h2>
<p className="text-muted mb-6">
Créez votre première session Moving Motivators pour explorer les motivations
intrinsèques de vos collaborateurs.
</p>
<Link href="/motivators/new">
<Button>Créer ma première session</Button>
</Link>
</Card>
) : (
<div className="space-y-8">
{/* My Sessions */}
{ownedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
📁 Mes sessions ({ownedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{ownedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
{/* Shared Sessions */}
{sharedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
🤝 Sessions partagées avec moi ({sharedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sharedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
</div>
)}
</main>
);
}
type SessionWithMeta = Awaited<ReturnType<typeof getMotivatorSessionsByUserId>>[number];
function SessionCard({ session: s }: { session: SessionWithMeta }) {
return (
<Link href={`/motivators/${s.id}`}>
<Card hover className="h-full p-6">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground line-clamp-1">
{s.title}
</h3>
<p className="text-sm text-muted">{s.participant}</p>
{!s.isOwner && (
<p className="text-xs text-muted mt-1">
Par {s.user.name || s.user.email}
</p>
)}
</div>
<div className="flex items-center gap-2">
{!s.isOwner && (
<Badge variant={s.role === 'EDITOR' ? 'primary' : 'warning'}>
{s.role === 'EDITOR' ? '✏️' : '👁️'}
</Badge>
)}
<span className="text-2xl">🎯</span>
</div>
</div>
<CardContent className="p-0">
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="primary">
{s._count.cards} motivations
</Badge>
</div>
<p className="text-xs text-muted">
Mis à jour le{' '}
{new Date(s.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -7,95 +7,182 @@ export default function Home() {
{/* Hero Section */}
<section className="mb-16 text-center">
<h1 className="mb-4 text-5xl font-bold text-foreground">
Analysez. Planifiez. <span className="text-primary">Progressez.</span>
Vos ateliers, <span className="text-primary">réinventés</span>
</h1>
<p className="mx-auto mb-8 max-w-2xl text-lg text-muted">
Créez des ateliers SWOT interactifs avec vos collaborateurs. Identifiez les forces,
faiblesses, opportunités et menaces, puis définissez ensemble une roadmap
d&apos;actions concrètes.
Des outils interactifs et collaboratifs pour accompagner vos équipes.
Analysez, comprenez et faites progresser vos collaborateurs avec des ateliers modernes.
</p>
<Link
href="/sessions/new"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3 text-lg font-semibold text-primary-foreground transition-colors hover:bg-primary-hover"
>
<span></span>
Nouvelle Session SWOT
</Link>
</section>
{/* Features Grid */}
{/* Workshops Grid */}
<section className="mb-16">
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
Comment ça marche ?
Choisissez votre atelier
</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* Strength */}
<div className="rounded-xl border border-strength-border bg-strength-bg p-6">
<div className="mb-3 text-3xl">💪</div>
<h3 className="mb-2 text-lg font-semibold text-strength">Forces</h3>
<p className="text-sm text-muted">
Les atouts et compétences sur lesquels s&apos;appuyer pour progresser.
</p>
</div>
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mx-auto">
{/* SWOT Workshop Card */}
<WorkshopCard
href="/sessions"
icon="📊"
title="Analyse SWOT"
tagline="Analysez. Planifiez. Progressez."
description="Cartographiez les forces et faiblesses de vos collaborateurs. Identifiez opportunités et menaces pour définir des actions concrètes."
features={[
'Matrice interactive Forces/Faiblesses/Opportunités/Menaces',
'Actions croisées et plan de développement',
'Collaboration en temps réel',
]}
accentColor="#06b6d4"
newHref="/sessions/new"
/>
{/* Weakness */}
<div className="rounded-xl border border-weakness-border bg-weakness-bg p-6">
<div className="mb-3 text-3xl"></div>
<h3 className="mb-2 text-lg font-semibold text-weakness">Faiblesses</h3>
<p className="text-sm text-muted">
Les axes d&apos;amélioration et points de vigilance à travailler.
</p>
</div>
{/* Opportunity */}
<div className="rounded-xl border border-opportunity-border bg-opportunity-bg p-6">
<div className="mb-3 text-3xl">🚀</div>
<h3 className="mb-2 text-lg font-semibold text-opportunity">Opportunités</h3>
<p className="text-sm text-muted">
Les occasions de développement et de croissance à saisir.
</p>
</div>
{/* Threat */}
<div className="rounded-xl border border-threat-border bg-threat-bg p-6">
<div className="mb-3 text-3xl">🛡</div>
<h3 className="mb-2 text-lg font-semibold text-threat">Menaces</h3>
<p className="text-sm text-muted">
Les risques et obstacles potentiels à anticiper.
</p>
</div>
{/* Moving Motivators Workshop Card */}
<WorkshopCard
href="/motivators"
icon="🎯"
title="Moving Motivators"
tagline="Révélez ce qui motive vraiment"
description="Explorez les 10 motivations intrinsèques de vos collaborateurs. Comprenez leur impact et alignez aspirations et missions."
features={[
'10 cartes de motivation à classer',
'Évaluation de l\'influence positive/négative',
'Récapitulatif personnalisé des motivations',
]}
accentColor="#8b5cf6"
newHref="/motivators/new"
/>
</div>
</section>
{/* Cross Actions Section */}
<section className="rounded-2xl border border-border bg-card p-8 text-center">
<h2 className="mb-4 text-2xl font-bold text-foreground">🔗 Actions Croisées</h2>
<p className="mx-auto mb-6 max-w-2xl text-muted">
La puissance du SWOT réside dans le croisement des catégories. Liez vos forces à vos
opportunités, anticipez les menaces avec vos atouts, et transformez vos faiblesses en
axes de progression.
</p>
<div className="flex flex-wrap justify-center gap-3">
<span className="rounded-full border border-strength-border bg-strength-bg px-4 py-2 text-sm font-medium text-strength">
S + O Maximiser
</span>
<span className="rounded-full border border-threat-border bg-threat-bg px-4 py-2 text-sm font-medium text-threat">
S + T Protéger
</span>
<span className="rounded-full border border-opportunity-border bg-opportunity-bg px-4 py-2 text-sm font-medium text-opportunity">
W + O Améliorer
</span>
<span className="rounded-full border border-weakness-border bg-weakness-bg px-4 py-2 text-sm font-medium text-weakness">
W + T Surveiller
</span>
{/* Benefits Section */}
<section className="rounded-2xl border border-border bg-card p-8">
<h2 className="mb-8 text-center text-2xl font-bold text-foreground">
Pourquoi nos ateliers ?
</h2>
<div className="grid gap-6 md:grid-cols-3">
<BenefitCard
icon="🤝"
title="Collaboratif"
description="Travaillez ensemble en temps réel avec vos collaborateurs et partagez facilement vos sessions."
/>
<BenefitCard
icon="💾"
title="Historique sauvegardé"
description="Retrouvez vos ateliers passés, suivez l'évolution et mesurez les progrès dans le temps."
/>
<BenefitCard
icon="✨"
title="Interface intuitive"
description="Des outils modernes avec drag & drop, pensés pour une utilisation simple et agréable."
/>
</div>
</section>
</main>
{/* Footer */}
<footer className="border-t border-border py-6 text-center text-sm text-muted">
SWOT Manager Outil d&apos;entretiens managériaux
Workshop Manager Vos ateliers managériaux en ligne
</footer>
</>
);
}
function WorkshopCard({
href,
icon,
title,
tagline,
description,
features,
accentColor,
newHref,
}: {
href: string;
icon: string;
title: string;
tagline: string;
description: string;
features: string[];
accentColor: string;
newHref: string;
}) {
return (
<div
className="group relative overflow-hidden rounded-2xl border-2 border-border bg-card p-8 transition-all hover:border-primary/50 hover:shadow-xl"
>
{/* Accent gradient */}
<div
className="absolute inset-x-0 top-0 h-1 opacity-80"
style={{ background: `linear-gradient(to right, ${accentColor}, ${accentColor}80)` }}
/>
{/* Icon & Title */}
<div className="mb-4 flex items-center gap-3">
<span className="text-4xl">{icon}</span>
<div>
<h3 className="text-xl font-bold text-foreground">{title}</h3>
<p className="text-sm font-medium" style={{ color: accentColor }}>
{tagline}
</p>
</div>
</div>
{/* Description */}
<p className="mb-6 text-muted">{description}</p>
{/* Features */}
<ul className="mb-6 space-y-2">
{features.map((feature, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-muted">
<svg
className="mt-0.5 h-4 w-4 shrink-0"
style={{ color: accentColor }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{feature}
</li>
))}
</ul>
{/* Actions */}
<div className="flex gap-3">
<Link
href={newHref}
className="flex-1 rounded-lg px-4 py-2.5 text-center font-medium text-white transition-colors"
style={{ backgroundColor: accentColor }}
>
Démarrer
</Link>
<Link
href={href}
className="rounded-lg border border-border px-4 py-2.5 font-medium text-foreground transition-colors hover:bg-card-hover"
>
Mes sessions
</Link>
</div>
</div>
);
}
function BenefitCard({
icon,
title,
description,
}: {
icon: string;
title: string;
description: string;
}) {
return (
<div className="text-center">
<div className="mb-3 text-3xl">{icon}</div>
<h3 className="mb-2 font-semibold text-foreground">{title}</h3>
<p className="text-sm text-muted">{description}</p>
</div>
);
}

View File

@@ -0,0 +1,272 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Card, Badge } from '@/components/ui';
type WorkshopType = 'all' | 'swot' | 'motivators';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: 'VIEWER' | 'EDITOR';
user: ShareUser;
}
interface SwotSession {
id: string;
title: string;
collaborator: string;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { items: number; actions: number };
workshopType: 'swot';
}
interface MotivatorSession {
id: string;
title: string;
participant: string;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
user: { id: string; name: string | null; email: string };
shares: Share[];
_count: { cards: number };
workshopType: 'motivators';
}
type AnySession = SwotSession | MotivatorSession;
interface WorkshopTabsProps {
swotSessions: SwotSession[];
motivatorSessions: MotivatorSession[];
}
export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsProps) {
const [activeTab, setActiveTab] = useState<WorkshopType>('all');
// Combine and sort all sessions
const allSessions: AnySession[] = [...swotSessions, ...motivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
// Filter based on active tab
const filteredSessions =
activeTab === 'all'
? allSessions
: activeTab === 'swot'
? swotSessions
: motivatorSessions;
// Separate by ownership
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
const sharedSessions = filteredSessions.filter((s) => !s.isOwner);
return (
<div className="space-y-6">
{/* Tabs */}
<div className="flex gap-2 border-b border-border pb-4">
<TabButton
active={activeTab === 'all'}
onClick={() => setActiveTab('all')}
icon="📋"
label="Tous"
count={allSessions.length}
/>
<TabButton
active={activeTab === 'swot'}
onClick={() => setActiveTab('swot')}
icon="📊"
label="SWOT"
count={swotSessions.length}
/>
<TabButton
active={activeTab === 'motivators'}
onClick={() => setActiveTab('motivators')}
icon="🎯"
label="Moving Motivators"
count={motivatorSessions.length}
/>
</div>
{/* Sessions */}
{filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">
Aucun atelier de ce type pour le moment
</div>
) : (
<div className="space-y-8">
{/* My Sessions */}
{ownedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
📁 Mes ateliers ({ownedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{ownedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
{/* Shared Sessions */}
{sharedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
🤝 Partagés avec moi ({sharedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sharedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
</div>
)}
</div>
);
}
function TabButton({
active,
onClick,
icon,
label,
count,
}: {
active: boolean;
onClick: () => void;
icon: string;
label: string;
count: number;
}) {
return (
<button
onClick={onClick}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors
${active
? 'bg-primary text-primary-foreground'
: 'text-muted hover:bg-card-hover hover:text-foreground'
}
`}
>
<span>{icon}</span>
<span>{label}</span>
<Badge variant={active ? 'default' : 'primary'} className="ml-1">
{count}
</Badge>
</button>
);
}
function SessionCard({ session }: { session: AnySession }) {
const isSwot = session.workshopType === 'swot';
const href = isSwot ? `/sessions/${session.id}` : `/motivators/${session.id}`;
const icon = isSwot ? '📊' : '🎯';
const participant = isSwot
? (session as SwotSession).collaborator
: (session as MotivatorSession).participant;
const accentColor = isSwot ? '#06b6d4' : '#8b5cf6';
return (
<Link href={href}>
<Card hover className="h-full p-4 relative overflow-hidden">
{/* Accent bar */}
<div
className="absolute top-0 left-0 right-0 h-1"
style={{ backgroundColor: accentColor }}
/>
{/* Header: Icon + Title + Role badge */}
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{icon}</span>
<h3 className="font-semibold text-foreground line-clamp-1 flex-1">
{session.title}
</h3>
{!session.isOwner && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: session.role === 'EDITOR' ? 'rgba(6,182,212,0.1)' : 'rgba(234,179,8,0.1)',
color: session.role === 'EDITOR' ? '#06b6d4' : '#eab308',
}}
>
{session.role === 'EDITOR' ? '✏️' : '👁️'}
</span>
)}
</div>
{/* Participant + Owner info */}
<p className="text-sm text-muted mb-3 line-clamp-1">
👤 {participant}
{!session.isOwner && (
<span className="text-xs"> · par {session.user.name || session.user.email}</span>
)}
</p>
{/* Footer: Stats + Avatars + Date */}
<div className="flex items-center justify-between text-xs">
{/* Stats */}
<div className="flex items-center gap-2 text-muted">
{isSwot ? (
<>
<span>{(session as SwotSession)._count.items} items</span>
<span>·</span>
<span>{(session as SwotSession)._count.actions} actions</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}
</div>
{/* Date */}
<span className="text-muted">
{new Date(session.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
{/* Shared with */}
{session.isOwner && session.shares.length > 0 && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<span className="text-[10px] text-muted uppercase tracking-wide">Partagé</span>
<div className="flex flex-wrap gap-1.5">
{session.shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-primary/10 text-[10px] text-primary"
title={share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
>
<span className="font-medium">
{share.user.name?.split(' ')[0] || share.user.email.split('@')[0]}
</span>
<span>{share.role === 'EDITOR' ? '✏️' : '👁️'}</span>
</div>
))}
{session.shares.length > 3 && (
<span className="text-[10px] text-muted">
+{session.shares.length - 3}
</span>
)}
</div>
</div>
)}
</Card>
</Link>
);
}

View File

@@ -1,7 +1,9 @@
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getSessionsByUserId } from '@/services/sessions';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
import { Card, CardContent, Badge, Button } from '@/components/ui';
import { WorkshopTabs } from './WorkshopTabs';
export default async function SessionsPage() {
const session = await auth();
@@ -10,129 +12,87 @@ export default async function SessionsPage() {
return null;
}
const sessions = await getSessionsByUserId(session.user.id);
// Fetch both SWOT and Moving Motivators sessions
const [swotSessions, motivatorSessions] = await Promise.all([
getSessionsByUserId(session.user.id),
getMotivatorSessionsByUserId(session.user.id),
]);
// Separate owned vs shared sessions
const ownedSessions = sessions.filter((s) => s.isOwner);
const sharedSessions = sessions.filter((s) => !s.isOwner);
// Add type to each session for unified display
const allSwotSessions = swotSessions.map((s) => ({
...s,
workshopType: 'swot' as const,
}));
const allMotivatorSessions = motivatorSessions.map((s) => ({
...s,
workshopType: 'motivators' as const,
}));
// Combine and sort by updatedAt
const allSessions = [...allSwotSessions, ...allMotivatorSessions].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const hasNoSessions = allSessions.length === 0;
return (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div className="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-foreground">Mes Sessions SWOT</h1>
<h1 className="text-3xl font-bold text-foreground">Mes Ateliers</h1>
<p className="mt-1 text-muted">
Gérez vos ateliers SWOT avec vos collaborateurs
Tous vos ateliers en un seul endroit
</p>
</div>
<Link href="/sessions/new">
<Button>
<span></span>
Nouvelle Session
</Button>
</Link>
<div className="flex gap-2">
<Link href="/sessions/new">
<Button variant="outline">
<span>📊</span>
Nouveau SWOT
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<span>🎯</span>
Nouveau Motivators
</Button>
</Link>
</div>
</div>
{/* Sessions Grid */}
{sessions.length === 0 ? (
{/* Content */}
{hasNoSessions ? (
<Card className="p-12 text-center">
<div className="text-5xl mb-4">📋</div>
<div className="text-5xl mb-4">🚀</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
Aucune session pour le moment
Commencez votre premier atelier
</h2>
<p className="text-muted mb-6">
Créez votre première session SWOT pour commencer à analyser les forces,
faiblesses, opportunités et menaces de vos collaborateurs.
<p className="text-muted mb-6 max-w-md mx-auto">
Créez un atelier SWOT pour analyser les forces et faiblesses, ou un Moving Motivators pour découvrir les motivations de vos collaborateurs.
</p>
<Link href="/sessions/new">
<Button>Créer ma première session</Button>
</Link>
<div className="flex gap-3 justify-center">
<Link href="/sessions/new">
<Button variant="outline">
<span>📊</span>
Créer un SWOT
</Button>
</Link>
<Link href="/motivators/new">
<Button>
<span>🎯</span>
Créer un Moving Motivators
</Button>
</Link>
</div>
</Card>
) : (
<div className="space-y-8">
{/* My Sessions */}
{ownedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
📁 Mes sessions ({ownedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{ownedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
{/* Shared Sessions */}
{sharedSessions.length > 0 && (
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">
🤝 Sessions partagées avec moi ({sharedSessions.length})
</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sharedSessions.map((s) => (
<SessionCard key={s.id} session={s} />
))}
</div>
</section>
)}
</div>
<WorkshopTabs
swotSessions={allSwotSessions}
motivatorSessions={allMotivatorSessions}
/>
)}
</main>
);
}
type SessionWithMeta = Awaited<ReturnType<typeof getSessionsByUserId>>[number];
function SessionCard({ session: s }: { session: SessionWithMeta }) {
return (
<Link href={`/sessions/${s.id}`}>
<Card hover className="h-full p-6">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground line-clamp-1">
{s.title}
</h3>
<p className="text-sm text-muted">{s.collaborator}</p>
{!s.isOwner && (
<p className="text-xs text-muted mt-1">
Par {s.user.name || s.user.email}
</p>
)}
</div>
<div className="flex items-center gap-2">
{!s.isOwner && (
<Badge variant={s.role === 'EDITOR' ? 'primary' : 'warning'}>
{s.role === 'EDITOR' ? '✏️' : '👁️'}
</Badge>
)}
<span className="text-2xl">📊</span>
</div>
</div>
<CardContent className="p-0">
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="primary">
{s._count.items} items
</Badge>
<Badge variant="success">
{s._count.actions} actions
</Badge>
</div>
<p className="text-xs text-muted">
Mis à jour le{' '}
{new Date(s.updatedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</p>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useSession, signOut } from 'next-auth/react';
import { useTheme } from '@/contexts/ThemeContext';
import { useState } from 'react';
@@ -9,23 +10,89 @@ export function Header() {
const { theme, toggleTheme } = useTheme();
const { data: session, status } = useSession();
const [menuOpen, setMenuOpen] = useState(false);
const [workshopsOpen, setWorkshopsOpen] = useState(false);
const pathname = usePathname();
const isActiveLink = (path: string) => pathname.startsWith(path);
return (
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-sm">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<Link href="/" className="flex items-center gap-2">
<span className="text-2xl">📊</span>
<span className="text-xl font-bold text-foreground">SWOT Manager</span>
<span className="text-2xl">🚀</span>
<span className="text-xl font-bold text-foreground">Workshop Manager</span>
</Link>
<nav className="flex items-center gap-4">
{status === 'authenticated' && session?.user && (
<Link
href="/sessions"
className="text-muted transition-colors hover:text-foreground"
>
Mes Sessions
</Link>
<>
{/* All Workshops Link */}
<Link
href="/sessions"
className={`text-sm font-medium transition-colors ${
isActiveLink('/sessions') && !isActiveLink('/sessions/')
? 'text-primary'
: 'text-muted hover:text-foreground'
}`}
>
Mes Ateliers
</Link>
{/* Workshops Dropdown */}
<div className="relative">
<button
onClick={() => setWorkshopsOpen(!workshopsOpen)}
onBlur={() => setTimeout(() => setWorkshopsOpen(false), 150)}
className={`flex items-center gap-1 text-sm font-medium transition-colors ${
isActiveLink('/sessions/') || isActiveLink('/motivators')
? 'text-primary'
: 'text-muted hover:text-foreground'
}`}
>
Ateliers
<svg
className={`h-4 w-4 transition-transform ${workshopsOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{workshopsOpen && (
<div className="absolute left-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg">
<Link
href="/sessions/new"
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
onClick={() => setWorkshopsOpen(false)}
>
<span className="text-lg">📊</span>
<div>
<div className="font-medium">Analyse SWOT</div>
<div className="text-xs text-muted">Forces, faiblesses, opportunités</div>
</div>
</Link>
<Link
href="/motivators/new"
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
onClick={() => setWorkshopsOpen(false)}
>
<span className="text-lg">🎯</span>
<div>
<div className="font-medium">Moving Motivators</div>
<div className="text-xs text-muted">Motivations intrinsèques</div>
</div>
</Link>
</div>
)}
</div>
</>
)}
<button

View File

@@ -0,0 +1,143 @@
'use client';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
import { MOTIVATOR_BY_TYPE } from '@/lib/types';
interface InfluenceZoneProps {
cards: MotivatorCardType[];
onInfluenceChange: (cardId: string, influence: number) => void;
canEdit: boolean;
}
export function InfluenceZone({ cards, onInfluenceChange, canEdit }: InfluenceZoneProps) {
// Sort by importance (orderIndex)
const sortedCards = [...cards].sort((a, b) => b.orderIndex - a.orderIndex);
return (
<div className="space-y-4">
{/* Legend */}
<div className="flex justify-center gap-8 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-muted">Influence négative</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400" />
<span className="text-muted">Neutre</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-muted">Influence positive</span>
</div>
</div>
{/* Cards with sliders */}
<div className="space-y-3">
{sortedCards.map((card) => (
<InfluenceSlider
key={card.id}
card={card}
onInfluenceChange={onInfluenceChange}
disabled={!canEdit}
/>
))}
</div>
</div>
);
}
function InfluenceSlider({
card,
onInfluenceChange,
disabled,
}: {
card: MotivatorCardType;
onInfluenceChange: (cardId: string, influence: number) => void;
disabled: boolean;
}) {
const config = MOTIVATOR_BY_TYPE[card.type];
return (
<div
className={`
flex items-center gap-4 p-4 rounded-xl border border-border bg-card
transition-all hover:shadow-md
`}
>
{/* Card info */}
<div className="flex items-center gap-3 w-40 shrink-0">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl"
style={{ backgroundColor: `${config.color}20` }}
>
{config.icon}
</div>
<div>
<div className="font-medium text-foreground text-sm">{config.name}</div>
<div className="text-xs text-muted">#{card.orderIndex}</div>
</div>
</div>
{/* Influence slider */}
<div className="flex-1 flex items-center gap-4">
<span className="text-xs text-red-500 font-medium w-6 text-right">-3</span>
<div className="flex-1 relative">
{/* Track background */}
<div className="absolute inset-0 h-2 top-1/2 -translate-y-1/2 rounded-full bg-gradient-to-r from-red-500 via-gray-300 to-green-500" />
{/* Slider */}
<input
type="range"
min={-3}
max={3}
value={card.influence}
onChange={(e) => onInfluenceChange(card.id, parseInt(e.target.value))}
disabled={disabled}
className={`
relative w-full h-8 appearance-none bg-transparent cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-6
[&::-webkit-slider-thumb]:h-6
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:border-2
[&::-webkit-slider-thumb]:border-foreground
[&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-grab
[&::-webkit-slider-thumb]:active:cursor-grabbing
[&::-moz-range-thumb]:w-6
[&::-moz-range-thumb]:h-6
[&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white
[&::-moz-range-thumb]:border-2
[&::-moz-range-thumb]:border-foreground
[&::-moz-range-thumb]:shadow-md
[&::-moz-range-thumb]:cursor-grab
disabled:cursor-not-allowed
disabled:[&::-webkit-slider-thumb]:cursor-not-allowed
`}
/>
{/* Zero marker */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-4 bg-foreground/30 rounded-full pointer-events-none" />
</div>
<span className="text-xs text-green-500 font-medium w-6">+3</span>
</div>
{/* Current value */}
<div
className={`
w-12 h-8 rounded-lg flex items-center justify-center font-bold text-sm
${card.influence > 0 ? 'bg-green-500/20 text-green-600' : ''}
${card.influence < 0 ? 'bg-red-500/20 text-red-600' : ''}
${card.influence === 0 ? 'bg-muted/20 text-muted' : ''}
`}
>
{card.influence > 0 ? `+${card.influence}` : card.influence}
</div>
</div>
);
}

View File

@@ -0,0 +1,276 @@
'use client';
import { useState, useTransition } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
import { MotivatorCard } from './MotivatorCard';
import { MotivatorSummary } from './MotivatorSummary';
import { InfluenceZone } from './InfluenceZone';
import { reorderMotivatorCards, updateCardInfluence } from '@/actions/moving-motivators';
interface MotivatorBoardProps {
sessionId: string;
cards: MotivatorCardType[];
canEdit: boolean;
}
type Step = 'ranking' | 'influence' | 'summary';
export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: MotivatorBoardProps) {
const [cards, setCards] = useState(initialCards);
const [step, setStep] = useState<Step>('ranking');
const [isPending, startTransition] = useTransition();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Sort cards by orderIndex
const sortedCards = [...cards].sort((a, b) => a.orderIndex - b.orderIndex);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortedCards.findIndex((c) => c.id === active.id);
const newIndex = sortedCards.findIndex((c) => c.id === over.id);
const newCards = arrayMove(sortedCards, oldIndex, newIndex).map((card, index) => ({
...card,
orderIndex: index + 1,
}));
setCards(newCards);
// Persist to server
startTransition(async () => {
await reorderMotivatorCards(sessionId, newCards.map((c) => c.id));
});
}
function handleInfluenceChange(cardId: string, influence: number) {
setCards((prev) =>
prev.map((c) => (c.id === cardId ? { ...c, influence } : c))
);
startTransition(async () => {
await updateCardInfluence(cardId, sessionId, influence);
});
}
function nextStep() {
if (step === 'ranking') setStep('influence');
else if (step === 'influence') setStep('summary');
}
function prevStep() {
if (step === 'influence') setStep('ranking');
else if (step === 'summary') setStep('influence');
}
return (
<div className={`space-y-6 ${isPending ? 'opacity-70' : ''}`}>
{/* Progress Steps */}
<div className="flex items-center justify-center gap-4">
<StepIndicator
number={1}
label="Classement"
active={step === 'ranking'}
completed={step !== 'ranking'}
onClick={() => setStep('ranking')}
/>
<div className="h-px w-12 bg-border" />
<StepIndicator
number={2}
label="Influence"
active={step === 'influence'}
completed={step === 'summary'}
onClick={() => setStep('influence')}
/>
<div className="h-px w-12 bg-border" />
<StepIndicator
number={3}
label="Récapitulatif"
active={step === 'summary'}
completed={false}
onClick={() => setStep('summary')}
/>
</div>
{/* Step Content */}
{step === 'ranking' && (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-2">
Classez vos motivations par importance
</h2>
<p className="text-muted">
Glissez les cartes de gauche (moins important) à droite (plus important)
</p>
</div>
{/* Importance axis */}
<div className="relative">
<div className="flex justify-between text-sm text-muted mb-4 px-4">
<span> Moins important</span>
<span>Plus important </span>
</div>
{/* Cards container */}
<div className="bg-gradient-to-r from-red-500/10 via-yellow-500/10 to-green-500/10 rounded-2xl p-4 border border-border overflow-x-auto">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedCards.map((c) => c.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-2 min-w-max px-2">
{sortedCards.map((card) => (
<MotivatorCard
key={card.id}
card={card}
disabled={!canEdit}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
{/* Next button */}
<div className="flex justify-end">
<button
onClick={nextStep}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
Suivant
</button>
</div>
</div>
)}
{step === 'influence' && (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-2">
Évaluez l&apos;influence de chaque motivation
</h2>
<p className="text-muted">
Pour chaque carte, indiquez si cette motivation a une influence positive ou négative sur votre situation actuelle
</p>
</div>
<InfluenceZone
cards={sortedCards}
onInfluenceChange={handleInfluenceChange}
canEdit={canEdit}
/>
{/* Navigation buttons */}
<div className="flex justify-between">
<button
onClick={prevStep}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-card transition-colors"
>
Retour
</button>
<button
onClick={nextStep}
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
Voir le récapitulatif
</button>
</div>
</div>
)}
{step === 'summary' && (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-2">
Récapitulatif de vos Moving Motivators
</h2>
<p className="text-muted">
Voici l&apos;analyse de vos motivations et leur impact
</p>
</div>
<MotivatorSummary cards={sortedCards} />
{/* Navigation buttons */}
<div className="flex justify-start">
<button
onClick={prevStep}
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-card transition-colors"
>
Modifier
</button>
</div>
</div>
)}
</div>
);
}
function StepIndicator({
number,
label,
active,
completed,
onClick,
}: {
number: number;
label: string;
active: boolean;
completed: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`
flex flex-col items-center gap-1 transition-colors
${active ? 'text-primary' : completed ? 'text-green-500' : 'text-muted'}
`}
>
<div
className={`
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm
${active ? 'bg-primary text-primary-foreground' : ''}
${completed ? 'bg-green-500 text-white' : ''}
${!active && !completed ? 'bg-muted/20 text-muted' : ''}
`}
>
{completed ? '✓' : number}
</div>
<span className="text-xs font-medium">{label}</span>
</button>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
import { MOTIVATOR_BY_TYPE } from '@/lib/types';
interface MotivatorCardProps {
card: MotivatorCardType;
onInfluenceChange?: (influence: number) => void;
disabled?: boolean;
showInfluence?: boolean;
}
export function MotivatorCard({
card,
disabled = false,
showInfluence = false,
}: MotivatorCardProps) {
const config = MOTIVATOR_BY_TYPE[card.type];
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: card.id,
disabled,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`
relative flex flex-col items-center justify-center
w-28 h-36 rounded-xl border-2 shrink-0
bg-card shadow-md
cursor-grab active:cursor-grabbing
transition-all duration-200
${isDragging ? 'opacity-50 scale-105 shadow-xl z-50' : 'hover:shadow-lg hover:-translate-y-1'}
${disabled ? 'cursor-default opacity-60' : ''}
`}
{...attributes}
{...listeners}
>
{/* Color accent bar */}
<div
className="absolute top-0 left-0 right-0 h-2 rounded-t-lg"
style={{ backgroundColor: config.color }}
/>
{/* Icon */}
<div className="text-3xl mb-1 mt-2">{config.icon}</div>
{/* Name */}
<div
className="font-semibold text-sm text-center px-2"
style={{ color: config.color }}
>
{config.name}
</div>
{/* Description */}
<p className="text-[10px] text-muted text-center px-2 mt-1 line-clamp-2">
{config.description}
</p>
{/* Influence indicator */}
{showInfluence && card.influence !== 0 && (
<div
className={`
absolute -top-2 -right-2 w-6 h-6 rounded-full
flex items-center justify-center text-xs font-bold text-white
${card.influence > 0 ? 'bg-green-500' : 'bg-red-500'}
`}
>
{card.influence > 0 ? `+${card.influence}` : card.influence}
</div>
)}
{/* Rank badge */}
<div
className="absolute -bottom-2 left-1/2 -translate-x-1/2
bg-foreground text-background text-xs font-bold
w-5 h-5 rounded-full flex items-center justify-center"
>
{card.orderIndex}
</div>
</div>
);
}
// Non-draggable version for summary
export function MotivatorCardStatic({
card,
size = 'normal',
}: {
card: MotivatorCardType;
size?: 'small' | 'normal';
}) {
const config = MOTIVATOR_BY_TYPE[card.type];
const sizeClasses = {
small: 'w-20 h-24 text-2xl',
normal: 'w-28 h-36 text-3xl',
};
return (
<div
className={`
relative flex flex-col items-center justify-center
rounded-xl border-2 bg-card shadow-md
${sizeClasses[size]}
`}
>
{/* Color accent bar */}
<div
className="absolute top-0 left-0 right-0 h-2 rounded-t-lg"
style={{ backgroundColor: config.color }}
/>
{/* Icon */}
<div className={`mb-1 mt-2 ${size === 'small' ? 'text-xl' : 'text-3xl'}`}>
{config.icon}
</div>
{/* Name */}
<div
className={`font-semibold text-center px-2 ${size === 'small' ? 'text-xs' : 'text-sm'}`}
style={{ color: config.color }}
>
{config.name}
</div>
{/* Influence indicator */}
{card.influence !== 0 && (
<div
className={`
absolute -top-2 -right-2 rounded-full
flex items-center justify-center font-bold text-white
${card.influence > 0 ? 'bg-green-500' : 'bg-red-500'}
${size === 'small' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'}
`}
>
{card.influence > 0 ? `+${card.influence}` : card.influence}
</div>
)}
{/* Rank badge */}
<div
className={`
absolute -bottom-2 left-1/2 -translate-x-1/2
bg-foreground text-background font-bold
rounded-full flex items-center justify-center
${size === 'small' ? 'w-4 h-4 text-[10px]' : 'w-5 h-5 text-xs'}
`}
>
{card.orderIndex}
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState, useCallback } from 'react';
import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { MotivatorShareModal } from './MotivatorShareModal';
import { Button } from '@/components/ui/Button';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface MotivatorLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
children: React.ReactNode;
}
export function MotivatorLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
children,
}: MotivatorLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: MotivatorLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useMotivatorLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<div
key={share.id}
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-primary/10 text-xs font-medium text-primary"
title={share.user.name || share.user.email}
>
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShareModalOpen(true)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>
{children}
</div>
{/* Share Modal */}
<MotivatorShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
sessionId={sessionId}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
/>
</>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useState, useTransition } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface MotivatorShareModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionTitle: string;
shares: Share[];
isOwner: boolean;
}
export function MotivatorShareModal({
isOpen,
onClose,
sessionId,
sessionTitle,
shares,
isOwner,
}: MotivatorShareModalProps) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<ShareRole>('EDITOR');
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
async function handleShare(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await shareMotivatorSession(sessionId, email, role);
if (result.success) {
setEmail('');
} else {
setError(result.error || 'Erreur lors du partage');
}
});
}
async function handleRemove(userId: string) {
startTransition(async () => {
await removeMotivatorShare(sessionId, userId);
});
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
<div className="space-y-6">
{/* Session info */}
<div>
<p className="text-sm text-muted">Session Moving Motivators</p>
<p className="font-medium text-foreground">{sessionTitle}</p>
</div>
{/* Share form (only for owner) */}
{isOwner && (
<form onSubmit={handleShare} className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="Email de l'utilisateur"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1"
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as ShareRole)}
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
>
<option value="EDITOR">Éditeur</option>
<option value="VIEWER">Lecteur</option>
</select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={isPending || !email} className="w-full">
{isPending ? 'Partage...' : 'Partager'}
</Button>
</form>
)}
{/* Current shares */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
Collaborateurs ({shares.length})
</p>
{shares.length === 0 ? (
<p className="text-sm text-muted">
Aucun collaborateur pour le moment
</p>
) : (
<ul className="space-y-2">
{shares.map((share) => (
<li
key={share.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-foreground">
{share.user.name || share.user.email}
</p>
{share.user.name && (
<p className="text-xs text-muted">{share.user.email}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
</Badge>
{isOwner && (
<button
onClick={() => handleRemove(share.user.id)}
disabled={isPending}
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
title="Retirer l'accès"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
</li>
))}
</ul>
)}
</div>
{/* Help text */}
<div className="rounded-lg bg-primary/5 p-3">
<p className="text-xs text-muted">
<strong>Éditeur</strong> : peut modifier les cartes et leurs positions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</p>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
import { MotivatorCardStatic } from './MotivatorCard';
interface MotivatorSummaryProps {
cards: MotivatorCardType[];
}
export function MotivatorSummary({ cards }: MotivatorSummaryProps) {
// Sort by orderIndex (importance)
const sortedByImportance = [...cards].sort((a, b) => a.orderIndex - b.orderIndex);
// Top 3 most important (highest orderIndex)
const top3 = sortedByImportance.slice(-3).reverse();
// Bottom 3 least important (lowest orderIndex)
const bottom3 = sortedByImportance.slice(0, 3);
// Cards with positive influence
const positiveInfluence = cards.filter((c) => c.influence > 0).sort((a, b) => b.influence - a.influence);
// Cards with negative influence
const negativeInfluence = cards.filter((c) => c.influence < 0).sort((a, b) => a.influence - b.influence);
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Top 3 Most Important */}
<SummarySection
title="🏆 Top 3 - Plus importantes"
subtitle="Ces motivations vous animent le plus"
cards={top3}
emptyMessage="Classez vos cartes pour voir ce résultat"
variant="success"
/>
{/* Bottom 3 Least Important */}
<SummarySection
title="📉 Moins importantes"
subtitle="Ces motivations ont moins d'impact pour vous"
cards={bottom3}
emptyMessage="Classez vos cartes pour voir ce résultat"
variant="muted"
/>
{/* Positive Influence */}
<SummarySection
title="✨ Influence positive"
subtitle="Ces motivations sont satisfaites actuellement"
cards={positiveInfluence}
emptyMessage="Aucune motivation en influence positive"
variant="success"
/>
{/* Negative Influence */}
<SummarySection
title="⚠️ Influence négative"
subtitle="Ces motivations ne sont pas satisfaites"
cards={negativeInfluence}
emptyMessage="Aucune motivation en influence négative"
variant="danger"
/>
</div>
);
}
function SummarySection({
title,
subtitle,
cards,
emptyMessage,
variant,
}: {
title: string;
subtitle: string;
cards: MotivatorCardType[];
emptyMessage: string;
variant: 'success' | 'danger' | 'muted';
}) {
const borderColors = {
success: 'border-green-500/30 bg-green-500/5',
danger: 'border-red-500/30 bg-red-500/5',
muted: 'border-border bg-muted/5',
};
return (
<div className={`rounded-xl border-2 p-5 ${borderColors[variant]}`}>
<h3 className="font-semibold text-foreground mb-1">{title}</h3>
<p className="text-sm text-muted mb-4">{subtitle}</p>
{cards.length > 0 ? (
<div className="flex gap-3 flex-wrap justify-center">
{cards.map((card) => (
<MotivatorCardStatic key={card.id} card={card} size="small" />
))}
</div>
) : (
<p className="text-sm text-muted text-center py-4">{emptyMessage}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
export { MotivatorBoard } from './MotivatorBoard';
export { MotivatorCard, MotivatorCardStatic } from './MotivatorCard';
export { MotivatorSummary } from './MotivatorSummary';
export { InfluenceZone } from './InfluenceZone';
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
export { MotivatorShareModal } from './MotivatorShareModal';

View File

@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
export type MotivatorLiveEvent = {
type: string;
payload: Record<string, unknown>;
userId?: string;
user?: { id: string; name: string | null; email: string };
timestamp: string;
};
interface UseMotivatorLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: MotivatorLiveEvent) => void;
}
interface UseMotivatorLiveReturn {
isConnected: boolean;
lastEvent: MotivatorLiveEvent | null;
error: string | null;
}
export function useMotivatorLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseMotivatorLiveOptions): UseMotivatorLiveReturn {
const [isConnected, setIsConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<MotivatorLiveEvent | null>(null);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const onEventRef = useRef(onEvent);
const currentUserIdRef = useRef(currentUserId);
// Keep refs updated
useEffect(() => {
onEventRef.current = onEvent;
}, [onEvent]);
useEffect(() => {
currentUserIdRef.current = currentUserId;
}, [currentUserId]);
useEffect(() => {
if (!enabled || typeof window === 'undefined') return;
function connect() {
// Close existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
try {
const eventSource = new EventSource(`/api/motivators/${sessionId}/subscribe`);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
setIsConnected(true);
setError(null);
reconnectAttemptsRef.current = 0;
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as MotivatorLiveEvent;
// Handle connection event
if (data.type === 'connected') {
return;
}
// Client-side filter: ignore events created by current user
if (currentUserIdRef.current && data.userId === currentUserIdRef.current) {
return;
}
setLastEvent(data);
onEventRef.current?.(data);
// Refresh the page data when we receive an event from another user
router.refresh();
} catch (e) {
console.error('Failed to parse SSE event:', e);
}
};
eventSource.onerror = () => {
setIsConnected(false);
eventSource.close();
// Exponential backoff reconnect
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
reconnectAttemptsRef.current++;
if (reconnectAttemptsRef.current <= 5) {
reconnectTimeoutRef.current = setTimeout(connect, delay);
} else {
setError('Connexion perdue. Rechargez la page.');
}
};
} catch (e) {
setError('Impossible de se connecter au mode live');
console.error('Failed to create EventSource:', e);
}
}
connect();
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
};
}, [sessionId, enabled, router]);
return { isConnected, lastEvent, error };
}

View File

@@ -152,3 +152,151 @@ export const STATUS_LABELS: Record<ActionStatus, string> = {
done: 'Terminé',
};
// ============================================
// Moving Motivators - Type Definitions
// ============================================
export type MotivatorType =
| 'STATUS'
| 'POWER'
| 'ORDER'
| 'ACCEPTANCE'
| 'HONOR'
| 'MASTERY'
| 'SOCIAL'
| 'FREEDOM'
| 'CURIOSITY'
| 'PURPOSE';
export interface MotivatorCard {
id: string;
type: MotivatorType;
orderIndex: number; // 1-10, position horizontale (importance)
influence: number; // -3 à +3, position verticale
sessionId: string;
createdAt: Date;
updatedAt: Date;
}
export interface MovingMotivatorsSession {
id: string;
title: string;
participant: string;
date: Date;
userId: string;
cards: MotivatorCard[];
createdAt: Date;
updatedAt: Date;
}
export interface CreateMotivatorSessionInput {
title: string;
participant: string;
date?: Date;
}
export interface UpdateMotivatorSessionInput {
title?: string;
participant?: string;
date?: Date;
}
export interface UpdateMotivatorCardInput {
orderIndex?: number;
influence?: number;
}
// ============================================
// Moving Motivators - UI Config
// ============================================
export interface MotivatorConfig {
type: MotivatorType;
name: string;
icon: string;
description: string;
color: string;
}
export const MOTIVATORS_CONFIG: MotivatorConfig[] = [
{
type: 'STATUS',
name: 'Statut',
icon: '👑',
description: 'Être reconnu et respecté pour sa position',
color: '#8b5cf6', // purple
},
{
type: 'POWER',
name: 'Pouvoir',
icon: '⚡',
description: 'Avoir de l\'influence et du contrôle sur les décisions',
color: '#ef4444', // red
},
{
type: 'ORDER',
name: 'Ordre',
icon: '📋',
description: 'Avoir un environnement stable et prévisible',
color: '#6b7280', // gray
},
{
type: 'ACCEPTANCE',
name: 'Acceptation',
icon: '🤝',
description: 'Être accepté et approuvé par le groupe',
color: '#f59e0b', // amber
},
{
type: 'HONOR',
name: 'Honneur',
icon: '🏅',
description: 'Agir en accord avec ses valeurs personnelles',
color: '#eab308', // yellow
},
{
type: 'MASTERY',
name: 'Maîtrise',
icon: '🎯',
description: 'Développer ses compétences et exceller',
color: '#22c55e', // green
},
{
type: 'SOCIAL',
name: 'Relations',
icon: '👥',
description: 'Créer des liens et appartenir à un groupe',
color: '#ec4899', // pink
},
{
type: 'FREEDOM',
name: 'Liberté',
icon: '🦅',
description: 'Être autonome et indépendant',
color: '#06b6d4', // cyan
},
{
type: 'CURIOSITY',
name: 'Curiosité',
icon: '🔍',
description: 'Explorer, apprendre et découvrir',
color: '#3b82f6', // blue
},
{
type: 'PURPOSE',
name: 'But',
icon: '🧭',
description: 'Avoir un sens et contribuer à quelque chose de plus grand',
color: '#14b8a6', // teal
},
];
export const MOTIVATOR_BY_TYPE: Record<MotivatorType, MotivatorConfig> =
MOTIVATORS_CONFIG.reduce(
(acc, config) => {
acc[config.type] = config;
return acc;
},
{} as Record<MotivatorType, MotivatorConfig>
);

View File

@@ -0,0 +1,339 @@
import { prisma } from '@/services/database';
import type { ShareRole, MotivatorType } from '@prisma/client';
// ============================================
// Moving Motivators Session CRUD
// ============================================
export async function getMotivatorSessionsByUserId(userId: string) {
// Get owned sessions + shared sessions
const [owned, shared] = await Promise.all([
prisma.movingMotivatorsSession.findMany({
where: { userId },
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
cards: true,
},
},
},
orderBy: { updatedAt: 'desc' },
}),
prisma.mMSessionShare.findMany({
where: { userId },
include: {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
cards: true,
},
},
},
},
},
}),
]);
// Mark owned sessions and merge with shared
const ownedWithRole = owned.map((s) => ({ ...s, isOwner: true as const, role: 'OWNER' as const }));
const sharedWithRole = shared.map((s) => ({
...s.session,
isOwner: false as const,
role: s.role,
sharedAt: s.createdAt,
}));
return [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
export async function getMotivatorSessionById(sessionId: string, userId: string) {
// Check if user owns the session OR has it shared
const session = await prisma.movingMotivatorsSession.findFirst({
where: {
id: sessionId,
OR: [
{ userId }, // Owner
{ shares: { some: { userId } } }, // Shared with user
],
},
include: {
user: { select: { id: true, name: true, email: true } },
cards: {
orderBy: { orderIndex: 'asc' },
},
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
if (!session) return null;
// Determine user's role
const isOwner = session.userId === userId;
const share = session.shares.find((s) => s.userId === userId);
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
return { ...session, isOwner, role, canEdit };
}
// Check if user can access session (owner or shared)
export async function canAccessMotivatorSession(sessionId: string, userId: string) {
const count = await prisma.movingMotivatorsSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId } } }],
},
});
return count > 0;
}
// Check if user can edit session (owner or EDITOR role)
export async function canEditMotivatorSession(sessionId: string, userId: string) {
const count = await prisma.movingMotivatorsSession.count({
where: {
id: sessionId,
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
},
});
return count > 0;
}
const DEFAULT_MOTIVATOR_TYPES: MotivatorType[] = [
'STATUS',
'POWER',
'ORDER',
'ACCEPTANCE',
'HONOR',
'MASTERY',
'SOCIAL',
'FREEDOM',
'CURIOSITY',
'PURPOSE',
];
export async function createMotivatorSession(
userId: string,
data: { title: string; participant: string }
) {
// Create session with all 10 cards initialized
return prisma.movingMotivatorsSession.create({
data: {
...data,
userId,
cards: {
create: DEFAULT_MOTIVATOR_TYPES.map((type, index) => ({
type,
orderIndex: index + 1,
influence: 0,
})),
},
},
include: {
cards: {
orderBy: { orderIndex: 'asc' },
},
},
});
}
export async function updateMotivatorSession(
sessionId: string,
userId: string,
data: { title?: string; participant?: string }
) {
return prisma.movingMotivatorsSession.updateMany({
where: { id: sessionId, userId },
data,
});
}
export async function deleteMotivatorSession(sessionId: string, userId: string) {
return prisma.movingMotivatorsSession.deleteMany({
where: { id: sessionId, userId },
});
}
// ============================================
// Motivator Cards CRUD
// ============================================
export async function updateMotivatorCard(
cardId: string,
data: { orderIndex?: number; influence?: number }
) {
return prisma.motivatorCard.update({
where: { id: cardId },
data,
});
}
export async function reorderMotivatorCards(
sessionId: string,
cardIds: string[]
) {
const updates = cardIds.map((id, index) =>
prisma.motivatorCard.update({
where: { id },
data: { orderIndex: index + 1 },
})
);
return prisma.$transaction(updates);
}
export async function updateCardInfluence(cardId: string, influence: number) {
// Clamp influence between -3 and +3
const clampedInfluence = Math.max(-3, Math.min(3, influence));
return prisma.motivatorCard.update({
where: { id: cardId },
data: { influence: clampedInfluence },
});
}
// ============================================
// Session Sharing
// ============================================
export async function shareMotivatorSession(
sessionId: string,
ownerId: string,
targetEmail: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.movingMotivatorsSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
// Find target user
const targetUser = await prisma.user.findUnique({
where: { email: targetEmail },
});
if (!targetUser) {
throw new Error('User not found');
}
// Can't share with yourself
if (targetUser.id === ownerId) {
throw new Error('Cannot share session with yourself');
}
// Create or update share
return prisma.mMSessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: targetUser.id },
},
update: { role },
create: {
sessionId,
userId: targetUser.id,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function removeMotivatorShare(
sessionId: string,
ownerId: string,
shareUserId: string
) {
// Verify owner
const session = await prisma.movingMotivatorsSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
return prisma.mMSessionShare.deleteMany({
where: { sessionId, userId: shareUserId },
});
}
export async function getMotivatorSessionShares(sessionId: string, userId: string) {
// Verify access
if (!(await canAccessMotivatorSession(sessionId, userId))) {
throw new Error('Access denied');
}
return prisma.mMSessionShare.findMany({
where: { sessionId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
// ============================================
// Session Events (for real-time sync)
// ============================================
export type MMSessionEventType =
| 'CARD_MOVED'
| 'CARD_INFLUENCE_CHANGED'
| 'CARDS_REORDERED'
| 'SESSION_UPDATED';
export async function createMotivatorSessionEvent(
sessionId: string,
userId: string,
type: MMSessionEventType,
payload: Record<string, unknown>
) {
return prisma.mMSessionEvent.create({
data: {
sessionId,
userId,
type,
payload: JSON.stringify(payload),
},
});
}
export async function getMotivatorSessionEvents(sessionId: string, since?: Date) {
return prisma.mMSessionEvent.findMany({
where: {
sessionId,
...(since && { createdAt: { gt: since } }),
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'asc' },
});
}
export async function getLatestMotivatorEventTimestamp(sessionId: string) {
const event = await prisma.mMSessionEvent.findFirst({
where: { sessionId },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return event?.createdAt;
}

View File

@@ -12,6 +12,11 @@ export async function getSessionsByUserId(userId: string) {
where: { userId },
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,
@@ -27,6 +32,11 @@ export async function getSessionsByUserId(userId: string) {
session: {
include: {
user: { select: { id: true, name: true, email: true } },
shares: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
_count: {
select: {
items: true,