diff --git a/src/app/(landing)/page.tsx b/src/app/(landing)/page.tsx index bf8a53a0..40e6e556 100644 --- a/src/app/(landing)/page.tsx +++ b/src/app/(landing)/page.tsx @@ -8,6 +8,7 @@ import type { Metadata } from 'next' import { APP_URLS, SOCIAL_URLS } from '@/shared/config' import { defaultOgImages } from '@/features/og-image' import { LandingContent, FAQ_ITEMS } from '@/widgets/landing' +import { getDemoInvoices } from '@/widgets/landing/constants/demo-invoices' // JSON-LD: FAQPage schema for rich snippets const faqSchema = { @@ -136,7 +137,9 @@ export const metadata: Metadata = { }, } -export default function LandingPage() { +export default async function LandingPage() { + const demoInvoices = await getDemoInvoices() + return ( <> {/* JSON-LD Structured Data - rendered in initial SSR HTML for crawlers */} @@ -159,7 +162,7 @@ export default function LandingPage() { />
- +
) diff --git a/src/widgets/landing/constants/demo-invoices.ts b/src/widgets/landing/constants/demo-invoices.ts index b87641e4..429a5359 100644 --- a/src/widgets/landing/constants/demo-invoices.ts +++ b/src/widgets/landing/constants/demo-invoices.ts @@ -5,9 +5,9 @@ * All fields of Invoice are populated to demonstrate full functionality. * Each demo showcases a different invoice status and payment state. * - * IMPORTANT: createHash is computed at build time (SSG). - * encodeInvoice runs during `next build`, not on client. - * Dates are computed relative to build time so demos stay fresh. + * getDemoInvoices() is called on the server (RSC / build time) and the + * resolved DemoInvoice[] is passed to DemoSection as a prop. The codec + * (brotli-wasm) never runs in the browser on the landing page. */ import { encodeInvoice, generateSalt, deriveMagicDust } from '@/features/invoice-codec' @@ -16,7 +16,7 @@ import type { Invoice } from '@/shared/lib/invoice-types' import type { InvoiceStatus } from '@/widgets/invoice-paper/types' /** Demo-only type for landing page invoice rotation */ -interface DemoInvoice { +export interface DemoInvoice { invoiceId: string invoiceUrl: string createdAt: string diff --git a/src/widgets/landing/demo-section/DemoSection.tsx b/src/widgets/landing/demo-section/DemoSection.tsx index 72352371..39129520 100644 --- a/src/widgets/landing/demo-section/DemoSection.tsx +++ b/src/widgets/landing/demo-section/DemoSection.tsx @@ -16,24 +16,20 @@ import { useReducedMotion } from '@/shared/ui' import { Button } from '@/shared/ui/button' import { Heading, Text } from '@/shared/ui/typography' import { InvoicePaper, ScaledInvoicePreview, InvoicePaperProps } from '@/widgets/invoice-paper' -import { getDemoInvoices, ROTATION_INTERVAL_MS } from '../constants/demo-invoices' +import { type DemoInvoice, ROTATION_INTERVAL_MS } from '../constants/demo-invoices' import { useDemoRotation } from '../hooks/use-demo-rotation' import { DemoPagination } from './ui/DemoPagination' -// Resolved type of getDemoInvoices element -type DemoInvoice = Awaited>[number] +interface DemoSectionProps { + demoInvoices: DemoInvoice[] +} -export function DemoSection() { +export function DemoSection({ demoInvoices }: DemoSectionProps) { const setNetworkTheme = useCreatorStore((s) => s.setNetworkTheme) const [isHovered, setIsHovered] = useState(false) - const [demoInvoices, setDemoInvoices] = useState([]) const prefersReducedMotion = useReducedMotion() - useEffect(() => { - void getDemoInvoices().then(setDemoInvoices) - }, []) - const { activeIndex, pause, resume, goTo } = useDemoRotation({ itemCount: demoInvoices.length, interval: ROTATION_INTERVAL_MS, diff --git a/src/widgets/landing/demo-section/__tests__/DemoSection.test.tsx b/src/widgets/landing/demo-section/__tests__/DemoSection.test.tsx index 8896b416..3990fec2 100644 --- a/src/widgets/landing/demo-section/__tests__/DemoSection.test.tsx +++ b/src/widgets/landing/demo-section/__tests__/DemoSection.test.tsx @@ -2,14 +2,18 @@ * DemoSection Tests * Feature: 012-landing-page * User Story: US4 (Interactive Demo) + * + * DemoSection now receives pre-resolved demoInvoices as a prop (server-side lift). + * Tests pass fixture data directly — no async loading, no brotli-wasm in the browser. */ import { render, screen, fireEvent, act, waitFor } from '@/shared/lib/test-utils' -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { describe, expect, it, vi, beforeAll, beforeEach, afterEach } from 'vitest' import * as React from 'react' import type { ReactElement } from 'react' import { DemoSection } from '../DemoSection' +import { getDemoInvoices, type DemoInvoice } from '../../constants/demo-invoices' // Custom render (no provider wrapper needed — NetworkThemeProvider removed) function renderWithProviders(ui: ReactElement) { @@ -19,8 +23,8 @@ function renderWithProviders(ui: ReactElement) { // Note: framer-motion is globally mocked via vitest.config.ts alias // Global mock: useReducedMotion returns true (accessibility mode) -// Mock only encodeInvoice (WASM/brotli) — real getDemoInvoices runs with real fixtures, -// but skips the WASM roundtrip that times out waitFor under parallel test load. +// Mock encodeInvoice so getDemoInvoices() can be called in beforeEach without WASM. +// In production, getDemoInvoices() runs on the server where WASM works fine. vi.mock('@/features/invoice-codec', async () => { const actual = await vi.importActual('@/features/invoice-codec') return { @@ -29,6 +33,8 @@ vi.mock('@/features/invoice-codec', async () => { } }) +let fixtureInvoices: DemoInvoice[] = [] + // Mock next/link to render as a proper anchor vi.mock('next/link', () => ({ default: vi.fn(({ children, href, ...props }) => ( @@ -63,6 +69,10 @@ vi.mock('@/shared/ui', async () => { }) describe('DemoSection', () => { + beforeAll(async () => { + fixtureInvoices = await getDemoInvoices() + }) + beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }) }) @@ -73,7 +83,7 @@ describe('DemoSection', () => { describe('T027-test: Invoice paper rendering', () => { it('should render invoice preview card', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(screen.getAllByText(/INVOICE/i)[0]).toBeInTheDocument() @@ -81,7 +91,7 @@ describe('DemoSection', () => { }) it('should display company name from invoice', async () => { - renderWithProviders() + renderWithProviders() // First demo invoice (Ethereum) - company name await waitFor(() => { @@ -90,7 +100,7 @@ describe('DemoSection', () => { }) it('should display line items', async () => { - renderWithProviders() + renderWithProviders() // First demo invoice line item await waitFor(() => { @@ -99,7 +109,7 @@ describe('DemoSection', () => { }) it('should display total amount with token', async () => { - renderWithProviders() + renderWithProviders() // First invoice: (40*0.125 + 8*0.1) - 5% = 5.51 ETH total await waitFor(() => { @@ -117,7 +127,7 @@ describe('DemoSection', () => { */ it('should NOT auto-rotate when reduced motion is preferred (accessibility)', async () => { - renderWithProviders() + renderWithProviders() // Wait for async demo invoices to load await waitFor(() => { @@ -134,7 +144,7 @@ describe('DemoSection', () => { }) it('should allow manual navigation via pagination dots', async () => { - renderWithProviders() + renderWithProviders() // Wait for async demo invoices to load await waitFor(() => { @@ -154,7 +164,7 @@ describe('DemoSection', () => { // TODO: Investigate flaky timer behavior with reduced motion mode // The component appears to change state after advanceTimersByTime even in reduced motion mode it.skip('should stay on first invoice after time passes (reduced motion mode)', async () => { - renderWithProviders() + renderWithProviders() // Wait for async demo invoices to load await waitFor(() => { @@ -176,7 +186,7 @@ describe('DemoSection', () => { const getHoverZone = () => document.querySelector('[class*="z-30"][class*="absolute"]') it('should show "Use This Template" button on hover', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(getHoverZone()).not.toBeNull() @@ -190,7 +200,7 @@ describe('DemoSection', () => { }) it('should link to /create with template parameter', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(getHoverZone()).not.toBeNull() @@ -207,7 +217,7 @@ describe('DemoSection', () => { }) it('should hide button on mouse leave', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { expect(getHoverZone()).not.toBeNull() @@ -232,7 +242,7 @@ describe('DemoSection', () => { describe('Navigation dots', () => { it('should render 5 navigation dots', async () => { - renderWithProviders() + renderWithProviders() // Dots have aria-label="View invoice {id}" format await waitFor(() => { @@ -242,7 +252,7 @@ describe('DemoSection', () => { }) it('should navigate to specific invoice on dot click', async () => { - renderWithProviders() + renderWithProviders() // Wait for async demo invoices to load await waitFor(() => { @@ -260,7 +270,7 @@ describe('DemoSection', () => { }) it('should navigate to fifth invoice (Polygon)', async () => { - renderWithProviders() + renderWithProviders() // Wait for async demo invoices to load await waitFor(() => { @@ -280,7 +290,7 @@ describe('DemoSection', () => { describe('Network theme', () => { it('should render invoice paper with network information', async () => { - renderWithProviders() + renderWithProviders() // The first invoice is Ethereum network (net: 1) // InvoicePaper renders Payment Info section @@ -292,7 +302,7 @@ describe('DemoSection', () => { describe('Accessibility', () => { it('should have proper aria-labelledby on section', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { const section = document.querySelector('section') @@ -301,7 +311,7 @@ describe('DemoSection', () => { }) it('should have aria-labels on navigation dots', async () => { - renderWithProviders() + renderWithProviders() await waitFor(() => { const dots = screen.getAllByRole('button') @@ -311,4 +321,25 @@ describe('DemoSection', () => { }) }) }) + + describe('Prop-driven data (server lift)', () => { + it('should show fallback when demoInvoices is empty', () => { + renderWithProviders() + expect(screen.getByText('Demo content unavailable')).toBeInTheDocument() + }) + + it('should render first invoice synchronously without async loading', () => { + renderWithProviders() + // Data is available immediately via props — no waitFor needed + expect(screen.getByText('EtherScale Solutions')).toBeInTheDocument() + }) + + it('createHash from fixture should be non-empty (server-resolved)', () => { + expect(fixtureInvoices.length).toBeGreaterThan(0) + fixtureInvoices.forEach((invoice) => { + // test-hash from mocked encodeInvoice — confirms hash was computed + expect(typeof invoice.createHash).toBe('string') + }) + }) + }) }) diff --git a/src/widgets/landing/ui/BelowFoldSections.tsx b/src/widgets/landing/ui/BelowFoldSections.tsx index 87c8005e..7daac6f1 100644 --- a/src/widgets/landing/ui/BelowFoldSections.tsx +++ b/src/widgets/landing/ui/BelowFoldSections.tsx @@ -16,6 +16,9 @@ * comparisonTable is accepted as a ReactNode prop so ComparisonTable * (which has no client-side needs) can be rendered by a server-side * ancestor when the RSC boundary allows it. + * + * demoInvoices is resolved on the server (RSC / page.tsx) so encodeInvoice + * (brotli-wasm) never runs in the browser on the landing page. */ import type { ReactNode } from 'react' @@ -27,17 +30,19 @@ import { WhyVoidPay } from '../why-voidpay/WhyVoidPay' import { AudienceSection } from '../audience-section/AudienceSection' import { FaqSection } from '../faq-section' import { FooterCta } from '../footer-cta/FooterCta' +import type { DemoInvoice } from '../constants/demo-invoices' interface BelowFoldSectionsProps { comparisonTable: ReactNode + demoInvoices: DemoInvoice[] } -export function BelowFoldSections({ comparisonTable }: BelowFoldSectionsProps) { +export function BelowFoldSections({ comparisonTable, demoInvoices }: BelowFoldSectionsProps) { return ( <> - + {comparisonTable} diff --git a/src/widgets/landing/ui/LandingContent.tsx b/src/widgets/landing/ui/LandingContent.tsx index 44141599..ad64eaf6 100644 --- a/src/widgets/landing/ui/LandingContent.tsx +++ b/src/widgets/landing/ui/LandingContent.tsx @@ -26,6 +26,7 @@ import { HeroSection } from '../hero-section/HeroSection' import { SocialProofStrip } from '../social-proof' import { BelowFoldLoader } from './BelowFoldLoader' import { ComparisonTable } from '../comparison/ComparisonTable' +import type { DemoInvoice } from '../constants/demo-invoices' /** * SEO-friendly placeholder for below-fold content. @@ -52,11 +53,16 @@ function BelowFoldPlaceholder() { ) } +interface BelowFoldSectionsProps { + comparisonTable: ReactNode + demoInvoices: DemoInvoice[] +} + /** * LazyBelowFold - Loads below-fold sections ONLY when triggered */ -function LazyBelowFold() { - const [Component, setComponent] = useState | null>(null) +function LazyBelowFold({ demoInvoices }: { demoInvoices: DemoInvoice[] }) { + const [Component, setComponent] = useState | null>(null) const [loadError, setLoadError] = useState(null) useEffect(() => { @@ -85,10 +91,14 @@ function LazyBelowFold() { } if (!Component) return - return } /> + return } demoInvoices={demoInvoices} /> +} + +interface LandingContentProps { + demoInvoices: DemoInvoice[] } -export function LandingContent() { +export function LandingContent({ demoInvoices }: LandingContentProps) { // Landing uses the store's default 'ethereum' theme - no mount-time mutation needed. // Preload bundles after initial paint for smoother scrolling @@ -136,7 +146,7 @@ export function LandingContent() { {/* Below-fold: Intersection Observer triggers lazy load. Same placeholder pre-trigger and while the chunk loads -> no CLS on swap. */} } rootMargin="400px"> - + diff --git a/src/widgets/landing/video-section/VideoSection.tsx b/src/widgets/landing/video-section/VideoSection.tsx index 5986d7e2..a3a79c46 100644 --- a/src/widgets/landing/video-section/VideoSection.tsx +++ b/src/widgets/landing/video-section/VideoSection.tsx @@ -7,8 +7,10 @@ * Only the video for the active viewport is mounted — no double-fetch. * Aspect-ratio box reserved to prevent CLS during SSR → client transition. * Reduced motion: autoplay suppressed; poster shown with native controls. + * Mobile: autoplay disabled entirely so preload="none" actually defers the + * 2.7MB fetch. Poster shown with native controls for tap-to-play affordance. * Off-screen: IntersectionObserver pauses video when scrolled out of view - * and resumes when scrolled back in (skipped under reduced-motion). + * and resumes when scrolled back in (skipped under reduced-motion or mobile). */ 'use client' @@ -37,9 +39,10 @@ export function VideoSection() { }, []) // Pause the off-screen video to avoid wasting data/battery. - // Skip entirely under reduced-motion — videos aren't autoplaying anyway. + // Skip under reduced-motion or mobile — videos aren't autoplaying in those cases. useEffect(() => { if (prefersReducedMotion) return + if (isMobile) return if (typeof window === 'undefined') return const section = sectionRef.current @@ -62,7 +65,7 @@ export function VideoSection() { observer.observe(section) return () => observer.disconnect() - }, [prefersReducedMotion]) + }, [prefersReducedMotion, isMobile]) const videoSrc = isMobile ? '/video/voidpay-9x16-v2.mp4' : '/video/voidpay-16x9-v2.mp4' const wrapperClassName = isMobile @@ -98,11 +101,11 @@ export function VideoSection() { src={videoSrc} poster="/video/poster-scene5.webp" muted - autoPlay={!prefersReducedMotion} + autoPlay={!prefersReducedMotion && !isMobile} loop playsInline preload="none" - controls={prefersReducedMotion} + controls={prefersReducedMotion || isMobile} aria-label="VoidPay product walkthrough: creating and paying a crypto invoice" onPlay={handlePlay} /> diff --git a/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx b/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx index fdd34eba..6fe4e9d0 100644 --- a/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx +++ b/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx @@ -172,6 +172,25 @@ describe('VideoSection', () => { expect(video).toHaveAttribute('controls') }) + it('mobile path: should NOT have autoplay attribute', () => { + stubMobileViewport() + render() + const video = document.querySelector('video') + // On mobile, autoPlay is disabled so preload="none" actually defers the fetch + expect(video).not.toHaveAttribute('autoplay') + }) + + it('mobile path: should show controls for tap-to-play affordance', () => { + stubMobileViewport() + render() + const video = document.querySelector('video') + expect(video).toHaveAttribute('controls') + }) + + // Desktop autoplay when reduced-motion is off cannot be tested in this suite: + // useReducedMotion is globally mocked to return true (vitest.setup.ts). + // The mobile-specific cases above cover the new branching logic. + it('should have an accessible aria-label on the video', () => { render() const video = document.querySelector('video') @@ -236,7 +255,8 @@ describe('VideoSection', () => { it('should register an IntersectionObserver when reduced-motion is off', () => { // Note: global mock returns prefersReducedMotion=true, so observer is NOT registered. - // This test documents that the observer is skipped in reduced-motion mode. + // Observer is also skipped on mobile. This test documents that the observer is + // skipped in reduced-motion mode (global mock). render() // Under global reduced-motion mock (true), IntersectionObserver should NOT be called expect(IntersectionObserver).not.toHaveBeenCalled()