Files
task-team/apps/tasks/app/invite/[token]/page.tsx
Admin 703541d29a Invitation system: DB + API + landing page + share links
- 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>
2026-03-30 00:31:22 +00:00

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">&#128533;</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">&#127881;</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>&#128203;</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">
&#128197; {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">
&ldquo;{invite.message}&rdquo;
</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>
);
}