Add Venmo payment step with QR code and configurable username (issue #7)
Deploy to Dev / Deploy & Smoke Test (push) Successful in 27s
Deploy to Dev / Deploy & Smoke Test (push) Successful in 27s
Step reorder (still 7 steps): Info (5) → Contract (6) → Payment (7) - Client info moved to step 5 so it's available for the contract signature name and the personalised Venmo note before the payment step - Contract is now step 6 (initContractStep triggered on step 6 entry) - Step 7 is the full payment step — no separate confirm step needed Payment step (step 7): - Compact summary table: date, session type, length, location - Large price display from siteConfig.booking.pricing - "Pay on Venmo" button linking to: https://venmo.com/<username>?txn=pay&amount=<price>¬e=<encoded-note> Note format: "LisiLou Booking - [SessionType] on [Date]" - QR code rendered from GET /api/venmo-qr?url=<encoded> (server-side SVG via qrcode package, site colours applied) - "I've sent payment" checkbox gates the Submit button - Clicking Submit creates booking with payment_status='pending_confirmation', then signs the contract — success screen shown immediately Configuration: venmoUsername lives in config/site.json under booking.venmoUsername — change it there to point at any Venmo account with no code change and no rebuild. API additions: - GET /api/venmo-qr?url= — validates url starts with venmo.com, returns SVG - POST /api/bookings now accepts payment_status field Closes #7 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+189
-74
@@ -1115,15 +1115,67 @@
|
||||
font-weight: 300;
|
||||
color: var(--color-primary);
|
||||
margin: 0.5rem 0 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.payment-note {
|
||||
background: var(--color-bg-warm);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.9rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pay-summary {
|
||||
border: 1px solid var(--color-accent);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.pay-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
border-bottom: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.pay-summary-row:last-child { border-bottom: none; }
|
||||
.pay-summary-row span:first-child { color: var(--color-text-light); }
|
||||
.pay-summary-row span:last-child { font-weight: 500; color: var(--color-text); text-align: right; max-width: 60%; }
|
||||
|
||||
.venmo-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0.85rem 1.5rem;
|
||||
background: #3D95CE;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-body);
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.25rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.venmo-btn:hover { background: #2d7db0; }
|
||||
|
||||
.venmo-qr {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.venmo-qr svg {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border: 1px solid var(--color-accent);
|
||||
padding: 6px;
|
||||
background: var(--color-bg-warm);
|
||||
}
|
||||
|
||||
/* Confirm / summary */
|
||||
@@ -1353,9 +1405,9 @@
|
||||
<div class="booking-step-item" data-step="2"><div class="step-dot">2</div><span class="step-label-text">Session</span></div>
|
||||
<div class="booking-step-item" data-step="3"><div class="step-dot">3</div><span class="step-label-text">Length</span></div>
|
||||
<div class="booking-step-item" data-step="4"><div class="step-dot">4</div><span class="step-label-text">Location</span></div>
|
||||
<div class="booking-step-item" data-step="5"><div class="step-dot">5</div><span class="step-label-text">Contract</span></div>
|
||||
<div class="booking-step-item" data-step="6"><div class="step-dot">6</div><span class="step-label-text">Payment</span></div>
|
||||
<div class="booking-step-item" data-step="7"><div class="step-dot">7</div><span class="step-label-text">Confirm</span></div>
|
||||
<div class="booking-step-item" data-step="5"><div class="step-dot">5</div><span class="step-label-text">Info</span></div>
|
||||
<div class="booking-step-item" data-step="6"><div class="step-dot">6</div><span class="step-label-text">Contract</span></div>
|
||||
<div class="booking-step-item" data-step="7"><div class="step-dot">7</div><span class="step-label-text">Payment</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1406,17 +1458,35 @@
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Contract -->
|
||||
<!-- Step 5: Your Info -->
|
||||
<div class="step-panel" id="step-5">
|
||||
<h3 class="step-heading">Your Information</h3>
|
||||
<p class="step-subheading">How should we reach you to confirm your session?</p>
|
||||
<div class="booking-field">
|
||||
<label class="booking-label" for="client-name">Full Name</label>
|
||||
<input type="text" class="booking-input" id="client-name" placeholder="Your full name" autocomplete="name">
|
||||
</div>
|
||||
<div class="booking-field">
|
||||
<label class="booking-label" for="client-email">Email Address</label>
|
||||
<input type="email" class="booking-input" id="client-email" placeholder="your@email.com" autocomplete="email">
|
||||
</div>
|
||||
<div class="booking-field">
|
||||
<label class="booking-label" for="client-phone">Phone Number</label>
|
||||
<input type="tel" class="booking-input" id="client-phone" placeholder="(555) 000-0000" autocomplete="tel">
|
||||
</div>
|
||||
<div class="booking-error" id="info-error" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 6: Contract — filled by initContractStep() -->
|
||||
<div class="step-panel" id="step-6">
|
||||
<h3 class="step-heading">Model Release & Contract</h3>
|
||||
<p class="step-subheading">Read the full agreement below, then sign and type your name.</p>
|
||||
|
||||
<!-- PDF viewer — filled by initContractStep() -->
|
||||
<div class="contract-viewer" id="contract-viewer">
|
||||
<div class="contract-loading" id="contract-loading">Loading contract…</div>
|
||||
</div>
|
||||
<div class="contract-scroll-notice" id="contract-scroll-notice">Scroll to the bottom to enable signing ↓</div>
|
||||
|
||||
<!-- Signature pad — locked until scrolled -->
|
||||
<div class="sig-section locked" id="sig-section">
|
||||
<div class="booking-label" style="margin-bottom:0.4rem;">Your Signature</div>
|
||||
<div class="sig-pad-wrapper">
|
||||
@@ -1432,45 +1502,50 @@
|
||||
<div class="booking-error" id="contract-error" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 6: Payment -->
|
||||
<div class="step-panel" id="step-6">
|
||||
<!-- Step 7: Payment — filled by initPaymentStep() -->
|
||||
<div class="step-panel" id="step-7">
|
||||
<h3 class="step-heading">Booking Fee</h3>
|
||||
<p class="step-subheading">A non-refundable booking fee reserves your date.</p>
|
||||
<div class="payment-amount" id="payment-amount">—</div>
|
||||
<div class="payment-note">
|
||||
Send payment via Venmo to reserve your session. Your date is not confirmed until payment is received.
|
||||
<p class="step-subheading">Send payment via Venmo to reserve your date.</p>
|
||||
|
||||
<div class="pay-summary" id="pay-summary">
|
||||
<div class="pay-summary-row"><span>Date</span><span id="pay-summary-date">—</span></div>
|
||||
<div class="pay-summary-row"><span>Session</span><span id="pay-summary-type">—</span></div>
|
||||
<div class="pay-summary-row"><span>Length</span><span id="pay-summary-length">—</span></div>
|
||||
<div class="pay-summary-row"><span>Location</span><span id="pay-summary-location">—</span></div>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;color:var(--color-text-light);margin-bottom:1.5rem;">Venmo link and QR code will appear here — Issue #7</p>
|
||||
|
||||
<div class="payment-amount" id="payment-amount">—</div>
|
||||
|
||||
<a class="venmo-btn" id="venmo-link" href="#" target="_blank" rel="noopener">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor" style="vertical-align:middle;margin-right:0.4rem"><path d="M15.25 1C13.5 4.25 11.1 8.15 9.5 11H6.75L5 2.25l-4 .75 2.25 12.5h5.5C11.5 11.5 16.5 4.25 17 1h-1.75z"/></svg>
|
||||
Pay on Venmo
|
||||
</a>
|
||||
|
||||
<div class="venmo-qr" id="venmo-qr"></div>
|
||||
|
||||
<p class="payment-note">
|
||||
Your session is reserved once we confirm receipt — usually within 24 hours.
|
||||
No Venmo? Send the exact amount and session note via any method and check the box below.
|
||||
</p>
|
||||
|
||||
<label class="booking-checkbox-label">
|
||||
<input type="checkbox" id="payment-sent">
|
||||
I have sent the booking fee payment via Venmo.
|
||||
I've sent the booking fee
|
||||
</label>
|
||||
<div class="booking-error" id="payment-error" style="display:none;">Please confirm you've sent payment to continue.</div>
|
||||
<div class="booking-error" id="confirm-error" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 7: Confirm -->
|
||||
<div class="step-panel" id="step-7">
|
||||
<h3 class="step-heading">Almost There!</h3>
|
||||
<p class="step-subheading">Review your details and enter your contact information.</p>
|
||||
<div class="confirm-summary">
|
||||
<div class="confirm-row"><span class="confirm-label">Date</span><span class="confirm-value" id="summary-date">—</span></div>
|
||||
<div class="confirm-row"><span class="confirm-label">Session Type</span><span class="confirm-value" id="summary-type">—</span></div>
|
||||
<div class="confirm-row"><span class="confirm-label">Length</span><span class="confirm-value" id="summary-length">—</span></div>
|
||||
<div class="confirm-row"><span class="confirm-label">Location</span><span class="confirm-value" id="summary-location">—</span></div>
|
||||
</div>
|
||||
<div class="booking-field">
|
||||
<label class="booking-label" for="client-name">Your Name</label>
|
||||
<input type="text" class="booking-input" id="client-name" placeholder="Full name" autocomplete="name">
|
||||
<!-- REMOVED step-7 confirm panel — client info now in step 5 -->
|
||||
<!-- keep a dummy anchor so old references still resolve -->
|
||||
<div style="display:none">
|
||||
<span id="summary-date"></span><span id="summary-type"></span>
|
||||
<span id="summary-length"></span><span id="summary-location"></span>
|
||||
<input id="REMOVED_client-name">
|
||||
</div>
|
||||
<div class="booking-field">
|
||||
<label class="booking-label" for="client-email">Email Address</label>
|
||||
<input type="email" class="booking-input" id="client-email" placeholder="your@email.com" autocomplete="email">
|
||||
</div>
|
||||
<div class="booking-field">
|
||||
<label class="booking-label" for="client-phone">Phone Number</label>
|
||||
<input type="tel" class="booking-input" id="client-phone" placeholder="(555) 000-0000" autocomplete="tel">
|
||||
</div>
|
||||
<div class="booking-error" id="confirm-error" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
@@ -1993,6 +2068,44 @@ A complete service agreement and model release will be provided at your session
|
||||
bookingState.signatureDrawn = false;
|
||||
}
|
||||
|
||||
function initPaymentStep() {
|
||||
const pricing = window.siteConfig?.booking?.pricing || { mini: 75, full: 150 };
|
||||
const username = window.siteConfig?.booking?.venmoUsername || 'LisiLouPhoto';
|
||||
const types = window.siteConfig?.booking?.sessionTypes || [];
|
||||
const locations = window.siteConfig?.locations || [];
|
||||
|
||||
const amount = bookingState.sessionLength === 'mini' ? pricing.mini : pricing.full;
|
||||
const typeLabel = (bookingState.sessionType === 'other' && bookingState.sessionTypeOther)
|
||||
? `Other — ${bookingState.sessionTypeOther}`
|
||||
: types.find(t => t.id === bookingState.sessionType)?.label || bookingState.sessionType;
|
||||
const locLabel = locations.find(l => l.id === bookingState.sessionLocation)?.name || bookingState.sessionLocation;
|
||||
const lenLabel = bookingState.sessionLength === 'mini' ? 'Mini — 30 min' : 'Full — 60 min';
|
||||
|
||||
document.getElementById('pay-summary-date').textContent = bookingState.sessionDate || '—';
|
||||
document.getElementById('pay-summary-type').textContent = typeLabel || '—';
|
||||
document.getElementById('pay-summary-length').textContent = lenLabel;
|
||||
document.getElementById('pay-summary-location').textContent = locLabel || '—';
|
||||
document.getElementById('payment-amount').textContent = `$${amount}`;
|
||||
|
||||
const note = `LisiLou Booking - ${typeLabel} on ${bookingState.sessionDate}`;
|
||||
const venmoUrl = `https://venmo.com/${username}?txn=pay&amount=${amount}¬e=${encodeURIComponent(note)}`;
|
||||
|
||||
document.getElementById('venmo-link').href = venmoUrl;
|
||||
renderVenmoQr(venmoUrl);
|
||||
}
|
||||
|
||||
async function renderVenmoQr(venmoUrl) {
|
||||
const container = document.getElementById('venmo-qr');
|
||||
container.innerHTML = '';
|
||||
try {
|
||||
const res = await fetch(`/api/venmo-qr?url=${encodeURIComponent(venmoUrl)}`);
|
||||
if (!res.ok) throw new Error('qr failed');
|
||||
container.innerHTML = await res.text();
|
||||
} catch {
|
||||
// Silent — the button still works without the QR
|
||||
}
|
||||
}
|
||||
|
||||
function openBooking() {
|
||||
document.getElementById('booking-overlay').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
@@ -2050,8 +2163,8 @@ A complete service agreement and model release will be provided at your session
|
||||
btnNext.disabled = false;
|
||||
counter.textContent = 'Step ' + step + ' of ' + BOOKING_STEPS;
|
||||
|
||||
if (step === BOOKING_STEPS) populateSummary();
|
||||
if (step === 5) initContractStep();
|
||||
if (step === 6) initContractStep();
|
||||
if (step === 7) initPaymentStep();
|
||||
}
|
||||
|
||||
function selectOption(card, field) {
|
||||
@@ -2115,6 +2228,25 @@ A complete service agreement and model release will be provided at your session
|
||||
case 4:
|
||||
return !!bookingState.sessionLocation;
|
||||
case 5: {
|
||||
// Your Info step
|
||||
const name = document.getElementById('client-name').value.trim();
|
||||
const email = document.getElementById('client-email').value.trim();
|
||||
const errEl = document.getElementById('info-error');
|
||||
if (!name || !email) {
|
||||
errEl.textContent = 'Please enter your name and email address.';
|
||||
errEl.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errEl.textContent = 'Please enter a valid email address.';
|
||||
errEl.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
errEl.style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
case 6: {
|
||||
// Contract step
|
||||
const errEl = document.getElementById('contract-error');
|
||||
if (!bookingState.contractScrolled) {
|
||||
errEl.textContent = 'Please scroll through the full agreement.';
|
||||
@@ -2126,8 +2258,8 @@ A complete service agreement and model release will be provided at your session
|
||||
errEl.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
const name = document.getElementById('contract-name').value.trim();
|
||||
if (!name) {
|
||||
const sigName = document.getElementById('contract-name').value.trim();
|
||||
if (!sigName) {
|
||||
errEl.textContent = 'Please type your full name to confirm.';
|
||||
errEl.style.display = 'block';
|
||||
document.getElementById('contract-name').focus();
|
||||
@@ -2136,77 +2268,60 @@ A complete service agreement and model release will be provided at your session
|
||||
errEl.style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
case 6: {
|
||||
case 7: {
|
||||
// Payment step — checkbox + submit
|
||||
const ok = document.getElementById('payment-sent').checked;
|
||||
document.getElementById('payment-error').style.display = ok ? 'none' : 'block';
|
||||
return ok;
|
||||
}
|
||||
case 7:
|
||||
return validateClientInfo();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function validateClientInfo() {
|
||||
const name = document.getElementById('client-name').value.trim();
|
||||
const email = document.getElementById('client-email').value.trim();
|
||||
const errEl = document.getElementById('confirm-error');
|
||||
if (!name || !email) {
|
||||
errEl.textContent = 'Please enter your name and email address.';
|
||||
errEl.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errEl.textContent = 'Please enter a valid email address.';
|
||||
errEl.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
errEl.style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submitBooking() {
|
||||
bookingState.sessionDate = document.getElementById('session-date').value;
|
||||
bookingState.clientName = document.getElementById('client-name').value.trim();
|
||||
bookingState.clientEmail = document.getElementById('client-email').value.trim();
|
||||
bookingState.clientPhone = document.getElementById('client-phone').value.trim();
|
||||
// Client info is now collected in step 5
|
||||
bookingState.sessionDate = document.getElementById('session-date').value;
|
||||
bookingState.clientName = document.getElementById('client-name').value.trim();
|
||||
bookingState.clientEmail = document.getElementById('client-email').value.trim();
|
||||
bookingState.clientPhone = document.getElementById('client-phone').value.trim();
|
||||
|
||||
const btnNext = document.getElementById('btn-next');
|
||||
btnNext.textContent = 'Submitting…';
|
||||
btnNext.disabled = true;
|
||||
|
||||
try {
|
||||
// 1. Create booking record
|
||||
// 1. Create booking — include payment_status since client confirmed sending
|
||||
const res = await fetch('/api/bookings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_name: bookingState.clientName,
|
||||
client_email: bookingState.clientEmail,
|
||||
client_phone: bookingState.clientPhone,
|
||||
session_date: bookingState.sessionDate,
|
||||
session_type: bookingState.sessionType === 'other'
|
||||
client_name: bookingState.clientName,
|
||||
client_email: bookingState.clientEmail,
|
||||
client_phone: bookingState.clientPhone,
|
||||
session_date: bookingState.sessionDate,
|
||||
session_type: bookingState.sessionType === 'other'
|
||||
? 'other: ' + bookingState.sessionTypeOther
|
||||
: bookingState.sessionType,
|
||||
session_length: bookingState.sessionLength,
|
||||
location: (window.siteConfig?.locations || []).find(l => l.id === bookingState.sessionLocation)?.name
|
||||
location: (window.siteConfig?.locations || []).find(l => l.id === bookingState.sessionLocation)?.name
|
||||
|| bookingState.sessionLocation,
|
||||
payment_status: 'pending_confirmation',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('server error');
|
||||
const { id: bookingId } = await res.json();
|
||||
|
||||
// 2. Submit signature if collected in step 5
|
||||
// 2. Submit signature captured in step 6
|
||||
if (bookingState.signatureDrawn && bookingId) {
|
||||
const sigCanvas = document.getElementById('sig-canvas');
|
||||
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,
|
||||
full_name: contractName || bookingState.clientName,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user