feat: Perfect onboarding journey after Stripe payment

NEW WELCOME PAGE (/command/welcome):
- Celebratory confetti animation on arrival
- Plan-specific welcome message (Trader/Tycoon)
- Features unlocked section with icons
- Next steps with quick links to key features
- Link to documentation and support

UPDATED USER JOURNEY:

1. Pricing Page (/pricing)
   ↓ Click plan button
2. (If not logged in) → Register → Back to Pricing
   ↓ Click plan button
3. Stripe Checkout (external)
   ↓ Payment successful
4. Welcome Page (/command/welcome?plan=trader)
   - Shows unlocked features
   - Guided next steps
   ↓ 'Go to Dashboard'
5. Dashboard (/command/dashboard)

CANCEL FLOW:
- Stripe Cancel → /pricing?cancelled=true
- Shows friendly banner: 'No worries! Card not charged.'
- Dismissible with X button
- URL cleaned up automatically

BACKEND UPDATES:
- Default success URL: /command/welcome?plan={plan}
- Default cancel URL: /pricing?cancelled=true
- Portal return URL: /command/settings (not /dashboard)

This creates a complete, professional onboarding experience
that celebrates the upgrade and guides users to get started.
This commit is contained in:
yves.gugger
2025-12-10 16:17:29 +01:00
parent 2f2a5218df
commit bc8d9cc8a3
3 changed files with 257 additions and 5 deletions

View File

