All checks were successful
Build & Push Docker Image / build (push) Successful in 4m58s
348 lines
14 KiB
JavaScript
348 lines
14 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
|
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
|
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
|
|
import BrandLogo from '../components/BrandLogo';
|
|
import api from '../services/api';
|
|
import toast from 'react-hot-toast';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { useBranding } from '../contexts/BrandingContext';
|
|
|
|
export default function GuestJoin() {
|
|
const { uid } = useParams();
|
|
const [searchParams] = useSearchParams();
|
|
const { t } = useLanguage();
|
|
const { user } = useAuth();
|
|
const { imprintUrl, privacyUrl } = useBranding();
|
|
const isLoggedIn = !!user;
|
|
const [roomInfo, setRoomInfo] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [joining, setJoining] = useState(false);
|
|
const [name, setName] = useState(user?.name || '');
|
|
const [accessCode, setAccessCode] = useState(searchParams.get('ac') || '');
|
|
const [moderatorCode, setModeratorCode] = useState('');
|
|
const [status, setStatus] = useState({ running: false });
|
|
const [recordingConsent, setRecordingConsent] = useState(false);
|
|
const [waiting, setWaiting] = useState(false);
|
|
const prevRunningRef = useRef(false);
|
|
|
|
const joinMeeting = async () => {
|
|
setJoining(true);
|
|
try {
|
|
const res = await api.post(`/rooms/${uid}/guest-join`, {
|
|
name: name.trim(),
|
|
access_code: accessCode || undefined,
|
|
moderator_code: moderatorCode || undefined,
|
|
});
|
|
if (res.data.joinUrl) {
|
|
window.location.href = res.data.joinUrl;
|
|
}
|
|
} catch (err) {
|
|
const errStatus = err.response?.status;
|
|
if (errStatus === 403) {
|
|
toast.error(t('room.guestWrongAccessCode'));
|
|
setWaiting(false);
|
|
} else {
|
|
toast.error(t('room.guestJoinFailed'));
|
|
setWaiting(false);
|
|
}
|
|
} finally {
|
|
setJoining(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const fetchRoom = async () => {
|
|
try {
|
|
const res = await api.get(`/rooms/${uid}/public`);
|
|
setRoomInfo(res.data.room);
|
|
setStatus({ running: res.data.running });
|
|
prevRunningRef.current = res.data.running;
|
|
} catch (err) {
|
|
const status = err.response?.status;
|
|
if (status === 403) {
|
|
setError(t('room.guestAccessNotEnabled'));
|
|
} else if (status === 404) {
|
|
setError(t('room.guestRoomNotFound'));
|
|
} else {
|
|
setError(t('room.guestRoomNotFound'));
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchRoom();
|
|
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
const res = await api.get(`/rooms/${uid}/status`);
|
|
setStatus(res.data);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, 5000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [uid]);
|
|
|
|
// Auto-join when meeting starts while waiting
|
|
useEffect(() => {
|
|
if (!prevRunningRef.current && status.running && waiting) {
|
|
new Audio('/sounds/meeting-started.mp3').play().catch(() => {});
|
|
toast.success(t('room.guestMeetingStartedJoining'));
|
|
joinMeeting();
|
|
}
|
|
prevRunningRef.current = status.running;
|
|
}, [status.running]);
|
|
|
|
const handleJoin = async (e) => {
|
|
e.preventDefault();
|
|
if (!name.trim()) {
|
|
toast.error(t('room.guestNameRequired'));
|
|
return;
|
|
}
|
|
if (roomInfo?.allow_recording && !recordingConsent) {
|
|
toast.error(t('room.guestRecordingConsent'));
|
|
return;
|
|
}
|
|
if (!status.running && !roomInfo?.anyone_can_start) {
|
|
setWaiting(true);
|
|
return;
|
|
}
|
|
await joinMeeting();
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-th-bg">
|
|
<Loader2 size={32} className="animate-spin text-th-accent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
|
<div className="absolute inset-0 bg-th-bg">
|
|
<div className="absolute inset-0 opacity-30">
|
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
|
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
|
</div>
|
|
</div>
|
|
<div className="relative w-full max-w-md">
|
|
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
|
|
<div className="w-16 h-16 bg-th-error/15 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<Video size={28} className="text-th-error" />
|
|
</div>
|
|
<h2 className="text-xl font-bold text-th-text mb-2">{t('room.guestAccessDenied')}</h2>
|
|
<p className="text-sm text-th-text-s mb-6">{error}</p>
|
|
<Link to="/login" className="btn-primary inline-flex">
|
|
{t('auth.login')}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
|
{/* Animated background */}
|
|
<div className="absolute inset-0 bg-th-bg">
|
|
<div className="absolute inset-0 opacity-30">
|
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
|
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Join card */}
|
|
<div className="relative w-full max-w-md">
|
|
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
|
{/* Logo */}
|
|
<div className="flex justify-center mb-6">
|
|
<BrandLogo size="lg" />
|
|
</div>
|
|
|
|
{/* Room info */}
|
|
<div className="text-center mb-6">
|
|
<h2 className="text-xl font-bold text-th-text mb-1">{roomInfo.name}</h2>
|
|
<p className="text-sm text-th-text-s">
|
|
{t('room.guestCreatedBy')} <span className="font-medium text-th-text">{roomInfo.owner_name}</span>
|
|
</p>
|
|
<div className="mt-3 inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium"
|
|
style={{
|
|
backgroundColor: status.running ? 'rgba(34, 197, 94, 0.15)' : 'rgba(100, 116, 139, 0.15)',
|
|
color: status.running ? '#22c55e' : '#94a3b8',
|
|
}}
|
|
>
|
|
{status.running ? <Radio size={10} className="animate-pulse" /> : <Users size={12} />}
|
|
{status.running ? t('room.guestMeetingRunning') : t('room.guestMeetingNotStarted')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Join form */}
|
|
{waiting ? (
|
|
<div className="flex flex-col items-center gap-5 py-4">
|
|
<div className="w-16 h-16 rounded-full flex items-center justify-center bg-th-accent/10">
|
|
<Clock size={28} className="text-th-accent animate-pulse" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="font-semibold text-th-text mb-1">{t('room.guestWaitingTitle')}</p>
|
|
<p className="text-sm text-th-text-s">{t('room.guestWaitingHint')}</p>
|
|
</div>
|
|
{joining && (
|
|
<div className="flex items-center gap-2 text-sm text-th-success font-medium">
|
|
<Loader2 size={16} className="animate-spin" />
|
|
{t('room.guestMeetingStartedJoining')}
|
|
</div>
|
|
)}
|
|
{!joining && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setWaiting(false)}
|
|
className="btn-ghost flex items-center gap-2 text-sm"
|
|
>
|
|
<X size={16} />
|
|
{t('room.guestCancelWaiting')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleJoin} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
|
|
<div className="relative">
|
|
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={e => !isLoggedIn && setName(e.target.value)}
|
|
readOnly={isLoggedIn}
|
|
className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
|
|
placeholder={t('room.guestNamePlaceholder')}
|
|
required
|
|
autoFocus={!isLoggedIn}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{roomInfo.has_access_code && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
|
|
<div className="relative">
|
|
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={accessCode}
|
|
onChange={e => setAccessCode(e.target.value)}
|
|
className="input-field pl-11"
|
|
placeholder={t('room.guestAccessCodePlaceholder')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">
|
|
{t('room.guestModeratorCode')}
|
|
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
|
|
</label>
|
|
<div className="relative">
|
|
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={moderatorCode}
|
|
onChange={e => setModeratorCode(e.target.value)}
|
|
className="input-field pl-11"
|
|
placeholder={t('room.guestModeratorPlaceholder')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recording consent notice */}
|
|
{roomInfo.allow_recording && (
|
|
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
|
|
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
|
|
</div>
|
|
<label className="flex items-center gap-2.5 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={recordingConsent}
|
|
onChange={e => setRecordingConsent(e.target.checked)}
|
|
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
|
|
/>
|
|
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={joining || (roomInfo.allow_recording && !recordingConsent)}
|
|
className="btn-primary w-full py-3"
|
|
>
|
|
{joining ? (
|
|
<Loader2 size={18} className="animate-spin" />
|
|
) : (
|
|
<>
|
|
{t('room.guestJoinButton')}
|
|
<ArrowRight size={18} />
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{!status.running && (
|
|
<p className="text-xs text-th-text-s text-center">
|
|
{t('room.guestWaitingMessage')}
|
|
</p>
|
|
)}
|
|
</form>
|
|
)}
|
|
|
|
{!isLoggedIn && (
|
|
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
|
<Link to="/login" className="text-sm text-th-text-s hover:text-th-accent transition-colors">
|
|
{t('room.guestHasAccount')} <span className="text-th-accent font-medium">{t('room.guestSignIn')}</span>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{(imprintUrl || privacyUrl) && (
|
|
<div className="flex items-center justify-center gap-4 mt-4 pt-4 border-t border-th-border/60">
|
|
{imprintUrl && (
|
|
<a
|
|
href={imprintUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
|
>
|
|
<FileText size={11} />
|
|
{t('nav.imprint')}
|
|
</a>
|
|
)}
|
|
{imprintUrl && privacyUrl && (
|
|
<span className="text-th-border text-xs">·</span>
|
|
)}
|
|
{privacyUrl && (
|
|
<a
|
|
href={privacyUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
|
>
|
|
<Lock size={11} />
|
|
{t('nav.privacy')}
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|