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}
/>