Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/app/(landing)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 */}
Expand All @@ -159,7 +162,7 @@ export default function LandingPage() {
/>

<main className="relative min-h-screen">
<LandingContent />
<LandingContent demoInvoices={demoInvoices} />
</main>
</>
)
Expand Down
8 changes: 4 additions & 4 deletions src/widgets/landing/constants/demo-invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
14 changes: 5 additions & 9 deletions src/widgets/landing/demo-section/DemoSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof getDemoInvoices>>[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<DemoInvoice[]>([])
const prefersReducedMotion = useReducedMotion()

useEffect(() => {
void getDemoInvoices().then(setDemoInvoices)
}, [])

const { activeIndex, pause, resume, goTo } = useDemoRotation({
itemCount: demoInvoices.length,
interval: ROTATION_INTERVAL_MS,
Expand Down
69 changes: 50 additions & 19 deletions src/widgets/landing/demo-section/__tests__/DemoSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<typeof import('@/features/invoice-codec')>('@/features/invoice-codec')
return {
Expand All @@ -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 }) => (
Expand Down Expand Up @@ -63,6 +69,10 @@ vi.mock('@/shared/ui', async () => {
})

describe('DemoSection', () => {
beforeAll(async () => {
fixtureInvoices = await getDemoInvoices()
})

beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
Expand All @@ -73,15 +83,15 @@ describe('DemoSection', () => {

describe('T027-test: Invoice paper rendering', () => {
it('should render invoice preview card', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

await waitFor(() => {
expect(screen.getAllByText(/INVOICE/i)[0]).toBeInTheDocument()
})
})

it('should display company name from invoice', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

// First demo invoice (Ethereum) - company name
await waitFor(() => {
Expand All @@ -90,7 +100,7 @@ describe('DemoSection', () => {
})

it('should display line items', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

// First demo invoice line item
await waitFor(() => {
Expand All @@ -99,7 +109,7 @@ describe('DemoSection', () => {
})

it('should display total amount with token', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

// First invoice: (40*0.125 + 8*0.1) - 5% = 5.51 ETH total
await waitFor(() => {
Expand All @@ -117,7 +127,7 @@ describe('DemoSection', () => {
*/

it('should NOT auto-rotate when reduced motion is preferred (accessibility)', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

// Wait for async demo invoices to load
await waitFor(() => {
Expand All @@ -134,7 +144,7 @@ describe('DemoSection', () => {
})

it('should allow manual navigation via pagination dots', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

// Wait for async demo invoices to load
await waitFor(() => {
Expand All @@ -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(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

// Wait for async demo invoices to load
await waitFor(() => {
Expand All @@ -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(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

await waitFor(() => {
expect(getHoverZone()).not.toBeNull()
Expand All @@ -190,7 +200,7 @@ describe('DemoSection', () => {
})

it('should link to /create with template parameter', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

await waitFor(() => {
expect(getHoverZone()).not.toBeNull()
Expand All @@ -207,7 +217,7 @@ describe('DemoSection', () => {
})

it('should hide button on mouse leave', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

await waitFor(() => {
expect(getHoverZone()).not.toBeNull()
Expand All @@ -232,7 +242,7 @@ describe('DemoSection', () => {

describe('Navigation dots', () => {
it('should render 5 navigation dots', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

// Dots have aria-label="View invoice {id}" format
await waitFor(() => {
Expand All @@ -242,7 +252,7 @@ describe('DemoSection', () => {
})

it('should navigate to specific invoice on dot click', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

// Wait for async demo invoices to load
await waitFor(() => {
Expand All @@ -260,7 +270,7 @@ describe('DemoSection', () => {
})

it('should navigate to fifth invoice (Polygon)', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

// Wait for async demo invoices to load
await waitFor(() => {
Expand All @@ -280,7 +290,7 @@ describe('DemoSection', () => {

describe('Network theme', () => {
it('should render invoice paper with network information', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

// The first invoice is Ethereum network (net: 1)
// InvoicePaper renders Payment Info section
Expand All @@ -292,7 +302,7 @@ describe('DemoSection', () => {

describe('Accessibility', () => {
it('should have proper aria-labelledby on section', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

await waitFor(() => {
const section = document.querySelector('section')
Expand All @@ -301,7 +311,7 @@ describe('DemoSection', () => {
})

it('should have aria-labels on navigation dots', async () => {
renderWithProviders(<DemoSection />)
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)

await waitFor(() => {
const dots = screen.getAllByRole('button')
Expand All @@ -311,4 +321,25 @@ describe('DemoSection', () => {
})
})
})

describe('Prop-driven data (server lift)', () => {
it('should show fallback when demoInvoices is empty', () => {
renderWithProviders(<DemoSection demoInvoices={[]} />)
expect(screen.getByText('Demo content unavailable')).toBeInTheDocument()
})

it('should render first invoice synchronously without async loading', () => {
renderWithProviders(<DemoSection demoInvoices={fixtureInvoices} />)
// 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')
})
})
})
})
9 changes: 7 additions & 2 deletions src/widgets/landing/ui/BelowFoldSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 (
<>
<HowItWorks />
<VideoSection />
<DemoSection />
<DemoSection demoInvoices={demoInvoices} />
<WhyVoidPay />
{comparisonTable}
<AudienceSection />
Expand Down
20 changes: 15 additions & 5 deletions src/widgets/landing/ui/LandingContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<ComponentType<{ comparisonTable: ReactNode }> | null>(null)
function LazyBelowFold({ demoInvoices }: { demoInvoices: DemoInvoice[] }) {
const [Component, setComponent] = useState<ComponentType<BelowFoldSectionsProps> | null>(null)
const [loadError, setLoadError] = useState<Error | null>(null)

useEffect(() => {
Expand Down Expand Up @@ -85,10 +91,14 @@ function LazyBelowFold() {
}

if (!Component) return <BelowFoldPlaceholder />
return <Component comparisonTable={<ComparisonTable />} />
return <Component comparisonTable={<ComparisonTable />} 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
Expand Down Expand Up @@ -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. */}
<BelowFoldLoader skeleton={<BelowFoldPlaceholder />} rootMargin="400px">
<LazyBelowFold />
<LazyBelowFold demoInvoices={demoInvoices} />
</BelowFoldLoader>
</div>
</>
Expand Down
Loading