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>
11
.env.example
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# OpenRouteService API Key
|
||||||
|
# Get your free key at https://openrouteservice.org/dev/#/signup
|
||||||
|
# Free tier: 2,000 requests/day
|
||||||
|
OPENROUTE_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
# Email configuration (for quote requests)
|
||||||
|
# SMTP_HOST=smtp.example.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=user@example.com
|
||||||
|
# SMTP_PASS=your_password
|
||||||
|
# EMAIL_TO=info@foamking.dk
|
||||||
3
.gitignore
vendored
|
|
@ -33,3 +33,6 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# database
|
||||||
|
/data/
|
||||||
10
CLAUDE.md
|
|
@ -9,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
**Planned stack:** Next.js + shadcn/ui + Tailwind CSS
|
**Planned stack:** Next.js + shadcn/ui + Tailwind CSS
|
||||||
|
|
||||||
- **Next.js**: For server-side rendering and API routes
|
- **Next.js**: For server-side rendering and API routes
|
||||||
- **shadcn/ui**: For accessible, customizable components
|
- **shadcn/ui**: For accessible, customizable components
|
||||||
- **Tailwind CSS**: For styling
|
- **Tailwind CSS**: For styling
|
||||||
|
|
@ -17,6 +18,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
Currently in **documentation phase** - no implementation exists yet. Key documentation files:
|
Currently in **documentation phase** - no implementation exists yet. Key documentation files:
|
||||||
|
|
||||||
- `docs/projektplan.md` - Complete project plan and requirements
|
- `docs/projektplan.md` - Complete project plan and requirements
|
||||||
- `docs/prisbeskrivelse.md` - Detailed pricing logic and formulas
|
- `docs/prisbeskrivelse.md` - Detailed pricing logic and formulas
|
||||||
- `docs/shadcn theme.txt` - Custom shadcn theme (blue/orange color scheme)
|
- `docs/shadcn theme.txt` - Custom shadcn theme (blue/orange color scheme)
|
||||||
|
|
@ -25,6 +27,7 @@ Currently in **documentation phase** - no implementation exists yet. Key documen
|
||||||
## Core Requirements
|
## Core Requirements
|
||||||
|
|
||||||
### Input Form Fields
|
### Input Form Fields
|
||||||
|
|
||||||
- Name (required, min 2 chars)
|
- Name (required, min 2 chars)
|
||||||
- Email (required, valid format)
|
- Email (required, valid format)
|
||||||
- Phone (required, 8 digits)
|
- Phone (required, 8 digits)
|
||||||
|
|
@ -35,6 +38,7 @@ Currently in **documentation phase** - no implementation exists yet. Key documen
|
||||||
- Remarks (optional)
|
- Remarks (optional)
|
||||||
|
|
||||||
### Price Calculation Components
|
### Price Calculation Components
|
||||||
|
|
||||||
1. **Insulation**: 3,730 kr/m³ (subtract 5cm from height for concrete)
|
1. **Insulation**: 3,730 kr/m³ (subtract 5cm from height for concrete)
|
||||||
2. **Floor heating**: 205 kr/m² (always included)
|
2. **Floor heating**: 205 kr/m² (always included)
|
||||||
3. **Synthetic mesh**: 49 kr/m² (always included)
|
3. **Synthetic mesh**: 49 kr/m² (always included)
|
||||||
|
|
@ -46,18 +50,22 @@ Currently in **documentation phase** - no implementation exists yet. Key documen
|
||||||
9. **VAT**: 25%
|
9. **VAT**: 25%
|
||||||
|
|
||||||
### Output
|
### Output
|
||||||
|
|
||||||
- Price estimate with ±10,000 kr variation
|
- Price estimate with ±10,000 kr variation
|
||||||
- Option to request binding quote (sends email to `info@foamking.dk`)
|
- Option to request binding quote (sends email to `info@foamking.dk`)
|
||||||
|
|
||||||
## Implementation Guidelines
|
## Implementation Guidelines
|
||||||
|
|
||||||
### Distance Calculation
|
### Distance Calculation
|
||||||
|
|
||||||
Three options for calculating transport distance:
|
Three options for calculating transport distance:
|
||||||
|
|
||||||
1. **Postal code table** (recommended for MVP)
|
1. **Postal code table** (recommended for MVP)
|
||||||
2. **OpenRouteService API** (free up to 2,000 requests/day)
|
2. **OpenRouteService API** (free up to 2,000 requests/day)
|
||||||
3. **Google Maps API** (paid)
|
3. **Google Maps API** (paid)
|
||||||
|
|
||||||
### Coverage Areas
|
### Coverage Areas
|
||||||
|
|
||||||
- 4000-4999: West Zealand
|
- 4000-4999: West Zealand
|
||||||
- 2000-2999: Copenhagen
|
- 2000-2999: Copenhagen
|
||||||
- 3000-3999: North Zealand
|
- 3000-3999: North Zealand
|
||||||
|
|
@ -67,6 +75,7 @@ Three options for calculating transport distance:
|
||||||
### Development Commands
|
### Development Commands
|
||||||
|
|
||||||
Since this is a new project, typical Next.js commands will apply once initialized:
|
Since this is a new project, typical Next.js commands will apply once initialized:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Initialize project
|
# Initialize project
|
||||||
npx create-next-app@latest . --typescript --tailwind --app
|
npx create-next-app@latest . --typescript --tailwind --app
|
||||||
|
|
@ -103,6 +112,7 @@ npm run typecheck
|
||||||
### Testing Scenarios
|
### Testing Scenarios
|
||||||
|
|
||||||
Test with examples from `prisbeskrivelse.md`:
|
Test with examples from `prisbeskrivelse.md`:
|
||||||
|
|
||||||
- 50 m², 20 cm height, 2100 Copenhagen → ~95,500 kr
|
- 50 m², 20 cm height, 2100 Copenhagen → ~95,500 kr
|
||||||
- Edge cases: minimum (25 m²) and maximum (300 m²) areas
|
- Edge cases: minimum (25 m²) and maximum (300 m²) areas
|
||||||
- Different pump truck weight thresholds
|
- Different pump truck weight thresholds
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ Projektet bruger en forudberegnet tabel over afstande fra 4550 Asnæs til danske
|
||||||
## 📱 Admin Mode
|
## 📱 Admin Mode
|
||||||
|
|
||||||
Klik på "Vis detaljer" for at se den fulde prissopgørelse med:
|
Klik på "Vis detaljer" for at se den fulde prissopgørelse med:
|
||||||
|
|
||||||
- Alle priskomponenter
|
- Alle priskomponenter
|
||||||
- Beregningslogik step-by-step
|
- Beregningslogik step-by-step
|
||||||
- Isolerings- og transportdetaljer
|
- Isolerings- og transportdetaljer
|
||||||
|
|
@ -120,6 +121,7 @@ Klik på "Vis detaljer" for at se den fulde prissopgørelse med:
|
||||||
## 🧪 Testing
|
## 🧪 Testing
|
||||||
|
|
||||||
Test med eksempel fra dokumentationen:
|
Test med eksempel fra dokumentationen:
|
||||||
|
|
||||||
- **Areal**: 50 m²
|
- **Areal**: 50 m²
|
||||||
- **Højde**: 20 cm
|
- **Højde**: 20 cm
|
||||||
- **Postnummer**: 2100 (København)
|
- **Postnummer**: 2100 (København)
|
||||||
|
|
|
||||||
157
app/admin/page.tsx
Normal file
|
|
@ -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 "Beregn pris"
|
||||||
|
</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 "Beregn pris"
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
app/api/auth/login/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/api/auth/logout/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/api/auth/setup/route.ts
Normal 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
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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 på de oplysninger du har indtastet:</p>
|
||||||
|
|
||||||
|
<div class="price-box">
|
||||||
|
<div class="price">${formatPrice(Math.round(details.totalInclVat))}</div>
|
||||||
|
<div style="color: #666;">inkl. moms</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="details">
|
||||||
|
<h3>Projektdetaljer</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Areal:</strong> ${details.area} m²</li>
|
||||||
|
<li><strong>Gulvhøjde:</strong> ${details.height} cm</li>
|
||||||
|
<li><strong>Placering:</strong> ${customer.postalCode}${customer.address ? `, ${customer.address}` : ""}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="details">
|
||||||
|
<h3>Inkluderet i prisen</h3>
|
||||||
|
<ul>
|
||||||
|
${components.map((c) => `<li>${c}</li>`).join("")}
|
||||||
|
<li>Transport</li>
|
||||||
|
<li>Startgebyr (udstyr og sikkerhed)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Bemærk:</strong> Dette er et vejledende prisoverslag og kan variere med ±10.000 kr afhængigt af konkrete forhold på stedet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Vi kontakter dig snarest for at aftale et uforpligtende besøg, hvor vi kan give dig et præcist og bindende tilbud.</p>
|
||||||
|
|
||||||
|
<p>Har du spørgsmål i mellemtiden, er du velkommen til at kontakte os.</p>
|
||||||
|
|
||||||
|
<p>Med venlig hilsen,<br>
|
||||||
|
<strong>Foam King ApS</strong><br>
|
||||||
|
Tlf: 35 90 10 66<br>
|
||||||
|
Email: info@foamking.dk</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Foam King ApS · Søgårdsvej 7, 4550 Asnæs · CVR: 44 48 54 51</p>
|
||||||
|
<p style="margin-top: 15px;">
|
||||||
|
<img src="${trackingUrl}" alt="Byg Garanti" width="120" height="auto" style="display: inline-block;" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFoamKingEmail(
|
||||||
|
customer: QuoteRequestBody["customerInfo"],
|
||||||
|
details: CalculationDetails,
|
||||||
|
quoteLink: string,
|
||||||
|
quoteId: number
|
||||||
|
): string {
|
||||||
|
const components = []
|
||||||
|
if (details.includeInsulation)
|
||||||
|
components.push(
|
||||||
|
`Isolering: ${details.insulationThickness} cm (${formatPrice(Math.round(details.insulation))})`
|
||||||
|
)
|
||||||
|
if (details.includeFloorHeating) {
|
||||||
|
components.push(`Gulvvarme: ${formatPrice(Math.round(details.floorHeating))}`)
|
||||||
|
components.push(`Syntetisk net: ${formatPrice(Math.round(details.syntheticNet))}`)
|
||||||
|
}
|
||||||
|
if (details.includeCompound) {
|
||||||
|
components.push(
|
||||||
|
`Flydespartel (${getFlooringTypeName(details.flooringType)}): ${formatPrice(Math.round(details.selfLevelingCompound))}`
|
||||||
|
)
|
||||||
|
components.push(
|
||||||
|
`Pumpebil (${details.compoundWeight} kg): ${formatPrice(Math.round(details.pumpTruckFee))}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 700px; margin: 0 auto; }
|
||||||
|
.header { background: #e67e22; color: white; padding: 20px; }
|
||||||
|
.header h1 { margin: 0; font-size: 20px; }
|
||||||
|
.section { padding: 20px; border-bottom: 1px solid #eee; }
|
||||||
|
.section h2 { color: #1e3a5f; font-size: 16px; margin-top: 0; text-transform: uppercase; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
td { padding: 8px 0; }
|
||||||
|
td:first-child { color: #666; width: 50%; }
|
||||||
|
td:last-child { text-align: right; }
|
||||||
|
.price-row { background: #f5f5f5; font-weight: bold; }
|
||||||
|
.price-row td { padding: 12px 8px; }
|
||||||
|
.total { font-size: 18px; color: #1e3a5f; }
|
||||||
|
.remarks { background: #fffbeb; padding: 15px; border-left: 4px solid #f59e0b; margin-top: 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Ny tilbudsanmodning #${quoteId}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #3b82f6; padding: 15px 20px; text-align: center;">
|
||||||
|
<a href="${quoteLink}" style="color: white; text-decoration: none; font-weight: bold; font-size: 16px;">
|
||||||
|
Se detaljeret tilbud online →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Kundeoplysninger</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>Navn:</td><td><strong>${customer.name}</strong></td></tr>
|
||||||
|
<tr><td>Email:</td><td><a href="mailto:${customer.email}">${customer.email}</a></td></tr>
|
||||||
|
<tr><td>Telefon:</td><td><a href="tel:${customer.phone}">${customer.phone}</a></td></tr>
|
||||||
|
<tr><td>Postnummer:</td><td>${customer.postalCode}</td></tr>
|
||||||
|
${customer.address ? `<tr><td>Adresse:</td><td>${customer.address}</td></tr>` : ""}
|
||||||
|
</table>
|
||||||
|
${customer.remarks ? `<div class="remarks"><strong>Bemærkninger fra kunden:</strong><br>${customer.remarks}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Projektspecifikationer</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>Gulvareal:</td><td><strong>${details.area} m²</strong></td></tr>
|
||||||
|
<tr><td>Gulvhøjde:</td><td>${details.height} cm</td></tr>
|
||||||
|
<tr><td>Isoleringstykkelse:</td><td>${details.insulationThickness} cm</td></tr>
|
||||||
|
<tr><td>Spartelvægt:</td><td>${details.compoundWeight} kg</td></tr>
|
||||||
|
<tr><td>Afstand (tur/retur):</td><td>${details.distance} km</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Prisberegning</h2>
|
||||||
|
<table>
|
||||||
|
${components
|
||||||
|
.map((c) => {
|
||||||
|
const [label, price] = c.split(": ")
|
||||||
|
return `<tr><td style="color: #666;">${label}:</td><td style="text-align: right;">${price}</td></tr>`
|
||||||
|
})
|
||||||
|
.join("")}
|
||||||
|
<tr><td style="color: #666;">Startgebyr:</td><td style="text-align: right;">${formatPrice(Math.round(details.startFee))}</td></tr>
|
||||||
|
<tr><td colspan="2" style="border-top: 1px solid #ddd; padding-top: 12px;"></td></tr>
|
||||||
|
<tr><td style="color: #666;">Subtotal:</td><td style="text-align: right;">${formatPrice(Math.round(details.subtotal))}</td></tr>
|
||||||
|
<tr><td style="color: #666;">Afdækning (0.7%):</td><td style="text-align: right;">${formatPrice(Math.round(details.coveringFee))}</td></tr>
|
||||||
|
<tr><td style="color: #666;">Affald (0.25%):</td><td style="text-align: right;">${formatPrice(Math.round(details.wasteFee))}</td></tr>
|
||||||
|
<tr><td style="color: #666;">Transport:</td><td style="text-align: right;">${formatPrice(Math.round(details.transport))}</td></tr>
|
||||||
|
${details.bridgeFee > 0 ? `<tr><td style="color: #666;">Storebælt:</td><td style="text-align: right;">${formatPrice(details.bridgeFee)}</td></tr>` : ""}
|
||||||
|
<tr style="background: #f5f5f5; font-weight: bold;"><td style="padding: 12px 8px;">Total ekskl. moms:</td><td style="text-align: right; padding: 12px 8px;">${formatPrice(Math.round(details.totalExclVat))}</td></tr>
|
||||||
|
<tr><td style="color: #666;">Moms (25%):</td><td style="text-align: right;">${formatPrice(Math.round(details.vat))}</td></tr>
|
||||||
|
<tr style="background: #f5f5f5; font-weight: bold;"><td style="padding: 12px 8px; font-size: 18px; color: #1e3a5f;">Total inkl. moms:</td><td style="text-align: right; padding: 12px 8px; font-size: 18px; color: #1e3a5f;">${formatPrice(Math.round(details.totalInclVat))}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="border: none;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Denne anmodning er genereret automatisk fra prisberegneren på beregner.foamking.dk<br>
|
||||||
|
Tidspunkt: ${new Date().toLocaleString("da-DK", { timeZone: "Europe/Copenhagen" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body: QuoteRequestBody = await request.json()
|
||||||
const { customerInfo, calculationDetails } = quoteRequestSchema.parse(body)
|
const { customerInfo, calculationDetails } = body
|
||||||
|
|
||||||
// Format email content
|
if (!customerInfo || !calculationDetails) {
|
||||||
const emailContent = formatEmailContent(customerInfo, calculationDetails)
|
return NextResponse.json({ error: "Manglende data" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
// In production, you would send this via an email service
|
// Save quote to database
|
||||||
// For now, we'll just log it and return success
|
const { id: quoteId, slug } = saveQuote({
|
||||||
console.log("Quote request email:", emailContent)
|
postalCode: customerInfo.postalCode,
|
||||||
|
address: customerInfo.address,
|
||||||
|
area: calculationDetails.area,
|
||||||
|
height: calculationDetails.height,
|
||||||
|
includeFloorHeating: calculationDetails.includeFloorHeating,
|
||||||
|
flooringType: calculationDetails.flooringType,
|
||||||
|
customerName: customerInfo.name,
|
||||||
|
customerEmail: customerInfo.email,
|
||||||
|
customerPhone: customerInfo.phone,
|
||||||
|
remarks: customerInfo.remarks,
|
||||||
|
totalExclVat: calculationDetails.totalExclVat,
|
||||||
|
totalInclVat: calculationDetails.totalInclVat,
|
||||||
|
})
|
||||||
|
|
||||||
// TODO: Implement actual email sending using a service like:
|
// Generate the quote link
|
||||||
// - SendGrid
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://beregner.foamking.dk"
|
||||||
// - AWS SES
|
const quoteLink = `${baseUrl}/tilbud/${slug}`
|
||||||
// - Resend
|
|
||||||
// - Nodemailer with SMTP
|
const transporter = createTransporter()
|
||||||
|
|
||||||
|
// Get Foam King recipients (supports comma-separated emails)
|
||||||
|
const foamKingEmails = (process.env.EMAIL_TO || "info@foamking.dk")
|
||||||
|
.split(",")
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter((email) => email.length > 0)
|
||||||
|
|
||||||
|
const fromName = process.env.EMAIL_FROM_NAME || "Foam King Prisberegner"
|
||||||
|
|
||||||
|
// Generate tracking URL for email open tracking
|
||||||
|
const trackingUrl = `${baseUrl}/api/track/${quoteId}`
|
||||||
|
|
||||||
|
// Send email to customer
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${fromName}" <${process.env.SMTP_USER}>`,
|
||||||
|
to: customerInfo.email,
|
||||||
|
subject: "Dit prisoverslag fra Foam King Gulve",
|
||||||
|
html: formatCustomerEmail(customerInfo, calculationDetails, trackingUrl),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send email to Foam King
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${fromName}" <${process.env.SMTP_USER}>`,
|
||||||
|
to: foamKingEmails,
|
||||||
|
replyTo: customerInfo.email,
|
||||||
|
subject: `Tilbud #${quoteId}: ${customerInfo.name} - ${customerInfo.postalCode} - ${calculationDetails.area} m²`,
|
||||||
|
html: formatFoamKingEmail(customerInfo, calculationDetails, quoteLink, quoteId),
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Tilbudsanmodning modtaget. Vi kontakter dig snarest muligt.",
|
message: "Tak! Vi har modtaget din anmodning og sendt en bekræftelse til din email.",
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Quote request error:", error)
|
console.error("Quote request error:", error)
|
||||||
|
return NextResponse.json({ error: "Der opstod en fejl. Prøv igen senere." }, { status: 500 })
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Ugyldige data", details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Der opstod en fejl. Prøv igen senere." },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEmailContent(
|
|
||||||
customerInfo: z.infer<typeof quoteRequestSchema>["customerInfo"],
|
|
||||||
details: CalculationDetails
|
|
||||||
): string {
|
|
||||||
return `
|
|
||||||
Ny tilbudsanmodning fra Foam King Gulve Prisberegner
|
|
||||||
|
|
||||||
KUNDEOPLYSNINGER:
|
|
||||||
-----------------
|
|
||||||
Navn: ${customerInfo.name}
|
|
||||||
Email: ${customerInfo.email}
|
|
||||||
Telefon: ${customerInfo.phone}
|
|
||||||
Postnummer: ${customerInfo.postalCode}
|
|
||||||
Adresse: ${customerInfo.address || "Ikke angivet"}
|
|
||||||
|
|
||||||
PROJEKTDETALJER:
|
|
||||||
----------------
|
|
||||||
Gulvareal: ${details.area} m²
|
|
||||||
Gulvhøjde: ${details.height} cm
|
|
||||||
Isoleringstykkelse: ${details.insulationThickness} cm
|
|
||||||
Isoleringsvolumen: ${details.insulationVolume.toFixed(2)} m³
|
|
||||||
Spartelvægt: ${details.compoundWeight.toLocaleString("da-DK")} kg
|
|
||||||
|
|
||||||
PRISBEREGNING:
|
|
||||||
--------------
|
|
||||||
Isolering: ${formatPrice(details.insulation)}
|
|
||||||
Gulvvarme: ${formatPrice(details.floorHeating)}
|
|
||||||
Syntetisk net: ${formatPrice(details.syntheticNet)}
|
|
||||||
Flydespartel: ${formatPrice(details.selfLevelingCompound)}
|
|
||||||
Pumpebil-tillæg: ${formatPrice(details.pumpTruckFee)}
|
|
||||||
Startgebyr: ${formatPrice(details.startFee)}
|
|
||||||
|
|
||||||
Subtotal: ${formatPrice(details.subtotal)}
|
|
||||||
Tillæg (afdækning + affald): ${formatPrice(details.totalFees)}
|
|
||||||
Transport: ${formatPrice(details.transport)}
|
|
||||||
${details.bridgeFee > 0 ? `Storebælt-tillæg: ${formatPrice(details.bridgeFee)}` : ""}
|
|
||||||
|
|
||||||
Total ekskl. moms: ${formatPrice(details.totalExclVat)}
|
|
||||||
Moms (25%): ${formatPrice(details.vat)}
|
|
||||||
TOTAL INKL. MOMS: ${formatPrice(details.totalInclVat)}
|
|
||||||
|
|
||||||
BEMÆRKNINGER:
|
|
||||||
-------------
|
|
||||||
${customerInfo.remarks || "Ingen bemærkninger"}
|
|
||||||
|
|
||||||
AFSTAND:
|
|
||||||
--------
|
|
||||||
Kørselsafstand (tur-retur): ${details.distance} km
|
|
||||||
|
|
||||||
---
|
|
||||||
Sendt fra beregner.foamking.dk
|
|
||||||
`.trim()
|
|
||||||
}
|
|
||||||
48
app/api/quotes/route.ts
Normal file
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/api/track/[id]/route.ts
Normal 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
|
|
@ -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 på 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,61 +4,48 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(0.985 0.0014 39.68);
|
--background: 39.68 0.14% 98.5%;
|
||||||
--foreground: oklch(0.2683 0.0043 41.05);
|
--foreground: 41.05 1.6% 16.5%;
|
||||||
--card: var(--color-white);
|
--card: 0 0% 100%;
|
||||||
--card-foreground: oklch(0.2683 0.0043 41.05);
|
--card-foreground: 41.05 1.6% 16.5%;
|
||||||
--popover: var(--color-white);
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: oklch(0.2683 0.0043 41.05);
|
--popover-foreground: 41.05 1.6% 16.5%;
|
||||||
--primary: oklch(0.8651 0.1153 207.08);
|
--primary: 207.08 60% 75%;
|
||||||
--primary-foreground: var(--color-black);
|
--primary-foreground: 0 0% 0%;
|
||||||
--secondary: oklch(0.72 0.1613 29.29);
|
--secondary: 29.29 70% 60%;
|
||||||
--secondary-foreground: var(--color-black);
|
--secondary-foreground: 0 0% 0%;
|
||||||
--muted: oklch(0.9674 0.0029 40.41);
|
--muted: 40.41 3% 96%;
|
||||||
--muted-foreground: oklch(0.4426 0.0055 43.48);
|
--muted-foreground: 43.48 3% 35%;
|
||||||
--accent: oklch(0.9674 0.0029 40.41);
|
--accent: 40.41 3% 96%;
|
||||||
--accent-foreground: oklch(0.2683 0.0043 41.05);
|
--accent-foreground: 41.05 1.6% 16.5%;
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: 27.325 70% 45%;
|
||||||
--destructive-foreground: oklch(0.985 0.0014 39.68);
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: oklch(0.9227 0.0041 40.62);
|
--border: 40.62 2% 90%;
|
||||||
--input: oklch(0.8693 0.0046 41.1);
|
--input: 41.1 2% 85%;
|
||||||
--ring: oklch(0.8651 0.1153 207.08);
|
--ring: 207.08 60% 75%;
|
||||||
--chart-1: oklch(0.8651 0.1153 207.08);
|
|
||||||
--chart-2: oklch(0.72 0.1613 29.29);
|
|
||||||
--chart-3: oklch(0.7886 0.1393 211.4);
|
|
||||||
--chart-4: oklch(0.8154 0.1004 27.92);
|
|
||||||
--chart-5: oklch(0.8651 0.1153 207.08);
|
|
||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
|
|
||||||
--color-white: #ffffff;
|
|
||||||
--color-black: #000000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.1465 0.0038 39.55);
|
--background: 39.55 3% 9%;
|
||||||
--foreground: oklch(0.9227 0.0041 40.62);
|
--foreground: 40.62 2% 90%;
|
||||||
--card: oklch(0.213 0.0041 40.86);
|
--card: 40.86 2% 13%;
|
||||||
--card-foreground: oklch(0.9227 0.0041 40.62);
|
--card-foreground: 40.62 2% 90%;
|
||||||
--popover: oklch(0.213 0.0041 40.86);
|
--popover: 40.86 2% 13%;
|
||||||
--popover-foreground: oklch(0.9227 0.0041 40.62);
|
--popover-foreground: 40.62 2% 90%;
|
||||||
--primary: oklch(0.8651 0.1153 207.08);
|
--primary: 207.08 60% 75%;
|
||||||
--primary-foreground: var(--color-black);
|
--primary-foreground: 0 0% 0%;
|
||||||
--secondary: oklch(0.72 0.1613 29.29);
|
--secondary: 29.29 70% 60%;
|
||||||
--secondary-foreground: var(--color-black);
|
--secondary-foreground: 0 0% 0%;
|
||||||
--muted: oklch(0.2683 0.0043 41.05);
|
--muted: 41.05 1.6% 16.5%;
|
||||||
--muted-foreground: oklch(0.8693 0.0046 41.1);
|
--muted-foreground: 41.1 2% 85%;
|
||||||
--accent: oklch(0.2683 0.0043 41.05);
|
--accent: 41.05 1.6% 16.5%;
|
||||||
--accent-foreground: oklch(0.9227 0.0041 40.62);
|
--accent-foreground: 40.62 2% 90%;
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: 22.216 65% 55%;
|
||||||
--destructive-foreground: oklch(0.985 0.0014 39.68);
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: oklch(0.2683 0.0043 41.05);
|
--border: 41.05 1.6% 16.5%;
|
||||||
--input: oklch(0.3732 0.0051 42.7);
|
--input: 42.7 3% 23%;
|
||||||
--ring: oklch(0.8651 0.1153 207.08);
|
--ring: 207.08 60% 75%;
|
||||||
--chart-1: oklch(0.8651 0.1153 207.08);
|
|
||||||
--chart-2: oklch(0.72 0.1613 29.29);
|
|
||||||
--chart-3: oklch(0.7886 0.1393 211.4);
|
|
||||||
--chart-4: oklch(0.8154 0.1004 27.92);
|
|
||||||
--chart-5: oklch(0.8651 0.1153 207.08);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
222
app/historik/page.tsx
Normal file
|
|
@ -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
|
After Width: | Height: | Size: 3.2 KiB |
|
|
@ -14,7 +14,8 @@ const geistMono = Geist_Mono({
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Foam King Gulve - Prisberegner",
|
title: "Foam King Gulve - Prisberegner",
|
||||||
description: "Få et hurtigt prisoverslag på din nye gulvløsning med isolering, gulvvarme og støbning",
|
description:
|
||||||
|
"Få et hurtigt prisoverslag på din nye gulvløsning med isolering, gulvvarme og støbning",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
@ -24,9 +25,7 @@ export default function RootLayout({
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="da">
|
<html lang="da">
|
||||||
<body
|
<body className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
123
app/login/page.tsx
Normal file
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
386
app/page.tsx
|
|
@ -2,39 +2,56 @@
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { CalculatorForm } from "@/components/calculator/calculator-form"
|
import { StepWizard } from "@/components/calculator/step-wizard"
|
||||||
import { CalculationDetailsView } from "@/components/calculator/calculation-details"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import type { CalculationDetails } from "@/lib/calculations"
|
import { formatEstimate, type CalculationDetails } from "@/lib/calculations"
|
||||||
import { formatEstimate } from "@/lib/calculations"
|
import {
|
||||||
import { Send, Eye, EyeOff } from "lucide-react"
|
Phone,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowRight,
|
||||||
|
RotateCcw,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [calculationResult, setCalculationResult] = useState<CalculationDetails | null>(null)
|
const [result, setResult] = useState<CalculationDetails | null>(null)
|
||||||
const [showAdminMode, setShowAdminMode] = useState(false)
|
const [customerData, setCustomerData] = useState<any>(null)
|
||||||
const [isRequestingQuote, setIsRequestingQuote] = useState(false)
|
const [showResult, setShowResult] = useState(false)
|
||||||
const [customerInfo, setCustomerInfo] = useState<any>(null)
|
|
||||||
|
|
||||||
const handleCalculation = (result: CalculationDetails, formData?: any) => {
|
const handleComplete = (calculationResult: CalculationDetails, formData: any) => {
|
||||||
setCalculationResult(result)
|
setResult(calculationResult)
|
||||||
if (formData) {
|
setCustomerData(formData)
|
||||||
setCustomerInfo(formData)
|
setShowResult(true)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleQuoteRequest = async () => {
|
const handleReset = () => {
|
||||||
if (!calculationResult || !customerInfo) return
|
setResult(null)
|
||||||
|
setCustomerData(null)
|
||||||
|
setShowResult(false)
|
||||||
|
}
|
||||||
|
|
||||||
setIsRequestingQuote(true)
|
const [isRequesting, setIsRequesting] = useState(false)
|
||||||
|
|
||||||
|
const handleRequestQuote = async () => {
|
||||||
|
if (!result || !customerData) return
|
||||||
|
|
||||||
|
setIsRequesting(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/quote-request", {
|
const response = await fetch("/api/quote-request", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
customerInfo,
|
customerInfo: {
|
||||||
calculationDetails: calculationResult,
|
name: customerData.name,
|
||||||
|
email: customerData.email,
|
||||||
|
phone: customerData.phone,
|
||||||
|
postalCode: customerData.postalCode,
|
||||||
|
address: customerData.address,
|
||||||
|
remarks: customerData.remarks,
|
||||||
|
},
|
||||||
|
calculationDetails: result,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -48,102 +65,271 @@ export default function Home() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("Der opstod en fejl. Prøv igen senere.")
|
alert("Der opstod en fejl. Prøv igen senere.")
|
||||||
} finally {
|
} finally {
|
||||||
setIsRequestingQuote(false)
|
setIsRequesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gradient-to-b from-background to-muted/20">
|
<main className="min-h-screen bg-background">
|
||||||
<div className="container mx-auto px-4 py-8">
|
{/* Hero Section */}
|
||||||
{/* Header */}
|
<section className="relative flex min-h-[70vh] items-center justify-center overflow-hidden">
|
||||||
<div className="mb-8 text-center">
|
{/* Background Image */}
|
||||||
<div className="mb-4 flex justify-center">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/foam-king-logo.png"
|
src="/gulv.jpeg"
|
||||||
alt="Foam King Gulve"
|
alt="Smukt gulv i moderne hjem"
|
||||||
width={200}
|
fill
|
||||||
height={80}
|
className="object-cover"
|
||||||
priority
|
priority
|
||||||
className="h-20 w-auto"
|
/>
|
||||||
/>
|
<div className="absolute inset-0 bg-gradient-to-b from-black/60 via-black/40 to-black/70" />
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold">Foam King Gulve</h1>
|
|
||||||
<p className="mt-2 text-lg text-muted-foreground">
|
|
||||||
Professionelle gulvløsninger med isolering, gulvvarme og støbning
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Mode Toggle */}
|
{/* Hero Content */}
|
||||||
<div className="mb-4 flex justify-center">
|
<div className="container relative z-10 mx-auto px-4 text-center text-white">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Image
|
||||||
|
src="/foam-king-logo.png"
|
||||||
|
alt="Foam King"
|
||||||
|
width={180}
|
||||||
|
height={72}
|
||||||
|
className="mx-auto h-16 w-auto brightness-0 invert"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-4 text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl">
|
||||||
|
Gulvarbejde i<br />
|
||||||
|
<span className="text-secondary">verdensklasse</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto mb-8 max-w-2xl text-lg text-white/90 sm:text-xl">
|
||||||
|
Professionel udførelse af betongulve, gulvvarme og isolering. Vi leverer kvalitet der
|
||||||
|
holder i mange år fremover.
|
||||||
|
</p>
|
||||||
|
<div className="mb-8 flex flex-wrap justify-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 backdrop-blur-sm">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-secondary" />
|
||||||
|
<span>Stor erfaring</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 backdrop-blur-sm">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-secondary" />
|
||||||
|
<span>Byg Garanti</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-white/10 px-4 py-2 backdrop-blur-sm">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-secondary" />
|
||||||
|
<span>Gratis tilbud</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
size="lg"
|
||||||
size="sm"
|
className="h-14 bg-secondary px-8 text-lg text-secondary-foreground hover:bg-secondary/90"
|
||||||
onClick={() => setShowAdminMode(!showAdminMode)}
|
onClick={() =>
|
||||||
className="gap-2"
|
document.getElementById("calculator")?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{showAdminMode ? (
|
Få 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">Få dit personlige tilbud</h2>
|
||||||
|
<p className="mx-auto max-w-xl text-muted-foreground">
|
||||||
|
Besvar nogle få spørgsmål, så 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 på {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
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)} m³ × ${formatPrice(PRICES.INSULATION_TOTAL)}/m³)` : "(simpel arbejdsløn)"}:
|
Isolering{" "}
|
||||||
|
{details.includeInsulation
|
||||||
|
? details.insulationThickness > 0
|
||||||
|
? `(${details.insulationVolume.toFixed(2)} m³ × ${formatPrice(PRICES.INSULATION_TOTAL_PER_M3)}/m³)`
|
||||||
|
: "(simpel arbejdsløn)"
|
||||||
|
: "(fravalgt)"}
|
||||||
|
:
|
||||||
</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} m² × ${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} m² × ${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} m² × ${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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,41 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm, Controller } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import * as z from "zod"
|
import * as z from "zod"
|
||||||
import { Calculator, Loader2 } from "lucide-react"
|
import { Calculator, Loader2, Thermometer, Layers, PaintBucket } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { CONSTRAINTS } from "@/lib/constants"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { validateDanishPostalCode, isInCoverageArea, getDistance } from "@/lib/distance"
|
import { Slider } from "@/components/ui/slider"
|
||||||
import { calculatePrice, formatEstimate, type CalculationDetails } from "@/lib/calculations"
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { CONSTRAINTS, FLOORING_TYPES, type FlooringType } from "@/lib/constants"
|
||||||
|
import { validateDanishPostalCode, getDistance } from "@/lib/distance"
|
||||||
|
import { calculatePrice, type CalculationDetails } from "@/lib/calculations"
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2, "Navn skal være mindst 2 tegn"),
|
name: z.string().refine((val) => {
|
||||||
|
const parts = val.trim().split(/\s+/)
|
||||||
|
return parts.length >= 2 && parts[0].length >= 3 && parts[1].length >= 3
|
||||||
|
}, "Indtast fornavn og efternavn (mindst 3 tegn hver)"),
|
||||||
email: z.string().email("Ugyldig email"),
|
email: z.string().email("Ugyldig email"),
|
||||||
phone: z.string().regex(/^\d{8}$/, "Telefonnummer skal være 8 cifre"),
|
phone: z.string().regex(/^\d{8}$/, "Telefonnummer skal være 8 cifre"),
|
||||||
postalCode: z
|
postalCode: z
|
||||||
.string()
|
.string()
|
||||||
.length(4, "Postnummer skal være 4 cifre")
|
.length(4, "Postnummer skal være 4 cifre")
|
||||||
.refine(validateDanishPostalCode, "Ugyldigt dansk postnummer")
|
.refine(validateDanishPostalCode, "Ugyldigt dansk postnummer"),
|
||||||
.refine(isInCoverageArea, "Beklager, vi dækker ikke dette område"),
|
|
||||||
address: z.string().optional(),
|
address: z.string().optional(),
|
||||||
area: z.coerce
|
area: z.coerce
|
||||||
.number()
|
.number()
|
||||||
|
|
@ -34,183 +46,424 @@ const formSchema = z.object({
|
||||||
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_HEIGHT} cm`)
|
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_HEIGHT} cm`)
|
||||||
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum højde er ${CONSTRAINTS.MAX_HEIGHT} cm`),
|
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum højde er ${CONSTRAINTS.MAX_HEIGHT} cm`),
|
||||||
remarks: z.string().optional(),
|
remarks: z.string().optional(),
|
||||||
|
includeInsulation: z.boolean(),
|
||||||
|
includeFloorHeating: z.boolean(),
|
||||||
|
includeCompound: z.boolean(),
|
||||||
|
flooringType: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type FormData = z.infer<typeof formSchema>
|
type FormData = z.infer<typeof formSchema>
|
||||||
|
|
||||||
interface CalculatorFormProps {
|
interface CalculatorFormProps {
|
||||||
onCalculation: (result: CalculationDetails, formData?: FormData) => void
|
onCalculation: (
|
||||||
|
result: CalculationDetails,
|
||||||
|
formData?: FormData,
|
||||||
|
distanceSource?: "openrouteservice" | "table"
|
||||||
|
) => void
|
||||||
showDetails?: boolean
|
showDetails?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CalculationProgress {
|
||||||
|
step: string
|
||||||
|
progress: number
|
||||||
|
}
|
||||||
|
|
||||||
export function CalculatorForm({ onCalculation, showDetails = false }: CalculatorFormProps) {
|
export function CalculatorForm({ onCalculation, showDetails = false }: CalculatorFormProps) {
|
||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
|
const [calculationProgress, setCalculationProgress] = useState<CalculationProgress | null>(null)
|
||||||
const [result, setResult] = useState<CalculationDetails | null>(null)
|
const [result, setResult] = useState<CalculationDetails | null>(null)
|
||||||
|
const [distanceSource, setDistanceSource] = useState<"openrouteservice" | "table" | null>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
watch,
|
watch,
|
||||||
|
control,
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
postalCode: "",
|
||||||
|
address: "",
|
||||||
|
area: 50,
|
||||||
|
height: 15,
|
||||||
|
remarks: "",
|
||||||
|
includeInsulation: true,
|
||||||
|
includeFloorHeating: true,
|
||||||
|
includeCompound: true,
|
||||||
|
flooringType: "STANDARD",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const watchedIncludeCompound = watch("includeCompound")
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
setIsCalculating(true)
|
setIsCalculating(true)
|
||||||
|
setDistanceSource(null)
|
||||||
|
setCalculationProgress({ step: "Finder din adresse...", progress: 20 })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate API delay
|
let distance: number
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
let source: "openrouteservice" | "table" = "table"
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCalculationProgress({ step: "Beregner afstand...", progress: 40 })
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
postalCode: data.postalCode,
|
||||||
|
...(data.address && { address: data.address }),
|
||||||
|
})
|
||||||
|
const distanceResponse = await fetch(`/api/distance?${params}`)
|
||||||
|
const distanceData = await distanceResponse.json()
|
||||||
|
distance = distanceData.distance
|
||||||
|
source = distanceData.source
|
||||||
|
} catch {
|
||||||
|
distance = getDistance(data.postalCode)
|
||||||
|
source = "table"
|
||||||
|
}
|
||||||
|
|
||||||
|
setDistanceSource(source)
|
||||||
|
setCalculationProgress({ step: "Beregner pris...", progress: 70 })
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
|
|
||||||
const distance = getDistance(data.postalCode)
|
|
||||||
const calculationResult = calculatePrice({
|
const calculationResult = calculatePrice({
|
||||||
area: data.area,
|
area: data.area,
|
||||||
height: data.height,
|
height: data.height,
|
||||||
postalCode: data.postalCode,
|
postalCode: data.postalCode,
|
||||||
distance,
|
distance,
|
||||||
|
includeInsulation: data.includeInsulation,
|
||||||
|
includeFloorHeating: data.includeFloorHeating,
|
||||||
|
includeCompound: data.includeCompound,
|
||||||
|
flooringType: data.flooringType as FlooringType,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setCalculationProgress({ step: "Færdig!", progress: 100 })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
|
||||||
setResult(calculationResult)
|
setResult(calculationResult)
|
||||||
onCalculation(calculationResult, data)
|
onCalculation(calculationResult, data, source)
|
||||||
} finally {
|
} finally {
|
||||||
setIsCalculating(false)
|
setIsCalculating(false)
|
||||||
|
setCalculationProgress(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-2xl">
|
<Card className="w-full max-w-2xl shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader className="rounded-t-lg bg-gradient-to-r from-secondary/10 to-secondary/5">
|
||||||
<CardTitle className="flex items-center gap-2 text-2xl">
|
<CardTitle className="flex items-center gap-3 text-2xl">
|
||||||
<Calculator className="h-6 w-6" />
|
<div className="rounded-full bg-primary p-2">
|
||||||
|
<Calculator className="h-5 w-5 text-secondary-foreground" />
|
||||||
|
</div>
|
||||||
Prisberegner
|
Prisberegner
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="text-base">
|
||||||
Få et hurtigt overslag på din nye gulvløsning
|
Få et hurtigt overslag på din nye gulvløsning
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="pt-6">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{/* Contact Section */}
|
||||||
<div>
|
<section>
|
||||||
<Label htmlFor="name">Navn *</Label>
|
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<Input
|
Kontaktoplysninger
|
||||||
id="name"
|
</h3>
|
||||||
{...register("name")}
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
placeholder="Dit navn"
|
<div className="space-y-2">
|
||||||
className="mt-1"
|
<Label htmlFor="name">Navn</Label>
|
||||||
/>
|
<Input id="name" {...register("name")} placeholder="Dit navn" />
|
||||||
{errors.name && (
|
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
|
||||||
<p className="mt-1 text-sm text-destructive">{errors.name.message}</p>
|
</div>
|
||||||
)}
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input id="email" type="email" {...register("email")} placeholder="din@email.dk" />
|
||||||
|
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">Telefon</Label>
|
||||||
|
<Input id="phone" {...register("phone")} placeholder="12345678" />
|
||||||
|
{errors.phone && <p className="text-sm text-destructive">{errors.phone.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="postalCode">Postnummer</Label>
|
||||||
|
<Input id="postalCode" {...register("postalCode")} placeholder="4550" />
|
||||||
|
{errors.postalCode && (
|
||||||
|
<p className="text-sm text-destructive">{errors.postalCode.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label htmlFor="address">
|
||||||
|
Adresse <span className="font-normal text-muted-foreground">(valgfrit)</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="address" {...register("address")} placeholder="Vejnavn og nummer" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div>
|
{/* Floor Dimensions Section */}
|
||||||
<Label htmlFor="email">Email *</Label>
|
<section>
|
||||||
<Input
|
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
id="email"
|
Gulvmål
|
||||||
type="email"
|
</h3>
|
||||||
{...register("email")}
|
<div className="space-y-6 rounded-xl bg-muted/30 p-5">
|
||||||
placeholder="din@email.dk"
|
{/* Area Slider */}
|
||||||
className="mt-1"
|
<div className="space-y-3">
|
||||||
/>
|
<div className="flex items-center justify-between">
|
||||||
{errors.email && (
|
<Label className="text-base">Gulvareal</Label>
|
||||||
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p>
|
<div className="flex items-baseline gap-1">
|
||||||
)}
|
<Controller
|
||||||
|
name="area"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
className="h-8 w-16 border-0 bg-transparent p-0 text-right text-lg font-semibold"
|
||||||
|
min={CONSTRAINTS.MIN_AREA}
|
||||||
|
max={CONSTRAINTS.MAX_AREA}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">m²</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
name="area"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Slider
|
||||||
|
min={CONSTRAINTS.MIN_AREA}
|
||||||
|
max={CONSTRAINTS.MAX_AREA}
|
||||||
|
step={5}
|
||||||
|
value={[field.value || CONSTRAINTS.MIN_AREA]}
|
||||||
|
onValueChange={([value]) => field.onChange(value)}
|
||||||
|
className="py-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{CONSTRAINTS.MIN_AREA} m²</span>
|
||||||
|
<span>{CONSTRAINTS.MAX_AREA} m²</span>
|
||||||
|
</div>
|
||||||
|
{errors.area && <p className="text-sm text-destructive">{errors.area.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Height Slider */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-base">Gulvhøjde</Label>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<Controller
|
||||||
|
name="height"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||||
|
className="h-8 w-16 border-0 bg-transparent p-0 text-right text-lg font-semibold"
|
||||||
|
min={CONSTRAINTS.MIN_HEIGHT}
|
||||||
|
max={CONSTRAINTS.MAX_HEIGHT}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">cm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
name="height"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Slider
|
||||||
|
min={CONSTRAINTS.MIN_HEIGHT}
|
||||||
|
max={CONSTRAINTS.MAX_HEIGHT}
|
||||||
|
step={1}
|
||||||
|
value={[field.value || CONSTRAINTS.MIN_HEIGHT]}
|
||||||
|
onValueChange={([value]) => field.onChange(value)}
|
||||||
|
className="py-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{CONSTRAINTS.MIN_HEIGHT} cm</span>
|
||||||
|
<span>{CONSTRAINTS.MAX_HEIGHT} cm</span>
|
||||||
|
</div>
|
||||||
|
{errors.height && (
|
||||||
|
<p className="text-sm text-destructive">{errors.height.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div>
|
{/* Components Section */}
|
||||||
<Label htmlFor="phone">Telefon *</Label>
|
<section>
|
||||||
<Input
|
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
id="phone"
|
Vælg komponenter
|
||||||
{...register("phone")}
|
</h3>
|
||||||
placeholder="12345678"
|
<div className="grid gap-3">
|
||||||
className="mt-1"
|
{/* Insulation Toggle */}
|
||||||
|
<Controller
|
||||||
|
name="includeInsulation"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label
|
||||||
|
className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
|
||||||
|
field.value
|
||||||
|
? "border-secondary bg-secondary/10"
|
||||||
|
: "border-muted hover:border-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
|
||||||
|
>
|
||||||
|
<Layers className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">Isolering</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Gulvisolering under varmeanlæg
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.phone && (
|
|
||||||
<p className="mt-1 text-sm text-destructive">{errors.phone.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{/* Floor Heating Toggle */}
|
||||||
<Label htmlFor="postalCode">Postnummer *</Label>
|
<Controller
|
||||||
<Input
|
name="includeFloorHeating"
|
||||||
id="postalCode"
|
control={control}
|
||||||
{...register("postalCode")}
|
render={({ field }) => (
|
||||||
placeholder="4550"
|
<label
|
||||||
className="mt-1"
|
className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
|
||||||
|
field.value
|
||||||
|
? "border-secondary bg-secondary/10"
|
||||||
|
: "border-muted hover:border-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
|
||||||
|
>
|
||||||
|
<Thermometer className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">Gulvvarme</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Syntetisk net + Ø16 PEX (excl. tilslutning)</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.postalCode && (
|
|
||||||
<p className="mt-1 text-sm text-destructive">{errors.postalCode.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{/* Compound Toggle */}
|
||||||
<Label htmlFor="address">Adresse</Label>
|
<Controller
|
||||||
<Input
|
name="includeCompound"
|
||||||
id="address"
|
control={control}
|
||||||
{...register("address")}
|
render={({ field }) => (
|
||||||
placeholder="Vejnavn og nummer (valgfrit)"
|
<label
|
||||||
className="mt-1"
|
className={`flex cursor-pointer items-center gap-4 rounded-xl border-2 p-4 transition-all ${
|
||||||
/>
|
field.value
|
||||||
</div>
|
? "border-secondary bg-secondary/10"
|
||||||
|
: "border-muted hover:border-muted-foreground/30"
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
}`}
|
||||||
<div>
|
>
|
||||||
<Label htmlFor="area">
|
<div
|
||||||
Gulvareal (m²) *
|
className={`rounded-lg p-2 ${field.value ? "bg-secondary text-secondary-foreground" : "bg-muted"}`}
|
||||||
<span className="ml-1 text-xs text-muted-foreground">
|
>
|
||||||
({CONSTRAINTS.MIN_AREA}-{CONSTRAINTS.MAX_AREA} m²)
|
<PaintBucket className="h-5 w-5" />
|
||||||
</span>
|
</div>
|
||||||
</Label>
|
<div className="flex-1">
|
||||||
<Input
|
<div className="font-medium">Gulvstøbning</div>
|
||||||
id="area"
|
<div className="text-sm text-muted-foreground">
|
||||||
type="number"
|
Flydespartel til færdigt gulv
|
||||||
{...register("area")}
|
</div>
|
||||||
placeholder="50"
|
</div>
|
||||||
className="mt-1"
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.area && (
|
|
||||||
<p className="mt-1 text-sm text-destructive">{errors.area.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div>
|
{/* Flooring Type Section */}
|
||||||
<Label htmlFor="height">
|
{watchedIncludeCompound && (
|
||||||
Gulvhøjde (cm) *
|
<section>
|
||||||
<span className="ml-1 text-xs text-muted-foreground">
|
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
({CONSTRAINTS.MIN_HEIGHT}-{CONSTRAINTS.MAX_HEIGHT} cm)
|
Gulvbelægning
|
||||||
</span>
|
</h3>
|
||||||
</Label>
|
<Controller
|
||||||
<Input
|
name="flooringType"
|
||||||
id="height"
|
control={control}
|
||||||
type="number"
|
render={({ field }) => (
|
||||||
{...register("height")}
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
placeholder="20"
|
{Object.entries(FLOORING_TYPES).map(([key, type]) => (
|
||||||
className="mt-1"
|
<label
|
||||||
|
key={key}
|
||||||
|
className={`flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 p-4 text-center transition-all ${
|
||||||
|
field.value === key
|
||||||
|
? "border-secondary bg-secondary/10"
|
||||||
|
: "border-muted hover:border-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value={key}
|
||||||
|
checked={field.value === key}
|
||||||
|
onChange={() => field.onChange(key)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{type.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{type.description}</span>
|
||||||
|
{type.compoundMultiplier > 1 && (
|
||||||
|
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600">
|
||||||
|
+28% spartel
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.height && (
|
</section>
|
||||||
<p className="mt-1 text-sm text-destructive">{errors.height.message}</p>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{/* Remarks Section */}
|
||||||
<Label htmlFor="remarks">Bemærkninger</Label>
|
<section>
|
||||||
|
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Bemærkninger <span className="font-normal">(valgfrit)</span>
|
||||||
|
</h3>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="remarks"
|
|
||||||
{...register("remarks")}
|
{...register("remarks")}
|
||||||
placeholder="Eventuelle særlige ønsker eller spørgsmål"
|
placeholder="Eventuelle særlige ønsker eller spørgsmål"
|
||||||
className="mt-1"
|
|
||||||
rows={3}
|
rows={3}
|
||||||
|
className="resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<Button type="submit" size="lg" className="w-full" disabled={isCalculating}>
|
{/* Progress Indicator */}
|
||||||
|
{calculationProgress && (
|
||||||
|
<div className="space-y-2 rounded-xl border border-secondary/30 bg-secondary/10 p-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">{calculationProgress.step}</span>
|
||||||
|
<span className="text-muted-foreground">{calculationProgress.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={calculationProgress.progress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
className="h-12 w-full text-base font-semibold"
|
||||||
|
disabled={isCalculating}
|
||||||
|
>
|
||||||
{isCalculating ? (
|
{isCalculating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
Beregner...
|
Beregner...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -218,15 +471,6 @@ export function CalculatorForm({ onCalculation, showDetails = false }: Calculato
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{result && !showDetails && (
|
|
||||||
<div className="mt-6 rounded-lg bg-muted p-6 text-center">
|
|
||||||
<p className="text-3xl font-bold">{formatEstimate(result.totalInclVat)}</p>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
*Prisen er vejledende og kan variere afhængigt af konkrete forhold
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
570
components/calculator/step-wizard.tsx
Normal 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} m²`)
|
||||||
|
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA} m²`),
|
||||||
|
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">Så 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
134
components/dashboard/kanban-board.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
components/dashboard/quote-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
components/dashboard/search-filter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,14 +9,11 @@ const buttonVariants = cva(
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
destructive:
|
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
|
|
@ -35,8 +32,7 @@ const buttonVariants = cva(
|
||||||
)
|
)
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,11 +40,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button"
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,9 @@ const labelVariants = cva(
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
VariantProps<typeof labelVariants>
|
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(labelVariants(), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
|
|
||||||
25
components/ui/progress.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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 }
|
||||||
|
|
@ -2,21 +2,20 @@ import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Textarea = React.forwardRef<
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||||
HTMLTextAreaElement,
|
({ className, ...props }, ref) => {
|
||||||
React.ComponentProps<"textarea">
|
return (
|
||||||
>(({ className, ...props }, ref) => {
|
<textarea
|
||||||
return (
|
className={cn(
|
||||||
<textarea
|
"flex min-h-[60px] w-full rounded-md border border-input bg-white px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className={cn(
|
className
|
||||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
)}
|
||||||
className
|
ref={ref}
|
||||||
)}
|
{...props}
|
||||||
ref={ref}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
)
|
)
|
||||||
})
|
|
||||||
Textarea.displayName = "Textarea"
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea }
|
||||||
BIN
docs/byg_trans.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
docs/company_email.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/dashboard.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
docs/gulv.jpeg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
docs/lovable.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/mobile.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
docs/new_design.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
|
|
@ -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_
|
||||||
|
|
|
||||||
|
|
@ -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
116
lib/auth.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
import { PRICES, PUMP_TRUCK_FEES, CONSTRAINTS, COVERAGE_AREAS } from "./constants"
|
import {
|
||||||
|
PRICES,
|
||||||
|
PUMP_TRUCK_FEES,
|
||||||
|
CONSTRAINTS,
|
||||||
|
COVERAGE_AREAS,
|
||||||
|
FLOORING_TYPES,
|
||||||
|
type FlooringType,
|
||||||
|
} from "./constants"
|
||||||
|
|
||||||
export interface CalculationInput {
|
export interface CalculationInput {
|
||||||
area: number // m²
|
area: number // m²
|
||||||
height: number // cm
|
height: number // cm
|
||||||
postalCode: string
|
postalCode: string
|
||||||
distance: number // km (round trip)
|
distance: number // km (round trip)
|
||||||
|
// Optional components
|
||||||
|
includeInsulation?: boolean // default: true
|
||||||
|
includeFloorHeating?: boolean // default: true
|
||||||
|
includeCompound?: boolean // default: true
|
||||||
|
flooringType?: FlooringType // default: KLINKER
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalculationDetails {
|
export interface CalculationDetails {
|
||||||
|
|
@ -14,6 +26,12 @@ export interface CalculationDetails {
|
||||||
postalCode: string
|
postalCode: string
|
||||||
distance: number
|
distance: number
|
||||||
|
|
||||||
|
// Optional component selections
|
||||||
|
includeInsulation: boolean
|
||||||
|
includeFloorHeating: boolean
|
||||||
|
includeCompound: boolean
|
||||||
|
flooringType: FlooringType
|
||||||
|
|
||||||
// Calculated values
|
// Calculated values
|
||||||
insulationThickness: number // cm
|
insulationThickness: number // cm
|
||||||
insulationVolume: number // m³
|
insulationVolume: number // m³
|
||||||
|
|
@ -43,16 +61,29 @@ export interface CalculationDetails {
|
||||||
totalInclVat: number
|
totalInclVat: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateInsulation(area: number, height: number): {
|
export function calculateInsulation(
|
||||||
|
area: number,
|
||||||
|
height: number
|
||||||
|
): {
|
||||||
thickness: number
|
thickness: number
|
||||||
volume: number
|
volume: number
|
||||||
|
volumePrice: number
|
||||||
|
baseLabor: number
|
||||||
price: number
|
price: number
|
||||||
} {
|
} {
|
||||||
const thickness = Math.max(0, height - CONSTRAINTS.CONCRETE_THICKNESS)
|
const thickness = Math.max(0, height - CONSTRAINTS.CONCRETE_THICKNESS)
|
||||||
const volume = area * (thickness / 100)
|
const volume = area * (thickness / 100)
|
||||||
const price = thickness > 0 ? volume * PRICES.INSULATION_TOTAL : area * PRICES.SIMPLE_LABOR
|
|
||||||
|
|
||||||
return { thickness, volume, price }
|
// Volume-based cost (materials + labor per m³)
|
||||||
|
const volumePrice = thickness > 0 ? volume * PRICES.INSULATION_TOTAL_PER_M3 : 0
|
||||||
|
|
||||||
|
// Base labor cost (always applied per m² when insulation is included)
|
||||||
|
const baseLabor = area * PRICES.INSULATION_BASE_LABOR
|
||||||
|
|
||||||
|
// Total insulation price
|
||||||
|
const price = volumePrice + baseLabor
|
||||||
|
|
||||||
|
return { thickness, volume, volumePrice, baseLabor, price }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculatePumpTruckFee(weight: number): number {
|
export function calculatePumpTruckFee(weight: number): number {
|
||||||
|
|
@ -73,22 +104,38 @@ export function getBridgeFee(postalCode: string): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculatePrice(input: CalculationInput): CalculationDetails {
|
export function calculatePrice(input: CalculationInput): CalculationDetails {
|
||||||
const { area, height, postalCode, distance } = input
|
const {
|
||||||
|
area,
|
||||||
|
height,
|
||||||
|
postalCode,
|
||||||
|
distance,
|
||||||
|
includeInsulation = true,
|
||||||
|
includeFloorHeating = true,
|
||||||
|
includeCompound = true,
|
||||||
|
flooringType = "STANDARD",
|
||||||
|
} = input
|
||||||
|
|
||||||
// Step 1: Calculate derived values
|
// Step 1: Calculate derived values
|
||||||
const insulation = calculateInsulation(area, height)
|
const insulation = calculateInsulation(area, height)
|
||||||
const compoundWeight = area * PRICES.COMPOUND_WEIGHT_PER_M2
|
const compoundWeight = includeCompound ? area * PRICES.COMPOUND_WEIGHT_PER_M2 : 0
|
||||||
|
|
||||||
// Step 2: Calculate components
|
// Get flooring type multiplier
|
||||||
const floorHeating = area * PRICES.FLOOR_HEATING_TOTAL
|
const flooringConfig = FLOORING_TYPES[flooringType]
|
||||||
const syntheticNet = area * PRICES.SYNTHETIC_NET_TOTAL
|
const compoundMultiplier = flooringConfig?.compoundMultiplier ?? 1.0
|
||||||
const selfLevelingCompound = area * PRICES.SELF_LEVELING_COMPOUND
|
|
||||||
const pumpTruckFee = calculatePumpTruckFee(compoundWeight)
|
// Step 2: Calculate components (only if included)
|
||||||
|
const insulationPrice = includeInsulation ? insulation.price : 0
|
||||||
|
const floorHeating = includeFloorHeating ? area * PRICES.FLOOR_HEATING_TOTAL : 0
|
||||||
|
const syntheticNet = includeFloorHeating ? area * PRICES.SYNTHETIC_NET_TOTAL : 0 // Net only with heating
|
||||||
|
const selfLevelingCompound = includeCompound
|
||||||
|
? area * PRICES.SELF_LEVELING_COMPOUND * compoundMultiplier
|
||||||
|
: 0
|
||||||
|
const pumpTruckFee = includeCompound ? calculatePumpTruckFee(compoundWeight) : 0
|
||||||
const startFee = PRICES.START_FEE
|
const startFee = PRICES.START_FEE
|
||||||
|
|
||||||
// Step 3: Calculate subtotal
|
// Step 3: Calculate subtotal
|
||||||
const subtotal =
|
const subtotal =
|
||||||
insulation.price + floorHeating + syntheticNet + selfLevelingCompound + pumpTruckFee + startFee
|
insulationPrice + floorHeating + syntheticNet + selfLevelingCompound + pumpTruckFee + startFee
|
||||||
|
|
||||||
// Step 4: Calculate percentage fees
|
// Step 4: Calculate percentage fees
|
||||||
const coveringFee = subtotal * PRICES.COVERING_PERCENTAGE
|
const coveringFee = subtotal * PRICES.COVERING_PERCENTAGE
|
||||||
|
|
@ -111,13 +158,19 @@ export function calculatePrice(input: CalculationInput): CalculationDetails {
|
||||||
postalCode,
|
postalCode,
|
||||||
distance,
|
distance,
|
||||||
|
|
||||||
|
// Optional component selections
|
||||||
|
includeInsulation,
|
||||||
|
includeFloorHeating,
|
||||||
|
includeCompound,
|
||||||
|
flooringType,
|
||||||
|
|
||||||
// Calculated values
|
// Calculated values
|
||||||
insulationThickness: insulation.thickness,
|
insulationThickness: insulation.thickness,
|
||||||
insulationVolume: insulation.volume,
|
insulationVolume: insulation.volume,
|
||||||
compoundWeight,
|
compoundWeight,
|
||||||
|
|
||||||
// Component prices
|
// Component prices
|
||||||
insulation: insulation.price,
|
insulation: insulationPrice,
|
||||||
floorHeating,
|
floorHeating,
|
||||||
syntheticNet,
|
syntheticNet,
|
||||||
selfLevelingCompound,
|
selfLevelingCompound,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
export const PRICES = {
|
export const PRICES = {
|
||||||
// Isolering
|
// Isolering (per m³ volume)
|
||||||
INSULATION_MATERIALS: 2850, // kr/m³
|
INSULATION_MATERIALS: 2850, // kr/m³
|
||||||
INSULATION_LABOR: 880, // kr/m³
|
INSULATION_LABOR_PER_M3: 880, // kr/m³
|
||||||
INSULATION_TOTAL: 3730, // kr/m³
|
INSULATION_TOTAL_PER_M3: 3730, // kr/m³
|
||||||
SIMPLE_LABOR: 75, // kr/m² (når højde = 0)
|
// Base insulation labor (always applied per m² area)
|
||||||
|
INSULATION_BASE_LABOR: 75, // kr/m² - always added when insulation is included
|
||||||
|
|
||||||
// Gulvvarme (altid inkluderet)
|
// Gulvvarme (altid inkluderet)
|
||||||
FLOOR_HEATING_MATERIALS: 75, // kr/m²
|
FLOOR_HEATING_MATERIALS: 75, // kr/m²
|
||||||
|
|
@ -35,23 +36,42 @@ export const PRICES = {
|
||||||
VAT: 0.25, // 25%
|
VAT: 0.25, // 25%
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// Pumpebil fees include both materials and labor (from rene.pdf)
|
||||||
export const PUMP_TRUCK_FEES = [
|
export const PUMP_TRUCK_FEES = [
|
||||||
{ minWeight: 8000, fee: 0 },
|
{ minWeight: 8000, materialFee: 0, laborFee: 8800, fee: 8800 }, // >8000 kg
|
||||||
{ minWeight: 5000, fee: 3800 },
|
{ minWeight: 5000, materialFee: 3800, laborFee: 8000, fee: 11800 }, // 5000-8000 kg
|
||||||
{ minWeight: 3000, fee: 6000 },
|
{ minWeight: 3000, materialFee: 6000, laborFee: 7000, fee: 13000 }, // 3000-5000 kg
|
||||||
{ minWeight: 0, fee: 8100 },
|
{ minWeight: 0, materialFee: 8100, laborFee: 7000, fee: 15100 }, // 0-3000 kg
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export const CONSTRAINTS = {
|
export const CONSTRAINTS = {
|
||||||
MIN_AREA: 25, // m²
|
MIN_AREA: 25, // m²
|
||||||
MAX_AREA: 300, // m²
|
MAX_AREA: 250, // m²
|
||||||
MIN_HEIGHT: 0, // cm
|
MIN_HEIGHT: 8, // cm
|
||||||
MAX_HEIGHT: 100, // cm
|
MAX_HEIGHT: 100, // cm
|
||||||
CONCRETE_THICKNESS: 5, // cm (fratrækkes fra højde)
|
CONCRETE_THICKNESS: 5, // cm (fratrækkes fra højde)
|
||||||
HOME_POSTAL_CODE: "4550", // Asnæs
|
HOME_POSTAL_CODE: "4550", // Asnæs
|
||||||
HOME_CITY: "Asnæs",
|
HOME_CITY: "Asnæs",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// Gulvbelægningstyper - påvirker valg af gulvspartel
|
||||||
|
export const FLOORING_TYPES = {
|
||||||
|
STANDARD: {
|
||||||
|
id: "standard",
|
||||||
|
name: "Klinker / Svømmende trægulv",
|
||||||
|
description: "Fliser, klinker eller trægulv der ikke limes",
|
||||||
|
compoundMultiplier: 1.0, // Standard compound
|
||||||
|
},
|
||||||
|
GLUED_WOOD: {
|
||||||
|
id: "glued_wood",
|
||||||
|
name: "Limet trægulv",
|
||||||
|
description: "Trægulv der limes fast til underlaget",
|
||||||
|
compoundMultiplier: 1.28, // Premium compound kræves (+28%)
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type FlooringType = keyof typeof FLOORING_TYPES
|
||||||
|
|
||||||
export const COVERAGE_AREAS = {
|
export const COVERAGE_AREAS = {
|
||||||
WEST_ZEALAND: { start: 4000, end: 4999, bridgeFee: 0 },
|
WEST_ZEALAND: { start: 4000, end: 4999, bridgeFee: 0 },
|
||||||
COPENHAGEN: { start: 2000, end: 2999, bridgeFee: 0 },
|
COPENHAGEN: { start: 2000, end: 2999, bridgeFee: 0 },
|
||||||
|
|
|
||||||
272
lib/db.ts
Normal file
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,18 @@ export const POSTAL_CODE_DISTANCES: Record<string, number> = {
|
||||||
|
|
||||||
// Default distances based on first two digits of postal code
|
// Default distances based on first two digits of postal code
|
||||||
const DEFAULT_DISTANCES: Record<string, number> = {
|
const DEFAULT_DISTANCES: Record<string, number> = {
|
||||||
|
// København centrum (1000-1999)
|
||||||
|
"10": 200,
|
||||||
|
"11": 200,
|
||||||
|
"12": 200,
|
||||||
|
"13": 200,
|
||||||
|
"14": 200,
|
||||||
|
"15": 200,
|
||||||
|
"16": 200,
|
||||||
|
"17": 200,
|
||||||
|
"18": 200,
|
||||||
|
"19": 200,
|
||||||
|
// København (2000-2999)
|
||||||
"20": 200, // København området
|
"20": 200, // København området
|
||||||
"21": 206,
|
"21": 206,
|
||||||
"22": 208,
|
"22": 208,
|
||||||
|
|
@ -109,6 +121,8 @@ export function getDistance(postalCode: string): number {
|
||||||
// If still no match, estimate based on region
|
// If still no match, estimate based on region
|
||||||
const firstDigit = postalCode[0]
|
const firstDigit = postalCode[0]
|
||||||
switch (firstDigit) {
|
switch (firstDigit) {
|
||||||
|
case "1":
|
||||||
|
return 200 // København centrum average
|
||||||
case "2":
|
case "2":
|
||||||
return 190 // København average
|
return 190 // København average
|
||||||
case "3":
|
case "3":
|
||||||
|
|
@ -123,23 +137,9 @@ export function getDistance(postalCode: string): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isInCoverageArea(postalCode: string): boolean {
|
export function isInCoverageArea(postalCode: string): boolean {
|
||||||
const firstDigit = postalCode[0]
|
|
||||||
const postalNumber = parseInt(postalCode)
|
const postalNumber = parseInt(postalCode)
|
||||||
|
// Coverage area: 0-5999 (Sjælland, Lolland-Falster, Fyn)
|
||||||
// Check main coverage areas
|
return postalNumber >= 0 && postalNumber <= 5999
|
||||||
if (["2", "3", "4", "5"].includes(firstDigit)) {
|
|
||||||
// Special check for Lolland-Falster (4800-4899)
|
|
||||||
if (postalNumber >= 4800 && postalNumber <= 4899) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Exclude other 4900+ areas
|
|
||||||
if (postalNumber >= 4900 && postalNumber < 5000) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateDanishPostalCode(postalCode: string): boolean {
|
export function validateDanishPostalCode(postalCode: string): boolean {
|
||||||
|
|
|
||||||
34
middleware.ts
Normal file
|
|
@ -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*"],
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { NextConfig } from 'next'
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
|
|
|
||||||
10013
package-lock.json
generated
Normal file
45
package.json
|
|
@ -3,41 +3,52 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -p 3001 -H 0.0.0.0",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start -p 3001 -H 0.0.0.0",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "5.2.2",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "2.1.8",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
"@radix-ui/react-slot": "1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"better-sqlite3": "^12.6.0",
|
||||||
|
"class-variance-authority": "0.7.1",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"lucide-react": "0.562.0",
|
||||||
|
"next": "16.1.1",
|
||||||
|
"nodemailer": "^7.0.12",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"next": "16.1.1",
|
"react-hook-form": "7.70.0",
|
||||||
"clsx": "2.2.1",
|
"tailwind-merge": "3.4.0",
|
||||||
"tailwind-merge": "2.7.0",
|
"zod": "4.3.5"
|
||||||
"lucide-react": "0.483.0",
|
|
||||||
"class-variance-authority": "0.7.1",
|
|
||||||
"@radix-ui/react-label": "2.1.2",
|
|
||||||
"@radix-ui/react-slot": "1.1.1",
|
|
||||||
"react-hook-form": "7.55.1",
|
|
||||||
"@hookform/resolvers": "3.10.2",
|
|
||||||
"zod": "3.24.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "3.3.3",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "25.0.5",
|
"@types/node": "25.0.5",
|
||||||
|
"@types/nodemailer": "^7.0.5",
|
||||||
"@types/react": "19.2.8",
|
"@types/react": "19.2.8",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"typescript": "5.9.3",
|
|
||||||
"tailwindcss": "3.4.17",
|
|
||||||
"postcss": "8.5.6",
|
|
||||||
"autoprefixer": "10.4.23",
|
"autoprefixer": "10.4.23",
|
||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
"@eslint/eslintrc": "3.3.3",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.7.4",
|
"prettier": "3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "0.7.2",
|
"prettier-plugin-tailwindcss": "0.7.2",
|
||||||
"tailwindcss-animate": "1.0.7"
|
"tailwindcss": "3.4.17",
|
||||||
|
"tailwindcss-animate": "1.0.7",
|
||||||
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
public/byg_trans.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/dansk_kvalitet.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/gulv.jpeg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/tilfredshed_service.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
}
|
}
|
||||||