display-name-form.tsx 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. "use client";
  2. import { useState } from "react";
  3. import { useMutation } from "@tanstack/react-query";
  4. import { useRouter } from "next/navigation";
  5. import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/constants";
  6. import { getSupabaseBrowserClient } from "@/lib/supabase/client";
  7. import { AvatarColorPicker } from "./avatar-color-picker";
  8. function validateDisplayName(name: string): string | null {
  9. const trimmed = name.trim();
  10. if (trimmed.length === 0) return "Display name is required";
  11. if (trimmed.length > DISPLAY_NAME_MAX_LENGTH)
  12. return `Display name must be ${DISPLAY_NAME_MAX_LENGTH} characters or less`;
  13. if (/<|>/.test(trimmed)) return "Display name cannot contain < or >";
  14. return null;
  15. }
  16. export function DisplayNameForm() {
  17. const router = useRouter();
  18. const [displayName, setDisplayName] = useState("");
  19. const [avatarColor, setAvatarColor] = useState<string | null>(null);
  20. const [validationError, setValidationError] = useState<string | null>(null);
  21. const signUpMutation = useMutation({
  22. mutationFn: async ({ name, color }: { name: string; color: string | null }) => {
  23. const supabase = getSupabaseBrowserClient();
  24. const { data: authData, error: authError } = await supabase.auth.signInAnonymously();
  25. if (authError) throw authError;
  26. if (!authData.user) throw new Error("Sign-in failed: no user returned");
  27. const { error: insertError } = await supabase.from("users").insert({
  28. id: authData.user.id,
  29. display_name: name,
  30. avatar_color: color,
  31. });
  32. if (insertError) throw insertError;
  33. return { userId: authData.user.id };
  34. },
  35. onSuccess: () => {
  36. router.push("/recovery");
  37. },
  38. });
  39. function handleSubmit(e: React.FormEvent) {
  40. e.preventDefault();
  41. const trimmed = displayName.trim();
  42. const error = validateDisplayName(trimmed);
  43. if (error) {
  44. setValidationError(error);
  45. return;
  46. }
  47. setValidationError(null);
  48. signUpMutation.mutate({ name: trimmed, color: avatarColor });
  49. }
  50. return (
  51. <form onSubmit={handleSubmit} className="space-y-6">
  52. <div>
  53. <label htmlFor="display-name" className="block text-sm font-medium mb-1">
  54. Display name
  55. </label>
  56. <input
  57. id="display-name"
  58. type="text"
  59. value={displayName}
  60. onChange={(e) => {
  61. setDisplayName(e.target.value);
  62. setValidationError(null);
  63. }}
  64. maxLength={DISPLAY_NAME_MAX_LENGTH}
  65. placeholder="What should we call you?"
  66. className="w-full rounded-lg border border-foreground/20 bg-background px-3 py-2 text-foreground placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-blue-500"
  67. autoFocus
  68. autoComplete="off"
  69. />
  70. {validationError && <p className="mt-1 text-sm text-red-500">{validationError}</p>}
  71. </div>
  72. <AvatarColorPicker value={avatarColor} onChange={setAvatarColor} />
  73. {signUpMutation.error && (
  74. <p className="text-sm text-red-500" role="alert">
  75. {signUpMutation.error instanceof Error
  76. ? signUpMutation.error.message
  77. : "Something went wrong. Please try again."}
  78. </p>
  79. )}
  80. <button
  81. type="submit"
  82. disabled={signUpMutation.isPending}
  83. className="w-full rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
  84. >
  85. {signUpMutation.isPending ? "Setting up..." : "Get started"}
  86. </button>
  87. </form>
  88. );
  89. }