a tool for shared writing and social publishing

use stripe billing portal

+135 -358
-40
actions/cancelSubscription.ts
··· 1 - "use server"; 2 - 3 - import { getIdentityData } from "./getIdentityData"; 4 - import { getStripe } from "stripe/client"; 5 - import { supabaseServerClient } from "supabase/serverClient"; 6 - import { Ok, Err, type Result } from "src/result"; 7 - 8 - export async function cancelSubscription(): Promise< 9 - Result<{ cancelAt: string }, string> 10 - > { 11 - const identity = await getIdentityData(); 12 - if (!identity) { 13 - return Err("Not authenticated"); 14 - } 15 - 16 - const { data: sub } = await supabaseServerClient 17 - .from("user_subscriptions") 18 - .select("stripe_subscription_id, current_period_end") 19 - .eq("identity_id", identity.id) 20 - .single(); 21 - 22 - if (!sub?.stripe_subscription_id) { 23 - return Err("No active subscription found"); 24 - } 25 - 26 - await getStripe().subscriptions.update(sub.stripe_subscription_id, { 27 - cancel_at_period_end: true, 28 - }); 29 - 30 - // Optimistic update 31 - await supabaseServerClient 32 - .from("user_subscriptions") 33 - .update({ 34 - status: "canceling", 35 - updated_at: new Date().toISOString(), 36 - }) 37 - .eq("identity_id", identity.id); 38 - 39 - return Ok({ cancelAt: sub.current_period_end || "" }); 40 - }
+32
actions/createBillingPortalSession.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "./getIdentityData"; 4 + import { getStripe } from "stripe/client"; 5 + import { supabaseServerClient } from "supabase/serverClient"; 6 + import { Ok, Err, type Result } from "src/result"; 7 + 8 + export async function createBillingPortalSession( 9 + returnUrl: string, 10 + ): Promise<Result<{ url: string }, string>> { 11 + const identity = await getIdentityData(); 12 + if (!identity) { 13 + return Err("Not authenticated"); 14 + } 15 + 16 + const { data: sub } = await supabaseServerClient 17 + .from("user_subscriptions") 18 + .select("stripe_customer_id") 19 + .eq("identity_id", identity.id) 20 + .single(); 21 + 22 + if (!sub?.stripe_customer_id) { 23 + return Err("No subscription found"); 24 + } 25 + 26 + const session = await getStripe().billingPortal.sessions.create({ 27 + customer: sub.stripe_customer_id, 28 + return_url: returnUrl, 29 + }); 30 + 31 + return Ok({ url: session.url }); 32 + }
-40
actions/reactivateSubscription.ts
··· 1 - "use server"; 2 - 3 - import { getIdentityData } from "./getIdentityData"; 4 - import { getStripe } from "stripe/client"; 5 - import { supabaseServerClient } from "supabase/serverClient"; 6 - import { Ok, Err, type Result } from "src/result"; 7 - 8 - export async function reactivateSubscription(): Promise< 9 - Result<{ renewsAt: string }, string> 10 - > { 11 - const identity = await getIdentityData(); 12 - if (!identity) { 13 - return Err("Not authenticated"); 14 - } 15 - 16 - const { data: sub } = await supabaseServerClient 17 - .from("user_subscriptions") 18 - .select("stripe_subscription_id, current_period_end") 19 - .eq("identity_id", identity.id) 20 - .single(); 21 - 22 - if (!sub?.stripe_subscription_id) { 23 - return Err("No active subscription found"); 24 - } 25 - 26 - await getStripe().subscriptions.update(sub.stripe_subscription_id, { 27 - cancel_at_period_end: false, 28 - }); 29 - 30 - // Optimistic update 31 - await supabaseServerClient 32 - .from("user_subscriptions") 33 - .update({ 34 - status: "active", 35 - updated_at: new Date().toISOString(), 36 - }) 37 - .eq("identity_id", identity.id); 38 - 39 - return Ok({ renewsAt: sub.current_period_end || "" }); 40 - }
+2 -143
app/(home-pages)/home/Actions/AccountSettings.tsx
··· 1 1 "use client"; 2 2 3 - import { useState } from "react"; 4 - import { mutate } from "swr"; 5 3 import { ActionButton } from "components/ActionBar/ActionButton"; 6 4 import { Popover } from "components/Popover"; 7 5 import { ThemeSetterContent } from "components/ThemeManager/ThemeSetter"; 8 6 import { useIsMobile } from "src/hooks/isMobile"; 9 7 import { PaintSmall } from "components/Icons/PaintSmall"; 10 - import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 11 - import { GoBackSmall } from "components/Icons/GoBackSmall"; 12 - import { LogoutSmall } from "components/Icons/LogoutSmall"; 13 - import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; 14 - import { Modal } from "components/Modal"; 15 - import { UpgradeContent } from "app/lish/[did]/[publication]/UpgradeModal"; 16 - import { useIsPro } from "src/hooks/useEntitlement"; 17 8 18 9 export const AccountSettings = (props: { entityID: string }) => { 19 - let [state, setState] = useState< 20 - "menu" | "general" | "theme" | "manage-subscription" 21 - >("menu"); 22 10 let isMobile = useIsMobile(); 23 11 24 12 return ( ··· 26 14 asChild 27 15 side={isMobile ? "top" : "right"} 28 16 align={isMobile ? "center" : "start"} 29 - className={`w-xs bg-white!`} 17 + className={`w-xs bg-white!`} 30 18 arrowFill="bg-white" 31 19 trigger={<ActionButton smallOnMobile icon=<PaintSmall /> label="Theme" />} 32 20 > 33 - {state === "general" ? ( 34 - <GeneralSettings backToMenu={() => setState("menu")} /> 35 - ) : state === "theme" ? ( 36 - <AccountThemeSettings 37 - entityID={props.entityID} 38 - backToMenu={() => setState("menu")} 39 - /> 40 - ) : state === "manage-subscription" ? ( 41 - <ManageProSubscription backToMenu={() => setState("menu")} /> 42 - ) : ( 43 - <SettingsMenu state={state} setState={setState} /> 44 - )} 21 + <ThemeSetterContent entityID={props.entityID} home /> 45 22 </Popover> 46 23 ); 47 24 }; 48 - 49 - const SettingsMenu = (props: { 50 - state: "menu" | "general" | "theme" | "manage-subscription"; 51 - setState: (s: typeof props.state) => void; 52 - }) => { 53 - let menuItemClassName = 54 - "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 55 - 56 - let isPro = useIsPro(); 57 - 58 - return ( 59 - <div className="flex flex-col gap-0.5"> 60 - <AccountSettingsHeader state={"menu"} /> 61 - <button 62 - className={menuItemClassName} 63 - type="button" 64 - onClick={() => { 65 - props.setState("general"); 66 - }} 67 - > 68 - General 69 - <ArrowRightTiny /> 70 - </button> 71 - <button 72 - className={menuItemClassName} 73 - type="button" 74 - onClick={() => props.setState("theme")} 75 - > 76 - Account Theme 77 - <ArrowRightTiny /> 78 - </button> 79 - {!isPro ? ( 80 - <Modal 81 - trigger={ 82 - <div 83 - className={`${menuItemClassName} bg-[var(--accent-light)]! border border-transparent hover:border-accent-contrast`} 84 - > 85 - Get Leaflet Pro 86 - <ArrowRightTiny />{" "} 87 - </div> 88 - } 89 - > 90 - <UpgradeContent /> 91 - </Modal> 92 - ) : ( 93 - <button 94 - className={`${menuItemClassName}`} 95 - type="button" 96 - onClick={() => props.setState("manage-subscription")} 97 - > 98 - Manage Pro Subscription <ArrowRightTiny />{" "} 99 - </button> 100 - )} 101 - </div> 102 - ); 103 - }; 104 - 105 - const GeneralSettings = (props: { backToMenu: () => void }) => { 106 - return ( 107 - <div className="flex flex-col gap-0.5"> 108 - <AccountSettingsHeader 109 - state={"general"} 110 - backToMenuAction={() => props.backToMenu()} 111 - /> 112 - 113 - <button 114 - className="flex gap-2 font-bold" 115 - onClick={async () => { 116 - await fetch("/api/auth/logout"); 117 - mutate("identity", null); 118 - }} 119 - > 120 - <LogoutSmall /> 121 - Logout 122 - </button> 123 - </div> 124 - ); 125 - }; 126 - const AccountThemeSettings = (props: { 127 - entityID: string; 128 - backToMenu: () => void; 129 - }) => { 130 - return ( 131 - <div className="flex flex-col gap-0.5"> 132 - <AccountSettingsHeader 133 - state={"theme"} 134 - backToMenuAction={() => props.backToMenu()} 135 - /> 136 - <ThemeSetterContent entityID={props.entityID} home /> 137 - </div> 138 - ); 139 - }; 140 - export const AccountSettingsHeader = (props: { 141 - state: "menu" | "general" | "theme"; 142 - backToMenuAction?: () => void; 143 - }) => { 144 - return ( 145 - <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1"> 146 - {props.state === "menu" 147 - ? "Settings" 148 - : props.state === "general" 149 - ? "General" 150 - : props.state === "theme" 151 - ? "Account Theme" 152 - : ""} 153 - {props.backToMenuAction && ( 154 - <button 155 - type="button" 156 - onClick={() => { 157 - props.backToMenuAction && props.backToMenuAction(); 158 - }} 159 - > 160 - <GoBackSmall className="text-accent-contrast" /> 161 - </button> 162 - )} 163 - </div> 164 - ); 165 - };
+32 -82
app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription.tsx
··· 1 1 import { useState } from "react"; 2 2 import { ButtonPrimary } from "components/Buttons"; 3 - import { PubSettingsHeader } from "./PublicationSettings"; 4 - import { cancelSubscription } from "actions/cancelSubscription"; 5 - import { reactivateSubscription } from "actions/reactivateSubscription"; 3 + import { createBillingPortalSession } from "actions/createBillingPortalSession"; 6 4 import { useIdentityData } from "components/IdentityProvider"; 7 5 import { DotLoader } from "components/utils/DotLoader"; 8 6 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 7 + import { GoBackSmall } from "components/Icons/GoBackSmall"; 9 8 10 9 export const ManageProSubscription = (props: { backToMenu: () => void }) => { 11 - const [state, setState] = useState<"manage" | "confirm" | "success">( 12 - "manage", 13 - ); 14 10 const [loading, setLoading] = useState(false); 15 11 const [error, setError] = useState<string | null>(null); 16 - const { identity, mutate } = useIdentityData(); 12 + const { identity } = useIdentityData(); 17 13 18 14 const subscription = identity?.subscription; 19 15 const renewalDate = useLocalizedDate( ··· 21 17 { month: "long", day: "numeric", year: "numeric" }, 22 18 ); 23 19 24 - async function handleCancel() { 20 + async function handleManageBilling() { 25 21 setLoading(true); 26 22 setError(null); 27 - let result = await cancelSubscription(); 23 + const result = await createBillingPortalSession(window.location.href); 28 24 if (result.ok) { 29 - setState("success"); 30 - mutate(); 25 + window.location.href = result.value.url; 31 26 } else { 32 27 setError(result.error); 28 + setLoading(false); 33 29 } 34 - setLoading(false); 35 - } 36 - 37 - async function handleReactivate() { 38 - setLoading(true); 39 - setError(null); 40 - let result = await reactivateSubscription(); 41 - if (result.ok) { 42 - mutate(); 43 - } else { 44 - setError(result.error); 45 - } 46 - setLoading(false); 47 30 } 48 31 49 32 return ( 50 33 <div> 51 - <PubSettingsHeader backToMenuAction={props.backToMenu}> 34 + <div className="flex justify-between font-bold text-secondary bg-border-light -mx-3 -mt-2 px-3 py-2 mb-1 flex-shrink-0"> 52 35 Manage Subscription 53 - </PubSettingsHeader> 36 + <button type="button" onClick={props.backToMenu}> 37 + <GoBackSmall className="text-accent-contrast" /> 38 + </button> 39 + </div> 54 40 <div className="text-secondary text-center flex flex-col justify-center gap-2 py-2"> 55 - {state === "manage" && ( 56 - <> 57 - <div> 58 - You have a <br /> 59 - {subscription?.plan || "Pro"} subscription 60 - <div className="text-lg font-bold text-primary"> 61 - {subscription?.plan || "Leaflet Pro"} 62 - </div> 63 - {subscription?.status === "canceled" 64 - ? "Your subscription has ended" 65 - : subscription?.status === "canceling" 66 - ? `Access until ${renewalDate}` 67 - : `Renews on ${renewalDate}`} 68 - </div> 69 - {subscription?.status === "canceling" && ( 70 - <ButtonPrimary 71 - className="mx-auto" 72 - compact 73 - onClick={handleReactivate} 74 - disabled={loading} 75 - > 76 - {loading ? <DotLoader /> : "Reactivate Subscription"} 77 - </ButtonPrimary> 78 - )} 79 - {error && <div className="text-sm text-red-500 mt-2">{error}</div>} 80 - {subscription?.status !== "canceling" && 81 - subscription?.status !== "canceled" && ( 82 - <ButtonPrimary 83 - className="mx-auto" 84 - compact 85 - onClick={() => setState("confirm")} 86 - > 87 - Cancel Subscription 88 - </ButtonPrimary> 89 - )} 90 - </> 91 - )} 92 - {state === "confirm" && ( 93 - <> 94 - <div>Are you sure you'd like to cancel your subscription?</div> 95 - <ButtonPrimary 96 - className="mx-auto" 97 - compact 98 - onClick={handleCancel} 99 - disabled={loading} 100 - > 101 - {loading ? <DotLoader /> : "Yes, Cancel it"} 102 - </ButtonPrimary> 103 - {error && <div className="text-sm text-red-500 mt-2">{error}</div>} 104 - </> 105 - )} 106 - {state === "success" && ( 107 - <div> 108 - Your subscription has been cancelled. You'll have access until{" "} 109 - {renewalDate}. 41 + <div> 42 + You have a <br /> 43 + {subscription?.plan || "Pro"} subscription 44 + <div className="text-lg font-bold text-primary"> 45 + {subscription?.plan || "Leaflet Pro"} 110 46 </div> 111 - )} 47 + {subscription?.status === "canceled" 48 + ? "Your subscription has ended" 49 + : subscription?.status === "canceling" 50 + ? `Access until ${renewalDate}` 51 + : `Renews on ${renewalDate}`} 52 + </div> 53 + <ButtonPrimary 54 + className="mx-auto" 55 + compact 56 + onClick={handleManageBilling} 57 + disabled={loading} 58 + > 59 + {loading ? <DotLoader /> : "Manage Billing"} 60 + </ButtonPrimary> 61 + {error && <div className="text-sm text-red-500 mt-2">{error}</div>} 112 62 </div> 113 63 </div> 114 64 );
+1 -35
app/lish/[did]/[publication]/dashboard/settings/PublicationSettings.tsx
··· 13 13 import { DotLoader } from "components/utils/DotLoader"; 14 14 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 15 15 import { PostOptions } from "./PostOptions"; 16 - import { UpgradeContent } from "../../UpgradeModal"; 17 - import { Modal } from "components/Modal"; 18 - import { ManageProSubscription } from "./ManageProSubscription"; 19 - import { useIsPro } from "src/hooks/useEntitlement"; 20 16 21 - type menuState = 22 - | "menu" 23 - | "pub-settings" 24 - | "theme" 25 - | "post-settings" 26 - | "manage-subscription"; 17 + type menuState = "menu" | "pub-settings" | "theme" | "post-settings"; 27 18 28 19 export function PublicationSettingsButton(props: { publication: string }) { 29 20 let isMobile = useIsMobile(); ··· 65 56 loading={loading} 66 57 setLoading={setLoading} 67 58 /> 68 - ) : state === "manage-subscription" ? ( 69 - <ManageProSubscription backToMenu={() => setState("menu")} /> 70 59 ) : ( 71 60 <PubSettingsMenu 72 61 state={state} ··· 88 77 }) => { 89 78 let menuItemClassName = 90 79 "menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"; 91 - let isPro = useIsPro(); 92 80 93 81 return ( 94 82 <div className="flex flex-col gap-0.5"> ··· 119 107 Theme and Layout 120 108 <ArrowRightTiny /> 121 109 </button> 122 - {!isPro ? ( 123 - <Modal 124 - trigger={ 125 - <div 126 - className={`${menuItemClassName} bg-[var(--accent-light)]! border border-transparent hover:border-accent-contrast`} 127 - > 128 - Get Leaflet Pro 129 - <ArrowRightTiny />{" "} 130 - </div> 131 - } 132 - > 133 - <UpgradeContent /> 134 - </Modal> 135 - ) : ( 136 - <button 137 - className={`${menuItemClassName} `} 138 - type="button" 139 - onClick={() => props.setState("manage-subscription")} 140 - > 141 - Manage Pro Subscription <ArrowRightTiny />{" "} 142 - </button> 143 - )} 144 110 </div> 145 111 ); 146 112 };
+68 -18
components/ActionBar/ProfileButton.tsx
··· 3 3 import { useIdentityData } from "components/IdentityProvider"; 4 4 import { AccountSmall } from "components/Icons/AccountSmall"; 5 5 import { useRecordFromDid } from "src/utils/useRecordFromDid"; 6 - import { Menu, MenuItem } from "components/Menu"; 7 6 import { useIsMobile } from "src/hooks/isMobile"; 8 7 import { LogoutSmall } from "components/Icons/LogoutSmall"; 9 8 import { mutate } from "swr"; 10 9 import { SpeedyLink } from "components/SpeedyLink"; 10 + import { Popover } from "components/Popover"; 11 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 12 + import { Modal } from "components/Modal"; 13 + import { UpgradeContent } from "app/lish/[did]/[publication]/UpgradeModal"; 14 + import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; 15 + import { useIsPro } from "src/hooks/useEntitlement"; 16 + import { useState } from "react"; 11 17 12 18 export const ProfileButton = () => { 13 19 let { identity } = useIdentityData(); 14 20 let { data: record } = useRecordFromDid(identity?.atp_did); 15 21 let isMobile = useIsMobile(); 22 + let [state, setState] = useState<"menu" | "manage-subscription">("menu"); 23 + let isPro = useIsPro(); 16 24 17 25 return ( 18 - <Menu 26 + <Popover 19 27 asChild 20 28 side={isMobile ? "top" : "right"} 21 29 align={isMobile ? "center" : "start"} 30 + onOpenChange={() => setState("menu")} 31 + className="w-xs" 22 32 trigger={ 23 33 <ActionButton 24 34 nav ··· 38 48 /> 39 49 } 40 50 > 41 - {record && ( 42 - <> 43 - <SpeedyLink className="no-underline!" href={`/p/${record.handle}`}> 44 - <MenuItem onSelect={() => {}}>View Profile</MenuItem> 45 - </SpeedyLink> 51 + {state === "manage-subscription" ? ( 52 + <ManageProSubscription backToMenu={() => setState("menu")} /> 53 + ) : ( 54 + <div className="flex flex-col gap-0.5"> 55 + {record && ( 56 + <> 57 + <SpeedyLink 58 + className="no-underline!" 59 + href={`/p/${record.handle}`} 60 + > 61 + <button 62 + type="button" 63 + className="menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline! w-full" 64 + > 65 + View Profile 66 + </button> 67 + </SpeedyLink> 68 + 69 + <hr className="border-border-light border-dashed" /> 70 + </> 71 + )} 72 + {!isPro ? ( 73 + <Modal 74 + trigger={ 75 + <div className="menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline! bg-[var(--accent-light)]! border border-transparent hover:border-accent-contrast"> 76 + Get Leaflet Pro 77 + <ArrowRightTiny /> 78 + </div> 79 + } 80 + > 81 + <UpgradeContent /> 82 + </Modal> 83 + ) : ( 84 + <button 85 + className="menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!" 86 + type="button" 87 + onClick={() => setState("manage-subscription")} 88 + > 89 + Manage Pro Subscription 90 + <ArrowRightTiny /> 91 + </button> 92 + )} 46 93 47 94 <hr className="border-border-light border-dashed" /> 48 - </> 95 + 96 + <button 97 + type="button" 98 + className="menuItem -mx-[8px] text-left flex items-center gap-2 hover:no-underline!" 99 + onClick={async () => { 100 + await fetch("/api/auth/logout"); 101 + mutate("identity", null); 102 + }} 103 + > 104 + <LogoutSmall /> 105 + Log Out 106 + </button> 107 + </div> 49 108 )} 50 - <MenuItem 51 - onSelect={async () => { 52 - await fetch("/api/auth/logout"); 53 - mutate("identity", null); 54 - }} 55 - > 56 - <LogoutSmall /> 57 - Log Out 58 - </MenuItem> 59 - </Menu> 109 + </Popover> 60 110 ); 61 111 };