feat: pricing and welcome CTAs now redirect to /checkout page
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
|||||||
type Organization,
|
type Organization,
|
||||||
type OrganizationMember,
|
type OrganizationMember,
|
||||||
} from '@/lib/api/organization'
|
} from '@/lib/api/organization'
|
||||||
import { createCheckoutSession } from '@/lib/api/billing'
|
|
||||||
import { createSite, type Site } from '@/lib/api/sites'
|
import { createSite, type Site } from '@/lib/api/sites'
|
||||||
import { setSessionAction } from '@/app/actions/auth'
|
import { setSessionAction } from '@/app/actions/auth'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
@@ -88,7 +87,6 @@ function WelcomeContent() {
|
|||||||
const [orgLoading, setOrgLoading] = useState(false)
|
const [orgLoading, setOrgLoading] = useState(false)
|
||||||
const [orgError, setOrgError] = useState('')
|
const [orgError, setOrgError] = useState('')
|
||||||
|
|
||||||
const [planLoading, setPlanLoading] = useState(false)
|
|
||||||
const [planError, setPlanError] = useState('')
|
const [planError, setPlanError] = useState('')
|
||||||
|
|
||||||
const [siteName, setSiteName] = useState('')
|
const [siteName, setSiteName] = useState('')
|
||||||
@@ -98,14 +96,9 @@ function WelcomeContent() {
|
|||||||
const [createdSite, setCreatedSite] = useState<Site | null>(null)
|
const [createdSite, setCreatedSite] = useState<Site | null>(null)
|
||||||
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||||
|
|
||||||
const [redirectingCheckout, setRedirectingCheckout] = useState(false)
|
|
||||||
const [hadPendingCheckout, setHadPendingCheckout] = useState<boolean | null>(null)
|
const [hadPendingCheckout, setHadPendingCheckout] = useState<boolean | null>(null)
|
||||||
const [dismissedPendingCheckout, setDismissedPendingCheckout] = useState(false)
|
const [dismissedPendingCheckout, setDismissedPendingCheckout] = useState(false)
|
||||||
|
|
||||||
const [welcomeCountry, setWelcomeCountry] = useState('')
|
|
||||||
const [welcomeVatId, setWelcomeVatId] = useState('')
|
|
||||||
const [showBillingFields, setShowBillingFields] = useState(false)
|
|
||||||
|
|
||||||
const [organizations, setOrganizations] = useState<OrganizationMember[] | null>(null)
|
const [organizations, setOrganizations] = useState<OrganizationMember[] | null>(null)
|
||||||
const [orgsLoading, setOrgsLoading] = useState(false)
|
const [orgsLoading, setOrgsLoading] = useState(false)
|
||||||
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
||||||
@@ -216,36 +209,14 @@ function WelcomeContent() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show billing fields first if country not yet selected
|
trackWelcomePlanContinue()
|
||||||
if (!welcomeCountry) {
|
|
||||||
setShowBillingFields(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setPlanLoading(true)
|
|
||||||
setPlanError('')
|
|
||||||
try {
|
try {
|
||||||
trackWelcomePlanContinue()
|
const { planId, interval, limit } = JSON.parse(raw)
|
||||||
const intent = JSON.parse(raw)
|
|
||||||
const { url } = await createCheckoutSession({
|
|
||||||
plan_id: intent.planId,
|
|
||||||
interval: intent.interval || 'month',
|
|
||||||
limit: intent.limit ?? 100000,
|
|
||||||
country: welcomeCountry,
|
|
||||||
vat_id: welcomeVatId || undefined,
|
|
||||||
})
|
|
||||||
localStorage.removeItem('pulse_pending_checkout')
|
localStorage.removeItem('pulse_pending_checkout')
|
||||||
if (url) {
|
router.push(`/checkout?plan=${planId}&interval=${interval || 'month'}&limit=${limit ?? 100000}`)
|
||||||
setRedirectingCheckout(true)
|
} catch {
|
||||||
window.location.href = url
|
setPlanError('Failed to parse checkout data')
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error('No checkout URL returned')
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setPlanError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to start checkout')
|
|
||||||
localStorage.removeItem('pulse_pending_checkout')
|
localStorage.removeItem('pulse_pending_checkout')
|
||||||
} finally {
|
|
||||||
setPlanLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,15 +304,6 @@ function WelcomeContent() {
|
|||||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Switching organization..." />
|
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Switching organization..." />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirectingCheckout || (planLoading && step === 3)) {
|
|
||||||
return (
|
|
||||||
<LoadingOverlay
|
|
||||||
logoSrc="/pulse_icon_no_margins.png"
|
|
||||||
title={redirectingCheckout ? 'Taking you to checkout...' : 'Preparing your plan...'}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardClass =
|
const cardClass =
|
||||||
'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-6 max-w-lg mx-auto'
|
'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-sm p-6 max-w-lg mx-auto'
|
||||||
|
|
||||||
@@ -581,52 +543,6 @@ function WelcomeContent() {
|
|||||||
{planError && (
|
{planError && (
|
||||||
<p className="text-sm text-red-500 dark:text-red-400 mb-4 text-center">{planError}</p>
|
<p className="text-sm text-red-500 dark:text-red-400 mb-4 text-center">{planError}</p>
|
||||||
)}
|
)}
|
||||||
{showBillingFields && showPendingCheckoutInStep3 && (
|
|
||||||
<div className="space-y-3 mb-5">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="welcome-country" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
||||||
Country <span className="text-red-400">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="welcome-country"
|
|
||||||
required
|
|
||||||
value={welcomeCountry}
|
|
||||||
onChange={(e) => setWelcomeCountry(e.target.value)}
|
|
||||||
className="w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2.5 text-sm text-neutral-900 dark:text-white focus:border-brand-orange focus:outline-none focus:ring-1 focus:ring-brand-orange transition-colors"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select a country</option>
|
|
||||||
{[
|
|
||||||
{ code: 'BE', label: 'Belgium' }, { code: 'NL', label: 'Netherlands' }, { code: 'DE', label: 'Germany' },
|
|
||||||
{ code: 'FR', label: 'France' }, { code: 'AT', label: 'Austria' }, { code: 'IT', label: 'Italy' },
|
|
||||||
{ code: 'ES', label: 'Spain' }, { code: 'PT', label: 'Portugal' }, { code: 'IE', label: 'Ireland' },
|
|
||||||
{ code: 'LU', label: 'Luxembourg' }, { code: 'FI', label: 'Finland' }, { code: 'SE', label: 'Sweden' },
|
|
||||||
{ code: 'DK', label: 'Denmark' }, { code: 'PL', label: 'Poland' }, { code: 'CZ', label: 'Czech Republic' },
|
|
||||||
{ code: 'RO', label: 'Romania' }, { code: 'BG', label: 'Bulgaria' }, { code: 'HR', label: 'Croatia' },
|
|
||||||
{ code: 'SI', label: 'Slovenia' }, { code: 'SK', label: 'Slovakia' }, { code: 'HU', label: 'Hungary' },
|
|
||||||
{ code: 'LT', label: 'Lithuania' }, { code: 'LV', label: 'Latvia' }, { code: 'EE', label: 'Estonia' },
|
|
||||||
{ code: 'MT', label: 'Malta' }, { code: 'CY', label: 'Cyprus' }, { code: 'GR', label: 'Greece' },
|
|
||||||
{ code: 'US', label: 'United States' }, { code: 'GB', label: 'United Kingdom' },
|
|
||||||
{ code: 'CH', label: 'Switzerland' }, { code: 'NO', label: 'Norway' },
|
|
||||||
{ code: 'CA', label: 'Canada' }, { code: 'AU', label: 'Australia' }, { code: 'OTHER', label: 'Other' },
|
|
||||||
].map((c) => (
|
|
||||||
<option key={c.code} value={c.code}>{c.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="welcome-vat" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
||||||
VAT ID <span className="text-neutral-500">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="welcome-vat"
|
|
||||||
type="text"
|
|
||||||
value={welcomeVatId}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setWelcomeVatId(e.target.value)}
|
|
||||||
placeholder="e.g. BE0123456789"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
{showPendingCheckoutInStep3 ? (
|
{showPendingCheckoutInStep3 ? (
|
||||||
<>
|
<>
|
||||||
@@ -634,15 +550,13 @@ function WelcomeContent() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
onClick={handlePlanContinue}
|
onClick={handlePlanContinue}
|
||||||
disabled={planLoading || (showBillingFields && !welcomeCountry)}
|
|
||||||
>
|
>
|
||||||
{showBillingFields ? 'Continue to payment' : 'Continue to checkout'}
|
Continue to checkout
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
onClick={handlePlanSkip}
|
onClick={handlePlanSkip}
|
||||||
disabled={planLoading}
|
|
||||||
>
|
>
|
||||||
Stay on free plan
|
Stay on free plan
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { logger } from '@/lib/utils/logger'
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
|
import { Button, CheckCircleIcon } from '@ciphera-net/ui'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { initiateOAuthFlow } from '@/lib/api/oauth'
|
import { initiateOAuthFlow } from '@/lib/api/oauth'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { COUNTRY_OPTIONS } from '@/lib/countries'
|
|
||||||
import { createCheckoutSession } from '@/lib/api/billing'
|
|
||||||
|
|
||||||
// 1. Define Plans with IDs and Site Limits
|
// 1. Define Plans with IDs and Site Limits
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
@@ -103,20 +101,12 @@ const TRAFFIC_TIERS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const PRICING_COUNTRY_OPTIONS = [
|
|
||||||
...COUNTRY_OPTIONS.map((c) => ({ code: c.value, label: c.label })),
|
|
||||||
{ code: 'OTHER', label: 'Other' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function PricingSection() {
|
export default function PricingSection() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
const [isYearly, setIsYearly] = useState(false)
|
const [isYearly, setIsYearly] = useState(false)
|
||||||
const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2)
|
const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2)
|
||||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
|
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
|
||||||
const [checkoutCountry, setCheckoutCountry] = useState('')
|
|
||||||
const [checkoutVatId, setCheckoutVatId] = useState('')
|
|
||||||
const [showCheckoutForm, setShowCheckoutForm] = useState(false)
|
|
||||||
const [pendingCheckout, setPendingCheckout] = useState<{ planId: string; interval: string; limit: number } | null>(null)
|
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
// * Show toast when redirected from Mollie Checkout with canceled=true
|
// * Show toast when redirected from Mollie Checkout with canceled=true
|
||||||
@@ -176,7 +166,7 @@ export default function PricingSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubscribe = async (planId: string, options?: { interval?: string, limit?: number }) => {
|
const handleSubscribe = (planId: string, options?: { interval?: string, limit?: number }) => {
|
||||||
// 1. If not logged in, redirect to login/signup
|
// 1. If not logged in, redirect to login/signup
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const intent = {
|
const intent = {
|
||||||
@@ -191,49 +181,10 @@ export default function PricingSection() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Show checkout form to collect country + optional VAT
|
// 2. Navigate to embedded checkout page
|
||||||
const interval = options?.interval || (isYearly ? 'year' : 'month')
|
const selectedInterval = options?.interval || (isYearly ? 'year' : 'month')
|
||||||
const limit = options?.limit || currentTraffic.value
|
const selectedLimit = options?.limit || currentTraffic.value
|
||||||
setPendingCheckout({ planId, interval, limit })
|
router.push(`/checkout?plan=${planId}&interval=${selectedInterval}&limit=${selectedLimit}`)
|
||||||
setCheckoutCountry('')
|
|
||||||
setCheckoutVatId('')
|
|
||||||
setShowCheckoutForm(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCheckoutSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!pendingCheckout || !checkoutCountry) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoadingPlan(pendingCheckout.planId)
|
|
||||||
|
|
||||||
const { url } = await createCheckoutSession({
|
|
||||||
plan_id: pendingCheckout.planId,
|
|
||||||
interval: pendingCheckout.interval,
|
|
||||||
limit: pendingCheckout.limit,
|
|
||||||
country: checkoutCountry,
|
|
||||||
vat_id: checkoutVatId || undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
window.location.href = url
|
|
||||||
} else {
|
|
||||||
throw new Error('No checkout URL returned')
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.error('Checkout error:', error)
|
|
||||||
toast.error('Failed to start checkout — please try again')
|
|
||||||
} finally {
|
|
||||||
setLoadingPlan(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCheckoutCancel = () => {
|
|
||||||
setShowCheckoutForm(false)
|
|
||||||
setPendingCheckout(null)
|
|
||||||
setCheckoutCountry('')
|
|
||||||
setCheckoutVatId('')
|
|
||||||
setLoadingPlan(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -463,84 +414,6 @@ export default function PricingSection() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Checkout Country / VAT Modal */}
|
|
||||||
{showCheckoutForm && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center px-4">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/60"
|
|
||||||
onClick={handleCheckoutCancel}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="relative w-full max-w-md border border-neutral-800 rounded-2xl bg-neutral-900 p-6 shadow-xl"
|
|
||||||
>
|
|
||||||
<h3 className="text-lg font-bold text-white mb-1">
|
|
||||||
Billing details
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-neutral-400 mb-6">
|
|
||||||
Select your country to calculate the correct tax rate.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleCheckoutSubmit} className="space-y-4">
|
|
||||||
{/* Country */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="checkout-country" className="block text-sm font-medium text-neutral-300 mb-1.5">
|
|
||||||
Country <span className="text-red-400">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="checkout-country"
|
|
||||||
required
|
|
||||||
value={checkoutCountry}
|
|
||||||
onChange={(e) => setCheckoutCountry(e.target.value)}
|
|
||||||
className="w-full rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 focus:border-brand-orange focus:outline-none focus:ring-1 focus:ring-brand-orange transition-colors"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select a country</option>
|
|
||||||
{PRICING_COUNTRY_OPTIONS.map((c) => (
|
|
||||||
<option key={c.code} value={c.code}>{c.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VAT ID */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="checkout-vat" className="block text-sm font-medium text-neutral-300 mb-1.5">
|
|
||||||
VAT ID <span className="text-neutral-500">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="checkout-vat"
|
|
||||||
type="text"
|
|
||||||
value={checkoutVatId}
|
|
||||||
onChange={(e) => setCheckoutVatId(e.target.value)}
|
|
||||||
placeholder="e.g. BE0123456789"
|
|
||||||
className="w-full rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 focus:border-brand-orange focus:outline-none focus:ring-1 focus:ring-brand-orange transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
disabled={!checkoutCountry || !!loadingPlan}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{loadingPlan ? 'Loading...' : 'Continue to payment'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleCheckoutCancel}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user