Skip to content

Commit 4dbc963

Browse files
committed
feat: replace disappearing toast with proper UpgradeLimitModal
- Create UpgradeLimitModal component with: - Clear error message display - Free vs Pro limit comparison - Built-in waitlist signup form - 30% early bird discount callout - Stays visible until user dismisses - Update DashboardHome to use modal instead of toast - Shows repo name and specific error reason - Much better UX for upgrade prompts
1 parent 0e55198 commit 4dbc963

2 files changed

Lines changed: 191 additions & 20 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { useState } from 'react'
2+
import { motion, AnimatePresence } from 'framer-motion'
3+
import { X, Loader2, CheckCircle2, Rocket, AlertTriangle, Zap } from 'lucide-react'
4+
import { Button } from '@/components/ui/button'
5+
6+
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
7+
8+
interface UpgradeLimitModalProps {
9+
isOpen: boolean
10+
onClose: () => void
11+
errorMessage: string
12+
repoName?: string
13+
}
14+
15+
export function UpgradeLimitModal({ isOpen, onClose, errorMessage, repoName }: UpgradeLimitModalProps) {
16+
const [email, setEmail] = useState('')
17+
const [sending, setSending] = useState(false)
18+
const [sent, setSent] = useState(false)
19+
const [error, setError] = useState('')
20+
21+
const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
22+
23+
const handleSubmit = async (e: React.FormEvent) => {
24+
e.preventDefault()
25+
if (!isValidEmail || sending) return
26+
27+
setSending(true)
28+
setError('')
29+
30+
try {
31+
const response = await fetch(`${API_URL}/api/v1/feedback/waitlist`, {
32+
method: 'POST',
33+
headers: { 'Content-Type': 'application/json' },
34+
body: JSON.stringify({ email, plan: 'pro' }),
35+
})
36+
if (!response.ok) throw new Error('Failed to submit')
37+
setSent(true)
38+
} catch {
39+
setError('Something went wrong. Please try again.')
40+
} finally {
41+
setSending(false)
42+
}
43+
}
44+
45+
const handleClose = () => {
46+
if (!sending) {
47+
onClose()
48+
setEmail('')
49+
setError('')
50+
setSent(false)
51+
}
52+
}
53+
54+
return (
55+
<AnimatePresence>
56+
{isOpen && (
57+
<motion.div
58+
initial={{ opacity: 0 }}
59+
animate={{ opacity: 1 }}
60+
exit={{ opacity: 0 }}
61+
className="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50 p-4"
62+
onClick={handleClose}
63+
>
64+
<motion.div
65+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
66+
animate={{ opacity: 1, scale: 1, y: 0 }}
67+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
68+
onClick={e => e.stopPropagation()}
69+
className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden"
70+
>
71+
{/* Header with warning */}
72+
<div className="relative p-6 pb-4 border-b border-border bg-gradient-to-br from-amber-500/10 to-transparent">
73+
<button
74+
onClick={handleClose}
75+
disabled={sending}
76+
className="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
77+
>
78+
<X className="w-4 h-4" />
79+
</button>
80+
81+
<div className="flex items-start gap-4">
82+
<div className="w-12 h-12 rounded-xl bg-amber-500/20 border border-amber-500/30 flex items-center justify-center shrink-0">
83+
<AlertTriangle className="w-6 h-6 text-amber-400" />
84+
</div>
85+
<div>
86+
<h3 className="text-lg font-semibold text-foreground">
87+
{repoName ? `Can't import ${repoName}` : 'Repository Limit Reached'}
88+
</h3>
89+
<p className="text-sm text-muted-foreground mt-1">
90+
{errorMessage}
91+
</p>
92+
</div>
93+
</div>
94+
</div>
95+
96+
{/* Limit comparison */}
97+
<div className="p-6 border-b border-border">
98+
<p className="text-sm font-medium text-foreground mb-3">Plan Comparison</p>
99+
<div className="grid grid-cols-2 gap-3">
100+
<div className="p-3 rounded-lg bg-muted/50 border border-border">
101+
<p className="text-xs text-muted-foreground mb-1">Free (Current)</p>
102+
<p className="text-sm font-medium text-foreground">2,000 functions</p>
103+
<p className="text-xs text-muted-foreground">500 files per repo</p>
104+
</div>
105+
<div className="p-3 rounded-lg bg-accent/10 border border-accent/30">
106+
<p className="text-xs text-accent mb-1">Pro</p>
107+
<p className="text-sm font-medium text-foreground">20,000 functions</p>
108+
<p className="text-xs text-muted-foreground">5,000 files per repo</p>
109+
</div>
110+
</div>
111+
</div>
112+
113+
{/* Waitlist form */}
114+
<div className="p-6">
115+
{sent ? (
116+
<motion.div
117+
initial={{ opacity: 0, scale: 0.9 }}
118+
animate={{ opacity: 1, scale: 1 }}
119+
className="py-4 text-center"
120+
>
121+
<CheckCircle2 className="w-12 h-12 text-green-500 mx-auto mb-3" />
122+
<p className="text-lg font-medium text-foreground">You're on the list!</p>
123+
<p className="text-sm text-muted-foreground mt-1">
124+
We'll notify you when Pro is available
125+
</p>
126+
<Button onClick={handleClose} variant="outline" className="mt-4">
127+
Got it
128+
</Button>
129+
</motion.div>
130+
) : (
131+
<>
132+
<p className="text-sm text-muted-foreground mb-3">
133+
Join the Pro waitlist to unlock higher limits
134+
</p>
135+
<form onSubmit={handleSubmit} className="space-y-3">
136+
<div className="flex gap-2">
137+
<input
138+
type="email"
139+
value={email}
140+
onChange={e => setEmail(e.target.value)}
141+
placeholder="your@email.com"
142+
className="flex-1 px-4 py-2.5 bg-muted border border-border rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-accent/50"
143+
disabled={sending}
144+
/>
145+
<Button type="submit" disabled={!isValidEmail || sending} className="gap-2 shrink-0">
146+
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Rocket className="w-4 h-4" />}
147+
{sending ? 'Joining...' : 'Join'}
148+
</Button>
149+
</div>
150+
{error && <p className="text-sm text-red-500">{error}</p>}
151+
</form>
152+
153+
<div className="flex items-center gap-2 mt-4 p-3 rounded-lg bg-accent/5 border border-accent/20">
154+
<Zap className="w-4 h-4 text-accent shrink-0" />
155+
<p className="text-xs text-muted-foreground">
156+
Early members get <span className="text-foreground font-medium">30% off</span> for the first year
157+
</p>
158+
</div>
159+
160+
<button
161+
onClick={handleClose}
162+
className="w-full mt-4 text-sm text-muted-foreground hover:text-foreground transition-colors"
163+
>
164+
Continue with Free tier
165+
</button>
166+
</>
167+
)}
168+
</div>
169+
</motion.div>
170+
</motion.div>
171+
)}
172+
</AnimatePresence>
173+
)
174+
}