@ -225,7 +225,7 @@ async def create_checkout_session(
# Get site URL from environment
site_url = os.getenv("SITE_URL", "http://localhost:3000")
success_url = request.success_url or f"{site_url}/dashboard?upgraded=true"
success_url = request.success_url or f"{site_url}/command/welcome?plan={request.plan}"
cancel_url = request.cancel_url or f"{site_url}/pricing?cancelled=true"
try:
@ -285,7 +285,7 @@ async def create_portal_session(
)
site_url = os.getenv("SITE_URL", "http://localhost:3000")
return_url = f"{site_url}/dashboard"
return_url = f"{site_url}/command/settings"
try:
portal_url = await StripeService.create_portal_session(

View File

@ -0,0 +1,221 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PageContainer } from '@/components/PremiumTable'
import { useStore } from '@/lib/store'
import {
CheckCircle,
Zap,
Crown,
ArrowRight,
Eye,
Store,
Bell,
BarChart3,
Sparkles,
TrendingUp,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
const planDetails = {
trader: {
name: 'Trader',
icon: TrendingUp,
color: 'text-accent',
bgColor: 'bg-accent/10',
features: [
{ icon: Eye, text: '50 domains in watchlist', description: 'Track up to 50 domains at once' },
{ icon: Zap, text: 'Hourly availability checks', description: '24x faster than Scout' },
{ icon: Store, text: '10 For Sale listings', description: 'List your domains on the marketplace' },
{ icon: Bell, text: '5 Sniper Alerts', description: 'Get notified when specific domains drop' },
{ icon: BarChart3, text: 'Deal scores & valuations', description: 'Know what domains are worth' },
],
nextSteps: [
{ href: '/command/watchlist', label: 'Add domains to watchlist', icon: Eye },
{ href: '/command/alerts', label: 'Set up Sniper Alerts', icon: Bell },
{ href: '/command/portfolio', label: 'Track your portfolio', icon: BarChart3 },
],
},
tycoon: {
name: 'Tycoon',
icon: Crown,
color: 'text-amber-400',
bgColor: 'bg-amber-400/10',
features: [
{ icon: Eye, text: '500 domains in watchlist', description: 'Massive tracking capacity' },
{ icon: Zap, text: 'Real-time checks (10 min)', description: 'Never miss a drop' },
{ icon: Store, text: '50 For Sale listings', description: 'Full marketplace access' },
{ icon: Bell, text: 'Unlimited Sniper Alerts', description: 'Set as many as you need' },
{ icon: Sparkles, text: 'SEO Juice Detector', description: 'Find domains with backlinks' },
],
nextSteps: [
{ href: '/command/watchlist', label: 'Add domains to watchlist', icon: Eye },
{ href: '/command/seo', label: 'Analyze SEO metrics', icon: Sparkles },
{ href: '/command/alerts', label: 'Create Sniper Alerts', icon: Bell },
],
},
}
export default function WelcomePage() {
const router = useRouter()
const searchParams = useSearchParams()
const { fetchSubscription, checkAuth } = useStore()
const [loading, setLoading] = useState(true)
const [showConfetti, setShowConfetti] = useState(true)
const planId = searchParams.get('plan') as 'trader' | 'tycoon' | null
const plan = planId && planDetails[planId] ? planDetails[planId] : planDetails.trader
useEffect(() => {
const init = async () => {
await checkAuth()
await fetchSubscription()
setLoading(false)
}
init()
// Hide confetti after animation
const timer = setTimeout(() => setShowConfetti(false), 3000)
return () => clearTimeout(timer)
}, [checkAuth, fetchSubscription])
if (loading) {
return (
<CommandCenterLayout title="Welcome" subtitle="Loading your new plan...">
<PageContainer>
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
</PageContainer>
</CommandCenterLayout>
)
}
return (
<CommandCenterLayout title="Welcome" subtitle="Your upgrade is complete">
<PageContainer>
{/* Confetti Effect */}
{showConfetti && (
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
{Array.from({ length: 50 }).map((_, i) => (
<div
key={i}
className="absolute w-2 h-2 rounded-full animate-[confetti_3s_ease-out_forwards]"
style={{
left: `${Math.random() * 100}%`,
top: '-10px',
backgroundColor: ['#10b981', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6'][Math.floor(Math.random() * 5)],
animationDelay: `${Math.random() * 0.5}s`,
}}
/>
))}
</div>
)}
{/* Success Header */}
<div className="text-center mb-12">
<div className={clsx(
"inline-flex items-center justify-center w-20 h-20 rounded-full mb-6",
plan.bgColor
)}>
<CheckCircle className={clsx("w-10 h-10", plan.color)} />
</div>
<h1 className="text-3xl sm:text-4xl font-semibold text-foreground mb-3">
Welcome to {plan.name}!
</h1>
<p className="text-lg text-foreground-muted max-w-lg mx-auto">
Your payment was successful. You now have access to all {plan.name} features.
</p>
</div>
{/* Features Unlocked */}
<div className="mb-12">
<h2 className="text-lg font-semibold text-foreground mb-6 text-center">
Features Unlocked
</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{plan.features.map((feature, i) => (
<div
key={i}
className="p-5 bg-background-secondary/50 border border-border/50 rounded-xl
animate-slide-up"
style={{ animationDelay: `${i * 100}ms` }}
>
<div className="flex items-start gap-4">
<div className={clsx("w-10 h-10 rounded-xl flex items-center justify-center shrink-0", plan.bgColor)}>
<feature.icon className={clsx("w-5 h-5", plan.color)} />
</div>
<div>
<p className="font-medium text-foreground">{feature.text}</p>
<p className="text-sm text-foreground-muted mt-1">{feature.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
{/* Next Steps */}
<div className="mb-12">
<h2 className="text-lg font-semibold text-foreground mb-6 text-center">
Get Started
</h2>
<div className="max-w-2xl mx-auto space-y-3">
{plan.nextSteps.map((step, i) => (
<Link
key={i}
href={step.href}
className="flex items-center justify-between p-5 bg-background-secondary/50 border border-border/50 rounded-xl
hover:border-accent/30 hover:bg-background-secondary transition-all group"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-foreground/5 flex items-center justify-center
group-hover:bg-accent/10 transition-colors">
<step.icon className="w-5 h-5 text-foreground-muted group-hover:text-accent transition-colors" />
</div>
<span className="font-medium text-foreground">{step.label}</span>
</div>
<ArrowRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent transition-colors" />
</Link>
))}
</div>
</div>
{/* Go to Dashboard */}
<div className="text-center">
<Link
href="/command/dashboard"
className="inline-flex items-center gap-2 px-8 py-4 bg-accent text-background font-medium rounded-xl
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
>
Go to Dashboard
<ArrowRight className="w-4 h-4" />
</Link>
<p className="text-sm text-foreground-muted mt-4">
Need help? Check out our <Link href="/docs" className="text-accent hover:underline">documentation</Link> or{' '}
<Link href="mailto:support@pounce.ch" className="text-accent hover:underline">contact support</Link>.
</p>
</div>
</PageContainer>
{/* Custom CSS for confetti animation */}
<style jsx>{`
@keyframes confetti {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
`}</style>
</CommandCenterLayout>
)
}

View File

@ -6,7 +6,7 @@ import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { Check, ArrowRight, Zap, TrendingUp, Crown, Loader2, Clock, X } from 'lucide-react'
import { Check, ArrowRight, Zap, TrendingUp, Crown, Loader2, Clock, X, AlertCircle } from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
@ -119,9 +119,20 @@ export default function PricingPage() {
const { checkAuth, isLoading, isAuthenticated } = useStore()
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
const [expandedFaq, setExpandedFaq] = useState<number | null>(null)
const [showCancelledBanner, setShowCancelledBanner] = useState(false)
useEffect(() => {
checkAuth()
// Check if user cancelled checkout
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search)
if (params.get('cancelled') === 'true') {
setShowCancelledBanner(true)
// Clean up URL
window.history.replaceState({}, '', '/pricing')
}
}
}, [checkAuth])
const handleSelectPlan = async (planId: string, isPaid: boolean) => {
@ -139,8 +150,8 @@ export default function PricingPage() {
try {
const response = await api.createCheckoutSession(
planId,
`${window.location.origin}/dashboard?upgraded=true`,
`${window.location.origin}/pricing`
`${window.location.origin}/command/welcome?plan=${planId}`,
`${window.location.origin}/pricing?cancelled=true`
)
window.location.href = response.checkout_url
} catch (error) {
@ -168,6 +179,26 @@ export default function PricingPage() {
<main className="flex-1 relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
<div className="max-w-6xl mx-auto">
{/* Cancelled Banner */}
{showCancelledBanner && (
<div className="mb-8 p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3 animate-fade-in">
<AlertCircle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-amber-400">Checkout cancelled</p>
<p className="text-sm text-foreground-muted mt-1">
No worries! Your card was not charged. You can try again whenever you&apos;re ready,
or continue with the free Scout plan.
</p>
</div>
<button
onClick={() => setShowCancelledBanner(false)}
className="p-1 text-foreground-muted hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Hero */}
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Pricing</span>