diff --git a/api/package-lock.json b/api/package-lock.json index 83babd5..b50b7b6 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -11,7 +11,26 @@ "better-sqlite3": "^9.4.3", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.18.3" + "express": "^4.18.3", + "pdf-lib": "^1.17.1" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" } }, "node_modules/accepts": { @@ -820,6 +839,12 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -835,6 +860,18 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1224,6 +1261,12 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/api/package.json b/api/package.json index aad12e0..2da7419 100644 --- a/api/package.json +++ b/api/package.json @@ -10,6 +10,7 @@ "better-sqlite3": "^9.4.3", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.18.3" + "express": "^4.18.3", + "pdf-lib": "^1.17.1" } } diff --git a/api/server.js b/api/server.js index 17cd692..f21627c 100644 --- a/api/server.js +++ b/api/server.js @@ -1,11 +1,16 @@ require('dotenv').config(); const express = require('express'); const cors = require('cors'); +const fs = require('fs'); +const path = require('path'); const db = require('./db'); const app = express(); const PORT = process.env.PORT || 3001; +const CONTRACTS_DIR = path.join(__dirname, 'contracts'); +const SIGNED_DIR = path.join(__dirname, 'signed-contracts'); + app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true })); app.use(express.json({ limit: '10mb' })); @@ -13,6 +18,17 @@ app.get('/api/health', (req, res) => { res.json({ ok: true }); }); +// Serve the contract template PDF to the frontend PDF.js viewer +app.get('/api/contracts/template', (req, res) => { + const contractPath = path.join(CONTRACTS_DIR, 'model-release.pdf'); + if (!fs.existsSync(contractPath)) { + return res.status(404).json({ error: 'Contract template not available' }); + } + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Cache-Control', 'no-store'); + res.sendFile(contractPath); +}); + app.post('/api/bookings', (req, res) => { const { client_name, client_email, client_phone, client_sub, @@ -32,4 +48,78 @@ app.post('/api/bookings', (req, res) => { res.status(201).json({ id: result.lastInsertRowid }); }); +// Stamp the signature onto the contract PDF and record it +app.post('/api/bookings/:id/sign', async (req, res) => { + const id = parseInt(req.params.id, 10); + const { signature_png, full_name } = req.body; + + if (!signature_png || !full_name) { + return res.status(400).json({ error: 'signature_png and full_name are required' }); + } + + const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); + if (!booking) return res.status(404).json({ error: 'Booking not found' }); + + const contractTemplate = path.join(CONTRACTS_DIR, 'model-release.pdf'); + let pdfPath = null; + + if (fs.existsSync(contractTemplate)) { + try { + const { PDFDocument, rgb, StandardFonts } = require('pdf-lib'); + + const pdfBytes = fs.readFileSync(contractTemplate); + const pdfDoc = await PDFDocument.load(pdfBytes); + const pages = pdfDoc.getPages(); + + const sigPageIndex = Math.max(0, parseInt(process.env.CONTRACT_SIG_PAGE || '1', 10) - 1); + const sigX = parseFloat(process.env.CONTRACT_SIG_X || '100'); + const sigY = parseFloat(process.env.CONTRACT_SIG_Y || '100'); + const page = pages[Math.min(sigPageIndex, pages.length - 1)]; + + // Embed the signature image + const sigBase64 = signature_png.replace(/^data:image\/png;base64,/, ''); + const sigImage = await pdfDoc.embedPng(Buffer.from(sigBase64, 'base64')); + const imgDims = sigImage.scaleToFit(200, 60); + page.drawImage(sigImage, { x: sigX, y: sigY, width: imgDims.width, height: imgDims.height }); + + // Print name, date, and booking ID below signature + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const dateStr = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + page.drawText(`${full_name} · ${dateStr} · Booking #${id}`, { + x: sigX, + y: sigY - 14, + size: 9, + font, + color: rgb(0.2, 0.2, 0.2), + }); + + fs.mkdirSync(SIGNED_DIR, { recursive: true }); + const outPath = path.join(SIGNED_DIR, `${id}.pdf`); + fs.writeFileSync(outPath, await pdfDoc.save()); + pdfPath = `signed-contracts/${id}.pdf`; + } catch (err) { + console.error('PDF stamping failed:', err.message); + // Non-fatal — still record the agreement + } + } + + const now = new Date().toISOString(); + db.prepare(` + UPDATE bookings SET contract_signed_at = ?, contract_pdf_path = ? WHERE id = ? + `).run(now, pdfPath, id); + + // Fire n8n webhook asynchronously if configured + const webhookUrl = process.env.N8N_WEBHOOK_URL; + if (webhookUrl) { + const record = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); + fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event: 'contract_signed', booking: record }), + }).catch(e => console.error('n8n webhook error:', e.message)); + } + + res.json({ ok: true, signed_at: now, pdf_path: pdfPath }); +}); + app.listen(PORT, () => console.log(`API listening on port ${PORT}`)); diff --git a/src/index.html b/src/index.html index 0d030c8..7cbf580 100644 --- a/src/index.html +++ b/src/index.html @@ -1001,16 +1001,97 @@ } /* Contract step */ - .contract-placeholder { - background: var(--color-bg-warm); + .contract-viewer { border: 1px solid var(--color-accent); - padding: 2rem; - margin-bottom: 1.5rem; + max-height: 300px; + overflow-y: auto; + margin-bottom: 0.75rem; + background: #e8e8e8; + position: relative; + scroll-behavior: smooth; + } + + .contract-viewer canvas { + display: block; + width: 100%; + margin-bottom: 2px; + } + + .contract-loading { + padding: 2.5rem 1rem; text-align: center; + font-style: italic; font-size: 0.85rem; color: var(--color-text-light); + background: var(--color-bg-warm); } + .contract-fallback { + padding: 1.25rem 1.5rem; + font-size: 0.8rem; + line-height: 1.7; + color: var(--color-text); + background: var(--color-white); + white-space: pre-line; + } + + .contract-fallback h4 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 400; + margin-bottom: 0.75rem; + letter-spacing: 0.05em; + } + + .contract-scroll-notice { + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-primary); + text-align: center; + margin-bottom: 1rem; + transition: opacity 0.4s; + } + + .contract-scroll-notice.hidden { opacity: 0; pointer-events: none; } + + .sig-section { transition: opacity 0.3s; } + .sig-section.locked { opacity: 0.35; pointer-events: none; } + + .sig-pad-wrapper { + position: relative; + border: 1px solid var(--color-accent); + background: #fff; + margin-bottom: 1rem; + cursor: crosshair; + } + + .sig-pad-wrapper:focus-within { border-color: var(--color-primary); } + + #sig-canvas { + display: block; + width: 100%; + height: 110px; + touch-action: none; + } + + .sig-clear-btn { + position: absolute; + top: 0.4rem; + right: 0.4rem; + font-size: 0.65rem; + letter-spacing: 0.1em; + text-transform: uppercase; + background: none; + border: 1px solid var(--color-accent); + color: var(--color-text-light); + padding: 0.2rem 0.55rem; + cursor: pointer; + transition: border-color 0.2s, color 0.2s; + } + + .sig-clear-btn:hover { border-color: var(--color-primary); color: var(--color-primary); } + .booking-checkbox-label { display: flex; align-items: flex-start; @@ -1327,16 +1408,28 @@
Please review and sign the session agreement before continuing.
-Contract document will be displayed here.
-Full PDF viewer and signature pad coming in Issue #6
+Read the full agreement below, then sign and type your name.
+ + +