Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions app/frontend/src/app/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import type { Profile } from "@/types/profile";
const FOCUS_RING_CLASS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300 focus-visible:ring-offset-2 focus-visible:ring-offset-black";

const ERROR_COPY = {
invalidUsername: "Invalid username",
notFound: "Username not found or profile is private",
fallback: "Failed to load profile",
} as const;

export default function PublicProfile() {
const params = useParams();
const router = useRouter();
Expand All @@ -19,6 +25,7 @@ export default function PublicProfile() {
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [avatarFailed, setAvatarFailed] = useState(false);

const [paymentForm, setPaymentForm] = useState({
amount: "",
Expand All @@ -29,30 +36,34 @@ export default function PublicProfile() {
useEffect(() => {
let mounted = true;

// Reset per-username state so navigating between profiles never shows
// stale content (avoids flicker / metadata inconsistency across refreshes).
setProfile(null);
setAvatarFailed(false);
setError(null);
setLoading(true);

async function fetchProfile() {
if (!username) {
setError("Invalid username");
setError(ERROR_COPY.invalidUsername);
setLoading(false);
return;
}

try {
setLoading(true);
setError(null);
const data = await getProfile(username);

if (mounted) {
setProfile(data);
}
} catch (err) {
if (!mounted) return;

if (err instanceof ProfileNotFoundError) {
setError("Username not found or profile is private");
setError(ERROR_COPY.notFound);
} else if (err instanceof Error) {
setError(err.message);
} else {
setError("Failed to load profile");
setError(ERROR_COPY.fallback);
}
setProfile(null);
} finally {
Expand All @@ -71,7 +82,11 @@ export default function PublicProfile() {

if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-white">
<div
className="min-h-screen flex items-center justify-center text-white"
role="status"
aria-live="polite"
>
<div className="text-center">
<div className="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-neutral-400">Loading profile...</p>
Expand All @@ -82,11 +97,14 @@ export default function PublicProfile() {

if (error || !profile) {
return (
<div className="min-h-screen flex items-center justify-center text-white px-4">
<div
className="min-h-screen flex items-center justify-center text-white px-4"
role="alert"
>
<div className="text-center max-w-md">
<h1 className="text-6xl font-black mb-4">404</h1>
<p className="text-xl text-neutral-300 mb-6">
{error || "Username not found"}
{error || ERROR_COPY.notFound}
</p>
<button
onClick={() => router.push("/")}
Expand All @@ -100,6 +118,7 @@ export default function PublicProfile() {
}

const primaryColor = profile.primaryColor || "#6366f1";
const showAvatar = Boolean(profile.avatarUrl) && !avatarFailed;

return (
<div className="relative min-h-screen text-white">
Expand Down Expand Up @@ -130,14 +149,15 @@ export default function PublicProfile() {
<div className="text-center mb-12">
{/* Avatar */}
<div className="flex justify-center mb-6">
{profile.avatarUrl ? (
{showAvatar ? (
<Image
src={profile.avatarUrl}
alt={profile.username}
width={128}
height={128}
className="w-32 h-32 rounded-full border-4 object-cover"
style={{ borderColor: primaryColor }}
onError={() => setAvatarFailed(true)}
/>
) : (
<div
Expand Down
25 changes: 15 additions & 10 deletions app/frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import AnalyticsDashboard from "@/components/AnalyticsDashboard";
import { NetworkBadge } from "@/components/NetworkBadge";
import { useApi } from "@/hooks/useApi";
import {
fetchUserBids,
fetchUserListings,
formatCountdown,
type UserBid,
type UserListing,
} from "@/hooks/marketplaceApi";
import {
MarketplaceApiProvider,
useMarketplaceApi,
} from "@/hooks/MarketplaceApiContext";
import { mockContractCall, mockFetch } from "@/hooks/mockApi";

type ActivityItem = {
Expand Down Expand Up @@ -84,6 +86,7 @@ const FOCUS_RING_CLASS =
function DashboardContent() {
const searchParams = useSearchParams();
const { data, error, loading, callApi } = useApi<DashboardResponse>();
const marketplaceApi = useMarketplaceApi();
const [userBids, setUserBids] = useState<UserBid[]>([]);
const [userListings, setUserListings] = useState<UserListing[]>([]);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
Expand All @@ -94,9 +97,9 @@ function DashboardContent() {
items: ACTIVITY_ITEMS,
}),
);
void fetchUserBids().then(setUserBids);
void fetchUserListings().then(setUserListings);
}, [callApi]);
void marketplaceApi.fetchUserBids().then(setUserBids);
void marketplaceApi.fetchUserListings().then(setUserListings);
}, [callApi, marketplaceApi]);

useEffect(() => {
if (!statusMessage) {
Expand Down Expand Up @@ -594,10 +597,12 @@ function DashboardContent() {

export default function Dashboard() {
return (
<Suspense
fallback={<p className="text-neutral-200">Loading dashboard...</p>}
>
<DashboardContent />
</Suspense>
<MarketplaceApiProvider>
<Suspense
fallback={<p className="text-neutral-200">Loading dashboard...</p>}
>
<DashboardContent />
</Suspense>
</MarketplaceApiProvider>
);
}
20 changes: 18 additions & 2 deletions app/frontend/src/app/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export default function manifest(): MetadataRoute.Manifest {
name: "RustAcademy",
short_name: "RustAcademy",
description: "Privacy-focused payments on Stellar",
lang: "en",
dir: "ltr",
start_url: "/",
scope: "/",
display: "standalone",
Expand Down Expand Up @@ -38,14 +40,28 @@ export default function manifest(): MetadataRoute.Manifest {
short_name: "Generate",
description: "Generate a new payment link",
url: "/generator",
icons: [{ src: "/icon-192.png", sizes: "192x192" }],
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
],
},
{
name: "Dashboard",
short_name: "Dashboard",
description: "View your dashboard",
url: "/dashboard",
icons: [{ src: "/icon-192.png", sizes: "192x192" }],
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
],
},
],
};
Expand Down
3 changes: 1 addition & 2 deletions app/frontend/src/app/marketplace/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import dynamic from "next/dynamic";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { UsernameCard } from "@/components/UsernameCard";
import { ListingDetailModal } from "@/components/ListingDetailModal";
import type { MarketplaceListing } from "@/hooks/marketplaceApi";
Expand Down Expand Up @@ -97,7 +97,6 @@ function MarketplacePageContent() {
() => listings.find((l) => l.id === detailListingId) ?? null,
[listings, detailListingId],
);
const [showWatchlistOnly, setShowWatchlistOnly] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);

const { watchlist, isInWatchlist, toggleWatchlist } = useWatchlist();
Expand Down
7 changes: 0 additions & 7 deletions app/frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { NetworkBadge } from "@/components/NetworkBadge";
import { fetchAnalytics } from "@/hooks/analyticsApi";
import { fetchListings } from "@/hooks/marketplaceApi";
import { errorReporter } from "@/lib/errorReporter";
import '@/lib/i18n';
import { useTranslation } from 'react-i18next';
Expand All @@ -18,12 +17,6 @@ export default function Home() {
const handlePrefetch = () => {
router.prefetch("/dashboard");
router.prefetch("/marketplace");
fetchListings().catch((err: unknown) =>
errorReporter.captureError(err instanceof Error ? err : new Error(String(err)), {
route: "/",
extra: { source: "page.tsx", operation: "fetchListings" },
})
);
fetchAnalytics("30d").catch((err: unknown) =>
errorReporter.captureError(err instanceof Error ? err : new Error(String(err)), {
route: "/",
Expand Down
14 changes: 13 additions & 1 deletion app/frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"use client";

import { useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LocaleSwitcher } from "@/components/LocaleSwitcher";
import { NotificationBell } from "@/components/NotificationBell";
import "@/lib/i18n";
import i18n from "@/lib/i18n";
import { useTranslation } from "react-i18next";

const NAV_LINK_CLASS =
Expand All @@ -14,6 +16,16 @@ export function Header() {
const { t } = useTranslation();
const pathname = usePathname();

// Restore the user's saved language after hydration. i18n initializes in
// "en" deterministically on server + client, so applying the stored locale
// here avoids a hydration mismatch while still persisting the choice.
useEffect(() => {
const saved = window.localStorage.getItem("i18nextLng");
if (saved && saved !== i18n.language) {
i18n.changeLanguage(saved);
}
}, []);

const isActive = (href: string) =>
pathname === href || pathname?.startsWith(`${href}/`);

Expand All @@ -31,7 +43,7 @@ export function Header() {
>
<Link
href="/"
aria-label=" RustAcademy home"
aria-label="RustAcademy home"
className={`flex shrink-0 items-center gap-2 lg:mr-4 ${NAV_LINK_CLASS}`}
>
<div
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/src/components/PWAHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function PWAHandler() {
});
});
})
.catch((err) => errorReporter.captureError(err, { context: { component: 'PWAHandler' } }));
.catch((err) => errorReporter.captureError(err, { route: "/", extra: { component: "PWAHandler" } }));
}

// Check if already installed
Expand Down
33 changes: 28 additions & 5 deletions app/frontend/src/components/UsernameCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { MarketplaceListing, formatCountdown } from "@/hooks/marketplaceApi";
import { useWatchlist } from "@/contexts/WatchlistContext";

Expand All @@ -25,9 +26,18 @@ const CATEGORY_COLORS: Record<MarketplaceListing["category"], string> = {
brand: "text-teal-400 bg-teal-400/10 border-teal-400/20",
};

function UrgencyBar({ endsAt }: { endsAt: Date }) {
function UrgencyBar({
endsAt,
now,
}: {
endsAt: Date;
now: number | null;
}) {
const total = 7 * 24 * 3600 * 1000; // 7 day auction window
const remaining = Math.max(0, endsAt.getTime() - Date.now());
// While not mounted (SSR / initial hydration), `now` is null so we render a
// stable empty bar — this avoids a hydration mismatch caused by Date.now().
const remaining =
now === null ? 0 : Math.max(0, endsAt.getTime() - now);
const pct = Math.min(100, Math.round((remaining / total) * 100));
const color =
pct < 15 ? "bg-red-500" : pct < 40 ? "bg-amber-500" : "bg-indigo-500";
Expand All @@ -48,10 +58,23 @@ export function UsernameCard({
onViewDetails,
}: UsernameCardProps) {
const { isInWatchlist, toggleWatchlist, isHydrated } = useWatchlist();

// Track "now" in state (updated every 30s) so the countdown is stable across
// SSR and client hydration, and ticks down while mounted. `null` on first
// render keeps server and client output identical.
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(id);
}, []);

const catColor = CATEGORY_COLORS[listing.category];
const catLabel = CATEGORY_LABELS[listing.category];
const countdown = formatCountdown(listing.endsAt);
const isUrgent = listing.endsAt.getTime() - Date.now() < 1000 * 60 * 90;
const countdown =
now !== null ? formatCountdown(listing.endsAt, now) : "—";
const isUrgent =
now !== null && listing.endsAt.getTime() - now < 1000 * 60 * 90;
const isWatched = isHydrated ? isInWatchlist(listing.id) : false;

const handleWatchlistClick = (e: React.MouseEvent) => {
Expand Down Expand Up @@ -135,7 +158,7 @@ export function UsernameCard({
</div>
</div>

<UrgencyBar endsAt={listing.endsAt} />
<UrgencyBar endsAt={listing.endsAt} now={now} />

{/* Bottom row: owner + bid count */}
<div className="flex items-center justify-between text-[11px] text-neutral-600">
Expand Down
7 changes: 4 additions & 3 deletions app/frontend/src/hooks/__tests__/usePersistentState.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { renderHook, act } from "@testing-library/react";
import { usePersistentState } from "../hooks/usePersistentState";
import { usePersistentState } from "../usePersistentState";
import { beforeEach, describe, expect, it, vi } from "vitest";

describe("usePersistentState", () => {
const TEST_KEY = "test_key";
const INITIAL_VALUE = { count: 0 };

beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
vi.clearAllMocks();
});

it("should initialize with initial value if local storage is empty", () => {
Expand Down Expand Up @@ -59,7 +60,7 @@ describe("usePersistentState", () => {

it("should sync to backend if provided", async () => {
const USER_ID = "user_123";
const syncToBackend = jest.fn().mockResolvedValue(undefined);
const syncToBackend = vi.fn().mockResolvedValue(undefined);

const { result } = renderHook(() =>
usePersistentState(TEST_KEY, INITIAL_VALUE, {
Expand Down
Loading
Loading