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 <noreply@anthropic.com>
This commit is contained in:
mikl0s 2026-02-22 20:59:11 +00:00
parent 7d2bbae1c6
commit 3ebb63dc6c
67 changed files with 14508 additions and 790 deletions

11
.env.example Normal file
View file

@ -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

3
.gitignore vendored
View file

@ -33,3 +33,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# database
/data/

View file

@ -9,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Technology Stack ## Technology Stack
**Planned stack:** Next.js + shadcn/ui + Tailwind CSS **Planned stack:** Next.js + shadcn/ui + Tailwind CSS
- **Next.js**: For server-side rendering and API routes - **Next.js**: For server-side rendering and API routes
- **shadcn/ui**: For accessible, customizable components - **shadcn/ui**: For accessible, customizable components
- **Tailwind CSS**: For styling - **Tailwind CSS**: For styling
@ -17,6 +18,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status ## Project Status
Currently in **documentation phase** - no implementation exists yet. Key documentation files: Currently in **documentation phase** - no implementation exists yet. Key documentation files:
- `docs/projektplan.md` - Complete project plan and requirements - `docs/projektplan.md` - Complete project plan and requirements
- `docs/prisbeskrivelse.md` - Detailed pricing logic and formulas - `docs/prisbeskrivelse.md` - Detailed pricing logic and formulas
- `docs/shadcn theme.txt` - Custom shadcn theme (blue/orange color scheme) - `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 ## Core Requirements
### Input Form Fields ### Input Form Fields
- Name (required, min 2 chars) - Name (required, min 2 chars)
- Email (required, valid format) - Email (required, valid format)
- Phone (required, 8 digits) - Phone (required, 8 digits)
@ -35,6 +38,7 @@ Currently in **documentation phase** - no implementation exists yet. Key documen
- Remarks (optional) - Remarks (optional)
### Price Calculation Components ### Price Calculation Components
1. **Insulation**: 3,730 kr/m³ (subtract 5cm from height for concrete) 1. **Insulation**: 3,730 kr/m³ (subtract 5cm from height for concrete)
2. **Floor heating**: 205 kr/m² (always included) 2. **Floor heating**: 205 kr/m² (always included)
3. **Synthetic mesh**: 49 kr/m² (always included) 3. **Synthetic mesh**: 49 kr/m² (always included)
@ -46,18 +50,22 @@ Currently in **documentation phase** - no implementation exists yet. Key documen
9. **VAT**: 25% 9. **VAT**: 25%
### Output ### Output
- Price estimate with ±10,000 kr variation - Price estimate with ±10,000 kr variation
- Option to request binding quote (sends email to `info@foamking.dk`) - Option to request binding quote (sends email to `info@foamking.dk`)
## Implementation Guidelines ## Implementation Guidelines
### Distance Calculation ### Distance Calculation
Three options for calculating transport distance: Three options for calculating transport distance:
1. **Postal code table** (recommended for MVP) 1. **Postal code table** (recommended for MVP)
2. **OpenRouteService API** (free up to 2,000 requests/day) 2. **OpenRouteService API** (free up to 2,000 requests/day)
3. **Google Maps API** (paid) 3. **Google Maps API** (paid)
### Coverage Areas ### Coverage Areas
- 4000-4999: West Zealand - 4000-4999: West Zealand
- 2000-2999: Copenhagen - 2000-2999: Copenhagen
- 3000-3999: North Zealand - 3000-3999: North Zealand
@ -67,6 +75,7 @@ Three options for calculating transport distance:
### Development Commands ### Development Commands
Since this is a new project, typical Next.js commands will apply once initialized: Since this is a new project, typical Next.js commands will apply once initialized:
```bash ```bash
# Initialize project # Initialize project
npx create-next-app@latest . --typescript --tailwind --app npx create-next-app@latest . --typescript --tailwind --app
@ -103,6 +112,7 @@ npm run typecheck
### Testing Scenarios ### Testing Scenarios
Test with examples from `prisbeskrivelse.md`: Test with examples from `prisbeskrivelse.md`:
- 50 m², 20 cm height, 2100 Copenhagen → ~95,500 kr - 50 m², 20 cm height, 2100 Copenhagen → ~95,500 kr
- Edge cases: minimum (25 m²) and maximum (300 m²) areas - Edge cases: minimum (25 m²) and maximum (300 m²) areas
- Different pump truck weight thresholds - Different pump truck weight thresholds

View file

@ -112,6 +112,7 @@ Projektet bruger en forudberegnet tabel over afstande fra 4550 Asnæs til danske
## 📱 Admin Mode ## 📱 Admin Mode
Klik på "Vis detaljer" for at se den fulde prissopgørelse med: Klik på "Vis detaljer" for at se den fulde prissopgørelse med:
- Alle priskomponenter - Alle priskomponenter
- Beregningslogik step-by-step - Beregningslogik step-by-step
- Isolerings- og transportdetaljer - Isolerings- og transportdetaljer
@ -120,6 +121,7 @@ Klik på "Vis detaljer" for at se den fulde prissopgørelse med:
## 🧪 Testing ## 🧪 Testing
Test med eksempel fra dokumentationen: Test med eksempel fra dokumentationen:
- **Areal**: 50 m² - **Areal**: 50 m²
- **Højde**: 20 cm - **Højde**: 20 cm
- **Postnummer**: 2100 (København) - **Postnummer**: 2100 (København)

157
app/admin/page.tsx Normal file
View file

@ -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<CalculationDetails | null>(null)
const [showAdminMode, setShowAdminMode] = useState(true)
const [isRequestingQuote, setIsRequestingQuote] = useState(false)
const [customerInfo, setCustomerInfo] = useState<any>(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 (
<main className="min-h-screen bg-gradient-to-b from-background to-muted/20">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8 text-center">
<div className="mb-4 flex justify-center">
<Image
src="/foam-king-logo.png"
alt="Foam King Gulve"
width={200}
height={80}
priority
className="h-20 w-auto"
/>
</div>
<h1 className="text-3xl font-bold">Foam King Gulve - Admin</h1>
<p className="mt-2 text-lg text-muted-foreground">
Detaljeret prisberegning til internt brug
</p>
</div>
{/* Admin Mode Toggle */}
<div className="mb-4 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdminMode(!showAdminMode)}
className="gap-2"
>
{showAdminMode ? (
<>
<EyeOff className="h-4 w-4" />
Skjul detaljer
</>
) : (
<>
<Eye className="h-4 w-4" />
Vis detaljer
</>
)}
</Button>
</div>
{/* Calculator */}
<div className="mx-auto max-w-6xl">
<div className="grid gap-8 lg:grid-cols-2">
<div className="flex justify-center">
<CalculatorForm onCalculation={handleCalculation} showDetails={showAdminMode} />
</div>
{/* Results */}
<div className="space-y-6">
{!showAdminMode ? (
<div className="rounded-xl bg-card p-8 text-center shadow">
<h2 className="mb-4 text-xl font-semibold">Dit prisoverslag</h2>
{calculationResult ? (
<>
<p className="text-4xl font-bold text-primary">
{formatEstimate(calculationResult.totalInclVat)}
</p>
<p className="mt-2 text-sm text-muted-foreground">inkl. moms</p>
<p className="mt-4 text-sm text-muted-foreground">
*Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete
forhold
</p>
<Button
onClick={handleQuoteRequest}
size="lg"
className="mt-6 gap-2"
disabled={isRequestingQuote}
>
<Send className="h-4 w-4" />
Anmod om bindende tilbud
</Button>
</>
) : (
<p className="text-2xl text-muted-foreground">
Udfyld formularen og klik &quot;Beregn pris&quot;
</p>
)}
</div>
) : calculationResult ? (
<CalculationDetailsView
details={calculationResult}
distanceSource={distanceSource}
/>
) : (
<div className="rounded-xl bg-card p-8 text-center shadow">
<h2 className="mb-4 text-xl font-semibold">Detaljeret prisberegning</h2>
<p className="text-muted-foreground">
Udfyld formularen og klik &quot;Beregn pris&quot;
</p>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<footer className="mt-16 border-t pt-8 text-center text-sm text-muted-foreground">
<p>Foam King Gulve · Asnæs · CVR: 44 48 54 51</p>
<p className="mt-1">Vi dækker Sjælland, Lolland-Falster og Fyn</p>
</footer>
</div>
</main>
)
}

View file

@ -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 })
}
}

View file

@ -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 })
}
}

View file

@ -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 })
}
}

145
app/api/distance/route.ts Normal file
View file

@ -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<string, string> = {
"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<number | null> {
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,
})
}

View file

@ -1,116 +1,303 @@
import { NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { z } from "zod" import nodemailer from "nodemailer"
import { formatPrice } from "@/lib/calculations" import { formatPrice, type CalculationDetails } from "@/lib/calculations"
import type { CalculationDetails } from "@/lib/calculations" import { FLOORING_TYPES } from "@/lib/constants"
import { saveQuote } from "@/lib/db"
const quoteRequestSchema = z.object({ interface QuoteRequestBody {
customerInfo: z.object({ customerInfo: {
name: z.string(), name: string
email: z.string().email(), email: string
phone: z.string(), phone: string
postalCode: z.string(), postalCode: string
address: z.string().optional(), address?: string
remarks: z.string().optional(), remarks?: string
}), }
calculationDetails: z.object({ calculationDetails: CalculationDetails
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<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 `
<!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 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 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 &rarr;
</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 beregner.foamking.dk<br>
Tidspunkt: ${new Date().toLocaleString("da-DK", { timeZone: "Europe/Copenhagen" })}
</p>
</div>
</body>
</html>
`
}
export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body: QuoteRequestBody = await request.json()
const { customerInfo, calculationDetails } = quoteRequestSchema.parse(body) const { customerInfo, calculationDetails } = body
// Format email content if (!customerInfo || !calculationDetails) {
const emailContent = formatEmailContent(customerInfo, calculationDetails) return NextResponse.json({ error: "Manglende data" }, { status: 400 })
}
// In production, you would send this via an email service // Save quote to database
// For now, we'll just log it and return success const { id: quoteId, slug } = saveQuote({
console.log("Quote request email:", emailContent) 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,
})
// TODO: Implement actual email sending using a service like: // Generate the quote link
// - SendGrid const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://beregner.foamking.dk"
// - AWS SES const quoteLink = `${baseUrl}/tilbud/${slug}`
// - Resend
// - Nodemailer with SMTP 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}`,
html: formatFoamKingEmail(customerInfo, calculationDetails, quoteLink, quoteId),
})
return NextResponse.json({ return NextResponse.json({
success: true, 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) { } catch (error) {
console.error("Quote request error:", error) console.error("Quote request error:", error)
return NextResponse.json({ error: "Der opstod en fejl. Prøv igen senere." }, { status: 500 })
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 }
)
} }
} }
function formatEmailContent(
customerInfo: z.infer<typeof quoteRequestSchema>["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()
}

48
app/api/quotes/route.ts Normal file
View file

@ -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 })
}
}

View file

@ -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",
},
})
}
}

266
app/dashboard/page.tsx Normal file
View file

