mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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>
86 lines
3.4 KiB
JavaScript
86 lines
3.4 KiB
JavaScript
// 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;
|