From f3ab25663ab68bc4949d21dbefad2118bef3830f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 Aug 2025 20:31:31 +0000
Subject: [PATCH 1/6] Initial plan
From 131cdd799f51dd17f1331aab4a9d4d6dbefcf2e4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 Aug 2025 20:54:30 +0000
Subject: [PATCH 2/6] feat(room): implement complete room layout with
role-based UI and wheel functionality
- Add role detection service with cookie-based persistence
- Implement responsive 3-column layout (desktop) and single-column stack (mobile)
- Create ParticipantsList component with add/disable/finish controls for organizer
- Build animated Wheel component with framer-motion spin animation
- Add TimerPanel with countdown, color coding, and control buttons
- Include RoomTitle with copy URL button for organizer
- Implement collapsible Instructions component with role-specific content
- Add RoomLayout orchestrator with mock data integration
- Support organizer vs guest role switching via cookies
- All components styled with Tailwind CSS following design requirements
Co-authored-by: underscorekadji <3449713+underscorekadji@users.noreply.github.com>
---
package-lock.json | 43 +++
package.json | 1 +
src/app/room/[id]/page.tsx | 10 +-
src/app/room/components/Instructions.tsx | 112 ++++++
src/app/room/components/ParticipantsList.tsx | 286 +++++++++++++++
src/app/room/components/RoomLayout.tsx | 366 +++++++++++++++++++
src/app/room/components/RoomTitle.tsx | 134 +++++++
src/app/room/components/TimerPanel.tsx | 241 ++++++++++++
src/app/room/components/Wheel.tsx | 286 +++++++++++++++
src/app/room/utils/role-detection.ts | 111 ++++++
10 files changed, 1584 insertions(+), 6 deletions(-)
create mode 100644 src/app/room/components/Instructions.tsx
create mode 100644 src/app/room/components/ParticipantsList.tsx
create mode 100644 src/app/room/components/RoomLayout.tsx
create mode 100644 src/app/room/components/RoomTitle.tsx
create mode 100644 src/app/room/components/TimerPanel.tsx
create mode 100644 src/app/room/components/Wheel.tsx
create mode 100644 src/app/room/utils/role-detection.ts
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..1c422a2
--- /dev/null
+++ b/src/app/room/components/RoomLayout.tsx
@@ -0,0 +1,366 @@
+'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
+}
+
+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)
+
+ // 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) {
+ // Mark as organizer if no cookie exists (first visitor)
+ RoleDetectionService.setAsOrganizer(roomId)
+ setUserRole(ParticipantRoleEnum.ORGANIZER)
+ } else {
+ 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,
+ },
+ ],
+ 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 handleSpin = async (timeInMinutes: number) => {
+ setIsSpinning(true)
+ setIsLoading(true)
+ try {
+ // Simulate wheel spin duration
+ await new Promise(resolve => setTimeout(resolve, 3000))
+
+ const eligibleParticipants =
+ roomData?.participants.filter(p => p.status === ParticipantStatusEnum.QUEUED) || []
+
+ if (eligibleParticipants.length > 0) {
+ const selectedParticipant =
+ eligibleParticipants[Math.floor(Math.random() * eligibleParticipants.length)]
+
+ setRoomData(prev =>
+ prev
+ ? {
+ ...prev,
+ participants: prev.participants.map(p =>
+ p.id === selectedParticipant.id
+ ? { ...p, status: ParticipantStatusEnum.ACTIVE }
+ : p
+ ),
+ currentPresenterId: selectedParticipant.id,
+ timerStartTime: new Date(),
+ timerDurationMinutes: timeInMinutes,
+ }
+ : null
+ )
+ }
+ } catch (error) {
+ console.error('Failed to spin wheel:', error)
+ setError('Failed to spin wheel')
+ } finally {
+ setIsSpinning(false)
+ setIsLoading(false)
+ }
+ }
+
+ const handleStopTimer = async () => {
+ setIsLoading(true)
+ try {
+ await new Promise(resolve => setTimeout(resolve, 300))
+
+ setRoomData(prev =>
+ prev
+ ? {
+ ...prev,
+ timerStartTime: undefined,
+ }
+ : null
+ )
+ } catch (error) {
+ console.error('Failed to stop timer:', error)
+ setError('Failed to stop timer')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (!roomData) {
+ return (
+
+
+
+
+
+
+
Loading room...
+
+
+ )
+ }
+
+ const currentPresenter = roomData.currentPresenterId
+ ? roomData.participants.find(p => p.id === roomData.currentPresenterId)
+ : null
+
+ const lastWinner = 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..e165736
--- /dev/null
+++ b/src/app/room/components/TimerPanel.tsx
@@ -0,0 +1,241 @@
+'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
+ onStopTimer?: () => void
+ timerStartTime?: Date | null
+ timerDurationMinutes?: number
+ isLoading?: boolean
+}
+
+export function TimerPanel({
+ currentPresenter,
+ currentUserRole,
+ onMarkFinished,
+ onStopTimer,
+ timerStartTime,
+ timerDurationMinutes = 10,
+ isLoading = false,
+}: TimerPanelProps) {
+ const [timeRemaining, setTimeRemaining] = useState(0)
+ const [isActive, setIsActive] = useState(false)
+
+ const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
+
+ // Calculate time remaining
+ useEffect(() => {
+ if (!timerStartTime || !currentPresenter) {
+ setTimeRemaining(0)
+ setIsActive(false)
+ return
+ }
+
+ setIsActive(true)
+ 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, 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 (!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 (!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
+ }
+
+ return (
+
+
Current Session
+
+ {currentPresenter ? (
+
+ {/* Current Presenter */}
+
+
+ Now Presenting
+
+
+
+ {currentPresenter.name}
+
+
+ {currentPresenter.status === ParticipantStatusEnum.ACTIVE
+ ? 'Presenting'
+ : 'Finished'}
+
+
+
+
+ {/* Timer Display */}
+ {isActive && (
+
+
+ Time Remaining
+
+
+
+ {formatTime(timeRemaining)}
+
+ {timeRemaining <= 30 && timeRemaining > 0 && (
+
+ Time running out!
+
+ )}
+ {timeRemaining === 0 && (
+
+ Time's up!
+
+ )}
+
+
+ )}
+
+ {/* 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
+
+ )}
+
+ {isActive && onStopTimer && (
+
+ Stop 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
+
+ • Timer duration: {timerDurationMinutes} 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..dc4292d
--- /dev/null
+++ b/src/app/room/components/Wheel.tsx
@@ -0,0 +1,286 @@
+'use client'
+
+import { useState } from 'react'
+import { motion } from 'framer-motion'
+import {
+ ParticipantRoleEnum,
+ ParticipantStatusEnum,
+} from '@/domain/room/value-objects/participant-attributes'
+
+interface Participant {
+ id: string
+ name: string
+ status: ParticipantStatusEnum
+ role: ParticipantRoleEnum
+}
+
+interface WheelProps {
+ participants: Participant[]
+ currentUserRole: ParticipantRoleEnum
+ isSpinning?: boolean
+ onSpin?: (timeInMinutes: number) => void
+ lastWinner?: {
+ id: string
+ name: string
+ } | null
+}
+
+const WHEEL_COLORS = [
+ '#FF6B6B',
+ '#4ECDC4',
+ '#45B7D1',
+ '#FFA07A',
+ '#98D8C8',
+ '#F7DC6F',
+ '#BB8FCE',
+ '#85C1E9',
+ '#F8C471',
+ '#82E0AA',
+ '#F1948A',
+ '#AED6F1',
+]
+
+export function Wheel({
+ participants,
+ currentUserRole,
+ isSpinning = false,
+ onSpin,
+ lastWinner,
+}: WheelProps) {
+ const [rotation, setRotation] = useState(0)
+ const [showTimeModal, setShowTimeModal] = useState(false)
+ const [selectedTime, setSelectedTime] = useState(10)
+
+ const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
+
+ // Get eligible participants (queued only)
+ const eligibleParticipants = participants.filter(
+ p => p.status === ParticipantStatusEnum.QUEUED || p.status === ParticipantStatusEnum.ACTIVE
+ )
+
+ const canSpin = isOrganizer && eligibleParticipants.length > 0 && !isSpinning
+
+ const handleSpinClick = () => {
+ if (!canSpin) return
+ setShowTimeModal(true)
+ }
+
+ const handleTimeSubmit = () => {
+ if (onSpin && selectedTime > 0) {
+ // Calculate random rotation (multiple full rotations + random position)
+ const baseRotations = 5 + Math.random() * 3 // 5-8 full rotations
+ const randomAngle = Math.random() * 360
+ const newRotation = rotation + baseRotations * 360 + randomAngle
+
+ setRotation(newRotation)
+ onSpin(selectedTime)
+ }
+ setShowTimeModal(false)
+ }
+
+ // Calculate sector size and positions
+ const sectorCount = Math.max(eligibleParticipants.length, 1)
+ const sectorAngle = 360 / sectorCount
+
+ const renderWheelSectors = () => {
+ if (eligibleParticipants.length === 0) {
+ return
+ }
+
+ return eligibleParticipants.map((participant, index) => {
+ const startAngle = index * sectorAngle - 90 // Start from top
+ const endAngle = (index + 1) * sectorAngle - 90
+ const color = WHEEL_COLORS[index % WHEEL_COLORS.length]
+
+ // Calculate path for sector
+ const startAngleRad = (startAngle * Math.PI) / 180
+ const endAngleRad = (endAngle * Math.PI) / 180
+ const largeArcFlag = sectorAngle > 180 ? 1 : 0
+
+ const x1 = 200 + 150 * Math.cos(startAngleRad)
+ const y1 = 200 + 150 * Math.sin(startAngleRad)
+ const x2 = 200 + 150 * Math.cos(endAngleRad)
+ const y2 = 200 + 150 * Math.sin(endAngleRad)
+
+ const pathData = [
+ `M 200 200`,
+ `L ${x1} ${y1}`,
+ `A 150 150 0 ${largeArcFlag} 1 ${x2} ${y2}`,
+ `Z`,
+ ].join(' ')
+
+ // Calculate text position
+ const textAngle = startAngle + sectorAngle / 2
+ const textAngleRad = (textAngle * Math.PI) / 180
+ const textX = 200 + 100 * Math.cos(textAngleRad)
+ const textY = 200 + 100 * Math.sin(textAngleRad)
+
+ return (
+
+
+
+ {participant.name.length > 10
+ ? participant.name.substring(0, 10) + '...'
+ : participant.name}
+
+
+ )
+ })
+ }
+
+ return (
+
+
+
+ Presenter Selection Wheel
+
+
+ {/* Wheel Container */}
+
+ {/* Wheel */}
+
+
+ {renderWheelSectors()}
+ {/* Center circle */}
+
+
+
+
+ {/* Pointer */}
+
+
+ {/* Spinning overlay */}
+ {isSpinning && (
+
+ )}
+
+
+ {/* Last Winner Display */}
+ {lastWinner && !isSpinning && (
+
+
+ Last Selected: {lastWinner.name}
+
+
+ )}
+
+ {/* Spin Button */}
+
+ {isOrganizer ? (
+
+ {isSpinning
+ ? 'Spinning...'
+ : eligibleParticipants.length === 0
+ ? 'No Participants Available'
+ : 'Spin the Wheel'}
+
+ ) : (
+
+
Only the organizer can spin the wheel
+
+ )}
+
+ {eligibleParticipants.length === 0 && (
+
+ Add participants to enable wheel spinning
+
+ )}
+
+
+
+ {/* Time Selection Modal */}
+ {showTimeModal && (
+
+
+
+ Set Presentation Time
+
+
+
+
+ Minutes (1-60):
+
+
+ setSelectedTime(Math.max(1, Math.min(60, parseInt(e.target.value) || 1)))
+ }
+ className='w-full 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'
+ autoFocus
+ />
+
+
+
+ setShowTimeModal(false)}
+ className='flex-1 px-4 py-2 text-gray-700 dark:text-gray-300
+ border border-gray-300 dark:border-gray-600 rounded-md
+ hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors'
+ >
+ Cancel
+
+
+ Spin ({selectedTime} min)
+
+
+
+
+ )}
+
+ )
+}
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=/`
+ }
+}
From 3226be7dcbfc6b36da3015391dd5d439864600e9 Mon Sep 17 00:00:00 2001
From: Alexander Belenkov
Date: Wed, 6 Aug 2025 13:35:20 +0300
Subject: [PATCH 3/6] feat(timer): add timer functionality with start and stop
controls for organizers
---
src/app/room/components/RoomLayout.tsx | 31 ++++++++++-
src/app/room/components/TimerPanel.tsx | 75 +++++++++++++++++++++++++
src/app/room/components/Wheel.tsx | 77 +++-----------------------
3 files changed, 111 insertions(+), 72 deletions(-)
diff --git a/src/app/room/components/RoomLayout.tsx b/src/app/room/components/RoomLayout.tsx
index 1c422a2..275f279 100644
--- a/src/app/room/components/RoomLayout.tsx
+++ b/src/app/room/components/RoomLayout.tsx
@@ -177,7 +177,7 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
}
}
- const handleSpin = async (timeInMinutes: number) => {
+ const handleSpin = async () => {
setIsSpinning(true)
setIsLoading(true)
try {
@@ -201,8 +201,8 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
: p
),
currentPresenterId: selectedParticipant.id,
- timerStartTime: new Date(),
- timerDurationMinutes: timeInMinutes,
+ timerStartTime: undefined, // Don't start timer automatically
+ timerDurationMinutes: undefined,
}
: null
)
@@ -216,6 +216,28 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
}
}
+ 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 handleStopTimer = async () => {
setIsLoading(true)
try {
@@ -351,6 +373,9 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
userRole === ParticipantRoleEnum.ORGANIZER ? handleMarkFinished : undefined
}
onStopTimer={userRole === ParticipantRoleEnum.ORGANIZER ? handleStopTimer : undefined}
+ onStartTimer={
+ userRole === ParticipantRoleEnum.ORGANIZER ? handleStartTimer : undefined
+ }
timerStartTime={roomData.timerStartTime}
timerDurationMinutes={roomData.timerDurationMinutes}
isLoading={isLoading}
diff --git a/src/app/room/components/TimerPanel.tsx b/src/app/room/components/TimerPanel.tsx
index e165736..5f09d6e 100644
--- a/src/app/room/components/TimerPanel.tsx
+++ b/src/app/room/components/TimerPanel.tsx
@@ -17,6 +17,7 @@ interface TimerPanelProps {
currentUserRole: ParticipantRoleEnum
onMarkFinished?: (id: string) => void
onStopTimer?: () => void
+ onStartTimer?: (timeInMinutes: number) => void
timerStartTime?: Date | null
timerDurationMinutes?: number
isLoading?: boolean
@@ -27,12 +28,14 @@ export function TimerPanel({
currentUserRole,
onMarkFinished,
onStopTimer,
+ onStartTimer,
timerStartTime,
timerDurationMinutes = 10,
isLoading = false,
}: TimerPanelProps) {
const [timeRemaining, setTimeRemaining] = useState(0)
const [isActive, setIsActive] = useState(false)
+ const [selectedTime, setSelectedTime] = useState(10)
const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
@@ -87,6 +90,12 @@ export function TimerPanel({
return 'bg-green-50 dark:bg-green-900/20' // Normal
}
+ const handleStartTimer = () => {
+ if (onStartTimer && selectedTime > 0) {
+ onStartTimer(selectedTime)
+ }
+ }
+
return (
Current Session
@@ -143,6 +152,72 @@ export function TimerPanel({
)}
+ {/* Timer Controls (Organizer Only) */}
+ {isOrganizer &&
+ !isActive &&
+ 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 && (
diff --git a/src/app/room/components/Wheel.tsx b/src/app/room/components/Wheel.tsx
index dc4292d..af7a9eb 100644
--- a/src/app/room/components/Wheel.tsx
+++ b/src/app/room/components/Wheel.tsx
@@ -18,7 +18,7 @@ interface WheelProps {
participants: Participant[]
currentUserRole: ParticipantRoleEnum
isSpinning?: boolean
- onSpin?: (timeInMinutes: number) => void
+ onSpin?: () => void
lastWinner?: {
id: string
name: string
@@ -48,8 +48,6 @@ export function Wheel({
lastWinner,
}: WheelProps) {
const [rotation, setRotation] = useState(0)
- const [showTimeModal, setShowTimeModal] = useState(false)
- const [selectedTime, setSelectedTime] = useState(10)
const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
@@ -61,21 +59,15 @@ export function Wheel({
const canSpin = isOrganizer && eligibleParticipants.length > 0 && !isSpinning
const handleSpinClick = () => {
- if (!canSpin) return
- setShowTimeModal(true)
- }
+ if (!canSpin || !onSpin) return
- const handleTimeSubmit = () => {
- if (onSpin && selectedTime > 0) {
- // Calculate random rotation (multiple full rotations + random position)
- const baseRotations = 5 + Math.random() * 3 // 5-8 full rotations
- const randomAngle = Math.random() * 360
- const newRotation = rotation + baseRotations * 360 + randomAngle
+ // Calculate random rotation (multiple full rotations + random position)
+ const baseRotations = 5 + Math.random() * 3 // 5-8 full rotations
+ const randomAngle = Math.random() * 360
+ const newRotation = rotation + baseRotations * 360 + randomAngle
- setRotation(newRotation)
- onSpin(selectedTime)
- }
- setShowTimeModal(false)
+ setRotation(newRotation)
+ onSpin()
}
// Calculate sector size and positions
@@ -228,59 +220,6 @@ export function Wheel({
)}
-
- {/* Time Selection Modal */}
- {showTimeModal && (
-
-
-
- Set Presentation Time
-
-
-
-
- Minutes (1-60):
-
-
- setSelectedTime(Math.max(1, Math.min(60, parseInt(e.target.value) || 1)))
- }
- className='w-full 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'
- autoFocus
- />
-
-
-
- setShowTimeModal(false)}
- className='flex-1 px-4 py-2 text-gray-700 dark:text-gray-300
- border border-gray-300 dark:border-gray-600 rounded-md
- hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors'
- >
- Cancel
-
-
- Spin ({selectedTime} min)
-
-
-
-
- )}
)
}
From bbcf0a686c14b3b8598c19398b800ebd1debf385 Mon Sep 17 00:00:00 2001
From: Alexander Belenkov
Date: Wed, 6 Aug 2025 15:50:26 +0300
Subject: [PATCH 4/6] feat(timer): enhance timer functionality with pause and
continue features
---
src/app/room/components/RoomLayout.tsx | 171 ++++++++++++++++++-------
src/app/room/components/TimerPanel.tsx | 78 +++++++++--
2 files changed, 190 insertions(+), 59 deletions(-)
diff --git a/src/app/room/components/RoomLayout.tsx b/src/app/room/components/RoomLayout.tsx
index 275f279..0859e7a 100644
--- a/src/app/room/components/RoomLayout.tsx
+++ b/src/app/room/components/RoomLayout.tsx
@@ -27,6 +27,8 @@ interface RoomData {
organizerId: string
timerStartTime?: Date
timerDurationMinutes?: number
+ timerPausedTime?: Date
+ timerRemainingSeconds?: number
}
interface RoomLayoutProps {
@@ -41,6 +43,7 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
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(() => {
@@ -87,6 +90,24 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
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',
})
@@ -177,45 +198,43 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
}
}
- const handleSpin = async () => {
+ const handleSpinStart = () => {
setIsSpinning(true)
setIsLoading(true)
- try {
- // Simulate wheel spin duration
- await new Promise(resolve => setTimeout(resolve, 3000))
-
- const eligibleParticipants =
- roomData?.participants.filter(p => p.status === ParticipantStatusEnum.QUEUED) || []
-
- if (eligibleParticipants.length > 0) {
- const selectedParticipant =
- eligibleParticipants[Math.floor(Math.random() * eligibleParticipants.length)]
+ }
- setRoomData(prev =>
- prev
- ? {
- ...prev,
- participants: prev.participants.map(p =>
- p.id === selectedParticipant.id
- ? { ...p, status: ParticipantStatusEnum.ACTIVE }
- : p
- ),
- currentPresenterId: selectedParticipant.id,
- timerStartTime: undefined, // Don't start timer automatically
- timerDurationMinutes: undefined,
- }
- : null
- )
- }
+ 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 spin wheel:', error)
- setError('Failed to spin wheel')
+ 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 {
@@ -238,22 +257,67 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
}
}
- const handleStopTimer = async () => {
+ const handlePauseTimer = async () => {
setIsLoading(true)
try {
await new Promise(resolve => setTimeout(resolve, 300))
- setRoomData(prev =>
- prev
- ? {
- ...prev,
- timerStartTime: undefined,
- }
- : null
- )
+ // 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 stop timer:', error)
- setError('Failed to stop timer')
+ console.error('Failed to continue timer:', error)
+ setError('Failed to continue timer')
} finally {
setIsLoading(false)
}
@@ -296,12 +360,14 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
? roomData.participants.find(p => p.id === roomData.currentPresenterId)
: null
- const lastWinner = currentPresenter
- ? {
- id: currentPresenter.id,
- name: currentPresenter.name,
- }
- : null
+ const lastWinner =
+ lastWinnerState ||
+ (currentPresenter
+ ? {
+ id: currentPresenter.id,
+ name: currentPresenter.name,
+ }
+ : null)
return (
@@ -359,7 +425,9 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
participants={roomData.participants}
currentUserRole={userRole}
isSpinning={isSpinning}
- onSpin={userRole === ParticipantRoleEnum.ORGANIZER ? handleSpin : undefined}
+ onSpinStart={userRole === ParticipantRoleEnum.ORGANIZER ? handleSpinStart : undefined}
+ onResult={userRole === ParticipantRoleEnum.ORGANIZER ? handleSpinResult : undefined}
+ onError={handleSpinError}
lastWinner={lastWinner}
/>
@@ -372,12 +440,19 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
onMarkFinished={
userRole === ParticipantRoleEnum.ORGANIZER ? handleMarkFinished : undefined
}
- onStopTimer={userRole === ParticipantRoleEnum.ORGANIZER ? handleStopTimer : undefined}
+ onPauseTimer={
+ userRole === ParticipantRoleEnum.ORGANIZER ? handlePauseTimer : undefined
+ }
+ onContinueTimer={
+ userRole === ParticipantRoleEnum.ORGANIZER ? handleContinueTimer : undefined
+ }
onStartTimer={
userRole === ParticipantRoleEnum.ORGANIZER ? handleStartTimer : undefined
}
timerStartTime={roomData.timerStartTime}
timerDurationMinutes={roomData.timerDurationMinutes}
+ timerPausedTime={roomData.timerPausedTime}
+ timerRemainingSeconds={roomData.timerRemainingSeconds}
isLoading={isLoading}
/>
diff --git a/src/app/room/components/TimerPanel.tsx b/src/app/room/components/TimerPanel.tsx
index 5f09d6e..5af2efe 100644
--- a/src/app/room/components/TimerPanel.tsx
+++ b/src/app/room/components/TimerPanel.tsx
@@ -16,10 +16,13 @@ interface TimerPanelProps {
currentPresenter: CurrentPresenter | null
currentUserRole: ParticipantRoleEnum
onMarkFinished?: (id: string) => void
- onStopTimer?: () => void
+ onPauseTimer?: () => void
+ onContinueTimer?: () => void
onStartTimer?: (timeInMinutes: number) => void
timerStartTime?: Date | null
timerDurationMinutes?: number
+ timerPausedTime?: Date | null
+ timerRemainingSeconds?: number
isLoading?: boolean
}
@@ -27,27 +30,53 @@ export function TimerPanel({
currentPresenter,
currentUserRole,
onMarkFinished,
- onStopTimer,
+ 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 (!timerStartTime || !currentPresenter) {
+ 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)
@@ -66,7 +95,13 @@ export function TimerPanel({
const interval = setInterval(updateTimer, 1000)
return () => clearInterval(interval)
- }, [timerStartTime, timerDurationMinutes, currentPresenter])
+ }, [
+ timerStartTime,
+ timerDurationMinutes,
+ timerPausedTime,
+ timerRemainingSeconds,
+ currentPresenter,
+ ])
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60)
@@ -75,6 +110,7 @@ export function TimerPanel({
}
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
@@ -83,6 +119,7 @@ export function TimerPanel({
}
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
@@ -127,7 +164,7 @@ export function TimerPanel({
{/* Timer Display */}
- {isActive && (
+ {(isActive || isPaused) && (
Time Remaining
@@ -138,12 +175,17 @@ export function TimerPanel({
>
{formatTime(timeRemaining)}
- {timeRemaining <= 30 && timeRemaining > 0 && (
+ {isPaused && (
+
+ ⏸️ Timer Paused
+
+ )}
+ {!isPaused && timeRemaining <= 30 && timeRemaining > 0 && (
Time running out!
)}
- {timeRemaining === 0 && (
+ {!isPaused && timeRemaining === 0 && (
Time's up!
@@ -155,6 +197,7 @@ export function TimerPanel({
{/* Timer Controls (Organizer Only) */}
{isOrganizer &&
!isActive &&
+ !isPaused &&
currentPresenter.status === ParticipantStatusEnum.ACTIVE &&
onStartTimer && (
@@ -232,14 +275,27 @@ export function TimerPanel({
)}
- {isActive && onStopTimer && (
+ {/* Show Pause button when timer is active */}
+ {isActive && onPauseTimer && (
+
+ Pause Timer
+
+ )}
+
+ {/* Show Continue button when timer is paused */}
+ {isPaused && onContinueTimer && (
- Stop Timer
+ Continue Timer
)}
From a5596d35b7a88710cb8a1348136ff559c6d77185 Mon Sep 17 00:00:00 2001
From: Alexander Belenkov
Date: Wed, 6 Aug 2025 15:53:51 +0300
Subject: [PATCH 5/6] feat(wheel): refactor Wheel component with enhanced color
generation and improved spinning logic
---
src/app/room/components/Wheel.tsx | 377 ++++++++++++++++++++----------
1 file changed, 260 insertions(+), 117 deletions(-)
diff --git a/src/app/room/components/Wheel.tsx b/src/app/room/components/Wheel.tsx
index af7a9eb..ffc6590 100644
--- a/src/app/room/components/Wheel.tsx
+++ b/src/app/room/components/Wheel.tsx
@@ -1,12 +1,19 @@
'use client'
-import { useState } from 'react'
-import { motion } from 'framer-motion'
+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
@@ -14,165 +21,299 @@ interface Participant {
role: ParticipantRoleEnum
}
-interface WheelProps {
+export type WheelProps = {
participants: Participant[]
currentUserRole: ParticipantRoleEnum
isSpinning?: boolean
- onSpin?: () => void
+ 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',
- '#4ECDC4',
- '#45B7D1',
- '#FFA07A',
- '#98D8C8',
- '#F7DC6F',
- '#BB8FCE',
- '#85C1E9',
- '#F8C471',
- '#82E0AA',
- '#F1948A',
- '#AED6F1',
+ '#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
]
-export function Wheel({
+// 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,
- onSpin,
+ onSpinStart,
+ onResult,
+ onError,
lastWinner,
-}: WheelProps) {
- const [rotation, setRotation] = useState(0)
-
- const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
-
- // Get eligible participants (queued only)
- const eligibleParticipants = participants.filter(
+ 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
)
- const canSpin = isOrganizer && eligibleParticipants.length > 0 && !isSpinning
+ // Convert participants to wheel items
+ const items = useMemo(
+ () =>
+ eligibleParticipants.map(p => ({
+ id: p.id,
+ label: p.name,
+ })),
+ [eligibleParticipants]
+ )
- const handleSpinClick = () => {
- if (!canSpin || !onSpin) return
+ // 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])
- // Calculate random rotation (multiple full rotations + random position)
- const baseRotations = 5 + Math.random() * 3 // 5-8 full rotations
- const randomAngle = Math.random() * 360
- const newRotation = rotation + baseRotations * 360 + randomAngle
+ const [currentAngle, setCurrentAngle] = useState(0)
+ const [spinning, setSpinning] = useState(false)
+ const rafRef = useRef(null)
- setRotation(newRotation)
- onSpin()
- }
-
- // Calculate sector size and positions
- const sectorCount = Math.max(eligibleParticipants.length, 1)
- const sectorAngle = 360 / sectorCount
+ const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER
+ const canSpin = isOrganizer && items.length >= 1 && !spinning && !isSpinning
- const renderWheelSectors = () => {
- if (eligibleParticipants.length === 0) {
- return
+ // Reduced motion preference
+ const prefersReduced = useMemo(() => {
+ if (typeof window !== 'undefined' && window.matchMedia) {
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
+ return false
+ }, [])
- return eligibleParticipants.map((participant, index) => {
- const startAngle = index * sectorAngle - 90 // Start from top
- const endAngle = (index + 1) * sectorAngle - 90
- const color = WHEEL_COLORS[index % WHEEL_COLORS.length]
+ // Angle per sector
+ const anglePerItem = items.length > 0 ? 360 / items.length : 360
- // Calculate path for sector
- const startAngleRad = (startAngle * Math.PI) / 180
- const endAngleRad = (endAngle * Math.PI) / 180
- const largeArcFlag = sectorAngle > 180 ? 1 : 0
+ // Sectors with colors
+ const sectors = useMemo(
+ () =>
+ items.map((item, index) => ({
+ ...item,
+ color: getUniqueWheelColor(item.id, index, items.length),
+ })),
+ [items]
+ )
- const x1 = 200 + 150 * Math.cos(startAngleRad)
- const y1 = 200 + 150 * Math.sin(startAngleRad)
- const x2 = 200 + 150 * Math.cos(endAngleRad)
- const y2 = 200 + 150 * Math.sin(endAngleRad)
+ // 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 [
+ ,
+ ]
+ }
- const pathData = [
- `M 200 200`,
- `L ${x1} ${y1}`,
- `A 150 150 0 ${largeArcFlag} 1 ${x2} ${y2}`,
- `Z`,
- ].join(' ')
+ 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 + sectorAngle / 2
- const textAngleRad = (textAngle * Math.PI) / 180
- const textX = 200 + 100 * Math.cos(textAngleRad)
- const textY = 200 + 100 * Math.sin(textAngleRad)
+ const textAngle = startAngle + anglePerItem / 2
+ const textPos = polarToCartesian(200, 200, 120, textAngle)
return (
-
-
+
+
- {participant.name.length > 10
- ? participant.name.substring(0, 10) + '...'
- : participant.name}
+ {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 */}
-
- {/* Wheel */}
-
-
- {renderWheelSectors()}
- {/* Center circle */}
-
-
-
-
- {/* Pointer */}
-
+
+
+ {renderSectors()}
+ {/* Pointer */}
+
+ {/* Center circle */}
+
+
{/* Spinning overlay */}
- {isSpinning && (
-
-
+ {(spinning || isSpinning) && (
+
@@ -180,7 +321,7 @@ export function Wheel({
{/* Last Winner Display */}
- {lastWinner && !isSpinning && (
+ {lastWinner && !spinning && !isSpinning && (
Last Selected: {lastWinner.name}
@@ -192,18 +333,17 @@ export function Wheel({
{isOrganizer ? (
- {isSpinning
+ {spinning || isSpinning
? 'Spinning...'
- : eligibleParticipants.length === 0
+ : items.length === 0
? 'No Participants Available'
: 'Spin the Wheel'}
@@ -213,7 +353,7 @@ export function Wheel({
)}
- {eligibleParticipants.length === 0 && (
+ {items.length === 0 && (
Add participants to enable wheel spinning
@@ -223,3 +363,6 @@ export function Wheel({
)
}
+
+// Add default export for test compatibility
+export default Wheel
From b2562e422d7c4951d2585f656731de368180fbaa Mon Sep 17 00:00:00 2001
From: Alexander Belenkov
Date: Thu, 7 Aug 2025 12:16:04 +0300
Subject: [PATCH 6/6] feat(room): update role detection logic and improve
session duration display in TimerPanel
---
src/app/room/components/RoomLayout.tsx | 6 +++---
src/app/room/components/TimerPanel.tsx | 4 +++-
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/app/room/components/RoomLayout.tsx b/src/app/room/components/RoomLayout.tsx
index 0859e7a..51f2563 100644
--- a/src/app/room/components/RoomLayout.tsx
+++ b/src/app/room/components/RoomLayout.tsx
@@ -51,11 +51,11 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) {
// In production, this would be handled by proper authentication
const isOrganizer = RoleDetectionService.isOrganizer(roomId)
- if (!isOrganizer) {
- // Mark as organizer if no cookie exists (first visitor)
- RoleDetectionService.setAsOrganizer(roomId)
+ if (isOrganizer) {
setUserRole(ParticipantRoleEnum.ORGANIZER)
} else {
+ // Mark as organizer if no cookie exists (first visitor)
+ RoleDetectionService.setAsOrganizer(roomId)
setUserRole(ParticipantRoleEnum.ORGANIZER)
}
diff --git a/src/app/room/components/TimerPanel.tsx b/src/app/room/components/TimerPanel.tsx
index 5af2efe..74d8728 100644
--- a/src/app/room/components/TimerPanel.tsx
+++ b/src/app/room/components/TimerPanel.tsx
@@ -335,7 +335,9 @@ export function TimerPanel({
Session Info
- • Timer duration: {timerDurationMinutes} minutes
+
+ • Session duration: {timerStartTime ? timerDurationMinutes : selectedTime} minutes
+
• Yellow warning: Last 2 minutes
• Red critical: Last 30 seconds
{isOrganizer && • Click "Mark as Finished" to end early }