Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
mikl0s
14445a092c fix: build-release script and remaining build errors
Remove --ignore-scripts from npm install (better-sqlite3 needs native
build). Add tar.gz fallback when zip is not available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:03:45 +00:00
mikl0s
3a54ba40d3 fix: update quotes route to use checkAuth, fix zod type errors
Replace getCurrentUser import with checkAuth in quotes API route.
Fix z.coerce.number() type mismatch with zodResolver in calculator
forms by using z.number() directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:01:36 +00:00
mikl0s
3816e5e2e8 feat: add release build script for zip delivery
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:59:34 +00:00
mikl0s
50c2664078 docs: add setup guides in English and Danish
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:58:44 +00:00
mikl0s
5ca984a018 chore: remove bcrypt dependency (auth is now env-based)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:57:24 +00:00
mikl0s
c22dc09cc7 docs: update .env.example with Office 365 SMTP and admin config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:57:11 +00:00
mikl0s
534ad07a73 feat: add npm run setup seed script for database initialization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:56:38 +00:00
mikl0s
05419e9457 feat: add rate limiting to login and quote-request endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:55:40 +00:00
mikl0s
4889ead690 chore: strip debug console.log/error statements
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:54:39 +00:00
mikl0s
efe19f0cda refactor: consolidate protected routes under /intern/*
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:53:20 +00:00
mikl0s
90407c4f8d refactor: simplify auth routes, remove setup endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:51:04 +00:00
mikl0s
9b6d8f0555 refactor: clean DB schema, remove users/sessions tables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:50:10 +00:00
mikl0s
bc300f54f2 refactor: replace DB-based auth with env-based single admin user
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:48:33 +00:00
mikl0s
7eae4fde33 Add delivery cleanup implementation plan
12-task plan covering route restructure, auth simplification,
DB cleanup, security hardening, debug stripping, setup docs,
and build script for zip delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:35:06 +00:00
mikl0s
23f623db2c Add delivery cleanup design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:31:40 +00:00
24 changed files with 1744 additions and 362 deletions

View file

@ -1,11 +1,22 @@
# OpenRouteService API Key # ─── Admin Login ────────────────────────────────────────────────────
# Get your free key at https://openrouteservice.org/dev/#/signup ADMIN_EMAIL=admin@example.com
# Free tier: 2,000 requests/day ADMIN_PASSWORD=changeme
OPENROUTE_API_KEY=your_api_key_here
# Email configuration (for quote requests) # ─── Email (SMTP) ──────────────────────────────────────────────────
# SMTP_HOST=smtp.example.com # Office 365: smtp.office365.com, port 587
# SMTP_PORT=587 # Requires SMTP AUTH enabled for the sending account in Exchange Admin Center.
# SMTP_USER=user@example.com # See SETUP.md / OPSÆTNING.md for instructions.
# SMTP_PASS=your_password SMTP_HOST=smtp.office365.com
# EMAIL_TO=info@foamking.dk SMTP_PORT=587
SMTP_USER=tilbud@foamking.dk
SMTP_PASS=your_password_here
EMAIL_FROM_NAME=Foam King Prisberegner
EMAIL_TO=info@foamking.dk
# ─── Base URL ───────────────────────────────────────────────────────
NEXT_PUBLIC_BASE_URL=https://beregner.foamking.dk
# ─── Distance Calculation (Optional) ───────────────────────────────
# Without this key, distances are calculated from a built-in postal code table.
# Get a free key at https://openrouteservice.org/dev/#/signup (2,000 requests/day)
# OPENROUTE_API_KEY=your_api_key_here

123
OPSÆTNING.md Normal file
View file

@ -0,0 +1,123 @@
# FoamKing Opsætningsguide
Komplet vejledning til at sætte FoamKing prisberegneren i drift.
---
## 1. Forudsætninger
- **Node.js** 18 eller nyere (20 LTS anbefales)
- **npm** (følger med Node.js)
## 2. Installation
```bash
npm install
```
## 3. Konfiguration af miljøvariabler
Kopier eksempelfilen og udfyld med jeres egne værdier:
```bash
cp .env.example .env.local
```
Åbn `.env.local` i en teksteditor og konfigurer hver sektion som beskrevet nedenfor.
### Admin-login (Påkrævet)
```env
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme
```
Disse oplysninger bruges til at logge ind på `/intern/login`. Skift begge værdier inden I går i produktion.
### E-mail — Office 365 SMTP (Påkrævet)
```env
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_USER=tilbud@foamking.dk
SMTP_PASS=your_password_here
EMAIL_FROM_NAME=Foam King Prisberegner
EMAIL_TO=info@foamking.dk
```
Applikationen sender tilbudsforespørgsler via Office 365. For at det virker, skal **SMTP AUTH være aktiveret** på afsenderpostkassen:
1. Gå til **Exchange Admin Center** — [admin.exchange.microsoft.com](https://admin.exchange.microsoft.com)
2. Gå til **Recipients** → **Mailboxes**
3. Vælg postkassen **tilbud@foamking.dk**
4. Klik på **Manage email apps settings** (eller Mail flow → Email apps)
5. Aktivér **Authenticated SMTP (SMTP AUTH)**
6. Gem
> **Bemærk:** Hvis jeres organisation bruger Security Defaults eller Conditional Access-politikker, der blokerer legacy-godkendelse, kan det være nødvendigt at oprette en undtagelse for denne postkasse. Hvis MFA er aktiveret på kontoen, skal I bruge et App Password i stedet for den almindelige adgangskode.
### Base-URL (Påkrævet)
```env
NEXT_PUBLIC_BASE_URL=https://beregner.foamking.dk
```
Bruges når der genereres links i udgående e-mails (f.eks. links til tilbudssider).
### Afstandsberegning (Valgfrit)
```env
OPENROUTE_API_KEY=your_api_key_here
```
Uden denne nøgle bruger applikationen en indbygget afstandstabel baseret på postnumre, hvilket fungerer fint i de fleste tilfælde. Ønsker I mere præcise køreafstande, kan I oprette en gratis API-nøgle på [openrouteservice.org](https://openrouteservice.org/dev/#/signup) (2.000 forespørgsler pr. dag).
## 4. Opret database
```bash
npm run setup
```
Dette opretter SQLite-databasen og de nødvendige tabeller.
## 5. Byg og start
```bash
npm run build
npm start
```
Applikationen starter på **port 3001**.
## 6. Ruteoversigt
| Rute | Beskrivelse |
| -------------------- | ---------------------------------------------- |
| `/` | Offentlig prisberegner |
| `/tilbud/[id]` | Offentlig tilbudsvisning (link fra e-mail) |
| `/intern` | Admin-dashboard — tilbudsstyring |
| `/intern/historik` | Admin — tilbudshistorik |
| `/intern/beregner` | Admin — detaljeret beregner med prisopbygning |
| `/intern/login` | Admin-loginside |
## 7. Tilpasset autentificering
Standardopsætningen bruger miljøvariablerne `ADMIN_EMAIL` og `ADMIN_PASSWORD` med sessioner i hukommelsen. Det er enkelt og tilstrækkeligt til en setup med en enkelt administrator.
Vil I integrere jeres egen autentificering (JWT, OAuth, SSO), skal I redigere **`lib/auth.ts`**. Hele applikationen bruger kun tre funktioner:
- `checkAuth()` — tjekker om den aktuelle forespørgsel er autentificeret
- `login(email, password)` — autentificerer en bruger og opretter en session
- `logout()` — afslutter den aktuelle session
Middlewaren i **`middleware.ts`** tjekker for en session-cookie på alle `/intern/*`-ruter og omdirigerer ikke-autentificerede besøgende til `/intern/login`. Opdater den, hvis I skifter til en anden mekanisme (f.eks. en JWT i en `Authorization`-header).
## 8. Udvikling
```bash
npm run dev # Start udviklingsserver (port 3001)
npm run build # Produktionsbuild
npm start # Start produktionsserver
npm run lint # Kør ESLint
npm run setup # Opret / nulstil database
```

123
SETUP.md Normal file
View file

@ -0,0 +1,123 @@
# FoamKing Setup Guide
Complete setup instructions for deploying the FoamKing flooring price calculator.
---
## 1. Prerequisites
- **Node.js** 18 or later (20 LTS recommended)
- **npm** (included with Node.js)
## 2. Installation
```bash
npm install
```
## 3. Configure Environment
Copy the example file and fill in your values:
```bash
cp .env.example .env.local
```
Open `.env.local` in a text editor and configure each section as described below.
### Admin Login (Required)
```env
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme
```
These credentials are used to sign in at `/intern/login`. Change both values before going to production.
### Email — Office 365 SMTP (Required)
```env
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_USER=tilbud@foamking.dk
SMTP_PASS=your_password_here
EMAIL_FROM_NAME=Foam King Prisberegner
EMAIL_TO=info@foamking.dk
```
The application sends quote-request emails through Office 365. For this to work, **SMTP AUTH must be enabled** on the sending mailbox:
1. Go to **Exchange Admin Center** — [admin.exchange.microsoft.com](https://admin.exchange.microsoft.com)
2. Navigate to **Recipients** → **Mailboxes**
3. Select the **tilbud@foamking.dk** mailbox
4. Click **Manage email apps settings** (or Mail flow → Email apps)
5. Enable **Authenticated SMTP (SMTP AUTH)**
6. Save
> **Note:** If your organization uses Security Defaults or Conditional Access policies that block legacy authentication, you may need to create an exclusion for this mailbox. If MFA is enabled on the account, use an App Password instead of the regular password.
### Base URL (Required)
```env
NEXT_PUBLIC_BASE_URL=https://beregner.foamking.dk
```
Used when generating links in outgoing emails (e.g. links to quote pages).
### Distance Calculation (Optional)
```env
OPENROUTE_API_KEY=your_api_key_here
```
Without this key the application uses a built-in postal code distance table, which is perfectly fine for most cases. If you want more precise driving distances, sign up for a free API key at [openrouteservice.org](https://openrouteservice.org/dev/#/signup) (2,000 requests per day).
## 4. Initialize Database
```bash
npm run setup
```
This creates the SQLite database and required tables.
## 5. Build and Start
```bash
npm run build
npm start
```
The application starts on **port 3001**.
## 6. Route Overview
| Route | Description |
| -------------------- | -------------------------------------------- |
| `/` | Public price calculator |
| `/tilbud/[id]` | Public quote view (linked from email) |
| `/intern` | Admin dashboard — quote management |
| `/intern/historik` | Admin — quote history |
| `/intern/beregner` | Admin — detailed calculator with price breakdown |
| `/intern/login` | Admin login page |
## 7. Custom Authentication
The default authentication uses the `ADMIN_EMAIL` and `ADMIN_PASSWORD` environment variables with in-memory sessions. This is simple and sufficient for a single-admin setup.
To integrate your own authentication (JWT, OAuth, SSO), edit **`lib/auth.ts`**. The entire application relies on only three functions:
- `checkAuth()` — checks whether the current request is authenticated
- `login(email, password)` — authenticates a user and creates a session
- `logout()` — destroys the current session
The middleware in **`middleware.ts`** checks for a session cookie on all `/intern/*` routes and redirects unauthenticated visitors to `/intern/login`. Update it if you switch to a different mechanism (e.g. a JWT in an `Authorization` header).
## 8. Development
```bash
npm run dev # Start development server (port 3001)
npm run build # Production build
npm start # Start production server
npm run lint # Run ESLint
npm run setup # Initialize / reset database
```

View file

@ -1,7 +1,13 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { login } from "@/lib/auth" import { login } from "@/lib/auth"
import { rateLimit } from "@/lib/rate-limit"
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") || "unknown"
if (!rateLimit(ip, 5, 60_000)) {
return NextResponse.json({ error: "For mange forsøg. Prøv igen om lidt." }, { status: 429 })
}
try { try {
const { email, password } = await request.json() const { email, password } = await request.json()
@ -15,12 +21,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: result.error }, { status: 401 }) return NextResponse.json({ error: result.error }, { status: 401 })
} }
return NextResponse.json({ return NextResponse.json({ success: true })
success: true, } catch {
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 }) return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
} }
} }

View file

@ -5,8 +5,7 @@ export async function POST() {
try { try {
await logout() await logout()
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch {
console.error("Logout error:", error)
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 }) return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
} }
} }

View file

@ -1,31 +0,0 @@
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 })
}
}

View file

@ -3,6 +3,7 @@ import nodemailer from "nodemailer"
import { formatPrice, type CalculationDetails } from "@/lib/calculations" import { formatPrice, type CalculationDetails } from "@/lib/calculations"
import { FLOORING_TYPES } from "@/lib/constants" import { FLOORING_TYPES } from "@/lib/constants"
import { saveQuote } from "@/lib/db" import { saveQuote } from "@/lib/db"
import { rateLimit } from "@/lib/rate-limit"
interface QuoteRequestBody { interface QuoteRequestBody {
customerInfo: { customerInfo: {
@ -234,6 +235,11 @@ function formatFoamKingEmail(
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") || "unknown"
if (!rateLimit(ip, 10, 60_000)) {
return NextResponse.json({ error: "For mange anmodninger. Prøv igen om lidt." }, { status: 429 })
}
try { try {
const body: QuoteRequestBody = await request.json() const body: QuoteRequestBody = await request.json()
const { customerInfo, calculationDetails } = body const { customerInfo, calculationDetails } = body
@ -296,8 +302,7 @@ export async function POST(request: NextRequest) {
success: true, success: true,
message: "Tak! Vi har modtaget din anmodning og sendt en bekræftelse til din email.", message: "Tak! Vi har modtaget din anmodning og sendt en bekræftelse til din email.",
}) })
} catch (error) { } catch {
console.error("Quote request error:", error)
return NextResponse.json({ error: "Der opstod en fejl. Prøv igen senere." }, { status: 500 }) return NextResponse.json({ error: "Der opstod en fejl. Prøv igen senere." }, { status: 500 })
} }
} }

View file

@ -1,26 +1,25 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getAllQuotes, updateQuoteStatus, type QuoteStatus } from "@/lib/db" import { getAllQuotes, updateQuoteStatus, type QuoteStatus } from "@/lib/db"
import { getCurrentUser } from "@/lib/auth" import { checkAuth } from "@/lib/auth"
export async function GET() { export async function GET() {
try { try {
const user = await getCurrentUser() const { authenticated } = await checkAuth()
if (!user) { if (!authenticated) {
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 }) return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
} }
const quotes = getAllQuotes() const quotes = getAllQuotes()
return NextResponse.json({ quotes }) return NextResponse.json({ quotes })
} catch (error) { } catch {
console.error("Get quotes error:", error)
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 }) return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
} }
} }
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { try {
const user = await getCurrentUser() const { authenticated } = await checkAuth()
if (!user) { if (!authenticated) {
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 }) return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
} }
@ -41,8 +40,7 @@ export async function PATCH(request: NextRequest) {
} }
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error) { } catch {
console.error("Update quote error:", error)
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 }) return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
} }
} }

View file

@ -36,10 +36,6 @@ export default function AdminPage() {
setIsRequestingQuote(true) setIsRequestingQuote(true)
try { try {
await new Promise((resolve) => setTimeout(resolve, 500)) 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") alert("TEST MODE: Tilbudsanmodning ville blive sendt til info@foamking.dk")
} catch (error) { } catch (error) {
alert("Der opstod en fejl. Prøv igen senere.") alert("Der opstod en fejl. Prøv igen senere.")

View file

@ -40,15 +40,15 @@ export default function HistorikPage() {
const res = await fetch("/api/quotes") const res = await fetch("/api/quotes")
if (!res.ok) { if (!res.ok) {
if (res.status === 401) { if (res.status === 401) {
router.push("/login") router.push("/intern/login")
return return
} }
throw new Error("Failed to fetch quotes") throw new Error("Failed to fetch quotes")
} }
const data = await res.json() const data = await res.json()
setQuotes(data.quotes) setQuotes(data.quotes)
} catch (error) { } catch {
console.error("Failed to fetch quotes:", error) // fetch failed
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -57,10 +57,10 @@ export default function HistorikPage() {
async function handleLogout() { async function handleLogout() {
try { try {
await fetch("/api/auth/logout", { method: "POST" }) await fetch("/api/auth/logout", { method: "POST" })
router.push("/login") router.push("/intern/login")
router.refresh() router.refresh()
} catch (error) { } catch {
console.error("Logout failed:", error) // logout failed
} }
} }
@ -104,7 +104,7 @@ export default function HistorikPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/dashboard"> <Link href="/intern">
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<LayoutDashboard className="mr-2 h-4 w-4" /> <LayoutDashboard className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Dashboard</span> <span className="hidden sm:inline">Dashboard</span>

View file

@ -35,7 +35,7 @@ export default function LoginPage() {
return return
} }
router.push("/dashboard") router.push("/intern")
router.refresh() router.refresh()
} catch { } catch {
setError("Der opstod en fejl. Prøv igen.") setError("Der opstod en fejl. Prøv igen.")

View file

@ -38,15 +38,15 @@ export default function DashboardPage() {
const res = await fetch("/api/quotes") const res = await fetch("/api/quotes")
if (!res.ok) { if (!res.ok) {
if (res.status === 401) { if (res.status === 401) {
router.push("/login") router.push("/intern/login")
return return
} }
throw new Error("Failed to fetch quotes") throw new Error("Failed to fetch quotes")
} }
const data = await res.json() const data = await res.json()
setQuotes(data.quotes) setQuotes(data.quotes)
} catch (error) { } catch {
console.error("Failed to fetch quotes:", error) // fetch failed
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -84,10 +84,10 @@ export default function DashboardPage() {
async function handleLogout() { async function handleLogout() {
try { try {
await fetch("/api/auth/logout", { method: "POST" }) await fetch("/api/auth/logout", { method: "POST" })
router.push("/login") router.push("/intern/login")
router.refresh() router.refresh()
} catch (error) { } catch {
console.error("Logout failed:", error) // logout failed
} }
} }
@ -133,7 +133,7 @@ export default function DashboardPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/historik"> <Link href="/intern/historik">
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<List className="mr-2 h-4 w-4" /> <List className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Historik</span> <span className="hidden sm:inline">Historik</span>

View file

@ -37,11 +37,11 @@ const formSchema = z.object({
.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"),
address: z.string().optional(), address: z.string().optional(),
area: z.coerce area: z
.number() .number()
.min(CONSTRAINTS.MIN_AREA, `Minimum areal er ${CONSTRAINTS.MIN_AREA}`) .min(CONSTRAINTS.MIN_AREA, `Minimum areal er ${CONSTRAINTS.MIN_AREA}`)
.max(CONSTRAINTS.MAX_AREA, `Maximum areal er ${CONSTRAINTS.MAX_AREA}`), .max(CONSTRAINTS.MAX_AREA, `Maximum areal er ${CONSTRAINTS.MAX_AREA}`),
height: z.coerce height: z
.number() .number()
.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`),

View file

@ -37,11 +37,11 @@ const formSchema = z.object({
.length(4, "Postnummer skal være 4 cifre") .length(4, "Postnummer skal være 4 cifre")
.refine(validateDanishPostalCode, "Vi dækker desværre ikke dette område"), .refine(validateDanishPostalCode, "Vi dækker desværre ikke dette område"),
address: z.string().optional(), address: z.string().optional(),
area: z.coerce area: z
.number() .number()
.min(CONSTRAINTS.MIN_AREA, `Minimum ${CONSTRAINTS.MIN_AREA}`) .min(CONSTRAINTS.MIN_AREA, `Minimum ${CONSTRAINTS.MIN_AREA}`)
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA}`), .max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA}`),
height: z.coerce height: z
.number() .number()
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum ${CONSTRAINTS.MIN_HEIGHT} cm`) .min(CONSTRAINTS.MIN_HEIGHT, `Minimum ${CONSTRAINTS.MIN_HEIGHT} cm`)
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum ${CONSTRAINTS.MAX_HEIGHT} cm`), .max(CONSTRAINTS.MAX_HEIGHT, `Maximum ${CONSTRAINTS.MAX_HEIGHT} cm`),

View file

@ -0,0 +1,134 @@
# FoamKing Delivery Cleanup Design
Date: 2026-02-22
## Goal
Prepare the FoamKing calculator for zip delivery to customer. Clean up routes, security, auth, DB, and strip debug/dev artifacts. Customer will integrate into their own infrastructure.
## 1. Route Restructure
| Before | After | Access |
|--------|-------|--------|
| `/` | `/` | Public — calculator |
| `/tilbud/[slug]` | `/tilbud/[slug]` | Public — quote view |
| `/admin` | `/intern/beregner` | Protected — detailed calculator breakdown |
| `/dashboard` | `/intern` | Protected — quote management (kanban) |
| `/historik` | `/intern/historik` | Protected — quote archive |
| `/login` | `/intern/login` | Public — login page |
Middleware protects `/intern/*` except `/intern/login`.
## 2. Authentication
**Replace DB-based auth with env-based single admin user.**
`lib/auth.ts` exports:
- `checkAuth(request)` — single function, clear comments for JWT/OAuth swap
- `login(email, password)` — compares against `ADMIN_EMAIL`/`ADMIN_PASSWORD` env vars
- `logout(token)` — clears session
Implementation: in-memory session Map or signed cookie. No DB tables for users/sessions.
Remove:
- `users` table
- `sessions` table
- `/api/auth/setup` endpoint
- `bcrypt` dependency
`.env` config:
```
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme
```
## 3. Database Cleanup
**Keep SQLite (`better-sqlite3`), clean schema, add seed script.**
Single table:
```sql
CREATE TABLE 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 DEFAULT 1,
flooring_type TEXT DEFAULT 'STANDARD',
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT NOT NULL,
remarks TEXT,
total_excl_vat REAL NOT NULL,
total_incl_vat REAL NOT NULL,
status TEXT DEFAULT 'new',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
email_opened_at TEXT
);
```
- Quote IDs start at 1000
- No migration hacks (clean CREATE TABLE only)
- `lib/db.ts` opens DB and exports connection — no schema creation on import
**Seed script** (`npm run setup`):
- Creates `data/` directory
- Creates quotes table
- Sets auto-increment start to 1000
- Prints success message
## 4. Security Hardening
- Remove all hardcoded credentials from codebase
- Remove `docs/mail.txt` from delivery
- Rate limiting on `/api/quote-request` and `/api/auth/login` (simple in-memory counter)
- Validate/sanitize all inputs server-side
- Cookies: `httpOnly`, `secure`, `sameSite: strict`
- Remove `/api/auth/setup` endpoint
## 5. Debug Stripping
- Remove all `console.log` statements
- Remove "Admin Mode" toggle from public pages
- Remove dev-only comments and TODO markers
## 6. Distance Calculation
Unchanged — dual approach:
- **Default:** Hardcoded postal code lookup table (no API key needed)
- **Optional:** OpenRouteService API for precise driving distances (free tier, 2000 req/day)
## 7. Build & Zip Delivery
**`scripts/build-release.sh`:**
1. Create temp directory `/tmp/foamking-release-<timestamp>/`
2. Copy shipping files:
- `app/`, `components/`, `lib/`, `public/`, `scripts/setup.js`
- `middleware.ts`
- `package.json`, `tsconfig.json`, `tailwind.config.ts`
- `postcss.config.mjs`, `eslint.config.mjs`, `next.config.ts`
- `.env.example`, `OPSÆTNING.md`, `SETUP.md`
3. Run `npm install` + `npm run build` to verify compilation
4. Remove `node_modules/` and `.next/` from temp dir
5. Zip as `foamking-beregner-<date>.zip`
**Excluded from zip:**
- `.git/`, `docs/`, `node_modules/`, `.next/`, `data/`
- `.env.local`, `CLAUDE.md`, `README.md`, `package-lock.json`
## 8. Setup Documentation
**`OPSÆTNING.md`** (Danish) + **`SETUP.md`** (English) covering:
1. Prerequisites (Node.js, npm)
2. Install dependencies (`npm install`)
3. Configure `.env.local`:
- `ADMIN_EMAIL` / `ADMIN_PASSWORD` (required)
- SMTP: Office 365 — `smtp.office365.com:587` with STARTTLS, enable SMTP AUTH for `tilbud@foamking.dk` in Exchange Admin Center (required)
- `OPENROUTE_API_KEY` (optional — for precise distance calculation)
4. Run `npm run setup` (creates database)
5. Build and start (`npm run build && npm start`)
6. Route overview: `/` = public calculator, `/intern` = admin dashboard
7. Auth customization: how to replace `checkAuth()` in `lib/auth.ts` for JWT/OAuth

File diff suppressed because it is too large Load diff

View file

@ -1,27 +1,10 @@
import bcrypt from "bcrypt"
import { cookies } from "next/headers" 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_COOKIE_NAME = "session"
const SESSION_DURATION_DAYS = 7 const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export async function hashPassword(password: string): Promise<string> { // In-memory session store. Replace this with your own session/token system.
return bcrypt.hash(password, SALT_ROUNDS) const sessions = new Map<string, { email: string; expiresAt: Date }>()
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
function generateSessionId(): string { function generateSessionId(): string {
const bytes = new Uint8Array(32) const bytes = new Uint8Array(32)
@ -31,86 +14,78 @@ function generateSessionId(): string {
.join("") .join("")
} }
// ─── AUTH INTERFACE ────────────────────────────────────────────────
// To integrate your own auth (JWT, OAuth, etc.), replace these functions.
// The rest of the application only calls checkAuth(), login(), and logout().
// ───────────────────────────────────────────────────────────────────
/**
* Check if the current request is authenticated.
* Replace this function to integrate JWT, OAuth, or any other auth system.
*/
export async function checkAuth(): Promise<{ authenticated: boolean; email?: string }> {
const cookieStore = await cookies()
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
if (!sessionId) return { authenticated: false }
const session = sessions.get(sessionId)
if (!session) return { authenticated: false }
if (session.expiresAt < new Date()) {
sessions.delete(sessionId)
return { authenticated: false }
}
return { authenticated: true, email: session.email }
}
/**
* Authenticate with email and password.
* Default: compares against ADMIN_EMAIL/ADMIN_PASSWORD env vars.
* Replace this function to integrate your own auth system.
*/
export async function login( export async function login(
email: string, email: string,
password: string password: string
): Promise<{ success: true; user: User } | { success: false; error: string }> { ): Promise<{ success: true } | { success: false; error: string }> {
const user = getUserByEmail(email) const adminEmail = process.env.ADMIN_EMAIL
const adminPassword = process.env.ADMIN_PASSWORD
if (!user) { if (!adminEmail || !adminPassword) {
return { success: false, error: "Admin-konfiguration mangler (ADMIN_EMAIL/ADMIN_PASSWORD)" }
}
if (email !== adminEmail || password !== adminPassword) {
return { success: false, error: "Forkert email eller adgangskode" } 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 sessionId = generateSessionId()
const expiresAt = new Date() const expiresAt = new Date(Date.now() + SESSION_DURATION_MS)
expiresAt.setDate(expiresAt.getDate() + SESSION_DURATION_DAYS)
createSession(sessionId, user.id, expiresAt) sessions.set(sessionId, { email, expiresAt })
// Set cookie
const cookieStore = await cookies() const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE_NAME, sessionId, { cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "strict",
expires: expiresAt, expires: expiresAt,
path: "/", path: "/",
}) })
return { return { success: true }
success: true,
user: { id: user.id, email: user.email, name: user.name, createdAt: user.createdAt },
}
} }
/**
* Log out the current session.
*/
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
const cookieStore = await cookies() const cookieStore = await cookies()
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
if (sessionId) { if (sessionId) {
deleteSession(sessionId) sessions.delete(sessionId)
cookieStore.delete(SESSION_COOKIE_NAME) 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)
}

