Forráskód Böngészése

feat: add "Create & Copy Link" option for invitations without email

- Add second email input section with "or" separator in invite form
- Create & Copy Link button generates invite URL and copies to clipboard immediately
- Shows success banner with the created link for confirmation
- Keeps existing "Send invite" flow as primary option

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Son Nguyen 1 hónapja
szülő
commit
c3e337d7b8
1 módosított fájl, 126 hozzáadás és 36 törlés
  1. 126 36
      src/app/(dashboard)/projects/[projectId]/page.tsx

+ 126 - 36
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -40,6 +40,7 @@ export default function ProjectDetailPage() {
   const [inviting, setInviting] = useState(false);
   const [inviteError, setInviteError] = useState('');
   const [inviteSuccess, setInviteSuccess] = useState('');
+  const [createdLink, setCreatedLink] = useState('');
 
   // Edit member role
   const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
@@ -95,6 +96,7 @@ export default function ProjectDetailPage() {
     setInviting(true);
     setInviteError('');
     setInviteSuccess('');
+    setCreatedLink('');
     try {
       const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
       const { invitations } = await invitationsApi.list(token, projectId);
@@ -110,6 +112,28 @@ export default function ProjectDetailPage() {
     }
   };
 
+  // ── Create & copy link ─────────────────────────────────────────────────────
+  const handleCreateLink = async () => {
+    if (!token || !inviteEmail.trim()) return;
+    setInviting(true);
+    setInviteError('');
+    setInviteSuccess('');
+    setCreatedLink('');
+    try {
+      const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
+      const { invitations } = await invitationsApi.list(token, projectId);
+      setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
+      const fullUrl = `${window.location.origin}${inviteUrl}`;
+      await navigator.clipboard.writeText(fullUrl);
+      setCreatedLink(fullUrl);
+      setInviteEmail('');
+    } catch (err) {
+      setInviteError(err instanceof Error ? err.message : 'Failed to create link');
+    } finally {
+      setInviting(false);
+    }
+  };
+
   // ── Change role ────────────────────────────────────────────────────────────
   const handleChangeRole = async (memberId: string) => {
     if (!token || !editingRole) return;
@@ -419,45 +443,111 @@ export default function ProjectDetailPage() {
                   Invite someone
                 </h2>
 
-                <form onSubmit={handleInvite} className="flex items-end gap-3 flex-wrap">
-                  <div className="flex-1 min-w-[180px]">
-                    <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email address</label>
-                    <input
-                      type="email"
-                      className="input"
-                      value={inviteEmail}
-                      onChange={e => setInviteEmail(e.target.value)}
-                      placeholder="colleague@company.com"
-                      required
-                    />
+                <div className="space-y-3">
+                  <form onSubmit={handleInvite} className="flex items-end gap-3 flex-wrap">
+                    <div className="flex-1 min-w-[180px]">
+                      <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email address</label>
+                      <input
+                        type="email"
+                        className="input"
+                        value={inviteEmail}
+                        onChange={e => setInviteEmail(e.target.value)}
+                        placeholder="colleague@company.com"
+                        required
+                      />
+                    </div>
+                    <div className="w-36">
+                      <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
+                      <select
+                        className="input"
+                        value={inviteRole}
+                        onChange={e => setInviteRole(e.target.value)}
+                      >
+                        {Object.entries(ROLE_LABELS).map(([value, label]) => (
+                          <option key={value} value={value}>{label}</option>
+                        ))}
+                      </select>
+                    </div>
+                    <button
+                      type="submit"
+                      disabled={inviting}
+                      className="btn btn-primary btn-md"
+                    >
+                      {inviting ? 'Sending…' : 'Send invite'}
+                    </button>
+                  </form>
+
+                  {/* Or separator */}
+                  <div className="flex items-center gap-3">
+                    <hr className="flex-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }} />
+                    <span className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>or</span>
+                    <hr className="flex-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }} />
                   </div>
-                  <div className="w-36">
-                    <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
-                    <select
-                      className="input"
-                      value={inviteRole}
-                      onChange={e => setInviteRole(e.target.value)}
+
+                  {/* Create & copy link */}
+                  <div className="flex items-end gap-3 flex-wrap">
+                    <div className="flex-1 min-w-[180px]">
+                      <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email for the link</label>
+                      <input
+                        type="email"
+                        className="input"
+                        value={inviteEmail}
+                        onChange={e => setInviteEmail(e.target.value)}
+                        placeholder="Paste their email here"
+                      />
+                    </div>
+                    <div className="w-36">
+                      <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
+                      <select
+                        className="input"
+                        value={inviteRole}
+                        onChange={e => setInviteRole(e.target.value)}
+                      >
+                        {Object.entries(ROLE_LABELS).map(([value, label]) => (
+                          <option key={value} value={value}>{label}</option>
+                        ))}
+                      </select>
+                    </div>
+                    <button
+                      type="button"
+                      disabled={inviting || !inviteEmail.trim()}
+                      onClick={handleCreateLink}
+                      className="btn btn-secondary btn-md"
                     >
-                      {Object.entries(ROLE_LABELS).map(([value, label]) => (
-                        <option key={value} value={value}>{label}</option>
-                      ))}
-                    </select>
+                      {inviting ? 'Creating…' : (
+                        <span className="flex items-center gap-1.5">
+                          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                            <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
+                          </svg>
+                          Create & Copy Link
+                        </span>
+                      )}
+                    </button>
                   </div>
-                  <button
-                    type="submit"
-                    disabled={inviting}
-                    className="btn btn-primary btn-md"
-                  >
-                    {inviting ? 'Sending…' : 'Send invite'}
-                  </button>
-                </form>
-
-                {inviteError && (
-                  <p className="text-xs mt-2" style={{ color: '#F87171' }}>{inviteError}</p>
-                )}
-                {inviteSuccess && (
-                  <p className="text-xs mt-2" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>
-                )}
+
+                  {/* Created link feedback */}
+                  {createdLink && (
+                    <div className="rounded-lg p-3 animate-scale-in"
+                         style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
+                      <div className="flex items-center gap-2 mb-1.5">
+                        <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                          <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                        </svg>
+                        <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied to clipboard!</span>
+                      </div>
+                      <p className="text-[10px] truncate" style={{ color: 'rgba(134,239,172,0.7)' }}>
+                        {createdLink}
+                      </p>
+                    </div>
+                  )}
+
+                  {inviteError && (
+                    <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
+                  )}
+                  {inviteSuccess && (
+                    <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>
+                  )}
+                </div>
               </div>
             )}