Files
malenia-frontend/.ref/calculator.html
2026-08-10 12:01:03 +07:00

1212 lines
35 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MaleniaVPN · Compose your plan</title>
<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=Cormorant+Garamond:wght@400;500;600;700&amp;family=Tenor+Sans&amp;display=swap" rel="stylesheet">
<style>
:root {
--bg: #faf7f2;
--surface: #f5f0e8;
--fg: #1a1610;
--muted: #3d3428;
--accent: #c9a84c;
--font-display: 'Cormorant Garamond', Georgia, 'Times New Roman', serif;
--font-body: 'Tenor Sans', 'Helvetica Neue', Arial, sans-serif;
--radius: 8px;
--baseline: 8px;
--hairline: rgba(26, 22, 16, 0.10);
--hairline-strong: rgba(26, 22, 16, 0.18);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
/* Grain overlay */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
opacity: 0.035;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Custom cursor */
.cursor-dot, .cursor-ring {
position: fixed;
top: 0; left: 0;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
transition: opacity 0.2s;
}
.cursor-dot {
width: 5px; height: 5px;
background: var(--fg);
border-radius: 50%;
}
.cursor-ring {
width: 30px; height: 30px;
border: 1px solid var(--muted);
border-radius: 50%;
opacity: 0.4;
transition: width 0.25s, height 0.25s, border-color 0.25s, opacity 0.25s, transform 0.08s linear;
}
.cursor-ring.is-active {
width: 52px; height: 52px;
border-color: var(--accent);
opacity: 0.9;
}
@media (hover: none), (pointer: coarse) {
.cursor-dot, .cursor-ring { display: none; }
* { cursor: auto !important; }
}
@media (hover: hover) and (pointer: fine) {
body, button, a, [data-cursor] { cursor: none; }
}
/* Layout */
.shell {
max-width: 1080px;
margin: 0 auto;
padding: 64px 32px 96px;
}
/* Topbar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 32px;
border-bottom: 1px solid var(--hairline);
}
.logo {
font-family: var(--font-display);
font-size: 22px;
font-weight: 500;
letter-spacing: 0.35em;
text-transform: uppercase;
color: var(--fg);
}
.logo .e { color: var(--accent); }
.nav {
display: flex;
gap: 28px;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
}
.nav a {
color: inherit;
text-decoration: none;
position: relative;
padding: 4px 0;
transition: color 0.3s;
}
.nav a:hover { color: var(--fg); }
.nav a.is-active::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: -2px;
height: 1px;
background: var(--accent);
}
/* Hero */
.hero {
padding: 64px 0 40px;
text-align: center;
border-bottom: 1px solid var(--hairline);
}
.kicker {
font-size: 11px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 22px;
}
.hero-title {
font-family: var(--font-display);
font-weight: 500;
font-size: 52px;
line-height: 1.12;
letter-spacing: -0.02em;
color: var(--fg);
}
.hero-sub {
margin: 18px auto 0;
font-size: 14px;
color: var(--muted);
letter-spacing: 0.02em;
max-width: 46ch;
line-height: 1.7;
}
/* Calculator block */
.calc {
padding: 72px 0 64px;
}
.calc-stage {
display: grid;
grid-template-columns: 1fr;
gap: 56px;
align-items: center;
}
.calc-readout {
text-align: center;
}
.readout-label {
font-size: 11px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 18px;
}
.readout-value {
font-family: var(--font-display);
font-weight: 500;
font-size: 96px;
line-height: 1;
letter-spacing: -0.03em;
color: var(--fg);
display: inline-flex;
align-items: baseline;
gap: 4px;
}
.readout-value .unit {
font-family: var(--font-body);
font-size: 16px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
margin-left: 14px;
align-self: center;
}
.readout-note {
margin-top: 16px;
font-size: 13px;
color: var(--muted);
letter-spacing: 0.02em;
}
/* Slider — the hero accent */
.slider-wrap {
padding: 0 8px;
user-select: none;
}
.slider-axis {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 22px;
}
.slider-axis .axis-now {
color: var(--fg);
font-family: var(--font-display);
font-size: 18px;
letter-spacing: 0;
}
.slider-track-frame {
position: relative;
height: 88px;
padding: 0 4px;
}
.slider-track {
position: absolute;
left: 4px; right: 4px;
top: 50%;
transform: translateY(-50%);
height: 1px;
background: var(--hairline-strong);
}
.slider-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: var(--fg);
transition: width 0.12s cubic-bezier(.2,.7,.2,1);
}
.slider-ticks {
position: absolute;
left: 4px; right: 4px;
top: 50%;
transform: translateY(-50%);
display: flex;
justify-content: space-between;
pointer-events: none;
}
.tick {
width: 1px;
height: 8px;
background: var(--hairline-strong);
transform: translateY(-50%);
position: relative;
}
.tick.is-major {
height: 14px;
background: var(--hairline-strong);
}
.tick-label {
position: absolute;
top: 14px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--muted);
opacity: 0.6;
font-family: var(--font-body);
}
.tick.is-major .tick-label { opacity: 0.9; }
.tick.is-active .tick-label {
color: var(--fg);
opacity: 1;
}
/* Rhombus pointer */
.pointer {
position: absolute;
top: 50%;
left: 0;
width: 26px;
height: 26px;
transform: translate(-50%, -50%) rotate(45deg);
background: var(--accent);
border: 1px solid var(--accent);
box-shadow: 0 6px 16px -4px rgba(201, 168, 76, 0.55), 0 0 0 6px rgba(201, 168, 76, 0.10);
transition: left 0.12s cubic-bezier(.2,.7,.2,1), box-shadow 0.25s, background 0.25s, border-color 0.25s;
z-index: 3;
}
.pointer::before {
content: "";
position: absolute;
inset: 5px;
border: 1px solid rgba(250, 247, 242, 0.7);
}
.slider-wrap.is-dragging .pointer {
box-shadow: 0 10px 24px -4px rgba(201, 168, 76, 0.7), 0 0 0 10px rgba(201, 168, 76, 0.14);
background: var(--fg);
border-color: var(--fg);
}
.slider-wrap.is-dragging .pointer::before {
border-color: rgba(250, 247, 242, 0.85);
}
/* Pointer halo ring (decorative, pulses on drag) */
.pointer-halo {
position: absolute;
top: 50%;
left: 0;
width: 46px;
height: 46px;
transform: translate(-50%, -50%);
border: 1px solid var(--accent);
border-radius: 50%;
opacity: 0;
transition: left 0.12s cubic-bezier(.2,.7,.2,1), opacity 0.3s;
pointer-events: none;
z-index: 2;
}
.slider-wrap.is-dragging .pointer-halo { opacity: 0.5; }
.slider-hit {
position: absolute;
left: 0; right: 0;
top: 0; bottom: 0;
touch-action: none;
}
.slider-caption {
margin-top: 18px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
}
.slider-caption .hint {
display: inline-flex;
align-items: center;
gap: 8px;
}
.slider-caption .hint svg { width: 14px; height: 14px; opacity: 0.7; }
/* Duration selector */
.duration {
margin-top: 56px;
}
.duration-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 18px;
padding: 0 4px;
}
.duration-label {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--muted);
}
.duration-cap {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
}
.duration-cap .save { color: var(--accent); font-family: var(--font-display); font-size: 14px; letter-spacing: 0; margin-left: 6px; }
.duration-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--hairline);
border: 1px solid var(--hairline);
border-radius: var(--radius);
overflow: hidden;
}
.dur {
appearance: none;
font: inherit;
text-align: center;
padding: 22px 10px 18px;
background: var(--surface);
border: none;
color: var(--fg);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
position: relative;
transition: background 0.25s, color 0.25s;
-webkit-tap-highlight-color: transparent;
min-height: 44px;
}
.dur:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--accent);
z-index: 1;
}
.dur:hover { background: color-mix(in srgb, var(--surface) 84%, var(--fg) 4%); }
.dur.is-active {
background: var(--fg);
color: var(--bg);
}
.dur-num {
font-family: var(--font-display);
font-weight: 500;
font-size: 30px;
letter-spacing: -0.02em;
line-height: 1;
}
.dur-unit {
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
}
.dur.is-active .dur-unit { color: rgba(250, 247, 242, 0.62); }
.dur-tag {
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
min-height: 12px;
margin-top: 2px;
}
.dur.is-active .dur-tag { color: var(--accent); }
/* Breakdown */
.breakdown {
margin-top: 64px;
border-top: 1px solid var(--hairline);
}
.breakdown-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 24px;
padding: 22px 4px;
border-bottom: 1px solid var(--hairline);
}
.breakdown-row:last-child { border-bottom: none; }
.breakdown-key {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--muted);
}
.breakdown-key em {
font-style: normal;
color: var(--fg);
font-family: var(--font-display);
font-size: 14px;
letter-spacing: 0;
margin-left: 8px;
}
.breakdown-val {
font-family: var(--font-display);
font-weight: 500;
font-size: 22px;
letter-spacing: -0.01em;
color: var(--fg);
}
.breakdown-val .muted {
font-family: var(--font-body);
font-size: 13px;
color: var(--muted);
letter-spacing: 0.04em;
margin-left: 6px;
}
.breakdown-total {
padding: 26px 4px 24px;
}
.breakdown-total .breakdown-key {
color: var(--fg);
font-size: 12px;
letter-spacing: 0.28em;
}
.breakdown-total .breakdown-val {
font-size: 38px;
}
.breakdown-total .breakdown-val .per {
font-family: var(--font-body);
font-size: 13px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
margin-left: 10px;
}
.breakdown-billed {
padding: 18px 4px 22px;
}
.breakdown-billed .breakdown-key { color: var(--muted); }
.breakdown-billed .breakdown-val {
font-size: 18px;
color: var(--muted);
}
/* Included strip */
.included {
margin-top: 56px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--hairline);
border: 1px solid var(--hairline);
border-radius: var(--radius);
overflow: hidden;
}
.incl-cell {
background: var(--surface);
padding: 28px 24px 26px;
display: flex;
flex-direction: column;
gap: 14px;
align-items: flex-start;
transition: background 0.3s;
}
.incl-cell:hover { background: color-mix(in srgb, var(--surface) 80%, var(--fg) 4%); }
.incl-glyph {
width: 32px; height: 32px;
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
}
.incl-cell:hover .incl-glyph { color: var(--accent); }
.incl-glyph svg { width: 100%; height: 100%; }
.incl-title {
font-family: var(--font-display);
font-weight: 500;
font-size: 19px;
letter-spacing: -0.01em;
color: var(--fg);
}
.incl-sub {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.02em;
line-height: 1.55;
}
/* Addons */
.addons {
margin-top: 64px;
}
.addons-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 26px 4px 18px;
border-top: 1px solid var(--hairline);
}
.addons-title {
font-family: var(--font-display);
font-weight: 500;
font-size: 24px;
letter-spacing: -0.01em;
color: var(--fg);
}
.addons-cap {
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--muted);
}
.addon-list {
display: grid;
gap: 1px;
background: var(--hairline);
border: 1px solid var(--hairline);
border-radius: var(--radius);
overflow: hidden;
}
.addon-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 20px;
width: 100%;
padding: 22px 24px;
background: var(--surface);
border: none;
font: inherit;
text-align: left;
color: var(--fg);
cursor: pointer;
transition: background 0.3s;
-webkit-tap-highlight-color: transparent;
}
.addon-row:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--accent);
}
.addon-row:hover { background: color-mix(in srgb, var(--surface) 82%, var(--fg) 4%); }
.addon-row.is-selected { background: color-mix(in srgb, var(--surface) 78%, var(--accent) 10%); }
.addon-toggle {
width: 18px; height: 18px;
border: 1px solid var(--hairline-strong);
border-radius: 3px;
transform: rotate(45deg);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.25s, border-color 0.25s;
position: relative;
}
.addon-row.is-selected .addon-toggle {
background: var(--accent);
border-color: var(--accent);
}
.addon-toggle::after {
content: "";
position: absolute;
inset: 3px;
border: 1px solid rgba(250, 247, 242, 0.75);
opacity: 0;
}
.addon-row.is-selected .addon-toggle::after { opacity: 1; }
.addon-info { display: flex; flex-direction: column; gap: 4px; }
.addon-name {
font-family: var(--font-display);
font-weight: 500;
font-size: 18px;
letter-spacing: -0.01em;
color: var(--fg);
}
.addon-desc {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.02em;
line-height: 1.5;
}
.addon-price {
font-family: var(--font-display);
font-weight: 500;
font-size: 18px;
letter-spacing: -0.01em;
color: var(--fg);
white-space: nowrap;
}
.addon-price .plus { color: var(--muted); font-family: var(--font-body); font-size: 12px; margin-right: 6px; letter-spacing: 0.04em; }
/* CTA */
.cta {
margin-top: 56px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
}
.btn {
appearance: none;
font-family: var(--font-body);
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
padding: 16px 38px;
border-radius: var(--radius);
border: 1px solid var(--hairline-strong);
background: transparent;
color: var(--fg);
transition: background 0.25s, color 0.25s, border-color 0.25s, transform 0.15s;
min-height: 44px;
}
.btn-primary {
background: var(--fg);
color: var(--bg);
border-color: var(--fg);
}
.btn-primary:hover {
background: var(--accent);
color: var(--fg);
border-color: var(--accent);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.35);
border-color: var(--accent);
}
.btn:active { transform: translateY(1px); }
.cta-note {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
}
/* Footer rule */
.foot {
margin-top: 64px;
padding-top: 28px;
border-top: 1px solid var(--hairline);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
}
/* Reveal */
.reveal {
opacity: 0;
transform: translateY(14px);
transition: opacity 0.7s cubic-bezier(.2,.7,.2,1), transform 0.7s cubic-bezier(.2,.7,.2,1);
}
.reveal.is-in { opacity: 1; transform: translateY(0); }
/* Responsive */
@media (max-width: 860px) {
.included { grid-template-columns: 1fr; }
.readout-value { font-size: 76px; }
.hero-title { font-size: 42px; }
}
@media (max-width: 560px) {
.shell { padding: 40px 20px 64px; }
.topbar { padding-bottom: 24px; }
.nav { gap: 18px; }
.nav a { font-size: 10px; }
.hero { padding: 44px 0 32px; }
.hero-title { font-size: 34px; }
.hero-sub { font-size: 13px; }
.calc { padding: 48px 0 40px; }
.readout-value { font-size: 60px; }
.readout-value .unit { font-size: 13px; margin-left: 10px; }
.breakdown-total .breakdown-val { font-size: 30px; }
.duration-grid { grid-template-columns: repeat(2, 1fr); }
.dur-num { font-size: 26px; }
.foot { flex-direction: column; gap: 10px; text-align: center; }
.tick-label { font-size: 9px; }
.cta .btn { width: 100%; }
.addon-row { grid-template-columns: auto 1fr auto; gap: 14px; padding: 18px 18px; }
.addon-name { font-size: 16px; }
@media (max-width: 400px) {
.addon-row { grid-template-columns: auto 1fr; }
.addon-price { grid-column: 2; }
}
}
</style>
</head>
<body>
<div class="cursor-ring" aria-hidden="true"></div>
<div class="cursor-dot" aria-hidden="true"></div>
<main class="shell">
<!-- Topbar -->
<header class="topbar" data-od-id="topbar">
<div class="logo" data-od-id="brand">Mal<span class="e">e</span>nia</div>
<nav class="nav" data-od-id="primary-nav">
<a href="#">Account</a>
<a href="#" class="is-active">Plan</a>
<a href="#">Devices</a>
<a href="#">Support</a>
</nav>
</header>
<!-- Hero -->
<section class="hero reveal" data-od-id="calc-hero">
<div class="kicker">Compose your plan</div>
<h1 class="hero-title">A circle, sized to you.</h1>
<p class="hero-sub">Choose how many devices share your freedom. The price scales quietly — one rate per seat, no tiers, no surcharges.</p>
</section>
<!-- Calculator -->
<section class="calc" data-od-id="calculator">
<div class="calc-stage reveal">
<!-- Live readout -->
<div class="calc-readout" data-od-id="calc-readout">
<div class="readout-label">Estimated price · monthly</div>
<div class="readout-value">
<span id="price-value">$18</span>
<span class="unit">USD / mo</span>
</div>
<div class="readout-note" id="readout-note">For 6 devices · $3.00 per seat</div>
</div>
<!-- Slider -->
<div class="slider-wrap" id="slider-wrap" data-od-id="device-slider" data-cursor="">
<div class="slider-axis">
<span>Devices</span>
<span><span class="axis-now" id="axis-now">6</span> &nbsp;seats</span>
</div>
<div class="slider-track-frame" id="track-frame">
<div class="slider-track">
<div class="slider-fill" id="slider-fill"></div>
</div>
<div class="slider-ticks" id="slider-ticks" aria-hidden="true"></div>
<div class="pointer-halo" id="pointer-halo" aria-hidden="true"></div>
<div class="pointer" id="pointer" role="slider" tabindex="0" aria-label="Number of devices" aria-valuemin="3" aria-valuemax="12" aria-valuenow="6" aria-valuetext="6 devices"></div>
<div class="slider-hit" id="slider-hit"></div>
</div>
<div class="slider-caption">
<span class="hint">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12h8M12 8l-4 4 4 4M12 8l4 4-4 4"></path></svg>
Drag the rhombus · arrow keys to fine-tune
</span>
<span>Range 3 – 12</span>
</div>
</div>
</div>
<!-- Duration -->
<div class="duration reveal" data-od-id="duration-selector">
<div class="duration-head">
<span class="duration-label">Commitment</span>
<span class="duration-cap" id="duration-cap">Billed monthly</span>
</div>
<div class="duration-grid" id="duration-grid" role="radiogroup" aria-label="Subscription duration"></div>
</div>
<!-- Breakdown -->
<div class="breakdown reveal" data-od-id="price-breakdown">
<div class="breakdown-row">
<div class="breakdown-key">Devices <em id="bd-devices">6</em></div>
<div class="breakdown-val"><span id="bd-per-seat">$3.00</span><span class="muted">/ seat</span></div>
</div>
<div class="breakdown-row" id="bd-addons-row" hidden="">
<div class="breakdown-key">Add-ons <em id="bd-addons-count">0</em></div>
<div class="breakdown-val"><span id="bd-addons-sum">$0</span><span class="muted">/ month</span></div>
</div>
<div class="breakdown-row" id="bd-commit-row" hidden="">
<div class="breakdown-key">Commitment <em id="bd-months">1</em></div>
<div class="breakdown-val"><span id="bd-discount">$0</span><span class="muted">saved / month</span></div>
</div>
</div>
<!-- Addons -->
<div class="addons reveal" data-od-id="addons" id="addons-section">
<div class="addons-head">
<div class="addons-title">Refine your circle</div>
<div class="addons-cap">Optional add-ons · placeholder</div>
</div>
<div class="addon-list" id="addon-list"></div>
</div>
<!-- CTA -->
<div class="cta reveal" data-od-id="plan-cta">
<button class="btn btn-primary" data-od-id="confirm-plan">Confirm plan</button>
<div class="cta-note">Plan adjusts at next renewal · No lock-in</div>
</div>
</section>
<!-- Footer -->
<footer class="foot" data-od-id="footer">
<span>MaleniaVPN · Solitude plan</span>
<span>Estimate only</span>
</footer>
</main>
<script>
(function() {
// ----- Slider -----
const MIN = 3, MAX = 12;
const wrap = document.getElementById('slider-wrap');
const frame = document.getElementById('track-frame');
const hit = document.getElementById('slider-hit');
const fill = document.getElementById('slider-fill');
const pointer = document.getElementById('pointer');
const halo = document.getElementById('pointer-halo');
const ticksHost = document.getElementById('slider-ticks');
const axisNow = document.getElementById('axis-now');
const priceValue = document.getElementById('price-value');
const readoutNote = document.getElementById('readout-note');
const bdDevices = document.getElementById('bd-devices');
const bdPerSeat = document.getElementById('bd-per-seat');
const bdTotal = document.getElementById('bd-total');
let value = 6;
let dragging = false;
// ----- Duration -----
// Placeholder pricing model — frontend will replace this; design only.
// Longer commitments earn a quiet discount on the base subscription.
const DURATIONS = [
{ months: 1, mult: 1.00, tag: 'Standard' },
{ months: 3, mult: 0.95, tag: 'Save 5%' },
{ months: 6, mult: 0.90, tag: 'Save 10%' },
{ months: 12, mult: 0.80, tag: 'Save 20%' }
];
let duration = DURATIONS[0];
const durationGrid = document.getElementById('duration-grid');
const durationCap = document.getElementById('duration-cap');
function buildDurations() {
durationGrid.innerHTML = '';
DURATIONS.forEach((d) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'dur' + (d === duration ? ' is-active' : '');
b.dataset.months = d.months;
b.setAttribute('role', 'radio');
b.setAttribute('aria-checked', d === duration ? 'true' : 'false');
b.dataset.odId = 'duration-' + d.months;
const num = document.createElement('span');
num.className = 'dur-num';
num.textContent = d.months;
const unit = document.createElement('span');
unit.className = 'dur-unit';
unit.textContent = d.months === 1 ? 'month' : 'months';
const tag = document.createElement('span');
tag.className = 'dur-tag';
tag.textContent = d.tag;
b.append(num, unit, tag);
b.addEventListener('click', () => {
duration = d;
syncDurations();
render();
});
durationGrid.appendChild(b);
});
}
function syncDurations() {
durationGrid.querySelectorAll('.dur').forEach((b) => {
const m = Number(b.dataset.months);
const d = DURATIONS.find(x => x.months === m);
const on = d === duration;
b.classList.toggle('is-active', on);
b.setAttribute('aria-checked', on ? 'true' : 'false');
});
}
// Visual pricing model — frontend will replace this; design only.
function priceFor(n) {
// gentle declining per-seat rate as the circle grows
const perSeat = 3.00 - (n - 3) * 0.12;
return { perSeat, total: perSeat * n };
}
function fmt(v) {
return (Math.round(v * 100) / 100).toFixed(2).replace(/\.00$/, '');
}
function money(v) {
const s = (Math.round(v * 100) / 100).toFixed(2);
return '$' + s.replace(/\.00$/, '');
}
// Build ticks
function buildTicks() {
ticksHost.innerHTML = '';
for (let i = MIN; i <= MAX; i++) {
const t = document.createElement('div');
t.className = 'tick' + ((i === MIN || i === MAX || i === Math.round((MIN+MAX)/2)) ? ' is-major' : '');
t.dataset.i = i;
const lab = document.createElement('span');
lab.className = 'tick-label';
lab.textContent = i;
t.appendChild(lab);
ticksHost.appendChild(t);
}
}
function valueToPct(v) { return (v - MIN) / (MAX - MIN); }
function pctToValue(p) {
let pClamped = Math.max(0, Math.min(1, p));
return Math.round(MIN + pClamped * (MAX - MIN));
}
function render() {
const pct = valueToPct(value);
const trackInnerW = Math.max(0, frame.clientWidth - 8);
const x = 4 + pct * trackInnerW;
pointer.style.left = x + 'px';
halo.style.left = x + 'px';
fill.style.width = (x - 4) + 'px';
axisNow.textContent = value;
// ticks active state
ticksHost.querySelectorAll('.tick').forEach(t => {
t.classList.toggle('is-active', Number(t.dataset.i) === value);
});
// pricing readout
const { perSeat, total } = priceFor(value);
const addons = addonsTotal();
const mult = duration.mult;
const discountedPerSeat = perSeat * mult;
const discountedBase = total * mult;
const savingsMonthly = total - discountedBase;
const grand = discountedBase + addons;
const periodTotal = grand * duration.months;
priceValue.textContent = money(grand);
readoutNote.textContent = 'For ' + value + ' devices · ' + money(discountedPerSeat) + ' per seat · ' + duration.months + (duration.months === 1 ? ' month' : ' months');
bdDevices.textContent = value;
bdPerSeat.textContent = money(discountedPerSeat);
const count = selectedAddons.size;
if (count > 0) {
bdAddonsRow.hidden = false;
bdAddonsCount.textContent = count;
bdAddonsSum.textContent = money(addons);
} else {
bdAddonsRow.hidden = true;
}
if (duration.months > 1) {
bdCommitRow.hidden = false;
bdMonths.textContent = duration.months;
bdDiscount.textContent = money(savingsMonthly);
bdPeriodRow.hidden = false;
bdPeriod.textContent = money(periodTotal);
bdPeriodLabel.textContent = '· ' + duration.months + ' months';
durationCap.innerHTML = 'Billed every ' + duration.months + ' months <span class="save">' + duration.tag + '</span>';
} else {
bdCommitRow.hidden = true;
bdPeriodRow.hidden = true;
durationCap.textContent = 'Billed monthly';
}
bdTotal.textContent = money(grand);
pointer.setAttribute('aria-valuenow', value);
pointer.setAttribute('aria-valuetext', value + ' devices');
}
function valueFromClientX(clientX) {
const rect = frame.getBoundingClientRect();
const innerLeft = rect.left + 4;
const innerW = rect.width - 8;
const p = (clientX - innerLeft) / innerW;
return pctToValue(p);
}
function setCursor(clientX, clientY) {
if (!fine) return;
mx = clientX; my = clientY;
dot.style.left = mx + 'px'; dot.style.top = my + 'px';
}
function startDrag(clientX, clientY) {
dragging = true;
wrap.classList.add('is-dragging');
setCursor(clientX, clientY);
value = valueFromClientX(clientX);
render();
}
function moveDrag(clientX, clientY) {
if (!dragging) return;
setCursor(clientX, clientY);
value = valueFromClientX(clientX);
render();
}
function endDrag() {
if (!dragging) return;
dragging = false;
wrap.classList.remove('is-dragging');
}
// Pointer events
hit.addEventListener('pointerdown', (e) => {
e.preventDefault();
hit.setPointerCapture(e.pointerId);
startDrag(e.clientX, e.clientY);
});
hit.addEventListener('pointermove', (e) => moveDrag(e.clientX, e.clientY));
hit.addEventListener('pointerup', (e) => { endDrag(); try { hit.releasePointerCapture(e.pointerId); } catch(_){} });
hit.addEventListener('pointercancel', endDrag);
// Click anywhere on track frame jumps
frame.addEventListener('click', (e) => {
if (dragging) return;
if (e.target.closest('.pointer')) return;
value = valueFromClientX(e.clientX);
render();
});
// Keyboard on the rhombus
pointer.addEventListener('keydown', (e) => {
let handled = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') value = Math.min(MAX, value + (e.shiftKey ? 2 : 1));
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') value = Math.max(MIN, value - (e.shiftKey ? 2 : 1));
else if (e.key === 'Home') value = MIN;
else if (e.key === 'End') value = MAX;
else handled = false;
if (handled) { e.preventDefault(); render(); }
});
pointer.addEventListener('focus', () => wrap.classList.add('is-dragging'));
pointer.addEventListener('blur', () => wrap.classList.remove('is-dragging'));
// Re-render on resize (positions are pixel-based)
window.addEventListener('resize', render);
buildTicks();
buildDurations();
// initial render after layout
requestAnimationFrame(render);
// ----- Addons -----
// Placeholder dataset — replace with backend response.
// Addon shape: { id, name, description, price }
const ADDONS = [
{ id: 'dedicated-ip', name: 'Dedicated IP', description: 'A private exit address reserved for your circle.', price: 4 },
{ id: 'multi-hop', name: 'Multi-Hop', description: 'Cascade through two regions for layered solitude.', price: 3 },
{ id: 'tracker-block', name: 'Tracker Block', description: 'Quiet blocking of ads and telemetry at the network edge.', price: 2 }
];
const selectedAddons = new Set();
const addonList = document.getElementById('addon-list');
const addonSection = document.getElementById('addons-section');
const bdAddonsRow = document.getElementById('bd-addons-row');
const bdAddonsCount = document.getElementById('bd-addons-count');
const bdAddonsSum = document.getElementById('bd-addons-sum');
const bdCommitRow = document.getElementById('bd-commit-row');
const bdMonths = document.getElementById('bd-months');
const bdDiscount = document.getElementById('bd-discount');
const bdPeriodRow = document.getElementById('bd-period-row');
const bdPeriod = document.getElementById('bd-period');
const bdPeriodLabel = document.getElementById('bd-period-label');
function renderAddons() {
// If the backend returns no addons, hide the entire section and bail.
if (!ADDONS || ADDONS.length === 0) {
if (addonSection) addonSection.hidden = true;
addonList.innerHTML = '';
selectedAddons.clear();
return;
}
if (addonSection) addonSection.hidden = false;
addonList.innerHTML = '';
ADDONS.forEach((a) => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'addon-row';
row.dataset.odId = 'addon-' + a.id;
row.setAttribute('aria-pressed', 'false');
row.innerHTML =
'<span class="addon-toggle" aria-hidden="true"></span>' +
'<span class="addon-info">' +
'<span class="addon-name"></span>' +
'<span class="addon-desc"></span>' +
'</span>' +
'<span class="addon-price"><span class="plus">+</span><span class="amt"></span>$</span>';
row.querySelector('.addon-name').textContent = a.name;
row.querySelector('.addon-desc').textContent = a.description;
row.querySelector('.amt').textContent = a.price;
row.addEventListener('click', () => {
if (selectedAddons.has(a.id)) selectedAddons.delete(a.id);
else selectedAddons.add(a.id);
syncAddonRow(row, a.id);
render();
});
addonList.appendChild(row);
syncAddonRow(row, a.id);
});
}
function syncAddonRow(row, id) {
const on = selectedAddons.has(id);
row.classList.toggle('is-selected', on);
row.setAttribute('aria-pressed', on ? 'true' : 'false');
}
function addonsTotal() {
return ADDONS.filter(a => selectedAddons.has(a.id)).reduce((s, a) => s + a.price, 0);
}
renderAddons();
// ----- Reveal observer -----
const io = new IntersectionObserver((entries) => {
entries.forEach(en => {
if (en.isIntersecting) {
en.target.classList.add('is-in');
io.unobserve(en.target);
}
});
}, { threshold: 0.12 });
document.querySelectorAll('.reveal').forEach((el, i) => {
el.style.transitionDelay = (i * 90) + 'ms';
io.observe(el);
});
// ----- Custom cursor -----
const dot = document.querySelector('.cursor-dot');
const ring = document.querySelector('.cursor-ring');
let mx = 0, my = 0, rx = 0, ry = 0;
const fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
if (fine) {
window.addEventListener('mousemove', (e) => {
mx = e.clientX; my = e.clientY;
dot.style.left = mx + 'px'; dot.style.top = my + 'px';
});
function tick() {
rx += (mx - rx) * 0.18; ry += (my - ry) * 0.18;
ring.style.left = rx + 'px'; ring.style.top = ry + 'px';
requestAnimationFrame(tick);
}
tick();
document.addEventListener('mouseover', (e) => {
const t = e.target.closest('a, button, [data-cursor]');
ring.classList.toggle('is-active', !!t);
});
} else {
dot.style.display = 'none'; ring.style.display = 'none';
}
})();
</script>
</body></html>