diff --git a/frontend/src/billing/billingApi.ts b/frontend/src/billing/billingApi.ts index 63329250c..cf3102bee 100644 --- a/frontend/src/billing/billingApi.ts +++ b/frontend/src/billing/billingApi.ts @@ -130,13 +130,15 @@ export async function createCheckoutSession( email: string, productId: string, successUrl: string, - cancelUrl: string + cancelUrl: string, + quantity?: number ): Promise { const requestBody = { email, product_id: productId, success_url: successUrl, - cancel_url: cancelUrl + cancel_url: cancelUrl, + ...(quantity !== undefined && { quantity }) }; const response = await fetch( `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/subscription/checkout`, @@ -195,12 +197,14 @@ export async function createZapriteCheckoutSession( thirdPartyToken: string, email: string, productId: string, - successUrl: string + successUrl: string, + quantity?: number ): Promise { const requestBody = { email, product_id: productId, - success_url: successUrl + success_url: successUrl, + ...(quantity !== undefined && { quantity }) }; const response = await fetch( `${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/subscription/zaprite`, diff --git a/frontend/src/billing/billingService.ts b/frontend/src/billing/billingService.ts index caddb5e58..067df65a0 100644 --- a/frontend/src/billing/billingService.ts +++ b/frontend/src/billing/billingService.ts @@ -106,20 +106,22 @@ class BillingService { email: string, productId: string, successUrl: string, - cancelUrl: string + cancelUrl: string, + quantity?: number ): Promise { return this.executeWithToken((token) => - createCheckoutSession(token, email, productId, successUrl, cancelUrl) + createCheckoutSession(token, email, productId, successUrl, cancelUrl, quantity) ); } async createZapriteCheckoutSession( email: string, productId: string, - successUrl: string + successUrl: string, + quantity?: number ): Promise { return this.executeWithToken((token) => - createZapriteCheckoutSession(token, email, productId, successUrl) + createZapriteCheckoutSession(token, email, productId, successUrl, quantity) ); } diff --git a/frontend/src/components/TeamSeatDialog.tsx b/frontend/src/components/TeamSeatDialog.tsx new file mode 100644 index 000000000..bfc6e95e2 --- /dev/null +++ b/frontend/src/components/TeamSeatDialog.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle, Info } from "lucide-react"; + +interface TeamSeatDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (seats: number) => void; +} + +export function TeamSeatDialog({ open, onOpenChange, onConfirm }: TeamSeatDialogProps) { + const [seats, setSeats] = useState("2"); + const [error, setError] = useState(null); + + const handleConfirm = (e: React.FormEvent) => { + e.preventDefault(); + + const numSeats = parseInt(seats, 10); + + if (isNaN(numSeats)) { + setError("Please enter a valid number"); + return; + } + + if (numSeats < 2) { + setError("Minimum 2 seats required"); + return; + } + + if (numSeats > 100) { + setError("Maximum 100 seats allowed"); + return; + } + + onConfirm(numSeats); + onOpenChange(false); + setSeats("2"); + setError(null); + }; + + const handleChange = (value: string) => { + setSeats(value); + setError(null); + }; + + const handleOpenChange = (newOpen: boolean) => { + onOpenChange(newOpen); + if (!newOpen) { + setSeats("2"); + setError(null); + } + }; + + return ( + + + + Select Team Seats + + How many seats would you like to purchase? You can change this at any time. + + + +
+
+
+ + handleChange(e.target.value)} + className="w-full" + autoFocus + /> +

Minimum 2 seats, maximum 100 seats

+
+ + + + + Each seat is billed per user per month. You can adjust the number of seats anytime + in your billing settings. + + + + {error && ( + + + {error} + + )} +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/routes/pricing.tsx b/frontend/src/routes/pricing.tsx index 1cae08f74..1ce9f93ac 100644 --- a/frontend/src/routes/pricing.tsx +++ b/frontend/src/routes/pricing.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { PRICING_PLANS } from "@/config/pricingConfig"; import { VerificationModal } from "@/components/VerificationModal"; +import { TeamSeatDialog } from "@/components/TeamSeatDialog"; import { isIOS, isAndroid, isMobile } from "@/utils/platform"; import packageJson from "../../package.json"; @@ -181,6 +182,8 @@ function PricingPage() { const [checkoutError, setCheckoutError] = useState(""); const [loadingProductId, setLoadingProductId] = useState(null); const [useBitcoin, setUseBitcoin] = useState(false); + const [showTeamSeatDialog, setShowTeamSeatDialog] = useState(false); + const [pendingTeamProductId, setPendingTeamProductId] = useState(null); const navigate = useNavigate(); const os = useOpenSecret(); const { setBillingStatus } = useLocalState(); @@ -338,7 +341,7 @@ function PricingPage() { }; const newHandleSubscribe = useCallback( - async (productId: string) => { + async (productId: string, quantity?: number) => { if (!isLoggedIn) { navigate({ to: "/signup" }); return; @@ -364,14 +367,16 @@ function PricingPage() { await billingService.createZapriteCheckoutSession( email, productId, - `https://trymaple.ai/payment-success?source=zaprite` + `https://trymaple.ai/payment-success?source=zaprite`, + quantity ); } else { await billingService.createCheckoutSession( email, productId, `https://trymaple.ai/payment-success?source=stripe`, - `https://trymaple.ai/payment-canceled?source=stripe` + `https://trymaple.ai/payment-canceled?source=stripe`, + quantity ); } } else { @@ -380,14 +385,16 @@ function PricingPage() { await billingService.createZapriteCheckoutSession( email, productId, - `${window.location.origin}/pricing?success=true` + `${window.location.origin}/pricing?success=true`, + quantity ); } else { await billingService.createCheckoutSession( email, productId, `${window.location.origin}/pricing?success=true`, - `${window.location.origin}/pricing?canceled=true` + `${window.location.origin}/pricing?canceled=true`, + quantity ); } } @@ -398,13 +405,21 @@ function PricingPage() { setLoadingProductId(null); } }, - [isLoggedIn, navigate, os.auth.user?.user.email, os.auth.user?.user.email_verified, useBitcoin] + [ + isLoggedIn, + navigate, + os.auth.user?.user.email, + os.auth.user?.user.email_verified, + useBitcoin, + isMobilePlatform + ] ); const handleButtonClick = useCallback( (product: Product) => { const targetPlanName = product.name.toLowerCase(); const isFreeplan = targetPlanName.includes("free"); + const isTeamPlan = targetPlanName.includes("team"); // Disable clicks for iOS paid plans if server says not available // Android can support paid plans @@ -446,6 +461,12 @@ function PricingPage() { // If user is on free plan and clicking a paid plan, use checkout URL if (isCurrentlyOnFreePlan && !isTargetFreePlan) { + // For team plans, show seat selection dialog first + if (isTeamPlan) { + setPendingTeamProductId(product.id); + setShowTeamSeatDialog(true); + return; + } newHandleSubscribe(product.id); return; } @@ -480,6 +501,12 @@ function PricingPage() { // If no portal URL exists and it's not a free plan user upgrading, // create checkout session + // For team plans, show seat selection dialog first + if (isTeamPlan) { + setPendingTeamProductId(product.id); + setShowTeamSeatDialog(true); + return; + } newHandleSubscribe(product.id); }, [ @@ -493,6 +520,16 @@ function PricingPage() { ] ); + const handleTeamSeatConfirm = useCallback( + (seats: number) => { + if (pendingTeamProductId) { + newHandleSubscribe(pendingTeamProductId, seats); + setPendingTeamProductId(null); + } + }, + [pendingTeamProductId, newHandleSubscribe] + ); + useEffect(() => { let isSubscribed = true; @@ -872,6 +909,11 @@ function PricingPage() { + );