Skip to main content

max / makenotwork

6.6 KB · 167 lines History Blame Raw
1 /* What's New modal + "New" badge mechanism.
2 *
3 * Two related UI surfaces that share localStorage as their source of truth:
4 *
5 * 1. Auto-show modal on a "feature version" bump. FEATURE_VERSION is an
6 * opaque string controlled here (not CARGO_PKG_VERSION). Edit it when
7 * you want to fire the modal, typically at the end of a sprint when
8 * the changelog has something worth surfacing. Skipping a bump is
9 * fine: nothing happens if FEATURE_VERSION matches the last-seen
10 * version in localStorage.
11 *
12 * 2. "New" badge on individual links/buttons. Mark any element with
13 * data-new-until="YYYY-MM-DD", JS adds a `is-new` class while today
14 * is before the date. CSS renders the dot. Once the date passes the
15 * class is removed at page load and the dot disappears.
16 *
17 * Both deliberately avoid server cooperation: no API to call, no template
18 * plumbing, no version-detection brittleness. The whole feature lives in
19 * this file plus the CSS for `.is-new`.
20 */
21
22 (function () {
23 'use strict';
24
25 // ── What's New modal ──────────────────────────────────────────────
26
27 /// Bump this when /changelog has shipped something users should see.
28 /// Setting it to a NEW value triggers the modal once per user.
29 var FEATURE_VERSION = 'v0.8-synckit-per-key';
30
31 /// Summary shown in the modal body. Keep to 1–3 sentences; the link
32 /// to /changelog is the canonical full list.
33 var FEATURE_HEADLINE = 'SyncKit per-key storage';
34 var FEATURE_BODY =
35 "SyncKit apps now bill per developer-defined key rather than per app, " +
36 "with mini-gauges in the dashboard and per-key warning emails. " +
37 "Existing SyncKit clients keep working; the SDK signature is updated " +
38 "for new integrations.";
39
40 var STORAGE_KEY = 'mnw_seen_feature_version';
41
42 function safeGet(key) {
43 try { return localStorage.getItem(key); } catch (e) { return null; }
44 }
45 function safeSet(key, value) {
46 try { localStorage.setItem(key, value); } catch (e) { /* ignore */ }
47 }
48
49 /// Show the modal regardless of seen state. Used by the "What's new"
50 /// link in the footer.
51 function showWhatsNewModal() {
52 var existing = document.getElementById('whats-new-modal');
53 if (existing) { existing.remove(); return; }
54
55 var overlay = document.createElement('div');
56 overlay.id = 'whats-new-modal';
57 overlay.className = 'modal-overlay';
58 overlay.style.display = 'flex';
59 overlay.onclick = function (e) {
60 if (e.target === overlay) {
61 overlay.remove();
62 safeSet(STORAGE_KEY, FEATURE_VERSION);
63 }
64 };
65
66 var content = document.createElement('div');
67 content.className = 'modal-content';
68 content.style.maxWidth = '480px';
69 content.style.padding = '2rem';
70
71 var header = document.createElement('div');
72 header.className = 'modal-header';
73 header.style.marginBottom = '1rem';
74 var h2 = document.createElement('h2');
75 h2.textContent = "What's new: " + FEATURE_HEADLINE;
76 header.appendChild(h2);
77 var closeBtn = document.createElement('button');
78 closeBtn.type = 'button';
79 closeBtn.className = 'modal-close';
80 closeBtn.setAttribute('aria-label', 'Dismiss');
81 closeBtn.innerHTML = '×';
82 closeBtn.onclick = function () {
83 overlay.remove();
84 safeSet(STORAGE_KEY, FEATURE_VERSION);
85 };
86 header.appendChild(closeBtn);
87
88 var body = document.createElement('p');
89 body.textContent = FEATURE_BODY;
90
91 // /changelog 404s until a changelog project is published, and base.html
92 // only renders the footer link while it is. Reuse that server-side guard
93 // rather than duplicating the check here.
94 var link = null;
95 if (document.querySelector('.site-footer-links a[href="/changelog"]')) {
96 link = document.createElement('a');
97 link.href = '/changelog';
98 link.textContent = 'Full changelog →';
99 link.className = 'section-link';
100 link.style.display = 'inline-block';
101 link.style.marginTop = '0.75rem';
102 }
103
104 content.appendChild(header);
105 content.appendChild(body);
106 if (link) {
107 content.appendChild(link);
108 }
109 overlay.appendChild(content);
110 document.body.appendChild(overlay);
111 }
112
113 /// Auto-show on first visit after a feature-version bump. Records the
114 /// seen version in localStorage so dismissal sticks across reloads.
115 function maybeAutoShowWhatsNew() {
116 var seen = safeGet(STORAGE_KEY);
117 if (seen === FEATURE_VERSION) return;
118 // First-ever visit also seeds the storage, no modal flash for
119 // genuinely new users (they're already getting onboarded).
120 if (seen === null) {
121 safeSet(STORAGE_KEY, FEATURE_VERSION);
122 return;
123 }
124 showWhatsNewModal();
125 }
126
127 // Expose for footer link click + onboarding flows.
128 window.showWhatsNewModal = showWhatsNewModal;
129
130 // ── "New" badge ────────────────────────────────────────────────────
131
132 /// Walk every `[data-new-until]` element. If today < that date, mark
133 /// it `.is-new` so the CSS dot renders. Otherwise strip the attribute
134 /// so it doesn't get re-evaluated on later page loads.
135 function applyNewBadges() {
136 var today = new Date();
137 today.setHours(0, 0, 0, 0);
138 var nodes = document.querySelectorAll('[data-new-until]');
139 for (var i = 0; i < nodes.length; i++) {
140 var el = nodes[i];
141 var until = new Date(el.getAttribute('data-new-until'));
142 if (isNaN(until.getTime())) {
143 el.removeAttribute('data-new-until');
144 continue;
145 }
146 if (today <= until) {
147 el.classList.add('is-new');
148 } else {
149 el.removeAttribute('data-new-until');
150 el.classList.remove('is-new');
151 }
152 }
153 }
154
155 // ── Init ──────────────────────────────────────────────────────────
156
157 if (document.readyState === 'loading') {
158 document.addEventListener('DOMContentLoaded', function () {
159 applyNewBadges();
160 maybeAutoShowWhatsNew();
161 });
162 } else {
163 applyNewBadges();
164 maybeAutoShowWhatsNew();
165 }
166 })();
167