Upload files to "/"

This commit is contained in:
2026-03-06 10:18:15 -06:00
parent 5a0916346b
commit 21004e6305
5 changed files with 1073 additions and 1 deletions
+82
View File
@@ -0,0 +1,82 @@
# QRknit — Third-Party Notices
QRknit is proprietary software. It is built using open-source components, each of which retains its original copyright and licence. The notices below satisfy the attribution requirements of those licences.
---
## Flask
Copyright © 2010 Pallets
https://flask.palletsprojects.com
Licence: BSD-3-Clause
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
---
## Werkzeug
Copyright © 2007 Pallets
https://werkzeug.palletsprojects.com
Licence: BSD-3-Clause
Same terms as Flask above.
---
## qrcode
Copyright © 2011 Lincoln Loop
https://github.com/lincolnloop/python-qrcode
Licence: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.
---
## Pillow
Copyright © 19952011 Fredrik Lundh and contributors
Copyright © 2010present Alex Clark and contributors
https://python-pillow.org
Licence: HPND (Historical Permission Notice and Disclaimer)
Permission to use, copy, modify, and distribute this software and its documentation for any purpose and without fee is hereby granted, provided that the above copyright notice appear in all copies and that the copyright notice and this permission notice appear in supporting documentation, and that the name of the author not be used in advertising or publicity pertaining to distribution of the software without specific, written prior permission.
---
## Gunicorn
Copyright © 2009 Benoit Chesneau
Copyright © 2009 Paul J. Davis
https://gunicorn.org
Licence: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.
---
## Google Fonts — Orbitron, Barlow, Share Tech Mono
Orbitron — Copyright © 2010 Matt McInerney
Barlow — Copyright © 2017 Jeremy Tribby
Share Tech Mono — Copyright © 2012 Carrois Apostrophe
https://fonts.google.com
Licence: SIL Open Font Licence 1.1 (OFL-1.1)
These fonts are used in the QRknit web interface and are served via the Google Fonts API. The OFL-1.1 licence allows free use, study, modification, and redistribution subject to the condition that the fonts are not sold on their own. Full licence text: https://openfontlicense.org
+129 -1
View File
@@ -1,2 +1,130 @@
# qrknit # QRknit — URL Shortener & QR Code Generator
A managed URL shortener with QR code generation, deep click analytics, and tag-based link organization.
- **Multi-user** — admin account seeded from env vars; admin can create/delete/manage accounts via the UI
- **Per-user isolation** — each user sees only their own links and tags; admin has its own isolated workspace separate from all other accounts
- **Organizations** — group users into orgs that share link/tag visibility and pool usage quotas under a shared plan
- **Plan tiers** — Admin / Free / Starter / Pro / Team with enforced limits on active links, monthly clicks, and analytics history window; org members inherit their org's plan
- **Scoped tags** — tags are owned by the user who creates them; org members share a tag namespace, solo users and admins each have their own isolated namespace
- **Deep analytics** — daily charts, referrer/device/browser/country breakdowns, hourly 7×24 heatmap, raw click CSV export
- **Private instance** — Pro and Team plans include a dedicated private deployment on your own infrastructure
---
## 🗺 Feature Map
### Shipped
| Feature area | What's included |
|---|---|
| **Short links** | Random & custom codes, link expiry, pinned links, one-click copy, auto-fetch page title |
| **QR codes** | QR generation for any link or URL, logo overlay, dot-shape presets, inline thumbnail, copy to clipboard |
| **Analytics** | Per-link daily click charts, referrer / device / browser / country breakdowns, hourly 7×24 heatmap, raw click CSV export |
| **Tags** | Tag links for filtering; tags are scoped per user — org members share a namespace, solo users and admins each have their own |
| **Bulk tools** | Bulk delete, bulk tag, bulk expire; CSV import & export |
| **Auth** | Session-cookie login, 30-day HttpOnly cookie, works behind Cloudflare and Nginx Proxy Manager |
| **Multi-user** | Per-user link isolation, admin user-management panel, username + password login |
| **Organizations** | Group users into orgs that share link/tag visibility and pool usage quotas under a shared plan |
| **Plan tiers** | Free / Starter / Pro / Team plans with enforced limits on active links, monthly clicks, and analytics history; org plan overrides member plans |
| **Admin tier** | Admin accounts sit above all plans with unlimited quotas and an isolated workspace; can filter into any user's links on demand |
| **Landing & portal** | Marketing landing page, SaaS pricing page, contact form, admin inbox with unread badge |
| **Deployment** | `APP_NAME` / `BASE_URL` env vars, four colour themes, single Docker container, Unraid-ready |
### Upcoming
| Feature area | What's planned |
|---|---|
| **Billing** | Stripe checkout & subscriptions, upgrade/downgrade UI, self-serve account portal |
| **Custom domains** | CNAME-based short domains, SSL provisioning, domain verification, Team tier multi-domain support |
| **Power features** | API key auth, password-protected links, UTM parameter builder, custom 404 & expired-link pages |
| **Link organisation** | Folders/groups, duplicate link, dead-link detection, per-link redirect type (301 vs 302) |
---
> **Installing?** See [INSTALL.md](INSTALL.md) for Docker, Docker Compose, and Unraid setup instructions, environment variables, and update steps.
---
## 🔌 API Reference
All write endpoints require an active session (log in via the web UI or `POST /api/auth/login`).
### Auth
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | `/api/auth/login` | — | Login — `{"username": "...", "password": "..."}`, sets session cookie; returns `plan`, `plan_limits`, `org_id` |
| POST | `/api/auth/logout` | ✓ | Clear session |
| GET | `/api/auth/me` | ✓ | Returns `{"authenticated": true, "username": "...", "is_admin": bool, "org_id": int\|null, "plan": "...", "plan_limits": {…}}` |
### Links
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | `/api/shorten` | ✓ | Create a short link |
| GET | `/api/links` | ✓ | List links — supports `?q=`, `?tag=`, `?page=`, `?per_page=`; org members see all links within their org; admin sees own links only, optionally filtered with `?user=<username>` |
| GET | `/api/links/:code` | ✓ | Link detail — includes `created_by` username |
| PATCH | `/api/links/:code` | ✓ | Edit link — `url`, `title`, `expires_at`, `tags`, `is_pinned` |
| DELETE | `/api/links/:code` | ✓ | Delete link |
| GET | `/api/links/:code/analytics` | ✓ | Click analytics — supports `?days=7\|30\|90` (capped to plan's max window); returns `daily`, `referrers`, `devices`, `browsers`, `countries`, `heatmap` (7×24 array), and `max_days` |
| GET | `/api/links/:code/clicks/export` | ✓ | Download raw click events as CSV — columns: `timestamp`, `referrer`, `device`, `browser`, `country` |
### Utilities
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | `/api/stats` | ✓ | Total links, total clicks, clicks/7d, top 5 links, and a 30-day `daily` click array for the dashboard chart |
| GET | `/api/plan` | ✓ | Current user's effective plan, limits, and live usage — `{plan, limits: {max_links, monthly_clicks, analytics_days}, usage: {active_links, monthly_clicks}}`; org members see pooled usage across all org members; admin plan is always `"admin"` (unlimited) |
| GET | `/api/tags` | ✓ | Tags scoped to the requesting user — own tags only for solo users and admins; all org member tags for org members |
| GET | `/api/fetch-title` | ✓ | Server-side page title fetch — `?url=`. Returns `{"title":"…"}` |
| GET | `/api/qr/:code` | — | QR PNG for a short link |
| GET | `/api/qr/custom` | — | QR PNG for any URL — `?url=`, `?fg=`, `?bg=`, `?size=`, `?style=` |
| POST | `/api/qr/custom` | — | QR PNG with logo overlay — `{url, fg, bg, size, style, logo}` (logo as base64) |
| POST | `/api/links/bulk` | ✓ | Bulk operations — `{action: "delete"\|"tag"\|"expire", codes: […]}` |
| GET | `/api/links/export` | ✓ | Download all links as CSV |
| POST | `/api/links/import` | ✓ | Import links from CSV — `{csv: "…"}` |
| GET | `/api/health` | — | Health check — `{"status":"ok"}` |
| GET | `/:code` | — | Redirect to destination URL |
### Admin
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | `/api/admin/users` | Admin | List all users with link counts, plan, org name, and org plan |
| POST | `/api/admin/users` | Admin | Create user — `{username, password, is_admin, plan}` |
| PATCH | `/api/admin/users/:id` | Admin | Update plan, admin status, and/or org — `{plan?, is_admin?, org_id?}` (set `org_id: null` to remove from org) |
| DELETE | `/api/admin/users/:id` | Admin | Delete user (cannot delete self) |
| PATCH | `/api/admin/users/:id/password` | Admin | Change user password — `{password}` |
| GET | `/api/admin/organizations` | Admin | List all organizations with member counts and plan |
| POST | `/api/admin/organizations` | Admin | Create organization — `{name, plan}` |
| PATCH | `/api/admin/organizations/:id` | Admin | Update organization name and/or plan — `{name?, plan?}` |
| DELETE | `/api/admin/organizations/:id` | Admin | Delete organization — members are unassigned but not deleted |
| GET | `/api/admin/messages` | Admin | List all contact/portal messages, newest first |
| DELETE | `/api/admin/messages/:id` | Admin | Delete a message |
| PATCH | `/api/admin/messages/:id/read` | Admin | Mark a message as read |
### Contact
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | `/api/contact` | — | Submit a contact message — `{name, email, subject, body}` |
---
## Third-party attributions
QRknit is built on open-source components. See [NOTICES.md](NOTICES.md) for full licence texts.
| Library | Licence | Used for |
|---|---|---|
| [Flask](https://flask.palletsprojects.com) | BSD-3-Clause | Web framework & routing |
| [Werkzeug](https://werkzeug.palletsprojects.com) | BSD-3-Clause | Password hashing, WSGI utilities |
| [qrcode](https://github.com/lincolnloop/python-qrcode) | MIT | QR code generation |
| [Pillow](https://python-pillow.org) | HPND | Image processing & QR rendering |
| [Gunicorn](https://gunicorn.org) | MIT | Production WSGI server |
| [Google Fonts](https://fonts.google.com) — Orbitron, Barlow, Share Tech Mono | OFL-1.1 | UI typography |
---
© 2025 QRknit. All rights reserved.
+862
View File
@@ -0,0 +1,862 @@
<!DOCTYPE html>
<html lang="en">
<script>
(function(){var t=localStorage.getItem('qrknit-theme');if(t&&t!=='default')document.documentElement.setAttribute('data-theme',t);})();
</script>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QRknit Private Instance — Self-Hosted URL Shortener & QR Generator</title>
<meta name="description" content="Run QRknit on your own infrastructure. Full data sovereignty, no third-party tracking, and complete control — available on Pro and Team plans. Deploy in minutes with Docker.">
<meta name="keywords" content="self-hosted URL shortener, private QR code generator, on-premise link management, Docker URL shortener, self-hosted link analytics">
<meta name="author" content="QRknit">
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1">
<link rel="canonical" href="https://qrknit.com/landing-os">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://qrknit.com/landing-os">
<meta property="og:site_name" content="QRknit">
<meta property="og:title" content="QRknit Private Instance — Self-Hosted URL Shortener & QR Generator">
<meta property="og:description" content="Run QRknit on your own infrastructure with full data sovereignty. Private deployment, no third-party tracking, complete control. Available on Pro and Team plans.">
<meta property="og:image" content="https://qrknit.com/static/og-image.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="QRknit Private Instance — Self-Hosted URL Shortener">
<meta property="og:locale" content="en_US">
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@qrknit">
<meta name="twitter:creator" content="@qrknit">
<meta name="twitter:title" content="QRknit Private Instance — Self-Hosted URL Shortener & QR Generator">
<meta name="twitter:description" content="Run QRknit on your own infrastructure with full data sovereignty. Private deployment, no third-party tracking, complete control.">
<meta name="twitter:image" content="https://qrknit.com/static/og-image.png">
<meta name="twitter:image:alt" content="QRknit Private Instance — Self-Hosted URL Shortener">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/static/qk-ico.png">
<link rel="apple-touch-icon" href="/static/qk-ico.png">
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://qrknit.com/landing-os#webpage",
"url": "https://qrknit.com/landing-os",
"name": "QRknit Private Instance — Self-Hosted URL Shortener & QR Generator",
"description": "Run QRknit on your own infrastructure with full data sovereignty. Private deployment available on Pro and Team plans.",
"isPartOf": { "@id": "https://qrknit.com/#website" },
"inLanguage": "en-US",
"breadcrumb": {
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://qrknit.com/" },
{ "@type": "ListItem", "position": 2, "name": "Private Instance", "item": "https://qrknit.com/landing-os" }
]
}
},
{
"@type": "SoftwareApplication",
"name": "QRknit Private Instance",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Linux, Docker",
"description": "Self-hosted, private deployment of QRknit — URL shortener with QR code generation and click analytics. Full data sovereignty with Docker-based deployment.",
"url": "https://qrknit.com/landing-os",
"offers": [
{
"@type": "Offer",
"name": "Pro Plan with Private Instance",
"price": "39",
"priceCurrency": "USD"
},
{
"@type": "Offer",
"name": "Team Plan with Private Instance",
"price": "79",
"priceCurrency": "USD"
}
],
"featureList": [
"Self-hosted deployment via Docker",
"Full data sovereignty",
"No third-party data sharing",
"Complete infrastructure control",
"All QRknit core features included",
"Private link shortening",
"Private QR code generation",
"Click analytics on your own servers"
]
}
]
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&family=Barlow:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0f;
--surface: #111118;
--surface2: #181824;
--surface3: #1e1e2e;
--border: rgba(255,255,255,0.07);
--accent: #00b8d4;
--accent-rgb: 0,184,212;
--accent-grad: #1a52c8;
--accent-grad-rgb: 26,82,200;
--accent2: #ff5e62;
--accent3: #7c6af7;
--accent3-rgb: 124,106,247;
--accent3-light: #a89cf7;
--text: #f0f0f8;
--muted: #808098;
--radius: 12px;
--font-head: 'Orbitron', sans-serif;
--font-mono: 'Share Tech Mono', monospace;
--font-body: 'Barlow', sans-serif;
}
[data-theme="gold"] { --accent:#f5a623; --accent-rgb:245,166,35; --accent-grad:#c47f17; --accent-grad-rgb:196,127,23; --accent3:#d4a847; --accent3-rgb:212,168,71; --accent3-light:#e8c96a; }
[data-theme="green"] { --accent:#00c896; --accent-rgb:0,200,150; --accent-grad:#007a58; --accent-grad-rgb:0,122,88; --accent3:#4caf7a; --accent3-rgb:76,175,122; --accent3-light:#7fd9a4; }
[data-theme="red"] { --accent:#ff4757; --accent-rgb:255,71,87; --accent-grad:#c0392b; --accent-grad-rgb:192,57,43; --accent3:#ff6b7a; --accent3-rgb:255,107,122; --accent3-light:#ff9daa; }
html { scroll-behavior: smooth; }
body { background:var(--bg); color:var(--text); font-family:var(--font-body); font-size:15px; line-height:1.6; min-height:100vh; overflow-x:hidden; }
body::before {
content:''; position:fixed; inset:0; pointer-events:none; z-index:0;
background:
radial-gradient(ellipse 90% 60% at 15% -5%, rgba(var(--accent-rgb),.09) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 90% 90%, rgba(var(--accent-grad-rgb),.08) 0%, transparent 55%),
radial-gradient(ellipse 50% 50% at 50% 50%, rgba(var(--accent-rgb),.03) 0%, transparent 60%);
}
/* ── HEADER ── */
header {
position:sticky; top:0; z-index:200;
display:flex; align-items:center; justify-content:space-between;
padding:0 40px; height:64px;
background:rgba(10,10,15,.88); backdrop-filter:blur(20px);
border-bottom:1px solid var(--border);
}
.logo { font-family:var(--font-head); font-size:18px; font-weight:700; letter-spacing:.04em; text-decoration:none; color:var(--text); display:inline-flex; align-items:center; gap:8px; }
.logo-dot-text { color:var(--accent); text-shadow:0 0 12px var(--accent); }
.logo-alwisp { text-transform:uppercase; }
.logo-icon-wrap { position:relative; display:inline-flex; align-items:center; flex-shrink:0; isolation:isolate; }
.logo-icon { height:26px; width:26px; object-fit:contain; border-radius:3px; display:block; }
.logo-icon-tint { position:absolute; inset:0; background:var(--accent); mix-blend-mode:hue; border-radius:3px; pointer-events:none; }
.header-right { display:flex; align-items:center; gap:12px; }
.theme-picker { display:flex; align-items:center; gap:6px; padding:0 4px; }
.theme-swatch { width:14px; height:14px; border-radius:50%; border:2px solid transparent; cursor:pointer; padding:0; transition:transform .2s,border-color .2s; }
.theme-swatch:hover { transform:scale(1.2); }
.theme-swatch.active { border-color:var(--text); transform:scale(1.15); }
.theme-swatch[data-theme="default"] { background:linear-gradient(135deg,#00b8d4,#1a52c8); }
.theme-swatch[data-theme="gold"] { background:linear-gradient(135deg,#f5a623,#c47f17); }
.theme-swatch[data-theme="green"] { background:linear-gradient(135deg,#00c896,#007a58); }
.theme-swatch[data-theme="red"] { background:linear-gradient(135deg,#ff4757,#c0392b); }
/* ── BUTTONS ── */
.btn-primary { background:linear-gradient(135deg,var(--accent),var(--accent-grad)); color:#fff; border:none; cursor:pointer; font-family:var(--font-head); font-weight:700; font-size:12px; letter-spacing:.04em; padding:12px 22px; border-radius:var(--radius); transition:all .2s; white-space:nowrap; text-decoration:none; display:inline-flex; align-items:center; gap:6px; }
.btn-primary:hover { transform:translateY(-1px); box-shadow:0 8px 24px rgba(var(--accent-rgb),.35); }
.btn-outline { background:transparent; border:1px solid var(--border); color:var(--text); font-family:var(--font-mono); font-size:12px; padding:11px 20px; border-radius:var(--radius); cursor:pointer; transition:all .2s; text-decoration:none; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
.btn-outline:hover { border-color:rgba(var(--accent-rgb),.5); color:var(--accent); }
/* ── SECTIONS ── */
section { position:relative; z-index:1; }
.section-inner { max-width:1080px; margin:0 auto; padding:0 40px; }
/* ── HERO ── */
.hero { text-align:center; padding:110px 40px 80px; position:relative; z-index:1; overflow:hidden; }
.hero-eyebrow { font-family:var(--font-mono); font-size:11px; letter-spacing:.16em; color:var(--accent); text-transform:uppercase; margin-bottom:24px; }
.hero h1 {
font-family:var(--font-head); font-weight:900; line-height:1.05;
font-size:clamp(38px,7vw,82px); letter-spacing:.04em; margin-bottom:28px;
}
.hero-accent { background:linear-gradient(135deg,var(--accent) 0%,var(--accent-grad) 100%); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
.hero-sub { font-size:clamp(15px,2vw,19px); color:var(--muted); max-width:600px; margin:0 auto 44px; font-weight:300; line-height:1.7; }
.hero-cta { display:flex; align-items:center; justify-content:center; gap:14px; flex-wrap:wrap; margin-bottom:70px; }
.hero-note { font-family:var(--font-mono); font-size:11px; color:var(--muted); letter-spacing:.06em; }
.hero-note span { color:var(--accent); }
/* hero glow ring */
.hero::after {
content:''; position:absolute; left:50%; top:60px; transform:translateX(-50%);
width:600px; height:600px; border-radius:50%;
background:radial-gradient(ellipse, rgba(var(--accent-rgb),.07) 0%, transparent 70%);
pointer-events:none; z-index:-1;
}
/* ── FEATURE GRID ── */
.feature-section { padding:80px 0 90px; }
.section-label { font-family:var(--font-mono); font-size:10px; letter-spacing:.18em; color:var(--accent); text-transform:uppercase; margin-bottom:12px; text-align:center; }
.section-title { font-family:var(--font-head); font-size:clamp(22px,3vw,32px); font-weight:700; text-align:center; margin-bottom:56px; }
.feature-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:18px; }
.feature-card {
background:var(--surface); border:1px solid var(--border); border-radius:16px;
padding:28px; position:relative; overflow:hidden; transition:border-color .25s, transform .25s;
}
.feature-card::before {
content:''; position:absolute; top:0; left:0; right:0; height:2px;
background:linear-gradient(90deg,var(--accent),transparent);
}
.feature-card:hover { border-color:rgba(var(--accent-rgb),.25); transform:translateY(-3px); }
.feature-icon { width:40px; height:40px; border-radius:10px; background:rgba(var(--accent-rgb),.1); border:1px solid rgba(var(--accent-rgb),.2); display:flex; align-items:center; justify-content:center; margin-bottom:18px; flex-shrink:0; }
.feature-icon svg { color:var(--accent); }
.feature-card h3 { font-family:var(--font-head); font-size:13px; font-weight:700; letter-spacing:.04em; margin-bottom:10px; }
.feature-card p { font-size:13px; color:var(--muted); line-height:1.65; }
.feature-tag { display:inline-block; font-family:var(--font-mono); font-size:9px; letter-spacing:.08em; color:var(--accent3-light); background:rgba(var(--accent3-rgb),.1); border:1px solid rgba(var(--accent3-rgb),.2); border-radius:4px; padding:1px 7px; margin-top:14px; }
/* ── ANALYTICS SHOWCASE ── */
.analytics-section { padding:80px 0 90px; background:linear-gradient(180deg, transparent 0%, rgba(var(--accent-rgb),.03) 40%, transparent 100%); }
.analytics-split { display:grid; grid-template-columns:1fr 1fr; gap:60px; align-items:center; }
.analytics-copy .section-label { text-align:left; }
.analytics-copy .section-title { text-align:left; margin-bottom:20px; }
.analytics-copy p { color:var(--muted); font-size:14px; line-height:1.75; margin-bottom:16px; }
.analytics-list { list-style:none; margin:24px 0 32px; display:flex; flex-direction:column; gap:10px; }
.analytics-list li { font-family:var(--font-mono); font-size:12px; color:var(--text); display:flex; align-items:center; gap:10px; }
.analytics-list li::before { content:''; width:6px; height:6px; border-radius:50%; background:var(--accent); flex-shrink:0; }
/* mock analytics panel */
.mock-panel {
background:var(--surface); border:1px solid var(--border); border-radius:16px;
padding:20px; box-shadow:0 32px 80px rgba(0,0,0,.5);
}
.mock-panel-title { font-family:var(--font-mono); font-size:10px; color:var(--muted); letter-spacing:.1em; margin-bottom:14px; }
.mock-chart { display:flex; align-items:flex-end; gap:3px; height:70px; margin-bottom:16px; }
.mock-bar { border-radius:2px 2px 0 0; flex:1; min-width:4px; transition:opacity .3s; }
.mock-heatmap { display:grid; grid-template-columns:24px repeat(12,1fr); gap:2px; margin-bottom:14px; }
.mock-hm-label { font-family:var(--font-mono); font-size:7px; color:var(--muted); display:flex; align-items:center; }
.mock-hm-cell { height:10px; border-radius:2px; }
.mock-stats { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
.mock-stat { background:var(--surface2); border:1px solid var(--border); border-radius:8px; padding:10px; }
.mock-stat-val { font-family:var(--font-head); font-size:16px; font-weight:700; color:var(--accent); line-height:1; margin-bottom:3px; }
.mock-stat-lbl { font-family:var(--font-mono); font-size:8px; color:var(--muted); letter-spacing:.06em; }
/* ── SELF-HOSTED ── */
.self-hosted-section { padding:80px 0; }
.pillar-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:20px; margin-top:56px; }
.pillar-card {
background:var(--surface2); border:1px solid var(--border); border-radius:14px;
padding:28px 24px; text-align:center;
}
.pillar-num { font-family:var(--font-head); font-size:36px; font-weight:900; line-height:1; background:linear-gradient(135deg,var(--accent),var(--accent-grad)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; margin-bottom:8px; }
.pillar-label{ font-family:var(--font-mono); font-size:10px; color:var(--muted); letter-spacing:.1em; text-transform:uppercase; margin-bottom:10px; }
.pillar-desc { font-size:13px; color:var(--muted); line-height:1.6; }
/* ── DEPLOY ── */
.deploy-section { padding:80px 0 90px; }
.deploy-split { display:grid; grid-template-columns:1fr 1fr; gap:60px; align-items:start; }
.code-block {
background:var(--surface2); border:1px solid var(--border); border-radius:14px;
padding:24px; position:relative; overflow:hidden;
}
.code-block::before { content:''; position:absolute; top:0; left:0; right:0; height:1px; background:linear-gradient(90deg,var(--accent),transparent); }
.code-block pre { font-family:var(--font-mono); font-size:12px; color:var(--text); line-height:1.8; overflow-x:auto; white-space:pre; }
.code-line { display:block; }
.code-key { color:var(--accent3-light); }
.code-val { color:rgba(var(--accent-rgb),.9); }
.code-flag { color:var(--muted); }
.deploy-copy .section-title { text-align:left; margin-bottom:18px; }
.deploy-copy .section-label { text-align:left; }
.deploy-copy p { color:var(--muted); font-size:14px; line-height:1.75; margin-bottom:16px; }
.deploy-steps { list-style:none; margin:24px 0 0; display:flex; flex-direction:column; gap:12px; counter-reset:step; }
.deploy-steps li { font-size:13px; color:var(--muted); display:flex; align-items:flex-start; gap:12px; counter-increment:step; }
.deploy-steps li::before {
content:counter(step); flex-shrink:0;
width:22px; height:22px; border-radius:50%;
background:rgba(var(--accent-rgb),.1); border:1px solid rgba(var(--accent-rgb),.25);
font-family:var(--font-mono); font-size:10px; color:var(--accent);
display:flex; align-items:center; justify-content:center; margin-top:1px;
}
/* ── PRICING ── */
.pricing-section { padding:80px 0 90px; }
.pricing-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:20px; margin-top:56px; }
.pricing-card {
background:var(--surface); border:1px solid var(--border); border-radius:16px;
padding:32px 28px; display:flex; flex-direction:column; position:relative; overflow:hidden;
}
.pricing-card.featured {
border-color:rgba(var(--accent-rgb),.35);
background:linear-gradient(160deg, var(--surface2) 0%, var(--surface) 100%);
box-shadow:0 0 0 1px rgba(var(--accent-rgb),.15), 0 24px 60px rgba(0,0,0,.4);
}
.pricing-card.featured::before { content:''; position:absolute; top:0; left:0; right:0; height:2px; background:linear-gradient(90deg,var(--accent),var(--accent-grad)); }
.pricing-badge { position:absolute; top:18px; right:18px; font-family:var(--font-mono); font-size:9px; letter-spacing:.1em; text-transform:uppercase; background:rgba(var(--accent-rgb),.15); border:1px solid rgba(var(--accent-rgb),.3); color:var(--accent); border-radius:4px; padding:2px 8px; }
.pricing-name { font-family:var(--font-head); font-size:13px; font-weight:700; letter-spacing:.06em; margin-bottom:6px; text-transform:uppercase; }
.pricing-price { font-family:var(--font-head); font-size:36px; font-weight:900; line-height:1; margin:14px 0 6px; }
.pricing-price span { font-size:14px; color:var(--muted); font-family:var(--font-body); font-weight:300; }
.pricing-desc { font-size:13px; color:var(--muted); margin-bottom:24px; line-height:1.6; }
.pricing-features { list-style:none; margin:0 0 28px; display:flex; flex-direction:column; gap:9px; flex:1; }
.pricing-features li { font-size:13px; color:var(--muted); display:flex; align-items:flex-start; gap:8px; }
.pricing-features li::before { content:'✓'; color:var(--accent); font-family:var(--font-mono); font-size:11px; flex-shrink:0; margin-top:2px; }
.pricing-features li.dim { opacity:.45; }
.pricing-features li.dim::before { content:'—'; color:var(--muted); }
.pricing-soon { background:var(--surface2); border:1px solid var(--border); color:var(--muted); font-family:var(--font-mono); font-size:11px; padding:10px; border-radius:8px; text-align:center; margin-top:auto; letter-spacing:.04em; cursor:pointer; transition:all .2s; }
.pricing-soon:hover { border-color:rgba(var(--accent-rgb),.5); color:var(--accent); }
/* ── FINAL CTA ── */
.cta-section { padding:80px 40px 100px; text-align:center; position:relative; z-index:1; }
.cta-section h2 { font-family:var(--font-head); font-size:clamp(24px,4vw,42px); font-weight:900; margin-bottom:16px; }
.cta-section p { font-size:15px; color:var(--muted); max-width:480px; margin:0 auto 36px; }
.cta-sub { font-family:var(--font-mono); font-size:11px; color:var(--muted); margin-top:20px; letter-spacing:.06em; }
/* ── FOOTER ── */
footer {
border-top:1px solid var(--border); padding:32px 40px;
display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;
position:relative; z-index:1;
}
.footer-logo { font-family:var(--font-head); font-size:14px; font-weight:700; color:var(--muted); letter-spacing:.04em; display:flex; align-items:center; gap:6px; }
.footer-logo span { color:var(--accent); }
.footer-links { display:flex; gap:20px; align-items:center; }
.footer-links a { font-family:var(--font-mono); font-size:11px; color:var(--muted); text-decoration:none; letter-spacing:.06em; transition:color .2s; }
.footer-links a:hover { color:var(--accent); }
.footer-note { font-family:var(--font-mono); font-size:10px; color:var(--muted); opacity:.5; letter-spacing:.05em; }
/* ── DIVIDER ── */
.section-divider { border:none; border-top:1px solid var(--border); margin:0; }
/* ── ERROR BANNER ── */
.error-banner { display:none; position:fixed; top:72px; left:50%; transform:translateX(-50%); z-index:300; background:var(--surface2); border:1px solid rgba(255,94,98,.3); border-radius:10px; padding:12px 20px; font-family:var(--font-mono); font-size:12px; color:#ff5e62; box-shadow:0 8px 24px rgba(0,0,0,.4); white-space:nowrap; }
.error-banner.show { display:block; animation:fadeDown .3s ease; }
@keyframes fadeDown { from{opacity:0;transform:translateX(-50%) translateY(-8px)} to{opacity:1;transform:translateX(-50%) translateY(0)} }
/* ── REVEAL ANIMATIONS ── */
.reveal { opacity:0; transform:translateY(24px); transition:opacity .55s ease, transform .55s ease; }
.reveal.visible { opacity:1; transform:none; }
.reveal-delay-1 { transition-delay:.1s; }
.reveal-delay-2 { transition-delay:.2s; }
.reveal-delay-3 { transition-delay:.3s; }
.reveal-delay-4 { transition-delay:.4s; }
.reveal-delay-5 { transition-delay:.5s; }
/* ── RESPONSIVE ── */
@media(max-width:900px) {
header { padding:0 20px; }
.section-inner { padding:0 20px; }
.hero { padding:80px 20px 60px; }
.feature-grid, .pillar-grid, .pricing-grid { grid-template-columns:1fr 1fr; }
.analytics-split, .deploy-split { grid-template-columns:1fr; gap:36px; }
footer { padding:24px 20px; }
}
@media(max-width:600px) {
.feature-grid, .pillar-grid, .pricing-grid { grid-template-columns:1fr; }
.hero-cta { flex-direction:column; align-items:center; }
}
/* ── CONTACT MODAL ── */
.contact-backdrop { display:none; position:fixed; inset:0; z-index:600; background:rgba(0,0,0,.65); backdrop-filter:blur(4px); align-items:center; justify-content:center; padding:20px; }
.contact-backdrop.open { display:flex; }
.contact-modal { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:36px; width:100%; max-width:480px; position:relative; box-shadow:0 24px 60px rgba(0,0,0,.5); }
.contact-modal h3 { font-family:var(--font-head); font-size:16px; font-weight:700; letter-spacing:.04em; margin-bottom:6px; }
.contact-modal .cm-sub { font-size:13px; color:var(--muted); margin-bottom:24px; line-height:1.5; }
.contact-modal label { display:block; font-family:var(--font-mono); font-size:11px; letter-spacing:.06em; color:var(--muted); margin-bottom:6px; }
.contact-modal input, .contact-modal textarea { width:100%; background:var(--surface2); border:1px solid var(--border); border-radius:8px; padding:10px 14px; color:var(--text); font-family:var(--font-body); font-size:14px; outline:none; transition:border-color .2s; }
.contact-modal input:focus, .contact-modal textarea:focus { border-color:rgba(var(--accent-rgb),.5); }
.contact-modal textarea { resize:vertical; min-height:80px; }
.contact-field { margin-bottom:16px; }
.cm-actions { display:flex; gap:12px; justify-content:flex-end; margin-top:24px; }
.cm-close { position:absolute; top:14px; right:16px; background:none; border:none; color:var(--muted); font-size:22px; cursor:pointer; line-height:1; padding:4px; }
.cm-close:hover { color:var(--text); }
.cm-success { display:none; text-align:center; padding:20px 0; }
.cm-success .cm-check { font-size:40px; color:var(--accent); margin-bottom:12px; }
.cm-success p { color:var(--muted); font-size:14px; margin-top:8px; }
</style>
</head>
<body>
<div class="error-banner" id="error-banner"></div>
<!-- ── HEADER ── -->
<header>
<a class="logo" href="/">
<span class="logo-icon-wrap"><img class="logo-icon" src="/static/qk-ico.png" alt=""><span class="logo-icon-tint"></span></span>QR<span class="logo-dot-text">knit</span>
</a>
<div class="header-right">
<div class="theme-picker">
<button class="theme-swatch" data-theme="default" title="Default" aria-label="Default blue theme"></button>
<button class="theme-swatch" data-theme="gold" title="Gold" aria-label="Gold theme"></button>
<button class="theme-swatch" data-theme="green" title="Green" aria-label="Green theme"></button>
<button class="theme-swatch" data-theme="red" title="Red" aria-label="Red theme"></button>
</div>
<a class="btn-primary" href="/app">Open App →</a>
</div>
</header>
<!-- ── HERO ── -->
<section class="hero">
<div class="hero-eyebrow reveal">Private Instance · Pro & Team plans</div>
<h1 class="reveal reveal-delay-1">Short links.<br><span class="hero-accent">Sharp QR codes.</span><br>Your infrastructure.</h1>
<p class="hero-sub reveal reveal-delay-2">
All the power of QRknit running on your own hardware, under your full control. Your data stays on your server — no sharing, no usage caps from noisy neighbours.
</p>
<div class="hero-cta reveal reveal-delay-3">
<a class="btn-primary" href="/app" style="font-size:13px;padding:14px 28px">→ Open App</a>
<a class="btn-outline" href="/" style="font-size:13px;padding:14px 28px">View plans →</a>
</div>
<div class="hero-note reveal reveal-delay-4">
<span>Single Docker container</span> · Included with Pro & Team plans
</div>
</section>
<!-- ── FEATURES ── -->
<section class="feature-section">
<div class="section-inner">
<div class="section-label reveal">What's inside</div>
<div class="section-title reveal reveal-delay-1">Everything in one container</div>
<div class="feature-grid">
<div class="feature-card reveal reveal-delay-1">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</div>
<h3>Link Shortening</h3>
<p>Custom codes, automatic expiry dates, tag organisation, bulk import and export. Search and filter your entire link library in milliseconds.</p>
<span class="feature-tag">Custom codes · Tags · Expiry · Bulk CSV</span>
</div>
<div class="feature-card reveal reveal-delay-2">
<div class="feature-icon" style="background:rgba(var(--accent3-rgb),.1);border-color:rgba(var(--accent3-rgb),.2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent3-light)"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 7h.01M17 7h.01M7 17h.01M11 7h2M7 11v2M15 11h2M11 17h2M17 13v2"/></svg>
</div>
<h3>QR Code Generator</h3>
<p>Five dot styles, full color control, logo overlay support, and downloadable PNGs — right in the browser. Generate for any URL, not just your short links.</p>
<span class="feature-tag" style="color:var(--accent3-light);background:rgba(var(--accent3-rgb),.1);border-color:rgba(var(--accent3-rgb),.2)">5 styles · Logo overlay · Custom colors</span>
</div>
<div class="feature-card reveal reveal-delay-3">
<div class="feature-icon" style="background:rgba(245,166,35,.1);border-color:rgba(245,166,35,.2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#f5a623"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div>
<h3>Click Analytics</h3>
<p>Daily bar charts, hourly 7×24 activity heatmap, geographic country breakdown, referrer and device tracking — all per link, with raw CSV export.</p>
<span class="feature-tag" style="color:#e8c96a;background:rgba(245,166,35,.1);border-color:rgba(245,166,35,.2)">Heatmap · Geo · Referrers · CSV export</span>
</div>
<div class="feature-card reveal reveal-delay-1">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<h3>Multi-user Support</h3>
<p>Admin account management, per-user isolation so each user only sees their own links, and owner attribution so admins can quickly identify and act on abuse.</p>
<span class="feature-tag">Admin panel · User isolation · Owner badges</span>
</div>
<div class="feature-card reveal reveal-delay-2">
<div class="feature-icon" style="background:rgba(0,200,150,.1);border-color:rgba(0,200,150,.2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#00c896"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
</div>
<h3>Docker Native</h3>
<p>One container, one volume, one command. SQLite keeps it simple — no Postgres, no Redis, no message queue. Works on Unraid, Synology, or any Docker host.</p>
<span class="feature-tag" style="color:#7fd9a4;background:rgba(0,200,150,.1);border-color:rgba(0,200,150,.2)">Single container · SQLite · Zero infra deps</span>
</div>
<div class="feature-card reveal reveal-delay-3">
<div class="feature-icon" style="background:rgba(255,71,87,.1);border-color:rgba(255,71,87,.2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#ff4757"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
</div>
<h3>Four Color Themes</h3>
<p>Switch between Cyan, Gold, Green, and Red accent palettes. Preference is stored in localStorage and applies instantly — no page reload, no account needed.</p>
<span class="feature-tag" style="color:#ff9daa;background:rgba(255,71,87,.1);border-color:rgba(255,71,87,.2)">Cyan · Gold · Green · Red</span>
</div>
</div>
</div>
</section>
<hr class="section-divider">
<!-- ── ANALYTICS SHOWCASE ── -->
<section class="analytics-section">
<div class="section-inner">
<div class="analytics-split">
<div class="analytics-copy">
<div class="section-label reveal">Analytics deep-dive</div>
<h2 class="section-title reveal reveal-delay-1">Know exactly who clicked, when, and from where</h2>
<p class="reveal reveal-delay-2">Most link shorteners give you a click count. QRknit gives you the full picture — every event captured and broken down so you can make real decisions.</p>
<ul class="analytics-list reveal reveal-delay-3">
<li>Daily click bar chart per link (7d · 30d · 90d windows)</li>
<li>Hourly 7×24 activity heatmap — spot your peak times at a glance</li>
<li>Geographic country breakdown via Cloudflare headers</li>
<li>Referrer, device type, and browser breakdown</li>
<li>30-day dashboard-wide click chart across all links</li>
<li>Raw click-event CSV export — every timestamp, referrer, country</li>
</ul>
<a class="btn-primary reveal reveal-delay-4" href="/app">See it in action →</a>
</div>
<!-- Mock analytics panel -->
<div class="mock-panel reveal reveal-delay-2" id="mock-panel">
<div class="mock-panel-title">CLICK ANALYTICS — LAST 30 DAYS</div>
<div class="mock-chart" id="mock-chart"></div>
<div class="mock-panel-title" style="margin-top:4px">HOURLY ACTIVITY</div>
<div class="mock-heatmap" id="mock-heatmap"></div>
<div class="mock-stats">
<div class="mock-stat"><div class="mock-stat-val" id="mock-total"></div><div class="mock-stat-lbl">TOTAL CLICKS</div></div>
<div class="mock-stat"><div class="mock-stat-val" id="mock-peak"></div><div class="mock-stat-lbl">PEAK DAY</div></div>
<div class="mock-stat"><div class="mock-stat-val" id="mock-countries"></div><div class="mock-stat-lbl">COUNTRIES</div></div>
</div>
</div>
</div>
</div>
</section>
<hr class="section-divider">
<!-- ── PRIVATE INSTANCE ── -->
<section class="self-hosted-section">
<div class="section-inner">
<div class="section-label reveal">Data sovereignty</div>
<div class="section-title reveal reveal-delay-1">Your data never leaves your server</div>
<div class="pillar-grid">
<div class="pillar-card reveal reveal-delay-1">
<div class="pillar-num">0</div>
<div class="pillar-label">External infra deps</div>
<p class="pillar-desc">No Redis, no Postgres, no message queue. A single SQLite file holds everything. Back it up with one <code style="font-family:var(--font-mono);font-size:11px;color:var(--accent)">cp</code>.</p>
</div>
<div class="pillar-card reveal reveal-delay-2">
<div class="pillar-num">1</div>
<div class="pillar-label">Docker container</div>
<p class="pillar-desc">The entire app — Python, Flask, frontend, and all assets — ships as a single image. Map one port, mount one volume, done.</p>
</div>
<div class="pillar-card reveal reveal-delay-3">
<div class="pillar-num"></div>
<div class="pillar-label">Links you can create</div>
<p class="pillar-desc">Your plan limits apply, but your instance is yours alone — no shared infrastructure, no noisy neighbours, no cross-tenant data.</p>
</div>
</div>
</div>
</section>
<hr class="section-divider">
<!-- ── DEPLOY ── -->
<section class="deploy-section">
<div class="section-inner">
<div class="deploy-split">
<div class="deploy-copy">
<div class="section-label reveal">Get running in minutes</div>
<h2 class="section-title reveal reveal-delay-1">One command to deploy</h2>
<p class="reveal reveal-delay-2">No CI pipeline, no cloud credentials, no YAML sprawl. Access the private repository with your plan credentials, build the image, run the container.</p>
<ol class="deploy-steps reveal reveal-delay-3">
<li>Clone the private repo using the credentials from your account</li>
<li>Set <code style="font-family:var(--font-mono);color:var(--accent);font-size:12px">BASE_URL</code>, <code style="font-family:var(--font-mono);color:var(--accent);font-size:12px">SECRET_KEY</code>, and <code style="font-family:var(--font-mono);color:var(--accent);font-size:12px">ADMIN_PASSWORD</code></li>
<li>Point a domain at your server and enable HTTPS</li>
<li>Log in at <code style="font-family:var(--font-mono);color:var(--accent);font-size:12px">/app</code> and start shortening</li>
</ol>
</div>
<div class="code-block reveal reveal-delay-2">
<pre><span class="code-line"><span class="code-flag">docker run -d \</span></span>
<span class="code-line"> <span class="code-key">--name</span> qrknit \</span>
<span class="code-line"> <span class="code-key">--restart</span> unless-stopped \</span>
<span class="code-line"> <span class="code-key">-p</span> 5000:5000 \</span>
<span class="code-line"> <span class="code-key">-v</span> qrknit-data:/app/data \</span>
<span class="code-line"> <span class="code-key">-e</span> <span class="code-val">BASE_URL</span>=https://yourdomain.com \</span>
<span class="code-line"> <span class="code-key">-e</span> <span class="code-val">SECRET_KEY</span>=your-generated-key \</span>
<span class="code-line"> <span class="code-key">-e</span> <span class="code-val">ADMIN_PASSWORD</span>=your-password \</span>
<span class="code-line"> qrknit:latest</span></pre>
</div>
</div>
</div>
</section>
<hr class="section-divider">
<!-- ── PRICING ── -->
<section class="pricing-section">
<div class="section-inner">
<div class="section-label reveal">Pricing</div>
<div class="section-title reveal reveal-delay-1">Private instance is included with Pro and Team plans.</div>
<div class="pricing-grid">
<div class="pricing-card featured reveal reveal-delay-1">
<div class="pricing-badge" style="background:rgba(var(--accent3-rgb),.12);border-color:rgba(var(--accent3-rgb),.3);color:var(--accent3-light)">Coming soon</div>
<div class="pricing-name">Pro</div>
<div class="pricing-price">$39 <span>/ month</span></div>
<p class="pricing-desc">For power users and growing teams who need full analytics and a private domain.</p>
<ul class="pricing-features">
<li>Unlimited active links</li>
<li>500,000 tracked clicks / month</li>
<li>90-day click analytics</li>
<li>Geographic country breakdown</li>
<li>Hourly 7×24 heatmap</li>
<li>1 custom short domain</li>
<li>Private instance</li>
<li>Up to 5 team members</li>
<li>Priority email support</li>
</ul>
<button class="pricing-soon" onclick="openContact('notify-me-pro')">Notify me when available</button>
</div>
<div class="pricing-card reveal reveal-delay-2">
<div class="pricing-badge" style="background:rgba(var(--accent3-rgb),.12);border-color:rgba(var(--accent3-rgb),.3);color:var(--accent3-light)">Coming soon</div>
<div class="pricing-name">Team</div>
<div class="pricing-price">$79 <span>/ month</span></div>
<p class="pricing-desc">Advanced features and full API access for teams that need more control.</p>
<ul class="pricing-features">
<li>Unlimited links &amp; clicks</li>
<li>Everything in Pro</li>
<li>Up to 3 custom short domains</li>
<li>Private instance</li>
<li>Unlimited team members</li>
<li>Full REST API + API key auth</li>
<li>UTM parameter builder</li>
<li>Password-protected links</li>
<li>Dedicated account support</li>
</ul>
<button class="pricing-soon" onclick="openContact('notify-me-team')" style="border-color:rgba(var(--accent3-rgb),.2)">Notify me when available</button>
</div>
</div>
</div>
</section>
<hr class="section-divider">
<!-- ── FINAL CTA ── -->
<section class="cta-section">
<div class="section-label reveal">Ready?</div>
<h2 class="reveal reveal-delay-1">Your links are waiting.</h2>
<p class="reveal reveal-delay-2">Open the app and start shortening URLs, generating QR codes, and tracking clicks — right now.</p>
<div class="reveal reveal-delay-3">
<a class="btn-primary" href="/app" style="font-size:13px;padding:14px 32px;display:inline-flex">→ Open App</a>
</div>
<div class="cta-sub reveal reveal-delay-4">Included with Pro &amp; Team plans · Full data sovereignty · Your domain</div>
</section>
<!-- ── FOOTER ── -->
<footer>
<div class="footer-logo">
<span class="logo-icon-wrap" style="isolation:isolate;position:relative;display:inline-flex"><img style="height:20px;width:20px;object-fit:contain;border-radius:2px;display:block" src="/static/qk-ico.png" alt=""><span style="position:absolute;inset:0;background:var(--accent);mix-blend-mode:hue;border-radius:2px;pointer-events:none"></span></span>
QR<span>k</span>nit
</div>
<div class="footer-links">
<a href="/">Plans</a>
<a href="#" onclick="openContact('contact-sales');return false">Contact Sales</a>
<a href="/api-docs">API Docs</a>
<a href="/app">App</a>
</div>
<div class="footer-note">© 2025 QRknit · All rights reserved · <a href="/notices" style="color:inherit;text-decoration:none;opacity:.7">Third-party notices</a></div>
</footer>
<!-- ── Contact modal ── -->
<div class="contact-backdrop" id="contact-backdrop" onclick="contactBackdropClick(event)">
<div class="contact-modal">
<button class="cm-close" onclick="closeContact()" aria-label="Close">&times;</button>
<div id="cm-form-content">
<h3>Get in Touch</h3>
<p class="cm-sub" id="cm-subtitle">Send us a message and we'll get back to you.</p>
<div class="contact-field">
<label for="cm-name">NAME</label>
<input type="text" id="cm-name" placeholder="Your name" autocomplete="name">
</div>
<div class="contact-field">
<label for="cm-email">EMAIL</label>
<input type="email" id="cm-email" placeholder="you@example.com" autocomplete="email">
</div>
<div class="contact-field">
<label for="cm-body">MESSAGE <span style="font-size:10px;opacity:.6">(optional)</span></label>
<textarea id="cm-body" placeholder="Anything you'd like to add…"></textarea>
</div>
<input type="hidden" id="cm-subject">
<div class="cm-actions">
<button class="btn-outline" type="button" onclick="closeContact()">Cancel</button>
<button class="btn-primary" type="button" onclick="submitContact()" id="cm-submit">Send message</button>
</div>
</div>
<div class="cm-success" id="cm-success">
<div class="cm-check"></div>
<h3 style="margin-bottom:8px">Message sent!</h3>
<p>Thanks for reaching out. We'll be in touch soon.</p>
</div>
</div>
</div>
<script>
// ── Theme picker ──────────────────────────────────────────────────────────
function applyTheme(name) {
if (name && name !== 'default') {
document.documentElement.setAttribute('data-theme', name);
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('qrknit-theme', name || 'default');
document.querySelectorAll('.theme-swatch').forEach(s => {
s.classList.toggle('active', s.dataset.theme === (name || 'default'));
});
}
document.querySelectorAll('.theme-swatch').forEach(s => {
s.addEventListener('click', () => applyTheme(s.dataset.theme));
});
// Restore active swatch on load
(function(){
const t = localStorage.getItem('qrknit-theme') || 'default';
document.querySelectorAll('.theme-swatch').forEach(s => {
s.classList.toggle('active', s.dataset.theme === t);
});
})();
// ── Error banner (for /?error= from expired/dead short links) ─────────────
(function(){
const p = new URLSearchParams(location.search);
const err = p.get('error');
const banner = document.getElementById('error-banner');
const msgs = { not_found: 'That short link does not exist.', expired: 'That short link has expired.' };
if (err && msgs[err]) {
banner.textContent = msgs[err];
banner.classList.add('show');
setTimeout(() => banner.classList.remove('show'), 5000);
}
})();
// ── Scroll reveal ─────────────────────────────────────────────────────────
(function(){
const obs = new IntersectionObserver((entries) => {
entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); obs.unobserve(e.target); } });
}, { threshold: 0.12 });
document.querySelectorAll('.reveal').forEach(el => obs.observe(el));
// Make hero items visible immediately (they're above the fold)
document.querySelectorAll('.hero .reveal').forEach(el => el.classList.add('visible'));
})();
// ── Mock analytics panel ──────────────────────────────────────────────────
(function(){
const accentRgb = () => getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '0,184,212';
// Generate plausible-looking demo data
const days = 30;
const data = Array.from({length: days}, (_, i) => {
const base = 20 + Math.sin(i / 4) * 12 + Math.random() * 18;
return Math.max(0, Math.round(base));
});
// Spike a couple of days
data[7] = 58; data[18] = 71; data[23] = 44;
const total = data.reduce((s, v) => s + v, 0);
const peak = Math.max(...data);
// Heatmap data (7 days × 12 two-hour buckets)
const hmap = Array.from({length: 7}, () =>
Array.from({length: 12}, (_, h) => {
const peak_h = (h >= 4 && h <= 8) ? 1.8 : (h >= 1 && h <= 3) ? 0.4 : 1;
return Math.round(Math.random() * 8 * peak_h);
})
);
const hmMax = Math.max(...hmap.flat(), 1);
function render() {
const rgb = accentRgb();
// Bar chart
const chartEl = document.getElementById('mock-chart');
if (!chartEl) return;
const maxV = Math.max(...data, 1);
chartEl.innerHTML = data.map(v => {
const pct = (v / maxV * 100).toFixed(0);
const op = v > 0 ? (0.2 + (v / maxV) * 0.7).toFixed(2) : '0.08';
return `<div class="mock-bar" style="height:${pct}%;background:rgba(${rgb},${op})"></div>`;
}).join('');
// Heatmap
const heatEl = document.getElementById('mock-heatmap');
if (!heatEl) return;
const days_lbl = ['M','T','W','T','F','S','S'];
let html = '';
hmap.forEach((row, di) => {
html += `<div class="mock-hm-label">${days_lbl[di]}</div>`;
row.forEach(v => {
const op = v > 0 ? (0.12 + (v / hmMax) * 0.82).toFixed(2) : '0.05';
html += `<div class="mock-hm-cell" style="background:rgba(${rgb},${op})"></div>`;
});
});
heatEl.innerHTML = html;
// Stats
document.getElementById('mock-total').textContent = total.toLocaleString();
document.getElementById('mock-peak').textContent = peak;
document.getElementById('mock-countries').textContent = '12';
}
render();
// Re-render when theme changes so colors update
document.querySelectorAll('.theme-swatch').forEach(s => {
s.addEventListener('click', () => setTimeout(render, 50));
});
})();
// ── Contact form ───────────────────────────────────────────────────────────
const CM_SUBTITLES = {
'request-access': "Interested in getting started? Leave your details and we'll follow up.",
'notify-me-cloud': "We'll let you know as soon as Cloud hosting is available.",
'notify-me-team': "We'll let you know when the Team tier launches.",
};
function openContact(subject) {
document.getElementById('cm-subject').value = subject;
const sub = CM_SUBTITLES[subject] || "Send us a message and we'll get back to you.";
document.getElementById('cm-subtitle').textContent = sub;
document.getElementById('cm-form-content').style.display = '';
document.getElementById('cm-success').style.display = 'none';
document.getElementById('cm-name').value = '';
document.getElementById('cm-email').value = '';
document.getElementById('cm-body').value = '';
const btn = document.getElementById('cm-submit');
btn.disabled = false;
btn.textContent = 'Send message';
document.getElementById('contact-backdrop').classList.add('open');
setTimeout(() => document.getElementById('cm-name').focus(), 80);
}
function closeContact() {
document.getElementById('contact-backdrop').classList.remove('open');
}
function contactBackdropClick(e) {
if (e.target === document.getElementById('contact-backdrop')) closeContact();
}
async function submitContact() {
const name = document.getElementById('cm-name').value.trim();
const email = document.getElementById('cm-email').value.trim();
const subject = document.getElementById('cm-subject').value;
const body = document.getElementById('cm-body').value.trim();
if (!name) { document.getElementById('cm-name').focus(); return; }
if (!email) { document.getElementById('cm-email').focus(); return; }
const btn = document.getElementById('cm-submit');
btn.disabled = true;
btn.textContent = 'Sending…';
try {
const resp = await fetch('/api/contact', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name, email, subject, body })
});
if (resp.ok) {
document.getElementById('cm-form-content').style.display = 'none';
document.getElementById('cm-success').style.display = 'block';
} else {
btn.disabled = false;
btn.textContent = 'Send message';
alert('Something went wrong. Please try again.');
}
} catch {
btn.disabled = false;
btn.textContent = 'Send message';
alert('Network error. Please try again.');
}
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeContact();
});
</script>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB