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>
691 lines
44 KiB
HTML
691 lines
44 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
<!-- Primary SEO -->
|
|
<title>ScreenTinker - Self-Hosted Open-Source Digital Signage CMS | Free Display Management</title>
|
|
<meta name="description" content="Open-source digital signage CMS. Free plan, self-host or cloud. Manage TVs, video walls, kiosks, and schedules across Android, Raspberry Pi, Windows, and any browser. MIT licensed.">
|
|
<meta name="keywords" content="digital signage, open source digital signage, self hosted signage, digital signage cms, free digital signage software, digital signage raspberry pi, digital signage android tv, video wall software, kiosk software, screen management">
|
|
<meta name="author" content="ScreenTinker">
|
|
<meta name="robots" content="index, follow">
|
|
<link rel="canonical" href="https://screentinker.com/">
|
|
|
|
<!-- Open Graph / Facebook -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://screentinker.com/">
|
|
<meta property="og:title" content="ScreenTinker - Open-Source Digital Signage CMS">
|
|
<meta property="og:description" content="Open-source digital signage CMS. Free plan, self-host or cloud. Manage TVs, video walls, kiosks, and schedules across 9 platforms.">
|
|
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
|
<meta property="og:image:width" content="2500">
|
|
<meta property="og:image:height" content="1314">
|
|
<meta property="og:image:alt" content="ScreenTinker open-source digital signage dashboard showing four online displays with playlist assignments">
|
|
<meta property="og:site_name" content="ScreenTinker">
|
|
|
|
<!-- Twitter -->
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="ScreenTinker - Open-Source Digital Signage CMS">
|
|
<meta name="twitter:description" content="Open-source digital signage CMS. Free plan, self-host or cloud. Video walls, kiosks, scheduling, and live remote control across 9 platforms.">
|
|
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
|
<meta name="twitter:image:alt" content="ScreenTinker open-source digital signage dashboard showing four online displays with playlist assignments">
|
|
|
|
<!-- Theme -->
|
|
<meta name="theme-color" content="#111827">
|
|
<link rel="icon" href="/assets/icon-192.png">
|
|
<link rel="apple-touch-icon" href="/assets/icon-192.png">
|
|
<style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
:root { --accent:#3b82f6; --bg:#111827; --card:#1e293b; --border:#334155; --text:#f1f5f9; --muted:#94a3b8; --dim:#64748b; }
|
|
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:var(--bg); color:var(--text); line-height:1.6; }
|
|
a { color:var(--accent); text-decoration:none; }
|
|
|
|
/* Nav */
|
|
nav { position:fixed; top:0; left:0; right:0; z-index:100; background:rgba(17,24,39,0.9); backdrop-filter:blur(12px); border-bottom:1px solid var(--border); }
|
|
.nav-inner { max-width:1200px; margin:0 auto; padding:16px 24px; display:flex; align-items:center; justify-content:space-between; }
|
|
.nav-logo { display:flex; align-items:center; gap:10px; font-weight:700; font-size:18px; color:var(--accent); flex-shrink:0; }
|
|
.nav-links a { color:var(--muted); margin-left:24px; font-size:14px; transition:color 0.2s; }
|
|
.nav-links a:hover { color:var(--text); }
|
|
.btn { display:inline-flex; align-items:center; gap:8px; padding:10px 20px; border-radius:8px; font-weight:600; font-size:14px; transition:all 0.2s; border:none; cursor:pointer; }
|
|
.btn-primary { background:var(--accent); color:white; }
|
|
.btn-primary:hover { background:#2563eb; }
|
|
.btn-outline { background:transparent; color:var(--accent); border:1px solid var(--accent); }
|
|
.btn-outline:hover { background:rgba(59,130,246,0.1); }
|
|
|
|
/* Hero */
|
|
.hero { padding:140px 24px 80px; text-align:center; max-width:900px; margin:0 auto; }
|
|
.hero h1 { font-size:clamp(36px,5vw,64px); font-weight:800; line-height:1.1; margin-bottom:20px; }
|
|
.hero h1 span { background:linear-gradient(135deg,#3b82f6,#8b5cf6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
|
.hero p { font-size:clamp(16px,2vw,20px); color:var(--muted); max-width:600px; margin:0 auto 32px; }
|
|
.hero-btns { display:flex; gap:12px; justify-content:center; flex-wrap:wrap; }
|
|
.hero-badge { display:inline-block; background:var(--card); border:1px solid var(--border); border-radius:20px; padding:6px 16px; font-size:13px; color:var(--muted); margin-bottom:24px; }
|
|
|
|
/* Screenshot */
|
|
.screenshot { max-width:1100px; margin:0 auto 80px; padding:0 24px; }
|
|
.screenshot img, .screenshot .mock { width:100%; border-radius:12px; border:1px solid var(--border); box-shadow:0 20px 60px rgba(0,0,0,0.5); }
|
|
.mock { background:var(--card); aspect-ratio:16/9; display:flex; align-items:center; justify-content:center; color:var(--dim); font-size:18px; }
|
|
|
|
/* Features */
|
|
.features { max-width:1200px; margin:0 auto; padding:80px 24px; }
|
|
.features h2 { text-align:center; font-size:36px; margin-bottom:12px; }
|
|
.features .subtitle { text-align:center; color:var(--muted); margin-bottom:48px; font-size:18px; }
|
|
.feature-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:24px; }
|
|
.feature-card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:28px; transition:border-color 0.2s; }
|
|
.feature-card:hover { border-color:var(--accent); }
|
|
.feature-icon { font-size:32px; margin-bottom:12px; }
|
|
.feature-card h3 { font-size:18px; margin-bottom:8px; }
|
|
.feature-card p { color:var(--muted); font-size:14px; }
|
|
|
|
/* Platforms */
|
|
.platforms { max-width:1200px; margin:0 auto; padding:80px 24px; text-align:center; }
|
|
.platforms h2 { font-size:36px; margin-bottom:12px; }
|
|
.platforms .subtitle { color:var(--muted); margin-bottom:40px; font-size:18px; }
|
|
.platform-grid { display:flex; justify-content:center; gap:32px; flex-wrap:wrap; }
|
|
.platform-item { text-align:center; width:100px; }
|
|
.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; }
|
|
.pricing .subtitle { text-align:center; color:var(--muted); margin-bottom:48px; font-size:18px; }
|
|
.pricing-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:20px; }
|
|
.price-card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:28px; position:relative; }
|
|
.price-card.featured { border-color:var(--accent); }
|
|
.price-card.featured::before { content:'Most Popular'; position:absolute; top:-12px; left:50%; transform:translateX(-50%); background:var(--accent); color:white; padding:4px 16px; border-radius:12px; font-size:12px; font-weight:600; }
|
|
.price-card h3 { font-size:20px; margin-bottom:4px; }
|
|
.price-card .price { font-size:36px; font-weight:700; color:var(--accent); margin:12px 0; }
|
|
.price-card .price span { font-size:16px; color:var(--muted); font-weight:400; }
|
|
.price-card .yearly { font-size:12px; color:var(--dim); margin-bottom:16px; }
|
|
.price-card ul { list-style:none; margin-bottom:20px; }
|
|
.price-card li { font-size:14px; color:var(--muted); padding:4px 0; }
|
|
.price-card li::before { content:'✓ '; color:var(--accent); }
|
|
|
|
/* Compare */
|
|
.compare { max-width:1000px; margin:0 auto; padding:80px 24px; }
|
|
.compare h2 { text-align:center; font-size:36px; margin-bottom:40px; }
|
|
.compare-table { width:100%; border-collapse:collapse; font-size:14px; }
|
|
.compare-table th, .compare-table td { padding:12px 16px; text-align:left; border-bottom:1px solid var(--border); }
|
|
.compare-table th { color:var(--dim); font-weight:500; }
|
|
.compare-table td:first-child { color:var(--muted); }
|
|
.compare-table .yes { color:#22c55e; }
|
|
.compare-table .no { color:#ef4444; }
|
|
.compare-table .paid { color:#f59e0b; }
|
|
|
|
/* CTA */
|
|
.cta { text-align:center; padding:80px 24px; background:linear-gradient(135deg,rgba(59,130,246,0.1),rgba(139,92,246,0.1)); border-top:1px solid var(--border); border-bottom:1px solid var(--border); margin:80px 0; }
|
|
.cta h2 { font-size:36px; margin-bottom:12px; }
|
|
.cta p { color:var(--muted); margin-bottom:24px; font-size:18px; }
|
|
|
|
/* Footer */
|
|
footer { max-width:1200px; margin:0 auto; padding:40px 24px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:16px; border-top:1px solid var(--border); }
|
|
footer .links a { color:var(--dim); margin-left:16px; font-size:13px; }
|
|
|
|
/* Horizontal-scroll wrapper for wide tables on mobile */
|
|
.table-scroll { width:100%; overflow-x:auto; -webkit-overflow-scrolling:touch; }
|
|
|
|
.nav-links { display:flex; align-items:center; flex-wrap:nowrap; }
|
|
.btn-short { display:none; }
|
|
@media (max-width:768px) {
|
|
/* Hide section anchor links on mobile; keep Try Free + Sign In */
|
|
.nav-links a:not(.btn) { display:none; }
|
|
.nav-inner { padding:12px 14px; gap:8px; }
|
|
.nav-links .btn { padding:8px 12px; font-size:13px; margin-left:8px !important; flex-shrink:0; min-height:0; }
|
|
.btn-full { display:none; }
|
|
.btn-short { display:inline; }
|
|
}
|
|
@media (max-width:420px) {
|
|
.nav-logo-text { display:none; }
|
|
}
|
|
|
|
.feature-grid { grid-template-columns:1fr; }
|
|
.pricing-grid { grid-template-columns:1fr; }
|
|
.compare-table { font-size:12px; min-width:560px; }
|
|
.compare-table th, .compare-table td { padding:8px; }
|
|
footer { flex-direction:column; text-align:center; }
|
|
|
|
/* 44px tap targets for all buttons on mobile */
|
|
.btn { min-height:44px; }
|
|
.hero { padding:110px 16px 60px; }
|
|
.features, .platforms, .pricing, .compare { padding:60px 16px; }
|
|
.cta { padding:60px 16px; }
|
|
.screenshot { padding:0 16px; margin-bottom:60px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<div class="nav-inner">
|
|
<div class="nav-logo">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
|
<span class="nav-logo-text">ScreenTinker</span>
|
|
</div>
|
|
<div class="nav-links">
|
|
<a href="#features">Features</a>
|
|
<a href="#platforms">Platforms</a>
|
|
<a href="#pricing">Pricing</a>
|
|
<a href="#compare">Compare</a>
|
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" aria-label="ScreenTinker on GitHub" class="nav-github" style="margin-left:16px;display:inline-flex;align-items:center;color:var(--muted)">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.4 3.6 1 .1-.8.4-1.4.8-1.7-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2a11 11 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3"/></svg>
|
|
</a>
|
|
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
|
|
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px"><span class="btn-full">Start Free Trial</span><span class="btn-short">Try Free</span></a>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main>
|
|
<!-- Hero -->
|
|
<section class="hero">
|
|
<div class="hero-badge">⚡ Open source · Free plan · 14-day Pro trial, no credit card</div>
|
|
<h1>Open-Source Digital Signage<br>for <span>Any Screen</span></h1>
|
|
<p>Manage content on TVs, displays, and kiosks from anywhere. Remote control, video walls, scheduling, and analytics. Self-host or use our managed cloud.</p>
|
|
<div class="hero-btns">
|
|
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free Trial</a>
|
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="margin-right:2px"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.4 3.6 1 .1-.8.4-1.4.8-1.7-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2a11 11 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3"/></svg>
|
|
View on GitHub
|
|
</a>
|
|
<a href="#compare" class="btn btn-outline" style="padding:14px 28px;font-size:16px">See How We Compare</a>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Intro video -->
|
|
<div style="max-width:800px;margin:32px auto;border-radius:12px;overflow:hidden;border:1px solid var(--border)">
|
|
<div style="position:relative;padding-bottom:56.25%;height:0">
|
|
<iframe src="https://www.youtube.com/embed/Sq3RbnuDgKw?rel=0" title="ScreenTinker - Open Source Digital Signage for Any Screen" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen style="position:absolute;top:0;left:0;width:100%;height:100%"></iframe>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dashboard preview -->
|
|
<div class="screenshot">
|
|
<img src="/assets/dashboard-preview.png" alt="ScreenTinker open-source digital signage dashboard showing 4 online displays with playlist assignments" loading="lazy" width="2500" height="1314">
|
|
</div>
|
|
|
|
<!-- Open Source callout -->
|
|
<section class="opensource" id="opensource" style="max-width:900px;margin:0 auto;padding:40px 24px;text-align:center">
|
|
<h2 style="font-size:28px;margin-bottom:12px">Fully Open Source</h2>
|
|
<p style="color:var(--muted);font-size:16px;margin-bottom:20px">MIT licensed. Self-host on your own infrastructure or use our managed cloud. Your data, your rules.</p>
|
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="display:inline-flex;align-items:center;gap:8px">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.4 3.6 1 .1-.8.4-1.4.8-1.7-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2a11 11 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3"/></svg>
|
|
github.com/screentinker/screentinker
|
|
</a>
|
|
</section>
|
|
|
|
<!-- Features -->
|
|
<section class="features" id="features">
|
|
<h2>Everything You Need</h2>
|
|
<p class="subtitle">One platform to manage all your digital signage</p>
|
|
<div class="feature-grid">
|
|
<div class="feature-card"><div class="feature-icon">📺</div><h3>Multi-Zone Layouts</h3><p>Split screens into zones with a drag-and-drop editor. 7 built-in templates or create your own.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🎬</div><h3>Video Wall</h3><p>Combine multiple displays into one giant screen. Automatic bezel compensation. Any grid size.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🖥</div><h3>Remote Control</h3><p>See what's on screen in real-time. Send key presses, navigate menus, power on/off remotely.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">📅</div><h3>Scheduling</h3><p>Visual weekly calendar with recurrence rules. Schedule per device or for whole groups — device rules override the group.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🎶</div><h3>Playlists</h3><p>Share one playlist across many displays. Draft changes, preview, then publish — or revert to the last published version.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🏢</div><h3>Directory Board</h3><p>Scrolling lobby, building, and staff directories. Categories, dark & light themes, anti-burn-in. Not found in any other open-source CMS.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🔧</div><h3>Content Designer</h3><p>Built-in editor with live clocks, weather, RSS tickers, countdowns, QR codes, and more.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🖱</div><h3>Kiosk Mode</h3><p>Create interactive touchscreen interfaces. Wayfinding, directories, check-in screens.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">📊</div><h3>Proof-of-Play</h3><p>Track what played, when, and on which device. Export CSV reports for ad verification.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🔔</div><h3>Alerts & Monitoring</h3><p>Email alerts when devices go offline. Full telemetry: battery, storage, WiFi, uptime.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🔌</div><h3>Offline Resilience</h3><p>Displays keep playing cached content when the internet or your server drops. They catch up automatically when you're back.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">📱</div><h3>Mobile Dashboard</h3><p>Manage everything from your phone. The full dashboard works on any mobile browser — no separate app needed.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🔒</div><h3>Self-Hosted Option</h3><p>Deploy on your own infrastructure. Your data never leaves your network. Lock down signups with a single env var.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🎨</div><h3>White Label</h3><p>Custom branding, colors, logo, and domain. Resell under your own brand.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">👥</div><h3>Teams</h3><p>Multi-user accounts with owner, editor, and viewer roles. Invite by email.</p></div>
|
|
<div class="feature-card"><div class="feature-icon">🔄</div><h3>Auto-Update</h3><p>Devices automatically update when you push a new version. Zero manual intervention.</p></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Platforms -->
|
|
<section class="platforms" id="platforms">
|
|
<h2>Runs on Everything</h2>
|
|
<p class="subtitle">No hardware lock-in. Use any screen you already have.</p>
|
|
<div class="platform-grid">
|
|
<div class="platform-item"><div class="icon">🤖</div><div class="name">Android TV</div></div>
|
|
<div class="platform-item"><div class="icon">🔥</div><div class="name">Fire TV</div></div>
|
|
<div class="platform-item"><div class="icon">🥏</div><div class="name">Raspberry Pi</div></div>
|
|
<div class="platform-item"><div class="icon">💻</div><div class="name">Windows</div></div>
|
|
<div class="platform-item"><div class="icon">🌐</div><div class="name">ChromeOS</div></div>
|
|
<div class="platform-item"><div class="icon">📺</div><div class="name">LG webOS</div></div>
|
|
<div class="platform-item"><div class="icon">📺</div><div class="name">Samsung Tizen</div></div>
|
|
<div class="platform-item"><div class="icon">🌎</div><div class="name">Any Browser</div></div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Pricing -->
|
|
<section class="pricing" id="pricing">
|
|
<h2>Simple, Honest Pricing</h2>
|
|
<p class="subtitle">All plans include remote control, monitoring, and unlimited content</p>
|
|
<div class="pricing-grid" id="pricingGrid"></div>
|
|
</section>
|
|
|
|
<!-- Compare -->
|
|
<section class="compare" id="compare">
|
|
<h2>How We Compare</h2>
|
|
<div class="table-scroll">
|
|
<table class="compare-table">
|
|
<thead><tr><th></th><th style="color:var(--accent);font-weight:700">ScreenTinker</th><th>Yodeck</th><th>ScreenCloud</th><th>OptiSigns</th></tr></thead>
|
|
<tbody>
|
|
<tr><td>Price (15 devices/yr)</td><td style="color:var(--accent);font-weight:600">$1,188</td><td>$1,440+</td><td>$3,600+</td><td>$1,800+</td></tr>
|
|
<tr><td>Free tier</td><td class="yes">✓ 1 device</td><td class="yes">✓</td><td class="no">✗</td><td class="yes">✓</td></tr>
|
|
<tr><td>Platforms</td><td class="yes">9 platforms</td><td>2</td><td>2</td><td>3</td></tr>
|
|
<tr><td>Video Wall</td><td class="yes">✓ Included</td><td class="no">✗</td><td class="no">✗</td><td class="paid">Paid add-on</td></tr>
|
|
<tr><td>Remote Control</td><td class="yes">✓ All plans</td><td class="paid">Paid add-on</td><td class="no">✗</td><td class="no">✗</td></tr>
|
|
<tr><td>Content Designer</td><td class="yes">✓ Built-in</td><td class="no">✗</td><td class="no">✗</td><td class="no">✗</td></tr>
|
|
<tr><td>Kiosk/Touchscreen</td><td class="yes">✓ Included</td><td class="no">✗</td><td class="no">✗</td><td class="no">✗</td></tr>
|
|
<tr><td>Proof-of-Play</td><td class="yes">✓ All plans</td><td class="paid">Paid tier</td><td class="paid">Paid</td><td class="paid">Paid</td></tr>
|
|
<tr><td>Self-Hosted</td><td class="yes">✓ Only us</td><td class="no">✗</td><td class="no">✗</td><td class="no">✗</td></tr>
|
|
<tr><td>White Label</td><td class="yes">✓ Included</td><td class="paid">Paid</td><td class="paid">Enterprise</td><td class="no">✗</td></tr>
|
|
<tr><td>Email Alerts</td><td class="yes">✓ All plans</td><td class="paid">Paid</td><td class="paid">Paid</td><td class="paid">Paid</td></tr>
|
|
<tr><td>Hardware Lock-in</td><td class="yes">None</td><td>RPi focused</td><td>Chromecast</td><td>Various</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Resources / internal linking for SEO -->
|
|
<section class="resources" id="resources" style="max-width:1000px;margin:0 auto;padding:60px 24px;">
|
|
<h2 style="text-align:center;font-size:32px;margin-bottom:8px">Resources</h2>
|
|
<p style="text-align:center;color:var(--muted);margin-bottom:32px">Setup guides and honest comparisons.</p>
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px">
|
|
<a href="/guides/raspberry-pi-digital-signage.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
|
|
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Guide</div>
|
|
<div style="font-weight:600;margin-bottom:4px">How to set up digital signage on Raspberry Pi</div>
|
|
<div style="font-size:13px;color:var(--muted)">Hardware, OS, install script, pairing.</div>
|
|
</a>
|
|
<a href="/guides/digital-signage-android-tv.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
|
|
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Guide</div>
|
|
<div style="font-weight:600;margin-bottom:4px">Free digital signage for Android TV & Fire TV</div>
|
|
<div style="font-size:13px;color:var(--muted)">APK sideload, kiosk mode, hardware tips.</div>
|
|
</a>
|
|
<a href="/guides/self-hosted-digital-signage.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
|
|
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Guide</div>
|
|
<div style="font-weight:600;margin-bottom:4px">Self-hosted digital signage guide</div>
|
|
<div style="font-size:13px;color:var(--muted)">Sizing, deploy, TLS, backups.</div>
|
|
</a>
|
|
<a href="/compare/yodeck-alternative.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
|
|
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Compare</div>
|
|
<div style="font-weight:600;margin-bottom:4px">ScreenTinker vs Yodeck</div>
|
|
<div style="font-size:13px;color:var(--muted)">Features, pricing, platform support.</div>
|
|
</a>
|
|
<a href="/compare/screencloud-alternative.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
|
|
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Compare</div>
|
|
<div style="font-weight:600;margin-bottom:4px">ScreenTinker vs ScreenCloud</div>
|
|
<div style="font-size:13px;color:var(--muted)">Pricing breakdown at scale.</div>
|
|
</a>
|
|
<a href="/compare/optisigns-alternative.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
|
|
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Compare</div>
|
|
<div style="font-weight:600;margin-bottom:4px">ScreenTinker vs OptiSigns</div>
|
|
<div style="font-size:13px;color:var(--muted)">Side by side feature and price comparison.</div>
|
|
</a>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- CTA -->
|
|
<section class="cta">
|
|
<h2>Ready to Get Started?</h2>
|
|
<p>14-day free Pro trial. No credit card required. Set up in under 5 minutes.</p>
|
|
<a href="/app#/login" class="btn btn-primary" style="padding:14px 32px;font-size:16px">Start Free Trial</a>
|
|
</section>
|
|
</main>
|
|
|
|
<!-- Footer -->
|
|
<div style="border-top:1px solid var(--border);background:rgba(15,23,42,0.4)">
|
|
<div style="max-width:1200px;margin:0 auto;padding:48px 24px 24px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:32px">
|
|
<div>
|
|
<div style="font-weight:700;color:var(--accent);margin-bottom:12px">ScreenTinker</div>
|
|
<div style="font-size:13px;color:var(--muted);line-height:1.6">Open-source digital signage. Free plan, self-host or cloud. MIT licensed.</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px">Guides</div>
|
|
<div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
|
|
<a href="/guides/raspberry-pi-digital-signage.html" style="color:var(--muted)">Raspberry Pi setup</a>
|
|
<a href="/guides/digital-signage-android-tv.html" style="color:var(--muted)">Android TV & Fire TV</a>
|
|
<a href="/guides/self-hosted-digital-signage.html" style="color:var(--muted)">Self-hosting guide</a>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px">Compare</div>
|
|
<div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
|
|
<a href="/compare/yodeck-alternative.html" style="color:var(--muted)">vs Yodeck</a>
|
|
<a href="/compare/screencloud-alternative.html" style="color:var(--muted)">vs ScreenCloud</a>
|
|
<a href="/compare/optisigns-alternative.html" style="color:var(--muted)">vs OptiSigns</a>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px">Project</div>
|
|
<div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
|
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" style="color:var(--muted)">GitHub</a>
|
|
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener" style="color:var(--muted)">Discord</a>
|
|
<a href="https://www.youtube.com/@ScreenTinker" target="_blank" rel="noopener" style="color:var(--muted)">YouTube</a>
|
|
<a href="/api/status" target="_blank" style="color:var(--muted)">Status</a>
|
|
<a href="/app#/login" style="color:var(--muted)">Sign in</a>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px">Legal</div>
|
|
<div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
|
|
<a href="/legal/terms.html" style="color:var(--muted)">Terms</a>
|
|
<a href="/legal/privacy.html" style="color:var(--muted)">Privacy</a>
|
|
<a href="/legal/third-party.html" style="color:var(--muted)">Licenses</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="max-width:1200px;margin:0 auto;padding:16px 24px 32px;border-top:1px solid var(--border);color:var(--dim);font-size:13px;text-align:center">
|
|
© 2026 ScreenTinker. All rights reserved.
|
|
</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 -->
|
|
<script type="application/ld+json">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "SoftwareApplication",
|
|
"name": "ScreenTinker",
|
|
"applicationCategory": "BusinessApplication",
|
|
"applicationSubCategory": "DigitalSignage",
|
|
"operatingSystem": "Android, Web, Windows, Linux, ChromeOS",
|
|
"description": "Self-hosted, open-source digital signage CMS. MIT licensed. Manage TVs, video walls, kiosks, and content schedules across Android, Raspberry Pi, Windows, ChromeOS, and any browser.",
|
|
"url": "https://screentinker.com",
|
|
"license": "https://opensource.org/license/mit",
|
|
"offers": [
|
|
{
|
|
"@type": "Offer",
|
|
"price": "0",
|
|
"priceCurrency": "USD",
|
|
"name": "Free",
|
|
"description": "1 device, 500MB storage"
|
|
},
|
|
{
|
|
"@type": "Offer",
|
|
"price": "39",
|
|
"priceCurrency": "USD",
|
|
"priceValidUntil": "2027-12-31",
|
|
"name": "Starter",
|
|
"description": "5 devices, 5GB storage, remote URL streaming"
|
|
},
|
|
{
|
|
"@type": "Offer",
|
|
"price": "99",
|
|
"priceCurrency": "USD",
|
|
"priceValidUntil": "2027-12-31",
|
|
"name": "Pro",
|
|
"description": "15 devices, 20GB storage, all features"
|
|
}
|
|
],
|
|
"aggregateRating": {
|
|
"@type": "AggregateRating",
|
|
"ratingValue": "4.8",
|
|
"ratingCount": "50"
|
|
},
|
|
"featureList": [
|
|
"Multi-zone screen layouts",
|
|
"Video wall support",
|
|
"Remote control with live view",
|
|
"Content scheduling with calendar (device and group level)",
|
|
"Playlists with draft/publish workflow",
|
|
"Directory Board widget for lobby and staff directories",
|
|
"Built-in content designer",
|
|
"Interactive kiosk/touchscreen mode",
|
|
"Proof-of-play analytics",
|
|
"Device monitoring and alerts",
|
|
"Offline resilience with cached playback",
|
|
"Mobile-responsive dashboard",
|
|
"White-label/reseller support",
|
|
"Self-hosted option",
|
|
"9 platform support",
|
|
"Auto-update OTA"
|
|
]
|
|
}
|
|
</script>
|
|
|
|
<script type="application/ld+json">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "Organization",
|
|
"name": "ScreenTinker",
|
|
"url": "https://screentinker.com",
|
|
"logo": "https://screentinker.com/assets/icon-512.png",
|
|
"sameAs": [
|
|
"https://github.com/screentinker/screentinker",
|
|
"https://discord.gg/utTdsrqq4Z"
|
|
]
|
|
}
|
|
</script>
|
|
|
|
<script type="application/ld+json">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "FAQPage",
|
|
"mainEntity": [
|
|
{
|
|
"@type": "Question",
|
|
"name": "What platforms does ScreenTinker support?",
|
|
"acceptedAnswer": {
|
|
"@type": "Answer",
|
|
"text": "ScreenTinker works on Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, and any device with a web browser."
|
|
}
|
|
},
|
|
{
|
|
"@type": "Question",
|
|
"name": "Is there a free plan?",
|
|
"acceptedAnswer": {
|
|
"@type": "Answer",
|
|
"text": "Yes, ScreenTinker offers a free plan with 1 device and 500MB of storage. New accounts also get a 14-day free trial of the Pro plan with 15 devices."
|
|
}
|
|
},
|
|
{
|
|
"@type": "Question",
|
|
"name": "Can I self-host ScreenTinker?",
|
|
"acceptedAnswer": {
|
|
"@type": "Answer",
|
|
"text": "Yes, ScreenTinker is the only digital signage platform that offers a self-hosted option. Deploy on your own infrastructure and keep all data on your network."
|
|
}
|
|
},
|
|
{
|
|
"@type": "Question",
|
|
"name": "Does ScreenTinker support video walls?",
|
|
"acceptedAnswer": {
|
|
"@type": "Answer",
|
|
"text": "Yes, you can combine multiple displays into a single video wall with automatic bezel compensation. Configure any grid size (2x2, 3x3, etc.)."
|
|
}
|
|
}
|
|
]
|
|
}
|
|
</script>
|
|
|
|
<script>
|
|
// 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 => {
|
|
const grid = document.getElementById('pricingGrid');
|
|
const publicPlans = plans.filter(p => p.active && p.name !== 'enterprise');
|
|
const planCardsHtml = publicPlans.map((p, i) => `
|
|
<div class="price-card ${i === 2 ? 'featured' : ''}">
|
|
<h3>${p.display_name}</h3>
|
|
<div class="price">${p.price_monthly > 0 ? '$' + p.price_monthly : 'Free'}<span>${p.price_monthly > 0 ? '/mo' : ''}</span></div>
|
|
${p.price_yearly > 0 ? `<div class="yearly">or $${p.price_yearly}/year (save ${Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100)}%)</div>` : '<div class="yearly"> </div>'}
|
|
<ul>
|
|
<li>${p.max_devices === -1 ? 'Unlimited' : p.max_devices} device${p.max_devices !== 1 ? 's' : ''}</li>
|
|
<li>${p.max_storage_mb === -1 ? 'Unlimited' : p.max_storage_mb >= 1024 ? (p.max_storage_mb / 1024) + ' GB' : p.max_storage_mb + ' MB'} storage</li>
|
|
<li>Remote control & live view</li>
|
|
${p.remote_url ? '<li>Remote URL streaming</li>' : ''}
|
|
${p.priority_support ? '<li>Priority support</li>' : ''}
|
|
</ul>
|
|
<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>
|
|
`).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
|
|
document.querySelectorAll('a[href^="#"]').forEach(a => {
|
|
if (a.getAttribute('href').startsWith('#/')) return; // Skip app routes
|
|
a.addEventListener('click', e => {
|
|
const target = document.querySelector(a.getAttribute('href'));
|
|
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>
|
|
</body>
|
|
</html>
|