From 3ebb63dc6c328667eaabf6b6e426a274b46bcb92 Mon Sep 17 00:00:00 2001 From: mikl0s Date: Sun, 22 Feb 2026 20:59:11 +0000 Subject: [PATCH] Add admin dashboard, authentication, step wizard, and quote management 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 --- .env.example | 11 + .gitignore | 5 +- .prettierrc.json | 2 +- CLAUDE.md | 14 +- README.md | 16 +- app/admin/page.tsx | 157 + app/api/auth/login/route.ts | 26 + app/api/auth/logout/route.ts | 12 + app/api/auth/setup/route.ts | 31 + app/api/distance/route.ts | 145 + app/api/quote-request/route.ts | 397 +- app/api/quotes/route.ts | 48 + app/api/track/[id]/route.ts | 46 + app/dashboard/page.tsx | 266 + app/globals.css | 91 +- app/historik/page.tsx | 222 + app/icon.png | Bin 0 -> 3260 bytes app/layout.tsx | 9 +- app/login/page.tsx | 123 + app/page.tsx | 392 +- app/tilbud/[slug]/page.tsx | 207 + components/calculator/calculation-details.tsx | 143 +- components/calculator/calculator-form.tsx | 522 +- components/calculator/step-wizard.tsx | 570 + components/dashboard/kanban-board.tsx | 134 + components/dashboard/quote-card.tsx | 114 + components/dashboard/search-filter.tsx | 35 + components/ui/button.tsx | 20 +- components/ui/card.tsx | 99 +- components/ui/dialog.tsx | 104 + components/ui/input.tsx | 4 +- components/ui/label.tsx | 11 +- components/ui/progress.tsx | 25 + components/ui/select.tsx | 153 + components/ui/slider.tsx | 25 + components/ui/switch.tsx | 29 + components/ui/textarea.tsx | 31 +- docs/Retelser fra rene.md | 194 +- docs/byg_trans.png | Bin 0 -> 48069 bytes docs/company_email.png | Bin 0 -> 28441 bytes docs/dashboard.png | Bin 0 -> 113253 bytes docs/gulv.jpeg | Bin 0 -> 175453 bytes docs/lovable.png | Bin 0 -> 1803345 bytes docs/mobile.png | Bin 0 -> 229740 bytes docs/new_design.png | Bin 0 -> 1888754 bytes docs/prisbeskrivelse.md | 97 +- docs/projektplan.md | 90 +- docs/tilbud.pdf | Bin 0 -> 4465605 bytes eslint.config.mjs | 2 +- lib/auth.ts | 116 + lib/calculations.ts | 85 +- lib/constants.ts | 42 +- lib/db.ts | 272 + lib/distance.ts | 34 +- lib/utils.ts | 2 +- middleware.ts | 34 + next.config.ts | 4 +- package-lock.json | 10013 ++++++++++++++++ package.json | 47 +- postcss.config.mjs | 2 +- public/byg_trans.png | Bin 0 -> 48069 bytes public/dansk_kvalitet.png | Bin 0 -> 74905 bytes public/favicon.ico | Bin 0 -> 4286 bytes public/gulv.jpeg | Bin 0 -> 175453 bytes public/tilfredshed_service.png | Bin 0 -> 91200 bytes tailwind.config.ts | 10 +- tsconfig.json | 15 +- 67 files changed, 14508 insertions(+), 790 deletions(-) create mode 100644 .env.example create mode 100644 app/admin/page.tsx create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/setup/route.ts create mode 100644 app/api/distance/route.ts create mode 100644 app/api/quotes/route.ts create mode 100644 app/api/track/[id]/route.ts create mode 100644 app/dashboard/page.tsx create mode 100644 app/historik/page.tsx create mode 100644 app/icon.png create mode 100644 app/login/page.tsx create mode 100644 app/tilbud/[slug]/page.tsx create mode 100644 components/calculator/step-wizard.tsx create mode 100644 components/dashboard/kanban-board.tsx create mode 100644 components/dashboard/quote-card.tsx create mode 100644 components/dashboard/search-filter.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/switch.tsx create mode 100644 docs/byg_trans.png create mode 100644 docs/company_email.png create mode 100644 docs/dashboard.png create mode 100644 docs/gulv.jpeg create mode 100644 docs/lovable.png create mode 100644 docs/mobile.png create mode 100644 docs/new_design.png create mode 100644 docs/tilbud.pdf create mode 100644 lib/auth.ts create mode 100644 lib/db.ts create mode 100644 middleware.ts create mode 100644 package-lock.json create mode 100644 public/byg_trans.png create mode 100644 public/dansk_kvalitet.png create mode 100644 public/favicon.ico create mode 100644 public/gulv.jpeg create mode 100644 public/tilfredshed_service.png diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f276831 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# OpenRouteService API Key +# Get your free key at https://openrouteservice.org/dev/#/signup +# Free tier: 2,000 requests/day +OPENROUTE_API_KEY=your_api_key_here + +# Email configuration (for quote requests) +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER=user@example.com +# SMTP_PASS=your_password +# EMAIL_TO=info@foamking.dk diff --git a/.gitignore b/.gitignore index bce9269..9dec228 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ yarn-error.log* # typescript *.tsbuildinfo -next-env.d.ts \ No newline at end of file +next-env.d.ts + +# database +/data/ \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json index 22bdec1..5ee660c 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -6,4 +6,4 @@ "printWidth": 100, "plugins": ["prettier-plugin-tailwindcss"], "tailwindFunctions": ["clsx", "cn"] -} \ No newline at end of file +} diff --git a/CLAUDE.md b/CLAUDE.md index 3994267..70eb1d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Technology Stack **Planned stack:** Next.js + shadcn/ui + Tailwind CSS + - **Next.js**: For server-side rendering and API routes - **shadcn/ui**: For accessible, customizable components - **Tailwind CSS**: For styling @@ -17,6 +18,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status Currently in **documentation phase** - no implementation exists yet. Key documentation files: + - `docs/projektplan.md` - Complete project plan and requirements - `docs/prisbeskrivelse.md` - Detailed pricing logic and formulas - `docs/shadcn theme.txt` - Custom shadcn theme (blue/orange color scheme) @@ -25,6 +27,7 @@ Currently in **documentation phase** - no implementation exists yet. Key documen ## Core Requirements ### Input Form Fields + - Name (required, min 2 chars) - Email (required, valid format) - Phone (required, 8 digits) @@ -35,6 +38,7 @@ Currently in **documentation phase** - no implementation exists yet. Key documen - Remarks (optional) ### Price Calculation Components + 1. **Insulation**: 3,730 kr/m³ (subtract 5cm from height for concrete) 2. **Floor heating**: 205 kr/m² (always included) 3. **Synthetic mesh**: 49 kr/m² (always included) @@ -46,20 +50,24 @@ Currently in **documentation phase** - no implementation exists yet. Key documen 9. **VAT**: 25% ### Output + - Price estimate with ±10,000 kr variation - Option to request binding quote (sends email to `info@foamking.dk`) ## Implementation Guidelines ### Distance Calculation + Three options for calculating transport distance: + 1. **Postal code table** (recommended for MVP) 2. **OpenRouteService API** (free up to 2,000 requests/day) 3. **Google Maps API** (paid) ### Coverage Areas + - 4000-4999: West Zealand -- 2000-2999: Copenhagen +- 2000-2999: Copenhagen - 3000-3999: North Zealand - 4800-4899: Lolland-Falster - 5000-5999: Funen (+500 kr Great Belt bridge fee) @@ -67,6 +75,7 @@ Three options for calculating transport distance: ### Development Commands Since this is a new project, typical Next.js commands will apply once initialized: + ```bash # Initialize project npx create-next-app@latest . --typescript --tailwind --app @@ -103,6 +112,7 @@ npm run typecheck ### Testing Scenarios Test with examples from `prisbeskrivelse.md`: + - 50 m², 20 cm height, 2100 Copenhagen → ~95,500 kr - Edge cases: minimum (25 m²) and maximum (300 m²) areas - Different pump truck weight thresholds @@ -140,4 +150,4 @@ Test with examples from `prisbeskrivelse.md`: - All prices exclude VAT unless specified - The calculator provides estimates only - final quotes require on-site inspection - Focus on Zealand, Lolland-Falster, and Funen regions -- The domain `beregner.foamking.dk` points to `185.158.133.1` \ No newline at end of file +- The domain `beregner.foamking.dk` points to `185.158.133.1` diff --git a/README.md b/README.md index ea26698..7de0fde 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Kalkulatoren beregner priser baseret på: - **Isolering**: 3.730 kr/m³ (eller 75 kr/m² simpel arbejdsløn) - **Gulvvarme**: 205 kr/m² (altid inkluderet) -- **Syntetisk net**: 49 kr/m² (altid inkluderet) +- **Syntetisk net**: 49 kr/m² (altid inkluderet) - **Flydespartel**: 450 kr/m² (90 kg/m²) - **Pumpebil-tillæg**: 0-8.100 kr baseret på spartelvægt - **Startgebyr**: 3.500 kr fast @@ -67,7 +67,7 @@ npm run dev # Build for production npm run build -# Start production server +# Start production server npm start # Lint code @@ -89,7 +89,7 @@ Ingen environment variables er nødvendige for MVP version. I produktion: ```bash # Email service configuration SMTP_HOST=smtp.example.com -SMTP_USER=user@example.com +SMTP_USER=user@example.com SMTP_PASS=password # Or use a service like SendGrid, AWS SES, etc. @@ -106,25 +106,27 @@ Projektet bruger en forudberegnet tabel over afstande fra 4550 Asnæs til danske ## 🎯 Dækningsområde - **Sjælland**: Postnummer 2000-4999 -- **Lolland-Falster**: Postnummer 4800-4899 +- **Lolland-Falster**: Postnummer 4800-4899 - **Fyn**: Postnummer 5000-5999 (+500 kr Storebælt) ## 📱 Admin Mode Klik på "Vis detaljer" for at se den fulde prissopgørelse med: + - Alle priskomponenter -- Beregningslogik step-by-step +- Beregningslogik step-by-step - Isolerings- og transportdetaljer - Procenttillæg og momsberegning ## 🧪 Testing Test med eksempel fra dokumentationen: + - **Areal**: 50 m² -- **Højde**: 20 cm +- **Højde**: 20 cm - **Postnummer**: 2100 (København) - **Forventet resultat**: Ca. 95.500 kr inkl. moms ## 📄 Licens -Proprietary - Foam King Gulve \ No newline at end of file +Proprietary - Foam King Gulve diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..c19c9ff --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,157 @@ +"use client" + +import { useState } from "react" +import Image from "next/image" +import { CalculatorForm } from "@/components/calculator/calculator-form" +import { CalculationDetailsView } from "@/components/calculator/calculation-details" +import { Button } from "@/components/ui/button" +import type { CalculationDetails } from "@/lib/calculations" +import { formatEstimate } from "@/lib/calculations" +import { Send, Eye, EyeOff } from "lucide-react" + +export default function AdminPage() { + const [calculationResult, setCalculationResult] = useState(null) + const [showAdminMode, setShowAdminMode] = useState(true) + const [isRequestingQuote, setIsRequestingQuote] = useState(false) + const [customerInfo, setCustomerInfo] = useState(null) + const [distanceSource, setDistanceSource] = useState<"openrouteservice" | "table" | null>(null) + + const handleCalculation = ( + result: CalculationDetails, + formData?: any, + source?: "openrouteservice" | "table" + ) => { + setCalculationResult(result) + if (formData) { + setCustomerInfo(formData) + } + if (source) { + setDistanceSource(source) + } + } + + const handleQuoteRequest = async () => { + if (!calculationResult || !customerInfo) return + + setIsRequestingQuote(true) + try { + await new Promise((resolve) => setTimeout(resolve, 500)) + console.log("Quote request (test mode):", { + customerInfo, + calculationDetails: calculationResult, + }) + alert("TEST MODE: Tilbudsanmodning ville blive sendt til info@foamking.dk") + } catch (error) { + alert("Der opstod en fejl. Prøv igen senere.") + } finally { + setIsRequestingQuote(false) + } + } + + return ( +
+
+ {/* Header */} +
+
+ Foam King Gulve +
+

