Skip to content
Open
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
24 changes: 21 additions & 3 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <UseCasesSkeleton /> }
)
const Faq = dynamic(() => import('@/components/landing-light/faq').then((m) => m.Faq), {
loading: () => <FaqSkeleton />,
})
const SiteFooter = dynamic(
() => import('@/components/landing-light/site-footer').then((m) => m.SiteFooter),
{ loading: () => <SiteFooterSkeleton /> }
)

export const metadata = {
title: "Aframp — Africa's gateway to global decentralized finance",
description:
Expand Down
1 change: 1 addition & 0 deletions components/landing-light/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</div>
Expand Down
60 changes: 60 additions & 0 deletions components/landing-light/section-skeletons.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-hidden="true" className="bg-white dark:bg-surface px-6 py-24">
<div className="mx-auto max-w-5xl animate-pulse">
<div className="mx-auto h-9 w-80 max-w-full rounded bg-black/10 dark:bg-white/10" />
<div className="mx-auto mt-3 h-4 w-96 max-w-full rounded bg-black/10 dark:bg-white/10" />

<div className="mt-12 grid gap-5 md:grid-cols-3">
<div className="h-64 rounded-2xl bg-black/5 md:col-span-2 dark:bg-white/5" />
<div className="h-64 rounded-2xl bg-black/5 dark:bg-white/5" />
<div className="h-64 rounded-2xl bg-black/5 dark:bg-white/5" />
<div className="h-64 rounded-2xl bg-black/5 dark:bg-white/5" />
</div>

<div className="mx-auto mt-16 h-12 w-44 rounded-full bg-black/10 dark:bg-white/10" />
</div>
</section>
)
}

export function FaqSkeleton() {
return (
<section aria-hidden="true" className="bg-mint dark:bg-band px-6 py-20">
<div className="mx-auto max-w-3xl animate-pulse">
<div className="mx-auto h-9 w-72 max-w-full rounded bg-black/10 dark:bg-white/10" />

<div className="mt-12 space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 rounded-lg bg-black/10 dark:bg-white/10" />
))}
</div>
</div>
</section>
)
}

export function SiteFooterSkeleton() {
return (
<footer aria-hidden="true" className="bg-lavender dark:bg-band px-6 py-16">
<div className="mx-auto max-w-5xl animate-pulse">
<div className="grid gap-10 sm:grid-cols-3">
<div className="h-28 rounded bg-black/10 dark:bg-white/10" />
<div className="h-28 rounded bg-black/10 dark:bg-white/10" />
<div className="h-28 rounded bg-black/10 dark:bg-white/10" />
</div>
<div className="mx-auto mt-12 h-4 w-40 rounded bg-black/10 dark:bg-white/10" />
</div>
</footer>
)
}
64 changes: 11 additions & 53 deletions components/session-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<string, unknown>
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<Session | null>(null)
const [ready, setReady] = useState(false)
Expand All @@ -60,54 +40,32 @@ 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)
const signedTransaction = await signChallengeTransaction(
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)
}, [])

Expand Down
48 changes: 48 additions & 0 deletions components/wallet/__tests__/memoized-dashboard-components.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TopAssets balances={BALANCES} />)
expect(screen.getByText('XLM')).toBeInTheDocument()
})

it('ActivityHighlights is wrapped with React.memo and still renders', () => {
expect(ActivityHighlights.$$typeof).toBe(REACT_MEMO_TYPE)

render(<ActivityHighlights payments={PAYMENTS} openRequestCount={3} />)
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(<QuickConvert openRequests={OPEN_REQUESTS} />)
expect(screen.getByText('No open charges right now.')).toBeInTheDocument()
})
})
5 changes: 3 additions & 2 deletions components/wallet/activity-highlights.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { memo } from 'react'
import type { Payment } from '@/lib/api'
import { formatStroops } from '@/lib/money'

Expand All @@ -21,7 +22,7 @@ function takingsToday(payments: Payment[]): Map<string, bigint> {
return totals
}

export function ActivityHighlights({
export const ActivityHighlights = memo(function ActivityHighlights({
payments,
openRequestCount,
}: {
Expand Down Expand Up @@ -56,4 +57,4 @@ export function ActivityHighlights({
</dl>
</section>
)
}
})
9 changes: 7 additions & 2 deletions components/wallet/quick-convert.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="bg-panel border-hairline rounded-2xl border p-5">
<div className="flex items-start justify-between">
Expand Down Expand Up @@ -44,4 +49,4 @@ export function QuickConvert({ openRequests }: { openRequests: PaymentRequest[]
</Link>
</section>
)
}
})
5 changes: 3 additions & 2 deletions components/wallet/top-assets.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<p className="text-dim mb-2 text-xs">Balances</p>
Expand Down Expand Up @@ -37,4 +38,4 @@ export function TopAssets({ balances }: { balances: Balance[] }) {
)}
</div>
)
}
})
Binary file removed public/logo.png
Binary file not shown.
Binary file added public/logo.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.