Skip to content

URL: https://mkdocs.justinsforge.com/memory/general/reference_links_module/

Link In Bio module

v2.1 Emil redesign DEPLOYED 2026-07-12 (CT main 0d1ca73; no migrations, no edge changes)

Four design waves on top of v2, all UI/motion: published-page Emil pass (token system, CSS linear() spring, entrance choreography, whole-page bubbles + 30x click burst, reduced-motion gated), app chrome pass, Beacons-style editor (collapsed block rows, block detail view, blue drag divider, device frame presets, scroll+highlight sync), Design panel (profile/avatar shape+size, social icon row with per-platform click tracking via deriveSocialLinkId, grouped colors, heading/paragraph fonts, block shapes), resizable preview, redesigned ImagePicker, ARCHIVE for blocks + link items (analytics-preserving retirement; archived = out of render/linkMap, ids + click history retained). Single "Link" block retired from the add menu (legacy renders).

v2 DEPLOYED 2026-07-12 (CT main a0eebd2, Worker version f4ecdd33)

Full Beacons/Linktree-parity build (3 waves, 8 workers, one night). CT side: links group block (4 layouts + styling), page settings (fonts/colors/backgrounds/effects incl. bubbles), image uploads (links.assets, publish inlines data URIs), Funnels tab (templates, sticky A/B splits, per-funnel pixels with Lead on post-capture step, conversion report, value-per-entry), lead magnets (on-page unlock + Mautic leadmagnet: tag), page categories + filtering, Tasks-style AppSidebar, audience analytics (countries/devices/visitor journeys). Edge side: _lvid visitor cookie, geo/device on clicks+captures, capture fields JSON, /go/<alias> vanity redirects, UTM auto-tagging, /f/<funnel>/<n> pages. 6 CT migrations + D1 0002 applied to prod. Full build story: links-app-v2-build-2026-07-11. - Pages must be REPUBLISHED to pick up the pv beacon/new rendering (not yet done). - INCIDENT found during deploy: ALL gusoutdoor.co Worker routes are dead (incl. pre-v2 /links, /c, /r). Apex A record now proxies to Shopify (23.227.38.65, O2O), where zone Workers do not execute; routes are attached and the record is proxied, so this is O2O behavior, not config drift. Fix options: host that brand's bio pages on a subdomain (e.g. links.gusoutdoor.co) routed to links-edge, or move the apex back off Shopify. gusthebass.com verified fully working (/go 302 + _lvid, /f branded 404, /links 200).

Owned bio-page + click-tracking + email-capture stack replacing Beacons/Linktree, built 2026-07-08. Public surfaces live on the always-on Cloudflare edge (survive a home outage); editing + reporting live in CreatorTrack on Console. Plan: affiliate-click-foundation.

Architecture (live)

PUBLIC (Cloudflare, always-on)
  gusthebass.com/links      -> Worker links-edge -> serves page HTML from KV (page:<slug>)
  gusthebass.com/r/<linkId> -> Worker -> KV lookup dest -> log click to D1 -> 302
  gusthebass.com/c   (POST) -> Worker -> validate + rate-limit -> capture to D1
    KV  links-map (eaca8ca562c3454885f2c7e3327a4f14):  <linkId> -> {destUrl,brand,label},  page:<slug> -> HTML,  rl:* counters
    D1  links-events (4da9b1d3-99c3-47fd-8ce3-33413372bdc4):  clicks + captures (append-only, ULID pk)

HOME (CreatorTrack on Console, DB lifeos)
  /link-in-bio app          -> editor (block kinds: header/link/affiliate-card/email-capture/text), Publish button
  POST /api/links/publish   -> render page -> write page:<slug> HTML + link map to KV (live gated by LINKS_PUBLISH_LIVE=1)
  links.* schema            -> pages, blocks, contacts, clicks (mirror), captures (mirror); workspace-scoped + RLS
  forge_links_sync.py       -> every 10 min: D1 clicks/captures -> links.* -> Mautic segment (idempotent by ULID)

