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/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/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/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/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/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/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/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__/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/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/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/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/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/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/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/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/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/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/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/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[]; +} 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; +} 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 }; +} 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 }; +}