Foam King Gulve - Admin

+

+ Detaljeret prisberegning til internt brug +

+
+ + {/* Admin Mode Toggle */} +
+ +
+ + {/* Calculator */} +
+
+
+ +
+ + {/* Results */} +
+ {!showAdminMode ? ( +
+

Dit prisoverslag

+ {calculationResult ? ( + <> +

+ {formatEstimate(calculationResult.totalInclVat)} +

+

inkl. moms

+

+ *Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete + forhold +

+ + + ) : ( +

+ Udfyld formularen og klik "Beregn pris" +

+ )} +
+ ) : calculationResult ? ( + + ) : ( +
+

Detaljeret prisberegning

+

+ Udfyld formularen og klik "Beregn pris" +

+
+ )} +
+
+
+ + {/* Footer */} +
+

Foam King Gulve · Asnæs · CVR: 44 48 54 51

+

Vi dækker Sjælland, Lolland-Falster og Fyn

+
+
+
+ ) +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..6e018b7 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server" +import { login } from "@/lib/auth" + +export async function POST(request: NextRequest) { + try { + const { email, password } = await request.json() + + if (!email || !password) { + return NextResponse.json({ error: "Email og adgangskode er påkrævet" }, { status: 400 }) + } + + const result = await login(email, password) + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 401 }) + } + + return NextResponse.json({ + success: true, + user: { name: result.user.name, email: result.user.email }, + }) + } catch (error) { + console.error("Login error:", error) + return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 }) + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..a9ff5bd --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server" +import { logout } from "@/lib/auth" + +export async function POST() { + try { + await logout() + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Logout error:", error) + return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 }) + } +} diff --git a/app/api/auth/setup/route.ts b/app/api/auth/setup/route.ts new file mode 100644 index 0000000..8c52271 --- /dev/null +++ b/app/api/auth/setup/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server" +import { createUserWithPassword } from "@/lib/auth" +import { getUserByEmail } from "@/lib/db" + +// One-time setup endpoint to create initial user +// Only works if no users exist yet or for the specific email +export async function POST(request: NextRequest) { + try { + const { email, password, name } = await request.json() + + if (!email || !password || !name) { + return NextResponse.json({ error: "Email, password og navn er påkrævet" }, { status: 400 }) + } + + // Check if user already exists + const existingUser = getUserByEmail(email) + if (existingUser) { + return NextResponse.json({ error: "Bruger findes allerede" }, { status: 409 }) + } + + const user = await createUserWithPassword(email, password, name) + + return NextResponse.json({ + success: true, + user: { id: user.id, email: user.email, name: user.name }, + }) + } catch (error) { + console.error("Setup error:", error) + return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 }) + } +} diff --git a/app/api/distance/route.ts b/app/api/distance/route.ts new file mode 100644 index 0000000..4bc1a75 --- /dev/null +++ b/app/api/distance/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from "next/server" +import { getDistance as getDistanceFromTable } from "@/lib/distance" + +const OPENROUTE_API_KEY = process.env.OPENROUTE_API_KEY +const HOME_COORDINATES = { lat: 55.8089, lng: 11.4955 } // 4550 Asnæs + +// Postal code prefix to city name for better geocoding +const POSTAL_TO_CITY: Record = { + "1": "København", + "2": "København", + "30": "Helsingør", + "31": "Hornbæk", + "32": "Helsinge", + "33": "Frederiksværk", + "34": "Hillerød", + "35": "Værløse", + "36": "Frederikssund", + "40": "Roskilde", + "41": "Ringsted", + "42": "Slagelse", + "43": "Holbæk", + "44": "Kalundborg", + "45": "Nykøbing Sjælland", + "46": "Køge", + "47": "Næstved", + "48": "Nykøbing Falster", + "49": "Nakskov", + "50": "Odense", + "51": "Odense", + "52": "Odense", + "55": "Middelfart", + "56": "Faaborg", + "57": "Svendborg", + "58": "Nyborg", +} + +function getCityFromPostalCode(postalCode: string): string { + // Try full prefix first (2 digits), then first digit + return ( + POSTAL_TO_CITY[postalCode.slice(0, 2)] || POSTAL_TO_CITY[postalCode.slice(0, 1)] || "Denmark" + ) +} + +interface OpenRouteResponse { + features: { + geometry: { + coordinates: number[] + } + }[] +} + +interface DirectionsResponse { + routes: { + summary: { + distance: number // meters + } + }[] +} + +async function geocodeAddress( + address: string | null, + postalCode: string +): Promise<{ lat: number; lng: number } | null> { + if (!OPENROUTE_API_KEY) return null + + try { + // Build search text from address and city (postal codes don't work well with OpenRouteService) + const city = getCityFromPostalCode(postalCode) + let searchText: string + if (address && address.trim()) { + searchText = `${address}, ${city}, Denmark` + } else { + searchText = `${city}, Denmark` + } + + const url = `https://api.openrouteservice.org/geocode/search?api_key=${OPENROUTE_API_KEY}&text=${encodeURIComponent(searchText)}&boundary.country=DK&size=1` + const response = await fetch(url, { headers: { Accept: "application/json" } }) + + if (!response.ok) return null + + const data: OpenRouteResponse = await response.json() + if (!data.features?.[0]?.geometry?.coordinates) return null + + const [lng, lat] = data.features[0].geometry.coordinates + return { lat, lng } + } catch { + return null + } +} + +async function getRouteDistance( + from: { lat: number; lng: number }, + to: { lat: number; lng: number } +): Promise { + if (!OPENROUTE_API_KEY) return null + + try { + const url = `https://api.openrouteservice.org/v2/directions/driving-car?api_key=${OPENROUTE_API_KEY}&start=${from.lng},${from.lat}&end=${to.lng},${to.lat}` + const response = await fetch(url, { headers: { Accept: "application/geo+json" } }) + + if (!response.ok) return null + + const data = await response.json() + const distance = data.features?.[0]?.properties?.summary?.distance + if (!distance) return null + + // Convert meters to km and double for round trip + return Math.round((distance / 1000) * 2) + } catch { + return null + } +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const postalCode = searchParams.get("postalCode") + const address = searchParams.get("address") + + if (!postalCode || !/^\d{4}$/.test(postalCode)) { + return NextResponse.json({ error: "Invalid postal code" }, { status: 400 }) + } + + // Try OpenRouteService first if API key is configured + if (OPENROUTE_API_KEY) { + const destination = await geocodeAddress(address, postalCode) + if (destination) { + const distance = await getRouteDistance(HOME_COORDINATES, destination) + if (distance !== null) { + return NextResponse.json({ + distance, + source: "openrouteservice", + postalCode, + }) + } + } + } + + // Fall back to postal code table + const distance = getDistanceFromTable(postalCode) + return NextResponse.json({ + distance, + source: "table", + postalCode, + }) +} diff --git a/app/api/quote-request/route.ts b/app/api/quote-request/route.ts index 0757567..6321853 100644 --- a/app/api/quote-request/route.ts +++ b/app/api/quote-request/route.ts @@ -1,116 +1,303 @@ -import { NextResponse } from "next/server" -import { z } from "zod" -import { formatPrice } from "@/lib/calculations" -import type { CalculationDetails } from "@/lib/calculations" +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" -const quoteRequestSchema = z.object({ - customerInfo: z.object({ - name: z.string(), - email: z.string().email(), - phone: z.string(), - postalCode: z.string(), - address: z.string().optional(), - remarks: z.string().optional(), - }), - calculationDetails: z.object({ - area: z.number(), - height: z.number(), - postalCode: z.string(), - distance: z.number(), - totalInclVat: z.number(), - // We'll validate other fields exist but not their exact shape - }) as z.ZodType, -}) +interface QuoteRequestBody { + customerInfo: { + name: string + email: string + phone: string + postalCode: string + address?: string + remarks?: string + } + calculationDetails: CalculationDetails +} -export async function POST(request: Request) { +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 ` + + + + + + + +
+

Foam King Gulve

+

Dit prisoverslag

+
+ +
+

Kære ${customer.name},

+

Tak for din interesse i Foam King Gulve. Her er dit prisoverslag baseret på de oplysninger du har indtastet:

+ +
+
${formatPrice(Math.round(details.totalInclVat))}
+
inkl. moms
+
+ +
+

Projektdetaljer

+
    +
  • Areal: ${details.area} m²
  • +
  • Gulvhøjde: ${details.height} cm
  • +
  • Placering: ${customer.postalCode}${customer.address ? `, ${customer.address}` : ""}
  • +
+
+ +
+

Inkluderet i prisen

+
    + ${components.map((c) => `
  • ${c}
  • `).join("")} +
  • Transport
  • +
  • Startgebyr (udstyr og sikkerhed)
  • +
+
+ +
+ Bemærk: Dette er et vejledende prisoverslag og kan variere med ±10.000 kr afhængigt af konkrete forhold på stedet. +
+ +

Vi kontakter dig snarest for at aftale et uforpligtende besøg, hvor vi kan give dig et præcist og bindende tilbud.

+ +

Har du spørgsmål i mellemtiden, er du velkommen til at kontakte os.

+ +

Med venlig hilsen,
+ Foam King ApS
+ Tlf: 35 90 10 66
+ Email: info@foamking.dk

+
+ + + + +` +} + +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 ` + + + + + + + +
+

Ny tilbudsanmodning #${quoteId}

+
+ + + +
+

Kundeoplysninger

+ + + + + + ${customer.address ? `` : ""} +
Navn:${customer.name}
Email:${customer.email}
Telefon:${customer.phone}
Postnummer:${customer.postalCode}
Adresse:${customer.address}
+ ${customer.remarks ? `
Bemærkninger fra kunden:
${customer.remarks}
` : ""} +
+ +
+

Projektspecifikationer

+ + + + + + +
Gulvareal:${details.area} m²
Gulvhøjde:${details.height} cm
Isoleringstykkelse:${details.insulationThickness} cm
Spartelvægt:${details.compoundWeight} kg
Afstand (tur/retur):${details.distance} km
+
+ +
+

Prisberegning

+ + ${components + .map((c) => { + const [label, price] = c.split(": ") + return `` + }) + .join("")} + + + + + + + ${details.bridgeFee > 0 ? `` : ""} + + + +
${label}:${price}
Startgebyr:${formatPrice(Math.round(details.startFee))}
Subtotal:${formatPrice(Math.round(details.subtotal))}
Afdækning (0.7%):${formatPrice(Math.round(details.coveringFee))}
Affald (0.25%):${formatPrice(Math.round(details.wasteFee))}
Transport:${formatPrice(Math.round(details.transport))}
Storebælt:${formatPrice(details.bridgeFee)}
Total ekskl. moms:${formatPrice(Math.round(details.totalExclVat))}
Moms (25%):${formatPrice(Math.round(details.vat))}
Total inkl. moms:${formatPrice(Math.round(details.totalInclVat))}
+
+ +
+

+ Denne anmodning er genereret automatisk fra prisberegneren på beregner.foamking.dk
+ Tidspunkt: ${new Date().toLocaleString("da-DK", { timeZone: "Europe/Copenhagen" })} +

+
+ + +` +} + +export async function POST(request: NextRequest) { try { - const body = await request.json() - const { customerInfo, calculationDetails } = quoteRequestSchema.parse(body) + 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), + }) - // Format email content - const emailContent = formatEmailContent(customerInfo, calculationDetails) - - // In production, you would send this via an email service - // For now, we'll just log it and return success - console.log("Quote request email:", emailContent) - - // TODO: Implement actual email sending using a service like: - // - SendGrid - // - AWS SES - // - Resend - // - Nodemailer with SMTP - return NextResponse.json({ success: true, - message: "Tilbudsanmodning modtaget. Vi kontakter dig snarest muligt.", + message: "Tak! Vi har modtaget din anmodning og sendt en bekræftelse til din email.", }) } catch (error) { console.error("Quote request error:", error) - - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: "Ugyldige data", details: error.errors }, - { status: 400 } - ) - } - - return NextResponse.json( - { error: "Der opstod en fejl. Prøv igen senere." }, - { status: 500 } - ) + return NextResponse.json({ error: "Der opstod en fejl. Prøv igen senere." }, { status: 500 }) } } - -function formatEmailContent( - customerInfo: z.infer["customerInfo"], - details: CalculationDetails -): string { - return ` -Ny tilbudsanmodning fra Foam King Gulve Prisberegner - -KUNDEOPLYSNINGER: ------------------ -Navn: ${customerInfo.name} -Email: ${customerInfo.email} -Telefon: ${customerInfo.phone} -Postnummer: ${customerInfo.postalCode} -Adresse: ${customerInfo.address || "Ikke angivet"} - -PROJEKTDETALJER: ----------------- -Gulvareal: ${details.area} m² -Gulvhøjde: ${details.height} cm -Isoleringstykkelse: ${details.insulationThickness} cm -Isoleringsvolumen: ${details.insulationVolume.toFixed(2)} m³ -Spartelvægt: ${details.compoundWeight.toLocaleString("da-DK")} kg - -PRISBEREGNING: --------------- -Isolering: ${formatPrice(details.insulation)} -Gulvvarme: ${formatPrice(details.floorHeating)} -Syntetisk net: ${formatPrice(details.syntheticNet)} -Flydespartel: ${formatPrice(details.selfLevelingCompound)} -Pumpebil-tillæg: ${formatPrice(details.pumpTruckFee)} -Startgebyr: ${formatPrice(details.startFee)} - -Subtotal: ${formatPrice(details.subtotal)} -Tillæg (afdækning + affald): ${formatPrice(details.totalFees)} -Transport: ${formatPrice(details.transport)} -${details.bridgeFee > 0 ? `Storebælt-tillæg: ${formatPrice(details.bridgeFee)}` : ""} - -Total ekskl. moms: ${formatPrice(details.totalExclVat)} -Moms (25%): ${formatPrice(details.vat)} -TOTAL INKL. MOMS: ${formatPrice(details.totalInclVat)} - -BEMÆRKNINGER: -------------- -${customerInfo.remarks || "Ingen bemærkninger"} - -AFSTAND: --------- -Kørselsafstand (tur-retur): ${details.distance} km - ---- -Sendt fra beregner.foamking.dk -`.trim() -} \ No newline at end of file diff --git a/app/api/quotes/route.ts b/app/api/quotes/route.ts new file mode 100644 index 0000000..06fc3e1 --- /dev/null +++ b/app/api/quotes/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAllQuotes, updateQuoteStatus, type QuoteStatus } from "@/lib/db" +import { getCurrentUser } from "@/lib/auth" + +export async function GET() { + try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 }) + } + + const quotes = getAllQuotes() + return NextResponse.json({ quotes }) + } catch (error) { + console.error("Get quotes error:", error) + return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 }) + } +} + +export async function PATCH(request: NextRequest) { + try { + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 }) + } + + const { id, status } = await request.json() + + if (!id || !status) { + return NextResponse.json({ error: "ID og status er påkrævet" }, { status: 400 }) + } + + const validStatuses: QuoteStatus[] = ["new", "contacted", "accepted", "rejected"] + if (!validStatuses.includes(status)) { + return NextResponse.json({ error: "Ugyldig status" }, { status: 400 }) + } + + const success = updateQuoteStatus(id, status) + if (!success) { + return NextResponse.json({ error: "Tilbud ikke fundet" }, { status: 404 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Update quote error:", error) + return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 }) + } +} diff --git a/app/api/track/[id]/route.ts b/app/api/track/[id]/route.ts new file mode 100644 index 0000000..750d170 --- /dev/null +++ b/app/api/track/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server" +import { markEmailOpened } from "@/lib/db" +import fs from "fs" +import path from "path" + +// Serve the byg-garanti image and track email opens +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const quoteId = parseInt(id, 10) + + // Mark email as opened (only first time) + if (!isNaN(quoteId)) { + markEmailOpened(quoteId) + } + + // Serve the image + const imagePath = path.join(process.cwd(), "public", "byg_trans.png") + + try { + const imageBuffer = fs.readFileSync(imagePath) + + return new NextResponse(imageBuffer, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + }) + } catch { + // Return a 1x1 transparent pixel if image not found + const pixel = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64" + ) + return new NextResponse(pixel, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "no-cache", + }, + }) + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..f8f0682 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,266 @@ +"use client" + +import { useEffect, useState, useMemo } from "react" +import { useRouter } from "next/navigation" +import Image from "next/image" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { KanbanBoard } from "@/components/dashboard/kanban-board" +import { SearchFilter } from "@/components/dashboard/search-filter" +import { formatPrice } from "@/lib/calculations" +import { type StoredQuote, type QuoteStatus } from "@/lib/db" +import { LogOut, List, Loader2, RotateCcw, ExternalLink } from "lucide-react" + +export default function DashboardPage() { + const router = useRouter() + const [quotes, setQuotes] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + + // Dialogs + const [rejectQuote, setRejectQuote] = useState(null) + const [showRejected, setShowRejected] = useState(false) + + useEffect(() => { + fetchQuotes() + }, []) + + async function fetchQuotes() { + try { + const res = await fetch("/api/quotes") + if (!res.ok) { + if (res.status === 401) { + router.push("/login") + return + } + throw new Error("Failed to fetch quotes") + } + const data = await res.json() + setQuotes(data.quotes) + } catch (error) { + console.error("Failed to fetch quotes:", error) + } finally { + setLoading(false) + } + } + + async function handleStatusChange(id: number, status: QuoteStatus) { + // Optimistic update + setQuotes((prev) => prev.map((q) => (q.id === id ? { ...q, status } : q))) + + try { + const res = await fetch("/api/quotes", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, status }), + }) + + if (!res.ok) { + fetchQuotes() + } + } catch { + fetchQuotes() + } + } + + function handleRejectClick(quote: StoredQuote) { + setRejectQuote(quote) + } + + async function confirmReject() { + if (!rejectQuote) return + await handleStatusChange(rejectQuote.id, "rejected") + setRejectQuote(null) + } + + async function handleLogout() { + try { + await fetch("/api/auth/logout", { method: "POST" }) + router.push("/login") + router.refresh() + } catch (error) { + console.error("Logout failed:", error) + } + } + + const filteredQuotes = useMemo(() => { + if (!search.trim()) return quotes + + const term = search.toLowerCase() + return quotes.filter( + (q) => + q.customerName.toLowerCase().includes(term) || + q.customerEmail.toLowerCase().includes(term) || + q.postalCode.includes(term) + ) + }, [quotes, search]) + + const activeQuotes = filteredQuotes.filter((q) => q.status !== "rejected") + const rejectedQuotes = filteredQuotes.filter((q) => q.status === "rejected") + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ + Foam King + +

Dashboard

+
+ +
+ + + + +
+
+
+ + {/* Main content */} +
+ {/* Search */} +
+ +
+ + {/* Kanban board */} + setShowRejected(true)} + /> + + {/* Empty state */} + {quotes.length === 0 && !loading && ( +
+

+ Ingen tilbud endnu. Når kunder anmoder om tilbud, vil de vises her. +

+
+ )} +
+ + {/* Reject confirmation dialog */} + setRejectQuote(null)}> + + + Afvis tilbud? + + Er du sikker på du vil afvise tilbuddet til{" "} + {rejectQuote?.customerName}? + + + {rejectQuote && ( +
+
+ Tilbud #{rejectQuote.id} + + {rejectQuote.totalInclVat + ? formatPrice(Math.round(rejectQuote.totalInclVat)) + : "—"} + +
+
+ {rejectQuote.postalCode} · {rejectQuote.area} m² +
+
+ )} + + + + +
+
+ + {/* Rejected quotes modal */} + + + + Afviste tilbud ({rejectedQuotes.length}) + +
+ {rejectedQuotes.length === 0 ? ( +

Ingen afviste tilbud

+ ) : ( + rejectedQuotes.map((quote) => { + const slug = `${quote.postalCode}-${quote.id}` + return ( +
+
+
+ {quote.customerName} + + + +
+
+ {quote.postalCode} · {quote.area} m² ·{" "} + {quote.totalInclVat ? formatPrice(Math.round(quote.totalInclVat)) : "—"} +
+
+ +
+ ) + }) + )} +
+
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css index 8699e07..479a2cb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,61 +4,48 @@ @layer base { :root { - --background: oklch(0.985 0.0014 39.68); - --foreground: oklch(0.2683 0.0043 41.05); - --card: var(--color-white); - --card-foreground: oklch(0.2683 0.0043 41.05); - --popover: var(--color-white); - --popover-foreground: oklch(0.2683 0.0043 41.05); - --primary: oklch(0.8651 0.1153 207.08); - --primary-foreground: var(--color-black); - --secondary: oklch(0.72 0.1613 29.29); - --secondary-foreground: var(--color-black); - --muted: oklch(0.9674 0.0029 40.41); - --muted-foreground: oklch(0.4426 0.0055 43.48); - --accent: oklch(0.9674 0.0029 40.41); - --accent-foreground: oklch(0.2683 0.0043 41.05); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.985 0.0014 39.68); - --border: oklch(0.9227 0.0041 40.62); - --input: oklch(0.8693 0.0046 41.1); - --ring: oklch(0.8651 0.1153 207.08); - --chart-1: oklch(0.8651 0.1153 207.08); - --chart-2: oklch(0.72 0.1613 29.29); - --chart-3: oklch(0.7886 0.1393 211.4); - --chart-4: oklch(0.8154 0.1004 27.92); - --chart-5: oklch(0.8651 0.1153 207.08); + --background: 39.68 0.14% 98.5%; + --foreground: 41.05 1.6% 16.5%; + --card: 0 0% 100%; + --card-foreground: 41.05 1.6% 16.5%; + --popover: 0 0% 100%; + --popover-foreground: 41.05 1.6% 16.5%; + --primary: 207.08 60% 75%; + --primary-foreground: 0 0% 0%; + --secondary: 29.29 70% 60%; + --secondary-foreground: 0 0% 0%; + --muted: 40.41 3% 96%; + --muted-foreground: 43.48 3% 35%; + --accent: 40.41 3% 96%; + --accent-foreground: 41.05 1.6% 16.5%; + --destructive: 27.325 70% 45%; + --destructive-foreground: 0 0% 100%; + --border: 40.62 2% 90%; + --input: 41.1 2% 85%; + --ring: 207.08 60% 75%; --radius: 1rem; - - --color-white: #ffffff; - --color-black: #000000; } .dark { - --background: oklch(0.1465 0.0038 39.55); - --foreground: oklch(0.9227 0.0041 40.62); - --card: oklch(0.213 0.0041 40.86); - --card-foreground: oklch(0.9227 0.0041 40.62); - --popover: oklch(0.213 0.0041 40.86); - --popover-foreground: oklch(0.9227 0.0041 40.62); - --primary: oklch(0.8651 0.1153 207.08); - --primary-foreground: var(--color-black); - --secondary: oklch(0.72 0.1613 29.29); - --secondary-foreground: var(--color-black); - --muted: oklch(0.2683 0.0043 41.05); - --muted-foreground: oklch(0.8693 0.0046 41.1); - --accent: oklch(0.2683 0.0043 41.05); - --accent-foreground: oklch(0.9227 0.0041 40.62); - --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0.985 0.0014 39.68); - --border: oklch(0.2683 0.0043 41.05); - --input: oklch(0.3732 0.0051 42.7); - --ring: oklch(0.8651 0.1153 207.08); - --chart-1: oklch(0.8651 0.1153 207.08); - --chart-2: oklch(0.72 0.1613 29.29); - --chart-3: oklch(0.7886 0.1393 211.4); - --chart-4: oklch(0.8154 0.1004 27.92); - --chart-5: oklch(0.8651 0.1153 207.08); + --background: 39.55 3% 9%; + --foreground: 40.62 2% 90%; + --card: 40.86 2% 13%; + --card-foreground: 40.62 2% 90%; + --popover: 40.86 2% 13%; + --popover-foreground: 40.62 2% 90%; + --primary: 207.08 60% 75%; + --primary-foreground: 0 0% 0%; + --secondary: 29.29 70% 60%; + --secondary-foreground: 0 0% 0%; + --muted: 41.05 1.6% 16.5%; + --muted-foreground: 41.1 2% 85%; + --accent: 41.05 1.6% 16.5%; + --accent-foreground: 40.62 2% 90%; + --destructive: 22.216 65% 55%; + --destructive-foreground: 0 0% 100%; + --border: 41.05 1.6% 16.5%; + --input: 42.7 3% 23%; + --ring: 207.08 60% 75%; } } @@ -69,4 +56,4 @@ body { @apply bg-background text-foreground; } -} \ No newline at end of file +} diff --git a/app/historik/page.tsx b/app/historik/page.tsx new file mode 100644 index 0000000..f0df4f8 --- /dev/null +++ b/app/historik/page.tsx @@ -0,0 +1,222 @@ +"use client" + +import { useEffect, useState, useMemo } from "react" +import { useRouter } from "next/navigation" +import Image from "next/image" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { SearchFilter } from "@/components/dashboard/search-filter" +import { formatPrice } from "@/lib/calculations" +import { type StoredQuote, type QuoteStatus } from "@/lib/db" +import { LogOut, LayoutDashboard, Loader2, ExternalLink } from "lucide-react" + +const STATUS_LABELS: Record = { + new: { label: "Ny", className: "bg-blue-100 text-blue-700" }, + contacted: { label: "Kontaktet", className: "bg-amber-100 text-amber-700" }, + accepted: { label: "Accepteret", className: "bg-green-100 text-green-700" }, + rejected: { label: "Afvist", className: "bg-gray-100 text-gray-500" }, +} + +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString("da-DK", { + day: "numeric", + month: "short", + year: "numeric", + }) +} + +export default function HistorikPage() { + const router = useRouter() + const [quotes, setQuotes] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + + useEffect(() => { + fetchQuotes() + }, []) + + async function fetchQuotes() { + try { + const res = await fetch("/api/quotes") + if (!res.ok) { + if (res.status === 401) { + router.push("/login") + return + } + throw new Error("Failed to fetch quotes") + } + const data = await res.json() + setQuotes(data.quotes) + } catch (error) { + console.error("Failed to fetch quotes:", error) + } finally { + setLoading(false) + } + } + + async function handleLogout() { + try { + await fetch("/api/auth/logout", { method: "POST" }) + router.push("/login") + router.refresh() + } catch (error) { + console.error("Logout failed:", error) + } + } + + const filteredQuotes = useMemo(() => { + if (!search.trim()) return quotes + + const term = search.toLowerCase() + return quotes.filter( + (q) => + q.customerName.toLowerCase().includes(term) || + q.customerEmail.toLowerCase().includes(term) || + q.postalCode.includes(term) || + q.id.toString().includes(term) + ) + }, [quotes, search]) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ + Foam King + +

Historik

+
+ +
+ + + + +
+
+
+ + {/* Main content */} +
+ {/* Search and stats */} +
+
+ +
+
+ {filteredQuotes.length} tilbud + {search && ` (af ${quotes.length})`} +
+
+ + {/* Table */} +
+
+ + + + + + + + + + + + + + + {filteredQuotes.map((quote) => { + const slug = `${quote.postalCode}-${quote.id}` + const status = STATUS_LABELS[quote.status] + return ( + + + + + + + + + + + ) + })} + +
+ # + + Kunde + + Lokation + + Areal + + Pris + + Status + + Dato +
{quote.id} +
{quote.customerName}
+
{quote.customerEmail}
+
+ {quote.postalCode} + {quote.address && ( + , {quote.address} + )} + {quote.area} m² + {quote.totalInclVat ? formatPrice(Math.round(quote.totalInclVat)) : "—"} + + + {status.label} + + + {formatDate(quote.createdAt)} + + + Åbn + + +
+
+ + {filteredQuotes.length === 0 && ( +
+ {search ? "Ingen tilbud matcher søgningen" : "Ingen tilbud endnu"} +
+ )} +
+
+
+ ) +} diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3454f4497f3b998c92a8ea2c808f850cf970fdc4 GIT binary patch literal 3260 zcmY+GcTf}97KdY*fE3vvk)nVEFhp7?DnvvOB1k7BfbfAi2T=Ukg?84K2-c(vUsO)9tZsvCFs2U5r z0}1_Kv1$q){R%2w{J6{e-a%NTt+3v#spj)AT?qJTZ=V7R=O9Z?nSjP1cal`NCOSaY z1|eECk;HMJ>r@@%^y=qxRMJ(S+q<@K>-M_*XaIGfhw+s`F-ak5pqm#5fQzQ!c z*R?Q*!lR_IkU)XNW|#anSJtKXQ?;`1BHuN(nsZ}g;)Rjjt)2&I#5W&_b-$7s)4pen zys9t5NU42PrYy*M(h}2xoa#3LGxWLCXQNFa&)O!YLW!&ZOv@&)B9B513<+Chw zd7Kd50@d4bFM2=o`$IFBDDR_d!q&>K95#*?x`5J(_ zN>aaHN&Jfb1n+_C$TU01S*UFy%GjfB?%E&)-HuFAsC!#DW_2_Ed27(Ha2i8>IVRZA zqgjq^f%3y!c0AWos|HYSrWXNmve9~+R^xhkw2Ua)_5_6x?2I1<_XA5(`>s^`y$kaA z^DWZ{-`5=r#?|BMiWe)g;!pXhob3pO+iCAyV1#_YXLPAJrzv0L96k{ouTn1deMQAv z15vVA>W|R*rW(fE;Y9ZAt8=e2_jY-ngtY0ijF4NEZ(B`ytbZD;uq)zTf)pF8T+RqeXX28J9jB)1h`$l8nzAmN{Z{Cw5lWTE*~EbD75pAwue zfY`Vf?Gr`0kR_A#fKj_q2i#uh$#GSU6Q|N^&vwPWo0-Qj`3scw+gFiU(HJDn{)^wN zrdsU0UqiB4_EwnMfw>gTQ7tn6!sdRzl~Kf)e`5Cp!|%{`w;unjT|lNo>5)(pgT=a9 z9O4wkk95@c{maC3k-_)fn}i;)xV!L{cZJ{4-SNI!Psa)DmQ@LApv*ZYSv+GB+bm)I z7pfV>GWV$Cn-r0qn4|N!JQ^>^ap4r?{b?Wu8HrsjpDl zPTkxM&$7Y)ls#3ppI9l{p2usG;}OPT!SjU!W}EWxn!tXo)TR8YGsEYdwWIMN>)Fe^ z(gEG-MLlN}Pv%03dbM0U9vcC#{e}#2op-v9vuG!CPiE3AR|d5{Ic}0nH82#Nj-xoI zr}3sJE3y5<#Jp$=?9MAFVZ(SZ#~ZWrrFGu7TZ-a5qp^E#S!(K2cJM2FY3Bl!G(K6* zy6@C#LJ#)!Zy0&ehiiDlh|U%6_@Y1)7JyDjvv|Zg6+|~NOwYZ<167b{jpKLm!q2ccN7gC4lG%IlYyOT7H8ocUTeZ^^kHslp#Oncv2*V2v?;;I@?p zQHM>r9Js^mVH}6?@vrJp7*QMAI)QUO$t@vEh0fJU6R0jf4%1lqE*Qgf#k?&3lXk9@ zlMl!i5?;0(mO@l-F4-KfJbE$WFPvhH#FvH(o=X_+hq5(h7F| zui`(V5Zr_`(-Bd<1$R}7sGRd|C|f+Zq6lA_TCD8KJGK!@>-fO4-xV`xy4xJIbfVKI zPxoR)9G?7+=$f&Yc%0?$<82EA#MSLfa5CG=SJFB;tF%Y0ZJZRhU-l{D%$k^4K}+F> z%>7enrrSlRy}<*VpW!LeOxj@(xj+1DjPE%Zs=uzblrxt}>+f+BY!7hj+-Tyx zF?kxaq~{=HKPN1{KQanDXEC+o$453X3aT@dddvS@*Zt{%x-WZpwP4cTOZ@u;6+69s zW!510UI<(h6Nf*WVH@meToE}`5Kt4ep<7pnY2c~?`= zE+aa6{NHw`L>gw)VL1y)XU5`}?67E`>iF)=NAmp*WTqQUI#EpHgJimocyT3gCYpnnFV8t*;3 zH#E6co`XNXt-Bymk|8f;DA_H20-I>_!BTo+MkL*KTBy72<`SW@m=&jDN@kMq(zQch z(?`#e{&BYSE!Bup*%l)Pv^j`MAYJ2nz<|RV6*ouC-t$BB^I3z2( ztCt`W)U1{3TbY)dv)WBu2Kt6#O&5}ayxoLn-DC!He+|*s zytjh4>C604Sw^#KJ*?M9b0JYXKF{1mY|LUyHJ3^V{e@%M;+cgViNoeS&wFWtAZMf+ zbUNzlh8M}=(Y^Ls$hc{^MUt+6%9=t0?MK7FOSMI%}e`7ceiOA=Z315HDJKT8aP5w2r0HuZOu|}afjHIMKxz;p@Q5s6$=<{!rvkp%V z1e$)%EZq3IPQTQ^aagW;ozkcIbj+V-CI0D$S7JhPn%~}t;wl+)HogvA+kPjJ_&uDrmSVfeY1E}^d z527`omFkgYU5~@o6#WvCXP!wFQGX3r3u10)mdIrvL4u_IKs$FMa0W)1#SHt6i03yz z7PN^(?AF(qpTA}}4J;}u-4W|zMUzm!m-rB_Zp*qa-Wyh?KL=>@fg}aK&q~a0OWfMYO_o1Y89HzbXxfBj9j$ zA!_iy1TP;K_xq3k5#VSAWrV^tgtF@2!bIfToMS=ie=h{Mdto02IC}a1<0zvQ;Rxkx Y2)NSUP6a69(Xj)#i!{) { return ( - + {children} ) -} \ No newline at end of file +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..9ae8a20 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,123 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import Image from "next/image" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Loader2 } from "lucide-react" + +export default function LoginPage() { + const router = useRouter() + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError("") + setLoading(true) + + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }) + + const data = await res.json() + + if (!res.ok) { + setError(data.error || "Login fejlede") + return + } + + router.push("/dashboard") + router.refresh() + } catch { + setError("Der opstod en fejl. Prøv igen.") + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+ + Foam King + +

Log ind

+

Adgang til dashboard

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="din@email.dk" + required + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + autoComplete="current-password" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ +

+ + ← Tilbage til forsiden + +

+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index e26aef5..ab72536 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,44 +2,61 @@ import { useState } from "react" import Image from "next/image" -import { CalculatorForm } from "@/components/calculator/calculator-form" -import { CalculationDetailsView } from "@/components/calculator/calculation-details" +import { StepWizard } from "@/components/calculator/step-wizard" import { Button } from "@/components/ui/button" -import type { CalculationDetails } from "@/lib/calculations" -import { formatEstimate } from "@/lib/calculations" -import { Send, Eye, EyeOff } from "lucide-react" +import { formatEstimate, type CalculationDetails } from "@/lib/calculations" +import { + Phone, + Mail, + MapPin, + CheckCircle2, + ArrowRight, + RotateCcw, + Loader2, +} from "lucide-react" export default function Home() { - const [calculationResult, setCalculationResult] = useState(null) - const [showAdminMode, setShowAdminMode] = useState(false) - const [isRequestingQuote, setIsRequestingQuote] = useState(false) - const [customerInfo, setCustomerInfo] = useState(null) + const [result, setResult] = useState(null) + const [customerData, setCustomerData] = useState(null) + const [showResult, setShowResult] = useState(false) - const handleCalculation = (result: CalculationDetails, formData?: any) => { - setCalculationResult(result) - if (formData) { - setCustomerInfo(formData) - } + const handleComplete = (calculationResult: CalculationDetails, formData: any) => { + setResult(calculationResult) + setCustomerData(formData) + setShowResult(true) } - const handleQuoteRequest = async () => { - if (!calculationResult || !customerInfo) return - - setIsRequestingQuote(true) + const handleReset = () => { + setResult(null) + setCustomerData(null) + setShowResult(false) + } + + const [isRequesting, setIsRequesting] = useState(false) + + const handleRequestQuote = async () => { + if (!result || !customerData) return + + setIsRequesting(true) try { const response = await fetch("/api/quote-request", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - customerInfo, - calculationDetails: calculationResult, + customerInfo: { + name: customerData.name, + email: customerData.email, + phone: customerData.phone, + postalCode: customerData.postalCode, + address: customerData.address, + remarks: customerData.remarks, + }, + calculationDetails: result, }), }) const data = await response.json() - + if (response.ok) { alert(data.message) } else { @@ -48,102 +65,271 @@ export default function Home() { } catch (error) { alert("Der opstod en fejl. Prøv igen senere.") } finally { - setIsRequestingQuote(false) + setIsRequesting(false) } } return ( -
-
- {/* Header */} -
-
- Foam King Gulve -
-

Foam King Gulve

-

- Professionelle gulvløsninger med isolering, gulvvarme og støbning -

+
+ {/* Hero Section */} +
+ {/* Background Image */} +
+ Smukt gulv i moderne hjem +
- {/* Admin Mode Toggle */} -
+ {/* Hero Content */} +
+
+ Foam King +
+

+ Gulvarbejde i
+ verdensklasse +

+

+ Professionel udførelse af betongulve, gulvvarme og isolering. Vi leverer kvalitet der + holder i mange år fremover. +

+
+
+ + Stor erfaring +
+
+ + Byg Garanti +
+
+ + Gratis tilbud +
+
- {/* Calculator */} -
-
-
- +
+
+
+
+
+ + {/* Calculator Section */} +
+
+
+

Prisberegner

+

Få dit personlige tilbud

+

+ Besvar nogle få spørgsmål, så kan give dig den mest nøjagtige prisberegning. +
+ Det tager kun 2 minutter. +

+
+ + {!showResult ? ( + + ) : ( + /* Result Card */ +
+
+
+ +
+

Dit prisoverslag

+

+ Baseret på {result?.area} m² gulv i {customerData?.postalCode} +

+ +
+

+ {result && formatEstimate(result.totalInclVat)} +

+

inkl. moms

+
+ +
+

Inkluderet i prisen:

+
    + {result?.includeInsulation && ( +
  • + + Isolering ({result.insulationThickness} cm) +
  • + )} + {result?.includeFloorHeating && ( +
  • + + Gulvvarme syntetisk net + Ø16 PEX (excl. tilslutning) +
  • + )} + {result?.includeCompound && ( +
  • + + Flydespartel (støbning) +
  • + )} +
  • + + Transport til {customerData?.postalCode} +
  • +
+
+ +

+ *Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete forhold +

+ +
+ + + +
+
+
+ )} +
+
+ + {/* Features Section */} +
+
+
+
+ Dansk Kvalitet
- {/* Results */} - {calculationResult && ( -
- {!showAdminMode ? ( -
-

Dit prisoverslag

-

- {formatEstimate(calculationResult.totalInclVat)} -

-

inkl. moms

-

- *Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete forhold -

- -
- ) : ( - - )} -
- )} +
+ Byg Garanti +
+ +
+ Tilfredshed & Service +
+
- {/* Footer */} -
-

Foam King Gulve · Asnæs · CVR: 12345678

-

- Vi dækker Sjælland, Lolland-Falster og Fyn + {/* Coverage Section */} +

+
+

Vi dækker hele Østdanmark

+

+ Sjælland · København · Nordsjælland · Lolland-Falster · Fyn

-
-
+ +
+ + + {/* Footer */} +
+
+
+
+ Foam King +
+

Foam King ApS · CVR: 44 48 54 51

+

+ + Søgårdsvej 7, 4550 Asnæs +

+
+
+

+ © {new Date().getFullYear()} Foam King. Alle rettigheder forbeholdes. +

+
+
+
) -} \ No newline at end of file +} diff --git a/app/tilbud/[slug]/page.tsx b/app/tilbud/[slug]/page.tsx new file mode 100644 index 0000000..8b30dc2 --- /dev/null +++ b/app/tilbud/[slug]/page.tsx @@ -0,0 +1,207 @@ +import { notFound } from "next/navigation" +import Image from "next/image" +import Link from "next/link" +import { getQuoteBySlug } from "@/lib/db" +import { calculatePrice, formatEstimate } from "@/lib/calculations" +import { getDistance } from "@/lib/distance" +import { CalculationDetailsView } from "@/components/calculator/calculation-details" +import { ArrowLeft, CheckCircle2, Calendar } from "lucide-react" +import { FLOORING_TYPES } from "@/lib/constants" + +interface PageProps { + params: Promise<{ slug: string }> +} + +export default async function TilbudPage({ params }: PageProps) { + const { slug } = await params + const quote = getQuoteBySlug(slug) + + if (!quote) { + notFound() + } + + // Recalculate the price based on stored inputs + const distance = getDistance(quote.postalCode) + const calculation = calculatePrice({ + area: quote.area, + height: quote.height, + postalCode: quote.postalCode, + distance, + includeInsulation: true, + includeFloorHeating: quote.includeFloorHeating, + includeCompound: true, + flooringType: quote.flooringType as keyof typeof FLOORING_TYPES, + }) + + const createdDate = new Date(quote.createdAt).toLocaleDateString("da-DK", { + day: "numeric", + month: "long", + year: "numeric", + }) + + return ( +
+ {/* Header */} +
+
+ + Foam King + +
Tilbud #{quote.id}
+
+
+ +
+ + + Tilbage til forsiden + + + {/* Quote Header */} +
+
+
+

Prisoverslag

+

+ + Oprettet {createdDate} +

+
+
+ + {/* Customer Info */} +
+

Kundeoplysninger

+
+
+
Navn:
+
{quote.customerName}
+
+ +
+
Telefon:
+
+ + {quote.customerPhone} + +
+
+
+
Adresse:
+
+ {quote.postalCode} + {quote.address ? `, ${quote.address}` : ""} +
+
+
+ {quote.remarks && ( +
+
Bemærkninger:
+

{quote.remarks}

+
+ )} +
+ + {/* Price Box */} +
+

+ {formatEstimate(calculation.totalInclVat)} +

+

inkl. moms

+
+ + {/* Project Details */} +
+
+

Projektdetaljer

+
+
+
Areal:
+
{quote.area} m²
+
+
+
Gulvhøjde:
+
{quote.height} cm
+
+
+
Placering:
+
+ {quote.postalCode} + {quote.address ? `, ${quote.address}` : ""} +
+
+
+
Gulvbelægning:
+
+ {FLOORING_TYPES[quote.flooringType as keyof typeof FLOORING_TYPES]?.name || + quote.flooringType} +
+
+
+
+ +
+

Inkluderet i prisen

+
    +
  • + + Isolering ({calculation.insulationThickness} cm) +
  • + {quote.includeFloorHeating && ( +
  • + + Gulvvarme syntetisk net + Ø16 PEX (excl. tilslutning) +
  • + )} +
  • + + Flydespartel (støbning) +
  • +
  • + + Transport +
  • +
+
+
+ +

+ *Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete forhold +

+
+ + {/* Detailed Calculation */} +
+

Detaljeret prisberegning

+ +
+
+ + {/* Footer */} +
+
+

Foam King ApS · Søgårdsvej 7, 4550 Asnæs · CVR: 44 48 54 51

+
+
+
+ ) +} diff --git a/components/calculator/calculation-details.tsx b/components/calculator/calculation-details.tsx index 91e75c6..c8533fa 100644 --- a/components/calculator/calculation-details.tsx +++ b/components/calculator/calculation-details.tsx @@ -1,19 +1,19 @@ +import { AlertTriangle, CheckCircle, Check, X } from "lucide-react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { formatPrice, type CalculationDetails } from "@/lib/calculations" -import { PRICES, CONSTRAINTS } from "@/lib/constants" +import { PRICES, CONSTRAINTS, FLOORING_TYPES } from "@/lib/constants" interface CalculationDetailsProps { details: CalculationDetails + distanceSource?: "openrouteservice" | "table" | null } -export function CalculationDetailsView({ details }: CalculationDetailsProps) { +export function CalculationDetailsView({ details, distanceSource }: CalculationDetailsProps) { return ( Detaljeret Prisberegning - - Komplet oversigt over alle delpriser og beregninger - + Komplet oversigt over alle delpriser og beregninger {/* Input Values */} @@ -39,13 +39,68 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) { + {/* Selected Components */} +
+