frontend/src/components/dashboard/DashboardHome.tsx

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { StyleInsights } from '../StyleInsights'
2626
import { ImpactAnalyzer } from '../ImpactAnalyzer'
2727
import { DashboardStats } from './DashboardStats'
2828
import { IndexingProgressModal } from '../IndexingProgressModal'
29-
import { WaitlistModal } from '../landing/WaitlistModal'
29+
import { UpgradeLimitModal } from '../UpgradeLimitModal'
3030
import type { Repository } from '../../types'
3131
import type { GitHubRepo } from '../../hooks/useGitHubRepos'
3232
import { API_URL } from '../../config/api'
@@ -58,14 +58,6 @@ function isUpgradeError(err: any): boolean {
5858
return ['REPO_TOO_LARGE', 'REPO_LIMIT_REACHED'].includes(code)
5959
}
6060

61-
// Show upgrade toast with waitlist CTA (DRY helper for 4 call sites)
62-
function showUpgradeToast(err: any, fallback: string, onJoin: () => void) {
63-
toast.error(extractErrorMessage(err, fallback), {
64-
description: 'Join the Pro waitlist for higher limits',
65-
action: { label: 'Join Waitlist', onClick: onJoin }
66-
})
67-
}
68-
6961
type RepoTab = 'overview' | 'search' | 'dependencies' | 'insights' | 'impact'
7062

