Skip to content
← Public packages

@kentcdodds/invisible-software

Invisible software: a phone-only step-through demo plus a chart of AI agent benchmark progress.

FollowForkOpen app

src/c/vc-finder.js

147 lines · 8.1 KB · JavaScript
Raw
// Victoria's client-finder app (slide 3 opening, reused by slide 2). Local time = slide time - t0, capped at tMax.
window.mountFinder = ({ root, onTime }, body, t0 = 0, tMax = Infinity) => {
body.insertAdjacentHTML('afterbegin', `			<div class="toast" id="toast"><div class="s-ic"><img src="assets/stripe.svg"></div><div id="toastText"></div></div>
			<div class="app" id="app">
				<div class="app-head"><img class="av-img" src="assets/victoria.jpg"><div><h1>Victoria's Chair</h1><p>Thursday, Oct 15 · <span id="openCount">4 open</span></p></div><span class="badge">Today</span></div>
				<div class="slots"><div class="slot" id="s0">9:00</div><div class="slot" id="s1">10:30</div><div class="slot" id="s2">1:00</div><div class="slot" id="s3">3:30</div></div>

				<div class="sec-title">Client finder <small id="selCount">5 clients</small></div>
				<div class="search"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>Search clients</div>
				<div class="chips"><div class="chip" id="chipDue">Due for a cut</div><div class="chip">All</div><div class="chip">Regulars</div></div>
				<div class="card list" id="list"></div>

				<div class="sec-title" id="secText">Thursday text <small>iMessage</small></div>
				<div class="card tpl">
					<label><span class="imsg"><svg width="13" height="13" viewBox="0 0 24 24" fill="#fff"><path d="M12 3C6.5 3 2 6.8 2 11.5c0 2.6 1.4 5 3.7 6.5L5 21.5l4.1-2.2c.9.2 1.9.3 2.9.3 5.5 0 10-3.8 10-8.5S17.5 3 12 3z"/></svg></span>Message template</label>
					<div class="area" id="area"></div>
					<div class="preview"><div class="to"><img class="av-img" src="assets/client.jpg">Preview for Marcus</div><div class="ibub" id="bub"></div></div>
					<div class="btn" id="send"></div>
				</div>

				<div class="sec-title" id="secBook">Thursday bookings <span class="live"><i></i>Live</span></div>
				<div class="card" id="bookings"></div>
			</div>
`)
const clients = [
	{ n: 'Marcus Reed', img: 'assets/client.jpg', m: '5 wks ago · usual 4 wks', due: true },
	{ n: 'Jay Ortiz', mono: ['JO', '#7c8cff'], m: '6 wks ago · usual 4 wks', due: true },
	{ n: 'Sam Lee', mono: ['SL', '#c48bd6'], m: '2 wks ago · usual 5 wks', due: false },
	{ n: 'Theo Park', mono: ['TP', '#f0a14b'], m: '4 wks ago · usual 3 wks', due: true },
	{ n: 'Andre Wells', mono: ['AW', '#4cb782'], m: '7 wks ago · usual 5 wks', due: true },
]
const av = (c) => c.img ? `<img class="av-img" src="${c.img}">` : `<span class="mono" style="background:${c.mono[1]}">${c.mono[0]}</span>`
const list = root.querySelector('#list')
list.innerHTML = clients.map((c, i) => `<div class="crow" data-i="${i}">${av(c)}<div><div class="nm">${c.n}</div><div class="meta">${c.m}</div></div>${c.due ? '<span class="due">Due</span>' : '<span style="margin-left:auto"></span>'}<span class="ck">${icon.check}</span></div>`).join('')
const rows = [...list.children]

const app = root.querySelector('#app')
const area = root.querySelector('#area')
const bub = root.querySelector('#bub')
const send = root.querySelector('#send')
// The template already carries the per-client Stripe link; in 3.2 Victoria only types a thank-you.
const depositLinks = { Marcus: 'buy.stripe.com/7sY3cK9aQ', Jay: 'buy.stripe.com/3fA8xP2kR', Theo: 'buy.stripe.com/9bE1mT6wZ', Andre: 'buy.stripe.com/5kQ4vH0dN' }
const base = (name, link) => `Hey ${name}, it's Victoria. I've got Thursday openings at 9, 10:30, 1 and 3:30. Reply with a time to grab one. $20 deposit: ${link}`
const add = ' Thanks!'
const bookings = root.querySelector('#bookings')
const slots = [['9:00', null], ['10:30', 0], ['1:00', 1], ['3:30', 3]]
bookings.innerHTML = slots.map(([tm], i) => `<div class="brow" id="b${i}"><span class="tm">${tm}</span><span class="who"><span class="open">Open</span></span><span class="st"></span></div>`).join('') + `<div class="totals"><span>Deposits collected</span><b id="tot">$0</b></div>`

// booked at, paid at
const events = { 1: [15.4, 16.8], 2: [17.6, 19.0], 3: [19.8, 21.2] }

onTime((T) => {
	const t = Math.min(T - t0, tMax)
	// filter
	const filtered = t >= 2.25
	root.querySelector('#chipDue').classList.toggle('on', filtered)
	root.querySelector('#chipDue').innerHTML = (filtered ? icon.check : '') + 'Due for a cut'
	rows.forEach((r, i) => {
		const c = clients[i]
		if (!c.due) {
			const p = ease(prog(t, 2.35, 2.75))
			r.style.height = 62 * (1 - p) + 'px'
			r.style.opacity = 1 - p
			r.style.borderTopWidth = p >= 1 ? '0' : ''
		}
		const di = clients.slice(0, i).filter((x) => x.due).length
		const badge = r.querySelector('.due')
		if (badge) {
			const p = ease(prog(t, 2.8 + di * 0.18, 3.2 + di * 0.18))
			badge.style.opacity = p
			badge.style.transform = `scale(${0.6 + 0.4 * p})`
		}
		const ck = r.querySelector('.ck')
		const on = c.due && t >= 3.9 + di * 0.25
		ck.classList.toggle('on', on)
		ck.style.color = on ? '#fff' : 'transparent'
	})
	const sel = clients.filter((c, i) => c.due && t >= 3.9 + clients.slice(0, i).filter((x) => x.due).length * 0.25).length
	root.querySelector('#selCount').textContent = sel ? `${sel} selected` : filtered ? '4 due' : '5 clients'

	// template typing
	const n = Math.floor(clamp((t - 8.6) * 10, 0, add.length))
	const typing = t >= 8.3 && t < 11.6
	area.classList.toggle('focus', typing)
	area.innerHTML = base('<span class="var">{first}</span>', '<span class="var">{depositLink}</span>') + add.slice(0, n) + (typing ? '<span class="caret"></span>' : '')
	bub.textContent = base('Marcus', depositLinks.Marcus) + add.slice(0, n)

	// send
	if (t < 11.95) send.innerHTML = `Send to 4 clients`
	else if (t < 12.9) send.innerHTML = `<span class="spin"></span>Sending…`
	else send.innerHTML = `${icon.check}Sent to 4 clients`
	send.classList.toggle('sent', t >= 12.9)

	// bookings
	let total = 0, taken = 0
	slots.forEach(([, ci], i) => {
		const row = root.querySelector('#b' + i)
		const who = row.querySelector('.who'), st = row.querySelector('.st')
		const ev = events[i]
		const state = !ev || t < ev[0] ? 'open' : t < ev[1] ? 'pending' : 'paid'
		if (row.__s !== state) {
			row.__s = state
			if (state === 'open') { who.innerHTML = '<span class="open">Open</span>'; st.innerHTML = '' }
			else { who.innerHTML = av(clients[ci]) + clients[ci].n; st.innerHTML = state === 'pending' ? '<span class="pay pending">Deposit pending</span>' : `<span class="pay paid">${icon.check}Paid $20</span>` }
		}
		if (ev) {
			const f = prog(t, ev[1], ev[1] + 1.2)
			row.style.background = f > 0 && f < 1 ? `rgb(182 240 204 / ${1 - f})` : ''
			const b = prog(t, ev[0], ev[0] + 0.4)
			who.style.opacity = state === 'open' ? 1 : b
		}
		if (state !== 'open') taken++
		if (state === 'paid') total += 20
		root.querySelector('#s' + i).classList.toggle('taken', state !== 'open')
	})
	root.querySelector('#tot').textContent = '$' + total
	root.querySelector('#openCount').textContent = `${4 - taken} open`

	// toast per payment
	const toast = root.querySelector('#toast')
	let shown = null
	for (const [i, ev] of Object.entries(events)) if (t >= ev[1] && t < ev[1] + 1.9) shown = [i, t - ev[1]]
	if (shown) {
		const [i, dt] = shown
		const inP = ease(clamp(dt / 0.35)), outP = clamp((dt - 1.55) / 0.35)
		toast.style.opacity = inP * (1 - outP)
		toast.style.transform = `translateY(${(1 - inP) * -30 - outP * 20}px)`
		root.querySelector('#toastText').innerHTML = `<b>${clients[slots[i][1]].n}</b> paid a $20 deposit<br><span style="opacity:.7">Stripe · just now</span>`
	} else toast.style.opacity = 0

	// scroll
	const yText = root.querySelector('#secText').offsetTop - 6
	const yBook = root.querySelector('#secBook').offsetTop - 120
	const y = yText * easeInOut(prog(t, 6.8, 7.7)) + (yBook - yText) * easeInOut(prog(t, 13.4, 14.4))
	app.style.transform = `translateY(${-y}px)`
	// send tap position follows the button on screen (screen y = 114 safari-top + body offset)
	const sb = send.offsetTop - y + 114 + 26
	const tapSend = root.querySelector('#tapSend')
	if (tapSend) tapSend.style.top = sb + 'px'

	// page load progress
	const pr = root.querySelector('.url-progress')
	const lp = prog(t, 0, 0.8)
	pr.style.width = lp * 100 + '%'
	pr.style.opacity = lp >= 1 ? 0 : 1
})
}