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
1 change: 1 addition & 0 deletions .capy_commit_msg
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Capy jam: Unify pricing to Free and Pro (0/mo); wire Autumn checkout/portal; consolidate Autumn wrapper; reduce Convex writes on home; default sequential thinking to Kimi K2; update env example
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ GROQ_API_KEY=your_groq_api_key_here
# Get yours at https://dashboard.stripe.com/apikeys
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key_here
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
# Stripe Pro price ID (fallback when Autumn isn't configured)
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID=price_your_pro_monthly_price_id

# Stripe Webhook Secret (get from Stripe Dashboard > Webhooks)
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
Expand Down
14 changes: 1 addition & 13 deletions AutumnWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,2 @@
"use client";
import { AutumnProvider } from "autumn-js/react";
import { api } from "./convex/_generated/api";
import { useConvex } from "convex/react";

export function AutumnWrapper({ children }: { children: React.ReactNode }) {
const convex = useConvex();

return (
<AutumnProvider convex={convex} convexApi={(api as any).autumn}>
{children}
</AutumnProvider>
);
}
export { AutumnWrapper } from "./app/components/AutumnWrapper";
45 changes: 45 additions & 0 deletions app/api/autumn/billing-portal/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuth } from '@clerk/nextjs/server';
import { autumn } from '@/convex/autumn';

export async function POST(request: NextRequest) {
try {
const auth = getAuth(request);
if (!auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { returnUrl } = await request.json().catch(() => ({ returnUrl: undefined }));
const origin = request.headers.get('origin') || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';

if (process.env.AUTUMN_SECRET_KEY) {
try {
const mockCtx = { auth: { getUserIdentity: () => ({ subject: auth.userId, name: '', email: '' }) } } as any;
const result: any = await autumn.billingPortal(mockCtx, { returnUrl: returnUrl || `${origin}/pricing` });
const url = result?.url || result?.portalUrl || result?.redirectUrl;
if (url) {
return NextResponse.json({ url });
}
} catch (e) {
// fall through to Stripe
}
}

// Fallback to Stripe portal
const stripeRes = await fetch(`${origin}/api/stripe/customer-portal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ returnUrl: returnUrl || `${origin}/pricing` })
});

if (!stripeRes.ok) {
const err = await stripeRes.text();
return NextResponse.json({ error: err || 'Failed to create portal session' }, { status: 500 });
}

const data = await stripeRes.json();
return NextResponse.json({ url: data.url });
} catch (error) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
49 changes: 49 additions & 0 deletions app/api/autumn/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuth } from '@clerk/nextjs/server';
import { autumn } from '@/convex/autumn';

export async function POST(request: NextRequest) {
try {
const auth = getAuth(request);
if (!auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { priceId } = await request.json().catch(() => ({ priceId: undefined }));

const mockCtx = { auth: { getUserIdentity: () => ({ subject: auth.userId, name: '', email: '' }) } } as any;

if (process.env.AUTUMN_SECRET_KEY) {
try {
const result: any = await autumn.checkout(mockCtx, { priceId });
const url = result?.url || result?.sessionUrl || result?.redirectUrl;
if (url) {
return NextResponse.json({ url });
}
} catch (e) {
// fall through to Stripe
}
}

// Fallback to Stripe
const origin = request.headers.get('origin') || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const stripeRes = await fetch(`${origin}/api/stripe/create-checkout-session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
priceId: priceId || process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID,
mode: 'subscription'
})
});

if (!stripeRes.ok) {
const err = await stripeRes.text();
return NextResponse.json({ error: err || 'Failed to create checkout session' }, { status: 500 });
}

