Skip to content

Commit 249d002

Browse files
committed
[TOOL-4951] Dashboard: Show Plan upsell on storage error in asset creation modal
1 parent 9c2b0f7 commit 249d002

File tree

12 files changed

+275
-20
lines changed

12 files changed

+275
-20
lines changed

apps/dashboard/src/@/analytics/report.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import posthog from "posthog-js";
22

33
import type { Team } from "@/api/team";
4+
import type { ProductSKU } from "../types/billing";
45

56
// ----------------------------
67
// CONTRACTS
@@ -380,3 +381,31 @@ export function reportAssetCreationFailed(
380381
step: properties.step,
381382
});
382383
}
384+
385+
type UpsellParams = {
386+
content: "storage-limit";
387+
campaign: "create-coin" | "create-nft";
388+
sku: Exclude<ProductSKU, null>;
389+
};
390+
391+
/**
392+
* ### Why do we need to report this event?
393+
* - To track how effective the upsells are in driving users to upgrade
394+
*
395+
* ### Who is responsible for this event?
396+
* @MananTank
397+
*/
398+
export function reportUpsellShown(properties: UpsellParams) {
399+
posthog.capture("upsell shown", properties);
400+
}
401+
402+
/**
403+
* ### Why do we need to report this event?
404+
* - To track how effective the upsells are in driving users to upgrade
405+
*
406+
* ### Who is responsible for this event?
407+
* @MananTank
408+
*/
409+
export function reportUpsellClicked(properties: UpsellParams) {
410+
posthog.capture("upsell clicked", properties);
411+
}

apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type MultiStepState<T extends string> = {
1818
}
1919
| {
2020
type: "error";
21-
message: React.ReactNode;
21+
message: string;
2222
};
2323
label: string;
2424
description?: string;
@@ -27,6 +27,10 @@ export type MultiStepState<T extends string> = {
2727
export function MultiStepStatus<T extends string>(props: {
2828
steps: MultiStepState<T>[];
2929
onRetry: (step: MultiStepState<T>) => void;
30+
renderError?: (
31+
step: MultiStepState<T>,
32+
errorMessage: string,
33+
) => React.ReactNode;
3034
}) {
3135
return (
3236
<DynamicHeight>
@@ -66,22 +70,24 @@ export function MultiStepStatus<T extends string>(props: {
6670
</p>
6771
)}
6872

69-
{step.status.type === "error" && (
70-
<div className="mt-1 space-y-2">
71-
<p className="mb-1 text-red-500 text-sm">
72-
{step.status.message}
73-
</p>
74-
<Button
75-
className="gap-2"
76-
onClick={() => props.onRetry(step)}
77-
size="sm"
78-
variant="destructive"
79-
>
80-
<RefreshCwIcon className="size-4" />
81-
Retry
82-
</Button>
83-
</div>
84-
)}
73+
{step.status.type === "error"
74+
? props.renderError?.(step, step.status.message) || (
75+
<div className="mt-1 space-y-2">
76+
<p className="mb-1 text-red-500 text-sm">
77+
{step.status.message}
78+
</p>
79+
<Button
80+
className="gap-2"
81+
onClick={() => props.onRetry(step)}
82+
size="sm"
83+
variant="destructive"
84+
>
85+
<RefreshCwIcon className="size-4" />
86+
Retry
87+
</Button>
88+
</div>
89+
)
90+
: null}
8591
</div>
8692
</div>
8793
))}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"use client";
2+
3+
import { ArrowRightIcon, RefreshCcwIcon } from "lucide-react";
4+
import Link from "next/link";
5+
import { useEffect, useRef, useState } from "react";
6+
import { apiServerProxy } from "@/actions/proxies";
7+
import { reportUpsellClicked, reportUpsellShown } from "@/analytics/report";
8+
import type { Team } from "@/api/team";
9+
import { Button } from "@/components/ui/button";
10+
import { Spinner } from "@/components/ui/Spinner/Spinner";
11+
import { ToolTipLabel } from "@/components/ui/tooltip";
12+
import { useStripeRedirectEvent } from "@/hooks/stripe/redirect-event";
13+
import { pollWithTimeout } from "@/utils/pollWithTimeout";
14+
import { tryCatch } from "@/utils/try-catch";
15+
16+
export function StorageErrorPlanUpsell(props: {
17+
teamSlug: string;
18+
trackingCampaign: "create-coin" | "create-nft";
19+
onRetry: () => void;
20+
}) {
21+
const [isPlanUpdated, setIsPlanUpdated] = useState(false);
22+
const [isPollingTeam, setIsPollingTeam] = useState(false);
23+
24+
useStripeRedirectEvent(async () => {
25+
setIsPollingTeam(true);
26+
const verifyResult = await tryCatch(
27+
pollWithTimeout({
28+
shouldStop: async () => {
29+
const team = await getTeam(props.teamSlug);
30+
if (team.billingPlan !== "free") {
31+
setIsPlanUpdated(true);
32+
return true;
33+
}
34+
return false;
35+
},
36+
timeoutMs: 5000,
37+
}),
38+
);
39+
40+
if (verifyResult.error) {
41+
return;
42+
}
43+
44+
setIsPollingTeam(false);
45+
});
46+
47+
const isEventSent = useRef(false);
48+
49+
// eslint-disable-next-line no-restricted-syntax
50+
useEffect(() => {
51+
if (isEventSent.current) {
52+
return;
53+
}
54+
55+
isEventSent.current = true;
56+
reportUpsellShown({
57+
campaign: props.trackingCampaign,
58+
content: "storage-limit",
59+
sku: "plan:starter",
60+
});
61+
}, [props.trackingCampaign]);
62+
63+
return (
64+
<div className="mt-1">
65+
{isPlanUpdated ? (
66+
<div>
67+
<p className="text-sm text-foreground">Plan upgraded successfully</p>
68+
<div className="mt-2.5">
69+
<Button className="gap-2" onClick={props.onRetry} size="sm">
70+
<RefreshCcwIcon className="size-4" />
71+
Retry
72+
</Button>
73+
</div>
74+
</div>
75+
) : (
76+
<div>
77+
<p className="text-sm text-red-500">
78+
You have reached the storage limit on the free plan
79+
</p>
80+
<p className="text-sm text-foreground mt-1">
81+
Upgrade now to unlock unlimited storage with any paid plan
82+
</p>
83+
84+
<div className="flex gap-2 mt-2.5">
85+
<Button asChild className="gap-2" size="sm">
86+
<Link
87+
href={`/team/${props.teamSlug}/~/billing?showPlans=true&highlight=starter`}
88+
onClick={() => {
89+
reportUpsellClicked({
90+
campaign: props.trackingCampaign,
91+
content: "storage-limit",
92+
sku: "plan:starter",
93+
});
94+
}}
95+
target="_blank"
96+
>
97+
Upgrade Plan{" "}
98+
{isPollingTeam ? (
99+
<Spinner className="size-4" />
100+
) : (
101+
<ArrowRightIcon className="size-4" />
102+
)}
103+
</Link>
104+
</Button>
105+
106+
<Button asChild className="bg-card" size="sm" variant="outline">
107+
<Link href="https://thirdweb.com/pricing" target="_blank">
108+
View Pricing
109+
</Link>
110+
</Button>
111+
112+
<ToolTipLabel label="Retry">
113+
<Button
114+
aria-label="Retry"
115+
onClick={props.onRetry}
116+
size="sm"
117+
variant="outline"
118+
>
119+
<RefreshCcwIcon className="size-4 text-muted-foreground" />
120+
</Button>
121+
</ToolTipLabel>
122+
</div>
123+
</div>
124+
)}
125+
</div>
126+
);
127+
}
128+
129+
async function getTeam(teamSlug: string) {
130+
const res = await apiServerProxy<{
131+
result: Team;
132+
}>({
133+
method: "GET",
134+
pathname: `/v1/teams/${teamSlug}`,
135+
});
136+
137+
if (!res.ok) {
138+
throw new Error(res.error);
139+
}
140+
141+
return res.data.result;
142+
}

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "thirdweb";
1111
import { useActiveAccount } from "thirdweb/react";
1212
import { reportAssetCreationStepConfigured } from "@/analytics/report";
13+
import type { Team } from "@/api/team";
1314
import {
1415
type CreateNFTCollectionFunctions,
1516
type NFTCollectionInfoFormValues,
@@ -30,6 +31,7 @@ export function CreateNFTPageUI(props: {
3031
onLaunchSuccess: () => void;
3132
teamSlug: string;
3233
projectSlug: string;
34+
teamPlan: Team["billingPlan"];
3335
}) {
3436
const [step, setStep] =
3537
useState<keyof typeof nftCreationPages>("collection-info");
@@ -140,6 +142,7 @@ export function CreateNFTPageUI(props: {
140142
setStep(nftCreationPages["sales-settings"]);
141143
}}
142144
projectSlug={props.projectSlug}
145+
teamPlan={props.teamPlan}
143146
teamSlug={props.teamSlug}
144147
values={{
145148
collectionInfo: nftCollectionInfoForm.watch(),

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
reportAssetCreationFailed,
2626
reportContractDeployed,
2727
} from "@/analytics/report";
28+
import type { Team } from "@/api/team";
2829
import { useAddContractToProject } from "@/hooks/project-contracts";
2930
import { parseError } from "@/utils/errorParser";
3031
import type { CreateNFTCollectionAllValues } from "./_common/form";
@@ -37,6 +38,7 @@ export function CreateNFTPage(props: {
3738
projectSlug: string;
3839
teamId: string;
3940
projectId: string;
41+
teamPlan: Team["billingPlan"];
4042
}) {
4143
const activeAccount = useActiveAccount();
4244

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
reportAssetCreationFailed,
1414
reportAssetCreationSuccessful,
1515
} from "@/analytics/report";
16+
import type { Team } from "@/api/team";
1617
import type { MultiStepState } from "@/components/blocks/multi-step-status/multi-step-status";
1718
import { MultiStepStatus } from "@/components/blocks/multi-step-status/multi-step-status";
1819
import { WalletAddress } from "@/components/blocks/wallet-address";
@@ -31,6 +32,7 @@ import { parseError } from "@/utils/errorParser";
3132
import { ChainOverview } from "../../_common/chain-overview";
3233
import { FilePreview } from "../../_common/file-preview";
3334
import { StepCard } from "../../_common/step-card";
35+
import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell";
3436
import type {
3537
CreateNFTCollectionAllValues,
3638
CreateNFTCollectionFunctions,
@@ -52,6 +54,7 @@ export function LaunchNFT(props: {
5254
onLaunchSuccess: () => void;
5355
teamSlug: string;
5456
projectSlug: string;
57+
teamPlan: Team["billingPlan"];
5558
}) {
5659
const formValues = props.values;
5760
const [steps, setSteps] = useState<MultiStepState<StepId>[]>([]);
@@ -312,7 +315,26 @@ export function LaunchNFT(props: {
312315
)}
313316
</DialogHeader>
314317

315-
<MultiStepStatus onRetry={handleRetry} steps={steps} />
318+
<MultiStepStatus
319+
onRetry={handleRetry}
320+
steps={steps}
321+
renderError={(step, errorMessage) => {
322+
if (
323+
props.teamPlan === "free" &&
324+
errorMessage.toLowerCase().includes("storage limit")
325+
) {
326+
return (
327+
<StorageErrorPlanUpsell
328+
onRetry={() => handleRetry(step)}
329+
teamSlug={props.teamSlug}
330+
trackingCampaign="create-nft"
331+
/>
332+
);
333+
}
334+
335+
return null;
336+
}}
337+
/>
316338
</div>
317339

318340
<div className="mt-2 flex justify-between gap-4 border-border border-t bg-card p-6">

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export default async function Page(props: {
5454
projectId={project.id}
5555
projectSlug={params.project_slug}
5656
teamId={team.id}
57+
teamPlan={team.billingPlan}
5758
teamSlug={params.team_slug}
5859
/>
5960
</div>

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
reportAssetCreationFailed,
2424
reportContractDeployed,
2525
} from "@/analytics/report";
26+
import type { Team } from "@/api/team";
2627
import {
2728
DEFAULT_FEE_BPS_NEW,
2829
DEFAULT_FEE_RECIPIENT,
@@ -42,6 +43,7 @@ export function CreateTokenAssetPage(props: {
4243
projectId: string;
4344
teamSlug: string;
4445
projectSlug: string;
46+
teamPlan: Team["billingPlan"];
4547
}) {
4648
const account = useActiveAccount();
4749
const { idToChain } = useAllChainsData();
@@ -347,6 +349,7 @@ export function CreateTokenAssetPage(props: {
347349
);
348350
}}
349351
projectSlug={props.projectSlug}
352+
teamPlan={props.teamPlan}
350353
teamSlug={props.teamSlug}
351354
/>
352355
);

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type ThirdwebClient,
1010
} from "thirdweb";
1111
import { reportAssetCreationStepConfigured } from "@/analytics/report";
12+
import type { Team } from "@/api/team";
1213
import {
1314
type CreateAssetFormValues,
1415
type TokenDistributionFormValues,
@@ -38,6 +39,7 @@ export function CreateTokenAssetPageUI(props: {
3839
onLaunchSuccess: () => void;
3940
teamSlug: string;
4041
projectSlug: string;
42+
teamPlan: Team["billingPlan"];
4143
}) {
4244
const [step, setStep] = useState<"token-info" | "distribution" | "launch">(
4345
"token-info",
@@ -133,6 +135,7 @@ export function CreateTokenAssetPageUI(props: {
133135
setStep("distribution");
134136
}}
135137
projectSlug={props.projectSlug}
138+
teamPlan={props.teamPlan}
136139
teamSlug={props.teamSlug}
137140
values={{
138141
...tokenInfoForm.getValues(),

0 commit comments

Comments
 (0)