Skip to content

Commit f47711a

Browse files
authored
embedded stripe checkout (#2268)
* wip * cleanup * Add embedded upgrades * fix color * update routes * cleanup * Changes to modals
1 parent 611b121 commit f47711a

File tree

12 files changed

+801
-290
lines changed

12 files changed

+801
-290
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
"@marsidev/react-turnstile": "1.1.0",
2525
"@meilisearch/instant-meilisearch": "0.27.0",
2626
"@rainbow-me/rainbowkit": "2.2.1",
27+
"@stripe/react-stripe-js": "^5.3.0",
28+
"@stripe/stripe-js": "^8.2.0",
2729
"@tailwindcss/typography": "^0.5.16",
2830
"@tanstack/react-query": "5.85.0",
2931
"@tanstack/react-query-devtools": "5.61.3",

src/components/Nav/Account.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const Account = memo(function Account() {
4242
<div className="flex flex-col gap-1.5">
4343
{user && (
4444
<BasicLink
45-
href="/subscription"
45+
href="/account"
4646
className="flex items-center gap-1.5 truncate text-sm font-medium text-(--text-label) hover:text-(--link-text) hover:underline"
4747
>
4848
<Icon name="users" className="h-4 w-4 shrink-0" />
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import { useCallback, useState } from 'react'
2+
import * as Ariakit from '@ariakit/react'
3+
import {
4+
Elements,
5+
EmbeddedCheckout,
6+
EmbeddedCheckoutProvider,
7+
PaymentElement,
8+
useElements,
9+
useStripe
10+
} from '@stripe/react-stripe-js'
11+
import { loadStripe } from '@stripe/stripe-js'
12+
import { useQueryClient } from '@tanstack/react-query'
13+
import { Icon } from '~/components/Icon'
14+
import { AUTH_SERVER, STRIPE_PUBLISHABLE_KEY } from '~/constants'
15+
import { useAuthContext } from '~/containers/Subscribtion/auth'
16+
17+
const stripeInstance = loadStripe(STRIPE_PUBLISHABLE_KEY)
18+
19+
interface StripeCheckoutModalProps {
20+
isOpen: boolean
21+
onClose: () => void
22+
paymentMethod: 'stripe'
23+
type: 'api' | 'contributor' | 'llamafeed'
24+
billingInterval?: 'year' | 'month'
25+
}
26+
27+
export function StripeCheckoutModal({
28+
isOpen,
29+
onClose,
30+
paymentMethod,
31+
type,
32+
billingInterval = 'month'
33+
}: StripeCheckoutModalProps) {
34+
const { authorizedFetch } = useAuthContext()!
35+
const queryClient = useQueryClient()
36+
const [error, setError] = useState<string | null>(null)
37+
const [isUpgrade, setIsUpgrade] = useState(false)
38+
const [requiresPayment, setRequiresPayment] = useState<boolean>(true)
39+
const [upgradeClientSecret, setUpgradeClientSecret] = useState<string | null>(null)
40+
const [upgradePricing, setUpgradePricing] = useState<{
41+
amount: number
42+
currency: string
43+
prorationCredit: number
44+
newSubscriptionPrice: number
45+
} | null>(null)
46+
47+
const fetchClientSecret = useCallback(async () => {
48+
try {
49+
setError(null)
50+
51+
const subscriptionData = {
52+
redirectUrl: `${window.location.origin}/account?success=true`,
53+
cancelUrl: `${window.location.origin}/subscription`,
54+
provider: paymentMethod,
55+
subscriptionType: type || 'api',
56+
billingInterval
57+
}
58+
59+
const response = await authorizedFetch(
60+
`${AUTH_SERVER}/subscription/create`,
61+
{
62+
method: 'POST',
63+
headers: {
64+
'Content-Type': 'application/json'
65+
},
66+
body: JSON.stringify(subscriptionData)
67+
},
68+
true
69+
)
70+
71+
const data = await response.json()
72+
73+
console.log('data', data)
74+
75+
if (!response.ok) {
76+
throw new Error(data.message || 'Failed to create subscription')
77+
}
78+
79+
if (data.isUpgrade) {
80+
setIsUpgrade(true)
81+
setRequiresPayment(data.requiresPayment !== false)
82+
83+
// If no payment required, close modal and refresh
84+
if (!data.requiresPayment) {
85+
await queryClient.invalidateQueries({ queryKey: ['subscription'] })
86+
onClose()
87+
return null
88+
}
89+
90+
if (!data.clientSecret) {
91+
throw new Error('No client secret returned for upgrade payment')
92+
}
93+
94+
setUpgradeClientSecret(data.clientSecret)
95+
96+
if (data.amount !== undefined && data.currency) {
97+
setUpgradePricing({
98+
amount: data.amount,
99+
currency: data.currency,
100+
prorationCredit: data.prorationCredit || 0,
101+
newSubscriptionPrice: data.newSubscriptionPrice || 0
102+
})
103+
}
104+
105+
return null
106+
}
107+
108+
if (!data.clientSecret) {
109+
throw new Error('No client secret returned from server')
110+
}
111+
112+
return data.clientSecret
113+
} catch (err) {
114+
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize checkout'
115+
setError(errorMessage)
116+
throw err
117+
}
118+
}, [authorizedFetch, paymentMethod, type, billingInterval, onClose, queryClient])
119+
120+
const options = { fetchClientSecret }
121+
122+
if (!stripeInstance) {
123+
return (
124+
<Ariakit.DialogProvider open={isOpen} setOpen={() => onClose()}>
125+
<Ariakit.Dialog className="dialog gap-4 md:max-w-[600px]" portal unmountOnHide>
126+
<div className="flex items-center justify-between">
127+
<h2 className="text-xl font-bold">Checkout</h2>
128+
<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">
129+
<Icon name="x" className="h-6 w-6" />
130+
</Ariakit.DialogDismiss>
131+
</div>
132+
<div className="py-8 text-center text-[#b4b7bc]">
133+
<p className="mb-2">Stripe is not configured.</p>
134+
<p className="text-sm">Please set NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY in your environment.</p>
135+
</div>
136+
</Ariakit.Dialog>
137+
</Ariakit.DialogProvider>
138+
)
139+
}
140+
141+
// Render upgrade payment form
142+
if (isUpgrade && upgradeClientSecret && requiresPayment) {
143+
const formatAmount = (cents: number, currency: string) => {
144+
const amount = cents / 100
145+
return new Intl.NumberFormat('en-US', {
146+
style: 'currency',
147+
currency: currency.toUpperCase()
148+
}).format(amount)
149+
}
150+
151+
const planName = type === 'api' ? 'API' : type === 'llamafeed' ? 'Pro' : type
152+
const billingPeriod = billingInterval === 'year' ? 'Annual' : 'Monthly'
153+
154+
return (
155+
<Ariakit.DialogProvider open={isOpen} setOpen={() => onClose()}>
156+
<Ariakit.Dialog className="dialog gap-0 md:max-w-[600px]" portal unmountOnHide>
157+
<div className="top-0 z-10 flex items-center justify-between border-b bg-(--app-bg) p-4">
158+
<h2 className="text-xl font-bold">Complete Your Upgrade</h2>
159+
<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">
160+
<Icon name="x" className="h-6 w-6" />
161+
</Ariakit.DialogDismiss>
162+
</div>
163+
164+
{error && (
165+
<div className="border-b border-[#39393E] bg-red-500/10 p-4">
166+
<div className="flex items-center gap-2 text-red-400">
167+
<Icon name="alert-triangle" height={20} width={20} />
168+
<p className="text-sm">{error}</p>
169+
</div>
170+
</div>
171+
)}
172+
173+
<div className="border-b border-[#39393E] bg-(--app-bg) p-4">
174+
<div className="space-y-3">
175+
<div>
176+
<h3 className="text-sm font-semibold text-[#8a8c90]">Upgrading to</h3>
177+
<p className="text-lg font-bold text-black dark:text-white">
178+
{planName} - {billingPeriod}
179+
</p>
180+
</div>
181+
182+
{upgradePricing ? (
183+
<div className="space-y-2 pt-2">
184+
<div className="flex justify-between text-sm">
185+
<span className="text-[#8a8c90]">New subscription price</span>
186+
<span className="font-medium">
187+
{formatAmount(upgradePricing.newSubscriptionPrice, upgradePricing.currency)}
188+
<span className="text-[#8a8c90]">/{billingInterval === 'year' ? 'year' : 'month'}</span>
189+
</span>
190+
</div>
191+
192+
{upgradePricing.prorationCredit > 0 && (
193+
<div className="flex justify-between text-sm">
194+
<span className="text-[#8a8c90]">Proration credit</span>
195+
<span className="font-medium text-green-400">
196+
-{formatAmount(upgradePricing.prorationCredit, upgradePricing.currency)}
197+
</span>
198+
</div>
199+
)}
200+
201+
<div className="border-t border-[#39393E] pt-2">
202+
<div className="flex justify-between">
203+
<span className="font-semibold">Amount due today</span>
204+
<span className="text-lg font-bold text-[#5C5CF9]">
205+
{formatAmount(upgradePricing.amount, upgradePricing.currency)}
206+
</span>
207+
</div>
208+
</div>
209+
210+
<p className="pt-1 text-xs text-[#8a8c90]">
211+
You'll be charged immediately and your subscription will be updated.
212+
</p>
213+
</div>
214+
) : (
215+
<p className="text-sm text-[#8a8c90]">Enter your payment details below to complete the upgrade.</p>
216+
)}
217+
</div>
218+
</div>
219+
220+
<div className="p-4">
221+
<Elements
222+
stripe={stripeInstance}
223+
options={{
224+
clientSecret: upgradeClientSecret
225+
}}
226+
>
227+
<UpgradePaymentForm onError={setError} />
228+
</Elements>
229+
</div>
230+
</Ariakit.Dialog>
231+
</Ariakit.DialogProvider>
232+
)
233+
}
234+
235+
return (
236+
<Ariakit.DialogProvider open={isOpen} setOpen={() => onClose()}>
237+
<Ariakit.Dialog className="dialog gap-0 md:max-w-[600px]" portal unmountOnHide>
238+
<div className="top-0 z-10 flex items-center justify-between border-b bg-(--app-bg) p-4">
239+
<h2 className="text-xl font-bold">Complete Your Purchase</h2>
240+
<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">
241+
<Icon name="x" className="h-6 w-6" />
242+
</Ariakit.DialogDismiss>
243+
</div>
244+
245+
{error && (
246+
<div className="border-b border-[#39393E] bg-red-500/10 p-4">
247+
<div className="flex items-center gap-2 text-red-400">
248+
<Icon name="alert-triangle" height={20} width={20} />
249+
<p className="text-sm">{error}</p>
250+
</div>
251+
</div>
252+
)}
253+
254+
<div className="min-h-[400px] p-4">
255+
<EmbeddedCheckoutProvider stripe={stripeInstance} options={options}>
256+
<EmbeddedCheckout />
257+
</EmbeddedCheckoutProvider>
258+
</div>
259+
</Ariakit.Dialog>
260+
</Ariakit.DialogProvider>
261+
)
262+
}
263+
264+
// Payment form component for upgrades
265+
function UpgradePaymentForm({ onError }: { onError: (error: string) => void }) {
266+
const queryClient = useQueryClient()
267+
const [isProcessing, setIsProcessing] = useState(false)
268+
const stripe = useStripe()
269+
const elements = useElements()
270+
271+
const handleSubmit = async (e: React.FormEvent) => {
272+
e.preventDefault()
273+
274+
if (!stripe || !elements) {
275+
return
276+
}
277+
278+
setIsProcessing(true)
279+
onError('')
280+
281+
try {
282+
const { error: submitError } = await elements.submit()
283+
if (submitError) {
284+
throw new Error(submitError.message)
285+
}
286+
287+
const { error: confirmError, paymentIntent } = await stripe.confirmPayment({
288+
elements,
289+
confirmParams: {
290+
return_url: `${window.location.origin}/account?success=true`
291+
},
292+
redirect: 'if_required'
293+
})
294+
295+
if (confirmError) {
296+
throw new Error(confirmError.message)
297+
}
298+
299+
if (paymentIntent?.status === 'succeeded') {
300+
queryClient.invalidateQueries({ queryKey: ['subscription'] })
301+
window.location.href = `${window.location.origin}/account?success=true`
302+
}
303+
} catch (err) {
304+
onError(err instanceof Error ? err.message : 'Payment failed')
305+
} finally {
306+
setIsProcessing(false)
307+
}
308+
}
309+
310+
return (
311+
<form onSubmit={handleSubmit} className="space-y-6">
312+
<PaymentElement />
313+
<button
314+
type="submit"
315+
disabled={!stripe || isProcessing}
316+
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"
317+
>
318+
{isProcessing ? 'Processing...' : 'Complete Upgrade'}
319+
</button>
320+
</form>
321+
)
322+
}

src/components/SubscribeCards/SubscribeAPICard.tsx

Lines changed: 17 additions & 4 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 (
@@ -82,7 +85,7 @@ export function SubscribeAPICard({
8285
{active && !isLegacyActive ? (
8386
<div className="flex flex-col gap-2">
8487
<span className="text-center font-bold text-green-400">Current Plan</span>
85-
{currentBillingInterval === 'month' && (
88+
{(currentBillingInterval === 'month' || !currentBillingInterval) && (
8689
<div className="flex flex-col gap-2">
8790
<button
8891
className="w-full rounded-lg border border-[#5C5CF9] bg-[#5C5CF9] px-4 py-3 font-medium text-white shadow-xs transition-all duration-200 hover:bg-[#4A4AF0] hover:shadow-md disabled:cursor-not-allowed disabled:opacity-70"
@@ -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
}

0 commit comments

Comments
 (0)