Refactor website pricing toward external license service
This commit is contained in:
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
185
src/components/PricingCards.tsx
Normal file
185
src/components/PricingCards.tsx
Normal 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
20
src/lib/licenseServer.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user