@ -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<StoredQuote[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
// Dialogs
const [rejectQuote, setRejectQuote] = useState<StoredQuote | null>(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 (
<main className="flex min-h-screen items-center justify-center bg-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</main>
)
}
return (
<main className="min-h-screen bg-muted/30">
{/* Header */}
<header className="sticky top-0 z-10 border-b bg-white">
<div className="container mx-auto flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-4">
<Link href="/">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={120}
height={48}
className="h-10 w-auto"
/>
</Link>
<h1 className="hidden text-lg font-semibold sm:block">Dashboard</h1>
</div>
<div className="flex items-center gap-2">
<Link href="/historik">
<Button variant="ghost" size="sm">
<List className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Historik</span>
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Log ud</span>
</Button>
</div>
</div>
</header>
{/* Main content */}
<div className="container mx-auto px-4 py-6">
{/* Search */}
<div className="mb-6 max-w-md">
<SearchFilter value={search} onChange={setSearch} />
</div>
{/* Kanban board */}
<KanbanBoard
quotes={activeQuotes}
onStatusChange={handleStatusChange}
onReject={handleRejectClick}
rejectedCount={rejectedQuotes.length}
onShowRejected={() => setShowRejected(true)}
/>
{/* Empty state */}
{quotes.length === 0 && !loading && (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Ingen tilbud endnu. Når kunder anmoder om tilbud, vil de vises her.
</p>
</div>
)}
</div>
{/* Reject confirmation dialog */}
<Dialog open={!!rejectQuote} onOpenChange={() => setRejectQuote(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Afvis tilbud?</DialogTitle>
<DialogDescription>
Er du sikker du vil afvise tilbuddet til{" "}
<span className="font-medium text-foreground">{rejectQuote?.customerName}</span>?
</DialogDescription>
</DialogHeader>
{rejectQuote && (
<div className="rounded-lg bg-muted/50 p-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Tilbud #{rejectQuote.id}</span>
<span className="font-medium">
{rejectQuote.totalInclVat
? formatPrice(Math.round(rejectQuote.totalInclVat))
: "—"}
</span>
</div>
<div className="text-muted-foreground">
{rejectQuote.postalCode} · {rejectQuote.area} m²
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setRejectQuote(null)}>
Annuller
</Button>
<Button variant="destructive" onClick={confirmReject}>
Afvis tilbud
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rejected quotes modal */}
<Dialog open={showRejected} onOpenChange={setShowRejected}>
<DialogContent className="max-h-[80vh] overflow-hidden sm:max-w-lg">
<DialogHeader>
<DialogTitle>Afviste tilbud ({rejectedQuotes.length})</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] space-y-2 overflow-y-auto pr-2">
{rejectedQuotes.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Ingen afviste tilbud</p>
) : (
rejectedQuotes.map((quote) => {
const slug = `${quote.postalCode}-${quote.id}`
return (
<div
key={quote.id}
className="flex items-center gap-3 rounded-lg border bg-white p-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{quote.customerName}</span>
<a
href={`/tilbud/${slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<div className="text-sm text-muted-foreground">
{quote.postalCode} · {quote.area} m² ·{" "}
{quote.totalInclVat ? formatPrice(Math.round(quote.totalInclVat)) : "—"}
</div>
</div>
<Button
size="sm"
variant="outline"
className="flex-shrink-0"
onClick={() => {
handleStatusChange(quote.id, "new")
}}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Gendan
</Button>
</div>
)
})
)}
</div>
</DialogContent>
</Dialog>
</main>
)
}

View file

@ -4,61 +4,48 @@
@layer base { @layer base {
:root { :root {
--background: oklch(0.985 0.0014 39.68); --background: 39.68 0.14% 98.5%;
--foreground: oklch(0.2683 0.0043 41.05); --foreground: 41.05 1.6% 16.5%;
--card: var(--color-white); --card: 0 0% 100%;
--card-foreground: oklch(0.2683 0.0043 41.05); --card-foreground: 41.05 1.6% 16.5%;
--popover: var(--color-white); --popover: 0 0% 100%;
--popover-foreground: oklch(0.2683 0.0043 41.05); --popover-foreground: 41.05 1.6% 16.5%;
--primary: oklch(0.8651 0.1153 207.08); --primary: 207.08 60% 75%;
--primary-foreground: var(--color-black); --primary-foreground: 0 0% 0%;
--secondary: oklch(0.72 0.1613 29.29); --secondary: 29.29 70% 60%;
--secondary-foreground: var(--color-black); --secondary-foreground: 0 0% 0%;
--muted: oklch(0.9674 0.0029 40.41); --muted: 40.41 3% 96%;
--muted-foreground: oklch(0.4426 0.0055 43.48); --muted-foreground: 43.48 3% 35%;
--accent: oklch(0.9674 0.0029 40.41); --accent: 40.41 3% 96%;
--accent-foreground: oklch(0.2683 0.0043 41.05); --accent-foreground: 41.05 1.6% 16.5%;
--destructive: oklch(0.577 0.245 27.325); --destructive: 27.325 70% 45%;
--destructive-foreground: oklch(0.985 0.0014 39.68); --destructive-foreground: 0 0% 100%;
--border: oklch(0.9227 0.0041 40.62); --border: 40.62 2% 90%;
--input: oklch(0.8693 0.0046 41.1); --input: 41.1 2% 85%;
--ring: oklch(0.8651 0.1153 207.08); --ring: 207.08 60% 75%;
--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);
--radius: 1rem; --radius: 1rem;
--color-white: #ffffff;
--color-black: #000000;
} }
.dark { .dark {
--background: oklch(0.1465 0.0038 39.55); --background: 39.55 3% 9%;
--foreground: oklch(0.9227 0.0041 40.62); --foreground: 40.62 2% 90%;
--card: oklch(0.213 0.0041 40.86); --card: 40.86 2% 13%;
--card-foreground: oklch(0.9227 0.0041 40.62); --card-foreground: 40.62 2% 90%;
--popover: oklch(0.213 0.0041 40.86); --popover: 40.86 2% 13%;
--popover-foreground: oklch(0.9227 0.0041 40.62); --popover-foreground: 40.62 2% 90%;
--primary: oklch(0.8651 0.1153 207.08); --primary: 207.08 60% 75%;
--primary-foreground: var(--color-black); --primary-foreground: 0 0% 0%;
--secondary: oklch(0.72 0.1613 29.29); --secondary: 29.29 70% 60%;
--secondary-foreground: var(--color-black); --secondary-foreground: 0 0% 0%;
--muted: oklch(0.2683 0.0043 41.05); --muted: 41.05 1.6% 16.5%;
--muted-foreground: oklch(0.8693 0.0046 41.1); --muted-foreground: 41.1 2% 85%;
--accent: oklch(0.2683 0.0043 41.05); --accent: 41.05 1.6% 16.5%;
--accent-foreground: oklch(0.9227 0.0041 40.62); --accent-foreground: 40.62 2% 90%;
--destructive: oklch(0.704 0.191 22.216); --destructive: 22.216 65% 55%;
--destructive-foreground: oklch(0.985 0.0014 39.68); --destructive-foreground: 0 0% 100%;
--border: oklch(0.2683 0.0043 41.05); --border: 41.05 1.6% 16.5%;
--input: oklch(0.3732 0.0051 42.7); --input: 42.7 3% 23%;
--ring: oklch(0.8651 0.1153 207.08); --ring: 207.08 60% 75%;
--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);
} }
} }

222
app/historik/page.tsx Normal file
View file

@ -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<QuoteStatus, { label: string; className: string }> = {
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<StoredQuote[]>([])
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 (
<main className="flex min-h-screen items-center justify-center bg-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</main>
)
}
return (
<main className="min-h-screen bg-muted/30">
{/* Header */}
<header className="sticky top-0 z-10 border-b bg-white">
<div className="container mx-auto flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-4">
<Link href="/">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={120}
height={48}
className="h-10 w-auto"
/>
</Link>
<h1 className="hidden text-lg font-semibold sm:block">Historik</h1>
</div>
<div className="flex items-center gap-2">
<Link href="/dashboard">
<Button variant="ghost" size="sm">
<LayoutDashboard className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Dashboard</span>
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Log ud</span>
</Button>
</div>
</div>
</header>
{/* Main content */}
<div className="container mx-auto px-4 py-6">
{/* Search and stats */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="max-w-md flex-1">
<SearchFilter value={search} onChange={setSearch} />
</div>
<div className="text-sm text-muted-foreground">
{filteredQuotes.length} tilbud
{search && ` (af ${quotes.length})`}
</div>
</div>
{/* Table */}
<div className="overflow-hidden rounded-xl border bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
#
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Kunde
</th>
<th className="hidden px-4 py-3 text-left text-sm font-medium text-muted-foreground md:table-cell">
Lokation
</th>
<th className="hidden px-4 py-3 text-left text-sm font-medium text-muted-foreground sm:table-cell">
Areal
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Pris
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Status
</th>
<th className="hidden px-4 py-3 text-left text-sm font-medium text-muted-foreground lg:table-cell">
Dato
</th>
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{filteredQuotes.map((quote) => {
const slug = `${quote.postalCode}-${quote.id}`
const status = STATUS_LABELS[quote.status]
return (
<tr key={quote.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 text-sm text-muted-foreground">{quote.id}</td>
<td className="px-4 py-3">
<div className="font-medium">{quote.customerName}</div>
<div className="text-sm text-muted-foreground">{quote.customerEmail}</div>
</td>
<td className="hidden px-4 py-3 text-sm md:table-cell">
{quote.postalCode}
{quote.address && (
<span className="text-muted-foreground">, {quote.address}</span>
)}
</td>
<td className="hidden px-4 py-3 text-sm sm:table-cell">{quote.area} m²</td>
<td className="px-4 py-3 font-medium">
{quote.totalInclVat ? formatPrice(Math.round(quote.totalInclVat)) : "—"}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${status.className}`}
>
{status.label}
</span>
</td>
<td className="hidden px-4 py-3 text-sm text-muted-foreground lg:table-cell">
{formatDate(quote.createdAt)}
</td>
<td className="px-4 py-3 text-right">
<a
href={`/tilbud/${slug}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
Åbn
<ExternalLink className="h-3 w-3" />
</a>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{filteredQuotes.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
{search ? "Ingen tilbud matcher søgningen" : "Ingen tilbud endnu"}
</div>
)}
</div>
</div>
</main>
)
}

BIN
app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -14,7 +14,8 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Foam King Gulve - Prisberegner", title: "Foam King Gulve - Prisberegner",
description: "Få et hurtigt prisoverslag på din nye gulvløsning med isolering, gulvvarme og støbning", description:
"Få et hurtigt prisoverslag på din nye gulvløsning med isolering, gulvvarme og støbning",
} }
export default function RootLayout({ export default function RootLayout({
@ -24,9 +25,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="da"> <html lang="da">
<body <body className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}>
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
{children} {children}
</body> </body>
</html> </html>

123
app/login/page.tsx Normal file
View file

@ -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 (
<main className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<div className="w-full max-w-sm">
<div className="rounded-2xl bg-white p-8 shadow-lg">
<div className="mb-8 text-center">
<Link href="/" className="inline-block">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={140}
height={56}
className="mx-auto h-12 w-auto"
/>
</Link>
<h1 className="mt-4 text-xl font-semibold">Log ind</h1>
<p className="mt-1 text-sm text-muted-foreground">Adgang til dashboard</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="din@email.dk"
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Adgangskode</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
/>
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
)}
<Button
type="submit"
className="h-11 w-full bg-secondary hover:bg-secondary/90"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Logger ind...
</>
) : (
"Log ind"
)}
</Button>
</form>
</div>
<p className="mt-6 text-center text-sm text-muted-foreground">
<Link href="/" className="hover:underline">
Tilbage til forsiden
</Link>
</p>
</div>
</main>
)
}

View file

@ -2,39 +2,56 @@
import { useState } from "react" import { useState } from "react"
import Image from "next/image" import Image from "next/image"
import { CalculatorForm } from "@/components/calculator/calculator-form" import { StepWizard } from "@/components/calculator/step-wizard"
import { CalculationDetailsView } from "@/components/calculator/calculation-details"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import type { CalculationDetails } from "@/lib/calculations" import { formatEstimate, type CalculationDetails } from "@/lib/calculations"
import { formatEstimate } from "@/lib/calculations" import {
import { Send, Eye, EyeOff } from "lucide-react" Phone,
Mail,
MapPin,
CheckCircle2,
ArrowRight,
RotateCcw,
Loader2,
} from "lucide-react"
export default function Home() { export default function Home() {
const [calculationResult, setCalculationResult] = useState<CalculationDetails | null>(null) const [result, setResult] = useState<CalculationDetails | null>(null)
const [showAdminMode, setShowAdminMode] = useState(false) const [customerData, setCustomerData] = useState<any>(null)
const [isRequestingQuote, setIsRequestingQuote] = useState(false) const [showResult, setShowResult] = useState(false)
const [customerInfo, setCustomerInfo] = useState<any>(null)
const handleCalculation = (result: CalculationDetails, formData?: any) => { const handleComplete = (calculationResult: CalculationDetails, formData: any) => {
setCalculationResult(result) setResult(calculationResult)
if (formData) { setCustomerData(formData)
setCustomerInfo(formData) setShowResult(true)
}
} }
const handleQuoteRequest = async () => { const handleReset = () => {
if (!calculationResult || !customerInfo) return setResult(null)
setCustomerData(null)
setShowResult(false)
}
setIsRequestingQuote(true) const [isRequesting, setIsRequesting] = useState(false)
const handleRequestQuote = async () => {
if (!result || !customerData) return
setIsRequesting(true)
try { try {
const response = await fetch("/api/quote-request", { const response = await fetch("/api/quote-request", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
body: JSON.stringify({ body: JSON.stringify({
customerInfo, customerInfo: {
calculationDetails: calculationResult, name: customerData.name,
email: customerData.email,
phone: customerData.phone,
postalCode: customerData.postalCode,
address: customerData.address,
remarks: customerData.remarks,
},
calculationDetails: result,
}), }),
}) })
@ -48,102 +65,271 @@ export default function Home() {
} catch (error) { } catch (error) {
alert("Der opstod en fejl. Prøv igen senere.") alert("Der opstod en fejl. Prøv igen senere.")
} finally { } finally {
setIsRequestingQuote(false) setIsRequesting(false)
} }
} }
return ( return (
<main className="min-h-screen bg-gradient-to-b from-background to-muted/20"> <main className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8"> {/* Hero Section */}
{/* Header */} <section className="relative flex min-h-[70vh] items-center justify-center overflow-hidden">
<div className="mb-8 text-center"> {/* Background Image */}
<div className="mb-4 flex justify-center"> <div className="absolute inset-0 z-0">
<Image <Image
src="/foam-king-logo.png" src="/gulv.jpeg"
alt="Foam King Gulve" alt="Smukt gulv i moderne hjem"
width={200} fill
height={80} className="object-cover"
priority priority
className="h-20 w-auto" />
/> <div className="absolute inset-0 bg-gradient-to-b from-black/60 via-black/40 to-black/70" />
</div>
<h1 className="text-3xl font-bold">Foam King Gulve</h1>
<p className="mt-2 text-lg text-muted-foreground">
Professionelle gulvløsninger med isolering, gulvvarme og støbning
</p>
</div> </div>
{/* Admin Mode Toggle */} {/* Hero Content */}
<div className="mb-4 flex justify-center"> <div className="container relative z-10 mx-auto px-4 text-center text-white">
<div className="mb-6">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={180}
height={72}
className="mx-auto h-16 w-auto brightness-0 invert"
priority
/>
</div>
<h1 className="mb-4 text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl">
Gulvarbejde i<br />
<span className="text-secondary">verdensklasse</span>
</h1>
<p className="mx-auto mb-8 max-w-2xl text-lg text-white/90 sm:text-xl">
Professionel udførelse af betongulve, gulvvarme og isolering. Vi leverer kvalitet der
holder i mange år fremover.
</p>
<div className="mb-8 flex flex-wrap justify-center gap-4">
<div className="flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 backdrop-blur-sm">
<CheckCircle2 className="h-5 w-5 text-secondary" />
<span>Stor erfaring</span>
</div>
<div className="flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 backdrop-blur-sm">
<CheckCircle2 className="h-5 w-5 text-secondary" />
<span>Byg Garanti</span>
</div>
<div className="flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 backdrop-blur-sm">
<CheckCircle2 className="h-5 w-5 text-secondary" />
<span>Gratis tilbud</span>
</div>
</div>
<Button <Button
variant="ghost" size="lg"
size="sm" className="h-14 bg-secondary px-8 text-lg text-secondary-foreground hover:bg-secondary/90"
onClick={() => setShowAdminMode(!showAdminMode)} onClick={() =>
className="gap-2" document.getElementById("calculator")?.scrollIntoView({ behavior: "smooth" })
}
> >
{showAdminMode ? ( dit prisoverslag
<> <ArrowRight className="ml-2 h-5 w-5" />
<EyeOff className="h-4 w-4" />
Skjul detaljer
</>
) : (
<>
<Eye className="h-4 w-4" />
Vis detaljer
</>
)}
</Button> </Button>
</div> </div>
{/* Calculator */} {/* Scroll Indicator */}
<div className="mx-auto max-w-6xl"> <div className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2 animate-bounce">
<div className="grid gap-8 lg:grid-cols-2"> <div className="flex h-12 w-8 items-start justify-center rounded-full border-2 border-white/50 pt-2">
<div className="flex justify-center"> <div className="h-3 w-1 rounded-full bg-white/70" />
<CalculatorForm </div>
onCalculation={handleCalculation} </div>
showDetails={showAdminMode} </section>
{/* Calculator Section */}
<section
id="calculator"
className="bg-gradient-to-b from-muted/50 to-background py-16 sm:py-24"
>
<div className="container mx-auto px-4">
<div className="mb-12 text-center">
<p className="mb-2 font-semibold text-secondary">Prisberegner</p>
<h2 className="mb-4 text-3xl font-bold sm:text-4xl"> dit personlige tilbud</h2>
<p className="mx-auto max-w-xl text-muted-foreground">
Besvar nogle spørgsmål, kan give dig den mest nøjagtige prisberegning.
<br />
Det tager kun 2 minutter.
</p>
</div>
{!showResult ? (
<StepWizard onComplete={handleComplete} />
) : (
/* Result Card */
<div className="mx-auto max-w-lg">
<div className="rounded-2xl bg-white p-8 text-center shadow-lg">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<h3 className="mb-2 text-2xl font-bold">Dit prisoverslag</h3>
<p className="mb-6 text-muted-foreground">
Baseret {result?.area} m² gulv i {customerData?.postalCode}
</p>
<div className="mb-6 rounded-xl bg-gradient-to-br from-primary/10 to-secondary/10 p-6">
<p className="mb-2 text-3xl font-bold text-primary sm:text-5xl">
{result && formatEstimate(result.totalInclVat)}
</p>
<p className="text-muted-foreground">inkl. moms</p>
</div>
<div className="mb-6 rounded-lg bg-muted/30 p-4 text-left text-sm">
<p className="mb-2 font-medium">Inkluderet i prisen:</p>
<ul className="space-y-1 text-muted-foreground">
{result?.includeInsulation && (
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Isolering ({result.insulationThickness} cm)
</li>
)}
{result?.includeFloorHeating && (
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Gulvvarme syntetisk net + Ø16 PEX (excl. tilslutning)
</li>
)}
{result?.includeCompound && (
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Flydespartel (støbning)
</li>
)}
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Transport til {customerData?.postalCode}
</li>
</ul>
</div>
<p className="mb-6 text-xs text-muted-foreground">
*Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete forhold
</p>
<div className="flex flex-col gap-3">
<Button
size="lg"
className="h-12 w-full bg-secondary text-secondary-foreground hover:bg-secondary/90"
onClick={handleRequestQuote}
disabled={isRequesting}
>
{isRequesting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sender...
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
Anmod om bindende tilbud
</>
)}
</Button>
<Button
variant="outline"
size="lg"
className="h-12 w-full"
onClick={handleReset}
>
<RotateCcw className="mr-2 h-4 w-4" />
Ny beregning
</Button>
</div>
</div>
</div>
)}
</div>
</section>
{/* Features Section */}
<section className="bg-muted/30 py-16 sm:py-24">
<div className="container mx-auto px-4">
<div className="grid gap-6 md:grid-cols-3">
<div className="flex h-32 items-center justify-center rounded-2xl bg-white p-6 shadow-md transition-shadow hover:shadow-lg">
<Image
src="/dansk_kvalitet.png"
alt="Dansk Kvalitet"
width={75}
height={100}
className="h-20 w-auto object-contain"
/> />
</div> </div>
{/* Results */} <div className="flex h-32 items-center justify-center rounded-2xl bg-white p-6 shadow-md transition-shadow hover:shadow-lg">
{calculationResult && ( <Image
<div className="space-y-6"> src="/byg_trans.png"
{!showAdminMode ? ( alt="Byg Garanti"
<div className="rounded-xl bg-card p-8 text-center shadow"> width={360}
<h2 className="mb-4 text-xl font-semibold">Dit prisoverslag</h2> height={97}
<p className="text-4xl font-bold text-primary"> className="h-20 w-auto object-contain"
{formatEstimate(calculationResult.totalInclVat)} />
</p> </div>
<p className="mt-2 text-sm text-muted-foreground">inkl. moms</p>
<p className="mt-4 text-sm text-muted-foreground"> <div className="flex h-32 items-center justify-center rounded-2xl bg-white p-6 shadow-md transition-shadow hover:shadow-lg">
*Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete forhold <Image
</p> src="/tilfredshed_service.png"
<Button alt="Tilfredshed & Service"
onClick={handleQuoteRequest} width={75}
size="lg" height={100}
className="mt-6 gap-2" className="h-20 w-auto object-contain"
disabled={isRequestingQuote} />
> </div>
<Send className="h-4 w-4" />
Anmod om bindende tilbud
</Button>
</div>
) : (
<CalculationDetailsView details={calculationResult} />
)}
</div>
)}
</div> </div>
</div> </div>
</section>
{/* Footer */} {/* Coverage Section */}
<footer className="mt-16 border-t pt-8 text-center text-sm text-muted-foreground"> <section className="bg-white py-16">
<p>Foam King Gulve · Asnæs · CVR: 12345678</p> <div className="container mx-auto px-4 text-center">
<p className="mt-1"> <h2 className="mb-4 text-2xl font-bold">Vi dækker hele Østdanmark</h2>
Vi dækker Sjælland, Lolland-Falster og Fyn <p className="mb-6 text-muted-foreground">
Sjælland · København · Nordsjælland · Lolland-Falster · Fyn
</p> </p>
</footer> <div className="flex justify-center gap-4">
</div> <a href="tel:35901066" className="flex items-center gap-2 text-primary hover:underline">
<Phone className="h-4 w-4" />
35 90 10 66
</a>
<a
href="mailto:info@foamking.dk"
className="flex items-center gap-2 text-primary hover:underline"
>
<Mail className="h-4 w-4" />
info@foamking.dk
</a>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-foreground py-8 text-background">
<div className="container mx-auto px-4">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<div className="flex items-center gap-4">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={120}
height={48}
className="h-10 w-auto brightness-0 invert"
/>
<div className="text-sm text-background/70">
<p>Foam King ApS · CVR: 44 48 54 51</p>
<p className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
Søgårdsvej 7, 4550 Asnæs
</p>
</div>
</div>
<p className="text-sm text-background/50">
© {new Date().getFullYear()} Foam King. Alle rettigheder forbeholdes.
</p>
</div>
</div>
</footer>
</main> </main>
) )
} }

207
app/tilbud/[slug]/page.tsx Normal file
View file

@ -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 (
<main className="min-h-screen bg-muted/30">
{/* Header */}
<header className="border-b bg-white">
<div className="container mx-auto flex items-center justify-between px-4 py-4">
<Link href="/" className="flex items-center gap-2">
<Image
src="/foam-king-logo.png"
alt="Foam King"
width={140}
height={56}
className="h-12 w-auto"
/>
</Link>
<div className="text-sm text-muted-foreground">Tilbud #{quote.id}</div>
</div>
</header>
<div className="container mx-auto max-w-4xl px-4 py-8">
<Link
href="/"
className="mb-6 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Tilbage til forsiden
</Link>
{/* Quote Header */}
<div className="mb-6 rounded-2xl bg-white p-8 shadow-lg">
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold">Prisoverslag</h1>
<p className="mt-1 flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
Oprettet {createdDate}
</p>
</div>
</div>
{/* Customer Info */}
<div className="mb-6 rounded-xl bg-muted/50 p-4">
<h3 className="mb-3 font-semibold">Kundeoplysninger</h3>
<dl className="grid gap-x-6 gap-y-2 text-sm md:grid-cols-2">
<div className="flex justify-between md:block">
<dt className="text-muted-foreground">Navn:</dt>
<dd className="font-medium">{quote.customerName}</dd>
</div>
<div className="flex justify-between md:block">
<dt className="text-muted-foreground">Email:</dt>
<dd className="font-medium">
<a
href={`mailto:${quote.customerEmail}`}
className="text-primary hover:underline"
>
{quote.customerEmail}
</a>
</dd>
</div>
<div className="flex justify-between md:block">
<dt className="text-muted-foreground">Telefon:</dt>
<dd className="font-medium">
<a href={`tel:${quote.customerPhone}`} className="text-primary hover:underline">
{quote.customerPhone}
</a>
</dd>
</div>
<div className="flex justify-between md:block">
<dt className="text-muted-foreground">Adresse:</dt>
<dd className="font-medium">
{quote.postalCode}
{quote.address ? `, ${quote.address}` : ""}
</dd>
</div>
</dl>
{quote.remarks && (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3">
<div className="text-sm font-medium text-amber-800">Bemærkninger:</div>
<p className="mt-1 text-sm text-amber-700">{quote.remarks}</p>
</div>
)}
</div>
{/* Price Box */}
<div className="mb-6 rounded-xl bg-gradient-to-br from-primary/10 to-secondary/10 p-6 text-center">
<p className="mb-2 text-5xl font-bold text-primary">
{formatEstimate(calculation.totalInclVat)}
</p>
<p className="text-muted-foreground">inkl. moms</p>
</div>
{/* Project Details */}
<div className="grid gap-6 md:grid-cols-2">
<div>
<h3 className="mb-3 font-semibold">Projektdetaljer</h3>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Areal:</dt>
<dd className="font-medium">{quote.area} m²</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Gulvhøjde:</dt>
<dd className="font-medium">{quote.height} cm</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Placering:</dt>
<dd className="font-medium">
{quote.postalCode}
{quote.address ? `, ${quote.address}` : ""}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Gulvbelægning:</dt>
<dd className="font-medium">
{FLOORING_TYPES[quote.flooringType as keyof typeof FLOORING_TYPES]?.name ||
quote.flooringType}
</dd>
</div>
</dl>
</div>
<div>
<h3 className="mb-3 font-semibold">Inkluderet i prisen</h3>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Isolering ({calculation.insulationThickness} cm)
</li>
{quote.includeFloorHeating && (
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Gulvvarme syntetisk net + Ø16 PEX (excl. tilslutning)
</li>
)}
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Flydespartel (støbning)
</li>
<li className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
Transport
</li>
</ul>
</div>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground">
*Prisen er vejledende og kan variere med ±10.000 kr afhængigt af konkrete forhold
</p>
</div>
{/* Detailed Calculation */}
<div className="mb-6 rounded-2xl bg-white p-8 shadow-lg">
<h2 className="mb-4 text-xl font-bold">Detaljeret prisberegning</h2>
<CalculationDetailsView details={calculation} />
</div>
</div>
{/* Footer */}
<footer className="mt-8 border-t bg-white py-8">
<div className="container mx-auto px-4 text-center text-sm text-muted-foreground">
<p>Foam King ApS · Søgårdsvej 7, 4550 Asnæs · CVR: 44 48 54 51</p>
</div>
</footer>
</main>
)
}

View file

@ -1,19 +1,19 @@
import { AlertTriangle, CheckCircle, Check, X } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { formatPrice, type CalculationDetails } from "@/lib/calculations" import { formatPrice, type CalculationDetails } from "@/lib/calculations"
import { PRICES, CONSTRAINTS } from "@/lib/constants" import { PRICES, CONSTRAINTS, FLOORING_TYPES } from "@/lib/constants"
interface CalculationDetailsProps { interface CalculationDetailsProps {
details: CalculationDetails details: CalculationDetails
distanceSource?: "openrouteservice" | "table" | null
} }
export function CalculationDetailsView({ details }: CalculationDetailsProps) { export function CalculationDetailsView({ details, distanceSource }: CalculationDetailsProps) {
return ( return (
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardHeader>
<CardTitle>Detaljeret Prisberegning</CardTitle> <CardTitle>Detaljeret Prisberegning</CardTitle>
<CardDescription> <CardDescription>Komplet oversigt over alle delpriser og beregninger</CardDescription>
Komplet oversigt over alle delpriser og beregninger
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Input Values */} {/* Input Values */}
@ -39,13 +39,68 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
</div> </div>
</div> </div>
{/* Selected Components */}
<div>
<h3 className="mb-2 font-semibold">Valgte komponenter</h3>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="flex items-center gap-2 text-muted-foreground">
{details.includeInsulation ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-500" />
)}
Isolering:
</span>
<span className={details.includeInsulation ? "" : "text-muted-foreground"}>
{details.includeInsulation ? "Inkluderet" : "Fravalgt"}
</span>
</div>
<div className="flex justify-between">
<span className="flex items-center gap-2 text-muted-foreground">
{details.includeFloorHeating ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-500" />
)}
Gulvvarme:
</span>
<span className={details.includeFloorHeating ? "" : "text-muted-foreground"}>
{details.includeFloorHeating ? "Inkluderet" : "Fravalgt"}
</span>
</div>
<div className="flex justify-between">
<span className="flex items-center gap-2 text-muted-foreground">
{details.includeCompound ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-500" />
)}
Gulvstøbning:
</span>
<span className={details.includeCompound ? "" : "text-muted-foreground"}>
{details.includeCompound ? "Inkluderet" : "Fravalgt"}
</span>
</div>
{details.includeCompound && (
<div className="flex justify-between">
<span className="text-muted-foreground">Gulvbelægning:</span>
<span>{FLOORING_TYPES[details.flooringType]?.name || details.flooringType}</span>
</div>
)}
</div>
</div>
{/* Calculated Values */} {/* Calculated Values */}
<div> <div>
<h3 className="mb-2 font-semibold">Beregnede værdier</h3> <h3 className="mb-2 font-semibold">Beregnede værdier</h3>
<div className="grid gap-2 text-sm"> <div className="grid gap-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Isoleringstykkelse:</span> <span className="text-muted-foreground">Isoleringstykkelse:</span>
<span>{details.insulationThickness} cm ({details.height} - {CONSTRAINTS.CONCRETE_THICKNESS} cm beton)</span> <span>
{details.insulationThickness} cm ({details.height} -{" "}
{CONSTRAINTS.CONCRETE_THICKNESS} cm beton)
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Isoleringsvolumen:</span> <span className="text-muted-foreground">Isoleringsvolumen:</span>
@ -53,7 +108,10 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Spartelvægt:</span> <span className="text-muted-foreground">Spartelvægt:</span>
<span>{details.compoundWeight.toLocaleString("da-DK")} kg ({details.area} m² × {PRICES.COMPOUND_WEIGHT_PER_M2} kg/m²)</span> <span>
{details.compoundWeight.toLocaleString("da-DK")} kg ({details.area} m² ×{" "}
{PRICES.COMPOUND_WEIGHT_PER_M2} kg/m²)
</span>
</div> </div>
</div> </div>
</div> </div>
@ -62,33 +120,61 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
<div> <div>
<h3 className="mb-2 font-semibold">Komponent priser</h3> <h3 className="mb-2 font-semibold">Komponent priser</h3>
<div className="grid gap-2 text-sm"> <div className="grid gap-2 text-sm">
<div className="flex justify-between"> <div
className={`flex justify-between ${!details.includeInsulation ? "opacity-50" : ""}`}
>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Isolering {details.insulationThickness > 0 ? `(${details.insulationVolume.toFixed(2)}× ${formatPrice(PRICES.INSULATION_TOTAL)}/m³)` : "(simpel arbejdsløn)"}: Isolering{" "}
{details.includeInsulation
? details.insulationThickness > 0
? `(${details.insulationVolume.toFixed(2)}× ${formatPrice(PRICES.INSULATION_TOTAL_PER_M3)}/m³)`
: "(simpel arbejdsløn)"
: "(fravalgt)"}
:
</span> </span>
<span className="font-medium">{formatPrice(details.insulation)}</span> <span className="font-medium">{formatPrice(details.insulation)}</span>
</div> </div>
<div className="flex justify-between"> <div
className={`flex justify-between ${!details.includeFloorHeating ? "opacity-50" : ""}`}
>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Gulvvarme ({details.area} m² × {formatPrice(PRICES.FLOOR_HEATING_TOTAL)}/m²): Gulvvarme{" "}
{details.includeFloorHeating
? `(${details.area}× ${formatPrice(PRICES.FLOOR_HEATING_TOTAL)}/m²)`
: "(fravalgt)"}
:
</span> </span>
<span className="font-medium">{formatPrice(details.floorHeating)}</span> <span className="font-medium">{formatPrice(details.floorHeating)}</span>
</div> </div>
<div className="flex justify-between"> <div
className={`flex justify-between ${!details.includeFloorHeating ? "opacity-50" : ""}`}
>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Syntetisk net ({details.area} m² × {formatPrice(PRICES.SYNTHETIC_NET_TOTAL)}/m²): Syntetisk net{" "}
{details.includeFloorHeating
? `(${details.area}× ${formatPrice(PRICES.SYNTHETIC_NET_TOTAL)}/m²)`
: "(fravalgt)"}
:
</span> </span>
<span className="font-medium">{formatPrice(details.syntheticNet)}</span> <span className="font-medium">{formatPrice(details.syntheticNet)}</span>
</div> </div>
<div className="flex justify-between"> <div className={`flex justify-between ${!details.includeCompound ? "opacity-50" : ""}`}>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Flydespartel ({details.area} m² × {formatPrice(PRICES.SELF_LEVELING_COMPOUND)}/m²): Flydespartel{" "}
{details.includeCompound
? `(${details.area}× ${formatPrice(PRICES.SELF_LEVELING_COMPOUND)}/m²${FLOORING_TYPES[details.flooringType]?.compoundMultiplier > 1 ? " +28%" : ""})`
: "(fravalgt)"}
:
</span> </span>
<span className="font-medium">{formatPrice(details.selfLevelingCompound)}</span> <span className="font-medium">{formatPrice(details.selfLevelingCompound)}</span>
</div> </div>
<div className="flex justify-between"> <div className={`flex justify-between ${!details.includeCompound ? "opacity-50" : ""}`}>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Pumpebil-tillæg ({details.compoundWeight.toLocaleString("da-DK")} kg): Pumpebil-tillæg{" "}
{details.includeCompound
? `(${details.compoundWeight.toLocaleString("da-DK")} kg)`
: "(fravalgt)"}
:
</span> </span>
<span className="font-medium">{formatPrice(details.pumpTruckFee)}</span> <span className="font-medium">{formatPrice(details.pumpTruckFee)}</span>
</div> </div>
@ -142,6 +228,27 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
<span>{formatPrice(details.bridgeFee)}</span> <span>{formatPrice(details.bridgeFee)}</span>
</div> </div>
)} )}
{distanceSource && (
<div
className={`mt-2 flex items-center gap-2 rounded-md p-2 text-xs ${
distanceSource === "openrouteservice"
? "bg-green-50 text-green-700"
: "bg-amber-50 text-amber-700"
}`}
>
{distanceSource === "openrouteservice" ? (
<>
<CheckCircle className="h-3 w-3" />
<span>Præcis afstand via OpenRouteService</span>
</>
) : (
<>
<AlertTriangle className="h-3 w-3" />
<span>Præcis afstandsberegning ikke mulig - overslag brugt</span>
</>
)}
</div>
)}
</div> </div>
</div> </div>

View file

@ -1,29 +1,41 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm, Controller } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { CONSTRAINTS } from "@/lib/constants" import { Switch } from "@/components/ui/switch"
import { validateDanishPostalCode, isInCoverageArea, getDistance } from "@/lib/distance" import { Slider } from "@/components/ui/slider"
import { calculatePrice, formatEstimate, type CalculationDetails } from "@/lib/calculations" 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({ 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"), email: z.string().email("Ugyldig email"),
phone: z.string().regex(/^\d{8}$/, "Telefonnummer skal være 8 cifre"), phone: z.string().regex(/^\d{8}$/, "Telefonnummer skal være 8 cifre"),
postalCode: z postalCode: z
.string() .string()
.length(4, "Postnummer skal være 4 cifre") .length(4, "Postnummer skal være 4 cifre")
.refine(validateDanishPostalCode, "Ugyldigt dansk postnummer") .refine(validateDanishPostalCode, "Ugyldigt dansk postnummer"),
.refine(isInCoverageArea, "Beklager, vi dækker ikke dette område"),
address: z.string().optional(), address: z.string().optional(),
area: z.coerce area: z.coerce
.number() .number()
@ -34,183 +46,424 @@ const formSchema = z.object({
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_HEIGHT} cm`) .min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_HEIGHT} cm`)
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum højde er ${CONSTRAINTS.MAX_HEIGHT} cm`), .max(CONSTRAINTS.MAX_HEIGHT, `Maximum højde er ${CONSTRAINTS.MAX_HEIGHT} cm`),
remarks: z.string().optional(), remarks: z.string().optional(),
includeInsulation: z.boolean(),
includeFloorHeating: z.boolean(),
includeCompound: z.boolean(),
flooringType: z.string(),
}) })
type FormData = z.infer<typeof formSchema> type FormData = z.infer<typeof formSchema>
interface CalculatorFormProps { interface CalculatorFormProps {
onCalculation: (result: CalculationDetails, formData?: FormData) => void onCalculation: (
result: CalculationDetails,
formData?: FormData,
distanceSource?: "openrouteservice" | "table"
) => void
showDetails?: boolean showDetails?: boolean
} }
interface CalculationProgress {
step: string
progress: number
}
export function CalculatorForm({ onCalculation, showDetails = false }: CalculatorFormProps) { export function CalculatorForm({ onCalculation, showDetails = false }: CalculatorFormProps) {
const [isCalculating, setIsCalculating] = useState(false) const [isCalculating, setIsCalculating] = useState(false)
const [calculationProgress, setCalculationProgress] = useState<CalculationProgress | null>(null)
const [result, setResult] = useState<CalculationDetails | null>(null) const [result, setResult] = useState<CalculationDetails | null>(null)
const [distanceSource, setDistanceSource] = useState<"openrouteservice" | "table" | null>(null)
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
watch, watch,
control,
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(formSchema), 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) => { const onSubmit = async (data: FormData) => {
setIsCalculating(true) setIsCalculating(true)
setDistanceSource(null)
setCalculationProgress({ step: "Finder din adresse...", progress: 20 })
try { try {
// Simulate API delay let distance: number
await new Promise((resolve) => setTimeout(resolve, 500)) 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 distance = getDistance(data.postalCode)
const calculationResult = calculatePrice({ const calculationResult = calculatePrice({
area: data.area, area: data.area,
height: data.height, height: data.height,
postalCode: data.postalCode, postalCode: data.postalCode,
distance, 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) setResult(calculationResult)
onCalculation(calculationResult, data) onCalculation(calculationResult, data, source)
} finally { } finally {
setIsCalculating(false) setIsCalculating(false)
setCalculationProgress(null)
} }
} }
return ( return (
<Card className="w-full max-w-2xl"> <Card className="w-full max-w-2xl shadow-lg">
<CardHeader> <CardHeader className="rounded-t-lg bg-gradient-to-r from-secondary/10 to-secondary/5">
<CardTitle className="flex items-center gap-2 text-2xl"> <CardTitle className="flex items-center gap-3 text-2xl">
<Calculator className="h-6 w-6" /> <div className="rounded-full bg-primary p-2">
<Calculator className="h-5 w-5 text-secondary-foreground" />
</div>
Prisberegner Prisberegner
</CardTitle> </CardTitle>
<CardDescription> <CardDescription className="text-base">
et hurtigt overslag din nye gulvløsning et hurtigt overslag din nye gulvløsning
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="pt-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="grid gap-4 sm:grid-cols-2"> {/* Contact Section */}
<div> <section>
<Label htmlFor="name">Navn *</Label> <h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
<Input Kontaktoplysninger
id="name" </h3>
{...register("name")} <div className="grid gap-4 sm:grid-cols-2">
placeholder="Dit navn" <div className="space-y-2">
className="mt-1" <Label htmlFor="name">Navn</Label>
/> <Input id="name" {...register("name")} placeholder="Dit navn" />
{errors.name && ( {errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
<p className="mt-1 text-sm text-destructive">{errors.name.message}</p> </div>
)} <div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...register("email")} placeholder="din@email.dk" />
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" {...register("phone")} placeholder="12345678" />
{errors.phone && <p className="text-sm text-destructive">{errors.phone.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="postalCode">Postnummer</Label>
<Input id="postalCode" {...register("postalCode")} placeholder="4550" />
{errors.postalCode && (
<p className="text-sm text-destructive">{errors.postalCode.message}</p>
)}
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="address">
Adresse <span className="font-normal text-muted-foreground">(valgfrit)</span>
</Label>
<Input id="address" {...register("address")} placeholder="Vejnavn og nummer" />
</div>
</div> </div>
</section>
<div> {/* Floor Dimensions Section */}
<Label htmlFor="email">Email *</Label> <section>
<Input <h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
id="email" Gulvmål
type="email" </h3>
{...register("email")} <div className="space-y-6 rounded-xl bg-muted/30 p-5">
placeholder="din@email.dk" {/* Area Slider */}
className="mt-1" <div className="space-y-3">
/> <div className="flex items-center justify-between">
{errors.email && ( <Label className="text-base">Gulvareal</Label>
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p> <div className="flex items-baseline gap-1">
)} <Controller
name="area"
control={control}
render={({ field }) => (
<Input
type="number"
value={field.value}
onChange={(e) => 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}
/>
)}
/>
<span className="text-muted-foreground">m²</span>
</div>
</div>
<Controller
name="area"
control={control}
render={({ field }) => (
<Slider
min={CONSTRAINTS.MIN_AREA}
max={CONSTRAINTS.MAX_AREA}
step={5}
value={[field.value || CONSTRAINTS.MIN_AREA]}
onValueChange={([value]) => field.onChange(value)}
className="py-2"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_AREA} m²</span>
<span>{CONSTRAINTS.MAX_AREA} m²</span>
</div>
{errors.area && <p className="text-sm text-destructive">{errors.area.message}</p>}
</div>
{/* Height Slider */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-base">Gulvhøjde</Label>
<div className="flex items-baseline gap-1">
<Controller
name="height"
control={control}
render={({ field }) => (
<Input
type="number"
value={field.value}
onChange={(e) => 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}
/>
)}
/>
<span className="text-muted-foreground">cm</span>
</div>
</div>
<Controller
name="height"
control={control}
render={({ field }) => (
<Slider
min={CONSTRAINTS.MIN_HEIGHT}
max={CONSTRAINTS.MAX_HEIGHT}
step={1}
value={[field.value || CONSTRAINTS.MIN_HEIGHT]}
onValueChange={([value]) => field.onChange(value)}
className="py-2"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_HEIGHT} cm</span>
<span>{CONSTRAINTS.MAX_HEIGHT} cm</span>
</div>
{errors.height && (
<p className="text-sm text-destructive">{errors.height.message}</p>
)}
</div>
</div> </div>
</section>
<div> {/* Components Section */}
<Label htmlFor="phone">Telefon *</Label> <section>
<Input <h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
id="phone" Vælg komponenter
{...register("phone")} </h3>
placeholder="12345678" <div className="grid gap-3">
className="mt-1" {/* Insulation Toggle */}
<Controller
name="includeInsulation"
control={control}
render={({ field }) => (
<label
className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
field.value
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<div
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
>
<Layers className="h-5 w-5" />
</div>
<div className="flex-1">
<div className="font-medium">Isolering</div>
<div className="text-sm text-muted-foreground">
Gulvisolering under varmeanlæg
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/> />
{errors.phone && (
<p className="mt-1 text-sm text-destructive">{errors.phone.message}</p>
)}
</div>
<div> {/* Floor Heating Toggle */}
<Label htmlFor="postalCode">Postnummer *</Label> <Controller
<Input name="includeFloorHeating"
id="postalCode" control={control}
{...register("postalCode")} render={({ field }) => (
placeholder="4550" <label
className="mt-1" className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
field.value
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<div
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
>
<Thermometer className="h-5 w-5" />
</div>
<div className="flex-1">
<div className="font-medium">Gulvvarme</div>
<div className="text-sm text-muted-foreground">Syntetisk net + Ø16 PEX (excl. tilslutning)</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/> />
{errors.postalCode && (
<p className="mt-1 text-sm text-destructive">{errors.postalCode.message}</p>
)}
</div>
</div>
<div> {/* Compound Toggle */}
<Label htmlFor="address">Adresse</Label> <Controller
<Input name="includeCompound"
id="address" control={control}
{...register("address")} render={({ field }) => (
placeholder="Vejnavn og nummer (valgfrit)" <label
className="mt-1" className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
/> field.value
</div> ? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
<div className="grid gap-4 sm:grid-cols-2"> }`}
<div> >
<Label htmlFor="area"> <div
Gulvareal (m²) * className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
<span className="ml-1 text-xs text-muted-foreground"> >
({CONSTRAINTS.MIN_AREA}-{CONSTRAINTS.MAX_AREA} m²) <PaintBucket className="h-5 w-5" />
</span> </div>
</Label> <div className="flex-1">
<Input <div className="font-medium">Gulvstøbning</div>
id="area" <div className="text-sm text-muted-foreground">
type="number" Flydespartel til færdigt gulv
{...register("area")} </div>
placeholder="50" </div>
className="mt-1" <Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/> />
{errors.area && (
<p className="mt-1 text-sm text-destructive">{errors.area.message}</p>
)}
</div> </div>
</section>
<div> {/* Flooring Type Section */}
<Label htmlFor="height"> {watchedIncludeCompound && (
Gulvhøjde (cm) * <section>
<span className="ml-1 text-xs text-muted-foreground"> <h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
({CONSTRAINTS.MIN_HEIGHT}-{CONSTRAINTS.MAX_HEIGHT} cm) Gulvbelægning
</span> </h3>
</Label> <Controller
<Input name="flooringType"
id="height" control={control}
type="number" render={({ field }) => (
{...register("height")} <div className="grid gap-2 sm:grid-cols-3">
placeholder="20" {Object.entries(FLOORING_TYPES).map(([key, type]) => (
className="mt-1" <label
key={key}
className={`flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 p-4 text-center transition-all ${
field.value === key
? "border-secondary bg-secondary/10"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<input
type="radio"
value={key}
checked={field.value === key}
onChange={() => field.onChange(key)}
className="sr-only"
/>
<span className="font-medium">{type.name}</span>
<span className="text-xs text-muted-foreground">{type.description}</span>
{type.compoundMultiplier > 1 && (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600">
+28% spartel
</span>
)}
</label>
))}
</div>
)}
/> />
{errors.height && ( </section>
<p className="mt-1 text-sm text-destructive">{errors.height.message}</p> )}
)}
</div>
</div>
<div> {/* Remarks Section */}
<Label htmlFor="remarks">Bemærkninger</Label> <section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Bemærkninger <span className="font-normal">(valgfrit)</span>
</h3>
<Textarea <Textarea
id="remarks"
{...register("remarks")} {...register("remarks")}
placeholder="Eventuelle særlige ønsker eller spørgsmål" placeholder="Eventuelle særlige ønsker eller spørgsmål"
className="mt-1"
rows={3} rows={3}
className="resize-none"
/> />
</div> </section>
<Button type="submit" size="lg" className="w-full" disabled={isCalculating}> {/* Progress Indicator */}
{calculationProgress && (
<div className="space-y-2 rounded-xl border border-secondary/30 bg-secondary/10 p-4">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{calculationProgress.step}</span>
<span className="text-muted-foreground">{calculationProgress.progress}%</span>
</div>
<Progress value={calculationProgress.progress} className="h-2" />
</div>
)}
<Button
type="submit"
size="lg"
className="h-12 w-full text-base font-semibold"
disabled={isCalculating}
>
{isCalculating ? ( {isCalculating ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-5 w-5 animate-spin" />
Beregner... Beregner...
</> </>
) : ( ) : (
@ -218,15 +471,6 @@ export function CalculatorForm({ onCalculation, showDetails = false }: Calculato
)} )}
</Button> </Button>
</form> </form>
{result && !showDetails && (
<div className="mt-6 rounded-lg bg-muted p-6 text-center">
<p className="text-3xl font-bold">{formatEstimate(result.totalInclVat)}</p>
<p className="mt-2 text-sm text-muted-foreground">
*Prisen er vejledende og kan variere afhængigt af konkrete forhold
</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
) )

View file

@ -0,0 +1,570 @@
"use client"
import { useState } from "react"
import { useForm, Controller } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import {
ArrowRight,
ArrowLeft,
MapPin,
Ruler,
Settings,
User,
Check,
Loader2,
CheckCircle2,
} 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 { Switch } from "@/components/ui/switch"
import { Slider } from "@/components/ui/slider"
import { CONSTRAINTS, FLOORING_TYPES, type FlooringType } from "@/lib/constants"
import { validateDanishPostalCode, getDistance } from "@/lib/distance"
import {
calculatePrice,
formatPrice,
formatEstimate,
type CalculationDetails,
} from "@/lib/calculations"
const formSchema = z.object({
postalCode: z
.string()
.length(4, "Postnummer skal være 4 cifre")
.refine(validateDanishPostalCode, "Vi dækker desværre ikke dette område"),
address: z.string().optional(),
area: z.coerce
.number()
.min(CONSTRAINTS.MIN_AREA, `Minimum ${CONSTRAINTS.MIN_AREA}`)
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA}`),
height: z.coerce
.number()
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum ${CONSTRAINTS.MIN_HEIGHT} cm`)
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum ${CONSTRAINTS.MAX_HEIGHT} cm`),
includeInsulation: z.boolean(),
includeFloorHeating: z.boolean(),
includeCompound: z.boolean(),
flooringType: z.string(),
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"),
remarks: z.string().optional(),
})
type FormData = z.infer<typeof formSchema>
interface StepWizardProps {
onComplete: (result: CalculationDetails, formData: FormData) => void
}
const steps = [
{ id: 1, name: "Placering", icon: MapPin },
{ id: 2, name: "Gulvmål", icon: Ruler },
{ id: 3, name: "Løsning", icon: Settings },
{ id: 4, name: "Kontakt", icon: User },
]
export function StepWizard({ onComplete }: StepWizardProps) {
const [currentStep, setCurrentStep] = useState(1)
const [isCalculating, setIsCalculating] = useState(false)
const [showHeightTip, setShowHeightTip] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
watch,
control,
trigger,
getValues,
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
postalCode: "",
address: "",
area: 75,
height: 15,
includeInsulation: true,
includeFloorHeating: true,
includeCompound: true,
flooringType: "STANDARD",
name: "",
email: "",
phone: "",
remarks: "",
},
mode: "onChange",
})
const watchedValues = watch()
const validateStep = async (step: number): Promise<boolean> => {
let fieldsToValidate: (keyof FormData)[] = []
switch (step) {
case 1:
fieldsToValidate = ["postalCode"]
break
case 2:
fieldsToValidate = ["area", "height"]
break
case 3:
fieldsToValidate = []
break
case 4:
fieldsToValidate = ["name", "email", "phone"]
break
}
const result = await trigger(fieldsToValidate)
return result
}
const nextStep = async () => {
const isValid = await validateStep(currentStep)
if (isValid && currentStep < 4) {
setCurrentStep(currentStep + 1)
}
}
const prevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const onSubmit = async (data: FormData) => {
setIsCalculating(true)
try {
let distance: number
try {
const params = new URLSearchParams({
postalCode: data.postalCode,
...(data.address && { address: data.address }),
})
const response = await fetch(`/api/distance?${params}`)
const distanceData = await response.json()
distance = distanceData.distance
} catch {
distance = getDistance(data.postalCode)
}
const result = 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,
})
onComplete(result, data)
} finally {
setIsCalculating(false)
}
}
return (
<div className="mx-auto w-full max-w-lg">
{/* Progress Steps */}
<div className="mb-8">
<div className="flex justify-between">
{steps.map((step, index) => {
const Icon = step.icon
const isActive = currentStep === step.id
const isCompleted = currentStep > step.id
return (
<div key={step.id} className="flex flex-1 flex-col items-center">
<div className="relative flex w-full items-center justify-center">
{index > 0 && (
<div
className={`absolute left-0 right-1/2 top-5 h-0.5 -translate-y-1/2 ${
isCompleted || isActive ? "bg-secondary" : "bg-muted"
}`}
/>
)}
{index < steps.length - 1 && (
<div
className={`absolute left-1/2 right-0 top-5 h-0.5 -translate-y-1/2 ${
isCompleted ? "bg-secondary" : "bg-muted"
}`}
/>
)}
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
isActive
? "border-secondary bg-secondary text-secondary-foreground"
: isCompleted
? "border-secondary bg-secondary text-secondary-foreground"
: "border-muted bg-background text-muted-foreground"
}`}
>
{isCompleted ? <Check className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
</div>
</div>
<span
className={`mt-2 text-xs font-medium ${
isActive || isCompleted ? "text-foreground" : "text-muted-foreground"
}`}
>
{step.name}
</span>
</div>
)
})}
</div>
</div>
{/* Form Card */}
<div className="rounded-2xl bg-white p-6 shadow-lg sm:p-8">
<form onSubmit={handleSubmit(onSubmit)}>
{/* Step 1: Location */}
{currentStep === 1 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Hvor skal gulvet lægges?</h2>
<p className="mt-1 text-muted-foreground">
Vi dækker Sjælland, Lolland-Falster og Fyn
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="postalCode">Postnummer *</Label>
<Input
id="postalCode"
{...register("postalCode")}
placeholder="F.eks. 4550"
className="h-12 text-lg"
maxLength={4}
/>
{errors.postalCode && (
<p className="text-sm text-destructive">{errors.postalCode.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="address">
Adresse <span className="font-normal text-muted-foreground">(valgfrit)</span>
</Label>
<Input
id="address"
{...register("address")}
placeholder="Vejnavn og nummer"
className="h-12"
/>
</div>
</div>
</div>
)}
{/* Step 2: Floor Dimensions */}
{currentStep === 2 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Hvor stort er gulvet?</h2>
<p className="mt-1 text-muted-foreground">Angiv areal og ønsket gulvhøjde</p>
</div>
<div className="space-y-8">
{/* Area Slider */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base">Gulvareal</Label>
<div className="flex items-baseline gap-1 rounded-lg bg-muted/50 px-3 py-1">
<span className="text-2xl font-bold">{watchedValues.area}</span>
<span className="text-muted-foreground">m²</span>
</div>
</div>
<Controller
name="area"
control={control}
render={({ field }) => (
<Slider
min={CONSTRAINTS.MIN_AREA}
max={CONSTRAINTS.MAX_AREA}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
className="py-4"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_AREA} m²</span>
<span>{CONSTRAINTS.MAX_AREA} m²</span>
</div>
</div>
{/* Height Slider */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="relative text-base">
Gulvhøjde
<button
type="button"
onClick={() => setShowHeightTip(!showHeightTip)}
className="ml-1.5 inline-flex cursor-pointer items-center justify-center rounded-full border border-muted-foreground/30 text-muted-foreground hover:bg-muted/50"
style={{ width: "16px", height: "16px", fontSize: "11px", position: "relative", top: "-1px" }}
>
?
</button>
{showHeightTip && (
<span className="absolute left-0 top-full z-10 mt-1 w-48 rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-lg">
Angiv dybde fra bund til ønsket niveau
</span>
)}
</Label>
<div className="flex items-baseline gap-1 rounded-lg bg-muted/50 px-3 py-1">
<span className="text-2xl font-bold">{watchedValues.height}</span>
<span className="text-muted-foreground">cm</span>
</div>
</div>
<Controller
name="height"
control={control}
render={({ field }) => (
<Slider
min={CONSTRAINTS.MIN_HEIGHT}
max={CONSTRAINTS.MAX_HEIGHT}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
className="py-4"
/>
)}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{CONSTRAINTS.MIN_HEIGHT} cm</span>
<span>{CONSTRAINTS.MAX_HEIGHT} cm</span>
</div>
</div>
</div>
</div>
)}
{/* Step 3: Components */}
{currentStep === 3 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Hvad skal inkluderes?</h2>
<p className="mt-1 text-muted-foreground">Vælg de komponenter du ønsker</p>
</div>
{/* Always included */}
<div className="mb-4 rounded-xl border border-green-200 bg-green-50 p-4">
<p className="mb-2 text-sm font-medium text-green-800">Altid inkluderet:</p>
<div className="space-y-2">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 flex-shrink-0 text-green-600" />
<div>
<span className="font-medium">Isolering</span>
<span className="ml-2 text-sm text-muted-foreground">PUR skumisolering</span>
</div>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 flex-shrink-0 text-green-600" />
<div>
<span className="font-medium">Gulvstøbning</span>
<span className="ml-2 text-sm text-muted-foreground">Flydespartel</span>
</div>
</div>
</div>
</div>
<p className="mb-2 text-sm font-medium text-muted-foreground">Tilvalg:</p>
<div className="space-y-3">
<Controller
name="includeFloorHeating"
control={control}
render={({ field }) => (
<label
className={`flex cursor-pointer items-center justify-between rounded-xl border-2 p-4 transition-all ${
field.value ? "border-secondary bg-secondary/5" : "border-muted"
}`}
>
<div>
<div className="font-medium">Gulvvarme</div>
<div className="text-sm text-muted-foreground">
Syntetisk net + Ø16 PEX (excl. tilslutning)
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</label>
)}
/>
</div>
{/* Flooring Type */}
{true && (
<div className="border-t pt-4">
<Label className="mb-3 block text-sm text-muted-foreground">
Hvilken gulvbelægning skal lægges?
</Label>
<Controller
name="flooringType"
control={control}
render={({ field }) => (
<div className="grid gap-2">
{Object.entries(FLOORING_TYPES).map(([key, type]) => (
<label
key={key}
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-all ${
field.value === key
? "border-secondary bg-secondary/5"
: "border-muted hover:border-muted-foreground/30"
}`}
>
<input
type="radio"
value={key}
checked={field.value === key}
onChange={() => field.onChange(key)}
className="sr-only"
/>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
field.value === key
? "border-secondary"
: "border-muted-foreground/30"
}`}
>
{field.value === key && (
<div className="h-2 w-2 rounded-full bg-secondary" />
)}
</div>
<div className="flex-1">
<span className="font-medium">{type.name}</span>
{type.compoundMultiplier > 1 && (
<span className="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-xs text-amber-600">
+28% spartel
</span>
)}
</div>
</label>
))}
</div>
)}
/>
</div>
)}
</div>
)}
{/* Step 4: Contact */}
{currentStep === 4 && (
<div className="space-y-6">
<div className="mb-6 text-center">
<h2 className="text-xl font-semibold">Dine kontaktoplysninger</h2>
<p className="mt-1 text-muted-foreground"> vi kan sende dit prisoverslag</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Navn *</Label>
<Input
id="name"
{...register("name")}
placeholder="Dit fulde navn"
className="h-12"
/>
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
{...register("email")}
placeholder="din@email.dk"
className="h-12"
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon *</Label>
<Input
id="phone"
{...register("phone")}
placeholder="12345678"
className="h-12"
maxLength={8}
/>
{errors.phone && (
<p className="text-sm text-destructive">{errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="remarks">
Bemærkninger{" "}
<span className="font-normal text-muted-foreground">(valgfrit)</span>
</Label>
<Textarea
id="remarks"
{...register("remarks")}
placeholder="Særlige ønsker eller spørgsmål"
rows={3}
className="resize-none"
/>
</div>
</div>
</div>
)}
{/* Navigation */}
<div className="mt-8 flex gap-3">
{currentStep > 1 && (
<Button type="button" variant="outline" onClick={prevStep} className="h-12 flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Tilbage
</Button>
)}
{currentStep < 4 ? (
<Button
type="button"
onClick={nextStep}
className="h-12 flex-1 bg-secondary text-secondary-foreground hover:bg-secondary/90"
>
Næste
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button
type="submit"
className="h-12 flex-1 bg-secondary text-secondary-foreground hover:bg-secondary/90"
disabled={isCalculating}
>
{isCalculating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Beregner...
</>
) : (
"Se mit prisoverslag"
)}
</Button>
)}
</div>
</form>
</div>
</div>
)
}

View file

@ -0,0 +1,134 @@
"use client"
import { formatPrice } from "@/lib/calculations"
import { type StoredQuote, type QuoteStatus } from "@/lib/db"
import { QuoteCard } from "./quote-card"
interface KanbanColumnProps {
title: string
status: QuoteStatus
quotes: StoredQuote[]
showTotal?: boolean
onStatusChange: (id: number, status: QuoteStatus) => void
onReject: (quote: StoredQuote) => void
}
function KanbanColumn({
title,
status,
quotes,
showTotal = true,
onStatusChange,
onReject,
}: KanbanColumnProps) {
const total = quotes.reduce((sum, q) => sum + (q.totalInclVat || 0), 0)
const bgColor = {
new: "bg-blue-50/50 border-blue-200",
contacted: "bg-amber-50/50 border-amber-200",
accepted: "bg-green-50/50 border-green-200",
rejected: "bg-gray-50/50 border-gray-200",
}[status]
const headerColor = {
new: "text-blue-700",
contacted: "text-amber-700",
accepted: "text-green-700",
rejected: "text-gray-500",
}[status]
return (
<div className={`flex flex-col rounded-xl border ${bgColor}`}>
{/* Fixed header */}
<div className="flex-shrink-0 border-b border-inherit p-3">
<div className="flex items-center justify-between">
<h2 className={`font-semibold ${headerColor}`}>{title}</h2>
<span className={`text-sm font-medium ${headerColor}`}>{quotes.length}</span>
</div>
{showTotal && quotes.length > 0 && (
<div className="mt-1 text-sm text-muted-foreground">
Værdi: <span className="font-medium">{formatPrice(Math.round(total))}</span>
</div>
)}
</div>
{/* Scrollable content */}
<div
className="flex-1 space-y-2 overflow-y-auto p-2"
style={{ maxHeight: "calc(100vh - 220px)" }}
>
{quotes.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">Ingen tilbud</p>
) : (
quotes.map((quote) => (
<QuoteCard
key={quote.id}
quote={quote}
onStatusChange={onStatusChange}
onReject={onReject}
/>
))
)}
</div>
</div>
)
}
interface KanbanBoardProps {
quotes: StoredQuote[]
onStatusChange: (id: number, status: QuoteStatus) => void
onReject: (quote: StoredQuote) => void
rejectedCount: number
onShowRejected: () => void
}
export function KanbanBoard({
quotes,
onStatusChange,
onReject,
rejectedCount,
onShowRejected,
}: KanbanBoardProps) {
// Only show 3 main columns
const columns: { title: string; status: QuoteStatus; showTotal: boolean }[] = [
{ title: "Nye tilbud", status: "new", showTotal: true },
{ title: "Kunde kontaktet", status: "contacted", showTotal: true },
{ title: "Tilbud accepteret", status: "accepted", showTotal: true },
]
const groupedQuotes = columns.reduce(
(acc, col) => {
acc[col.status] = quotes.filter((q) => q.status === col.status)
return acc
},
{} as Record<QuoteStatus, StoredQuote[]>
)
return (
<div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{columns.map((col) => (
<KanbanColumn
key={col.status}
title={col.title}
status={col.status}
quotes={groupedQuotes[col.status] || []}
showTotal={col.showTotal}
onStatusChange={onStatusChange}
onReject={onReject}
/>
))}
</div>
{/* Rejected quotes link */}
{rejectedCount > 0 && (
<button
onClick={onShowRejected}
className="mt-4 text-sm text-muted-foreground hover:text-foreground hover:underline"
>
Se {rejectedCount} afviste tilbud
</button>
)}
</div>
)
}

View file

@ -0,0 +1,114 @@
"use client"
import { formatPrice } from "@/lib/calculations"
import { type StoredQuote, type QuoteStatus } from "@/lib/db"
import { Button } from "@/components/ui/button"
import { Phone, Check, X, ExternalLink } from "lucide-react"
interface QuoteCardProps {
quote: StoredQuote
onStatusChange: (id: number, status: QuoteStatus) => void
onReject: (quote: StoredQuote) => void
}
function formatRelativeDate(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) return "I dag"
if (diffDays === 1) return "I går"
if (diffDays < 7) return `${diffDays}d`
if (diffDays < 30) return `${Math.floor(diffDays / 7)}u`
return `${Math.floor(diffDays / 30)}m`
}
export function QuoteCard({ quote, onStatusChange, onReject }: QuoteCardProps) {
const slug = `${quote.postalCode}-${quote.id}`
const detailUrl = `/tilbud/${slug}`
return (
<div className="group flex items-center gap-2 rounded-lg border bg-white p-2 shadow-sm transition-shadow hover:shadow-md">
{/* Main content - clickable */}
<a href={detailUrl} target="_blank" rel="noopener noreferrer" className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{quote.customerName}</span>
<ExternalLink className="h-3 w-3 flex-shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100" />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{quote.postalCode}</span>
<span>·</span>
<span>{quote.area}m²</span>
<span>·</span>
<span>{formatRelativeDate(quote.createdAt)}</span>
</div>
</div>
<div className="flex-shrink-0 text-right">
<div className="text-sm font-semibold text-primary">
{quote.totalInclVat ? formatPrice(Math.round(quote.totalInclVat)) : "—"}
</div>
</div>
</div>
</a>
{/* Action buttons - always visible */}
<div className="flex flex-shrink-0 items-center gap-0.5 border-l pl-2">
<Button
size="icon"
variant="ghost"
className={`h-7 w-7 ${
quote.status === "contacted"
? "text-blue-300"
: "text-blue-600 hover:bg-blue-50 hover:text-blue-700"
}`}
onClick={(e) => {
e.stopPropagation()
if (quote.status !== "contacted") {
onStatusChange(quote.id, "contacted")
}
}}
title="Marker som kontaktet"
disabled={quote.status === "contacted"}
>
<Phone className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className={`h-7 w-7 ${
quote.status === "accepted"
? "text-green-300"
: "text-green-600 hover:bg-green-50 hover:text-green-700"
}`}
onClick={(e) => {
e.stopPropagation()
if (quote.status !== "accepted") {
onStatusChange(quote.id, "accepted")
}
}}
title="Accepter"
disabled={quote.status === "accepted"}
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={(e) => {
e.stopPropagation()
onReject(quote)
}}
title="Afvis"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,35 @@
"use client"
import { Search, X } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
interface SearchFilterProps {
value: string
onChange: (value: string) => void
}
export function SearchFilter({ value, onChange }: SearchFilterProps) {
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="Søg efter kunde, email eller postnummer..."
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-11 pl-10 pr-10"
/>
{value && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 p-0"
onClick={() => onChange("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
)
}

View file

@ -9,14 +9,11 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
"bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
@ -35,8 +32,7 @@ const buttonVariants = cva(
) )
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean
} }
@ -44,11 +40,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
) )
} }
) )

View file

@ -2,75 +2,54 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const Card = React.forwardRef< const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div
>(({ className, ...props }, ref) => ( ref={ref}
<div className={cn("rounded-xl border bg-white text-card-foreground shadow-sm", className)}
ref={ref} {...props}
className={cn( />
"rounded-xl border bg-card text-card-foreground shadow", )
className )
)}
{...props}
/>
))
Card.displayName = "Card" Card.displayName = "Card"
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<div )
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader" CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div
>(({ className, ...props }, ref) => ( ref={ref}
<div className={cn("font-semibold leading-none tracking-tight", className)}
ref={ref} {...props}
className={cn("font-semibold leading-none tracking-tight", className)} />
{...props} )
/> )
))
CardTitle.displayName = "CardTitle" CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<div )
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription" CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef< const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> )
))
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
HTMLDivElement, ({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
>(({ className, ...props }, ref) => ( )
<div )
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter" CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

104
components/ui/dialog.tsx Normal file
View file

@ -0,0 +1,104 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View file

@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "flex h-9 w-full rounded-md border border-input bg-white px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className className
)} )}
ref={ref} ref={ref}

View file

@ -10,14 +10,9 @@ const labelVariants = cva(
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<LabelPrimitive.Root <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
)) ))
Label.displayName = LabelPrimitive.Root.displayName Label.displayName = LabelPrimitive.Root.displayName

View file

@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-3 w-full overflow-hidden rounded-full bg-primary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-secondary transition-all duration-300 ease-out"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

153
components/ui/select.tsx Normal file
View file

@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-white text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

25
components/ui/slider.tsx Normal file
View file

@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-primary">
<SliderPrimitive.Range className="absolute h-full bg-secondary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-6 w-6 cursor-grab rounded-full border-2 border-secondary bg-white shadow-lg ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:cursor-grabbing disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

29
components/ui/switch.tsx Normal file
View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-secondary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View file

@ -2,21 +2,20 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const Textarea = React.forwardRef< const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
HTMLTextAreaElement, ({ className, ...props }, ref) => {
React.ComponentProps<"textarea"> return (
>(({ className, ...props }, ref) => { <textarea
return ( className={cn(
<textarea "flex min-h-[60px] w-full rounded-md border border-input bg-white px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className={cn( className
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", )}
className ref={ref}
)} {...props}
ref={ref} />
{...props} )
/> }
) )
})
Textarea.displayName = "Textarea" Textarea.displayName = "Textarea"
export { Textarea } export { Textarea }

