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>
This commit is contained in:
192
apps/tasks/app/invite/[token]/page.tsx
Normal file
192
apps/tasks/app/invite/[token]/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
searchUsers,
|
||||
} from "@/lib/api";
|
||||
import TaskForm from "@/components/TaskForm";
|
||||
import InviteModal from "@/components/InviteModal";
|
||||
import StatusBadge from "@/components/StatusBadge";
|
||||
|
||||
function isDone(status: string): boolean {
|
||||
@@ -23,7 +24,7 @@ function isDone(status: string): boolean {
|
||||
}
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const { token } = useAuth();
|
||||
const { token, user } = useAuth();
|
||||
const { t, locale } = useTranslation();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
@@ -36,6 +37,7 @@ export default function TaskDetailPage() {
|
||||
const [error, setError] = useState("");
|
||||
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
|
||||
const [assigneeNames, setAssigneeNames] = useState<Record<string, string>>({});
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
|
||||
const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU";
|
||||
|
||||
@@ -383,6 +385,16 @@ export default function TaskDetailPage() {
|
||||
<p className="text-sm text-gray-400">{t("collab.noAssignees")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Modal */}
|
||||
{showInvite && token && user && (
|
||||
<InviteModal
|
||||
taskId={id}
|
||||
token={token}
|
||||
userId={user.id}
|
||||
onClose={() => setShowInvite(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user