summaryrefslogtreecommitdiffstats
path: root/main.js
blob: f0019ec34d57db4e9a0b2274a3641601a7903970 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
// Settings. Replace the two placeholder IDs before launch. Anything that still
// starts with REPLACE_WITH_ is treated as unset and the related feature stays off.
const TALLY_FORM_ID = '7RQlb9';
// Shared with ugmi.ca (no free Umami site slots). Filter by Hostname or the tag below.
const UMAMI_WEBSITE_ID = 'cb84c6d3-69d1-4cdb-93b9-3589b58f981b';
const UMAMI_TAG = 'watnow';

// Umami Cloud script. Change this if you self-host Umami.
const UMAMI_SCRIPT_URL = 'https://cloud.umami.is/script.js';
// Only count visits on the real domain, so local testing does not pollute the numbers.
const UMAMI_DOMAINS = 'watnow.ugmi.ca';

// Query parameters copied from the page URL into the Tally form, so a link like
// watnow.ugmi.ca/?ref=reel3 shows up in the submission. Add a matching hidden field in Tally.
const FORWARDED_PARAMS = ['ref', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content'];

(function () {
  'use strict';

  function isSet(value) {
    return typeof value === 'string' && value.trim() !== '' && value.indexOf('REPLACE_WITH_') !== 0;
  }

  // Analytics

  var pending = [];

  function umamiReady() {
    return typeof window.umami === 'object' && window.umami !== null && typeof window.umami.track === 'function';
  }

  function track(eventName) {
    try {
      if (umamiReady()) {
        window.umami.track(eventName);
      } else if (isSet(UMAMI_WEBSITE_ID)) {
        pending.push(eventName);
      }
    } catch (err) {
      // Analytics must never break the page.
    }
  }

  function flushPending() {
    while (pending.length && umamiReady()) {
      try {
        window.umami.track(pending.shift());
      } catch (err) {
        return;
      }
    }
  }

  function loadUmami() {
    if (!isSet(UMAMI_WEBSITE_ID)) return;
    var script = document.createElement('script');
    script.defer = true;
    script.src = UMAMI_SCRIPT_URL;
    script.setAttribute('data-website-id', UMAMI_WEBSITE_ID);
    if (UMAMI_DOMAINS) script.setAttribute('data-domains', UMAMI_DOMAINS);
    if (UMAMI_TAG) script.setAttribute('data-tag', UMAMI_TAG);
    script.onload = flushPending;
    document.head.appendChild(script);
  }

  // Waitlist form

  function forwardedParams() {
    var out = new URLSearchParams();
    var current = new URLSearchParams(window.location.search);
    FORWARDED_PARAMS.forEach(function (key) {
      var value = current.get(key);
      if (value) out.set(key, value.slice(0, 100));
    });
    return out;
  }

  function mountTally() {
    var slot = document.querySelector('[data-form-slot]');
    if (!slot) return;

    // Without a form ID the "waitlist opens soon" message in the HTML stays in place.
    if (!isSet(TALLY_FORM_ID)) {
      slot.setAttribute('data-state', 'placeholder');
      return;
    }

    var formId = encodeURIComponent(TALLY_FORM_ID.trim());
    var passThrough = forwardedParams();

    var embedParams = new URLSearchParams({
      alignLeft: '1',
      hideTitle: '1',
      transparentBackground: '1',
      dynamicHeight: '1'
    });
    passThrough.forEach(function (value, key) {
      embedParams.set(key, value);
    });

    var sheet = document.createElement('div');
    sheet.className = 'form-sheet';

    var iframe = document.createElement('iframe');
    iframe.setAttribute('data-tally-src', 'https://tally.so/embed/' + formId + '?' + embedParams.toString());
    iframe.setAttribute('loading', 'lazy');
    iframe.setAttribute('width', '100%');
    iframe.setAttribute('height', '320');
    iframe.setAttribute('title', 'Waitlist signup form');
    sheet.appendChild(iframe);

    // Instagram's in-app browser sometimes blocks embeds, so offer the hosted form too.
    var help = document.createElement('p');
    help.className = 'form-help';
    help.appendChild(document.createTextNode('Form not loading? '));
    var link = document.createElement('a');
    var query = passThrough.toString();
    link.href = 'https://tally.so/r/' + formId + (query ? '?' + query : '');
    link.target = '_blank';
    link.rel = 'noopener';
    link.textContent = 'Open it in a new tab';
    help.appendChild(link);
    help.appendChild(document.createTextNode('.'));

    var pending = document.createElement('p');
    pending.className = 'form-pending';
    pending.textContent = 'Loading the form.';
    sheet.insertBefore(pending, iframe);

    var settled = false;

    function settle(failed) {
      if (settled) return;
      settled = true;
      if (pending.parentNode) pending.parentNode.removeChild(pending);
      if (!failed) return;
      var note = document.createElement('p');
      note.className = 'form-pending';
      note.textContent = 'The form did not load here.';
      sheet.replaceChildren(note);
      slot.setAttribute('data-state', 'blocked');
    }

    // Without a src the browser fires load for the initial about:blank document, which would
    // settle the slot before the embed had a chance to paint. Only the real src counts.
    iframe.addEventListener('load', function () { if (iframe.getAttribute('src')) settle(false); });

    // The embed is lazy, so the browser only fetches it once the form is close to the screen.
    // Start the give-up timer from that moment, or a visitor who takes a while to scroll down
    // would find the form already marked as failed.
    function startGiveUpTimer() { setTimeout(function () { settle(true); }, 6000); }
    if ('IntersectionObserver' in window) {
      var nearView = new IntersectionObserver(function (entries) {
        if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
        nearView.disconnect();
        startGiveUpTimer();
      }, { rootMargin: '200px 0px' });
      nearView.observe(slot);
    } else {
      startGiveUpTimer();
    }

    slot.replaceChildren(sheet, help);
    slot.setAttribute('data-state', 'live');

    function loadEmbeds() {
      if (typeof window.Tally === 'object' && window.Tally !== null && typeof window.Tally.loadEmbeds === 'function') {
        window.Tally.loadEmbeds();
        return;
      }
      document.querySelectorAll('iframe[data-tally-src]:not([src])').forEach(function (frame) {
        frame.src = frame.getAttribute('data-tally-src');
      });
    }

    var script = document.createElement('script');
    script.src = 'https://tally.so/widgets/embed.js';
    script.async = true;
    script.onload = loadEmbeds;
    script.onerror = loadEmbeds;
    document.body.appendChild(script);
  }

  // Fires once, the first time the form area scrolls into view.
  function watchWaitlistView() {
    var target = document.querySelector('[data-form-slot]');
    if (!target || !('IntersectionObserver' in window)) return;
    var observer = new IntersectionObserver(function (entries) {
      for (var i = 0; i < entries.length; i++) {
        if (entries[i].isIntersecting) {
          track('waitlist-view');
          observer.disconnect();
          return;
        }
      }
    }, { threshold: 0.3 });
    observer.observe(target);
  }

  // Tally posts a message to the parent page when someone submits the embedded form.
  var submitted = false;

  function onMessage(event) {
    if (!/^https:\/\/([a-z0-9-]+\.)?tally\.so$/.test(event.origin || '')) return;
    var data = event.data;
    if (typeof data === 'string') {
      if (data.indexOf('Tally.FormSubmitted') === -1) return;
      try {
        data = JSON.parse(data);
      } catch (err) {
        return;
      }
    }
    if (!data || data.event !== 'Tally.FormSubmitted' || submitted) return;
    submitted = true;
    track('waitlist-submit');
  }

  // Demo dates
  //
  // The sample deadlines are written relative to the visitor's week, so the panel never
  // shows a week that has already passed. Monday to Friday use this week's Monday;
  // Saturday and Sunday use the coming Monday. data-day is days after that Monday.

  var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
  var MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

  function weekAnchor(now) {
    var d = new Date(now.getFullYear(), now.getMonth(), now.getDate());
    var wd = d.getDay();
    var offset = wd === 0 ? 1 : wd === 6 ? 2 : 1 - wd;
    d.setDate(d.getDate() + offset);
    return d;
  }

  function plusDays(base, n) {
    var d = new Date(base.getTime());
    d.setDate(d.getDate() + n);
    return d;
  }

  function fillDemoDates() {
    var anchor = weekAnchor(new Date());
    document.querySelectorAll('[data-day]').forEach(function (el) {
      var d = plusDays(anchor, parseInt(el.getAttribute('data-day'), 10) || 0);
      var fmt = el.getAttribute('data-fmt');
      var text;
      if (fmt === 'wd') {
        text = DAY_NAMES[d.getDay()];
      } else if (fmt === 'range') {
        var end = plusDays(anchor, parseInt(el.getAttribute('data-to'), 10) || 0);
        text = MONTH_NAMES[d.getMonth()] + ' ' + d.getDate() + ' to ' +
          (end.getMonth() === d.getMonth() ? '' : MONTH_NAMES[end.getMonth()] + ' ') + end.getDate();
      } else {
        text = DAY_NAMES[d.getDay()] + ', ' + MONTH_NAMES[d.getMonth()] + ' ' + d.getDate();
      }
      if (el.textContent !== text) el.textContent = text;
    });
  }

  // Hero demo: a prof moves CS 341 Assignment 1 from this week to next week.
  //
  // The list is one flex column and the move is a change of CSS order, so the
  // start and end states live in styles.css. This code only plays the steps
  // between them, the same way the extension's side panel does.

  function heroDemo() {
    var root = document.documentElement;
    var demo = document.querySelector('[data-demo]');
    if (!demo) return;
    var list = demo.querySelector('.p-list');
    var mover = demo.querySelector('[data-mover]');
    var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');

    function showEndState() {
      root.classList.remove('anim');
      demo.setAttribute('data-step', 'end');
      mover.classList.remove('is-striking', 'is-travelling', 'is-landing');
    }

    if (!list || !mover || reduce.matches || !root.classList.contains('anim') || !('IntersectionObserver' in window)) {
      showEndState();
      return;
    }

    var EASE_IN_OUT = 'cubic-bezier(0.65, 0, 0.35, 1)';
    var inView = false;
    var running = false;
    var stopped = false;
    var firstRun = true;
    var hovered = false;
    // The move plays a few times and then rests on the moved row, so the panel
    // never keeps moving while someone reads the rest of the page.
    var playsLeft = 3;

    function sleep(ms) {
      return new Promise(function (resolve) { setTimeout(resolve, ms); });
    }

    function canPlay() {
      return inView && !hovered && !document.hidden && !stopped;
    }

    function tops() {
      var map = new Map();
      Array.prototype.forEach.call(list.children, function (el) {
        map.set(el, el.getBoundingClientRect().top);
      });
      return map;
    }

    function resetToStart() {
      if (demo.getAttribute('data-step') !== 'end') return Promise.resolve();
      var out = list.animate([{ opacity: 1 }, { opacity: 0.25 }], { duration: 160, easing: EASE_IN_OUT, fill: 'forwards' });
      return out.finished.then(function () {
        demo.setAttribute('data-step', 'start');
        mover.classList.remove('is-landing');
        list.animate([{ opacity: 0.25 }, { opacity: 1 }], { duration: 240, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
        out.cancel();
      });
    }

    function play() {
      if (running || !canPlay()) return;
      running = true;

      resetToStart()
        .then(function () { return sleep(firstRun ? 900 : 1500); })
        .then(function () {
          firstRun = false;
          if (stopped) throw new Error('stopped');
          playsLeft -= 1;
          // 1. The old date gets struck out.
          mover.classList.add('is-striking');
          return sleep(780);
        })
        .then(function () {
          if (stopped) throw new Error('stopped');
          // 2. The row lifts and travels to next week.
          var before = tops();
          demo.setAttribute('data-step', 'end');
          mover.classList.remove('is-striking');
          mover.classList.add('is-travelling');
          var after = tops();
          var dy = before.get(mover) - after.get(mover);
          var duration = Math.min(1050, Math.max(760, 560 + Math.abs(dy) * 0.9));

          before.forEach(function (top, el) {
            if (el === mover) return;
            var shift = top - after.get(el);
            if (Math.abs(shift) > 0.5) {
              el.animate([{ transform: 'translateY(' + shift + 'px)' }, { transform: 'translateY(0)' }], { duration: duration * 0.85, easing: EASE_IN_OUT });
            }
          });

          var travel = mover.animate([
            { transform: 'translateY(' + dy + 'px) scale(1)' },
            { transform: 'translateY(' + (dy * 0.9) + 'px) scale(1.03)', offset: 0.2 },
            { transform: 'translateY(' + (dy * 0.08) + 'px) scale(1.03)', offset: 0.82 },
            { transform: 'translateY(0) scale(1)' }
          ], { duration: duration, easing: EASE_IN_OUT });
          return travel.finished.catch(function () {});
        })
        .then(function () {
          // 3. It lands and the new date gets highlighted.
          mover.classList.remove('is-travelling');
          mover.classList.add('is-landing');
          return sleep(3600);
        })
        .catch(function () {})
        .then(function () {
          running = false;
          if (playsLeft <= 0) stopped = true;
          if (stopped) {
            showEndState();
            return;
          }
          if (canPlay()) play();
        });
    }

    var observer = new IntersectionObserver(function (entries) {
      var entry = entries[entries.length - 1];
      inView = entry.isIntersecting && entry.intersectionRatio >= 0.45;
      if (inView) play();
    }, { threshold: [0, 0.45, 0.8] });
    observer.observe(demo);

    document.addEventListener('visibilitychange', function () {
      if (!document.hidden) play();
    });

    // Pointing at the panel holds it still so the moved row can be read.
    demo.addEventListener('pointerenter', function () { hovered = true; });
    demo.addEventListener('pointerleave', function () { hovered = false; play(); });

    function onReduceChange() {
      if (reduce.matches) {
        stopped = true;
        observer.disconnect();
        if (!running) showEndState();
      }
    }
    if (typeof reduce.addEventListener === 'function') reduce.addEventListener('change', onReduceChange);
    else if (typeof reduce.addListener === 'function') reduce.addListener(onReduceChange);
  }

  // Theme toggle. Light is the default; the choice is remembered in this browser only.

  function themeToggle() {
    var button = document.querySelector('[data-theme-toggle]');
    if (!button) return;
    var root = document.documentElement;
    var meta = document.querySelector('meta[name="theme-color"]');

    function sync() {
      var dark = root.getAttribute('data-theme') === 'dark';
      button.setAttribute('aria-pressed', dark ? 'true' : 'false');
      if (meta) meta.setAttribute('content', dark ? '#1A1E2C' : '#DDE6F3');
    }

    button.addEventListener('click', function () {
      var dark = root.getAttribute('data-theme') !== 'dark';
      if (dark) root.setAttribute('data-theme', 'dark');
      else root.removeAttribute('data-theme');
      try {
        if (dark) localStorage.setItem('whatnow-theme', 'dark');
        else localStorage.removeItem('whatnow-theme');
      } catch (err) {}
      sync();
    });

    sync();
  }

  themeToggle();
  fillDemoDates();
  heroDemo();
  window.addEventListener('message', onMessage);
  loadUmami();
  mountTally();
  watchWaitlistView();
})();