BIN
docs/byg_trans.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
docs/company_email.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
docs/gulv.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

BIN
docs/lovable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
docs/mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
docs/new_design.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -6,11 +6,11 @@ Dette dokument beskriver prisberegningslogikken for Foam King Gulves overslagsbe
## 1. Input fra kunden ## 1. Input fra kunden
| Felt | Enhed | Interval | Beskrivelse | | Felt | Enhed | Interval | Beskrivelse |
|------|-------|----------|-------------| | ---------- | ----- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
| Gulvareal | m² | 25-300 | Areal opmåles fra indvendig væg til væg (inkl. skillevægge) | | Gulvareal | m² | 25-300 | Areal opmåles fra indvendig væg til væg (inkl. skillevægge) |
| Gulvhøjde | cm | 0-100 | Højde fra underlag til overkant af gulv (ekskl. gulvbelægning). Der fratrækkes automatisk 5 cm til betonstøbning | | Gulvhøjde | cm | 0-100 | Højde fra underlag til overkant af gulv (ekskl. gulvbelægning). Der fratrækkes automatisk 5 cm til betonstøbning |
| Postnummer | - | Dansk postnr. | Bruges til beregning af kørselsafstand fra 4550 Asnæs | | Postnummer | - | Dansk postnr. | Bruges til beregning af kørselsafstand fra 4550 Asnæs |
--- ---
@ -18,74 +18,74 @@ Dette dokument beskriver prisberegningslogikken for Foam King Gulves overslagsbe
### 2.1 Isolering ### 2.1 Isolering
| Beskrivelse | Pris | Enhed | | Beskrivelse | Pris | Enhed |
|-------------|------|-------| | ---------------------- | ------------ | ---------- |
| Isolering - materialer | 2.850 kr | pr. m³ | | Isolering - materialer | 2.850 kr | pr. m³ |
| Isolering - arbejdsløn | 880 kr | pr. m³ | | Isolering - arbejdsløn | 880 kr | pr. m³ |
| **Isolering samlet** | **3.730 kr** | **pr. m³** | | **Isolering samlet** | **3.730 kr** | **pr. m³** |
*Hvis højde = 0 (ingen isolering): Simpel arbejdsløn på 75 kr/m² tilføjes i stedet.* _Hvis højde = 0 (ingen isolering): Simpel arbejdsløn på 75 kr/m² tilføjes i stedet._
### 2.2 Gulvvarme (altid inkluderet) ### 2.2 Gulvvarme (altid inkluderet)
| Beskrivelse | Pris | Enhed | | Beskrivelse | Pris | Enhed |
|-------------|------|-------| | ---------------------- | ---------- | ---------- |
| Gulvvarme - materialer | 75 kr | pr. m² | | Gulvvarme - materialer | 75 kr | pr. m² |
| Gulvvarme - arbejdsløn | 130 kr | pr. m² | | Gulvvarme - arbejdsløn | 130 kr | pr. m² |
| **Gulvvarme samlet** | **205 kr** | **pr. m²** | | **Gulvvarme samlet** | **205 kr** | **pr. m²** |
*Gulvvarme udføres som Ø16 Pex i relevante gulvvarmekredse (ekskl. tilslutning til fordeler).* _Gulvvarme udføres som Ø16 Pex i relevante gulvvarmekredse (ekskl. tilslutning til fordeler)._
### 2.3 Syntetisk net (altid inkluderet) ### 2.3 Syntetisk net (altid inkluderet)
| Beskrivelse | Pris | Enhed | | Beskrivelse | Pris | Enhed |
|-------------|------|-------| | -------------------------- | --------- | ---------- |
| Syntetisk net - materialer | 24 kr | pr. m² | | Syntetisk net - materialer | 24 kr | pr. m² |
| Syntetisk net - arbejdsløn | 25 kr | pr. m² | | Syntetisk net - arbejdsløn | 25 kr | pr. m² |
| **Syntetisk net samlet** | **49 kr** | **pr. m²** | | **Syntetisk net samlet** | **49 kr** | **pr. m²** |
### 2.4 Flydespartel ### 2.4 Flydespartel
| Beskrivelse | Pris | Enhed | | Beskrivelse | Pris | Enhed |
|-------------|------|-------| | ------------------------- | ------ | ------ |
| Flydespartel - materialer | 450 kr | pr. m² | | Flydespartel - materialer | 450 kr | pr. m² |
| Spartelforbrug | 90 kg | pr. m² | | Spartelforbrug | 90 kg | pr. m² |
*Flydespartel støbes i 50 mm tykkelse og er egnet til svømmende trægulv og klinker.* _Flydespartel støbes i 50 mm tykkelse og er egnet til svømmende trægulv og klinker._
### 2.5 Pumpebil-tillæg ### 2.5 Pumpebil-tillæg
Tillæg baseret på den samlede spartelvægt (areal × 90 kg/m²): Tillæg baseret på den samlede spartelvægt (areal × 90 kg/m²):
| Vægtinterval | Tillæg | | Vægtinterval | Tillæg |
|--------------|--------| | -------------- | -------- |
| Over 8.000 kg | 0 kr | | Over 8.000 kg | 0 kr |
| 5.000-8.000 kg | 3.800 kr | | 5.000-8.000 kg | 3.800 kr |
| 3.000-5.000 kg | 6.000 kr | | 3.000-5.000 kg | 6.000 kr |
| 0-3.000 kg | 8.100 kr | | 0-3.000 kg | 8.100 kr |
### 2.6 Faste gebyrer ### 2.6 Faste gebyrer
| Beskrivelse | Pris | | Beskrivelse | Pris |
|-------------|------| | ---------------------------------------------- | -------- |
| Startgebyr (leje af anlæg og sikkerhedsudstyr) | 3.500 kr | | Startgebyr (leje af anlæg og sikkerhedsudstyr) | 3.500 kr |
### 2.7 Transport ### 2.7 Transport
| Beskrivelse | Pris | Enhed | | Beskrivelse | Pris | Enhed |
|-------------|------|-------| | ------------------------------------------------ | -------- | ------ |
| Kørsel (inkl. bil, diesel og mandskab) | 18,75 kr | pr. km | | Kørsel (inkl. bil, diesel og mandskab) | 18,75 kr | pr. km |
| Storebælt brotillæg (kun Fyn, postnr. 5000-5999) | 500 kr | fast | | Storebælt brotillæg (kun Fyn, postnr. 5000-5999) | 500 kr | fast |
*Afstand beregnes som tur-retur fra 4550 Asnæs til kundens adresse.* _Afstand beregnes som tur-retur fra 4550 Asnæs til kundens adresse._
### 2.8 Procenttillæg ### 2.8 Procenttillæg
| Beskrivelse | Procent | Forklaring | | Beskrivelse | Procent | Forklaring |
|-------------|---------|------------| | ----------------- | --------- | ---------------------------------------------- |
| Afdækning | 0,7% | Plast, tape mv. til afdækning af arbejdsområde | | Afdækning | 0,7% | Plast, tape mv. til afdækning af arbejdsområde |
| Affald | 0,25% | Bortskaffelse af beton, skum, plastaffald mv. | | Affald | 0,25% | Bortskaffelse af beton, skum, plastaffald mv. |
| **Samlet tillæg** | **0,95%** | Af subtotal | | **Samlet tillæg** | **0,95%** | Af subtotal |
--- ---
@ -102,6 +102,7 @@ Spartelvægt (kg) = Areal × 90
### Trin 2: Beregn komponenter ### Trin 2: Beregn komponenter
**Isolering:** **Isolering:**
``` ```
Hvis isoleringstykkelse > 0: Hvis isoleringstykkelse > 0:
Isolering = Isoleringsvolumen × 3.730 kr Isolering = Isoleringsvolumen × 3.730 kr
@ -110,21 +111,25 @@ Ellers:
``` ```
**Gulvvarme:** **Gulvvarme:**
``` ```
Gulvvarme = Areal × 205 kr Gulvvarme = Areal × 205 kr
``` ```
**Syntetisk net:** **Syntetisk net:**
``` ```
Syntetisk net = Areal × 49 kr Syntetisk net = Areal × 49 kr
``` ```
**Flydespartel:** **Flydespartel:**
``` ```
Flydespartel = Areal × 450 kr Flydespartel = Areal × 450 kr
``` ```
**Pumpebil-tillæg:** **Pumpebil-tillæg:**
``` ```
Hvis spartelvægt > 8.000 kg: Pumpebil = 0 kr Hvis spartelvægt > 8.000 kg: Pumpebil = 0 kr
Hvis spartelvægt > 5.000 kg: Pumpebil = 3.800 kr Hvis spartelvægt > 5.000 kg: Pumpebil = 3.800 kr
@ -169,6 +174,7 @@ Total inkl. moms = Total ekskl. moms × 1,25
## 4. Regneeksempel ## 4. Regneeksempel
**Input:** **Input:**
- Areal: 50 m² - Areal: 50 m²
- Højde: 20 cm - Højde: 20 cm
- Postnummer: 2100 (København) - Postnummer: 2100 (København)
@ -203,9 +209,10 @@ Total inkl. moms = 76.365,42 × 1,25 = 95.456,78 kr
``` ```
**Output til kunden:** **Output til kunden:**
> Ca. 95.500 kr inkl. moms > Ca. 95.500 kr inkl. moms
> >
> *Den endelige pris kan variere afhængigt af konkrete forhold på stedet.* > _Den endelige pris kan variere afhængigt af konkrete forhold på stedet._
--- ---
@ -221,5 +228,5 @@ Total inkl. moms = 76.365,42 × 1,25 = 95.456,78 kr
--- ---
*Dokumentversion: 1.0* _Dokumentversion: 1.0_
*Sidst opdateret: Januar 2026* _Sidst opdateret: Januar 2026_