const { sessionId } = await stripeRes.json();
return NextResponse.json({ provider: 'stripe', sessionId });
} catch (error) {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
123 changes: 46 additions & 77 deletions app/components/AutumnFallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@
// This file provides working alternatives when autumn-js/react is not available

import React, { createContext, useContext, useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Check, Sparkles, Crown } from "lucide-react";
import CheckoutButton from "@/components/stripe/CheckoutButton";
import { CheckCircle } from "lucide-react";

// Types
interface Customer {
id: string;
email: string;
Expand All @@ -27,7 +24,6 @@ interface UsageLimit {
resetAt?: Date;
}

// Mock context
const AutumnContext = createContext<{
customer: Customer | null;
isLoading: boolean;
Expand All @@ -38,13 +34,11 @@ const AutumnContext = createContext<{
error: null
});

// Mock provider
export const AutumnProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [customer, setCustomer] = useState<Customer | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

// Mock loading customer data
useEffect(() => {
setIsLoading(true);
setTimeout(() => {
Expand All @@ -69,7 +63,6 @@ export const AutumnProvider: React.FC<{ children: React.ReactNode }> = ({ childr
);
};

// Mock hook for customer data
export const useCustomer = () => {
const context = useContext(AutumnContext);
return {
Expand All @@ -81,92 +74,68 @@ export const useCustomer = () => {

// Mock pricing table component (two-tier)
export const PricingTable: React.FC = () => {
const proPriceId = process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID || 'price_pro_monthly';

const plans = [
{
id: 'free',
name: 'Free',
price: '$0',
subtext: 'No credit card required',
description: 'Everything you need to get started.',
features: ['Up to 5 chats', 'Basic templates', 'Standard sandbox time', 'Community support'],
buttonText: 'Get started',
popular: false,
icon: Sparkles,
period: 'forever',
description: 'Perfect for getting started',
features: ['5 chats', '1 sandbox', 'Basic models'],
buttonText: 'Get Started',
popular: false
},
{
id: 'pro',
name: 'Pro',
price: '$20',
subtext: 'per month, cancel anytime',
description: 'Build without limits with advanced AI.',
features: ['Unlimited chats', 'Advanced AI models', 'Extended sandbox time', 'Priority support'],
period: 'per month',
description: 'For builders who want more',
features: ['Unlimited chats', 'Advanced models'],
buttonText: 'Upgrade to Pro',
popular: true,
icon: Crown,
},
popular: true
}
];

return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{plans.map((plan) => {
const Icon = plan.icon;
const isFree = plan.id === 'free';
return (
<Card
key={plan.id}
className={`relative transition hover:shadow-md hover:scale-[1.01] ${plan.popular ? 'ring-1 ring-primary/20' : ''}`}
>
{plan.popular && (
<div className="absolute top-3 right-3">
<span className="bg-primary/10 text-primary text-xs font-bold px-2 py-0.5 rounded-full">
Most popular
</span>
</div>
)}
<CardHeader>
<div className="flex items-center gap-3">
<div className="rounded-md bg-primary/10 p-2">
<Icon className="h-6 w-6 text-primary" aria-hidden="true" />
</div>
<div>
<CardTitle className="text-2xl">{plan.name}</CardTitle>
<CardDescription>{plan.description}</CardDescription>
</div>
</div>
<div className="mt-4 flex items-baseline gap-2">
<span className="text-4xl font-bold">{plan.price}</span>
<span className="text-sm text-muted-foreground">{plan.subtext}</span>
</div>
</CardHeader>
<CardContent>
<ul className="space-y-3 mb-6" role="list">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-start">
<Check className="h-5 w-5 text-green-600 dark:text-green-500 mr-3 mt-0.5" aria-hidden="true" />
<span>{feature}</span>
</li>
))}
</ul>
{isFree ? (
<Link href="/sign-in" className="block w-full" aria-label="Get started with Free">
<Button variant="outline" className="w-full">{plan.buttonText}</Button>
</Link>
) : (
<CheckoutButton priceId={proPriceId} mode="subscription" variant="orange" size="lg" className="w-full">
{plan.buttonText}
</CheckoutButton>
)}
</CardContent>
</Card>
);
})}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{plans.map((plan) => (
<Card
key={plan.id}
className={`relative ${plan.popular ? 'border-blue-500 ring-2 ring-blue-500' : ''}`}
>
{plan.popular && (
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-blue-500 text-white text-xs font-bold px-3 py-1 rounded-full">
MOST POPULAR
</div>
)}
<CardHeader>
<CardTitle className="text-2xl">{plan.name}</CardTitle>
<div className="flex items-baseline">
<span className="text-4xl font-bold">{plan.price}</span>
{plan.period && <span className="text-gray-500 ml-1">{plan.period}</span>}
</div>
<CardDescription>{plan.description}</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3 mb-6">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-center">
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
<span>{feature}</span>
</li>
))}
</ul>
<Button className="w-full" variant={plan.popular ? 'default' : 'outline'}>
{plan.buttonText}
</Button>
</CardContent>
</Card>
))}
</div>
);
};

// Mock usage limit hook
export const useUsageLimits = (featureId: string) => {
const [limits, setLimits] = useState<UsageLimit | null>(null);
const [isLoading, setIsLoading] = useState(false);
Expand All @@ -189,4 +158,4 @@ export const useUsageLimits = (featureId: string) => {
mutate: () => {},
isValidating: false
};
};
};
Loading