7163
export function DashboardHome() {
@@ -85,7 +77,12 @@ export function DashboardHome() {
8577
const [showIndexingModal, setShowIndexingModal] = useState(false)
8678

8779
// Upgrade prompt modal state
88-
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
80+
const [upgradeModal, setUpgradeModal] = useState<{ show: boolean; message: string; repoName?: string }>({ show: false, message: '' })
81+
82+
// Helper to show upgrade modal with context
83+
const showUpgradeModal = (err: any, repoName?: string) => {
84+
setUpgradeModal({ show: true, message: extractErrorMessage(err, 'Repository exceeds free tier limits'), repoName })
85+
}
8986

9087
// Auto-open GitHub import modal if redirected from OAuth callback
9188
useEffect(() => {
@@ -134,7 +131,7 @@ export function DashboardHome() {
134131
if (!response.ok) {
135132
const err = await response.json().catch(() => ({}))
136133
if (isUpgradeError(err)) {
137-
showUpgradeToast(err, 'Repository too large', () => setShowUpgradeModal(true))
134+
showUpgradeModal(err, name)
138135
return
139136
}
140137
throw new Error(extractErrorMessage(err, 'Failed to add repository'))
@@ -152,7 +149,7 @@ export function DashboardHome() {
152149
if (!indexResponse.ok) {
153150
const err = await indexResponse.json().catch(() => ({}))
154151
if (isUpgradeError(err)) {
155-
showUpgradeToast(err, 'Repository too large', () => setShowUpgradeModal(true))
152+
showUpgradeModal(err, name)
156153
return
157154
}
158155
throw new Error(extractErrorMessage(err, 'Failed to start indexing'))
@@ -196,7 +193,7 @@ export function DashboardHome() {
196193
if (!response.ok) {
197194
const err = await response.json().catch(() => ({}))
198195
if (isUpgradeError(err)) {
199-
showUpgradeToast(err, `${repo.name} too large`, () => setShowUpgradeModal(true))
196+
showUpgradeModal(err, repo.name)
200197
continue
201198
}
202199
throw new Error(extractErrorMessage(err, `Failed to add ${repo.name}`))
@@ -214,7 +211,7 @@ export function DashboardHome() {
214211
if (!indexResponse.ok) {
215212
const err = await indexResponse.json().catch(() => ({}))
216213
if (isUpgradeError(err)) {
217-
showUpgradeToast(err, `${repo.name} too large`, () => setShowUpgradeModal(true))
214+
showUpgradeModal(err, repo.name)
218215
continue
219216
}
220217
const errMsg = extractErrorMessage(err, 'Indexing failed to start')
@@ -493,12 +490,12 @@ export function DashboardHome() {
493490
currentRepoCount={repos.length}
494491
/>
495492

496-
{/* Upgrade/Waitlist Modal */}
497-
<WaitlistModal
498-
isOpen={showUpgradeModal}
499-
onClose={() => setShowUpgradeModal(false)}
500-
planName="Pro"
501-
planPrice="$19/mo"
493+
{/* Upgrade Limit Modal */}
494+
<UpgradeLimitModal
495+
isOpen={upgradeModal.show}
496+
onClose={() => setUpgradeModal({ show: false, message: '' })}
497+
errorMessage={upgradeModal.message}
498+
repoName={upgradeModal.repoName}
502499
/>
503500
</div>
504501
)

0 commit comments

Comments
 (0)