diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts new file mode 100644 index 0000000..9ac8d3f --- /dev/null +++ b/app/api/checkout/route.ts @@ -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 = { + [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 }); +} diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts new file mode 100644 index 0000000..278139c --- /dev/null +++ b/app/api/webhook/route.ts @@ -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 = { + Weekly: 7, + Monthly: 30, + Annual: 365, + Lifetime: null, +}; + +async function generateLicense(tier: string, email: string): Promise { + 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" `, + to: email, + subject: `Your UIT ${tier} License Key`, + html: ` +
+

+ Verimund Solutions +

+

Your license key is ready.

+

+ Thank you for purchasing UIT ${tier}. ${expiryText} +

+ +
+

License Key

+

+ ${licenseKey} +

+
+ +

+ To activate, open UIT and enter this key when prompted. The license will bind to your machine on first use. +

+

+ If you need to transfer your license to a new machine, contact us at + support@verimundsolutions.com. +

+ +
+

+ © ${new Date().getFullYear()} Verimund Solutions. This email was sent to ${email} because you purchased a UIT license. +

+
+ `, + }); +} + +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 }); +} diff --git a/app/purchase/success/page.tsx b/app/purchase/success/page.tsx new file mode 100644 index 0000000..d2822cd --- /dev/null +++ b/app/purchase/success/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Link from "next/link"; +import { motion } from "framer-motion"; + +export default function PurchaseSuccess() { + return ( +
+ +
+ +
+ +

+ Purchase complete. +

+

+ Your license key is on its way. Check your email — delivery is typically within a minute. +

+

+ If you don't see it, check your spam folder or contact{" "} + + support@verimundsolutions.com + . +

+ + { + 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 + +
+
+ ); +} diff --git a/app/uit/page.tsx b/app/uit/page.tsx index 8a06cc1..5f298a3 100644 --- a/app/uit/page.tsx +++ b/app/uit/page.tsx @@ -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); - alert("Purchase flow coming soon. Check back shortly."); + 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 ( diff --git a/package-lock.json b/package-lock.json index 0b57793..d4e65a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 6a7508c..3276fd7 100644 --- a/package.json +++ b/package.json @@ -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",