Skip to content

Commit 9f8036d

Browse files
Filip-Bumbuclaude
andcommitted
Add Stripe billing, pricing page, and plan enforcement
- Pricing page at /pricing with value-framed tiers (Free/Starter/Pro/Team) - Stripe Checkout integration (checkout sessions, customer portal) - Billing API client (createCheckout, createPortalSession, getBillingStatus) - Plan enforcement: free plan limited to 3 cloud projects, no private projects - UpgradeModal shown when user hits plan limits in the IDE - Worker billing endpoints (checkout, portal, webhook, status) - D1 schema migration for subscriptions table and user billing columns - Webhook signature verification with constant-time comparison - Pricing link added to landing page nav and footer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 10f38fc commit 9f8036d

File tree

9 files changed

+1094
-31
lines changed

9 files changed

+1094
-31
lines changed

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import IDE from './pages/IDE'
55
import Embed from './pages/Embed'
66
import ProjectPage from './pages/ProjectPage'
77
import UserProfile from './pages/UserProfile'
8+
import Pricing from './pages/Pricing'
89

910
function App() {
1011
const navigate = useNavigate()
@@ -27,6 +28,7 @@ function App() {
2728
<Route path="/embed" element={<Embed />} />
2829
<Route path="/p/:id" element={<ProjectPage />} />
2930
<Route path="/u/:username" element={<UserProfile />} />
31+
<Route path="/pricing" element={<Pricing />} />
3032
</Routes>
3133
)
3234
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* UpgradeModal — shown when user hits a plan limit
3+
* Nudges them to upgrade with context about what they tried to do
4+
*/
5+
import { Link } from 'react-router-dom'
6+
import { X, Sparkles, ArrowRight } from 'lucide-react'
7+
8+
interface UpgradeModalProps {
9+
isOpen: boolean
10+
onClose: () => void
11+
reason?: 'cloud_limit' | 'private_project' | 'generic'
12+
message?: string
13+
}
14+
15+
const REASONS = {
16+
cloud_limit: {
17+
title: "You've hit your project limit",
18+
description: 'Free accounts can save up to 3 cloud projects. Upgrade to Starter to save unlimited projects with short URLs.',
19+
cta: 'Unlock unlimited projects',
20+
},
21+
private_project: {
22+
title: 'Private projects need Starter',
23+
description: 'Keep your work private with a Starter plan. You also get version history, short URLs, and a public profile.',
24+
cta: 'Go private',
25+
},
26+
generic: {
27+
title: 'Upgrade to do more',
28+
description: 'Get cloud storage, private projects, and more with a paid plan.',
29+
cta: 'See plans',
30+
},
31+
}
32+
33+
export default function UpgradeModal({ isOpen, onClose, reason = 'generic', message }: UpgradeModalProps) {
34+
if (!isOpen) return null
35+
36+
const content = REASONS[reason] || REASONS.generic
37+
38+
return (
39+
<div className="fixed inset-0 z-[100] flex items-center justify-center">
40+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
41+
<div className="relative bg-[#0f0f11] border border-white/10 rounded-2xl p-8 max-w-md w-full mx-4 shadow-2xl">
42+
{/* Close */}
43+
<button
44+
onClick={onClose}
45+
className="absolute top-4 right-4 text-gray-500 hover:text-white transition"
46+
>
47+
<X className="w-5 h-5" />
48+
</button>
49+
50+
{/* Icon */}
51+
<div className="w-12 h-12 bg-purple-500/10 rounded-xl flex items-center justify-center mb-5">
52+
<Sparkles className="w-6 h-6 text-purple-400" />
53+
</div>
54+
55+
{/* Content */}
56+
<h2 className="text-xl font-bold text-white mb-2">{content.title}</h2>
57+
<p className="text-gray-400 text-sm leading-relaxed mb-2">
58+
{message || content.description}
59+
</p>
60+
61+
{/* Free tier note */}
62+
<p className="text-gray-500 text-xs mb-6">
63+
Your existing URL-based projects are always free and unlimited.
64+
</p>
65+
66+
{/* Actions */}
67+
<div className="flex gap-3">
68+
<Link
69+
to="/pricing"
70+
onClick={onClose}
71+
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-purple-600 hover:bg-purple-500 text-white font-medium text-sm transition"
72+
>
73+
{content.cta}
74+
<ArrowRight className="w-4 h-4" />
75+
</Link>
76+
<button
77+
onClick={onClose}
78+
className="px-4 py-3 rounded-xl bg-white/5 hover:bg-white/10 text-gray-400 text-sm transition"
79+
>
80+
Later
81+
</button>
82+
</div>
83+
</div>
84+
</div>
85+
)
86+
}

frontend/src/lib/api.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,14 @@ function getAuthHeaders(): HeadersInit {
5050
return headers
5151
}
5252

53-
class ApiError extends Error {
54-
constructor(message: string, public status: number) {
53+
export class ApiError extends Error {
54+
public code: string
55+
public feature?: string
56+
constructor(message: string, public status: number, code?: string, feature?: string) {
5557
super(message)
5658
this.name = 'ApiError'
59+
this.code = code || 'error'
60+
this.feature = feature
5761
}
5862
}
5963

@@ -66,10 +70,15 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
6670
},
6771
})
6872