Key facts / decisions

  • GOTCHA (2026-07-12): Cloudflare exact-match Worker routes do NOT match URLs with query strings. gusthebass.com/secretdrop served the bare page but 404'd every ?src= share link and QR variant. Canonical page-route pattern is now <host>/<slug>* (trailing wildcard); ensureRoute in creatortrack lib/links/cf.ts registers the wildcard AND self-heals by deleting the legacy exact route on each publish. Only /c stays exact (POST-only). Verified fixed live on both zones.

  • Bio page served by the Worker from KV, NOT Cloudflare Pages. gusthebass.com apex is a proxied tunnel-CNAME, so a path-based Pages custom domain isn't possible. One Worker owning /r, /c, and the page path is simpler with the same always-on property. (Deviation from the plan's Task 6 Pages project; no Pages project was created.)

  • Publish = two idempotent KV writes (link map + page:<slug> HTML). Gated behind LINKS_PUBLISH_LIVE=1 (set only in prod, via creatortrack-auth.env EnvironmentFile) + a CF token; dev stays a render-only dry run. Live URL: https://gusthebass.com/<slug>.
  • linkIds are deterministic: <brand>-<blockId> (from the block's DB id), so republishing is idempotent (same KV keys).
  • Workspace: both Gus brands live in the Gus The Bass workspace (lifeos id 85). The brand field per page (links.pages.brand) distinguishes gusthebass vs gusoutdoor. Brand picker LIVE (lib/links/brands.ts BRAND_OPTIONS/brandHost/normalizeBrand, merged 2026-07-08): a dropdown in both the editor header and the create-modal sets the page's brand, publish is host-aware (page:<host>:<slug> KV key, per-host ensureRoute), and each email-capture block can override the brand + carry custom tags so one page can "send as either brand." gusoutdoor.co is a second LIVE routed domain: A record proxied + all 3 Worker routes (/links, /c, /r/*) deployed pointing at links-edge. Edge CORS + the sync's Mautic segment mapping were the last real gaps, closed 2026-07-11 (segment 2 "Gus Outdoor Co Members" vs segment 4 "Gus The Bass" — see links-brand-picker-status-2026-07-11 handoff).
  • Sync (scripts/forge_links_sync.py, timer forge-links-sync.timer, every 10 min): pulls new D1 rows (watermark = max ULID already mirrored), upserts into links.clicks/captures/ contacts for the brand's workspace, then fans each new capture to its brand's own Mautic segment (gusthebass -> segment 4 "Gus The Bass", gusoutdoor -> segment 2 "Gus Outdoor Co Members") tagged brand:<b> / source:<s> / page:<slug> (subscriber-source analytics). Single source of truth per brand (BRAND_MAUTIC: workspace_id + segment_id + welcome_email_id, as of 2026-07-11) so workspace and Mautic routing can never drift apart; unknown brand is skipped loudly, never misrouted. Mautic fan-out is a separate txn so a Mautic outage never rolls back the CT mirror. No dedicated Outdoor welcome email yet; that brand's welcome send is a no-op until Justin sets one up.

Control model

  • Justin = root/infra: Worker, KV/D1, CF token, publish flag, sync cron, Mautic. wrangler + ~/.forge-secrets/{cloudflare,mautic}.env.
  • Michael = CreatorTrack UI only: the Link In Bio editor, Gus-scoped by per-app permission. Never touches Cloudflare/D1/Mautic; edits blocks and hits Publish. A home outage stops his editing, not the live page or tracking (edge keeps logging; sync catches up on reconnect).

Files / resources

Thing Where
Edge Worker forge/infra/links-edge/ (src/{index,redirect,capture,page,cors,ulid}.ts, wrangler.toml, schema.sql, README.md)
CF resources KV links-map, D1 links-events, Worker links-edge, routes gusthebass.com/{r/*,c,links}. Account 5b4138008d635b79e07af0c7388cd46f.
CreatorTrack app creatortrack/app/(suite)/link-in-bio/, components/apps/link-in-bio/, lib/links/{render,cf,bio-css}.ts, lib/links-store.ts, app/api/links/{route,publish}
DB schema links.* in lifeos (migration 20260708T184043_feat_link_in_bio_...)
Sync forge/scripts/forge_links_sync.py, forge/infra/systemd/forge-links-sync.{service,timer}
Mautic segment 4 "Gus The Bass" on CT 112 (192.168.86.87:8090); see reference_forge_mail_mautic
  • Display name is "Links" everywhere user-facing (catalog card, page headings, empty-state labels). Route (/link-in-bio), the links.* schema, and internal code/comments kept the original name; only strings a user sees changed.
  • Source attribution: the deployed capture form no longer hardcodes source: 'links'. It reads a ?src=<channel> query param off the page URL first (drop ?src=youtube on the link you post to a platform); absent that, it classifies document.referrer into a coarse channel (instagram/youtube/tiktok/facebook/twitter/reddit/pinterest/snapchat/linkedin/google/bing/ duckduckgo/referral), else direct.
  • Stats page (/link-in-bio/stats) adds a "Clicks by referrer" table (raw links.clicks. referer) alongside the existing "Signups by source" table, plus a Tags column on recent signups.
  • Email-capture block config (BlockEditor.tsx) gained two fields, stored in block.data: brand (dropdown, overrides the page's brand for that one form) and tags (freeform comma-separated string, e.g. "July 2026 Merch Drop"). Threaded end to end: LinkBioPreview stamps data-brand/data-tags on the <form> -> render.ts's captureScript reads them per-form -> POST /c body carries tags: string[] -> edge capture.ts stores a comma-joined captures.tags TEXT column (D1 migration infra/links-edge/migrations/0001_captures_tags_ column.sql, must be applied by hand to prod D1, schema.sql's CREATE TABLE IF NOT EXISTS only covers a fresh DB) -> forge_links_sync.py splits it into a links.captures.tags Postgres text[] column (CT migration 20260711T214757_...) and appends each tag verbatim (unnamespaced, unlike brand:/source:/page:) to the Mautic contact's tag list.
  • Deferred / out of scope: picking a specific Mautic segment or welcome-email id per block (still hardcoded to segment 4 / email 14 for everyone). Full status: links-core-status-2026-07-11.

Ops

  • Publish a page: open /link-in-bio in CreatorTrack (Gus The Bass workspace), edit, hit Publish.
  • Deploy Worker: cd forge/infra/links-edge && set -a; source ~/.forge-secrets/cloudflare.env; set +a && npx wrangler deploy. NOTE: the CF token in cloudflare.env is quoted; source strips the quotes, a bare cut does not.
  • Run sync manually: python3 forge/scripts/forge_links_sync.py --once [--dry-run].
  • Tail Worker logs: wrangler tail links-edge (needs the token's Workers Tail scope).
  • Reset all stats to zero: python3 forge/scripts/forge_links_stats_wipe.py --apply --stamp $(date -u +%Y%m%dT%H%M%SZ). Backs up every row to forge/data/backups/links-stats-preswipe-<stamp>.json first, stops forge-links-sync.timer, clears D1 clicks/captures, truncates links.clicks/links.captures/links.contacts, restarts the timer. Run without --apply for a dry-run backup.

Analytics data model (read before touching stats)

  • links.clicks holds THREE metrics, not one: link clicks, page views (rows whose link_id matches the page-view sentinel, see derivePageViewLinkId in lib/links/theme.ts), and QR scans (page-view rows whose referer carries ?src=qr / ?src=<channel>-qr, see isQrSource in lib/links-store.ts). Contact saves live in links.captures, deduped into links.contacts.
  • No rollup tables, no materialized views. Every Stats tile is a live count(*) filter (...), so truncating the three tables zeroes the whole dashboard, including funnels and visitor profiles.
  • GOTCHA: Postgres links.* is a MIRROR, not the source of truth. Cloudflare D1 links-events is the append-only origin, and forge_links_sync.py watermarks off max(id) from links.clicks. Truncating Postgres alone resets the watermark to '', and the 30s timer re-imports all of D1 history. Any wipe MUST clear D1 first (with the timer stopped) or it silently undoes itself.
  • iOS input zoom: every writable control on a published page (and funnels) must COMPUTE to >=16px font-size or iOS Safari auto-zooms on focus. Enforced by a .lbp :where(input, select, textarea) { font-size: max(1em, 16px); } guard at the top of lib/links/bio-css.ts plus a max(16px, ...) on .lbp-capture-in; any new rule sizing a control must wrap in max(16px, ...). Never use viewport maximum-scale clamps (breaks pinch-zoom accessibility).
  • Overlay GOTCHA (contact-card sheet, 2026-07-14): a block-rendered fixed overlay must NOT live inside .lbp-blocks. Two killers there: THEME_CSS's .lbp > * rule makes .lbp-blocks a z-index:1 stacking context (the later .lbp-foot paints over the overlay), and the .lbp-anim entrance animation's transform keyframe runs on it, which on iOS WebKit re-positions a position:fixed element against its parent (sheet clipped at the page edge on short pages; Chromium unaffected, so ALWAYS verify overlay bugs on WebKit). Fix pattern: hoist the overlay to a direct .lbp child (ContactCardSheet in LinkBioPreview), exclude it via .lbp > *:not(.lbp-sheet-scrim) and from the entrance-animation selectors.
  • Email in capture forms: position is free (an "email" marker entry in the fields array, orderedCaptureRows in lib/links/theme.ts; no marker = legacy = last). Include/required stay PINNED on the email-capture block because the edge /c handler hard-requires a valid email (D1 captures.email NOT NULL, Mautic/CRM sync key on it); making it optional there is a links-edge + sync project. Contact-card collect-before can toggle email off (vCard fallback).
  • Liquid Glass: new pages seed liquidGlass: 50 (links-store.ts createPage); editor toggle on = 50, off = 0.

[Claude Code]