Compare commits

..

10 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
18 changed files with 488 additions and 106 deletions

View file

@ -1,11 +1,22 @@
# 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
# ─── Admin Login ────────────────────────────────────────────────────
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme
# 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
# ─── Email (SMTP) ──────────────────────────────────────────────────
# Office 365: smtp.office365.com, port 587
# Requires SMTP AUTH enabled for the sending account in Exchange Admin Center.
# See SETUP.md / OPSÆTNING.md for instructions.
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
# ─── 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 { login } from "@/lib/auth"
import { rateLimit } from "@/lib/rate-limit"
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 {
const { email, password } = await request.json()

View file

@ -3,6 +3,7 @@ import nodemailer from "nodemailer"
import { formatPrice, type CalculationDetails } from "@/lib/calculations"
import { FLOORING_TYPES } from "@/lib/constants"
import { saveQuote } from "@/lib/db"
import { rateLimit } from "@/lib/rate-limit"
interface QuoteRequestBody {
customerInfo: {
@ -234,6 +235,11 @@ function formatFoamKingEmail(
}
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 {
const body: QuoteRequestBody = await request.json()
const { customerInfo, calculationDetails } = body
@ -296,8 +302,7 @@ export async function POST(request: NextRequest) {
success: true,
message: "Tak! Vi har modtaget din anmodning og sendt en bekræftelse til din email.",
})
} catch (error) {
console.error("Quote request error:", error)
} catch {
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 { getAllQuotes, updateQuoteStatus, type QuoteStatus } from "@/lib/db"
import { getCurrentUser } from "@/lib/auth"
import { checkAuth } from "@/lib/auth"
export async function GET() {
try {
const user = await getCurrentUser()
if (!user) {
const { authenticated } = await checkAuth()
if (!authenticated) {
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
}
const quotes = getAllQuotes()
return NextResponse.json({ quotes })
} catch (error) {
console.error("Get quotes error:", error)
} catch {
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
}
}
export async function PATCH(request: NextRequest) {
try {
const user = await getCurrentUser()
if (!user) {
const { authenticated } = await checkAuth()
if (!authenticated) {
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
}
@ -41,8 +40,7 @@ export async function PATCH(request: NextRequest) {
}
return NextResponse.json({ success: true })
} catch (error) {
console.error("Update quote error:", error)
} catch {
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
}
}

View file

@ -36,10 +36,6 @@ export default function AdminPage() {
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.")

View file

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

View file

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

View file

@ -38,15 +38,15 @@ export default function DashboardPage() {
const res = await fetch("/api/quotes")
if (!res.ok) {
if (res.status === 401) {
router.push("/login")
router.push("/intern/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)
} catch {
// fetch failed
} finally {
setLoading(false)
}
@ -84,10 +84,10 @@ export default function DashboardPage() {
async function handleLogout() {
try {
await fetch("/api/auth/logout", { method: "POST" })
router.push("/login")
router.push("/intern/login")
router.refresh()
} catch (error) {
console.error("Logout failed:", error)
} catch {
// logout failed
}
}
@ -133,7 +133,7 @@ export default function DashboardPage() {
</div>
<div className="flex items-center gap-2">
<Link href="/historik">
<Link href="/intern/historik">
<Button variant="ghost" size="sm">
<List className="mr-2 h-4 w-4" />
<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")
.refine(validateDanishPostalCode, "Ugyldigt dansk postnummer"),
address: z.string().optional(),
area: z.coerce
area: z
.number()
.min(CONSTRAINTS.MIN_AREA, `Minimum areal er ${CONSTRAINTS.MIN_AREA}`)
.max(CONSTRAINTS.MAX_AREA, `Maximum areal er ${CONSTRAINTS.MAX_AREA}`),
height: z.coerce
height: z
.number()
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_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")
.refine(validateDanishPostalCode, "Vi dækker desværre ikke dette område"),
address: z.string().optional(),
area: z.coerce
area: z
.number()
.min(CONSTRAINTS.MIN_AREA, `Minimum ${CONSTRAINTS.MIN_AREA}`)
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA}`),
height: z.coerce
height: z
.number()
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum ${CONSTRAINTS.MIN_HEIGHT} cm`)
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum ${CONSTRAINTS.MAX_HEIGHT} cm`),

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 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) {
// Don't protect the login page
if (pathname === "/intern/login") {
return NextResponse.next()
}
// Check for session cookie
// Check for session cookie on all /intern/* routes
const sessionCookie = request.cookies.get("session")
if (!sessionCookie?.value) {
// Redirect to login
const loginUrl = new URL("/login", request.url)
const loginUrl = new URL("/intern/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*"],
matcher: ["/intern/:path*"],
}

46
package-lock.json generated
View file

@ -16,7 +16,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",
@ -31,7 +30,6 @@
},
"devDependencies": {
"@eslint/eslintrc": "3.3.3",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "25.0.5",
"@types/nodemailer": "^7.0.5",
@ -3905,16 +3903,6 @@
"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": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@ -4924,20 +4912,6 @@
"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": {
"version": "12.6.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz",
@ -7705,26 +7679,6 @@
"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": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",

View file

@ -9,7 +9,8 @@
"lint": "next lint",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check ."
"format:check": "prettier --check .",
"setup": "node scripts/setup.js"
},
"dependencies": {
"@hookform/resolvers": "5.2.2",
@ -20,7 +21,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",
@ -35,7 +35,6 @@
},
"devDependencies": {
"@eslint/eslintrc": "3.3.3",
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "25.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")