Skip to content
Merged
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
2 changes: 2 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const nextConfig = {
'wagmi',
'viem',
'@rainbow-me/rainbowkit',
'framer-motion',
'@web3icons/react',
],
},

Expand Down
15 changes: 5 additions & 10 deletions src/app/(landing)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
*/

import type { Metadata } from 'next'
import Script from 'next/script'

import { APP_URLS, SOCIAL_URLS } from '@/shared/config'
import { defaultOgImages } from '@/features/og-image'
Expand Down Expand Up @@ -140,25 +139,21 @@ export const metadata: Metadata = {
export default function LandingPage() {
return (
<>
{/* JSON-LD Structured Data */}
<Script
id="faq-schema"
{/* JSON-LD Structured Data - rendered in initial SSR HTML for crawlers */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
<Script
id="organization-schema"
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
/>
<Script
id="software-schema"
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareSchema) }}
/>
{/* HowTo schema for rich snippets - safe: static data with JSON.stringify */}
<Script
id="howto-schema"
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(howToSchema) }}
/>
Expand Down
10 changes: 7 additions & 3 deletions src/widgets/landing/audience-section/AudienceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Feature: 012-landing-page
*/

import { memo } from 'react'

import type { IconProps } from '@/shared/ui/icons'

import { cn } from '@/shared/lib/utils'
Expand All @@ -16,15 +18,15 @@ export type AudienceCardProps = {
iconColor?: string
}

export function AudienceCard({
export const AudienceCard = memo(function AudienceCard({
icon: Icon,
title,
headline,
description,
iconColor = 'text-violet-500',
}: AudienceCardProps) {
return (
<div className="group rounded-3xl border border-zinc-800/50 bg-gradient-to-b from-zinc-900/60 to-zinc-950/60 p-5 backdrop-blur-sm transition-all hover:border-zinc-700 hover:shadow-lg hover:shadow-zinc-900/20 md:p-8">
<div className="group rounded-3xl border border-zinc-800/50 bg-gradient-to-b from-zinc-900/60 to-zinc-950/60 p-5 transition-all hover:border-zinc-700 hover:shadow-lg hover:shadow-zinc-900/20 md:p-8">
{/* Icon + Title row */}
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-zinc-800 bg-zinc-950">
Expand All @@ -46,4 +48,6 @@ export function AudienceCard({
</Text>
</div>
)
}
})

AudienceCard.displayName = 'AudienceCard'
2 changes: 1 addition & 1 deletion src/widgets/landing/comparison/ComparisonTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ function ValueCell({ value }: { value: ComparisonValue }) {

export function ComparisonTable() {
return (
<section className="relative border-t border-zinc-900 bg-zinc-950/10 px-6 py-16 backdrop-blur-sm md:py-32">
<section className="relative border-t border-zinc-900 bg-zinc-950/10 px-6 py-16 md:py-32">
<div className="mx-auto max-w-4xl">
{/* Section header - SEO: competitor comparison keywords */}
<div className="mb-16 text-center">
Expand Down
32 changes: 20 additions & 12 deletions src/widgets/landing/faq-section/FaqSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

'use client'

import { useState } from 'react'
import { useState, useCallback, memo } from 'react'
import { ChevronDownIcon } from '@/shared/ui/icons'
import { useReducedMotion } from '@/shared/ui'

Expand All @@ -17,14 +17,20 @@ import { AnimatePresence, motion } from '@/shared/ui/motion'

import { FAQ_ITEMS, type FaqItem } from '../constants/faq'

function FaqItem({ question, answer, isOpen, onToggle, index }: FaqItem & { isOpen: boolean; onToggle: () => void; index: number }) {
const shouldReduceMotion = useReducedMotion()
const FaqItem = memo(function FaqItem({
question,
answer,
isOpen,
onToggle,
index,
reducedMotion,
}: FaqItem & { isOpen: boolean; onToggle: (index: number) => void; index: number; reducedMotion: boolean }) {
return (
<div className="border-b border-zinc-800 last:border-b-0">
<button
onClick={() => {
track(AnalyticsEvent.FAQ_EXPAND, { question_id: question })
onToggle()
onToggle(index)
}}
className="flex w-full cursor-pointer items-center justify-between gap-4 py-6 text-left transition-colors hover:text-zinc-100"
aria-expanded={isOpen}
Expand All @@ -46,7 +52,7 @@ function FaqItem({ question, answer, isOpen, onToggle, index }: FaqItem & { isOp
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2, ease: 'easeInOut' }}
transition={{ duration: reducedMotion ? 0 : 0.2, ease: 'easeInOut' }}
className="overflow-hidden"
>
<Text variant="body" className="pb-6 text-zinc-400">
Expand All @@ -57,19 +63,20 @@ function FaqItem({ question, answer, isOpen, onToggle, index }: FaqItem & { isOp
</AnimatePresence>
</div>
)
}
})

export function FaqSection() {
const [openIndex, setOpenIndex] = useState<number | null>(0)
const reducedMotion = useReducedMotion()

const handleToggle = (index: number) => {
setOpenIndex(openIndex === index ? null : index)
}
const handleToggle = useCallback((index: number) => {
setOpenIndex((prev) => (prev === index ? null : index))
}, [])

return (
<section
id="faq"
className="relative border-t border-zinc-900 bg-zinc-950/10 px-4 py-16 backdrop-blur-sm md:px-6 md:py-32"
className="relative border-t border-zinc-900 bg-zinc-950/50 px-4 py-16 md:px-6 md:py-32"
aria-labelledby="faq-heading"
>
<div className="mx-auto max-w-3xl">
Expand All @@ -84,15 +91,16 @@ export function FaqSection() {
</div>

{/* FAQ Accordion */}
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/20 px-4 backdrop-blur-sm md:px-8">
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 px-4 md:px-8">
{FAQ_ITEMS.map((item, index) => (
<FaqItem
key={index}
index={index}
question={item.question}
answer={item.answer}
isOpen={openIndex === index}
onToggle={() => handleToggle(index)}
onToggle={handleToggle}
reducedMotion={reducedMotion ?? false}
/>
))}
</div>
Expand Down
36 changes: 36 additions & 0 deletions src/widgets/landing/hero-section/HeroCta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* HeroCta - Client CTA button for HeroSection
* Feature: 012-landing-page
*
* Extracted as a thin client child so HeroSection can stay a Server Component
* (the <h1> paints from SSR HTML without waiting for hydration). This component
* owns the analytics track() call on click.
*/

'use client'

import Link from 'next/link'

import { track, AnalyticsEvent } from '@/features/analytics'
import { ArrowRightIcon } from '@/shared/ui/icons'
import { Button } from '@/shared/ui/button'

export function HeroCta(): React.JSX.Element {
return (
<div className="hero-animate-cta flex flex-col items-center px-4 pt-8">
<Button
variant="glow"
size="lg"
className="h-14 rounded-2xl px-8 text-base shadow-[0_0_40px_-10px_rgba(124,58,237,0.5)]"
onClick={() => track(AnalyticsEvent.LANDING_CTA_CLICK, { cta_location: 'hero' })}
asChild
>
<Link href="/create">
Create Your Invoice
<ArrowRightIcon size={16} />
</Link>
</Button>
<span className="mt-3 text-sm text-zinc-400">No signup. Takes 30 seconds.</span>
</div>
)
}
28 changes: 6 additions & 22 deletions src/widgets/landing/hero-section/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,16 @@
* Performance: Uses CSS animations instead of Framer Motion for LCP optimization.
* CSS animations in globals.css: hero-animate-container, hero-animate-badge, etc.
* Reduced motion is handled via @media (prefers-reduced-motion) in CSS.
*
* Server Component: the <h1> paints from SSR HTML without waiting for hydration.
* The CTA (which needs analytics) lives in the HeroCta client child.
*/

'use client'

import Link from 'next/link'

import { track, AnalyticsEvent } from '@/features/analytics'
import { ArrowRightIcon } from '@/shared/ui/icons'
import { AuroraText } from '@/shared/ui/aurora-text'
import { Button } from '@/shared/ui/button'
import { Heading, Text } from '@/shared/ui/typography'

import { HeroCta } from './HeroCta'

export function HeroSection() {
return (
<section
Expand Down Expand Up @@ -58,21 +56,7 @@ export function HeroSection() {
</Text>

{/* CTA */}
<div className="hero-animate-cta flex flex-col items-center px-4 pt-8">
<Button
variant="glow"
size="lg"
className="h-14 rounded-2xl px-8 text-base shadow-[0_0_40px_-10px_rgba(124,58,237,0.5)]"
onClick={() => track(AnalyticsEvent.LANDING_CTA_CLICK, { cta_location: 'hero' })}
asChild
>
<Link href="/create">
Create Your Invoice
<ArrowRightIcon size={16} />
</Link>
</Button>
<span className="mt-3 text-sm text-zinc-400">No signup. Takes 30 seconds.</span>
</div>
<HeroCta />
</div>

{/* Scroll indicator - positioned at bottom of section */}
Expand Down
65 changes: 61 additions & 4 deletions src/widgets/landing/hooks/__tests__/use-demo-rotation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,17 +225,74 @@ describe('useDemoRotation', () => {
const { result } = renderHook(() =>
useDemoRotation({ itemCount: 3 })
)

act(() => {
vi.advanceTimersByTime(9999)
})

expect(result.current.activeIndex).toBe(0)

act(() => {
vi.advanceTimersByTime(1)
})


expect(result.current.activeIndex).toBe(1)
})
})

describe('Visibility pause', () => {
function setHidden(hidden: boolean) {
Object.defineProperty(document, 'hidden', {
value: hidden,
writable: true,
configurable: true,
})
document.dispatchEvent(new Event('visibilitychange'))
}

afterEach(() => {
setHidden(false)
})

it('should pause rotation when the tab becomes hidden', () => {
const { result } = renderHook(() =>
useDemoRotation({ itemCount: 3, interval: 1000 })
)

act(() => {
setHidden(true)
})

act(() => {
vi.advanceTimersByTime(5000)
})

expect(result.current.activeIndex).toBe(0)
})

it('should resume rotation when the tab becomes visible again', () => {
const { result } = renderHook(() =>
useDemoRotation({ itemCount: 3, interval: 1000 })
)

act(() => {
setHidden(true)
})

act(() => {
vi.advanceTimersByTime(2000)
})

expect(result.current.activeIndex).toBe(0)

act(() => {
setHidden(false)
})

act(() => {
vi.advanceTimersByTime(1000)
})

expect(result.current.activeIndex).toBe(1)
})
})
Expand Down
17 changes: 14 additions & 3 deletions src/widgets/landing/hooks/use-demo-rotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Feature: 012-landing-page
*/

import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'

import { useInterval } from './use-interval'

Expand Down Expand Up @@ -37,6 +37,7 @@ export function useDemoRotation({
}: UseDemoRotationOptions): UseDemoRotationReturn {
const [activeIndex, setActiveIndex] = useState(0)
const [isPaused, setIsPaused] = useState(!autoStart)
const [isHidden, setIsHidden] = useState(false)

const next = useCallback(() => {
setActiveIndex((current) => (current + 1) % itemCount)
Expand All @@ -58,8 +59,18 @@ export function useDemoRotation({
const pause = useCallback(() => setIsPaused(true), [])
const resume = useCallback(() => setIsPaused(false), [])

// Auto-rotate when not paused
useInterval(next, isPaused ? null : interval)
// Pause rotation while the tab is backgrounded - composes with manual/hover pause
useEffect(() => {
const handleVisibilityChange = (): void => {
setIsHidden(document.hidden)
}

document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [])

// Auto-rotate only when neither manually paused nor backgrounded
useInterval(next, isPaused || isHidden ? null : interval)

return {
activeIndex,
Expand Down
Loading