Expand the calculator with a multi-step wizard flow, admin dashboard with quote tracking, login/auth system, distance API integration, and history page. Add new UI components (dialog, progress, select, slider, switch), update pricing logic, and improve the overall design with new assets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server"
|
|
import nodemailer from "nodemailer"
|
|
import { formatPrice, type CalculationDetails } from "@/lib/calculations"
|
|
import { FLOORING_TYPES } from "@/lib/constants"
|
|
import { saveQuote } from "@/lib/db"
|
|
|
|
interface QuoteRequestBody {
|
|
customerInfo: {
|
|
name: string
|
|
email: string
|
|
phone: string
|
|
postalCode: string
|
|
address?: string
|
|
remarks?: string
|
|
}
|
|
calculationDetails: CalculationDetails
|
|
}
|
|
|
|
function createTransporter() {
|
|
return nodemailer.createTransport({
|
|
host: process.env.SMTP_HOST,
|
|
port: parseInt(process.env.SMTP_PORT || "587"),
|
|
secure: process.env.SMTP_PORT === "465",
|
|
auth: {
|
|
user: process.env.SMTP_USER,
|
|
pass: process.env.SMTP_PASS,
|
|
},
|
|
})
|
|
}
|
|
|
|
function getFlooringTypeName(type: string): string {
|
|
return FLOORING_TYPES[type as keyof typeof FLOORING_TYPES]?.name || type
|
|
}
|
|
|
|
function formatCustomerEmail(
|
|
customer: QuoteRequestBody["customerInfo"],
|
|
details: CalculationDetails,
|
|
trackingUrl: string
|
|
): string {
|
|
const components = []
|
|
if (details.includeInsulation) components.push(`Isolering (${details.insulationThickness} cm)`)
|
|
if (details.includeFloorHeating) components.push("Gulvvarme syntetisk net + Ø16 PEX (excl. tilslutning)")
|
|
if (details.includeCompound)
|
|
components.push(`Flydespartel (${getFlooringTypeName(details.flooringType)})`)
|
|
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; }
|
|
.header { background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%); color: white; padding: 30px; text-align: center; }
|
|
.header h1 { margin: 0; font-size: 24px; }
|
|
.content { padding: 30px; background: #f9f9f9; }
|
|
.price-box { background: white; border-radius: 12px; padding: 24px; text-align: center; margin: 20px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
.price { font-size: 36px; font-weight: bold; color: #1e3a5f; }
|
|
.details { background: white; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
|
.details h3 { margin-top: 0; color: #1e3a5f; }
|
|
.details ul { margin: 0; padding-left: 20px; }
|
|
.details li { margin: 8px 0; }
|
|
.footer { padding: 20px; text-align: center; font-size: 12px; color: #666; }
|
|
.note { background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; margin: 20px 0; font-size: 14px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>Foam King Gulve</h1>
|
|
<p>Dit prisoverslag</p>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<p>Kære ${customer.name},</p>
|
|
<p>Tak for din interesse i Foam King Gulve. Her er dit prisoverslag baseret på de oplysninger du har indtastet:</p>
|
|
|
|
<div class="price-box">
|
|
<div class="price">${formatPrice(Math.round(details.totalInclVat))}</div>
|
|
<div style="color: #666;">inkl. moms</div>
|
|
</div>
|
|
|
|
<div class="details">
|
|
<h3>Projektdetaljer</h3>
|
|
<ul>
|
|
<li><strong>Areal:</strong> ${details.area} m²</li>
|
|
<li><strong>Gulvhøjde:</strong> ${details.height} cm</li>
|
|
<li><strong>Placering:</strong> ${customer.postalCode}${customer.address ? `, ${customer.address}` : ""}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="details">
|
|
<h3>Inkluderet i prisen</h3>
|
|
<ul>
|
|
${components.map((c) => `<li>${c}</li>`).join("")}
|
|
<li>Transport</li>
|
|
<li>Startgebyr (udstyr og sikkerhed)</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="note">
|
|
<strong>Bemærk:</strong> Dette er et vejledende prisoverslag og kan variere med ±10.000 kr afhængigt af konkrete forhold på stedet.
|
|
</div>
|
|
|
|
<p>Vi kontakter dig snarest for at aftale et uforpligtende besøg, hvor vi kan give dig et præcist og bindende tilbud.</p>
|
|
|
|
<p>Har du spørgsmål i mellemtiden, er du velkommen til at kontakte os.</p>
|
|
|
|
<p>Med venlig hilsen,<br>
|
|
<strong>Foam King ApS</strong><br>
|
|
Tlf: 35 90 10 66<br>
|
|
Email: info@foamking.dk</p>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>Foam King ApS · Søgårdsvej 7, 4550 Asnæs · CVR: 44 48 54 51</p>
|
|
<p style="margin-top: 15px;">
|
|
<img src="${trackingUrl}" alt="Byg Garanti" width="120" height="auto" style="display: inline-block;" />
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|
|
}
|
|
|
|
function formatFoamKingEmail(
|
|
customer: QuoteRequestBody["customerInfo"],
|
|
details: CalculationDetails,
|
|
quoteLink: string,
|
|
quoteId: number
|
|
): string {
|
|
const components = []
|
|
if (details.includeInsulation)
|
|
components.push(
|
|
`Isolering: ${details.insulationThickness} cm (${formatPrice(Math.round(details.insulation))})`
|
|
)
|
|
if (details.includeFloorHeating) {
|
|
components.push(`Gulvvarme: ${formatPrice(Math.round(details.floorHeating))}`)
|
|
components.push(`Syntetisk net: ${formatPrice(Math.round(details.syntheticNet))}`)
|
|
}
|
|
if (details.includeCompound) {
|
|
components.push(
|
|
`Flydespartel (${getFlooringTypeName(details.flooringType)}): ${formatPrice(Math.round(details.selfLevelingCompound))}`
|
|
)
|
|
components.push(
|
|
`Pumpebil (${details.compoundWeight} kg): ${formatPrice(Math.round(details.pumpTruckFee))}`
|
|
)
|
|
}
|
|
|
|
return `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 700px; margin: 0 auto; }
|
|
.header { background: #e67e22; color: white; padding: 20px; }
|
|
.header h1 { margin: 0; font-size: 20px; }
|
|
.section { padding: 20px; border-bottom: 1px solid #eee; }
|
|
.section h2 { color: #1e3a5f; font-size: 16px; margin-top: 0; text-transform: uppercase; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
td { padding: 8px 0; }
|
|
td:first-child { color: #666; width: 50%; }
|
|
td:last-child { text-align: right; }
|
|
.price-row { background: #f5f5f5; font-weight: bold; }
|
|
.price-row td { padding: 12px 8px; }
|
|
.total { font-size: 18px; color: #1e3a5f; }
|
|
.remarks { background: #fffbeb; padding: 15px; border-left: 4px solid #f59e0b; margin-top: 10px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>Ny tilbudsanmodning #${quoteId}</h1>
|
|
</div>
|
|
|
|
<div style="background: #3b82f6; padding: 15px 20px; text-align: center;">
|
|
<a href="${quoteLink}" style="color: white; text-decoration: none; font-weight: bold; font-size: 16px;">
|
|
Se detaljeret tilbud online →
|
|
</a>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>Kundeoplysninger</h2>
|
|
<table>
|
|
<tr><td>Navn:</td><td><strong>${customer.name}</strong></td></tr>
|
|
<tr><td>Email:</td><td><a href="mailto:${customer.email}">${customer.email}</a></td></tr>
|
|
<tr><td>Telefon:</td><td><a href="tel:${customer.phone}">${customer.phone}</a></td></tr>
|
|
<tr><td>Postnummer:</td><td>${customer.postalCode}</td></tr>
|
|
${customer.address ? `<tr><td>Adresse:</td><td>${customer.address}</td></tr>` : ""}
|
|
</table>
|
|
${customer.remarks ? `<div class="remarks"><strong>Bemærkninger fra kunden:</strong><br>${customer.remarks}</div>` : ""}
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>Projektspecifikationer</h2>
|
|
<table>
|
|
<tr><td>Gulvareal:</td><td><strong>${details.area} m²</strong></td></tr>
|
|
<tr><td>Gulvhøjde:</td><td>${details.height} cm</td></tr>
|
|
<tr><td>Isoleringstykkelse:</td><td>${details.insulationThickness} cm</td></tr>
|
|
<tr><td>Spartelvægt:</td><td>${details.compoundWeight} kg</td></tr>
|
|
<tr><td>Afstand (tur/retur):</td><td>${details.distance} km</td></tr>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>Prisberegning</h2>
|
|
<table>
|
|
${components
|
|
.map((c) => {
|
|
const [label, price] = c.split(": ")
|
|
return `<tr><td style="color: #666;">${label}:</td><td style="text-align: right;">${price}</td></tr>`
|
|
})
|
|
.join("")}
|
|
<tr><td style="color: #666;">Startgebyr:</td><td style="text-align: right;">${formatPrice(Math.round(details.startFee))}</td></tr>
|
|
<tr><td colspan="2" style="border-top: 1px solid #ddd; padding-top: 12px;"></td></tr>
|
|
<tr><td style="color: #666;">Subtotal:</td><td style="text-align: right;">${formatPrice(Math.round(details.subtotal))}</td></tr>
|
|
<tr><td style="color: #666;">Afdækning (0.7%):</td><td style="text-align: right;">${formatPrice(Math.round(details.coveringFee))}</td></tr>
|
|
<tr><td style="color: #666;">Affald (0.25%):</td><td style="text-align: right;">${formatPrice(Math.round(details.wasteFee))}</td></tr>
|
|
<tr><td style="color: #666;">Transport:</td><td style="text-align: right;">${formatPrice(Math.round(details.transport))}</td></tr>
|
|
${details.bridgeFee > 0 ? `<tr><td style="color: #666;">Storebælt:</td><td style="text-align: right;">${formatPrice(details.bridgeFee)}</td></tr>` : ""}
|
|
<tr style="background: #f5f5f5; font-weight: bold;"><td style="padding: 12px 8px;">Total ekskl. moms:</td><td style="text-align: right; padding: 12px 8px;">${formatPrice(Math.round(details.totalExclVat))}</td></tr>
|
|
<tr><td style="color: #666;">Moms (25%):</td><td style="text-align: right;">${formatPrice(Math.round(details.vat))}</td></tr>
|
|
<tr style="background: #f5f5f5; font-weight: bold;"><td style="padding: 12px 8px; font-size: 18px; color: #1e3a5f;">Total inkl. moms:</td><td style="text-align: right; padding: 12px 8px; font-size: 18px; color: #1e3a5f;">${formatPrice(Math.round(details.totalInclVat))}</td></tr>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="section" style="border: none;">
|
|
<p style="color: #666; font-size: 12px;">
|
|
Denne anmodning er genereret automatisk fra prisberegneren på beregner.foamking.dk<br>
|
|
Tidspunkt: ${new Date().toLocaleString("da-DK", { timeZone: "Europe/Copenhagen" })}
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body: QuoteRequestBody = await request.json()
|
|
const { customerInfo, calculationDetails } = body
|
|
|
|
if (!customerInfo || !calculationDetails) {
|
|
return NextResponse.json({ error: "Manglende data" }, { status: 400 })
|
|
}
|
|
|
|
// Save quote to database
|
|
const { id: quoteId, slug } = saveQuote({
|
|
postalCode: customerInfo.postalCode,
|
|
address: customerInfo.address,
|
|
area: calculationDetails.area,
|
|
height: calculationDetails.height,
|
|
includeFloorHeating: calculationDetails.includeFloorHeating,
|
|
flooringType: calculationDetails.flooringType,
|
|
customerName: customerInfo.name,
|
|
customerEmail: customerInfo.email,
|
|
customerPhone: customerInfo.phone,
|
|
remarks: customerInfo.remarks,
|
|
totalExclVat: calculationDetails.totalExclVat,
|
|
totalInclVat: calculationDetails.totalInclVat,
|
|
})
|
|
|
|
// Generate the quote link
|
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://beregner.foamking.dk"
|
|
const quoteLink = `${baseUrl}/tilbud/${slug}`
|
|
|
|
const transporter = createTransporter()
|
|
|
|
// Get Foam King recipients (supports comma-separated emails)
|
|
const foamKingEmails = (process.env.EMAIL_TO || "info@foamking.dk")
|
|
.split(",")
|
|
.map((email) => email.trim())
|
|
.filter((email) => email.length > 0)
|
|
|
|
const fromName = process.env.EMAIL_FROM_NAME || "Foam King Prisberegner"
|
|
|
|
// Generate tracking URL for email open tracking
|
|
const trackingUrl = `${baseUrl}/api/track/${quoteId}`
|
|
|
|
// Send email to customer
|
|
await transporter.sendMail({
|
|
from: `"${fromName}" <${process.env.SMTP_USER}>`,
|
|
to: customerInfo.email,
|
|
subject: "Dit prisoverslag fra Foam King Gulve",
|
|
html: formatCustomerEmail(customerInfo, calculationDetails, trackingUrl),
|
|
})
|
|
|
|
// Send email to Foam King
|
|
await transporter.sendMail({
|
|
from: `"${fromName}" <${process.env.SMTP_USER}>`,
|
|
to: foamKingEmails,
|
|
replyTo: customerInfo.email,
|
|
subject: `Tilbud #${quoteId}: ${customerInfo.name} - ${customerInfo.postalCode} - ${calculationDetails.area} m²`,
|
|
html: formatFoamKingEmail(customerInfo, calculationDetails, quoteLink, quoteId),
|
|
})
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
message: "Tak! Vi har modtaget din anmodning og sendt en bekræftelse til din email.",
|
|
})
|
|
} catch (error) {
|
|
console.error("Quote request error:", error)
|
|
return NextResponse.json({ error: "Der opstod en fejl. Prøv igen senere." }, { status: 500 })
|
|
}
|
|
}
|