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()