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

13198
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,74 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-03-25.dahlia",
});
const PRICE_IDS: Record<string, string | undefined> = {
monthly: process.env.STRIPE_PRICE_MONTHLY,
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 });
}
}
import { NextResponse } from "next/server";
export async function POST() {
return NextResponse.json(
{
error: "Website-local checkout is disabled. Use the external DashCaddy license service."
},
{ status: 410 }
);
}

View File

@@ -1,99 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-03-25.dahlia",
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
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 }
);
}
}
import { NextResponse } from "next/server";
export async function POST() {
return NextResponse.json(
{
error: "Website-local Stripe webhooks are disabled. Use the external DashCaddy license service."
},
{ status: 410 }
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,280 +1,169 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
export default function PricingPage() {
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 = [
{
question: 'Can I try Premium for free?',
answer: 'Yes! Premium includes a 14-day free trial. No credit card required. You can cancel anytime.',
},
{
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.',
},
{
question: 'Can I self-host the license server?',
answer: 'Coming soon! We\'re working on a self-hosted license server option for enterprise deployments.',
},
{
question: 'Do you offer refunds?',
answer: 'Yes, we offer a 30-day money-back guarantee. If you\'re not satisfied with Premium, contact support for a full refund.',
},
{
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.',
},
];
const [expandedFaq, setExpandedFaq] = useState<number | null>(0);
return (
<div className="flex flex-col min-h-screen bg-surface-950 text-surface-50">
<Navbar />
{/* Hero Section */}
<section className="relative py-16 sm:py-20 lg:py-24">
<div className="absolute inset-0 -z-10">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-brand-500/20 rounded-full blur-3xl opacity-30 animate-pulse" />
</div>
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold mb-6">
Simple, Transparent <span className="text-brand-400">Pricing</span>
</h1>
<p className="text-xl text-surface-300 mb-8 max-w-2xl mx-auto">
Start free. Upgrade when you need advanced features. No surprises, no lock-in.
</p>
{/* Toggle for Monthly/Yearly */}
<div className="flex items-center justify-center gap-4 mb-12">
<span className={`text-sm font-medium ${!isAnnual ? 'text-surface-50' : 'text-surface-400'}`}>
Monthly
</span>
<button
onClick={() => setIsAnnual(!isAnnual)}
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-colors ${
isAnnual ? 'bg-brand-500' : 'bg-surface-700'
}`}
aria-label="Toggle annual pricing"
>
<span
className={`inline-block h-6 w-6 transform rounded-full bg-white transition-transform ${
isAnnual ? 'translate-x-7' : 'translate-x-1'
}`}
/>
</button>
<span className={`text-sm font-medium ${isAnnual ? 'text-surface-50' : 'text-surface-400'}`}>
Annual
</span>
{isAnnual && (
<span className="ml-2 inline-block rounded-full bg-brand-500/20 px-3 py-1 text-sm font-semibold text-brand-300">
Best Value
</span>
)}
</div>
</div>
</section>
{/* Pricing Cards */}
<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="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-5xl mx-auto">
{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>
</section>
{/* FAQ Section */}
<section className="relative py-16 sm:py-20 lg:py-24 bg-gradient-to-b from-surface-950 to-surface-900">
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
<div className="mb-12 text-center">
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4">
Frequently Asked <span className="text-brand-400">Questions</span>
</h2>
<p className="text-lg text-surface-400">
Have a question? We've got answers.
</p>
</div>
{/* FAQ Accordion */}
<div className="space-y-4">
{faqs.map((faq, idx) => (
<div
key={idx}
className="rounded-lg border border-surface-700/50 bg-surface-800/50 overflow-hidden transition-all duration-200 hover:border-surface-700"
>
<button
onClick={() => setExpandedFaq(expandedFaq === idx ? null : idx)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-surface-800/70 transition-colors"
>
<h3 className="text-lg font-semibold text-surface-50 text-left">{faq.question}</h3>
<svg
className={`h-6 w-6 flex-shrink-0 text-brand-400 transition-transform duration-200 ${
expandedFaq === idx ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{expandedFaq === idx && (
<div className="border-t border-surface-700/50 bg-surface-900/50 px-6 py-4">
<p className="text-surface-300 leading-relaxed">{faq.answer}</p>
</div>
)}
</div>
))}
</div>
</div>
</section>
{/* Final CTA */}
<section className="relative py-16 sm:py-20 lg:py-24 bg-surface-950">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl sm:text-4xl font-bold mb-6">
Ready to Get Started?
</h2>
<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.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/docs"
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"
>
View Documentation
</Link>
<Link
href="/"
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-colors"
>
Back to Home
</Link>
</div>
</div>
</section>
<Footer />
</div>
);
}
'use client';
import { useState } from 'react';
import PricingCards from '@/components/PricingCards';
import Link from 'next/link';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
export default function PricingPage() {
const [isAnnual, setIsAnnual] = useState(false);
const faqs = [
{
question: 'Can I try Premium for free?',
answer: 'There is no free trial. Premium features unlock when you start a paid subscription.',
},
{
question: 'What happens when my subscription ends?',
answer: 'If your subscription ends, DashCaddy falls back to the free tier. Your data remains intact.',
},
{
question: 'Can I self-host the license server?',
answer: 'Coming soon! We\'re working on a self-hosted license server option for enterprise deployments.',
},
{
question: 'Do you offer refunds?',
answer: 'Yes, we offer a 30-day money-back guarantee. If you\'re not satisfied with Premium, contact support for a full refund.',
},
{
question: 'Is my data safe?',
answer: 'DashCaddy remains self-hosted for your infrastructure. Only billing and license validation touch the hosted licensing service.',
},
];
const [expandedFaq, setExpandedFaq] = useState<number | null>(0);
return (
<div className="flex flex-col min-h-screen bg-surface-950 text-surface-50">
<Navbar />
{/* Hero Section */}
<section className="relative py-16 sm:py-20 lg:py-24">
<div className="absolute inset-0 -z-10">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-brand-500/20 rounded-full blur-3xl opacity-30 animate-pulse" />
</div>
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold mb-6">
Simple, Transparent <span className="text-brand-400">Pricing</span>
</h1>
<p className="text-xl text-surface-300 mb-8 max-w-2xl mx-auto">
Start free. Upgrade when you need advanced features. No surprises, no lock-in.
</p>
{/* Toggle for Monthly/Yearly */}
<div className="flex items-center justify-center gap-4 mb-12">
<span className={`text-sm font-medium ${!isAnnual ? 'text-surface-50' : 'text-surface-400'}`}>
Monthly
</span>
<button
onClick={() => setIsAnnual(!isAnnual)}
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-colors ${
isAnnual ? 'bg-brand-500' : 'bg-surface-700'
}`}
aria-label="Toggle annual pricing"
>
<span
className={`inline-block h-6 w-6 transform rounded-full bg-white transition-transform ${
isAnnual ? 'translate-x-7' : 'translate-x-1'
}`}
/>
</button>
<span className={`text-sm font-medium ${isAnnual ? 'text-surface-50' : 'text-surface-400'}`}>
Annual
</span>
{isAnnual && (
<span className="ml-2 inline-block rounded-full bg-brand-500/20 px-3 py-1 text-sm font-semibold text-brand-300">
Best Value
</span>
)}
</div>
</div>
</section>
{/* Pricing Cards */}
<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">
<PricingCards />
</div>
</section>
{/* FAQ Section */}
<section className="relative py-16 sm:py-20 lg:py-24 bg-gradient-to-b from-surface-950 to-surface-900">
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
<div className="mb-12 text-center">
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4">
Frequently Asked <span className="text-brand-400">Questions</span>
</h2>
<p className="text-lg text-surface-400">
Have a question? We've got answers.
</p>
</div>
{/* FAQ Accordion */}
<div className="space-y-4">
{faqs.map((faq, idx) => (
<div
key={idx}
className="rounded-lg border border-surface-700/50 bg-surface-800/50 overflow-hidden transition-all duration-200 hover:border-surface-700"
>
<button
onClick={() => setExpandedFaq(expandedFaq === idx ? null : idx)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-surface-800/70 transition-colors"
>
<h3 className="text-lg font-semibold text-surface-50 text-left">{faq.question}</h3>
<svg
className={`h-6 w-6 flex-shrink-0 text-brand-400 transition-transform duration-200 ${
expandedFaq === idx ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
{expandedFaq === idx && (
<div className="border-t border-surface-700/50 bg-surface-900/50 px-6 py-4">
<p className="text-surface-300 leading-relaxed">{faq.answer}</p>
</div>
)}
</div>
))}
</div>
</div>
</section>
{/* Final CTA */}
<section className="relative py-16 sm:py-20 lg:py-24 bg-surface-950">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl sm:text-4xl font-bold mb-6">
Ready to Get Started?
</h2>
<p className="text-xl text-surface-300 mb-8 max-w-2xl mx-auto">
Use DashCaddy free for core self-hosting, or unlock Premium for gated features.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/docs"
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"
>
View Documentation
</Link>
<Link
href="/"
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-colors"
>
Back to Home
</Link>
</div>
</div>
</section>
<Footer />
</div>
);
}

