arbret/frontend/app/profile/page.tsx
counterweight 3beb23a765
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.
2025-12-25 19:04:45 +01:00

366 lines
12 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
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,
utilityStyles,
} from "../styles/shared";
import { validateProfileFields } from "../utils/validation";
// Use generated type from OpenAPI schema
type ProfileData = components["schemas"]["ProfileResponse"];
// UI-specific types (not from API)
interface FormData {
contact_email: string;
telegram: string;
signal: string;
nostr_npub: string;
}
function toFormData(data: ProfileData): FormData {
return {
contact_email: data.contact_email || "",
telegram: data.telegram || "",
signal: data.signal || "",
nostr_npub: data.nostr_npub || "",
};
}
export default function ProfilePage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.MANAGE_OWN_PROFILE,
fallbackRedirect: "/admin/trades",
});
const [originalData, setOriginalData] = useState<FormData | null>(null);
const [formData, setFormData] = useState<FormData>({
contact_email: "",
telegram: "",
signal: "",
nostr_npub: "",
});
const [godfatherEmail, setGodfatherEmail] = useState<string | null>(null);
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const {
errors,
setErrors,
validate: validateForm,
} = useDebouncedValidation(formData, validateProfileFields, 500);
// Check if form has changes
const hasChanges = useCallback(() => {
if (!originalData) return false;
return (
formData.contact_email !== originalData.contact_email ||
formData.telegram !== originalData.telegram ||
formData.signal !== originalData.signal ||
formData.nostr_npub !== originalData.nostr_npub
);
}, [formData, originalData]);
// Check if form is valid
const isValid = useCallback(() => {
return Object.keys(errors).length === 0;
}, [errors]);
const fetchProfile = useCallback(async () => {
try {
const data = await api.get<ProfileData>("/api/profile");
const formValues = toFormData(data);
setFormData(formValues);
setOriginalData(formValues);
setGodfatherEmail(data.godfather_email ?? null);
} catch (err) {
console.error("Profile load error:", err);
setToast({ message: "Failed to load profile", type: "error" });
} finally {
setIsLoadingProfile(false);
}
}, []);
useEffect(() => {
if (user && isAuthorized) {
fetchProfile();
}
}, [user, isAuthorized, fetchProfile]);
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)
if (/^[a-zA-Z]/.test(value)) {
value = "@" + value;
}
}
const newFormData = { ...formData, [field]: value };
setFormData(newFormData);
// Trigger debounced validation with the new data
validateForm(newFormData);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const validationErrors = validateProfileFields(formData);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return;
}
setIsSubmitting(true);
try {
const data = await api.put<ProfileData>("/api/profile", {
contact_email: formData.contact_email || null,
telegram: formData.telegram || null,
signal: formData.signal || null,
nostr_npub: formData.nostr_npub || null,
});
const formValues = toFormData(data);
setFormData(formValues);
setOriginalData(formValues);
setToast({ message: "Profile saved successfully!", type: "success" });
} catch (err) {
console.error("Profile save error:", err);
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: extractApiErrorMessage(err, "Network error. Please try again."),
type: "error",
});
}
} finally {
setIsSubmitting(false);
}
};
if (isLoading || isLoadingProfile) {
return <LoadingState />;
}
if (!user || !isAuthorized) {
return null;
}
const canSubmit = hasChanges() && isValid() && !isSubmitting;
return (
<main style={layoutStyles.main}>
{/* Toast notification */}
{toast && (
<Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />
)}
<Header currentPage="profile" />
<div style={layoutStyles.contentCentered}>
<div style={styles.profileCard}>
<div style={cardStyles.cardHeader}>
<h1 style={cardStyles.cardTitle}>My Profile</h1>
<p style={cardStyles.cardSubtitle}>Manage your contact information</p>
</div>
<form onSubmit={handleSubmit} style={formStyles.form}>
{/* Login email - read only */}
<div style={formStyles.field}>
<label style={styles.labelWithBadge}>
Login Email
<span style={utilityStyles.readOnlyBadge}>Read only</span>
</label>
<input
type="email"
value={user.email}
style={{ ...formStyles.input, ...formStyles.inputReadOnly }}
disabled
/>
<span style={formStyles.hint}>
This is your login email and cannot be changed here.
</span>
</div>
{/* Godfather - shown if user was invited */}
{godfatherEmail && (
<div style={formStyles.field}>
<label style={styles.labelWithBadge}>
Invited By
<span style={utilityStyles.readOnlyBadge}>Read only</span>
</label>
<div style={styles.godfatherBox}>
<span style={styles.godfatherEmail}>{godfatherEmail}</span>
</div>
<span style={formStyles.hint}>The user who invited you to join.</span>
</div>
)}
<div style={utilityStyles.divider} />
<p style={styles.sectionLabel}>Contact Details</p>
<p style={styles.sectionHint}>
These are for communication purposes only they won&apos;t affect your login.
</p>
{/* Contact email */}
<div style={formStyles.field}>
<label htmlFor="contact_email" style={formStyles.label}>
Contact Email
</label>
<input
id="contact_email"
type="email"
value={formData.contact_email}
onChange={handleInputChange("contact_email")}
style={{
...formStyles.input,
...(errors.contact_email ? formStyles.inputError : {}),
}}
placeholder="alternate@example.com"
/>
{errors.contact_email && (
<span style={formStyles.errorText}>{errors.contact_email}</span>
)}
</div>
{/* Telegram */}
<div style={formStyles.field}>
<label htmlFor="telegram" style={formStyles.label}>
Telegram
</label>
<input
id="telegram"
type="text"
value={formData.telegram}
onChange={handleInputChange("telegram")}
style={{
...formStyles.input,
...(errors.telegram ? formStyles.inputError : {}),
}}
placeholder="@username"
/>
{errors.telegram && <span style={formStyles.errorText}>{errors.telegram}</span>}
</div>
{/* Signal */}
<div style={formStyles.field}>
<label htmlFor="signal" style={formStyles.label}>
Signal
</label>
<input
id="signal"
type="text"
value={formData.signal}
onChange={handleInputChange("signal")}
style={{
...formStyles.input,
...(errors.signal ? formStyles.inputError : {}),
}}
placeholder="username.01"
/>
{errors.signal && <span style={formStyles.errorText}>{errors.signal}</span>}
</div>
{/* Nostr npub */}
<div style={formStyles.field}>
<label htmlFor="nostr_npub" style={formStyles.label}>
Nostr (npub)
</label>
<input
id="nostr_npub"
type="text"
value={formData.nostr_npub}
onChange={handleInputChange("nostr_npub")}
style={{
...formStyles.input,
...(errors.nostr_npub ? formStyles.inputError : {}),
}}
placeholder="npub1..."
/>
{errors.nostr_npub && <span style={formStyles.errorText}>{errors.nostr_npub}</span>}
</div>
<button
type="submit"
style={{
...buttonStyles.primaryButton,
marginTop: "1rem",
...(!canSubmit ? buttonStyles.buttonDisabled : {}),
}}
disabled={!canSubmit}
>
{isSubmitting ? "Saving..." : "Save Changes"}
</button>
</form>
</div>
</div>
</main>
);
}
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
profileCard: {
...cardStyles.card,
width: "100%",
maxWidth: "480px",
},
labelWithBadge: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.875rem",
fontWeight: 500,
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
godfatherBox: {
padding: "0.875rem 1rem",
background: "rgba(99, 102, 241, 0.08)",
border: "1px solid rgba(99, 102, 241, 0.2)",
borderRadius: "12px",
},
godfatherEmail: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
color: "rgba(129, 140, 248, 0.9)",
},
sectionLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
fontWeight: 600,
color: "rgba(255, 255, 255, 0.8)",
margin: 0,
textTransform: "uppercase",
letterSpacing: "0.05em",
},
sectionHint: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.4)",
margin: 0,
marginBottom: "0.5rem",
},
};