// Root app component — owns navigation, search state, bookings collection.

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "accent": "#0f1729",
  "density": "regular",
  "glow": false,
  "groupBy": "flat"
}/*EDITMODE-END*/;

function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);

  // Auth gate: don't paint the portal chrome until we know the user is signed
  // in. While 'pending' (auth check in flight) or 'anon' (redirecting to
  // login) we render a neutral full-screen splash instead of the sidebar/topbar.
  const [authState, setAuthState] = React.useState(() => (window.__pp_auth) || 'pending');
  // Why sign-in failed, when it did (see ppSignInFailed). Held in state so the
  // splash re-renders if the reason lands after the state flip.
  const [authError, setAuthError] = React.useState(() => window.__pp_live?.authError || null);
  React.useEffect(() => {
    const onAuth = (e) => {
      setAuthState((e && e.detail && e.detail.state) || window.__pp_auth || 'pending');
      setAuthError(window.__pp_live?.authError || null);
    };
    window.addEventListener('pp:auth', onAuth);
    // Auth may have resolved before this listener attached.
    if (window.__pp_auth && window.__pp_auth !== 'pending') setAuthState(window.__pp_auth);
    return () => window.removeEventListener('pp:auth', onAuth);
  }, []);

  const [route, setRoute] = React.useState({ screen: 'results' });
  const [query, setQuery] = React.useState({
    text: '',
    locations: [],
    productTypes: [],
    operatorIds: [],
    date: new Date().toISOString().slice(0, 10),
    guests: 2,
    // Whether availability has been explicitly requested. Browsing into the
    // listings (by product / operator / venue) shows the matching venues but
    // leaves this false, so no times are fetched. Pressing the search icon
    // commits a real search and flips it true. See useAllProductsAvailability.
    searched: false,
  });
  const [drawerSlot, setDrawerSlot] = React.useState(null);
  const [bookings, setBookings] = React.useState(SEED_BOOKINGS);
  // Placeholder ids for bookings we've POSTed but not yet heard back on. A
  // counter in a ref, not `bookings.length`: the itinerary drawer awaits
  // handleConfirm once per line through ONE captured closure, so state-derived
  // ids repeat across the loop and a failed line's row gets overwritten by the
  // next success (its `bs.map(b => b.id === localId …)` matches both).
  const bookingSeq = React.useRef(3000);
  const [bookingsInitialText, setBookingsInitialText] = React.useState('');
  const [viewingBookingId, setViewingBookingId] = React.useState(null);
  const [walkinOpen, setWalkinOpen] = React.useState(false);
  // BOO-644: itinerary mode. Off (default) a time chip opens BookingDrawer for
  // that one slot; on, it collects slots here and books them in one pass.
  const [itineraryMode] = ppUsePref('pp_itinerary_mode', false);
  const [itinerary, setItinerary] = React.useState([]);
  const [itineraryPanel, setItineraryPanel] = React.useState(false);
  const [itineraryOpen, setItineraryOpen] = React.useState(false);
  // The prototype's "Listing overview" panel — every view opens it.
  const [listingId, setListingId] = React.useState(null);
  const [dataVersion, setDataVersion] = React.useState(0);
  // `empty` = signed in, API answered, zero venues shared with this partner. A
  // provisioning state, NOT an outage — it gets its own splash.
  const readLive = () => ({
    ready: !!window.__pp_live?.ready,
    live: !!window.__pp_live?.dataLive,
    error: window.__pp_live?.error || null,
    detail: window.__pp_live?.errorDetail || null,
    empty: !!window.__pp_live?.emptyCatalogue,
  });
  const [liveStatus, setLiveStatus] = React.useState(readLive);
  React.useEffect(() => {
    const onLoaded = (e) => {
      setLiveStatus({ ...readLive(), ready: true, live: !!(e?.detail?.live ?? window.__pp_live?.dataLive) });
      // Re-seed bookings from live data once the catalogue has resolved.
      if (window.SEED_BOOKINGS) setBookings(window.SEED_BOOKINGS.slice());
      setDataVersion(v => v + 1);
    };
    window.addEventListener('pp:data-loaded', onLoaded);
    if (window.__pp_live?.ready) onLoaded({ detail: { live: window.__pp_live.dataLive } });
    return () => window.removeEventListener('pp:data-loaded', onLoaded);
  }, []);

  // Mirror route + overlay state into the browser history so swipe-back /
  // back-button match the in-app back behavior instead of leaving the SPA.
  // Each route change or overlay open pushes a history entry; close handlers
  // call history.back() to consume it; popstate restores from the snapshot.
  const popInFlight = React.useRef(false);
  const queryRef = React.useRef(query);
  React.useEffect(() => { queryRef.current = query; }, [query]);
  const fp = `${route.screen}|${drawerSlot ? 'd' : '0'}|${viewingBookingId || ''}|${walkinOpen ? '1' : '0'}|${itineraryOpen ? '1' : '0'}|${listingId || ''}`;
  const fpRef = React.useRef(fp);
  const snapFp = (s) => `${s.route?.screen || 'results'}|${s.drawerSlot ? 'd' : '0'}|${s.viewingBookingId || ''}|${s.walkinOpen ? '1' : '0'}|${s.itineraryOpen ? '1' : '0'}|${s.listingId || ''}`;
  React.useEffect(() => {
    window.history.replaceState({
      ppSnapshot: { route: { screen: 'results' }, drawerSlot: null, viewingBookingId: null, walkinOpen: false, itineraryOpen: false, listingId: null },
      ppQuery: queryRef.current,
    }, '');
    const onPop = (e) => {
      const snap = e.state?.ppSnapshot;
      if (!snap) return;
      popInFlight.current = true;
      fpRef.current = snapFp(snap);
      setRoute(snap.route || { screen: 'results' });
      if (e.state.ppQuery) setQuery(e.state.ppQuery);
      setDrawerSlot(snap.drawerSlot || null);
      setViewingBookingId(snap.viewingBookingId || null);
      setWalkinOpen(!!snap.walkinOpen);
      setItineraryOpen(!!snap.itineraryOpen);
      setListingId(snap.listingId || null);
    };
    window.addEventListener('popstate', onPop);
    return () => window.removeEventListener('popstate', onPop);
  }, []);
  React.useEffect(() => {
    if (popInFlight.current) { popInFlight.current = false; fpRef.current = fp; return; }
    if (fp === fpRef.current) return;
    window.history.pushState({
      ppSnapshot: { route, drawerSlot, viewingBookingId, walkinOpen, itineraryOpen, listingId },
      ppQuery: queryRef.current,
    }, '');
    fpRef.current = fp;
  }, [route, drawerSlot, viewingBookingId, walkinOpen, itineraryOpen, listingId]);
  // Keep the CURRENT history entry's query snapshot in sync with live filters.
  // The pushState above only captures `query` at navigation time, so a filter
  // changed in place (date, guests, location…) would otherwise leave a stale
  // snapshot — and a later history.back() (e.g. closing the booking drawer)
  // would restore the old value, resetting the date to today. Patch the
  // current entry in place whenever query changes.
  React.useEffect(() => {
    const st = window.history.state;
    if (!st || !st.ppSnapshot) return;
    window.history.replaceState({ ...st, ppQuery: query }, '');
  }, [query]);

  const handleNewWalkin = (booking) => setBookings(bs => [booking, ...bs]);

  const handleOpenBooking = (booking) => setViewingBookingId(booking.id);
  const handleCancelBooking = async (id) => {
    setBookings(bs => bs.map(b => b.id === id ? { ...b, status: 'cancelled' } : b));
    if (!liveStatus.live) return;
    try { await ppCancelBooking(id); }
    catch (e) { console.warn('[Bookable] cancel failed', e); }
  };

  // Amend an existing booking. Optimistically patch local state, then mirror
  // the change to Bookable as a JSON Patch (only the fields that actually
  // changed). The drawer has already re-checked availability for any
  // date/time/party-size change before calling this.
  // Returns true on success, false if the live PATCH failed (the drawer relies
  // on this to decide whether to show the "Booking updated" confirmation — we
  // must not claim the customer was notified when the amend never landed).
  const handleSaveBooking = async (updated) => {
    const orig = bookings.find(b => b.id === updated.id);
    setBookings(bs => bs.map(b => b.id === updated.id ? updated : b));
    if (!liveStatus.live || !orig) return true;
    const ops = [];
    const rep = (path, value) => ops.push({ op: 'replace', path, value });
    if (updated.date  !== orig.date)  rep('/date', updated.date);
    if (updated.time  !== orig.time)  rep('/time', updated.time);
    if (updated.guests !== orig.guests) rep('/partySize', updated.guests);
    if ((updated.email || '') !== (orig.email || '')) rep('/email', updated.email || '');
    if ((updated.phone || '') !== (orig.phone || '')) rep('/phone', updated.phone || '');
    if (!ops.length) return true;
    try {
      await ppUpdateBooking(updated.id, ops);
      return true;
    } catch (e) {
      console.warn('[Bookable] amend failed', e);
      // Roll the optimistic update back so the list reflects reality.
      setBookings(bs => bs.map(b => b.id === updated.id ? orig : b));
      return false;
    }
  };

  const viewingBooking = bookings.find(b => b.id === viewingBookingId) || null;

  // `baseQuery` lets a caller commit an explicit filter set (the listing edits a
  // local draft and only commits it on submit, so search never re-runs as the
  // partner tweaks date/guests/products/operator).
  const handleSearch = async (baseQuery) => {
    // Pressing Search is the explicit commit that fetches availability.
    setQuery({ ...(baseQuery || query), searched: true });
    setRoute({ screen: 'results' });
  };

  const goToBookings = (textPrefill) => {
    if (textPrefill !== undefined) setBookingsInitialText(textPrefill);
    setRoute({ screen: 'bookings' });
  };

  const handleNav = (id) => {
    if (id === 'search') setRoute({ screen: 'search' });
    if (id === 'bookings') setRoute({ screen: 'bookings' });
    if (id === 'apikeys') setRoute({ screen: 'apikeys' });
  };

  const handleBookNow = React.useCallback((slot) => setDrawerSlot(slot), []);

  // Same slot payload the booking drawer takes, keyed so the same time can't be
  // added twice.
  const handleAddToItinerary = React.useCallback((slot) => {
    const key = [slot.venueId, slot.productId, slot.date, slot.time || ''].join('|');
    setItinerary(list => list.some(i => i.key === key) ? list : [...list, { ...slot, key }]);
    setItineraryPanel(true);
  }, []);

  // What a time click does, decided once: the listing rows, the cards, the map
  // and the listing panel all go through this. Identity has to be stable or the
  // rows' React.memo bails and every App state change re-renders the whole list.
  const handleBook = itineraryMode ? handleAddToItinerary : handleBookNow;
  const handleRemoveFromItinerary = React.useCallback((key) => setItinerary(list => list.filter(i => i.key !== key)), []);

  const handleConfirm = async (full) => {
    const venue = venueById(full.venueId) || {};
    // Prefer the composite the slot was sourced from (multiple Bookable
    // products can collapse into one portal bucket — book against the one
    // whose availability surfaced this time).
    const compositeId = full.compositeId || (venue.composites && venue.composites[full.productId]);
    const localId = 'b-' + bookingSeq.current++;
    const localBooking = {
      id: localId,
      source: 'Portal',
      operator: venue.operator,
      venue: venue.name,
      city: venue.city,
      product: full.productId,
      productName: (venue.productNames && venue.productNames[full.productId]) || full.productId,
      customer: full.customer,
      email: full.email,
      phone: full.phone,
      notes: full.notes,
      partnerBookingId: full.partnerBookingId,
      date: full.date,
      time: full.time,
      rcvd: new Date().toISOString().slice(0, 10),
      guests: full.guests,
      status: full.type === 'request' ? 'pending' : 'confirmed',
      compositeId,
    };
    setBookings(bs => [localBooking, ...bs]);

    if (!compositeId || !liveStatus.live) return;
    const [firstName, ...rest] = (full.customer || '').split(' ');
    const lastName = rest.join(' ') || '-';
    try {
      const data = await ppCreateBooking(compositeId, {
        firstName,
        lastName,
        email: full.email,
        phone: full.phone,
        partySize: full.guests,
        date: full.date,
        time: full.time,
        type: full.type || 'book',
        notes: full.notes,
        // BOO-601: the partner's own reference, so the operator can find this
        // booking by the id it has on the partner's side.
        partnerBookingId: full.partnerBookingId || undefined,
        preOrder: full.preOrder || null,
      });
      const live = data && (data.booking || data.raw);
      if (live && live.id) {
        setBookings(bs => bs.map(b => b.id === localId ? { ...localBooking, ...data.booking, id: live.id } : b));
      }
      // Returned to the drawer so the confirmation can show the real operator ref.
      return { booking: data && (data.booking || data.raw) };
    } catch (e) {
      const error = String(e?.message || e);
      setBookings(bs => bs.map(b => b.id === localId ? { ...b, status: 'pending', _error: error } : b));
      return { error };
    }
  };

  // BOO-644: the inventory listing is every partner's landing page — there is no
  // browse-the-catalogue screen in front of it any more. 'search' stays a valid
  // route id so the rail, omni "New search" jumps and history all keep working;
  // it just renders the listing. (Generalised from the booking-only case, #100.)
  const screen = route.screen === 'search' ? 'results' : route.screen;

  const accent = t.accent || '#0f1729';

  // Sign-in isn't completing — we stopped redirecting rather than spin the
  // browser through login→callback→401 forever. Show why.
  if (authState === 'failed') {
    return <SignInFailedSplash reason={authError} />;
  }

  // Pre-auth: neutral splash, no portal chrome. 'anon' means a login redirect
  // is already in flight (see ppCheckAuth) so this is shown only momentarily.
  if (authState !== 'authed') {
    return <PortalBootSplash redirecting={authState === 'anon'} />;
  }

  return (
    <div className={"pp-app pp-density-" + (t.density || 'regular')}
         style={{ '--pp-accent': accent }}
         data-glow={t.glow === false ? 'off' : 'on'}
         data-screen-label={
           screen === 'results'  ? '01 Inventory listing'
         : screen === 'bookings' ? '02 Bookings list'
         : screen
         }>
      <div className="pp-main">
        <SandboxBanner/>
        <TopBar screen={screen} onNav={handleNav} liveStatus={liveStatus}
                upcomingCount={bookings.filter(b => b.status === 'confirmed' || b.status === 'pending').length}/>
        <div className="pp-main-body">
          {!liveStatus.ready ? (
            <LoadingSplash/>
          ) : !liveStatus.live && liveStatus.empty ? (
            <NoInventorySplash/>
          ) : !liveStatus.live ? (
            <DisconnectedSplash error={liveStatus.error} detail={liveStatus.detail}/>
          ) : (
            <React.Fragment>
              {screen === 'results'  && <ResultsScreen  query={query} setQuery={setQuery} onSearch={handleSearch} onBook={handleBook} onOpenListing={setListingId}/>}
              {screen === 'bookings' && <BookingsScreen bookings={bookings} groupBy={t.groupBy} setGroupBy={(g) => setTweak('groupBy', g)} initialText={bookingsInitialText} onOpen={handleOpenBooking} onNewWalkin={() => setWalkinOpen(true)}/>}
              {screen === 'apikeys'  && <SettingsScreen keysEnabled={!!window.__pp_keys_enabled} canManageKeys={window.__pp_can_manage_keys !== false} userEmail={window.__pp_session?.email}/>}
            </React.Fragment>
          )}
        </div>
      </div>

      <ListingDrawer venue={listingId ? venueById(listingId) : null}
                     query={query}
                     itineraryMode={itineraryMode}
                     onBook={(slot) => { setListingId(null); handleBook(slot); }}
                     onClose={() => window.history.back()}/>

      <BookingDrawer slot={drawerSlot}
                     onClose={() => window.history.back()}
                     onConfirm={handleConfirm}/>

      <EditBookingDrawer booking={viewingBooking}
                         onClose={() => window.history.back()}
                         onSave={handleSaveBooking}
                         onCancelBooking={handleCancelBooking}/>

      <WalkinDrawer open={walkinOpen}
                    onClose={() => window.history.back()}
                    onCreate={handleNewWalkin}/>

      {itineraryMode && (
        <React.Fragment>
          <ItineraryBar items={itinerary}
                        open={itineraryPanel}
                        onToggle={() => setItineraryPanel(o => !o)}
                        onRemove={handleRemoveFromItinerary}
                        onBook={() => { setItineraryPanel(false); setItineraryOpen(true); }}/>
          <ItineraryDrawer open={itineraryOpen}
                           items={itinerary}
                           onClose={() => window.history.back()}
                           onRemove={handleRemoveFromItinerary}
                           onConfirm={handleConfirm}
                           onBooked={() => setItinerary([])}
                           onGoToBookings={() => { setItineraryOpen(false); goToBookings(''); }}/>
        </React.Fragment>
      )}

      <TweaksPanel>
        <TweakSection label="Appearance"/>
        <TweakColor  label="Accent"
                     value={t.accent}
                     options={['#0f1729','#060a14','#157a4a','#b3261e']}
                     onChange={(v) => setTweak('accent', v)}/>
        <TweakRadio  label="Density"
                     value={t.density}
                     options={['compact','regular','comfy']}
                     onChange={(v) => setTweak('density', v)}/>
        <TweakToggle label="Search glow"
                     value={t.glow}
                     onChange={(v) => setTweak('glow', v)}/>
        <TweakSection label="Bookings list"/>
        <TweakRadio  label="Group bookings by"
                     value={t.groupBy}
                     options={[
                       { value: 'flat',     label: 'List' },
                       { value: 'operator', label: 'Operator' },
                       { value: 'product',  label: 'Product' },
                       { value: 'date',     label: 'Date' },
                     ]}
                     onChange={(v) => setTweak('groupBy', v)}/>
        <TweakSection label="Quick jump"/>
        <div style={{ display:'flex', gap:6 }}>
          <TweakButton label="Inventory" onClick={() => setRoute({ screen: 'results' })}/>
          <TweakButton label="Bookings"  onClick={() => setRoute({ screen: 'bookings' })}/>
        </div>
      </TweaksPanel>
    </div>
  );
}