View File

@@ -1,121 +1,121 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
function SuccessContent() {
const searchParams = useSearchParams();
const sessionId = searchParams.get("session_id");
return (
<>
<Navbar />
<main className="flex-1 flex items-center justify-center px-4 py-24">
<div className="max-w-lg w-full text-center">
{/* Success icon */}
<div className="mx-auto w-20 h-20 rounded-full bg-green-500/20 flex items-center justify-center mb-8">
<svg
className="w-10 h-10 text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 className="text-4xl font-bold text-white mb-4">
Welcome to DashCaddy Premium!
</h1>
<p className="text-lg text-surface-300 mb-8">
Your subscription is active. Check your email for your license key
and setup instructions. Your 14-day free trial has started.
</p>
<div className="glass-card rounded-xl p-6 mb-8 text-left">
<h2 className="text-lg font-semibold text-white mb-4">
Next Steps
</h2>
<ol className="space-y-3 text-surface-300">
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
1
</span>
<span>
Check your email for your license key (DC-XXXXX-...)
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
2
</span>
<span>
Open your DashCaddy dashboard and go to Admin &rarr; License
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
3
</span>
<span>Paste your license key and click Activate</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
4
</span>
<span>
Enjoy SSO, Recipes, Docker Swarm, and all premium features!
</span>
</li>
</ol>
</div>
{sessionId && (
<p className="text-sm text-surface-500 mb-6">
Session ID: {sessionId.substring(0, 20)}...
</p>
)}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/docs"
className="px-6 py-3 rounded-lg bg-brand-600 hover:bg-brand-500 text-white font-medium transition-colors"
>
View Setup Guide
</Link>
<Link
href="/"
className="px-6 py-3 rounded-lg border border-surface-700 hover:border-surface-500 text-surface-300 font-medium transition-colors"
>
Back to Home
</Link>
</div>
</div>
</main>
<Footer />
</>
);
}
export default function SuccessPage() {
return (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center">
<div className="text-surface-400">Loading...</div>
</div>
}
>
<SuccessContent />
</Suspense>
);
}
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
function SuccessContent() {
const searchParams = useSearchParams();
const sessionId = searchParams.get("session_id");
return (
<>
<Navbar />
<main className="flex-1 flex items-center justify-center px-4 py-24">
<div className="max-w-lg w-full text-center">
{/* Success icon */}
<div className="mx-auto w-20 h-20 rounded-full bg-green-500/20 flex items-center justify-center mb-8">
<svg
className="w-10 h-10 text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 className="text-4xl font-bold text-white mb-4">
Welcome to DashCaddy Premium!
</h1>
<p className="text-lg text-surface-300 mb-8">
Your subscription is active. Check your email for your license key
and setup instructions.
</p>
<div className="glass-card rounded-xl p-6 mb-8 text-left">
<h2 className="text-lg font-semibold text-white mb-4">
Next Steps
</h2>
<ol className="space-y-3 text-surface-300">
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
1
</span>
<span>
Check your email for your license key (DC-XXXXX-...)
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
2
</span>
<span>
Open your DashCaddy dashboard and go to Admin &rarr; License
</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
3
</span>
<span>Paste your license key and click Activate</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-500/20 text-brand-400 text-sm flex items-center justify-center font-medium">
4
</span>
<span>
Enjoy SSO, Recipes, Docker Swarm, and all premium features!
</span>
</li>
</ol>
</div>
{sessionId && (
<p className="text-sm text-surface-500 mb-6">
Session ID: {sessionId.substring(0, 20)}...
</p>
)}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/docs"
className="px-6 py-3 rounded-lg bg-brand-600 hover:bg-brand-500 text-white font-medium transition-colors"
>
View Setup Guide
</Link>
<Link
href="/"
className="px-6 py-3 rounded-lg border border-surface-700 hover:border-surface-500 text-surface-300 font-medium transition-colors"
>
Back to Home
</Link>
</div>
</div>
</main>
<Footer />
</>
);
}
export default function SuccessPage() {
return (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center">
<div className="text-surface-400">Loading...</div>
</div>
}
>
<SuccessContent />
</Suspense>
);
}