View file

@ -3,16 +3,20 @@
## 1. Projektbeskrivelse ## 1. Projektbeskrivelse
### 1.1 Formål ### 1.1 Formål
Udvikle en online overslagsberegner til Foam King Gulve, der giver potentielle kunder et hurtigt prisestimat på gulvløsninger. Beregneren skal være tilgængelig på `beregner.foamking.dk`. Udvikle en online overslagsberegner til Foam King Gulve, der giver potentielle kunder et hurtigt prisestimat på gulvløsninger. Beregneren skal være tilgængelig på `beregner.foamking.dk`.
### 1.2 Målgruppe ### 1.2 Målgruppe
- Private husejere - Private husejere
- Sommerhusejere - Sommerhusejere
- Hovedentreprenører - Hovedentreprenører
- Bygherrer - Bygherrer
### 1.3 Scope ### 1.3 Scope
Beregneren dækker **kun gulvløsninger** med følgende komponenter: Beregneren dækker **kun gulvløsninger** med følgende komponenter:
- Isolering mellem strøer - Isolering mellem strøer
- Gulvvarme - Gulvvarme
- Syntetisk net - Syntetisk net
@ -24,20 +28,21 @@ Beregneren dækker **kun gulvløsninger** med følgende komponenter:
### 2.1 Input-felter ### 2.1 Input-felter
| Felt | Type | Validering | Påkrævet | | Felt | Type | Validering | Påkrævet |
|------|------|------------|----------| | ------------ | --------- | ------------------------------ | -------- |
| Navn | Tekst | Min. 2 tegn | Ja | | Navn | Tekst | Min. 2 tegn | Ja |
| Email | Email | Gyldig email-format | Ja | | Email | Email | Gyldig email-format | Ja |
| Telefon | Tal | 8 cifre | Ja | | Telefon | Tal | 8 cifre | Ja |
| Postnummer | Tal | 4 cifre, gyldigt dansk postnr. | Ja | | Postnummer | Tal | 4 cifre, gyldigt dansk postnr. | Ja |
| Adresse | Tekst | - | Nej | | Adresse | Tekst | - | Nej |
| Gulvareal | Tal | 25-300 m² | Ja | | Gulvareal | Tal | 25-300 m² | Ja |
| Gulvhøjde | Tal | 0-100 cm | Ja | | Gulvhøjde | Tal | 0-100 cm | Ja |
| Bemærkninger | Tekstfelt | - | Nej | | Bemærkninger | Tekstfelt | - | Nej |
### 2.2 Output ### 2.2 Output
Beregneren skal vise: Beregneren skal vise:
1. **Prisestimat**: "Ca. X kr inkl. moms" 1. **Prisestimat**: "Ca. X kr inkl. moms"
2. **Disclaimer**: Note om at prisen er vejledende og kan variere 2. **Disclaimer**: Note om at prisen er vejledende og kan variere
3. **Kontaktmulighed**: Mulighed for at anmode om et bindende tilbud 3. **Kontaktmulighed**: Mulighed for at anmode om et bindende tilbud
@ -45,6 +50,7 @@ Beregneren skal vise:
### 2.3 Beregningslogik ### 2.3 Beregningslogik
Se [prisbeskrivelse.md](prisbeskrivelse.md) for komplet dokumentation af: Se [prisbeskrivelse.md](prisbeskrivelse.md) for komplet dokumentation af:
- Alle priskonstanter - Alle priskonstanter
- Beregningsformler - Beregningsformler
- Trin-for-trin beregningsproces - Trin-for-trin beregningsproces
@ -59,6 +65,7 @@ Se [prisbeskrivelse.md](prisbeskrivelse.md) for komplet dokumentation af:
**Krav:** Præcis beregning af kørselsafstand fra 4550 Asnæs til kundens adresse. **Krav:** Præcis beregning af kørselsafstand fra 4550 Asnæs til kundens adresse.
**Mulige løsninger:** **Mulige løsninger:**
1. **Google Maps Distance Matrix API** 1. **Google Maps Distance Matrix API**
- Præcis afstand - Præcis afstand
- Koster pr. request - Koster pr. request
@ -78,13 +85,13 @@ Se [prisbeskrivelse.md](prisbeskrivelse.md) for komplet dokumentation af:
Foam King arbejder primært i følgende områder: Foam King arbejder primært i følgende områder:
| Postnummer-interval | Område | Bro/færge-tillæg | | Postnummer-interval | Område | Bro/færge-tillæg |
|---------------------|--------|------------------| | ------------------- | --------------- | ------------------ |
| 4000-4999 | Vestsjælland | Ingen | | 4000-4999 | Vestsjælland | Ingen |
| 2000-2999 | København | Ingen | | 2000-2999 | København | Ingen |
| 3000-3999 | Nordsjælland | Ingen | | 3000-3999 | Nordsjælland | Ingen |
| 4800-4899 | Lolland-Falster | Ingen | | 4800-4899 | Lolland-Falster | Ingen |
| 5000-5999 | Fyn | 500 kr (Storebælt) | | 5000-5999 | Fyn | 500 kr (Storebælt) |
### 3.3 Hosting ### 3.3 Hosting
@ -116,15 +123,19 @@ Foam King arbejder primært i følgende områder:
## 5. Dataflow ## 5. Dataflow
### 5.1 Ved prisberegning (kun visning) ### 5.1 Ved prisberegning (kun visning)
- Ingen data gemmes - Ingen data gemmes
- Beregning sker i browseren - Beregning sker i browseren
### 5.2 Ved tilbudsanmodning ### 5.2 Ved tilbudsanmodning
Data sendes til: Data sendes til:
1. Email til `info@foamking.dk` med kalkulationsskema 1. Email til `info@foamking.dk` med kalkulationsskema
2. (Valgfrit) Integration med eksisterende system 2. (Valgfrit) Integration med eksisterende system
Indhold i email: Indhold i email:
- Kundens kontaktoplysninger - Kundens kontaktoplysninger
- Indtastede værdier (areal, højde, postnr.) - Indtastede værdier (areal, højde, postnr.)
- Beregnet prisestimat - Beregnet prisestimat
@ -138,20 +149,20 @@ Følgende punkter skal afklares før/under udvikling:
### 6.1 Forretningslogik ### 6.1 Forretningslogik
| Nr. | Spørgsmål | Status | | Nr. | Spørgsmål | Status |
|-----|-----------|--------| | --- | ------------------------------------------------------------------------------ | -------------------- |
| 1 | Skal kunden kunne fravælge gulvvarme? | Afventer | | 1 | Skal kunden kunne fravælge gulvvarme? | Afventer |
| 2 | Skal der være mulighed for forskellige gulvbelægninger (påvirker sparteltype)? | Afventer | | 2 | Skal der være mulighed for forskellige gulvbelægninger (påvirker sparteltype)? | Afventer |
| 3 | Hvad er den præcise Storebælt-pris? (Antaget 500 kr) | Afventer bekræftelse | | 3 | Hvad er den præcise Storebælt-pris? (Antaget 500 kr) | Afventer bekræftelse |
| 4 | Skal opgaver uden for dækningsområdet afvises eller vises med advarsel? | Afventer | | 4 | Skal opgaver uden for dækningsområdet afvises eller vises med advarsel? | Afventer |
### 6.2 Teknisk ### 6.2 Teknisk
| Nr. | Spørgsmål | Status | | Nr. | Spørgsmål | Status |
|-----|-----------|--------| | --- | ------------------------------------------------------- | -------- |
| 5 | Hvilken afstands-API foretrækkes? | Afventer | | 5 | Hvilken afstands-API foretrækkes? | Afventer |
| 6 | Skal beregnerdata gemmes i database? | Afventer | | 6 | Skal beregnerdata gemmes i database? | Afventer |
| 7 | Er der eksisterende CRM/system der skal integreres med? | Afventer | | 7 | Er der eksisterende CRM/system der skal integreres med? | Afventer |
--- ---
@ -159,15 +170,15 @@ Følgende punkter skal afklares før/under udvikling:
### 7.1 Faste priser ### 7.1 Faste priser
| Komponent | Samlet pris | Enhed | | Komponent | Samlet pris | Enhed |
|-----------|-------------|-------| | --------------- | ----------- | ------ |
| Isolering | 3.730 kr | pr. m³ | | Isolering | 3.730 kr | pr. m³ |
| Gulvvarme | 205 kr | pr. m² | | Gulvvarme | 205 kr | pr. m² |
| Syntetisk net | 49 kr | pr. m² | | Syntetisk net | 49 kr | pr. m² |
| Flydespartel | 450 kr | pr. m² | | Flydespartel | 450 kr | pr. m² |
| Startgebyr | 3.500 kr | fast | | Startgebyr | 3.500 kr | fast |
| Kørsel | 18,75 kr | pr. km | | Kørsel | 18,75 kr | pr. km |
| Storebælt (Fyn) | 500 kr | fast | | Storebælt (Fyn) | 500 kr | fast |
### 7.2 Variable tillæg ### 7.2 Variable tillæg
@ -180,6 +191,7 @@ Følgende punkter skal afklares før/under udvikling:
| < 3.000 kg | 8.100 kr | | < 3.000 kg | 8.100 kr |
**Procenttillæg:** **Procenttillæg:**
- Afdækning: 0,7% - Afdækning: 0,7%
- Affald: 0,25% - Affald: 0,25%
@ -205,5 +217,5 @@ Pris = (Isolering + Gulvvarme + Net + Spartel + Pumpebil + Startgebyr)
--- ---
*Dokumentversion: 1.0* _Dokumentversion: 1.0_
*Sidst opdateret: Januar 2026* _Sidst opdateret: Januar 2026_

