diff --git a/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx b/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx index 48091acc635..e6507f45d6d 100644 --- a/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx +++ b/apps/dashboard/src/@/components/blocks/NetworkSelectors.tsx @@ -152,6 +152,7 @@ export function SingleNetworkSelector(props: { disableChainId?: boolean; align?: "center" | "start" | "end"; disableTestnets?: boolean; + disableDeprecated?: boolean; placeholder?: string; client: ThirdwebClient; }) { @@ -169,8 +170,17 @@ export function SingleNetworkSelector(props: { chains = chains.filter((chain) => chainIdSet.has(chain.chainId)); } + if (props.disableDeprecated) { + chains = chains.filter((chain) => chain.status !== "deprecated"); + } + return chains; - }, [allChains, props.chainIds, props.disableTestnets]); + }, [ + allChains, + props.chainIds, + props.disableTestnets, + props.disableDeprecated, + ]); const options = useMemo(() => { return chainsToShow.map((chain) => { diff --git a/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx index 9050c2854de..a9461a8a6e8 100644 --- a/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx +++ b/apps/dashboard/src/@/components/blocks/UpsellBannerCard.tsx @@ -38,12 +38,18 @@ const ACCENT = { type UpsellBannerCardProps = { title: React.ReactNode; description: React.ReactNode; - cta: { - text: React.ReactNode; - icon?: React.ReactNode; - target?: "_blank"; - link: string; - }; + cta?: + | { + text: React.ReactNode; + icon?: React.ReactNode; + target?: "_blank"; + link: string; + } + | { + text: React.ReactNode; + icon?: React.ReactNode; + onClick: () => void; + }; accentColor?: keyof typeof ACCENT; icon?: React.ReactNode; }; @@ -93,25 +99,41 @@ export function UpsellBannerCard(props: UpsellBannerCardProps) { - + ) : props.cta && "onClick" in props.cta ? ( + + + ) : null} ); diff --git a/apps/dashboard/src/@/icons/ChainIcon.tsx b/apps/dashboard/src/@/icons/ChainIcon.tsx index ae845a75915..fef285a6635 100644 --- a/apps/dashboard/src/@/icons/ChainIcon.tsx +++ b/apps/dashboard/src/@/icons/ChainIcon.tsx @@ -30,7 +30,7 @@ export const ChainIconClient = ({ fallback={} key={resolvedSrc} loading={restProps.loading || "lazy"} - skeleton={
} + skeleton={} src={resolvedSrc} /> ); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/[chain_id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/[chain_id]/page.tsx new file mode 100644 index 00000000000..019793d80d8 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/[chain_id]/page.tsx @@ -0,0 +1,12 @@ +import { getChain } from "../../../../../../(dashboard)/(chain)/utils"; + +export default async function InfrastructurePage(props: { + params: Promise<{ + team_slug: string; + chain_id: string; + }>; +}) { + const params = await props.params; + const chain = await getChain(params.chain_id); + return
Infrastructure for: {chain.name}
; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/checkout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/checkout.tsx new file mode 100644 index 00000000000..f3373f57aaf --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/checkout.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { DatabaseIcon, NetworkIcon, ShieldIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { ClientOnly } from "@/components/blocks/client-only"; +import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { Badge } from "../../../../../../../../../@/components/ui/badge"; +import { ChainIconClient } from "../../../../../../../../../@/icons/ChainIcon"; +import { OrderSummary } from "./order-summary"; +import { + type PaymentFrequency, + PaymentFrequencySelector, +} from "./payment-frequency-selector"; +import { type ServiceConfig, ServiceSelector } from "./service-selector"; + +const services = { + accountAbstraction: { + description: "Smart wallets & gasless transactions", + features: [ + "Fully managed Bundler & Paymaster", + "Audited ERC-4337 smart wallets out of the box", + "ERC-7702 support", + "Session key support", + ], + icon: ShieldIcon, + id: "account-abstraction" as const, + monthlyPrice: 750, + name: "Account Abstraction", + upsellReason: " ", + }, + insight: { + description: "Instant, real-time data APIs, without the hassle", + features: [ + "Comprehensive onchain data APIs", + "Webhooks for real-time event streaming", + "Instant wallet, token & NFT balance lookups", + "Fully managed and battle-tested", + ], + icon: DatabaseIcon, + id: "insight" as const, + monthlyPrice: 2000, + name: "Insight", + upsellReason: " ", + }, + rpc: { + description: "Low-latency edge RPC with no node maintenance", + features: [ + "Low-latency edge RPC", + "Auto-scaling & global load balancing", + "Smart caching & automatic failover", + "Fully managed and battle-tested", + ], + icon: NetworkIcon, + id: "rpc" as const, + monthlyPrice: 2000, + name: "RPC", + required: true, + upsellReason: " ", + }, +} satisfies Record; + +const serviceConfigs = [ + services.rpc, + services.insight, + services.accountAbstraction, +]; + +export function InfrastructureCheckout(props: { client: ThirdwebClient }) { + const [selectedChain, setSelectedChain] = useState(0); + const { idToChain } = useAllChainsData(); + const [selectedServices, setSelectedServices] = useState([ + services.rpc, + ]); + + const selectedChainDetails = useMemo(() => { + return idToChain.get(selectedChain); + }, [idToChain, selectedChain]); + + const [paymentFrequency, setPaymentFrequency] = + useState("monthly"); + + return ( +
+ {/* Configuration Section */} +
+ {/* Chain Selection */} + + + + + Step 1 + + Select Chain + + + Choose the chain to deploy infrastructure on. + + + + + + + + + + {/* Service Selection */} + + + + + Step 2 + + Select Services + + + Choose the infrastructure services you need. RPC service is + required for all other services. + + + + + + + + {/* Payment Frequency */} + + + + + Step 3 + + Payment Frequency + + + Choose your billing frequency. Save 15% with annual payment. + + + + + + +
+ + {/* Pricing Summary */} +
+ + + Order Summary + + {selectedChainDetails ? ( +
+ + + {selectedChainDetails.name} + + + Chain ID + {selectedChainDetails.chainId} + +
+ ) : ( + Select a chain + )} +
+
+ + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/order-summary.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/order-summary.tsx new file mode 100644 index 00000000000..74516e54826 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/order-summary.tsx @@ -0,0 +1,191 @@ +import { CreditCardIcon } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import type { PaymentFrequency } from "./payment-frequency-selector"; +import type { ServiceConfig } from "./service-selector"; + +export function OrderSummary(props: { + selectedChainId: number; + selectedServices: ServiceConfig[]; + paymentFrequency: PaymentFrequency; +}) { + const pricing = calculatePricing( + props.selectedServices, + props.paymentFrequency, + ); + + return ( +
+ {/* Selected Services */} +
+

Selected Services

+ {props.selectedServices.length > 0 ? ( + props.selectedServices.map((service) => { + if (!service) return null; + const Icon = service.icon; + return ( +
+
+ + {service.name} +
+ + $ + {service.monthlyPrice.toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + })} + /mo + +
+ ); + }) + ) : ( +

No services selected

+ )} +
+ + + {/* Pricing Breakdown */} +
+
+ + Subtotal ( + {props.paymentFrequency === "annual" ? "12 months" : "monthly"} + ): + + + $ + {( + pricing.baseMonthlyTotal * + (props.paymentFrequency === "annual" ? 12 : 1) + ).toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + })} + +
+ + {pricing.bundleDiscount > 0 && ( +
+ + Bundle Discount ({pricing.bundleDiscountPercent}%): + + + -$ + {( + pricing.bundleDiscount * + (props.paymentFrequency === "annual" ? 12 : 1) + ).toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + })} + +
+ )} + + {pricing.annualDiscount > 0 && ( +
+ Annual Discount (15%): + + -$ + {( + pricing.annualDiscount * + (props.paymentFrequency === "annual" ? 12 : 1) + ).toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + })} + +
+ )} + + + +
+ Total: + + $ + {pricing.displayTotal.toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + })} + +
+ + {pricing.totalSavings > 0 && ( +
+ + You are saving $ + {pricing.totalSavings.toLocaleString("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + })}{" "} + +
+ )} +
+ + + + {/* Checkout Button */} + +
+ ); +} + +function calculatePricing( + selectedServices: ServiceConfig[], + paymentFrequency: PaymentFrequency, +) { + const baseMonthlyTotal = selectedServices.reduce((total, service) => { + return total + (service?.monthlyPrice || 0); + }, 0); + + // Bundle discount calculation + let bundleDiscount = 0; + if (selectedServices.length === 3) { + bundleDiscount = 0.15; // 15% for all three services + } else if (selectedServices.length >= 2) { + bundleDiscount = 0.1; // 10% for two or more services + } + + // Annual discount + const annualDiscount = paymentFrequency === "annual" ? 0.15 : 0; + + // Calculate discounts + const bundleDiscountAmount = baseMonthlyTotal * bundleDiscount; + const subtotalAfterBundle = baseMonthlyTotal - bundleDiscountAmount; + const annualDiscountAmount = subtotalAfterBundle * annualDiscount; + const monthlyFinalTotal = subtotalAfterBundle - annualDiscountAmount; + + // Calculate totals based on payment frequency + const multiplier = paymentFrequency === "annual" ? 12 : 1; + const displayTotal = monthlyFinalTotal * multiplier; + + return { + annualDiscount: annualDiscountAmount, + annualDiscountPercent: annualDiscount * 100, + baseMonthlyTotal, + bundleDiscount: bundleDiscountAmount, + bundleDiscountPercent: bundleDiscount * 100, + displayTotal, + monthlyFinalTotal, + totalSavings: (bundleDiscountAmount + annualDiscountAmount) * multiplier, + }; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/payment-frequency-selector.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/payment-frequency-selector.tsx new file mode 100644 index 00000000000..da1863ee669 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/payment-frequency-selector.tsx @@ -0,0 +1,47 @@ +import { Badge } from "@/components/ui/badge"; +import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group"; + +export type PaymentFrequency = "monthly" | "annual"; + +export function PaymentFrequencySelector(props: { + annualDiscountPercent: number; + paymentFrequency: PaymentFrequency; + setPaymentFrequency: (paymentFrequency: PaymentFrequency) => void; +}) { + return ( + + props.setPaymentFrequency(value) + } + value={props.paymentFrequency} + > + +
+
Monthly Billing
+
+ Pay monthly, cancel anytime +
+
+
+ + +
+
+ Annual Billing + Save {props.annualDiscountPercent}% +
+
+ Pay annually, save on costs +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/service-selector.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/service-selector.tsx new file mode 100644 index 00000000000..c8d6b35e896 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/_components/service-selector.tsx @@ -0,0 +1,157 @@ +import { CheckIcon } from "lucide-react"; +import { useMemo } from "react"; +import { UpsellBannerCard } from "@/components/blocks/UpsellBannerCard"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +export type Service = "rpc" | "insight" | "account-abstraction"; + +export type ServiceConfig = { + id: Service; + name: string; + description: string; + monthlyPrice: number; + icon: React.ComponentType<{ className?: string }>; + required?: boolean; + features: string[]; + upsellReason: string; +}; + +export function ServiceSelector(props: { + services: ServiceConfig[]; + selectedServices: ServiceConfig[]; + setSelectedServices: (services: ServiceConfig[]) => void; +}) { + const nextServiceToAdd = useMemo(() => { + // find the first service that is not in the selected services yet + return props.services.find((service) => { + return !props.selectedServices.find((s) => s.id === service.id); + }); + }, [props.services, props.selectedServices]); + + const upsellTitle = useMemo(() => { + if (!nextServiceToAdd) { + return "You are getting the maximum 15% bundle discount!"; + } + + switch (props.selectedServices.length) { + case 1: + return `Add ${nextServiceToAdd.name} to get a 10% bundle discount!`; + case 2: + return `Add ${nextServiceToAdd.name} to get the maximum 15% bundle discount!`; + default: + return "You are getting the maximum 15% bundle discount!"; + } + }, [props.selectedServices.length, nextServiceToAdd]); + + return ( +
+
+ {props.services.map((service) => { + const isSelected = props.selectedServices.includes(service); + + return ( + { + // if the service is required, don't allow it to be toggled + if (toggledService.required) { + return; + } + + props.setSelectedServices( + isSelected + ? props.selectedServices.filter( + (s) => s.id !== toggledService.id, + ) + : [...props.selectedServices, toggledService], + ); + }} + service={service} + /> + ); + })} +
+ {/* Upsell additional services */} + + { + if (nextServiceToAdd) { + props.setSelectedServices([ + ...props.selectedServices, + nextServiceToAdd, + ]); + } + }, + text: `Add ${nextServiceToAdd?.name}`, + } + : undefined + } + description={ + nextServiceToAdd?.upsellReason || + "🎉 Congratulations, you are getting our best deal!" + } + title={upsellTitle} + /> +
+ ); +} + +function ServiceCheckboxCard(props: { + service: ServiceConfig; + isSelected: boolean; + onToggle: (service: ServiceConfig) => void; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/page.tsx new file mode 100644 index 00000000000..5b368073759 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/deploy/page.tsx @@ -0,0 +1,8 @@ +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { InfrastructureCheckout } from "./_components/checkout"; + +export default async function DeployInfrastructurePage() { + const client = getClientThirdwebClient(); + + return ; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/layout.tsx new file mode 100644 index 00000000000..86e822c683a --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/layout.tsx @@ -0,0 +1,52 @@ +import { RocketIcon } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getTeamBySlug } from "@/api/team"; +import { UpsellWrapper } from "@/components/blocks/upsell-wrapper"; +import { Button } from "@/components/ui/button"; + +export default async function Layout(props: { + children: React.ReactNode; + params: Promise<{ + team_slug: string; + }>; +}) { + const params = await props.params; + const team = await getTeamBySlug(params.team_slug); + if (!team) { + redirect("/team"); + } + return ( + +
+
+
+

+ Chain Infrastructure +

+ +
+
+
{props.children}
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/page.tsx new file mode 100644 index 00000000000..605998609fc --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/scale/page.tsx @@ -0,0 +1,3 @@ +export default function InfrastructurePage() { + return
Infrastructure Overview
; +}