All files / Bandstand/src/components/tour Tour.tsx

48.03% Statements 147/306
57.33% Branches 43/75
18.18% Functions 2/11
48.03% Lines 147/306

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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    1x               1x 1x 1x     1x       1x     1x   7x 7x 7x 7x     7x               31x 31x 31x 31x       31x 31x 31x 31x 31x 31x 31x     31x 10x 10x 6x 10x     10x 31x   31x 9x 9x 9x 9x 9x 9x 9x 8x 9x       9x 9x     31x   31x 4x 4x 4x 1x 1x     4x 4x 4x 4x 4x 4x 31x   31x       31x 20x 20x 20x 20x 7x                                                                                                   31x       31x 31x 20x   20x 31x   31x                                               31x                                   31x 31x 31x 20x 20x 31x   31x 31x 31x   14x 31x               31x 31x 31x 31x 31x 14x           31x   14x 31x   14x   31x 31x 31x 31x 31x 31x 31x                   31x 31x 31x 31x 31x 31x 31x 31x 31x                       31x 31x 31x 31x 31x 31x 31x 31x   14x   14x   14x 31x   31x                       31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x 31x   31x 31x 31x 31x                 31x 31x 31x 31x   31x 31x 31x 31x 31x   31x                                                                    
'use client'
 
import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react'
import {usePathname, useRouter} from 'next/navigation'
import {stepsFor, type TourFeatures, type TourStep} from './steps'
import {DEMO} from '@/lib/demo'
 
/** Start the tour from anywhere (the menu, the help page). */
export function startTour() {
  window.dispatchEvent(new Event('ms:tour'))
}
 
const KEY = 'ms:tour-step'
// The public demo can't save that someone took the tour: their browser
// remembers instead, so it opens once per visitor, not on every page load
const DEMO_DONE = 'ms:tour-done'
 
function doneHere() {
  try {
    return DEMO && localStorage.getItem(DEMO_DONE) === '1'
  } catch {
    return false
  }
}
 
/**
 * A walk through the app on the real screens: the screen dims except the
 * thing being explained, with a card pointing at it. Shown once, the first
 * time someone signs in; "Take the tour" (menu, Help) or ?tour on any page
 * starts it again.
 */
