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);