Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

5.3 kB Ruw bestand
import { useEffect, useState } from 'react';
import { resolveVoice, type VoiceAssignment, type VoicePoint } from '@wellformed/shared';
import { api } from '../api/client';

/**
 * The four selling points this visit gets, and the one beat we send back.
 *
 * Three things this deliberately is not.
 *
 * It is not a cookie. The assignment lives in `sessionStorage`, and the only
 * thing that ever leaves the browser about it is the impression id, once, on
 * the signup form. Nothing is written to the device that outlives the tab,
 * nothing identifies anyone across visits, and there is no third party — which
 * keeps a measurement that only needs to count pages out of consent territory
 * entirely.
 *
 * It is not per-render. One assignment covers the whole tab, so opening
 * `/pricing` from the hero does not reshuffle the points under someone mid-read,
 * and a reload does not invent a second impression that never had a reader.
 *
 * It is not eager. The beat below is what separates a person from a crawler,
 * and the fit only counts impressions that got one. Since the measure is
 * durable use *per impression*, an unfiltered denominator would be mostly bots
 * and every weight in the model would be noise around zero.
 */

const ASSIGNMENT_KEY = 'wf.voice';
const BEAT_KEY = 'wf.voice.seen';

/** How long a page has to be looked at before we believe someone is looking. */
const DWELL_MS = 4000;

function shaped(parsed: unknown): VoiceAssignment | null {
  const value = parsed as Partial<VoiceAssignment>;
  if (typeof value?.id !== 'string' || !Array.isArray(value.items)) return null;
  return { id: value.id, items: value.items.filter((v) => typeof v === 'string') };
}

function readStored(): VoiceAssignment | null {
  try {
    const raw = sessionStorage.getItem(ASSIGNMENT_KEY);
    if (!raw) return null;
    return shaped(JSON.parse(raw));
  } catch {
    // Private browsing, a full quota, or something that is not JSON. The page
    // still has to render, so every one of those is simply "no assignment".
    return null;
  }
}

/**
 * The assignment a server-rendered page carried along, as a JSON script tag.
 *
 * The server already drew and recorded the impression when it rendered the
 * page, so fetching another here would double-count the visit and swap four
 * blocks of copy under the reader. Adopting the embedded one keeps the page
 * and the measurement telling the same story. Stored on adoption, so the rest
 * of the tab stays as sticky as a fetched assignment would be.
 */
function readEmbedded(): VoiceAssignment | null {
  try {
    const raw = document.getElementById('wf-voice')?.textContent;
    if (!raw) return null;
    const parsed = shaped(JSON.parse(raw));
    if (parsed) store(ASSIGNMENT_KEY, JSON.stringify(parsed));
    return parsed;
  } catch {
    return null;
  }
}

function store(key: string, value: string): void {
  try {
    sessionStorage.setItem(key, value);
  } catch {
    // Storage being unavailable costs stickiness, not the page.
  }
}

export function useVoice(ssr?: VoiceAssignment | null): VoicePoint[] {
  // Order matters: an assignment this tab already had wins (stickiness), then
  // the one a server-rendered page embedded, then — only during server-side
  // rendering itself, where neither storage nor document exists — the one the
  // caller was handed directly.
  const [assignment, setAssignment] = useState<VoiceAssignment | null>(
    () => readStored() ?? readEmbedded() ?? ssr ?? null,
  );

  useEffect(() => {
    if (assignment) return;
    let live = true;
    void api
      .voice()
      .then((next) => {
        if (!live) return;
        store(ASSIGNMENT_KEY, JSON.stringify(next));
        setAssignment(next);
      })
      .catch(() => {
        // The server not answering must not blank the landing page. Leaving the
        // assignment null renders the deterministic fallback in `resolveVoice`.
      });
    return () => {
      live = false;
    };
  }, [assignment]);

  useEffect(() => {
    const id = assignment?.id;
    // An empty id is the server saying "composed but not recorded" — over the
    // rate limit. There is nothing to beat against.
    if (!id) return;
    if (sessionStorage.getItem(BEAT_KEY) === id) return;

    let done = false;
    const beat = () => {
      if (done) return;
      done = true;
      cleanup();
      store(BEAT_KEY, id);
      void api.voiceSeen(id).catch(() => {
        // Nothing to tell the reader, and nothing to retry: an impression that
        // failed to register simply does not vote.
      });
    };

    // Either signal will do. A quick scroll is as good a sign of a person as
    // four seconds of stillness, and waiting for both would lose the visitor
    // who reads the hero and leaves.
    const events = ['scroll', 'pointerdown', 'keydown'] as const;
    for (const event of events) window.addEventListener(event, beat, { once: true, passive: true });
    const timer = window.setTimeout(beat, DWELL_MS);

    function cleanup() {
      window.clearTimeout(timer);
      for (const event of events) window.removeEventListener(event, beat);
    }
    return cleanup;
  }, [assignment?.id]);

  return resolveVoice(assignment?.items ?? []);
}

/** The impression to credit a signup to, if this tab has one. */
export function currentVoiceId(): string | undefined {
  return readStored()?.id || undefined;
}