// Override the search glow via a tweak.
function GlowApplier({ tweak }) { return null; }

// Full-viewport splash shown before auth resolves — deliberately has NO
// sidebar/topbar so a signed-out visitor never sees the portal flash before the
// login redirect.
function PortalBootSplash({ redirecting }) {
  return (
    <div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--pp-cream)' }}>
      <div className="pp-empty" style={{ border: 0, background: 'transparent' }}>
        <div className="pp-trace-loader-wrap" aria-hidden="true">
          <svg width="32" height="32" viewBox="0 0 24 24" style={{ animation: 'pp-spin 0.8s linear infinite' }}>
            <circle cx="12" cy="12" r="9" fill="none" stroke="var(--pp-line-strong)" strokeWidth="3"/>
            <path d="M21 12a9 9 0 0 0-9-9" fill="none" stroke="var(--pp-accent)" strokeWidth="3" strokeLinecap="round"/>
          </svg>
        </div>
        <div className="pp-empty-title">{redirecting ? 'Taking you to sign in…' : 'Loading…'}</div>
      </div>
    </div>
  );
}

function LoadingSplash() {
  const canvasRef = React.useRef(null);
  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    const off = document.createElement('canvas');
    off.width = 64; off.height = 64;
    const offCtx = off.getContext('2d');
    const favLink = document.getElementById('pp-favicon');
    const originalFav = favLink ? favLink.getAttribute('href') : null;
    const originalTitle = document.title;
    document.title = 'Loading — Bookable';

    const img = new Image();
    let ready = false, progress = 0, raf = 0, last = performance.now(), cancelled = false;
    const ease = (t) => t * t * (3 - 2 * t);

    function drawFill(target, size) {
      target.clearRect(0, 0, size, size);
      const d = size * 0.92;
      const o = (size - d) / 2;
      target.save();
      target.globalAlpha = 0.16;
      target.drawImage(img, o, o, d, d);
      target.restore();
      const p = ease(progress);
      const revealH = size * p;
      target.save();
      target.beginPath();
      target.rect(0, size - revealH, size, revealH);
      target.clip();
      target.globalAlpha = 1;
      target.drawImage(img, o, o, d, d);
      target.restore();
    }

    function frame(now) {
      if (cancelled) return;
      const dt = Math.min((now - last) / 1000, 0.1);
      last = now;
      if (ready) {
        progress += dt * 0.5; // ~2s per fill
        if (progress >= 1) progress = 0;
        drawFill(ctx, canvas.width);
        drawFill(offCtx, off.width);
        if (favLink) favLink.href = off.toDataURL('image/png');
      }
      raf = requestAnimationFrame(frame);
    }

    img.onload = () => { ready = true; };
    img.src = '/assets/bookable-icon.png';
    raf = requestAnimationFrame((t) => { last = t; frame(t); });

    return () => {
      cancelled = true;
      cancelAnimationFrame(raf);
      document.title = originalTitle;
      if (favLink && originalFav) favLink.href = originalFav;
    };
  }, []);

  return (
    <div className="pp-empty pp-empty--loading" style={{ marginTop: 64 }}>
      <div className="pp-trace-loader-wrap" aria-hidden="true">
        <canvas ref={canvasRef} className="pp-cs-loader" width="192" height="192"/>
      </div>
      <div className="pp-empty-title">Loading Bookable…</div>
      <div className="pp-empty-sub">Pulling your live operator catalogue and bookings.</div>
    </div>
  );
}

