Add Prettier for TypeScript formatting

- Install prettier
- Configure .prettierrc.json and .prettierignore
- Add npm scripts: format, format:check
- Add Makefile target: format-frontend
- Format all frontend files
This commit is contained in:
counterweight 2025-12-21 21:59:26 +01:00
parent 4b394b0698
commit 37de6f70e0
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
44 changed files with 906 additions and 856 deletions

View file

@ -162,12 +162,13 @@ describe("ProfilePage - Display", () => {
test("displays empty fields when profile has null values", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
render(<ProfilePage />);
@ -291,7 +292,7 @@ describe("ProfilePage - Form Behavior", () => {
} as Response);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
});
@ -308,78 +309,83 @@ describe("ProfilePage - Form Behavior", () => {
test("auto-prepends @ to telegram when user starts with letter", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
// 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,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
// User types @ first - no auto-prepend
fireEvent.change(telegramInput, { target: { value: "@myhandle" } });
expect(telegramInput.value).toBe("@myhandle");
});
});
describe("ProfilePage - Form Submission", () => {
test("shows success toast after successful save", async () => {
const fetchSpy = vi.spyOn(global, "fetch")
const fetchSpy = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
contact_email: "new@example.com",
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: "new@example.com",
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
@ -410,27 +416,29 @@ describe("ProfilePage - Form Submission", () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response)
.mockResolvedValueOnce({
ok: false,
status: 422,
json: () => Promise.resolve({
detail: {
field_errors: {
telegram: "Backend error: invalid handle",
json: () =>
Promise.resolve({
detail: {
field_errors: {
telegram: "Backend error: invalid handle",
},
},
},
}),
}),
} as Response);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
@ -452,17 +460,18 @@ describe("ProfilePage - Form Submission", () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response)
.mockRejectedValueOnce(new Error("Network error"));
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
@ -489,17 +498,18 @@ describe("ProfilePage - Form Submission", () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response)
.mockReturnValueOnce(submitPromise as Promise<Response>);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
@ -519,12 +529,13 @@ describe("ProfilePage - Form Submission", () => {
// Resolve the promise
resolveSubmit!({
ok: true,
json: () => Promise.resolve({
contact_email: "new@example.com",
telegram: null,
signal: null,
nostr_npub: null,
}),
json: () =>
Promise.resolve({
contact_email: "new@example.com",
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
await waitFor(() => {
@ -532,4 +543,3 @@ describe("ProfilePage - Form Submission", () => {
});
});
});

View file

@ -34,7 +34,8 @@ function validateEmail(value: string): string | undefined {
if (!value) return undefined;
// More comprehensive email regex that matches email-validator behavior
// Checks for: local part, @, domain with at least one dot, valid TLD
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
if (!emailRegex.test(value)) {
return "Please enter a valid email address";
}
@ -72,7 +73,7 @@ function validateNostrNpub(value: string): string | undefined {
if (!value.startsWith(npubRules.prefix)) {
return `Nostr npub must start with '${npubRules.prefix}'`;
}
try {
const decoded = bech32.decode(value);
if (decoded.prefix !== "npub") {
@ -186,7 +187,7 @@ export default function ProfilePage() {
const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
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)
@ -194,14 +195,14 @@ export default function ProfilePage() {
value = "@" + value;
}
}
setFormData((prev) => ({ ...prev, [field]: value }));
// 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 };
@ -212,11 +213,11 @@ export default function ProfilePage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const validationErrors = validateForm(formData);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return;
}
@ -300,9 +301,7 @@ export default function ProfilePage() {
style={{ ...styles.input, ...styles.inputReadOnly }}
disabled
/>
<span style={styles.hint}>
This is your login email and cannot be changed here.
</span>
<span style={styles.hint}>This is your login email and cannot be changed here.</span>
</div>
{/* Godfather - shown if user was invited */}
@ -315,9 +314,7 @@ export default function ProfilePage() {
<div style={styles.godfatherBox}>
<span style={styles.godfatherEmail}>{godfatherEmail}</span>
</div>
<span style={styles.hint}>
The user who invited you to join.
</span>
<span style={styles.hint}>The user who invited you to join.</span>
</div>
)}
@ -344,9 +341,7 @@ export default function ProfilePage() {
}}
placeholder="alternate@example.com"
/>
{errors.contact_email && (
<span style={styles.errorText}>{errors.contact_email}</span>
)}
{errors.contact_email && <span style={styles.errorText}>{errors.contact_email}</span>}
</div>
{/* Telegram */}
@ -365,9 +360,7 @@ export default function ProfilePage() {
}}
placeholder="@username"
/>
{errors.telegram && (
<span style={styles.errorText}>{errors.telegram}</span>
)}
{errors.telegram && <span style={styles.errorText}>{errors.telegram}</span>}
</div>
{/* Signal */}
@ -386,9 +379,7 @@ export default function ProfilePage() {
}}
placeholder="username.01"
/>
{errors.signal && (
<span style={styles.errorText}>{errors.signal}</span>
)}
{errors.signal && <span style={styles.errorText}>{errors.signal}</span>}
</div>
{/* Nostr npub */}
@ -407,9 +398,7 @@ export default function ProfilePage() {
}}
placeholder="npub1..."
/>
{errors.nostr_npub && (
<span style={styles.errorText}>{errors.nostr_npub}</span>
)}
{errors.nostr_npub && <span style={styles.errorText}>{errors.nostr_npub}</span>}
</div>
<button