BIN
docs/tilbud.pdf Normal file

Binary file not shown.

116
lib/auth.ts Normal file
View file

@ -0,0 +1,116 @@
import bcrypt from "bcrypt"
import { cookies } from "next/headers"
import {
createSession,
deleteSession,
getSession,
getUserByEmail,
getUserById,
createUser as dbCreateUser,
cleanExpiredSessions,
type User,
} from "./db"
const SALT_ROUNDS = 10
const SESSION_COOKIE_NAME = "session"
const SESSION_DURATION_DAYS = 7
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
function generateSessionId(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}
export async function login(
email: string,
password: string
): Promise<{ success: true; user: User } | { success: false; error: string }> {
const user = getUserByEmail(email)
if (!user) {
return { success: false, error: "Forkert email eller adgangskode" }
}
const validPassword = await verifyPassword(password, user.passwordHash)
if (!validPassword) {
return { success: false, error: "Forkert email eller adgangskode" }
}
// Clean up old sessions periodically
cleanExpiredSessions()
// Create new session
const sessionId = generateSessionId()
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + SESSION_DURATION_DAYS)
createSession(sessionId, user.id, expiresAt)
// Set cookie
const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: expiresAt,
path: "/",
})
return {
success: true,
user: { id: user.id, email: user.email, name: user.name, createdAt: user.createdAt },
}
}
export async function logout(): Promise<void> {
const cookieStore = await cookies()
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
if (sessionId) {
deleteSession(sessionId)
cookieStore.delete(SESSION_COOKIE_NAME)
}
}
export async function getCurrentUser(): Promise<User | null> {
const cookieStore = await cookies()
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
if (!sessionId) return null
const session = getSession(sessionId)
if (!session) return null
// Check if session is expired
if (session.expiresAt < new Date()) {
deleteSession(sessionId)
return null
}
return getUserById(session.userId)
}
export async function isAuthenticated(): Promise<boolean> {
const user = await getCurrentUser()
return user !== null
}
// Helper to create users (run from CLI or seed script)
export async function createUserWithPassword(
email: string,
password: string,
name: string
): Promise<User> {
const passwordHash = await hashPassword(password)
return dbCreateUser(email, passwordHash, name)
}