function DisconnectedSplash({ error, detail }) {
  return (
    <div className="pp-empty" style={{ marginTop: 64 }}>
      <div className="pp-empty-glyph">
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
          <path d="M12 9v4M12 17v.01M4.93 19.07l14.14-14.14M3 12a9 9 0 1 0 18 0 9 9 0 0 0-18 0Z"/>
        </svg>
      </div>
      <div className="pp-empty-title">Can't reach Bookable</div>
      <div className="pp-empty-sub">The Bookable API isn't responding. {error ? <span className="pp-mono">{error}</span> : null}</div>
      {detail ? <div className="pp-empty-sub">{detail}</div> : null}
      <button className="pp-btn pp-btn--ghost" onClick={() => window.location.reload()}>Retry</button>
    </div>
  );
}

// Signed in fine, but Bookable returned no venues for this partner — nobody has
// shared inventory with the account yet. Says so, instead of blaming the API.
function NoInventorySplash() {
  const email = window.__pp_session?.email;
  return (
    <div className="pp-empty" style={{ marginTop: 64 }}>
      <div className="pp-empty-glyph">
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
          <path d="M4 20V9l8-5 8 5v11M4 20h16M9 20v-6h6v6"/>
        </svg>
      </div>
      <div className="pp-empty-title">No inventory shared with you yet</div>
      <div className="pp-empty-sub">
        You're signed in{email ? <> as <span className="pp-mono">{email}</span></> : null}, but no venue has shared
        their inventory with your account. Once a venue shares it, their products appear here automatically.
      </div>
      <button className="pp-btn pp-btn--ghost" onClick={() => window.location.reload()}>Check again</button>
    </div>
  );
}

