- invitations table (token, task, inviter, expiry 7d) - POST /invitations (create + share links: WhatsApp, Telegram, SMS, Copy) - GET /invite/:token (public landing page) - POST /invite/:token/accept (register + assign + JWT) - Landing page at /invite/[token] with accept flow - CLAUDE.md + Notion key deployed to all servers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
193 lines
8.3 KiB
TypeScript
193 lines
8.3 KiB
TypeScript
'use client';
|
|
import { useState, useEffect } from 'react';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
|
|
interface InviteData {
|
|
inviter_name: string | null;
|
|
inviter_avatar: string | null;
|
|
invitee_name: string | null;
|
|
invitee_email: string | null;
|
|
task_title: string | null;
|
|
task_description: string | null;
|
|
due_at: string | null;
|
|
message: string | null;
|
|
}
|
|
|
|
export default function InvitePage() {
|
|
const { token } = useParams();
|
|
const router = useRouter();
|
|
const [invite, setInvite] = useState<InviteData | null>(null);
|
|
const [name, setName] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [accepting, setAccepting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetch(`/api/v1/invite/${token}`)
|
|
.then(r => {
|
|
if (!r.ok) return r.json().then(d => { throw new Error(d.message || 'Not found'); });
|
|
return r.json();
|
|
})
|
|
.then(d => {
|
|
setInvite(d.data);
|
|
setName(d.data?.invitee_name || '');
|
|
setEmail(d.data?.invitee_email || '');
|
|
})
|
|
.catch(e => setError(e.message || 'Pozvanka nenalezena'))
|
|
.finally(() => setLoading(false));
|
|
}, [token]);
|
|
|
|
async function accept() {
|
|
if (!name || name.trim().length < 2) {
|
|
setError('Jmeno musi mit alespon 2 znaky');
|
|
return;
|
|
}
|
|
setAccepting(true);
|
|
setError('');
|
|
try {
|
|
const res = await fetch(`/api/v1/invite/${token}/accept`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name,
|
|
password: password || undefined,
|
|
email: email || invite?.invitee_email
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(data.message || 'Chyba pri prijimani');
|
|
}
|
|
if (data.data?.token) {
|
|
localStorage.setItem('taskteam_token', data.data.token);
|
|
localStorage.setItem('taskteam_user', JSON.stringify(data.data.user));
|
|
router.push('/tasks');
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Chyba pri prijimani');
|
|
}
|
|
setAccepting(false);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-blue-600 to-blue-800">
|
|
<div className="animate-spin h-8 w-8 border-b-2 border-white rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && !invite) {
|
|
return (
|
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-blue-600 to-blue-800 p-4">
|
|
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl text-center">
|
|
<div className="text-4xl mb-4">😕</div>
|
|
<h1 className="text-xl font-bold mb-2">Pozvanka neni platna</h1>
|
|
<p className="text-gray-500 mb-4">{error}</p>
|
|
<button
|
|
onClick={() => router.push('/login')}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium"
|
|
>
|
|
Prihlasit se
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-b from-blue-600 to-blue-800 flex items-center justify-center p-4">
|
|
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl">
|
|
<div className="text-center mb-6">
|
|
<div className="text-4xl mb-2">🎉</div>
|
|
<h1 className="text-xl font-bold">Task Team</h1>
|
|
<div className="flex items-center justify-center gap-2 mt-3">
|
|
{invite?.inviter_avatar ? (
|
|
<img
|
|
src={invite.inviter_avatar}
|
|
alt=""
|
|
className="w-10 h-10 rounded-full"
|
|
/>
|
|
) : (
|
|
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold">
|
|
{invite?.inviter_name?.[0]?.toUpperCase() || '?'}
|
|
</div>
|
|
)}
|
|
<span className="text-gray-600 dark:text-gray-300">
|
|
{invite?.inviter_name || 'Nekdo'} te zve
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 mb-4">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span>📋</span>
|
|
<span className="font-semibold">{invite?.task_title || 'Task'}</span>
|
|
</div>
|
|
{invite?.task_description && (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
|
|
{invite.task_description}
|
|
</p>
|
|
)}
|
|
{invite?.due_at && (
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
|
📅 {new Date(invite.due_at).toLocaleDateString('cs-CZ')}
|
|
</div>
|
|
)}
|
|
{invite?.message && (
|
|
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2 italic border-l-2 border-blue-400 pl-2">
|
|
“{invite.message}”
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg p-2 mb-3">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<input
|
|
value={name}
|
|
onChange={e => setName(e.target.value)}
|
|
placeholder="Tvoje jmeno"
|
|
className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"
|
|
/>
|
|
{!invite?.invitee_email && (
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={e => setEmail(e.target.value)}
|
|
placeholder="Tvuj email"
|
|
className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"
|
|
/>
|
|
)}
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={e => setPassword(e.target.value)}
|
|
placeholder="Heslo (min 6 znaku)"
|
|
className="w-full px-4 py-3 rounded-lg border dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"
|
|
/>
|
|
<button
|
|
onClick={accept}
|
|
disabled={accepting || !name.trim()}
|
|
className="w-full py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg font-bold text-lg disabled:opacity-50 transition-colors"
|
|
>
|
|
{accepting ? 'Zpracovavam...' : 'Prijmout a zaregistrovat'}
|
|
</button>
|
|
<button
|
|
onClick={() => router.push('/login')}
|
|
className="w-full py-2 text-blue-600 dark:text-blue-400 text-sm hover:underline"
|
|
>
|
|
Mam ucet - prihlasit se
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|