View File

@@ -1,139 +1,129 @@
import Link from 'next/link';
import Image from 'next/image';
export default function Footer() {
const currentYear = new Date().getFullYear();
const footerSections = [
{
title: 'Product',
links: [
{ label: 'Features', href: '/features' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Documentation', href: '/docs' },
{ label: 'About', href: '/about' },
],
},
{
title: 'Resources',
links: [
{ label: 'Getting Started', href: '/docs' },
{ label: 'API Reference', href: '/docs#api' },
{ label: 'GitHub', href: 'https://git.dashcaddy.net/sami7777/dashcaddy' },
{ label: 'Community', href: '#' },
],
},
{
title: 'Support',
links: [
{ label: 'Contact', href: 'mailto:support@dashcaddy.net' },
{ label: 'Discord', href: '#' },
{ label: 'Privacy Policy', href: '#' },
{ label: 'Terms of Service', href: '#' },
],
},
];
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: (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515a.074.074 0 00-.079.037c-.211.375-.444.864-.607 1.25a18.27 18.27 0 00-5.487 0c-.163-.386-.395-.875-.607-1.25a.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.873-1.295 1.226-1.994a.076.076 0 00-.042-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.294.075.075 0 01.078-.01c3.928 1.793 8.18 1.793 12.062 0a.075.075 0 01.079.009c.12.098.246.198.373.295a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.076.076 0 00-.041.107c.359.698.77 1.364 1.225 1.994a.077.077 0 00.084.028 19.839 19.839 0 006.002-3.03.076.076 0 00.032-.057c.534-4.506-.9-8.4-3.821-11.865a.055.055 0 00-.032-.027zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.948-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.948 2.419-2.157 2.419zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.948-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.948 2.419-2.157 2.419z" />
</svg>
),
label: 'Discord',
href: '#',
},
];
return (
<footer className="border-t border-surface-700/50 bg-surface-950">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
{/* Brand Section */}
<div>
<Link href="/" className="flex items-center gap-2 font-bold text-lg text-brand-400 hover:text-brand-300 transition-colors mb-4">
<span>DashCaddy</span>
</Link>
<p className="text-surface-400 text-sm leading-relaxed mb-4">
Self-hosted Docker dashboard with automatic SSL, DNS, and reverse proxy. Making self-hosting beautiful and effortless.
</p>
<div className="flex items-center gap-4">
{socialLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="text-surface-400 hover:text-brand-400 transition-colors"
aria-label={link.label}
target="_blank"
rel="noopener noreferrer"
>
{link.icon}
</a>
))}
</div>
</div>
{/* Link Sections */}
{footerSections.map((section) => (
<div key={section.title}>
<h3 className="text-sm font-semibold text-surface-50 mb-4">
{section.title}
</h3>
<ul className="space-y-3">
{section.links.map((link) => (
<li key={link.label}>
<Link
href={link.href}
className="text-sm text-surface-400 hover:text-brand-400 transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
{/* Support Link */}
<div className="border-t border-surface-700/50 pt-8 mb-8">
<p className="text-sm text-surface-400">
Need help? Email us at{' '}
<a
href="mailto:support@dashcaddy.net"
className="text-brand-400 hover:text-brand-300 transition-colors font-medium"
>
support@dashcaddy.net
</a>
</p>
</div>
{/* Copyright with samiahmed7777 logo */}
<div className="border-t border-surface-700/50 pt-8">
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<p className="text-sm text-surface-500">
&copy; {currentYear} DashCaddy. All rights reserved. A product by
</p>
<Image
src="/images/samiahmed7777-logo.png"
alt="samiahmed7777"
width={160}
height={32}
className="h-7 w-auto opacity-80 hover:opacity-100 transition-opacity"
/>
</div>
</div>
</div>
</footer>
);
}
import Link from 'next/link';
import Image from 'next/image';
export default function Footer() {
const currentYear = new Date().getFullYear();
const footerSections = [
{
title: 'Product',
links: [
{ label: 'Features', href: '/features' },
{ label: 'Pricing', href: '/pricing' },
{ label: 'Documentation', href: '/docs' },
{ label: 'About', href: '/about' },
],
},
{
title: 'Resources',
links: [
{ label: 'Getting Started', href: '/docs' },
{ label: 'API Reference', href: '/docs#api' },
{ label: 'Community', href: '#' },
],
},
{
title: 'Support',
links: [
{ label: 'Contact', href: 'mailto:support@dashcaddy.net' },
{ label: 'Discord', href: '#' },
{ label: 'Privacy Policy', href: '#' },
{ label: 'Terms of Service', href: '#' },
],
},
];
const socialLinks = [
{
icon: (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515a.074.074 0 00-.079.037c-.211.375-.444.864-.607 1.25a18.27 18.27 0 00-5.487 0c-.163-.386-.395-.875-.607-1.25a.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.873-1.295 1.226-1.994a.076.076 0 00-.042-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.294.075.075 0 01.078-.01c3.928 1.793 8.18 1.793 12.062 0a.075.075 0 01.079.009c.12.098.246.198.373.295a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.076.076 0 00-.041.107c.359.698.77 1.364 1.225 1.994a.077.077 0 00.084.028 19.839 19.839 0 006.002-3.03.076.076 0 00.032-.057c.534-4.506-.9-8.4-3.821-11.865a.055.055 0 00-.032-.027zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.948-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.948 2.419-2.157 2.419zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.948-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.948 2.419-2.157 2.419z" />
</svg>
),
label: 'Discord',
href: '#',
},
];
return (
<footer className="border-t border-surface-700/50 bg-surface-950">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
{/* Brand Section */}
<div>
<Link href="/" className="flex items-center gap-2 font-bold text-lg text-brand-400 hover:text-brand-300 transition-colors mb-4">
<span>DashCaddy</span>
</Link>
<p className="text-surface-400 text-sm leading-relaxed mb-4">
Self-hosted Docker dashboard with automatic SSL, DNS, and reverse proxy. Making self-hosting beautiful and effortless.
</p>
<div className="flex items-center gap-4">
{socialLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="text-surface-400 hover:text-brand-400 transition-colors"
aria-label={link.label}
target="_blank"
rel="noopener noreferrer"
>
{link.icon}
</a>
))}
</div>
</div>
{/* Link Sections */}
{footerSections.map((section) => (
<div key={section.title}>
<h3 className="text-sm font-semibold text-surface-50 mb-4">
{section.title}
</h3>
<ul className="space-y-3">
{section.links.map((link) => (
<li key={link.label}>
<Link
href={link.href}
className="text-sm text-surface-400 hover:text-brand-400 transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
{/* Support Link */}
<div className="border-t border-surface-700/50 pt-8 mb-8">
<p className="text-sm text-surface-400">
Need help? Email us at{' '}
<a
href="mailto:support@dashcaddy.net"
className="text-brand-400 hover:text-brand-300 transition-colors font-medium"
>
support@dashcaddy.net
</a>
</p>
</div>
{/* Copyright with samiahmed7777 logo */}
<div className="border-t border-surface-700/50 pt-8">
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<p className="text-sm text-surface-500">
&copy; {currentYear} DashCaddy. All rights reserved. A product by
</p>
<Image
src="/images/samiahmed7777-logo.png"
alt="samiahmed7777"
width={160}
height={32}
className="h-7 w-auto opacity-80 hover:opacity-100 transition-opacity"
/>
</div>
</div>
</div>
</footer>
);
}

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;
}