mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
f5ca26ae2d
commit
8439f2bf18
|
|
@ -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">×</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"> </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
85
server/routes/contact.js
Normal 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;
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue