@@ -28,13 +28,25 @@ type BillingAccessResponse = {
2828 cancel_scheduled ?: boolean ;
2929} ;
3030
31+ type PaddlePricesResponse = Record < string , string > ;
32+
3133const PLAN_NAME_BY_KEY = Object . fromEntries (
3234 ( planConfigData . plans ?? [ ] ) . map ( ( plan ) => [
3335 String ( plan ?. paddle ?. plan_key ?? "" ) . toLowerCase ( ) ,
3436 String ( plan ?. name ?? "" ) . trim ( ) ,
3537 ] ) ,
3638) as Record < string , string > ;
3739
40+ const PRO_PLAN_KEY = (
41+ planConfigData . plans ?. find ( ( plan ) => plan ?. key === "pro" ) ?. paddle ?. plan_key ??
42+ "pro_pack_monthly"
43+ ) . toLowerCase ( ) ;
44+
45+ const STARTER_PLAN_KEY = (
46+ planConfigData . plans ?. find ( ( plan ) => plan ?. key === "starter" ) ?. paddle ?. plan_key ??
47+ "starter_pack_monthly"
48+ ) . toLowerCase ( ) ;
49+
3850function formatPlanKey ( planKey : string ) : string {
3951 return planKey
4052 . replace ( / [ _ - ] + / g, " " )
@@ -80,6 +92,11 @@ export default function PricingPlansPage() {
8092
8193 const [ cancelError , setCancelError ] = useState < string | null > ( null ) ;
8294 const [ alreadyCancelled , setAlreadyCancelled ] = useState ( false ) ;
95+ const [ upgradeLoading , setUpgradeLoading ] = useState ( false ) ;
96+ const [ upgradeError , setUpgradeError ] = useState < string | null > ( null ) ;
97+ const [ upgradeSuccess , setUpgradeSuccess ] = useState < string | null > ( null ) ;
98+
99+ const [ upgradeModalOpen , setUpgradeModalOpen ] = useState ( false ) ;
83100
84101 const formattedNextBillingDate = useMemo ( ( ) => {
85102 const nextDateRaw = billing ?. next_billed_at ?? billing ?. current_period_end ;
@@ -108,6 +125,14 @@ export default function PricingPlansPage() {
108125 ! isCancelScheduled &&
109126 ! alreadyCancelled ;
110127
128+ const currentPlanKey = ( billing ?. subscription_plan_key ?? "" ) . toLowerCase ( ) ;
129+ const isProPlan = currentPlanKey === PRO_PLAN_KEY ;
130+
131+ const canChangeSubscription =
132+ Boolean ( billing ?. paddle_subscription_id ) &&
133+ ! isCancelledState &&
134+ ! isCancelScheduled ;
135+
111136 useEffect ( ( ) => {
112137 if ( ! IS_CLERK_AUTH ) {
113138 setLoading ( false ) ;
@@ -212,6 +237,58 @@ export default function PricingPlansPage() {
212237 }
213238 } ;
214239
240+ const handleChangeSubscription = async ( ) => {
241+ setUpgradeLoading ( true ) ;
242+ setUpgradeError ( null ) ;
243+ setUpgradeSuccess ( null ) ;
244+
245+ try {
246+ const token = await getToken ( ) ;
247+ if ( ! token ) throw new Error ( "Missing auth token" ) ;
248+
249+ const { data : priceData } = await api . get < PaddlePricesResponse > (
250+ getURL ( "GET_PADDLE_PRICES" ) ,
251+ { headers : { Authorization : `Bearer ${ token } ` } } ,
252+ ) ;
253+
254+ const targetPlanKey = isProPlan ? STARTER_PLAN_KEY : PRO_PLAN_KEY ;
255+ const targetPriceId = priceData ?. [ targetPlanKey ] ;
256+
257+ if ( ! targetPriceId ) {
258+ throw new Error ( "Unable to find target price ID" ) ;
259+ }
260+
261+ await api . post (
262+ getURL ( "CHANGE_SUBSCRIPTION" ) ,
263+ {
264+ price_id : targetPriceId ,
265+ quantity : 1 ,
266+ is_upgrade : ! isProPlan ,
267+ } ,
268+ { headers : { Authorization : `Bearer ${ token } ` } } ,
269+ ) ;
270+
271+ const res = await api . get ( getURL ( "BILLING_ACCESS" ) , {
272+ headers : { Authorization : `Bearer ${ token } ` } ,
273+ } ) ;
274+
275+ setBilling ( res . data ?? null ) ;
276+ setUpgradeSuccess (
277+ isProPlan
278+ ? "Subscription will be downgraded at next billing cycle."
279+ : "Subscription upgraded successfully." ,
280+ ) ;
281+ } catch ( error : any ) {
282+ const msg =
283+ error ?. response ?. data ?. detail ??
284+ error ?. message ??
285+ "Failed to change subscription." ;
286+ setUpgradeError ( String ( msg ) ) ;
287+ } finally {
288+ setUpgradeLoading ( false ) ;
289+ }
290+ } ;
291+
215292 return (
216293 < div className = "flex h-full w-full flex-col gap-6" >
217294 < div className = "flex flex-col" >
@@ -245,6 +322,18 @@ export default function PricingPlansPage() {
245322 </ div >
246323 ) }
247324
325+ { upgradeError && (
326+ < div className = "rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive" >
327+ { upgradeError }
328+ </ div >
329+ ) }
330+
331+ { upgradeSuccess && (
332+ < div className = "rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-300" >
333+ { upgradeSuccess }
334+ </ div >
335+ ) }
336+
248337 { alreadyCancelled && (
249338 < div className = "rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300" >
250339 You already cancelled your subscription. You still have access{ " " }
@@ -261,6 +350,20 @@ export default function PricingPlansPage() {
261350 </ Button >
262351 ) }
263352
353+ { billing ?. paddle_subscription_id && (
354+ < Button
355+ onClick = { ( ) => setUpgradeModalOpen ( true ) }
356+ disabled = { upgradeLoading }
357+ className = "ml-2"
358+ >
359+ { upgradeLoading
360+ ? "Processing..."
361+ : isProPlan
362+ ? "Downgrade to Starter"
363+ : "Upgrade to Pro" }
364+ </ Button >
365+ ) }
366+
264367 < Dialog open = { cancelModalOpen } onOpenChange = { setCancelModalOpen } >
265368 < DialogContent >
266369 < DialogHeader >
@@ -282,8 +385,41 @@ export default function PricingPlansPage() {
282385 </ DialogFooter >
283386 </ DialogContent >
284387 </ Dialog >
388+
389+ < Dialog open = { upgradeModalOpen } onOpenChange = { setUpgradeModalOpen } >
390+ < DialogContent >
391+ < DialogHeader >
392+ < DialogTitle >
393+ { isProPlan ? "Downgrade to Starter" : "Upgrade to Pro" }
394+ </ DialogTitle >
395+ < DialogDescription >
396+ { isProPlan
397+ ? "Your plan will be downgraded at the next billing cycle."
398+ : "You will be charged a prorated amount today based on your remaining billing period." }
399+ </ DialogDescription >
400+ </ DialogHeader >
401+ < DialogFooter >
402+ < Button onClick = { ( ) => setUpgradeModalOpen ( false ) } >
403+ Cancel
404+ </ Button >
405+ < Button
406+ onClick = { async ( ) => {
407+ setUpgradeModalOpen ( false ) ;
408+ await handleChangeSubscription ( ) ;
409+ } }
410+ disabled = { upgradeLoading }
411+ >
412+ { upgradeLoading
413+ ? "Processing..."
414+ : isProPlan
415+ ? "Confirm Downgrade"
416+ : "Confirm Upgrade" }
417+ </ Button >
418+ </ DialogFooter >
419+ </ DialogContent >
420+ </ Dialog >
285421 </ div >
286422 ) }
287423 </ div >
288424 ) ;
289- }
425+ }
0 commit comments