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

5
.gitignore vendored
View file

@ -32,4 +32,7 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# database
/data/

View file

@ -6,4 +6,4 @@
"printWidth": 100, "printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx", "cn"] "tailwindFunctions": ["clsx", "cn"]
} }

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,20 +50,24 @@ 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
- 4800-4899: Lolland-Falster - 4800-4899: Lolland-Falster
- 5000-5999: Funen (+500 kr Great Belt bridge fee) - 5000-5999: Funen (+500 kr Great Belt bridge fee)
@ -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
@ -140,4 +150,4 @@ Test with examples from `prisbeskrivelse.md`:
- All prices exclude VAT unless specified - All prices exclude VAT unless specified
- The calculator provides estimates only - final quotes require on-site inspection - The calculator provides estimates only - final quotes require on-site inspection
- Focus on Zealand, Lolland-Falster, and Funen regions - Focus on Zealand, Lolland-Falster, and Funen regions
- The domain `beregner.foamking.dk` points to `185.158.133.1` - The domain `beregner.foamking.dk` points to `185.158.133.1`

View file

@ -26,7 +26,7 @@ Kalkulatoren beregner priser baseret på:
- **Isolering**: 3.730 kr/m³ (eller 75 kr/m² simpel arbejdsløn) - **Isolering**: 3.730 kr/m³ (eller 75 kr/m² simpel arbejdsløn)
- **Gulvvarme**: 205 kr/m² (altid inkluderet) - **Gulvvarme**: 205 kr/m² (altid inkluderet)
- **Syntetisk net**: 49 kr/m² (altid inkluderet) - **Syntetisk net**: 49 kr/m² (altid inkluderet)
- **Flydespartel**: 450 kr/m² (90 kg/m²) - **Flydespartel**: 450 kr/m² (90 kg/m²)
- **Pumpebil-tillæg**: 0-8.100 kr baseret på spartelvægt - **Pumpebil-tillæg**: 0-8.100 kr baseret på spartelvægt
- **Startgebyr**: 3.500 kr fast - **Startgebyr**: 3.500 kr fast
@ -67,7 +67,7 @@ npm run dev
# Build for production # Build for production
npm run build npm run build
# Start production server # Start production server
npm start npm start
# Lint code # Lint code
@ -89,7 +89,7 @@ Ingen environment variables er nødvendige for MVP version. I produktion:
```bash ```bash
# Email service configuration # Email service configuration
SMTP_HOST=smtp.example.com SMTP_HOST=smtp.example.com
SMTP_USER=user@example.com SMTP_USER=user@example.com
SMTP_PASS=password SMTP_PASS=password
# Or use a service like SendGrid, AWS SES, etc. # Or use a service like SendGrid, AWS SES, etc.
@ -106,25 +106,27 @@ Projektet bruger en forudberegnet tabel over afstande fra 4550 Asnæs til danske
## 🎯 Dækningsområde ## 🎯 Dækningsområde
- **Sjælland**: Postnummer 2000-4999 - **Sjælland**: Postnummer 2000-4999
- **Lolland-Falster**: Postnummer 4800-4899 - **Lolland-Falster**: Postnummer 4800-4899
- **Fyn**: Postnummer 5000-5999 (+500 kr Storebælt) - **Fyn**: Postnummer 5000-5999 (+500 kr Storebælt)
## 📱 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
- Procenttillæg og momsberegning - Procenttillæg og momsberegning
## 🧪 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)
- **Forventet resultat**: Ca. 95.500 kr inkl. moms - **Forventet resultat**: Ca. 95.500 kr inkl. moms
## 📄 Licens ## 📄 Licens
Proprietary - Foam King Gulve Proprietary - Foam King Gulve

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
if (!customerInfo || !calculationDetails) {
return NextResponse.json({ error: "Manglende data" }, { status: 400 })
}
// Save quote to database
const { id: quoteId, slug } = saveQuote({
postalCode: customerInfo.postalCode,
address: customerInfo.address,
area: calculationDetails.area,
height: calculationDetails.height,
includeFloorHeating: calculationDetails.includeFloorHeating,
flooringType: calculationDetails.flooringType,
customerName: customerInfo.name,
customerEmail: customerInfo.email,
customerPhone: customerInfo.phone,
remarks: customerInfo.remarks,
totalExclVat: calculationDetails.totalExclVat,
totalInclVat: calculationDetails.totalInclVat,
})
// Generate the quote link
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://beregner.foamking.dk"
const quoteLink = `${baseUrl}/tilbud/${slug}`
const transporter = createTransporter()
// Get Foam King recipients (supports comma-separated emails)
const foamKingEmails = (process.env.EMAIL_TO || "info@foamking.dk")
.split(",")
.map((email) => email.trim())
.filter((email) => email.length > 0)
const fromName = process.env.EMAIL_FROM_NAME || "Foam King Prisberegner"
// Generate tracking URL for email open tracking
const trackingUrl = `${baseUrl}/api/track/${quoteId}`
// Send email to customer
await transporter.sendMail({
from: `"${fromName}" <${process.env.SMTP_USER}>`,
to: customerInfo.email,
subject: "Dit prisoverslag fra Foam King Gulve",
html: formatCustomerEmail(customerInfo, calculationDetails, trackingUrl),
})
// Send email to Foam King
await transporter.sendMail({
from: `"${fromName}" <${process.env.SMTP_USER}>`,
to: foamKingEmails,
replyTo: customerInfo.email,
subject: `Tilbud #${quoteId}: ${customerInfo.name} - ${customerInfo.postalCode} - ${calculationDetails.area}`,
html: formatFoamKingEmail(customerInfo, calculationDetails, quoteLink, quoteId),
})
// Format email content
const emailContent = formatEmailContent(customerInfo, calculationDetails)
// In production, you would send this via an email service
// For now, we'll just log it and return success
console.log("Quote request email:", emailContent)
// TODO: Implement actual email sending using a service like:
// - SendGrid
// - AWS SES
// - Resend
// - Nodemailer with SMTP
return NextResponse.json({ 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);
} }
} }
@ -69,4 +56,4 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }

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,11 +25,9 @@ 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,44 +2,61 @@
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)
setIsRequestingQuote(true) setShowResult(false)
}
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,
}), }),
}) })
const data = await response.json() const data = await response.json()
if (response.ok) { if (response.ok) {
alert(data.message) alert(data.message)
} else { } else {
@ -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>
@ -166,4 +273,4 @@ export function CalculationDetailsView({ details }: CalculationDetailsProps) {
</CardContent> </CardContent>
</Card> </Card>
) )
} }

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"
const distance = getDistance(data.postalCode) try {
setCalculationProgress({ step: "Beregner afstand...", progress: 40 })
const params = new URLSearchParams({
postalCode: data.postalCode,
...(data.address && { address: data.address }),
})
const distanceResponse = await fetch(`/api/distance?${params}`)
const distanceData = await distanceResponse.json()
distance = distanceData.distance
source = distanceData.source
} catch {
distance = getDistance(data.postalCode)
source = "table"
}
setDistanceSource(source)
setCalculationProgress({ step: "Beregner pris...", progress: 70 })
await new Promise((resolve) => setTimeout(resolve, 200))
const calculationResult = calculatePrice({ 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>
<Label htmlFor="email">Email *</Label> {/* Floor Dimensions Section */}
<Input <section>
id="email" <h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
type="email" Gulvmål
{...register("email")} </h3>
placeholder="din@email.dk" <div className="space-y-6 rounded-xl bg-muted/30 p-5">
className="mt-1" {/* Area Slider */}
/> <div className="space-y-3">
{errors.email && ( <div className="flex items-center justify-between">
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p> <Label className="text-base">Gulvareal</Label>
)} <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>
<Label htmlFor="phone">Telefon *</Label> {/* Components Section */}
<Input <section>
id="phone" <h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{...register("phone")} Vælg komponenter
placeholder="12345678" </h3>
className="mt-1" <div className="grid gap-3">
{/* 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> {/* Floor Heating Toggle */}
)} <Controller
</div> name="includeFloorHeating"
control={control}
<div> render={({ field }) => (
<Label htmlFor="postalCode">Postnummer *</Label> <label
<Input className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
id="postalCode" field.value
{...register("postalCode")} ? "border-secondary bg-secondary/10"
placeholder="4550" : "border-muted hover:border-muted-foreground/30"
className="mt-1" }`}
>
<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> {/* Compound Toggle */}
)} <Controller
</div> name="includeCompound"
</div> control={control}
render={({ field }) => (
<div> <label
<Label htmlFor="address">Adresse</Label> className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
<Input field.value
id="address" ? "border-secondary bg-secondary/10"
{...register("address")} : "border-muted hover:border-muted-foreground/30"
placeholder="Vejnavn og nummer (valgfrit)" }`}
className="mt-1" >
/> <div
</div> className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
>
<div className="grid gap-4 sm:grid-cols-2"> <PaintBucket className="h-5 w-5" />
<div> </div>
<Label htmlFor="area"> <div className="flex-1">
Gulvareal (m²) * <div className="font-medium">Gulvstøbning</div>
<span className="ml-1 text-xs text-muted-foreground"> <div className="text-sm text-muted-foreground">
({CONSTRAINTS.MIN_AREA}-{CONSTRAINTS.MAX_AREA} m²) Flydespartel til færdigt gulv
</span> </div>
</Label> </div>
<Input <Switch checked={field.value} onCheckedChange={field.onChange} />
id="area" </label>
type="number" )}
{...register("area")}
placeholder="50"
className="mt-1"
/> />
{errors.area && (
<p className="mt-1 text-sm text-destructive">{errors.area.message}</p>
)}
</div> </div>
</section>
<div>
<Label htmlFor="height"> {/* Flooring Type Section */}
Gulvhøjde (cm) * {watchedIncludeCompound && (
<span className="ml-1 text-xs text-muted-foreground"> <section>
({CONSTRAINTS.MIN_HEIGHT}-{CONSTRAINTS.MAX_HEIGHT} cm) <h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
</span> Gulvbelægning
</Label> </h3>
<Input <Controller
id="height" name="flooringType"
type="number" control={control}
{...register("height")} render={({ field }) => (
placeholder="20" <div className="grid gap-2 sm:grid-cols-3">
className="mt-1" {Object.entries(FLOORING_TYPES).map(([key, type]) => (
<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> {/* Remarks Section */}
</div> <section>
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
<div> Bemærkninger <span className="font-normal">(valgfrit)</span>
<Label htmlFor="remarks">Bemærkninger</Label> </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,16 +471,7 @@ 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,14 +40,10 @@ 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}
/>
) )
} }
) )
Button.displayName = "Button" Button.displayName = "Button"
export { Button, buttonVariants } export { Button, buttonVariants }

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}
@ -19,4 +19,4 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
) )
Input.displayName = "Input" Input.displayName = "Input"
export { Input } export { Input }