69-
const data = await res.json()
73+
const data = await res.json() as any
7074

7175
if (!res.ok) {
72-
throw new ApiError(data.error || 'Request failed', res.status)
76+
throw new ApiError(
77+
data.message || data.error || 'Request failed',
78+
res.status,
79+
data.error, // 'plan_limit' or other error code
80+
data.feature, // 'private_projects' etc.
81+
)
7382
}
7483

7584
return data as T
@@ -131,3 +140,29 @@ export async function getMyProjects(): Promise<{ projects: ProjectListItem[] }>
131140
export async function getUserProjects(userId: string): Promise<{ projects: ProjectListItem[] }> {
132141
return request(`/api/users/${userId}/projects`)
133142
}
143+
144+
// ── Billing ────────────────────────────────────────
145+
146+
export interface BillingStatus {
147+
plan: 'free' | 'starter' | 'pro' | 'team'
148+
status: 'active' | 'canceled' | 'past_due' | 'none'
149+
current_period_end: string | null
150+
cancel_at_period_end: boolean
151+
}
152+
153+
export async function getBillingStatus(): Promise<BillingStatus> {
154+
return request('/api/billing/status')
155+
}
156+
157+
export async function createCheckout(priceId: string): Promise<{ url: string }> {
158+
return request('/api/billing/checkout', {
159+
method: 'POST',
160+
body: JSON.stringify({ price_id: priceId }),
161+
})
162+
}
163+
164+
export async function createPortalSession(): Promise<{ url: string }> {
165+
return request('/api/billing/portal', {
166+
method: 'POST',
167+
})
168+
}

frontend/src/pages/IDE.tsx

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ import SearchPanel from '../components/SearchPanel'
3535
import GitHubModal from '../components/GitHubModal'
3636
import SourceControlPanel from '../components/SourceControlPanel'
3737
import EditorPaneV2 from '../components/EditorPaneV2'
38+
import UpgradeModal from '../components/UpgradeModal'
3839
import { PaneManagerProvider, usePaneManager } from '../components/PaneManager'
3940
import { GitHubRepo } from '../lib/github'
41+
import { ApiError } from '../lib/api'
4042
import { File } from '../types/workspace'
4143

