diff --git a/package-lock.json b/package-lock.json
index 52c3005..9cf3b89 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@types/socket.io": "^3.0.1",
+ "framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"next": "^15.0.0",
"react": "^19.0.0",
@@ -5933,6 +5934,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.23.12",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz",
+ "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.23.12",
+ "motion-utils": "^12.23.6",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -7518,6 +7546,21 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.23.12",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz",
+ "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.23.6"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.23.6",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
+ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
diff --git a/package.json b/package.json
index f4858d7..d801bdf 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
},
"dependencies": {
"@types/socket.io": "^3.0.1",
+ "framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"next": "^15.0.0",
"react": "^19.0.0",
diff --git a/src/app/room/[id]/page.tsx b/src/app/room/[id]/page.tsx
index ba44b3d..00bc723 100644
--- a/src/app/room/[id]/page.tsx
+++ b/src/app/room/[id]/page.tsx
@@ -1,9 +1,7 @@
+import { RoomLayout } from '../components/RoomLayout'
+
export default async function RoomPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
- return (
-
-
Room: {id}
-
Room functionality will be implemented here.
-
- )
+
+ return
}
diff --git a/src/app/room/components/Instructions.tsx b/src/app/room/components/Instructions.tsx
new file mode 100644
index 0000000..af55c44
--- /dev/null
+++ b/src/app/room/components/Instructions.tsx
@@ -0,0 +1,112 @@
+'use client'
+
+import { useState } from 'react'
+import { ParticipantRoleEnum } from '@/domain/room/value-objects/participant-attributes'
+
+interface InstructionsProps {
+ currentUserRole: ParticipantRoleEnum
+ className?: string
+}
+
+export function Instructions({ currentUserRole, className = '' }: InstructionsProps) {
+ const [isCollapsed, setIsCollapsed] = useState(false)
+
+ const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
+
+ const organizerInstructions = [
+ 'Add participants using the form in the participants panel',
+ 'Click "Spin the Wheel" and set a presentation time (1-60 minutes)',
+ 'The wheel will randomly select a presenter and start the timer',
+ 'Mark presentations as finished or let the timer expire',
+ 'Enable/disable participants as needed during the session',
+ 'Share the room URL with others to have them join as guests',
+ ]
+
+ const guestInstructions = [
+ 'Wait for the organizer to add you to the participants list',
+ 'Watch the wheel spin and see who gets selected to present',
+ 'Follow along with the presentation timer',
+ 'Your name will be highlighted in the participants list',
+ 'All updates happen in real-time across all connected users',
+ ]
+
+ const instructions = isOrganizer ? organizerInstructions : guestInstructions
+
+ return (
+
+
setIsCollapsed(!isCollapsed)}
+ className='w-full p-4 text-left focus:outline-none focus:ring-2 focus:ring-blue-500
+ rounded-lg transition-colors hover:bg-gray-50 dark:hover:bg-gray-700'
+ >
+
+
+ {isOrganizer ? 'Organizer Instructions' : 'How it Works'}
+
+
+
+
+
+
+
+ {!isCollapsed && (
+
+
+
+ {instructions.map((instruction, index) => (
+
+
+ {index + 1}
+
+ {instruction}
+
+ ))}
+
+
+ {/* Additional Info */}
+
+
+ 💡 Pro Tips
+
+
+ {isOrganizer ? (
+ <>
+
+ • Participants must be in "Queued" status to be selected by the
+ wheel
+
+ • Use "Disable" to temporarily remove someone from selection
+ • The timer shows yellow (last 2 min) and red (last 30 sec) warnings
+ • You can stop the timer early if needed
+ >
+ ) : (
+ <>
+ • All changes sync in real-time - no need to refresh
+ • Your name will be highlighted when you're in the list
+ • The wheel uses random selection for fairness
+ • You can see the current presentation status and timer
+ >
+ )}
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/app/room/components/ParticipantsList.tsx b/src/app/room/components/ParticipantsList.tsx
new file mode 100644
index 0000000..6787fc9
--- /dev/null
+++ b/src/app/room/components/ParticipantsList.tsx
@@ -0,0 +1,286 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ ParticipantRoleEnum,
+ ParticipantStatusEnum,
+} from '@/domain/room/value-objects/participant-attributes'
+
+interface Participant {
+ id: string
+ name: string
+ status: ParticipantStatusEnum
+ role: ParticipantRoleEnum
+ isCurrentUser?: boolean
+}
+
+interface ParticipantsListProps {
+ participants: Participant[]
+ currentUserRole: ParticipantRoleEnum
+ currentUserId?: string
+ onAddParticipant?: (name: string) => void
+ onToggleParticipant?: (id: string, enable: boolean) => void
+ onMarkFinished?: (id: string) => void
+ isLoading?: boolean
+}
+
+export function ParticipantsList({
+ participants,
+ currentUserRole,
+ currentUserId,
+ onAddParticipant,
+ onToggleParticipant,
+ onMarkFinished,
+ isLoading = false,
+}: ParticipantsListProps) {
+ const [newParticipantName, setNewParticipantName] = useState('')
+ const [isAddingParticipant, setIsAddingParticipant] = useState(false)
+
+ const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
+
+ const handleAddParticipant = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!newParticipantName.trim() || !onAddParticipant) return
+
+ setIsAddingParticipant(true)
+ try {
+ await onAddParticipant(newParticipantName.trim())
+ setNewParticipantName('')
+ } catch (error) {
+ console.error('Failed to add participant:', error)
+ } finally {
+ setIsAddingParticipant(false)
+ }
+ }
+
+ const getStatusColor = (status: ParticipantStatusEnum) => {
+ switch (status) {
+ case ParticipantStatusEnum.QUEUED:
+ return 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300'
+ case ParticipantStatusEnum.ACTIVE:
+ return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300'
+ case ParticipantStatusEnum.FINISHED:
+ return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
+ case ParticipantStatusEnum.DISABLED:
+ return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300'
+ default:
+ return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
+ }
+ }
+
+ const getStatusText = (status: ParticipantStatusEnum) => {
+ switch (status) {
+ case ParticipantStatusEnum.QUEUED:
+ return 'Queued'
+ case ParticipantStatusEnum.ACTIVE:
+ return 'Presenting'
+ case ParticipantStatusEnum.FINISHED:
+ return 'Finished'
+ case ParticipantStatusEnum.DISABLED:
+ return 'Disabled'
+ default:
+ return status
+ }
+ }
+
+ return (
+
+
+
+ Participants ({participants.length})
+
+ {isOrganizer && (
+ Organizer View
+ )}
+
+
+ {/* Add Participant Form (Organizer Only) */}
+ {isOrganizer && onAddParticipant && (
+
+ )}
+
+ {/* Participants List */}
+
+ {participants.length === 0 ? (
+
+
No participants yet
+ {isOrganizer ? (
+
Add the first participant to get started
+ ) : (
+
Waiting for organizer to add participants
+ )}
+
+ ) : (
+ participants.map(participant => {
+ const isCurrentUser = participant.id === currentUserId
+ return (
+
+
+
+
+ {participant.name}
+ {isCurrentUser && (You) }
+ {participant.role === ParticipantRoleEnum.ORGANIZER && (
+
+ Organizer
+
+ )}
+
+
+
+
+
+ {/* Status Badge */}
+
+ {getStatusText(participant.status)}
+
+
+ {/* Action Buttons (Organizer Only) */}
+ {isOrganizer && participant.role !== ParticipantRoleEnum.ORGANIZER && (
+
+ {/* Toggle Enable/Disable */}
+ {onToggleParticipant && (
+
+ onToggleParticipant(
+ participant.id,
+ participant.status === ParticipantStatusEnum.DISABLED
+ )
+ }
+ className={`px-2 py-1 text-xs font-medium rounded-md transition-colors
+ ${
+ participant.status === ParticipantStatusEnum.DISABLED
+ ? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/20 dark:text-green-300 dark:hover:bg-green-900/30'
+ : 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30'
+ }`}
+ disabled={isLoading}
+ >
+ {participant.status === ParticipantStatusEnum.DISABLED
+ ? 'Enable'
+ : 'Disable'}
+
+ )}
+
+ {/* Mark as Finished */}
+ {onMarkFinished && participant.status === ParticipantStatusEnum.ACTIVE && (
+ onMarkFinished(participant.id)}
+ className='px-2 py-1 text-xs font-medium bg-gray-100 text-gray-700
+ hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300
+ dark:hover:bg-gray-600 rounded-md transition-colors'
+ disabled={isLoading}
+ >
+ Finish
+
+ )}
+
+ )}
+
+
+ )
+ })
+ )}
+
+
+ {isLoading && (
+
+ )}
+
+ )
+}
diff --git a/src/app/room/components/RoomLayout.tsx b/src/app/room/components/RoomLayout.tsx
new file mode 100644
index 0000000..51f2563
--- /dev/null
+++ b/src/app/room/components/RoomLayout.tsx
@@ -0,0 +1,466 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import {
+ ParticipantRoleEnum,
+ ParticipantStatusEnum,
+} from '@/domain/room/value-objects/participant-attributes'
+import { RoleDetectionService } from '../utils/role-detection'
+import { RoomTitle } from './RoomTitle'
+import { ParticipantsList } from './ParticipantsList'
+import { Wheel } from './Wheel'
+import { TimerPanel } from './TimerPanel'
+import { Instructions } from './Instructions'
+
+interface Participant {
+ id: string
+ name: string
+ status: ParticipantStatusEnum
+ role: ParticipantRoleEnum
+}
+
+interface RoomData {
+ id: string
+ name?: string
+ participants: Participant[]
+ currentPresenterId?: string
+ organizerId: string
+ timerStartTime?: Date
+ timerDurationMinutes?: number
+ timerPausedTime?: Date
+ timerRemainingSeconds?: number
+}
+
+interface RoomLayoutProps {
+ roomId: string
+ initialData?: RoomData
+}
+
+export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
+ const [userRole, setUserRole] = useState(ParticipantRoleEnum.GUEST)
+ const [currentUserId, setCurrentUserId] = useState('')
+ const [roomData, setRoomData] = useState(initialData || null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSpinning, setIsSpinning] = useState(false)
+ const [error, setError] = useState(null)
+ const [lastWinnerState, setLastWinner] = useState<{ id: string; name: string } | null>(null)
+
+ // Initialize role detection
+ useEffect(() => {
+ // For MVP, first visitor to a room becomes organizer
+ // In production, this would be handled by proper authentication
+ const isOrganizer = RoleDetectionService.isOrganizer(roomId)
+
+ if (isOrganizer) {
+ setUserRole(ParticipantRoleEnum.ORGANIZER)
+ } else {
+ // Mark as organizer if no cookie exists (first visitor)
+ RoleDetectionService.setAsOrganizer(roomId)
+ setUserRole(ParticipantRoleEnum.ORGANIZER)
+ }
+
+ // Generate a temporary user ID for this session
+ // In real implementation, this would come from authentication or session management
+ setCurrentUserId(crypto.randomUUID())
+ }, [roomId])
+
+ // Mock data for development - replace with real Socket.IO integration
+ useEffect(() => {
+ if (!roomData) {
+ // Simulate loading room data
+ setRoomData({
+ id: roomId,
+ name: `Demo Room`,
+ participants: [
+ {
+ id: '1',
+ name: 'Alice Johnson',
+ status: ParticipantStatusEnum.QUEUED,
+ role: ParticipantRoleEnum.ORGANIZER,
+ },
+ {
+ id: '2',
+ name: 'Bob Smith',
+ status: ParticipantStatusEnum.QUEUED,
+ role: ParticipantRoleEnum.GUEST,
+ },
+ {
+ id: '3',
+ name: 'Carol Williams',
+ status: ParticipantStatusEnum.FINISHED,
+ role: ParticipantRoleEnum.GUEST,
+ },
+ {
+ id: '4',
+ name: 'David Wilson',
+ status: ParticipantStatusEnum.QUEUED,
+ role: ParticipantRoleEnum.GUEST,
+ },
+ {
+ id: '5',
+ name: 'Eve Brown',
+ status: ParticipantStatusEnum.QUEUED,
+ role: ParticipantRoleEnum.GUEST,
+ },
+ {
+ id: '6',
+ name: 'Frank Miller',
+ status: ParticipantStatusEnum.DISABLED,
+ role: ParticipantRoleEnum.GUEST,
+ },
+ ],
+ organizerId: '1',
+ })
+ }
+ }, [roomId, roomData])
+
+ const handleAddParticipant = async (name: string) => {
+ setIsLoading(true)
+ try {
+ // Simulate API call
+ await new Promise(resolve => setTimeout(resolve, 500))
+
+ const newParticipant: Participant = {
+ id: crypto.randomUUID(),
+ name,
+ status: ParticipantStatusEnum.QUEUED,
+ role: ParticipantRoleEnum.GUEST,
+ }
+
+ setRoomData(prev =>
+ prev
+ ? {
+ ...prev,
+ participants: [...prev.participants, newParticipant],
+ }
+ : null
+ )
+ } catch (error) {
+ console.error('Failed to add participant:', error)
+ setError('Failed to add participant')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleToggleParticipant = async (id: string, enable: boolean) => {
+ setIsLoading(true)
+ try {
+ await new Promise(resolve => setTimeout(resolve, 300))
+
+ setRoomData(prev =>
+ prev
+ ? {
+ ...prev,
+ participants: prev.participants.map(p =>
+ p.id === id
+ ? {
+ ...p,
+ status: enable
+ ? ParticipantStatusEnum.QUEUED
+ : ParticipantStatusEnum.DISABLED,
+ }
+ : p
+ ),
+ }
+ : null
+ )
+ } catch (error) {
+ console.error('Failed to toggle participant:', error)
+ setError('Failed to update participant')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleMarkFinished = async (id: string) => {
+ setIsLoading(true)
+ try {
+ await new Promise(resolve => setTimeout(resolve, 300))
+
+ setRoomData(prev =>
+ prev
+ ? {
+ ...prev,
+ participants: prev.participants.map(p =>
+ p.id === id ? { ...p, status: ParticipantStatusEnum.FINISHED } : p
+ ),
+ currentPresenterId: undefined,
+ timerStartTime: undefined,
+ }
+ : null
+ )
+ } catch (error) {
+ console.error('Failed to mark participant as finished:', error)
+ setError('Failed to mark as finished')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleSpinStart = () => {
+ setIsSpinning(true)
+ setIsLoading(true)
+ }
+
+ const handleSpinResult = async (winner: { id: string; name: string }) => {
+ try {
+ setRoomData(prev =>
+ prev
+ ? {
+ ...prev,
+ participants: prev.participants.map(p =>
+ p.id === winner.id ? { ...p, status: ParticipantStatusEnum.ACTIVE } : p
+ ),
+ currentPresenterId: winner.id,
+ timerStartTime: undefined, // Don't start timer automatically
+ timerDurationMinutes: undefined,
+ }
+ : null
+ )
+ setLastWinner({ id: winner.id, name: winner.name })
+ } catch (error) {
+ console.error('Failed to process wheel result:', error)
+ setError('Failed to process wheel result')
+ } finally {
+ setIsSpinning(false)
+ setIsLoading(false)
+ }
+ }
+
+ const handleSpinError = (error: Error) => {
+ console.error('Wheel spin error:', error)
+ setError(error.message)
+ setIsSpinning(false)
+ setIsLoading(false)
+ }
+
+ const handleStartTimer = async (timeInMinutes: number) => {
+ setIsLoading(true)
+ try {
+ await new Promise(resolve => setTimeout(resolve, 300))
+
+ setRoomData(prev =>
+ prev
+ ? {
+ ...prev,
+ timerStartTime: new Date(),
+ timerDurationMinutes: timeInMinutes,
+ }
+ : null
+ )
+ } catch (error) {
+ console.error('Failed to start timer:', error)
+ setError('Failed to start timer')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handlePauseTimer = async () => {
+ setIsLoading(true)
+ try {
+ await new Promise(resolve => setTimeout(resolve, 300))
+
+ // Calculate remaining time when pausing
+ const now = new Date()
+ const currentRoomData = roomData
+ if (currentRoomData?.timerStartTime && currentRoomData?.timerDurationMinutes) {
+ const elapsed = Math.floor(
+ (now.getTime() - currentRoomData.timerStartTime.getTime()) / 1000
+ )
+ const totalSeconds = currentRoomData.timerDurationMinutes * 60
+ const remainingSeconds = Math.max(0, totalSeconds - elapsed)
+
+ setRoomData(prev =>
+ prev
+ ? {
+ ...prev,
+ timerStartTime: undefined, // Stop the timer
+ timerPausedTime: now, // Mark when it was paused
+ timerRemainingSeconds: remainingSeconds, // Save remaining time
+ }
+ : null
+ )
+ }
+ } catch (error) {
+ console.error('Failed to pause timer:', error)
+ setError('Failed to pause timer')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleContinueTimer = async () => {
+ setIsLoading(true)
+ try {
+ await new Promise(resolve => setTimeout(resolve, 300))
+
+ // Resume timer with remaining time
+ const now = new Date()
+ const currentRoomData = roomData
+ if (currentRoomData?.timerRemainingSeconds !== undefined) {
+ // Calculate new duration in minutes from remaining seconds
+ const remainingMinutes = currentRoomData.timerRemainingSeconds / 60
+
+ setRoomData(prev =>
+ prev
+ ? {
+ ...prev,
+ timerStartTime: now,
+ timerDurationMinutes: remainingMinutes,
+ timerPausedTime: undefined,
+ timerRemainingSeconds: undefined,
+ }
+ : null
+ )
+ }
+ } catch (error) {
+ console.error('Failed to continue timer:', error)
+ setError('Failed to continue timer')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (!roomData) {
+ return (
+
+
+
+
+
+
+
Loading room...
+
+
+ )
+ }
+
+ const currentPresenter = roomData.currentPresenterId
+ ? roomData.participants.find(p => p.id === roomData.currentPresenterId)
+ : null
+
+ const lastWinner =
+ lastWinnerState ||
+ (currentPresenter
+ ? {
+ id: currentPresenter.id,
+ name: currentPresenter.name,
+ }
+ : null)
+
+ return (
+
+
+ {/* Error Message */}
+ {error && (
+
+
{error}
+
setError(null)}
+ className='text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300'
+ >
+
+
+
+
+
+ )}
+
+ {/* Room Title - Full Width */}
+
+
+ {/* Main Content - Responsive Grid */}
+
+ {/* Desktop: Left Column - Participants List (1/3) */}
+
+
+ {/* Desktop: Center Column - Wheel (2/3) */}
+
+
+
+
+ {/* Mobile: Top / Desktop: Right Column - Timer Panel (1/3) */}
+
+
+
+
+
+ {/* Instructions - Full Width */}
+
+
+
+ )
+}
diff --git a/src/app/room/components/RoomTitle.tsx b/src/app/room/components/RoomTitle.tsx
new file mode 100644
index 0000000..a3da07f
--- /dev/null
+++ b/src/app/room/components/RoomTitle.tsx
@@ -0,0 +1,134 @@
+'use client'
+
+import { useState } from 'react'
+import { ParticipantRoleEnum } from '@/domain/room/value-objects/participant-attributes'
+
+interface RoomTitleProps {
+ roomId: string
+ roomName?: string
+ currentUserRole: ParticipantRoleEnum
+}
+
+export function RoomTitle({ roomId, roomName, currentUserRole }: RoomTitleProps) {
+ const [showCopied, setShowCopied] = useState(false)
+
+ const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
+
+ const handleCopyUrl = async () => {
+ try {
+ const url = `${window.location.origin}/room/${roomId}`
+ await navigator.clipboard.writeText(url)
+ setShowCopied(true)
+ setTimeout(() => setShowCopied(false), 2000)
+ } catch (error) {
+ console.error('Failed to copy URL:', error)
+ // Fallback for browsers that don't support clipboard API
+ const textArea = document.createElement('textarea')
+ textArea.value = `${window.location.origin}/room/${roomId}`
+ document.body.appendChild(textArea)
+ textArea.select()
+ document.execCommand('copy')
+ document.body.removeChild(textArea)
+ setShowCopied(true)
+ setTimeout(() => setShowCopied(false), 2000)
+ }
+ }
+
+ const displayName = roomName || `Room ${roomId.substring(0, 8)}...`
+
+ return (
+
+
+ {/* Room Title */}
+
+
+ {displayName}
+
+
Room ID: {roomId}
+
+
+ {isOrganizer ? 'Organizer' : 'Guest'}
+
+
+
+
+ {/* Copy URL Button (Organizer Only) */}
+ {isOrganizer && (
+
+
+ {showCopied ? (
+ <>
+
+
+
+ Copied!
+ >
+ ) : (
+ <>
+
+
+
+ Copy Invite URL
+ >
+ )}
+
+
+ )}
+
+
+ {/* Instructions for guests */}
+ {!isOrganizer && (
+
+
+ 💡 You're in guest mode. You can view the wheel and timer in real-time, but only
+ the organizer can control the session.
+
+
+ )}
+
+ {/* Instructions for organizer */}
+ {isOrganizer && (
+
+
+ 🎯 You're the organizer! Add participants, spin the wheel, and manage the session.
+ Share the invite URL with others to have them join as guests.
+
+
+ )}
+
+ )
+}
diff --git a/src/app/room/components/TimerPanel.tsx b/src/app/room/components/TimerPanel.tsx
new file mode 100644
index 0000000..74d8728
--- /dev/null
+++ b/src/app/room/components/TimerPanel.tsx
@@ -0,0 +1,374 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import {
+ ParticipantRoleEnum,
+ ParticipantStatusEnum,
+} from '@/domain/room/value-objects/participant-attributes'
+
+interface CurrentPresenter {
+ id: string
+ name: string
+ status: ParticipantStatusEnum
+}
+
+interface TimerPanelProps {
+ currentPresenter: CurrentPresenter | null
+ currentUserRole: ParticipantRoleEnum
+ onMarkFinished?: (id: string) => void
+ onPauseTimer?: () => void
+ onContinueTimer?: () => void
+ onStartTimer?: (timeInMinutes: number) => void
+ timerStartTime?: Date | null
+ timerDurationMinutes?: number
+ timerPausedTime?: Date | null
+ timerRemainingSeconds?: number
+ isLoading?: boolean
+}
+
+export function TimerPanel({
+ currentPresenter,
+ currentUserRole,
+ onMarkFinished,
+ onPauseTimer,
+ onContinueTimer,
+ onStartTimer,
+ timerStartTime,
+ timerDurationMinutes = 10,
+ timerPausedTime,
+ timerRemainingSeconds,
+ isLoading = false,
+}: TimerPanelProps) {
+ const [timeRemaining, setTimeRemaining] = useState(0)
+ const [isActive, setIsActive] = useState(false)
+ const [isPaused, setIsPaused] = useState(false)
+ const [selectedTime, setSelectedTime] = useState(10)
+
+ const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
+
+ // Calculate time remaining
+ useEffect(() => {
+ if (!currentPresenter) {
+ setTimeRemaining(0)
+ setIsActive(false)
+ setIsPaused(false)
+ return
+ }
+
+ // Check if timer is paused
+ if (timerPausedTime && !timerStartTime) {
+ setIsPaused(true)
+ setIsActive(false)
+ // Set the remaining time from room data if available
+ if (timerRemainingSeconds !== undefined) {
+ setTimeRemaining(timerRemainingSeconds)
+ }
+ return
+ }
+
+ // Check if timer is not started
+ if (!timerStartTime) {
+ setTimeRemaining(0)
+ setIsActive(false)
+ setIsPaused(false)
+ return
+ }
+
+ setIsActive(true)
+ setIsPaused(false)
+
+ const updateTimer = () => {
+ const now = new Date()
+ const elapsed = Math.floor((now.getTime() - timerStartTime.getTime()) / 1000)
+ const totalSeconds = timerDurationMinutes * 60
+ const remaining = Math.max(0, totalSeconds - elapsed)
+
+ setTimeRemaining(remaining)
+
+ if (remaining === 0) {
+ setIsActive(false)
+ // Timer ended - could trigger auto-finish here
+ }
+ }
+
+ updateTimer()
+ const interval = setInterval(updateTimer, 1000)
+
+ return () => clearInterval(interval)
+ }, [
+ timerStartTime,
+ timerDurationMinutes,
+ timerPausedTime,
+ timerRemainingSeconds,
+ currentPresenter,
+ ])
+
+ const formatTime = (seconds: number): string => {
+ const minutes = Math.floor(seconds / 60)
+ const remainingSeconds = seconds % 60
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
+ }
+
+ const getTimerColor = (): string => {
+ if (isPaused) return 'text-orange-500' // Paused
+ if (!isActive || timeRemaining === 0) return 'text-gray-500 dark:text-gray-400'
+
+ if (timeRemaining <= 30) return 'text-red-500' // Critical: last 30 seconds
+ if (timeRemaining <= 120) return 'text-yellow-500' // Warning: last 2 minutes
+ return 'text-green-500' // Normal
+ }
+
+ const getTimerBgColor = (): string => {
+ if (isPaused) return 'bg-orange-50 dark:bg-orange-900/20' // Paused
+ if (!isActive || timeRemaining === 0) return 'bg-gray-100 dark:bg-gray-700'
+
+ if (timeRemaining <= 30) return 'bg-red-50 dark:bg-red-900/20' // Critical
+ if (timeRemaining <= 120) return 'bg-yellow-50 dark:bg-yellow-900/20' // Warning
+ return 'bg-green-50 dark:bg-green-900/20' // Normal
+ }
+
+ const handleStartTimer = () => {
+ if (onStartTimer && selectedTime > 0) {
+ onStartTimer(selectedTime)
+ }
+ }
+
+ return (
+
+
Current Session
+
+ {currentPresenter ? (
+
+ {/* Current Presenter */}
+
+
+ Now Presenting
+
+
+
+ {currentPresenter.name}
+
+
+ {currentPresenter.status === ParticipantStatusEnum.ACTIVE
+ ? 'Presenting'
+ : 'Finished'}
+
+
+
+
+ {/* Timer Display */}
+ {(isActive || isPaused) && (
+
+
+ Time Remaining
+
+
+
+ {formatTime(timeRemaining)}
+
+ {isPaused && (
+
+ ⏸️ Timer Paused
+
+ )}
+ {!isPaused && timeRemaining <= 30 && timeRemaining > 0 && (
+
+ Time running out!
+
+ )}
+ {!isPaused && timeRemaining === 0 && (
+
+ Time's up!
+
+ )}
+
+
+ )}
+
+ {/* Timer Controls (Organizer Only) */}
+ {isOrganizer &&
+ !isActive &&
+ !isPaused &&
+ currentPresenter.status === ParticipantStatusEnum.ACTIVE &&
+ onStartTimer && (
+
+
+
+ Presentation Duration
+
+
+
+ Minutes:
+
+
+ setSelectedTime(Math.max(1, Math.min(60, parseInt(e.target.value) || 1)))
+ }
+ className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600
+ rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500
+ dark:bg-gray-700 dark:text-white text-center'
+ />
+
+
+ {/* Quick Choose Section */}
+
+
+ Quick Choose:
+
+
+ {[5, 10, 15, 30].map(minutes => (
+ setSelectedTime(minutes)}
+ className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
+ selectedTime === minutes
+ ? 'bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-600'
+ : 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600'
+ }`}
+ >
+ {minutes}m
+
+ ))}
+
+
+
+
+
+ Start Timer ({selectedTime} min)
+
+
+ )}
+
+ {/* Control Buttons (Organizer Only) */}
+ {isOrganizer && (
+
+ {currentPresenter.status === ParticipantStatusEnum.ACTIVE && onMarkFinished && (
+ onMarkFinished(currentPresenter.id)}
+ disabled={isLoading}
+ className='w-full bg-green-600 hover:bg-green-700 disabled:bg-green-400
+ text-white font-medium py-3 px-4 rounded-lg transition-colors'
+ >
+ Mark as Finished
+
+ )}
+
+ {/* Show Pause button when timer is active */}
+ {isActive && onPauseTimer && (
+
+ Pause Timer
+
+ )}
+
+ {/* Show Continue button when timer is paused */}
+ {isPaused && onContinueTimer && (
+
+ Continue Timer
+
+ )}
+
+ )}
+
+ ) : (
+ /* No Current Presenter */
+
+
+
+ No Active Presentation
+
+
+ {isOrganizer
+ ? 'Spin the wheel to select the next presenter'
+ : 'Waiting for organizer to spin the wheel'}
+
+
+ )}
+
+ {/* Session Instructions */}
+
+
Session Info
+
+
+ • Session duration: {timerStartTime ? timerDurationMinutes : selectedTime} minutes
+
+ • Yellow warning: Last 2 minutes
+ • Red critical: Last 30 seconds
+ {isOrganizer && • Click "Mark as Finished" to end early }
+
+
+
+ {isLoading && (
+
+ )}
+
+ )
+}
diff --git a/src/app/room/components/Wheel.tsx b/src/app/room/components/Wheel.tsx
new file mode 100644
index 0000000..ffc6590
--- /dev/null
+++ b/src/app/room/components/Wheel.tsx
@@ -0,0 +1,368 @@
+'use client'
+
+import React, { useRef, useState, useEffect, useMemo } from 'react'
+import {
+ ParticipantRoleEnum,
+ ParticipantStatusEnum,
+} from '@/domain/room/value-objects/participant-attributes'
+
+// Types
+export type WheelItem = {
+ id: string
+ label: string
+ color?: string
+ meta?: Record
+}
+
+interface Participant {
+ id: string
+ name: string
+ status: ParticipantStatusEnum
+ role: ParticipantRoleEnum
+}
+
+export type WheelProps = {
+ participants: Participant[]
+ currentUserRole: ParticipantRoleEnum
+ isSpinning?: boolean
+ onSpinStart?: () => void
+ onResult?: (winner: { id: string; name: string }) => void
+ onError?: (e: Error) => void
+ lastWinner?: {
+ id: string
+ name: string
+ } | null
+ className?: string
+}
+
+const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3)
+
+// Predefined unique colors for wheel sectors
+const WHEEL_COLORS = [
+ '#FF6B6B', // Red
+ '#4ECDC4', // Turquoise
+ '#45B7D1', // Blue
+ '#FFA07A', // Salmon
+ '#98D8C8', // Mint
+ '#F7DC6F', // Yellow
+ '#BB8FCE', // Violet
+ '#85C1E9', // Light Blue
+ '#F8C471', // Orange
+ '#82E0AA', // Green
+ '#F1948A', // Pink
+ '#AED6F1', // Sky Blue
+ '#D7BDE2', // Lavender
+ '#A3E4D7', // Aquamarine
+ '#FAD7A0', // Peach
+ '#F9E79F', // Cream
+ '#D5A6BD', // Dusty Rose
+ '#A9CCE3', // Sky Blue
+ '#ABEBC6', // Light Green
+ '#F5B7B1', // Coral
+]
+
+// Generate color ensuring uniqueness within the wheel
+const getUniqueWheelColor = (
+ participantId: string,
+ index: number,
+ totalParticipants: number
+): string => {
+ // If participants are fewer than predefined colors, use them in order
+ if (totalParticipants <= WHEEL_COLORS.length) {
+ return WHEEL_COLORS[index]
+ }
+
+ // If more participants, generate colors evenly distributed on the color wheel
+ const hueStep = 360 / totalParticipants
+ const hue = Math.round(index * hueStep)
+
+ // Add a small variation based on ID for determinism
+ let hash = 0
+ for (let i = 0; i < participantId.length; i++) {
+ hash = participantId.charCodeAt(i) + ((hash << 5) - hash)
+ }
+ const hueOffset = (Math.abs(hash) % 30) - 15 // ±15 градусов вариации
+ const finalHue = (hue + hueOffset + 360) % 360
+
+ // Use high saturation and medium brightness for good contrast
+ const saturation = 65 + (Math.abs(hash) % 20) // 65-84%
+ const lightness = 55 + (Math.abs(hash >> 8) % 15) // 55-69%
+
+ return `hsl(${finalHue}, ${saturation}%, ${lightness}%)`
+}
+
+export const Wheel: React.FC = ({
+ participants,
+ currentUserRole,
+ isSpinning = false,
+ onSpinStart,
+ onResult,
+ onError,
+ lastWinner,
+ className = '',
+}) => {
+ // Ensure participants is array and get eligible ones
+ const safeParticipants = Array.isArray(participants) ? participants : []
+ const eligibleParticipants = safeParticipants.filter(
+ p => p.status === ParticipantStatusEnum.QUEUED || p.status === ParticipantStatusEnum.ACTIVE
+ )
+
+ // Convert participants to wheel items
+ const items = useMemo(
+ () =>
+ eligibleParticipants.map(p => ({
+ id: p.id,
+ label: p.name,
+ })),
+ [eligibleParticipants]
+ )
+
+ // Props validation
+ useEffect(() => {
+ try {
+ if (items.length < 2 || items.length > 20) {
+ if (items.length === 1) {
+ // Allow single participant for display purposes
+ return
+ }
+ throw new Error('Participants count must be between 2 and 20')
+ }
+ const ids = items.map(i => i.id)
+ if (new Set(ids).size !== ids.length) {
+ throw new Error('Duplicate participant id found')
+ }
+ items.forEach(item => {
+ if (!item.label || item.label.length > 20) {
+ throw new Error(`Label missing or exceeds 20 chars: ${item.id}`)
+ }
+ })
+ } catch (err) {
+ onError?.(err as Error)
+ }
+ }, [items, onError])
+
+ const [currentAngle, setCurrentAngle] = useState(0)
+ const [spinning, setSpinning] = useState(false)
+ const rafRef = useRef(null)
+
+ const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
+ const canSpin = isOrganizer && items.length >= 1 && !spinning && !isSpinning
+
+ // Reduced motion preference
+ const prefersReduced = useMemo(() => {
+ if (typeof window !== 'undefined' && window.matchMedia) {
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ }
+ return false
+ }, [])
+
+ // Angle per sector
+ const anglePerItem = items.length > 0 ? 360 / items.length : 360
+
+ // Sectors with colors
+ const sectors = useMemo(
+ () =>
+ items.map((item, index) => ({
+ ...item,
+ color: getUniqueWheelColor(item.id, index, items.length),
+ })),
+ [items]
+ )
+
+ // Convert polar to cartesian coordinates
+ const polarToCartesian = (
+ cx: number,
+ cy: number,
+ r: number,
+ deg: number
+ ): { x: number; y: number } => {
+ const rad = ((deg - 90) * Math.PI) / 180
+ return {
+ x: cx + r * Math.cos(rad),
+ y: cy + r * Math.sin(rad),
+ }
+ }
+
+ // Render sectors
+ const renderSectors = (): React.ReactElement[] => {
+ if (sectors.length === 0) {
+ return [
+ ,
+ ]
+ }
+
+ return sectors.map((item, idx) => {
+ const startAngle = idx * anglePerItem
+ const endAngle = startAngle + anglePerItem
+ const largeArcFlag = anglePerItem > 180 ? 1 : 0
+ const start = polarToCartesian(200, 200, 180, startAngle)
+ const end = polarToCartesian(200, 200, 180, endAngle)
+ const pathData = `M200,200 L${start.x},${start.y} A180,180 0 ${largeArcFlag} 1 ${end.x},${end.y} Z`
+
+ // Calculate text position
+ const textAngle = startAngle + anglePerItem / 2
+ const textPos = polarToCartesian(200, 200, 120, textAngle)
+
+ return (
+
+
+
+ {item.label.length > 10 ? item.label.substring(0, 10) + '...' : item.label}
+
+
+ )
+ })
+ }
+
+ // Обработчик спина
+ const spin = (): void => {
+ if (spinning || !canSpin || items.length === 0) return
+ try {
+ onSpinStart?.()
+ setSpinning(true)
+
+ // Выбор победителя
+ const buf = new Uint32Array(1)
+ window.crypto.getRandomValues(buf)
+ const random = buf[0] / 0xffffffff
+ const idx = Math.floor(random * items.length)
+ const winner = items[idx]
+
+ // Целевой угол (центр сектора)
+ const sectorStart = idx * anglePerItem
+ const targetAngle = sectorStart + anglePerItem / 2
+
+ // Параметры анимации
+ const rotations = prefersReduced ? 1 : Math.floor(Math.random() * 5) + 2
+ const duration = prefersReduced ? 500 : Math.floor(Math.random() * 3000) + 2000
+ const totalRotation = rotations * 360 + (360 - targetAngle)
+ const startTime = performance.now()
+
+ // Анимационный цикл
+ const animate = (now: number) => {
+ const elapsed = now - startTime
+ const t = Math.min(elapsed / duration, 1)
+ const eased = easeOutCubic(t)
+ setCurrentAngle(eased * totalRotation)
+ if (t < 1) {
+ rafRef.current = requestAnimationFrame(animate)
+ } else {
+ setSpinning(false)
+ onResult?.({
+ id: winner.id,
+ name: winner.label,
+ })
+ }
+ }
+
+ rafRef.current = requestAnimationFrame(animate)
+ } catch (err) {
+ onError?.(err as Error)
+ setSpinning(false)
+ }
+ }
+
+ // Очистка при размонтировании
+ useEffect(() => {
+ return () => {
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current)
+ }
+ }
+ }, [])
+
+ return (
+
+
+
+ Presenter Selection Wheel
+
+
+ {/* Wheel Container */}
+
+
+ {renderSectors()}
+ {/* Pointer */}
+
+ {/* Center circle */}
+
+
+
+ {/* Spinning overlay */}
+ {(spinning || isSpinning) && (
+
+ )}
+
+
+ {/* Last Winner Display */}
+ {lastWinner && !spinning && !isSpinning && (
+
+
+ Last Selected: {lastWinner.name}
+
+
+ )}
+
+ {/* Spin Button */}
+
+ {isOrganizer ? (
+
+ {spinning || isSpinning
+ ? 'Spinning...'
+ : items.length === 0
+ ? 'No Participants Available'
+ : 'Spin the Wheel'}
+
+ ) : (
+
+
Only the organizer can spin the wheel
+
+ )}
+
+ {items.length === 0 && (
+
+ Add participants to enable wheel spinning
+
+ )}
+
+
+
+ )
+}
+
+// Add default export for test compatibility
+export default Wheel
diff --git a/src/app/room/utils/role-detection.ts b/src/app/room/utils/role-detection.ts
new file mode 100644
index 0000000..8246e67
--- /dev/null
+++ b/src/app/room/utils/role-detection.ts
@@ -0,0 +1,111 @@
+'use client'
+
+import { ParticipantRoleEnum } from '@/domain/room/value-objects/participant-attributes'
+
+const ROLE_COOKIE_KEY = 'wheel-user-role'
+const ROOM_ORGANIZER_KEY = 'wheel-room-organizer'
+
+/**
+ * Cookie utilities for role detection in room
+ */
+export class RoleDetectionService {
+ /**
+ * Check if user is organizer for a specific room
+ */
+ static isOrganizer(roomId: string): boolean {
+ if (typeof document === 'undefined') return false
+
+ const organizerRooms = this.getOrganizerRooms()
+ return organizerRooms.includes(roomId)
+ }
+
+ /**
+ * Mark user as organizer for a room (first visitor)
+ */
+ static setAsOrganizer(roomId: string): void {
+ if (typeof document === 'undefined') return
+
+ const organizerRooms = this.getOrganizerRooms()
+ if (!organizerRooms.includes(roomId)) {
+ organizerRooms.push(roomId)
+ this.setOrganizerRooms(organizerRooms)
+ }
+
+ // Also set current role
+ this.setCurrentRole(ParticipantRoleEnum.ORGANIZER)
+ }
+
+ /**
+ * Set user as guest for current session
+ */
+ static setAsGuest(): void {
+ if (typeof document === 'undefined') return
+ this.setCurrentRole(ParticipantRoleEnum.GUEST)
+ }
+
+ /**
+ * Get current user role
+ */
+ static getCurrentRole(): ParticipantRoleEnum {
+ if (typeof document === 'undefined') return ParticipantRoleEnum.GUEST
+
+ const role = this.getCookie(ROLE_COOKIE_KEY)
+ return role === ParticipantRoleEnum.ORGANIZER
+ ? ParticipantRoleEnum.ORGANIZER
+ : ParticipantRoleEnum.GUEST
+ }
+
+ /**
+ * Clear role information (for testing/debugging)
+ */
+ static clearRoles(): void {
+ if (typeof document === 'undefined') return
+ this.deleteCookie(ROLE_COOKIE_KEY)
+ this.deleteCookie(ROOM_ORGANIZER_KEY)
+ }
+
+ // Private helper methods
+ private static getOrganizerRooms(): string[] {
+ const rooms = this.getCookie(ROOM_ORGANIZER_KEY)
+ if (!rooms) return []
+ try {
+ return JSON.parse(rooms)
+ } catch {
+ return []
+ }
+ }
+
+ private static setOrganizerRooms(rooms: string[]): void {
+ this.setCookie(ROOM_ORGANIZER_KEY, JSON.stringify(rooms), 7) // 7 days
+ }
+
+ private static setCurrentRole(role: ParticipantRoleEnum): void {
+ this.setCookie(ROLE_COOKIE_KEY, role, 1) // 1 day
+ }
+
+ private static getCookie(name: string): string | null {
+ if (typeof document === 'undefined') return null
+
+ const nameEQ = name + '='
+ const ca = document.cookie.split(';')
+ for (let i = 0; i < ca.length; i++) {
+ let c = ca[i]
+ while (c.charAt(0) === ' ') c = c.substring(1, c.length)
+ if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
+ }
+ return null
+ }
+
+ private static setCookie(name: string, value: string, days: number): void {
+ if (typeof document === 'undefined') return
+
+ const expires = new Date()
+ expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
+ document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
+ }
+
+ private static deleteCookie(name: string): void {
+ if (typeof document === 'undefined') return
+ document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/`
+ }
+}