page.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. 'use client';
  2. import { useState, useEffect, Suspense } from 'react';
  3. import { useRouter, useSearchParams } from 'next/navigation';
  4. import { useAuth } from '@/lib/auth-context';
  5. import { Button } from '@/components/ui/button';
  6. import { invitationsApi, InvitationInfo } from '@/lib/api';
  7. function RegisterForm() {
  8. const router = useRouter();
  9. const searchParams = useSearchParams();
  10. const inviteToken = searchParams.get('invite_token');
  11. const { register, acceptedProjects, clearAcceptedProjects, justRegisteredName, user } = useAuth();
  12. const [name, setName] = useState('');
  13. const [email, setEmail] = useState('');
  14. const [password, setPassword] = useState('');
  15. const [error, setError] = useState('');
  16. const [loading, setLoading] = useState(false);
  17. const [inviteInfo, setInviteInfo] = useState<InvitationInfo | null>(null);
  18. const [inviteLoading, setInviteLoading] = useState(false);
  19. const [justJoined, setJustJoined] = useState(false);
  20. // Verify invite token if present — auto-fill email
  21. useEffect(() => {
  22. if (inviteToken) {
  23. setInviteLoading(true);
  24. invitationsApi.verify(inviteToken)
  25. .then(({ invitation }) => {
  26. setInviteInfo(invitation);
  27. setEmail(invitation.email);
  28. })
  29. .catch(() => { /* invalid token, not critical */ })
  30. .finally(() => setInviteLoading(false));
  31. }
  32. }, [inviteToken]);
  33. useEffect(() => {
  34. if (acceptedProjects.length > 0 && !justJoined) {
  35. setJustJoined(true);
  36. }
  37. }, [acceptedProjects, justJoined]);
  38. // Redirect if already logged in
  39. useEffect(() => {
  40. if (user) {
  41. if (inviteToken) {
  42. router.push(`/invite/${inviteToken}`);
  43. } else {
  44. router.push('/projects');
  45. }
  46. }
  47. }, [user, inviteToken, router]);
  48. const handleSubmit = async (e: React.FormEvent) => {
  49. e.preventDefault();
  50. setError('');
  51. if (password.length < 6) {
  52. setError('Password must be at least 6 characters');
  53. return;
  54. }
  55. setLoading(true);
  56. try {
  57. await register(email, name, password, inviteToken ?? undefined);
  58. if (inviteToken) {
  59. router.push(`/invite/${inviteToken}`);
  60. } else {
  61. router.push('/projects');
  62. }
  63. } catch (err: unknown) {
  64. setError(err instanceof Error ? err.message : 'Registration failed');
  65. } finally {
  66. setLoading(false);
  67. }
  68. };
  69. const handleGoToProject = (projectId: string) => {
  70. clearAcceptedProjects();
  71. router.push(`/projects/${projectId}`);
  72. };
  73. return (
  74. <div className="min-h-screen flex items-center justify-center relative overflow-hidden"
  75. style={{ background: 'var(--bg)' }}>
  76. {/* Ambient blobs */}
  77. <div className="absolute inset-0 overflow-hidden pointer-events-none">
  78. <div className="absolute top-20 right-20 w-[500px] h-[500px] rounded-full opacity-[0.06]"
  79. style={{ background: 'radial-gradient(circle, #6366F1 0%, transparent 70%)' }} />
  80. <div className="absolute -bottom-32 left-20 w-[400px] h-[400px] rounded-full opacity-[0.04]"
  81. style={{ background: 'radial-gradient(circle, #818CF8 0%, transparent 70%)' }}
  82. />
  83. <div className="absolute inset-0 opacity-[0.025]"
  84. style={{
  85. backgroundImage: 'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
  86. backgroundSize: '48px 48px',
  87. }} />
  88. </div>
  89. {/* Logo */}
  90. <div className="absolute top-8 left-1/2 -translate-x-1/2 flex items-center gap-2.5 animate-fade-in">
  91. <div className="w-9 h-9 rounded-xl flex items-center justify-center"
  92. style={{ background: '#6366F1', boxShadow: '0 0 24px rgba(99,102,241,0.5)' }}>
  93. <svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  94. <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
  95. <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  96. </svg>
  97. </div>
  98. <span className="text-lg font-semibold tracking-tight" style={{ color: 'var(--text)' }}>VidReview</span>
  99. </div>
  100. {/* Card */}
  101. <div className="relative w-full max-w-[400px] mx-6 animate-scale-in">
  102. <div className="rounded-2xl p-8"
  103. style={{
  104. background: 'rgba(22,24,34,0.90)',
  105. backdropFilter: 'blur(20px)',
  106. border: '1px solid rgba(255,255,255,0.08)',
  107. boxShadow: 'var(--shadow-modal)',
  108. }}>
  109. {/* Invite banner */}
  110. {inviteLoading ? (
  111. <div className="mb-5 rounded-lg px-4 py-3 text-sm animate-fade-in"
  112. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.20)', color: '#A5B4FC' }}>
  113. <div className="flex items-center gap-2">
  114. <div className="w-3.5 h-3.5 rounded-full animate-spin"
  115. style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: 1.5 }} />
  116. Verifying invitation…
  117. </div>
  118. </div>
  119. ) : inviteInfo ? (
  120. <div className="mb-5 rounded-lg px-4 py-3 text-sm animate-scale-in"
  121. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.20)', color: '#A5B4FC' }}>
  122. <div className="flex items-start gap-2.5">
  123. <svg className="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  124. <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
  125. </svg>
  126. <div>
  127. <p className="font-medium">You're invited to join</p>
  128. <p className="font-semibold mt-0.5">{inviteInfo.projectName}</p>
  129. <span className="badge badge-brand text-[10px] mt-1.5 capitalize">{inviteInfo.role.toLowerCase()}</span>
  130. </div>
  131. </div>
  132. </div>
  133. ) : null}
  134. {/* Accepted projects notification */}
  135. {justJoined && (
  136. <div className="mb-5 rounded-lg px-4 py-3 text-sm animate-scale-in"
  137. style={{ background: 'rgba(34,197,94,0.12)', border: '1px solid rgba(34,197,94,0.25)', color: '#86EFAC' }}>
  138. {justRegisteredName && (
  139. <p className="font-medium mb-1">Welcome, {justRegisteredName}!</p>
  140. )}
  141. {acceptedProjects.length > 0 ? (
  142. <>
  143. <p className="font-medium mb-2">You're now a member of:</p>
  144. {acceptedProjects.map(p => (
  145. <button
  146. key={p.projectId}
  147. onClick={() => handleGoToProject(p.projectId)}
  148. className="block text-left hover:underline"
  149. style={{ color: '#86EFAC' }}
  150. >
  151. → {p.projectName}
  152. </button>
  153. ))}
  154. </>
  155. ) : (
  156. <p>Your account is ready. Redirecting…</p>
  157. )}
  158. </div>
  159. )}
  160. {error && (
  161. <div className="mb-5 rounded-lg px-4 py-3 text-sm flex items-start gap-2.5 animate-scale-in"
  162. style={{ background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.25)', color: '#FCA5A5' }}>
  163. <svg className="w-4 h-4 mt-0.5 shrink-0 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  164. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
  165. d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  166. </svg>
  167. {error}
  168. </div>
  169. )}
  170. <div className="mb-7">
  171. <h1 className="text-xl font-semibold tracking-tight" style={{ color: 'var(--text)' }}>
  172. {inviteInfo ? 'Create your account' : 'Create workspace'}
  173. </h1>
  174. <p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>
  175. {inviteInfo ? 'Join your team in seconds' : 'Set up your team in under a minute'}
  176. </p>
  177. </div>
  178. <form onSubmit={handleSubmit} className="space-y-4">
  179. <div>
  180. <label htmlFor="name" className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
  181. Full name
  182. </label>
  183. <input
  184. id="name"
  185. type="text"
  186. className="input"
  187. value={name}
  188. onChange={e => setName(e.target.value)}
  189. placeholder="Jane Editor"
  190. required
  191. autoComplete="name"
  192. />
  193. </div>
  194. <div>
  195. <label htmlFor="email" className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
  196. Email address
  197. </label>
  198. <input
  199. id="email"
  200. type="email"
  201. className="input"
  202. value={email}
  203. onChange={e => setEmail(e.target.value)}
  204. placeholder="you@company.com"
  205. required
  206. autoComplete="email"
  207. />
  208. </div>
  209. <div>
  210. <label htmlFor="password" className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
  211. Password
  212. </label>
  213. <input
  214. id="password"
  215. type="password"
  216. className="input"
  217. value={password}
  218. onChange={e => setPassword(e.target.value)}
  219. placeholder="At least 6 characters"
  220. required
  221. autoComplete="new-password"
  222. />
  223. <p className="text-xs mt-1.5" style={{ color: 'var(--text-subtle)' }}>
  224. Use 8+ characters with a mix of letters and numbers
  225. </p>
  226. </div>
  227. <Button
  228. type="submit"
  229. loading={loading}
  230. className="btn btn-primary btn-lg w-full mt-2"
  231. >
  232. {loading ? 'Creating account…' : 'Create account'}
  233. </Button>
  234. </form>
  235. <p className="text-center text-sm mt-5" style={{ color: 'var(--text-muted)' }}>
  236. Already have an account?{' '}
  237. <a href="/login"
  238. className="font-medium transition-colors hover:underline"
  239. style={{ color: '#818CF8' }}>
  240. Sign in
  241. </a>
  242. </p>
  243. </div>
  244. </div>
  245. </div>
  246. );
  247. }
  248. export default function RegisterPage() {
  249. return (
  250. <Suspense fallback={
  251. <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  252. <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
  253. <div className="w-5 h-5 rounded-full animate-spin"
  254. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  255. <span className="text-sm">Loading…</span>
  256. </div>
  257. </div>
  258. }>
  259. <RegisterForm />
  260. </Suspense>
  261. );
  262. }