View file

@ -10,15 +10,10 @@ 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
export { Label } export { Label }

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 }

View file

@ -1,97 +1,97 @@
Retelser fra rene Retelser fra rene
<br/>Gulvareal 25-300 m2 <br/>Gulvareal 25-300 m2
Gulvhøjde (cm): 0-100 cm (isoleringstykkelse) Gulvhøjde (cm): 0-100 cm (isoleringstykkelse)
<br/><br/><br/>![💰](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAADAFBMVEUAAAD/yij/yij/yij/yij/yijiphD/yijiphDiphDmrBTiphDiphBrS0b/yijiphD/yij/yij/yij/yijmqxPiphD5wyP/yij/yijvthv9yCfprxbkqBL0vR/2vyHyuh7iphD7xiX6wyTRojG1izfImjPtuixrS0aWbjrboBOsgzmQaz+fcy72wip+W0LaqjDjsi6+kjWZcz10U0Sjezv4wSKHY0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACFs/+SAAAAGHRSTlMAMGDfvxCfn3CAzxCvz3AgIIDvQHC/78+4pAktAAABqElEQVR4XoVSXVfiMBC9SZtGoO2ibBHX//+HfNvnPZ4jiiJQWQr92JlJUrHsOd6HztdNZuamwDdQwwRlOmhVYKVOLhxAz9Wrumbn7cBxPKjTCT2v2YncWf2lSiiARpy2E3NBIBj+WKMnbC8IK+iIzFhhLj0uCNHExjlMC9/qYsiEM1P1F/XqP1uorLqNVI0urp9HUj/XYaq2hTjFYVT9DtmeYFIbXMLtg1uSZvJWIXdOYa5L4COxrkOYQU8TNvlkZ5JH9pK1K/gWM9EGmSwdmyc6YTbvkpOUcQ1zDZshraMFcK/C3fKR+0HSdnu7syWFf2o/AxPkdQlbuj6ttw02nBy5JF/kzrP+1R41vVVFwUlplluGvAmMRUulpvQRnpmh+hWQj5b49UHSvAXGspMZ5CZCdrhDlf1AswgEVvHsuUvqfbV5PZP/p/pCiNHcleNctvHIhMBLMXSKPQ57pOOeEAnBvxJejhkb27z0BLh+1gsFpDcVlmdVWoOFao9BCFX2KzLWnZ9YafcrDbBmRWWLTlfHQZH+/xPXP3eetbtZCI7b6JS73+F7/AOyqXEFmvkN7gAAAABJRU5ErkJggg==) Priskonstanter (ekskl. moms) <br/><br/><br/>![💰](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAADAFBMVEUAAAD/yij/yij/yij/yij/yijiphD/yijiphDiphDmrBTiphDiphBrS0b/yijiphD/yij/yij/yij/yijmqxPiphD5wyP/yij/yijvthv9yCfprxbkqBL0vR/2vyHyuh7iphD7xiX6wyTRojG1izfImjPtuixrS0aWbjrboBOsgzmQaz+fcy72wip+W0LaqjDjsi6+kjWZcz10U0Sjezv4wSKHY0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACFs/+SAAAAGHRSTlMAMGDfvxCfn3CAzxCvz3AgIIDvQHC/78+4pAktAAABqElEQVR4XoVSXVfiMBC9SZtGoO2ibBHX//+HfNvnPZ4jiiJQWQr92JlJUrHsOd6HztdNZuamwDdQwwRlOmhVYKVOLhxAz9Wrumbn7cBxPKjTCT2v2YncWf2lSiiARpy2E3NBIBj+WKMnbC8IK+iIzFhhLj0uCNHExjlMC9/qYsiEM1P1F/XqP1uorLqNVI0urp9HUj/XYaq2hTjFYVT9DtmeYFIbXMLtg1uSZvJWIXdOYa5L4COxrkOYQU8TNvlkZ5JH9pK1K/gWM9EGmSwdmyc6YTbvkpOUcQ1zDZshraMFcK/C3fKR+0HSdnu7syWFf2o/AxPkdQlbuj6ttw02nBy5JF/kzrP+1R41vVVFwUlplluGvAmMRUulpvQRnpmh+hWQj5b49UHSvAXGspMZ5CZCdrhDlf1AswgEVvHsuUvqfbV5PZP/p/pCiNHcleNctvHIhMBLMXSKPQ57pOOeEAnBvxJejhkb27z0BLh+1gsFpDcVlmdVWoOFao9BCFX2KzLWnZ9YafcrDbBmRWWLTlfHQZH+/xPXP3eetbtZCI7b6JS73+F7/AOyqXEFmvkN7gAAAABJRU5ErkJggg==) Priskonstanter (ekskl. moms)
Timesatser & Basis Timesatser & Basis
Timepris: 550 kr/time Timepris: 550 kr/time
Kørsel: 18,75 kr/km omfatter bil, disel og mandskab Kørsel: 18,75 kr/km omfatter bil, disel og mandskab
Startgebyr: 3500 kr Startgebyr: 3500 kr
AFDækningsbidrag: 0,7% Afdækning (Plast og tape mv.) AFDækningsbidrag: 0,7% Afdækning (Plast og tape mv.)
Affald : 0,25% af subtotal udført arbejde (beton, skum plastaffald mv.) Affald : 0,25% af subtotal udført arbejde (beton, skum plastaffald mv.)
<br/>Materiale: 2.850 kr/m³ <br/>Materiale: 2.850 kr/m³
Arbejdsløn: 880 pr m3 Arbejdsløn: 880 pr m3
Enkel arbejdsløn (uden højde): 75 kr/m² pr m2 areal som tillæg til de 2 ovenstående priser Enkel arbejdsløn (uden højde): 75 kr/m² pr m2 areal som tillæg til de 2 ovenstående priser
Gulvvarme (altid inkluderet) Gulvvarme (altid inkluderet)
Materiale: 75,00KR pr m2 Materiale: 75,00KR pr m2
Arbejdsløn: 130,00 KR pr m2 Arbejdsløn: 130,00 KR pr m2
Syntetisk net (altid inkluderet) Syntetisk net (altid inkluderet)
Materiale: 24 kr/m² Materiale: 24 kr/m²
Arbejdsløn: 25 kr/m² Arbejdsløn: 25 kr/m²
Flydende spartelmasse Flydende spartelmasse
Materiale: 450 kr/m² Materiale: 450 kr/m²
Vægt: 90 kg/m² Vægt: 90 kg/m²
<br/>Pumpebil har omkostninger ved(prisinterval baseret på vægt) <br/>Pumpebil har omkostninger ved(prisinterval baseret på vægt)
0-3000 kg 8.100,00 kr pr gang 0-3000 kg 8.100,00 kr pr gang
3000-5000 6.000,00 kr pr gang 3000-5000 6.000,00 kr pr gang
5000-8000 3.800,00 kr pr gang 5000-8000 3.800,00 kr pr gang
![🧮](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADv0lEQVR4AdSWA7BkRxSGb9mpKa7N5/tsrxkbk1KcrI1nI7Zt23Zy197t99bu2MnJ+ftiZm8NeuJM1V/fcZ8eG/+px+Zbzn+DRayav2F2jTP7jf/+ArtOKaUjxabck51iHchPl/vz0gTbEva+3FQLPFiQ/sQuM6nmSEkmbU8e9QS4KzPp+E2jhpmHijKoN32MxURM1WOm/gIzCkmWZtHurGTCEF5AaW9OKqRiPRNy5fqiNImDtyWNJPDjKTni5TOLra8qc3mBsWoGiNye44v0F9hUnUl8k5hanzLSWpc68gnHl+DneUnd70/InBWpHjP13wNLj8dgDDqeFQR9NnIVLJNVE8YA5jh5M5yY+f95E3559WD67TqDfmo3rN+uN+jbZUYQ/LnLeAJ1v3Qb1i9XGZJ9AUIcU/z1ajuHXrYVMQsz9Rfo7Es8kH5oNBQ3LBwswIP1x8nHZk9448cWXqbDIPCntmPl5ZqxMKhmYKb+Akcb+uLW4apxqJqZEn4s+WowU3+B3msmPsFNs1gmiJzrO/bxrCAYRchVoMclZmovsP3Os7rp47EmKxCJqI3Aikh07YTehF9/XEW0r5Boa5ZQ3GQKOlAU4sYMS3FNqs3VqYIOFds+uCHD9temzeKeWbAPvThFai/w3UclRL25ROvSiUQOD06DH+LnySHuyvP7qg7c9lCBgGAffmEi6S/weh7x0wZJh37fpfDRn69hzYJ9+JnxCbwJ7z31DrxurKE+BjTp1bsxvK+0F3j71jPuWPJUZcWFdxQM9TEAuvVubPaDpYp1L084xg/v/fSWs/UXuP7JqdT61mRa9GSF9LP9nSnEQzE4CHvh4xUCnP1QKXKoicgH7j1e/yVov28CLXyigq64v5iWPVOlyLegS+8tUvEbV1ZZTe1lwo1F44rnq73e++6emcACt4/HLSERhSYrGCUnI8XvvW2G1F4Ar5dzSCAS3XrHBiviMaEvoraua94Y2NAza2DtdtNj3Y6afgvWBOAPWLb5eBWvFx7R79QGUTtgxZaKQU27PN53VY3+V/H07vdp6NWSuJGGXf+1x/z5z9+h/ObdckjXIUpq3WaB8E+fd/2sYdd+Gepp6AE9f+lVd+u/B6Y2vkp8CxqwbJNi/0Xr1EA+RAys2abiYMHS1yTIt6SVc2dbbg61IA53e5e036q/wCl1TxA/3ZL1RBgRO97x3/Dl4Q91bIFaxMK5su16+Z/+S4aCit93vC3k/LGW4P8nO8Lmg/jUxCAzQWaD7ADxMSwHuY7O2IFh1AGDAQAAwNd371sKcVQAAAAASUVORK5CYII=) Beregningsproces ![🧮](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADv0lEQVR4AdSWA7BkRxSGb9mpKa7N5/tsrxkbk1KcrI1nI7Zt23Zy197t99bu2MnJ+ftiZm8NeuJM1V/fcZ8eG/+px+Zbzn+DRayav2F2jTP7jf/+ArtOKaUjxabck51iHchPl/vz0gTbEva+3FQLPFiQ/sQuM6nmSEkmbU8e9QS4KzPp+E2jhpmHijKoN32MxURM1WOm/gIzCkmWZtHurGTCEF5AaW9OKqRiPRNy5fqiNImDtyWNJPDjKTni5TOLra8qc3mBsWoGiNye44v0F9hUnUl8k5hanzLSWpc68gnHl+DneUnd70/InBWpHjP13wNLj8dgDDqeFQR9NnIVLJNVE8YA5jh5M5yY+f95E3559WD67TqDfmo3rN+uN+jbZUYQ/LnLeAJ1v3Qb1i9XGZJ9AUIcU/z1ajuHXrYVMQsz9Rfo7Es8kH5oNBQ3LBwswIP1x8nHZk9448cWXqbDIPCntmPl5ZqxMKhmYKb+Akcb+uLW4apxqJqZEn4s+WowU3+B3msmPsFNs1gmiJzrO/bxrCAYRchVoMclZmovsP3Os7rp47EmKxCJqI3Aikh07YTehF9/XEW0r5Boa5ZQ3GQKOlAU4sYMS3FNqs3VqYIOFds+uCHD9temzeKeWbAPvThFai/w3UclRL25ROvSiUQOD06DH+LnySHuyvP7qg7c9lCBgGAffmEi6S/weh7x0wZJh37fpfDRn69hzYJ9+JnxCbwJ7z31DrxurKE+BjTp1bsxvK+0F3j71jPuWPJUZcWFdxQM9TEAuvVubPaDpYp1L084xg/v/fSWs/UXuP7JqdT61mRa9GSF9LP9nSnEQzE4CHvh4xUCnP1QKXKoicgH7j1e/yVov28CLXyigq64v5iWPVOlyLegS+8tUvEbV1ZZTe1lwo1F44rnq73e++6emcACt4/HLSERhSYrGCUnI8XvvW2G1F4Ar5dzSCAS3XrHBiviMaEvoraua94Y2NAza2DtdtNj3Y6afgvWBOAPWLb5eBWvFx7R79QGUTtgxZaKQU27PN53VY3+V/H07vdp6NWSuJGGXf+1x/z5z9+h/ObdckjXIUpq3WaB8E+fd/2sYdd+Gepp6AE9f+lVd+u/B6Y2vkp8CxqwbJNi/0Xr1EA+RAys2abiYMHS1yTIt6SVc2dbbg61IA53e5e036q/wCl1TxA/3ZL1RBgRO97x3/Dl4Q91bIFaxMK5su16+Z/+S4aCit93vC3k/LGW4P8nO8Lmg/jUxCAzQWaD7ADxMSwHuY7O2IFh1AGDAQAAwNd371sKcVQAAAAASUVORK5CYII=) Beregningsproces
Trin 1: Afledte værdier Trin 1: Afledte værdier
Isolering (m³) = Areal × (Højde ÷ 100) Isolering (m³) = Areal × (Højde ÷ 100)
Pumpet vægt (kg) = Areal × 90 kg/m² Pumpet vægt (kg) = Areal × 90 kg/m²
Trin 2: Beregn komponenter Trin 2: Beregn komponenter
Isolering: Isolering:
<br/>Hvis højde > 0: Materiale + arbejde baseret på m³ <br/>Hvis højde > 0: Materiale + arbejde baseret på m³
Hvis højde = 0: Kun simpel arbejde baseret på m² Hvis højde = 0: Kun simpel arbejde baseret på m²
Gulvvarme: Materiale + arbejde (altid inkluderet) Gulvvarme: Materiale + arbejde (altid inkluderet)
<br/>Syntetisk net: Materiale + arbejde (altid inkluderet) <br/>Syntetisk net: Materiale + arbejde (altid inkluderet)
<br/>Flydende spartelmasse: Materiale baseret på areal <br/>Flydende spartelmasse: Materiale baseret på areal
<br/>Pumpebil: Vælg prisinterval ud fra total vægt <br/>Pumpebil: Vælg prisinterval ud fra total vægt
<br/>Startgebyr: Fast beløb <br/>Startgebyr: Fast beløb
<br/>Trin 3: Subtotal A <br/>Trin 3: Subtotal A
Subtotal A = Sum af alle ovenstående komponenter Subtotal A = Sum af alle ovenstående komponenter
Trin 4: Tillæg til subtotal Trin 4: Tillæg til subtotal
AFDækningsbidrag = Subtotal A × 0,7% omksotninger til afdækning plast og tape mv. AFDækningsbidrag = Subtotal A × 0,7% omksotninger til afdækning plast og tape mv.
Affald = Subtotal A × 0,25% Dette er affald omk. Efter udført arbejde Affald = Subtotal A × 0,25% Dette er affald omk. Efter udført arbejde
Trin 5: Transportomkostninger Trin 5: Transportomkostninger
Afstand fra base (4550 Asnæs) baseret på postnummer: Afstand fra base (4550 Asnæs) baseret på postnummer:
<br/>4000-4999: 40 km tur-retur (Vestsjælland) <br/>4000-4999: 40 km tur-retur (Vestsjælland)
2000-2999: 160 km tur-retur (København) 2000-2999: 160 km tur-retur (København)
3000-3999: 120 km tur-retur (Nordsjælland) 3000-3999: 120 km tur-retur (Nordsjælland)
5000-5999: 60 km tur-retur (Fyn) 5000-5999: 60 km tur-retur (Fyn)
<br/>Beregning: <br/>Beregning:
Bemærkninger at der er ekstra omkostninger hvor der er bro afgifter eller sejlans forbundet med udførsel af opgaven Bemærkninger at der er ekstra omkostninger hvor der er bro afgifter eller sejlans forbundet med udførsel af opgaven
Fyn er indeholdt med broafgift Fyn er indeholdt med broafgift
Jeg tænker hvis vi har km til opgaven og anvender km. Takst på 18,75 så er transport indeholdt Jeg tænker hvis vi har km til opgaven og anvender km. Takst på 18,75 så er transport indeholdt
Køretid (timer) = Afstand ÷ 70 km/t Køretid (timer) = Afstand ÷ 70 km/t
Transport arbejde = 2 medarbejdere × Timepris × Køretid × 2 (tur-retur) Transport arbejde = 2 medarbejdere × Timepris × Køretid × 2 (tur-retur)
Transport bil = Afstand × 4 kr/km Transport bil = Afstand × 4 kr/km
Total transport = Transport arbejde + Transport bil Total transport = Transport arbejde + Transport bil
Trin 6: Total før hast Trin 6: Total før hast
Total før hast = Subtotal A + Dækningsbidrag + Spild + Total transport Total før hast = Subtotal A + Dækningsbidrag + Spild + Total transport
Trin 7: Hastighedstillæg Trin 7: Hastighedstillæg
Hastighedsmultiplikator: Hastighedsmultiplikator:
\- Normal: × 1,00 \- Normal: × 1,00
\- Hurtigt: × 1,10 \- Hurtigt: × 1,10
\- Rush: × 1,20 \- Rush: × 1,20
<br/>Pris ekskl. moms = Total før hast × Hastighedsmultiplikator <br/>Pris ekskl. moms = Total før hast × Hastighedsmultiplikator
Trin 8: Moms og prisinterval Trin 8: Moms og prisinterval
Moms = Pris ekskl. moms × 25% Moms = Pris ekskl. moms × 25%
Pris inkl. moms = Pris ekskl. moms + Moms Pris inkl. moms = Pris ekskl. moms + Moms
<br/>Prisinterval: <br/>Prisinterval:
Min pris = Pris inkl. moms - 10.000 kr Min pris = Pris inkl. moms - 10.000 kr
Max pris = Pris inkl. moms + 10.000 kr Max pris = Pris inkl. moms + 10.000 kr
![📈](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAB50lEQVR4AWIYFGDDvqP7BwgbAIgrB996giCO/1M/m/HPLIO6/QtqRXXj1LZt27Ztm9vOJnPZd8/v7dtucprMzXxuvrNzFKC5d5jI1/jcIjfb1t4BPXBtuziSLg9Pcg/wTjjAYXQkWXn++GEALnp7aPK1zx/IcGuHWIDNxWVITAHOGuqpjwRQ2doNBoUDoHjZekenyIy1FU2+6O+PPuIqgLpvWluwPmIAWN2vV1fFAtwcHUq6H9ZUg0ksAOx3SD7j7Y3bEJfpm3A2OJgmXzH/B00Ih9zHdBUYyMlndcc5IEYC0H3x/Rtpv8MSCoC674eHoEkcgHy/6wxQ19mPTnhA0+hl22xoZOe8gs/00ioc8ve0VgD0xFKiTa2ffM7DMloCLOmev69GALnuXACgjBAUD4BQBdDn48vqzg9gxd0NAkMVIDgmgXJrn/P6Asgn4WhBEQ28/vMrfZ4anZAgpn5+J6ODI/TA/b5UUKBpOuo/CVFTHCQ4YCA5VgJ9toID0YWPBJCUltXeVmXgPX+qOYKAjSsAlB2CgwzqAgME6s4X4Dg9TWPHMzZsOj4A0IRsU8329uv7Oza6CXHowFXXL+NXgYbSStzP0O3iAWpiEtivFw9QmZJJJhzsjfobarLdjWBtCGQ0DBBWAACcIW/qxNrtTAAAAABJRU5ErkJggg==) Eksempel på beregning ![📈](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAB50lEQVR4AWIYFGDDvqP7BwgbAIgrB996giCO/1M/m/HPLIO6/QtqRXXj1LZt27Ztm9vOJnPZd8/v7dtucprMzXxuvrNzFKC5d5jI1/jcIjfb1t4BPXBtuziSLg9Pcg/wTjjAYXQkWXn++GEALnp7aPK1zx/IcGuHWIDNxWVITAHOGuqpjwRQ2doNBoUDoHjZekenyIy1FU2+6O+PPuIqgLpvWluwPmIAWN2vV1fFAtwcHUq6H9ZUg0ksAOx3SD7j7Y3bEJfpm3A2OJgmXzH/B00Ih9zHdBUYyMlndcc5IEYC0H3x/Rtpv8MSCoC674eHoEkcgHy/6wxQ19mPTnhA0+hl22xoZOe8gs/00ioc8ve0VgD0xFKiTa2ffM7DMloCLOmev69GALnuXACgjBAUD4BQBdDn48vqzg9gxd0NAkMVIDgmgXJrn/P6Asgn4WhBEQ28/vMrfZ4anZAgpn5+J6ODI/TA/b5UUKBpOuo/CVFTHCQ4YCA5VgJ9toID0YWPBJCUltXeVmXgPX+qOYKAjSsAlB2CgwzqAgME6s4X4Dg9TWPHMzZsOj4A0IRsU8329uv7Oza6CXHowFXXL+NXgYbSStzP0O3iAWpiEtivFw9QmZJJJhzsjfobarLdjWBtCGQ0DBBWAACcIW/qxNrtTAAAAABJRU5ErkJggg==) Eksempel på beregning
Input: Input:
<br/>Areal: 50 m² <br/>Areal: 50 m²
Højde: 20 cm Ved beregning skal der på de 20 cm. Fratrækkes 5 cm. Til beton så der kun bliver 15 cm. isolering Højde: 20 cm Ved beregning skal der på de 20 cm. Fratrækkes 5 cm. Til beton så der kun bliver 15 cm. isolering
Postnummer: 2100 (København) Postnummer: 2100 (København)
Tidsramme: Normal Tidsramme: Normal
Output: Output:
<br/>Isolering: 7,5 m3 <br/>Isolering: 7,5 m3
Pumpet vægt: 4.500 kg Pumpet vægt: 4.500 kg
Transport: 160 km tur-retur Transport: 160 km tur-retur
Estimeret pris: Ca. 120.000-140.000 kr inkl. moms Estimeret pris: Ca. 120.000-140.000 kr inkl. moms
Prisinterval: ±10.000 kr variation Prisinterval: ±10.000 kr variation
![⚠️](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAACJUlEQVR4AWKgBHxaxiAAwgwDAQC9l0P8FkEcxq+5rl0yXy+z7Tpl252zu2Vesl2n7Eu2dcm262meMJ938q/5/PfwfbG7Mw/WSriLAt9YELV4sRfrcoNkmagZmYEPB8otwBEX5N2uohQnuyMRV6LFFMjm5cb8kbVAAwtwpRlwtrY28GF/GUELtukp/HQIcKdrtC3o9BSmgeutKGzRgk36xwNwe2dFLJxUGeTy5kDagn36Hu2qoWbNmiBNG9XA8/2eqAV5+gv1dHocC1G9enWD46vkLcjSP+hj7Ptq1aoZHFsZSI8FQfpLjSgOmmB6ClWpUsXA4owQpL/USIv071QBlSpV0sjPCHl6Q6RvxxAVK1bUCK6O9ulJn/YBwjAkaNEo4DJRC4Lz3kivGT3Qg+/7BL3b+Xq5bQtGegV43cfJyj9NPm+UC8/zCHq11cvtWzDueDznb3b85eRzRzpwnK/0aOPo5bYtmOkprq/5JnOUgXQ6Tfiby6xbMNP/hcNLHaRSKZDZIzJcZt2Cmf4vHFqaQSKRAJk1PM1lwhYs0pPz6x3EYjGQDVMMA8IW5Ok1iyakMHNYCs/2OfwvbMEiPaHoovHKwNCkNiBvwSL9wvFJlC1bFqRfuxiXCVuwSE8WjkugdOnSIH3blucycQvS9AZPVe0LxiYwfmCMv7ns/1vgigjJfqPKRG/A3A0Z4S6w5/2eEr98oaWJQoqaOcnbrYVrKmGiX+k/A8p4TZk7YN6vAAAAAElFTkSuQmCC) Vigtige noter ![⚠️](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAACJUlEQVR4AWKgBHxaxiAAwgwDAQC9l0P8FkEcxq+5rl0yXy+z7Tpl252zu2Vesl2n7Eu2dcm262meMJ938q/5/PfwfbG7Mw/WSriLAt9YELV4sRfrcoNkmagZmYEPB8otwBEX5N2uohQnuyMRV6LFFMjm5cb8kbVAAwtwpRlwtrY28GF/GUELtukp/HQIcKdrtC3o9BSmgeutKGzRgk36xwNwe2dFLJxUGeTy5kDagn36Hu2qoWbNmiBNG9XA8/2eqAV5+gv1dHocC1G9enWD46vkLcjSP+hj7Ptq1aoZHFsZSI8FQfpLjSgOmmB6ClWpUsXA4owQpL/USIv071QBlSpV0sjPCHl6Q6RvxxAVK1bUCK6O9ulJn/YBwjAkaNEo4DJRC4Lz3kivGT3Qg+/7BL3b+Xq5bQtGegV43cfJyj9NPm+UC8/zCHq11cvtWzDueDznb3b85eRzRzpwnK/0aOPo5bYtmOkprq/5JnOUgXQ6Tfiby6xbMNP/hcNLHaRSKZDZIzJcZt2Cmf4vHFqaQSKRAJk1PM1lwhYs0pPz6x3EYjGQDVMMA8IW5Ok1iyakMHNYCs/2OfwvbMEiPaHoovHKwNCkNiBvwSL9wvFJlC1bFqRfuxiXCVuwSE8WjkugdOnSIH3blucycQvS9AZPVe0LxiYwfmCMv7ns/1vgigjJfqPKRG/A3A0Z4S6w5/2eEr98oaWJQoqaOcnbrYVrKmGiX+k/A8p4TZk7YN6vAAAAAElFTkSuQmCC) Vigtige noter
Alle priser er afrundede til nærmeste hele krone Alle priser er afrundede til nærmeste hele krone
Prisintervallet er ±10.000 kr (i alt 20.000 kr spænd) for at dække variation i konkrete forhold Prisintervallet er ±10.000 kr (i alt 20.000 kr spænd) for at dække variation i konkrete forhold
Transportomkostninger er estimerede baseret på postnummerområder Transportomkostninger er estimerede baseret på postnummerområder
Minimum areal er 25 m², maksimum er 300 m² Minimum areal er 25 m², maksimum er 300 m²
Maksimal højde er 100 cm Maksimal højde er 100 cm

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.

View file

@ -19,4 +19,4 @@ const eslintConfig = [
}, },
] ]
export default eslintConfig export default eslintConfig

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 {
@ -62,33 +93,49 @@ export function calculatePumpTruckFee(weight: number): number {
export function getBridgeFee(postalCode: string): number { export function getBridgeFee(postalCode: string): number {
const postalNumber = parseInt(postalCode) const postalNumber = parseInt(postalCode)
for (const area of Object.values(COVERAGE_AREAS)) { for (const area of Object.values(COVERAGE_AREAS)) {
if (postalNumber >= area.start && postalNumber <= area.end) { if (postalNumber >= area.start && postalNumber <= area.end) {
return area.bridgeFee return area.bridgeFee
} }
} }
return 0 return 0
} }
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,
@ -154,4 +207,4 @@ export function formatEstimate(price: number): string {
// Round to nearest 500 // Round to nearest 500
const rounded = Math.round(price / 500) * 500 const rounded = Math.round(price / 500) * 500
return `Ca. ${formatPrice(rounded)}` return `Ca. ${formatPrice(rounded)}`
} }

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 },
@ -60,4 +80,4 @@ export const COVERAGE_AREAS = {
FUNEN: { start: 5000, end: 5999, bridgeFee: PRICES.GREAT_BELT_FEE }, FUNEN: { start: 5000, end: 5999, bridgeFee: PRICES.GREAT_BELT_FEE },
} as const } as const
export type CoverageArea = keyof typeof COVERAGE_AREAS export type CoverageArea = keyof typeof COVERAGE_AREAS

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 {
@ -151,4 +151,4 @@ export function validateDanishPostalCode(postalCode: string): boolean {
// Valid ranges for Danish postal codes // Valid ranges for Danish postal codes
const code = parseInt(postalCode) const code = parseInt(postalCode)
return code >= 1000 && code <= 9999 return code >= 1000 && code <= 9999
} }

View file

@ -3,4 +3,4 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }

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,7 +1,7 @@
import type { NextConfig } from 'next' import type { NextConfig } from "next"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
} }
export default nextConfig export default nextConfig

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"
} }
} }

View file

@ -6,4 +6,4 @@ const config = {
}, },
} }
export default config export default config

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: {
@ -75,4 +75,4 @@ export default {
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require("tailwindcss-animate")],
} satisfies Config } satisfies Config

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"]
} }