4244
function EditorPaneRoot({
@@ -120,6 +122,9 @@ export default function IDE() {
120122
const [showGitHub, setShowGitHub] = useState(false)
121123
const [showSourceControl, setShowSourceControl] = useState(false)
122124
const [showEmbed, setShowEmbed] = useState(false)
125+
const [showUpgrade, setShowUpgrade] = useState(false)
126+
const [upgradeReason, setUpgradeReason] = useState<'cloud_limit' | 'private_project' | 'generic'>('generic')
127+
const [upgradeMessage, setUpgradeMessage] = useState<string | undefined>()
123128
const [sourceRepo, setSourceRepo] = useState<GitHubRepo | null>(null)
124129
const [githubUser, setGithubUser] = useState<{ login: string; avatar_url: string } | null>(null)
125130
const [isRunning, setIsRunning] = useState(false)
@@ -250,19 +255,30 @@ export default function IDE() {
250255
}
251256

252257
const handleSaveToCloud = async () => {
253-
let id: string | null
254-
if (cloudProjectId) {
255-
await updateCloud()
256-
id = cloudProjectId
257-
} else {
258-
const title = getProjectTitle(workspace)
259-
id = await saveToCloud({ title })
260-
}
261-
if (id) {
262-
const url = `${window.location.origin}/p/${id}`
263-
await navigator.clipboard.writeText(url)
264-
setShowCloudSaved(true)
265-
setTimeout(() => setShowCloudSaved(false), 3000)
258+
try {
259+
let id: string | null
260+
if (cloudProjectId) {
261+
await updateCloud()
262+
id = cloudProjectId
263+
} else {
264+
const title = getProjectTitle(workspace)
265+
id = await saveToCloud({ title })
266+
}
267+
if (id) {
268+
const url = `${window.location.origin}/p/${id}`
269+
await navigator.clipboard.writeText(url)
270+
setShowCloudSaved(true)
271+
setTimeout(() => setShowCloudSaved(false), 3000)
272+
}
273+
} catch (e: any) {
274+
if (e instanceof ApiError && e.code === 'plan_limit') {
275+
setUpgradeReason(e.feature === 'private_projects' ? 'private_project' : 'cloud_limit')
276+
setUpgradeMessage(e.message)
277+
setShowUpgrade(true)
278+
} else {
279+
// Show generic error for network failures, auth errors, etc.
280+
useWorkspaceStore.setState({ error: e?.message || 'Failed to save project' })
281+
}
266282
}
267283
}
268284

@@ -734,6 +750,7 @@ export default function IDE() {
734750
<GitHubModal isOpen={showGitHub} onClose={() => setShowGitHub(false)} onImport={setSourceRepo} />
735751
<SourceControlPanel isOpen={showSourceControl} onClose={() => setShowSourceControl(false)} sourceRepo={sourceRepo} />
736752
<EmbedModal isOpen={showEmbed} onClose={() => setShowEmbed(false)} />
753+
<UpgradeModal isOpen={showUpgrade} onClose={() => setShowUpgrade(false)} reason={upgradeReason} message={upgradeMessage} />
737754
</div>
738755
)
739756
}

frontend/src/pages/Landing.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,18 @@ export default function Landing() {
7070
<span className="text-lg font-semibold text-white">HashIDEA</span>
7171
</Link>
7272
<div className="flex items-center gap-4">
73-
<a
74-
href="https://github.com/WickTheThird/HashIDEA"
75-
target="_blank"
73+
<Link to="/pricing" className="text-sm text-gray-400 hover:text-white transition-colors">
74+
Pricing
75+
</Link>
76+
<a
77+
href="https://github.com/WickTheThird/HashIDEA"
78+
target="_blank"
7679
rel="noopener"
7780
className="text-gray-400 hover:text-white transition-colors"
7881
>
7982
<Github className="w-5 h-5" />
8083
</a>
81-
<Link
84+
<Link
8285
to="/ide"
8386
className="px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white text-sm font-medium rounded transition-colors"
8487
>
@@ -450,6 +453,7 @@ export default function Landing() {
450453
<span className="text-gray-400">HashIDEA</span>
451454
</div>
452455
<div className="flex items-center gap-6">
456+
<Link to="/pricing" className="hover:text-white transition-colors">Pricing</Link>
453457
<a href="https://github.com/WickTheThird/HashIDEA" target="_blank" rel="noopener" className="hover:text-white transition-colors">GitHub</a>
454458
<span>Built by <a href="https://github.com/WickTheThird" target="_blank" rel="noopener" className="hover:text-white transition-colors">WickTheThird</a></span>
455459
</div>

0 commit comments

Comments
 (0)