Files
pulse/components/PricingSection.tsx
Usman Baig a495ef8389 fix: only show slider focus ring on keyboard navigation
Replace focus: with focus-visible: on range input so the
orange ring only appears during keyboard nav, not on click.
2026-03-27 12:26:54 +01:00

422 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect } from 'react'
import { logger } from '@/lib/utils/logger'
import { useSearchParams, useRouter } from 'next/navigation'
import { motion } from 'framer-motion'
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context'
import { initiateOAuthFlow } from '@/lib/api/oauth'
import { toast } from '@ciphera-net/ui'
// 1. Define Plans with IDs and Site Limits
const PLANS = [
{
id: 'solo',
name: 'Solo',
description: 'For personal sites and freelancers',
features: [
'1 site',
'1 year data retention',
'Email reports',
'100% Data ownership'
]
},
{
id: 'team',
name: 'Team',
description: 'For startups and growing agencies',
features: [
'Up to 5 sites',
'2 year data retention',
'Team dashboard',
'Shared links'
]
},
{
id: 'business',
name: 'Business',
description: 'For large organizations',
features: [
'Up to 10 sites',
'3 years data retention',
'Priority support',
'Custom events'
]
}
]
// 2. Define Explicit Pricing per Tier (approx 20% cheaper than Plausible)
// Includes intermediate steps: 50k, 250k, 2.5M
const TRAFFIC_TIERS = [
{
label: '10k',
value: 10000,
prices: { solo: 7, team: 11, business: 15 }
},
{
label: '50k',
value: 50000,
prices: { solo: 11, team: 19, business: 27 }
},
{
label: '100k',
value: 100000,
prices: { solo: 15, team: 23, business: 31 }
},
{
label: '250k',
value: 250000,
prices: { solo: 25, team: 39, business: 59 }
},
{
label: '500k',
value: 500000,
prices: { solo: 39, team: 59, business: 79 }
},
{
label: '1M',
value: 1000000,
prices: { solo: 55, team: 79, business: 111 }
},
{
label: '2.5M',
value: 2500000,
prices: { solo: 79, team: 119, business: 169 }
},
{
label: '5M',
value: 5000000,
prices: { solo: 103, team: 155, business: 207 }
},
{
label: '10M',
value: 10000000,
prices: { solo: 135, team: 199, business: 269 }
},
{
label: '10M+',
value: 10000001,
prices: { solo: null, team: null, business: null }
},
]
export default function PricingSection() {
const searchParams = useSearchParams()
const router = useRouter()
const [isYearly, setIsYearly] = useState(false)
const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2)
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
const { user } = useAuth()
// * Show toast when redirected from Mollie Checkout with canceled=true
useEffect(() => {
if (searchParams.get('canceled') === 'true') {
toast.info('Checkout was canceled. You can try again whenever youre ready.')
const url = new URL(window.location.href)
url.searchParams.delete('canceled')
window.history.replaceState({}, '', url.pathname + url.search)
}
}, [searchParams])
// * Check for pending checkout on mount/auth
useEffect(() => {
if (!user) return
const pendingCheckout = localStorage.getItem('pulse_pending_checkout')
if (pendingCheckout) {
try {
const intent = JSON.parse(pendingCheckout)
// Restore UI state
if (typeof intent.sliderIndex === 'number') setSliderIndex(intent.sliderIndex)
if (typeof intent.isYearly === 'boolean') setIsYearly(intent.isYearly)
// Trigger checkout
handleSubscribe(intent.planId, {
interval: intent.interval,
limit: intent.limit
})
// Clear intent
localStorage.removeItem('pulse_pending_checkout')
} catch (e) {
logger.error('Failed to parse pending checkout', e)
localStorage.removeItem('pulse_pending_checkout')
}
}
}, [user])
const currentTraffic = TRAFFIC_TIERS[sliderIndex]
// Helper to get all price details
const getPriceDetails = (planId: string) => {
const basePrice = currentTraffic.prices[planId as keyof typeof currentTraffic.prices]
// Handle "Custom"
if (basePrice === null || basePrice === undefined) return null
const yearlyTotal = basePrice * 11 // 1 month free (pay for 11)
const effectiveMonthly = Math.round(yearlyTotal / 12)
return {
baseMonthly: basePrice,
yearlyTotal: yearlyTotal,
effectiveMonthly: effectiveMonthly
}
}
const handleSubscribe = (planId: string, options?: { interval?: string, limit?: number }) => {
// 1. If not logged in, redirect to login/signup
if (!user) {
const intent = {
planId,
interval: isYearly ? 'year' : 'month',
limit: currentTraffic.value,
sliderIndex,
isYearly
}
localStorage.setItem('pulse_pending_checkout', JSON.stringify(intent))
initiateOAuthFlow()
return
}
// 2. Navigate to embedded checkout page
const selectedInterval = options?.interval || (isYearly ? 'year' : 'month')
const selectedLimit = options?.limit || currentTraffic.value
router.push(`/checkout?plan=${planId}&interval=${selectedInterval}&limit=${selectedLimit}`)
}
return (
<section className="py-24 px-4 max-w-6xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<h2 className="text-3xl font-bold text-white mb-4">
Transparent Pricing
</h2>
<p className="text-lg text-neutral-400">
Scale with your traffic. No hidden fees.
</p>
</motion.div>
{/* Unified Container */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="max-w-6xl mx-auto border border-neutral-800 rounded-2xl bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20"
>
{/* Top Toolbar */}
<div className="p-6 border-b border-neutral-800 flex flex-col md:flex-row items-center justify-between gap-8 bg-neutral-900/50">
<div className="w-full md:w-2/3">
<div className="flex justify-between text-sm font-medium text-neutral-400 mb-4">
<span>10k</span>
<span className="text-brand-orange font-bold text-lg">
Up to {currentTraffic.label} monthly pageviews
</span>
<span>10M+</span>
</div>
<input
type="range"
min="0"
max={TRAFFIC_TIERS.length - 1}
step="1"
value={sliderIndex}
onChange={(e) => setSliderIndex(parseInt(e.target.value))}
aria-label="Monthly pageview limit"
aria-valuetext={`${currentTraffic.label} pageviews per month`}
className="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-brand-orange focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
/>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<span className="text-xs text-neutral-400 font-medium uppercase tracking-wide">
Get 1 month free with yearly
</span>
<div className="bg-neutral-800 p-1 rounded-lg flex" role="radiogroup" aria-label="Billing interval">
<button
onClick={() => setIsYearly(false)}
role="radio"
aria-checked={!isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
!isYearly
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-white'
}`}
>
Monthly
</button>
<button
onClick={() => setIsYearly(true)}
role="radio"
aria-checked={isYearly}
className={`min-w-[88px] px-4 py-2 rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange ${
isYearly
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-white'
}`}
>
Yearly
</button>
</div>
</div>
</div>
{/* Pricing Grid */}
<div className="grid md:grid-cols-5 divide-y md:divide-y-0 md:divide-x divide-neutral-800">
{/* Free Plan */}
<div className="p-6 flex flex-col relative transition-colors hover:bg-neutral-800/50">
<div className="mb-8">
<h3 className="text-lg font-bold text-white mb-2">Free</h3>
<p className="text-sm text-neutral-400 min-h-[40px] mb-4">For trying Pulse on a personal project</p>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold text-white">0</span>
<span className="text-neutral-400 font-medium">/forever</span>
</div>
</div>
<Button
onClick={() => {
if (!user) {
initiateOAuthFlow()
return
}
window.location.href = '/'
}}
variant="secondary"
className="w-full mb-8"
>
Get started
</Button>
<ul className="space-y-4 flex-grow">
{['1 site', '5k monthly pageviews', '6 months data retention', '100% Data ownership'].map((feature) => (
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-400">
<CheckCircleIcon className="w-5 h-5 shrink-0 text-neutral-400" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
{PLANS.map((plan) => {
const priceDetails = getPriceDetails(plan.id)
const isTeam = plan.id === 'team'
return (
<div key={plan.id} className={`p-6 flex flex-col relative transition-colors ${isTeam ? 'bg-brand-orange/[0.02]' : 'hover:bg-neutral-800/50'}`}>
{isTeam && (
<>
<div className="absolute top-0 left-0 w-full h-1 bg-brand-orange" />
<span className="absolute top-4 right-4 badge-primary">
Most Popular
</span>
</>
)}
<div className="mb-8">
<h3 className="text-lg font-bold text-white mb-2">{plan.name}</h3>
<p className="text-sm text-neutral-400 min-h-[40px] mb-4">{plan.description}</p>
{priceDetails ? (
isYearly ? (
<div>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold text-white">
{priceDetails.yearlyTotal}
</span>
<span className="text-neutral-400 font-medium">/year</span>
<span className="text-xs text-neutral-500 ml-1">excl. VAT</span>
</div>
<div className="flex items-center gap-2 mt-2 text-sm font-medium">
<span className="text-neutral-400 line-through decoration-neutral-400">
{priceDetails.baseMonthly}/mo
</span>
<span className="text-brand-orange">
{priceDetails.effectiveMonthly}/mo
</span>
</div>
</div>
) : (
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold text-white">
{priceDetails.baseMonthly}
</span>
<span className="text-neutral-400 font-medium">/mo</span>
<span className="text-xs text-neutral-500 ml-1">excl. VAT</span>
</div>
)
) : (
<div className="text-4xl font-bold text-white">
Custom
</div>
)}
</div>
<Button
onClick={() => handleSubscribe(plan.id)}
disabled={loadingPlan === plan.id || !!loadingPlan || !priceDetails}
variant={isTeam ? 'primary' : 'secondary'}
className="w-full mb-8"
>
{loadingPlan === plan.id ? 'Loading...' : !priceDetails ? 'Contact us' : 'Start free trial'}
</Button>
<ul className="space-y-4 flex-grow">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-400">
<CheckCircleIcon className={`w-5 h-5 shrink-0 ${isTeam ? 'text-brand-orange' : 'text-neutral-400'}`} />
<span>{feature}</span>
</li>
))}
</ul>
</div>
)
})}
{/* Enterprise Section */}
<div className="p-6 bg-neutral-900/50 flex flex-col">
<div className="mb-8">
<h3 className="text-lg font-bold text-white mb-2">Enterprise</h3>
<p className="text-sm text-neutral-400 min-h-[40px] mb-4">For high volume sites and custom needs</p>
<div className="text-4xl font-bold text-white">
Custom
</div>
</div>
<Button
variant="secondary"
className="w-full mb-8"
onClick={() => { window.location.href = 'mailto:business@ciphera.net?subject=Enterprise%20Plan%20Inquiry' }}
>
Contact us
</Button>
<ul className="space-y-4">
{[
'Everything in Business',
'10+ sites',
'Unlimited team members',
'SLA & Priority Support',
'Managed Proxy',
'Raw data export'
].map((feature) => (
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-400">
<CheckCircleIcon className="w-5 h-5 text-neutral-400 shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
</div>
</motion.div>
</section>
)
}