Add contract e-signature step with PDF viewer and canvas pad (issue #6)
Deploy to Dev / Deploy & Smoke Test (push) Failing after 32s
Deploy to Dev / Deploy & Smoke Test (push) Failing after 32s
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/<id>.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 <noreply@anthropic.com>
This commit is contained in:
Generated
+44
-1
@@ -11,7 +11,26 @@
|
|||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.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": {
|
"node_modules/accepts": {
|
||||||
@@ -820,6 +839,12 @@
|
|||||||
"wrappy": "1"
|
"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": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -835,6 +860,18 @@
|
|||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
@@ -1224,6 +1261,12 @@
|
|||||||
"node": ">=0.6"
|
"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": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -10,6 +10,7 @@
|
|||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.18.3"
|
"express": "^4.18.3",
|
||||||
|
"pdf-lib": "^1.17.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
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(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true }));
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
@@ -13,6 +18,17 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.json({ ok: true });
|
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) => {
|
app.post('/api/bookings', (req, res) => {
|
||||||
const {
|
const {
|
||||||
client_name, client_email, client_phone, client_sub,
|
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 });
|
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}`));
|
app.listen(PORT, () => console.log(`API listening on port ${PORT}`));
|
||||||
|
|||||||
+294
-17
@@ -1001,16 +1001,97 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Contract step */
|
/* Contract step */
|
||||||
.contract-placeholder {
|
.contract-viewer {
|
||||||
background: var(--color-bg-warm);
|
|
||||||
border: 1px solid var(--color-accent);
|
border: 1px solid var(--color-accent);
|
||||||
padding: 2rem;
|
max-height: 300px;
|
||||||
margin-bottom: 1.5rem;
|
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;
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-light);
|
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 {
|
.booking-checkbox-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1327,16 +1408,28 @@
|
|||||||
<!-- Step 5: Contract -->
|
<!-- Step 5: Contract -->
|
||||||
<div class="step-panel" id="step-5">
|
<div class="step-panel" id="step-5">
|
||||||
<h3 class="step-heading">Model Release & Contract</h3>
|
<h3 class="step-heading">Model Release & Contract</h3>
|
||||||
<p class="step-subheading">Please review and sign the session agreement before continuing.</p>
|
<p class="step-subheading">Read the full agreement below, then sign and type your name.</p>
|
||||||
<div class="contract-placeholder">
|
|
||||||
<p>Contract document will be displayed here.</p>
|
<!-- PDF viewer — filled by initContractStep() -->
|
||||||
<p style="margin-top:0.5rem;font-size:0.75rem;">Full PDF viewer and signature pad coming in Issue #6</p>
|
<div class="contract-viewer" id="contract-viewer">
|
||||||
|
<div class="contract-loading" id="contract-loading">Loading contract…</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="booking-checkbox-label">
|
<div class="contract-scroll-notice" id="contract-scroll-notice">Scroll to the bottom to enable signing ↓</div>
|
||||||
<input type="checkbox" id="contract-agree">
|
|
||||||
I have read and agree to the session contract and model release. I understand my rights and responsibilities as outlined in the agreement.
|
<!-- Signature pad — locked until scrolled -->
|
||||||
</label>
|
<div class="sig-section locked" id="sig-section">
|
||||||
<div class="booking-error" id="contract-error" style="display:none;">Please agree to the contract to continue.</div>
|
<div class="booking-label" style="margin-bottom:0.4rem;">Your Signature</div>
|
||||||
|
<div class="sig-pad-wrapper">
|
||||||
|
<canvas id="sig-canvas"></canvas>
|
||||||
|
<button type="button" class="sig-clear-btn" onclick="clearSignature()">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="booking-field">
|
||||||
|
<label class="booking-label" for="contract-name">Full Name (type to confirm)</label>
|
||||||
|
<input type="text" class="booking-input" id="contract-name" placeholder="Your full legal name" autocomplete="name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="booking-error" id="contract-error" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 6: Payment -->
|
<!-- Step 6: Payment -->
|
||||||
@@ -1657,6 +1750,7 @@
|
|||||||
const bookingState = {
|
const bookingState = {
|
||||||
sessionDate: '', sessionType: '', sessionTypeOther: '', sessionLength: '',
|
sessionDate: '', sessionType: '', sessionTypeOther: '', sessionLength: '',
|
||||||
sessionLocation: '', clientName: '', clientEmail: '', clientPhone: '',
|
sessionLocation: '', clientName: '', clientEmail: '', clientPhone: '',
|
||||||
|
contractScrolled: false, signatureDrawn: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentStep = 0;
|
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 = '<div class="contract-loading" id="contract-loading">Loading contract…</div>';
|
||||||
|
|
||||||
|
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 = `<div class="contract-fallback">
|
||||||
|
<h4>Session Agreement & Model Release</h4>
|
||||||
|
By proceeding you agree to the following terms with LisiLou Photography:
|
||||||
|
|
||||||
|
<strong>Session & Payment</strong>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<strong>Image Rights & Model Release</strong>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<strong>Delivery</strong>
|
||||||
|
Edited digital images will be delivered within 2–3 weeks via a private online gallery. RAW files are not included.
|
||||||
|
|
||||||
|
<strong>Full Contract</strong>
|
||||||
|
A complete service agreement and model release will be provided at your session for signature. This electronic agreement acknowledges the key terms above.
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function openBooking() {
|
||||||
document.getElementById('booking-overlay').classList.add('active');
|
document.getElementById('booking-overlay').classList.add('active');
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
Object.keys(bookingState).forEach(k => bookingState[k] = '');
|
Object.keys(bookingState).forEach(k => bookingState[k] = '');
|
||||||
document.getElementById('contract-agree').checked = false;
|
|
||||||
document.getElementById('payment-sent').checked = false;
|
document.getElementById('payment-sent').checked = false;
|
||||||
document.getElementById('client-name').value = '';
|
document.getElementById('client-name').value = '';
|
||||||
document.getElementById('client-email').value = '';
|
document.getElementById('client-email').value = '';
|
||||||
document.getElementById('client-phone').value = '';
|
document.getElementById('client-phone').value = '';
|
||||||
document.getElementById('other-type-text').value = '';
|
document.getElementById('other-type-text').value = '';
|
||||||
document.getElementById('other-type-field').style.display = 'none';
|
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').min = new Date().toISOString().split('T')[0];
|
||||||
document.getElementById('session-date').value = '';
|
document.getElementById('session-date').value = '';
|
||||||
renderSessionTypeCards();
|
renderSessionTypeCards();
|
||||||
@@ -1810,6 +2051,7 @@
|
|||||||
counter.textContent = 'Step ' + step + ' of ' + BOOKING_STEPS;
|
counter.textContent = 'Step ' + step + ' of ' + BOOKING_STEPS;
|
||||||
|
|
||||||
if (step === BOOKING_STEPS) populateSummary();
|
if (step === BOOKING_STEPS) populateSummary();
|
||||||
|
if (step === 5) initContractStep();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectOption(card, field) {
|
function selectOption(card, field) {
|
||||||
@@ -1873,9 +2115,26 @@
|
|||||||
case 4:
|
case 4:
|
||||||
return !!bookingState.sessionLocation;
|
return !!bookingState.sessionLocation;
|
||||||
case 5: {
|
case 5: {
|
||||||
const ok = document.getElementById('contract-agree').checked;
|
const errEl = document.getElementById('contract-error');
|
||||||
document.getElementById('contract-error').style.display = ok ? 'none' : 'block';
|
if (!bookingState.contractScrolled) {
|
||||||
return ok;
|
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: {
|
case 6: {
|
||||||
const ok = document.getElementById('payment-sent').checked;
|
const ok = document.getElementById('payment-sent').checked;
|
||||||
@@ -1918,6 +2177,7 @@
|
|||||||
btnNext.disabled = true;
|
btnNext.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Create booking record
|
||||||
const res = await fetch('/api/bookings', {
|
const res = await fetch('/api/bookings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -1935,6 +2195,22 @@
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('server error');
|
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);
|
goToStep(BOOKING_STEPS + 1);
|
||||||
} catch {
|
} catch {
|
||||||
const errEl = document.getElementById('confirm-error');
|
const errEl = document.getElementById('confirm-error');
|
||||||
@@ -1981,5 +2257,6 @@
|
|||||||
observeRevealElements();
|
observeRevealElements();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" crossorigin="anonymous"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user