Valgte komponenter

+
+
+ + {details.includeInsulation ? ( + + ) : ( + + )} + Isolering: + + + {details.includeInsulation ? "Inkluderet" : "Fravalgt"} + +
+
+ + {details.includeFloorHeating ? ( + + ) : ( + + )} + Gulvvarme: + + + {details.includeFloorHeating ? "Inkluderet" : "Fravalgt"} + +
+
+ + {details.includeCompound ? ( + + ) : ( + + )} + Gulvstøbning: + + + {details.includeCompound ? "Inkluderet" : "Fravalgt"} + +
+ {details.includeCompound && ( +
+ Gulvbelægning: + {FLOORING_TYPES[details.flooringType]?.name || details.flooringType} +
+ )} +
+
+ {/* Calculated Values */}

Beregnede værdier

Isoleringstykkelse: - {details.insulationThickness} cm ({details.height} - {CONSTRAINTS.CONCRETE_THICKNESS} cm beton) + + {details.insulationThickness} cm ({details.height} -{" "} + {CONSTRAINTS.CONCRETE_THICKNESS} cm beton) +
Isoleringsvolumen: @@ -53,7 +108,10 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
Spartelvægt: - {details.compoundWeight.toLocaleString("da-DK")} kg ({details.area} m² × {PRICES.COMPOUND_WEIGHT_PER_M2} kg/m²) + + {details.compoundWeight.toLocaleString("da-DK")} kg ({details.area} m² ×{" "} + {PRICES.COMPOUND_WEIGHT_PER_M2} kg/m²) +
@@ -62,33 +120,61 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {

Komponent priser

-
+
- Isolering {details.insulationThickness > 0 ? `(${details.insulationVolume.toFixed(2)} m³ × ${formatPrice(PRICES.INSULATION_TOTAL)}/m³)` : "(simpel arbejdsløn)"}: + Isolering{" "} + {details.includeInsulation + ? details.insulationThickness > 0 + ? `(${details.insulationVolume.toFixed(2)} m³ × ${formatPrice(PRICES.INSULATION_TOTAL_PER_M3)}/m³)` + : "(simpel arbejdsløn)" + : "(fravalgt)"} + : {formatPrice(details.insulation)}
-
+
- Gulvvarme ({details.area} m² × {formatPrice(PRICES.FLOOR_HEATING_TOTAL)}/m²): + Gulvvarme{" "} + {details.includeFloorHeating + ? `(${details.area} m² × ${formatPrice(PRICES.FLOOR_HEATING_TOTAL)}/m²)` + : "(fravalgt)"} + : {formatPrice(details.floorHeating)}
-
+
- Syntetisk net ({details.area} m² × {formatPrice(PRICES.SYNTHETIC_NET_TOTAL)}/m²): + Syntetisk net{" "} + {details.includeFloorHeating + ? `(${details.area} m² × ${formatPrice(PRICES.SYNTHETIC_NET_TOTAL)}/m²)` + : "(fravalgt)"} + : {formatPrice(details.syntheticNet)}
-
+
- Flydespartel ({details.area} m² × {formatPrice(PRICES.SELF_LEVELING_COMPOUND)}/m²): + Flydespartel{" "} + {details.includeCompound + ? `(${details.area} m² × ${formatPrice(PRICES.SELF_LEVELING_COMPOUND)}/m²${FLOORING_TYPES[details.flooringType]?.compoundMultiplier > 1 ? " +28%" : ""})` + : "(fravalgt)"} + : {formatPrice(details.selfLevelingCompound)}
-
+
- Pumpebil-tillæg ({details.compoundWeight.toLocaleString("da-DK")} kg): + Pumpebil-tillæg{" "} + {details.includeCompound + ? `(${details.compoundWeight.toLocaleString("da-DK")} kg)` + : "(fravalgt)"} + : {formatPrice(details.pumpTruckFee)}
@@ -142,6 +228,27 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) { {formatPrice(details.bridgeFee)}
)} + {distanceSource && ( +
+ {distanceSource === "openrouteservice" ? ( + <> + + Præcis afstand via OpenRouteService + + ) : ( + <> + + Præcis afstandsberegning ikke mulig - overslag brugt + + )} +
+ )}
@@ -166,4 +273,4 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) { ) -} \ No newline at end of file +} diff --git a/components/calculator/calculator-form.tsx b/components/calculator/calculator-form.tsx index 2f0f671..87fdfdd 100644 --- a/components/calculator/calculator-form.tsx +++ b/components/calculator/calculator-form.tsx @@ -1,29 +1,41 @@ "use client" import { useState } from "react" -import { useForm } from "react-hook-form" +import { useForm, Controller } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import * as z from "zod" -import { Calculator, Loader2 } from "lucide-react" +import { Calculator, Loader2, Thermometer, Layers, PaintBucket } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { CONSTRAINTS } from "@/lib/constants" -import { validateDanishPostalCode, isInCoverageArea, getDistance } from "@/lib/distance" -import { calculatePrice, formatEstimate, type CalculationDetails } from "@/lib/calculations" +import { Switch } from "@/components/ui/switch" +import { Slider } from "@/components/ui/slider" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Progress } from "@/components/ui/progress" +import { CONSTRAINTS, FLOORING_TYPES, type FlooringType } from "@/lib/constants" +import { validateDanishPostalCode, getDistance } from "@/lib/distance" +import { calculatePrice, type CalculationDetails } from "@/lib/calculations" const formSchema = z.object({ - name: z.string().min(2, "Navn skal være mindst 2 tegn"), + name: z.string().refine((val) => { + const parts = val.trim().split(/\s+/) + return parts.length >= 2 && parts[0].length >= 3 && parts[1].length >= 3 + }, "Indtast fornavn og efternavn (mindst 3 tegn hver)"), email: z.string().email("Ugyldig email"), phone: z.string().regex(/^\d{8}$/, "Telefonnummer skal være 8 cifre"), postalCode: z .string() .length(4, "Postnummer skal være 4 cifre") - .refine(validateDanishPostalCode, "Ugyldigt dansk postnummer") - .refine(isInCoverageArea, "Beklager, vi dækker ikke dette område"), + .refine(validateDanishPostalCode, "Ugyldigt dansk postnummer"), address: z.string().optional(), area: z.coerce .number() @@ -34,183 +46,424 @@ const formSchema = z.object({ .min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_HEIGHT} cm`) .max(CONSTRAINTS.MAX_HEIGHT, `Maximum højde er ${CONSTRAINTS.MAX_HEIGHT} cm`), remarks: z.string().optional(), + includeInsulation: z.boolean(), + includeFloorHeating: z.boolean(), + includeCompound: z.boolean(), + flooringType: z.string(), }) type FormData = z.infer interface CalculatorFormProps { - onCalculation: (result: CalculationDetails, formData?: FormData) => void + onCalculation: ( + result: CalculationDetails, + formData?: FormData, + distanceSource?: "openrouteservice" | "table" + ) => void showDetails?: boolean } +interface CalculationProgress { + step: string + progress: number +} + export function CalculatorForm({ onCalculation, showDetails = false }: CalculatorFormProps) { const [isCalculating, setIsCalculating] = useState(false) + const [calculationProgress, setCalculationProgress] = useState(null) const [result, setResult] = useState(null) + const [distanceSource, setDistanceSource] = useState<"openrouteservice" | "table" | null>(null) const { register, handleSubmit, formState: { errors }, watch, + control, } = useForm({ resolver: zodResolver(formSchema), + defaultValues: { + name: "", + email: "", + phone: "", + postalCode: "", + address: "", + area: 50, + height: 15, + remarks: "", + includeInsulation: true, + includeFloorHeating: true, + includeCompound: true, + flooringType: "STANDARD", + }, }) + const watchedIncludeCompound = watch("includeCompound") + const onSubmit = async (data: FormData) => { setIsCalculating(true) - + setDistanceSource(null) + setCalculationProgress({ step: "Finder din adresse...", progress: 20 }) + try { - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 500)) - - const distance = getDistance(data.postalCode) + let distance: number + let source: "openrouteservice" | "table" = "table" + + try { + setCalculationProgress({ step: "Beregner afstand...", progress: 40 }) + const params = new URLSearchParams({ + postalCode: data.postalCode, + ...(data.address && { address: data.address }), + }) + const distanceResponse = await fetch(`/api/distance?${params}`) + const distanceData = await distanceResponse.json() + distance = distanceData.distance + source = distanceData.source + } catch { + distance = getDistance(data.postalCode) + source = "table" + } + + setDistanceSource(source) + setCalculationProgress({ step: "Beregner pris...", progress: 70 }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + const calculationResult = calculatePrice({ area: data.area, height: data.height, postalCode: data.postalCode, distance, + includeInsulation: data.includeInsulation, + includeFloorHeating: data.includeFloorHeating, + includeCompound: data.includeCompound, + flooringType: data.flooringType as FlooringType, }) - + + setCalculationProgress({ step: "Færdig!", progress: 100 }) + await new Promise((resolve) => setTimeout(resolve, 300)) + setResult(calculationResult) - onCalculation(calculationResult, data) + onCalculation(calculationResult, data, source) } finally { setIsCalculating(false) + setCalculationProgress(null) } } return ( - - - - + + + +
+ +
Prisberegner
- + Få et hurtigt overslag på din nye gulvløsning
- -
-
-
- - - {errors.name && ( -

{errors.name.message}

- )} + + + {/* Contact Section */} +
+

+ Kontaktoplysninger +

+
+
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + + {errors.email &&

{errors.email.message}

} +
+
+ + + {errors.phone &&

{errors.phone.message}

} +
+
+ + + {errors.postalCode && ( +

{errors.postalCode.message}

+ )} +
+
+ + +
- -
- - - {errors.email && ( -

{errors.email.message}

- )} +
+ + {/* Floor Dimensions Section */} +
+

+ Gulvmål +

+
+ {/* Area Slider */} +
+
+ +
+ ( + field.onChange(Number(e.target.value))} + className="h-8 w-16 border-0 bg-transparent p-0 text-right text-lg font-semibold" + min={CONSTRAINTS.MIN_AREA} + max={CONSTRAINTS.MAX_AREA} + /> + )} + /> + +
+
+ ( + field.onChange(value)} + className="py-2" + /> + )} + /> +
+ {CONSTRAINTS.MIN_AREA} m² + {CONSTRAINTS.MAX_AREA} m² +
+ {errors.area &&

{errors.area.message}

} +
+ + {/* Height Slider */} +
+
+ +
+ ( + field.onChange(Number(e.target.value))} + className="h-8 w-16 border-0 bg-transparent p-0 text-right text-lg font-semibold" + min={CONSTRAINTS.MIN_HEIGHT} + max={CONSTRAINTS.MAX_HEIGHT} + /> + )} + /> + cm +
+
+ ( + field.onChange(value)} + className="py-2" + /> + )} + /> +
+ {CONSTRAINTS.MIN_HEIGHT} cm + {CONSTRAINTS.MAX_HEIGHT} cm +
+ {errors.height && ( +

{errors.height.message}

+ )} +
- -
- - + + {/* Components Section */} +
+

+ Vælg komponenter +

+
+ {/* Insulation Toggle */} + ( + + )} /> - {errors.phone && ( -

{errors.phone.message}

- )} -
- -
- - ( + + )} /> - {errors.postalCode && ( -

{errors.postalCode.message}

- )} -
-
- -
- - -
- -
-
- - ( + + )} /> - {errors.area && ( -

{errors.area.message}

- )}
- -
- - + + {/* Flooring Type Section */} + {watchedIncludeCompound && ( +
+

+ Gulvbelægning +

+ ( +
+ {Object.entries(FLOORING_TYPES).map(([key, type]) => ( + + ))} +
+ )} /> - {errors.height && ( -

{errors.height.message}

- )} -
-
- -
- +
+ )} + + {/* Remarks Section */} +
+

+ Bemærkninger (valgfrit) +