refactor(frontend): improve code quality and maintainability

- Extract API error handling utility (utils/error-handling.ts)
  - Centralize error message extraction logic
  - Add type guards for API errors
  - Replace duplicated error handling across components

- Create reusable Toast component (components/Toast.tsx)
  - Extract toast notification logic from profile page
  - Support auto-dismiss functionality
  - Consistent styling with shared styles

- Extract form validation debouncing hook (hooks/useDebouncedValidation.ts)
  - Reusable debounced validation logic
  - Clean timeout management
  - Used in profile page for form validation

- Consolidate duplicate styles (styles/auth-form.ts)
  - Use shared style tokens instead of duplicating values
  - Reduce code duplication between auth-form and shared styles

- Extract loading state component (components/LoadingState.tsx)
  - Standardize loading UI across pages
  - Replace duplicated loading JSX patterns
  - Used in profile, exchange, and trades pages

- Fix useRequireAuth dependency array
  - Remove unnecessary hasPermission from dependencies
  - Add eslint-disable comment with explanation
  - Improve hook stability and performance

All frontend tests pass. Linting passes.
This commit is contained in:
counterweight 2025-12-25 19:04:45 +01:00
parent db181b338c
commit 3beb23a765
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
10 changed files with 231 additions and 143 deletions

View file

@ -1,21 +1,24 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useEffect, useState, useCallback } from "react";
import { api, ApiError } from "../api";
import { api } from "../api";
import { extractApiErrorMessage, extractFieldErrors } from "../utils/error-handling";
import { Permission } from "../auth-context";
import { Header } from "../components/Header";
import { Toast } from "../components/Toast";
import { LoadingState } from "../components/LoadingState";
import { components } from "../generated/api";
import { useRequireAuth } from "../hooks/useRequireAuth";
import { useDebouncedValidation } from "../hooks/useDebouncedValidation";
import {
layoutStyles,
cardStyles,
formStyles,
buttonStyles,
toastStyles,
utilityStyles,
} from "../styles/shared";
import { FieldErrors, validateProfileFields } from "../utils/validation";
import { validateProfileFields } from "../utils/validation";
// Use generated type from OpenAPI schema
type ProfileData = components["schemas"]["ProfileResponse"];
@ -50,11 +53,15 @@ export default function ProfilePage() {
nostr_npub: "",
});
const [godfatherEmail, setGodfatherEmail] = useState<string | null>(null);
const [errors, setErrors] = useState<FieldErrors>({});
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const {
errors,
setErrors,
validate: validateForm,
} = useDebouncedValidation(formData, validateProfileFields, 500);
// Check if form has changes
const hasChanges = useCallback(() => {
@ -93,23 +100,6 @@ export default function ProfilePage() {
}
}, [user, isAuthorized, fetchProfile]);
// Auto-dismiss toast after 3 seconds
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}
}, [toast]);
// Cleanup validation timeout on unmount
useEffect(() => {
return () => {
if (validationTimeoutRef.current) {
clearTimeout(validationTimeoutRef.current);
}
};
}, []);
const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value;
@ -121,19 +111,11 @@ export default function ProfilePage() {
}
}
setFormData((prev) => ({ ...prev, [field]: value }));
const newFormData = { ...formData, [field]: value };
setFormData(newFormData);
// 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 = validateProfileFields(newFormData);
setErrors(newErrors);
}, 500);
// Trigger debounced validation with the new data
validateForm(newFormData);
};
const handleSubmit = async (e: React.FormEvent) => {
@ -162,14 +144,15 @@ export default function ProfilePage() {
setToast({ message: "Profile saved successfully!", type: "success" });
} catch (err) {
console.error("Profile save error:", err);
if (err instanceof ApiError && err.status === 422) {
const errorData = err.data as { detail?: { field_errors?: FieldErrors } };
if (errorData?.detail?.field_errors) {
setErrors(errorData.detail.field_errors);
}
const fieldErrors = extractFieldErrors(err);
if (fieldErrors?.detail?.field_errors) {
setErrors(fieldErrors.detail.field_errors);
setToast({ message: "Please fix the errors below", type: "error" });
} else {
setToast({ message: "Network error. Please try again.", type: "error" });
setToast({
message: extractApiErrorMessage(err, "Network error. Please try again."),
type: "error",
});
}
} finally {
setIsSubmitting(false);
@ -177,11 +160,7 @@ export default function ProfilePage() {
};
if (isLoading || isLoadingProfile) {
return (
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
return <LoadingState />;
}
if (!user || !isAuthorized) {
@ -194,14 +173,7 @@ export default function ProfilePage() {
<main style={layoutStyles.main}>
{/* Toast notification */}
{toast && (
<div
style={{
...toastStyles.toast,
...(toast.type === "success" ? toastStyles.toastSuccess : toastStyles.toastError),
}}
>
{toast.message}
</div>
<Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />
)}
<Header currentPage="profile" />