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>
Old invite replaced with current permanent invite across README,
landing page, and anywhere else it appeared.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Pro plan is \$99/mo flat, so 15 devices for a year = \$1,188. The
landing page's compare table mistakenly showed \$989, which would imply
\$82.42/mo and contradicts every other place the price is quoted (the
comparison pages, the demo video, the pricing cards).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Landing page (frontend/landing.html):
- Title now includes "Self-Hosted" for that keyword
- Description appended "MIT licensed."
- Keywords aligned to spec (digital signage raspberry pi, digital
signage android tv, video wall software, kiosk software, etc.)
- SoftwareApplication JSON-LD: added applicationSubCategory
"DigitalSignage", license URL, refreshed description
- Image alt text + og:image:alt + twitter:image:alt now include
"open-source digital signage"
- New Resources section above the CTA with 6 cards linking to all
new guides and comparison pages
- Footer rewritten as a 5-column grid (Brand / Guides / Compare /
Project / Legal) with the new internal links
New SEO pages, all dark-themed, mobile-responsive, ASCII-only:
- frontend/css/seo-page.css (shared nav/footer/article/table styles)
- frontend/compare/yodeck-alternative.html
- frontend/compare/screencloud-alternative.html
- frontend/compare/optisigns-alternative.html
- frontend/guides/raspberry-pi-digital-signage.html
- frontend/guides/digital-signage-android-tv.html
- frontend/guides/self-hosted-digital-signage.html
Each new page has unique title/description/canonical, OG and Twitter
card tags, BreadcrumbList JSON-LD, single h1, proper h2/h3 nesting,
visible breadcrumb, comparison table or step-by-step ordered list,
"Related guides" cross-link block, and a CTA.
Sitemap (frontend/sitemap.xml): added all 6 new URLs with appropriate
priority (0.8 for compare pages, 0.9 for guides). Existing landing
(1.0) and legal pages preserved.
Robots (frontend/robots.txt): allow /compare/ and /guides/, disallow
/player (was previously allowed by mistake).
Server (server/server.js): added explicit GET /sitemap.xml and
GET /robots.txt routes ahead of the static middleware so the
Content-Type is guaranteed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- README + landing page footer now link to the community Discord
- Landing page feature grid gains Playlists, Directory Board,
Offline Resilience, and Mobile Dashboard cards; Scheduling and
Self-Hosted copy updated to mention group-level schedules and
the DISABLE_REGISTRATION env var
- Structured data featureList expanded to match; Organization
sameAs now includes Discord
- README feature list clarifies scheduling precedence, mobile
responsiveness scope, and the auth/IDOR/XSS audit work
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Retarget primary keywords ("open-source", "CMS") in title, description,
OG/Twitter tags and hero h1
- Swap OG/Twitter image from icon-512 to dashboard-preview.png with
width/height/alt metadata
- Add GitHub link in nav (icon), hero (secondary btn), footer, and a
new Open Source callout section
- Wrap content in <main> landmark; add width/height on screenshot for
CLS; add third-party license page to sitemap; Organization schema
sameAs now points to the GitHub repo
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Swaps the live-app iframe for a static PNG of the Displays view.
Faster load, no auth flash, looks sharp.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removes the far-right floating position; Sign In sits in the nav
cluster alongside the CTA instead of pinned to the viewport edge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign In now lives outside the nav-links cluster with margin-left:auto,
pinning it to the top-right corner with visible separation from the
primary CTA.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pixel 8 Pro portrait (~412px) was clipping Sign In because logo + both
buttons overflowed. Hide logo text below 420px, shorten 'Start Free Trial'
to 'Try Free' on mobile, nowrap nav-links with tighter padding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously hidden behind the primary CTA; now shows alongside it with
tighter padding on small screens.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Login view:
- Remove `margin-left: calc(-1 * var(--sidebar-width))` from the
centering wrapper. It was a hack to compensate for the sidebar
offset, but app.js already zeros the app margin on the login
route. On mobile this was pushing the login card ~240px off
the left edge of the viewport.
- Use min-height + padding so the card breathes on short screens.
- Drop inline font-size:11px on the support-token input so the
global .input 16px mobile rule applies (iOS focus-zoom prevention).
app.js:
- Hide the mobile hamburger button on the login route; it has no
function there since the sidebar is already hidden.
Landing page:
- Scope the old blanket `.nav-links { display: none }` to hide only
the section anchors + secondary Sign In button, so the primary
"Start Free Trial" CTA stays visible on mobile.
- Wrap the 5-column Compare table in a horizontal-scroll container
and set min-width:560px so it scrolls instead of overflowing
the page.
- Add min-height:44px to .btn on mobile, tighten section padding
to 16px (from 24px) so content doesn't feel cramped against
the viewport edge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>