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/secretdropserved the bare page but 404'd every?src=share link and QR variant. Canonical page-route pattern is now<host>/<slug>*(trailing wildcard);ensureRoutein creatortracklib/links/cf.tsregisters the wildcard AND self-heals by deleting the legacy exact route on each publish. Only/cstays exact (POST-only). Verified fixed live on both zones. -
Bio page served by the Worker from KV, NOT Cloudflare Pages.
gusthebass.comapex 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 behindLINKS_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
brandfield per page (links.pages.brand) distinguishes gusthebass vs gusoutdoor. Brand picker LIVE (lib/links/brands.tsBRAND_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-hostensureRoute), and each email-capture block can override the brand + carry custom tags so one page can "send as either brand."gusoutdoor.cois a second LIVE routed domain: A record proxied + all 3 Worker routes (/links,/c,/r/*) deployed pointing atlinks-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, timerforge-links-sync.timer, every 10 min): pulls new D1 rows (watermark = max ULID already mirrored), upserts intolinks.clicks/captures/contactsfor 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") taggedbrand:<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 |
Rename + source/tags update (2026-07-11, branch links-core)¶
- Display name is "Links" everywhere user-facing (catalog card, page headings, empty-state
labels). Route (
/link-in-bio), thelinks.*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=youtubeon the link you post to a platform); absent that, it classifiesdocument.referrerinto a coarse channel (instagram/youtube/tiktok/facebook/twitter/reddit/pinterest/snapchat/linkedin/google/bing/ duckduckgo/referral), elsedirect. - Stats page (
/link-in-bio/stats) adds a "Clicks by referrer" table (rawlinks.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) andtags(freeform comma-separated string, e.g. "July 2026 Merch Drop"). Threaded end to end: LinkBioPreview stampsdata-brand/data-tagson the<form>-> render.ts's captureScript reads them per-form -> POST/cbody carriestags: string[]-> edgecapture.tsstores a comma-joinedcaptures.tagsTEXT column (D1 migrationinfra/links-edge/migrations/0001_captures_tags_ column.sql, must be applied by hand to prod D1,schema.sql'sCREATE TABLE IF NOT EXISTSonly covers a fresh DB) ->forge_links_sync.pysplits it into alinks.captures.tagsPostgrestext[]column (CT migration20260711T214757_...) and appends each tag verbatim (unnamespaced, unlikebrand:/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-bioin 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;sourcestrips the quotes, a barecutdoes 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 toforge/data/backups/links-stats-preswipe-<stamp>.jsonfirst, stopsforge-links-sync.timer, clears D1clicks/captures, truncateslinks.clicks/links.captures/links.contacts, restarts the timer. Run without--applyfor a dry-run backup.
Analytics data model (read before touching stats)¶
links.clicksholds THREE metrics, not one: link clicks, page views (rows whoselink_idmatches the page-view sentinel, seederivePageViewLinkIdinlib/links/theme.ts), and QR scans (page-view rows whosereferercarries?src=qr/?src=<channel>-qr, seeisQrSourceinlib/links-store.ts). Contact saves live inlinks.captures, deduped intolinks.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 D1links-eventsis the append-only origin, andforge_links_sync.pywatermarks offmax(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.
Published-page design rules (2026-07-14, feat/links-form-blocks-ux, SHIPPED to prod 2f55acb; live pages need a republish to pick up the CSS fixes)¶
- 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 oflib/links/bio-css.tsplus amax(16px, ...)on.lbp-capture-in; any new rule sizing a control must wrap inmax(16px, ...). Never use viewportmaximum-scaleclamps (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-blocksa z-index:1 stacking context (the later.lbp-footpaints over the overlay), and the.lbp-animentrance animation's transform keyframe runs on it, which on iOS WebKit re-positions aposition:fixedelement 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.lbpchild (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,orderedCaptureRowsin lib/links/theme.ts; no marker = legacy = last). Include/required stay PINNED on the email-capture block because the edge/chandler hard-requires a valid email (D1captures.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]