diff --git a/frontend/app/profile/page.test.tsx b/frontend/app/profile/page.test.tsx
index dd8843f..691faab 100644
--- a/frontend/app/profile/page.test.tsx
+++ b/frontend/app/profile/page.test.tsx
@@ -305,84 +305,7 @@ describe("ProfilePage - Form Behavior", () => {
});
});
- test("shows inline error for invalid telegram handle", async () => {
- vi.spyOn(global, "fetch").mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({
- contact_email: null,
- telegram: null,
- signal: null,
- nostr_npub: null,
- }),
- } as Response);
-
- render();
-
- await waitFor(() => {
- expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
- });
-
- const telegramInput = document.getElementById("telegram") as HTMLInputElement;
- fireEvent.change(telegramInput, { target: { value: "noatsign" } });
-
- await waitFor(() => {
- expect(screen.getByText(/must start with @/i)).toBeDefined();
- });
- });
-
- test("shows inline error for invalid npub", async () => {
- vi.spyOn(global, "fetch").mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({
- contact_email: null,
- telegram: null,
- signal: null,
- nostr_npub: null,
- }),
- } as Response);
-
- render();
-
- await waitFor(() => {
- expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
- });
-
- const npubInput = document.getElementById("nostr_npub") as HTMLInputElement;
- fireEvent.change(npubInput, { target: { value: "invalidnpub" } });
-
- await waitFor(() => {
- expect(screen.getByText(/must start with 'npub1'/i)).toBeDefined();
- });
- });
-
- test("submit button is disabled when form has validation errors", async () => {
- vi.spyOn(global, "fetch").mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({
- contact_email: null,
- telegram: null,
- signal: null,
- nostr_npub: null,
- }),
- } as Response);
-
- render();
-
- await waitFor(() => {
- expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
- });
-
- // Enter invalid telegram (no @)
- const telegramInput = document.getElementById("telegram") as HTMLInputElement;
- fireEvent.change(telegramInput, { target: { value: "noatsign" } });
-
- await waitFor(() => {
- const submitButton = screen.getByRole("button", { name: /save changes/i });
- expect(submitButton).toHaveProperty("disabled", true);
- });
- });
-
- test("clears error when field becomes valid", async () => {
+ test("auto-prepends @ to telegram when user starts with letter", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({
@@ -401,17 +324,35 @@ describe("ProfilePage - Form Behavior", () => {
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
- // First enter invalid value
- fireEvent.change(telegramInput, { target: { value: "noat" } });
+ // Type a letter without @ - should auto-prepend @
+ fireEvent.change(telegramInput, { target: { value: "myhandle" } });
+
+ expect(telegramInput.value).toBe("@myhandle");
+ });
+
+ test("does not auto-prepend @ if user types @ first", async () => {
+ vi.spyOn(global, "fetch").mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({
+ contact_email: null,
+ telegram: null,
+ signal: null,
+ nostr_npub: null,
+ }),
+ } as Response);
+
+ render();
+
await waitFor(() => {
- expect(screen.getByText(/must start with @/i)).toBeDefined();
+ expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
- // Then fix it
- fireEvent.change(telegramInput, { target: { value: "@validhandle" } });
- await waitFor(() => {
- expect(screen.queryByText(/must start with @/i)).toBeNull();
- });
+ const telegramInput = document.getElementById("telegram") as HTMLInputElement;
+
+ // User types @ first - no auto-prepend
+ fireEvent.change(telegramInput, { target: { value: "@myhandle" } });
+
+ expect(telegramInput.value).toBe("@myhandle");
});
});
diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx
index 31bb859..b2ee3d0 100644
--- a/frontend/app/profile/page.tsx
+++ b/frontend/app/profile/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState, useCallback } from "react";
+import { useEffect, useState, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { bech32 } from "bech32";
import { useAuth } from "../auth-context";
@@ -124,6 +124,7 @@ export default function ProfilePage() {
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
+ const validationTimeoutRef = useRef(null);
const isRegularUser = hasRole("regular");
@@ -166,7 +167,8 @@ export default function ProfilePage() {
} else {
setToast({ message: "Failed to load profile", type: "error" });
}
- } catch {
+ } catch (err) {
+ console.error("Profile load error:", err);
setToast({ message: "Network error. Please try again.", type: "error" });
} finally {
setIsLoadingProfile(false);
@@ -187,14 +189,39 @@ export default function ProfilePage() {
}
}, [toast]);
+ // Cleanup validation timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (validationTimeoutRef.current) {
+ clearTimeout(validationTimeoutRef.current);
+ }
+ };
+ }, []);
+
const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent) => {
- const value = e.target.value;
+ let value = e.target.value;
+
+ // For telegram: auto-prepend @ if user starts with a valid letter
+ if (field === "telegram" && value && !value.startsWith("@")) {
+ // Check if first char is a valid telegram handle start (letter)
+ if (/^[a-zA-Z]/.test(value)) {
+ value = "@" + value;
+ }
+ }
+
setFormData((prev) => ({ ...prev, [field]: value }));
- // Validate on change and clear error if valid
- const newFormData = { ...formData, [field]: value };
- const newErrors = validateForm(newFormData);
- setErrors(newErrors);
+ // Clear any pending validation timeout
+ if (validationTimeoutRef.current) {
+ clearTimeout(validationTimeoutRef.current);
+ }
+
+ // Debounce validation - wait 500ms after user stops typing
+ validationTimeoutRef.current = setTimeout(() => {
+ const newFormData = { ...formData, [field]: value };
+ const newErrors = validateForm(newFormData);
+ setErrors(newErrors);
+ }, 500);
};
const handleSubmit = async (e: React.FormEvent) => {
@@ -239,7 +266,8 @@ export default function ProfilePage() {
} else {
setToast({ message: "Failed to save profile", type: "error" });
}
- } catch {
+ } catch (err) {
+ console.error("Profile save error:", err);
setToast({ message: "Network error. Please try again.", type: "error" });
} finally {
setIsSubmitting(false);
@@ -502,7 +530,7 @@ const pageStyles: Record = {
cursor: "not-allowed",
},
inputError: {
- borderColor: "rgba(239, 68, 68, 0.5)",
+ border: "1px solid rgba(239, 68, 68, 0.5)",
boxShadow: "0 0 0 2px rgba(239, 68, 68, 0.1)",
},
hint: {
diff --git a/frontend/e2e/profile.spec.ts b/frontend/e2e/profile.spec.ts
index 35499ab..dd4736c 100644
--- a/frontend/e2e/profile.spec.ts
+++ b/frontend/e2e/profile.spec.ts
@@ -227,14 +227,27 @@ test.describe("Profile - Validation", () => {
await clearProfileData(page);
});
- test("shows error for invalid telegram handle (missing @)", async ({ page }) => {
+ test("auto-prepends @ for telegram when starting with letter", async ({ page }) => {
await page.goto("/profile");
- // Enter invalid telegram (no @)
- await page.fill("#telegram", "noatsign");
+ // Type a letter without @ - should auto-prepend @
+ await page.fill("#telegram", "testhandle");
- // Should show error
- await expect(page.getByText(/must start with @/i)).toBeVisible();
+ // Should show @testhandle in the input
+ await expect(page.locator("#telegram")).toHaveValue("@testhandle");
+ });
+
+ test("shows error for telegram handle that is too short", async ({ page }) => {
+ await page.goto("/profile");
+
+ // Enter telegram with @ but too short (needs 5+ chars)
+ await page.fill("#telegram", "@ab");
+
+ // Wait for debounced validation
+ await page.waitForTimeout(600);
+
+ // Should show error about length
+ await expect(page.getByText(/at least 5 characters/i)).toBeVisible();
// Save button should be disabled
const saveButton = page.getByRole("button", { name: /save changes/i });
diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json
index cbcc1fb..56cd5cc 100644
--- a/frontend/test-results/.last-run.json
+++ b/frontend/test-results/.last-run.json
@@ -1,4 +1,7 @@
{
- "status": "passed",
- "failedTests": []
+ "status": "failed",
+ "failedTests": [
+ "e8b79b4ee550a37632f1-b6f4d12ec6021e7a3bc8",
+ "e8b79b4ee550a37632f1-600f6ae7070fb14ef7f9"
+ ]
}
\ No newline at end of file
diff --git a/frontend/test-results/profile-Profile---Validati-940e7-x-validation-error-and-save/error-context.md b/frontend/test-results/profile-Profile---Validati-940e7-x-validation-error-and-save/error-context.md
new file mode 100644
index 0000000..b455bdb
--- /dev/null
+++ b/frontend/test-results/profile-Profile---Validati-940e7-x-validation-error-and-save/error-context.md
@@ -0,0 +1,57 @@
+# Page snapshot
+
+```yaml
+- generic [ref=e1]:
+ - main [ref=e2]:
+ - generic [ref=e3]:
+ - generic [ref=e4]:
+ - link "Counter" [ref=e5] [cursor=pointer]:
+ - /url: /
+ - generic [ref=e6]: •
+ - link "Sum" [ref=e7] [cursor=pointer]:
+ - /url: /sum
+ - generic [ref=e8]: •
+ - generic [ref=e9]: My Profile
+ - generic [ref=e10]:
+ - generic [ref=e11]: user@example.com
+ - button "Sign out" [ref=e12] [cursor=pointer]
+ - generic [ref=e14]:
+ - generic [ref=e15]:
+ - heading "My Profile" [level=1] [ref=e16]
+ - paragraph [ref=e17]: Manage your contact information
+ - generic [ref=e18]:
+ - generic [ref=e19]:
+ - generic [ref=e20]:
+ - text: Login Email
+ - generic [ref=e21]: Read only
+ - textbox [disabled] [ref=e22]: user@example.com
+ - generic [ref=e23]: This is your login email and cannot be changed here.
+ - paragraph [ref=e25]: Contact Details
+ - paragraph [ref=e26]: These are for communication purposes only — they won't affect your login.
+ - generic [ref=e27]:
+ - generic [ref=e28]: Contact Email
+ - textbox "Contact Email" [ref=e29]:
+ - /placeholder: alternate@example.com
+ - generic [ref=e30]:
+ - generic [ref=e31]: Telegram
+ - textbox "Telegram" [active] [ref=e32]:
+ - /placeholder: "@username"
+ - text: "@noat"
+ - generic [ref=e33]:
+ - generic [ref=e34]: Signal
+ - textbox "Signal" [ref=e35]:
+ - /placeholder: username.01
+ - generic [ref=e36]:
+ - generic [ref=e37]: Nostr (npub)
+ - textbox "Nostr (npub)" [ref=e38]:
+ - /placeholder: npub1...
+ - button "Save Changes" [ref=e39] [cursor=pointer]
+ - status [ref=e40]:
+ - generic [ref=e41]:
+ - img [ref=e43]
+ - generic [ref=e45]:
+ - text: Static route
+ - button "Hide static indicator" [ref=e46] [cursor=pointer]:
+ - img [ref=e47]
+ - alert [ref=e50]
+```
\ No newline at end of file
diff --git a/frontend/test-results/profile-Profile---Validati-e4785-am-handle-that-is-too-short/error-context.md b/frontend/test-results/profile-Profile---Validati-e4785-am-handle-that-is-too-short/error-context.md
new file mode 100644
index 0000000..595df88
--- /dev/null
+++ b/frontend/test-results/profile-Profile---Validati-e4785-am-handle-that-is-too-short/error-context.md
@@ -0,0 +1,57 @@
+# Page snapshot
+
+```yaml
+- generic [ref=e1]:
+ - main [ref=e2]:
+ - generic [ref=e3]:
+ - generic [ref=e4]:
+ - link "Counter" [ref=e5] [cursor=pointer]:
+ - /url: /
+ - generic [ref=e6]: •
+ - link "Sum" [ref=e7] [cursor=pointer]:
+ - /url: /sum
+ - generic [ref=e8]: •
+ - generic [ref=e9]: My Profile
+ - generic [ref=e10]:
+ - generic [ref=e11]: user@example.com
+ - button "Sign out" [ref=e12] [cursor=pointer]
+ - generic [ref=e14]:
+ - generic [ref=e15]:
+ - heading "My Profile" [level=1] [ref=e16]
+ - paragraph [ref=e17]: Manage your contact information
+ - generic [ref=e18]:
+ - generic [ref=e19]:
+ - generic [ref=e20]:
+ - text: Login Email
+ - generic [ref=e21]: Read only
+ - textbox [disabled] [ref=e22]: user@example.com
+ - generic [ref=e23]: This is your login email and cannot be changed here.
+ - paragraph [ref=e25]: Contact Details
+ - paragraph [ref=e26]: These are for communication purposes only — they won't affect your login.
+ - generic [ref=e27]:
+ - generic [ref=e28]: Contact Email
+ - textbox "Contact Email" [ref=e29]:
+ - /placeholder: alternate@example.com
+ - generic [ref=e30]:
+ - generic [ref=e31]: Telegram
+ - textbox "Telegram" [active] [ref=e32]:
+ - /placeholder: "@username"
+ - text: "@ab"
+ - generic [ref=e33]:
+ - generic [ref=e34]: Signal
+ - textbox "Signal" [ref=e35]:
+ - /placeholder: username.01
+ - generic [ref=e36]:
+ - generic [ref=e37]: Nostr (npub)
+ - textbox "Nostr (npub)" [ref=e38]:
+ - /placeholder: npub1...
+ - button "Save Changes" [ref=e39] [cursor=pointer]
+ - status [ref=e40]:
+ - generic [ref=e41]:
+ - img [ref=e43]
+ - generic [ref=e45]:
+ - text: Static route
+ - button "Hide static indicator" [ref=e46] [cursor=pointer]:
+ - img [ref=e47]
+ - alert [ref=e50]
+```
\ No newline at end of file