From eea64006fc0f5b9104186453ef88a859ef50f702 Mon Sep 17 00:00:00 2001 From: jhodgkin Date: Wed, 8 Jul 2026 09:31:13 -0600 Subject: [PATCH] Add contract e-signature step with PDF viewer and canvas pad (issue #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend (step 5): - PDF.js 3.11.174 from CDN renders the contract inline in a scrollable viewer - Next button logic stays unlocked; signature pad is visually locked (greyed + pointer-events:none) until user scrolls to the bottom of the contract - HTML5 canvas signature pad with mouse and touch support; Clear button resets - Typed full name field required to confirm identity - Fallback agreement text renders if /api/contracts/template returns 404, auto-marks as scrolled — photographer adds PDF later with no code change Backend: - GET /api/contracts/template — serves api/contracts/model-release.pdf - POST /api/bookings/:id/sign — stamps signature image + name/date/booking ID onto the PDF via pdf-lib, saves to api/signed-contracts/.pdf, updates contract_signed_at and contract_pdf_path in the bookings table, fires n8n webhook; gracefully skips PDF stamping if template is missing - submitBooking() now calls POST /api/bookings then POST /api/bookings/:id/sign sequentially before showing the success screen Closes #6 Co-Authored-By: Claude Sonnet 4.6 --- api/package-lock.json | 45 +++++- api/package.json | 3 +- api/server.js | 90 ++++++++++++ src/index.html | 311 +++++++++++++++++++++++++++++++++++++++--- 4 files changed, 430 insertions(+), 19 deletions(-) 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 @@

Model Release & Contract

-

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.

+ + +
+
Loading contract…
- - +
Scroll to the bottom to enable signing ↓
+ + +
+
Your Signature
+
+ + +
+
+ + +
+
+ +
@@ -1657,6 +1750,7 @@ const bookingState = { sessionDate: '', sessionType: '', sessionTypeOther: '', sessionLength: '', sessionLocation: '', clientName: '', clientEmail: '', clientPhone: '', + contractScrolled: false, signatureDrawn: false, }; let currentStep = 0; @@ -1754,17 +1848,164 @@ } } + // ── Contract step ────────────────────────────────────────────────────────── + + async function initContractStep() { + const viewer = document.getElementById('contract-viewer'); + const notice = document.getElementById('contract-scroll-notice'); + const sigSection = document.getElementById('sig-section'); + + // Reset state + bookingState.contractScrolled = false; + bookingState.signatureDrawn = false; + notice.classList.remove('hidden'); + sigSection.classList.add('locked'); + + // Clear any previous render + viewer.innerHTML = '
Loading contract…
'; + + const hasPdfJs = typeof window.pdfjsLib !== 'undefined'; + let pdfLoaded = false; + + if (hasPdfJs) { + window.pdfjsLib.GlobalWorkerOptions.workerSrc = + 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; + + try { + const loadingEl = document.getElementById('contract-loading'); + const pdf = await window.pdfjsLib.getDocument('/api/contracts/template').promise; + if (loadingEl) loadingEl.remove(); + + for (let p = 1; p <= pdf.numPages; p++) { + const page = await pdf.getPage(p); + const viewport = page.getViewport({ scale: 1.4 }); + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + viewer.appendChild(canvas); + await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; + } + pdfLoaded = true; + } catch (e) { + // PDF not available or fetch failed — fall through to text fallback + } + } + + if (!pdfLoaded) { + viewer.innerHTML = `
+

Session Agreement & Model Release

