arbret/frontend/app/profile/page.tsx

367 lines
12 KiB
TypeScript
Raw Normal View History

2025-12-19 10:12:55 +01:00
"use client";
import { useEffect, useState, useCallback } from "react";
import { api } from "../api";
import { extractApiErrorMessage, extractFieldErrors } from "../utils/error-handling";
import { Permission } from "../auth-context";
2025-12-19 11:08:19 +01:00
import { Header } from "../components/Header";
import { Toast } from "../components/Toast";
import { LoadingState } from "../components/LoadingState";
2025-12-20 23:06:05 +01:00
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";
2025-12-19 10:12:55 +01:00
2025-12-20 23:06:05 +01:00
// Use generated type from OpenAPI schema
type ProfileData = components["schemas"]["ProfileResponse"];
2025-12-19 10:12:55 +01:00
2025-12-20 23:06:05 +01:00
// UI-specific types (not from API)
2025-12-19 10:12:55 +01:00
interface FormData {
contact_email: string;
telegram: string;
signal: string;
nostr_npub: string;
}
2025-12-19 10:38:15 +01:00
function toFormData(data: ProfileData): FormData {
return {
contact_email: data.contact_email || "",
telegram: data.telegram || "",
signal: data.signal || "",
nostr_npub: data.nostr_npub || "",
};
}
2025-12-19 10:12:55 +01:00
export default function ProfilePage() {
2025-12-19 11:08:19 +01:00
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.MANAGE_OWN_PROFILE,
fallbackRedirect: "/admin/trades",
2025-12-19 11:08:19 +01:00
});
2025-12-19 10:12:55 +01:00
const [originalData, setOriginalData] = useState<FormData | null>(null);
const [formData, setFormData] = useState<FormData>({
contact_email: "",
telegram: "",
signal: "",
nostr_npub: "",
});
2025-12-20 11:12:11 +01:00
const [godfatherEmail, setGodfatherEmail] = useState<string | null>(null);
2025-12-19 10:12:55 +01:00
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);
2025-12-19 10:12:55 +01:00
// 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]);
2025-12-19 10:30:23 +01:00
const fetchProfile = useCallback(async () => {
2025-12-19 10:12:55 +01:00
try {
2025-12-19 11:08:19 +01:00
const data = await api.get<ProfileData>("/api/profile");
const formValues = toFormData(data);
setFormData(formValues);
setOriginalData(formValues);
2025-12-20 23:06:05 +01:00
setGodfatherEmail(data.godfather_email ?? null);
2025-12-19 10:52:47 +01:00
} catch (err) {
console.error("Profile load error:", err);
2025-12-19 11:08:19 +01:00
setToast({ message: "Failed to load profile", type: "error" });
2025-12-19 10:12:55 +01:00
} finally {
setIsLoadingProfile(false);
}
2025-12-19 10:30:23 +01:00
}, []);
useEffect(() => {
2025-12-19 11:08:19 +01:00
if (user && isAuthorized) {
2025-12-19 10:30:23 +01:00
fetchProfile();
}
2025-12-19 11:08:19 +01:00
}, [user, isAuthorized, fetchProfile]);
2025-12-19 10:30:23 +01:00
2025-12-19 10:12:55 +01:00
const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
2025-12-19 10:52:47 +01:00
let value = e.target.value;
2025-12-19 10:52:47 +01:00
// 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);
2025-12-19 10:12:55 +01:00
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
2025-12-19 10:12:55 +01:00
// Validate all fields
const validationErrors = validateProfileFields(formData);
2025-12-19 10:12:55 +01:00
setErrors(validationErrors);
2025-12-19 10:12:55 +01:00
if (Object.keys(validationErrors).length > 0) {
return;
}
setIsSubmitting(true);
try {
2025-12-19 11:08:19 +01:00
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,
2025-12-19 10:12:55 +01:00
});
2025-12-19 11:08:19 +01:00
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);
2025-12-19 10:12:55 +01:00
setToast({ message: "Please fix the errors below", type: "error" });
} else {
setToast({
message: extractApiErrorMessage(err, "Network error. Please try again."),
type: "error",
});
2025-12-19 10:12:55 +01:00
}
} finally {
setIsSubmitting(false);
}
};
if (isLoading || isLoadingProfile) {
return <LoadingState />;
2025-12-19 10:12:55 +01:00
}
2025-12-19 11:08:19 +01:00
if (!user || !isAuthorized) {
2025-12-19 10:12:55 +01:00
return null;
}
const canSubmit = hasChanges() && isValid() && !isSubmitting;
return (
<main style={layoutStyles.main}>
2025-12-19 10:12:55 +01:00
{/* Toast notification */}
{toast && (
<Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />
2025-12-19 10:12:55 +01:00
)}
2025-12-19 11:08:19 +01:00
<Header currentPage="profile" />
2025-12-19 10:12:55 +01:00
<div style={layoutStyles.contentCentered}>
2025-12-19 10:12:55 +01:00
<div style={styles.profileCard}>
<div style={cardStyles.cardHeader}>
<h1 style={cardStyles.cardTitle}>My Profile</h1>
<p style={cardStyles.cardSubtitle}>Manage your contact information</p>
2025-12-19 10:12:55 +01:00
</div>
<form onSubmit={handleSubmit} style={formStyles.form}>
2025-12-19 10:12:55 +01:00
{/* Login email - read only */}
<div style={formStyles.field}>
<label style={styles.labelWithBadge}>
2025-12-19 10:12:55 +01:00
Login Email
<span style={utilityStyles.readOnlyBadge}>Read only</span>
2025-12-19 10:12:55 +01:00
</label>
<input
type="email"
value={user.email}
style={{ ...formStyles.input, ...formStyles.inputReadOnly }}
2025-12-19 10:12:55 +01:00
disabled
/>
<span style={formStyles.hint}>
This is your login email and cannot be changed here.
</span>
2025-12-19 10:12:55 +01:00
</div>
2025-12-20 11:12:11 +01:00
{/* Godfather - shown if user was invited */}
{godfatherEmail && (
<div style={formStyles.field}>
<label style={styles.labelWithBadge}>
2025-12-20 11:12:11 +01:00
Invited By
<span style={utilityStyles.readOnlyBadge}>Read only</span>
2025-12-20 11:12:11 +01:00
</label>
<div style={styles.godfatherBox}>
<span style={styles.godfatherEmail}>{godfatherEmail}</span>
</div>
<span style={formStyles.hint}>The user who invited you to join.</span>
2025-12-20 11:12:11 +01:00
</div>
)}
<div style={utilityStyles.divider} />
2025-12-19 10:12:55 +01:00
<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}>
2025-12-19 10:12:55 +01:00
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 : {}),
2025-12-19 10:12:55 +01:00
}}
placeholder="alternate@example.com"
/>
{errors.contact_email && (
<span style={formStyles.errorText}>{errors.contact_email}</span>
)}
2025-12-19 10:12:55 +01:00
</div>
{/* Telegram */}
<div style={formStyles.field}>
<label htmlFor="telegram" style={formStyles.label}>
2025-12-19 10:12:55 +01:00
Telegram
</label>
<input
id="telegram"
type="text"
value={formData.telegram}
onChange={handleInputChange("telegram")}
style={{
...formStyles.input,
...(errors.telegram ? formStyles.inputError : {}),
2025-12-19 10:12:55 +01:00
}}
placeholder="@username"
/>
{errors.telegram && <span style={formStyles.errorText}>{errors.telegram}</span>}
2025-12-19 10:12:55 +01:00
</div>
{/* Signal */}
<div style={formStyles.field}>
<label htmlFor="signal" style={formStyles.label}>
2025-12-19 10:12:55 +01:00
Signal
</label>
<input
id="signal"
type="text"
value={formData.signal}
onChange={handleInputChange("signal")}
style={{
...formStyles.input,
...(errors.signal ? formStyles.inputError : {}),
2025-12-19 10:12:55 +01:00
}}
placeholder="username.01"
/>
{errors.signal && <span style={formStyles.errorText}>{errors.signal}</span>}
2025-12-19 10:12:55 +01:00
</div>
{/* Nostr npub */}
<div style={formStyles.field}>
<label htmlFor="nostr_npub" style={formStyles.label}>
2025-12-19 10:12:55 +01:00
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 : {}),
2025-12-19 10:12:55 +01:00
}}
placeholder="npub1..."
/>
{errors.nostr_npub && <span style={formStyles.errorText}>{errors.nostr_npub}</span>}
2025-12-19 10:12:55 +01:00
</div>
<button
type="submit"
style={{
...buttonStyles.primaryButton,
marginTop: "1rem",
...(!canSubmit ? buttonStyles.buttonDisabled : {}),
2025-12-19 10:12:55 +01:00
}}
disabled={!canSubmit}
>
{isSubmitting ? "Saving..." : "Save Changes"}
</button>
</form>
</div>
</div>
</main>
);
}
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
2025-12-19 10:12:55 +01:00
profileCard: {
...cardStyles.card,
2025-12-19 10:12:55 +01:00
width: "100%",
maxWidth: "480px",
},
labelWithBadge: {
2025-12-19 10:12:55 +01:00
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",
},
2025-12-20 11:12:11 +01:00
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)",
},
2025-12-19 10:12:55 +01:00
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",
},
};