feat: Add veto speaker button when boredom exceeds 66%
- Add veto button that appears when collective boredom > 66% - Implement 15-second voting period for veto decisions - Majority vote (50%+) required to pass veto - Add animated UI for veto voting progress - Show veto result (passed/failed) with animations - WebSocket broadcasts veto state to all participants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2b0c831399
commit
cb76142b2b
106
server/index.js
106
server/index.js
|
|
@ -4,12 +4,16 @@ const crypto = require('crypto');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
// Room storage: { roomId: { users: Map, createdAt: Date, name: string } }
|
// Room storage: { roomId: { users: Map, createdAt: Date, name: string, veto: object } }
|
||||||
const rooms = new Map();
|
const rooms = new Map();
|
||||||
|
|
||||||
// Global room (the default public room with bots)
|
// Global room (the default public room with bots)
|
||||||
const GLOBAL_ROOM_ID = 'global';
|
const GLOBAL_ROOM_ID = 'global';
|
||||||
|
|
||||||
|
// Veto threshold - when collective boredom exceeds this, veto becomes available
|
||||||
|
const VETO_THRESHOLD = 66;
|
||||||
|
const VETO_DURATION = 15000; // 15 seconds to vote
|
||||||
|
|
||||||
// Generate short room codes
|
// Generate short room codes
|
||||||
const generateRoomCode = () => {
|
const generateRoomCode = () => {
|
||||||
return crypto.randomBytes(3).toString('hex').toUpperCase();
|
return crypto.randomBytes(3).toString('hex').toUpperCase();
|
||||||
|
|
@ -81,15 +85,102 @@ const getRoomStats = (roomId) => {
|
||||||
name: u.name || null
|
name: u.name || null
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Include veto state
|
||||||
|
const veto = room.veto ? {
|
||||||
|
active: true,
|
||||||
|
initiator: room.veto.initiatorName || 'Someone',
|
||||||
|
votes: room.veto.votes.size,
|
||||||
|
needed: Math.ceil(count / 2),
|
||||||
|
timeLeft: Math.max(0, Math.ceil((room.veto.endTime - Date.now()) / 1000))
|
||||||
|
} : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
average,
|
average,
|
||||||
count,
|
count,
|
||||||
individuals,
|
individuals,
|
||||||
roomName: room.name,
|
roomName: room.name,
|
||||||
roomId
|
roomId,
|
||||||
|
vetoAvailable: average >= VETO_THRESHOLD,
|
||||||
|
veto
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Start a veto vote
|
||||||
|
const startVeto = (roomId, initiatorId, initiatorName) => {
|
||||||
|
const room = rooms.get(roomId);
|
||||||
|
if (!room || room.veto) return false;
|
||||||
|
|
||||||
|
const stats = getRoomStats(roomId);
|
||||||
|
if (stats.average < VETO_THRESHOLD) return false;
|
||||||
|
|
||||||
|
room.veto = {
|
||||||
|
initiatorId,
|
||||||
|
initiatorName: initiatorName || 'Someone',
|
||||||
|
votes: new Set([initiatorId]), // Initiator automatically votes yes
|
||||||
|
startTime: Date.now(),
|
||||||
|
endTime: Date.now() + VETO_DURATION
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`Veto started in room ${roomId} by ${initiatorName}`);
|
||||||
|
|
||||||
|
// Broadcast veto started
|
||||||
|
broadcastToRoom(roomId);
|
||||||
|
|
||||||
|
// Set timeout to end veto
|
||||||
|
setTimeout(() => {
|
||||||
|
endVeto(roomId, false);
|
||||||
|
}, VETO_DURATION);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cast a veto vote
|
||||||
|
const castVetoVote = (roomId, voterId) => {
|
||||||
|
const room = rooms.get(roomId);
|
||||||
|
if (!room || !room.veto) return false;
|
||||||
|
|
||||||
|
room.veto.votes.add(voterId);
|
||||||
|
|
||||||
|
// Check if majority reached
|
||||||
|
const realUsers = Array.from(room.users.values()).filter(u => !u.isBot && u.ws);
|
||||||
|
const needed = Math.ceil(realUsers.length / 2);
|
||||||
|
|
||||||
|
if (room.veto.votes.size >= needed) {
|
||||||
|
endVeto(roomId, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToRoom(roomId);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// End veto voting
|
||||||
|
const endVeto = (roomId, passed) => {
|
||||||
|
const room = rooms.get(roomId);
|
||||||
|
if (!room || !room.veto) return;
|
||||||
|
|
||||||
|
const voteCount = room.veto.votes.size;
|
||||||
|
room.veto = null;
|
||||||
|
|
||||||
|
// Broadcast result
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type: 'vetoResult',
|
||||||
|
passed,
|
||||||
|
votes: voteCount
|
||||||
|
});
|
||||||
|
|
||||||
|
room.users.forEach((user) => {
|
||||||
|
if (user.ws && user.ws.readyState === WebSocket.OPEN) {
|
||||||
|
user.ws.send(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Veto in room ${roomId} ${passed ? 'PASSED' : 'failed'} with ${voteCount} votes`);
|
||||||
|
|
||||||
|
// Also broadcast updated stats (veto no longer active)
|
||||||
|
setTimeout(() => broadcastToRoom(roomId), 100);
|
||||||
|
};
|
||||||
|
|
||||||
// Broadcast to all users in a room
|
// Broadcast to all users in a room
|
||||||
const broadcastToRoom = (roomId) => {
|
const broadcastToRoom = (roomId) => {
|
||||||
const room = rooms.get(roomId);
|
const room = rooms.get(roomId);
|
||||||
|
|
@ -270,6 +361,17 @@ wss.on('connection', (ws, req) => {
|
||||||
broadcastToRoom(roomId);
|
broadcastToRoom(roomId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Veto actions
|
||||||
|
if (message.type === 'startVeto') {
|
||||||
|
const user = room.users.get(userId);
|
||||||
|
const name = user?.name || 'Someone';
|
||||||
|
startVeto(roomId, userId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'vetoVote') {
|
||||||
|
castVetoVote(roomId, userId);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Invalid message:', err.message);
|
console.error('Invalid message:', err.message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
231
src/App.css
231
src/App.css
|
|
@ -697,3 +697,234 @@ body {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== VETO SPEAKER ==================== */
|
||||||
|
.veto-section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
animation: vetoSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vetoSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Veto Available State */
|
||||||
|
.veto-available {
|
||||||
|
background: linear-gradient(135deg, rgba(251, 146, 60, 0.15), rgba(239, 68, 68, 0.15));
|
||||||
|
border: 1px solid rgba(251, 146, 60, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
animation: vietoPulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vietoPulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-message {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fb923c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-veto {
|
||||||
|
background: linear-gradient(135deg, #fb923c, #ef4444);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
animation: vetoPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vetoPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(251, 146, 60, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 20px 5px rgba(251, 146, 60, 0.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-veto:hover {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
box-shadow: 0 8px 25px rgba(251, 146, 60, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Veto Active (Voting) State */
|
||||||
|
.veto-active {
|
||||||
|
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2));
|
||||||
|
border: 2px solid rgba(139, 92, 246, 0.5);
|
||||||
|
animation: vetoSlideIn 0.3s ease-out, vetoBorder 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vetoBorder {
|
||||||
|
0%, 100% { border-color: rgba(139, 92, 246, 0.5); }
|
||||||
|
50% { border-color: rgba(236, 72, 153, 0.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-voting {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e4e4e7;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-initiator {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #a1a1aa;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-bar {
|
||||||
|
height: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #a78bfa, #ec4899);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-count {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #c4b5fd;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-timer {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fbbf24;
|
||||||
|
animation: timerPulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes timerPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-vote {
|
||||||
|
background: linear-gradient(135deg, #a78bfa, #ec4899);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-vote:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 8px 25px rgba(167, 139, 250, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Veto Result States */
|
||||||
|
.veto-result {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-result-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-result-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
animation: resultBounce 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes resultBounce {
|
||||||
|
0% { transform: scale(0); }
|
||||||
|
50% { transform: scale(1.2); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-result h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-result p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #a1a1aa;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-passed {
|
||||||
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.3));
|
||||||
|
border: 2px solid rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-passed h3 {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-failed {
|
||||||
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(22, 163, 74, 0.2));
|
||||||
|
border: 2px solid rgba(34, 197, 94, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-failed h3 {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments for veto */
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.veto-section {
|
||||||
|
margin: 1rem auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veto-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-veto,
|
||||||
|
.btn-vote {
|
||||||
|
padding: 0.875rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
95
src/App.js
95
src/App.js
|
|
@ -27,6 +27,9 @@ const useWebSocket = (roomId = 'global', userName = null) => {
|
||||||
const [userCount, setUserCount] = useState(0);
|
const [userCount, setUserCount] = useState(0);
|
||||||
const [individuals, setIndividuals] = useState([]);
|
const [individuals, setIndividuals] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [vetoAvailable, setVetoAvailable] = useState(false);
|
||||||
|
const [veto, setVeto] = useState(null);
|
||||||
|
const [vetoResult, setVetoResult] = useState(null);
|
||||||
const wsRef = useRef(null);
|
const wsRef = useRef(null);
|
||||||
const reconnectTimeoutRef = useRef(null);
|
const reconnectTimeoutRef = useRef(null);
|
||||||
|
|
||||||
|
|
@ -56,10 +59,19 @@ const useWebSocket = (roomId = 'global', userName = null) => {
|
||||||
setGlobalBoredom(data.average || 50);
|
setGlobalBoredom(data.average || 50);
|
||||||
setUserCount(data.count || 0);
|
setUserCount(data.count || 0);
|
||||||
setIndividuals(data.individuals || []);
|
setIndividuals(data.individuals || []);
|
||||||
|
setVetoAvailable(data.vetoAvailable || false);
|
||||||
|
setVeto(data.veto || null);
|
||||||
} else if (data.type === 'stats') {
|
} else if (data.type === 'stats') {
|
||||||
setGlobalBoredom(data.average || 50);
|
setGlobalBoredom(data.average || 50);
|
||||||
setUserCount(data.count || 0);
|
setUserCount(data.count || 0);
|
||||||
setIndividuals(data.individuals || []);
|
setIndividuals(data.individuals || []);
|
||||||
|
setVetoAvailable(data.vetoAvailable || false);
|
||||||
|
setVeto(data.veto || null);
|
||||||
|
} else if (data.type === 'vetoResult') {
|
||||||
|
setVetoResult({ passed: data.passed, votes: data.votes });
|
||||||
|
setVeto(null);
|
||||||
|
// Clear result after 5 seconds
|
||||||
|
setTimeout(() => setVetoResult(null), 5000);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse message:', err);
|
console.error('Failed to parse message:', err);
|
||||||
|
|
@ -114,6 +126,22 @@ const useWebSocket = (roomId = 'global', userName = null) => {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const startVeto = useCallback(() => {
|
||||||
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({
|
||||||
|
type: 'startVeto'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const voteVeto = useCallback(() => {
|
||||||
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({
|
||||||
|
type: 'vetoVote'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isConnected,
|
isConnected,
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -123,7 +151,12 @@ const useWebSocket = (roomId = 'global', userName = null) => {
|
||||||
individuals,
|
individuals,
|
||||||
error,
|
error,
|
||||||
sendBoredom,
|
sendBoredom,
|
||||||
sendName
|
sendName,
|
||||||
|
vetoAvailable,
|
||||||
|
veto,
|
||||||
|
vetoResult,
|
||||||
|
startVeto,
|
||||||
|
voteVeto
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -310,7 +343,10 @@ function RoomPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [myBoredom, setMyBoredom] = useState(50);
|
const [myBoredom, setMyBoredom] = useState(50);
|
||||||
const [showShare, setShowShare] = useState(false);
|
const [showShare, setShowShare] = useState(false);
|
||||||
const { isConnected, userId, roomName, globalBoredom, userCount, individuals, error, sendBoredom } = useWebSocket(roomId);
|
const {
|
||||||
|
isConnected, userId, roomName, globalBoredom, userCount, individuals, error, sendBoredom,
|
||||||
|
vetoAvailable, veto, vetoResult, startVeto, voteVeto
|
||||||
|
} = useWebSocket(roomId);
|
||||||
|
|
||||||
const handleBoredomChange = useCallback((value) => {
|
const handleBoredomChange = useCallback((value) => {
|
||||||
setMyBoredom(value);
|
setMyBoredom(value);
|
||||||
|
|
@ -453,6 +489,61 @@ function RoomPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Veto Speaker Section */}
|
||||||
|
{vetoAvailable && !veto && !vetoResult && (
|
||||||
|
<div className="veto-section veto-available">
|
||||||
|
<div className="veto-alert">
|
||||||
|
<span className="veto-icon">⚠️</span>
|
||||||
|
<span className="veto-message">Collective boredom exceeds 66%!</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-veto" onClick={startVeto}>
|
||||||
|
🗳️ Veto Speaker
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{veto && (
|
||||||
|
<div className="veto-section veto-active">
|
||||||
|
<div className="veto-voting">
|
||||||
|
<h3 className="veto-title">🗳️ Veto in Progress!</h3>
|
||||||
|
<p className="veto-initiator">{veto.initiator} called for a vote</p>
|
||||||
|
<div className="veto-progress">
|
||||||
|
<div className="veto-bar">
|
||||||
|
<div
|
||||||
|
className="veto-bar-fill"
|
||||||
|
style={{ width: `${(veto.votes / veto.needed) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="veto-count">{veto.votes} / {veto.needed} votes needed</span>
|
||||||
|
</div>
|
||||||
|
<div className="veto-timer">⏱️ {veto.timeLeft}s remaining</div>
|
||||||
|
<button className="btn btn-vote" onClick={voteVeto}>
|
||||||
|
✋ Vote to Veto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vetoResult && (
|
||||||
|
<div className={`veto-section veto-result ${vetoResult.passed ? 'veto-passed' : 'veto-failed'}`}>
|
||||||
|
<div className="veto-result-content">
|
||||||
|
{vetoResult.passed ? (
|
||||||
|
<>
|
||||||
|
<span className="veto-result-icon">🚫</span>
|
||||||
|
<h3>Speaker Vetoed!</h3>
|
||||||
|
<p>The group has spoken. Time for a change!</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="veto-result-icon">✅</span>
|
||||||
|
<h3>Veto Failed</h3>
|
||||||
|
<p>Not enough votes. The speaker continues.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue