Skip to content

[TOOL-4951] Dashboard: Show Plan upsell on storage error in asset creation modal #7512

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/dashboard/src/@/analytics/report.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import posthog from "posthog-js";

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

// ----------------------------
// CONTRACTS
Expand Down Expand Up @@ -380,3 +381,31 @@ export function reportAssetCreationFailed(
step: properties.step,
});
}

type UpsellParams = {
content: "storage-limit";
campaign: "create-coin" | "create-nft";
sku: Exclude<ProductSKU, null>;
};

/**
* ### 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type MultiStepState<T extends string> = {
}
| {
type: "error";
message: React.ReactNode;
message: string;
};
label: string;
description?: string;
Expand All @@ -27,6 +27,10 @@ export type MultiStepState<T extends string> = {
export function MultiStepStatus<T extends string>(props: {
steps: MultiStepState<T>[];
onRetry: (step: MultiStepState<T>) => void;
renderError?: (
step: MultiStepState<T>,
errorMessage: string,
) => React.ReactNode;
}) {
return (
<DynamicHeight>
Expand Down Expand Up @@ -66,22 +70,24 @@ export function MultiStepStatus<T extends string>(props: {
</p>
)}

{step.status.type === "error" && (
<div className="mt-1 space-y-2">
<p className="mb-1 text-red-500 text-sm">
{step.status.message}
</p>
<Button
className="gap-2"
onClick={() => props.onRetry(step)}
size="sm"
variant="destructive"
>
<RefreshCwIcon className="size-4" />
Retry
</Button>
</div>
)}
{step.status.type === "error"
? props.renderError?.(step, step.status.message) || (
<div className="mt-1 space-y-2">
<p className="mb-1 text-red-500 text-sm">
{step.status.message}
</p>
<Button
className="gap-2"
onClick={() => props.onRetry(step)}
size="sm"
variant="destructive"
>
<RefreshCwIcon className="size-4" />
Retry
</Button>
</div>
)
: null}
</div>
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-1">
{isPlanUpdated ? (
<div>
<p className="text-sm text-foreground">Plan upgraded successfully</p>
<div className="mt-2.5">
<Button className="gap-2" onClick={props.onRetry} size="sm">
<RefreshCcwIcon className="size-4" />
Retry
</Button>
</div>
</div>
) : (
<div>
<p className="text-sm text-red-500">
You have reached the storage limit on the free plan
</p>
<p className="text-sm text-foreground mt-1">
Upgrade now to unlock unlimited storage with any paid plan
</p>

<div className="flex gap-2 mt-2.5">
<Button asChild className="gap-2" size="sm">
<Link
href={`/team/${props.teamSlug}/~/billing?showPlans=true&highlight=starter`}
onClick={() => {
reportUpsellClicked({
campaign: props.trackingCampaign,
content: "storage-limit",
sku: "plan:starter",
});
}}
target="_blank"
>
Upgrade Plan{" "}
{isPollingTeam ? (
<Spinner className="size-4" />
) : (
<ArrowRightIcon className="size-4" />
)}
</Link>
</Button>

<Button asChild className="bg-card" size="sm" variant="outline">
<Link href="https://thirdweb.com/pricing" target="_blank">
View Pricing
</Link>
</Button>

<ToolTipLabel label="Retry">
<Button
aria-label="Retry"
onClick={props.onRetry}
size="sm"
variant="outline"
>
<RefreshCcwIcon className="size-4 text-muted-foreground" />
</Button>
</ToolTipLabel>
</div>
</div>
)}
</div>
);
}

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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,6 +31,7 @@ export function CreateNFTPageUI(props: {
onLaunchSuccess: () => void;
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
}) {
const [step, setStep] =
useState<keyof typeof nftCreationPages>("collection-info");
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -37,6 +38,7 @@ export function CreateNFTPage(props: {
projectSlug: string;
teamId: string;
projectId: string;
teamPlan: Team["billingPlan"];
}) {
const activeAccount = useActiveAccount();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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<MultiStepState<StepId>[]>([]);
Expand Down Expand Up @@ -312,7 +315,26 @@ export function LaunchNFT(props: {
)}
</DialogHeader>

<MultiStepStatus onRetry={handleRetry} steps={steps} />
<MultiStepStatus
onRetry={handleRetry}
renderError={(step, errorMessage) => {
if (
props.teamPlan === "free" &&
errorMessage.toLowerCase().includes("storage limit")
) {
return (
<StorageErrorPlanUpsell
onRetry={() => handleRetry(step)}
teamSlug={props.teamSlug}
trackingCampaign="create-nft"
/>
);
}

return null;
}}
steps={steps}
/>
</div>

<div className="mt-2 flex justify-between gap-4 border-border border-t bg-card p-6">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
reportAssetCreationFailed,
reportContractDeployed,
} from "@/analytics/report";
import type { Team } from "@/api/team";
import {
DEFAULT_FEE_BPS_NEW,
DEFAULT_FEE_RECIPIENT,
Expand All @@ -42,6 +43,7 @@ export function CreateTokenAssetPage(props: {
projectId: string;
teamSlug: string;
projectSlug: string;
teamPlan: Team["billingPlan"];
}) {
const account = useActiveAccount();
const { idToChain } = useAllChainsData();
Expand Down Expand Up @@ -347,6 +349,7 @@ export function CreateTokenAssetPage(props: {
);
}}
projectSlug={props.projectSlug}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -133,6 +135,7 @@ export function CreateTokenAssetPageUI(props: {
setStep("distribution");
}}
projectSlug={props.projectSlug}
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
values={{
...tokenInfoForm.getValues(),
Expand Down
Loading
Loading