From 8ac6e492aa4703e0d562661073d4faa32c5f0537 Mon Sep 17 00:00:00 2001 From: Precious Igwealor Date: Sat, 1 Aug 2026 01:28:46 +0100 Subject: [PATCH 1/4] feat(mobile): build compact trustless escrow widget for mobile Adds MobileEscrowWidget, the compact mobile version of the escrow status widget. Renders the ESCROW LOCKED status pill and the locked amount (42,500 USDC), with supporting metrics (signature count, network, release ETA) stacked as individual blocks below the main widget so it reads cleanly on narrow viewports with no horizontal scrolling. Follows the Component -> Hook -> Service layered pattern used elsewhere in the codebase (see useKineticExplorer/kineticExplorerService for the established shape): the component only calls useMobileEscrowSummary, which calls mobileEscrowService, which fetches from a new /api/mobile/escrow-summary Next.js route rather than using inline mock data in the component. --- app/api/mobile/escrow-summary/route.ts | 22 +++++ components/mobile/MobileEscrowWidget.tsx | 84 +++++++++++++++++++ .../__tests__/MobileEscrowWidget.test.tsx | 84 +++++++++++++++++++ hooks/useMobileEscrowSummary.ts | 69 +++++++++++++++ services/mobileEscrowService.ts | 18 ++++ types/mobileEscrow.ts | 15 ++++ 6 files changed, 292 insertions(+) create mode 100644 app/api/mobile/escrow-summary/route.ts create mode 100644 components/mobile/MobileEscrowWidget.tsx create mode 100644 components/mobile/__tests__/MobileEscrowWidget.test.tsx create mode 100644 hooks/useMobileEscrowSummary.ts create mode 100644 services/mobileEscrowService.ts create mode 100644 types/mobileEscrow.ts diff --git a/app/api/mobile/escrow-summary/route.ts b/app/api/mobile/escrow-summary/route.ts new file mode 100644 index 0000000..be63dbd --- /dev/null +++ b/app/api/mobile/escrow-summary/route.ts @@ -0,0 +1,22 @@ +// Local content endpoint for the mobile Trustless Escrow Widget. +// Serves the exact `MobileEscrowSummary` contract the platform backend will +// expose, so the client layers (service -> hook -> component) talk to a +// real HTTP endpoint. Point NEXT_PUBLIC_API_URL at the backend to override it. +import { NextResponse } from 'next/server'; +import type { MobileEscrowSummary } from '@/types/mobileEscrow'; + +const payload: MobileEscrowSummary = { + status: 'locked', + statusLabel: 'ESCROW LOCKED', + amount: 42500, + currency: 'USDC', + metrics: [ + { id: 'metric-signatures', label: 'Signatures', value: '2 / 3' }, + { id: 'metric-network', label: 'Network', value: 'Stellar Mainnet' }, + { id: 'metric-eta', label: 'Release ETA', value: '~4s after confirmation' }, + ], +}; + +export async function GET() { + return NextResponse.json(payload); +} diff --git a/components/mobile/MobileEscrowWidget.tsx b/components/mobile/MobileEscrowWidget.tsx new file mode 100644 index 0000000..868beb3 --- /dev/null +++ b/components/mobile/MobileEscrowWidget.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useMobileEscrowSummary } from '@/hooks/useMobileEscrowSummary'; +import type { EscrowStatus } from '@/types/mobileEscrow'; + +const STATUS_STYLES: Record = { + locked: 'bg-blue-500/15 text-blue-400', + released: 'bg-emerald-500/15 text-emerald-400', + disputed: 'bg-amber-500/15 text-amber-400', +}; + +function formatAmount(amount: number, currency: string): string { + return `${amount.toLocaleString('en-US')} ${currency}`; +} + +/** + * MobileEscrowWidget — compact mobile version of the Trustless Escrow + * Widget. Shows the escrow's current status and locked amount, with a + * vertical stack of supporting metrics underneath so it reads cleanly on + * narrow viewports without horizontal scrolling. + */ +export function MobileEscrowWidget() { + const { summary, isLoading, error } = useMobileEscrowSummary(); + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (isLoading || !summary) { + return ( +
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+
+ ); + } + + return ( +
+ + {summary.statusLabel} + + +

+ {formatAmount(summary.amount, summary.currency)} +

+ + {/* Metric blocks stacked below the main widget */} +
+ {summary.metrics.map((metric) => ( +
+ {metric.label} + {metric.value} +
+ ))} +
+
+ ); +} + +export default MobileEscrowWidget; diff --git a/components/mobile/__tests__/MobileEscrowWidget.test.tsx b/components/mobile/__tests__/MobileEscrowWidget.test.tsx new file mode 100644 index 0000000..5d662b6 --- /dev/null +++ b/components/mobile/__tests__/MobileEscrowWidget.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react'; +import { MobileEscrowWidget } from '@/components/mobile/MobileEscrowWidget'; +import { useMobileEscrowSummary } from '@/hooks/useMobileEscrowSummary'; + +jest.mock('@/hooks/useMobileEscrowSummary'); +const mockUseMobileEscrowSummary = useMobileEscrowSummary as jest.Mock; + +describe('MobileEscrowWidget', () => { + it('renders the escrow status and locked amount once loaded', () => { + mockUseMobileEscrowSummary.mockReturnValue({ + summary: { + status: 'locked', + statusLabel: 'ESCROW LOCKED', + amount: 42500, + currency: 'USDC', + metrics: [ + { id: 'metric-signatures', label: 'Signatures', value: '2 / 3' }, + ], + }, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByText('ESCROW LOCKED')).toBeInTheDocument(); + expect(screen.getByText('42,500 USDC')).toBeInTheDocument(); + expect(screen.getByText('Signatures')).toBeInTheDocument(); + expect(screen.getByText('2 / 3')).toBeInTheDocument(); + }); + + it('stacks each metric block below the main widget', () => { + mockUseMobileEscrowSummary.mockReturnValue({ + summary: { + status: 'locked', + statusLabel: 'ESCROW LOCKED', + amount: 42500, + currency: 'USDC', + metrics: [ + { id: 'metric-signatures', label: 'Signatures', value: '2 / 3' }, + { id: 'metric-network', label: 'Network', value: 'Stellar Mainnet' }, + { id: 'metric-eta', label: 'Release ETA', value: '~4s' }, + ], + }, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByText('Network')).toBeInTheDocument(); + expect(screen.getByText('Stellar Mainnet')).toBeInTheDocument(); + expect(screen.getByText('Release ETA')).toBeInTheDocument(); + }); + + it('shows a loading skeleton while data is being fetched', () => { + mockUseMobileEscrowSummary.mockReturnValue({ + summary: null, + isLoading: true, + error: null, + refetch: jest.fn(), + }); + + const { container } = render(); + + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + expect(screen.queryByText('ESCROW LOCKED')).not.toBeInTheDocument(); + }); + + it('renders an error message when the fetch fails', () => { + mockUseMobileEscrowSummary.mockReturnValue({ + summary: null, + isLoading: false, + error: 'Failed to load escrow status', + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByText('Failed to load escrow status')).toBeInTheDocument(); + }); +}); diff --git a/hooks/useMobileEscrowSummary.ts b/hooks/useMobileEscrowSummary.ts new file mode 100644 index 0000000..723878f --- /dev/null +++ b/hooks/useMobileEscrowSummary.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from 'react'; +import axios from 'axios'; +import { mobileEscrowService } from '@/services/mobileEscrowService'; +import type { MobileEscrowSummary } from '@/types/mobileEscrow'; + +interface UseMobileEscrowSummaryResult { + summary: MobileEscrowSummary | null; + isLoading: boolean; + error: string | null; + refetch: () => Promise; +} + +/** + * useMobileEscrowSummary — single source for the mobile Trustless Escrow + * Widget's status and metrics. + * + * Components consume this hook; they never call mobileEscrowService + * directly. Aborts in-flight requests on unmount or refetch to avoid + * setting state on an unmounted component. + */ +export function useMobileEscrowSummary(): UseMobileEscrowSummaryResult { + const [summary, setSummary] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadTick, setReloadTick] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + mobileEscrowService + .getSummary(controller.signal) + .then((data) => { + if (cancelled) return; + setSummary(data); + setError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (axios.isCancel(err)) return; + const message = + err instanceof Error && err.message + ? err.message + : 'Failed to load escrow status'; + setError(message); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [reloadTick]); + + const refetch = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + setReloadTick((tick) => tick + 1); + }, []); + + return { + summary, + isLoading, + error, + refetch, + }; +} diff --git a/services/mobileEscrowService.ts b/services/mobileEscrowService.ts new file mode 100644 index 0000000..e14ace1 --- /dev/null +++ b/services/mobileEscrowService.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; +import type { MobileEscrowSummary } from '@/types/mobileEscrow'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +/** + * mobileEscrowService — all Mobile Escrow Widget API communication. + * Hooks call this; components never call this directly. + */ +export const mobileEscrowService = { + async getSummary(signal?: AbortSignal): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/api/mobile/escrow-summary`, + { signal }, + ); + return data; + }, +}; diff --git a/types/mobileEscrow.ts b/types/mobileEscrow.ts new file mode 100644 index 0000000..04de6df --- /dev/null +++ b/types/mobileEscrow.ts @@ -0,0 +1,15 @@ +export type EscrowStatus = 'locked' | 'released' | 'disputed'; + +export interface EscrowMetric { + id: string; + label: string; + value: string; +} + +export interface MobileEscrowSummary { + status: EscrowStatus; + statusLabel: string; + amount: number; + currency: string; + metrics: EscrowMetric[]; +} From 3b807afbe5d7ab5f9cf614cdf431576a531fa0b6 Mon Sep 17 00:00:00 2001 From: Precious Igwealor Date: Sat, 1 Aug 2026 01:28:59 +0100 Subject: [PATCH 2/4] feat(mobile): implement touch-optimized mobile hero and nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MobileHero: touch-optimized top navigation with a hamburger menu and the mobile hero section. The hamburger opens a full-screen overlay (useMobileNavOverlay) that traps Tab/Shift+Tab focus inside itself, disables body scroll while open, restores focus to the trigger button on close, and closes on Escape. Renders the MAINNET V4.0 ACTIVE badge and the primary headline/CTAs fetched via useMobileHeroContent. Every interactive control (menu toggle, nav links, both CTAs) uses a 44px minimum touch target. Follows the Component -> Hook -> Service pattern (types/mobileHero.ts, app/api/mobile/hero-content/route.ts, mobileHeroService, useMobileHeroContent) — no inline mock data in the component. Nav link labels stay static config, matching the same convention the existing desktop HeroSection uses for its NAV_LINKS. --- app/api/mobile/hero-content/route.ts | 19 +++ components/mobile/MobileHero.tsx | 156 ++++++++++++++++++ .../mobile/__tests__/MobileHero.test.tsx | 119 +++++++++++++ hooks/useMobileHeroContent.ts | 69 ++++++++ hooks/useMobileNavOverlay.ts | 56 +++++++ services/mobileHeroService.ts | 18 ++ types/mobileHero.ts | 7 + 7 files changed, 444 insertions(+) create mode 100644 app/api/mobile/hero-content/route.ts create mode 100644 components/mobile/MobileHero.tsx create mode 100644 components/mobile/__tests__/MobileHero.test.tsx create mode 100644 hooks/useMobileHeroContent.ts create mode 100644 hooks/useMobileNavOverlay.ts create mode 100644 services/mobileHeroService.ts create mode 100644 types/mobileHero.ts diff --git a/app/api/mobile/hero-content/route.ts b/app/api/mobile/hero-content/route.ts new file mode 100644 index 0000000..cd08b98 --- /dev/null +++ b/app/api/mobile/hero-content/route.ts @@ -0,0 +1,19 @@ +// Local content endpoint for the mobile hero section. +// Serves the exact `MobileHeroContent` contract the platform backend will +// expose, so the client layers (service -> hook -> component) talk to a +// real HTTP endpoint. Point NEXT_PUBLIC_API_URL at the backend to override it. +import { NextResponse } from 'next/server'; +import type { MobileHeroContent } from '@/types/mobileHero'; + +const payload: MobileHeroContent = { + networkBadge: 'MAINNET V4.0 ACTIVE', + headline: 'Deliver Anything. Pay Only When It Arrives.', + subheadline: + 'SwiftChain protects your deliveries using blockchain escrow — funds stay locked until delivery is confirmed.', + primaryCta: { label: 'Secure Your Shipment', href: '/dashboard' }, + secondaryCta: { label: 'See How It Works', href: '#value-props' }, +}; + +export async function GET() { + return NextResponse.json(payload); +} diff --git a/components/mobile/MobileHero.tsx b/components/mobile/MobileHero.tsx new file mode 100644 index 0000000..f034f3a --- /dev/null +++ b/components/mobile/MobileHero.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Menu, X } from 'lucide-react'; +import { useMobileHeroContent } from '@/hooks/useMobileHeroContent'; +import { useMobileNavOverlay } from '@/hooks/useMobileNavOverlay'; + +const NAV_LINKS = ['Product', 'Fleet', 'Pricing', 'Docs'] as const; + +function MobileNavOverlay({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) { + const overlayRef = useRef(null); + useMobileNavOverlay(overlayRef, isOpen); + + return ( + + {isOpen && ( + { + if (event.key === 'Escape') onClose(); + }} + > +
+ + SwiftChain + + +
+ + + +
+ +
+
+ )} +
+ ); +} + +/** + * MobileHero — touch-optimized mobile top navigation and hero section. + * The hamburger menu opens a full-screen overlay that traps focus and + * disables body scroll while active (see useMobileNavOverlay). All CTAs + * and the nav toggle meet the 44px minimum touch target. + */ +export function MobileHero() { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { content, isLoading, error } = useMobileHeroContent(); + + return ( +
+ + + setIsMenuOpen(false)} /> + +
+ {error ? ( +

+ {error} +

+ ) : isLoading || !content ? ( +
+
+
+
+
+ ) : ( + <> + + {content.networkBadge} + + +

+ {content.headline} +

+ +

+ {content.subheadline} +

+ + + + )} +
+
+ ); +} + +export default MobileHero; diff --git a/components/mobile/__tests__/MobileHero.test.tsx b/components/mobile/__tests__/MobileHero.test.tsx new file mode 100644 index 0000000..cc4ea8b --- /dev/null +++ b/components/mobile/__tests__/MobileHero.test.tsx @@ -0,0 +1,119 @@ +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MobileHero } from '@/components/mobile/MobileHero'; +import { useMobileHeroContent } from '@/hooks/useMobileHeroContent'; + +jest.mock('@/hooks/useMobileHeroContent'); +const mockUseMobileHeroContent = useMobileHeroContent as jest.Mock; + +const baseContent = { + networkBadge: 'MAINNET V4.0 ACTIVE', + headline: 'Deliver Anything. Pay Only When It Arrives.', + subheadline: 'SwiftChain protects your deliveries using blockchain escrow.', + primaryCta: { label: 'Secure Your Shipment', href: '/dashboard' }, + secondaryCta: { label: 'See How It Works', href: '#value-props' }, +}; + +describe('MobileHero', () => { + it('renders the network badge and headline once loaded', () => { + mockUseMobileHeroContent.mockReturnValue({ + content: baseContent, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByText('MAINNET V4.0 ACTIVE')).toBeInTheDocument(); + expect( + screen.getByText('Deliver Anything. Pay Only When It Arrives.'), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Secure Your Shipment' }), + ).toBeInTheDocument(); + }); + + it('shows a loading skeleton while data is being fetched', () => { + mockUseMobileHeroContent.mockReturnValue({ + content: null, + isLoading: true, + error: null, + refetch: jest.fn(), + }); + + const { container } = render(); + + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + expect(screen.queryByText('MAINNET V4.0 ACTIVE')).not.toBeInTheDocument(); + }); + + it('renders an error message when the fetch fails', () => { + mockUseMobileHeroContent.mockReturnValue({ + content: null, + isLoading: false, + error: 'Failed to load hero content', + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByText('Failed to load hero content')).toBeInTheDocument(); + }); + + it('opens the hamburger menu overlay and closes it via the close button', async () => { + mockUseMobileHeroContent.mockReturnValue({ + content: baseContent, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + const user = userEvent.setup(); + render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Open menu' })); + expect(screen.getByRole('dialog', { name: 'Mobile navigation' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close menu' })); + await waitForElementToBeRemoved(() => screen.queryByRole('dialog')); + }); + + it('disables body scroll while the menu overlay is open', async () => { + mockUseMobileHeroContent.mockReturnValue({ + content: baseContent, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open menu' })); + expect(document.body.style.overflow).toBe('hidden'); + + await user.click(screen.getByRole('button', { name: 'Close menu' })); + expect(document.body.style.overflow).toBe(''); + }); + + it('gives the menu toggle and CTAs at least a 44px touch target', () => { + mockUseMobileHeroContent.mockReturnValue({ + content: baseContent, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + const menuButton = screen.getByRole('button', { name: 'Open menu' }); + expect(menuButton.className).toContain('h-11'); + expect(menuButton.className).toContain('w-11'); + + const primaryCta = screen.getByRole('link', { name: 'Secure Your Shipment' }); + expect(primaryCta.className).toContain('min-h-[44px]'); + }); +}); diff --git a/hooks/useMobileHeroContent.ts b/hooks/useMobileHeroContent.ts new file mode 100644 index 0000000..57c817c --- /dev/null +++ b/hooks/useMobileHeroContent.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from 'react'; +import axios from 'axios'; +import { mobileHeroService } from '@/services/mobileHeroService'; +import type { MobileHeroContent } from '@/types/mobileHero'; + +interface UseMobileHeroContentResult { + content: MobileHeroContent | null; + isLoading: boolean; + error: string | null; + refetch: () => Promise; +} + +/** + * useMobileHeroContent — single source for the mobile hero's network + * badge, headline and CTAs. + * + * Components consume this hook; they never call mobileHeroService + * directly. Aborts in-flight requests on unmount or refetch to avoid + * setting state on an unmounted component. + */ +export function useMobileHeroContent(): UseMobileHeroContentResult { + const [content, setContent] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadTick, setReloadTick] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + mobileHeroService + .getContent(controller.signal) + .then((data) => { + if (cancelled) return; + setContent(data); + setError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (axios.isCancel(err)) return; + const message = + err instanceof Error && err.message + ? err.message + : 'Failed to load hero content'; + setError(message); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [reloadTick]); + + const refetch = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + setReloadTick((tick) => tick + 1); + }, []); + + return { + content, + isLoading, + error, + refetch, + }; +} diff --git a/hooks/useMobileNavOverlay.ts b/hooks/useMobileNavOverlay.ts new file mode 100644 index 0000000..e0c2ea0 --- /dev/null +++ b/hooks/useMobileNavOverlay.ts @@ -0,0 +1,56 @@ +import { useEffect, type RefObject } from 'react'; + +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +/** + * useMobileNavOverlay — focus trap + body scroll lock for the mobile + * hamburger menu overlay. While `isOpen`, Tab/Shift+Tab cycle only through + * focusable elements inside `overlayRef`, and the page behind the overlay + * cannot scroll. Both are undone automatically when the overlay closes or + * the component unmounts. + */ +export function useMobileNavOverlay( + overlayRef: RefObject, + isOpen: boolean, +): void { + useEffect(() => { + if (!isOpen) return; + + document.body.style.overflow = 'hidden'; + + const overlay = overlayRef.current; + const previouslyFocused = document.activeElement as HTMLElement | null; + let firstFocusable: HTMLElement | null = null; + let lastFocusable: HTMLElement | null = null; + + if (overlay) { + const focusable = overlay.querySelectorAll(FOCUSABLE_SELECTOR); + firstFocusable = focusable[0] ?? null; + lastFocusable = focusable[focusable.length - 1] ?? null; + firstFocusable?.focus(); + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || !firstFocusable || !lastFocusable) return; + + if (event.shiftKey) { + if (document.activeElement === firstFocusable) { + event.preventDefault(); + lastFocusable.focus(); + } + } else if (document.activeElement === lastFocusable) { + event.preventDefault(); + firstFocusable.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.body.style.overflow = ''; + document.removeEventListener('keydown', handleKeyDown); + previouslyFocused?.focus(); + }; + }, [overlayRef, isOpen]); +} diff --git a/services/mobileHeroService.ts b/services/mobileHeroService.ts new file mode 100644 index 0000000..aba356e --- /dev/null +++ b/services/mobileHeroService.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; +import type { MobileHeroContent } from '@/types/mobileHero'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +/** + * mobileHeroService — all Mobile Hero content API communication. + * Hooks call this; components never call this directly. + */ +export const mobileHeroService = { + async getContent(signal?: AbortSignal): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/api/mobile/hero-content`, + { signal }, + ); + return data; + }, +}; diff --git a/types/mobileHero.ts b/types/mobileHero.ts new file mode 100644 index 0000000..4608c95 --- /dev/null +++ b/types/mobileHero.ts @@ -0,0 +1,7 @@ +export interface MobileHeroContent { + networkBadge: string; + headline: string; + subheadline: string; + primaryCta: { label: string; href: string }; + secondaryCta: { label: string; href: string }; +} From 387f744e9bd51caf63dac67be62970cb0335a459 Mon Sep 17 00:00:00 2001 From: Precious Igwealor Date: Sat, 1 Aug 2026 01:29:12 +0100 Subject: [PATCH 3/4] feat(mobile): assemble vertically stacked kinetic feature cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MobileFeatures: the vertically stacked Spatial Anchoring / Smart Contracts / Immutable Audit cards for mobile. Card padding (p-5) is kept tight for narrow screens, and body text uses text-gray-300 against the #0b0f19 card background — a 13:1 contrast ratio, comfortably clearing the WCAG AA 4.5:1 threshold for normal text. Follows the Component -> Hook -> Service pattern (types/mobileFeatures.ts, app/api/mobile/features/route.ts, mobileFeaturesService, useMobileFeatures) — no inline mock data in the component. --- app/api/mobile/features/route.ts | 34 ++++++ components/mobile/MobileFeatures.tsx | 55 ++++++++++ .../mobile/__tests__/MobileFeatures.test.tsx | 102 ++++++++++++++++++ hooks/useMobileFeatures.ts | 69 ++++++++++++ services/mobileFeaturesService.ts | 18 ++++ types/mobileFeatures.ts | 6 ++ 6 files changed, 284 insertions(+) create mode 100644 app/api/mobile/features/route.ts create mode 100644 components/mobile/MobileFeatures.tsx create mode 100644 components/mobile/__tests__/MobileFeatures.test.tsx create mode 100644 hooks/useMobileFeatures.ts create mode 100644 services/mobileFeaturesService.ts create mode 100644 types/mobileFeatures.ts diff --git a/app/api/mobile/features/route.ts b/app/api/mobile/features/route.ts new file mode 100644 index 0000000..84c499c --- /dev/null +++ b/app/api/mobile/features/route.ts @@ -0,0 +1,34 @@ +// Local content endpoint for the mobile Kinetic Features Stack. +// Serves the exact `MobileFeatureCard[]` contract the platform backend will +// expose, so the client layers (service -> hook -> component) talk to a +// real HTTP endpoint. Point NEXT_PUBLIC_API_URL at the backend to override it. +import { NextResponse } from 'next/server'; +import type { MobileFeatureCard } from '@/types/mobileFeatures'; + +const payload: MobileFeatureCard[] = [ + { + id: 'spatial-anchoring', + icon: '📍', + title: 'Spatial Anchoring', + description: + 'Every shipment is pinned to a live location trail, so pickup, transit and delivery are all verifiable against real coordinates.', + }, + { + id: 'smart-contracts', + icon: '📜', + title: 'Smart Contracts', + description: + 'Escrow terms are enforced by code, not a promise — funds release automatically the moment delivery conditions are met.', + }, + { + id: 'immutable-audit', + icon: '🔒', + title: 'Immutable Audit', + description: + 'Every status change and payout is written to the ledger permanently, giving disputes a record no one can quietly edit.', + }, +]; + +export async function GET() { + return NextResponse.json(payload); +} diff --git a/components/mobile/MobileFeatures.tsx b/components/mobile/MobileFeatures.tsx new file mode 100644 index 0000000..7f98532 --- /dev/null +++ b/components/mobile/MobileFeatures.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useMobileFeatures } from '@/hooks/useMobileFeatures'; + +/** + * MobileFeatures — vertically stacked feature cards for mobile (Spatial + * Anchoring, Smart Contracts, Immutable Audit). Card text uses + * text-gray-100/text-gray-300 against the dark card background to keep + * contrast ratios at WCAG AA, and padding is kept tight so cards read well + * on narrow screens without overflowing. + */ +export function MobileFeatures() { + const { features, isLoading, error } = useMobileFeatures(); + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {isLoading && + Array.from({ length: 3 }).map((_, index) => ( +
+ ))} + + {!isLoading && + features.map((feature) => ( +
+ +

{feature.title}

+

+ {feature.description} +

+
+ ))} +
+ ); +} + +export default MobileFeatures; diff --git a/components/mobile/__tests__/MobileFeatures.test.tsx b/components/mobile/__tests__/MobileFeatures.test.tsx new file mode 100644 index 0000000..1baf0b9 --- /dev/null +++ b/components/mobile/__tests__/MobileFeatures.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from '@testing-library/react'; +import { MobileFeatures } from '@/components/mobile/MobileFeatures'; +import { useMobileFeatures } from '@/hooks/useMobileFeatures'; + +jest.mock('@/hooks/useMobileFeatures'); +const mockUseMobileFeatures = useMobileFeatures as jest.Mock; + +const baseFeatures = [ + { + id: 'spatial-anchoring', + icon: '📍', + title: 'Spatial Anchoring', + description: 'Every shipment is pinned to a live location trail.', + }, + { + id: 'smart-contracts', + icon: '📜', + title: 'Smart Contracts', + description: 'Escrow terms are enforced by code.', + }, + { + id: 'immutable-audit', + icon: '🔒', + title: 'Immutable Audit', + description: 'Every status change is written to the ledger permanently.', + }, +]; + +describe('MobileFeatures', () => { + it('renders all three feature cards once loaded', () => { + mockUseMobileFeatures.mockReturnValue({ + features: baseFeatures, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByText('Spatial Anchoring')).toBeInTheDocument(); + expect(screen.getByText('Smart Contracts')).toBeInTheDocument(); + expect(screen.getByText('Immutable Audit')).toBeInTheDocument(); + }); + + it('stacks cards vertically', () => { + mockUseMobileFeatures.mockReturnValue({ + features: baseFeatures, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + const { container } = render(); + const wrapper = container.firstElementChild; + + expect(wrapper?.className).toContain('flex-col'); + }); + + it('uses high-contrast text classes against the dark card background', () => { + mockUseMobileFeatures.mockReturnValue({ + features: baseFeatures, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + const title = screen.getByText('Spatial Anchoring'); + const description = screen.getByText('Every shipment is pinned to a live location trail.'); + + expect(title.className).toContain('text-white'); + expect(description.className).toContain('text-gray-300'); + }); + + it('shows a loading skeleton while data is being fetched', () => { + mockUseMobileFeatures.mockReturnValue({ + features: [], + isLoading: true, + error: null, + refetch: jest.fn(), + }); + + const { container } = render(); + + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + expect(screen.queryByText('Spatial Anchoring')).not.toBeInTheDocument(); + }); + + it('renders an error message when the fetch fails', () => { + mockUseMobileFeatures.mockReturnValue({ + features: [], + isLoading: false, + error: 'Failed to load features', + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByText('Failed to load features')).toBeInTheDocument(); + }); +}); diff --git a/hooks/useMobileFeatures.ts b/hooks/useMobileFeatures.ts new file mode 100644 index 0000000..e5dc446 --- /dev/null +++ b/hooks/useMobileFeatures.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from 'react'; +import axios from 'axios'; +import { mobileFeaturesService } from '@/services/mobileFeaturesService'; +import type { MobileFeatureCard } from '@/types/mobileFeatures'; + +interface UseMobileFeaturesResult { + features: MobileFeatureCard[]; + isLoading: boolean; + error: string | null; + refetch: () => Promise; +} + +/** + * useMobileFeatures — single source for the mobile Kinetic Features Stack + * (Spatial Anchoring, Smart Contracts, Immutable Audit). + * + * Components consume this hook; they never call mobileFeaturesService + * directly. Aborts in-flight requests on unmount or refetch to avoid + * setting state on an unmounted component. + */ +export function useMobileFeatures(): UseMobileFeaturesResult { + const [features, setFeatures] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadTick, setReloadTick] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + mobileFeaturesService + .getFeatures(controller.signal) + .then((data) => { + if (cancelled) return; + setFeatures(data); + setError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (axios.isCancel(err)) return; + const message = + err instanceof Error && err.message + ? err.message + : 'Failed to load features'; + setError(message); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [reloadTick]); + + const refetch = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + setReloadTick((tick) => tick + 1); + }, []); + + return { + features, + isLoading, + error, + refetch, + }; +} diff --git a/services/mobileFeaturesService.ts b/services/mobileFeaturesService.ts new file mode 100644 index 0000000..350a725 --- /dev/null +++ b/services/mobileFeaturesService.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; +import type { MobileFeatureCard } from '@/types/mobileFeatures'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +/** + * mobileFeaturesService — all Mobile Kinetic Features Stack API + * communication. Hooks call this; components never call this directly. + */ +export const mobileFeaturesService = { + async getFeatures(signal?: AbortSignal): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/api/mobile/features`, + { signal }, + ); + return data; + }, +}; diff --git a/types/mobileFeatures.ts b/types/mobileFeatures.ts new file mode 100644 index 0000000..a3c015c --- /dev/null +++ b/types/mobileFeatures.ts @@ -0,0 +1,6 @@ +export interface MobileFeatureCard { + id: string; + icon: string; + title: string; + description: string; +} From fe7be8254587e0849ec8d1824dab2b5182734958 Mon Sep 17 00:00:00 2001 From: Precious Igwealor Date: Sat, 1 Aug 2026 01:29:25 +0100 Subject: [PATCH 4/4] feat(landing): implement scale pathways split card component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds NetworkPathways, the "Architected for Scale" split card layout for the landing page: a Logistics Enterprises card and an Independent Carriers card, each with a custom thin-stroke SVG icon (a warehouse glyph for enterprises, a truck-on-a-route glyph for carriers — hand-drawn line icons rather than an icon-library import, since each represents a distinct concept). Cards use CSS Grid (grid-cols-1 md:grid-cols-2), so they stack vertically below the 768px md breakpoint and sit side by side above it. Hover state lifts the card, brightens its border, and shifts the icon/CTA to a lighter blue. Follows the Component -> Hook -> Service pattern (types/networkPathways.ts, app/api/landing/network-pathways/route.ts, networkPathwaysService, useNetworkPathways) — no inline mock data in the component. --- app/api/landing/network-pathways/route.ts | 29 ++++ components/landing/NetworkPathways.tsx | 130 ++++++++++++++++++ .../__tests__/NetworkPathways.test.tsx | 97 +++++++++++++ hooks/useNetworkPathways.ts | 69 ++++++++++ services/networkPathwaysService.ts | 18 +++ types/networkPathways.ts | 9 ++ 6 files changed, 352 insertions(+) create mode 100644 app/api/landing/network-pathways/route.ts create mode 100644 components/landing/NetworkPathways.tsx create mode 100644 components/landing/__tests__/NetworkPathways.test.tsx create mode 100644 hooks/useNetworkPathways.ts create mode 100644 services/networkPathwaysService.ts create mode 100644 types/networkPathways.ts diff --git a/app/api/landing/network-pathways/route.ts b/app/api/landing/network-pathways/route.ts new file mode 100644 index 0000000..6f613b5 --- /dev/null +++ b/app/api/landing/network-pathways/route.ts @@ -0,0 +1,29 @@ +// Local content endpoint for the landing page's Network Pathways cards. +// Serves the exact `NetworkPathwayCard[]` contract the platform backend will +// expose, so the client layers (service -> hook -> component) talk to a +// real HTTP endpoint. Point NEXT_PUBLIC_API_URL at the backend to override it. +import { NextResponse } from 'next/server'; +import type { NetworkPathwayCard } from '@/types/networkPathways'; + +const payload: NetworkPathwayCard[] = [ + { + id: 'logistics-enterprises', + icon: 'enterprise', + title: 'Logistics Enterprises', + description: + 'Run your entire fleet through one escrow-backed command center — dispatch, exceptions and settlement in a single operational view.', + cta: { label: 'Talk to our team', href: '/contact' }, + }, + { + id: 'independent-carriers', + icon: 'carrier', + title: 'Independent Carriers', + description: + 'Pick up jobs, get paid the moment delivery is confirmed, and build an on-chain reputation that travels with you across the network.', + cta: { label: 'Join as a carrier', href: '/dashboard' }, + }, +]; + +export async function GET() { + return NextResponse.json(payload); +} diff --git a/components/landing/NetworkPathways.tsx b/components/landing/NetworkPathways.tsx new file mode 100644 index 0000000..ff5974c --- /dev/null +++ b/components/landing/NetworkPathways.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useNetworkPathways } from '@/hooks/useNetworkPathways'; +import type { NetworkPathwayIcon } from '@/types/networkPathways'; + +/** + * Custom thin-stroke line icons for the pathway cards. Kept as small, + * hand-drawn glyphs (not an icon library import) since each represents a + * distinct concept — a multi-bay warehouse for enterprises, a route line + * with a truck for independent carriers. + */ +function EnterpriseIcon() { + return ( + + ); +} + +function CarrierIcon() { + return ( + + ); +} + +const ICONS: Record React.JSX.Element> = { + enterprise: EnterpriseIcon, + carrier: CarrierIcon, +}; + +/** + * NetworkPathways — "Architected for Scale" split card layout showing the + * two ways an organization joins the network (Logistics Enterprises, + * Independent Carriers). Cards sit side by side from the md breakpoint + * (768px) up and stack vertically below it. + */ +export function NetworkPathways() { + const { cards, isLoading, error } = useNetworkPathways(); + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+
+

+ Architected for Scale +

+

+ Built for every kind of operator +

+
+ +
+ {isLoading && + Array.from({ length: 2 }).map((_, index) => ( +
+ ))} + + {!isLoading && + cards.map((card) => { + const Icon = ICONS[card.icon]; + return ( +
+
+ +
+ +

{card.title}

+

+ {card.description} +

+ + + {card.cta.label} + + +
+ ); + })} +
+
+ ); +} + +export default NetworkPathways; diff --git a/components/landing/__tests__/NetworkPathways.test.tsx b/components/landing/__tests__/NetworkPathways.test.tsx new file mode 100644 index 0000000..abd30d1 --- /dev/null +++ b/components/landing/__tests__/NetworkPathways.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react'; +import { NetworkPathways } from '@/components/landing/NetworkPathways'; +import { useNetworkPathways } from '@/hooks/useNetworkPathways'; + +jest.mock('@/hooks/useNetworkPathways'); +const mockUseNetworkPathways = useNetworkPathways as jest.Mock; + +const baseCards = [ + { + id: 'logistics-enterprises', + icon: 'enterprise', + title: 'Logistics Enterprises', + description: 'Run your entire fleet through one escrow-backed command center.', + cta: { label: 'Talk to our team', href: '/contact' }, + }, + { + id: 'independent-carriers', + icon: 'carrier', + title: 'Independent Carriers', + description: 'Pick up jobs, get paid the moment delivery is confirmed.', + cta: { label: 'Join as a carrier', href: '/dashboard' }, + }, +]; + +describe('NetworkPathways', () => { + it('renders both pathway cards once loaded', () => { + mockUseNetworkPathways.mockReturnValue({ + cards: baseCards, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByText('Logistics Enterprises')).toBeInTheDocument(); + expect(screen.getByText('Independent Carriers')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Talk to our team/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Join as a carrier/i })).toBeInTheDocument(); + }); + + it('stacks cards in a single column below md and two columns from md up', () => { + mockUseNetworkPathways.mockReturnValue({ + cards: baseCards, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + const { container } = render(); + const grid = container.querySelector('.grid'); + + expect(grid?.className).toContain('grid-cols-1'); + expect(grid?.className).toContain('md:grid-cols-2'); + }); + + it('applies a hover state class to each card', () => { + mockUseNetworkPathways.mockReturnValue({ + cards: baseCards, + isLoading: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + const card = screen.getByText('Logistics Enterprises').closest('div.group'); + expect(card?.className).toContain('hover:'); + }); + + it('shows a loading skeleton while data is being fetched', () => { + mockUseNetworkPathways.mockReturnValue({ + cards: [], + isLoading: true, + error: null, + refetch: jest.fn(), + }); + + const { container } = render(); + + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + expect(screen.queryByText('Logistics Enterprises')).not.toBeInTheDocument(); + }); + + it('renders an error message when the fetch fails', () => { + mockUseNetworkPathways.mockReturnValue({ + cards: [], + isLoading: false, + error: 'Failed to load network pathways', + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByText('Failed to load network pathways')).toBeInTheDocument(); + }); +}); diff --git a/hooks/useNetworkPathways.ts b/hooks/useNetworkPathways.ts new file mode 100644 index 0000000..8e57bd7 --- /dev/null +++ b/hooks/useNetworkPathways.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from 'react'; +import axios from 'axios'; +import { networkPathwaysService } from '@/services/networkPathwaysService'; +import type { NetworkPathwayCard } from '@/types/networkPathways'; + +interface UseNetworkPathwaysResult { + cards: NetworkPathwayCard[]; + isLoading: boolean; + error: string | null; + refetch: () => Promise; +} + +/** + * useNetworkPathways — single source for the landing page's Network + * Pathways split cards (Logistics Enterprises, Independent Carriers). + * + * Components consume this hook; they never call networkPathwaysService + * directly. Aborts in-flight requests on unmount or refetch to avoid + * setting state on an unmounted component. + */ +export function useNetworkPathways(): UseNetworkPathwaysResult { + const [cards, setCards] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadTick, setReloadTick] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + networkPathwaysService + .getCards(controller.signal) + .then((data) => { + if (cancelled) return; + setCards(data); + setError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (axios.isCancel(err)) return; + const message = + err instanceof Error && err.message + ? err.message + : 'Failed to load network pathways'; + setError(message); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [reloadTick]); + + const refetch = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + setReloadTick((tick) => tick + 1); + }, []); + + return { + cards, + isLoading, + error, + refetch, + }; +} diff --git a/services/networkPathwaysService.ts b/services/networkPathwaysService.ts new file mode 100644 index 0000000..42e0705 --- /dev/null +++ b/services/networkPathwaysService.ts @@ -0,0 +1,18 @@ +import axios from 'axios'; +import type { NetworkPathwayCard } from '@/types/networkPathways'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +/** + * networkPathwaysService — all Network Pathways card API communication. + * Hooks call this; components never call this directly. + */ +export const networkPathwaysService = { + async getCards(signal?: AbortSignal): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/api/landing/network-pathways`, + { signal }, + ); + return data; + }, +}; diff --git a/types/networkPathways.ts b/types/networkPathways.ts new file mode 100644 index 0000000..61031ae --- /dev/null +++ b/types/networkPathways.ts @@ -0,0 +1,9 @@ +export type NetworkPathwayIcon = 'enterprise' | 'carrier'; + +export interface NetworkPathwayCard { + id: string; + icon: NetworkPathwayIcon; + title: string; + description: string; + cta: { label: string; href: string }; +}