diff --git a/app/page.tsx b/app/page.tsx index f8c43c2..779fd6e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,14 +1,32 @@ -import { Faq } from '@/components/landing-light/faq' +import dynamic from 'next/dynamic' + import { Features } from '@/components/landing-light/features' import { FinalCta } from '@/components/landing-light/final-cta' import { Hero } from '@/components/landing-light/hero' import { HowItWorks } from '@/components/landing-light/how-it-works' import { Pricing } from '@/components/landing-light/pricing' -import { SiteFooter } from '@/components/landing-light/site-footer' +import { + FaqSkeleton, + SiteFooterSkeleton, + UseCasesSkeleton, +} from '@/components/landing-light/section-skeletons' import { Trust } from '@/components/landing-light/trust' -import { UseCases } from '@/components/landing-light/use-cases' import { WhyUs } from '@/components/landing-light/why-us' +// Below-the-fold sections: split out of the initial bundle and streamed in +// once ready, so they don't compete with the hero for the first paint. (#477) +const UseCases = dynamic( + () => import('@/components/landing-light/use-cases').then((m) => m.UseCases), + { loading: () => } +) +const Faq = dynamic(() => import('@/components/landing-light/faq').then((m) => m.Faq), { + loading: () => , +}) +const SiteFooter = dynamic( + () => import('@/components/landing-light/site-footer').then((m) => m.SiteFooter), + { loading: () => } +) + export const metadata = { title: "Aframp — Africa's gateway to global decentralized finance", description: diff --git a/components/landing-light/hero.tsx b/components/landing-light/hero.tsx index 09c1798..d177503 100644 --- a/components/landing-light/hero.tsx +++ b/components/landing-light/hero.tsx @@ -36,6 +36,7 @@ export function Hero() { width={705} height={835} priority + sizes="(min-width: 1024px) 460px, 0px" className="hidden h-auto w-full max-w-[460px] justify-self-end lg:block" /> diff --git a/components/landing-light/section-skeletons.tsx b/components/landing-light/section-skeletons.tsx new file mode 100644 index 0000000..eb2a8f8 --- /dev/null +++ b/components/landing-light/section-skeletons.tsx @@ -0,0 +1,60 @@ +/** + * Static, CSS-only loading placeholders for the landing page's lazy-loaded, + * below-the-fold sections. Deliberately not the `framer-motion`-based + * `components/ui/skeleton.tsx` primitive — that one needs `'use client'` to + * run its animation, and a `next/dynamic` loading fallback should stay cheap + * and framework-agnostic. Tailwind's `animate-pulse` needs no JS. + * Each shape roughly matches its real section's layout to minimize layout + * shift when the real content swaps in. + */ + +export function UseCasesSkeleton() { + return ( + + ) +} + +export function FaqSkeleton() { + return ( + + ) +} + +export function SiteFooterSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/components/session-provider.tsx b/components/session-provider.tsx index 5a4f12e..0fd351c 100644 --- a/components/session-provider.tsx +++ b/components/session-provider.tsx @@ -2,17 +2,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { api, setUnauthorizedHandler, type AuthResponse, type Session } from '@/lib/api' -import { api, setUnauthorizedHandler, type AuthResponse } from '@/lib/api' import { connectFreighter, signChallengeTransaction } from '@/lib/freighter' -const STORAGE_KEY = 'aframp.session' - -interface Session { - token: string - userId: string - merchantId: string | null -} - interface SessionContextValue { session: Session | null /** False until session cookie has been validated — guards against redirecting on first paint. */ @@ -34,17 +25,6 @@ function toSession(response: AuthResponse): Session { } } -function isValidSession(value: unknown): value is Session { - if (typeof value !== 'object' || value === null) return false - const obj = value as Record - return ( - typeof obj.token === 'string' && - obj.token.length > 0 && - typeof obj.userId === 'string' && - obj.userId.length > 0 - ) -} - export function SessionProvider({ children }: { children: React.ReactNode }) { const [session, setSession] = useState(null) const [ready, setReady] = useState(false) @@ -60,42 +40,18 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { // Session restoration failed, user will need to log in } setReady(true) - try { - const stored = window.localStorage.getItem(STORAGE_KEY) - if (stored) { - const parsed = JSON.parse(stored) - if (isValidSession(parsed)) { - setSession(parsed) - } else { - window.localStorage.removeItem(STORAGE_KEY) - } - } - } catch { - window.localStorage.removeItem(STORAGE_KEY) } restoreSession() }, []) - const signIn = useCallback( - async (email: string, password: string) => { - setSession(toSession(await api.login(email, password))) - }, - [] - ) + const signIn = useCallback(async (email: string, password: string) => { + setSession(toSession(await api.login(email, password))) + }, []) - const signUp = useCallback( - async (email: string, password: string, name: string) => { - setSession(toSession(await api.signup(email, password, name))) - }, - [] - ) + const signUp = useCallback(async (email: string, password: string, name: string) => { + setSession(toSession(await api.signup(email, password, name))) + }, []) - const signOut = useCallback(async () => { - try { - await api.logout() - } catch { - // Logout failed, but clear session anyway - } const signInWithFreighter = useCallback(async () => { const address = await connectFreighter() const challenge = await api.getStellarChallenge(address) @@ -103,11 +59,13 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { challenge.transaction, challenge.network_passphrase ) - persist(toSession(await api.verifyStellarChallenge(signedTransaction))) - }, [persist]) + setSession(toSession(await api.verifyStellarChallenge(signedTransaction))) + }, []) const signOut = useCallback(() => { - window.localStorage.removeItem(STORAGE_KEY) + void api.logout().catch(() => { + // Logout failed, but clear session anyway + }) setSession(null) }, []) diff --git a/components/wallet/__tests__/memoized-dashboard-components.test.tsx b/components/wallet/__tests__/memoized-dashboard-components.test.tsx new file mode 100644 index 0000000..cf0ec3e --- /dev/null +++ b/components/wallet/__tests__/memoized-dashboard-components.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react' + +import { TopAssets } from '../top-assets' +import { ActivityHighlights } from '../activity-highlights' +import { QuickConvert } from '../quick-convert' +import type { Balance, Payment, PaymentRequest } from '@/lib/api' + +const REACT_MEMO_TYPE = Symbol.for('react.memo') + +const BALANCES: Balance[] = [ + { merchant_id: 'm-1', asset: 'XLM', available: 100n, pending: 0n } as Balance, +] + +const PAYMENTS: Payment[] = [] + +const OPEN_REQUESTS: PaymentRequest[] = [] + +// `React.memo` skips re-rendering a component whenever its props are shallowly +// equal to the previous render — that bail-out is React's own guarantee, not +// something a re-render count can usefully re-prove in a jsdom test (the +// Profiler's onRender fires once per *commit* that reaches a subtree, even +// when the memoized child inside it bails out, so counting calls doesn't +// distinguish "skipped" from "rendered"). What a test *can* pin down is that +// the component is actually wrapped in memo() in the first place, and that +// wrapping it didn't change what it renders. +describe('TopAssets, ActivityHighlights, and QuickConvert are memoized', () => { + it('TopAssets is wrapped with React.memo and still renders balances', () => { + expect(TopAssets.$$typeof).toBe(REACT_MEMO_TYPE) + + render() + expect(screen.getByText('XLM')).toBeInTheDocument() + }) + + it('ActivityHighlights is wrapped with React.memo and still renders', () => { + expect(ActivityHighlights.$$typeof).toBe(REACT_MEMO_TYPE) + + render() + expect(screen.getByText('Activity highlights')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('QuickConvert is wrapped with React.memo and still renders', () => { + expect(QuickConvert.$$typeof).toBe(REACT_MEMO_TYPE) + + render() + expect(screen.getByText('No open charges right now.')).toBeInTheDocument() + }) +}) diff --git a/components/wallet/activity-highlights.tsx b/components/wallet/activity-highlights.tsx index 28ebd08..92443b2 100644 --- a/components/wallet/activity-highlights.tsx +++ b/components/wallet/activity-highlights.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import type { Payment } from '@/lib/api' import { formatStroops } from '@/lib/money' @@ -21,7 +22,7 @@ function takingsToday(payments: Payment[]): Map { return totals } -export function ActivityHighlights({ +export const ActivityHighlights = memo(function ActivityHighlights({ payments, openRequestCount, }: { @@ -56,4 +57,4 @@ export function ActivityHighlights({ ) -} +}) diff --git a/components/wallet/quick-convert.tsx b/components/wallet/quick-convert.tsx index 5ec5769..bef132f 100644 --- a/components/wallet/quick-convert.tsx +++ b/components/wallet/quick-convert.tsx @@ -1,10 +1,15 @@ +import { memo } from 'react' import Link from 'next/link' import { ArrowRight, Clock } from 'lucide-react' import type { PaymentRequest } from '@/lib/api' import { formatStroops } from '@/lib/money' -export function QuickConvert({ openRequests }: { openRequests: PaymentRequest[] }) { +export const QuickConvert = memo(function QuickConvert({ + openRequests, +}: { + openRequests: PaymentRequest[] +}) { return (
@@ -44,4 +49,4 @@ export function QuickConvert({ openRequests }: { openRequests: PaymentRequest[]
) -} +}) diff --git a/components/wallet/top-assets.tsx b/components/wallet/top-assets.tsx index 7b52389d..459da47 100644 --- a/components/wallet/top-assets.tsx +++ b/components/wallet/top-assets.tsx @@ -1,8 +1,9 @@ +import { memo } from 'react' import Link from 'next/link' import type { Balance } from '@/lib/api' import { formatStroops } from '@/lib/money' -export function TopAssets({ balances }: { balances: Balance[] }) { +export const TopAssets = memo(function TopAssets({ balances }: { balances: Balance[] }) { return (

Balances

@@ -37,4 +38,4 @@ export function TopAssets({ balances }: { balances: Balance[] }) { )}
) -} +}) diff --git a/public/logo.png b/public/logo.png deleted file mode 100644 index 4bf65c1..0000000 Binary files a/public/logo.png and /dev/null differ diff --git a/public/logo.webp b/public/logo.webp new file mode 100644 index 0000000..248f594 Binary files /dev/null and b/public/logo.webp differ