@willow-hq/roots
v0.4.0
Published
Shared client-side package for Willow customer sites: attribution capture, the lead form island, CallRail dynamic number insertion, and JSON-LD helpers. Built for Astro sites with React 19 islands, deployed per customer on Cloudflare.
Readme
@willow-hq/roots
Shared client-side package for Willow customer sites: attribution capture, the lead form island, CallRail dynamic number insertion, and JSON-LD helpers. Built for Astro sites with React 19 islands, deployed per customer on Cloudflare.
Install
Public package on npmjs.com, no auth token or .npmrc needed.
bun add @willow-hq/rootsreact (^19.0.0) is a peer dependency. Astro's React integration already
provides it; if a site doesn't have it yet, run bunx astro add react.
Attribution capture
initAttribution() reads gclid/gbraid/wbraid and UTM params off the
current URL and writes a wl_attr record to localStorage. It's last-paid-
touch with a 90-day window: a new paid touch overwrites the stored record,
an organic visit does not clobber an existing paid touch. Call it once, as
early as possible, from the Astro base layout:
---
// src/layouts/Base.astro
---
<html lang="en">
<head>
<script>
import { initAttribution } from "@willow-hq/roots";
initAttribution();
</script>
</head>
<body>
<slot />
</body>
</html>readAttributionBrowser() reads the stored record back (or null if
nothing's stored or it's expired past 90 days). LeadForm calls this
internally on submit, so most sites never need to call it directly.
The stored record keeps all three click-id candidates (gclid / gbraid /
wbraid) raw; the platform backend resolves the single winner at write time
(backend/app/click_id.py, gclid first), so no click-id logic lives client-side.
Lead form
LeadForm is the only interactive piece a customer site needs. It's
unstyled by design, mounts as a React island, and POSTs to the platform
backend on submit.
---
import { LeadForm } from "@willow-hq/roots";
---
<LeadForm
client:visible
siteId="willow"
classNames={{
form: "flex flex-col gap-3",
input: "border rounded px-3 py-2",
textarea: "border rounded px-3 py-2",
button: "bg-ink text-white rounded px-4 py-2",
success: "text-green-700",
error: "text-red-700",
}}
/>Props:
siteId(required): the tenant slug, sent assite_idon the payload.apiBase(optional): backend origin; defaults to the exportedAPI_BASEconstant (the shared Railway backend every customer site posts to). The form POSTs to${apiBase}/web-leads. Custom forms should importAPI_BASEinstead of hardcoding the origin.submitLabel,successText,errorText,namePlaceholder,emailPlaceholder,phonePlaceholder,messagePlaceholder: optional copy overrides.classNames: optional{ form, input, textarea, button, success, error }map. There's no default styling; pass whatever the tenant's design system needs.
Fields: name, phone, email are required (the form no-ops on submit if
any are empty); message is optional. There's a hidden subject honeypot
field, visually hidden, excluded from tab order, and opted out of browser
autofill, that real users never fill in. On submit the form reads the stored
attribution via readAttributionBrowser() and builds the payload with
buildLeadPayload, which drops captured_at and omits message/subject
entirely when empty.
buildLeadPayload and its LeadPayload type are exported directly if you
need to build the same payload shape outside the form (a custom form, a
non-React island, and so on):
import { buildLeadPayload, readAttributionBrowser } from "@willow-hq/roots";
const payload = buildLeadPayload(
"willow",
{ name, email, phone, message },
readAttributionBrowser(),
);CallRail dynamic number insertion
CallRail.astro renders CallRail's swap script for dynamic number insertion.
The swapSrc URL comes from the tenant's sites registry (typed columns
arrive with the onboarding-pipeline migration), not from a hardcoded value.
Pass siteUrl (the canonical production origin, i.e. SITE.url) so the swap
script only loads when served from that host. Dev servers and preview origins
otherwise start real CallRail sessions and hold pool numbers, which pollutes
the customer's attribution data. Omitting siteUrl loads the script
unconditionally.
---
import CallRail from "@willow-hq/roots/callrail.astro";
---
<CallRail swapSrc="https://cdn.callrail.com/companies/XXXX/swap.js" siteUrl="https://www.example.com" />JSON-LD helpers
localBusinessJsonLd and faqJsonLd build the SEO structured-data objects
every customer site needs. Render the result in a <script type="application/ld+json">
tag with set:html (Astro escapes plain interpolation, which breaks JSON):
---
import { localBusinessJsonLd, faqJsonLd } from "@willow-hq/roots";
const business = localBusinessJsonLd({
name: "Cool Power HVAC",
url: "https://coolpower.example.com",
phone: "+1-555-010-0100",
areaServed: ["Austin, TX", "Round Rock, TX"],
});
const faqs = faqJsonLd([
{ q: "Do you offer emergency service?", a: "Yes, 24/7 for existing customers." },
]);
---
<script type="application/ld+json" set:html={JSON.stringify(business)} />
<script type="application/ld+json" set:html={JSON.stringify(faqs)} />localBusinessJsonLd takes name, url, and optional phone and
areaServed. faqJsonLd takes an array of { q, a } pairs.
Publishing
CI publishes on every push to master that touches packages/roots/**
(.github/workflows/publish-roots.yml): it installs, runs bun test, runs
bun run build, then publishes to npmjs.com if the version in package.json
isn't already published. There's no changesets step, versioning is a manual
bump in package.json before merging. Publishing needs the NPM_TOKEN repo
secret (a granular automation token for the willow-hq npm org) set up
before the first publish; until that's done the test and build steps still
run, only the publish step fails.