View file

@ -1,10 +1,22 @@
import { PRICES, PUMP_TRUCK_FEES, CONSTRAINTS, COVERAGE_AREAS } from "./constants" import {
PRICES,
PUMP_TRUCK_FEES,
CONSTRAINTS,
COVERAGE_AREAS,
FLOORING_TYPES,
type FlooringType,
} from "./constants"
export interface CalculationInput { export interface CalculationInput {
area: number // m² area: number // m²
height: number // cm height: number // cm
postalCode: string postalCode: string
distance: number // km (round trip) distance: number // km (round trip)
// Optional components
includeInsulation?: boolean // default: true
includeFloorHeating?: boolean // default: true
includeCompound?: boolean // default: true
flooringType?: FlooringType // default: KLINKER
} }
export interface CalculationDetails { export interface CalculationDetails {
@ -14,6 +26,12 @@ export interface CalculationDetails {
postalCode: string postalCode: string
distance: number distance: number
// Optional component selections
includeInsulation: boolean
includeFloorHeating: boolean
includeCompound: boolean
flooringType: FlooringType
// Calculated values // Calculated values
insulationThickness: number // cm insulationThickness: number // cm
insulationVolume: number // m³ insulationVolume: number // m³
@ -43,16 +61,29 @@ export interface CalculationDetails {
totalInclVat: number totalInclVat: number
} }
export function calculateInsulation(area: number, height: number): { export function calculateInsulation(
area: number,
height: number
): {
thickness: number thickness: number
volume: number volume: number
volumePrice: number
baseLabor: number
price: number price: number
} { } {
const thickness = Math.max(0, height - CONSTRAINTS.CONCRETE_THICKNESS) const thickness = Math.max(0, height - CONSTRAINTS.CONCRETE_THICKNESS)
const volume = area * (thickness / 100) const volume = area * (thickness / 100)
const price = thickness > 0 ? volume * PRICES.INSULATION_TOTAL : area * PRICES.SIMPLE_LABOR
return { thickness, volume, price } // Volume-based cost (materials + labor per m³)
const volumePrice = thickness > 0 ? volume * PRICES.INSULATION_TOTAL_PER_M3 : 0
// Base labor cost (always applied per m² when insulation is included)
const baseLabor = area * PRICES.INSULATION_BASE_LABOR
// Total insulation price
const price = volumePrice + baseLabor
return { thickness, volume, volumePrice, baseLabor, price }
} }
export function calculatePumpTruckFee(weight: number): number { export function calculatePumpTruckFee(weight: number): number {
@ -73,22 +104,38 @@ export function getBridgeFee(postalCode: string): number {
} }
export function calculatePrice(input: CalculationInput): CalculationDetails { export function calculatePrice(input: CalculationInput): CalculationDetails {
const { area, height, postalCode, distance } = input const {
area,
height,
postalCode,
distance,
includeInsulation = true,
includeFloorHeating = true,
includeCompound = true,
flooringType = "STANDARD",
} = input
// Step 1: Calculate derived values // Step 1: Calculate derived values
const insulation = calculateInsulation(area, height) const insulation = calculateInsulation(area, height)
const compoundWeight = area * PRICES.COMPOUND_WEIGHT_PER_M2 const compoundWeight = includeCompound ? area * PRICES.COMPOUND_WEIGHT_PER_M2 : 0
// Step 2: Calculate components // Get flooring type multiplier
const floorHeating = area * PRICES.FLOOR_HEATING_TOTAL const flooringConfig = FLOORING_TYPES[flooringType]
const syntheticNet = area * PRICES.SYNTHETIC_NET_TOTAL const compoundMultiplier = flooringConfig?.compoundMultiplier ?? 1.0
const selfLevelingCompound = area * PRICES.SELF_LEVELING_COMPOUND
const pumpTruckFee = calculatePumpTruckFee(compoundWeight) // Step 2: Calculate components (only if included)
const insulationPrice = includeInsulation ? insulation.price : 0
const floorHeating = includeFloorHeating ? area * PRICES.FLOOR_HEATING_TOTAL : 0
const syntheticNet = includeFloorHeating ? area * PRICES.SYNTHETIC_NET_TOTAL : 0 // Net only with heating
const selfLevelingCompound = includeCompound
? area * PRICES.SELF_LEVELING_COMPOUND * compoundMultiplier
: 0
const pumpTruckFee = includeCompound ? calculatePumpTruckFee(compoundWeight) : 0
const startFee = PRICES.START_FEE const startFee = PRICES.START_FEE
// Step 3: Calculate subtotal // Step 3: Calculate subtotal
const subtotal = const subtotal =
insulation.price + floorHeating + syntheticNet + selfLevelingCompound + pumpTruckFee + startFee insulationPrice + floorHeating + syntheticNet + selfLevelingCompound + pumpTruckFee + startFee
// Step 4: Calculate percentage fees // Step 4: Calculate percentage fees
const coveringFee = subtotal * PRICES.COVERING_PERCENTAGE const coveringFee = subtotal * PRICES.COVERING_PERCENTAGE
@ -111,13 +158,19 @@ export function calculatePrice(input: CalculationInput): CalculationDetails {
postalCode, postalCode,
distance, distance,
// Optional component selections
includeInsulation,
includeFloorHeating,
includeCompound,
flooringType,
// Calculated values // Calculated values
insulationThickness: insulation.thickness, insulationThickness: insulation.thickness,
insulationVolume: insulation.volume, insulationVolume: insulation.volume,
compoundWeight, compoundWeight,
// Component prices // Component prices
insulation: insulation.price, insulation: insulationPrice,
floorHeating, floorHeating,
syntheticNet, syntheticNet,
selfLevelingCompound, selfLevelingCompound,

View file

@ -1,9 +1,10 @@
export const PRICES = { export const PRICES = {
// Isolering // Isolering (per m³ volume)
INSULATION_MATERIALS: 2850, // kr/m³ INSULATION_MATERIALS: 2850, // kr/m³
INSULATION_LABOR: 880, // kr/m³ INSULATION_LABOR_PER_M3: 880, // kr/m³
INSULATION_TOTAL: 3730, // kr/m³ INSULATION_TOTAL_PER_M3: 3730, // kr/m³
SIMPLE_LABOR: 75, // kr/m² (når højde = 0) // Base insulation labor (always applied per m² area)
INSULATION_BASE_LABOR: 75, // kr/m² - always added when insulation is included
// Gulvvarme (altid inkluderet) // Gulvvarme (altid inkluderet)
FLOOR_HEATING_MATERIALS: 75, // kr/m² FLOOR_HEATING_MATERIALS: 75, // kr/m²
@ -35,23 +36,42 @@ export const PRICES = {
VAT: 0.25, // 25% VAT: 0.25, // 25%
} as const } as const
// Pumpebil fees include both materials and labor (from rene.pdf)
export const PUMP_TRUCK_FEES = [ export const PUMP_TRUCK_FEES = [
{ minWeight: 8000, fee: 0 }, { minWeight: 8000, materialFee: 0, laborFee: 8800, fee: 8800 }, // >8000 kg
{ minWeight: 5000, fee: 3800 }, { minWeight: 5000, materialFee: 3800, laborFee: 8000, fee: 11800 }, // 5000-8000 kg
{ minWeight: 3000, fee: 6000 }, { minWeight: 3000, materialFee: 6000, laborFee: 7000, fee: 13000 }, // 3000-5000 kg
{ minWeight: 0, fee: 8100 }, { minWeight: 0, materialFee: 8100, laborFee: 7000, fee: 15100 }, // 0-3000 kg
] as const ] as const
export const CONSTRAINTS = { export const CONSTRAINTS = {
MIN_AREA: 25, // m² MIN_AREA: 25, // m²
MAX_AREA: 300, // m² MAX_AREA: 250, // m²
MIN_HEIGHT: 0, // cm MIN_HEIGHT: 8, // cm
MAX_HEIGHT: 100, // cm MAX_HEIGHT: 100, // cm
CONCRETE_THICKNESS: 5, // cm (fratrækkes fra højde) CONCRETE_THICKNESS: 5, // cm (fratrækkes fra højde)
HOME_POSTAL_CODE: "4550", // Asnæs HOME_POSTAL_CODE: "4550", // Asnæs
HOME_CITY: "Asnæs", HOME_CITY: "Asnæs",
} as const } as const
// Gulvbelægningstyper - påvirker valg af gulvspartel
export const FLOORING_TYPES = {
STANDARD: {
id: "standard",
name: "Klinker / Svømmende trægulv",
description: "Fliser, klinker eller trægulv der ikke limes",
compoundMultiplier: 1.0, // Standard compound
},
GLUED_WOOD: {
id: "glued_wood",
name: "Limet trægulv",
description: "Trægulv der limes fast til underlaget",
compoundMultiplier: 1.28, // Premium compound kræves (+28%)
},
} as const
export type FlooringType = keyof typeof FLOORING_TYPES
export const COVERAGE_AREAS = { export const COVERAGE_AREAS = {
WEST_ZEALAND: { start: 4000, end: 4999, bridgeFee: 0 }, WEST_ZEALAND: { start: 4000, end: 4999, bridgeFee: 0 },
COPENHAGEN: { start: 2000, end: 2999, bridgeFee: 0 }, COPENHAGEN: { start: 2000, end: 2999, bridgeFee: 0 },

272
lib/db.ts Normal file
View file

@ -0,0 +1,272 @@
import Database from "better-sqlite3"
import path from "path"
// Database file stored in project root
const DB_PATH = path.join(process.cwd(), "data", "quotes.db")
// Ensure data directory exists
import fs from "fs"
const dataDir = path.dirname(DB_PATH)
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true })
}
const db = new Database(DB_PATH)
// Initialize database schema
db.exec(`
CREATE TABLE IF NOT EXISTS quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
postal_code TEXT NOT NULL,
address TEXT,
area REAL NOT NULL,
height REAL NOT NULL,
include_floor_heating INTEGER NOT NULL DEFAULT 1,
flooring_type TEXT NOT NULL DEFAULT 'STANDARD',
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT NOT NULL,
remarks TEXT,
total_excl_vat REAL,
total_incl_vat REAL,
status TEXT NOT NULL DEFAULT 'new',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`)
// Migration: Add status column if it doesn't exist
try {
db.exec("ALTER TABLE quotes ADD COLUMN status TEXT NOT NULL DEFAULT 'new'")
} catch {
// Column already exists
}
// Migration: Add email_opened_at column for tracking
try {
db.exec("ALTER TABLE quotes ADD COLUMN email_opened_at TEXT")
} catch {
// Column already exists
}
// Users table for authentication
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`)
// Sessions table for login sessions
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`)
// Start IDs at 1000
const countResult = db.prepare("SELECT COUNT(*) as count FROM quotes").get() as { count: number }
if (countResult.count === 0) {
db.exec(
"INSERT INTO quotes (id, postal_code, area, height, customer_name, customer_email, customer_phone) VALUES (999, '0000', 0, 0, 'init', 'init', 'init')"
)
db.exec("DELETE FROM quotes WHERE id = 999")
// Reset autoincrement to start at 1000
db.exec("UPDATE sqlite_sequence SET seq = 999 WHERE name = 'quotes'")
}
export interface QuoteInput {
postalCode: string
address?: string
area: number
height: number
includeFloorHeating: boolean
flooringType: string
customerName: string
customerEmail: string
customerPhone: string
remarks?: string
totalExclVat?: number
totalInclVat?: number
}
export type QuoteStatus = "new" | "contacted" | "accepted" | "rejected"
export interface StoredQuote extends QuoteInput {
id: number
status: QuoteStatus
createdAt: string
emailOpenedAt: string | null
}
export function saveQuote(quote: QuoteInput): { id: number; slug: string } {
const stmt = db.prepare(`
INSERT INTO quotes (
postal_code, address, area, height, include_floor_heating, flooring_type,
customer_name, customer_email, customer_phone, remarks, total_excl_vat, total_incl_vat
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const result = stmt.run(
quote.postalCode,
quote.address || null,
quote.area,
quote.height,
quote.includeFloorHeating ? 1 : 0,
quote.flooringType,
quote.customerName,
quote.customerEmail,
quote.customerPhone,
quote.remarks || null,
quote.totalExclVat || null,
quote.totalInclVat || null
)
const id = result.lastInsertRowid as number
const slug = `${quote.postalCode}-${id}`
return { id, slug }
}
export function getQuoteBySlug(slug: string): StoredQuote | null {
const match = slug.match(/^(\d{4})-(\d+)$/)
if (!match) return null
const [, postalCode, idStr] = match
const id = parseInt(idStr, 10)
const stmt = db.prepare("SELECT * FROM quotes WHERE id = ? AND postal_code = ?")
const row = stmt.get(id, postalCode) as any
if (!row) return null
return rowToQuote(row)
}
export function getQuoteById(id: number): StoredQuote | null {
const stmt = db.prepare("SELECT * FROM quotes WHERE id = ?")
const row = stmt.get(id) as any
if (!row) return null
return rowToQuote(row)
}
export function getAllQuotes(): StoredQuote[] {
const stmt = db.prepare("SELECT * FROM quotes ORDER BY id DESC")
const rows = stmt.all() as any[]
return rows.map(rowToQuote)
}
export function updateQuoteStatus(id: number, status: QuoteStatus): boolean {
const stmt = db.prepare("UPDATE quotes SET status = ? WHERE id = ?")
const result = stmt.run(status, id)
return result.changes > 0
}
export function markEmailOpened(id: number): boolean {
// Only update if not already set (first open)
const stmt = db.prepare(
"UPDATE quotes SET email_opened_at = ? WHERE id = ? AND email_opened_at IS NULL"
)
const result = stmt.run(new Date().toISOString(), id)
return result.changes > 0
}
function rowToQuote(row: any): StoredQuote {
return {
id: row.id,
postalCode: row.postal_code,
address: row.address,
area: row.area,
height: row.height,
includeFloorHeating: row.include_floor_heating === 1,
flooringType: row.flooring_type,
customerName: row.customer_name,
customerEmail: row.customer_email,
customerPhone: row.customer_phone,
remarks: row.remarks,
totalExclVat: row.total_excl_vat,
totalInclVat: row.total_incl_vat,
status: row.status || "new",
createdAt: row.created_at,
emailOpenedAt: row.email_opened_at || null,
}
}
// User management
export interface User {
id: number
email: string
name: string
createdAt: string
}
export interface UserWithPassword extends User {
passwordHash: string
}
export function createUser(email: string, passwordHash: string, name: string): User {
const stmt = db.prepare("INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)")
const result = stmt.run(email, passwordHash, name)
return {
id: result.lastInsertRowid as number,
email,
name,
createdAt: new Date().toISOString(),
}
}
export function getUserByEmail(email: string): UserWithPassword | null {
const stmt = db.prepare("SELECT * FROM users WHERE email = ?")
const row = stmt.get(email) as any
if (!row) return null
return {
id: row.id,
email: row.email,
name: row.name,
passwordHash: row.password_hash,
createdAt: row.created_at,
}
}
export function getUserById(id: number): User | null {
const stmt = db.prepare("SELECT id, email, name, created_at FROM users WHERE id = ?")
const row = stmt.get(id) as any
if (!row) return null
return {
id: row.id,
email: row.email,
name: row.name,
createdAt: row.created_at,
}
}
// Session management
export function createSession(sessionId: string, userId: number, expiresAt: Date): void {
const stmt = db.prepare("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)")
stmt.run(sessionId, userId, expiresAt.toISOString())
}
export function getSession(sessionId: string): { userId: number; expiresAt: Date } | null {
const stmt = db.prepare("SELECT user_id, expires_at FROM sessions WHERE id = ?")
const row = stmt.get(sessionId) as any
if (!row) return null
return {
userId: row.user_id,
expiresAt: new Date(row.expires_at),
}
}
export function deleteSession(sessionId: string): void {
const stmt = db.prepare("DELETE FROM sessions WHERE id = ?")
stmt.run(sessionId)
}
export function cleanExpiredSessions(): void {
const stmt = db.prepare("DELETE FROM sessions WHERE expires_at < ?")
stmt.run(new Date().toISOString())
}

View file

@ -55,6 +55,18 @@ export const POSTAL_CODE_DISTANCES: Record<string, number> = {
// Default distances based on first two digits of postal code // Default distances based on first two digits of postal code
const DEFAULT_DISTANCES: Record<string, number> = { const DEFAULT_DISTANCES: Record<string, number> = {
// København centrum (1000-1999)
"10": 200,
"11": 200,
"12": 200,
"13": 200,
"14": 200,
"15": 200,
"16": 200,
"17": 200,
"18": 200,
"19": 200,
// København (2000-2999)
"20": 200, // København området "20": 200, // København området
"21": 206, "21": 206,
"22": 208, "22": 208,
@ -109,6 +121,8 @@ export function getDistance(postalCode: string): number {
// If still no match, estimate based on region // If still no match, estimate based on region
const firstDigit = postalCode[0] const firstDigit = postalCode[0]
switch (firstDigit) { switch (firstDigit) {
case "1":
return 200 // København centrum average
case "2": case "2":
return 190 // København average return 190 // København average
case "3": case "3":
@ -123,23 +137,9 @@ export function getDistance(postalCode: string): number {
} }
export function isInCoverageArea(postalCode: string): boolean { export function isInCoverageArea(postalCode: string): boolean {
const firstDigit = postalCode[0]
const postalNumber = parseInt(postalCode) const postalNumber = parseInt(postalCode)
// Coverage area: 0-5999 (Sjælland, Lolland-Falster, Fyn)
// Check main coverage areas return postalNumber >= 0 && postalNumber <= 5999
if (["2", "3", "4", "5"].includes(firstDigit)) {
// Special check for Lolland-Falster (4800-4899)
if (postalNumber >= 4800 && postalNumber <= 4899) {
return true
}
// Exclude other 4900+ areas
if (postalNumber >= 4900 && postalNumber < 5000) {
return false
}
return true
}
return false
} }
export function validateDanishPostalCode(postalCode: string): boolean { export function validateDanishPostalCode(postalCode: string): boolean {

34
middleware.ts Normal file
View file

@ -0,0 +1,34 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
// Routes that require authentication
const protectedPaths = ["/dashboard", "/historik", "/admin"]
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Check if path requires authentication
const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path))
if (!isProtectedPath) {
return NextResponse.next()
}
// Check for session cookie
const sessionCookie = request.cookies.get("session")
if (!sessionCookie?.value) {
// Redirect to login
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
}
// Cookie exists - let the page validate the session
// (Session validation happens server-side in the page)
return NextResponse.next()
}
export const config = {
matcher: ["/dashboard/:path*", "/historik/:path*", "/admin/:path*"],
}

View file

@ -1,4 +1,4 @@
import type { NextConfig } from 'next' import type { NextConfig } from "next"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */

10013
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,41 +3,52 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev -p 3001 -H 0.0.0.0",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start -p 3001 -H 0.0.0.0",
"lint": "next lint", "lint": "next lint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "0.562.0",
"next": "16.1.1",
"nodemailer": "^7.0.12",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"next": "16.1.1", "react-hook-form": "7.70.0",
"clsx": "2.2.1", "tailwind-merge": "3.4.0",
"tailwind-merge": "2.7.0", "zod": "4.3.5"
"lucide-react": "0.483.0",
"class-variance-authority": "0.7.1",
"@radix-ui/react-label": "2.1.2",
"@radix-ui/react-slot": "1.1.1",
"react-hook-form": "7.55.1",
"@hookform/resolvers": "3.10.2",
"zod": "3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "3.3.3",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "25.0.5", "@types/node": "25.0.5",
"@types/nodemailer": "^7.0.5",
"@types/react": "19.2.8", "@types/react": "19.2.8",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"typescript": "5.9.3",
"tailwindcss": "3.4.17",
"postcss": "8.5.6",
"autoprefixer": "10.4.23", "autoprefixer": "10.4.23",
"eslint": "9.39.2", "eslint": "9.39.2",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"@eslint/eslintrc": "3.3.3", "postcss": "8.5.6",
"prettier": "3.7.4", "prettier": "3.7.4",
"prettier-plugin-tailwindcss": "0.7.2", "prettier-plugin-tailwindcss": "0.7.2",
"tailwindcss-animate": "1.0.7" "tailwindcss": "3.4.17",
"tailwindcss-animate": "1.0.7",
"typescript": "5.9.3"
} }
} }

BIN
public/byg_trans.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
public/dansk_kvalitet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/gulv.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View file

@ -3,10 +3,10 @@ import type { Config } from "tailwindcss"
export default { export default {
darkMode: ["class"], darkMode: ["class"],
content: [ content: [
'./pages/**/*.{ts,tsx}', "./pages/**/*.{ts,tsx}",
'./components/**/*.{ts,tsx}', "./components/**/*.{ts,tsx}",
'./app/**/*.{ts,tsx}', "./app/**/*.{ts,tsx}",
'./src/**/*.{ts,tsx}', "./src/**/*.{ts,tsx}",
], ],
prefix: "", prefix: "",
theme: { theme: {

View file

@ -10,7 +10,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@ -19,8 +19,15 @@
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"]
} },
"target": "ES2017"
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }