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
26 changes: 15 additions & 11 deletions src/app/(home)/pricing/page-content.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
"use client";

import Image from "next/image";
import PricingTable from "@/components/autumn/pricing-table";
import CustomPricingTable from "@/components/autumn/custom-pricing-table";

export function PricingPageContent() {
return (
<div className="flex flex-col max-w-3xl mx-auto w-full">
<section className="space-y-6 pt-[16vh] 2xl:pt-48">
<div className="flex flex-col items-center">
<div className="flex flex-col max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8">
<section className="space-y-12 pt-[16vh] 2xl:pt-48 pb-24">
<div className="flex flex-col items-center space-y-6">
<Image
src="/logo.svg"
alt="ZapDev - AI Development Platform"
width={50}
height={50}
width={60}
height={60}
className="hidden md:block"
/>
<div className="text-center space-y-4 max-w-3xl">
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
Simple, Transparent Pricing
</h1>
<p className="text-muted-foreground text-lg md:text-xl">
Choose the perfect plan for your development needs. Upgrade or downgrade at any time.
</p>
</div>
</div>
<h1 className="text-xl md:text-3xl font-bold text-center">Pricing</h1>
<p className="text-muted-foreground text-center text-sm md:text-base">
Choose the plan that fits your needs
</p>
<PricingTable />
<CustomPricingTable />
</section>
</div>
);
Expand Down
313 changes: 313 additions & 0 deletions src/components/autumn/custom-pricing-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
'use client';

import React, { useState } from "react";
import { useCustomer, usePricingTable, type ProductDetails } from "autumn-js/react";
import type { Product, ProductItem } from "autumn-js";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import CheckoutDialog from "@/components/autumn/checkout-dialog";
import { getPricingTableContent } from "@/lib/autumn/pricing-table-content";
import { Check, Loader2, Sparkles, Zap } from "lucide-react";

interface CustomPricingTableProps {
productDetails?: ProductDetails[];
className?: string;
}

export default function CustomPricingTable({ productDetails, className }: CustomPricingTableProps) {
const { customer, checkout } = useCustomer({ errorOnNotFound: false });
const [isAnnual, setIsAnnual] = useState(false);
const { products, isLoading, error } = usePricingTable({ productDetails });

if (isLoading) {
return (
<div className="w-full h-full flex justify-center items-center min-h-[400px]">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
<p className="text-sm text-muted-foreground">Loading pricing plans...</p>
</div>
</div>
);
}

if (error) {
return (
<div className="w-full h-full flex justify-center items-center min-h-[400px]">
<div className="text-center space-y-2">
<p className="text-destructive font-medium">Unable to load pricing plans</p>
<p className="text-sm text-muted-foreground">Please try again later</p>
</div>
</div>
);
}

if (!products || products.length === 0) {
return null;
}

// Check if we have multiple intervals (monthly/annual)
const intervalGroups = products
.map((p) => p.properties?.interval_group)
.filter((intervalGroup): intervalGroup is string => Boolean(intervalGroup));
const intervals = Array.from(new Set(intervalGroups));
const multiInterval = intervals.length > 1;

// Filter products based on selected interval
const intervalFilter = (product: Product) => {
if (!product.properties?.interval_group) {
return true;
}

if (multiInterval) {
if (isAnnual) {
return product.properties?.interval_group === "year";
} else {
return product.properties?.interval_group === "month";
}
}

return true;
};

const filteredProducts = products.filter(intervalFilter);
const hasRecommended = filteredProducts.some((p) => p.display?.recommend_text);

return (
<div className={cn("w-full", className)}>
{/* Annual/Monthly Toggle */}
{multiInterval && (
<div className="flex justify-center mb-12">
<div className="inline-flex items-center gap-3 p-1 rounded-full bg-secondary/50 border">
<button
onClick={() => setIsAnnual(false)}
className={cn(
"px-6 py-2 rounded-full text-sm font-medium transition-all",
!isAnnual
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Monthly
</button>
<button
onClick={() => setIsAnnual(true)}
className={cn(
"px-6 py-2 rounded-full text-sm font-medium transition-all relative",
isAnnual
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Annual
<Badge className="ml-2 bg-green-500 text-white border-0 text-[10px] py-0 px-1.5">
Save 20%
</Badge>
</button>
</div>
</div>
)}

{/* Pricing Cards Grid */}
<div className={cn(
"grid gap-8 max-w-7xl mx-auto",
filteredProducts.length === 1 && "grid-cols-1 max-w-md",
filteredProducts.length === 2 && "grid-cols-1 md:grid-cols-2",
filteredProducts.length === 3 && "grid-cols-1 md:grid-cols-3",
filteredProducts.length > 3 && "grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
)}>
{filteredProducts.map((product, index) => (
<PricingCard
key={product.id ?? index}
product={product}
customer={customer}
checkout={checkout}
isRecommended={hasRecommended && Boolean(product.display?.recommend_text)}
/>
))}
</div>
</div>
);
}

interface PricingCardProps {
product: Product;
customer: any;
checkout: any;
isRecommended: boolean;
}

function PricingCard({ product, customer, checkout, isRecommended }: PricingCardProps) {
const [loading, setLoading] = useState(false);
const { buttonText } = getPricingTableContent(product);

const handleClick = async () => {
if (loading) return;

setLoading(true);
try {
if (product.id && customer) {
await checkout({
productId: product.id,
dialog: CheckoutDialog,
});
} else if (product.display?.button_url) {
window.open(product.display?.button_url, "_blank", "noopener,noreferrer");
}
} catch (error) {
Comment on lines +150 to +158
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove the customer gate before invoking checkout()

useCustomer().checkout() is documented to work without a populated customer object—it will create or fetch the customer on demand. Gating the call on customer means the CTA silently does nothing whenever the hook hasn’t resolved yet or when a brand-new visitor (no stored customer) clicks it and the product lacks a button_url, effectively breaking the purchase flow.(docs.useautumn.com)

Please drop the customer check so we always call checkout when product.id is available:

-      if (product.id && customer) {
+      if (product.id) {
         await checkout({
           productId: product.id,
           dialog: CheckoutDialog,
         });
       } else if (product.display?.button_url) {
         window.open(product.display?.button_url, "_blank", "noopener,noreferrer");
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (product.id && customer) {
await checkout({
productId: product.id,
dialog: CheckoutDialog,
});
} else if (product.display?.button_url) {
window.open(product.display?.button_url, "_blank", "noopener,noreferrer");
}
} catch (error) {
if (product.id) {
await checkout({
productId: product.id,
dialog: CheckoutDialog,
});
} else if (product.display?.button_url) {
window.open(product.display?.button_url, "_blank", "noopener,noreferrer");
}
} catch (error) {
🤖 Prompt for AI Agents
In src/components/autumn/custom-pricing-table.tsx around lines 150 to 158, the
current logic prevents calling checkout() unless a `customer` exists; remove the
`customer` check so that whenever `product.id` is present we call `checkout({
productId: product.id, dialog: CheckoutDialog })` unconditionally, and keep the
existing else branch that opens `product.display?.button_url` when `product.id`
is falsy; update the conditional to only test `product.id` (and ensure error
handling remains unchanged).

console.error("Checkout error:", error);
} finally {
setLoading(false);
}
};

const isDisabled =
loading ||
(product.scenario === "active" && !product.properties.updateable) ||
product.scenario === "scheduled";

// Get main price
const mainPriceDisplay = product.properties?.is_free
? {
primary_text: "Free",
secondary_text: "",
}
: product.items?.[0]?.display ?? {
primary_text: "Custom",
secondary_text: "",
};

// Get features (skip first item if it's the price)
const featureItems = product.properties?.is_free
? product.items ?? []
: (product.items?.length ?? 0) > 1
? product.items.slice(1)
: [];

// Determine if this is the current plan
const isCurrentPlan = product.scenario === "active";

return (
<div
className={cn(
"relative flex flex-col rounded-2xl border bg-card transition-all duration-300",
isRecommended && [
"md:scale-105 shadow-xl border-primary/50",
"bg-gradient-to-b from-primary/5 to-card"
],
!isRecommended && "shadow-sm hover:shadow-md",
isCurrentPlan && "ring-2 ring-primary/20"
)}
>
{/* Recommended Badge */}
{isRecommended && product.display?.recommend_text && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 z-10">
<Badge className="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground border-0 shadow-lg px-4 py-1">
<Sparkles className="w-3 h-3 mr-1" />
{product.display.recommend_text}
</Badge>
</div>
)}

<div className="flex-1 p-8 space-y-6">
{/* Plan Name & Description */}
<div className="space-y-2">
<h3 className="text-2xl font-bold">
{product.display?.name || product.name}
</h3>
{product.display?.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{product.display.description}
</p>
)}
</div>

{/* Price */}
<div className="space-y-1">
<div className="flex items-baseline gap-1">
{mainPriceDisplay.primary_text && (
<span className="text-4xl font-bold tracking-tight">
{mainPriceDisplay.primary_text}
</span>
)}
{mainPriceDisplay.secondary_text && (
<span className="text-muted-foreground">
{mainPriceDisplay.secondary_text}
</span>
)}
</div>
</div>

{/* Features List */}
{featureItems.length > 0 && (
<div className="space-y-3 pt-6 border-t">
{product.display?.everything_from && (
<p className="text-sm text-muted-foreground mb-3">
Everything from {product.display.everything_from}, plus:
</p>
)}
<ul className="space-y-3">
{featureItems.map((item, index) => (
<li key={index} className="flex items-start gap-3">
<div className="mt-0.5 shrink-0">
<div className={cn(
"w-5 h-5 rounded-full flex items-center justify-center",
isRecommended
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
)}>
<Check className="w-3 h-3" />
</div>
</div>
<div className="flex-1 space-y-0.5">
<p className="text-sm font-medium leading-tight">
{item.display?.primary_text}
</p>
{item.display?.secondary_text && (
<p className="text-xs text-muted-foreground">
{item.display.secondary_text}
</p>
)}
</div>
</li>
))}
</ul>
</div>
)}
</div>

{/* CTA Button */}
<div className="p-8 pt-0">
<Button
onClick={handleClick}
disabled={isDisabled}
className={cn(
"w-full h-11 text-base font-semibold transition-all group",
isRecommended && "bg-primary hover:bg-primary/90 shadow-lg hover:shadow-xl",
isCurrentPlan && "bg-secondary hover:bg-secondary/90 text-secondary-foreground"
)}
variant={isRecommended ? "default" : isCurrentPlan ? "secondary" : "outline"}
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<span className="flex items-center gap-2">
{isCurrentPlan && <Check className="w-4 h-4" />}
{product.display?.button_text || buttonText}
{isRecommended && !isCurrentPlan && (
<Zap className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
)}
</span>
)}
</Button>

{isCurrentPlan && (
<p className="text-xs text-center text-muted-foreground mt-3">
Your current plan
</p>
)}
</div>
</div>
);
}
Loading