a tool for shared writing and social publishing

add modal and feature gate

+110 -29
+3 -1
app/api/checkout/success/route.ts
··· 57 57 console.error("Error processing checkout success:", err); 58 58 } 59 59 60 - return NextResponse.redirect(new URL(returnUrl, req.url)); 60 + const redirectUrl = new URL(returnUrl, req.url); 61 + redirectUrl.searchParams.set("upgrade", "success"); 62 + return NextResponse.redirect(redirectUrl); 61 63 }
+1 -1
app/api/rpc/[command]/get_publication_analytics.ts
··· 21 21 { supabase }: Pick<Env, "supabase">, 22 22 ) => { 23 23 const identity = await getIdentityData(); 24 - if (!identity?.atp_did || !identity.entitlements?.publication_analytics) { 24 + if (!identity?.atp_did || !identity.entitlements?.publication_analytics || !identity.entitlements?.pro_plan_visible) { 25 25 return { error: "unauthorized" as const }; 26 26 } 27 27
+1 -1
app/api/rpc/[command]/get_publication_subscribers_timeseries.ts
··· 19 19 { supabase }: Pick<Env, "supabase">, 20 20 ) => { 21 21 const identity = await getIdentityData(); 22 - if (!identity?.atp_did || !identity.entitlements?.publication_analytics) { 22 + if (!identity?.atp_did || !identity.entitlements?.publication_analytics || !identity.entitlements?.pro_plan_visible) { 23 23 return { error: "unauthorized" as const }; 24 24 } 25 25
+5
app/layout.tsx
··· 9 9 import { headers } from "next/headers"; 10 10 import { RequestHeadersProvider } from "components/Providers/RequestHeadersProvider"; 11 11 import { RouteUIStateManager } from "components/RouteUIStateManger"; 12 + import { SubscriptionSuccessModal } from "components/SubscriptionSuccessModal"; 13 + import { Suspense } from "react"; 12 14 13 15 export const metadata = { 14 16 title: "Leaflet", ··· 87 89 timezone={ipTimezone} 88 90 > 89 91 <ViewportSizeLayout>{children}</ViewportSizeLayout> 92 + <Suspense> 93 + <SubscriptionSuccessModal /> 94 + </Suspense> 90 95 <RouteUIStateManager /> 91 96 </RequestHeadersProvider> 92 97 </IdentityProviderServer>
+3 -2
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 13 13 import { ButtonSecondary, ButtonTertiary } from "components/Buttons"; 14 14 import { UpgradeModal } from "../UpgradeModal"; 15 15 import { LeafletPro } from "components/Icons/LeafletPro"; 16 - import { useIsPro } from "src/hooks/useEntitlement"; 16 + import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; 17 17 18 18 export const Actions = (props: { publication: string }) => { 19 19 let isPro = useIsPro(); 20 + let canSeePro = useCanSeePro(); 20 21 return ( 21 22 <> 22 23 <NewDraftActionButton publication={props.publication} /> 23 - {!isPro && <MobileUpgrade />} 24 + {canSeePro && !isPro && <MobileUpgrade />} 24 25 25 26 <PublicationShareButton /> 26 27 <PublicationSettingsButton publication={props.publication} />
+4 -1
app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx
··· 7 7 import type { DateRange } from "react-day-picker"; 8 8 import { usePublicationData } from "./PublicationSWRProvider"; 9 9 import { Combobox, ComboboxResult } from "components/Combobox"; 10 - import { useIsPro } from "src/hooks/useEntitlement"; 10 + import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; 11 11 import { callRPC } from "app/api/rpc/client"; 12 12 import useSWR from "swr"; 13 13 import { ··· 76 76 showPageBackground: boolean; 77 77 }) => { 78 78 let isPro = useIsPro(); 79 + let canSeePro = useCanSeePro(); 79 80 80 81 let { data: publication } = usePublicationData(); 81 82 let [dateRange, setDateRange] = useState<DateRange>(() => { ··· 145 146 ), 146 147 [analyticsData?.traffic, dateRange.from, dateRange.to], 147 148 ); 149 + 150 + if (!canSeePro) return null; 148 151 149 152 if (!isPro) 150 153 return (
+27 -22
components/ActionBar/ProfileButton.tsx
··· 12 12 import { Modal } from "components/Modal"; 13 13 import { UpgradeContent } from "app/lish/[did]/[publication]/UpgradeModal"; 14 14 import { ManageProSubscription } from "app/lish/[did]/[publication]/dashboard/settings/ManageProSubscription"; 15 - import { useIsPro } from "src/hooks/useEntitlement"; 15 + import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; 16 16 import { useState } from "react"; 17 17 18 18 export const ProfileButton = () => { ··· 21 21 let isMobile = useIsMobile(); 22 22 let [state, setState] = useState<"menu" | "manage-subscription">("menu"); 23 23 let isPro = useIsPro(); 24 + let canSeePro = useCanSeePro(); 24 25 25 26 return ( 26 27 <Popover ··· 69 70 <hr className="border-border-light border-dashed" /> 70 71 </> 71 72 )} 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 73 + {canSeePro && ( 74 + <> 75 + {!isPro ? ( 76 + <Modal 77 + trigger={ 78 + <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"> 79 + Get Leaflet Pro 80 + <ArrowRightTiny /> 81 + </div> 82 + } 83 + > 84 + <UpgradeContent /> 85 + </Modal> 86 + ) : ( 87 + <button 88 + className="menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!" 89 + type="button" 90 + onClick={() => setState("manage-subscription")} 91 + > 92 + Manage Pro Subscription 77 93 <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> 94 + </button> 95 + )} 96 + 97 + <hr className="border-border-light border-dashed" /> 98 + </> 92 99 )} 93 - 94 - <hr className="border-border-light border-dashed" /> 95 100 96 101 <button 97 102 type="button"
+61
components/SubscriptionSuccessModal.tsx
··· 1 + "use client"; 2 + 3 + import { useSearchParams, useRouter, usePathname } from "next/navigation"; 4 + import { useEffect, useState } from "react"; 5 + import { useIdentityData } from "./IdentityProvider"; 6 + import { Modal } from "./Modal"; 7 + 8 + export function SubscriptionSuccessModal() { 9 + let searchParams = useSearchParams(); 10 + let router = useRouter(); 11 + let pathname = usePathname(); 12 + let { identity, mutate } = useIdentityData(); 13 + let [open, setOpen] = useState(false); 14 + let [loading, setLoading] = useState(true); 15 + 16 + let isUpgradeSuccess = searchParams.get("upgrade") === "success"; 17 + 18 + useEffect(() => { 19 + if (!isUpgradeSuccess) return; 20 + setOpen(true); 21 + setLoading(true); 22 + mutate().then(() => setLoading(false)); 23 + }, [isUpgradeSuccess]); 24 + 25 + function handleOpenChange(next: boolean) { 26 + setOpen(next); 27 + if (!next) { 28 + let params = new URLSearchParams(searchParams.toString()); 29 + params.delete("upgrade"); 30 + let qs = params.toString(); 31 + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); 32 + } 33 + } 34 + 35 + if (!isUpgradeSuccess && !open) return null; 36 + 37 + return ( 38 + <Modal 39 + open={open} 40 + onOpenChange={handleOpenChange} 41 + trigger={<span />} 42 + title="Welcome to Pro" 43 + className="w-80" 44 + > 45 + {loading ? ( 46 + <div className="flex flex-col items-center gap-3 py-4"> 47 + <div className="h-5 w-5 animate-spin rounded-full border-2 border-border border-t-accent-contrast" /> 48 + <p className="text-secondary text-sm"> 49 + Activating your subscription... 50 + </p> 51 + </div> 52 + ) : ( 53 + <div className="flex flex-col gap-2 py-2"> 54 + <p className="text-secondary text-sm"> 55 + Your Pro subscription is active. Thanks for supporting Leaflet! 56 + </p> 57 + </div> 58 + )} 59 + </Modal> 60 + ); 61 + }
+4
src/hooks/useEntitlement.ts
··· 9 9 export function useIsPro(): boolean { 10 10 return useHasEntitlement("publication_analytics"); 11 11 } 12 + 13 + export function useCanSeePro(): boolean { 14 + return useHasEntitlement("pro_plan_visible"); 15 + }
+1 -1
stripe/products.ts
··· 4 4 name: "Leaflet Pro", 5 5 metadata: { 6 6 product_def_id: PRODUCT_DEF_ID, 7 - entitlements: JSON.stringify({ publication_analytics: true }), 7 + entitlements: JSON.stringify({ publication_analytics: true, pro_plan_visible: true }), 8 8 }, 9 9 }; 10 10