174
lib/db.ts
View file

@ -1,11 +1,10 @@
import Database from "better-sqlite3" import Database from "better-sqlite3"
import path from "path" import path from "path"
import fs from "fs"
// Database file stored in project root
const DB_PATH = path.join(process.cwd(), "data", "quotes.db") const DB_PATH = path.join(process.cwd(), "data", "quotes.db")
// Ensure data directory exists // Ensure data directory exists
import fs from "fs"
const dataDir = path.dirname(DB_PATH) const dataDir = path.dirname(DB_PATH)
if (!fs.existsSync(dataDir)) { if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true }) fs.mkdirSync(dataDir, { recursive: true })
@ -13,7 +12,10 @@ if (!fs.existsSync(dataDir)) {
const db = new Database(DB_PATH) const db = new Database(DB_PATH)
// Initialize database schema // Enable WAL mode for better concurrent read performance
db.pragma("journal_mode = WAL")
// Create schema if not exists
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS quotes ( CREATE TABLE IF NOT EXISTS quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -21,65 +23,38 @@ db.exec(`
address TEXT, address TEXT,
area REAL NOT NULL, area REAL NOT NULL,
height REAL NOT NULL, height REAL NOT NULL,
include_floor_heating INTEGER NOT NULL DEFAULT 1, include_floor_heating INTEGER DEFAULT 1,
flooring_type TEXT NOT NULL DEFAULT 'STANDARD', flooring_type TEXT DEFAULT 'STANDARD',
customer_name TEXT NOT NULL, customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL, customer_email TEXT NOT NULL,
customer_phone TEXT NOT NULL, customer_phone TEXT NOT NULL,
remarks TEXT, remarks TEXT,
total_excl_vat REAL, total_excl_vat REAL NOT NULL,
total_incl_vat REAL, total_incl_vat REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'new', status TEXT DEFAULT 'new',
created_at TEXT DEFAULT CURRENT_TIMESTAMP created_at TEXT DEFAULT CURRENT_TIMESTAMP,
email_opened_at TEXT
) )
`) `)
// Migration: Add status column if it doesn't exist // Ensure quote IDs start at 1000
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 } const countResult = db.prepare("SELECT COUNT(*) as count FROM quotes").get() as { count: number }
if (countResult.count === 0) { if (countResult.count === 0) {
try {
const seqExists = db.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'quotes'").get()
if (!seqExists) {
db.exec( db.exec(
"INSERT INTO quotes (id, postal_code, area, height, customer_name, customer_email, customer_phone) VALUES (999, '0000', 0, 0, 'init', 'init', 'init')" "INSERT INTO quotes (id, postal_code, area, height, customer_name, customer_email, customer_phone, total_excl_vat, total_incl_vat) VALUES (999, '0000', 0, 0, 'init', 'init', 'init', 0, 0)"
) )
db.exec("DELETE FROM quotes WHERE id = 999") 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'") } catch {
// sqlite_sequence may not exist yet on fresh DB — that's fine
}
} }
// ─── Types ─────────────────────────────────────────────────────────
export interface QuoteInput { export interface QuoteInput {
postalCode: string postalCode: string
address?: string address?: string
@ -91,8 +66,8 @@ export interface QuoteInput {
customerEmail: string customerEmail: string
customerPhone: string customerPhone: string
remarks?: string remarks?: string
totalExclVat?: number totalExclVat: number
totalInclVat?: number totalInclVat: number
} }
export type QuoteStatus = "new" | "contacted" | "accepted" | "rejected" export type QuoteStatus = "new" | "contacted" | "accepted" | "rejected"
@ -104,6 +79,8 @@ export interface StoredQuote extends QuoteInput {
emailOpenedAt: string | null emailOpenedAt: string | null
} }
// ─── Queries ───────────────────────────────────────────────────────
export function saveQuote(quote: QuoteInput): { id: number; slug: string } { export function saveQuote(quote: QuoteInput): { id: number; slug: string } {
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO quotes ( INSERT INTO quotes (
@ -123,8 +100,8 @@ export function saveQuote(quote: QuoteInput): { id: number; slug: string } {
quote.customerEmail, quote.customerEmail,
quote.customerPhone, quote.customerPhone,
quote.remarks || null, quote.remarks || null,
quote.totalExclVat || null, quote.totalExclVat,
quote.totalInclVat || null quote.totalInclVat
) )
const id = result.lastInsertRowid as number const id = result.lastInsertRowid as number
@ -140,39 +117,32 @@ export function getQuoteBySlug(slug: string): StoredQuote | null {
const [, postalCode, idStr] = match const [, postalCode, idStr] = match
const id = parseInt(idStr, 10) const id = parseInt(idStr, 10)
const stmt = db.prepare("SELECT * FROM quotes WHERE id = ? AND postal_code = ?") const row = db.prepare("SELECT * FROM quotes WHERE id = ? AND postal_code = ?").get(id, postalCode) as any
const row = stmt.get(id, postalCode) as any
if (!row) return null if (!row) return null
return rowToQuote(row) return rowToQuote(row)
} }
export function getQuoteById(id: number): StoredQuote | null { export function getQuoteById(id: number): StoredQuote | null {
const stmt = db.prepare("SELECT * FROM quotes WHERE id = ?") const row = db.prepare("SELECT * FROM quotes WHERE id = ?").get(id) as any
const row = stmt.get(id) as any
if (!row) return null if (!row) return null
return rowToQuote(row) return rowToQuote(row)
} }
export function getAllQuotes(): StoredQuote[] { export function getAllQuotes(): StoredQuote[] {
const stmt = db.prepare("SELECT * FROM quotes ORDER BY id DESC") const rows = db.prepare("SELECT * FROM quotes ORDER BY id DESC").all() as any[]
const rows = stmt.all() as any[]
return rows.map(rowToQuote) return rows.map(rowToQuote)
} }
export function updateQuoteStatus(id: number, status: QuoteStatus): boolean { export function updateQuoteStatus(id: number, status: QuoteStatus): boolean {
const stmt = db.prepare("UPDATE quotes SET status = ? WHERE id = ?") const result = db.prepare("UPDATE quotes SET status = ? WHERE id = ?").run(status, id)
const result = stmt.run(status, id)
return result.changes > 0 return result.changes > 0
} }
export function markEmailOpened(id: number): boolean { export function markEmailOpened(id: number): boolean {
// Only update if not already set (first open) const result = db.prepare(
const stmt = db.prepare(
"UPDATE quotes SET email_opened_at = ? WHERE id = ? AND email_opened_at IS NULL" "UPDATE quotes SET email_opened_at = ? WHERE id = ? AND email_opened_at IS NULL"
) ).run(new Date().toISOString(), id)
const result = stmt.run(new Date().toISOString(), id)
return result.changes > 0 return result.changes > 0
} }
@ -196,77 +166,3 @@ function rowToQuote(row: any): StoredQuote {
emailOpenedAt: row.email_opened_at || null, 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())
}

41
lib/rate-limit.ts Normal file
View file

@ -0,0 +1,41 @@
// Simple in-memory rate limiter. No external dependencies.
// Tracks requests per IP with a sliding window.
interface RateLimitEntry {
count: number
resetAt: number
}
const store = new Map<string, RateLimitEntry>()
// Clean up expired entries periodically
setInterval(() => {
const now = Date.now()
for (const [key, entry] of store) {
if (entry.resetAt < now) store.delete(key)
}
}, 60_000)
/**
* Check if a request should be rate-limited.
* @param key - Unique identifier (e.g. IP address)
* @param limit - Max requests per window
* @param windowMs - Time window in milliseconds
* @returns true if the request is allowed, false if rate-limited
*/
export function rateLimit(key: string, limit: number, windowMs: number): boolean {
const now = Date.now()
const entry = store.get(key)
if (!entry || entry.resetAt < now) {
store.set(key, { count: 1, resetAt: now + windowMs })
return true
}
if (entry.count >= limit) {
return false
}
entry.count++
return true
}

View file

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

46
package-lock.json generated
View file

@ -16,7 +16,6 @@
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.4", "@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0", "better-sqlite3": "^12.6.0",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@ -31,7 +30,6 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "3.3.3", "@eslint/eslintrc": "3.3.3",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "25.0.5", "@types/node": "25.0.5",
"@types/nodemailer": "^7.0.5", "@types/nodemailer": "^7.0.5",
@ -3905,16 +3903,6 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/better-sqlite3": { "node_modules/@types/better-sqlite3": {
"version": "7.6.13", "version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@ -4924,20 +4912,6 @@
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
} }
}, },
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "12.6.0", "version": "12.6.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz",
@ -7705,26 +7679,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",

View file

@ -9,7 +9,8 @@
"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 .",
"setup": "node scripts/setup.js"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
@ -20,7 +21,6 @@
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.4", "@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0", "better-sqlite3": "^12.6.0",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@ -35,7 +35,6 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "3.3.3", "@eslint/eslintrc": "3.3.3",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "25.0.5", "@types/node": "25.0.5",
"@types/nodemailer": "^7.0.5", "@types/nodemailer": "^7.0.5",

69
scripts/build-release.sh Executable file
View file

@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
DATE=$(date +%Y-%m-%d)
RELEASE_DIR="/tmp/foamking-release-${TIMESTAMP}"
ZIP_NAME="foamking-beregner-${DATE}.zip"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo "FoamKing Release Builder"
echo "========================"
echo ""
# Create temp directory
mkdir -p "${RELEASE_DIR}"
echo "Build directory: ${RELEASE_DIR}"
# Copy shipping files
echo "Copying files..."
cp -r "${PROJECT_DIR}/app" "${RELEASE_DIR}/"
cp -r "${PROJECT_DIR}/components" "${RELEASE_DIR}/"
cp -r "${PROJECT_DIR}/lib" "${RELEASE_DIR}/"
cp -r "${PROJECT_DIR}/public" "${RELEASE_DIR}/"
mkdir -p "${RELEASE_DIR}/scripts"
cp "${PROJECT_DIR}/scripts/setup.js" "${RELEASE_DIR}/scripts/"
cp "${PROJECT_DIR}/middleware.ts" "${RELEASE_DIR}/"
cp "${PROJECT_DIR}/package.json" "${RELEASE_DIR}/"
cp "${PROJECT_DIR}/tsconfig.json" "${RELEASE_DIR}/"
cp "${PROJECT_DIR}/tailwind.config.ts" "${RELEASE_DIR}/"
cp "${PROJECT_DIR}/postcss.config.mjs" "${RELEASE_DIR}/"
cp "${PROJECT_DIR}/eslint.config.mjs" "${RELEASE_DIR}/"
cp "${PROJECT_DIR}/next.config.ts" "${RELEASE_DIR}/"
cp "${PROJECT_DIR}/.env.example" "${RELEASE_DIR}/"
cp "${PROJECT_DIR}/SETUP.md" "${RELEASE_DIR}/"
cp "${PROJECT_DIR}/OPSÆTNING.md" "${RELEASE_DIR}/"
# Copy .prettierrc if it exists
[ -f "${PROJECT_DIR}/.prettierrc.json" ] && cp "${PROJECT_DIR}/.prettierrc.json" "${RELEASE_DIR}/"
# Verify build
echo ""
echo "Installing dependencies..."
cd "${RELEASE_DIR}"
npm install 2>&1 | tail -1
echo "Verifying build..."
npm run build 2>&1 | tail -3
# Clean up build artifacts
echo ""
echo "Cleaning up..."
rm -rf "${RELEASE_DIR}/node_modules"
rm -rf "${RELEASE_DIR}/.next"
# Create archive
cd /tmp
if command -v zip &> /dev/null; then
zip -rq "${ZIP_NAME}" "foamking-release-${TIMESTAMP}"
ARCHIVE="/tmp/${ZIP_NAME}"
else
TAR_NAME="foamking-beregner-${DATE}.tar.gz"
tar -czf "${TAR_NAME}" "foamking-release-${TIMESTAMP}"
ARCHIVE="/tmp/${TAR_NAME}"
fi
echo ""
echo "Release built successfully!"
echo "Archive: ${ARCHIVE}"
echo "Size: $(du -h "${ARCHIVE}" | cut -f1)"

65
scripts/setup.js Normal file
View file

@ -0,0 +1,65 @@
#!/usr/bin/env node
const Database = require("better-sqlite3")
const path = require("path")
const fs = require("fs")
const DB_PATH = path.join(process.cwd(), "data", "quotes.db")
const dataDir = path.dirname(DB_PATH)
console.log("FoamKing Prisberegner - Database Setup")
console.log("======================================\n")
// Create data directory
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true })
console.log("Created data/ directory")
}
// Check if DB already exists
const dbExists = fs.existsSync(DB_PATH)
if (dbExists) {
console.log("Database already exists at", DB_PATH)
console.log("Delete data/quotes.db to start fresh.\n")
process.exit(0)
}
const db = new Database(DB_PATH)
db.pragma("journal_mode = WAL")
db.exec(`
CREATE TABLE 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 DEFAULT 1,
flooring_type TEXT DEFAULT 'STANDARD',
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT NOT NULL,
remarks TEXT,
total_excl_vat REAL NOT NULL,
total_incl_vat REAL NOT NULL,
status TEXT DEFAULT 'new',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
email_opened_at TEXT
)
`)
// Set auto-increment to start at 1000
db.exec(
"INSERT INTO quotes (id, postal_code, area, height, customer_name, customer_email, customer_phone, total_excl_vat, total_incl_vat) VALUES (999, '0000', 0, 0, 'init', 'init', 'init', 0, 0)"
)
db.exec("DELETE FROM quotes WHERE id = 999")
db.close()
console.log("Database created at", DB_PATH)
console.log("Quote IDs will start at 1000.\n")
console.log("Next steps:")
console.log(" 1. Configure .env.local (see SETUP.md / OPSÆTNING.md)")
console.log(" 2. npm run build")
console.log(" 3. npm start")