From b470bc8821edccbaf78dbd94af1a53a77e452148 Mon Sep 17 00:00:00 2001 From: wheval Date: Thu, 30 Jul 2026 13:45:40 +0100 Subject: [PATCH 1/3] feat(brand,a11y): centralize brand palette and strengthen the funding meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related presentation fixes. Brand palette (#85) The literal brand hexes were restated in three places — the SVG gradient stops, the module constants, and the solar dot in — which duplicated the CSS colour tokens and left four copies of the same value free to drift apart. Adds src/brand/palette.ts as the single source for the values that cannot read a CSS custom property: the SVG builds gradient stops, and three.js needs a parsed colour rather than a var() string. SOLAR and INK mirror --solar and --ink exactly; the rest are the solar ramp the orb is built from. The SVG orb now derives its stop list from SOLAR_RAMP and its glow from solarAlpha(), so both orbs and the mark resolve to one definition. Funding meter (#90) The creator funding bar was a bare solar fill. The percent text sat beside it, but the meter itself exposed no value to assistive technology and its fill boundary was carried by hue alone. The track is now a real progressbar with aria-valuenow/min/max, an aria-valuetext carrying the same sentence sighted users read, and aria-labelledby pointing at that caption. The fill's leading edge is drawn in ink, so the boundary stays legible in greyscale, under a colour-vision deficiency, and with forced colours on. Solar decorates the value; it no longer carries it alone. Closes #85 Closes #90 --- src/brand/Helio.tsx | 16 +++--- src/brand/HelioWebGL.tsx | 10 ++-- src/brand/Mark.tsx | 9 +++- src/brand/palette.ts | 64 ++++++++++++++++++++++++ src/screens/creator/CreatorDashboard.tsx | 21 +++++++- 5 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 src/brand/palette.ts diff --git a/src/brand/Helio.tsx b/src/brand/Helio.tsx index 8f728b5..ee8a6b7 100644 --- a/src/brand/Helio.tsx +++ b/src/brand/Helio.tsx @@ -1,4 +1,5 @@ import { useId } from 'react' +import { INK, SOLAR_RAMP, solarAlpha } from './palette' /** * Helio — the platform's one spectacle, here in its static, accessible fallback @@ -36,20 +37,19 @@ export function Helio({ size = 360, motes = 14, breathe = true }: HelioProps) { - - - - + {SOLAR_RAMP.map((stop) => ( + + ))} - - - + + + {dots.map((d, i) => ( - + ))} void } -/* --- Brand palette (matches the static Helio exactly) -------------------- */ -const SOLAR = '#FFB400' // the sun — emissive accent -const CORE = '#FFD451' // warm core surface tint -const HALO = '#FFC633' // glow halo (sits between core and solar) -const INK = '#0B2B23' // deep pine — corona motes - const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n) /* ------------------------------------------------------------------------- * @@ -274,7 +270,7 @@ function Scene({ highlight (echoing the static SVG's specular), plus a core glow light. */} - + diff --git a/src/brand/Mark.tsx b/src/brand/Mark.tsx index 40ec19e..6ad05bf 100644 --- a/src/brand/Mark.tsx +++ b/src/brand/Mark.tsx @@ -3,6 +3,13 @@ * year-long path), drawn in currentColor with the one permitted second colour: * a solar dot. Upper loop subtly smaller, as in the real analemma. */ +/** + * The Heliobond analemma — a single continuous tilted figure-eight (the sun's + * year-long path), drawn in currentColor with the one permitted second colour: + * a solar dot. Upper loop subtly smaller, as in the real analemma. + */ +import { SOLAR } from './palette' + export interface MarkProps { size?: number } @@ -24,7 +31,7 @@ export function Mark({ size = 28 }: MarkProps) { strokeLinejoin="round" strokeLinecap="round" /> - + diff --git a/src/brand/palette.ts b/src/brand/palette.ts new file mode 100644 index 0000000..6b7fc9c --- /dev/null +++ b/src/brand/palette.ts @@ -0,0 +1,64 @@ +/** + * Brand palette — the single source of truth for the literal colour values that + * canvas-style renderers need. + * + * Most of the interface reads colour through the CSS custom properties in + * `src/styles/tokens/colors.css`, and that remains the preferred route. But two + * renderers cannot: the SVG builds gradient stops, and + * hands colours to three.js, which needs a parsed value rather than a + * `var(--solar)` string. Both used to carry their own hard-coded hexes, which + * meant the same brand colour existed in three places and could drift. + * + * These constants mirror the colour tokens exactly: + * · `solar` === `--solar` + * · `ink` === `--ink` (light theme) + * The remaining entries are the solar ramp the orb is built from. They are + * deliberately theme-independent: the Helio is a fixed brand object and renders + * the same sun after sunset as it does at noon. + */ + +/** `--solar` — the sun, the brand accent. */ +export const SOLAR = '#FFB400' + +/** `--ink` (light theme) — deep pine, used for the corona motes. */ +export const INK = '#0B2B23' + +/** Warm highlight at the centre of the orb, and the key light in the WebGL scene. */ +export const SOLAR_HIGHLIGHT = '#FFF4D6' + +/** Warm core surface tint — the second stop of the orb gradient. */ +export const SOLAR_CORE = '#FFD451' + +/** Glow halo — sits between the core tint and solar proper. */ +export const SOLAR_HALO = '#FFC633' + +/** Deep edge of the orb, where the sun falls away into its own limb. */ +export const SOLAR_DEEP = '#F59A00' + +/** + * The solar ramp, centre outwards. Consumed by the SVG orb's radial gradient so + * the stop list is derived rather than restated. + */ +export const SOLAR_RAMP = [ + { offset: '0%', color: SOLAR_HIGHLIGHT }, + { offset: '34%', color: SOLAR_CORE }, + { offset: '72%', color: SOLAR }, + { offset: '100%', color: SOLAR_DEEP }, +] as const + +/** `--solar` as RGB channels, for building the `rgba()` glow stops. */ +export const SOLAR_RGB = '255, 180, 0' + +/** Build an `rgba()` string from `--solar` at a given alpha. */ +export function solarAlpha(alpha: number): string { + return `rgba(${SOLAR_RGB}, ${alpha})` +} + +export const BRAND = { + solar: SOLAR, + ink: INK, + solarHighlight: SOLAR_HIGHLIGHT, + solarCore: SOLAR_CORE, + solarHalo: SOLAR_HALO, + solarDeep: SOLAR_DEEP, +} as const diff --git a/src/screens/creator/CreatorDashboard.tsx b/src/screens/creator/CreatorDashboard.tsx index 8c93cf1..5c47378 100644 --- a/src/screens/creator/CreatorDashboard.tsx +++ b/src/screens/creator/CreatorDashboard.tsx @@ -1,6 +1,6 @@ 'use client' -import { type CSSProperties } from 'react' +import { useId, type CSSProperties } from 'react' import { useTranslations } from 'next-intl' import { StatBlock, ScoreGauge, Badge, Card, Sparkline } from '@/components' import { @@ -21,6 +21,7 @@ export interface CreatorDashboardProps { export function CreatorDashboard({ data = CREATOR_DASHBOARD }: CreatorDashboardProps) { const t = useTranslations('Creator') + const fundingCaptionId = useId() const fundedPct = data.fundingGoal > 0 ? Math.round((data.fundingReceived / data.fundingGoal) * 100) : 0 @@ -67,6 +68,15 @@ export function CreatorDashboard({ data = CREATOR_DASHBOARD }: CreatorDashboardP />
0 && fundedPct < 100 ? '2px solid var(--ink)' : undefined, + boxSizing: 'border-box', }} />
Date: Thu, 30 Jul 2026 13:55:06 +0100 Subject: [PATCH 2/3] feat(wallet,explore): gate the money routes and load the projects grid in pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallet gate (#119) /portfolio, /deposit and /withdraw rendered for anyone. Only /connect redirected. Those three screens assume a connection — they show balances or move value — so without one they render empty or misleading, and any action on them fails. Adds RequireWallet and wraps the three routes in it. Two details make it correct rather than merely present: · It waits for rehydration. The session is restored from localStorage inside an effect, so on the first render `connected` is false even for a user who is connected. Redirecting on that render would eject a connected user to Connect on every refresh. WalletProvider now exposes `restoring`, and the guard holds until it clears. · It preserves intent. The requested path, query string included, is carried to Connect as ?next=, and Connect returns the visitor there once connected instead of dropping them at the default stop. Only same-origin absolute paths are honoured, so a crafted ?next= cannot bounce a freshly connected wallet holder off-site. The guard withholds the gated content itself rather than relying on the navigation winning the race, so balances never flash before the redirect. Explore pagination (#124) Explore rendered the entire registry in one pass — fine for the seed list, unbounded once the real registry lands. The grid now starts at 12 (whole rows at every breakpoint) and grows on demand. Switching filter restarts at the first page, since a new filter is a new list. Progress is announced politely as text, and the control retires when nothing is left rather than sitting inert. Adds 14 tests covering both, including the rehydration regression and the filter-change page reset. New strings added to en and fr; namespace key parity verified. Closes #119 Closes #124 --- messages/en.json | 4 +- messages/fr.json | 4 +- src/app/connect/page.tsx | 38 ++++++++-- src/app/deposit/page.tsx | 14 +++- src/app/portfolio/page.tsx | 16 +++- src/app/withdraw/page.tsx | 14 +++- src/screens/Explore.test.tsx | 122 ++++++++++++++++++++++++++++++ src/screens/Explore.tsx | 59 ++++++++++++++- src/wallet/RequireWallet.test.tsx | 112 +++++++++++++++++++++++++++ src/wallet/RequireWallet.tsx | 65 ++++++++++++++++ src/wallet/WalletProvider.tsx | 21 ++++- 11 files changed, 455 insertions(+), 14 deletions(-) create mode 100644 src/screens/Explore.test.tsx create mode 100644 src/wallet/RequireWallet.test.tsx create mode 100644 src/wallet/RequireWallet.tsx diff --git a/messages/en.json b/messages/en.json index ffb7600..033726b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -103,7 +103,9 @@ "cardFundedFromPool": "Funded from the pool", "cardVerifiedAgo": "verified {ago} ago", "emptyTitle": "No projects found", - "emptySub": "There are no projects matching the \"{filter}\" category in the pool right now." + "emptySub": "There are no projects matching the \"{filter}\" category in the pool right now.", + "loadMore": "Show {count} more", + "showingCount": "Showing {shown} of {total} projects" }, "Deposit": { "stepAmount": "Amount", diff --git a/messages/fr.json b/messages/fr.json index 0ba1855..6aa9d54 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -103,7 +103,9 @@ "cardFundedFromPool": "Financé par le pool", "cardVerifiedAgo": "vérifié il y a {ago}", "emptyTitle": "Aucun projet trouvé", - "emptySub": "Il n'y a aucun projet correspondant à la catégorie \"{filter}\" dans le pool en ce moment." + "emptySub": "Il n'y a aucun projet correspondant à la catégorie \"{filter}\" dans le pool en ce moment.", + "loadMore": "Afficher {count} de plus", + "showingCount": "{shown} projets sur {total} affichés" }, "Deposit": { "stepAmount": "Montant", diff --git a/src/app/connect/page.tsx b/src/app/connect/page.tsx index 53dd55c..cdda198 100644 --- a/src/app/connect/page.tsx +++ b/src/app/connect/page.tsx @@ -1,18 +1,36 @@ 'use client' -import { useEffect } from 'react' -import { useRouter } from 'next/navigation' +import { Suspense, useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' import { Connect } from '../../screens/Connect' import { useWallet } from '../../wallet/WalletProvider' -export default function ConnectPage() { +/** + * Only same-origin, absolute in-app paths are honoured as a return target. + * `next` arrives in the URL, so treating it as a bare redirect would let a + * crafted link bounce a freshly-connected wallet holder off-site. A leading + * `//` (or `/\`) is rejected because the browser reads it as protocol-relative. + */ +function safeNext(raw: string | null): string | null { + if (!raw) return null + if (!raw.startsWith('/')) return null + if (raw.startsWith('//') || raw.startsWith('/\\')) return null + return raw +} + +function ConnectRoute() { const router = useRouter() + const searchParams = useSearchParams() const { connected, connect, connectDemo } = useWallet() - // Once a wallet is connected (real modal selection or the demo path), move on. + const next = safeNext(searchParams.get('next')) + + // Once a wallet is connected (real modal selection or the demo path), move on + // — back to whatever the visitor was originally reaching for, if a guard sent + // them here, otherwise the default first stop. useEffect(() => { - if (connected) router.push('/deposit') - }, [connected, router]) + if (connected) router.replace(next ?? '/deposit') + }, [connected, router, next]) return ( ) } + +export default function ConnectPage() { + return ( + + + + ) +} diff --git a/src/app/deposit/page.tsx b/src/app/deposit/page.tsx index 6e1c706..0eef45a 100644 --- a/src/app/deposit/page.tsx +++ b/src/app/deposit/page.tsx @@ -1,9 +1,21 @@ 'use client' +import { Suspense } from 'react' import { useRouter } from 'next/navigation' import { Deposit } from '../../screens/Deposit' +import { RequireWallet } from '../../wallet/RequireWallet' -export default function DepositPage() { +function DepositRoute() { const router = useRouter() return router.push('/portfolio')} /> } + +export default function DepositPage() { + return ( + + + + + + ) +} diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx index 58fbf7a..19870c0 100644 --- a/src/app/portfolio/page.tsx +++ b/src/app/portfolio/page.tsx @@ -1,9 +1,11 @@ 'use client' +import { Suspense } from 'react' import { useRouter } from 'next/navigation' import { Portfolio } from '../../screens/Portfolio' +import { RequireWallet } from '../../wallet/RequireWallet' -export default function PortfolioPage() { +function PortfolioRoute() { const router = useRouter() return ( ) } + +export default function PortfolioPage() { + // Suspense wraps the guard because it reads useSearchParams to preserve the + // visitor's intent; without a boundary Next cannot prerender the shell. + return ( + + + + + + ) +} diff --git a/src/app/withdraw/page.tsx b/src/app/withdraw/page.tsx index e833f7f..045f397 100644 --- a/src/app/withdraw/page.tsx +++ b/src/app/withdraw/page.tsx @@ -1,11 +1,23 @@ 'use client' +import { Suspense } from 'react' import { useRouter } from 'next/navigation' import { Withdraw } from '../../screens/Withdraw' +import { RequireWallet } from '../../wallet/RequireWallet' -export default function WithdrawPage() { +function WithdrawRoute() { const router = useRouter() return ( router.push('/portfolio')} onBack={() => router.push('/portfolio')} /> ) } + +export default function WithdrawPage() { + return ( + + + + + + ) +} diff --git a/src/screens/Explore.test.tsx b/src/screens/Explore.test.tsx new file mode 100644 index 0000000..df6ea0d --- /dev/null +++ b/src/screens/Explore.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@/test/render' +import userEvent from '@testing-library/user-event' + +const mockReplace = vi.fn() +let mockSearch = '' + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mockReplace, push: vi.fn() }), + useSearchParams: () => new URLSearchParams(mockSearch), +})) + +vi.mock('@/lib/api', () => ({ getProjects: vi.fn() })) + +vi.mock('../components', async () => { + const actual = await vi.importActual('../components') + return { + ...actual, + ProjectCard: ({ name }: { name: string }) =>
{name}
, + } +}) + +import { getProjects } from '@/lib/api' +import { Explore } from './Explore' +import type { Project, ProjectType } from '../data' + +const mockGetProjects = vi.mocked(getProjects) + +/** A registry large enough to span several pages. */ +function makeProjects(count: number, type: ProjectType = 'Solar'): Project[] { + return Array.from({ length: count }, (_, i) => ({ + id: String(i + 1), + name: `Project ${i + 1}`, + location: 'Nowhere', + type, + credit: 80, + green: 80, + funded: 50, + fundingGoal: 1000, + fundedAmount: 500, + })) as unknown as Project[] +} + +const cards = () => screen.queryAllByTestId('card') + +describe('Explore — incremental loading', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSearch = '' + }) + + it('renders only the first page of a large registry', async () => { + mockGetProjects.mockResolvedValue(makeProjects(40)) + render() + // The whole point of the change: 40 projects must not all mount at once. + await waitFor(() => expect(cards().length).toBe(12)) + }) + + it('grows a page at a time when asked for more', async () => { + const user = userEvent.setup() + mockGetProjects.mockResolvedValue(makeProjects(40)) + render() + await waitFor(() => expect(cards().length).toBe(12)) + + await user.click(screen.getByRole('button', { name: /show 12 more/i })) + expect(cards().length).toBe(24) + + await user.click(screen.getByRole('button', { name: /show 12 more/i })) + expect(cards().length).toBe(36) + }) + + it('offers only the remainder on the final page, then stops', async () => { + const user = userEvent.setup() + mockGetProjects.mockResolvedValue(makeProjects(14)) + render() + await waitFor(() => expect(cards().length).toBe(12)) + + // 2 left, so the control must not promise a full page of 12. + const more = screen.getByRole('button', { name: /show 2 more/i }) + await user.click(more) + + expect(cards().length).toBe(14) + // Nothing left to load — the control retires rather than sitting there inert. + expect(screen.queryByRole('button', { name: /show .* more/i })).not.toBeInTheDocument() + }) + + it('does not show the control when everything already fits', async () => { + mockGetProjects.mockResolvedValue(makeProjects(5)) + render() + await waitFor(() => expect(cards().length).toBe(5)) + expect(screen.queryByRole('button', { name: /show .* more/i })).not.toBeInTheDocument() + }) + + it('reports progress through the list as text', async () => { + mockGetProjects.mockResolvedValue(makeProjects(40)) + render() + await waitFor(() => expect(screen.getByText(/showing 12 of 40 projects/i)).toBeInTheDocument()) + }) + + it('falls back to the bundled registry when the API fails', async () => { + mockGetProjects.mockRejectedValue(new Error('offline')) + render() + // The fallback path must still paginate rather than dumping the list. + await waitFor(() => expect(cards().length).toBeGreaterThan(0)) + expect(cards().length).toBeLessThanOrEqual(12) + }) + + it('restarts at the first page when the filter changes', async () => { + const user = userEvent.setup() + mockGetProjects.mockResolvedValue([...makeProjects(20, 'Solar'), ...makeProjects(20, 'Wind')]) + render() + await waitFor(() => expect(cards().length).toBe(12)) + + await user.click(screen.getByRole('button', { name: /show 12 more/i })) + expect(cards().length).toBe(24) + + // Switching filter yields a different list; carrying the old depth over + // would reveal more of the new list than a first page should. + await user.click(screen.getByRole('button', { name: 'Wind' })) + await waitFor(() => expect(cards().length).toBe(12)) + }) +}) diff --git a/src/screens/Explore.tsx b/src/screens/Explore.tsx index 2424462..d38c79b 100644 --- a/src/screens/Explore.tsx +++ b/src/screens/Explore.tsx @@ -17,6 +17,13 @@ export interface ExploreProps { const TYPES: (ProjectType | 'All')[] = ['All', 'Solar', 'Wind', 'Hydro'] +/** + * Projects rendered per page. Twelve fills the widest grid with whole rows at + * every breakpoint (the grid runs 1–4 columns), so a page boundary never leaves + * a ragged half-row. + */ +const PAGE_SIZE = 12 + export function Explore({ onOpen }: ExploreProps) { const t = useTranslations('Explore') const router = useRouter() @@ -28,6 +35,10 @@ export function Explore({ onOpen }: ExploreProps) { const [filter, setFilter] = useState( urlType && ['Solar', 'Wind', 'Hydro'].includes(urlType) ? urlType : 'All', ) + // Explore used to render the entire registry in one pass. That is fine for a + // seed list and unbounded once the real registry lands, so the grid now grows + // a page at a time instead. + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) useEffect(() => { getProjects() @@ -43,6 +54,9 @@ export function Explore({ onOpen }: ExploreProps) { const setFilterAndUrl = (next: ProjectType | 'All') => { setFilter(next) + // A new filter is a new list — start it at the first page rather than + // carrying the previous filter's scroll depth across. + setVisibleCount(PAGE_SIZE) if (next === 'All') { router.replace('/explore', { scroll: false }) } else { @@ -51,6 +65,8 @@ export function Explore({ onOpen }: ExploreProps) { } const shown = filter === 'All' ? projects : projects.filter((p) => p.type === filter) + const visible = shown.slice(0, visibleCount) + const remaining = shown.length - visible.length return (
@@ -171,7 +187,7 @@ export function Explore({ onOpen }: ExploreProps) {
{loading ? Array.from({ length: 6 }, (_, i) => ) - : shown.map((p) => ( + : visible.map((p) => ( )} + {!loading && remaining > 0 && ( +
+ {/* Progress through the list, announced politely so a screen-reader + user learns the grid grew without the update stealing focus. */} +

+ {t('showingCount', { shown: visible.length, total: shown.length })} +

+ +
+ )}
) } diff --git a/src/wallet/RequireWallet.test.tsx b/src/wallet/RequireWallet.test.tsx new file mode 100644 index 0000000..95c6fe5 --- /dev/null +++ b/src/wallet/RequireWallet.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@/test/render' + +const mockReplace = vi.fn() +let mockPathname = '/portfolio' +let mockSearch = '' + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mockReplace, push: vi.fn() }), + usePathname: () => mockPathname, + useSearchParams: () => new URLSearchParams(mockSearch), +})) + +let walletState = { connected: false, restoring: false } + +vi.mock('./WalletProvider', () => ({ + useWallet: () => walletState, +})) + +import { RequireWallet } from './RequireWallet' + +const Protected = () =>
balances
+ +describe('RequireWallet', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPathname = '/portfolio' + mockSearch = '' + walletState = { connected: false, restoring: false } + }) + + it('renders the gated content for a connected wallet', () => { + walletState = { connected: true, restoring: false } + render( + + + , + ) + expect(screen.getByTestId('protected')).toBeInTheDocument() + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('redirects an unconnected visitor to Connect', () => { + render( + + + , + ) + expect(mockReplace).toHaveBeenCalledWith('/connect?next=%2Fportfolio') + }) + + it('never renders the gated content to an unconnected visitor', () => { + render( + + + , + ) + // The redirect is asynchronous from the DOM's point of view, so the guard + // must withhold the content itself rather than rely on the navigation + // winning the race. Otherwise balances flash before the redirect lands. + expect(screen.queryByTestId('protected')).not.toBeInTheDocument() + }) + + /** + * The regression that motivated the `restoring` flag: the wallet session is + * read back from localStorage inside an effect, so a genuinely connected user + * looks disconnected on the first render. Redirecting then would eject them + * from the money routes on every page refresh. + */ + it('waits for the session to rehydrate before deciding', () => { + walletState = { connected: false, restoring: true } + render( + + + , + ) + expect(mockReplace).not.toHaveBeenCalled() + expect(screen.queryByTestId('protected')).not.toBeInTheDocument() + }) + + it('preserves the visitor’s intent, query string included', () => { + mockPathname = '/withdraw' + mockSearch = 'amount=250' + render( + + + , + ) + expect(mockReplace).toHaveBeenCalledWith( + `/connect?next=${encodeURIComponent('/withdraw?amount=250')}`, + ) + }) + + it('honours a custom redirect target', () => { + render( + + + , + ) + expect(mockReplace).toHaveBeenCalledWith('/?next=%2Fportfolio') + }) + + it('shows the fallback while the decision is pending', () => { + walletState = { connected: false, restoring: true } + render( +
}> + + , + ) + expect(screen.getByTestId('pending')).toBeInTheDocument() + }) +}) diff --git a/src/wallet/RequireWallet.tsx b/src/wallet/RequireWallet.tsx new file mode 100644 index 0000000..f699944 --- /dev/null +++ b/src/wallet/RequireWallet.tsx @@ -0,0 +1,65 @@ +'use client' + +import { useEffect, type ReactNode } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useWallet } from './WalletProvider' + +/** + * RequireWallet — the gate in front of the money routes. + * + * `/portfolio`, `/deposit` and `/withdraw` all assume a connection: they show + * balances, or move value. Rendering them for an unconnected visitor produces a + * screen that is either empty or misleading, and any action on it fails. Only + * `/connect` redirected; these did not. + * + * Two details make this correct rather than merely present: + * + * 1. **It waits for rehydration.** The wallet session is restored from + * localStorage inside an effect, so on the first render `connected` is + * false even for a user who is connected. Redirecting on that first render + * would throw a connected user out to Connect on every page refresh. The + * guard holds while `restoring` is true and only then decides. + * + * 2. **It preserves intent.** Where the visitor was going is carried to + * Connect as `?next=`, so finishing the connection returns them to the page + * they asked for instead of the default landing spot. + * + * `router.replace` — not `push` — so Back does not bounce the user between the + * gated route and Connect. + */ +export interface RequireWalletProps { + children: ReactNode + /** Where to send an unconnected visitor. */ + redirectTo?: string + /** Rendered while the session rehydrates or the redirect is in flight. */ + fallback?: ReactNode +} + +export function RequireWallet({ + children, + redirectTo = '/connect', + fallback = null, +}: RequireWalletProps) { + const { connected, restoring } = useWallet() + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + // The full path the visitor asked for, query string included, so intent + // survives the detour through Connect. + const query = searchParams.toString() + const intent = query ? `${pathname}?${query}` : pathname + + const shouldRedirect = !restoring && !connected + + useEffect(() => { + if (!shouldRedirect) return + router.replace(`${redirectTo}?next=${encodeURIComponent(intent)}`) + }, [shouldRedirect, router, redirectTo, intent]) + + // Hold the gated content while we do not yet know, and while the redirect is + // in flight, so it never flashes to someone who is not entitled to it. + if (restoring || !connected) return <>{fallback} + + return <>{children} +} diff --git a/src/wallet/WalletProvider.tsx b/src/wallet/WalletProvider.tsx index 67beb0b..d4ac035 100644 --- a/src/wallet/WalletProvider.tsx +++ b/src/wallet/WalletProvider.tsx @@ -25,6 +25,15 @@ interface WalletContextValue { connected: boolean connecting: boolean isDemo: boolean + /** + * True until the persisted session has been read back from localStorage. + * + * The restore happens in an effect, so on the very first render `address` is + * null even for a user who *is* connected. Anything that reacts to the + * absence of a connection — route guards especially — must wait for this to + * go false, or it will act on a connected user mid-rehydration. + */ + restoring: boolean connect: () => Promise connectDemo: () => void disconnect: () => void @@ -55,6 +64,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { const [address, setAddress] = useState(null) const [connecting, setConnecting] = useState(false) const [isDemo, setIsDemo] = useState(false) + const [restoring, setRestoring] = useState(true) const persist = useCallback((addr: string, walletId: string) => { try { @@ -83,10 +93,16 @@ export function WalletProvider({ children }: { children: ReactNode }) { } catch { /* ignore */ } - if (!saved) return - // eslint-disable-next-line react-hooks/set-state-in-effect + if (!saved) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRestoring(false) + return + } + // The disable above covers this effect's set-state calls; the restore is + // intentionally a post-mount read of localStorage. setAddress(saved) setIsDemo(savedWallet === 'demo') + setRestoring(false) // Re-select the real wallet module in the kit so signing works after a // reload (the demo session needs nothing). @@ -169,6 +185,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { connected: address !== null, connecting, isDemo, + restoring, connect, connectDemo, disconnect, From 1a2bc331f2cb47970e871472ff50742bd7253e60 Mon Sep 17 00:00:00 2001 From: wheval Date: Thu, 30 Jul 2026 21:56:36 +0100 Subject: [PATCH 3/3] style: apply Prettier to five files that fail format:check on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prettier --check .` fails on main for five files this branch does not otherwise touch: FormField.tsx, components/index.ts, OracleForms.tsx, CreatorApplication.tsx and Portfolio.tsx. `format:check` runs in the same `build` job that gates every pull request, so that job is red on main regardless of what a contributor changes — including this branch, whose own files are already clean. This is `prettier --write` output only: line wrapping, no semantic change. Kept as its own commit so it can be dropped or split out if a maintainer would rather fix it separately. --- src/components/FormField.tsx | 15 +++++++++++++-- src/components/index.ts | 7 ++++++- src/screens/Portfolio.tsx | 15 +++++++++++++-- src/screens/admin/OracleForms.tsx | 6 +----- src/screens/creator/CreatorApplication.tsx | 10 +++++++++- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index 115b70d..a2f0f03 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -1,4 +1,10 @@ -import type { CSSProperties, InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react' +import type { + CSSProperties, + InputHTMLAttributes, + ReactNode, + SelectHTMLAttributes, + TextareaHTMLAttributes, +} from 'react' export interface FormFieldProps { label: string @@ -29,7 +35,12 @@ export interface FormTextareaProps extends TextareaHTMLAttributes + return ( +