export default function Tour({
  auto,
  features = {scheduling: true},
}: {
  auto: boolean
  /** The band's switchable tools: steps about ones it has off are left out */
  features?: TourFeatures
}) {
  const STEPS = useMemo(() => stepsFor(features), [features])
  const router = useRouter()
  const pathname = usePathname() ?? '/'
  const [step, setStep] = useState<number | null>(null)
  const [rect, setRect] = useState<DOMRect | null>(null)
  const [missing, setMissing] = useState(false)
 
  // Survives the page changes the tour itself makes
  const go = useCallback((n: number | null) => {
    try {
      if (n === null) sessionStorage.removeItem(KEY)
      else sessionStorage.setItem(KEY, String(n))
    } catch {
      // private mode: the tour still runs on this page
    }
    setStep(n)
  }, [])
 
  useEffect(() => {
    let saved: number | null = null
    try {
      const v = sessionStorage.getItem(KEY)
      saved = v === null ? null : Number(v)
    } catch {}
    const asked = new URLSearchParams(window.location.search).has('tour')
    if (saved !== null && !Number.isNaN(saved)) setStep(saved)
    else if (asked || (auto && pathname === '/songs' && !doneHere())) go(0)
    const start = () => {
      if (window.location.pathname !== '/songs') router.push('/songs')
      go(0)
    }
    window.addEventListener('ms:tour', start)
    return () => window.removeEventListener('ms:tour', start)
    // Once per mount: later page changes are the tour's own
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])
 
  const finish = useCallback(() => {
    go(null)
    if (DEMO)
      try {
        localStorage.setItem(DEMO_DONE, '1')
      } catch {}
    // Back where it started: the band's own songs (not a sample page, or
    // wherever the last steps were)
    if (window.location.pathname !== '/songs') router.push('/songs')
    fetch('/api/account/settings', {
      method: 'PATCH',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({tourDone: true}),
    }).catch(() => {})
  }, [go, router])
 
  const current: TourStep | null = step === null ? null : (STEPS[step] ?? null)
 
  // Find what the step points at (it may still be rendering), bring it into
  // view, and follow it as the page moves
  useLayoutEffect(() => {
    setRect(null)
    setMissing(false)
    if (!current) return
    if (current.path && !current.path.test(pathname)) return
    if (!current.target) return
    let tries = 0
    let el: Element | null = null
    let follow: ReturnType<typeof setInterval> | undefined
    let opening: ReturnType<typeof setTimeout> | undefined
    const find = () => visible(current.target!)
    // The thing, and what it opened (a chord's diagram), lit up together
    const measure = () => {
      if (!el) return
      const r = el.getBoundingClientRect()
      const extra = current.also ? visible(current.also) : null
      setRect((old) => {
        const next = extra ? union(r, extra.getBoundingClientRect()) : r
        return old && same(old, next) ? old : next
      })
    }
    const timer = setInterval(() => {
      el = find()
      if (el) {
        clearInterval(timer)
        // Taller than most of the screen (the editor): from its top, clear
        // of the sticky header
        const r = el.getBoundingClientRect()
        if (r.height > window.innerHeight * 0.6)
          window.scrollBy({top: r.top - 88, behavior: 'instant'})
        else el.scrollIntoView({block: 'center', behavior: 'instant'})
        measure()
        // Show it working: open it once the page has stopped moving (a
        // scroll puts a chord's diagram away)
        if (current.open)
          opening = setTimeout(() => {
            const o = visible(current.open!)
            if (o instanceof HTMLElement) o.click()
          }, 200)
        // What opens grows as it draws
        follow = setInterval(measure, 150)
      } else if (++tries > 25) {
        clearInterval(timer)
        setMissing(true)
      }
    }, 120)
    window.addEventListener('resize', measure)
    window.addEventListener('scroll', measure, true)
    return () => {
      clearInterval(timer)
      clearInterval(follow)
      clearTimeout(opening)
      window.removeEventListener('resize', measure)
      window.removeEventListener('scroll', measure, true)
    }
  }, [current, pathname])
 
  // A step whose thing isn't on this page (a song with no notation): skip
  // it, the way you were going
  const heading = useRef<1 | -1>(1)
  useEffect(() => {
    if (!missing || step === null) return
    const n = step + heading.current
    go(n >= 0 && n < STEPS.length ? n : null)
  }, [missing, step, go, STEPS.length])
 
  const next = () => {
    if (step === null) return
    heading.current = 1
    let n = step + 1
    // Steps on this page whose thing isn't here (no notation in this song)
    while (
      n < STEPS.length &&
      STEPS[n].target &&
      !STEPS[n].from &&
      !STEPS[n].href &&
      (!STEPS[n].path || STEPS[n].path!.test(pathname)) &&
      !visible(STEPS[n].target!)
    )
      n++
    if (n >= STEPS.length) return finish()
    const to = STEPS[n]
    // The next step is on another page: follow the link it names
    if (to.path && !to.path.test(pathname)) {
      const href =
        to.href ?? (to.from && visible(to.from)?.getAttribute('href'))
      if (href) router.push(href)
    }
    go(n)
  }
  const back = () => {
    if (!step) return
    heading.current = -1
    let n = step - 1
    // Back past steps on this page whose thing isn't here (no notation)
    while (
      n > 0 &&
      STEPS[n].target &&
      (!STEPS[n].path || STEPS[n].path!.test(pathname)) &&
      !visible(STEPS[n].target!)
    )
      n--
    const to = STEPS[n]
    if (to.path && !to.path.test(pathname)) router.back()
    go(n)
  }
 
  // The card's real height, to tell whether it fits beside what it points at
  const card = useRef<HTMLDivElement>(null)
  const [cardH, setCardH] = useState(240)
  useLayoutEffect(() => {
    const h = card.current?.offsetHeight
    if (h && h !== cardH) setCardH(h)
  }, [step, rect, cardH])
 
  if (!current || step === null) return null
  if (current.path && !current.path.test(pathname)) return null
  if (current.target && !rect) return null
 
  const pad = 8
  const hole = rect && {
    x: rect.left - pad,
    y: rect.top - pad,
    w: rect.width + pad * 2,
    h: rect.height + pad * 2,
  }
  // The card goes below what it points at, or above it; when neither has
  // room (a tall editor), over its lower part, so Next stays on screen
  const vw = typeof window === 'undefined' ? 400 : window.innerWidth
  const vh = typeof window === 'undefined' ? 800 : window.innerHeight
  const width = Math.min(340, vw - 24)
  const gap = 14
  const place = !hole
    ? 'center'
    : hole.y + hole.h + gap + cardH <= vh - 12
      ? 'below'
      : hole.y - gap - cardH >= 12
        ? 'above'
        : 'over'
  const left = hole
    ? Math.min(Math.max(12, hole.x + hole.w / 2 - width / 2), vw - width - 12)
    : (vw - width) / 2
  const arrowX = hole
    ? Math.min(Math.max(20, hole.x + hole.w / 2 - left), width - 20)
    : 0
 
  return (
    <div className="fixed inset-0 z-[70]" role="dialog" aria-label="Tour">
      <svg className="absolute inset-0 h-full w-full" aria-hidden>
        <defs>
          <mask id="tour-hole">
            <rect width="100%" height="100%" fill="white" />
            {hole && (
              <rect
                x={hole.x}
                y={hole.y}
                width={hole.w}
                height={hole.h}
                rx={12}
                fill="black"
              />
            )}
          </mask>
        </defs>
        <rect
          width="100%"
          height="100%"
          fill="rgba(10,10,12,0.72)"
          mask="url(#tour-hole)"
        />
        {hole && (
          <rect
            x={hole.x}
            y={hole.y}
            width={hole.w}
            height={hole.h}
            rx={12}
            fill="none"
            stroke="var(--color-amber)"
            strokeWidth={3}
          />
        )}
      </svg>
      <div
        ref={card}
        className="absolute rounded-2xl border border-line-2 bg-panel p-4 text-text shadow-2xl"
        style={{
          width,
          left,
          ...(place === 'below'
            ? {top: hole!.y + hole!.h + gap}
            : place === 'above'
              ? {bottom: vh - hole!.y + gap}
              : place === 'over'
                ? {bottom: 16}
                : {top: '50%', transform: 'translateY(-50%)'}),
        }}
      >
        {(place === 'below' || place === 'above') && (
          <span
            aria-hidden
            className="absolute h-3 w-3 rotate-45 border-line-2 bg-panel"
            style={{
              left: arrowX - 6,
              ...(place === 'below'
                ? {top: -7, borderLeftWidth: 1, borderTopWidth: 1}
                : {bottom: -7, borderRightWidth: 1, borderBottomWidth: 1}),
            }}
          />
        )}
        <p className="text-[11px] font-bold uppercase tracking-[0.15em] text-sky">
          {step + 1} of {STEPS.length}
        </p>
        <h2 className="mt-1 text-lg font-extrabold">{current.title}</h2>
        <p className="mt-1.5 text-[15px] leading-snug text-muted">
          {current.body}
        </p>
        <div className="mt-4 flex items-center gap-2">
          <button
            type="button"
            onClick={finish}
            className="min-h-11 px-2 text-sm text-muted underline"
          >
            {step + 1 === STEPS.length ? 'Close' : 'Skip tour'}
          </button>
          <span className="flex-1" />
          {step > 0 && (
            <button
              type="button"
              onClick={back}
              className="min-h-11 rounded-lg border border-line-2 px-4"
            >
              Back
            </button>
          )}
          <button
            type="button"
            onClick={next}
            className="min-h-11 rounded-lg bg-accent px-5 font-bold text-on-accent"
          >
            {step + 1 === STEPS.length ? 'Done' : 'Next'}
          </button>
        </div>
      </div>
    </div>
  )
}
 
function union(a: DOMRect, b: DOMRect) {
  const left = Math.min(a.left, b.left)
  const top = Math.min(a.top, b.top)
  return new DOMRect(
    left,
    top,
    Math.max(a.right, b.right) - left,
    Math.max(a.bottom, b.bottom) - top,
  )
}
 
function same(a: DOMRect, b: DOMRect) {
  return (
    a.left === b.left &&
    a.top === b.top &&
    a.width === b.width &&
    a.height === b.height
  )
}
 
/**
 * The first match that's actually on screen (the header has two navs);
 * several selectors are tried in order.
 */
function visible(selectors: string | string[]) {
  for (const selector of Array.isArray(selectors) ? selectors : [selectors])
    for (const el of Array.from(document.querySelectorAll(selector))) {
      const r = el.getBoundingClientRect()
      if (r.width > 0 && r.height > 0) return el
    }
  return null
}