Skip to content
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
12 changes: 8 additions & 4 deletions frontend/src/billing/billingApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,15 @@ export async function createCheckoutSession(
email: string,
productId: string,
successUrl: string,
cancelUrl: string
cancelUrl: string,
quantity?: number
): Promise<void> {
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`,
Expand Down Expand Up @@ -195,12 +197,14 @@ export async function createZapriteCheckoutSession(
thirdPartyToken: string,
email: string,
productId: string,
successUrl: string
successUrl: string,
quantity?: number
): Promise<void> {
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`,
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/billing/billingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,20 +106,22 @@ class BillingService {
email: string,
productId: string,
successUrl: string,
cancelUrl: string
cancelUrl: string,
quantity?: number
): Promise<void> {
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<void> {
return this.executeWithToken((token) =>
createZapriteCheckoutSession(token, email, productId, successUrl)
createZapriteCheckoutSession(token, email, productId, successUrl, quantity)
);
}

Expand Down
118 changes: 118 additions & 0 deletions frontend/src/components/TeamSeatDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("2");
const [error, setError] = useState<string | null>(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;
}
Comment thread
AnthonyRonning marked this conversation as resolved.

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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Select Team Seats</DialogTitle>
<DialogDescription>
How many seats would you like to purchase? You can change this at any time.
</DialogDescription>
</DialogHeader>

<form onSubmit={handleConfirm}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="seats">Number of Seats</Label>
<Input
id="seats"
type="number"
min="2"
max="100"
value={seats}
onChange={(e) => handleChange(e.target.value)}
className="w-full"
autoFocus
/>
<p className="text-sm text-muted-foreground">Minimum 2 seats, maximum 100 seats</p>
</div>

<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Each seat is billed per user per month. You can adjust the number of seats anytime
in your billing settings.
</AlertDescription>
</Alert>

{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>

<DialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button type="submit">Continue to Checkout</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
54 changes: 48 additions & 6 deletions frontend/src/routes/pricing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -181,6 +182,8 @@ function PricingPage() {
const [checkoutError, setCheckoutError] = useState<string>("");
const [loadingProductId, setLoadingProductId] = useState<string | null>(null);
const [useBitcoin, setUseBitcoin] = useState(false);
const [showTeamSeatDialog, setShowTeamSeatDialog] = useState(false);
const [pendingTeamProductId, setPendingTeamProductId] = useState<string | null>(null);
const navigate = useNavigate();
const os = useOpenSecret();
const { setBillingStatus } = useLocalState();
Expand Down Expand Up @@ -338,7 +341,7 @@ function PricingPage() {
};

const newHandleSubscribe = useCallback(
async (productId: string) => {
async (productId: string, quantity?: number) => {
if (!isLoggedIn) {
navigate({ to: "/signup" });
return;
Expand All @@ -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 {
Expand All @@ -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
);
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
},
[
Expand All @@ -493,6 +520,16 @@ function PricingPage() {
]
);

const handleTeamSeatConfirm = useCallback(
(seats: number) => {
if (pendingTeamProductId) {
newHandleSubscribe(pendingTeamProductId, seats);
setPendingTeamProductId(null);
}
},
[pendingTeamProductId, newHandleSubscribe]
);

useEffect(() => {
let isSubscribed = true;

Expand Down Expand Up @@ -872,6 +909,11 @@ function PricingPage() {

<PricingFAQ />
</FullPageMain>
<TeamSeatDialog
open={showTeamSeatDialog}
onOpenChange={setShowTeamSeatDialog}
onConfirm={handleTeamSeatConfirm}
/>
<VerificationModal />
</>
);
Expand Down
Loading