Skip to content

Commit 4d93cef

Browse files
committed
[TOOL-4951] Dashboard: Show Plan upsell on storage error in asset creation modal
1 parent 7437289 commit 4d93cef

File tree

12 files changed

+271
-20
lines changed

12 files changed

+271
-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,138 @@
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+
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: 10000,
37+
}),
38+
);
39+
40+
setIsPollingTeam(false);
41+
});
42+
43+
const isEventSent = useRef(false);
44+
45+
// eslint-disable-next-line no-restricted-syntax
46+
useEffect(() => {
47+
if (isEventSent.current) {
48+
return;
49+
}
50+
51+
isEventSent.current = true;
52+
reportUpsellShown({
53+
campaign: props.trackingCampaign,
54+
content: "storage-limit",
55+
sku: "plan:starter",
56+
});
57+
}, [props.trackingCampaign]);
58+
59+
return (
60+
<div className="mt-1">
61+
{isPlanUpdated ? (
62+
<div>
63+
<p className="text-sm text-foreground">Plan upgraded successfully</p>
64+
<div className="mt-2.5">
65+
<Button className="gap-2" onClick={props.onRetry} size="sm">
66+
<RefreshCcwIcon className="size-4" />
67+
Retry
68+
</Button>
69+
</div>
70+
</div>
71+
) : (
72+
<div>
73+
<p className="text-sm text-red-500">
74+
You have reached the storage limit on the free plan
75+
</p>
76+
<p className="text-sm text-foreground mt-1">
77+
Upgrade now to unlock unlimited storage with any paid plan
78+
</p>
79+
80+
<div className="flex gap-2 mt-2.5">
81+
<Button asChild className="gap-2" size="sm">
82+
<Link
83+
href={`/team/${props.teamSlug}/~/billing?showPlans=true&highlight=starter`}
84+
onClick={() => {
85+
reportUpsellClicked({
86+
campaign: props.trackingCampaign,
87+
content: "storage-limit",
88+
sku: "plan:starter",
89+
});
90+
}}
91+
target="_blank"
92+
>
93+
Upgrade Plan{" "}
94+
{isPollingTeam ? (
95+
<Spinner className="size-4" />
96+
) : (
97+
<ArrowRightIcon className="size-4" />
98+
)}
99+
</Link>
100+
</Button>
101+
102+
<Button asChild className="bg-card" size="sm" variant="outline">
103+
<Link href="https://thirdweb.com/pricing" target="_blank">
104+
View Pricing
105+
</Link>
106+
</Button>
107+
108+
<ToolTipLabel label="Retry">
109+
<Button
110+
aria-label="Retry"
111+
onClick={props.onRetry}
112+
size="sm"
113+
variant="outline"
114+
>
115+
<RefreshCcwIcon className="size-4 text-muted-foreground" />
116+
</Button>
117+
</ToolTipLabel>
118+
</div>
119+
</div>
120+
)}
121+
</div>
122+
);
123+
}
124+
125+
async function getTeam(teamSlug: string) {
126+
const res = await apiServerProxy<{
127+
result: Team;
128+
}>({
129+
method: "GET",
130+
pathname: `/v1/teams/${teamSlug}`,
131+
});
132+
133+
if (!res.ok) {
134+
throw new Error(res.error);
135+
}
136+
137+
return res.data.result;
138+
}

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+
renderError={(step, errorMessage) => {
321+
if (
322+
props.teamPlan === "free" &&
323+
errorMessage.toLowerCase().includes("storage limit")
324+
) {
325+
return (
326+
<StorageErrorPlanUpsell
327+
onRetry={() => handleRetry(step)}
328+
teamSlug={props.teamSlug}
329+
trackingCampaign="create-nft"
330+
/>
331+
);
332+
}
333+
334+
return null;
335+
}}
336+
steps={steps}
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)