+By proceeding you agree to the following terms with LisiLou Photography: + +Session & Payment +A non-refundable booking fee reserves your date. The remainder is due on the day of the session. Cancellations with less than 48 hours notice forfeit the booking fee. A $25 rescheduling fee applies for changes made within 48 hours. + +Image Rights & Model Release +You grant LisiLou Photography permission to display, reproduce, and use session images for portfolio, social media, and promotional purposes. Images will not be sold to third parties. You retain the right to print and share your own digital files. + +Delivery +Edited digital images will be delivered within 2–3 weeks via a private online gallery. RAW files are not included. + +Full Contract +A complete service agreement and model release will be provided at your session for signature. This electronic agreement acknowledges the key terms above. +
`; + } + + setupScrollDetection(viewer); + setupSignaturePad(); + } + + function setupScrollDetection(viewer) { + function check() { + const atBottom = viewer.scrollHeight - viewer.scrollTop <= viewer.clientHeight + 60; + if (atBottom) { + bookingState.contractScrolled = true; + document.getElementById('contract-scroll-notice').classList.add('hidden'); + document.getElementById('sig-section').classList.remove('locked'); + viewer.removeEventListener('scroll', check); + } + } + viewer.addEventListener('scroll', check); + // Trigger immediately in case content is shorter than the container + check(); + } + + function setupSignaturePad() { + const canvas = document.getElementById('sig-canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + // Size canvas pixels to its CSS display size + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, rect.width, rect.height); + ctx.strokeStyle = '#2c2c2c'; + ctx.lineWidth = 1.8; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + let drawing = false; + + function pos(e) { + const r = canvas.getBoundingClientRect(); + const src = e.touches ? e.touches[0] : e; + return { x: src.clientX - r.left, y: src.clientY - r.top }; + } + + function start(e) { + e.preventDefault(); + drawing = true; + const { x, y } = pos(e); + ctx.beginPath(); + ctx.moveTo(x, y); + } + + function move(e) { + if (!drawing) return; + e.preventDefault(); + const { x, y } = pos(e); + ctx.lineTo(x, y); + ctx.stroke(); + bookingState.signatureDrawn = true; + } + + function stop() { drawing = false; } + + canvas.addEventListener('mousedown', start); + canvas.addEventListener('mousemove', move); + canvas.addEventListener('mouseup', stop); + canvas.addEventListener('mouseleave', stop); + canvas.addEventListener('touchstart', start, { passive: false }); + canvas.addEventListener('touchmove', move, { passive: false }); + canvas.addEventListener('touchend', stop); + } + + function clearSignature() { + const canvas = document.getElementById('sig-canvas'); + const ctx = canvas.getContext('2d'); + const rect = canvas.getBoundingClientRect(); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, rect.width, rect.height); + bookingState.signatureDrawn = false; + } + function openBooking() { document.getElementById('booking-overlay').classList.add('active'); document.body.style.overflow = 'hidden'; Object.keys(bookingState).forEach(k => bookingState[k] = ''); - document.getElementById('contract-agree').checked = false; document.getElementById('payment-sent').checked = false; document.getElementById('client-name').value = ''; document.getElementById('client-email').value = ''; document.getElementById('client-phone').value = ''; document.getElementById('other-type-text').value = ''; document.getElementById('other-type-field').style.display = 'none'; + document.getElementById('contract-name').value = ''; + bookingState.contractScrolled = false; + bookingState.signatureDrawn = false; document.getElementById('session-date').min = new Date().toISOString().split('T')[0]; document.getElementById('session-date').value = ''; renderSessionTypeCards(); @@ -1810,6 +2051,7 @@ counter.textContent = 'Step ' + step + ' of ' + BOOKING_STEPS; if (step === BOOKING_STEPS) populateSummary(); + if (step === 5) initContractStep(); } function selectOption(card, field) { @@ -1873,9 +2115,26 @@ case 4: return !!bookingState.sessionLocation; case 5: { - const ok = document.getElementById('contract-agree').checked; - document.getElementById('contract-error').style.display = ok ? 'none' : 'block'; - return ok; + const errEl = document.getElementById('contract-error'); + if (!bookingState.contractScrolled) { + errEl.textContent = 'Please scroll through the full agreement.'; + errEl.style.display = 'block'; + return false; + } + if (!bookingState.signatureDrawn) { + errEl.textContent = 'Please sign in the box above.'; + errEl.style.display = 'block'; + return false; + } + const name = document.getElementById('contract-name').value.trim(); + if (!name) { + errEl.textContent = 'Please type your full name to confirm.'; + errEl.style.display = 'block'; + document.getElementById('contract-name').focus(); + return false; + } + errEl.style.display = 'none'; + return true; } case 6: { const ok = document.getElementById('payment-sent').checked; @@ -1918,6 +2177,7 @@ btnNext.disabled = true; try { + // 1. Create booking record const res = await fetch('/api/bookings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1935,6 +2195,22 @@ }), }); if (!res.ok) throw new Error('server error'); + const { id: bookingId } = await res.json(); + + // 2. Submit signature if collected in step 5 + if (bookingState.signatureDrawn && bookingId) { + const sigCanvas = document.getElementById('sig-canvas'); + const contractName = document.getElementById('contract-name').value.trim(); + await fetch(`/api/bookings/${bookingId}/sign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + signature_png: sigCanvas.toDataURL('image/png'), + full_name: contractName || bookingState.clientName, + }), + }); + } + goToStep(BOOKING_STEPS + 1); } catch { const errEl = document.getElementById('confirm-error'); @@ -1981,5 +2257,6 @@ observeRevealElements(); }); +