Add subscription management and pricing tiers to the platform
Implement Stripe integration for subscription creation, management, and webhook handling. Update pricing page to display membership tiers and integrate checkout functionality. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 6d5c407a-473f-4820-a33d-abb2ae3e6b37 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/MdI1YXa Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
fed6ba1b7b
commit
2decee28e2
4 changed files with 851 additions and 10 deletions
138
api/subscriptions/create-checkout.ts
Normal file
138
api/subscriptions/create-checkout.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { getAdminClient } from "../_supabase.js";
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
|
||||||
|
apiVersion: "2024-04-10",
|
||||||
|
});
|
||||||
|
|
||||||
|
const SUBSCRIPTION_TIERS = {
|
||||||
|
pro: {
|
||||||
|
name: "Pro",
|
||||||
|
priceMonthly: 900,
|
||||||
|
priceId: process.env.STRIPE_PRO_PRICE_ID || "",
|
||||||
|
},
|
||||||
|
council: {
|
||||||
|
name: "Council",
|
||||||
|
priceMonthly: 2900,
|
||||||
|
priceId: process.env.STRIPE_COUNCIL_PRICE_ID || "",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type TierKey = keyof typeof SUBSCRIPTION_TIERS;
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = getAdminClient();
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace("Bearer ", "");
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error: authError,
|
||||||
|
} = await admin.auth.getUser(token);
|
||||||
|
|
||||||
|
if (authError || !user) {
|
||||||
|
return res.status(401).json({ error: "Invalid token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { tier, successUrl, cancelUrl } = req.body;
|
||||||
|
|
||||||
|
if (!tier || !["pro", "council"].includes(tier)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid tier. Must be 'pro' or 'council'",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierKey = tier as TierKey;
|
||||||
|
const tierConfig = SUBSCRIPTION_TIERS[tierKey];
|
||||||
|
|
||||||
|
const { data: profile } = await admin
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("stripe_customer_id, tier, full_name")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (profile?.tier === tier || profile?.tier === "council") {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
profile?.tier === "council"
|
||||||
|
? "You already have the highest tier"
|
||||||
|
: "You already have this subscription",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let customerId = profile?.stripe_customer_id;
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
email: user.email,
|
||||||
|
name: profile?.full_name || user.email,
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
customerId = customer.id;
|
||||||
|
|
||||||
|
await admin
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({ stripe_customer_id: customerId })
|
||||||
|
.eq("id", user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
process.env.VITE_APP_URL || process.env.REPLIT_DEV_DOMAIN
|
||||||
|
? `https://${process.env.REPLIT_DEV_DOMAIN}`
|
||||||
|
: "https://aethex.dev";
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
mode: "subscription",
|
||||||
|
payment_method_types: ["card"],
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: "usd",
|
||||||
|
product_data: {
|
||||||
|
name: `AeThex ${tierConfig.name} Subscription`,
|
||||||
|
description: `Monthly ${tierConfig.name} tier subscription for AeThex`,
|
||||||
|
},
|
||||||
|
unit_amount: tierConfig.priceMonthly,
|
||||||
|
recurring: {
|
||||||
|
interval: "month",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_url: successUrl || `${baseUrl}/dashboard?subscription=success`,
|
||||||
|
cancel_url: cancelUrl || `${baseUrl}/pricing?subscription=cancelled`,
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
tier: tierKey,
|
||||||
|
},
|
||||||
|
subscription_data: {
|
||||||
|
metadata: {
|
||||||
|
userId: user.id,
|
||||||
|
tier: tierKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
sessionId: session.id,
|
||||||
|
url: session.url,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Checkout session error:", error);
|
||||||
|
return res.status(500).json({ error: error?.message || "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
139
api/subscriptions/manage.ts
Normal file
139
api/subscriptions/manage.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { getAdminClient } from "../_supabase.js";
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
|
||||||
|
apiVersion: "2024-04-10",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const admin = getAdminClient();
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace("Bearer ", "");
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error: authError,
|
||||||
|
} = await admin.auth.getUser(token);
|
||||||
|
|
||||||
|
if (authError || !user) {
|
||||||
|
return res.status(401).json({ error: "Invalid token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: profile } = await admin
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("tier, stripe_customer_id, stripe_subscription_id")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (req.method === "GET") {
|
||||||
|
try {
|
||||||
|
let subscription = null;
|
||||||
|
|
||||||
|
if (profile?.stripe_subscription_id) {
|
||||||
|
try {
|
||||||
|
const stripeSubscription = await stripe.subscriptions.retrieve(
|
||||||
|
profile.stripe_subscription_id
|
||||||
|
);
|
||||||
|
subscription = {
|
||||||
|
id: stripeSubscription.id,
|
||||||
|
status: stripeSubscription.status,
|
||||||
|
currentPeriodEnd: stripeSubscription.current_period_end,
|
||||||
|
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to fetch Stripe subscription:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
tier: profile?.tier || "free",
|
||||||
|
subscription,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Get subscription error:", error);
|
||||||
|
return res.status(500).json({ error: error?.message || "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST") {
|
||||||
|
const { action } = req.body;
|
||||||
|
|
||||||
|
if (action === "cancel") {
|
||||||
|
try {
|
||||||
|
if (!profile?.stripe_subscription_id) {
|
||||||
|
return res.status(400).json({ error: "No active subscription" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.update(
|
||||||
|
profile.stripe_subscription_id,
|
||||||
|
{ cancel_at_period_end: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Subscription will be cancelled at period end",
|
||||||
|
cancelAt: subscription.current_period_end,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Cancel subscription error:", error);
|
||||||
|
return res.status(500).json({ error: error?.message || "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "resume") {
|
||||||
|
try {
|
||||||
|
if (!profile?.stripe_subscription_id) {
|
||||||
|
return res.status(400).json({ error: "No active subscription" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.update(
|
||||||
|
profile.stripe_subscription_id,
|
||||||
|
{ cancel_at_period_end: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "Subscription resumed",
|
||||||
|
status: subscription.status,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Resume subscription error:", error);
|
||||||
|
return res.status(500).json({ error: error?.message || "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "portal") {
|
||||||
|
try {
|
||||||
|
if (!profile?.stripe_customer_id) {
|
||||||
|
return res.status(400).json({ error: "No Stripe customer found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
process.env.VITE_APP_URL || process.env.REPLIT_DEV_DOMAIN
|
||||||
|
? `https://${process.env.REPLIT_DEV_DOMAIN}`
|
||||||
|
: "https://aethex.dev";
|
||||||
|
|
||||||
|
const portalSession = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: profile.stripe_customer_id,
|
||||||
|
return_url: `${baseUrl}/dashboard`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
url: portalSession.url,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Portal session error:", error);
|
||||||
|
return res.status(500).json({ error: error?.message || "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid action. Must be 'cancel', 'resume', or 'portal'",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
|
}
|
||||||
206
api/subscriptions/webhook.ts
Normal file
206
api/subscriptions/webhook.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { getAdminClient } from "../_supabase.js";
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
|
||||||
|
apiVersion: "2024-04-10",
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhookSecret = process.env.STRIPE_SUBSCRIPTION_WEBHOOK_SECRET || "";
|
||||||
|
|
||||||
|
async function getUserIdBySubscription(admin: any, subscriptionId: string): Promise<string | null> {
|
||||||
|
const { data } = await admin
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("id")
|
||||||
|
.eq("stripe_subscription_id", subscriptionId)
|
||||||
|
.single();
|
||||||
|
return data?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getRawBody(req: VercelRequest): Promise<string> {
|
||||||
|
if (typeof req.body === "string") {
|
||||||
|
return req.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of req as any) {
|
||||||
|
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = getAdminClient();
|
||||||
|
const sig = req.headers["stripe-signature"] as string;
|
||||||
|
let event: Stripe.Event;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!sig || !webhookSecret) {
|
||||||
|
console.warn("Missing webhook signature or secret");
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing webhook signature or secret" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await getRawBody(req);
|
||||||
|
event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Webhook signature verification failed:", error.message);
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Webhook signature verification failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "checkout.session.completed": {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
const { userId, tier } = session.metadata || {};
|
||||||
|
|
||||||
|
if (userId && tier && session.subscription) {
|
||||||
|
await admin
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({
|
||||||
|
tier: tier,
|
||||||
|
stripe_subscription_id: session.subscription as string,
|
||||||
|
})
|
||||||
|
.eq("id", userId);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Subscription] User ${userId} upgraded to ${tier} tier`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "customer.subscription.updated": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
let { userId, tier } = subscription.metadata || {};
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
userId = await getUserIdBySubscription(admin, subscription.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
const newStatus = subscription.status;
|
||||||
|
|
||||||
|
if (newStatus === "active") {
|
||||||
|
await admin
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({
|
||||||
|
tier: tier || "pro",
|
||||||
|
stripe_subscription_id: subscription.id,
|
||||||
|
})
|
||||||
|
.eq("id", userId);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Subscription] User ${userId} subscription active, tier: ${tier || "pro"}`
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
newStatus === "canceled" ||
|
||||||
|
newStatus === "unpaid" ||
|
||||||
|
newStatus === "past_due"
|
||||||
|
) {
|
||||||
|
await admin
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({
|
||||||
|
tier: "free",
|
||||||
|
})
|
||||||
|
.eq("id", userId);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Subscription] User ${userId} subscription ${newStatus}, downgraded to free`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[Subscription] Could not find user for subscription ${subscription.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "customer.subscription.deleted": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
let { userId } = subscription.metadata || {};
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
userId = await getUserIdBySubscription(admin, subscription.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await admin
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({
|
||||||
|
tier: "free",
|
||||||
|
stripe_subscription_id: null,
|
||||||
|
})
|
||||||
|
.eq("id", userId);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Subscription] User ${userId} subscription deleted, downgraded to free`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`[Subscription] Could not find user for deleted subscription ${subscription.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "invoice.payment_succeeded": {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
const subscriptionId = invoice.subscription as string;
|
||||||
|
|
||||||
|
if (subscriptionId) {
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
const { userId } = subscription.metadata || {};
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
console.log(
|
||||||
|
`[Subscription] Payment succeeded for user ${userId}, subscription ${subscriptionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "invoice.payment_failed": {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
const subscriptionId = invoice.subscription as string;
|
||||||
|
|
||||||
|
if (subscriptionId) {
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
const { userId } = subscription.metadata || {};
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
console.warn(
|
||||||
|
`[Subscription] Payment failed for user ${userId}, subscription ${subscriptionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`[Subscription Webhook] Unhandled event type: ${event.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ received: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Subscription webhook processing error:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: error?.message || "Webhook processing failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,9 +9,10 @@ import {
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
|
CardFooter,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { aethexToast } from "@/lib/aethex-toast";
|
import { aethexToast } from "@/lib/aethex-toast";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
BadgeDollarSign,
|
BadgeDollarSign,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
|
@ -28,7 +29,16 @@ import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Stars,
|
Stars,
|
||||||
Users,
|
Users,
|
||||||
|
Crown,
|
||||||
|
Zap,
|
||||||
|
Bot,
|
||||||
|
Lock,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { supabase } from "@/lib/supabase";
|
||||||
|
|
||||||
type ServiceBundle = {
|
type ServiceBundle = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -256,9 +266,183 @@ const faqs = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type MembershipTier = {
|
||||||
|
id: "free" | "pro" | "council";
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
priceNote?: string;
|
||||||
|
description: string;
|
||||||
|
features: string[];
|
||||||
|
aiPersonas: string[];
|
||||||
|
highlight?: boolean;
|
||||||
|
badge?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const membershipTiers: MembershipTier[] = [
|
||||||
|
{
|
||||||
|
id: "free",
|
||||||
|
name: "Free",
|
||||||
|
price: "$0",
|
||||||
|
priceNote: "forever",
|
||||||
|
description: "Essential access to the AeThex ecosystem with core AI assistants.",
|
||||||
|
features: [
|
||||||
|
"Community feed & discussions",
|
||||||
|
"Basic profile & passport",
|
||||||
|
"Opportunity browsing",
|
||||||
|
"2 AI personas included",
|
||||||
|
],
|
||||||
|
aiPersonas: ["Network Agent", "Ethics Sentinel"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Pro",
|
||||||
|
price: "$9",
|
||||||
|
priceNote: "/month",
|
||||||
|
description: "Unlock advanced AI personas and enhanced platform features.",
|
||||||
|
features: [
|
||||||
|
"Everything in Free",
|
||||||
|
"Priority support",
|
||||||
|
"Advanced analytics",
|
||||||
|
"8 AI personas total",
|
||||||
|
"Badge unlocks via achievements",
|
||||||
|
],
|
||||||
|
aiPersonas: [
|
||||||
|
"Network Agent",
|
||||||
|
"Ethics Sentinel",
|
||||||
|
"Forge Master",
|
||||||
|
"SBS Architect",
|
||||||
|
"Curriculum Weaver",
|
||||||
|
"QuantumLeap",
|
||||||
|
"Vapor",
|
||||||
|
"Apex",
|
||||||
|
],
|
||||||
|
highlight: true,
|
||||||
|
badge: "Most Popular",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "council",
|
||||||
|
name: "Council",
|
||||||
|
price: "$29",
|
||||||
|
priceNote: "/month",
|
||||||
|
description: "Full access to all AI personas and exclusive Council benefits.",
|
||||||
|
features: [
|
||||||
|
"Everything in Pro",
|
||||||
|
"All 10 AI personas",
|
||||||
|
"Council-only Discord channels",
|
||||||
|
"Early feature access",
|
||||||
|
"Direct team communication",
|
||||||
|
"Governance participation",
|
||||||
|
],
|
||||||
|
aiPersonas: [
|
||||||
|
"All Pro personas",
|
||||||
|
"Ethos Producer",
|
||||||
|
"AeThex Archivist",
|
||||||
|
],
|
||||||
|
badge: "Elite",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Engage() {
|
export default function Engage() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [checkoutLoading, setCheckoutLoading] = useState<string | null>(null);
|
||||||
|
const [userTier, setUserTier] = useState<string | null>(null);
|
||||||
const toastShownRef = useRef(false);
|
const toastShownRef = useRef(false);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserTier = async () => {
|
||||||
|
if (!user) {
|
||||||
|
setUserTier(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("tier")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
setUserTier(data?.tier || "free");
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserTier();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscriptionStatus = searchParams.get("subscription");
|
||||||
|
if (subscriptionStatus === "success") {
|
||||||
|
aethexToast.success({ title: "Subscription Activated", description: "Welcome to your new tier!" });
|
||||||
|
} else if (subscriptionStatus === "cancelled") {
|
||||||
|
aethexToast.info({ title: "Checkout Cancelled", description: "No changes were made." });
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleCheckout = async (tier: "pro" | "council") => {
|
||||||
|
if (!user) {
|
||||||
|
aethexToast.error({ title: "Sign In Required", description: "Please sign in to subscribe" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCheckoutLoading(tier);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
const response = await fetch("/api/subscriptions/create-checkout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${session?.access_token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tier,
|
||||||
|
successUrl: `${window.location.origin}/pricing?subscription=success`,
|
||||||
|
cancelUrl: `${window.location.origin}/pricing?subscription=cancelled`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || "Failed to create checkout session");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.url) {
|
||||||
|
window.location.href = data.url;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Checkout error:", error);
|
||||||
|
aethexToast.error({ title: "Checkout Error", description: error.message || "Failed to start checkout" });
|
||||||
|
} finally {
|
||||||
|
setCheckoutLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageSubscription = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
const response = await fetch("/api/subscriptions/manage", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${session?.access_token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ action: "portal" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.url) {
|
||||||
|
window.open(data.url, "_blank");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
aethexToast.error({ title: "Billing Portal Error", description: "Failed to open billing portal" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|
@ -333,10 +517,10 @@ export default function Engage() {
|
||||||
size="lg"
|
size="lg"
|
||||||
className="bg-gradient-to-r from-aethex-500 to-neon-blue hover:from-aethex-600 hover:to-neon-blue/90 text-base sm:text-lg px-6 py-4 sm:px-8 sm:py-6"
|
className="bg-gradient-to-r from-aethex-500 to-neon-blue hover:from-aethex-600 hover:to-neon-blue/90 text-base sm:text-lg px-6 py-4 sm:px-8 sm:py-6"
|
||||||
>
|
>
|
||||||
<Link to="#bundles" className="flex items-center gap-2">
|
<a href="#membership" className="flex items-center gap-2">
|
||||||
<BadgeDollarSign className="h-5 w-5" />
|
<Crown className="h-5 w-5" />
|
||||||
Explore Engagement Bundles
|
View Membership Tiers
|
||||||
</Link>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
|
|
@ -344,10 +528,10 @@ export default function Engage() {
|
||||||
size="lg"
|
size="lg"
|
||||||
className="border-aethex-400/60 text-aethex-100 hover:bg-aethex-500/10 text-base sm:text-lg px-6 py-4 sm:px-8 sm:py-6"
|
className="border-aethex-400/60 text-aethex-100 hover:bg-aethex-500/10 text-base sm:text-lg px-6 py-4 sm:px-8 sm:py-6"
|
||||||
>
|
>
|
||||||
<Link to="/contact" className="flex items-center gap-2">
|
<a href="#bundles" className="flex items-center gap-2">
|
||||||
<Rocket className="h-5 w-5" />
|
<BadgeDollarSign className="h-5 w-5" />
|
||||||
Book a Scoping Call
|
Explore Engagement Bundles
|
||||||
</Link>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -369,8 +553,182 @@ export default function Engage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Membership Tiers */}
|
||||||
|
<section id="membership" className="bg-background/30 py-16 sm:py-20">
|
||||||
|
<div className="container mx-auto max-w-6xl px-4">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-aethex-400/50 text-aethex-300 backdrop-blur-sm mb-4"
|
||||||
|
>
|
||||||
|
<Bot className="mr-2 h-3 w-3" />
|
||||||
|
AI-Powered Membership
|
||||||
|
</Badge>
|
||||||
|
<h2 className="text-3xl font-bold text-gradient mb-4 sm:text-4xl">
|
||||||
|
Membership Tiers
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Unlock advanced AI personas and exclusive platform features.
|
||||||
|
Choose the tier that matches your creative ambitions.
|
||||||
|
</p>
|
||||||
|
{userTier && userTier !== "free" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4 border-aethex-400/60 text-aethex-100 hover:bg-aethex-500/10"
|
||||||
|
onClick={handleManageSubscription}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Manage Subscription
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{membershipTiers.map((tier) => {
|
||||||
|
const isCurrentTier = userTier === tier.id;
|
||||||
|
const tierOrder = { free: 0, pro: 1, council: 2 };
|
||||||
|
const canUpgrade = !userTier || tierOrder[tier.id] > tierOrder[userTier as keyof typeof tierOrder];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={tier.id}
|
||||||
|
className={`relative overflow-hidden border-border/50 transition-all duration-500 hover:-translate-y-1 ${
|
||||||
|
tier.highlight
|
||||||
|
? "border-aethex-400/70 bg-gradient-to-b from-aethex-950/50 to-background shadow-lg shadow-aethex-500/10"
|
||||||
|
: "hover:border-aethex-400/50"
|
||||||
|
} ${isCurrentTier ? "ring-2 ring-aethex-400" : ""}`}
|
||||||
|
>
|
||||||
|
{tier.badge && (
|
||||||
|
<div className="absolute top-0 right-0">
|
||||||
|
<div className={`px-3 py-1 text-xs font-bold uppercase tracking-wider ${
|
||||||
|
tier.badge === "Elite"
|
||||||
|
? "bg-gradient-to-r from-amber-500 to-yellow-400 text-black"
|
||||||
|
: "bg-gradient-to-r from-aethex-500 to-neon-blue text-white"
|
||||||
|
}`}>
|
||||||
|
{tier.badge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardHeader className="space-y-4 pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{tier.id === "free" && <Zap className="h-6 w-6 text-muted-foreground" />}
|
||||||
|
{tier.id === "pro" && <Sparkles className="h-6 w-6 text-aethex-400" />}
|
||||||
|
{tier.id === "council" && <Crown className="h-6 w-6 text-amber-400" />}
|
||||||
|
<CardTitle className="text-2xl">{tier.name}</CardTitle>
|
||||||
|
{isCurrentTier && (
|
||||||
|
<Badge variant="outline" className="ml-auto border-aethex-400 text-aethex-300">
|
||||||
|
Current
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-4xl font-bold text-foreground">{tier.price}</span>
|
||||||
|
{tier.priceNote && (
|
||||||
|
<span className="text-muted-foreground">{tier.priceNote}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardDescription>{tier.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tier.features.map((feature) => (
|
||||||
|
<div key={feature} className="flex items-start gap-2 text-sm">
|
||||||
|
<Check className="mt-0.5 h-4 w-4 text-aethex-400 flex-shrink-0" />
|
||||||
|
<span className="text-muted-foreground">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-border/50">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2 flex items-center gap-1">
|
||||||
|
<Bot className="h-3 w-3" />
|
||||||
|
AI Personas Included
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tier.aiPersonas.map((persona) => (
|
||||||
|
<Badge
|
||||||
|
key={persona}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs bg-background/60"
|
||||||
|
>
|
||||||
|
{persona}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="pt-0">
|
||||||
|
{tier.id === "free" ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-border/60 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Link to={user ? "/dashboard" : "/login"}>
|
||||||
|
{user ? "Go to Dashboard" : "Sign Up Free"}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : isCurrentTier ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-aethex-400/60 text-aethex-300"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
Current Plan
|
||||||
|
</Button>
|
||||||
|
) : !canUpgrade ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-border/60 text-muted-foreground"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Included in Current Plan
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className={`w-full ${
|
||||||
|
tier.id === "council"
|
||||||
|
? "bg-gradient-to-r from-amber-500 to-yellow-500 hover:from-amber-600 hover:to-yellow-600 text-black"
|
||||||
|
: "bg-gradient-to-r from-aethex-500 to-neon-blue hover:from-aethex-600 hover:to-neon-blue/90"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleCheckout(tier.id as "pro" | "council")}
|
||||||
|
disabled={checkoutLoading !== null}
|
||||||
|
>
|
||||||
|
{checkoutLoading === tier.id ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Upgrade to {tier.name}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
All subscriptions are billed monthly. Cancel anytime from your billing portal.
|
||||||
|
<br />
|
||||||
|
Badges earned through achievements can unlock personas without a paid subscription.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Service Bundles */}
|
{/* Service Bundles */}
|
||||||
<section id="bundles" className="bg-background/30 py-16 sm:py-20">
|
<section id="bundles" className="py-16 sm:py-20">
|
||||||
<div className="container mx-auto max-w-6xl px-4">
|
<div className="container mx-auto max-w-6xl px-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-3xl font-bold text-gradient mb-4 sm:text-4xl">
|
<h2 className="text-3xl font-bold text-gradient mb-4 sm:text-4xl">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue