diff --git a/app/(dashboard)/profile/__tests__/profile.optimistic.test.tsx b/app/(dashboard)/profile/__tests__/profile.optimistic.test.tsx
new file mode 100644
index 0000000..1c6e932
--- /dev/null
+++ b/app/(dashboard)/profile/__tests__/profile.optimistic.test.tsx
@@ -0,0 +1,90 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import ProfilePage from "../page";
+import { useWalletContext } from "@/context/WalletContext";
+
+jest.mock("@/context/WalletContext", () => ({
+ useWalletContext: jest.fn(),
+}));
+
+jest.mock("@/components/profile/ProfileShareCard", () => ({
+ ProfileShareCard: () => ,
+}));
+
+jest.mock("qrcode", () => ({
+ toDataURL: jest.fn().mockResolvedValue("data:image/png;base64,test"),
+}));
+
+jest.mock("html-to-image", () => ({
+ toPng: jest.fn().mockResolvedValue("data:image/png;base64,test"),
+}));
+
+jest.mock("next-themes", () => ({
+ useTheme: () => ({ resolvedTheme: "light" }),
+}));
+
+const mockUseWalletContext = useWalletContext as jest.Mock;
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ mockUseWalletContext.mockReturnValue({
+ address: "0xabc123def456",
+ name: "Test User",
+ connected: true,
+ connect: jest.fn(),
+ disconnect: jest.fn(),
+ isLoading: false,
+ });
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+ jest.clearAllMocks();
+});
+
+describe("ProfilePage Optimistic UI", () => {
+ it("shows a loading state on the submit button while saving", async () => {
+ render();
+ const submitBtn = screen.getByRole("button", { name: /save changes/i });
+ fireEvent.click(submitBtn);
+
+ // Immediately shows loading state
+ expect(screen.getByText("Saving...")).toBeInTheDocument();
+ expect(submitBtn).toBeDisabled();
+ });
+
+ it("shows a success message after a successful save", async () => {
+ render();
+ const submitBtn = screen.getByRole("button", { name: /save changes/i });
+ fireEvent.click(submitBtn);
+
+ await act(async () => {
+ jest.advanceTimersByTime(1500);
+ });
+
+ expect(
+ screen.getByText("Profile updated successfully!")
+ ).toBeInTheDocument();
+ });
+
+ it("resets to idle after the success message times out", async () => {
+ render();
+ const submitBtn = screen.getByRole("button", { name: /save changes/i });
+ fireEvent.click(submitBtn);
+
+ await act(async () => {
+ jest.advanceTimersByTime(1500);
+ });
+ expect(
+ screen.getByText("Profile updated successfully!")
+ ).toBeInTheDocument();
+
+ await act(async () => {
+ jest.advanceTimersByTime(3000);
+ });
+ expect(
+ screen.queryByText("Profile updated successfully!")
+ ).not.toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/app/(dashboard)/profile/page.tsx b/app/(dashboard)/profile/page.tsx
index f757ac9..bac2a7f 100644
--- a/app/(dashboard)/profile/page.tsx
+++ b/app/(dashboard)/profile/page.tsx
@@ -2,33 +2,72 @@
import { Badge } from "@/components/ui/badge"
-import React, { useState } from "react"
+import React, { useCallback, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
-import { AlertCircle } from "lucide-react"
+import { AlertCircle, Loader2 } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { ProfileShareCard } from "@/components/profile/ProfileShareCard"
import { useWalletContext } from "@/context/WalletContext"
+type SaveState = "idle" | "saving" | "success" | "error";
+
export default function ProfilePage() {
- const [saveSuccess, setSaveSuccess] = useState(false)
+ const [saveState, setSaveState] = useState("idle")
const { address, name } = useWalletContext()
- const handleSave = (e: React.FormEvent) => {
+ const handleSave = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
- // Simulate API call
- setTimeout(() => {
- setSaveSuccess(true)
+
+ // Optimistic update: immediately show success
+ setSaveState("saving")
+
+ try {
+ // Simulate API call
+ await new Promise((resolve, reject) => {
+ setTimeout(() => {
+ // Simulate 90% success rate
+ if (Math.random() > 0.1) {
+ resolve()
+ } else {
+ reject(new Error("Network error"))
+ }
+ }, 1500)
+ })
+
+ setSaveState("success")
// Clear success message after 3 seconds
setTimeout(() => {
- setSaveSuccess(false)
+ setSaveState("idle")
}, 3000)
- }, 1000)
+ } catch {
+ // Revert on failure
+ setSaveState("error")
+
+ // Clear error message after 4 seconds
+ setTimeout(() => {
+ setSaveState("idle")
+ }, 4000)
+ }
+ }, [])
+
+ const shareProfile = {
+ displayName: name || "Anonymous",
+ handle: address
+ ? `${address.slice(0, 6)}...${address.slice(-4)}`
+ : "user",
+ avatarUrl: "/placeholder.svg?height=80&width=80",
+ winRate: 0.62,
+ totalPredictions: 128,
+ topCategory: "Sports",
+ publicProfileUrl: address
+ ? `https://predictify.vercel.app/profile/${address}`
+ : "https://predictify.vercel.app/profile",
}
return (
@@ -46,12 +85,18 @@ export default function ProfilePage() {
Update your personal information
- {saveSuccess && (
+ {saveState === "success" && (
Profile updated successfully!
)}
+ {saveState === "error" && (
+
+
+ Failed to update profile. Please try again.
+
+ )}
@@ -92,7 +137,16 @@ export default function ProfilePage() {
-
+