From 8439f2bf18506f31c47c993136ece7ffc8d998d5 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 14 May 2026 13:52:24 -0500 Subject: [PATCH] fix(landing): replace broken Custom pricing card with enterprise contact form The "Custom" tier on the public pricing page was misrendering as a better-than-Free tier: headline "Custom", price "Free", "Unlimited devices/storage", "Get Started" button. Root cause is in DB data, not markup - the 'enterprise' plan row has price_monthly=0 and max_devices/storage=-1, and the dynamic render in landing.html maps those to "Free" + "Unlimited" with the wrong CTA. Fix: filter the 'enterprise' plan out of the public landing render (client-side, in landing.html only) and replace it with a hardcoded Enterprise / Custom marketing card whose Contact Us button opens a new lead-capture modal. The DB row itself stays - it is actively used elsewhere: - auth.js: first user in SELF_HOSTED=true mode is assigned to it - settings.js: white-label feature is gated on enterprise plan - 1 user (the dev account) is currently assigned to it - /api/subscription/plans is also consumed by billing.js, settings.js, admin.js (logged-in surfaces); they keep getting the full plan list. The filter is scoped to landing.html's render only. The in-app billing page renders the same plan with the same cosmetic bug; that's a logged-in admin surface, out of scope for this commit. Other 4 cards (Free, Starter, Pro, Business) unchanged. Frontend (landing.html): - Filter 'enterprise' from public render - Hardcoded Enterprise / Custom card. Uses .price class with "Let's talk" + empty .yearly spacer to match Free card's vertical baseline so the feature list aligns with the paid cards' baselines. - Modal markup, CSS (mirrored from frontend/css/main.css conventions since landing.html doesn't import main.css), and inline JS for open/close/submit/escape/background-click. - Honeypot field: hidden 'fax_number' input (off-screen + aria-hidden + tabindex=-1). Picked over the obvious 'website' name to catch mid-tier bots that explicitly skip the well-known honeypot names. Backend (new server/routes/contact.js): - POST /api/contact/enterprise, public (unauthenticated) - Rate limited 5/min/IP+path via the existing rateLimit middleware - Honeypot check: populated fax_number returns 200 silently, no email - Server-side validation: required fields, email format, screens 1-100000, multi_tenant in {single,multi}, hosting in {hosted,self, unsure}. Length caps prevent textarea-bomb abuse. - Sends via existing services/email.js (Microsoft Graph) to dan@bytetinker.net from the support@screentinker.com Graph sender. - Log lines: "[contact] enterprise inquiry from EMAIL (COMPANY) delivered" or "[contact] honeypot triggered from IP; dropping". Wired in server.js alongside other public routes (before requireAuth). Build-time tests passed locally: - Module loads, server boots clean - Validation: missing fields, bad email, bad multi_tenant, bad hosting, screens out of range - all return 400 with the right error message - Honeypot: populated fax_number returns 200 success, no email sent, log line confirms drop - Rate limit: kicks in at 6th request within a minute as expected - Real end-to-end send: one test submission delivered to dan@bytetinker.net via Graph (subject "[ScreenTinker] Enterprise inquiry: ScreenTinker Build Verification", body formatted with all fields). GRAPH_DEV_RESTRICT_TO was temporarily widened to include the recipient for the test and restored to dw5304@gmail.com immediately after. - Card render order verified against live API: Free (outline, Get Started) | Starter | Pro (featured, Most Popular badge) | Business | Enterprise / Custom (Contact Us -> modal). Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/landing.html | 166 ++++++++++++++++++++++++++++++++++++++- server/routes/contact.js | 85 ++++++++++++++++++++ server/server.js | 5 ++ 3 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 server/routes/contact.js diff --git a/frontend/landing.html b/frontend/landing.html index 3ff6fd3..30d64db 100644 --- a/frontend/landing.html +++ b/frontend/landing.html @@ -85,6 +85,24 @@ .platform-item .icon { font-size:40px; margin-bottom:8px; } .platform-item .name { font-size:13px; color:var(--muted); } + /* Modal (mirrors frontend/css/main.css conventions so screenshots look + consistent across landing and dashboard; copied inline since + landing.html doesn't import main.css). */ + .modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); display:flex; align-items:center; justify-content:center; z-index:1000; padding:16px; } + .modal { background:var(--card); border:1px solid var(--border); border-radius:12px; width:100%; max-width:560px; max-height:90vh; overflow-y:auto; } + .modal-header { padding:20px 24px; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; } + .modal-header h3 { font-size:18px; margin:0; } + .modal-close { background:none; border:none; color:var(--muted); font-size:24px; cursor:pointer; padding:0; line-height:1; } + .modal-body { padding:20px 24px; } + .modal-description { color:var(--muted); font-size:14px; margin-bottom:16px; } + .modal-footer { padding:16px 24px; border-top:1px solid var(--border); display:flex; gap:12px; justify-content:flex-end; } + .modal-body label { display:block; margin-bottom:12px; font-size:13px; color:var(--text); } + .modal-body input, .modal-body select, .modal-body textarea { width:100%; margin-top:4px; padding:8px 10px; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; font-size:14px; font-family:inherit; box-sizing:border-box; } + .modal-body input:focus, .modal-body select:focus, .modal-body textarea:focus { outline:none; border-color:var(--accent); } + .modal-body textarea { resize:vertical; } + .contact-status-success { color:#10b981; font-size:13px; } + .contact-status-error { color:#f87171; font-size:13px; } + /* Pricing */ .pricing { max-width:1200px; margin:0 auto; padding:80px 24px; } .pricing h2 { text-align:center; font-size:36px; margin-bottom:12px; } @@ -376,6 +394,56 @@ + + + diff --git a/server/routes/contact.js b/server/routes/contact.js new file mode 100644 index 0000000..be47ffb --- /dev/null +++ b/server/routes/contact.js @@ -0,0 +1,85 @@ +// Public (unauthenticated) contact form endpoint. Used by the Enterprise / +// Custom card on the marketing landing page to send a lead to Dan's inbox via +// the existing Microsoft Graph email service. +// +// Honeypot strategy: the form has a hidden 'fax_number' field that real users +// never see (off-screen + aria-hidden + tabindex=-1). If a submission arrives +// with that field populated, we return success to the bot but drop the +// submission silently. Combined with the rate limit applied in server.js +// (5 req/min/IP+path), this is enough friction for a low-traffic public form. + +const express = require('express'); +const router = express.Router(); +const { sendEmail } = require('../services/email'); + +function isEmail(s) { + return typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); +} +function clamp(s, max) { + return String(s || '').slice(0, max); +} + +router.post('/enterprise', async (req, res) => { + const { name, email, company, screens, multi_tenant, hosting, message, fax_number } = req.body || {}; + + // Honeypot. Real users can't see or tab to this field; only bots fill it. + // Return 200 so the bot's retry logic doesn't kick in, but skip the send. + if (fax_number && String(fax_number).trim() !== '') { + console.log(`[contact] honeypot triggered from ${req.ip}; dropping`); + return res.json({ success: true }); + } + + // Server-side validation. Client validates too but we never trust that. + if (!name || !email || !company || !screens || !multi_tenant || !hosting) { + return res.status(400).json({ error: 'Missing required fields' }); + } + if (!isEmail(email)) { + return res.status(400).json({ error: 'Invalid email address' }); + } + const screensNum = parseInt(screens); + if (!Number.isFinite(screensNum) || screensNum < 1 || screensNum > 100000) { + return res.status(400).json({ error: 'Screens must be a positive number' }); + } + if (!['single', 'multi'].includes(multi_tenant)) { + return res.status(400).json({ error: 'Invalid multi-tenant selection' }); + } + if (!['hosted', 'self', 'unsure'].includes(hosting)) { + return res.status(400).json({ error: 'Invalid hosting selection' }); + } + + // Length caps - keeps a 10MB textarea from filling the mailbox + const cleanName = clamp(name, 200); + const cleanEmail = clamp(email, 200); + const cleanCompany = clamp(company, 200); + const cleanMessage = clamp(message, 5000); + + const tenantLabel = multi_tenant === 'multi' ? 'Multiple organizations' : 'Single organization'; + const hostingLabel = { hosted: 'Hosted for me', self: 'Self-host', unsure: 'Not sure yet' }[hosting]; + + const subject = `Enterprise inquiry: ${cleanCompany}`; + const text = +`New enterprise inquiry from ${cleanName} (${cleanEmail}) + +Company: ${cleanCompany} +Estimated screens: ${screensNum} +Multi-tenant: ${tenantLabel} +Hosting preference: ${hostingLabel} + +Message: +${cleanMessage || '(none)'} + +--- +Submitted from screentinker.com pricing page +Source IP: ${req.ip} +`; + + const result = await sendEmail({ to: 'dan@bytetinker.net', subject, text }); + if (!result.sent) { + console.error(`[contact] email send failed for ${cleanEmail}: reason=${result.reason} error=${result.error || ''}`); + return res.status(500).json({ error: 'Could not send your message. Please email dan@bytetinker.net directly.' }); + } + console.log(`[contact] enterprise inquiry from ${cleanEmail} (${cleanCompany}) delivered`); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/server/server.js b/server/server.js index cc60142..22fab0e 100644 --- a/server/server.js +++ b/server/server.js @@ -234,6 +234,11 @@ app.use('/api/content', rateLimit(60000, 30)); // 30 content operations per minu // Subscription routes (mixed auth) app.use('/api/subscription', require('./routes/subscription')); +// Public contact form (enterprise inquiries from landing page). Rate limited +// to 5 submissions per minute per IP; honeypot enforced inside the route. +app.use('/api/contact', rateLimit(60000, 5)); +app.use('/api/contact', require('./routes/contact')); + // Stripe billing routes (checkout, portal) app.use('/api/stripe', stripeRouter);