Skip to content

Commit 6642be8

Browse files
committed
Add embedded upgrades
1 parent dea0047 commit 6642be8

File tree

7 files changed

+422
-125
lines changed

7 files changed

+422
-125
lines changed

src/components/StripeCheckoutModal.tsx

Lines changed: 251 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { useCallback, useState } from 'react'
2+
import { useRouter } from 'next/router'
23
import * as Ariakit from '@ariakit/react'
3-
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js'
4+
import {
5+
Elements,
6+
EmbeddedCheckout,
7+
EmbeddedCheckoutProvider,
8+
PaymentElement,
9+
useElements,
10+
useStripe
11+
} from '@stripe/react-stripe-js'
412
import { loadStripe } from '@stripe/stripe-js'
13+
import { useQueryClient } from '@tanstack/react-query'
514
import { Icon } from '~/components/Icon'
615
import { AUTH_SERVER, STRIPE_PUBLISHABLE_KEY } from '~/constants'
716
import { useAuthContext } from '~/containers/Subscribtion/auth'
@@ -24,19 +33,30 @@ export function StripeCheckoutModal({
2433
billingInterval = 'month'
2534
}: StripeCheckoutModalProps) {
2635
const { authorizedFetch } = useAuthContext()!
36+
const router = useRouter()
37+
const queryClient = useQueryClient()
2738
const [error, setError] = useState<string | null>(null)
39+
const [isUpgrade, setIsUpgrade] = useState(false)
40+
const [subscriptionId, setSubscriptionId] = useState<string | null>(null)
41+
const [requiresPayment, setRequiresPayment] = useState<boolean>(true)
42+
const [upgradeClientSecret, setUpgradeClientSecret] = useState<string | null>(null)
43+
const [upgradePricing, setUpgradePricing] = useState<{
44+
amount: number
45+
currency: string
46+
prorationCredit: number
47+
newSubscriptionPrice: number
48+
} | null>(null)
2849

2950
const fetchClientSecret = useCallback(async () => {
3051
try {
3152
setError(null)
3253

3354
const subscriptionData = {
34-
redirectUrl: `${window.location.origin}/account?session_id={CHECKOUT_SESSION_ID}`,
55+
redirectUrl: `${window.location.origin}/account`,
3556
cancelUrl: `${window.location.origin}/subscription`,
3657
provider: paymentMethod,
3758
subscriptionType: type || 'api',
38-
billingInterval,
39-
uiMode: 'embedded'
59+
billingInterval
4060
}
4161

4262
const response = await authorizedFetch(
@@ -53,10 +73,46 @@ export function StripeCheckoutModal({
5373

5474
const data = await response.json()
5575

76+
console.log('data', data)
77+
5678
if (!response.ok) {
5779
throw new Error(data.message || 'Failed to create subscription')
5880
}
5981

82+
// Check if this is an upgrade
83+
if (data.isUpgrade) {
84+
setIsUpgrade(true)
85+
setSubscriptionId(data.subscriptionId)
86+
setRequiresPayment(data.requiresPayment !== false)
87+
88+
// If no payment required, close modal and refresh
89+
if (!data.requiresPayment) {
90+
await queryClient.invalidateQueries({ queryKey: ['subscription'] })
91+
onClose()
92+
return null
93+
}
94+
95+
// Payment required - set client secret and pricing info
96+
if (!data.clientSecret) {
97+
throw new Error('No client secret returned for upgrade payment')
98+
}
99+
100+
setUpgradeClientSecret(data.clientSecret)
101+
102+
// Set pricing information if available
103+
if (data.amount !== undefined && data.currency) {
104+
setUpgradePricing({
105+
amount: data.amount,
106+
currency: data.currency,
107+
prorationCredit: data.prorationCredit || 0,
108+
newSubscriptionPrice: data.newSubscriptionPrice || 0
109+
})
110+
}
111+
112+
return null // Don't use embedded checkout for upgrades
113+
}
114+
115+
// For new subscriptions, client secret is required
60116
if (!data.clientSecret) {
61117
throw new Error('No client secret returned from server')
62118
}
@@ -67,7 +123,7 @@ export function StripeCheckoutModal({
67123
setError(errorMessage)
68124
throw err
69125
}
70-
}, [authorizedFetch, paymentMethod, type, billingInterval])
126+
}, [authorizedFetch, paymentMethod, type, billingInterval, onClose, queryClient])
71127

72128
const options = { fetchClientSecret }
73129

@@ -90,10 +146,112 @@ export function StripeCheckoutModal({
90146
)
91147
}
92148

149+
// Render upgrade payment form
150+
if (isUpgrade && upgradeClientSecret && requiresPayment) {
151+
const formatAmount = (cents: number, currency: string) => {
152+
const amount = cents / 100
153+
return new Intl.NumberFormat('en-US', {
154+
style: 'currency',
155+
currency: currency.toUpperCase()
156+
}).format(amount)
157+
}
158+
159+
const planName = type === 'api' ? 'API' : type === 'llamafeed' ? 'Pro' : type
160+
const billingPeriod = billingInterval === 'year' ? 'Annual' : 'Monthly'
161+
162+
return (
163+
<Ariakit.DialogProvider open={isOpen} setOpen={() => onClose()}>
164+
<Ariakit.Dialog className="dialog gap-0 md:max-w-[600px]" portal unmountOnHide>
165+
<div className="top-0 z-10 flex items-center justify-between border-b bg-(--app-bg) p-4">
166+
<h2 className="text-xl font-bold">Complete Your Upgrade</h2>
167+
<Ariakit.DialogDismiss className="rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 dark:hover:text-white">
168+
<Icon name="x" className="h-6 w-6" />
169+
</Ariakit.DialogDismiss>
170+
</div>
171+
172+
{error && (
173+
<div className="border-b border-[#39393E] bg-red-500/10 p-4">
174+
<div className="flex items-center gap-2 text-red-400">
175+
<Icon name="alert-circle" height={20} width={20} />
176+
<p className="text-sm">{error}</p>
177+
</div>
178+
</div>
179+
)}
180+
181+
<div className="border-b border-[#39393E] bg-(--app-bg) p-4">
182+
<div className="space-y-3">
183+
<div>
184+
<h3 className="text-sm font-semibold text-[#8a8c90]">Upgrading to</h3>
185+
<p className="text-lg font-bold text-black dark:text-white">
186+
{planName} - {billingPeriod}
187+
</p>
188+
</div>
189+
190+
{upgradePricing ? (
191+
<div className="space-y-2 pt-2">
192+
<div className="flex justify-between text-sm">
193+
<span className="text-[#8a8c90]">New subscription price</span>
194+
<span className="font-medium">
195+
{formatAmount(upgradePricing.newSubscriptionPrice, upgradePricing.currency)}
196+
<span className="text-[#8a8c90]">/{billingInterval === 'year' ? 'year' : 'month'}</span>
197+
</span>
198+
</div>
199+
200+
{upgradePricing.prorationCredit > 0 && (
201+
<div className="flex justify-between text-sm">
202+
<span className="text-[#8a8c90]">Proration credit</span>
203+
<span className="font-medium text-green-400">
204+
-{formatAmount(upgradePricing.prorationCredit, upgradePricing.currency)}
205+
</span>
206+
</div>
207+
)}
208+
209+
<div className="border-t border-[#39393E] pt-2">
210+
<div className="flex justify-between">
211+
<span className="font-semibold">Amount due today</span>
212+
<span className="text-lg font-bold text-[#5C5CF9]">
213+
{formatAmount(upgradePricing.amount, upgradePricing.currency)}
214+
</span>
215+
</div>
216+
</div>
217+
218+
<p className="pt-1 text-xs text-[#8a8c90]">
219+
You'll be charged immediately and your subscription will be updated.
220+
</p>
221+
</div>
222+
) : (
223+
<p className="text-sm text-[#8a8c90]">Enter your payment details below to complete the upgrade.</p>
224+
)}
225+
</div>
226+
</div>
227+
228+
<div className="p-4">
229+
<Elements
230+
stripe={stripeInstance}
231+
options={{
232+
clientSecret: upgradeClientSecret
233+
}}
234+
>
235+
<UpgradePaymentForm
236+
subscriptionId={subscriptionId!}
237+
onSuccess={() => {
238+
queryClient.invalidateQueries({ queryKey: ['subscription'] })
239+
onClose()
240+
}}
241+
onError={setError}
242+
/>
243+
</Elements>
244+
</div>
245+
</Ariakit.Dialog>
246+
</Ariakit.DialogProvider>
247+
)
248+
}
249+
250+
// Render new subscription checkout
93251
return (
94252
<Ariakit.DialogProvider open={isOpen} setOpen={() => onClose()}>
95253
<Ariakit.Dialog className="dialog gap-0 md:max-w-[600px]" portal unmountOnHide>
96-
<div className="sticky top-0 z-10 flex items-center justify-between border-b p-4">
254+
<div className="top-0 z-10 flex items-center justify-between border-b bg-(--app-bg) p-4">
97255
<h2 className="text-xl font-bold">Complete Your Purchase</h2>
98256
<Ariakit.DialogDismiss className="rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 dark:hover:text-white">
99257
<Icon name="x" className="h-6 w-6" />
@@ -118,3 +276,90 @@ export function StripeCheckoutModal({
118276
</Ariakit.DialogProvider>
119277
)
120278
}
279+
280+
// Payment form component for upgrades
281+
function UpgradePaymentForm({
282+
subscriptionId,
283+
onSuccess,
284+
onError
285+
}: {
286+
subscriptionId: string
287+
onSuccess: () => void
288+
onError: (error: string) => void
289+
}) {
290+
const { authorizedFetch } = useAuthContext()!
291+
const [isProcessing, setIsProcessing] = useState(false)
292+
const stripe = useStripe()
293+
const elements = useElements()
294+
295+
const handleSubmit = async (e: React.FormEvent) => {
296+
e.preventDefault()
297+
298+
if (!stripe || !elements) {
299+
return
300+
}
301+
302+
setIsProcessing(true)
303+
onError('')
304+
305+
try {
306+
const { error: submitError } = await elements.submit()
307+
if (submitError) {
308+
throw new Error(submitError.message)
309+
}
310+
311+
const { error: confirmError, paymentIntent } = await stripe.confirmPayment({
312+
elements,
313+
confirmParams: {
314+
return_url: `${window.location.origin}/account`
315+
},
316+
redirect: 'if_required'
317+
})
318+
319+
if (confirmError) {
320+
throw new Error(confirmError.message)
321+
}
322+
323+
if (paymentIntent?.status === 'succeeded') {
324+
// Call backend to confirm upgrade
325+
const response = await authorizedFetch(
326+
`${AUTH_SERVER}/subscription/confirm-upgrade`,
327+
{
328+
method: 'POST',
329+
headers: {
330+
'Content-Type': 'application/json'
331+
},
332+
body: JSON.stringify({
333+
paymentIntentId: paymentIntent.id,
334+
subscriptionId
335+
})
336+
},
337+
true
338+
)
339+
340+
if (!response.ok) {
341+
throw new Error('Failed to confirm upgrade')
342+
}
343+
344+
onSuccess()
345+
}
346+
} catch (err) {
347+
onError(err instanceof Error ? err.message : 'Payment failed')
348+
} finally {
349+
setIsProcessing(false)
350+
}
351+
}
352+
353+
return (
354+
<form onSubmit={handleSubmit} className="space-y-6">
355+
<PaymentElement />
356+
<button
357+
type="submit"
358+
disabled={!stripe || isProcessing}
359+
className="w-full rounded-lg bg-[#5C5CF9] px-6 py-3 font-medium text-white transition-colors hover:bg-[#4A4AF0] disabled:cursor-not-allowed disabled:opacity-50"
360+
>
361+
{isProcessing ? 'Processing...' : 'Complete Upgrade'}
362+
</button>
363+
</form>
364+
)
365+
}

src/components/SubscribeCards/SubscribeAPICard.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { useState } from 'react'
12
import { Icon } from '~/components/Icon'
23
import { PaymentButton } from '~/containers/Subscribtion/Crypto'
34
import { SignIn } from '~/containers/Subscribtion/SignIn'
45
import { useSubscribe } from '~/hooks/useSubscribe'
6+
import { StripeCheckoutModal } from '../StripeCheckoutModal'
57

68
export function SubscribeAPICard({
79
context = 'page',
@@ -22,10 +24,11 @@ export function SubscribeAPICard({
2224
const yearlyPrice = monthlyPrice * 10
2325
const displayPrice = billingInterval === 'year' ? yearlyPrice : monthlyPrice
2426
const displayPeriod = billingInterval === 'year' ? '/year' : '/month'
25-
const { handleSubscribe, loading } = useSubscribe()
27+
const { loading } = useSubscribe()
28+
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false)
2629

27-
const handleUpgradeToYearly = async () => {
28-
await handleSubscribe('stripe', 'api', undefined, 'year')
30+
const handleUpgradeToYearly = () => {
31+
setIsUpgradeModalOpen(true)
2932
}
3033

3134
return (
@@ -132,6 +135,16 @@ export function SubscribeAPICard({
132135
</>
133136
)}
134137
</div>
138+
139+
{isUpgradeModalOpen && (
140+
<StripeCheckoutModal
141+
isOpen={isUpgradeModalOpen}
142+
onClose={() => setIsUpgradeModalOpen(false)}
143+
paymentMethod="stripe"
144+
type="api"
145+
billingInterval="year"
146+
/>
147+
)}
135148
</>
136149
)
137150
}

src/components/SubscribeCards/SubscribeProCard.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { useState } from 'react'
12
import { useRouter } from 'next/router'
23
import * as Ariakit from '@ariakit/react'
34
import { Icon } from '~/components/Icon'
45
import { PaymentButton } from '~/containers/Subscribtion/Crypto'
56
import { SignIn } from '~/containers/Subscribtion/SignIn'
67
import { useSubscribe } from '~/hooks/useSubscribe'
78
import { BasicLink } from '../Link'
9+
import { StripeCheckoutModal } from '../StripeCheckoutModal'
810

911
interface SubscribeProCardProps {
1012
context?: 'modal' | 'page' | 'account'
@@ -28,10 +30,11 @@ export function SubscribeProCard({
2830
const yearlyPrice = monthlyPrice * 10
2931
const displayPrice = billingInterval === 'year' ? yearlyPrice : monthlyPrice
3032
const displayPeriod = billingInterval === 'year' ? '/year' : '/month'
31-
const { handleSubscribe, loading } = useSubscribe()
33+
const { loading } = useSubscribe()
34+
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false)
3235

33-
const handleUpgradeToYearly = async () => {
34-
await handleSubscribe('stripe', 'llamafeed', undefined, 'year')
36+
const handleUpgradeToYearly = () => {
37+
setIsUpgradeModalOpen(true)
3538
}
3639

3740
return (
@@ -171,6 +174,16 @@ export function SubscribeProCard({
171174
</>
172175
)}
173176
</div>
177+
178+
{isUpgradeModalOpen && (
179+
<StripeCheckoutModal
180+
isOpen={isUpgradeModalOpen}
181+
onClose={() => setIsUpgradeModalOpen(false)}
182+
paymentMethod="stripe"
183+
type="llamafeed"
184+
billingInterval="year"
185+
/>
186+
)}
174187
</>
175188
)
176189
}

0 commit comments

Comments
 (0)