diff --git a/apps/dashboard/src/@/components/blocks/pricing-card.tsx b/apps/dashboard/src/@/components/blocks/pricing-card.tsx index dcb01de9529..b517441ea2a 100644 --- a/apps/dashboard/src/@/components/blocks/pricing-card.tsx +++ b/apps/dashboard/src/@/components/blocks/pricing-card.tsx @@ -3,33 +3,35 @@ import type { Team } from "@/api/team"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ToolTipLabel } from "@/components/ui/tooltip"; -import { TrackedLinkTW } from "@/components/ui/tracked-link"; import { cn } from "@/lib/utils"; -import { CheckIcon, CircleAlertIcon, CircleDollarSignIcon } from "lucide-react"; +import { CheckIcon, CircleDollarSignIcon } from "lucide-react"; +import Link from "next/link"; import type React from "react"; import { TEAM_PLANS } from "utils/pricing"; +import { useTrack } from "../../../hooks/analytics/useTrack"; import { remainingDays } from "../../../utils/date-utils"; import type { GetBillingCheckoutUrlAction } from "../../actions/billing"; +import type { ProductSKU } from "../../lib/billing"; import { CheckoutButton } from "../billing"; -type ButtonProps = React.ComponentProps; - -const PRO_CONTACT_US_URL = - "https://meetings.hubspot.com/sales-thirdweb/thirdweb-pro"; +type PricingCardCta = { + hint?: string; + title: string; + onClick?: () => void; +} & ( + | { + type: "link"; + href: string; + } + | { + type: "checkout"; + } +); type PricingCardProps = { teamSlug: string; - billingPlan: Exclude; - cta?: { - hint?: string; - title: string; - tracking: { - category: string; - label?: string; - }; - variant?: ButtonProps["variant"]; - onClick?: () => void; - }; + billingPlan: keyof typeof TEAM_PLANS; + cta?: PricingCardCta; ctaHint?: string; highlighted?: boolean; current?: boolean; @@ -49,13 +51,23 @@ export const PricingCard: React.FC = ({ const plan = TEAM_PLANS[billingPlan]; const isCustomPrice = typeof plan.price === "string"; + const trackEvent = useTrack(); const remainingTrialDays = (activeTrialEndsAt ? remainingDays(activeTrialEndsAt) : 0) || 0; + const handleCTAClick = () => { + cta?.onClick?.(); + trackEvent({ + category: "account", + label: `${billingPlan}Plan`, + action: "click", + }); + }; + return (
= ({
{/* Title + Desc */}
-
+

{plan.title}

{current && Current plan}
-

+

{plan.description}

@@ -85,30 +97,13 @@ export const PricingCard: React.FC = ({ {/* Price */}
- + ${plan.price} {!isCustomPrice && ( / month )} - - {billingPlan === "starter" && ( - - - - )}
{remainingTrialDays > 0 && ( @@ -124,7 +119,7 @@ export const PricingCard: React.FC = ({
{plan.subTitle && ( -

{plan.subTitle}

+

{plan.subTitle}

)} {plan.features.map((f) => ( @@ -134,29 +129,30 @@ export const PricingCard: React.FC = ({ {cta && (
- {billingPlan !== "pro" ? ( + {billingPlanToSkuMap[billingPlan] && cta.type === "checkout" && ( {cta.title} - ) : ( - )} @@ -171,6 +167,19 @@ export const PricingCard: React.FC = ({ ); }; +const billingPlanToSkuMap: Record = + { + starter: "plan:starter", + growth: "plan:growth", + accelerate: "plan:accelerate", + scale: "plan:scale", + // we can't render checkout buttons for these plans: + pro: undefined, + free: undefined, + growth_legacy: undefined, + starter_legacy: undefined, + }; + type FeatureItemProps = { text: string | string[]; }; diff --git a/apps/dashboard/src/@/lib/billing.ts b/apps/dashboard/src/@/lib/billing.ts index 58dfeffcf13..cca8d3385f8 100644 --- a/apps/dashboard/src/@/lib/billing.ts +++ b/apps/dashboard/src/@/lib/billing.ts @@ -3,6 +3,8 @@ export type ProductSKU = | "plan:starter" | "plan:growth" | "plan:custom" + | "plan:accelerate" + | "plan:scale" | "product:ecosystem_wallets" | "product:engine_standard" | "product:engine_premium" diff --git a/apps/dashboard/src/app/components/TeamPlanBadge.tsx b/apps/dashboard/src/app/components/TeamPlanBadge.tsx index 3e06098b10b..01f947c70ef 100644 --- a/apps/dashboard/src/app/components/TeamPlanBadge.tsx +++ b/apps/dashboard/src/app/components/TeamPlanBadge.tsx @@ -1,23 +1,45 @@ import type { Team } from "@/api/team"; -import { Badge } from "@/components/ui/badge"; +import { Badge, type BadgeProps } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; +const teamPlanToBadgeVariant: Record< + Team["billingPlan"], + BadgeProps["variant"] +> = { + // gray + free: "secondary", + starter: "secondary", + // yellow + starter_legacy: "warning", + growth_legacy: "warning", + // green + accelerate: "success", + growth: "success", + scale: "success", + // blue + pro: "default", +}; + +function getTeamPlanBadgeLabel(plan: Team["billingPlan"]) { + if (plan === "growth_legacy") { + return "Growth - Legacy"; + } + if (plan === "starter_legacy") { + return "Starter - Legacy"; + } + return plan; +} + export function TeamPlanBadge(props: { plan: Team["billingPlan"]; className?: string; }) { return ( - {props.plan} + {getTeamPlanBadgeLabel(props.plan)} ); } diff --git a/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx index c74df9307da..fc2bddb6838 100644 --- a/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; +import type { Team } from "../../../../@/api/team"; import { teamStub } from "../../../../stories/stubs"; import { storybookLog } from "../../../../stories/utils"; import { TeamOnboardingLayout } from "../onboarding-layout"; @@ -36,8 +37,26 @@ export const GrowthPlan: Story = { }, }; +export const AcceleratePlan: Story = { + args: { + plan: "accelerate", + }, +}; + +export const ScalePlan: Story = { + args: { + plan: "scale", + }, +}; + +export const ProPlan: Story = { + args: { + plan: "pro", + }, +}; + function Story(props: { - plan: "free" | "growth" | "starter"; + plan: Team["billingPlan"]; }) { return ( diff --git a/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.tsx b/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.tsx index c857259ff98..374cd91ae17 100644 --- a/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.tsx +++ b/apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.tsx @@ -7,12 +7,12 @@ import { Spinner } from "@/components/ui/Spinner/Spinner"; import { UnderlineLink } from "@/components/ui/UnderlineLink"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { TabButtons } from "@/components/ui/tabs"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { ArrowRightIcon, CircleArrowUpIcon } from "lucide-react"; @@ -74,15 +74,15 @@ export function InviteTeamMembersUI(props: { return (
- - + + - - + + void; }) { - const [planToShow, setPlanToShow] = useState<"starter" | "growth">("starter"); + const [planToShow, setPlanToShow] = useState< + "starter" | "growth" | "accelerate" | "scale" + >("growth"); const starterPlan = ( @@ -177,11 +176,8 @@ function InviteModalContent(props: { billingPlan="growth" teamSlug={props.teamSlug} cta={{ - title: "Get Started with Growth", - tracking: { - category: "account", - label: "growthPlan", - }, + title: "Get Started", + type: "checkout", onClick() { props.trackEvent({ category: "teamOnboarding", @@ -190,33 +186,75 @@ function InviteModalContent(props: { plan: "growth", }); }, - variant: "default", }} highlighted getBillingCheckoutUrl={props.getBillingCheckoutUrl} /> ); + const acceleratePlan = ( + + ); + + const scalePlan = ( + + ); + return (
- - Choose a plan - - + + + Choose a plan + + Get started with the free Starter plan or upgrade to Growth plan for increased limits and advanced features.{" "} Learn more about pricing - - + + -
+
{/* Desktop */} -
+
{starterPlan} {growthPlan} + {acceleratePlan} + {scalePlan}
{/* Mobile */} @@ -233,11 +271,23 @@ function InviteModalContent(props: { onClick: () => setPlanToShow("growth"), isActive: planToShow === "growth", }, + { + name: "Accelerate", + onClick: () => setPlanToShow("accelerate"), + isActive: planToShow === "accelerate", + }, + { + name: "Scale", + onClick: () => setPlanToShow("scale"), + isActive: planToShow === "scale", + }, ]} />
{planToShow === "starter" && starterPlan} {planToShow === "growth" && growthPlan} + {planToShow === "accelerate" && acceleratePlan} + {planToShow === "scale" && scalePlan}
); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx new file mode 100644 index 00000000000..6902dda91c7 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.client.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { getBillingCheckoutUrl, getBillingPortalUrl } from "@/actions/billing"; +import { apiServerProxy } from "@/actions/proxies"; +import type { Team } from "@/api/team"; +import type { TeamSubscription } from "@/api/team-subscription"; +import { PlanInfoCardUI } from "./PlanInfoCard"; + +export function PlanInfoCardClient(props: { + subscriptions: TeamSubscription[]; + team: Team; +}) { + return ( + { + const res = await apiServerProxy<{ + result: Team; + }>({ + pathname: `/v1/teams/${props.team.slug}`, + method: "GET", + }); + + if (!res.ok) { + throw new Error(res.error); + } + + return res.data.result; + }} + cancelPlan={async (params) => { + const res = await apiServerProxy<{ + data: { + result: "success"; + }; + }>({ + pathname: `/v1/teams/${props.team.id}/checkout/cancel-plan`, + headers: { + "Content-Type": "application/json", + }, + method: "PUT", + body: JSON.stringify(params), + }); + + if (!res.ok) { + console.error(res.error); + throw new Error(res.error); + } + }} + /> + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx index c7c5dbc3a07..d7e77da9f7d 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.stories.tsx @@ -1,8 +1,9 @@ +import type { Team } from "@/api/team"; import type { Meta, StoryObj } from "@storybook/react"; import { addDays } from "date-fns"; import { teamStub, teamSubscriptionsStub } from "stories/stubs"; import { BadgeContainer } from "../../../../../../../../stories/utils"; -import { PlanInfoCard } from "./PlanInfoCard"; +import { PlanInfoCardUI } from "./PlanInfoCard"; const meta = { title: "Billing/PlanInfoCard", @@ -17,12 +18,58 @@ const meta = { export default meta; type Story = StoryObj; -export const Variants: Story = { - args: {}, +export const Free: Story = { + args: { + plan: "free", + }, +}; + +export const StarterLegacy: Story = { + args: { + plan: "starter_legacy", + }, +}; + +export const Starter: Story = { + args: { + plan: "starter", + }, +}; + +export const GrowthLegacy: Story = { + args: { + plan: "growth_legacy", + }, +}; + +export const Growth: Story = { + args: { + plan: "growth", + }, +}; + +export const Accelerate: Story = { + args: { + plan: "accelerate", + }, +}; + +export const Scale: Story = { + args: { + plan: "scale", + }, +}; + +export const Pro: Story = { + args: { + plan: "pro", + }, }; -function Story() { - const team = teamStub("foo", "growth"); +function Story(props: { + plan: Team["billingPlan"]; +}) { + const team = teamStub("foo", props.plan); const zeroUsageOnDemandSubs = teamSubscriptionsStub("plan:growth"); const trialPlanZeroUsageOnDemandSubs = teamSubscriptionsStub("plan:growth", { trialEnd: addDays(new Date(), 7).toISOString(), @@ -63,37 +110,65 @@ function Story() { url: "https://example.com", }); + const getBillingCheckoutUrlStub = async () => ({ + status: 200, + url: "https://example.com", + }); + + const cancelPlanStub = async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return; + }; + + const teamTeamStub = async () => + ({ + ...team, + billingPlan: "free", + }) satisfies Team; + return (
- - - -
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx index 63cab193a93..6d21d60c785 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx @@ -1,25 +1,47 @@ -import type { GetBillingPortalUrlAction } from "@/actions/billing"; +"use client"; + +import type { + GetBillingCheckoutUrlAction, + GetBillingPortalUrlAction, +} from "@/actions/billing"; import type { Team } from "@/api/team"; import type { TeamSubscription } from "@/api/team-subscription"; import { BillingPortalButton } from "@/components/billing"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { differenceInDays, isAfter } from "date-fns"; import { format } from "date-fns/format"; -import { InfoIcon } from "lucide-react"; +import { CreditCardIcon, FileTextIcon, SquarePenIcon } from "lucide-react"; import { CircleAlertIcon } from "lucide-react"; import Link from "next/link"; +import { useState } from "react"; +import { + type CancelPlan, + CancelPlanButton, +} from "../../../../../../../../components/settings/Account/Billing/CancelPlanModal/CancelPlanModal"; +import { BillingPricing } from "../../../../../../../../components/settings/Account/Billing/Pricing"; import { getValidTeamPlan } from "../../../../../../components/TeamHeader/getValidTeamPlan"; -export function PlanInfoCard(props: { +export function PlanInfoCardUI(props: { subscriptions: TeamSubscription[]; team: Team; getBillingPortalUrl: GetBillingPortalUrlAction; + getBillingCheckoutUrl: GetBillingCheckoutUrlAction; + cancelPlan: CancelPlan; + getTeam: () => Promise; }) { const { subscriptions, team } = props; const validPlan = getValidTeamPlan(team); const isActualFreePlan = team.billingPlan === "free"; + const [isPlanSheetOpen, setIsPlanSheetOpen] = useState(false); const planSub = subscriptions.find( (subscription) => subscription.type === "PLAN", @@ -35,49 +57,74 @@ export function PlanInfoCard(props: { return (
+ +

- {validPlan} Plan + {validPlan === "growth_legacy" + ? "Growth" + : validPlan === "starter_legacy" + ? "Starter" + : validPlan}{" "} + Plan

- {trialEndsInFuture && Trial} + {validPlan.includes("legacy") && ( + Legacy + )} + {trialEndsInFuture && Trial}
-

- Click on "Manage Billing" to view your invoices, update your - payment method, or edit your billing details. -

+ + {validPlan.includes("legacy") && ( +

+ You are on the legacy plan. You may save by upgrading to new + plan.{" "} + + Learn More + +

+ )}
+ {trialEndsAfterDays > 0 && ( -

- +

Your trial ends in {trialEndsAfterDays} days

)}
-
- {/* go to invoices page */} - - - - {/* manage team billing */} - - Manage Billing - -
+ {props.team.billingPlan !== "free" && ( +
+ + + +
+ )}
@@ -91,11 +138,71 @@ export function PlanInfoCard(props: { To unlock additional usage, upgrade your plan to Starter or Growth.

+
+ +
) : ( )}
+ + {props.team.billingPlan !== "free" && ( +
+

+ + Adjust your plan here to avoid unnecessary charges.{" "} +
For more details, See{" "} +
+ + + {" "} + how to manage billing + {" "} + +

+ +
+ + + {/* manage team billing */} + + + Manage Billing + +
+
+ )}
); } @@ -175,3 +282,26 @@ function formatCurrencyAmount(centsAmount: number, currency: string) { currency: currency, }).format(centsAmount / 100); } + +function ViewPlansSheet(props: { + team: Team; + trialPeriodEndedAt: string | undefined; + getBillingCheckoutUrl: GetBillingCheckoutUrlAction; + isOpen: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + Manage plans + + + + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/SettingsCreditsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/SettingsCreditsPage.tsx index b576c0adbd4..1a3b4945204 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/SettingsCreditsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/credits/SettingsCreditsPage.tsx @@ -1,30 +1,33 @@ "use client"; + import type { Team } from "@/api/team"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; import { ApplyForOpCredits } from "components/onboarding/ApplyForOpCreditsModal"; -import { Heading, LinkButton } from "tw-components"; export const SettingsGasCreditsPage = (props: { team: Team; account: Account; }) => { return ( -
-
- - Apply to the Optimism Superchain App Accelerator - - - Learn More - +
+
+
+

+ Credits +

+

+ Apply to the Optimism Superchain App Accelerator.{" "} + + Learn More + +

+
- +
); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.stories.tsx index 604f7119794..bff1541182f 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.stories.tsx @@ -27,6 +27,12 @@ const TEAM_CONFIGS = [ { id: "free", label: "Free Team", team: teamStub("foo", "free") }, { id: "starter", label: "Starter Team", team: teamStub("foo", "starter") }, { id: "growth", label: "Growth Team", team: teamStub("bazz", "growth") }, + { + id: "accelerate", + label: "Accelerate Team", + team: teamStub("baz", "accelerate"), + }, + { id: "scale", label: "Scale Team", team: teamStub("qux", "scale") }, { id: "pro", label: "Pro Team", team: teamStub("bar", "pro") }, ] as const; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx index ac14de73d3a..6205ae94b7c 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx @@ -129,7 +129,7 @@ export function InviteSection(props: { } else { bottomSection = (
- {teamPlan === "pro" && ( + {teamPlan === "pro" ? (

Team members are billed according to your plan.{" "} .

- )} - - {(teamPlan === "starter" || teamPlan === "growth") && ( + ) : (

Team members are billed according to your plan.{" "} t.team.billingPlan === "free", + ); + const starterTeam = teamsAndProjectsStub.find( + (t) => t.team.billingPlan === "starter", + ); - const acccountAddressStub = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37"; + const starterLegacyTeam = teamsAndProjectsStub.find( + (t) => t.team.billingPlan === "starter_legacy", + ); - const team1 = teamsAndProjectsStub[0]?.team; - const team2 = teamsAndProjectsStub[1]?.team; - const team3 = teamsAndProjectsStub[2]?.team; - const team3Project = teamsAndProjectsStub[2]?.projects[0]; + const growthTeam = teamsAndProjectsStub.find( + (t) => t.team.billingPlan === "growth", + ); - if (!team1 || !team2 || !team3 || !team3Project) { - return

failed to get team and project stubs
; - } + const growthLegacyTeam = teamsAndProjectsStub.find( + (t) => t.team.billingPlan === "growth_legacy", + ); - const getChangelogsStub = () => Promise.resolve([]); - const getInboxNotificationsStub = () => Promise.resolve([]); - const markNotificationAsReadStub = () => Promise.resolve(); + const accelerateTeam = teamsAndProjectsStub.find( + (t) => t.team.billingPlan === "accelerate", + ); + + const scaleTeam = teamsAndProjectsStub.find( + (t) => t.team.billingPlan === "scale", + ); + + const proTeam = teamsAndProjectsStub.find( + (t) => t.team.billingPlan === "pro", + ); + + if ( + !freeTeam || + !starterTeam || + !growthTeam || + !growthLegacyTeam || + !accelerateTeam || + !scaleTeam || + !proTeam || + !starterLegacyTeam + ) { + return
invalid storybook stubs
; + } return (
- -
- {}} - connectButton={} - createProject={() => {}} - account={{ - email: "foo@example.com", - id: "1", - }} - client={client} - getChangelogNotifications={getChangelogsStub} - getInboxNotifications={getInboxNotificationsStub} - markNotificationAsRead={markNotificationAsReadStub} - /> -
+ + - -
- {}} - connectButton={} - createProject={() => {}} - client={client} - getChangelogNotifications={getChangelogsStub} - getInboxNotifications={getInboxNotificationsStub} - markNotificationAsRead={markNotificationAsReadStub} - /> -
+ + + + + + - + + + + + + + + + + + + + + + + +
- {}} - connectButton={} - createProject={() => {}} - client={client} - getChangelogNotifications={getChangelogsStub} - getInboxNotifications={getInboxNotificationsStub} - markNotificationAsRead={markNotificationAsReadStub} - /> +
+ + + +
); @@ -133,3 +137,38 @@ function Variants(props: { function ConnectButtonStub() { return ; } + +function Variant(props: { + team: Team; + type: "mobile" | "desktop"; + currentProject?: Project; +}) { + const Comp = + props.type === "mobile" ? TeamHeaderMobileUI : TeamHeaderDesktopUI; + + const getChangelogsStub = () => Promise.resolve([]); + const getInboxNotificationsStub = () => Promise.resolve([]); + const markNotificationAsReadStub = () => Promise.resolve(); + + return ( +
+ {}} + connectButton={} + createProject={() => {}} + client={client} + getChangelogNotifications={getChangelogsStub} + getInboxNotifications={getInboxNotificationsStub} + markNotificationAsRead={markNotificationAsReadStub} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/onboarding/ApplyForOpCreditsForm.tsx b/apps/dashboard/src/components/onboarding/ApplyForOpCreditsForm.tsx index 6be55713142..b1d6ad4b05f 100644 --- a/apps/dashboard/src/components/onboarding/ApplyForOpCreditsForm.tsx +++ b/apps/dashboard/src/components/onboarding/ApplyForOpCreditsForm.tsx @@ -1,14 +1,16 @@ +import type { Team } from "@/api/team"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; -import { Flex, FormControl, Input, Textarea } from "@chakra-ui/react"; +import { Flex, FormControl } from "@chakra-ui/react"; import { Select as ChakraSelect } from "chakra-react-select"; -import { ChakraNextImage } from "components/Image"; import { useTrack } from "hooks/analytics/useTrack"; import { useLocalStorage } from "hooks/useLocalStorage"; import { useTxNotifications } from "hooks/useTxNotifications"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useForm } from "react-hook-form"; -import { Button, FormHelperText, FormLabel } from "tw-components"; -import type { Team } from "../../@/api/team"; +import { FormHelperText, FormLabel } from "tw-components"; import { PlanToCreditsRecord } from "./ApplyForOpCreditsModal"; import { applyOpSponsorship } from "./applyOpSponsorship"; @@ -46,7 +48,7 @@ export const ApplyForOpCreditsForm: React.FC = ({ firstname: "", lastname: "", thirdweb_account_id: account?.id || "", - plan_type: PlanToCreditsRecord[plan].title, + plan_type: PlanToCreditsRecord[plan].plan, email: account?.email || "", company: "", website: "", @@ -70,16 +72,6 @@ export const ApplyForOpCreditsForm: React.FC = ({ "Something went wrong, please try again.", ); - // TODO: find better way to track impressions - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - trackEvent({ - category: "op-sponsorship", - action: "modal", - label: "view-form", - }); - }, [trackEvent]); - return ( = ({ })} > - First Name @@ -151,10 +138,12 @@ export const ApplyForOpCreditsForm: React.FC = ({ + Company Name + Company Website @@ -242,10 +231,9 @@ export const ApplyForOpCreditsForm: React.FC = ({
diff --git a/apps/dashboard/src/components/onboarding/ApplyForOpCreditsModal.tsx b/apps/dashboard/src/components/onboarding/ApplyForOpCreditsModal.tsx index 55f0c5076aa..63e088c0e5e 100644 --- a/apps/dashboard/src/components/onboarding/ApplyForOpCreditsModal.tsx +++ b/apps/dashboard/src/components/onboarding/ApplyForOpCreditsModal.tsx @@ -1,71 +1,85 @@ -import { Badge } from "@/components/ui/badge"; -import { type Account, accountPlan } from "@3rdweb-sdk/react/hooks/useApi"; +import type { Team } from "@/api/team"; +import { UnderlineLink } from "@/components/ui/UnderlineLink"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; import { - Alert, - AlertDescription, - AlertIcon, - Box, - Flex, - SimpleGrid, -} from "@chakra-ui/react"; + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { type Account, accountPlan } from "@3rdweb-sdk/react/hooks/useApi"; import { useTrack } from "hooks/analytics/useTrack"; import { useLocalStorage } from "hooks/useLocalStorage"; -import Link from "next/link"; -import { useEffect, useState } from "react"; -import { Button, Card, Heading, Text } from "tw-components"; -import type { Team } from "../../@/api/team"; +import { ArrowRightIcon, CircleAlertIcon } from "lucide-react"; +import { useState } from "react"; +import { TeamPlanBadge } from "../../app/components/TeamPlanBadge"; import { getValidTeamPlan } from "../../app/team/components/TeamHeader/getValidTeamPlan"; import { ApplyForOpCreditsForm } from "./ApplyForOpCreditsForm"; import { PlanCard } from "./PlanCard"; export type CreditsRecord = { - title: string; + plan: Team["billingPlan"]; upTo?: true; credits: string; - color: string; features?: string[]; - ctaTitle?: string; - ctaHref?: string; +}; + +const tier2Credits: Omit = { + upTo: true, + credits: "$2,500", + features: [ + "10k monthly active wallets", + "User analytics", + "Custom Auth", + "Custom Branding", + ], +}; + +const tier1Credits: Omit = { + upTo: true, + credits: "$250", }; export const PlanToCreditsRecord: Record = { free: { - title: "Free", - upTo: true, - credits: "$250", - color: "#3b394b", + plan: "free", + ...tier1Credits, }, starter: { - title: "Starter", - upTo: true, - credits: "$250", - color: "#3b394b", + plan: "starter", + ...tier1Credits, + }, + starter_legacy: { + plan: "starter_legacy", + ...tier1Credits, }, growth: { - title: "Growth", - upTo: true, - credits: "$2,500", - color: "#28622A", - features: [ - "10k monthly active wallets", - "User analytics", - "Custom Auth", - "Custom Branding", - ], - ctaTitle: "Upgrade for $99", - ctaHref: "/team/~/~/settings/billing", + plan: "growth", + ...tier1Credits, + }, + growth_legacy: { + plan: "growth_legacy", + ...tier2Credits, + }, + accelerate: { + plan: "accelerate", + ...tier2Credits, + }, + scale: { + plan: "scale", + ...tier2Credits, }, pro: { - title: "Pro", + plan: "pro", + upTo: true, credits: "$3,000+", - color: "#282B6F", features: [ "Custom rate limits for APIs & Infra", "Enterprise grade SLAs", "Dedicated support", ], - ctaTitle: "Contact Us", - ctaHref: "https://meetings.hubspot.com/sales-thirdweb/thirdweb-pro", }, }; @@ -77,129 +91,143 @@ export function ApplyForOpCredits(props: { const validTeamPlan = getValidTeamPlan(team); const hasValidPaymentMethod = validTeamPlan !== "free"; - const [page, setPage] = useState<"eligible" | "form">("eligible"); - const [hasAppliedForOpGrant] = useLocalStorage( `appliedForOpGrant-${team.id}`, false, ); - const trackEvent = useTrack(); - - // TODO: find better way to track impressions - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - trackEvent({ - category: "op-sponsorship", - action: "modal", - label: "view-modal", - }); - }, [trackEvent]); - const isStarterPlan = validTeamPlan === "starter"; const isProPlan = validTeamPlan === "pro"; const creditsRecord = PlanToCreditsRecord[validTeamPlan]; return ( - <> - {page === "eligible" ? ( - <> - - - - - {creditsRecord.title} - - - - - {creditsRecord.upTo && "Up to"} - - - {creditsRecord.credits} - - - GAS CREDITS - - - - - {!hasValidPaymentMethod && ( - - - - - You need to add a payment method to be able to claim - credits. This is to prevent abuse, you will not be - charged.{" "} - - Upgrade to Starter plan to get started - - . - - - +
+
+ {/* credits info */} +
+
+

+ {creditsRecord.upTo && "Up to"} {creditsRecord.credits} Gas + Credits +

+ +
+ + {/* alert */} + {!hasValidPaymentMethod && ( +
+ + + Payment method required + + You need to add a payment method to be able to claim credits.{" "} +
This is to prevent abuse, you will not be charged.{" "} + + Upgrade plan to get started + + . +
+
+
+ )} + +
+ +
+
+ + {!isProPlan && ( +
+

+ Or upgrade and get access to more credits +

+
+ {isStarterPlan && ( + )} - - - {!isProPlan && ( - <> - - Or upgrade and get access to more credits: - - - {isStarterPlan && ( - - )} - - - - )} - - - We are open to distributing more than the upper limit for each tier - if you make a strong case about how it will be utilized. - - - ) : ( + +
+
+ )} +
+

+ We are open to distributing more than the upper limit for each tier if + you make a strong case about how it will be utilized. +

+
+ ); +} + +function ApplyOpCreditsButton(props: { + hasAppliedForOpGrant: boolean; + hasValidPaymentMethod: boolean; + validTeamPlan: Team["billingPlan"]; + account: Account; +}) { + const trackEvent = useTrack(); + const { + hasAppliedForOpGrant, + hasValidPaymentMethod, + validTeamPlan, + account, + } = props; + + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + + + Apply for OP credits + +
{ - setPage("eligible"); + setIsOpen(false); }} plan={validTeamPlan} account={account} /> - )} - + + ); } diff --git a/apps/dashboard/src/components/onboarding/PlanCard.tsx b/apps/dashboard/src/components/onboarding/PlanCard.tsx index cae49dcae91..a527e4d454c 100644 --- a/apps/dashboard/src/components/onboarding/PlanCard.tsx +++ b/apps/dashboard/src/components/onboarding/PlanCard.tsx @@ -1,56 +1,45 @@ -import { Badge } from "@/components/ui/badge"; -import { Flex, ListItem, UnorderedList } from "@chakra-ui/react"; -import { Card, Heading, LinkButton, Text } from "tw-components"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import { TeamPlanBadge } from "../../app/components/TeamPlanBadge"; import type { CreditsRecord } from "./ApplyForOpCreditsModal"; -interface PlanCardProps { +type PlanCardProps = { creditsRecord: CreditsRecord; -} + teamSlug: string; +}; -export const PlanCard: React.FC = ({ creditsRecord }) => { +export function PlanCard({ creditsRecord, teamSlug }: PlanCardProps) { return ( - - -
- - {creditsRecord.title} - -
- - {creditsRecord.upTo ? "Up to" : "\u00A0"} - - {creditsRecord.credits} - - - GAS CREDITS - - +
+
+

+ {creditsRecord.upTo || "Up To"} {creditsRecord.credits} Gas Credits +

+ +
+ +
{creditsRecord.features && ( - +
    {creditsRecord.features.map((feature) => ( - +
  • {feature} - +
  • ))} - +
)} - - {creditsRecord.ctaTitle && creditsRecord.ctaHref && ( - + +
+ +
+
); -}; +} diff --git a/apps/dashboard/src/components/settings/Account/Billing/BillingPricing.stories.tsx b/apps/dashboard/src/components/settings/Account/Billing/BillingPricing.stories.tsx deleted file mode 100644 index 641da24abc7..00000000000 --- a/apps/dashboard/src/components/settings/Account/Billing/BillingPricing.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { teamStub } from "../../../../stories/stubs"; -import { BadgeContainer } from "../../../../stories/utils"; -import { BillingPricing } from "./Pricing"; - -const meta = { - title: "Billing/PricingCards", - component: Story, - parameters: { - nextjs: { - appDirectory: true, - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Variants: Story = { - args: {}, -}; - -function Story() { - const getBillingPortalUrlStub = async () => ({ - status: 200, - url: "https://example.com", - }); - - return ( -
- - - - - - - - - - - - - - - -
- ); -} diff --git a/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx b/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx new file mode 100644 index 00000000000..842e9389773 --- /dev/null +++ b/apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx @@ -0,0 +1,253 @@ +"use client"; + +import type { Team } from "@/api/team"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Textarea } from "@/components/ui/textarea"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { CircleXIcon, ExternalLinkIcon } from "lucide-react"; +import Link from "next/link"; +import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { pollWithTimeout } from "../../../../../utils/pollWithTimeout"; + +const cancelPlanFormSchema = z + .object({ + comment: z.string().optional(), + feedback: z.enum([ + "customer_service", + "low_quality", + "missing_features", + "other", + "switched_service", + "too_complex", + "too_expensive", + "unused", + ]), + }) + // if feedback is other, comment is required + .refine( + (data) => + !( + data.feedback === "other" && + (!data.comment || data.comment.trim() === "") + ), + { + message: "Required", + path: ["comment"], + }, + ); + +type CancelPlanParams = z.infer; +export type CancelPlan = (params: CancelPlanParams) => Promise; + +const cancelReasons: Array<{ + value: CancelPlanParams["feedback"]; + label: string; +}> = [ + { value: "too_expensive", label: "Too expensive" }, + { value: "too_complex", label: "Too complex to use" }, + { value: "missing_features", label: "Missing features I need" }, + { value: "low_quality", label: "Quality doesn't meet expectations" }, + { value: "unused", label: "Not using it enough" }, + { value: "switched_service", label: "Switched to another service" }, + { value: "customer_service", label: "Unhappy with customer service" }, + { value: "other", label: "Other reason" }, +]; + +export function CancelPlanButton(props: { + cancelPlan: CancelPlan; + currentPlan: Team["billingPlan"]; + getTeam: () => Promise; +}) { + return ( + + + + + + + + Cancel Plan + + + + {props.currentPlan === "pro" ? ( + + ) : ( + + )} + + + ); +} + +const PRO_CONTACT_US_URL = + "https://meetings.hubspot.com/sales-thirdweb/thirdweb-pro"; + +function ProPlanCancelPlanSheetContent() { + return ( +
+

+ Please contact us to cancel your Pro plan +

+ + +
+ ); +} + +function CancelPlanSheetContent(props: { + cancelPlan: CancelPlan; + getTeam: () => Promise; +}) { + const [_isPending, startTransition] = useTransition(); + const [isPollingTeam, setIsPollingTeam] = useState(false); + const router = useDashboardRouter(); + const isPending = _isPending || isPollingTeam; + + const form = useForm>({ + resolver: zodResolver(cancelPlanFormSchema), + defaultValues: { + comment: "", + feedback: undefined, + }, + }); + + const cancelPlan = useMutation({ + mutationFn: props.cancelPlan, + }); + + function onSubmit(values: z.infer) { + const promise = cancelPlan.mutateAsync(values); + toast.promise(promise, { + success: "Plan cancelled successfully", + error: "Failed to cancel plan", + }); + promise.then(async () => { + setIsPollingTeam(true); + // keep polling until the team plan is free, then refresh the page + await pollWithTimeout({ + shouldStop: async () => { + const team = await props.getTeam(); + return team.billingPlan === "free"; + }, + timeoutMs: 7000, + }); + + setIsPollingTeam(false); + + startTransition(() => { + router.refresh(); + }); + }); + } + + return ( +
+
+

Cancel Plan

+ {isPending && } +
+

+ Please let us know why you're cancelling your plan. Your feedback helps + us improve our service. +

+ +
+ + ( + + Why are you cancelling? + + + + )} + /> + + ( + + Additional comments + +