diff --git a/packages/website/src/constants/contracts.ts b/packages/website/src/constants/contracts.ts new file mode 100644 index 000000000..01dd10eea --- /dev/null +++ b/packages/website/src/constants/contracts.ts @@ -0,0 +1,284 @@ +import { Address } from 'viem'; + +// Contract addresses for different networks +export const CONTRACT_ADDRESSES = { + // Mainnet + 1: { + CANNON_REGISTRY: '0x8E5C7EFC9636A6A0408A46BB7F617094B81e5dba' as Address, + CANNON_SUBSCRIPTION: '0x0000000000000000000000000000000000000000' as Address, // TODO: Add actual address + USDC: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Address, + }, + // Optimism + 10: { + CANNON_REGISTRY: '0x8E5C7EFC9636A6A0408A46BB7F617094B81e5dba' as Address, + CANNON_SUBSCRIPTION: '0x337D68596cEc15647f2710C9EdE2F48aB7f30657' as Address, // TODO: Add actual address + USDC: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85' as Address, + }, + // Sepolia + 11155111: { + CANNON_REGISTRY: '0x0000000000000000000000000000000000000000' as Address, // TODO: Add actual address + CANNON_SUBSCRIPTION: '0x0000000000000000000000000000000000000000' as Address, // TODO: Add actual address + USDC: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' as Address, + }, +} as const; + +// CannonSubscription ABI +export const CANNON_SUBSCRIPTION_ABI = [ + { + inputs: [ + { + internalType: 'uint16', + name: '_planId', + type: 'uint16', + }, + ], + name: 'getPlan', + outputs: [ + { + components: [ + { + internalType: 'uint256', + name: 'price', + type: 'uint256', + }, + { + internalType: 'uint16', + name: 'id', + type: 'uint16', + }, + { + internalType: 'uint32', + name: 'duration', + type: 'uint32', + }, + { + internalType: 'uint32', + name: 'quota', + type: 'uint32', + }, + { + internalType: 'uint16', + name: 'minTerms', + type: 'uint16', + }, + { + internalType: 'uint16', + name: 'maxTerms', + type: 'uint16', + }, + { + internalType: 'bool', + name: 'active', + type: 'bool', + }, + { + internalType: 'bool', + name: 'refundable', + type: 'bool', + }, + ], + internalType: 'struct Subscription.Plan', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '_user', + type: 'address', + }, + ], + name: 'getAvailablePlans', + outputs: [ + { + internalType: 'uint16[]', + name: '', + type: 'uint16[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '_user', + type: 'address', + }, + ], + name: 'getMembership', + outputs: [ + { + components: [ + { + internalType: 'uint16', + name: 'planId', + type: 'uint16', + }, + { + internalType: 'uint32', + name: 'activeFrom', + type: 'uint32', + }, + { + internalType: 'uint32', + name: 'activeUntil', + type: 'uint32', + }, + { + internalType: 'uint32', + name: 'availableCredits', + type: 'uint32', + }, + ], + internalType: 'struct Subscription.Membership', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '_user', + type: 'address', + }, + ], + name: 'hasActiveMembership', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint16', + name: '_planId', + type: 'uint16', + }, + { + internalType: 'uint32', + name: '_amountOfTerms', + type: 'uint32', + }, + ], + name: 'purchaseMembership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'cancelMembership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'TOKEN', + outputs: [ + { + internalType: 'contract IERC20', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'VAULT', + outputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +// ERC20 ABI for token interactions +export const ERC20_ABI = [ + { + inputs: [ + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + ], + name: 'balanceOf', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + ], + name: 'allowance', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + name: 'approve', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; diff --git a/packages/website/src/constants/links.ts b/packages/website/src/constants/links.ts index 3fc21292c..eae3da0dc 100644 --- a/packages/website/src/constants/links.ts +++ b/packages/website/src/constants/links.ts @@ -17,4 +17,5 @@ export const links = { IPFS_UPLOAD: '/ipfs/upload', DOCS_CLI_RUN: '/learn/cli#run', DOCS_CANNONFILE_PROVISION: '/learn/cannonfile#clone', + PRICING: '/pricing', }; diff --git a/packages/website/src/features/Header/Header.tsx b/packages/website/src/features/Header/Header.tsx index 3ec23d229..ca909ea0d 100644 --- a/packages/website/src/features/Header/Header.tsx +++ b/packages/website/src/features/Header/Header.tsx @@ -37,6 +37,9 @@ const NavLinks = () => { Docs + + Pricing + ); }; diff --git a/packages/website/src/hooks/subscription.ts b/packages/website/src/hooks/subscription.ts new file mode 100644 index 000000000..aa7782169 --- /dev/null +++ b/packages/website/src/hooks/subscription.ts @@ -0,0 +1,298 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAccount, usePublicClient, useWalletClient } from 'wagmi'; +import { Address, createPublicClient, formatUnits, parseUnits, zeroAddress } from 'viem'; +import { CONTRACT_ADDRESSES, CANNON_SUBSCRIPTION_ABI, ERC20_ABI } from '@/constants/contracts'; +import { useContractCall, useContractTransaction } from './ethereum'; +import { useContractInteraction } from '@/features/Packages/interact/useContractInteraction'; +import { useCannonChains } from '@/providers/CannonProvidersProvider'; + +export interface SubscriptionPlan { + id: number; + price: bigint; + duration: number; + quota: number; + minTerms: number; + maxTerms: number; + active: boolean; + refundable: boolean; +} + +export interface SubscriptionMembership { + planId: number; + activeFrom: number; + activeUntil: number; + availableCredits: number; +} + +export interface PricingData { + plans: SubscriptionPlan[]; + userMembership: SubscriptionMembership | null; + hasActiveMembership: boolean; + userBalance: bigint; + userAllowance: bigint; + isLoading: boolean; + error: string | null; +} + +export function useSubscription() { + const { address, isConnected } = useAccount(); + const { getChainById, transports } = useCannonChains(); + const chain = getChainById(10); + const publicClient = createPublicClient({ + chain, + transport: transports[10], + }); + const { data: walletClient } = useWalletClient({ chainId: 10 }); + const [pricingData, setPricingData] = useState({ + plans: [], + userMembership: null, + hasActiveMembership: false, + userBalance: 0n, + userAllowance: 0n, + isLoading: true, + error: null, + }); + + // Get contract addresses for current chain + const chainId = publicClient?.chain?.id ?? 0; + const contractAddresses = chainId ? CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] : null; + + // Contract call hooks + const getPlan = useContractCall( + contractAddresses?.CANNON_SUBSCRIPTION || '0x0000000000000000000000000000000000000000', + 'getPlan', + [], + 0n, + CANNON_SUBSCRIPTION_ABI, + publicClient! + ); + + const getAvailablePlans = useContractCall( + contractAddresses?.CANNON_SUBSCRIPTION || '0x0000000000000000000000000000000000000000', + 'getAvailablePlans', + [], + 0n, + CANNON_SUBSCRIPTION_ABI, + publicClient! + ); + + const getMembership = useContractCall( + contractAddresses?.CANNON_SUBSCRIPTION || '0x0000000000000000000000000000000000000000', + 'getMembership', + [], + 0n, + CANNON_SUBSCRIPTION_ABI, + publicClient! + ); + + const checkActiveMembership = useContractCall( + contractAddresses?.CANNON_SUBSCRIPTION || '0x0000000000000000000000000000000000000000', + 'hasActiveMembership', + [], + 0n, + CANNON_SUBSCRIPTION_ABI, + publicClient! + ); + + const getBalance = useContractCall( + contractAddresses?.USDC || '0x0000000000000000000000000000000000000000', + 'balanceOf', + [], + 0n, + ERC20_ABI, + publicClient! + ); + + const getAllowance = useContractCall( + contractAddresses?.USDC || '0x0000000000000000000000000000000000000000', + 'allowance', + [], + 0n, + ERC20_ABI, + publicClient! + ); + + // Transaction hooks + const purchaseMembershipInteraction = useContractInteraction({ + f: CANNON_SUBSCRIPTION_ABI.find((f) => f.name === 'purchaseMembership') as any, + abi: CANNON_SUBSCRIPTION_ABI, + address: contractAddresses?.CANNON_SUBSCRIPTION || zeroAddress, + chainId: chainId, + params: [0, 1], // Will be updated dynamically + value: 0n, + isFunctionReadOnly: false, + }); + + const cancelMembershipInteraction = useContractInteraction({ + f: CANNON_SUBSCRIPTION_ABI.find((f) => f.name === 'cancelMembership') as any, + abi: CANNON_SUBSCRIPTION_ABI, + address: contractAddresses?.CANNON_SUBSCRIPTION || zeroAddress, + chainId: chainId, + params: [], + value: 0n, + isFunctionReadOnly: false, + }); + + // Load pricing data + const loadPricingData = useCallback(async () => { + if (!contractAddresses || !publicClient || contractAddresses.CANNON_SUBSCRIPTION === zeroAddress) { + setPricingData((prev) => ({ ...prev, isLoading: false, error: 'Network is not supported' })); + return; + } + + try { + setPricingData((prev) => ({ ...prev, isLoading: true, error: null })); + + // Get available plans + const availablePlansResult = await getAvailablePlans(address || '0x0000000000000000000000000000000000000000'); + const availablePlanIds = availablePlansResult.value || []; + + // Get plan details for each available plan + const plans: SubscriptionPlan[] = []; + for (const planId of availablePlanIds) { + const planResult = await getPlan(planId); + if (planResult.value) { + const plan = planResult.value as any; + plans.push({ + id: Number(plan.id), + price: plan.price, + duration: Number(plan.duration), + quota: Number(plan.quota), + minTerms: Number(plan.minTerms), + maxTerms: Number(plan.maxTerms), + active: plan.active, + refundable: plan.refundable, + }); + } + } + + // Get user data if connected + let userMembership: SubscriptionMembership | null = null; + let hasActiveMembership = false; + let userBalance = 0n; + let userAllowance = 0n; + + if (isConnected && address) { + // Get membership + const membershipResult = await getMembership(address); + if (membershipResult.value) { + const membership = membershipResult.value as any; + userMembership = { + planId: Number(membership.planId), + activeFrom: Number(membership.activeFrom), + activeUntil: Number(membership.activeUntil), + availableCredits: Number(membership.availableCredits), + }; + } + + // Check if has active membership + const hasActiveResult = await checkActiveMembership(address); + hasActiveMembership = hasActiveResult.value || false; + + // Get balance and allowance + const balanceResult = await getBalance(address); + userBalance = balanceResult.value || 0n; + + const allowanceResult = await getAllowance(address); + userAllowance = allowanceResult.value || 0n; + } + + setPricingData({ + plans, + userMembership, + hasActiveMembership, + userBalance, + userAllowance, + isLoading: false, + error: null, + }); + } catch (error) { + console.error('Error loading pricing data:', error); + setPricingData((prev) => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load pricing data', + })); + } + }, [ + contractAddresses, + publicClient, + address, + isConnected, + getAvailablePlans, + getPlan, + getMembership, + checkActiveMembership, + getBalance, + getAllowance, + ]); + + // Load data on mount and when dependencies change + useEffect(() => { + loadPricingData(); + }, [loadPricingData]); + + // Purchase membership function + const purchase = useCallback( + async (planId: number, amountOfTerms: number) => { + if (!isConnected || !address) { + throw new Error('Wallet not connected'); + } + + try { + const result = await purchaseMembershipInteraction.submit(); + if (purchaseMembershipInteraction.callMethodResult?.error) { + throw purchaseMembershipInteraction.callMethodResult.error; + } + + // Reload data after purchase + await loadPricingData(); + return result; + } catch (error) { + console.error('Error purchasing membership:', error); + throw error; + } + }, + [isConnected, address, purchaseMembershipInteraction, loadPricingData] + ); + + // Cancel membership function + const cancel = useCallback(async () => { + if (!isConnected || !address) { + throw new Error('Wallet not connected'); + } + + try { + const result = await cancelMembershipInteraction.submit(); + if (cancelMembershipInteraction.callMethodResult?.error) { + throw cancelMembershipInteraction.callMethodResult.error; + } + + // Reload data after cancellation + await loadPricingData(); + return result; + } catch (error) { + console.error('Error cancelling membership:', error); + throw error; + } + }, [isConnected, address, cancelMembershipInteraction, loadPricingData]); + + // Format price for display + const formatPrice = useCallback((price: bigint, decimals: number = 6) => { + return formatUnits(price, decimals); + }, []); + + // Calculate total price for multiple terms + const calculateTotalPrice = useCallback((price: bigint, terms: number) => { + return price * BigInt(terms); + }, []); + + return { + ...pricingData, + purchase, + cancel, + formatPrice, + calculateTotalPrice, + reload: loadPricingData, + }; +} diff --git a/packages/website/src/pages/pricing.tsx b/packages/website/src/pages/pricing.tsx new file mode 100644 index 000000000..ea0b63175 --- /dev/null +++ b/packages/website/src/pages/pricing.tsx @@ -0,0 +1,384 @@ +'use client'; + +import { useState } from 'react'; +import { useAccount } from 'wagmi'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Check, X, Loader2, Zap, Shield, Star } from 'lucide-react'; +import { useSubscription } from '@/hooks/subscription'; + +export default function PricingPage() { + const { isConnected } = useAccount(); + const { + plans, + userMembership, + hasActiveMembership, + userBalance, + userAllowance, + isLoading, + error, + purchase, + cancel, + formatPrice: formatSubscriptionPrice, + calculateTotalPrice, + } = useSubscription(); + + const [purchasingPlanId, setPurchasingPlanId] = useState(null); + const [cancelling, setCancelling] = useState(false); + + const handlePurchase = async (planId: number, amountOfTerms: number = 1) => { + if (!isConnected) { + alert('Please connect your wallet to purchase a subscription'); + return; + } + + setPurchasingPlanId(planId); + try { + await purchase(planId, amountOfTerms); + alert('Subscription purchased successfully!'); + } catch (error) { + console.error('Purchase error:', error); + alert( + `Failed to purchase subscription: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } finally { + setPurchasingPlanId(null); + } + }; + + const handleCancel = async () => { + if (!isConnected) { + alert('Please connect your wallet to cancel your subscription'); + return; + } + + setCancelling(true); + try { + await cancel(); + alert('Subscription cancelled successfully!'); + } catch (error) { + console.error('Cancel error:', error); + alert( + `Failed to cancel subscription: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } finally { + setCancelling(false); + } + }; + + const formatDuration = (seconds: number) => { + const days = Math.floor(seconds / 86400); + const months = Math.floor(days / 30); + const years = Math.floor(months / 12); + + if (years > 0) return `${years} year${years > 1 ? 's' : ''}`; + if (months > 0) return `${months} month${months > 1 ? 's' : ''}`; + return `${days} day${days > 1 ? 's' : ''}`; + }; + + const getPlanIcon = (planId: number) => { + switch (planId) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + default: + return ; + } + }; + + const getPlanName = (planId: number) => { + switch (planId) { + case 1: + return 'Starter'; + case 2: + return 'Professional'; + case 3: + return 'Enterprise'; + default: + return `Plan ${planId}`; + } + }; + + /*if (isLoading) { + return ( +
+
+ + Loading pricing information... +
+
+ ); + }*/ + + if (error) { + return ( +
+ + + Failed to load pricing information: {error} + + +
+ ); + } + + return ( +
+ {/* Header */} +
+

Choose Your Plan

+

+ Get access to Cannon's powerful deployment infrastructure with + flexible subscription plans +

+
+ + {/* Current Membership Status */} + {isConnected && hasActiveMembership && userMembership && ( +
+ + + + + Active Subscription + + + +
+
+

Current Plan

+

+ {getPlanName(userMembership.planId)} +

+
+
+

+ Available Credits +

+

+ {userMembership.availableCredits} +

+
+
+

Expires

+

+ {new Date( + userMembership.activeUntil * 1000 + ).toLocaleDateString()} +

+
+
+
+ +
+
+
+
+ )} + + {/* Wallet Connection Notice */} + {!isConnected && ( +
+ + + Connect your wallet to view personalized pricing and purchase + subscriptions. + + +
+ )} + + {/* Pricing Cards */} +
+ {plans.map((plan) => { + const isCurrentPlan = userMembership?.planId === plan.id; + const isPurchasing = purchasingPlanId === plan.id; + const monthlyPrice = formatSubscriptionPrice(plan.price, 6); + const yearlyPrice = formatSubscriptionPrice( + calculateTotalPrice(plan.price, 12), + 6 + ); + + return ( + + {isCurrentPlan && ( + + Current Plan + + )} + + +
+ {getPlanIcon(plan.id)} +
+ + {getPlanName(plan.id)} + + + {plan.quota} credits per {formatDuration(plan.duration)} + +
+ + + {/* Pricing */} +
+
+ ${monthlyPrice} + + /term + +
+

+ ${yearlyPrice} for 12 terms +

+
+ + {/* Features */} +
+
+ + + {plan.quota} deployment credits + +
+
+ + + {formatDuration(plan.duration)} term duration + +
+
+ + Min {plan.minTerms} terms +
+
+ + Max {plan.maxTerms} terms +
+ {plan.refundable && ( +
+ + + Refundable on cancellation + +
+ )} +
+ + {/* Action Button */} +
+ {isCurrentPlan ? ( + + ) : ( + + )} +
+ + {/* Balance Warning */} + {isConnected && + userBalance < + calculateTotalPrice(plan.price, plan.minTerms) && ( +
+ Insufficient USDC balance +
+ )} +
+
+ ); + })} +
+ + {/* Additional Information */} +
+

How It Works

+
+
+
+ 1 +
+

Choose Your Plan

+

+ Select a subscription plan that fits your support and deployment + needs +

+
+
+
+ 2 +
+

Purchase with USDC

+

+ Pay for your subscription using USDC tokens +

+
+
+
+ 3 +
+

Use your Plan

+

+ Once you complete your purchase, the benefits of your plan will be + automatically applied to your account. +

+
+
+
+
+ ); +}