Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/client/src/components/Toast.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { LuCheck } from 'react-icons/lu';

export default function Toast({ message }) {
return (
<div className="toast" role="status" aria-live="polite">
<span className="toast-icon">
<LuCheck size={14} />
</span>
{message}
</div>
);
}
24 changes: 17 additions & 7 deletions apps/client/src/hooks/useSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import socket from '../socket';
/**
* Custom hook — manages socket subscriptions for a session room.
*
* Both Host and Participant need the same three socket events:
* session_joined → exposes the question object
* vote_update → keeps results live
* session_closed → calls onClose() to navigate home
* Both Host and Participant need the same socket events:
* session_joined → exposes the question object
* vote_update → keeps results live
* session_closed → calls onClose() to navigate home
* question_changed → updates question, resets results, calls onQuestionChanged()
*
* Extracting this into a hook means neither page owns the subscription
* logic: they just call useSession() and receive { question, results }.
Expand All @@ -21,9 +22,10 @@ import socket from '../socket';
* Inline arrows would create new references each render, making
* socket.off() a no-op and stacking duplicate handlers over time.
*/
export function useSession(code, onClose) {
export function useSession(code, onClose, onQuestionChanged) {
const [question, setQuestion] = useState(null);
const [results, setResults] = useState([]);
const [hasMore, setHasMore] = useState(true);

useEffect(() => {
function onSessionJoined({ question: q }) {
Expand All @@ -35,18 +37,26 @@ export function useSession(code, onClose) {
function onSessionClosed() {
onClose();
}
function onQuestionChangedHandler({ question: q, hasMore: more }) {
setQuestion(q);
setResults([]);
setHasMore(more);
onQuestionChanged?.();
}

socket.on('session_joined', onSessionJoined);
socket.on('vote_update', onVoteUpdate);
socket.on('session_closed', onSessionClosed);
socket.on('question_changed', onQuestionChangedHandler);
socket.emit('join_session', code);

return () => {
socket.off('session_joined', onSessionJoined);
socket.off('vote_update', onVoteUpdate);
socket.off('session_closed', onSessionClosed);
socket.off('question_changed', onQuestionChangedHandler);
};
}, [code, onClose]);
}, [code, onClose, onQuestionChanged]);

return { question, results };
return { question, results, hasMore };
}
13 changes: 11 additions & 2 deletions apps/client/src/pages/Host.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { useState } from 'react';
import { LuPower, LuShare2 } from 'react-icons/lu';
import { LuPower, LuShare2, LuSkipForward } from 'react-icons/lu';
import Layout from '../components/Layout';
import ConfirmModal from '../components/ConfirmModal';
import ResultBar from '../components/ResultBar';
import { useSession } from '../hooks/useSession';
import { BAR_COLORS, calcPct } from '../utils';
import socket from '../socket';

const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? 'http://localhost:3001';

export default function Host({ code, onClose }) {
const { question, results } = useSession(code, onClose);
const { question, results, hasMore } = useSession(code, onClose);
const [showConfirm, setShowConfirm] = useState(false);

async function handleClose() {
Expand Down Expand Up @@ -65,6 +66,14 @@ export default function Host({ code, onClose }) {
</div>

<div className="action-row">
<button
className="btn-ghost btn-icon btn-sm"
onClick={() => socket.emit('next_question', code)}
disabled={!hasMore}
>
<LuSkipForward size={14} />
{hasMore ? 'Next question' : 'All questions used'}
</button>
<button
className="btn-danger-outline btn-icon"
onClick={() => setShowConfirm(true)}
Expand Down
255 changes: 137 additions & 118 deletions apps/client/src/pages/Participant.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@ import confetti from 'canvas-confetti';
import { LuClock, LuSend, LuRefreshCw } from 'react-icons/lu';
import Layout from '../components/Layout';
import ResultBar from '../components/ResultBar';
import Toast from '../components/Toast';
import { useSession } from '../hooks/useSession';
import { BAR_COLORS, calcPct } from '../utils';
import socket from '../socket';

export default function Participant({ code, onClose }) {
const { question, results } = useSession(code, onClose);
const [selectedId, setSelectedId] = useState(null);
const [phase, setPhase] = useState('voting');
const [showThanks, setShowThanks] = useState(false);

function handleClose() {
setShowThanks(true);
setTimeout(onClose, 2500);
}

const { question, results } = useSession(code, handleClose, () => {
setPhase('voting');
setSelectedId(null);
});

function handleSubmit() {
socket.emit('submit_vote', { code, answerId: selectedId });
Expand Down Expand Up @@ -46,139 +57,147 @@ export default function Participant({ code, onClose }) {
}

return (
<Layout
code={code}
showLive
navAction={{ label: '← Leave', onClick: onClose }}
>
<div className="page-content">
{phase === 'voting' ? (
/* ── Voting phase ── */
<div className="participant-grid">
<div>
<div className="card" style={{ marginBottom: '16px' }}>
<div className="section-label">Question</div>
<div
<>
<Layout
code={code}
showLive
navAction={{ label: '← Leave', onClick: onClose }}
>
<div className="page-content">
{phase === 'voting' ? (
/* ── Voting phase ── */
<div className="participant-grid">
<div>
<div className="card" style={{ marginBottom: '16px' }}>
<div className="section-label">Question</div>
<div
style={{
fontSize: '22px',
fontWeight: 500,
color: 'var(--rd-text)',
lineHeight: 1.4,
}}
>
{question.text}
</div>
</div>

<div className="answers-grid">
{question.answers.map((answer) => (
<div
key={answer.id}
className={`answer-opt${selectedId === answer.id ? ' selected' : ''}`}
onClick={() => setSelectedId(answer.id)}
>
<div className="radio">
<div className="radio-inner" />
</div>
<span className="answer-opt-text">{answer.text}</span>
</div>
))}
</div>

<button
className="btn-primary btn-icon"
style={{ width: '100%', marginTop: '16px', padding: '14px' }}
onClick={handleSubmit}
disabled={!selectedId}
>
<LuSend size={15} />
Submit vote
</button>
</div>

<div className="card" style={{ alignSelf: 'start' }}>
<div className="section-label">How it works</div>
<p
style={{
fontSize: '22px',
fontWeight: 500,
color: 'var(--rd-text)',
lineHeight: 1.4,
fontSize: '13px',
color: 'var(--rd-muted)',
lineHeight: 1.7,
}}
>
{question.text}
</div>
Select your answer and submit. Results appear live once
you&apos;ve voted — and you can change your mind until the
host closes the question.
</p>
</div>
</div>
) : (
/* ── Waiting phase ── */
<div className="participant-grid">
<div>
<div className="waiting-card">
<div className="waiting-icon">
<LuClock size={20} color="#E17000" aria-hidden="true" />
</div>

<div className="answers-grid">
{question.answers.map((answer) => (
<div
key={answer.id}
className={`answer-opt${selectedId === answer.id ? ' selected' : ''}`}
onClick={() => setSelectedId(answer.id)}
style={{
fontSize: '15px',
fontWeight: 500,
color: 'var(--rd-text)',
marginBottom: '4px',
}}
>
<div className="radio">
<div className="radio-inner" />
</div>
<span className="answer-opt-text">{answer.text}</span>
Vote submitted
</div>
<div style={{ fontSize: '13px', color: 'var(--rd-muted)' }}>
Waiting for others…
</div>
))}
</div>

<button
className="btn-primary btn-icon"
style={{ width: '100%', marginTop: '16px', padding: '14px' }}
onClick={handleSubmit}
disabled={!selectedId}
>
<LuSend size={15} />
Submit vote
</button>
</div>
{selectedAnswer && (
<div className="voted-pill">
<div className="voted-pill-dot" />
<span
style={{ fontSize: '13px', color: 'var(--rd-text)' }}
>
{selectedAnswer.text}
</span>
</div>
)}

<div className="card" style={{ alignSelf: 'start' }}>
<div className="section-label">How it works</div>
<p
style={{
fontSize: '13px',
color: 'var(--rd-muted)',
lineHeight: 1.7,
}}
>
Select your answer and submit. Results appear live once
you&apos;ve voted — and you can change your mind until the host
closes the question.
</p>
</div>
</div>
) : (
/* ── Waiting phase ── */
<div className="participant-grid">
<div>
<div className="waiting-card">
<div className="waiting-icon">
<LuClock size={20} color="#E17000" aria-hidden="true" />
<div className="waiting-dot-row">
<div className="wdot" />
<div className="wdot" />
<div className="wdot" />
</div>

<button
className="btn-change btn-icon"
onClick={handleChange}
>
<LuRefreshCw size={14} />
Change my answer
</button>
</div>
</div>

<div
<div className="card" style={{ alignSelf: 'start' }}>
<div className="section-label">Live results</div>
{question.answers.map((answer, i) => (
<ResultBar
key={answer.id}
label={answer.text}
pct={calcPct(results, answer.id)}
color={BAR_COLORS[i] ?? BAR_COLORS[BAR_COLORS.length - 1]}
/>
))}
<p
style={{
fontSize: '15px',
fontWeight: 500,
color: 'var(--rd-text)',
marginBottom: '4px',
fontSize: '11px',
color: 'var(--rd-muted)',
marginTop: '16px',
}}
>
Vote submitted
</div>
<div style={{ fontSize: '13px', color: 'var(--rd-muted)' }}>
Waiting for others…
</div>

{selectedAnswer && (
<div className="voted-pill">
<div className="voted-pill-dot" />
<span style={{ fontSize: '13px', color: 'var(--rd-text)' }}>
{selectedAnswer.text}
</span>
</div>
)}

<div className="waiting-dot-row">
<div className="wdot" />
<div className="wdot" />
<div className="wdot" />
</div>

<button className="btn-change btn-icon" onClick={handleChange}>
<LuRefreshCw size={14} />
Change my answer
</button>
Updates live as others vote
</p>
</div>
</div>

<div className="card" style={{ alignSelf: 'start' }}>
<div className="section-label">Live results</div>
{question.answers.map((answer, i) => (
<ResultBar
key={answer.id}
label={answer.text}
pct={calcPct(results, answer.id)}
color={BAR_COLORS[i] ?? BAR_COLORS[BAR_COLORS.length - 1]}
/>
))}
<p
style={{
fontSize: '11px',
color: 'var(--rd-muted)',
marginTop: '16px',
}}
>
Updates live as others vote
</p>
</div>
</div>
)}
</div>
</Layout>
)}
</div>
</Layout>
{showThanks && <Toast message="Thanks for participating!" />}
</>
);
}
Loading
Loading