Refactor website pricing toward external license service

This commit is contained in:
Krystie
2026-04-17 20:40:53 -07:00
parent 69c2179a43
commit f5104578c9
9 changed files with 7754 additions and 7823 deletions

View File

@@ -1,74 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { export async function POST() {
apiVersion: "2026-03-25.dahlia", return NextResponse.json(
}); {
error: "Website-local checkout is disabled. Use the external DashCaddy license service."
const PRICE_IDS: Record<string, string | undefined> = { },
monthly: process.env.STRIPE_PRICE_MONTHLY, { status: 410 }
yearly: process.env.STRIPE_PRICE_YEARLY, );
};
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { plan, email } = body;
if (!plan || !PRICE_IDS[plan]) {
return NextResponse.json(
{ error: "Invalid plan. Choose 'monthly' or 'yearly'." },
{ status: 400 }
);
}
const priceId = PRICE_IDS[plan];
if (!priceId) {
return NextResponse.json(
{ error: "Price not configured. Please contact support." },
{ status: 500 }
);
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://dashcaddy.net";
const sessionParams: Stripe.Checkout.SessionCreateParams = {
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${appUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/pricing`,
allow_promotion_codes: true,
billing_address_collection: "required",
subscription_data: {
trial_period_days: 14,
metadata: {
plan,
source: "dashcaddy-website",
},
},
metadata: {
plan,
},
};
// Pre-fill email if provided
if (email) {
sessionParams.customer_email = email;
}
const session = await stripe.checkout.sessions.create(sessionParams);
return NextResponse.json({ url: session.url });
} catch (error) {
console.error("Stripe checkout error:", error);
const message =
error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
}
} }

View File

@@ -1,99 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { export async function POST() {
apiVersion: "2026-03-25.dahlia", return NextResponse.json(
}); {
error: "Website-local Stripe webhooks are disabled. Use the external DashCaddy license service."
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; },
{ status: 410 }
export async function POST(request: NextRequest) { );
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error(`Webhook signature verification failed: ${message}`);
return NextResponse.json({ error: message }, { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
console.log("Checkout completed:", {
sessionId: session.id,
customerEmail: session.customer_email,
plan: session.metadata?.plan,
subscriptionId: session.subscription,
});
// TODO: Generate and deliver license key to customer
// This is where you'd:
// 1. Generate a DC-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX license code
// 2. Store it in your database
// 3. Email it to the customer
// 4. Associate it with the Stripe subscription ID
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
console.log("Subscription updated:", {
subscriptionId: subscription.id,
status: subscription.status,
});
// TODO: Update license expiration based on subscription status
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
console.log("Subscription cancelled:", {
subscriptionId: subscription.id,
status: subscription.status,
});
// TODO: Deactivate/expire the license key
// The DashCaddy instance will gracefully downgrade to free tier
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
console.log("Payment failed:", {
invoiceId: invoice.id,
customerEmail: invoice.customer_email,
});
// TODO: Notify customer about failed payment
// Consider a grace period before deactivating license
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error("Webhook handler error:", error);
return NextResponse.json(
{ error: "Webhook handler failed" },
{ status: 500 }
);
}
} }

View File

@@ -43,19 +43,19 @@ export default function Home() {
href="/pricing" href="/pricing"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500 px-8 py-3 text-base font-semibold text-white hover:bg-brand-600 transition-all duration-200 hover:shadow-lg hover:shadow-brand-500/30 hover:scale-105" className="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500 px-8 py-3 text-base font-semibold text-white hover:bg-brand-600 transition-all duration-200 hover:shadow-lg hover:shadow-brand-500/30 hover:scale-105"
> >
<span>Get Started Free</span> <span>View Pricing</span>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /> <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg> </svg>
</Link> </Link>
<a <a
href="#" href="/pricing"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-surface-700 bg-surface-800/50 px-8 py-3 text-base font-semibold text-surface-50 hover:border-brand-400 hover:bg-surface-800 transition-all duration-200 hover:text-brand-400" className="inline-flex items-center justify-center gap-2 rounded-lg border border-surface-700 bg-surface-800/50 px-8 py-3 text-base font-semibold text-surface-50 hover:border-brand-400 hover:bg-surface-800 transition-all duration-200 hover:text-brand-400"
> >
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.6.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.6.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg> </svg>
View on GitHub Compare Plans
</a> </a>
</div> </div>
@@ -135,8 +135,8 @@ export default function Home() {
<div className="text-xs text-surface-500">Containers</div> <div className="text-xs text-surface-500">Containers</div>
</div> </div>
<div className="text-center py-2 border-l border-r border-surface-700/30"> <div className="text-center py-2 border-l border-r border-surface-700/30">
<div className="text-lg font-bold text-brand-400">100%</div> <div className="text-lg font-bold text-brand-400">Premium</div>
<div className="text-xs text-surface-500">Uptime</div> <div className="text-xs text-surface-500">Gated features</div>
</div> </div>
<div className="text-center py-2"> <div className="text-center py-2">
<div className="text-lg font-bold text-brand-400">42GB</div> <div className="text-lg font-bold text-brand-400">42GB</div>

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import PricingCards from '@/components/PricingCards';
import Link from 'next/link'; import Link from 'next/link';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
@@ -8,57 +9,14 @@ import Footer from '@/components/Footer';
export default function PricingPage() { export default function PricingPage() {
const [isAnnual, setIsAnnual] = useState(false); const [isAnnual, setIsAnnual] = useState(false);
const plans = [
{
name: 'Free',
price: '0',
period: 'forever',
description: 'Perfect for getting started with self-hosting',
features: [
'Dashboard & monitoring',
'Up to 10 services',
'50+ app templates',
'Automatic SSL & DNS',
'TOTP 2FA',
'Community support',
],
cta: {
text: 'Get Started Free',
href: '/docs',
},
highlighted: false,
},
{
name: 'Premium',
price: isAnnual ? '99' : '20',
period: isAnnual ? 'per year' : 'per month',
savings: isAnnual ? 'Save 58%' : null,
description: 'For power users and production deployments',
features: [
'Everything in Free, plus:',
'Unlimited services',
'Auto-Login SSO for deployed apps',
'Recipes (multi-container stack deployment)',
'Docker Swarm (multi-node cluster orchestration)',
'Priority email support',
'Early access to new features',
],
cta: {
text: 'Start 14-Day Free Trial',
href: '/api/checkout?plan=premium',
},
highlighted: true,
},
];
const faqs = [ const faqs = [
{ {
question: 'Can I try Premium for free?', question: 'Can I try Premium for free?',
answer: 'Yes! Premium includes a 14-day free trial. No credit card required. You can cancel anytime.', answer: 'There is no free trial. Premium features unlock when you start a paid subscription.',
}, },
{ {
question: 'What happens when my subscription ends?', question: 'What happens when my subscription ends?',
answer: 'Your subscription gracefully downgrades to the Free tier. All your data remains intact—no data loss. You can resubscribe at any time.', answer: 'If your subscription ends, DashCaddy falls back to the free tier. Your data remains intact.',
}, },
{ {
question: 'Can I self-host the license server?', question: 'Can I self-host the license server?',
@@ -70,7 +28,7 @@ export default function PricingPage() {
}, },
{ {
question: 'Is my data safe?', question: 'Is my data safe?',
answer: '100% self-hosted means your data never leaves your server. DashCaddy runs entirely on your infrastructure. We have no access to your applications, configurations, or data.', answer: 'DashCaddy remains self-hosted for your infrastructure. Only billing and license validation touch the hosted licensing service.',
}, },
]; ];
@@ -127,76 +85,7 @@ export default function PricingPage() {
{/* Pricing Cards */} {/* Pricing Cards */}
<section className="relative py-12 sm:py-16 lg:py-20"> <section className="relative py-12 sm:py-16 lg:py-20">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-5xl mx-auto"> <PricingCards />
{plans.map((plan, idx) => (
<div
key={idx}
className={`relative rounded-2xl border transition-all duration-300 ${
plan.highlighted
? 'border-brand-500/50 bg-gradient-to-br from-surface-800 to-surface-900 shadow-2xl shadow-brand-500/20 scale-105 md:scale-105'
: 'border-surface-700/50 bg-surface-800/50 hover:border-surface-700 hover:bg-surface-800/80'
}`}
>
{/* Popular Badge */}
{plan.highlighted && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
<span className="inline-block rounded-full bg-brand-500 px-4 py-1 text-xs font-bold uppercase tracking-wide text-white">
Most Popular
</span>
</div>
)}
<div className="p-8 sm:p-10">
{/* Header */}
<div className="mb-8">
<h3 className="text-2xl font-bold text-surface-50 mb-2">{plan.name}</h3>
<p className="text-surface-400 text-sm mb-6">{plan.description}</p>
{/* Price */}
<div className="flex items-baseline gap-2 mb-2">
<span className="text-5xl font-bold text-surface-50">${plan.price}</span>
<span className="text-surface-400">/{plan.period}</span>
</div>
{plan.savings && (
<p className="text-sm text-brand-400 font-semibold">{plan.savings}</p>
)}
</div>
{/* CTA Button */}
<Link
href={plan.cta.href}
className={`block w-full rounded-lg px-6 py-3 text-center font-semibold transition-all duration-200 mb-8 ${
plan.highlighted
? 'bg-brand-500 text-white hover:bg-brand-600 hover:shadow-lg hover:shadow-brand-500/30'
: 'border border-surface-700 bg-surface-700/50 text-surface-50 hover:border-brand-400 hover:bg-surface-700 hover:text-brand-400'
}`}
>
{plan.cta.text}
</Link>
{/* Features List */}
<div className="border-t border-surface-700/50 pt-8">
<ul className="space-y-4">
{plan.features.map((feature, featureIdx) => (
<li key={featureIdx} className="flex items-start gap-3">
{feature.startsWith('Everything in') ? (
<span className="text-sm font-semibold text-surface-300">{feature}</span>
) : (
<>
<svg className="h-5 w-5 flex-shrink-0 text-green-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span className="text-surface-300 text-sm">{feature}</span>
</>
)}
</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</div> </div>
</section> </section>
@@ -255,7 +144,7 @@ export default function PricingPage() {
Ready to Get Started? Ready to Get Started?
</h2> </h2>
<p className="text-xl text-surface-300 mb-8 max-w-2xl mx-auto"> <p className="text-xl text-surface-300 mb-8 max-w-2xl mx-auto">
Try DashCaddy free forever or upgrade to Premium for advanced features. Use DashCaddy free for core self-hosting, or unlock Premium for gated features.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link <Link

View File

@@ -38,7 +38,7 @@ function SuccessContent() {
<p className="text-lg text-surface-300 mb-8"> <p className="text-lg text-surface-300 mb-8">
Your subscription is active. Check your email for your license key Your subscription is active. Check your email for your license key
and setup instructions. Your 14-day free trial has started. and setup instructions.
</p> </p>
<div className="glass-card rounded-xl p-6 mb-8 text-left"> <div className="glass-card rounded-xl p-6 mb-8 text-left">

View File

@@ -19,7 +19,6 @@ export default function Footer() {
links: [ links: [
{ label: 'Getting Started', href: '/docs' }, { label: 'Getting Started', href: '/docs' },
{ label: 'API Reference', href: '/docs#api' }, { label: 'API Reference', href: '/docs#api' },
{ label: 'GitHub', href: 'https://git.dashcaddy.net/sami7777/dashcaddy' },
{ label: 'Community', href: '#' }, { label: 'Community', href: '#' },
], ],
}, },
@@ -35,15 +34,6 @@ export default function Footer() {
]; ];
const socialLinks = [ const socialLinks = [
{
icon: (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.6.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
),
label: 'GitHub',
href: 'https://git.dashcaddy.net/sami7777/dashcaddy',
},
{ {
icon: ( icon: (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">

View File

@@ -0,0 +1,185 @@
'use client';
import { useMemo, useState } from 'react';
import { createCheckoutSession } from '@/lib/licenseServer';
type Plan = {
code: string;
name: string;
price: string;
period: string;
description: string;
savings?: string | null;
highlighted?: boolean;
features: string[];
};
export default function PricingCards() {
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const plans = useMemo<Plan[]>(() => [
{
code: 'free',
name: 'Free',
price: '$0',
period: 'forever',
description: 'Everything you need to self-host DashCaddy for core use.',
features: [
'Dashboard and monitoring',
'Container deployment',
'Caddy management',
'DNS management',
'Health checks',
'App templates'
]
},
{
code: 'premium_1m',
name: 'Premium, 1 Month',
price: '$25',
period: 'per month',
description: 'Premium feature access for one month.',
features: [
'Auto-Login SSO',
'Recipes (multi-container stack deployment)',
'Docker Swarm orchestration'
]
},
{
code: 'premium_3m',
name: 'Premium, 3 Months',
price: '$50',
period: 'every 3 months',
savings: 'Best short-term value',
description: 'A better value plan for regular use.',
highlighted: true,
features: [
'Auto-Login SSO',
'Recipes (multi-container stack deployment)',
'Docker Swarm orchestration'
]
},
{
code: 'premium_6m',
name: 'Premium, 6 Months',
price: '$65',
period: 'every 6 months',
savings: 'Strong loyalty pricing',
description: 'The commitment plan with aggressive value.',
features: [
'Auto-Login SSO',
'Recipes (multi-container stack deployment)',
'Docker Swarm orchestration'
]
},
{
code: 'premium_12m',
name: 'Premium, 12 Months',
price: '$99',
period: 'per year',
savings: 'Best overall value',
description: 'The best-value annual Premium plan.',
features: [
'Auto-Login SSO',
'Recipes (multi-container stack deployment)',
'Docker Swarm orchestration'
]
}
], []);
async function handleCheckout(planCode: string) {
try {
setError(null);
setLoadingPlan(planCode);
const result = await createCheckoutSession(planCode);
window.location.href = result.url;
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to start checkout');
} finally {
setLoadingPlan(null);
}
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-6 max-w-7xl mx-auto">
{plans.map((plan) => {
const isFree = plan.code === 'free';
const isLoading = loadingPlan === plan.code;
return (
<div
key={plan.code}
className={`relative rounded-2xl border transition-all duration-300 ${
plan.highlighted
? 'border-brand-500/50 bg-gradient-to-br from-surface-800 to-surface-900 shadow-2xl shadow-brand-500/20'
: 'border-surface-700/50 bg-surface-800/50 hover:border-surface-700 hover:bg-surface-800/80'
}`}
>
{plan.highlighted && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
<span className="inline-block rounded-full bg-brand-500 px-4 py-1 text-xs font-bold uppercase tracking-wide text-white">
Recommended
</span>
</div>
)}
<div className="p-8">
<div className="mb-8">
<h3 className="text-2xl font-bold text-surface-50 mb-2">{plan.name}</h3>
<p className="text-surface-400 text-sm mb-6">{plan.description}</p>
<div className="flex items-baseline gap-2 mb-2">
<span className="text-4xl font-bold text-surface-50">{plan.price}</span>
<span className="text-surface-400 text-sm">/{plan.period}</span>
</div>
{plan.savings && <p className="text-sm text-brand-400 font-semibold">{plan.savings}</p>}
</div>
{isFree ? (
<a
href="/docs"
className="block w-full rounded-lg px-6 py-3 text-center font-semibold transition-all duration-200 mb-8 border border-surface-700 bg-surface-700/50 text-surface-50 hover:border-brand-400 hover:bg-surface-700 hover:text-brand-400"
>
Use Free Tier
</a>
) : (
<button
onClick={() => handleCheckout(plan.code)}
disabled={Boolean(loadingPlan)}
className={`block w-full rounded-lg px-6 py-3 text-center font-semibold transition-all duration-200 mb-8 ${
plan.highlighted
? 'bg-brand-500 text-white hover:bg-brand-600 hover:shadow-lg hover:shadow-brand-500/30'
: 'border border-surface-700 bg-surface-700/50 text-surface-50 hover:border-brand-400 hover:bg-surface-700 hover:text-brand-400'
} disabled:opacity-60 disabled:cursor-not-allowed`}
>
{isLoading ? 'Opening Checkout...' : 'Buy Premium'}
</button>
)}
<div className="border-t border-surface-700/50 pt-8">
<ul className="space-y-4">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-3">
<svg className="h-5 w-5 flex-shrink-0 text-green-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span className="text-surface-300 text-sm">{feature}</span>
</li>
))}
</ul>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

20
src/lib/licenseServer.ts Normal file
View File

@@ -0,0 +1,20 @@
export const LICENSE_SERVER_URL =
process.env.NEXT_PUBLIC_LICENSE_SERVER_URL || 'https://licenses.dashcaddy.net';
export async function createCheckoutSession(planCode: string, customerEmail?: string) {
const response = await fetch(`${LICENSE_SERVER_URL}/api/checkout/session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ planCode, customerEmail })
});
const data = await response.json();
if (!response.ok || !data?.url) {
throw new Error(data?.error || 'Unable to start checkout');
}
return data;
}