feat: split checkout layout with auto-cycling feature slideshow
This commit is contained in:
160
components/checkout/FeatureSlideshow.tsx
Normal file
160
components/checkout/FeatureSlideshow.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Check } from '@phosphor-icons/react'
|
||||
import { PulseMockup } from '@/components/marketing/mockups/pulse-mockup'
|
||||
import { PulseFeaturesCarousel } from '@/components/marketing/mockups/pulse-features-carousel'
|
||||
import { FunnelMockup } from '@/components/marketing/mockups/funnel-mockup'
|
||||
import { EmailReportMockup } from '@/components/marketing/mockups/email-report-mockup'
|
||||
|
||||
interface Slide {
|
||||
headline: string
|
||||
description: string
|
||||
features: string[]
|
||||
mockup: React.ReactNode
|
||||
}
|
||||
|
||||
const slides: Slide[] = [
|
||||
{
|
||||
headline: 'Your traffic, at a glance.',
|
||||
description:
|
||||
'A clean, real-time dashboard that shows pageviews, visitors, bounce rate, and session duration — no learning curve required.',
|
||||
features: [
|
||||
'Real-time visitor count',
|
||||
'Pageview trends over time',
|
||||
'Bounce rate & session duration',
|
||||
'Top pages & referrers',
|
||||
],
|
||||
mockup: <PulseMockup />,
|
||||
},
|
||||
{
|
||||
headline: 'Everything you need to know about your visitors.',
|
||||
description:
|
||||
'Break down your audience by device, browser, OS, country, and language — all without cookies or fingerprinting.',
|
||||
features: [
|
||||
'Device & browser breakdown',
|
||||
'Country & region stats',
|
||||
'Language preferences',
|
||||
'Screen size distribution',
|
||||
],
|
||||
mockup: <PulseFeaturesCarousel />,
|
||||
},
|
||||
{
|
||||
headline: 'See where visitors drop off.',
|
||||
description:
|
||||
'Build funnels to track multi-step flows and find exactly where users abandon your conversion paths.',
|
||||
features: [
|
||||
'Multi-step funnel builder',
|
||||
'Drop-off visualization',
|
||||
'Conversion rate tracking',
|
||||
'Custom event support',
|
||||
],
|
||||
mockup: <FunnelMockup />,
|
||||
},
|
||||
{
|
||||
headline: 'Reports delivered to your inbox.',
|
||||
description:
|
||||
'Schedule weekly or monthly email reports so your team stays informed without logging in.',
|
||||
features: [
|
||||
'Weekly & monthly digests',
|
||||
'Customizable metrics',
|
||||
'Team-wide distribution',
|
||||
'PDF & inline previews',
|
||||
],
|
||||
mockup: <EmailReportMockup />,
|
||||
},
|
||||
]
|
||||
|
||||
export default function FeatureSlideshow() {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const advance = useCallback(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % slides.length)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(advance, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [advance])
|
||||
|
||||
const slide = slides[activeIndex]
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* Background image */}
|
||||
<Image
|
||||
src="/pulse-showcase-bg.png"
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
|
||||
{/* Dark overlay */}
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex h-full flex-col justify-center px-10 xl:px-14 py-12">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeIndex}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
className="flex flex-col gap-8"
|
||||
>
|
||||
{/* Text */}
|
||||
<div>
|
||||
<h2 className="text-3xl xl:text-4xl font-bold text-white leading-tight">
|
||||
{slide.headline}
|
||||
</h2>
|
||||
<p className="mt-3 text-neutral-400 text-sm xl:text-base leading-relaxed max-w-md">
|
||||
{slide.description}
|
||||
</p>
|
||||
|
||||
<ul className="mt-5 space-y-2.5">
|
||||
{slide.features.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2.5 text-sm text-neutral-300">
|
||||
<Check weight="bold" className="h-4 w-4 shrink-0 text-brand-orange" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Mockup */}
|
||||
<div className="relative">
|
||||
{/* Orange glow */}
|
||||
<div className="absolute -inset-8 rounded-3xl bg-brand-orange/8 blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative rounded-2xl border border-white/[0.08] overflow-hidden">
|
||||
{slide.mockup}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Dot indicators */}
|
||||
<div className="mt-8 flex items-center gap-2">
|
||||
{slides.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setActiveIndex(i)}
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
i === activeIndex
|
||||
? 'w-6 bg-brand-orange'
|
||||
: 'w-2 bg-white/25 hover:bg-white/40'
|
||||
}`}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,11 +3,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check } from '@phosphor-icons/react'
|
||||
import {
|
||||
TRAFFIC_TIERS,
|
||||
getSitesLimitForPlan,
|
||||
getMaxRetentionMonthsForPlan,
|
||||
PLAN_PRICES,
|
||||
} from '@/lib/plans'
|
||||
|
||||
@@ -30,12 +27,6 @@ export default function PlanSummary({ plan, interval, limit }: PlanSummaryProps)
|
||||
const tierLabel =
|
||||
TRAFFIC_TIERS.find((t) => t.value === limit)?.label ||
|
||||
`${(limit / 1000).toFixed(0)}k`
|
||||
const sitesLimit = getSitesLimitForPlan(plan)
|
||||
const retentionMonths = getMaxRetentionMonthsForPlan(plan)
|
||||
const retentionLabel =
|
||||
retentionMonths >= 12
|
||||
? `${retentionMonths / 12} year${retentionMonths > 12 ? 's' : ''}`
|
||||
: `${retentionMonths} months`
|
||||
|
||||
const handleIntervalToggle = (newInterval: string) => {
|
||||
setCurrentInterval(newInterval)
|
||||
@@ -44,80 +35,59 @@ export default function PlanSummary({ plan, interval, limit }: PlanSummaryProps)
|
||||
router.replace(`/checkout?${params.toString()}`, { scroll: false })
|
||||
}
|
||||
|
||||
const features = [
|
||||
`${tierLabel} pageviews/mo`,
|
||||
`${sitesLimit} site${sitesLimit && sitesLimit > 1 ? 's' : ''}`,
|
||||
`${retentionLabel} data retention`,
|
||||
'Unlimited team members',
|
||||
'Custom events & goals',
|
||||
'Funnels & user journeys',
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-800 bg-neutral-900/50 backdrop-blur-xl p-6">
|
||||
{/* Plan header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<h2 className="text-xl font-semibold text-white capitalize">{plan}</h2>
|
||||
<span className="rounded-full bg-brand-orange/15 px-3 py-0.5 text-xs font-medium text-brand-orange">
|
||||
30-day trial
|
||||
</span>
|
||||
<div className="rounded-2xl border border-neutral-800 bg-neutral-900/50 backdrop-blur-xl p-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
{/* Plan name + badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-white capitalize">{plan}</h2>
|
||||
<span className="rounded-full bg-brand-orange/15 px-3 py-0.5 text-xs font-medium text-brand-orange">
|
||||
30-day trial
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Interval toggle */}
|
||||
<div className="flex items-center gap-1 p-1 bg-neutral-800/50 rounded-xl sm:ml-auto">
|
||||
{(['month', 'year'] as const).map((iv) => (
|
||||
<button
|
||||
key={iv}
|
||||
type="button"
|
||||
onClick={() => handleIntervalToggle(iv)}
|
||||
className={`relative px-3.5 py-1.5 text-sm font-medium rounded-lg transition-colors duration-200 ${
|
||||
currentInterval === iv ? 'text-white' : 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{currentInterval === iv && (
|
||||
<motion.div
|
||||
layoutId="checkout-interval-bg"
|
||||
className="absolute inset-0 bg-neutral-700 rounded-lg shadow-sm"
|
||||
transition={{ type: 'spring', bounce: 0.15, duration: 0.35 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{iv === 'month' ? 'Monthly' : 'Yearly'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price display */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold tracking-tight text-white">
|
||||
€{isYearly ? monthlyEquivalent.toFixed(2) : displayPrice.toFixed(0)}
|
||||
</span>
|
||||
<span className="text-neutral-400 text-sm">/mo</span>
|
||||
</div>
|
||||
{/* Price row */}
|
||||
<div className="mt-4 flex items-baseline gap-2 flex-wrap">
|
||||
<span className="text-3xl font-bold tracking-tight text-white">
|
||||
€{isYearly ? monthlyEquivalent.toFixed(2) : displayPrice.toFixed(0)}
|
||||
</span>
|
||||
<span className="text-neutral-400 text-sm">/mo</span>
|
||||
<span className="text-neutral-500 text-sm">· {tierLabel} pageviews</span>
|
||||
{isYearly && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<span className="text-sm text-neutral-400">
|
||||
€{displayPrice.toFixed(2)} billed yearly
|
||||
</span>
|
||||
<span className="rounded-full bg-brand-orange/15 px-2.5 py-0.5 text-xs font-medium text-brand-orange">
|
||||
Save 1 month
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-brand-orange/15 px-2.5 py-0.5 text-xs font-medium text-brand-orange">
|
||||
Save 1 month
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interval toggle */}
|
||||
<div className="mb-6 flex items-center gap-1 p-1 bg-neutral-800/50 rounded-xl">
|
||||
{(['month', 'year'] as const).map((iv) => (
|
||||
<button
|
||||
key={iv}
|
||||
type="button"
|
||||
onClick={() => handleIntervalToggle(iv)}
|
||||
className={`relative flex-1 px-4 py-2 text-sm font-medium rounded-lg transition-colors duration-200 ${
|
||||
currentInterval === iv ? 'text-white' : 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{currentInterval === iv && (
|
||||
<motion.div
|
||||
layoutId="checkout-interval-bg"
|
||||
className="absolute inset-0 bg-neutral-700 rounded-lg shadow-sm"
|
||||
transition={{ type: 'spring', bounce: 0.15, duration: 0.35 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{iv === 'month' ? 'Monthly' : 'Yearly'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-neutral-800 mb-6" />
|
||||
|
||||
{/* Features list */}
|
||||
<ul className="space-y-3">
|
||||
{features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-3 text-sm text-neutral-300">
|
||||
<Check weight="bold" className="h-4 w-4 shrink-0 text-brand-orange" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{isYearly && (
|
||||
<p className="mt-1 text-sm text-neutral-400">
|
||||
€{displayPrice.toFixed(2)} billed yearly
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user