diff --git a/frontend/app/auth-context.tsx b/frontend/app/auth-context.tsx index 7b23511..85b79f8 100644 --- a/frontend/app/auth-context.tsx +++ b/frontend/app/auth-context.tsx @@ -2,8 +2,9 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; -import { api, ApiError } from "./api"; +import { api } from "./api"; import { components } from "./generated/api"; +import { extractApiErrorMessage } from "./utils/error-handling"; // Permission type from generated OpenAPI schema export type PermissionType = components["schemas"]["Permission"]; @@ -67,11 +68,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const userData = await api.post("/api/auth/login", { email, password }); setUser(userData); } catch (err) { - if (err instanceof ApiError) { - const data = err.data as { detail?: string }; - throw new Error(data?.detail || "Login failed"); - } - throw err; + throw new Error(extractApiErrorMessage(err, "Login failed")); } }; @@ -84,11 +81,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); setUser(userData); } catch (err) { - if (err instanceof ApiError) { - const data = err.data as { detail?: string }; - throw new Error(data?.detail || "Registration failed"); - } - throw err; + throw new Error(extractApiErrorMessage(err, "Registration failed")); } }; diff --git a/frontend/app/components/LoadingState.tsx b/frontend/app/components/LoadingState.tsx new file mode 100644 index 0000000..ff92367 --- /dev/null +++ b/frontend/app/components/LoadingState.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { layoutStyles } from "../styles/shared"; + +interface LoadingStateProps { + /** Custom loading message (default: "Loading...") */ + message?: string; +} + +/** + * Standard loading state component. + * Displays a centered loading message with consistent styling. + */ +export function LoadingState({ message = "Loading..." }: LoadingStateProps) { + return ( +
+
{message}
+
+ ); +} diff --git a/frontend/app/components/Toast.tsx b/frontend/app/components/Toast.tsx new file mode 100644 index 0000000..5e9cbfb --- /dev/null +++ b/frontend/app/components/Toast.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect } from "react"; +import { toastStyles } from "../styles/shared"; + +export type ToastType = "success" | "error"; + +export interface ToastProps { + message: string; + type: ToastType; + onDismiss?: () => void; + /** Auto-dismiss delay in milliseconds (default: 3000) */ + autoDismissDelay?: number; +} + +/** + * Toast notification component with auto-dismiss functionality. + * Displays success or error messages in a fixed position. + */ +export function Toast({ message, type, onDismiss, autoDismissDelay = 3000 }: ToastProps) { + useEffect(() => { + if (onDismiss) { + const timer = setTimeout(() => { + onDismiss(); + }, autoDismissDelay); + return () => clearTimeout(timer); + } + }, [onDismiss, autoDismissDelay]); + + return ( +
+ {message} +
+ ); +} diff --git a/frontend/app/exchange/page.tsx b/frontend/app/exchange/page.tsx index 482f524..9cf6f70 100644 --- a/frontend/app/exchange/page.tsx +++ b/frontend/app/exchange/page.tsx @@ -3,9 +3,11 @@ import { useEffect, useState, useCallback, useMemo, ChangeEvent, CSSProperties } from "react"; import { useRouter } from "next/navigation"; import { Permission } from "../auth-context"; -import { api, ApiError } from "../api"; +import { api } from "../api"; +import { extractApiErrorMessage } from "../utils/error-handling"; import { Header } from "../components/Header"; import { SatsDisplay } from "../components/SatsDisplay"; +import { LoadingState } from "../components/LoadingState"; import { useRequireAuth } from "../hooks/useRequireAuth"; import { components } from "../generated/api"; import { formatDate, formatTime, getDateRange } from "../utils/date"; @@ -325,18 +327,7 @@ export default function ExchangePage() { // Redirect to trades page after successful booking router.push("/trades"); } catch (err) { - let errorMessage = "Failed to book trade"; - if (err instanceof ApiError) { - // Extract detail from API error response - if (err.data && typeof err.data === "object") { - const data = err.data as { detail?: string }; - errorMessage = data.detail || err.message; - } else { - errorMessage = err.message; - } - } else if (err instanceof Error) { - errorMessage = err.message; - } + const errorMessage = extractApiErrorMessage(err, "Failed to book trade"); setError(errorMessage); // Check if it's a "same day" error and extract trade public_id (UUID) @@ -352,11 +343,7 @@ export default function ExchangePage() { }; if (isLoading) { - return ( -
-
Loading...
-
- ); + return ; } if (!isAuthorized) { diff --git a/frontend/app/hooks/useDebouncedValidation.ts b/frontend/app/hooks/useDebouncedValidation.ts new file mode 100644 index 0000000..7477a75 --- /dev/null +++ b/frontend/app/hooks/useDebouncedValidation.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Hook for debounced form validation. + * Validates form data after the user stops typing for a specified delay. + * + * @param formData - The form data to validate + * @param validator - Function that validates the form data and returns field errors + * @param delay - Debounce delay in milliseconds (default: 500) + * @returns Object containing current errors and a function to manually trigger validation + */ +export function useDebouncedValidation( + formData: T, + validator: (data: T) => Record, + delay: number = 500 +): { + errors: Record; + setErrors: React.Dispatch>>; + validate: (data?: T) => void; +} { + const [errors, setErrors] = useState>({}); + const validationTimeoutRef = useRef(null); + const formDataRef = useRef(formData); + + // Keep formDataRef in sync with formData + useEffect(() => { + formDataRef.current = formData; + }, [formData]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (validationTimeoutRef.current) { + clearTimeout(validationTimeoutRef.current); + } + }; + }, []); + + const validate = (data?: T) => { + // Clear any pending validation timeout + if (validationTimeoutRef.current) { + clearTimeout(validationTimeoutRef.current); + } + + // Debounce validation - wait for user to stop typing + validationTimeoutRef.current = setTimeout(() => { + const dataToValidate = data ?? formDataRef.current; + const newErrors = validator(dataToValidate); + setErrors(newErrors); + }, delay); + }; + + return { errors, setErrors, validate }; +} diff --git a/frontend/app/hooks/useRequireAuth.ts b/frontend/app/hooks/useRequireAuth.ts index d5eb944..a1566b8 100644 --- a/frontend/app/hooks/useRequireAuth.ts +++ b/frontend/app/hooks/useRequireAuth.ts @@ -46,6 +46,7 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA if (!isAuthorized) { // Redirect to the most appropriate page based on permissions + // Use hasPermission/hasRole directly since they're stable callbacks const redirect = fallbackRedirect ?? (hasPermission(Permission.VIEW_ALL_EXCHANGES) @@ -55,7 +56,11 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA : "/login"); router.push(redirect); } - }, [isLoading, user, isAuthorized, router, fallbackRedirect, hasPermission]); + // Note: hasPermission and hasRole are stable callbacks from useAuth, + // so they don't need to be in the dependency array. They're only included + // for clarity and to satisfy exhaustive-deps if needed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading, user, isAuthorized, router, fallbackRedirect]); return { user, diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 8d3c18b..7720d0c 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -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(null); - const [errors, setErrors] = useState({}); 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 { + 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) => { 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 ( -
-
Loading...
-
- ); + return ; } if (!user || !isAuthorized) { @@ -194,14 +173,7 @@ export default function ProfilePage() {
{/* Toast notification */} {toast && ( -
- {toast.message} -
+ setToast(null)} /> )}
diff --git a/frontend/app/styles/auth-form.ts b/frontend/app/styles/auth-form.ts index 4ef086b..efe6701 100644 --- a/frontend/app/styles/auth-form.ts +++ b/frontend/app/styles/auth-form.ts @@ -1,12 +1,21 @@ import { CSSProperties } from "react"; +// Import shared tokens and styles to avoid duplication +// Note: We can't directly import tokens from shared.ts as it's not exported, +// so we'll use the shared style objects where possible +import { + layoutStyles, + cardStyles, + formStyles, + buttonStyles, + bannerStyles, + typographyStyles, +} from "./shared"; + export const authFormStyles: Record = { main: { + ...layoutStyles.contentCentered, minHeight: "100vh", - background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)", - display: "flex", - alignItems: "center", - justifyContent: "center", padding: "1rem", }, container: { @@ -14,80 +23,41 @@ export const authFormStyles: Record = { maxWidth: "420px", }, card: { - background: "rgba(255, 255, 255, 0.03)", - backdropFilter: "blur(10px)", - border: "1px solid rgba(255, 255, 255, 0.08)", - borderRadius: "24px", + ...cardStyles.card, padding: "3rem 2.5rem", - boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", }, header: { textAlign: "center" as const, marginBottom: "2.5rem", }, title: { - fontFamily: "'Instrument Serif', Georgia, serif", + ...typographyStyles.pageTitle, fontSize: "2.5rem", - fontWeight: 400, - color: "#fff", - margin: 0, - letterSpacing: "-0.02em", + textAlign: "center" as const, }, subtitle: { - fontFamily: "'DM Sans', system-ui, sans-serif", - color: "rgba(255, 255, 255, 0.5)", - marginTop: "0.5rem", - fontSize: "0.95rem", + ...typographyStyles.pageSubtitle, + textAlign: "center" as const, }, form: { - display: "flex", - flexDirection: "column" as const, + ...formStyles.form, gap: "1.5rem", }, field: { - display: "flex", - flexDirection: "column" as const, - gap: "0.5rem", + ...formStyles.field, }, label: { - fontFamily: "'DM Sans', system-ui, sans-serif", - color: "rgba(255, 255, 255, 0.7)", - fontSize: "0.875rem", - fontWeight: 500, + ...formStyles.label, }, input: { - fontFamily: "'DM Sans', system-ui, sans-serif", - padding: "0.875rem 1rem", - fontSize: "1rem", - background: "rgba(255, 255, 255, 0.05)", - border: "1px solid rgba(255, 255, 255, 0.1)", - borderRadius: "12px", - color: "#fff", - outline: "none", - transition: "border-color 0.2s, box-shadow 0.2s", + ...formStyles.input, }, button: { - fontFamily: "'DM Sans', system-ui, sans-serif", + ...buttonStyles.primaryButton, marginTop: "0.5rem", - padding: "1rem", - fontSize: "1rem", - fontWeight: 600, - background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)", - color: "#fff", - border: "none", - borderRadius: "12px", - cursor: "pointer", - transition: "transform 0.2s, box-shadow 0.2s", - boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)", }, error: { - fontFamily: "'DM Sans', system-ui, sans-serif", - padding: "0.875rem 1rem", - background: "rgba(239, 68, 68, 0.1)", - border: "1px solid rgba(239, 68, 68, 0.3)", - borderRadius: "12px", - color: "#fca5a5", - fontSize: "0.875rem", + ...bannerStyles.errorBanner, textAlign: "center" as const, }, footer: { diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx index 0eff03f..f9e7a13 100644 --- a/frontend/app/trades/page.tsx +++ b/frontend/app/trades/page.tsx @@ -6,6 +6,7 @@ import { Permission } from "../auth-context"; import { api } from "../api"; import { Header } from "../components/Header"; import { SatsDisplay } from "../components/SatsDisplay"; +import { LoadingState } from "../components/LoadingState"; import { useRequireAuth } from "../hooks/useRequireAuth"; import { components } from "../generated/api"; import { formatDateTime } from "../utils/date"; @@ -68,11 +69,7 @@ export default function TradesPage() { }; if (isLoading) { - return ( -
-
Loading...
-
- ); + return ; } if (!isAuthorized) { diff --git a/frontend/app/utils/error-handling.ts b/frontend/app/utils/error-handling.ts new file mode 100644 index 0000000..101e0cc --- /dev/null +++ b/frontend/app/utils/error-handling.ts @@ -0,0 +1,50 @@ +import { ApiError } from "../api"; + +/** + * Extract a user-friendly error message from an API error or generic error. + * Handles ApiError instances with structured data, regular Error instances, and unknown errors. + * + * @param err - The error to extract a message from + * @param fallback - Default message if extraction fails (default: "An error occurred") + * @returns A user-friendly error message string + */ +export function extractApiErrorMessage( + err: unknown, + fallback: string = "An error occurred" +): string { + if (err instanceof ApiError) { + if (err.data && typeof err.data === "object") { + const data = err.data as { detail?: string }; + return data.detail || err.message || fallback; + } + return err.message || fallback; + } + if (err instanceof Error) { + return err.message; + } + return fallback; +} + +/** + * Type guard to check if an error is an ApiError with structured detail data. + */ +export function isApiErrorWithDetail( + err: unknown +): err is ApiError & { data: { detail?: string } } { + return err instanceof ApiError && err.data !== undefined && typeof err.data === "object"; +} + +/** + * Extract field errors from a 422 validation error response. + * Returns undefined if the error doesn't contain field errors. + */ +export function extractFieldErrors( + err: unknown +): { detail?: { field_errors?: Record } } | undefined { + if (err instanceof ApiError && err.status === 422) { + if (err.data && typeof err.data === "object") { + return err.data as { detail?: { field_errors?: Record } }; + } + } + return undefined; +}