From 9f88b5c2beedd51730dbe4f2dfdd15e6bcb6e967 Mon Sep 17 00:00:00 2001 From: MananTank Date: Wed, 2 Jul 2025 20:39:36 +0000 Subject: [PATCH] [TOOL-4951] Dashboard: Show Plan upsell on storage error in asset creation modal (#7512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR introduces the `teamPlan` property to various components related to NFT and token creation, allowing the application to handle billing plans effectively. It also adds a new component for handling storage limit errors and integrates upsell reporting for plan upgrades. ### Detailed summary - Added `teamPlan` prop to components: `CreateNFTPage`, `CreateTokenAssetPage`, `CreateNFTPageUI`, `CreateTokenAssetPageUI`, `LaunchNFT`, `LaunchTokenStatus`. - Introduced `StorageErrorPlanUpsell` component for storage limit handling. - Implemented upsell reporting functions: `reportUpsellShown`, `reportUpsellClicked`. - Updated `MultiStepStatus` to use a render function for error messages. - Changed error message type from `React.ReactNode` to `string` in `MultiStepStatus`. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit * **New Features** * Introduced a tailored upsell experience when users on a free plan hit the storage limit during token or NFT creation, including a new prompt to upgrade and real-time detection of plan changes. * Added analytics tracking for upsell prompts and upgrade link clicks. * **Enhancements** * Error messages in multi-step workflows can now be customized, allowing for more informative and actionable error displays. * Components involved in token and NFT creation now accept and utilize the team's billing plan to enable conditional UI and logic. * **Storybook** * Updated stories to reflect storage limit scenarios and demonstrate the new upsell flow for free plan users. --- apps/dashboard/src/@/analytics/report.ts | 29 ++++ .../multi-step-status/multi-step-status.tsx | 40 ++--- .../create/_common/storage-error-upsell.tsx | 138 ++++++++++++++++++ .../tokens/create/nft/create-nft-page-ui.tsx | 3 + .../tokens/create/nft/create-nft-page.tsx | 2 + .../tokens/create/nft/launch/launch-nft.tsx | 24 ++- .../(sidebar)/tokens/create/nft/page.tsx | 1 + .../create/token/create-token-page-impl.tsx | 3 + .../create/token/create-token-page.client.tsx | 3 + .../token/create-token-page.stories.tsx | 22 +++ .../create/token/launch/launch-token.tsx | 25 +++- .../(sidebar)/tokens/create/token/page.tsx | 1 + 12 files changed, 271 insertions(+), 20 deletions(-) create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/storage-error-upsell.tsx diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts index 022cc5fcaac..71a514d27d9 100644 --- a/apps/dashboard/src/@/analytics/report.ts +++ b/apps/dashboard/src/@/analytics/report.ts @@ -1,6 +1,7 @@ import posthog from "posthog-js"; import type { Team } from "@/api/team"; +import type { ProductSKU } from "../types/billing"; // ---------------------------- // CONTRACTS @@ -380,3 +381,31 @@ export function reportAssetCreationFailed( step: properties.step, }); } + +type UpsellParams = { + content: "storage-limit"; + campaign: "create-coin" | "create-nft"; + sku: Exclude; +}; + +/** + * ### Why do we need to report this event? + * - To track how effective the upsells are in driving users to upgrade + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportUpsellShown(properties: UpsellParams) { + posthog.capture("upsell shown", properties); +} + +/** + * ### Why do we need to report this event? + * - To track how effective the upsells are in driving users to upgrade + * + * ### Who is responsible for this event? + * @MananTank + */ +export function reportUpsellClicked(properties: UpsellParams) { + posthog.capture("upsell clicked", properties); +} diff --git a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx index fa37f0f1149..33b6002e809 100644 --- a/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx +++ b/apps/dashboard/src/@/components/blocks/multi-step-status/multi-step-status.tsx @@ -18,7 +18,7 @@ export type MultiStepState = { } | { type: "error"; - message: React.ReactNode; + message: string; }; label: string; description?: string; @@ -27,6 +27,10 @@ export type MultiStepState = { export function MultiStepStatus(props: { steps: MultiStepState[]; onRetry: (step: MultiStepState) => void; + renderError?: ( + step: MultiStepState, + errorMessage: string, + ) => React.ReactNode; }) { return ( @@ -66,22 +70,24 @@ export function MultiStepStatus(props: {

)} - {step.status.type === "error" && ( -
-

- {step.status.message} -

- -
- )} + {step.status.type === "error" + ? props.renderError?.(step, step.status.message) || ( +
+

+ {step.status.message} +

+ +
+ ) + : null} ))} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/storage-error-upsell.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/storage-error-upsell.tsx new file mode 100644 index 00000000000..bae8e63417d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/storage-error-upsell.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { ArrowRightIcon, RefreshCcwIcon } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { apiServerProxy } from "@/actions/proxies"; +import { reportUpsellClicked, reportUpsellShown } from "@/analytics/report"; +import type { Team } from "@/api/team"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { useStripeRedirectEvent } from "@/hooks/stripe/redirect-event"; +import { pollWithTimeout } from "@/utils/pollWithTimeout"; +import { tryCatch } from "@/utils/try-catch"; + +export function StorageErrorPlanUpsell(props: { + teamSlug: string; + trackingCampaign: "create-coin" | "create-nft"; + onRetry: () => void; +}) { + const [isPlanUpdated, setIsPlanUpdated] = useState(false); + const [isPollingTeam, setIsPollingTeam] = useState(false); + + useStripeRedirectEvent(async () => { + setIsPollingTeam(true); + await tryCatch( + pollWithTimeout({ + shouldStop: async () => { + const team = await getTeam(props.teamSlug); + if (team.billingPlan !== "free") { + setIsPlanUpdated(true); + return true; + } + return false; + }, + timeoutMs: 10000, + }), + ); + + setIsPollingTeam(false); + }); + + const isEventSent = useRef(false); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (isEventSent.current) { + return; + } + + isEventSent.current = true; + reportUpsellShown({ + campaign: props.trackingCampaign, + content: "storage-limit", + sku: "plan:starter", + }); + }, [props.trackingCampaign]); + + return ( +
+ {isPlanUpdated ? ( +
+

Plan upgraded successfully

+
+ +
+
+ ) : ( +
+

+ You have reached the storage limit on the free plan +

+

+ Upgrade now to unlock unlimited storage with any paid plan +

+ +
+ + + + + + + +
+
+ )} +
+ ); +} + +async function getTeam(teamSlug: string) { + const res = await apiServerProxy<{ + result: Team; + }>({ + method: "GET", + pathname: `/v1/teams/${teamSlug}`, + }); + + if (!res.ok) { + throw new Error(res.error); + } + + return res.data.result; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx index a4cf72010b9..c3a40d3c18f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx @@ -10,6 +10,7 @@ import { } from "thirdweb"; import { useActiveAccount } from "thirdweb/react"; import { reportAssetCreationStepConfigured } from "@/analytics/report"; +import type { Team } from "@/api/team"; import { type CreateNFTCollectionFunctions, type NFTCollectionInfoFormValues, @@ -30,6 +31,7 @@ export function CreateNFTPageUI(props: { onLaunchSuccess: () => void; teamSlug: string; projectSlug: string; + teamPlan: Team["billingPlan"]; }) { const [step, setStep] = useState("collection-info"); @@ -140,6 +142,7 @@ export function CreateNFTPageUI(props: { setStep(nftCreationPages["sales-settings"]); }} projectSlug={props.projectSlug} + teamPlan={props.teamPlan} teamSlug={props.teamSlug} values={{ collectionInfo: nftCollectionInfoForm.watch(), diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx index ece2365437d..7db1b21e18b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx @@ -25,6 +25,7 @@ import { reportAssetCreationFailed, reportContractDeployed, } from "@/analytics/report"; +import type { Team } from "@/api/team"; import { useAddContractToProject } from "@/hooks/project-contracts"; import { parseError } from "@/utils/errorParser"; import type { CreateNFTCollectionAllValues } from "./_common/form"; @@ -37,6 +38,7 @@ export function CreateNFTPage(props: { projectSlug: string; teamId: string; projectId: string; + teamPlan: Team["billingPlan"]; }) { const activeAccount = useActiveAccount(); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx index a294364716a..7010f4c0e39 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/launch/launch-nft.tsx @@ -13,6 +13,7 @@ import { reportAssetCreationFailed, reportAssetCreationSuccessful, } from "@/analytics/report"; +import type { Team } from "@/api/team"; import type { MultiStepState } from "@/components/blocks/multi-step-status/multi-step-status"; import { MultiStepStatus } from "@/components/blocks/multi-step-status/multi-step-status"; import { WalletAddress } from "@/components/blocks/wallet-address"; @@ -31,6 +32,7 @@ import { parseError } from "@/utils/errorParser"; import { ChainOverview } from "../../_common/chain-overview"; import { FilePreview } from "../../_common/file-preview"; import { StepCard } from "../../_common/step-card"; +import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell"; import type { CreateNFTCollectionAllValues, CreateNFTCollectionFunctions, @@ -52,6 +54,7 @@ export function LaunchNFT(props: { onLaunchSuccess: () => void; teamSlug: string; projectSlug: string; + teamPlan: Team["billingPlan"]; }) { const formValues = props.values; const [steps, setSteps] = useState[]>([]); @@ -312,7 +315,26 @@ export function LaunchNFT(props: { )} - + { + if ( + props.teamPlan === "free" && + errorMessage.toLowerCase().includes("storage limit") + ) { + return ( + handleRetry(step)} + teamSlug={props.teamSlug} + trackingCampaign="create-nft" + /> + ); + } + + return null; + }} + steps={steps} + />
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx index d79e1f365ff..bb059cc64bf 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/page.tsx @@ -54,6 +54,7 @@ export default async function Page(props: { projectId={project.id} projectSlug={params.project_slug} teamId={team.id} + teamPlan={team.billingPlan} teamSlug={params.team_slug} />
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx index 28eee054f9d..40d170391df 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page-impl.tsx @@ -23,6 +23,7 @@ import { reportAssetCreationFailed, reportContractDeployed, } from "@/analytics/report"; +import type { Team } from "@/api/team"; import { DEFAULT_FEE_BPS_NEW, DEFAULT_FEE_RECIPIENT, @@ -42,6 +43,7 @@ export function CreateTokenAssetPage(props: { projectId: string; teamSlug: string; projectSlug: string; + teamPlan: Team["billingPlan"]; }) { const account = useActiveAccount(); const { idToChain } = useAllChainsData(); @@ -347,6 +349,7 @@ export function CreateTokenAssetPage(props: { ); }} projectSlug={props.projectSlug} + teamPlan={props.teamPlan} teamSlug={props.teamSlug} /> ); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx index 10699b2dfb6..f97b4e06df4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.client.tsx @@ -9,6 +9,7 @@ import { type ThirdwebClient, } from "thirdweb"; import { reportAssetCreationStepConfigured } from "@/analytics/report"; +import type { Team } from "@/api/team"; import { type CreateAssetFormValues, type TokenDistributionFormValues, @@ -38,6 +39,7 @@ export function CreateTokenAssetPageUI(props: { onLaunchSuccess: () => void; teamSlug: string; projectSlug: string; + teamPlan: Team["billingPlan"]; }) { const [step, setStep] = useState<"token-info" | "distribution" | "launch">( "token-info", @@ -133,6 +135,7 @@ export function CreateTokenAssetPageUI(props: { setStep("distribution"); }} projectSlug={props.projectSlug} + teamPlan={props.teamPlan} teamSlug={props.teamSlug} values={{ ...tokenInfoForm.getValues(), diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx index 185efd02438..5053343245f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/create-token-page.stories.tsx @@ -45,6 +45,7 @@ export const Default: Story = { createTokenFunctions: mockCreateTokenFunctions, onLaunchSuccess: () => {}, projectSlug: "test-project", + teamPlan: "free", teamSlug: "test-team", }, }; @@ -62,6 +63,27 @@ export const ErrorOnDeploy: Story = { }, onLaunchSuccess: () => {}, projectSlug: "test-project", + teamPlan: "free", + teamSlug: "test-team", + }, +}; + +export const StorageErrorOnDeploy: Story = { + args: { + accountAddress: "0x1234567890123456789012345678901234567890", + client: storybookThirdwebClient, + createTokenFunctions: { + ...mockCreateTokenFunctions, + deployContract: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + throw new Error( + "You have reached your storage limit. Please add a valid payment method to continue using the service.", + ); + }, + }, + onLaunchSuccess: () => {}, + projectSlug: "test-project", + teamPlan: "free", teamSlug: "test-team", }, }; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx index ef0ad460c20..fd3aa75cddd 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/launch/launch-token.tsx @@ -12,6 +12,7 @@ import { reportAssetCreationFailed, reportAssetCreationSuccessful, } from "@/analytics/report"; +import type { Team } from "@/api/team"; import { type MultiStepState, MultiStepStatus, @@ -29,6 +30,7 @@ import { parseError } from "@/utils/errorParser"; import { ChainOverview } from "../../_common/chain-overview"; import { FilePreview } from "../../_common/file-preview"; import { StepCard } from "../../_common/step-card"; +import { StorageErrorPlanUpsell } from "../../_common/storage-error-upsell"; import type { CreateAssetFormValues } from "../_common/form"; import type { CreateTokenFunctions } from "../create-token-page.client"; import { TokenDistributionBarChart } from "../distribution/token-distribution"; @@ -50,6 +52,7 @@ export function LaunchTokenStatus(props: { onLaunchSuccess: () => void; teamSlug: string; projectSlug: string; + teamPlan: Team["billingPlan"]; }) { const formValues = props.values; const { createTokenFunctions } = props; @@ -177,7 +180,6 @@ export function LaunchTokenStatus(props: { await executeSteps(steps, startIndex); } - return ( - + { + if ( + props.teamPlan === "free" && + errorMessage.toLowerCase().includes("storage limit") + ) { + return ( + handleRetry(step)} + teamSlug={props.teamSlug} + trackingCampaign="create-coin" + /> + ); + } + + return null; + }} + steps={steps} + />
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx index 2e4b938550e..a6e63df8be1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/page.tsx @@ -54,6 +54,7 @@ export default async function Page(props: { projectId={project.id} projectSlug={params.project_slug} teamId={team.id} + teamPlan={team.billingPlan} teamSlug={params.team_slug} />