tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
27
pulls
pipelines
add modal and feature gate
awarm.space
2 days ago
79cbb5a8
914b770c
+110
-29
10 changed files
expand all
collapse all
unified
split
app
api
checkout
success
route.ts
rpc
[command]
get_publication_analytics.ts
get_publication_subscribers_timeseries.ts
layout.tsx
lish
[did]
[publication]
dashboard
Actions.tsx
PublicationAnalytics.tsx
components
ActionBar
ProfileButton.tsx
SubscriptionSuccessModal.tsx
src
hooks
useEntitlement.ts
stripe
products.ts
+3
-1
app/api/checkout/success/route.ts
···
57
57
console.error("Error processing checkout success:", err);
58
58
}
59
59
60
60
-
return NextResponse.redirect(new URL(returnUrl, req.url));
60
60
+
const redirectUrl = new URL(returnUrl, req.url);
61
61
+
redirectUrl.searchParams.set("upgrade", "success");
62
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
24
-
if (!identity?.atp_did || !identity.entitlements?.publication_analytics) {
24
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
22
-
if (!identity?.atp_did || !identity.entitlements?.publication_analytics) {
22
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
12
+
import { SubscriptionSuccessModal } from "components/SubscriptionSuccessModal";
13
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
92
+
<Suspense>
93
93
+
<SubscriptionSuccessModal />
94
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
16
-
import { useIsPro } from "src/hooks/useEntitlement";
16
16
+
import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement";
17
17
18
18
export const Actions = (props: { publication: string }) => {
19
19
let isPro = useIsPro();
20
20
+
let canSeePro = useCanSeePro();
20
21
return (
21
22
<>
22
23
<NewDraftActionButton publication={props.publication} />
23
23
-
{!isPro && <MobileUpgrade />}
24
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
10
-
import { useIsPro } from "src/hooks/useEntitlement";
10
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
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
149
+
150
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
15
-
import { useIsPro } from "src/hooks/useEntitlement";
15
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
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
72
-
{!isPro ? (
73
73
-
<Modal
74
74
-
trigger={
75
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
76
-
Get Leaflet Pro
73
73
+
{canSeePro && (
74
74
+
<>
75
75
+
{!isPro ? (
76
76
+
<Modal
77
77
+
trigger={
78
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
79
+
Get Leaflet Pro
80
80
+
<ArrowRightTiny />
81
81
+
</div>
82
82
+
}
83
83
+
>
84
84
+
<UpgradeContent />
85
85
+
</Modal>
86
86
+
) : (
87
87
+
<button
88
88
+
className="menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"
89
89
+
type="button"
90
90
+
onClick={() => setState("manage-subscription")}
91
91
+
>
92
92
+
Manage Pro Subscription
77
93
<ArrowRightTiny />
78
78
-
</div>
79
79
-
}
80
80
-
>
81
81
-
<UpgradeContent />
82
82
-
</Modal>
83
83
-
) : (
84
84
-
<button
85
85
-
className="menuItem -mx-[8px] text-left flex items-center justify-between hover:no-underline!"
86
86
-
type="button"
87
87
-
onClick={() => setState("manage-subscription")}
88
88
-
>
89
89
-
Manage Pro Subscription
90
90
-
<ArrowRightTiny />
91
91
-
</button>
94
94
+
</button>
95
95
+
)}
96
96
+
97
97
+
<hr className="border-border-light border-dashed" />
98
98
+
</>
92
99
)}
93
93
-
94
94
-
<hr className="border-border-light border-dashed" />
95
100
96
101
<button
97
102
type="button"
+61
components/SubscriptionSuccessModal.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import { useSearchParams, useRouter, usePathname } from "next/navigation";
4
4
+
import { useEffect, useState } from "react";
5
5
+
import { useIdentityData } from "./IdentityProvider";
6
6
+
import { Modal } from "./Modal";
7
7
+
8
8
+
export function SubscriptionSuccessModal() {
9
9
+
let searchParams = useSearchParams();
10
10
+
let router = useRouter();
11
11
+
let pathname = usePathname();
12
12
+
let { identity, mutate } = useIdentityData();
13
13
+
let [open, setOpen] = useState(false);
14
14
+
let [loading, setLoading] = useState(true);
15
15
+
16
16
+
let isUpgradeSuccess = searchParams.get("upgrade") === "success";
17
17
+
18
18
+
useEffect(() => {
19
19
+
if (!isUpgradeSuccess) return;
20
20
+
setOpen(true);
21
21
+
setLoading(true);
22
22
+
mutate().then(() => setLoading(false));
23
23
+
}, [isUpgradeSuccess]);
24
24
+
25
25
+
function handleOpenChange(next: boolean) {
26
26
+
setOpen(next);
27
27
+
if (!next) {
28
28
+
let params = new URLSearchParams(searchParams.toString());
29
29
+
params.delete("upgrade");
30
30
+
let qs = params.toString();
31
31
+
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
32
32
+
}
33
33
+
}
34
34
+
35
35
+
if (!isUpgradeSuccess && !open) return null;
36
36
+
37
37
+
return (
38
38
+
<Modal
39
39
+
open={open}
40
40
+
onOpenChange={handleOpenChange}
41
41
+
trigger={<span />}
42
42
+
title="Welcome to Pro"
43
43
+
className="w-80"
44
44
+
>
45
45
+
{loading ? (
46
46
+
<div className="flex flex-col items-center gap-3 py-4">
47
47
+
<div className="h-5 w-5 animate-spin rounded-full border-2 border-border border-t-accent-contrast" />
48
48
+
<p className="text-secondary text-sm">
49
49
+
Activating your subscription...
50
50
+
</p>
51
51
+
</div>
52
52
+
) : (
53
53
+
<div className="flex flex-col gap-2 py-2">
54
54
+
<p className="text-secondary text-sm">
55
55
+
Your Pro subscription is active. Thanks for supporting Leaflet!
56
56
+
</p>
57
57
+
</div>
58
58
+
)}
59
59
+
</Modal>
60
60
+
);
61
61
+
}
+4
src/hooks/useEntitlement.ts
···
9
9
export function useIsPro(): boolean {
10
10
return useHasEntitlement("publication_analytics");
11
11
}
12
12
+
13
13
+
export function useCanSeePro(): boolean {
14
14
+
return useHasEntitlement("pro_plan_visible");
15
15
+
}
+1
-1
stripe/products.ts
···
4
4
name: "Leaflet Pro",
5
5
metadata: {
6
6
product_def_id: PRODUCT_DEF_ID,
7
7
-
entitlements: JSON.stringify({ publication_analytics: true }),
7
7
+
entitlements: JSON.stringify({ publication_analytics: true, pro_plan_visible: true }),
8
8
},
9
9
};
10
10