Fix: read Stripe price IDs from NEXT_PUBLIC env vars
This commit is contained in:
parent
607fa059b9
commit
8ac25ae633
|
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
// Map price IDs to human-readable tier names
|
||||
const TIER_NAMES: Record<string, string> = {
|
||||
[process.env.STRIPE_PRICE_WEEKLY!]: "Weekly",
|
||||
[process.env.STRIPE_PRICE_MONTHLY!]: "Monthly",
|
||||
[process.env.STRIPE_PRICE_ANNUAL!]: "Annual",
|
||||
[process.env.STRIPE_PRICE_LIFETIME!]: "Lifetime",
|
||||
};
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { priceId } = await req.json();
|
||||
|
||||
if (!priceId || !TIER_NAMES[priceId]) {
|
||||
return NextResponse.json({ error: "Invalid price" }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/purchase/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/uit#pricing`,
|
||||
metadata: { tier: TIER_NAMES[priceId] },
|
||||
invoice_creation: { enabled: true },
|
||||
custom_text: {
|
||||
submit: { message: "Your license key will be emailed to you immediately after purchase." },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
const mailer = nodemailer.createTransport({
|
||||
host: "smtp.gmail.com",
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER, // nolankovacs@verimundsolutions.com Gmail address
|
||||
pass: process.env.SMTP_PASS, // Gmail App Password
|
||||
},
|
||||
});
|
||||
|
||||
// Tier → expiry days (null = lifetime)
|
||||
const TIER_EXPIRY: Record<string, number | null> = {
|
||||
Weekly: 7,
|
||||
Monthly: 30,
|
||||
Annual: 365,
|
||||
Lifetime: null,
|
||||
};
|
||||
|
||||
async function generateLicense(tier: string, email: string): Promise<string> {
|
||||
const res = await fetch(`${process.env.API_URL}/admin/generate-license`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Admin-Key": process.env.ADMIN_KEY!,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tier: tier.toLowerCase(),
|
||||
email,
|
||||
days_valid: TIER_EXPIRY[tier],
|
||||
channel: "stable",
|
||||
source: "stripe",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`License generation failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.license_key;
|
||||
}
|
||||
|
||||
async function sendLicenseEmail(email: string, tier: string, licenseKey: string) {
|
||||
const expiryText = TIER_EXPIRY[tier] === null
|
||||
? "Your license never expires."
|
||||
: `Your license is valid for ${TIER_EXPIRY[tier]} days from today.`;
|
||||
|
||||
await mailer.sendMail({
|
||||
from: `"Verimund Solutions" <noreply@verimundsolutions.com>`,
|
||||
to: email,
|
||||
subject: `Your UIT ${tier} License Key`,
|
||||
html: `
|
||||
<div style="background:#0a0a0a;color:#ffffff;font-family:Arial,sans-serif;padding:40px;max-width:520px;margin:0 auto;">
|
||||
<p style="color:#4ade80;font-size:11px;font-weight:600;letter-spacing:0.2em;text-transform:uppercase;margin-bottom:24px;">
|
||||
Verimund Solutions
|
||||
</p>
|
||||
<h1 style="font-size:24px;font-weight:600;margin-bottom:8px;">Your license key is ready.</h1>
|
||||
<p style="color:#888888;font-size:14px;margin-bottom:32px;">
|
||||
Thank you for purchasing UIT ${tier}. ${expiryText}
|
||||
</p>
|
||||
|
||||
<div style="background:#111111;border:1px solid #222222;border-radius:4px;padding:20px;margin-bottom:32px;">
|
||||
<p style="color:#888888;font-size:11px;text-transform:uppercase;letter-spacing:0.15em;margin-bottom:8px;">License Key</p>
|
||||
<p style="font-family:monospace;font-size:18px;font-weight:600;letter-spacing:0.05em;color:#ffffff;word-break:break-all;">
|
||||
${licenseKey}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="color:#888888;font-size:13px;line-height:1.7;margin-bottom:16px;">
|
||||
To activate, open UIT and enter this key when prompted. The license will bind to your machine on first use.
|
||||
</p>
|
||||
<p style="color:#888888;font-size:13px;line-height:1.7;margin-bottom:32px;">
|
||||
If you need to transfer your license to a new machine, contact us at
|
||||
<a href="mailto:support@verimundsolutions.com" style="color:#ffffff;">support@verimundsolutions.com</a>.
|
||||
</p>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #222222;margin-bottom:24px;" />
|
||||
<p style="color:#3a3a3a;font-size:11px;">
|
||||
© ${new Date().getFullYear()} Verimund Solutions. This email was sent to ${email} because you purchased a UIT license.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const sig = req.headers.get("stripe-signature")!;
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const email = session.customer_details?.email;
|
||||
const tier = session.metadata?.tier;
|
||||
|
||||
if (!email || !tier) {
|
||||
console.error("[WEBHOOK] Missing email or tier", { email, tier });
|
||||
return NextResponse.json({ error: "Missing data" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const licenseKey = await generateLicense(tier, email);
|
||||
await sendLicenseEmail(email, tier, licenseKey);
|
||||
console.log(`[WEBHOOK] License issued: ${tier} → ${email}`);
|
||||
} catch (err) {
|
||||
console.error("[WEBHOOK] Failed to issue license:", err);
|
||||
// Return 200 so Stripe doesn't retry — log the failure for manual follow-up
|
||||
// TODO: add to a failed_deliveries table for retry
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export default function PurchaseSuccess() {
|
||||
return (
|
||||
<section className="min-h-screen flex items-center justify-center px-6">
|
||||
<motion.div
|
||||
className="max-w-md w-full text-center"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6"
|
||||
style={{ background: "#4ade8018", border: "1px solid #4ade8030" }}
|
||||
>
|
||||
<span style={{ color: "#4ade80", fontSize: "20px" }}>✓</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-semibold mb-3" style={{ letterSpacing: "-0.02em" }}>
|
||||
Purchase complete.
|
||||
</h1>
|
||||
<p className="text-sm mb-2" style={{ color: "#888888", lineHeight: 1.7 }}>
|
||||
Your license key is on its way. Check your email — delivery is typically within a minute.
|
||||
</p>
|
||||
<p className="text-sm mb-10" style={{ color: "#888888", lineHeight: 1.7 }}>
|
||||
If you don't see it, check your spam folder or contact{" "}
|
||||
<a
|
||||
href="mailto:support@verimundsolutions.com"
|
||||
style={{ color: "#ffffff" }}
|
||||
>
|
||||
support@verimundsolutions.com
|
||||
</a>.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/uit"
|
||||
className="inline-block px-6 py-3 text-sm font-medium rounded-sm border transition-all duration-200"
|
||||
style={{ border: "1px solid #222222", color: "#888888" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#ffffff";
|
||||
e.currentTarget.style.color = "#ffffff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#222222";
|
||||
e.currentTarget.style.color = "#888888";
|
||||
}}
|
||||
>
|
||||
Back to UIT
|
||||
</Link>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ const pricingTiers = [
|
|||
sub: null,
|
||||
discount: null,
|
||||
popular: false,
|
||||
priceId: "STRIPE_PRICE_ID_WEEKLY",
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_WEEKLY ?? "",
|
||||
},
|
||||
{
|
||||
name: "Monthly",
|
||||
|
|
@ -49,7 +49,7 @@ const pricingTiers = [
|
|||
sub: null,
|
||||
discount: null,
|
||||
popular: false,
|
||||
priceId: "STRIPE_PRICE_ID_MONTHLY",
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_MONTHLY ?? "",
|
||||
},
|
||||
{
|
||||
name: "Annual",
|
||||
|
|
@ -58,7 +58,7 @@ const pricingTiers = [
|
|||
sub: "$12.42 / mo",
|
||||
discount: "Save 35%",
|
||||
popular: true,
|
||||
priceId: "STRIPE_PRICE_ID_ANNUAL",
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ANNUAL ?? "",
|
||||
},
|
||||
{
|
||||
name: "Lifetime",
|
||||
|
|
@ -67,15 +67,27 @@ const pricingTiers = [
|
|||
sub: null,
|
||||
discount: null,
|
||||
popular: false,
|
||||
priceId: "STRIPE_PRICE_ID_LIFETIME",
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME ?? "",
|
||||
},
|
||||
];
|
||||
|
||||
export default function UIT() {
|
||||
const handleBuy = (priceId: string) => {
|
||||
// Stripe Checkout — wired once live keys are in place
|
||||
console.log("Checkout:", priceId);
|
||||
const handleBuy = async (priceId: string) => {
|
||||
if (priceId.startsWith("STRIPE_PRICE_ID")) {
|
||||
alert("Purchase flow coming soon. Check back shortly.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ priceId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) window.location.href = data.url;
|
||||
} catch {
|
||||
alert("Something went wrong. Please try again or contact support@verimundsolutions.com.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -8,14 +8,18 @@
|
|||
"name": "website",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@stripe/stripe-js": "^9.0.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"next": "16.2.2",
|
||||
"nodemailer": "^8.0.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"stripe": "^21.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
|
|
@ -1234,6 +1238,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.0.1.tgz",
|
||||
"integrity": "sha512-un0URSosrW7wNr7xZ5iI2mC9mdeXZ3KERoVlA2RdmeLXYxHUPXq0yHzir2n/MtyXXEdSaELtz4WXGS6dzPEeKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
|
|
@ -1550,12 +1563,22 @@
|
|||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
|
|
@ -5134,6 +5157,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -6067,6 +6099,23 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "21.0.1",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-21.0.1.tgz",
|
||||
"integrity": "sha512-ocv0j7dWttswDWV2XL/kb6+yiLpDXNXL3RQAOB5OB2kr49z0cEatdQc12+zP/j5nrXk6rAsT4N3y/NUvBbK7Pw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
|
|
@ -6395,7 +6444,7 @@
|
|||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
|
|
|
|||
|
|
@ -9,14 +9,18 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/stripe-js": "^9.0.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"next": "16.2.2",
|
||||
"nodemailer": "^8.0.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"stripe": "^21.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
|
|
|
|||
Loading…
Reference in New Issue