All files / src/features/collections InviteForm.tsx

100% Statements 34/34
92.85% Branches 13/14
100% Functions 6/6
100% Lines 31/31

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 1072x               2x                   46x 46x 46x 46x 46x 46x       46x 21x 8x 8x       12x 12x 12x 12x 12x   12x 12x 12x       10x 10x 10x     10x 10x   2x 1x   1x     12x       46x                                                               13x                    
"use client";
 
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Input, ErrorMessage } from "@/components/ui";
import { apiPost } from "@/lib/api-client";
import { z } from "zod";
 
const inviteSchema = z.object({
  email: z.string().email("Invalid email address"),
});
 
interface InviteFormProps {
  collectionId: string;
  onSuccess?: () => void;
}
 
export function InviteForm({ collectionId, onSuccess }: InviteFormProps) {
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const [success, setSuccess] = useState(false);
  const [emailNotSent, setEmailNotSent] = useState(false);
  const [email, setEmail] = useState("");
 
  // Auto-reset the success view after 3s, cleaning the timer up on unmount so we
  // never call setSuccess on an unmounted component (purses-th14).
  useEffect(() => {
    if (!success) return;
    const t = setTimeout(() => setSuccess(false), 3000);
    return () => clearTimeout(t);
  }, [success]);
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setSuccess(false);
    setEmailNotSent(false);
    setLoading(true);
 
    try {
      const validated = inviteSchema.parse({ email });
      const result = await apiPost<{ emailSent?: boolean }>(
        `/api/collections/${collectionId}/invitations`,
        validated,
      );
      setSuccess(true);
      setEmailNotSent(result.emailSent === false);
      setEmail("");
      // The members page is a server component; refresh so the new pending
      // row (with its Copy link button) appears while this notice is visible.
      router.refresh();
      onSuccess?.();
    } catch (err: any) {
      if (err instanceof z.ZodError) {
        setError(err.issues[0].message);
      } else {
        setError(err?.message || "Failed to send invitation");
      }
    } finally {
      setLoading(false);
    }
  }
 
  return (
    <form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
      {error && <ErrorMessage message={error} />}
 
      {/* Persistent live region: pre-existing in the DOM so the text change on
          success is announced reliably (a region mounted together with its text
          is frequently skipped by screen readers). Both the success message and
          the (rarer) email-not-sent notice share this one region.
          `emailNotSent` is intentionally NOT gated on `success` here: the plain
          success message auto-resets after 3s, but the email-not-sent notice
          must stay visible — there's still a Copy link the user needs — until
          the next submit begins (cleared there, same beat as `error`). */}
      <div role="status" aria-live="polite">
        {success && (
          <div className="bg-success-soft border border-success-soft rounded-control px-4 py-3 text-success-deep">
            Invitation sent successfully!
          </div>
        )}
        {emailNotSent && (
          <div className="mt-2 bg-info-soft border border-info-soft rounded-control px-4 py-3 text-info-deep">
            The invite is saved — the email didn&apos;t go out, so share the link yourself with Copy
            link.
          </div>
        )}
      </div>
 
      <Input
        label="Invite by email"
        type="email"
        required
        placeholder="curator@example.com"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        disabled={loading}
      />
 
      <Button type="submit" disabled={loading}>
        {loading ? "Sending..." : "Send invite"}
      </Button>
    </form>
  );
}