// Login keeps bouncing back unauthenticated. Name the reason /api/auth/callback
// reported (?auth_error=…) rather than looping the browser through it again.
function SignInFailedSplash({ reason }) {
  return (
    <div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--pp-cream)' }}>
      <div className="pp-empty" style={{ maxWidth: 460 }}>
        <div className="pp-empty-title">Sign-in isn't completing</div>
        <div className="pp-empty-sub">
          {reason === 'login_loop'
            ? 'The portal signed you in but the session came straight back as expired. Check that cookies are allowed for this site.'
            : 'Auth0 sent us back without a usable session.'}
          {reason && reason !== 'login_loop' ? <> Reason: <span className="pp-mono">{reason}</span></> : null}
        </div>
        <button className="pp-btn pp-btn--ghost"
                onClick={() => { try { sessionStorage.removeItem('pp_login_attempts'); } catch (e) {} window.location.href = '/api/auth/login?returnTo=%2F'; }}>
          Try signing in again
        </button>
      </div>
    </div>
  );
}

// Debug scaffolding (see bookable-api.jsx): this line runs only after
// Babel-standalone has compiled ALL 16 text/babel scripts in the browser, so
// the gap between "bookable-api.jsx evaluating" and here is compile time.
ppDebug('all scripts compiled — mounting React app');
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
