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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-05-14 13:52:24 -05:00
parent f5ca26ae2d
commit 8439f2bf18
3 changed files with 254 additions and 2 deletions

View file

@ -85,6 +85,24 @@
.platform-item .icon { font-size:40px; margin-bottom:8px; } .platform-item .icon { font-size:40px; margin-bottom:8px; }
.platform-item .name { font-size:13px; color:var(--muted); } .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 */
.pricing { max-width:1200px; margin:0 auto; padding:80px 24px; } .pricing { max-width:1200px; margin:0 auto; padding:80px 24px; }
.pricing h2 { text-align:center; font-size:36px; margin-bottom:12px; } .pricing h2 { text-align:center; font-size:36px; margin-bottom:12px; }
@ -376,6 +394,56 @@
</div> </div>
</div> </div>
<!-- Enterprise contact form modal. Opened by the Enterprise / Custom card's
Contact Us button. Submits to POST /api/contact/enterprise which sends
the inquiry via Microsoft Graph to dan@bytetinker.net. -->
<div class="modal-overlay" id="contactModal" style="display:none">
<div class="modal" role="dialog" aria-labelledby="contactModalTitle" aria-modal="true">
<div class="modal-header">
<h3 id="contactModalTitle">Enterprise Inquiry</h3>
<button type="button" class="modal-close" onclick="closeContactModal()" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p class="modal-description">Tell us about your deployment and we'll get back to you within one business day.</p>
<form id="contactForm" novalidate>
<label>Name *<input name="name" type="text" required autocomplete="name" maxlength="200"></label>
<label>Email *<input name="email" type="email" required autocomplete="email" maxlength="200"></label>
<label>Company / organization *<input name="company" type="text" required autocomplete="organization" maxlength="200"></label>
<label>Estimated number of screens *<input name="screens" type="number" min="1" max="100000" required></label>
<label>Multi-tenant? *
<select name="multi_tenant" required>
<option value="">Select one</option>
<option value="single">Single organization</option>
<option value="multi">Multiple organizations / managing screens for multiple clients</option>
</select>
</label>
<label>Hosting preference *
<select name="hosting" required>
<option value="">Select one</option>
<option value="hosted">Hosted for me</option>
<option value="self">Self-host</option>
<option value="unsure">Not sure yet</option>
</select>
</label>
<label>Message<textarea name="message" rows="4" maxlength="5000"></textarea></label>
<!-- Honeypot: hidden from real users (off-screen + aria-hidden +
tabindex=-1), bots fill anything they see. If this comes back
populated, the server returns success but drops the submission.
Field name 'fax_number' is plausible enough to fool mid-tier
bots that explicitly skip the obvious 'website' name. -->
<div style="position:absolute;left:-9999px" aria-hidden="true">
<label>Fax number<input name="fax_number" type="text" tabindex="-1" autocomplete="off"></label>
</div>
<div id="contactFormStatus" style="min-height:20px;margin-top:8px"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" onclick="closeContactModal()">Cancel</button>
<button type="button" class="btn btn-primary" id="contactSubmitBtn" onclick="submitContactForm()">Send</button>
</div>
</div>
</div>
<!-- Structured Data for Google --> <!-- Structured Data for Google -->
<script type="application/ld+json"> <script type="application/ld+json">
{ {
@ -495,10 +563,18 @@
</script> </script>
<script> <script>
// Load pricing from API // Load pricing from API. The 'enterprise' plan is filtered out of the
// public render because its DB row has price=0 / max=-1 (renders as
// "Free" + "Unlimited") which would undercut the actual Free tier and
// confuse visitors. The row itself stays in the DB - it's actively used
// for self-hosted first-user assignment and white-label gating - we just
// replace it on the public marketing page with a hardcoded Contact Us
// card. Other consumers of /api/subscription/plans (billing.js,
// settings.js, admin.js) get the full list as before.
fetch('/api/subscription/plans').then(r => r.json()).then(plans => { fetch('/api/subscription/plans').then(r => r.json()).then(plans => {
const grid = document.getElementById('pricingGrid'); const grid = document.getElementById('pricingGrid');
grid.innerHTML = plans.filter(p => p.active).map((p, i) => ` const publicPlans = plans.filter(p => p.active && p.name !== 'enterprise');
const planCardsHtml = publicPlans.map((p, i) => `
<div class="price-card ${i === 2 ? 'featured' : ''}"> <div class="price-card ${i === 2 ? 'featured' : ''}">
<h3>${p.display_name}</h3> <h3>${p.display_name}</h3>
<div class="price">${p.price_monthly > 0 ? '$' + p.price_monthly : 'Free'}<span>${p.price_monthly > 0 ? '/mo' : ''}</span></div> <div class="price">${p.price_monthly > 0 ? '$' + p.price_monthly : 'Free'}<span>${p.price_monthly > 0 ? '/mo' : ''}</span></div>
@ -513,6 +589,25 @@
<a href="/app#/login" class="btn ${i === 0 ? 'btn-outline' : 'btn-primary'}" style="width:100%;justify-content:center">${p.price_monthly > 0 ? 'Start Trial' : 'Get Started'}</a> <a href="/app#/login" class="btn ${i === 0 ? 'btn-outline' : 'btn-primary'}" style="width:100%;justify-content:center">${p.price_monthly > 0 ? 'Start Trial' : 'Get Started'}</a>
</div> </div>
`).join(''); `).join('');
// Hardcoded Enterprise / Custom card. Uses .price class for vertical
// alignment with the other cards (same baseline for the feature list);
// empty .yearly spacer matches the Free card's structure.
const enterpriseCardHtml = `
<div class="price-card">
<h3>Enterprise / Custom</h3>
<div class="price">Let's talk</div>
<div class="yearly">&nbsp;</div>
<ul>
<li>Everything in Business</li>
<li>Multi-tenant / multiple organizations</li>
<li>Volume device pricing</li>
<li>Custom hosting options</li>
<li>Priority support</li>
</ul>
<button type="button" class="btn btn-primary" style="width:100%;justify-content:center" onclick="openContactModal()">Contact Us</button>
</div>
`;
grid.innerHTML = planCardsHtml + enterpriseCardHtml;
}); });
// Smooth scroll for anchor links // Smooth scroll for anchor links
@ -523,6 +618,73 @@
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); } if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); }
}); });
}); });
// Contact modal: open/close, submit, validation. Functions are attached
// to window so the inline onclick handlers on the Contact Us button (in
// the dynamically-rendered pricing card) can find them.
window.openContactModal = function() {
document.getElementById('contactModal').style.display = 'flex';
setTimeout(() => document.querySelector('#contactForm input[name="name"]')?.focus(), 50);
};
window.closeContactModal = function() {
document.getElementById('contactModal').style.display = 'none';
const status = document.getElementById('contactFormStatus');
status.className = '';
status.textContent = '';
};
window.submitContactForm = async function() {
const form = document.getElementById('contactForm');
const status = document.getElementById('contactFormStatus');
const btn = document.getElementById('contactSubmitBtn');
status.className = '';
status.textContent = '';
const data = Object.fromEntries(new FormData(form).entries());
const required = ['name', 'email', 'company', 'screens', 'multi_tenant', 'hosting'];
for (const f of required) {
if (!data[f] || String(data[f]).trim() === '') {
status.className = 'contact-status-error';
status.textContent = 'Please fill all required fields.';
return;
}
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
status.className = 'contact-status-error';
status.textContent = 'Please enter a valid email address.';
return;
}
btn.disabled = true;
try {
const res = await fetch('/api/contact/enterprise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.ok) {
status.className = 'contact-status-success';
status.textContent = "Thanks! We'll be in touch within one business day.";
form.reset();
setTimeout(() => window.closeContactModal(), 3000);
} else {
const err = await res.json().catch(() => ({}));
status.className = 'contact-status-error';
status.textContent = err.error || "Sorry, something went wrong. Try emailing dan@bytetinker.net directly.";
}
} catch (e) {
status.className = 'contact-status-error';
status.textContent = "Network error. Try emailing dan@bytetinker.net directly.";
} finally {
btn.disabled = false;
}
};
// Close on background click + Escape
document.getElementById('contactModal').addEventListener('click', e => {
if (e.target.id === 'contactModal') window.closeContactModal();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('contactModal').style.display === 'flex') {
window.closeContactModal();
}
});
</script> </script>
</body> </body>
</html> </html>

85
server/routes/contact.js Normal file
View file

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

View file

@ -234,6 +234,11 @@ app.use('/api/content', rateLimit(60000, 30)); // 30 content operations per minu
// Subscription routes (mixed auth) // Subscription routes (mixed auth)
app.use('/api/subscription', require('./routes/subscription')); 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) // Stripe billing routes (checkout, portal)
app.use('/api/stripe', stripeRouter); app.use('/api/stripe', stripeRouter);